mirror of
https://github.com/cline/cline.git
synced 2026-09-02 15:52:29 +08:00
Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eab96e6a8a | |||
| 00526721d7 | |||
| 9852d459ba | |||
| 9153f36989 | |||
| 08047d6efd | |||
| b970af74ca | |||
| a915122c6d | |||
| 835ed94736 | |||
| 00ca3d13fa | |||
| 3f3a87aed9 | |||
| 7a0f11837e | |||
| 03d2d01eed | |||
| db1b1c45bd | |||
| a4131e57d8 | |||
| 683dd9a741 | |||
| 0d437f71b0 | |||
| fc77c0faea | |||
| 12c85c4233 | |||
| d6b7a1ab41 | |||
| 3926e7b404 | |||
| 5ba4314b9a | |||
| 9ccad7f764 | |||
| 3562f54dbf | |||
| d427d5d76a | |||
| 072e2887b0 | |||
| 9c71a6f021 | |||
| 7627a382aa | |||
| 978c633c90 | |||
| e807b520e0 | |||
| f3a3f30db5 | |||
| f1c7934064 | |||
| 1d42da5248 | |||
| 7f3974a827 | |||
| d40ab56aff | |||
| bbdf445db7 | |||
| d992a3bf21 | |||
| ace95988f8 | |||
| 54726f1677 | |||
| 6308fef0a9 | |||
| b3fc79b8ce | |||
| ff05ec3bbe | |||
| bde7049c01 | |||
| 7cd06744ad | |||
| c88d3238cf | |||
| 852ca2348f | |||
| 7091ccf2c7 | |||
| ad6c33ac5b | |||
| 71e312e92a | |||
| 5903840f79 | |||
| 0c677b63db | |||
| b741135f85 | |||
| 2eab216815 | |||
| e57174eec8 | |||
| ad4631c682 | |||
| aae23e8685 | |||
| 0ff27591d5 | |||
| 23127f22de | |||
| 57ed14d1b1 | |||
| a769585406 | |||
| c44b29b002 | |||
| bb4e397a51 | |||
| 9824d8d476 | |||
| a46c5288ca | |||
| 91b947de69 | |||
| 7b25a21b26 | |||
| 1d1071dcf5 | |||
| 8d07b7d6cb | |||
| aacc69a558 | |||
| a27bedffb6 | |||
| bbeecefe84 | |||
| 50b57f472f | |||
| 0e7e0099cd | |||
| 44cbfe9d9c | |||
| 4ecccf5105 | |||
| 6d29bc6551 | |||
| 820057308a | |||
| 7355f7e3b9 |
@@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
echo "PostToolUse running inside local cline/.clinerules/hooks/ directory"
|
||||
|
||||
input=$(cat)
|
||||
echo $input | jq .
|
||||
|
||||
for i in {1..5}; do
|
||||
sleep 1
|
||||
echo "$i"
|
||||
done
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
"cancel": false,
|
||||
"contextModification": "PostToolUse response from the local cline/.clinerules/hooks/ directory.",
|
||||
"errorMessage": "PostToolUse hook custom errorMessage"
|
||||
}
|
||||
EOF
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
echo "PreToolUse running inside local cline/.clinerules/hooks/ directory"
|
||||
|
||||
input=$(cat)
|
||||
echo $input | jq .
|
||||
|
||||
for i in {1..5}; do
|
||||
sleep 1
|
||||
echo "$i"
|
||||
done
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
"cancel": false,
|
||||
"contextModification": "PreToolUse response from the local cline/.clinerules/hooks/ directory.",
|
||||
"errorMessage": "PreToolUse hook custom errorMessage"
|
||||
}
|
||||
EOF
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
echo "TaskCancel running inside local cline/.clinerules/hooks/ directory"
|
||||
|
||||
input=$(cat)
|
||||
echo $input | jq .
|
||||
|
||||
for i in {1..5}; do
|
||||
sleep 1
|
||||
echo "$i"
|
||||
done
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
"cancel": false,
|
||||
"contextModification": "TaskCancel response from the local cline/.clinerules/hooks/ directory.",
|
||||
"errorMessage": "TaskCancel hook custom errorMessage"
|
||||
}
|
||||
EOF
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
echo "TaskResume running inside local cline/.clinerules/hooks/ directory"
|
||||
|
||||
input=$(cat)
|
||||
echo $input | jq .
|
||||
|
||||
for i in {1..5}; do
|
||||
sleep 1
|
||||
echo "$i"
|
||||
done
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
"cancel": false,
|
||||
"contextModification": "TaskResume response from the local cline/.clinerules/hooks/ directory.",
|
||||
"errorMessage": "TaskResume hook custom errorMessage"
|
||||
}
|
||||
EOF
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
echo "TaskStart running inside local cline/.clinerules/hooks/ directory"
|
||||
|
||||
input=$(cat)
|
||||
echo $input | jq .
|
||||
|
||||
for i in {1..5}; do
|
||||
sleep 1
|
||||
echo "$i"
|
||||
done
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
"cancel": false,
|
||||
"contextModification": "TaskStart response from the local cline/.clinerules/hooks/ directory.",
|
||||
"errorMessage": "TaskStart hook custom errorMessage"
|
||||
}
|
||||
EOF
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
echo "UserPromptSubmit running inside local cline/.clinerules/hooks/ directory"
|
||||
|
||||
input=$(cat)
|
||||
echo $input | jq .
|
||||
|
||||
for i in {1..5}; do
|
||||
sleep 1
|
||||
echo "$i"
|
||||
done
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
"cancel": false,
|
||||
"contextModification": "UserPromptSubmit response from the local cline/.clinerules/hooks/ directory.",
|
||||
"errorMessage": "UserPromptSubmit hook custom errorMessage"
|
||||
}
|
||||
EOF
|
||||
@@ -1,4 +1,5 @@
|
||||
demo.gif filter=lfs diff=lfs merge=lfs -text
|
||||
assets/docs/demo.gif filter=lfs diff=lfs merge=lfs -text
|
||||
webview-ui/src/assets/cline_kanban_demo.webm filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
* text=auto eol=lf
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
name: CLI TUI Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
cli-tui-tests:
|
||||
name: CLI TUI Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build CLI
|
||||
run: npm run cli:build
|
||||
|
||||
- name: Run TUI Tests
|
||||
id: tui_tests
|
||||
run: |
|
||||
npm run test:e2e:cli:tui 2>&1 | tee tui-test-output.log
|
||||
exit_code=${PIPESTATUS[0]}
|
||||
echo "tui_exit_code=$exit_code" >> $GITHUB_OUTPUT
|
||||
exit $exit_code
|
||||
|
||||
- name: Write failure summary
|
||||
if: always() && steps.tui_tests.outcome != 'success' && steps.tui_tests.outcome != 'skipped'
|
||||
run: |
|
||||
echo "## ❌ CLI TUI Tests Failed" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Step outcome:** \`${{ steps.tui_tests.outcome }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Test Output" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
if [ -f tui-test-output.log ]; then
|
||||
cat tui-test-output.log >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "(no test output captured — process may have been killed before output was flushed)" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Debugging" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **TUI traces** are attached as artifacts below — download and inspect them to see terminal state at the point of failure." >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **To view a trace replay/Run a TUI Trace: ** run \`npx tui-test show-trace path/to/trace/file\` in your terminal" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Full test log** is also attached as an artifact." >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Tests run with \`retries: 2\` so any failure shown is a consistent failure, not a flake." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Upload TUI traces
|
||||
if: always() && steps.tui_tests.outcome != 'success' && steps.tui_tests.outcome != 'skipped'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: tui-test-traces
|
||||
path: tests/e2e/cli/tui-traces/
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload test log
|
||||
if: always() && steps.tui_tests.outcome != 'success' && steps.tui_tests.outcome != 'skipped'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: tui-test-log
|
||||
path: tui-test-output.log
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
@@ -30,7 +30,11 @@ permissions:
|
||||
pull-requests: write # Required by nested reusable test workflow
|
||||
|
||||
jobs:
|
||||
cli-tui-tests:
|
||||
uses: ./.github/workflows/cli-tui-tests.yml
|
||||
|
||||
publish-main:
|
||||
needs: cli-tui-tests
|
||||
if: |
|
||||
github.repository == 'cline/cline' && (
|
||||
github.event_name == 'workflow_dispatch' &&
|
||||
@@ -44,6 +48,7 @@ jobs:
|
||||
confirm_publish: ${{ github.event.inputs.confirm_publish }}
|
||||
|
||||
publish-nightly:
|
||||
needs: cli-tui-tests
|
||||
if: |
|
||||
github.repository == 'cline/cline' && (
|
||||
github.event_name == 'schedule' ||
|
||||
|
||||
@@ -24,6 +24,8 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: true
|
||||
|
||||
- name: Check for recent commits
|
||||
run: |
|
||||
@@ -49,6 +51,14 @@ jobs:
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
- name: Verify LFS media assets are resolved
|
||||
run: |
|
||||
FILE="webview-ui/src/assets/cline_kanban_demo.webm"
|
||||
if grep -q "git-lfs.github.com/spec/v1" "$FILE"; then
|
||||
echo "Error: $FILE is still a Git LFS pointer in CI checkout"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Publish Extension as Pre-release
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
|
||||
@@ -43,6 +43,7 @@ jobs:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
lfs: true
|
||||
|
||||
- name: Resolve Release Tag
|
||||
id: resolve_tag
|
||||
@@ -133,6 +134,14 @@ jobs:
|
||||
fi
|
||||
echo "Tag and package version match: $TAG"
|
||||
|
||||
- name: Verify LFS media assets are resolved
|
||||
run: |
|
||||
FILE="webview-ui/src/assets/cline_kanban_demo.webm"
|
||||
if grep -q "git-lfs.github.com/spec/v1" "$FILE"; then
|
||||
echo "Error: $FILE is still a Git LFS pointer in CI checkout"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Package and Publish Extension
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
|
||||
@@ -51,3 +51,8 @@ test-results
|
||||
|
||||
# Smoke test results (generated)
|
||||
evals/smoke-tests/results/
|
||||
|
||||
.tui-test
|
||||
secrets.json
|
||||
tui-traces
|
||||
tests/**/cache
|
||||
|
||||
@@ -1,5 +1,80 @@
|
||||
# Changelog
|
||||
|
||||
## [3.76.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Add Cline Kanban launch modal in webview; CLI now launches Kanban by default with a migration view
|
||||
- Add toggle to disable feature tips in chat
|
||||
- Add repeated tool call loop detection to prevent infinite loops wasting tokens
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix CLI Kanban spawn on Windows by enabling shell mode for `npx.cmd`
|
||||
|
||||
## [3.75.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Latency improvements for remote workspaces
|
||||
|
||||
### Fixed
|
||||
|
||||
- Stabilize flaky hooks tests
|
||||
|
||||
### Changed
|
||||
|
||||
- Remove example hooks in favor of reading the docs
|
||||
|
||||
## [3.74.0]
|
||||
|
||||
### Added
|
||||
- Implement dynamic free model detection for Cline API
|
||||
- Add file read deduplication cache to prevent repeated reads
|
||||
- Add feature tips tooltip during thinking state
|
||||
|
||||
### Fixed
|
||||
- Replace error message when not logged in to Cline
|
||||
- Align ClineRulesToggleModal padding with ServersToggleModal
|
||||
- Skip WebP for GLM and Devstral models running through llama.cpp
|
||||
- Respect user-configured context window in LiteLLM getModel()
|
||||
- Honor explicit model IDs outside static catalog in W&B provider
|
||||
- Add missing Fireworks serverless models and pricing
|
||||
|
||||
## [3.73.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Added W&B Inference by CoreWeave as a new API provider with 17 models
|
||||
- Improved parallel tool calling support for OpenRouter and Cline providers
|
||||
|
||||
### Fixed
|
||||
|
||||
- Claude Code Provider: handle rate limit events, empty content arrays, error results, and unknown content types without crashing
|
||||
- Tool handlers (`read_file`, `list_files`, `list_code_definition_names`, `search_files`) now return graceful errors instead of crashing
|
||||
|
||||
## [3.72.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Added Anthropic Opus 4.6 fast mode variants
|
||||
|
||||
### Fixed
|
||||
|
||||
- Resolved native tool placeholder interpolation in prompts
|
||||
- Gemini: capped Flash output tokens to 8192 across providers
|
||||
- Fixed Windows unit test path normalization
|
||||
- Fixed flaky hooks tests on Windows
|
||||
- Bedrock: handle thinking and redacted_thinking blocks correctly in message conversion and streaming
|
||||
- Prevent crash when `list_files` or `list_code_definition_names` receives a file path
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated Jupyter Notebook GIFs
|
||||
- Markdown image loading now requires user consent
|
||||
- Added `.github/copilot-instructions.md` for coding agents
|
||||
- Hooks: reintroduced feature toggle
|
||||
|
||||
## [3.71.0]
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,5 +1,66 @@
|
||||
# cline
|
||||
|
||||
## [2.9.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Latency improvements for remote workspaces
|
||||
|
||||
## [2.8.2]
|
||||
|
||||
### Fixed
|
||||
- Use `kanban@latest` in `cline kanban` to always fetch the newest version
|
||||
|
||||
## [2.8.1]
|
||||
|
||||
### Added
|
||||
- Implement dynamic free model detection for Cline API
|
||||
- Add file read deduplication cache to prevent repeated reads
|
||||
- Add feature tips tooltip during thinking state
|
||||
|
||||
### Fixed
|
||||
- Fix flaky CLI Enter-key handling across Windows/test environments
|
||||
- Replace error message when not logged in to Cline
|
||||
- Align ClineRulesToggleModal padding with ServersToggleModal
|
||||
- Skip WebP for GLM and Devstral models running through llama.cpp
|
||||
- Respect user-configured context window in LiteLLM getModel()
|
||||
- Honor explicit model IDs outside static catalog in W&B provider
|
||||
- Add missing Fireworks serverless models and pricing
|
||||
|
||||
## [2.8.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Added W&B Inference by CoreWeave as a new API provider with 17 models including DeepSeek-V3.1, Llama 4, and Qwen3-Coder
|
||||
- Added CLI TUI end-to-end test suite
|
||||
|
||||
### Fixed
|
||||
|
||||
- Claude Code: handle rate limit events, empty content arrays, error results, and unknown content types without crashing
|
||||
- CLI: `/q` and `/exit` slash commands now execute immediately on Enter without requiring the slash menu to be visible
|
||||
- CLI: slash command filtering now prioritizes exact and prefix matches over fuzzy matches
|
||||
|
||||
## [2.7.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Added MCP add shortcuts for stdio and HTTP servers
|
||||
- Added `--continue` for the current directory
|
||||
- Added `--auto-condense` flag for AI-powered context compaction
|
||||
- Added `--hooks-dir` flag for runtime hook injection
|
||||
- Enabled error autocapture
|
||||
- Prompt rules now include test verification guidance and make `CLI_RULES` language-agnostic
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed remount behavior so TUI remounts only on width resize
|
||||
- Fixed startup prompt replay on resize remount
|
||||
- Fixed task flags so they are applied before the welcome TUI mounts
|
||||
|
||||
### Changed
|
||||
|
||||
- Hooks: reintroduced feature toggle
|
||||
|
||||
## [2.6.1]
|
||||
|
||||
### Added
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cline",
|
||||
"version": "2.6.1",
|
||||
"version": "2.11.0",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"main": "dist/lib.mjs",
|
||||
"types": "dist/lib.d.ts",
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Box, Text, useInput } from "ink"
|
||||
import React from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { isEnterKey, isMouseEscapeSequence } from "../utils/input"
|
||||
|
||||
interface ApiKeyInputProps {
|
||||
providerName: string
|
||||
@@ -39,7 +39,7 @@ export const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
|
||||
onCancel()
|
||||
return
|
||||
}
|
||||
if (key.return) {
|
||||
if (isEnterKey(input, key)) {
|
||||
onSubmit(value)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import React, { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { useTaskController } from "../context/TaskContext"
|
||||
import { useLastCompletedAskMessage } from "../hooks/useStateSubscriber"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { isEnterKey, isMouseEscapeSequence } from "../utils/input"
|
||||
import { jsonParseSafe } from "../utils/parser"
|
||||
|
||||
interface AskPromptProps {
|
||||
@@ -136,7 +136,7 @@ export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
|
||||
} else if (promptType === "options") {
|
||||
// Number selection for options, or free text input
|
||||
const parts = jsonParseSafe(text, { options: [] as string[] })
|
||||
if (key.return) {
|
||||
if (isEnterKey(input, key)) {
|
||||
// Submit free text on Enter
|
||||
if (textInput.trim()) {
|
||||
sendResponse("messageResponse", textInput.trim())
|
||||
@@ -145,7 +145,7 @@ export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
|
||||
setTextInput((prev) => prev.slice(0, -1))
|
||||
} else if (input && !key.ctrl && !key.meta) {
|
||||
// Check if it's a number for option selection (only when no text typed yet)
|
||||
const num = parseInt(input, 10)
|
||||
const num = Number.parseInt(input, 10)
|
||||
if (textInput === "" && !Number.isNaN(num) && num >= 1 && num <= parts.options.length) {
|
||||
const selectedOption = parts.options[num - 1]
|
||||
sendResponse("messageResponse", selectedOption)
|
||||
@@ -156,7 +156,7 @@ export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
|
||||
}
|
||||
} else if (promptType === "text") {
|
||||
// Text input mode
|
||||
if (key.return) {
|
||||
if (isEnterKey(input, key)) {
|
||||
// Submit on Enter
|
||||
if (textInput.trim()) {
|
||||
sendResponse("messageResponse", textInput.trim())
|
||||
@@ -169,7 +169,7 @@ export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
|
||||
}
|
||||
} else if (promptType === "plan_mode_text") {
|
||||
// Plan mode text input - allows text response or toggle to Act mode
|
||||
if (key.return) {
|
||||
if (isEnterKey(input, key)) {
|
||||
// Submit on Enter
|
||||
if (textInput.trim()) {
|
||||
sendResponse("messageResponse", textInput.trim())
|
||||
@@ -185,7 +185,7 @@ export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
|
||||
}
|
||||
} else if (promptType === "completion") {
|
||||
// Task completed - allow follow-up question or exit
|
||||
if (key.return) {
|
||||
if (isEnterKey(input, key)) {
|
||||
if (textInput.trim()) {
|
||||
// Send follow-up question
|
||||
sendResponse("messageResponse", textInput.trim())
|
||||
@@ -401,43 +401,42 @@ function getCliMessagePrefixIcon(message: ClineMessage): string {
|
||||
default:
|
||||
return "❔"
|
||||
}
|
||||
} else {
|
||||
switch (message.say) {
|
||||
case "task":
|
||||
return "📋"
|
||||
case "error":
|
||||
return "❌"
|
||||
case "text":
|
||||
return "💬"
|
||||
case "reasoning":
|
||||
return "🧠"
|
||||
case "completion_result":
|
||||
return "✅"
|
||||
case "user_feedback":
|
||||
return "👤"
|
||||
case "command":
|
||||
case "command_output":
|
||||
return "⚙️"
|
||||
case "tool":
|
||||
return "🔧"
|
||||
case "browser_action":
|
||||
case "browser_action_launch":
|
||||
case "browser_action_result":
|
||||
return "🌐"
|
||||
case "mcp_server_request_started":
|
||||
case "mcp_server_response":
|
||||
return "🔌"
|
||||
case "api_req_started":
|
||||
case "api_req_finished":
|
||||
return "🔄"
|
||||
case "checkpoint_created":
|
||||
return "💾"
|
||||
case "info":
|
||||
return "ℹ️"
|
||||
case "generate_explanation":
|
||||
return "📝"
|
||||
default:
|
||||
return " "
|
||||
}
|
||||
}
|
||||
switch (message.say) {
|
||||
case "task":
|
||||
return "📋"
|
||||
case "error":
|
||||
return "❌"
|
||||
case "text":
|
||||
return "💬"
|
||||
case "reasoning":
|
||||
return "🧠"
|
||||
case "completion_result":
|
||||
return "✅"
|
||||
case "user_feedback":
|
||||
return "👤"
|
||||
case "command":
|
||||
case "command_output":
|
||||
return "⚙️"
|
||||
case "tool":
|
||||
return "🔧"
|
||||
case "browser_action":
|
||||
case "browser_action_launch":
|
||||
case "browser_action_result":
|
||||
return "🌐"
|
||||
case "mcp_server_request_started":
|
||||
case "mcp_server_response":
|
||||
return "🔌"
|
||||
case "api_req_started":
|
||||
case "api_req_finished":
|
||||
return "🔄"
|
||||
case "checkpoint_created":
|
||||
return "💾"
|
||||
case "info":
|
||||
return "ℹ️"
|
||||
case "generate_explanation":
|
||||
return "📝"
|
||||
default:
|
||||
return " "
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useClineFeaturedModels } from "../hooks/useClineFeaturedModels"
|
||||
import { useOcaAuth } from "../hooks/useOcaAuth"
|
||||
import { useScrollableList } from "../hooks/useScrollableList"
|
||||
import { type DetectedSources, detectImportSources, type ImportSource } from "../utils/import-configs"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { isEnterKey, isMouseEscapeSequence } from "../utils/input"
|
||||
import { applyBedrockConfig, applyProviderConfig } from "../utils/provider-config"
|
||||
import { useValidProviders } from "../utils/providers"
|
||||
import { ApiKeyInput } from "./ApiKeyInput"
|
||||
@@ -79,12 +79,12 @@ const Select: React.FC<{
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
useInput(
|
||||
(_, key) => {
|
||||
(input, key) => {
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1))
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0))
|
||||
} else if (key.return) {
|
||||
} else if (isEnterKey(input, key)) {
|
||||
onSelect(items[selectedIndex].value)
|
||||
}
|
||||
},
|
||||
@@ -130,7 +130,7 @@ const TextInput: React.FC<{
|
||||
return
|
||||
}
|
||||
|
||||
if (key.return) {
|
||||
if (isEnterKey(input, key)) {
|
||||
onSubmit(value)
|
||||
} else if (key.backspace || key.delete) {
|
||||
onChange(value.slice(0, -1))
|
||||
@@ -853,7 +853,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
setMenuIndex((prev) => (prev > 0 ? prev - 1 : mainMenuItems.length - 1))
|
||||
} else if (key.downArrow) {
|
||||
setMenuIndex((prev) => (prev < mainMenuItems.length - 1 ? prev + 1 : 0))
|
||||
} else if (key.return) {
|
||||
} else if (isEnterKey(input, key)) {
|
||||
handleMainMenuSelect(mainMenuItems[menuIndex].value)
|
||||
}
|
||||
} else if (step === "provider") {
|
||||
@@ -861,7 +861,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
setProviderIndex((prev) => (prev > 0 ? prev - 1 : providerItems.length - 1))
|
||||
} else if (key.downArrow) {
|
||||
setProviderIndex((prev) => (prev < providerItems.length - 1 ? prev + 1 : 0))
|
||||
} else if (key.return) {
|
||||
} else if (isEnterKey(input, key)) {
|
||||
if (providerItems[providerIndex]) {
|
||||
handleProviderSelect(providerItems[providerIndex].value)
|
||||
}
|
||||
@@ -877,7 +877,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
setClineModelIndex((prev) => (prev > 0 ? prev - 1 : maxIndex))
|
||||
} else if (key.downArrow) {
|
||||
setClineModelIndex((prev) => (prev < maxIndex ? prev + 1 : 0))
|
||||
} else if (key.return) {
|
||||
} else if (isEnterKey(input, key)) {
|
||||
if (isBrowseAllSelected(clineModelIndex, featuredModels)) {
|
||||
setStep("modelid")
|
||||
} else {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Box, Text, useInput } from "ink"
|
||||
import React, { useCallback, useState } from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { isEnterKey } from "../utils/input"
|
||||
import { getModelList } from "./ModelPicker"
|
||||
import { SearchableList } from "./SearchableList"
|
||||
|
||||
@@ -43,7 +44,7 @@ export const BedrockCustomModelFlow: React.FC<BedrockCustomModelFlowProps> = ({
|
||||
if (step === "arn_input") {
|
||||
if (key.escape) {
|
||||
onCancel()
|
||||
} else if (key.return) {
|
||||
} else if (isEnterKey(input, key)) {
|
||||
handleArnSubmit()
|
||||
} else if (key.backspace || key.delete) {
|
||||
setCustomArn((prev) => prev.slice(0, -1))
|
||||
|
||||
@@ -12,7 +12,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { ChatView } from "./ChatView"
|
||||
|
||||
// Helper to wait for async state updates
|
||||
const delay = (ms: number = 60) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
const delay = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
// Type for our exit mock function
|
||||
type ExitMockFn = ReturnType<typeof vi.fn> & (() => void)
|
||||
@@ -126,12 +126,16 @@ vi.mock("../utils/file-search", () => ({
|
||||
searchWorkspaceFiles: vi.fn(async () => []),
|
||||
}))
|
||||
|
||||
vi.mock("../utils/slash-commands", () => ({
|
||||
extractSlashQuery: vi.fn(() => ({ inSlashMode: false, query: "", slashIndex: -1 })),
|
||||
filterCommands: vi.fn(() => []),
|
||||
insertSlashCommand: vi.fn((text: string) => text),
|
||||
sortCommandsWorkflowsFirst: vi.fn((cmds: unknown[]) => cmds),
|
||||
}))
|
||||
vi.mock("../utils/slash-commands", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../utils/slash-commands")>()
|
||||
return {
|
||||
...actual,
|
||||
extractSlashQuery: vi.fn(() => ({ inSlashMode: false, query: "", slashIndex: -1 })),
|
||||
filterCommands: vi.fn(() => []),
|
||||
insertSlashCommand: vi.fn((text: string) => text),
|
||||
sortCommandsWorkflowsFirst: vi.fn((cmds: unknown[]) => cmds),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("../utils/input", () => ({
|
||||
isMouseEscapeSequence: vi.fn(() => false),
|
||||
|
||||
+100
-71
@@ -108,7 +108,6 @@ import type { ClineAsk, ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { getApiMetrics, getLastApiReqTotalTokens } from "@shared/getApiMetrics"
|
||||
import { EmptyRequest, StringRequest } from "@shared/proto/cline/common"
|
||||
import type { SlashCommandInfo } from "@shared/proto/cline/slash"
|
||||
import { CLI_ONLY_COMMANDS } from "@shared/slashCommands"
|
||||
import { getProviderDefaultModelId, getProviderModelIdKey } from "@shared/storage"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import { execSync } from "child_process"
|
||||
@@ -137,7 +136,14 @@ import {
|
||||
} from "../utils/file-search"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { jsonParseSafe, parseImagesFromInput } from "../utils/parser"
|
||||
import { extractSlashQuery, filterCommands, insertSlashCommand, sortCommandsWorkflowsFirst } from "../utils/slash-commands"
|
||||
import {
|
||||
createCliOnlySlashCommands,
|
||||
extractSlashQuery,
|
||||
filterCommands,
|
||||
getStandaloneSlashCommandToExecute,
|
||||
insertSlashCommand,
|
||||
sortCommandsWorkflowsFirst,
|
||||
} from "../utils/slash-commands"
|
||||
import { waitFor } from "../utils/timeout"
|
||||
import { isFileEditTool, parseToolFromMessage } from "../utils/tools"
|
||||
import { shutdownEvent } from "../vscode-shim"
|
||||
@@ -403,7 +409,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
const PASTE_UPDATE_DEBOUNCE_MS = 50 // Debounce visual updates to avoid flicker
|
||||
|
||||
// Slash command state
|
||||
const [availableCommands, setAvailableCommands] = useState<SlashCommandInfo[]>([])
|
||||
const [availableCommands, setAvailableCommands] = useState<SlashCommandInfo[]>(() => createCliOnlySlashCommands())
|
||||
const [selectedSlashIndex, setSelectedSlashIndex] = useState(0)
|
||||
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false)
|
||||
const lastSlashIndexRef = useRef<number>(-1)
|
||||
@@ -614,16 +620,15 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
try {
|
||||
const response = await getAvailableSlashCommands(ctrl, EmptyRequest.create())
|
||||
const cliCommands = response.commands.filter((cmd) => cmd.cliCompatible !== false)
|
||||
// Add CLI-only commands (like /settings) that are handled locally
|
||||
const cliOnlyCommands: SlashCommandInfo[] = CLI_ONLY_COMMANDS.map((cmd) => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description || "",
|
||||
section: cmd.section || "default",
|
||||
cliCompatible: true,
|
||||
}))
|
||||
// Add CLI-only commands (like /settings) that are handled locally.
|
||||
// Seed these synchronously on first render so locally handled commands like
|
||||
// /q and /exit are immediately available, even before the async command
|
||||
// fetch completes. This avoids a race that can make the quit command tests
|
||||
// flaky on slower Windows CI runners.
|
||||
const cliOnlyCommands = createCliOnlySlashCommands()
|
||||
setAvailableCommands([...cliOnlyCommands, ...sortCommandsWorkflowsFirst(cliCommands)])
|
||||
} catch {
|
||||
// Fallback: commands will be empty, menu won't show
|
||||
// Keep CLI-only commands available even if backend command loading fails.
|
||||
}
|
||||
}
|
||||
loadCommands()
|
||||
@@ -843,6 +848,77 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
}, 150)
|
||||
}, [inkExit, onExit])
|
||||
|
||||
const handleCliOnlySlashCommand = useCallback(
|
||||
(commandName: string): boolean => {
|
||||
if (commandName === "help") {
|
||||
setActivePanel({ type: "help" })
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return true
|
||||
}
|
||||
|
||||
if (commandName === "settings") {
|
||||
setActivePanel({ type: "settings" })
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return true
|
||||
}
|
||||
|
||||
if (commandName === "models") {
|
||||
const apiConfig = StateManager.get().getApiConfiguration()
|
||||
const provider =
|
||||
mode === "act"
|
||||
? apiConfig.actModeApiProvider || apiConfig.planModeApiProvider
|
||||
: apiConfig.planModeApiProvider || apiConfig.actModeApiProvider
|
||||
const initialMode = !provider ? undefined : provider === "cline" ? "featured-models" : "model-picker"
|
||||
const initialModelKey = mode === "act" ? "actModelId" : "planModelId"
|
||||
setActivePanel({ type: "settings", initialMode, initialModelKey })
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return true
|
||||
}
|
||||
|
||||
if (commandName === "history") {
|
||||
setActivePanel({ type: "history" })
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return true
|
||||
}
|
||||
|
||||
if (commandName === "skills") {
|
||||
setActivePanel({ type: "skills" })
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return true
|
||||
}
|
||||
|
||||
if (commandName === "clear") {
|
||||
void clearViewAndResetTask()
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return true
|
||||
}
|
||||
|
||||
if (commandName === "exit" || commandName === "q") {
|
||||
handleExit()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
[clearViewAndResetTask, handleExit, mode, setCursorPos, setTextInput],
|
||||
)
|
||||
|
||||
// Get button config based on the last message state
|
||||
const buttonConfig = useMemo(() => {
|
||||
const lastMsg = messages[messages.length - 1] as ClineMessage | undefined
|
||||
@@ -1102,6 +1178,17 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
|
||||
const inSlashMenu = slashInfo.inSlashMode && filteredCommands.length > 0 && !slashMenuDismissed
|
||||
const inFileMenu = mentionInfo.inMentionMode && fileResults.length > 0 && !inSlashMenu
|
||||
const standaloneSlashCommand = getStandaloneSlashCommandToExecute({
|
||||
prompt,
|
||||
inSlashMode: slashInfo.inSlashMode,
|
||||
hasSlashMenu: inSlashMenu,
|
||||
hasPendingAsk: !!pendingAsk,
|
||||
isSpinnerActive,
|
||||
})
|
||||
|
||||
if (key.return && standaloneSlashCommand && handleCliOnlySlashCommand(standaloneSlashCommand)) {
|
||||
return
|
||||
}
|
||||
|
||||
// 5. Slash command menu navigation (takes priority over file menu)
|
||||
if (inSlashMenu) {
|
||||
@@ -1116,64 +1203,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
if (key.tab || key.return) {
|
||||
const cmd = filteredCommands[selectedSlashIndex]
|
||||
if (cmd) {
|
||||
// Handle CLI-only commands locally
|
||||
if (cmd.name === "help") {
|
||||
setActivePanel({ type: "help" })
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return
|
||||
}
|
||||
if (cmd.name === "settings") {
|
||||
setActivePanel({ type: "settings" })
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return
|
||||
}
|
||||
if (cmd.name === "models") {
|
||||
const apiConfig = StateManager.get().getApiConfiguration()
|
||||
// Use current mode's provider to determine picker type
|
||||
const provider =
|
||||
mode === "act"
|
||||
? apiConfig.actModeApiProvider || apiConfig.planModeApiProvider
|
||||
: apiConfig.planModeApiProvider || apiConfig.actModeApiProvider
|
||||
const initialMode = !provider ? undefined : provider === "cline" ? "featured-models" : "model-picker"
|
||||
// Set model for current mode (plan or act)
|
||||
const initialModelKey = mode === "act" ? "actModelId" : "planModelId"
|
||||
setActivePanel({ type: "settings", initialMode, initialModelKey })
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return
|
||||
}
|
||||
if (cmd.name === "history") {
|
||||
setActivePanel({ type: "history" })
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return
|
||||
}
|
||||
if (cmd.name === "skills") {
|
||||
setActivePanel({ type: "skills" })
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return
|
||||
}
|
||||
if (cmd.name === "clear") {
|
||||
clearViewAndResetTask()
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return
|
||||
}
|
||||
if (cmd.name === "exit" || cmd.name === "q") {
|
||||
handleExit()
|
||||
if (handleCliOnlySlashCommand(cmd.name)) {
|
||||
return
|
||||
}
|
||||
const newText = insertSlashCommand(textInput, slashInfo.slashIndex, cmd.name)
|
||||
@@ -1462,13 +1492,12 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
if (item.type === "header") {
|
||||
// Show static robot frame in header (first frame, looking straight ahead)
|
||||
return (
|
||||
<Box flexDirection="column" key="header">
|
||||
<Box flexDirection="column" key="header" marginBottom={1}>
|
||||
<StaticRobotFrame />
|
||||
<Text> </Text>
|
||||
<Text bold color="white">
|
||||
{centerText("What can I do for you?")}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useState } from "react"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { isEnterKey } from "../utils/input"
|
||||
|
||||
export type RestoreType = "task" | "workspace" | "taskAndWorkspace"
|
||||
|
||||
@@ -101,7 +102,7 @@ export const CheckpointMenu: React.FC<CheckpointMenuProps> = ({ messages, onSele
|
||||
setSelectedCheckpoint((i) => Math.max(0, i - 1))
|
||||
} else if (key.downArrow) {
|
||||
setSelectedCheckpoint((i) => Math.min(checkpoints.length - 1, i + 1))
|
||||
} else if (key.return && checkpoints.length > 0) {
|
||||
} else if (isEnterKey(input, key) && checkpoints.length > 0) {
|
||||
setStage("restoreType")
|
||||
}
|
||||
} else if (stage === "restoreType") {
|
||||
@@ -109,7 +110,7 @@ export const CheckpointMenu: React.FC<CheckpointMenuProps> = ({ messages, onSele
|
||||
setSelectedRestoreType((i) => Math.max(0, i - 1))
|
||||
} else if (key.downArrow) {
|
||||
setSelectedRestoreType((i) => Math.min(RESTORE_TYPE_OPTIONS.length - 1, i + 1))
|
||||
} else if (key.return) {
|
||||
} else if (isEnterKey(input, key)) {
|
||||
const checkpoint = checkpoints[selectedCheckpoint]
|
||||
const restoreType = RESTORE_TYPE_OPTIONS[selectedRestoreType]
|
||||
if (checkpoint && restoreType) {
|
||||
@@ -120,7 +121,7 @@ export const CheckpointMenu: React.FC<CheckpointMenuProps> = ({ messages, onSele
|
||||
|
||||
// Quick number selection for checkpoints
|
||||
if (stage === "checkpoint") {
|
||||
const num = parseInt(input, 10)
|
||||
const num = Number.parseInt(input, 10)
|
||||
if (!Number.isNaN(num) && num >= 1 && num <= checkpoints.length) {
|
||||
setSelectedCheckpoint(num - 1)
|
||||
setStage("restoreType")
|
||||
|
||||
@@ -56,7 +56,13 @@ export interface ObjectEditorState {
|
||||
editValue: string
|
||||
}
|
||||
|
||||
export const EXCLUDED_KEYS = new Set(["taskHistory", "primaryRootIndex", "welcomeViewCompleted", "isNewUser"])
|
||||
export const EXCLUDED_KEYS = new Set([
|
||||
"taskHistory",
|
||||
"primaryRootIndex",
|
||||
"welcomeViewCompleted",
|
||||
"isNewUser",
|
||||
"cliKanbanMigrationAnnouncementShown",
|
||||
])
|
||||
|
||||
export const EDITABLE_TYPES: Set<ValueType> = new Set(["string", "number", "boolean", "object"])
|
||||
export const MAX_VISIBLE = 12
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Rotating feature tips shown during thinking/acting phases.
|
||||
* Appears after a brief delay and cycles through tips to educate users
|
||||
* about Cline features while they wait.
|
||||
*/
|
||||
|
||||
import { Box, Text } from "ink"
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react"
|
||||
|
||||
interface FeatureTipItem {
|
||||
text: string
|
||||
}
|
||||
|
||||
const FEATURE_TIPS: FeatureTipItem[] = [
|
||||
{
|
||||
text: 'Enable "Double-Check Completion" in settings to have Cline verify its work before finishing a task.',
|
||||
},
|
||||
{
|
||||
text: "Add a .clinerules file to your project root to give Cline project-specific instructions.",
|
||||
},
|
||||
{
|
||||
text: "Press Tab to switch between Plan and Act mode — plan an approach before Cline takes action.",
|
||||
},
|
||||
{
|
||||
text: "Use @ in the chat input to add files, folders, or URLs as context for your task.",
|
||||
},
|
||||
{
|
||||
text: "Set up MCP Servers to give Cline access to external tools and APIs.",
|
||||
},
|
||||
{
|
||||
text: "Cline creates checkpoints after changes — you can always restore to a previous state.",
|
||||
},
|
||||
{
|
||||
text: "Use /compact to condense long conversations and free up context window space.",
|
||||
},
|
||||
{
|
||||
text: "Enable auto-approve for read-only tools like file reads to speed up exploration.",
|
||||
},
|
||||
{
|
||||
text: "Use /settings to configure your API provider and model without leaving the terminal.",
|
||||
},
|
||||
{
|
||||
text: "You can pass images with --images flag or paste image file paths in the chat.",
|
||||
},
|
||||
{
|
||||
text: "Cline can browse websites — ask it to test your local dev server in the browser.",
|
||||
},
|
||||
{
|
||||
text: "Use /reportbug to quickly file a GitHub issue with diagnostic context included.",
|
||||
},
|
||||
{
|
||||
text: "Try 'npm i -g cline' to manage tasks on a Kankan board — orchestrate coding agents across worktrees.",
|
||||
},
|
||||
{
|
||||
text: "Use Shift+Tab to toggle auto-approve all — let Cline work uninterrupted on trusted tasks.",
|
||||
},
|
||||
{
|
||||
text: "Press Up/Down arrows in an empty input to browse your previous task prompts.",
|
||||
},
|
||||
{
|
||||
text: "Type / to see all available commands — /history, /compact, /settings, and more.",
|
||||
},
|
||||
{
|
||||
text: "Use /skills to browse and attach reusable skill files that guide Cline's behavior.",
|
||||
},
|
||||
{
|
||||
text: 'You can disable these tips in /settings → Features → "Feature tips".',
|
||||
},
|
||||
]
|
||||
|
||||
const SHOW_DELAY_MS = 2000
|
||||
const CYCLE_INTERVAL_MS = 8000
|
||||
|
||||
/**
|
||||
* Shows rotating feature tips below the thinking indicator.
|
||||
* Appears after a brief delay and cycles through tips while Cline is thinking/acting.
|
||||
*/
|
||||
export const FeatureTip: React.FC = React.memo(() => {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [tipIndex, setTipIndex] = useState(Math.floor(Math.random() * FEATURE_TIPS.length))
|
||||
const cycleTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const showTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const currentTip = FEATURE_TIPS[tipIndex]
|
||||
|
||||
const advanceTip = useCallback(() => {
|
||||
setTipIndex((prev) => (prev + 1) % FEATURE_TIPS.length)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
showTimerRef.current = setTimeout(() => {
|
||||
setIsVisible(true)
|
||||
cycleTimerRef.current = setInterval(advanceTip, CYCLE_INTERVAL_MS)
|
||||
}, SHOW_DELAY_MS)
|
||||
|
||||
return () => {
|
||||
if (showTimerRef.current) {
|
||||
clearTimeout(showTimerRef.current)
|
||||
}
|
||||
if (cycleTimerRef.current) {
|
||||
clearInterval(cycleTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [advanceTip])
|
||||
|
||||
if (!isVisible) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Box paddingLeft={1}>
|
||||
<Text color="gray">
|
||||
💡 <Text bold>Tip:</Text> {currentTip.text}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
@@ -13,7 +13,7 @@ import { showTaskWithId } from "@/core/controller/task/showTaskWithId"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { useTerminalSize } from "../hooks/useTerminalSize"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { isEnterKey, isMouseEscapeSequence } from "../utils/input"
|
||||
import { Panel } from "./Panel"
|
||||
|
||||
interface TaskHistoryItem {
|
||||
@@ -142,7 +142,7 @@ export const HistoryPanelContent: React.FC<HistoryPanelContentProps> = ({ onClos
|
||||
return
|
||||
}
|
||||
|
||||
if (key.return && items[selectedIndex]) {
|
||||
if (isEnterKey(input, key) && items[selectedIndex]) {
|
||||
handleSelect(items[selectedIndex])
|
||||
return
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { showTaskWithId } from "@/core/controller/task/showTaskWithId"
|
||||
import { StringRequest } from "@/shared/proto/cline/common"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { useTerminalSize } from "../hooks/useTerminalSize"
|
||||
import { isEnterKey } from "../utils/input"
|
||||
|
||||
interface TaskHistoryItem {
|
||||
id: string
|
||||
@@ -40,7 +41,7 @@ interface HistoryViewProps {
|
||||
/**
|
||||
* Format separator
|
||||
*/
|
||||
function formatSeparator(char: string = "─", width: number = 80): string {
|
||||
function formatSeparator(char = "─", width = 80): string {
|
||||
return char.repeat(Math.max(width, 10))
|
||||
}
|
||||
|
||||
@@ -111,7 +112,7 @@ export const HistoryView: React.FC<HistoryViewProps> = ({
|
||||
setSelectedIndex((prev) => Math.max(0, prev - 1))
|
||||
} else if (key.downArrow || input === "j") {
|
||||
setSelectedIndex((prev) => Math.min(pageItems.length - 1, prev + 1))
|
||||
} else if (key.return && pageItems[selectedIndex]) {
|
||||
} else if (isEnterKey(input, key) && pageItems[selectedIndex]) {
|
||||
onSelect(pageItems[selectedIndex])
|
||||
} else if (key.leftArrow && hasPrevPage) {
|
||||
handlePageChange(currentPage - 1)
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
importFromCodex,
|
||||
importFromOpenCode,
|
||||
} from "../utils/import-configs"
|
||||
import { isEnterKey } from "../utils/input"
|
||||
import { applyProviderConfig } from "../utils/provider-config"
|
||||
|
||||
type ImportStep = "select" | "confirm" | "saving" | "error"
|
||||
@@ -95,13 +96,13 @@ export const ImportView: React.FC<ImportViewProps> = ({ source, onComplete, onCa
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : keys.length - 1))
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex((prev) => (prev < keys.length - 1 ? prev + 1 : 0))
|
||||
} else if (key.return) {
|
||||
} else if (isEnterKey(input, key)) {
|
||||
setStep("confirm")
|
||||
}
|
||||
} else if (step === "confirm") {
|
||||
if (key.upArrow || key.downArrow) {
|
||||
setConfirmIndex((prev) => (prev === 0 ? 1 : 0))
|
||||
} else if (key.return) {
|
||||
} else if (isEnterKey(input, key)) {
|
||||
if (confirmIndex === 0) {
|
||||
handleConfirm()
|
||||
} else {
|
||||
@@ -109,7 +110,7 @@ export const ImportView: React.FC<ImportViewProps> = ({ source, onComplete, onCa
|
||||
}
|
||||
}
|
||||
} else if (step === "error") {
|
||||
if (key.return) {
|
||||
if (isEnterKey(input, key)) {
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { render } from "ink-testing-library"
|
||||
import { createElement } from "react"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { KanbanMigrationView } from "./KanbanMigrationView"
|
||||
|
||||
describe("KanbanMigrationView", () => {
|
||||
it("renders the migration options", () => {
|
||||
const onSelect = vi.fn()
|
||||
const { lastFrame } = render(createElement(KanbanMigrationView, { isRawModeSupported: true, onSelect }))
|
||||
|
||||
expect(lastFrame()).toContain("Cline is moving out of the terminal. Introducing Cline Kanban.")
|
||||
expect(lastFrame()).toContain("Open the new experience")
|
||||
expect(lastFrame()).toContain("Launch Cline Kanban and start there by default.")
|
||||
expect(lastFrame()).toContain("cline --tui")
|
||||
expect(lastFrame()).toContain("Close and rerun with cline --tui if you want the old CLI.")
|
||||
expect(lastFrame()).toContain("Exit")
|
||||
})
|
||||
|
||||
it("selects the highlighted option with Enter", () => {
|
||||
const onSelect = vi.fn()
|
||||
const { stdin } = render(createElement(KanbanMigrationView, { isRawModeSupported: true, onSelect }))
|
||||
|
||||
stdin.write("\r")
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith("kanban")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Box, Text, useApp, useInput } from "ink"
|
||||
import React, { useMemo, useState } from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { StdinProvider, useStdinContext } from "../context/StdinContext"
|
||||
import { isEnterKey } from "../utils/input"
|
||||
import { type KanbanMigrationAction } from "../utils/kanban"
|
||||
import { StaticRobotFrame } from "./AsciiMotionCli"
|
||||
import { ErrorBoundary } from "./ErrorBoundary"
|
||||
|
||||
interface KanbanMigrationViewProps {
|
||||
isRawModeSupported: boolean
|
||||
onSelect: (action: KanbanMigrationAction) => void
|
||||
}
|
||||
|
||||
interface MigrationMenuItem {
|
||||
label: string
|
||||
description: string
|
||||
value: KanbanMigrationAction
|
||||
}
|
||||
|
||||
const InternalKanbanMigrationView: React.FC<Pick<KanbanMigrationViewProps, "onSelect">> = ({ onSelect }) => {
|
||||
const { exit } = useApp()
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const items = useMemo<MigrationMenuItem[]>(
|
||||
() => [
|
||||
{
|
||||
label: "Open the new experience",
|
||||
description: "Launch Cline Kanban and start there by default.",
|
||||
value: "kanban",
|
||||
},
|
||||
{
|
||||
label: "Exit",
|
||||
description: "Close and rerun with cline --tui if you want the old CLI.",
|
||||
value: "exit",
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (key.escape) {
|
||||
onSelect("exit")
|
||||
exit()
|
||||
} else if (key.upArrow) {
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1))
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0))
|
||||
} else if (isEnterKey(input, key)) {
|
||||
onSelect(items[selectedIndex].value)
|
||||
exit()
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported },
|
||||
)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width="100%">
|
||||
<StaticRobotFrame />
|
||||
<Text> </Text>
|
||||
<Text bold color="white">
|
||||
Cline is moving out of the terminal. Introducing Cline Kanban.
|
||||
</Text>
|
||||
<Text color="gray">A board for orchestrating coding agents across worktrees, right from your browser.</Text>
|
||||
<Text> </Text>
|
||||
{items.map((item, index) => {
|
||||
const isSelected = index === selectedIndex
|
||||
return (
|
||||
<Box flexDirection="column" key={item.value} marginBottom={1}>
|
||||
<Text color={isSelected ? COLORS.primaryBlue : undefined}>
|
||||
{isSelected ? "❯ " : " "}
|
||||
{item.label}
|
||||
</Text>
|
||||
<Text color="gray"> {item.description}</Text>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
<Text> </Text>
|
||||
<Text color="gray">Use arrow keys to navigate, Enter to select, Esc or Ctrl+C to exit</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const KanbanMigrationView: React.FC<KanbanMigrationViewProps> = ({ isRawModeSupported, onSelect }) => {
|
||||
const { exit } = useApp()
|
||||
|
||||
return (
|
||||
<ErrorBoundary exit={exit}>
|
||||
<StdinProvider isRawModeSupported={isRawModeSupported}>
|
||||
<InternalKanbanMigrationView onSelect={onSelect} />
|
||||
</StdinProvider>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
@@ -62,6 +62,8 @@ import {
|
||||
sapAiCoreModels,
|
||||
vertexDefaultModelId,
|
||||
vertexModels,
|
||||
wandbDefaultModelId,
|
||||
wandbModels,
|
||||
xaiDefaultModelId,
|
||||
xaiModels,
|
||||
} from "@/shared/api"
|
||||
@@ -101,6 +103,7 @@ export const providerModels: Record<string, { models: Record<string, unknown>; d
|
||||
sambanova: { models: sambanovaModels, defaultId: sambanovaDefaultModelId },
|
||||
sapaicore: { models: sapAiCoreModels, defaultId: sapAiCoreDefaultModelId },
|
||||
vertex: { models: vertexModels, defaultId: vertexDefaultModelId },
|
||||
wandb: { models: wandbModels, defaultId: wandbDefaultModelId },
|
||||
xai: { models: xaiModels, defaultId: xaiDefaultModelId },
|
||||
zai: { models: internationalZAiModels, defaultId: internationalZAiDefaultModelId },
|
||||
}
|
||||
|
||||
@@ -1,112 +1,38 @@
|
||||
import { render } from "ink-testing-library"
|
||||
// biome-ignore lint/correctness/noUnusedImports: React must be in scope for JSX in this test file.
|
||||
import React from "react"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { CLI_ONLY_COMMANDS } from "@shared/slashCommands"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { filterCommands, getStandaloneSlashCommandName, getStandaloneSlashCommandToExecute } from "../utils/slash-commands"
|
||||
|
||||
// Mock ink's useApp
|
||||
const mockExit = vi.fn()
|
||||
vi.mock("ink", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("ink")>()
|
||||
return {
|
||||
...actual,
|
||||
useApp: () => ({ exit: mockExit }),
|
||||
}
|
||||
})
|
||||
|
||||
// Mock child_process
|
||||
vi.mock("child_process", () => ({
|
||||
execSync: vi.fn().mockReturnValue(""),
|
||||
exec: vi.fn(),
|
||||
const cliOnlySlashCommands = CLI_ONLY_COMMANDS.map((cmd) => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description || "",
|
||||
section: cmd.section || "default",
|
||||
cliCompatible: true,
|
||||
}))
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/core/controller/slash/getAvailableSlashCommands", () => ({
|
||||
getAvailableSlashCommands: vi.fn().mockResolvedValue({ commands: [] }),
|
||||
}))
|
||||
|
||||
vi.mock("@/core/storage/StateManager", () => ({
|
||||
StateManager: {
|
||||
get: () => ({
|
||||
getGlobalSettingsKey: vi.fn().mockReturnValue("act"),
|
||||
getGlobalStateKey: vi.fn().mockReturnValue([]),
|
||||
getApiConfiguration: vi.fn().mockReturnValue({}),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/services/telemetry", () => ({
|
||||
telemetryService: {
|
||||
captureHostEvent: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@shared/services/Session", () => ({
|
||||
Session: {
|
||||
get: () => ({
|
||||
getStats: vi.fn().mockReturnValue({}),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("../context/TaskContext", () => ({
|
||||
useTaskContext: () => ({
|
||||
controller: {},
|
||||
clearState: vi.fn(),
|
||||
}),
|
||||
useTaskState: () => ({
|
||||
clineMessages: [],
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock("../hooks/useStateSubscriber", () => ({
|
||||
useIsSpinnerActive: () => ({ isActive: false, startTime: 0 }),
|
||||
}))
|
||||
|
||||
import { ChatView } from "./ChatView"
|
||||
|
||||
// Helper to wait for async state updates
|
||||
const delay = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
describe("Quit Command (/q and /exit)", () => {
|
||||
const mockOnExit = vi.fn()
|
||||
it("prioritizes /q as the selected slash command for an exact q query", () => {
|
||||
const result = filterCommands(cliOnlySlashCommands, "q")
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
expect(result[0]?.name).toBe("q")
|
||||
})
|
||||
|
||||
it("should exit the application when /q is selected from slash menu", async () => {
|
||||
const { stdin } = render(<ChatView onExit={mockOnExit} />)
|
||||
await delay()
|
||||
|
||||
// Type /q
|
||||
stdin.write("/q")
|
||||
await delay()
|
||||
|
||||
// Press Enter
|
||||
stdin.write("\r")
|
||||
|
||||
// handleExit has a 150ms timeout
|
||||
await delay(200)
|
||||
|
||||
expect(mockExit).toHaveBeenCalled()
|
||||
expect(mockOnExit).toHaveBeenCalled()
|
||||
it("detects /q as a standalone slash command", () => {
|
||||
expect(getStandaloneSlashCommandName("/q")).toBe("q")
|
||||
})
|
||||
|
||||
it("should exit the application when /exit is selected from slash menu", async () => {
|
||||
const { stdin } = render(<ChatView onExit={mockOnExit} />)
|
||||
await delay()
|
||||
it("detects /exit as a standalone slash command", () => {
|
||||
expect(getStandaloneSlashCommandName("/exit")).toBe("exit")
|
||||
})
|
||||
|
||||
// Type /exit
|
||||
stdin.write("/exit")
|
||||
await delay()
|
||||
|
||||
// Press Enter
|
||||
stdin.write("\r")
|
||||
|
||||
// handleExit has a 150ms timeout
|
||||
await delay(200)
|
||||
|
||||
expect(mockExit).toHaveBeenCalled()
|
||||
expect(mockOnExit).toHaveBeenCalled()
|
||||
it("resolves /q to direct execution when no slash menu is active", () => {
|
||||
expect(
|
||||
getStandaloneSlashCommandToExecute({
|
||||
prompt: "/q",
|
||||
inSlashMode: true,
|
||||
hasSlashMenu: false,
|
||||
hasPendingAsk: false,
|
||||
isSpinnerActive: false,
|
||||
}),
|
||||
).toBe("q")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Box, Text, useInput } from "ink"
|
||||
import React, { useState } from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { isEnterKey } from "../utils/input"
|
||||
|
||||
export interface SelectListItem {
|
||||
id: string
|
||||
@@ -31,7 +32,7 @@ export function SelectList<T extends SelectListItem>({ items, onSelect, isActive
|
||||
setSelectedIndex((i) => (i > 0 ? i - 1 : items.length - 1))
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex((i) => (i < items.length - 1 ? i + 1 : 0))
|
||||
} else if (key.return) {
|
||||
} else if (isEnterKey(_input, key)) {
|
||||
const item = items[selectedIndex]
|
||||
if (item) {
|
||||
onSelect(item)
|
||||
|
||||
@@ -129,6 +129,12 @@ const FEATURE_SETTINGS = {
|
||||
label: "Double-check completion",
|
||||
description: "Reject first completion attempt and require re-verification",
|
||||
},
|
||||
showFeatureTips: {
|
||||
stateKey: "showFeatureTips",
|
||||
default: true,
|
||||
label: "Feature tips",
|
||||
description: "Show tips during thinking phases",
|
||||
},
|
||||
} as const
|
||||
|
||||
type FeatureKey = keyof typeof FEATURE_SETTINGS
|
||||
|
||||
@@ -120,7 +120,9 @@ describe("SkillsPanelContent", () => {
|
||||
await delay()
|
||||
|
||||
// Navigate down to marketplace (past the one skill)
|
||||
stdin.write("\x1B[B") // Down arrow
|
||||
// Use vim-style navigation here because it's more deterministic in the
|
||||
// full suite than raw arrow escape sequences on Windows.
|
||||
stdin.write("j")
|
||||
await delay()
|
||||
|
||||
stdin.write("\r") // Enter
|
||||
|
||||
@@ -12,7 +12,7 @@ import { refreshSkills } from "@/core/controller/file/refreshSkills"
|
||||
import { toggleSkill } from "@/core/controller/file/toggleSkill"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { isEnterKey, isMouseEscapeSequence } from "../utils/input"
|
||||
import { Panel } from "./Panel"
|
||||
|
||||
const SKILLS_MARKETPLACE_URL = "https://skills.sh/"
|
||||
@@ -143,7 +143,7 @@ export const SkillsPanelContent: React.FC<SkillsPanelContentProps> = ({ controll
|
||||
}
|
||||
|
||||
// Actions
|
||||
if (key.return) {
|
||||
if (isEnterKey(input, key)) {
|
||||
if (isMarketplaceSelected) {
|
||||
openMarketplace()
|
||||
} else {
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useEffect, useMemo, useState } from "react"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { FeatureTip } from "./FeatureTip"
|
||||
|
||||
interface ThinkingIndicatorProps {
|
||||
mode?: "act" | "plan"
|
||||
@@ -52,6 +54,7 @@ const ShimmerText: React.FC<{ text: string; color: string; shimmerPos: number }>
|
||||
}
|
||||
|
||||
export const ThinkingIndicator: React.FC<ThinkingIndicatorProps> = ({ mode = "act", startTime, onCancel }) => {
|
||||
const showFeatureTips = StateManager.get().getGlobalSettingsKey("showFeatureTips") ?? true
|
||||
const message = mode === "plan" ? "Planning" : "Acting"
|
||||
const color = mode === "plan" ? "yellow" : COLORS.primaryBlue
|
||||
|
||||
@@ -118,9 +121,12 @@ export const ThinkingIndicator: React.FC<ThinkingIndicatorProps> = ({ mode = "ac
|
||||
}, [startTime, elapsedMs])
|
||||
|
||||
return (
|
||||
<Box paddingLeft={1}>
|
||||
<ShimmerText color={color} shimmerPos={shimmerPos} text={fullText} />
|
||||
{elapsedStr && <Text color="gray"> ({elapsedStr} · esc to interrupt)</Text>}
|
||||
<Box flexDirection="column">
|
||||
<Box paddingLeft={1}>
|
||||
<ShimmerText color={color} shimmerPos={shimmerPos} text={fullText} />
|
||||
{elapsedStr && <Text color="gray"> ({elapsedStr} · esc to interrupt)</Text>}
|
||||
</Box>
|
||||
{showFeatureTips && <FeatureTip />}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
/**
|
||||
* Welcome view component
|
||||
* Shows an interactive prompt when user starts cline without a command
|
||||
* Supports file mentions with @
|
||||
*/
|
||||
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import type { ApiProvider } from "@/shared/api"
|
||||
import { getProviderDefaultModelId, getProviderModelIdKey, Mode, SettingsKey } from "@/shared/storage"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import {
|
||||
checkAndWarnRipgrepMissing,
|
||||
extractMentionQuery,
|
||||
type FileSearchResult,
|
||||
getRipgrepInstallInstructions,
|
||||
insertMention,
|
||||
searchWorkspaceFiles,
|
||||
} from "../utils/file-search"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { parseImagesFromInput } from "../utils/parser"
|
||||
import { AccountInfoView } from "./AccountInfoView"
|
||||
import { FileMentionMenu } from "./FileMentionMenu"
|
||||
|
||||
interface WelcomeViewProps {
|
||||
onSubmit: (prompt: string, imagePaths: string[]) => void
|
||||
onExit?: () => void
|
||||
controller?: any
|
||||
}
|
||||
|
||||
// ASCII art Cline logo
|
||||
const CLINE_LOGO = [
|
||||
" ::::::: ",
|
||||
" ::::::::: ",
|
||||
" ::::::::::::::::: ",
|
||||
" ::::::::::::::::::::::: ",
|
||||
" ::::::::::::::::::::::::: ",
|
||||
" ::::::::::::::::::::::::::: ",
|
||||
" ::::::: ::::::: ::::::: ",
|
||||
" ::::::: ::::: ::::::: ",
|
||||
":::::::: ::::: ::::::::",
|
||||
":::::::: ::::: ::::::::",
|
||||
" ::::::: ::::: ::::::: ",
|
||||
" ::::::: ::::::: ::::::: ",
|
||||
" ::::::::::::::::::::::::::: ",
|
||||
" ::::::::::::::::::::::::: ",
|
||||
" ::::::::::::::::::::::: ",
|
||||
" :::::::::::::::: ",
|
||||
]
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 150
|
||||
const RIPGREP_WARNING_DURATION_MS = 5000
|
||||
const MAX_SEARCH_RESULTS = 15
|
||||
|
||||
export const WelcomeView: React.FC<WelcomeViewProps> = ({ onSubmit, onExit, controller }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const [textInput, setTextInput] = useState("")
|
||||
const [fileResults, setFileResults] = useState<FileSearchResult[]>([])
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [showRipgrepWarning, setShowRipgrepWarning] = useState(false)
|
||||
const [escPressedOnce, setEscPressedOnce] = useState(false)
|
||||
const [mode, setMode] = useState<Mode>(() => {
|
||||
const stateManager = StateManager.get()
|
||||
return stateManager.getGlobalSettingsKey("mode") || "act"
|
||||
})
|
||||
|
||||
const provider = useMemo(() => {
|
||||
const stateManager = StateManager.get()
|
||||
const mode = stateManager.getGlobalSettingsKey("mode") as string
|
||||
const providerKey = mode === "act" ? "actModeApiProvider" : "planModeApiProvider"
|
||||
const currentProvider = stateManager.getGlobalSettingsKey(providerKey) as string
|
||||
return currentProvider || "cline"
|
||||
}, [controller])
|
||||
|
||||
// Get model ID based on current mode and provider
|
||||
// Different providers use different state keys (e.g., cline uses actModeOpenRouterModelId)
|
||||
const modelId = useMemo(() => {
|
||||
const stateManager = StateManager.get()
|
||||
const modelKey = getProviderModelIdKey(provider as ApiProvider, mode)
|
||||
return (
|
||||
(stateManager.getGlobalSettingsKey(modelKey as SettingsKey) as string) ||
|
||||
getProviderDefaultModelId(provider as ApiProvider)
|
||||
)
|
||||
}, [mode, provider])
|
||||
|
||||
const toggleMode = useCallback(() => {
|
||||
const newMode: Mode = mode === "act" ? "plan" : "act"
|
||||
setMode(newMode)
|
||||
const stateManager = StateManager.get()
|
||||
stateManager.setGlobalState("mode", newMode)
|
||||
}, [mode])
|
||||
|
||||
const refs = useRef({
|
||||
searchTimeout: null as NodeJS.Timeout | null,
|
||||
lastQuery: "",
|
||||
hasCheckedRipgrep: false,
|
||||
})
|
||||
|
||||
const { prompt, imagePaths } = parseImagesFromInput(textInput)
|
||||
|
||||
const mentionInfo = useMemo(() => extractMentionQuery(textInput), [textInput])
|
||||
|
||||
const workspacePath = useMemo(() => {
|
||||
try {
|
||||
const root = controller?.getWorkspaceManagerSync?.()?.getPrimaryRoot?.()
|
||||
if (root?.path) {
|
||||
return root.path
|
||||
}
|
||||
} catch {
|
||||
// Fallback to cwd
|
||||
}
|
||||
return process.cwd()
|
||||
}, [controller])
|
||||
|
||||
// Search for files when in mention mode
|
||||
useEffect(() => {
|
||||
const { current: r } = refs
|
||||
|
||||
if (!mentionInfo.inMentionMode) {
|
||||
setFileResults([])
|
||||
setSelectedIndex(0)
|
||||
if (r.searchTimeout) {
|
||||
clearTimeout(r.searchTimeout)
|
||||
r.searchTimeout = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check for ripgrep on first mention trigger
|
||||
if (!r.hasCheckedRipgrep) {
|
||||
r.hasCheckedRipgrep = true
|
||||
if (checkAndWarnRipgrepMissing()) {
|
||||
setShowRipgrepWarning(true)
|
||||
setTimeout(() => setShowRipgrepWarning(false), RIPGREP_WARNING_DURATION_MS)
|
||||
}
|
||||
}
|
||||
|
||||
const { query } = mentionInfo
|
||||
if (query === r.lastQuery) {
|
||||
return
|
||||
}
|
||||
r.lastQuery = query
|
||||
|
||||
if (r.searchTimeout) {
|
||||
clearTimeout(r.searchTimeout)
|
||||
}
|
||||
setIsSearching(true)
|
||||
|
||||
r.searchTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const results = await searchWorkspaceFiles(query, workspacePath, MAX_SEARCH_RESULTS)
|
||||
setFileResults(results)
|
||||
setSelectedIndex(0)
|
||||
} catch {
|
||||
setFileResults([])
|
||||
} finally {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}, SEARCH_DEBOUNCE_MS)
|
||||
|
||||
return () => {
|
||||
if (r.searchTimeout) {
|
||||
clearTimeout(r.searchTimeout)
|
||||
}
|
||||
}
|
||||
}, [mentionInfo.inMentionMode, mentionInfo.query, workspacePath])
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
// Filter out mouse escape sequences
|
||||
if (isMouseEscapeSequence(input)) {
|
||||
return
|
||||
}
|
||||
|
||||
const inMenu = mentionInfo.inMentionMode && fileResults.length > 0
|
||||
|
||||
// Menu navigation
|
||||
if (inMenu) {
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((i) => (i > 0 ? i - 1 : fileResults.length - 1))
|
||||
return
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setSelectedIndex((i) => (i < fileResults.length - 1 ? i + 1 : 0))
|
||||
return
|
||||
}
|
||||
if (key.tab || key.return) {
|
||||
const file = fileResults[selectedIndex]
|
||||
if (file) {
|
||||
setTextInput(insertMention(textInput, mentionInfo.atIndex, file.path))
|
||||
setFileResults([])
|
||||
setSelectedIndex(0)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (key.escape) {
|
||||
setFileResults([])
|
||||
setSelectedIndex(0)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Normal input handling
|
||||
if (key.tab && !mentionInfo.inMentionMode) {
|
||||
toggleMode()
|
||||
return
|
||||
}
|
||||
if (key.return && !mentionInfo.inMentionMode) {
|
||||
if (prompt.trim() || imagePaths.length > 0) {
|
||||
onSubmit(prompt.trim(), imagePaths)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (key.escape && !mentionInfo.inMentionMode) {
|
||||
if (escPressedOnce) {
|
||||
onExit?.()
|
||||
} else {
|
||||
setEscPressedOnce(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (key.backspace || key.delete) {
|
||||
setTextInput((prev) => prev.slice(0, -1))
|
||||
setEscPressedOnce(false)
|
||||
return
|
||||
}
|
||||
if (input && !key.ctrl && !key.meta && !key.upArrow && !key.downArrow && !key.tab) {
|
||||
setTextInput((prev) => prev + input)
|
||||
setEscPressedOnce(false)
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported },
|
||||
)
|
||||
|
||||
const borderColor = mode === "act" ? "blue" : "yellow"
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width="100%">
|
||||
{/* Account/Provider info at top */}
|
||||
{controller && (
|
||||
<Box marginBottom={1}>
|
||||
<AccountInfoView controller={controller} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Cline logo - centered */}
|
||||
<Box alignItems="center" flexDirection="column">
|
||||
{CLINE_LOGO.map((line, idx) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static array that never changes
|
||||
<Text color="white" key={idx}>
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Main prompt - centered, bold */}
|
||||
<Box justifyContent="center" marginTop={1}>
|
||||
<Text bold color="white">
|
||||
What can I do for you?
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Ripgrep warning if needed */}
|
||||
{showRipgrepWarning && (
|
||||
<Box marginTop={1}>
|
||||
<Text color="yellow">⚠ ripgrep not found - file search will be slower. </Text>
|
||||
<Text color="gray">Install: {getRipgrepInstallInstructions()}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Input field with border */}
|
||||
<Box
|
||||
borderColor={borderColor}
|
||||
borderStyle="round"
|
||||
flexDirection="row"
|
||||
marginTop={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
width="100%">
|
||||
<Text>{textInput}</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
|
||||
{/* Model ID and Mode toggle row */}
|
||||
<Box justifyContent="space-between" width="100%">
|
||||
{/* Model ID on left */}
|
||||
<Text color="gray">{modelId}</Text>
|
||||
|
||||
{/* Mode toggle on right */}
|
||||
<Box gap={1}>
|
||||
<Box>
|
||||
<Text bold={mode === "plan"} color={mode === "plan" ? "yellow" : "gray"}>
|
||||
{mode === "plan" ? "●" : "○"} Plan
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text bold={mode === "act"} color={mode === "act" ? "blue" : "gray"}>
|
||||
{mode === "act" ? "●" : "○"} Act
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color="gray">(Tab)</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* File mention menu - below input */}
|
||||
{mentionInfo.inMentionMode && (
|
||||
<FileMentionMenu
|
||||
isLoading={isSearching}
|
||||
query={mentionInfo.query}
|
||||
results={fileResults}
|
||||
selectedIndex={selectedIndex}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Attached images */}
|
||||
{imagePaths.length > 0 && (
|
||||
<Text color="magenta">
|
||||
📎 {imagePaths.length} image{imagePaths.length > 1 ? "s" : ""} attached
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Help text */}
|
||||
<Box>
|
||||
<Text color="gray">Enter to submit · @ to mention files · </Text>
|
||||
<Text bold={escPressedOnce} color={escPressedOnce ? "white" : "gray"}>
|
||||
{escPressedOnce ? "Press Esc again to exit" : "Esc to exit"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -79,7 +79,7 @@ export class CliDiffServiceClient implements DiffServiceClientInterface {
|
||||
* CLI implementation of EnvService - handles environment operations
|
||||
*/
|
||||
export class CliEnvServiceClient implements EnvServiceClientInterface {
|
||||
private clipboardContent: string = ""
|
||||
private clipboardContent = ""
|
||||
|
||||
private getTelemetrySetting(): proto.host.Setting {
|
||||
// Read from StateManager - defaults to ENABLED if not set or "unset"
|
||||
@@ -102,6 +102,8 @@ export class CliEnvServiceClient implements EnvServiceClientInterface {
|
||||
version: CLI_VERSION,
|
||||
platform: "Cline CLI - Node.js",
|
||||
clineType: ClineClient.Cli,
|
||||
// remoteName is intentionally omitted — the CLI runs locally on the user's machine.
|
||||
// If CLI-in-container scenarios arise, populate this field to enable remote cadence tuning.
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+107
-30
@@ -11,6 +11,22 @@ import { captureUnhandledException } from "."
|
||||
describe("CLI Commands", () => {
|
||||
let program: Command
|
||||
|
||||
function getCommand(name: string): Command {
|
||||
const command = program.commands.find((candidate) => candidate.name() === name)
|
||||
if (!command) {
|
||||
throw new Error(`Missing command: ${name}`)
|
||||
}
|
||||
return command
|
||||
}
|
||||
|
||||
function getSubcommand(commandName: string, subcommandName: string): Command {
|
||||
const subcommand = getCommand(commandName).commands.find((candidate) => candidate.name() === subcommandName)
|
||||
if (!subcommand) {
|
||||
throw new Error(`Missing subcommand: ${commandName} ${subcommandName}`)
|
||||
}
|
||||
return subcommand
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a fresh program instance for each test
|
||||
program = new Command()
|
||||
@@ -67,6 +83,22 @@ describe("CLI Commands", () => {
|
||||
.option("--config <path>", "Configuration directory")
|
||||
.action(() => {})
|
||||
|
||||
const mcpCommand = program.command("mcp").description("Manage MCP servers")
|
||||
mcpCommand
|
||||
.command("add")
|
||||
.description("Add an MCP server shortcut")
|
||||
.argument("<name>", "MCP server name")
|
||||
.argument("[targetOrCommand...]", "Command args for stdio, or URL for remote")
|
||||
.option("--type <type>", "Transport type", "stdio")
|
||||
.option("-c, --cwd <path>", "Working directory")
|
||||
.option("--config <path>", "Configuration directory")
|
||||
.action(() => {})
|
||||
|
||||
program
|
||||
.command("kanban")
|
||||
.description("Run kanban")
|
||||
.action(() => {})
|
||||
|
||||
// Default command for interactive mode
|
||||
program
|
||||
.argument("[prompt]", "Task prompt")
|
||||
@@ -81,6 +113,8 @@ describe("CLI Commands", () => {
|
||||
.option("--auto-condense", "Enable AI-powered context compaction instead of mechanical truncation")
|
||||
.option("--hooks-dir <path>", "Additional hooks directory")
|
||||
.option("--auto-approve-all", "Enable auto-approve all")
|
||||
.option("--kanban", "Run kanban")
|
||||
.option("--tui", "Open the legacy terminal UI instead of the kanban experience")
|
||||
.action(() => {})
|
||||
})
|
||||
|
||||
@@ -97,119 +131,119 @@ describe("CLI Commands", () => {
|
||||
})
|
||||
|
||||
it("should parse --act flag", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--act"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().act).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --plan flag", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--plan"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().plan).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --yolo flag", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--yolo"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().yolo).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --auto-approve-all flag", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--auto-approve-all"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().autoApproveAll).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --model option", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--model", "claude-sonnet-4-20250514"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().model).toBe("claude-sonnet-4-20250514")
|
||||
})
|
||||
|
||||
it("should parse --images option with multiple paths", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--images", "/path/to/img1.png", "/path/to/img2.jpg"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().images).toEqual(["/path/to/img1.png", "/path/to/img2.jpg"])
|
||||
})
|
||||
|
||||
it("should parse --verbose flag", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--verbose"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().verbose).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --cwd option", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--cwd", "/some/path"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().cwd).toBe("/some/path")
|
||||
})
|
||||
|
||||
it("should parse --config option", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--config", "/custom/config"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().config).toBe("/custom/config")
|
||||
})
|
||||
|
||||
it("should parse --thinking flag", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--thinking"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().thinking).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --thinking with token budget", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--thinking", "8000"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().thinking).toBe("8000")
|
||||
})
|
||||
|
||||
it("should parse --reasoning-effort option", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--reasoning-effort", "high"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().reasoningEffort).toBe("high")
|
||||
})
|
||||
|
||||
it("should parse --max-consecutive-mistakes option", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--max-consecutive-mistakes", "999"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().maxConsecutiveMistakes).toBe("999")
|
||||
})
|
||||
|
||||
it("should parse --hooks-dir option", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--hooks-dir", "/tmp/hooks"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().hooksDir).toBe("/tmp/hooks")
|
||||
})
|
||||
|
||||
it("should parse --double-check-completion flag", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--double-check-completion"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().doubleCheckCompletion).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --auto-condense flag", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--auto-condense"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().autoCondense).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse short flags", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "-a", "-v", "-m", "gpt-4"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().act).toBe(true)
|
||||
@@ -220,26 +254,26 @@ describe("CLI Commands", () => {
|
||||
|
||||
describe("history command", () => {
|
||||
it("should have default limit of 10", () => {
|
||||
const historyCmd = program.commands.find((c) => c.name() === "history")!
|
||||
const historyCmd = getCommand("history")
|
||||
historyCmd.parse([], { from: "user" })
|
||||
expect(historyCmd.opts().limit).toBe("10")
|
||||
})
|
||||
|
||||
it("should have default page of 1", () => {
|
||||
const historyCmd = program.commands.find((c) => c.name() === "history")!
|
||||
const historyCmd = getCommand("history")
|
||||
historyCmd.parse([], { from: "user" })
|
||||
expect(historyCmd.opts().page).toBe("1")
|
||||
})
|
||||
|
||||
it("should parse --limit option", () => {
|
||||
const historyCmd = program.commands.find((c) => c.name() === "history")!
|
||||
const historyCmd = getCommand("history")
|
||||
const args = ["--limit", "20"]
|
||||
historyCmd.parse(args, { from: "user" })
|
||||
expect(historyCmd.opts().limit).toBe("20")
|
||||
})
|
||||
|
||||
it("should parse --page option", () => {
|
||||
const historyCmd = program.commands.find((c) => c.name() === "history")!
|
||||
const historyCmd = getCommand("history")
|
||||
const args = ["--page", "3"]
|
||||
historyCmd.parse(args, { from: "user" })
|
||||
expect(historyCmd.opts().page).toBe("3")
|
||||
@@ -252,7 +286,7 @@ describe("CLI Commands", () => {
|
||||
})
|
||||
|
||||
it("should parse short flags", () => {
|
||||
const historyCmd = program.commands.find((c) => c.name() === "history")!
|
||||
const historyCmd = getCommand("history")
|
||||
const args = ["-n", "5", "-p", "2"]
|
||||
historyCmd.parse(args, { from: "user" })
|
||||
expect(historyCmd.opts().limit).toBe("5")
|
||||
@@ -267,13 +301,20 @@ describe("CLI Commands", () => {
|
||||
})
|
||||
|
||||
it("should parse --config option", () => {
|
||||
const configCmd = program.commands.find((c) => c.name() === "config")!
|
||||
const configCmd = getCommand("config")
|
||||
const args = ["--config", "/custom/path"]
|
||||
configCmd.parse(args, { from: "user" })
|
||||
expect(configCmd.opts().config).toBe("/custom/path")
|
||||
})
|
||||
})
|
||||
|
||||
describe("kanban command", () => {
|
||||
it("should parse kanban command", () => {
|
||||
const args = ["node", "cli", "kanban"]
|
||||
program.parse(args)
|
||||
})
|
||||
})
|
||||
|
||||
describe("auth command", () => {
|
||||
it("should parse auth command", () => {
|
||||
const args = ["node", "cli", "auth"]
|
||||
@@ -281,35 +322,35 @@ describe("CLI Commands", () => {
|
||||
})
|
||||
|
||||
it("should parse --provider option", () => {
|
||||
const authCmd = program.commands.find((c) => c.name() === "auth")!
|
||||
const authCmd = getCommand("auth")
|
||||
const args = ["--provider", "openai"]
|
||||
authCmd.parse(args, { from: "user" })
|
||||
expect(authCmd.opts().provider).toBe("openai")
|
||||
})
|
||||
|
||||
it("should parse --apikey option", () => {
|
||||
const authCmd = program.commands.find((c) => c.name() === "auth")!
|
||||
const authCmd = getCommand("auth")
|
||||
const args = ["--apikey", "sk-test-key"]
|
||||
authCmd.parse(args, { from: "user" })
|
||||
expect(authCmd.opts().apikey).toBe("sk-test-key")
|
||||
})
|
||||
|
||||
it("should parse --modelid option", () => {
|
||||
const authCmd = program.commands.find((c) => c.name() === "auth")!
|
||||
const authCmd = getCommand("auth")
|
||||
const args = ["--modelid", "gpt-4"]
|
||||
authCmd.parse(args, { from: "user" })
|
||||
expect(authCmd.opts().modelid).toBe("gpt-4")
|
||||
})
|
||||
|
||||
it("should parse --baseurl option", () => {
|
||||
const authCmd = program.commands.find((c) => c.name() === "auth")!
|
||||
const authCmd = getCommand("auth")
|
||||
const args = ["--baseurl", "https://api.example.com"]
|
||||
authCmd.parse(args, { from: "user" })
|
||||
expect(authCmd.opts().baseurl).toBe("https://api.example.com")
|
||||
})
|
||||
|
||||
it("should parse short flags", () => {
|
||||
const authCmd = program.commands.find((c) => c.name() === "auth")!
|
||||
const authCmd = getCommand("auth")
|
||||
const args = ["-p", "anthropic", "-k", "key123", "-m", "claude-sonnet-4-20250514"]
|
||||
authCmd.parse(args, { from: "user" })
|
||||
expect(authCmd.opts().provider).toBe("anthropic")
|
||||
@@ -318,6 +359,30 @@ describe("CLI Commands", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("mcp command", () => {
|
||||
it("should parse mcp add stdio syntax", () => {
|
||||
const args = ["node", "cli", "mcp", "add", "kanban", "--", "kanban", "mcp"]
|
||||
program.parse(args)
|
||||
})
|
||||
|
||||
it("should parse mcp add remote http syntax", () => {
|
||||
const args = ["node", "cli", "mcp", "add", "linear", "https://mcp.linear.app/mcp", "--type", "http"]
|
||||
program.parse(args)
|
||||
})
|
||||
|
||||
it("should default mcp add type to stdio", () => {
|
||||
const addCmd = getSubcommand("mcp", "add")
|
||||
addCmd.parse(["kanban", "--", "kanban", "mcp"], { from: "user" })
|
||||
expect(addCmd.opts().type).toBe("stdio")
|
||||
})
|
||||
|
||||
it("should parse mcp add type option", () => {
|
||||
const addCmd = getSubcommand("mcp", "add")
|
||||
addCmd.parse(["linear", "https://mcp.linear.app/mcp", "--type", "http"], { from: "user" })
|
||||
expect(addCmd.opts().type).toBe("http")
|
||||
})
|
||||
})
|
||||
|
||||
describe("default command (interactive mode)", () => {
|
||||
it("should parse optional prompt argument", () => {
|
||||
const args = ["node", "cli", "do something"]
|
||||
@@ -368,6 +433,16 @@ describe("CLI Commands", () => {
|
||||
program.parse(["node", "cli", "--auto-approve-all"])
|
||||
expect(program.opts().autoApproveAll).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --kanban flag", () => {
|
||||
program.parse(["node", "cli", "--kanban"])
|
||||
expect(program.opts().kanban).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --tui flag", () => {
|
||||
program.parse(["node", "cli", "--tui"])
|
||||
expect(program.opts().tui).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("command structure", () => {
|
||||
@@ -377,11 +452,13 @@ describe("CLI Commands", () => {
|
||||
expect(commandNames).toContain("history")
|
||||
expect(commandNames).toContain("config")
|
||||
expect(commandNames).toContain("auth")
|
||||
expect(commandNames).toContain("mcp")
|
||||
expect(commandNames).toContain("kanban")
|
||||
})
|
||||
|
||||
it("should have correct aliases", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const historyCmd = program.commands.find((c) => c.name() === "history")!
|
||||
const taskCmd = getCommand("task")
|
||||
const historyCmd = getCommand("history")
|
||||
expect(taskCmd.aliases()).toContain("t")
|
||||
expect(historyCmd.aliases()).toContain("h")
|
||||
})
|
||||
|
||||
+228
-1
@@ -2,6 +2,7 @@
|
||||
* Cline CLI - TypeScript implementation with React Ink
|
||||
*/
|
||||
|
||||
import type { ChildProcess } from "node:child_process"
|
||||
import { exit } from "node:process"
|
||||
import type { ApiProvider } from "@shared/api"
|
||||
import { Command } from "commander"
|
||||
@@ -27,6 +28,7 @@ import { isOpenaiReasoningEffort, OPENAI_REASONING_EFFORT_OPTIONS, type OpenaiRe
|
||||
import { version as CLI_VERSION } from "../package.json"
|
||||
import { runAcpMode } from "./acp/index.js"
|
||||
import { App } from "./components/App"
|
||||
import { KanbanMigrationView } from "./components/KanbanMigrationView"
|
||||
import { checkRawModeSupport } from "./context/StdinContext"
|
||||
import { createCliHostBridgeProvider } from "./controllers"
|
||||
import { CliCommentReviewController } from "./controllers/CliCommentReviewController"
|
||||
@@ -34,6 +36,21 @@ import { CliWebviewProvider } from "./controllers/CliWebviewProvider"
|
||||
import { isAuthConfigured } from "./utils/auth"
|
||||
import { restoreConsole, suppressConsoleUnlessVerbose } from "./utils/console"
|
||||
import { printInfo, printWarning } from "./utils/display"
|
||||
import {
|
||||
forwardSignalToKanbanProcess,
|
||||
isKanbanCommandAvailable,
|
||||
KANBAN_LAUNCH_COMMAND,
|
||||
KANBAN_SHUTDOWN_TIMEOUT_MS,
|
||||
type KanbanMigrationAction,
|
||||
LEGACY_TUI_FLAG,
|
||||
markKanbanMigrationAnnouncementShown,
|
||||
resolveKanbanInstallCommand,
|
||||
shouldLaunchKanbanByDefault,
|
||||
shouldShowKanbanMigrationAnnouncementForCurrentUser,
|
||||
spawnKanbanInstallProcess,
|
||||
spawnKanbanProcess,
|
||||
} from "./utils/kanban"
|
||||
import { addMcpServerShortcut, type McpAddOptions } from "./utils/mcp"
|
||||
import { selectOutputMode } from "./utils/mode-selection"
|
||||
import { parseImagesFromInput, processImagePaths } from "./utils/parser"
|
||||
import { CLINE_CLI_DIR, getCliBinaryPath } from "./utils/path"
|
||||
@@ -56,6 +73,8 @@ suppressConsoleUnlessVerbose()
|
||||
interface TaskOptions {
|
||||
act?: boolean
|
||||
plan?: boolean
|
||||
kanban?: boolean
|
||||
tui?: boolean
|
||||
model?: string
|
||||
verbose?: boolean
|
||||
cwd?: string
|
||||
@@ -248,6 +267,83 @@ function getPlainTextModeReason(options: TaskOptions): string {
|
||||
return getModeSelection(options).reason
|
||||
}
|
||||
|
||||
function runKanbanAlias(spawnOptions?: Parameters<typeof spawnKanbanProcess>[0]): void {
|
||||
const launchKanban = () => {
|
||||
const child = spawnKanbanProcess(spawnOptions)
|
||||
activeKanbanProcess = child
|
||||
|
||||
child.on("error", (error) => {
|
||||
clearActiveKanbanProcess()
|
||||
const errorMessage = error instanceof Error ? ` ${error.message}` : ""
|
||||
printWarning(`Failed to run '${KANBAN_LAUNCH_COMMAND}'.${errorMessage}`)
|
||||
exit(1)
|
||||
})
|
||||
|
||||
child.on("close", (code, signal) => {
|
||||
clearActiveKanbanProcess()
|
||||
exit(resolveProcessExitCode(code, signal))
|
||||
})
|
||||
}
|
||||
|
||||
if (isKanbanCommandAvailable()) {
|
||||
launchKanban()
|
||||
return
|
||||
}
|
||||
|
||||
const installCommand = resolveKanbanInstallCommand()
|
||||
if (!installCommand) {
|
||||
printWarning(
|
||||
`'${KANBAN_LAUNCH_COMMAND}' not found and no supported package manager was detected in PATH (npm, pnpm, bun). Install Kanban globally and try again.`,
|
||||
)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
const installProcess = spawnKanbanInstallProcess(installCommand)
|
||||
|
||||
installProcess.on("error", (error) => {
|
||||
const errorMessage = error instanceof Error ? ` ${error.message}` : ""
|
||||
printWarning(`Failed to run '${installCommand.displayCommand}'.${errorMessage}`)
|
||||
exit(1)
|
||||
})
|
||||
|
||||
installProcess.on("close", (code, signal) => {
|
||||
const installExitCode = resolveProcessExitCode(code, signal)
|
||||
if (installExitCode !== 0) {
|
||||
printWarning(`Failed to install Kanban automatically. Please run '${installCommand.displayCommand}' manually.`)
|
||||
exit(installExitCode)
|
||||
}
|
||||
|
||||
launchKanban()
|
||||
})
|
||||
}
|
||||
|
||||
async function showKanbanMigrationView(): Promise<KanbanMigrationAction> {
|
||||
let selectedAction: KanbanMigrationAction = "exit"
|
||||
|
||||
await runInkApp(
|
||||
React.createElement(KanbanMigrationView, {
|
||||
isRawModeSupported: checkRawModeSupport(),
|
||||
onSelect: (action: KanbanMigrationAction) => {
|
||||
selectedAction = action
|
||||
},
|
||||
}),
|
||||
async () => {},
|
||||
)
|
||||
|
||||
return selectedAction
|
||||
}
|
||||
|
||||
async function addMcpServer(name: string, targetOrCommand: string[] = [], options: McpAddOptions): Promise<void> {
|
||||
try {
|
||||
const result = await addMcpServerShortcut(name, targetOrCommand, options)
|
||||
const transportLabel = result.transportType === "streamableHttp" ? "http" : result.transportType
|
||||
printInfo(`Added MCP server '${result.serverName}' (${transportLabel}) to ${result.settingsPath}`)
|
||||
} catch (error) {
|
||||
printWarning(error instanceof Error ? error.message : "Failed to add MCP server.")
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a task in plain text mode (no Ink UI).
|
||||
* Handles auth check, task execution, cleanup, and exit.
|
||||
@@ -314,6 +410,62 @@ let activeContext: CliContext | null = null
|
||||
let isShuttingDown = false
|
||||
// Track if we're in plain text mode (no Ink UI) - set by runTask when piped stdin detected
|
||||
let isPlainTextMode = false
|
||||
let activeKanbanProcess: ChildProcess | null = null
|
||||
let activeKanbanShutdownTimer: NodeJS.Timeout | null = null
|
||||
|
||||
function clearActiveKanbanProcess(): void {
|
||||
activeKanbanProcess = null
|
||||
if (activeKanbanShutdownTimer) {
|
||||
clearTimeout(activeKanbanShutdownTimer)
|
||||
activeKanbanShutdownTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function requestKanbanProcessShutdown(signal: NodeJS.Signals): void {
|
||||
if (!activeKanbanProcess) {
|
||||
return
|
||||
}
|
||||
|
||||
forwardSignalToKanbanProcess({
|
||||
child: activeKanbanProcess,
|
||||
signal,
|
||||
})
|
||||
|
||||
if (activeKanbanShutdownTimer) {
|
||||
clearTimeout(activeKanbanShutdownTimer)
|
||||
}
|
||||
|
||||
if (signal === "SIGKILL") {
|
||||
activeKanbanShutdownTimer = null
|
||||
return
|
||||
}
|
||||
|
||||
activeKanbanShutdownTimer = setTimeout(() => {
|
||||
if (!activeKanbanProcess) {
|
||||
return
|
||||
}
|
||||
forwardSignalToKanbanProcess({
|
||||
child: activeKanbanProcess,
|
||||
signal: "SIGKILL",
|
||||
})
|
||||
}, KANBAN_SHUTDOWN_TIMEOUT_MS)
|
||||
activeKanbanShutdownTimer.unref?.()
|
||||
}
|
||||
|
||||
function resolveProcessExitCode(code: number | null, signal: NodeJS.Signals | null): number {
|
||||
if (code !== null) {
|
||||
return code
|
||||
}
|
||||
|
||||
switch (signal) {
|
||||
case "SIGINT":
|
||||
return 130
|
||||
case "SIGTERM":
|
||||
return 143
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for stdout to fully drain before exiting.
|
||||
@@ -369,6 +521,17 @@ function onUnhandledException(reason: unknown, context: string) {
|
||||
|
||||
function setupSignalHandlers() {
|
||||
const shutdown = async (signal: string) => {
|
||||
if (activeKanbanProcess) {
|
||||
if (isShuttingDown) {
|
||||
requestKanbanProcessShutdown("SIGKILL")
|
||||
return
|
||||
}
|
||||
|
||||
isShuttingDown = true
|
||||
requestKanbanProcessShutdown(signal === "SIGTERM" ? "SIGTERM" : "SIGINT")
|
||||
return
|
||||
}
|
||||
|
||||
if (isShuttingDown) {
|
||||
// Force exit on second signal
|
||||
process.exit(1)
|
||||
@@ -843,6 +1006,18 @@ program
|
||||
.option("--config <path>", "Path to Cline configuration directory")
|
||||
.action(runAuth)
|
||||
|
||||
const mcpCommand = program.command("mcp").description("Manage MCP servers")
|
||||
|
||||
mcpCommand
|
||||
.command("add")
|
||||
.description("Add an MCP server shortcut to cline_mcp_settings.json")
|
||||
.argument("<name>", "MCP server name")
|
||||
.argument("[targetOrCommand...]", "For stdio: use -- <command> [args]. For http/sse: provide <url>.")
|
||||
.option("--type <type>", "Transport type: stdio (default), http, or sse", "stdio")
|
||||
.option("-c, --cwd <path>", "Working directory for config resolution")
|
||||
.option("--config <path>", "Path to Cline configuration directory")
|
||||
.action(addMcpServer)
|
||||
|
||||
program
|
||||
.command("version")
|
||||
.description("Show Cline CLI version number")
|
||||
@@ -854,6 +1029,11 @@ program
|
||||
.option("-v, --verbose", "Show verbose output")
|
||||
.action(() => checkForUpdates(CLI_VERSION))
|
||||
|
||||
program
|
||||
.command("kanban")
|
||||
.description(`Run ${KANBAN_LAUNCH_COMMAND}`)
|
||||
.action(() => runKanbanAlias())
|
||||
|
||||
// Dev command with subcommands
|
||||
const devCommand = program.command("dev").description("Developer tools and utilities")
|
||||
|
||||
@@ -1003,9 +1183,26 @@ program
|
||||
.option("--auto-condense", "Enable AI-powered context compaction instead of mechanical truncation")
|
||||
.option("--hooks-dir <path>", "Path to additional hooks directory for runtime hook injection")
|
||||
.option("--acp", "Run in ACP (Agent Client Protocol) mode for editor integration")
|
||||
.option("--kanban", `Run ${KANBAN_LAUNCH_COMMAND}`)
|
||||
.option("--tui", "Open the legacy terminal UI instead of the kanban experience")
|
||||
.option("-T, --taskId <id>", "Resume an existing task by ID")
|
||||
.option("--continue", "Resume the most recent task from the current working directory")
|
||||
.action(async (prompt, options) => {
|
||||
if (options.kanban && options.tui) {
|
||||
printWarning(`Use either --kanban or ${LEGACY_TUI_FLAG}, not both.`)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
if (options.kanban) {
|
||||
if (prompt) {
|
||||
printWarning("Use --kanban without a prompt.")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
runKanbanAlias({ cwd: options.cwd })
|
||||
return
|
||||
}
|
||||
|
||||
// Check for ACP mode first - this takes precedence over everything else
|
||||
if (options.acp) {
|
||||
await runAcpMode({
|
||||
@@ -1026,6 +1223,34 @@ program
|
||||
// stdinInput has content means stdin was piped with data
|
||||
const stdinWasPiped = stdinInput !== null
|
||||
|
||||
if (
|
||||
shouldLaunchKanbanByDefault({
|
||||
prompt,
|
||||
stdinWasPiped,
|
||||
taskId: options.taskId,
|
||||
continue: options.continue,
|
||||
tui: options.tui,
|
||||
})
|
||||
) {
|
||||
let migrationAction: "kanban" | "exit" = "kanban"
|
||||
const ctx = await initializeCli({ ...options, enableAuth: true })
|
||||
try {
|
||||
if (await shouldShowKanbanMigrationAnnouncementForCurrentUser()) {
|
||||
migrationAction = await showKanbanMigrationView()
|
||||
await markKanbanMigrationAnnouncementShown()
|
||||
}
|
||||
} finally {
|
||||
await disposeCliContext(ctx)
|
||||
}
|
||||
|
||||
if (migrationAction === "exit") {
|
||||
exit(0)
|
||||
}
|
||||
|
||||
runKanbanAlias({ cwd: options.cwd })
|
||||
return
|
||||
}
|
||||
|
||||
if (options.taskId && options.continue) {
|
||||
printWarning("Use either --taskId or --continue, not both.")
|
||||
exit(1)
|
||||
@@ -1091,4 +1316,6 @@ program
|
||||
})
|
||||
|
||||
// Parse and run
|
||||
program.parse()
|
||||
if (process.env.VITEST !== "true") {
|
||||
program.parse()
|
||||
}
|
||||
|
||||
@@ -15,3 +15,13 @@ export function isMouseEscapeSequence(input: string): boolean {
|
||||
// They contain [< followed by numbers, semicolons, and end with M or m
|
||||
return input.includes("[<") && /\[<\d+;\d+;\d+[Mm]/.test(input)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ink's key metadata can be inconsistent across platforms/test environments for Enter.
|
||||
* In particular, some Windows CI/test runs surface Enter as raw "\r" input without
|
||||
* setting key.return. Treat either representation as Enter so keyboard handlers remain
|
||||
* stable in production and in tests across platforms.
|
||||
*/
|
||||
export function isEnterKey(input: string, key: { return?: boolean }): boolean {
|
||||
return key.return === true || input === "\r" || input === "\n"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import {
|
||||
buildKanbanInstallSpawnOptions,
|
||||
buildKanbanSpawnOptions,
|
||||
forwardSignalToKanbanProcess,
|
||||
hasUsedLegacyCli,
|
||||
isKanbanCommandAvailable,
|
||||
resolveKanbanInstallCommand,
|
||||
shouldDetachKanbanProcess,
|
||||
shouldLaunchKanbanByDefault,
|
||||
shouldShowKanbanMigrationAnnouncement,
|
||||
} from "./kanban"
|
||||
|
||||
describe("shouldLaunchKanbanByDefault", () => {
|
||||
it("launches kanban for a bare interactive run", () => {
|
||||
expect(
|
||||
shouldLaunchKanbanByDefault({
|
||||
stdinWasPiped: false,
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("does not launch kanban when a prompt is provided", () => {
|
||||
expect(
|
||||
shouldLaunchKanbanByDefault({
|
||||
prompt: "fix the tests",
|
||||
stdinWasPiped: false,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it("does not launch kanban when stdin is piped", () => {
|
||||
expect(
|
||||
shouldLaunchKanbanByDefault({
|
||||
stdinWasPiped: true,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it("does not launch kanban when the legacy tui is requested", () => {
|
||||
expect(
|
||||
shouldLaunchKanbanByDefault({
|
||||
stdinWasPiped: false,
|
||||
tui: true,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("hasUsedLegacyCli", () => {
|
||||
it("treats task history as legacy usage", () => {
|
||||
expect(
|
||||
hasUsedLegacyCli({
|
||||
taskHistoryCount: 1,
|
||||
isNewUser: true,
|
||||
welcomeViewCompleted: undefined,
|
||||
hasConfiguredAuth: false,
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("treats configured auth as legacy usage", () => {
|
||||
expect(
|
||||
hasUsedLegacyCli({
|
||||
taskHistoryCount: 0,
|
||||
isNewUser: true,
|
||||
welcomeViewCompleted: undefined,
|
||||
hasConfiguredAuth: true,
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("skips the announcement for fresh installs", () => {
|
||||
expect(
|
||||
hasUsedLegacyCli({
|
||||
taskHistoryCount: 0,
|
||||
isNewUser: true,
|
||||
welcomeViewCompleted: undefined,
|
||||
hasConfiguredAuth: false,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("kanban process launch", () => {
|
||||
it("detaches the kanban process on unix-like platforms", () => {
|
||||
expect(shouldDetachKanbanProcess("darwin")).toBe(true)
|
||||
expect(shouldDetachKanbanProcess("linux")).toBe(true)
|
||||
})
|
||||
|
||||
it("keeps the kanban process attached on windows", () => {
|
||||
expect(shouldDetachKanbanProcess("win32")).toBe(false)
|
||||
})
|
||||
|
||||
it("uses a detached process group by default on unix-like platforms", () => {
|
||||
expect(buildKanbanSpawnOptions({}, "darwin")).toMatchObject({
|
||||
stdio: "inherit",
|
||||
detached: true,
|
||||
})
|
||||
})
|
||||
|
||||
it("does not detach the process on windows", () => {
|
||||
expect(buildKanbanSpawnOptions({}, "win32")).toMatchObject({
|
||||
stdio: "inherit",
|
||||
detached: false,
|
||||
})
|
||||
})
|
||||
|
||||
it("enables shell mode on windows for command launches", () => {
|
||||
expect(buildKanbanSpawnOptions({}, "win32")).toMatchObject({
|
||||
shell: true,
|
||||
})
|
||||
})
|
||||
|
||||
it("does not set shell mode on unix-like platforms", () => {
|
||||
expect(buildKanbanSpawnOptions({}, "darwin")).not.toHaveProperty("shell")
|
||||
})
|
||||
})
|
||||
|
||||
describe("kanban command availability", () => {
|
||||
it("returns false when PATH is empty", () => {
|
||||
expect(isKanbanCommandAvailable({ PATH: "" }, "darwin")).toBe(false)
|
||||
})
|
||||
|
||||
it("detects the kanban command in PATH", () => {
|
||||
const tempDirectory = mkdtempSync(join(tmpdir(), "kanban-cli-test-"))
|
||||
const commandPath = join(tempDirectory, process.platform === "win32" ? "kanban.cmd" : "kanban")
|
||||
writeFileSync(commandPath, process.platform === "win32" ? "@echo off\r\necho ok\r\n" : "#!/bin/sh\necho ok\n")
|
||||
if (process.platform !== "win32") {
|
||||
chmodSync(commandPath, 0o755)
|
||||
}
|
||||
|
||||
try {
|
||||
expect(isKanbanCommandAvailable({ PATH: tempDirectory }, process.platform)).toBe(true)
|
||||
} finally {
|
||||
rmSync(tempDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("kanban install process launch", () => {
|
||||
it("does not detach the install process on unix-like platforms", () => {
|
||||
expect(buildKanbanInstallSpawnOptions({}, "darwin")).toMatchObject({
|
||||
stdio: "inherit",
|
||||
detached: false,
|
||||
})
|
||||
})
|
||||
|
||||
it("enables shell mode on windows for npm.cmd launches", () => {
|
||||
expect(buildKanbanInstallSpawnOptions({}, "win32")).toMatchObject({
|
||||
shell: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("kanban installer resolution", () => {
|
||||
it("prefers npm when available", () => {
|
||||
const tempDirectory = mkdtempSync(join(tmpdir(), "kanban-installer-test-"))
|
||||
writeFileSync(join(tempDirectory, "npm"), "#!/bin/sh\necho npm\n")
|
||||
writeFileSync(join(tempDirectory, "pnpm"), "#!/bin/sh\necho pnpm\n")
|
||||
writeFileSync(join(tempDirectory, "bun"), "#!/bin/sh\necho bun\n")
|
||||
chmodSync(join(tempDirectory, "npm"), 0o755)
|
||||
chmodSync(join(tempDirectory, "pnpm"), 0o755)
|
||||
chmodSync(join(tempDirectory, "bun"), 0o755)
|
||||
|
||||
try {
|
||||
expect(resolveKanbanInstallCommand({ PATH: tempDirectory }, "darwin")?.packageManager).toBe("npm")
|
||||
} finally {
|
||||
rmSync(tempDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("falls back to pnpm when npm is unavailable", () => {
|
||||
const tempDirectory = mkdtempSync(join(tmpdir(), "kanban-installer-test-"))
|
||||
writeFileSync(join(tempDirectory, "pnpm"), "#!/bin/sh\necho pnpm\n")
|
||||
chmodSync(join(tempDirectory, "pnpm"), 0o755)
|
||||
|
||||
try {
|
||||
const installer = resolveKanbanInstallCommand({ PATH: tempDirectory }, "darwin")
|
||||
expect(installer?.packageManager).toBe("pnpm")
|
||||
expect(installer?.displayCommand).toBe("pnpm add -g kanban@latest")
|
||||
} finally {
|
||||
rmSync(tempDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("falls back to bun when npm and pnpm are unavailable", () => {
|
||||
const tempDirectory = mkdtempSync(join(tmpdir(), "kanban-installer-test-"))
|
||||
writeFileSync(join(tempDirectory, "bun"), "#!/bin/sh\necho bun\n")
|
||||
chmodSync(join(tempDirectory, "bun"), 0o755)
|
||||
|
||||
try {
|
||||
const installer = resolveKanbanInstallCommand({ PATH: tempDirectory }, "darwin")
|
||||
expect(installer?.packageManager).toBe("bun")
|
||||
expect(installer?.displayCommand).toBe("bun add -g kanban@latest")
|
||||
} finally {
|
||||
rmSync(tempDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("returns null when no supported package manager is available", () => {
|
||||
expect(resolveKanbanInstallCommand({ PATH: "" }, "darwin")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("forwardSignalToKanbanProcess", () => {
|
||||
it("signals the detached kanban process group on unix-like platforms", () => {
|
||||
const killProcess = vi.fn()
|
||||
const child = {
|
||||
pid: 4321,
|
||||
kill: vi.fn(),
|
||||
}
|
||||
|
||||
forwardSignalToKanbanProcess({
|
||||
child,
|
||||
signal: "SIGINT",
|
||||
platform: "darwin",
|
||||
killProcess,
|
||||
})
|
||||
|
||||
expect(killProcess).toHaveBeenCalledWith(-4321, "SIGINT")
|
||||
expect(child.kill).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("signals the child process directly on windows", () => {
|
||||
const killProcess = vi.fn()
|
||||
const child = {
|
||||
pid: 4321,
|
||||
kill: vi.fn(),
|
||||
}
|
||||
|
||||
forwardSignalToKanbanProcess({
|
||||
child,
|
||||
signal: "SIGTERM",
|
||||
platform: "win32",
|
||||
killProcess,
|
||||
})
|
||||
|
||||
expect(killProcess).not.toHaveBeenCalled()
|
||||
expect(child.kill).toHaveBeenCalledWith("SIGTERM")
|
||||
})
|
||||
})
|
||||
|
||||
describe("shouldShowKanbanMigrationAnnouncement", () => {
|
||||
it("shows the announcement once for legacy users", () => {
|
||||
expect(
|
||||
shouldShowKanbanMigrationAnnouncement({
|
||||
announcementShown: false,
|
||||
hasUsedLegacyCli: true,
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("does not show the announcement twice", () => {
|
||||
expect(
|
||||
shouldShowKanbanMigrationAnnouncement({
|
||||
announcementShown: true,
|
||||
hasUsedLegacyCli: true,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,246 @@
|
||||
import { type ChildProcess, type SpawnOptions, spawn } from "node:child_process"
|
||||
import { accessSync, constants as fsConstants } from "node:fs"
|
||||
import { delimiter, extname, join } from "node:path"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { checkAnyProviderConfigured } from "./auth"
|
||||
|
||||
export const KANBAN_LAUNCH_COMMAND = "kanban"
|
||||
export const KANBAN_SHUTDOWN_TIMEOUT_MS = 10_000
|
||||
export const LEGACY_TUI_FLAG = "--tui"
|
||||
export type KanbanMigrationAction = "kanban" | "exit"
|
||||
type KanbanInstaller = "npm" | "pnpm" | "bun"
|
||||
|
||||
interface KanbanInstallCommand {
|
||||
packageManager: KanbanInstaller
|
||||
command: string
|
||||
args: readonly string[]
|
||||
displayCommand: string
|
||||
}
|
||||
|
||||
interface SignalableKanbanProcess {
|
||||
pid?: number
|
||||
kill: (signal?: NodeJS.Signals | number) => boolean
|
||||
}
|
||||
|
||||
function getKanbanCommand(platform: NodeJS.Platform = process.platform): string {
|
||||
return platform === "win32" ? "kanban.cmd" : "kanban"
|
||||
}
|
||||
|
||||
function getPackageManagerCommand(packageManager: KanbanInstaller, platform: NodeJS.Platform = process.platform): string {
|
||||
if (platform !== "win32") {
|
||||
return packageManager
|
||||
}
|
||||
|
||||
return packageManager === "bun" ? "bun" : `${packageManager}.cmd`
|
||||
}
|
||||
|
||||
const KANBAN_INSTALL_COMMANDS: ReadonlyArray<Omit<KanbanInstallCommand, "displayCommand">> = [
|
||||
{
|
||||
packageManager: "npm",
|
||||
command: "npm",
|
||||
args: ["install", "-g", "kanban@latest"],
|
||||
},
|
||||
{
|
||||
packageManager: "pnpm",
|
||||
command: "pnpm",
|
||||
args: ["add", "-g", "kanban@latest"],
|
||||
},
|
||||
{
|
||||
packageManager: "bun",
|
||||
command: "bun",
|
||||
args: ["add", "-g", "kanban@latest"],
|
||||
},
|
||||
]
|
||||
|
||||
function toDisplayCommand(command: string, args: readonly string[]): string {
|
||||
return `${command} ${args.join(" ")}`
|
||||
}
|
||||
|
||||
export function shouldDetachKanbanProcess(platform: NodeJS.Platform = process.platform): boolean {
|
||||
return platform !== "win32"
|
||||
}
|
||||
|
||||
export function buildKanbanSpawnOptions(options: SpawnOptions = {}, platform: NodeJS.Platform = process.platform): SpawnOptions {
|
||||
return {
|
||||
stdio: "inherit",
|
||||
detached: shouldDetachKanbanProcess(platform),
|
||||
...(platform === "win32" ? { shell: true } : {}),
|
||||
...options,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildKanbanInstallSpawnOptions(
|
||||
options: SpawnOptions = {},
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): SpawnOptions {
|
||||
return {
|
||||
stdio: "inherit",
|
||||
detached: false,
|
||||
...(platform === "win32" ? { shell: true } : {}),
|
||||
...options,
|
||||
}
|
||||
}
|
||||
|
||||
export function spawnKanbanProcess(options: SpawnOptions = {}): ChildProcess {
|
||||
return spawn(getKanbanCommand(), [], buildKanbanSpawnOptions(options))
|
||||
}
|
||||
|
||||
export function spawnKanbanInstallProcess(installCommand: KanbanInstallCommand, options: SpawnOptions = {}): ChildProcess {
|
||||
return spawn(
|
||||
getPackageManagerCommand(installCommand.packageManager),
|
||||
[...installCommand.args],
|
||||
buildKanbanInstallSpawnOptions(options),
|
||||
)
|
||||
}
|
||||
|
||||
function getPathEntries(env: NodeJS.ProcessEnv): string[] {
|
||||
const pathValue = env.PATH ?? env.Path ?? env.path
|
||||
if (!pathValue) {
|
||||
return []
|
||||
}
|
||||
|
||||
return pathValue
|
||||
.split(delimiter)
|
||||
.map((entry) => entry.trim().replace(/^"(.*)"$/u, "$1"))
|
||||
.filter((entry) => entry.length > 0)
|
||||
}
|
||||
|
||||
function pathExists(candidatePath: string, platform: NodeJS.Platform): boolean {
|
||||
try {
|
||||
if (platform === "win32") {
|
||||
accessSync(candidatePath, fsConstants.F_OK)
|
||||
} else {
|
||||
accessSync(candidatePath, fsConstants.X_OK)
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function isCommandAvailable(
|
||||
command: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): boolean {
|
||||
const commandHasExtension = extname(command).length > 0
|
||||
const pathExtensions =
|
||||
platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter((ext) => ext.length > 0) : []
|
||||
|
||||
for (const pathEntry of getPathEntries(env)) {
|
||||
const commandPath = join(pathEntry, command)
|
||||
if (pathExists(commandPath, platform)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!commandHasExtension && platform === "win32") {
|
||||
for (const extension of pathExtensions) {
|
||||
if (pathExists(`${commandPath}${extension}`, platform)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function isKanbanCommandAvailable(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): boolean {
|
||||
return isCommandAvailable(getKanbanCommand(platform), env, platform)
|
||||
}
|
||||
|
||||
export function resolveKanbanInstallCommand(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): KanbanInstallCommand | null {
|
||||
for (const installCommand of KANBAN_INSTALL_COMMANDS) {
|
||||
if (isCommandAvailable(installCommand.command, env, platform)) {
|
||||
return {
|
||||
...installCommand,
|
||||
displayCommand: toDisplayCommand(installCommand.command, installCommand.args),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function forwardSignalToKanbanProcess(options: {
|
||||
child: SignalableKanbanProcess
|
||||
signal: NodeJS.Signals
|
||||
platform?: NodeJS.Platform
|
||||
killProcess?: (pid: number, signal: NodeJS.Signals | number) => boolean
|
||||
}): void {
|
||||
if (options.child.pid == null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (shouldDetachKanbanProcess(options.platform)) {
|
||||
try {
|
||||
;(options.killProcess ?? process.kill)(-options.child.pid, options.signal)
|
||||
return
|
||||
} catch (error) {
|
||||
if (error && typeof error === "object" && "code" in error && error.code === "ESRCH") {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
options.child.kill(options.signal)
|
||||
}
|
||||
|
||||
export function shouldLaunchKanbanByDefault(options: {
|
||||
prompt?: string
|
||||
stdinWasPiped: boolean
|
||||
taskId?: string
|
||||
continue?: boolean
|
||||
tui?: boolean
|
||||
}): boolean {
|
||||
return !options.prompt && !options.stdinWasPiped && !options.taskId && !options.continue && !options.tui
|
||||
}
|
||||
|
||||
export function hasUsedLegacyCli(options: {
|
||||
taskHistoryCount: number
|
||||
isNewUser: boolean
|
||||
welcomeViewCompleted: boolean | undefined
|
||||
hasConfiguredAuth: boolean
|
||||
}): boolean {
|
||||
return (
|
||||
options.taskHistoryCount > 0 ||
|
||||
options.isNewUser === false ||
|
||||
options.welcomeViewCompleted !== undefined ||
|
||||
options.hasConfiguredAuth
|
||||
)
|
||||
}
|
||||
|
||||
export function shouldShowKanbanMigrationAnnouncement(options: {
|
||||
announcementShown: boolean
|
||||
hasUsedLegacyCli: boolean
|
||||
}): boolean {
|
||||
return !options.announcementShown && options.hasUsedLegacyCli
|
||||
}
|
||||
|
||||
export async function shouldShowKanbanMigrationAnnouncementForCurrentUser(): Promise<boolean> {
|
||||
const stateManager = StateManager.get()
|
||||
const hasConfiguredAuth = await checkAnyProviderConfigured()
|
||||
const hasUsedLegacy = hasUsedLegacyCli({
|
||||
taskHistoryCount: stateManager.getGlobalStateKey("taskHistory")?.length ?? 0,
|
||||
isNewUser: stateManager.getGlobalStateKey("isNewUser"),
|
||||
welcomeViewCompleted: stateManager.getGlobalStateKey("welcomeViewCompleted"),
|
||||
hasConfiguredAuth,
|
||||
})
|
||||
|
||||
return shouldShowKanbanMigrationAnnouncement({
|
||||
announcementShown: stateManager.getGlobalStateKey("cliKanbanMigrationAnnouncementShown"),
|
||||
hasUsedLegacyCli: hasUsedLegacy,
|
||||
})
|
||||
}
|
||||
|
||||
export async function markKanbanMigrationAnnouncementShown(): Promise<void> {
|
||||
const stateManager = StateManager.get()
|
||||
stateManager.setGlobalState("cliKanbanMigrationAnnouncementShown", true)
|
||||
await stateManager.flushPendingState()
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import * as fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { afterEach, describe, expect, it } from "vitest"
|
||||
import { addMcpServerShortcut } from "./mcp"
|
||||
|
||||
const tempDirs: string[] = []
|
||||
|
||||
async function createTempConfigDir(): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "cline-mcp-test-"))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
type McpSettingsFile = {
|
||||
mcpServers: Record<string, Record<string, unknown>>
|
||||
}
|
||||
|
||||
async function readMcpSettings(configDir: string): Promise<McpSettingsFile> {
|
||||
const settingsPath = path.join(configDir, "data", "settings", "cline_mcp_settings.json")
|
||||
return JSON.parse(await fs.readFile(settingsPath, "utf-8")) as McpSettingsFile
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const dir of tempDirs.splice(0, tempDirs.length)) {
|
||||
await fs.rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe("addMcpServerShortcut", () => {
|
||||
it("writes stdio servers with type=stdio", async () => {
|
||||
const configDir = await createTempConfigDir()
|
||||
|
||||
await addMcpServerShortcut("kanban", ["kanban", "mcp"], { config: configDir })
|
||||
const settings = await readMcpSettings(configDir)
|
||||
|
||||
expect(settings.mcpServers.kanban).toEqual({
|
||||
command: "kanban",
|
||||
args: ["mcp"],
|
||||
type: "stdio",
|
||||
})
|
||||
})
|
||||
|
||||
it("maps --type http to streamableHttp", async () => {
|
||||
const configDir = await createTempConfigDir()
|
||||
|
||||
await addMcpServerShortcut("linear", ["https://mcp.linear.app/mcp"], { config: configDir, type: "http" })
|
||||
const settings = await readMcpSettings(configDir)
|
||||
|
||||
expect(settings.mcpServers.linear).toEqual({
|
||||
url: "https://mcp.linear.app/mcp",
|
||||
type: "streamableHttp",
|
||||
})
|
||||
})
|
||||
|
||||
it("errors when URL is provided without --type http", async () => {
|
||||
const configDir = await createTempConfigDir()
|
||||
|
||||
await expect(addMcpServerShortcut("linear", ["https://mcp.linear.app/mcp"], { config: configDir })).rejects.toThrow(
|
||||
"Use --type http",
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,159 @@
|
||||
import * as fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { getMcpSettingsFilePath } from "@/core/storage/disk"
|
||||
import { ServerConfigSchema } from "@/services/mcp/schemas"
|
||||
import { initializeCliContext } from "../vscode-context"
|
||||
|
||||
export interface McpAddOptions {
|
||||
type?: string
|
||||
config?: string
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
export type McpAddTransportType = "stdio" | "streamableHttp" | "sse"
|
||||
|
||||
export interface AddMcpServerResult {
|
||||
serverName: string
|
||||
transportType: McpAddTransportType
|
||||
settingsPath: string
|
||||
}
|
||||
|
||||
function normalizeMcpTransportType(value?: string): McpAddTransportType {
|
||||
const normalized = (value || "stdio").trim().toLowerCase()
|
||||
|
||||
switch (normalized) {
|
||||
case "stdio":
|
||||
return "stdio"
|
||||
case "http":
|
||||
case "streamable-http":
|
||||
case "streamablehttp":
|
||||
return "streamableHttp"
|
||||
case "sse":
|
||||
return "sse"
|
||||
default:
|
||||
throw new Error(`Invalid MCP transport type '${value}'. Valid values: stdio, http, sse.`)
|
||||
}
|
||||
}
|
||||
|
||||
function parseMcpSettings(content: string, settingsPath: string): Record<string, unknown> {
|
||||
const trimmedContent = content.trim()
|
||||
if (!trimmedContent) {
|
||||
return { mcpServers: {} }
|
||||
}
|
||||
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(content)
|
||||
} catch {
|
||||
throw new Error(`Invalid JSON in ${settingsPath}. Please fix the file and try again.`)
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error(`Invalid MCP settings file at ${settingsPath}. Expected a JSON object.`)
|
||||
}
|
||||
|
||||
const settings = parsed as Record<string, unknown>
|
||||
if (settings.mcpServers === undefined) {
|
||||
settings.mcpServers = {}
|
||||
}
|
||||
|
||||
if (!settings.mcpServers || typeof settings.mcpServers !== "object" || Array.isArray(settings.mcpServers)) {
|
||||
throw new Error(`Invalid MCP settings file at ${settingsPath}. Expected 'mcpServers' to be an object.`)
|
||||
}
|
||||
|
||||
return settings
|
||||
}
|
||||
|
||||
function createMcpServerConfig(targetOrCommand: string[], transportType: McpAddTransportType): Record<string, unknown> {
|
||||
if (transportType === "stdio") {
|
||||
if (targetOrCommand.length < 1) {
|
||||
throw new Error("Missing stdio command. Example: cline mcp add kanban -- kanban mcp")
|
||||
}
|
||||
|
||||
// Guard against common mistake:
|
||||
// `cline mcp add <name> <url>` without `--type http`
|
||||
if (targetOrCommand.length === 1) {
|
||||
const [value] = targetOrCommand
|
||||
try {
|
||||
const parsedUrl = new URL(value)
|
||||
if (parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:") {
|
||||
throw new Error(
|
||||
`Looks like you provided a URL for '${value}'. Use --type http, for example: cline mcp add <name> ${value} --type http`,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith("Looks like you provided a URL")) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [command, ...args] = targetOrCommand
|
||||
const config: Record<string, unknown> = {
|
||||
command,
|
||||
type: "stdio",
|
||||
}
|
||||
|
||||
if (args.length > 0) {
|
||||
config.args = args
|
||||
}
|
||||
|
||||
ServerConfigSchema.parse(config)
|
||||
return config
|
||||
}
|
||||
|
||||
if (targetOrCommand.length !== 1) {
|
||||
throw new Error(
|
||||
"HTTP/SSE MCP servers require exactly one URL. Example: cline mcp add linear https://mcp.linear.app/mcp --type http",
|
||||
)
|
||||
}
|
||||
|
||||
const config = {
|
||||
url: targetOrCommand[0],
|
||||
type: transportType,
|
||||
}
|
||||
|
||||
ServerConfigSchema.parse(config)
|
||||
return config
|
||||
}
|
||||
|
||||
export async function addMcpServerShortcut(
|
||||
name: string,
|
||||
targetOrCommand: string[] = [],
|
||||
options: McpAddOptions,
|
||||
): Promise<AddMcpServerResult> {
|
||||
const trimmedName = name.trim()
|
||||
if (!trimmedName) {
|
||||
throw new Error("Server name is required.")
|
||||
}
|
||||
|
||||
const transportType = normalizeMcpTransportType(options.type)
|
||||
|
||||
const { DATA_DIR } = initializeCliContext({
|
||||
clineDir: options.config,
|
||||
workspaceDir: options.cwd || process.cwd(),
|
||||
})
|
||||
|
||||
const settingsDirectoryPath = path.join(DATA_DIR, "settings")
|
||||
await fs.mkdir(settingsDirectoryPath, { recursive: true })
|
||||
const settingsPath = await getMcpSettingsFilePath(settingsDirectoryPath)
|
||||
|
||||
const content = await fs.readFile(settingsPath, "utf-8")
|
||||
const settings = parseMcpSettings(content, settingsPath)
|
||||
const mcpServers = settings.mcpServers as Record<string, unknown>
|
||||
|
||||
if (mcpServers[trimmedName]) {
|
||||
throw new Error(`An MCP server named '${trimmedName}' already exists.`)
|
||||
}
|
||||
|
||||
const serverConfig = createMcpServerConfig(targetOrCommand, transportType)
|
||||
mcpServers[trimmedName] = serverConfig
|
||||
|
||||
await fs.writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf-8")
|
||||
|
||||
return {
|
||||
serverName: trimmedName,
|
||||
transportType,
|
||||
settingsPath,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { SlashCommandInfo } from "@shared/proto/cline/slash"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { filterCommands, getStandaloneSlashCommandToExecute } from "./slash-commands"
|
||||
|
||||
const createCommand = (name: string): SlashCommandInfo => ({
|
||||
name,
|
||||
description: `${name} command`,
|
||||
section: "default",
|
||||
cliCompatible: true,
|
||||
})
|
||||
|
||||
describe("filterCommands", () => {
|
||||
it("prioritizes exact matches ahead of fuzzy matches", () => {
|
||||
const commands = [createCommand("help"), createCommand("history"), createCommand("q")]
|
||||
|
||||
const result = filterCommands(commands, "q")
|
||||
|
||||
expect(result.map((command) => command.name)[0]).toBe("q")
|
||||
})
|
||||
|
||||
it("prioritizes prefix matches ahead of fuzzy matches", () => {
|
||||
const commands = [createCommand("history"), createCommand("help"), createCommand("exit")]
|
||||
|
||||
const result = filterCommands(commands, "hi")
|
||||
|
||||
expect(result.map((command) => command.name)[0]).toBe("history")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getStandaloneSlashCommandToExecute", () => {
|
||||
it("ignores standalone execution when slash menu is visible", () => {
|
||||
expect(
|
||||
getStandaloneSlashCommandToExecute({
|
||||
prompt: "/q",
|
||||
inSlashMode: true,
|
||||
hasSlashMenu: true,
|
||||
hasPendingAsk: false,
|
||||
isSpinnerActive: false,
|
||||
}),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it("returns standalone command when enter should execute it directly", () => {
|
||||
expect(
|
||||
getStandaloneSlashCommandToExecute({
|
||||
prompt: "/exit",
|
||||
inSlashMode: false,
|
||||
hasSlashMenu: false,
|
||||
hasPendingAsk: false,
|
||||
isSpinnerActive: false,
|
||||
}),
|
||||
).toBe("exit")
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import type { SlashCommandInfo } from "@shared/proto/cline/slash"
|
||||
import { CLI_ONLY_COMMANDS } from "@shared/slashCommands"
|
||||
import { fuzzyFilter } from "./fuzzy-search"
|
||||
|
||||
export interface SlashQueryInfo {
|
||||
@@ -17,12 +18,29 @@ export interface VisibleWindow<T> {
|
||||
startIndex: number
|
||||
}
|
||||
|
||||
export interface StandaloneSlashCommandExecutionInput {
|
||||
prompt: string
|
||||
inSlashMode: boolean
|
||||
hasSlashMenu: boolean
|
||||
hasPendingAsk: boolean
|
||||
isSpinnerActive: boolean
|
||||
}
|
||||
|
||||
export function createCliOnlySlashCommands(): SlashCommandInfo[] {
|
||||
return CLI_ONLY_COMMANDS.map((cmd) => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description || "",
|
||||
section: cmd.section || "default",
|
||||
cliCompatible: true,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate visible window for a scrollable list menu.
|
||||
* Centers the selected item in the visible window when possible.
|
||||
* Returns the visible items and the start index for selection tracking.
|
||||
*/
|
||||
export function getVisibleWindow<T>(items: T[], selectedIndex: number, maxVisible: number = 5): VisibleWindow<T> {
|
||||
export function getVisibleWindow<T>(items: T[], selectedIndex: number, maxVisible = 5): VisibleWindow<T> {
|
||||
if (items.length <= maxVisible) {
|
||||
return { items, startIndex: 0 }
|
||||
}
|
||||
@@ -91,6 +109,42 @@ export function extractSlashQuery(text: string, cursorPosition?: number): SlashQ
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a standalone slash command (for example "/q" or "/exit")
|
||||
* that should be executed immediately when enter is pressed.
|
||||
*/
|
||||
export function getStandaloneSlashCommandName(text: string): string | null {
|
||||
const match = text.trim().match(/^\/([a-zA-Z0-9_.-]+)$/)
|
||||
return match?.[1] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve whether pressing Enter should execute a standalone CLI slash command.
|
||||
* This keeps ChatView's key handling deterministic and easy to test.
|
||||
*/
|
||||
export function getStandaloneSlashCommandToExecute({
|
||||
prompt,
|
||||
inSlashMode,
|
||||
hasSlashMenu,
|
||||
hasPendingAsk,
|
||||
isSpinnerActive,
|
||||
}: StandaloneSlashCommandExecutionInput): string | null {
|
||||
const standaloneSlashCommand = getStandaloneSlashCommandName(prompt)
|
||||
if (!standaloneSlashCommand) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (hasPendingAsk || isSpinnerActive) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (inSlashMode && hasSlashMenu) {
|
||||
return null
|
||||
}
|
||||
|
||||
return standaloneSlashCommand
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter commands using fuzzy matching
|
||||
*/
|
||||
@@ -98,7 +152,26 @@ export function filterCommands(commands: SlashCommandInfo[], query: string): Sla
|
||||
if (!query) {
|
||||
return commands
|
||||
}
|
||||
return fuzzyFilter(commands, query, (cmd) => cmd.name)
|
||||
|
||||
const normalizedQuery = query.toLowerCase()
|
||||
const exactMatches: SlashCommandInfo[] = []
|
||||
const prefixMatches: SlashCommandInfo[] = []
|
||||
const remaining: SlashCommandInfo[] = []
|
||||
|
||||
for (const command of commands) {
|
||||
const normalizedName = command.name.toLowerCase()
|
||||
if (normalizedName === normalizedQuery) {
|
||||
exactMatches.push(command)
|
||||
continue
|
||||
}
|
||||
if (normalizedName.startsWith(normalizedQuery)) {
|
||||
prefixMatches.push(command)
|
||||
continue
|
||||
}
|
||||
remaining.push(command)
|
||||
}
|
||||
|
||||
return [...exactMatches, ...prefixMatches, ...fuzzyFilter(remaining, query, (cmd) => cmd.name)]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,7 +24,7 @@ Your App → Cline API (api.cline.bot) → Anthropic / OpenAI / Google / etc
|
||||
<Card title="Chat Completions" icon="message" href="/api/chat-completions">
|
||||
Full endpoint reference with request schemas, streaming, and tool calling.
|
||||
</Card>
|
||||
<Card title="SDK Examples" icon="code" href="/api/sdk-examples">
|
||||
<Card title="Code Examples" icon="code" href="/api/sdk-examples">
|
||||
Ready-to-copy examples for Python, Node.js, curl, and the Cline CLI.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "SDK Examples"
|
||||
sidebarTitle: "SDK Examples"
|
||||
title: "Code Examples"
|
||||
sidebarTitle: "Code Examples"
|
||||
description: "Use the Cline API from Python, Node.js, curl, the Cline CLI, and the VS Code extension."
|
||||
---
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 8.9 MiB After Width: | Height: | Size: 8.9 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.2 MiB After Width: | Height: | Size: 6.1 MiB |
@@ -180,13 +180,23 @@ Cline CLI supports [MCP (Model Context Protocol)](/mcp/mcp-overview) servers, gi
|
||||
|
||||
### Setting Up MCP Servers
|
||||
|
||||
To configure MCP servers for the CLI, create or edit the settings file at:
|
||||
You can add MCP servers from the CLI:
|
||||
|
||||
```bash
|
||||
# STDIO server
|
||||
cline mcp add kanban -- kanban mcp
|
||||
|
||||
# Remote HTTP server
|
||||
cline mcp add linear https://mcp.linear.app/mcp --type http
|
||||
```
|
||||
|
||||
These commands update:
|
||||
|
||||
```
|
||||
~/.cline/data/settings/cline_mcp_settings.json
|
||||
```
|
||||
|
||||
The file uses the same JSON format as the VS Code extension:
|
||||
You can still edit this file directly. It uses the same JSON format as the VS Code extension:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -207,7 +217,7 @@ The file uses the same JSON format as the VS Code extension:
|
||||
For the full configuration reference including STDIO and SSE transport types, see [Adding and Configuring MCP Servers](/mcp/adding-and-configuring-servers).
|
||||
|
||||
<Note>
|
||||
The CLI does not yet have a `/mcp` slash command for managing MCP servers interactively. For now, you'll need to edit the `cline_mcp_settings.json` file directly.
|
||||
The CLI does not yet have a `/mcp` slash command for interactive management inside the terminal UI. Use `cline mcp add` or edit `cline_mcp_settings.json` directly.
|
||||
</Note>
|
||||
|
||||
### Custom Config Directory
|
||||
|
||||
+109
-15
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: "Cline SDK"
|
||||
sidebarTitle: "SDK (Programmatic Use)"
|
||||
description: "Embed Cline as a programmable coding agent in your Node.js applications using an ACP-compatible TypeScript API."
|
||||
---
|
||||
|
||||
@@ -180,13 +181,15 @@ await agent.prompt({
|
||||
|
||||
#### Stop Reasons
|
||||
|
||||
`prompt()` resolves with a `stopReason`:
|
||||
`prompt()` resolves with a `stopReason`. The ACP `StopReason` type defines the full set of possible values:
|
||||
|
||||
| Value | Meaning |
|
||||
|-------|---------|
|
||||
| `"end_turn"` | Agent finished normally (completed task or waiting for user input) |
|
||||
| `"error"` | An error occurred |
|
||||
|
||||
> **Note:** Cline currently returns `"end_turn"` or `"error"`. Other `StopReason` values like `"max_tokens"` or `"cancelled"` are part of the ACP type but may not be produced by the current implementation.
|
||||
|
||||
### Streaming Events
|
||||
|
||||
Subscribe to real-time output via `ClineSessionEmitter`. Each session has its own emitter.
|
||||
@@ -267,9 +270,8 @@ Each permission request includes an array of `PermissionOption` objects:
|
||||
| `kind` | Meaning |
|
||||
|--------|---------|
|
||||
| `allow_once` | Approve this single operation |
|
||||
| `allow_always` | Approve and remember for future operations |
|
||||
| `allow_always` | Approve and remember for future operations (sent for commands, tools, MCP servers) |
|
||||
| `reject_once` | Deny this single operation |
|
||||
| `reject_always` | Deny and remember for future operations |
|
||||
|
||||
**Important:** If no permission handler is set, all tool calls are rejected for safety.
|
||||
|
||||
@@ -319,7 +321,29 @@ await agent.authenticate({ methodId: "openai-codex-oauth" })
|
||||
|
||||
Both methods open a browser window for the OAuth flow and block until authentication completes (5-minute timeout for Cline OAuth).
|
||||
|
||||
For BYO (bring-your-own) API key providers, configure the key through the cline config directory before creating a session. The `authenticate()` call is not needed for BYO providers. We plan to support more auth providers in the near future.
|
||||
For BYO (bring-your-own) API key providers, you can pre-configure credentials using the Cline CLI before using the SDK:
|
||||
|
||||
```bash
|
||||
# Configure an Anthropic API key (default directory: ~/.cline/data/)
|
||||
cline auth -p anthropic -k "sk-ant-..." -m anthropic/claude-sonnet-4-20250514
|
||||
|
||||
# Configure an OpenRouter API key
|
||||
cline auth -p openrouter -k "sk-or-..." -m openrouter/anthropic/claude-sonnet-4
|
||||
```
|
||||
|
||||
This writes credentials to `~/.cline/data/`. Once configured, the SDK will use these credentials automatically — no `authenticate()` call needed.
|
||||
|
||||
**Using a custom directory:** If you specify a custom `clineDir` when creating `ClineAgent`, you must use the same path with `--config` when running `cline auth`:
|
||||
|
||||
```typescript
|
||||
// SDK code using custom directory
|
||||
const agent = new ClineAgent({ clineDir: "/custom/path" })
|
||||
```
|
||||
|
||||
```bash
|
||||
# CLI auth command must use the same path
|
||||
cline auth -p anthropic -k "sk-ant-..." -m anthropic/claude-sonnet-4-20250514 --config /custom/path
|
||||
```
|
||||
|
||||
### Cancellation
|
||||
|
||||
@@ -343,6 +367,8 @@ interface ClineAgentOptions {
|
||||
debug?: boolean
|
||||
/** Custom Cline config directory (default: ~/.cline) */
|
||||
clineDir?: string
|
||||
/** Additional runtime hooks directory */
|
||||
hooksDir?: string
|
||||
}
|
||||
```
|
||||
|
||||
@@ -368,13 +394,13 @@ const response = await agent.initialize({
|
||||
|
||||
// Response includes:
|
||||
{
|
||||
protocolVersion: "0.9.0",
|
||||
protocolVersion: 1,
|
||||
agentCapabilities: {
|
||||
loadSession: true,
|
||||
promptCapabilities: { image: true, audio: false, embeddedContext: true },
|
||||
mcpCapabilities: { http: true, sse: false }
|
||||
},
|
||||
agentInfo: { name: "cline", version: "2.2.3" },
|
||||
agentInfo: { name: "cline", version: "<installed_version>" },
|
||||
authMethods: [
|
||||
{ id: "cline-oauth", name: "Sign in with Cline", description: "..." },
|
||||
{ id: "openai-codex-oauth", name: "Sign in with ChatGPT", description: "..." }
|
||||
@@ -382,6 +408,24 @@ const response = await agent.initialize({
|
||||
}
|
||||
```
|
||||
|
||||
#### Client Capabilities
|
||||
|
||||
The `clientCapabilities` object in `initialize()` declares what your environment supports. It is part of the ACP protocol handshake.
|
||||
|
||||
| Capability | Type | Description |
|
||||
|------------|------|-------------|
|
||||
| `fs.readTextFile` | `boolean` | Client supports file read requests |
|
||||
| `fs.writeTextFile` | `boolean` | Client supports file write requests |
|
||||
| `terminal` | `boolean` | Client supports terminal command execution |
|
||||
|
||||
**When using `ClineAgent` directly (SDK use)**, the agent always uses standalone providers for file operations and terminal commands — it reads/writes files and runs shell commands on the local machine regardless of what you pass here. Simply pass `{}`:
|
||||
|
||||
```typescript
|
||||
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} })
|
||||
```
|
||||
|
||||
These capabilities only affect behavior when `ClineAgent` is used through the `AcpAgent` stdio wrapper (e.g., IDE integrations), where an ACP connection delegates operations back to the client.
|
||||
|
||||
#### `newSession(params): Promise<NewSessionResponse>`
|
||||
|
||||
Create a new conversation session.
|
||||
@@ -411,8 +455,8 @@ const session = await agent.newSession({
|
||||
currentModeId: "act"
|
||||
},
|
||||
models: {
|
||||
currentModelId: "anthropic/claude-sonnet-4-5-20241022",
|
||||
availableModels: [{ modelId: "anthropic/claude-3-5-sonnet-20241022", name: "..." }]
|
||||
currentModelId: "anthropic/claude-sonnet-4-20250514",
|
||||
availableModels: [{ modelId: "anthropic/claude-sonnet-4-20250514", name: "claude-sonnet-4-20250514" } /* ... */]
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -487,11 +531,18 @@ await agent.shutdown()
|
||||
|
||||
#### `setPermissionHandler(handler)`
|
||||
|
||||
Set a callback to handle tool permission requests.
|
||||
Set a callback to handle tool permission requests. The handler receives a `RequestPermissionRequest` and must return a `Promise<RequestPermissionResponse>`.
|
||||
|
||||
```typescript
|
||||
agent.setPermissionHandler((request, resolve) => {
|
||||
resolve({ outcome: { outcome: "selected", optionId: "allow_once" } })
|
||||
agent.setPermissionHandler(async (request) => {
|
||||
// request.toolCall — details about what the agent wants to do
|
||||
// request.options — available choices (allow_once, reject_once, etc.)
|
||||
const allow = request.options.find(o => o.kind === "allow_once")
|
||||
return {
|
||||
outcome: allow
|
||||
? { outcome: "selected", optionId: allow.optionId }
|
||||
: { outcome: "cancelled" }
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
@@ -513,13 +564,45 @@ for (const [sessionId, session] of agent.sessions) {
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
SDK methods throw standard JavaScript errors. Key error scenarios:
|
||||
|
||||
| Method | Error | Cause |
|
||||
|--------|-------|-------|
|
||||
| `newSession()` | `RequestError` (auth required) | No credentials configured — call `authenticate()` or pre-configure via CLI |
|
||||
| `prompt()` | `Error("Session not found")` | Invalid `sessionId` |
|
||||
| `prompt()` | `Error("already processing")` | Called `prompt()` while a previous prompt is still running on the same session |
|
||||
| `unstable_setSessionModel()` | `Error("Invalid modelId format")` | Model ID must be `"provider/modelId"` format (e.g., `"anthropic/claude-sonnet-4-20250514"`) |
|
||||
| `authenticate()` | `Error("Unknown authentication method")` | Invalid `methodId` — use `"cline-oauth"` or `"openai-codex-oauth"` |
|
||||
| `authenticate()` | `Error("Authentication timed out")` | OAuth flow not completed within 5 minutes |
|
||||
|
||||
```typescript
|
||||
try {
|
||||
const { sessionId } = await agent.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
} catch (error) {
|
||||
if (error.message?.includes("auth")) {
|
||||
// Need to authenticate first
|
||||
await agent.authenticate({ methodId: "cline-oauth" })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Session-level errors during `prompt()` execution are emitted on the session emitter rather than thrown:
|
||||
|
||||
```typescript
|
||||
emitter.on("error", (err) => {
|
||||
console.error("Session error:", err.message)
|
||||
})
|
||||
```
|
||||
|
||||
## Full Example: Auto-Approve Agent
|
||||
|
||||
```typescript
|
||||
import { ClineAgent } from "cline";
|
||||
|
||||
async function runTask(taskPrompt: string, cwd: string) {
|
||||
const agent = new ClineAgent({ clineDir: "/Users/maxpaulus/.cline" });
|
||||
const agent = new ClineAgent({ clineDir: "/path/to/.cline" });
|
||||
|
||||
await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
@@ -642,23 +725,34 @@ All types are re-exported from the `cline` package. Key types:
|
||||
|------|-------------|
|
||||
| `ClineAgent` | Main agent class |
|
||||
| `ClineSessionEmitter` | Typed event emitter for session events |
|
||||
| `ClineAgentOptions` | Constructor options |
|
||||
| `ClineAgentOptions` | Constructor options (`debug`, `clineDir`, `hooksDir`) |
|
||||
| `ClineAcpSession` | Session metadata (read-only) |
|
||||
| `ClineSessionEvents` | Event name → handler signature map |
|
||||
| `PermissionHandler` | `(request, resolve) => void` callback |
|
||||
| `PermissionResolver` | `(response) => void` callback |
|
||||
| `AcpSessionStatus` | Session lifecycle enum: `Idle`, `Processing`, `Cancelled` |
|
||||
| `AcpSessionState` | Session state tracking (status, pending tool calls) |
|
||||
| `PermissionHandler` | `(request: RequestPermissionRequest) => Promise<RequestPermissionResponse>` |
|
||||
| `RequestPermissionRequest` | Permission request details (sessionId, toolCall, options) |
|
||||
| `RequestPermissionResponse` | Permission response with outcome |
|
||||
| `PermissionOption` | Permission choice (`kind`, `optionId`, `name`) |
|
||||
| `SessionUpdate` | Union of all session update types |
|
||||
| `SessionUpdateType` | Discriminator values (`"agent_message_chunk"`, `"tool_call"`, etc.) |
|
||||
| `SessionUpdatePayload` | Typed payload for a given `SessionUpdateType` |
|
||||
| `SessionModelState` | Current model and available models |
|
||||
| `ToolCall` | Tool call details (id, title, kind, status, content) |
|
||||
| `ToolCallUpdate` | Partial update to an existing tool call |
|
||||
| `ToolCallStatus` | `"pending" \| "in_progress" \| "completed" \| "failed"` |
|
||||
| `ToolKind` | `"read" \| "edit" \| "delete" \| "execute" \| "search" \| ...` |
|
||||
| `StopReason` | `"end_turn" \| "cancelled" \| "error" \| "max_tokens" \| ...` |
|
||||
| `ContentBlock` | `TextContent \| ImageContent \| AudioContent \| ...` |
|
||||
| `TextContent` / `ImageContent` / `AudioContent` | Individual content block types |
|
||||
| `McpServer` | MCP server configuration (stdio, http) |
|
||||
| `ModelInfo` | Model metadata (`modelId`, `name`) |
|
||||
| `PromptRequest` / `PromptResponse` | Prompt call types |
|
||||
| `NewSessionRequest` / `NewSessionResponse` | Session creation types |
|
||||
| `InitializeRequest` / `InitializeResponse` | Initialization types |
|
||||
| `SetSessionModeRequest` / `SetSessionModeResponse` | Mode switching types |
|
||||
| `SetSessionModelRequest` / `SetSessionModelResponse` | Model switching types |
|
||||
| `TranslatedMessage` | Result of translating a Cline message to ACP updates |
|
||||
|
||||
See the [ACP Schema](https://agentclientprotocol.com/protocol/schema) for the full type definitions.
|
||||
|
||||
|
||||
+16
-1
@@ -101,6 +101,7 @@
|
||||
"pages": [
|
||||
"cline-cli/overview",
|
||||
"cline-cli/installation",
|
||||
"cline-sdk/overview",
|
||||
"cline-cli/interactive-mode",
|
||||
{
|
||||
"group": "Headless Mode",
|
||||
@@ -116,7 +117,6 @@
|
||||
},
|
||||
"cline-cli/configuration",
|
||||
"cline-cli/acp-editor-integrations",
|
||||
"cline-sdk/overview",
|
||||
"cline-cli/cli-reference"
|
||||
]
|
||||
},
|
||||
@@ -332,6 +332,21 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "Kanban",
|
||||
"icon": "table-columns",
|
||||
"groups": [
|
||||
{
|
||||
"group": "Cline Kanban",
|
||||
"pages": [
|
||||
"kanban/overview",
|
||||
"kanban/getting-started",
|
||||
"kanban/core-workflow",
|
||||
"kanban/features"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "Learn",
|
||||
"icon": "graduation-cap",
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
title: "Core Workflow"
|
||||
description: "The end-to-end workflow for using Cline Kanban: create tasks, run agents, review changes, and ship"
|
||||
---
|
||||
|
||||
This guide walks through the typical Kanban workflow from start to finish.
|
||||
|
||||
## 1. Create Tasks
|
||||
|
||||
There are two ways to add tasks to the board:
|
||||
|
||||
- **Manually** — click the add button and write a task description
|
||||
- **Via sidebar chat** — open the sidebar chat and ask the agent to break down a piece of work into tasks. The agent can create cards, link them together, and start work directly on the board.
|
||||
|
||||
Each card on the board represents a discrete unit of work for an agent to complete.
|
||||
|
||||
## 2. Link Tasks
|
||||
|
||||
Link cards together to create dependency chains:
|
||||
|
||||
- **⌘ + click** (Mac) / **Ctrl + click** (Windows/Linux) a card to link it to another task
|
||||
- When a linked card completes and is moved to trash, the next linked task **automatically starts**
|
||||
|
||||
Combined with auto-commit, this enables fully autonomous chains where one task's output feeds into the next without manual intervention.
|
||||
|
||||
## 3. Start Tasks
|
||||
|
||||
Hit the **play button** on a card to start it. Here's what happens:
|
||||
|
||||
1. Kanban creates an **ephemeral git worktree** for the task — an isolated copy of your repo where the agent can make changes without affecting your main working directory or other tasks
|
||||
2. Gitignored files like `node_modules` are **symlinked** from your main repo into the worktree, avoiding slow reinstalls for each task
|
||||
3. The agent starts working in its own terminal within that worktree
|
||||
4. The card displays the agent's **latest message or tool call** so you can monitor progress from the board
|
||||
|
||||
Multiple tasks run in parallel, each in their own worktree, so agents never create merge conflicts with each other.
|
||||
|
||||
<Warning>
|
||||
Symlinks work well for gitignored files that agents don't need to modify (like `node_modules`). If your workflow requires agents to modify gitignored files, be aware that changes will affect the symlink target (your main repo's copy).
|
||||
</Warning>
|
||||
|
||||
## 4. Review Changes
|
||||
|
||||
Click a card to open the detail view, which shows:
|
||||
|
||||
- **The agent's TUI** — the full text interface showing the agent's conversation and actions
|
||||
- **A diff of all changes** in that worktree compared to your base branch
|
||||
|
||||
The diff viewer includes a **checkpoint system** — you can see diffs scoped to specific message ranges, not just the full cumulative diff. This makes it easier to understand what changed and when.
|
||||
|
||||
### Inline Comments
|
||||
|
||||
Click on any line in the diff to leave a comment. Comments are sent back to the agent as feedback, letting you steer its work without rewriting the task description. This is useful for corrections like "use a different approach here" or "this edge case isn't handled."
|
||||
|
||||
## 5. Ship It
|
||||
|
||||
When you're satisfied with the changes, you have two options:
|
||||
|
||||
- **Commit** — merges the worktree changes into a commit on your base branch
|
||||
- **Open PR** — creates a new branch and opens a pull request
|
||||
|
||||
In both cases, Kanban sends a dynamic prompt to the agent to handle the operation. The agent converts the worktree into the appropriate git action and **intelligently handles merge conflicts** if the base branch has moved since the worktree was created.
|
||||
|
||||
## 6. Clean Up
|
||||
|
||||
After shipping, move the card to **trash** to clean up the ephemeral worktree and free disk space.
|
||||
|
||||
<Tip>
|
||||
If you need to resume work on a trashed card later, Kanban provides a **resume ID** for each task. You can use this to pick up where you left off.
|
||||
</Tip>
|
||||
|
||||
## Workflow Summary
|
||||
|
||||
| Step | Action | What Happens |
|
||||
|------|--------|-------------|
|
||||
| Create | Add card or use sidebar chat | Task card appears on the board |
|
||||
| Link | ⌘ + click to connect cards | Dependency chain is established |
|
||||
| Start | Hit play on a card | Ephemeral worktree is created, agent begins work |
|
||||
| Monitor | Watch card status on board | Latest agent message/tool call shown on card |
|
||||
| Review | Click card to see diff | Full diff with checkpoints and inline commenting |
|
||||
| Ship | Click Commit or Open PR | Agent handles merge into base branch or creates PR |
|
||||
| Clean up | Move to trash | Worktree is removed, resume ID saved |
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
title: "Features"
|
||||
description: "Detailed overview of Cline Kanban features: worktrees, auto-commit, task linking, diff viewer, git interface, and more"
|
||||
---
|
||||
|
||||
<Warning>
|
||||
Kanban is a **research preview**. Some features described here use experimental capabilities. Expect changes.
|
||||
</Warning>
|
||||
|
||||
## Ephemeral Worktrees
|
||||
|
||||
Every task card runs in its own [git worktree](https://git-scm.com/docs/git-worktree) — an isolated checkout of your repository. This is the foundation that enables parallel agent execution:
|
||||
|
||||
- Each agent works in its own directory with its own terminal
|
||||
- Changes in one worktree don't affect other worktrees or your main working directory
|
||||
- No merge conflicts between agents running simultaneously
|
||||
- Worktrees are cleaned up when you move a card to trash
|
||||
|
||||
### Symlinked Dependencies
|
||||
|
||||
When creating a worktree, Kanban symlinks gitignored files (like `node_modules`) from your main repo rather than copying or reinstalling them. This avoids the overhead of running `npm install` for every task.
|
||||
|
||||
<Warning>
|
||||
Symlinks point back to the original files in your main repo. This works well for dependencies that agents don't modify, but if an agent does modify a symlinked file, the change affects the original too.
|
||||
</Warning>
|
||||
|
||||
## Auto-Commit
|
||||
|
||||
When enabled, agents automatically commit their changes to the worktree branch as they work. This creates a trail of incremental commits rather than one large diff at the end.
|
||||
|
||||
Auto-commit can be toggled in the Kanban settings.
|
||||
|
||||
## Auto-PR
|
||||
|
||||
When enabled alongside auto-commit, agents can automatically create pull requests when they finish their work. The agent generates a PR with the changes from its worktree branch.
|
||||
|
||||
Auto-PR can be toggled in the Kanban settings.
|
||||
|
||||
## Task Linking & Dependency Chains
|
||||
|
||||
Task linking lets you create sequential workflows where completing one task triggers the next:
|
||||
|
||||
1. **⌘ + click** a card to link it to another card
|
||||
2. When the first card is completed and moved to trash, the linked card **starts automatically**
|
||||
3. Chain multiple cards together for multi-step workflows
|
||||
|
||||
When combined with auto-commit, this creates fully autonomous pipelines — one agent finishes, its work is committed, and the next agent picks up where it left off.
|
||||
|
||||
## Diff Viewer & Checkpoints
|
||||
|
||||
Clicking a card opens a detail view with a full diff of all changes in that worktree. The diff viewer includes:
|
||||
|
||||
- **Checkpoint-scoped diffs** — rather than showing only the cumulative diff, you can view changes from specific message ranges. This is useful for understanding what changed at each step.
|
||||
- **Inline commenting** — click any line in the diff to leave a comment that gets sent back to the agent. Use this to give targeted feedback like "handle this edge case" or "use a different pattern here."
|
||||
|
||||
## Sidebar Chat & Board Management
|
||||
|
||||
The sidebar chat gives you a conversational interface for managing the board. Instead of manually creating and configuring cards, you can ask the agent to:
|
||||
|
||||
- Break down a piece of work into multiple task cards
|
||||
- Link cards together into dependency chains
|
||||
- Start tasks on the board
|
||||
|
||||
The agent manipulates the board directly based on your instructions.
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
Kanban includes keyboard shortcuts for common actions:
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| **C** | Create a new task card |
|
||||
| **⌘ + click** | Link a card to another card |
|
||||
|
||||
<Tip>
|
||||
The "C" shortcut works from the main board view. You need at least one project open to create a task.
|
||||
</Tip>
|
||||
|
||||
## Settings
|
||||
|
||||
Open the settings dialog to configure how Kanban behaves. Available settings include:
|
||||
|
||||
- **Auto-commit** — toggle whether agents automatically commit changes as they work
|
||||
- **Auto-PR** — toggle whether agents automatically create pull requests on completion (requires auto-commit)
|
||||
- **Script shortcuts** — define frequently-used commands that appear as buttons on task cards
|
||||
- **Project paths** — displayed with `~` instead of full home directory paths for readability
|
||||
|
||||
## Script Shortcuts
|
||||
|
||||
Define frequently-used commands (like `npm run dev` or `npm test`) in the Kanban settings. These appear as play buttons on task cards, giving you quick access to run, test, or debug the application within a worktree without switching to a separate terminal.
|
||||
|
||||
## Git Interface
|
||||
|
||||
Click the **branch name** in the navbar to open a full git interface. From here you can:
|
||||
|
||||
- Browse commit history
|
||||
- Switch branches
|
||||
- Fetch, pull, and push
|
||||
- Visualize the git graph
|
||||
|
||||
This lets you manage your repository without leaving Kanban or opening a separate git client.
|
||||
|
||||
## Agent Compatibility
|
||||
|
||||
Kanban works with CLI-based coding agents. It uses experimental features that bypass permissions and runtime hooks, giving agents more autonomy to work without interruption. Agents currently compatible with Kanban include:
|
||||
|
||||
- **Cline CLI**
|
||||
- **Claude Code**
|
||||
- **Codex**
|
||||
- **OpenCode**
|
||||
|
||||
and more. Check settings for all available agent runtimes
|
||||
|
||||
## Resume Tasks
|
||||
|
||||
When you move a card to trash, the worktree is cleaned up but Kanban saves a **resume ID**. If you need to continue work on a trashed task, you can use this ID to pick up where you left off without starting from scratch.
|
||||
|
||||
## Remote Config Gating
|
||||
|
||||
For teams and organizations, Kanban access can be gated via Cline remote config. This allows administrators to control who can access the Kanban board within their organization, enabling phased rollouts or restricting access to specific teams.
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
title: "Getting Started"
|
||||
description: "Install Cline Kanban and launch your first board"
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js 18 or higher** — check with `node --version`
|
||||
- **A git repository** — Kanban must be run from the root of a git repo
|
||||
|
||||
## Installation
|
||||
|
||||
Install Cline CLI globally via npm:
|
||||
|
||||
```bash
|
||||
npm i -g cline
|
||||
```
|
||||
|
||||
Then launch Kanban:
|
||||
|
||||
```bash
|
||||
cline
|
||||
```
|
||||
|
||||
<Tip>
|
||||
This launches a local web server and opens the Kanban board in your default browser.
|
||||
</Tip>
|
||||
|
||||
## First Launch
|
||||
|
||||
1. Open your terminal and `cd` to the root of any git repository
|
||||
2. Run `cline`
|
||||
3. Your browser opens to the Kanban board
|
||||
|
||||
### Onboarding
|
||||
|
||||
On your first launch, Kanban walks you through a short setup:
|
||||
|
||||
1. **Pick a project directory** — a directory picker opens so you can select (or confirm) the repository you want to work in
|
||||
2. **Choose your agent** — select which coding agent to use for tasks (Cline, Claude Code, or Codex)
|
||||
|
||||
After onboarding, you land on the board and can start creating tasks immediately. No account creation, API keys, or configuration files required.
|
||||
|
||||
## Creating Your First Task
|
||||
|
||||
Once the board is open:
|
||||
|
||||
1. **Create a card** — click the add button to create a new task card
|
||||
2. **Write a task description** — describe what you want the agent to do
|
||||
3. **Hit play** — Kanban creates an ephemeral git worktree for the task and starts an agent in its own terminal
|
||||
|
||||
The card updates in real time, showing the agent's latest message or tool call so you can monitor progress from the board.
|
||||
|
||||
<Tip>
|
||||
You can also use the **sidebar chat** to create tasks. Open the chat and ask the agent to break down work into multiple task cards — it can create, link, and start tasks directly on the board.
|
||||
</Tip>
|
||||
|
||||
## Next Steps
|
||||
|
||||
<Columns cols={2}>
|
||||
<Card title="Core Workflow" icon="arrows-spin" href="/kanban/core-workflow">
|
||||
Learn the full workflow: create → link → start → review → ship.
|
||||
</Card>
|
||||
<Card title="Features" icon="list-check" href="/kanban/features">
|
||||
Explore worktrees, auto-commit, task linking, the diff viewer, and more.
|
||||
</Card>
|
||||
</Columns>
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
title: "Cline Kanban"
|
||||
sidebarTitle: "Overview"
|
||||
description: "A kanban board for orchestrating coding agents in parallel using git worktrees"
|
||||
---
|
||||
|
||||
<Warning>
|
||||
Kanban is a **research preview**. Share feedback in [#kanban on Discord](https://discord.gg/cline).
|
||||
</Warning>
|
||||
|
||||
## What is Cline Kanban?
|
||||
|
||||
Cline Kanban is a terminal-launched kanban board that runs in your browser. Each task card gets its own git worktree and terminal, so you can run multiple coding agents in parallel without merge conflicts. You create tasks, assign them to agents, review diffs, leave inline comments, and ship commits or PRs — all from one interface.
|
||||
|
||||
It runs locally, requires no account or setup, and works out of the box from any git repository.
|
||||
|
||||
```bash
|
||||
npm i -g cline
|
||||
cline
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Run `cline`** from the root of any git repo — a local web server opens in your browser
|
||||
2. **Create task cards** manually or ask the sidebar chat agent to break work into tasks
|
||||
3. **Hit play** on a card — Kanban creates an ephemeral worktree and starts an agent
|
||||
4. **Monitor progress** — each card shows the agent's latest message or tool call
|
||||
5. **Review diffs** — click a card to see all changes, leave inline comments to steer the agent
|
||||
6. **Ship it** — hit Commit or Open PR, then trash the card to clean up the worktree
|
||||
|
||||
## Key Capabilities
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="Parallel Execution" icon="clone">
|
||||
Each task runs in its own git worktree with its own terminal. Multiple agents work simultaneously without stepping on each other.
|
||||
</Card>
|
||||
<Card title="Unified Task Board" icon="table-columns">
|
||||
Create, triage, link, and monitor all agent tasks from a single browser-based kanban board.
|
||||
</Card>
|
||||
<Card title="Works With Existing Agents" icon="plug">
|
||||
Compatible with CLI agents you already use — Cline, Claude Code, and Codex. Kanban uses experimental features that bypass permissions and runtime hooks for more agent autonomy.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Links
|
||||
|
||||
- [GitHub Repository](https://github.com/cline/kanban) — source code, issues, and feature requests
|
||||
- [npm Package](https://www.npmjs.com/package/kanban) — version history and package details
|
||||
- [Cline App](https://app.cline.bot) — account management
|
||||
- [Discord #kanban](https://discord.gg/cline) — feedback and discussion
|
||||
|
||||
## Next Steps
|
||||
|
||||
<Columns cols={2}>
|
||||
<Card title="Getting Started" icon="rocket" href="/kanban/getting-started">
|
||||
Install Kanban and launch your first board.
|
||||
</Card>
|
||||
<Card title="Core Workflow" icon="arrows-spin" href="/kanban/core-workflow">
|
||||
The full workflow from creating tasks to shipping PRs.
|
||||
</Card>
|
||||
<Card title="Features" icon="list-check" href="/kanban/features">
|
||||
Worktrees, auto-commit, task linking, diff viewer, git interface, and more.
|
||||
</Card>
|
||||
</Columns>
|
||||
@@ -18,18 +18,22 @@ Fireworks AI is a leading infrastructure platform for generative AI that focuses
|
||||
|
||||
Cline supports the following Fireworks AI models:
|
||||
|
||||
- `accounts/fireworks/models/kimi-k2-instruct-0905` (Default) - Kimi K2 with 262K context, prompt caching ($0.60/$2.50 per 1M tokens)
|
||||
- `accounts/fireworks/models/qwen3-235b-a22b-instruct-2507` - Latest Qwen3 thinking model (256K context, $0.22/$0.88 per 1M tokens)
|
||||
- `accounts/fireworks/models/qwen3-coder-480b-a35b-instruct` - Qwen3's most agentic code model (256K context, $0.45/$1.80 per 1M tokens)
|
||||
- `accounts/fireworks/models/deepseek-r1-0528` - DeepSeek R1 reasoning model (160K context, $3.00/$8.00 per 1M tokens)
|
||||
- `accounts/fireworks/models/deepseek-v3` - DeepSeek V3 general-purpose model (128K context, $0.90/$0.90 per 1M tokens)
|
||||
- `accounts/fireworks/models/kimi-k2p5` (Default) - Kimi K2.5 flagship agentic model with multimodal support (262K context, prompt caching, $0.60/$3.00 per 1M tokens)
|
||||
- `accounts/fireworks/models/qwen3-vl-30b-a3b-thinking` - Qwen3-VL reasoning model with image support (262K context, prompt caching, $0.15/$0.60 per 1M tokens)
|
||||
- `accounts/fireworks/models/qwen3-vl-30b-a3b-instruct` - Qwen3-VL instruct model with image support (262K context, $0.15/$0.60 per 1M tokens)
|
||||
- `accounts/fireworks/models/deepseek-v3p2` - DeepSeek V3.2 model (164K context, prompt caching, $0.56/$1.68 per 1M tokens)
|
||||
- `accounts/fireworks/models/glm-4p7` - GLM-4.7 model (203K context, prompt caching, $0.60/$2.20 per 1M tokens)
|
||||
- `accounts/fireworks/models/glm-5` - GLM-5 model (203K context, prompt caching, $1.00/$3.20 per 1M tokens)
|
||||
- `accounts/fireworks/models/minimax-m2p5` - MiniMax M2.5 model (197K context, prompt caching, $0.30/$1.20 per 1M tokens)
|
||||
- `accounts/fireworks/models/minimax-m2p1` - MiniMax M2.1 model (197K context, prompt caching, $0.30/$1.20 per 1M tokens)
|
||||
- `accounts/fireworks/models/gpt-oss-120b` - OpenAI gpt-oss-120b model (131K context, prompt caching, $0.15/$0.60 per 1M tokens)
|
||||
|
||||
### Configuration in Cline
|
||||
|
||||
1. **Open Cline Settings:** Click the settings icon (⚙️) in the Cline panel.
|
||||
2. **Select Provider:** Choose "Fireworks" from the "API Provider" dropdown.
|
||||
3. **Enter API Key:** Paste your Fireworks API key into the "Fireworks API Key" field.
|
||||
4. **Enter Model ID:** Specify the model you want to use (e.g., "accounts/fireworks/models/llama-v3p1-70b-instruct").
|
||||
4. **Enter Model ID:** Specify the model you want to use (e.g., "accounts/fireworks/models/kimi-k2p5").
|
||||
5. **Configure Tokens:** Optionally set max completion tokens and context window size.
|
||||
|
||||
### Fireworks AI's Performance Focus
|
||||
|
||||
@@ -18,9 +18,12 @@ MiniMax provides AI models with large context windows and competitive pricing, f
|
||||
|
||||
Cline supports the following MiniMax models:
|
||||
|
||||
- `MiniMax-M2.5` (Default) - Latest model with 192K context, prompt caching, and reasoning/thinking support ($0.30/$1.20 per 1M tokens)
|
||||
- `MiniMax-M2.1` - Previous generation with 192K context and prompt caching ($0.30/$1.20 per 1M tokens)
|
||||
- `MiniMax-M2.1-lightning` - Fast variant with higher output pricing ($0.30/$2.40 per 1M tokens)
|
||||
- `MiniMax-M2.7` (Default) - Latest flagship model with enhanced reasoning and coding, 192K context, prompt caching, and reasoning/thinking support ($0.30/$1.20 per 1M tokens)
|
||||
- `MiniMax-M2.7-highspeed` - High-speed version of M2.7 for low-latency scenarios ($0.60/$2.40 per 1M tokens)
|
||||
- `MiniMax-M2.5` - Previous flagship with 192K context, prompt caching, and reasoning support ($0.30/$1.20 per 1M tokens)
|
||||
- `MiniMax-M2.5-highspeed` - High-speed version of M2.5 ($0.60/$2.40 per 1M tokens)
|
||||
- `MiniMax-M2.1` - Earlier generation with 192K context and prompt caching ($0.30/$1.20 per 1M tokens)
|
||||
- `MiniMax-M2.1-lightning` - Fast variant with higher output pricing ($0.60/$2.40 per 1M tokens)
|
||||
- `MiniMax-M2` - Earlier generation with 192K context ($0.30/$1.20 per 1M tokens)
|
||||
|
||||
### Configuration in Cline
|
||||
@@ -33,6 +36,6 @@ Cline supports the following MiniMax models:
|
||||
### Tips and Notes
|
||||
|
||||
- **Large Context:** All models support 192K token context windows.
|
||||
- **Reasoning Support:** M2.5 supports extended thinking/reasoning for complex tasks.
|
||||
- **Prompt Caching:** M2.5 and M2.1 models support prompt caching for reduced costs on repeated queries.
|
||||
- **Reasoning Support:** M2.7 and M2.5 support extended thinking/reasoning for complex tasks.
|
||||
- **Prompt Caching:** M2.7 (including highspeed), M2.5 (including highspeed), and M2.1 models support prompt caching for reduced costs on repeated queries.
|
||||
- **Pricing:** Check the [MiniMax pricing page](https://www.minimax.io/platform/document/pricing) for current rates.
|
||||
|
||||
@@ -5,7 +5,7 @@ description: "Learn how to configure and use Oracle Code Assist with Cline. Acce
|
||||
|
||||
Oracle Code Assist provides AI-powered coding assistance through Oracle Cloud Infrastructure (OCI) Generative AI service.
|
||||
|
||||
**Website:** [https://www.oracle.com/artificial-intelligence/code-assist/](https://www.oracle.com/artificial-intelligence/code-assist/)
|
||||
**Website:** [https://www.oracle.com/application-development/code-assist/](https://www.oracle.com/application-development/code-assist/)
|
||||
|
||||
### Getting Started
|
||||
|
||||
|
||||
Generated
+1506
-756
File diff suppressed because it is too large
Load Diff
+11
-5
@@ -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.71.0",
|
||||
"version": "3.76.0",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"workspaces": [
|
||||
".",
|
||||
@@ -428,6 +428,7 @@
|
||||
"test:e2e:build": "vsce package --allow-package-secrets sendgrid --out dist/e2e.vsix",
|
||||
"test:e2e": "playwright install && npm run test:e2e:build && node src/test/e2e/utils/build.mjs && playwright test",
|
||||
"test:e2e:optimal": "npm run test:e2e:build && node src/test/e2e/utils/build.mjs && playwright test",
|
||||
"test:e2e:cli:tui": "cd tests/e2e/cli && tui-test",
|
||||
"test:e2e:ui": "npx tsx scripts/interactive-playwright.ts",
|
||||
"install:all": "npm install && cd webview-ui && npm install",
|
||||
"dev:webview": "cd webview-ui && npm run dev",
|
||||
@@ -460,6 +461,7 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.14",
|
||||
"@bufbuild/buf": "^1.54.0",
|
||||
"@microsoft/tui-test": "0.0.1",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/chai": "^5.0.1",
|
||||
"@types/clone-deep": "^4.0.4",
|
||||
@@ -473,10 +475,10 @@
|
||||
"@types/proxyquire": "^1.3.31",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/should": "^11.2.0",
|
||||
"@types/sinon": "^17.0.4",
|
||||
"@types/sinon": "^21.0.0",
|
||||
"@types/turndown": "^5.0.5",
|
||||
"@types/vscode": "1.84.0",
|
||||
"@vscode/test-cli": "^0.0.10",
|
||||
"@vscode/test-cli": "^0.0.12",
|
||||
"@vscode/test-electron": "^2.5.2",
|
||||
"@vscode/vsce": "^3.6.0",
|
||||
"c8": "^10.1.3",
|
||||
@@ -496,7 +498,7 @@
|
||||
"proxyquire": "^2.1.3",
|
||||
"rimraf": "^6.0.1",
|
||||
"should": "^13.2.3",
|
||||
"sinon": "^19.0.2",
|
||||
"sinon": "^21.0.3",
|
||||
"tree-kill": "^1.2.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"ts-proto": "^2.6.1",
|
||||
@@ -604,7 +606,11 @@
|
||||
"tar-fs": ">=3.1.1",
|
||||
"tar": "^7.5.2",
|
||||
"vite": "^7.1.11",
|
||||
"js-yaml": "^4.1.1"
|
||||
"js-yaml": "^4.1.1",
|
||||
"serialize-javascript": ">=7.0.3",
|
||||
"mocha": {
|
||||
"diff": ">=8.0.3"
|
||||
}
|
||||
},
|
||||
"c8": {
|
||||
"reporter": [
|
||||
|
||||
@@ -67,6 +67,13 @@ message NotificationData {
|
||||
string source = 2;
|
||||
string message = 3;
|
||||
bool waiting_for_user_input = 4;
|
||||
string event_version = 5;
|
||||
string event_id = 6;
|
||||
bool message_truncated = 7;
|
||||
string source_type = 8;
|
||||
string source_id = 9;
|
||||
bool requires_user_action = 10;
|
||||
string severity = 11;
|
||||
}
|
||||
|
||||
// Data for TaskStart hook
|
||||
|
||||
@@ -460,6 +460,7 @@ enum ApiProvider {
|
||||
AIHUBMIX = 38;
|
||||
NOUSRESEARCH = 39;
|
||||
OPENAI_CODEX = 40;
|
||||
WANDB = 41;
|
||||
}
|
||||
|
||||
enum ApiFormat {
|
||||
@@ -598,6 +599,7 @@ message ModelsApiConfiguration {
|
||||
optional string aihubmix_app_code = 84;
|
||||
optional string nous_research_api_key = 85;
|
||||
optional bool azure_identity = 86;
|
||||
optional string wandb_api_key = 87;
|
||||
|
||||
// Plan mode configurations
|
||||
optional ApiProvider plan_mode_api_provider = 100;
|
||||
|
||||
@@ -105,6 +105,7 @@ message Secrets {
|
||||
optional string mcp_o_auth_secrets = 43;
|
||||
optional string cline_api_key = 44;
|
||||
optional string openai_codex_oauth_credentials = 48;
|
||||
optional string wandb_api_key = 50;
|
||||
}
|
||||
|
||||
// NOTE: Add new fields under API_HANDLER_SETTINGS_FIELDS or USER_SETTINGS_FIELDS
|
||||
@@ -284,6 +285,8 @@ message Settings {
|
||||
optional OpenRouterModelInfo plan_mode_cline_model_info = 179;
|
||||
optional string act_mode_cline_model_id = 180;
|
||||
optional OpenRouterModelInfo act_mode_cline_model_info = 181;
|
||||
optional bool show_feature_tips = 182;
|
||||
optional bool code_intelligence_enabled = 183;
|
||||
}
|
||||
|
||||
message State {
|
||||
@@ -425,6 +428,8 @@ message UpdateSettingsRequest {
|
||||
optional bool opt_out_of_remote_config = 39;
|
||||
optional bool worktrees_enabled = 40;
|
||||
optional bool double_check_completion_enabled = 41;
|
||||
optional bool show_feature_tips = 42;
|
||||
optional bool code_intelligence_enabled = 43;
|
||||
}
|
||||
|
||||
message UpdateTerminalConnectionTimeoutRequest {
|
||||
|
||||
@@ -54,6 +54,9 @@ message GetHostVersionResponse {
|
||||
optional string cline_type = 3;
|
||||
// The version of the cline host environment, e.g. 33.2.10 for extension, or 1.0.6 for JetBrains.
|
||||
optional string cline_version = 4;
|
||||
// The remote environment name when the host is connected to a remote workspace
|
||||
// (for example `ssh-remote`, `dev-container`, or `codespaces`).
|
||||
optional string remote_name = 5;
|
||||
}
|
||||
|
||||
enum Setting {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package host;
|
||||
|
||||
option go_package = "github.com/cline/grpc-go/host";
|
||||
option java_multiple_files = true;
|
||||
option java_package = "bot.cline.host.proto";
|
||||
|
||||
// Provides PSI-based code intelligence capabilities.
|
||||
// All operations require smart mode (indexing complete) unless noted.
|
||||
service PsiService {
|
||||
// Check if smart mode is active. Ultra-lightweight (<1ms).
|
||||
// Can be called frequently (e.g., for every environment_details build).
|
||||
rpc getIndexingStatus(GetIndexingStatusRequest) returns (GetIndexingStatusResponse);
|
||||
|
||||
// Search for symbols by name (like shift-shift "Go to Symbol").
|
||||
// Partially DumbAware — may return limited results during indexing.
|
||||
rpc searchSymbols(SearchSymbolsRequest) returns (SymbolQueryResponse);
|
||||
|
||||
// Resolve the definition(s) of the symbol at the given position.
|
||||
rpc getDefinition(SymbolQuery) returns (SymbolQueryResponse);
|
||||
|
||||
// Find all references/usages of the symbol.
|
||||
rpc getReferences(SymbolQuery) returns (SymbolQueryResponse);
|
||||
|
||||
// Find callables (methods/functions) that reference/call this symbol.
|
||||
// Works for any symbol type — for methods this finds callers,
|
||||
// for classes this finds instantiation sites, etc.
|
||||
rpc getCallers(SymbolQuery) returns (SymbolQueryResponse);
|
||||
|
||||
// Find symbols referenced/called within the body of the given callable.
|
||||
rpc getCallees(SymbolQuery) returns (SymbolQueryResponse);
|
||||
|
||||
// Get the type hierarchy (supertypes and subtypes) for a class/interface.
|
||||
rpc getTypeHierarchy(SymbolQuery) returns (TypeHierarchyResponse);
|
||||
}
|
||||
|
||||
// ─── Indexing Status ───────────────────────────────────────
|
||||
|
||||
message GetIndexingStatusRequest {}
|
||||
|
||||
message GetIndexingStatusResponse {
|
||||
// True when indexing is complete and PSI operations are available.
|
||||
bool is_smart_mode = 1;
|
||||
}
|
||||
|
||||
// ─── Symbol Query (shared input for most operations) ──────
|
||||
|
||||
message SymbolQuery {
|
||||
// The text of the symbol to find (e.g., "resetBoard", "Player").
|
||||
// Always required.
|
||||
string symbol_text = 1;
|
||||
|
||||
// Absolute file path. Optional — if omitted, all matching symbols
|
||||
// in the project are searched.
|
||||
optional string file_path = 2;
|
||||
|
||||
// 1-based line number within the file. Optional — used for
|
||||
// disambiguation when the same symbol appears multiple times.
|
||||
optional int32 line = 3;
|
||||
|
||||
// Maximum number of results to return per definition group.
|
||||
// Default: 50.
|
||||
optional int32 max_results = 4;
|
||||
}
|
||||
|
||||
// ─── Symbol Result ─────────────────────────────────────────
|
||||
|
||||
message SymbolResult {
|
||||
// Absolute file path where this result is located.
|
||||
string file_path = 1;
|
||||
|
||||
// 1-based line number.
|
||||
int32 line = 2;
|
||||
|
||||
// The full text of the source line (trimmed of leading/trailing whitespace).
|
||||
string line_content = 3;
|
||||
|
||||
// The name of the symbol at this location.
|
||||
string symbol_name = 4;
|
||||
|
||||
// The kind of symbol: "class", "method", "function", "field",
|
||||
// "property", "variable", "interface", "enum", "constructor",
|
||||
// "parameter", "type_alias", etc.
|
||||
string kind = 5;
|
||||
|
||||
// Name of the enclosing class/function/module, if any.
|
||||
string container_name = 6;
|
||||
|
||||
// 1-based line of the container's definition.
|
||||
// 0 if there is no container (e.g., top-level symbol).
|
||||
int32 container_line = 7;
|
||||
|
||||
// File path of the container (may differ from file_path for inner classes etc.)
|
||||
string container_file_path = 8;
|
||||
}
|
||||
|
||||
// ─── Responses ─────────────────────────────────────────────
|
||||
|
||||
message SymbolQueryResponse {
|
||||
// Empty string on success. Descriptive error message on failure.
|
||||
string error = 1;
|
||||
|
||||
// Results, potentially grouped by definition when the query
|
||||
// matched multiple definitions.
|
||||
repeated SymbolResultGroup groups = 2;
|
||||
}
|
||||
|
||||
message SymbolResultGroup {
|
||||
// The definition this group of results relates to.
|
||||
// For "definition" queries, this is the definition itself.
|
||||
// For "references"/"callers"/"callees", this is the symbol being queried.
|
||||
SymbolResult definition = 1;
|
||||
|
||||
// The results for this definition.
|
||||
repeated SymbolResult results = 2;
|
||||
|
||||
// True if results were truncated due to max_results.
|
||||
bool truncated = 3;
|
||||
}
|
||||
|
||||
message TypeHierarchyResponse {
|
||||
string error = 1;
|
||||
|
||||
// The queried type.
|
||||
SymbolResult target = 2;
|
||||
|
||||
// Supertypes (parent classes/interfaces), ordered from direct parent to root.
|
||||
repeated SymbolResult supertypes = 3;
|
||||
|
||||
// Direct subtypes (implementing classes, subclasses).
|
||||
repeated SymbolResult subtypes = 4;
|
||||
|
||||
bool subtypes_truncated = 5;
|
||||
}
|
||||
|
||||
// ─── Search Symbols ────────────────────────────────────────
|
||||
|
||||
message SearchSymbolsRequest {
|
||||
// The search pattern (supports partial/fuzzy matching like shift-shift).
|
||||
string pattern = 1;
|
||||
|
||||
// Maximum results. Default: 20.
|
||||
optional int32 max_results = 2;
|
||||
}
|
||||
+32
-21
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import chalk from "chalk"
|
||||
import { execSync } from "child_process"
|
||||
import { execFileSync, execSync } from "child_process"
|
||||
import fsSync from "fs"
|
||||
import * as fs from "fs/promises"
|
||||
import { globby } from "globby"
|
||||
import { createRequire } from "module"
|
||||
@@ -12,7 +13,20 @@ import { main as generateHostBridgeClient } from "./generate-host-bridge-client.
|
||||
import { main as generateProtoBusSetup } from "./generate-protobus-setup.mjs"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const PROTOC = path.join(require.resolve("grpc-tools"), "../bin/protoc")
|
||||
const isWindows = process.platform === "win32"
|
||||
const GRPC_TOOLS_PROTOC = path.join(require.resolve("grpc-tools"), "../bin", isWindows ? "protoc.exe" : "protoc")
|
||||
// Legacy compatibility: some older/local Windows setups provision protoc into tmp-protoc.
|
||||
// Prefer that path when present, but fall back to the grpc-tools bundled binary used by CI/npm installs.
|
||||
const LEGACY_WINDOWS_PROTOC = path.resolve("tmp-protoc/bin/protoc.exe")
|
||||
const PROTOC = isWindows && fsSync.existsSync(LEGACY_WINDOWS_PROTOC) ? LEGACY_WINDOWS_PROTOC : GRPC_TOOLS_PROTOC
|
||||
|
||||
if (!fsSync.existsSync(PROTOC)) {
|
||||
const windowsHint = isWindows
|
||||
? ` Neither ${LEGACY_WINDOWS_PROTOC} nor the grpc-tools bundled protoc at ${GRPC_TOOLS_PROTOC} exists.`
|
||||
: ""
|
||||
console.error(chalk.red(`protoc not found at ${PROTOC}.${windowsHint}`))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const PROTO_DIR = path.resolve("proto")
|
||||
const TS_OUT_DIR = path.resolve("src/shared/proto")
|
||||
@@ -20,7 +34,6 @@ const GRPC_JS_OUT_DIR = path.resolve("src/generated/grpc-js")
|
||||
const NICE_JS_OUT_DIR = path.resolve("src/generated/nice-grpc")
|
||||
const DESCRIPTOR_OUT_DIR = path.resolve("dist-standalone/proto")
|
||||
|
||||
const isWindows = process.platform === "win32"
|
||||
const TS_PROTO_PLUGIN = isWindows
|
||||
? path.resolve("node_modules/.bin/protoc-gen-ts_proto.cmd") // Use the .bin directory path for Windows
|
||||
: require.resolve("ts-proto/protoc-gen-ts_proto")
|
||||
@@ -62,16 +75,16 @@ async function compileProtos() {
|
||||
tsProtoc(NICE_JS_OUT_DIR, protoFiles, ["outputServices=nice-grpc,useExactTypes=false", ...TS_PROTO_OPTIONS])
|
||||
|
||||
const descriptorFile = path.join(DESCRIPTOR_OUT_DIR, "descriptor_set.pb")
|
||||
const descriptorProtocCommand = [
|
||||
PROTOC,
|
||||
`--proto_path="${PROTO_DIR}"`,
|
||||
`--descriptor_set_out="${descriptorFile}"`,
|
||||
const descriptorProtocArgs = [
|
||||
`--proto_path=${PROTO_DIR}`,
|
||||
`--descriptor_set_out=${descriptorFile}`,
|
||||
"--include_imports",
|
||||
...protoFiles,
|
||||
].join(" ")
|
||||
]
|
||||
try {
|
||||
log_verbose(chalk.cyan("Generating descriptor set..."))
|
||||
execSync(descriptorProtocCommand, { stdio: "inherit" })
|
||||
log_verbose(`${PROTOC} ${descriptorProtocArgs.join(" ")}`)
|
||||
execFileSync(PROTOC, descriptorProtocArgs, { stdio: "inherit" })
|
||||
} catch (error) {
|
||||
console.error(chalk.red("Error generating descriptor set for proto file:"), error)
|
||||
process.exit(1)
|
||||
@@ -81,20 +94,18 @@ async function compileProtos() {
|
||||
log_verbose(chalk.green(`TypeScript files generated in: ${TS_OUT_DIR}`))
|
||||
}
|
||||
|
||||
async function tsProtoc(outDir, protoFiles, protoOptions) {
|
||||
// Build the protoc command with proper path handling for cross-platform
|
||||
const command = [
|
||||
PROTOC,
|
||||
`--proto_path="${PROTO_DIR}"`,
|
||||
`--plugin=protoc-gen-ts_proto="${TS_PROTO_PLUGIN}"`,
|
||||
`--ts_proto_out="${outDir}"`,
|
||||
`--ts_proto_opt=${protoOptions.join(",")} `,
|
||||
...protoFiles.map((s) => `"${s}"`),
|
||||
].join(" ")
|
||||
function tsProtoc(outDir, protoFiles, protoOptions) {
|
||||
const args = [
|
||||
`--proto_path=${PROTO_DIR}`,
|
||||
`--plugin=protoc-gen-ts_proto=${TS_PROTO_PLUGIN}`,
|
||||
`--ts_proto_out=${outDir}`,
|
||||
`--ts_proto_opt=${protoOptions.join(",")}`,
|
||||
...protoFiles,
|
||||
]
|
||||
try {
|
||||
log_verbose(chalk.cyan(`Generating TypeScript code in ${outDir} for:\n${protoFiles.join("\n")}...`))
|
||||
log_verbose(command)
|
||||
execSync(command, { stdio: "inherit" })
|
||||
log_verbose(`${PROTOC} ${args.join(" ")}`)
|
||||
execFileSync(PROTOC, args, { stdio: "inherit" })
|
||||
} catch (error) {
|
||||
console.error(chalk.red("Error generating TypeScript for proto files:"), error)
|
||||
process.exit(1)
|
||||
|
||||
@@ -230,6 +230,40 @@ function snakeToCamel(str) {
|
||||
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a property key to the camelCase form parsed from proto field names.
|
||||
* Example: "openai-codex-oauth-credentials" -> "openaiCodexOauthCredentials".
|
||||
* We do this before looking up existing field numbers so regenerated proto fields
|
||||
* reuse their old numbers instead of being treated as new fields.
|
||||
*/
|
||||
function normalizePropertyNameForProtoLookup(fieldName) {
|
||||
return snakeToCamel(toProtoFieldName(fieldName))
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail fast when two distinct property keys collapse to the same normalized lookup key.
|
||||
* Collisions make field-number preservation ambiguous and can cause proto renumbering.
|
||||
*/
|
||||
function assertNoNormalizedFieldNameCollisions(fields) {
|
||||
const normalizedToOriginalNames = new Map()
|
||||
|
||||
for (const field of fields) {
|
||||
const normalizedName = normalizePropertyNameForProtoLookup(field.name)
|
||||
const names = normalizedToOriginalNames.get(normalizedName) ?? new Set()
|
||||
names.add(field.name)
|
||||
normalizedToOriginalNames.set(normalizedName, names)
|
||||
}
|
||||
|
||||
for (const [normalizedName, names] of normalizedToOriginalNames) {
|
||||
if (names.size > 1) {
|
||||
const sortedNames = [...names].sort()
|
||||
throw new Error(
|
||||
`Field-name collision after proto normalization: ${sortedNames.join(", ")} all normalize to "${normalizedName}". Rename one field to keep proto field-number mapping unambiguous.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse field numbers from an existing proto message definition
|
||||
* Returns a map of camelCase field names to their field numbers
|
||||
@@ -248,7 +282,8 @@ function parseProtoMessageFieldNumbers(protoContent, messageName) {
|
||||
const messageBody = match[1]
|
||||
|
||||
// Match field definitions: optional/required/repeated type name = number;
|
||||
const fieldRegex = /(?:optional|required|repeated)?\s*\w+\s+(\w+)\s*=\s*(\d+)\s*;/g
|
||||
// Supports scalar/message types and map fields.
|
||||
const fieldRegex = /(?:optional|required|repeated)?\s*(?:map<[^>]+>|[\w.]+)\s+(\w+)\s*=\s*(\d+)\s*;/g
|
||||
const matches = messageBody.matchAll(fieldRegex)
|
||||
|
||||
for (const fieldMatch of matches) {
|
||||
@@ -287,6 +322,8 @@ function assignFieldNumbers(fields, existingNumbers, startNumber = 1) {
|
||||
const result = {}
|
||||
let nextNumber = startNumber
|
||||
|
||||
assertNoNormalizedFieldNameCollisions(fields)
|
||||
|
||||
// Find the highest existing number
|
||||
for (const num of Object.values(existingNumbers)) {
|
||||
if (num >= nextNumber) {
|
||||
@@ -296,8 +333,21 @@ function assignFieldNumbers(fields, existingNumbers, startNumber = 1) {
|
||||
|
||||
// Preserve existing assignments
|
||||
for (const field of fields) {
|
||||
if (existingNumbers[field.name] !== undefined) {
|
||||
result[field.name] = existingNumbers[field.name]
|
||||
// Normalize before lookup so keys like "openai-codex-oauth-credentials"
|
||||
// reuse existing proto numbers instead of being treated as new fields.
|
||||
const normalizedFieldName = normalizePropertyNameForProtoLookup(field.name)
|
||||
const normalizedFieldNumber = existingNumbers[normalizedFieldName]
|
||||
const rawFieldNumber = existingNumbers[field.name]
|
||||
|
||||
if (normalizedFieldNumber !== undefined && rawFieldNumber !== undefined && normalizedFieldNumber !== rawFieldNumber) {
|
||||
throw new Error(
|
||||
`Ambiguous proto field-number mapping for "${field.name}": normalized key "${normalizedFieldName}" -> ${normalizedFieldNumber}, raw key "${field.name}" -> ${rawFieldNumber}.`,
|
||||
)
|
||||
}
|
||||
|
||||
const existingFieldNumber = normalizedFieldNumber ?? rawFieldNumber
|
||||
if (existingFieldNumber !== undefined) {
|
||||
result[field.name] = existingFieldNumber
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ import { TogetherHandler } from "./providers/together"
|
||||
import { VercelAIGatewayHandler } from "./providers/vercel-ai-gateway"
|
||||
import { VertexHandler } from "./providers/vertex"
|
||||
import { VsCodeLmHandler } from "./providers/vscode-lm"
|
||||
import { WandbHandler } from "./providers/wandb"
|
||||
import { XAIHandler } from "./providers/xai"
|
||||
import { ZAiHandler } from "./providers/zai"
|
||||
import { ApiStream, ApiStreamUsageChunk } from "./transform/stream"
|
||||
@@ -97,6 +98,7 @@ function createHandlerForProvider(
|
||||
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
enableParallelToolCalling: options.enableParallelToolCalling,
|
||||
})
|
||||
case "bedrock":
|
||||
return new AwsBedrockHandler({
|
||||
@@ -271,6 +273,7 @@ function createHandlerForProvider(
|
||||
openRouterProviderSorting: options.openRouterProviderSorting,
|
||||
openRouterModelId: clineModelId,
|
||||
openRouterModelInfo: clineModelInfo,
|
||||
enableParallelToolCalling: options.enableParallelToolCalling,
|
||||
})
|
||||
}
|
||||
case "litellm":
|
||||
@@ -450,6 +453,12 @@ function createHandlerForProvider(
|
||||
nousResearchApiKey: options.nousResearchApiKey,
|
||||
apiModelId: mode === "plan" ? options.planModeNousResearchModelId : options.actModeNousResearchModelId,
|
||||
})
|
||||
case "wandb":
|
||||
return new WandbHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
wandbApiKey: options.wandbApiKey,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
})
|
||||
default:
|
||||
return new AnthropicHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
|
||||
@@ -226,6 +226,136 @@ describe("ClaudeCodeHandler", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should not crash when assistant message has empty content array", async () => {
|
||||
const runClaudeCodeModule = await import("@/integrations/claude-code/run")
|
||||
const runClaudeCodeStub = sandbox.stub(runClaudeCodeModule, "runClaudeCode")
|
||||
|
||||
async function* mockGenerator() {
|
||||
yield {
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [], // empty content — triggered TypeError in older code
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 0,
|
||||
},
|
||||
stop_reason: "end_turn",
|
||||
},
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "result",
|
||||
result: {},
|
||||
total_cost_usd: 0,
|
||||
}
|
||||
}
|
||||
|
||||
runClaudeCodeStub.returns(mockGenerator() as any)
|
||||
|
||||
const chunks: any[] = []
|
||||
// Should not throw
|
||||
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const usageChunk = chunks.find((c) => c.type === "usage")
|
||||
usageChunk.should.be.ok()
|
||||
usageChunk.inputTokens.should.equal(10)
|
||||
})
|
||||
|
||||
it("should throw when result has is_error=true (e.g. rate limit with no assistant message)", async () => {
|
||||
const runClaudeCodeModule = await import("@/integrations/claude-code/run")
|
||||
const runClaudeCodeStub = sandbox.stub(runClaudeCodeModule, "runClaudeCode")
|
||||
|
||||
async function* mockGenerator() {
|
||||
yield {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
apiKeySource: "none",
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "system",
|
||||
subtype: "rate_limit_event",
|
||||
message: "Rate limit hit",
|
||||
retryAfterSeconds: 30,
|
||||
}
|
||||
|
||||
// No assistant message — CLI hit rate limit and gave up
|
||||
yield {
|
||||
type: "result",
|
||||
subtype: "error",
|
||||
is_error: true,
|
||||
result: "Rate limit exceeded",
|
||||
total_cost_usd: 0,
|
||||
duration_ms: 1000,
|
||||
duration_api_ms: 500,
|
||||
num_turns: 0,
|
||||
session_id: "test",
|
||||
}
|
||||
}
|
||||
|
||||
runClaudeCodeStub.returns(mockGenerator() as any)
|
||||
|
||||
let thrownError: Error | undefined
|
||||
try {
|
||||
for await (const _ of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
|
||||
// consume
|
||||
}
|
||||
} catch (err) {
|
||||
thrownError = err as Error
|
||||
}
|
||||
|
||||
thrownError!.message.should.containEql("Rate limit exceeded")
|
||||
})
|
||||
|
||||
it("should ignore rate_limit_event system messages without throwing", async () => {
|
||||
const runClaudeCodeModule = await import("@/integrations/claude-code/run")
|
||||
const runClaudeCodeStub = sandbox.stub(runClaudeCodeModule, "runClaudeCode")
|
||||
|
||||
async function* mockGenerator() {
|
||||
yield {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
apiKeySource: "none",
|
||||
}
|
||||
|
||||
// Newer Claude Code CLI emits this during rate limiting
|
||||
yield {
|
||||
type: "system",
|
||||
subtype: "rate_limit_event",
|
||||
message: "Rate limit hit, retrying...",
|
||||
retryAfterSeconds: 30,
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [{ type: "text", text: "Response after retry" }],
|
||||
usage: { input_tokens: 20, output_tokens: 10 },
|
||||
stop_reason: "end_turn",
|
||||
},
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "result",
|
||||
result: {},
|
||||
total_cost_usd: 0,
|
||||
}
|
||||
}
|
||||
|
||||
runClaudeCodeStub.returns(mockGenerator() as any)
|
||||
|
||||
const textChunks: string[] = []
|
||||
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
|
||||
if (chunk.type === "text") textChunks.push(chunk.text)
|
||||
}
|
||||
|
||||
textChunks.should.deepEqual(["Response after retry"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("getModel", () => {
|
||||
it("should return the correct model when specified", () => {
|
||||
const handler = new ClaudeCodeHandler({
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import "should"
|
||||
import { openRouterDefaultModelInfo } from "@shared/api"
|
||||
import sinon from "sinon"
|
||||
import { ClineAccountService } from "@/services/account/ClineAccountService"
|
||||
import { AuthService } from "@/services/auth/AuthService"
|
||||
import { ClineHandler } from "../cline"
|
||||
|
||||
describe("ClineHandler", () => {
|
||||
@@ -14,9 +16,14 @@ describe("ClineHandler", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const createHandler = (options: ConstructorParameters<typeof ClineHandler>[0]) => {
|
||||
sinon.stub(ClineAccountService, "getInstance").returns({} as any)
|
||||
sinon.stub(AuthService, "getInstance").returns({} as any)
|
||||
return new ClineHandler(options)
|
||||
}
|
||||
|
||||
it("should handle usage-only chunks when delta is missing", async () => {
|
||||
const handler = Object.create(ClineHandler.prototype) as ClineHandler
|
||||
;(handler as any).options = {}
|
||||
const handler = createHandler({})
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
@@ -56,4 +63,31 @@ describe("ClineHandler", () => {
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should forward enableParallelToolCalling to OpenRouter payload", async () => {
|
||||
const handler = createHandler({ enableParallelToolCalling: true })
|
||||
const createStub = sinon.stub().resolves(createAsyncIterable([]))
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: createStub,
|
||||
},
|
||||
},
|
||||
}
|
||||
sinon.stub(handler as any, "ensureClient").resolves(fakeClient as any)
|
||||
sinon.stub(handler, "getModel").returns({
|
||||
id: "openai/gpt-4o-mini",
|
||||
info: openRouterDefaultModelInfo,
|
||||
})
|
||||
|
||||
const tools = [
|
||||
{ type: "function", function: { name: "read_file", description: "", parameters: { type: "object" } } },
|
||||
] as any
|
||||
for await (const _chunk of handler.createMessage("system", [{ role: "user", content: "hi" }], tools)) {
|
||||
// drain stream
|
||||
}
|
||||
|
||||
const payload = createStub.firstCall.args[0]
|
||||
payload.parallel_tool_calls.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -52,4 +52,46 @@ describe("FireworksHandler", () => {
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should read cache hits from prompt_tokens_details when hit tokens are not present", async () => {
|
||||
const handler = new FireworksHandler({
|
||||
fireworksApiKey: "test-api-key",
|
||||
fireworksModelId: "accounts/fireworks/models/llama-v3p1-8b-instruct",
|
||||
})
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: sinon.stub().resolves(
|
||||
createAsyncIterable([
|
||||
{
|
||||
choices: [{}],
|
||||
usage: {
|
||||
prompt_tokens: 60,
|
||||
completion_tokens: 12,
|
||||
prompt_tokens_details: { cached_tokens: 20 },
|
||||
prompt_cache_miss_tokens: 40,
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
|
||||
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
chunks.should.deepEqual([
|
||||
{
|
||||
type: "usage",
|
||||
inputTokens: 60,
|
||||
outputTokens: 12,
|
||||
cacheReadTokens: 20,
|
||||
cacheWriteTokens: 40,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import "should"
|
||||
import sinon from "sinon"
|
||||
import { GeminiHandler } from "../gemini"
|
||||
|
||||
describe("GeminiHandler", () => {
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
const createAsyncIterable = (data: any[] = []) => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield* data
|
||||
},
|
||||
})
|
||||
|
||||
it("caps maxOutputTokens to 8192 for Flash models", async () => {
|
||||
const handler = new GeminiHandler({
|
||||
geminiApiKey: "test-api-key",
|
||||
apiModelId: "gemini-2.5-flash",
|
||||
})
|
||||
|
||||
const generateContentStream = sinon.stub().resolves(
|
||||
createAsyncIterable([
|
||||
{
|
||||
responseId: "resp-1",
|
||||
usageMetadata: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 20,
|
||||
cachedContentTokenCount: 0,
|
||||
thoughtsTokenCount: 0,
|
||||
},
|
||||
},
|
||||
]),
|
||||
)
|
||||
sinon.stub(handler as any, "ensureClient").returns({
|
||||
models: { generateContentStream },
|
||||
} as any)
|
||||
|
||||
for await (const _chunk of handler.createMessage("system", [{ role: "user", content: "hi" }] as any)) {
|
||||
// Consume stream to trigger request execution.
|
||||
}
|
||||
|
||||
const requestArgs = generateContentStream.firstCall.args[0] as Record<string, any>
|
||||
requestArgs.config.should.have.property("maxOutputTokens", 8_192)
|
||||
})
|
||||
|
||||
it("does not set maxOutputTokens for non-Flash models", async () => {
|
||||
const handler = new GeminiHandler({
|
||||
geminiApiKey: "test-api-key",
|
||||
apiModelId: "gemini-2.5-pro",
|
||||
})
|
||||
|
||||
const generateContentStream = sinon.stub().resolves(
|
||||
createAsyncIterable([
|
||||
{
|
||||
responseId: "resp-2",
|
||||
usageMetadata: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 20,
|
||||
cachedContentTokenCount: 0,
|
||||
thoughtsTokenCount: 0,
|
||||
},
|
||||
},
|
||||
]),
|
||||
)
|
||||
sinon.stub(handler as any, "ensureClient").returns({
|
||||
models: { generateContentStream },
|
||||
} as any)
|
||||
|
||||
for await (const _chunk of handler.createMessage("system", [{ role: "user", content: "hi" }] as any)) {
|
||||
// Consume stream to trigger request execution.
|
||||
}
|
||||
|
||||
const requestArgs = generateContentStream.firstCall.args[0] as Record<string, any>
|
||||
requestArgs.config.should.not.have.property("maxOutputTokens")
|
||||
})
|
||||
|
||||
it("should emit unique tool call IDs when multiple function calls share one responseId", async () => {
|
||||
const handler = new GeminiHandler({
|
||||
geminiApiKey: "test-api-key",
|
||||
})
|
||||
|
||||
const fakeClient = {
|
||||
models: {
|
||||
generateContentStream: sinon.stub().resolves(
|
||||
createAsyncIterable([
|
||||
{
|
||||
responseId: "resp_1",
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
name: "read_file",
|
||||
args: { path: ".nvmrc" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
responseId: "resp_1",
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
name: "read_file",
|
||||
args: { path: ".gitattributes" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
}
|
||||
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
|
||||
|
||||
const tools = [{ name: "read_file", description: "read file", parameters: { type: "OBJECT" } }] as any
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }], tools)) {
|
||||
if (chunk.type === "tool_calls") {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
chunks.should.have.length(2)
|
||||
chunks[0].tool_call.function.id.should.equal("resp_1-tool-0")
|
||||
chunks[1].tool_call.function.id.should.equal("resp_1-tool-1")
|
||||
chunks[0].tool_call.call_id.should.equal(chunks[0].tool_call.function.id)
|
||||
chunks[1].tool_call.call_id.should.equal(chunks[1].tool_call.function.id)
|
||||
JSON.parse(chunks[0].tool_call.function.arguments).path.should.equal(".nvmrc")
|
||||
JSON.parse(chunks[1].tool_call.function.arguments).path.should.equal(".gitattributes")
|
||||
})
|
||||
|
||||
it("should preserve Gemini-provided functionCall.id when present", async () => {
|
||||
const handler = new GeminiHandler({
|
||||
geminiApiKey: "test-api-key",
|
||||
})
|
||||
|
||||
const fakeClient = {
|
||||
models: {
|
||||
generateContentStream: sinon.stub().resolves(
|
||||
createAsyncIterable([
|
||||
{
|
||||
responseId: "resp_2",
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
id: "call_alpha",
|
||||
name: "read_file",
|
||||
args: { path: ".nvmrc" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
}
|
||||
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
|
||||
|
||||
const tools = [{ name: "read_file", description: "read file", parameters: { type: "OBJECT" } }] as any
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }], tools)) {
|
||||
if (chunk.type === "tool_calls") {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
chunks.should.have.length(1)
|
||||
chunks[0].tool_call.function.id.should.equal("call_alpha")
|
||||
chunks[0].tool_call.call_id.should.equal("call_alpha")
|
||||
JSON.parse(chunks[0].tool_call.function.arguments).path.should.equal(".nvmrc")
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,9 @@
|
||||
import { LiteLlmHandler, type LiteLlmModelInfoResponse } from "@core/api/providers/litellm"
|
||||
import { convertToOpenAiMessages } from "@core/api/transform/openai-format"
|
||||
import { liteLlmModelInfoSaneDefaults } from "@shared/api" // used in getModel tests
|
||||
import { expect } from "chai"
|
||||
import sinon from "sinon"
|
||||
import { StateManager } from "@/core/storage/StateManager" // used in getModel tests
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { mockFetchForTesting } from "@/shared/net"
|
||||
|
||||
@@ -246,4 +248,79 @@ describe("LiteLlmHandler", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("getModel", () => {
|
||||
let stateManagerStub: sinon.SinonStub
|
||||
|
||||
beforeEach(() => {
|
||||
stateManagerStub = sinon.stub(StateManager, "get").returns({
|
||||
getModelInfo: () => null,
|
||||
} as any)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
stateManagerStub.restore()
|
||||
})
|
||||
|
||||
it("returns sane defaults when no liteLlmModelInfo option is provided", () => {
|
||||
const h = new LiteLlmHandler({
|
||||
liteLlmApiKey: "test",
|
||||
liteLlmModelId: "some-model",
|
||||
})
|
||||
const model = h.getModel()
|
||||
expect(model.id).to.equal("some-model")
|
||||
expect(model.info.contextWindow).to.equal(liteLlmModelInfoSaneDefaults.contextWindow)
|
||||
})
|
||||
|
||||
it("returns user-configured model info when liteLlmModelInfo is provided and no cache exists", () => {
|
||||
const h = new LiteLlmHandler({
|
||||
liteLlmApiKey: "test",
|
||||
liteLlmModelId: "claude-sonnet-4-6",
|
||||
liteLlmModelInfo: {
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 8192,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 3,
|
||||
outputPrice: 15,
|
||||
cacheWritesPrice: 3.75,
|
||||
cacheReadsPrice: 0.3,
|
||||
},
|
||||
})
|
||||
const model = h.getModel()
|
||||
expect(model.id).to.equal("claude-sonnet-4-6")
|
||||
expect(model.info.contextWindow).to.equal(1_000_000)
|
||||
})
|
||||
|
||||
it("prefers StateManager cached model info over user-configured liteLlmModelInfo", () => {
|
||||
const cachedInfo = {
|
||||
contextWindow: 200_000,
|
||||
maxTokens: 4096,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
}
|
||||
stateManagerStub.returns({
|
||||
getModelInfo: () => cachedInfo,
|
||||
} as any)
|
||||
|
||||
const h = new LiteLlmHandler({
|
||||
liteLlmApiKey: "test",
|
||||
liteLlmModelId: "claude-sonnet-4-6",
|
||||
liteLlmModelInfo: {
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 8192,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 3,
|
||||
outputPrice: 15,
|
||||
cacheWritesPrice: 3.75,
|
||||
cacheReadsPrice: 0.3,
|
||||
},
|
||||
})
|
||||
const model = h.getModel()
|
||||
expect(model.info.contextWindow).to.equal(200_000)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { expect } from "chai"
|
||||
import { afterEach, describe, it } from "mocha"
|
||||
import sinon from "sinon"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { ApiFormat } from "@/shared/proto/index.cline"
|
||||
import { OcaHandler } from "../oca"
|
||||
|
||||
const messages: ClineStorageMessage[] = [{ role: "user", content: "Hello" }]
|
||||
|
||||
async function collectChunks(stream: AsyncGenerator<any>) {
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
describe("OcaHandler.createMessage", () => {
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
it("routes OPENAI_RESPONSES models to createMessageResponsesApi", async () => {
|
||||
const handler = new OcaHandler({
|
||||
ocaModelInfo: { apiFormat: ApiFormat.OPENAI_RESPONSES } as any,
|
||||
})
|
||||
|
||||
const chatStub = sinon.stub(handler as any, "createMessageChatApi").callsFake(async function* () {
|
||||
yield { type: "text", text: "chat" }
|
||||
})
|
||||
const responsesStub = sinon.stub(handler as any, "createMessageResponsesApi").callsFake(async function* () {
|
||||
yield { type: "text", text: "responses" }
|
||||
})
|
||||
const messagesStub = sinon.stub(handler as any, "createMessageMessagesApi").callsFake(async function* () {
|
||||
yield { type: "text", text: "messages" }
|
||||
})
|
||||
|
||||
const chunks = await collectChunks(handler.createMessage("system", messages))
|
||||
|
||||
expect(chunks).to.deep.equal([{ type: "text", text: "responses" }])
|
||||
sinon.assert.notCalled(chatStub)
|
||||
sinon.assert.calledOnce(responsesStub)
|
||||
sinon.assert.notCalled(messagesStub)
|
||||
})
|
||||
|
||||
it("routes ANTHROPIC_CHAT models to createMessageMessagesApi", async () => {
|
||||
const handler = new OcaHandler({
|
||||
ocaModelInfo: { apiFormat: ApiFormat.ANTHROPIC_CHAT } as any,
|
||||
})
|
||||
|
||||
const chatStub = sinon.stub(handler as any, "createMessageChatApi").callsFake(async function* () {
|
||||
yield { type: "text", text: "chat" }
|
||||
})
|
||||
const responsesStub = sinon.stub(handler as any, "createMessageResponsesApi").callsFake(async function* () {
|
||||
yield { type: "text", text: "responses" }
|
||||
})
|
||||
const messagesStub = sinon.stub(handler as any, "createMessageMessagesApi").callsFake(async function* () {
|
||||
yield { type: "text", text: "messages" }
|
||||
})
|
||||
|
||||
const chunks = await collectChunks(handler.createMessage("system", messages))
|
||||
|
||||
expect(chunks).to.deep.equal([{ type: "text", text: "messages" }])
|
||||
sinon.assert.notCalled(chatStub)
|
||||
sinon.assert.notCalled(responsesStub)
|
||||
sinon.assert.calledOnce(messagesStub)
|
||||
})
|
||||
|
||||
it("defaults to createMessageChatApi for OPENAI_CHAT and undefined apiFormat", async () => {
|
||||
for (const apiFormat of [ApiFormat.OPENAI_CHAT, undefined]) {
|
||||
const handler = new OcaHandler({
|
||||
ocaModelInfo: { apiFormat } as any,
|
||||
})
|
||||
|
||||
const chatStub = sinon.stub(handler as any, "createMessageChatApi").callsFake(async function* () {
|
||||
yield { type: "text", text: "chat" }
|
||||
})
|
||||
const responsesStub = sinon.stub(handler as any, "createMessageResponsesApi").callsFake(async function* () {
|
||||
yield { type: "text", text: "responses" }
|
||||
})
|
||||
const messagesStub = sinon.stub(handler as any, "createMessageMessagesApi").callsFake(async function* () {
|
||||
yield { type: "text", text: "messages" }
|
||||
})
|
||||
|
||||
const chunks = await collectChunks(handler.createMessage("system", messages))
|
||||
|
||||
expect(chunks).to.deep.equal([{ type: "text", text: "chat" }])
|
||||
sinon.assert.calledOnce(chatStub)
|
||||
sinon.assert.notCalled(responsesStub)
|
||||
sinon.assert.notCalled(messagesStub)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,8 @@ describe("OpenRouterHandler", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const tools = [{ type: "function", function: { name: "read_file", description: "", parameters: { type: "object" } } }] as any
|
||||
|
||||
it("should handle usage-only chunks when delta is missing", async () => {
|
||||
const handler = new OpenRouterHandler({
|
||||
openRouterApiKey: "test-api-key",
|
||||
@@ -57,4 +59,106 @@ describe("OpenRouterHandler", () => {
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should read cache_write_tokens from prompt_tokens_details", async () => {
|
||||
const handler = new OpenRouterHandler({
|
||||
openRouterApiKey: "test-api-key",
|
||||
})
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: sinon.stub().resolves(
|
||||
createAsyncIterable([
|
||||
{
|
||||
choices: [{}],
|
||||
usage: {
|
||||
prompt_tokens: 1000,
|
||||
completion_tokens: 200,
|
||||
prompt_tokens_details: {
|
||||
cached_tokens: 500,
|
||||
cache_write_tokens: 300,
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
|
||||
sinon.stub(handler, "getModel").returns({
|
||||
id: "anthropic/claude-sonnet-4.6",
|
||||
info: openRouterDefaultModelInfo,
|
||||
})
|
||||
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
chunks.should.deepEqual([
|
||||
{
|
||||
type: "usage",
|
||||
cacheWriteTokens: 300,
|
||||
cacheReadTokens: 500,
|
||||
inputTokens: 200,
|
||||
outputTokens: 200,
|
||||
totalCost: 0,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
type ParallelToolCallsTestCase = {
|
||||
modelId: string
|
||||
enableParallelToolCalling: boolean
|
||||
expectedParallelToolCalls: boolean
|
||||
}
|
||||
|
||||
const parallelToolCallsTestCases: ParallelToolCallsTestCase[] = [
|
||||
{
|
||||
modelId: "openai/gpt-4o-mini",
|
||||
enableParallelToolCalling: true,
|
||||
expectedParallelToolCalls: true,
|
||||
},
|
||||
{
|
||||
modelId: "openai/gpt-4o-mini",
|
||||
enableParallelToolCalling: false,
|
||||
expectedParallelToolCalls: false,
|
||||
},
|
||||
{
|
||||
modelId: "google/gemini-3-flash-preview",
|
||||
enableParallelToolCalling: true,
|
||||
expectedParallelToolCalls: true,
|
||||
},
|
||||
]
|
||||
|
||||
for (const testCase of parallelToolCallsTestCases) {
|
||||
const settingLabel = testCase.enableParallelToolCalling ? "enabled" : "disabled"
|
||||
it(`should set parallel_tool_calls=${testCase.expectedParallelToolCalls} for ${testCase.modelId} when setting is ${settingLabel}`, async () => {
|
||||
const handler = new OpenRouterHandler({
|
||||
openRouterApiKey: "test-api-key",
|
||||
enableParallelToolCalling: testCase.enableParallelToolCalling,
|
||||
})
|
||||
const createStub = sinon.stub().resolves(createAsyncIterable([]))
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: createStub,
|
||||
},
|
||||
},
|
||||
}
|
||||
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
|
||||
sinon.stub(handler, "getModel").returns({
|
||||
id: testCase.modelId,
|
||||
info: openRouterDefaultModelInfo,
|
||||
})
|
||||
|
||||
for await (const _chunk of handler.createMessage("system", [{ role: "user", content: "hi" }], tools)) {
|
||||
// drain stream
|
||||
}
|
||||
|
||||
const payload = createStub.firstCall.args[0]
|
||||
payload.parallel_tool_calls.should.equal(testCase.expectedParallelToolCalls)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import "should"
|
||||
import { openAiModelInfoSaneDefaults, wandbDefaultModelId, wandbModels } from "@shared/api"
|
||||
import { WandbHandler } from "../wandb"
|
||||
|
||||
describe("WandbHandler", () => {
|
||||
it("returns known catalog model metadata when model id is recognized", () => {
|
||||
const modelId = "meta-llama/Llama-3.3-70B-Instruct"
|
||||
const handler = new WandbHandler({
|
||||
wandbApiKey: "test-api-key",
|
||||
apiModelId: modelId,
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
|
||||
model.id.should.equal(modelId)
|
||||
model.info.should.deepEqual(wandbModels[modelId])
|
||||
})
|
||||
|
||||
it("passes through an explicit unknown model id instead of silently falling back", () => {
|
||||
const unknownModelId = "moonshotai/Kimi-K2.5"
|
||||
const handler = new WandbHandler({
|
||||
wandbApiKey: "test-api-key",
|
||||
apiModelId: unknownModelId,
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
|
||||
model.id.should.equal(unknownModelId)
|
||||
model.info.should.deepEqual(openAiModelInfoSaneDefaults)
|
||||
})
|
||||
|
||||
it("uses the default W&B model when no model id is configured", () => {
|
||||
const handler = new WandbHandler({
|
||||
wandbApiKey: "test-api-key",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
|
||||
model.id.should.equal(wandbDefaultModelId)
|
||||
model.info.should.deepEqual(wandbModels[wandbDefaultModelId])
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { filterMessagesForClaudeCode } from "@/integrations/claude-code/message-
|
||||
import { runClaudeCode } from "@/integrations/claude-code/run"
|
||||
import { ClaudeCodeModelId, claudeCodeDefaultModelId, claudeCodeModels } from "@/shared/api"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { type ApiHandler, CommonApiHandlerOptions } from ".."
|
||||
import { withRetry } from "../retry"
|
||||
import { type ApiStream, ApiStreamUsageChunk } from "../transform/stream"
|
||||
@@ -58,20 +59,47 @@ export class ClaudeCodeHandler implements ApiHandler {
|
||||
continue
|
||||
}
|
||||
|
||||
if (chunk.type === "system" && chunk.subtype === "init") {
|
||||
// Based on my tests, subscription usage sets the `apiKeySource` to "none"
|
||||
isPaidUsage = chunk.apiKeySource !== "none"
|
||||
// Handle system init messages
|
||||
if (chunk.type === "system" && "subtype" in chunk) {
|
||||
if (chunk.subtype === "init") {
|
||||
// Based on my tests, subscription usage sets the `apiKeySource` to "none"
|
||||
isPaidUsage = (chunk as any).apiKeySource !== "none"
|
||||
}
|
||||
// Also handles legacy rate_limit_event format (type: "system", subtype: "rate_limit_event")
|
||||
// by falling through — no special handling needed.
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle rate_limit_event (newer CLI format: top-level type)
|
||||
if (chunk.type === "rate_limit_event") {
|
||||
// Rate limit events are informational. Log them but don't yield anything.
|
||||
// If the rate limit blocks the response, the stream will end without
|
||||
// assistant messages and the task loop will handle the empty response.
|
||||
Logger.log("Claude Code rate limit event:", JSON.stringify(chunk))
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip user messages (tool results from Claude Code's own tool execution)
|
||||
if (chunk.type === "user") {
|
||||
continue
|
||||
}
|
||||
|
||||
if (chunk.type === "assistant" && "message" in chunk) {
|
||||
const message = chunk.message
|
||||
|
||||
if (message.stop_reason !== null) {
|
||||
const content = "text" in message.content[0] ? message.content[0] : undefined
|
||||
// Check for error field on the message (newer CLI format)
|
||||
if (message.error) {
|
||||
const firstContent = message.content?.[0]
|
||||
const errorText = firstContent && "text" in firstContent ? firstContent.text : undefined
|
||||
throw new Error(errorText ?? `Claude Code error: ${message.error}`)
|
||||
}
|
||||
|
||||
const isError = content && content.text.startsWith(`API Error`)
|
||||
if (isError) {
|
||||
if (message.stop_reason !== null) {
|
||||
const firstContent = message.content?.[0]
|
||||
const content = firstContent && "text" in firstContent ? firstContent : undefined
|
||||
|
||||
// Check if content exists before accessing its properties
|
||||
if (content && content.text.startsWith(`API Error`)) {
|
||||
// Error messages are formatted as: `API Error: <<status code>> <<json>>`
|
||||
const errorMessageStart = content.text.indexOf("{")
|
||||
const errorMessage = content.text.slice(errorMessageStart)
|
||||
@@ -126,6 +154,21 @@ export class ClaudeCodeHandler implements ApiHandler {
|
||||
},
|
||||
}
|
||||
break
|
||||
default: {
|
||||
// Handle unknown content block types gracefully.
|
||||
// Newer Anthropic models or CLI versions may introduce new content types
|
||||
// (e.g., server_tool_use, mcp_tool_use). Log them instead of silently dropping.
|
||||
const unknownBlock = content as { type: string; text?: string }
|
||||
Logger.warn(`Unhandled content type in Claude Code response: ${unknownBlock.type}`)
|
||||
// If the unknown block has a text-like field, try to yield it as text
|
||||
if (typeof unknownBlock.text === "string") {
|
||||
yield {
|
||||
type: "text",
|
||||
text: unknownBlock.text,
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,10 +185,24 @@ export class ClaudeCodeHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
if (chunk.type === "result" && "result" in chunk) {
|
||||
if (chunk.is_error) {
|
||||
throw new Error(`Claude Code returned an error: ${chunk.result}`)
|
||||
}
|
||||
|
||||
usage.totalCost = isPaidUsage ? chunk.total_cost_usd : 0
|
||||
|
||||
yield usage
|
||||
continue
|
||||
}
|
||||
|
||||
// ErrorMessage — log it explicitly and skip
|
||||
if ((chunk as any).type === "error") {
|
||||
Logger.warn("Claude Code emitted an error-type chunk:", JSON.stringify(chunk))
|
||||
continue
|
||||
}
|
||||
|
||||
// Any completely unrecognized chunk type — log and skip
|
||||
Logger.warn(`Unrecognized Claude Code chunk type: ${(chunk as any).type}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,12 @@ import axios from "axios"
|
||||
import OpenAI from "openai"
|
||||
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
|
||||
import { ClineEnv } from "@/config"
|
||||
import { refreshClineRecommendedModels } from "@/core/controller/models/refreshClineRecommendedModels"
|
||||
import { ClineAccountService } from "@/services/account/ClineAccountService"
|
||||
import { AuthService } from "@/services/auth/AuthService"
|
||||
import { buildClineExtraHeaders } from "@/services/EnvUtils"
|
||||
import { CLINE_ACCOUNT_AUTH_ERROR_MESSAGE } from "@/shared/ClineAccount"
|
||||
import { CLINE_RECOMMENDED_MODELS_FALLBACK } from "@/shared/cline/recommended-models"
|
||||
import type { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { fetch, getAxiosSettings } from "@/shared/net"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
@@ -28,9 +30,14 @@ interface ClineHandlerOptions extends CommonApiHandlerOptions {
|
||||
openRouterModelInfo?: ModelInfo
|
||||
clineAccountId?: string
|
||||
clineApiKey?: string
|
||||
enableParallelToolCalling?: boolean
|
||||
}
|
||||
|
||||
const CLINE_FREE_MODELS = ["minimax/minimax-m2.5", "kwaipilot/kat-coder-pro", "z-ai/glm-5"]
|
||||
function normalizeModelId(modelId: string): string {
|
||||
return modelId.trim().toLowerCase()
|
||||
}
|
||||
|
||||
const CLINE_FREE_MODEL_IDS = new Set(CLINE_RECOMMENDED_MODELS_FALLBACK.free.map((model) => normalizeModelId(model.id)))
|
||||
|
||||
export class ClineHandler implements ApiHandler {
|
||||
private options: ClineHandlerOptions
|
||||
@@ -49,6 +56,20 @@ export class ClineHandler implements ApiHandler {
|
||||
this._authService = AuthService.getInstance()
|
||||
}
|
||||
|
||||
private async getFreeModelIdSet(): Promise<Set<string>> {
|
||||
try {
|
||||
const models = await refreshClineRecommendedModels()
|
||||
const freeModelIds = models.free.map((model) => normalizeModelId(model.id)).filter((modelId) => modelId.length > 0)
|
||||
if (freeModelIds.length > 0) {
|
||||
return new Set(freeModelIds)
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error("Error resolving Cline free model IDs from recommended models:", error)
|
||||
}
|
||||
|
||||
return CLINE_FREE_MODEL_IDS
|
||||
}
|
||||
|
||||
private async ensureClient(): Promise<OpenAI> {
|
||||
const clineAccountAuthToken = this.options.clineApiKey || (await this._authService.getAuthToken())
|
||||
if (!clineAccountAuthToken) {
|
||||
@@ -111,6 +132,7 @@ export class ClineHandler implements ApiHandler {
|
||||
this.lastRequestId = undefined
|
||||
|
||||
let didOutputUsage = false
|
||||
const freeModelIds = await this.getFreeModelIdSet()
|
||||
|
||||
const stream = await createOpenRouterStream(
|
||||
client,
|
||||
@@ -121,6 +143,7 @@ export class ClineHandler implements ApiHandler {
|
||||
this.options.thinkingBudgetTokens,
|
||||
this.options.openRouterProviderSorting,
|
||||
tools,
|
||||
this.options.enableParallelToolCalling,
|
||||
)
|
||||
|
||||
const toolCallProcessor = new ToolCallProcessor()
|
||||
@@ -206,7 +229,7 @@ export class ClineHandler implements ApiHandler {
|
||||
// @ts-expect-error-next-line
|
||||
let totalCost = (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0)
|
||||
const modelId = this.getModel().id
|
||||
const isFreeModel = CLINE_FREE_MODELS.includes(modelId)
|
||||
const isFreeModel = freeModelIds.has(normalizeModelId(modelId))
|
||||
|
||||
if (isFreeModel) {
|
||||
totalCost = 0
|
||||
@@ -227,7 +250,7 @@ export class ClineHandler implements ApiHandler {
|
||||
// Fallback to generation endpoint if usage chunk not returned
|
||||
if (!didOutputUsage) {
|
||||
Logger.warn("Cline API did not return usage chunk, fetching from generation endpoint")
|
||||
const apiStreamUsage = await this.getApiStreamUsage()
|
||||
const apiStreamUsage = await this.getApiStreamUsage(freeModelIds)
|
||||
if (apiStreamUsage) {
|
||||
yield apiStreamUsage
|
||||
}
|
||||
@@ -238,9 +261,10 @@ export class ClineHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
|
||||
async getApiStreamUsage(): Promise<ApiStreamUsageChunk | undefined> {
|
||||
async getApiStreamUsage(freeModelIds?: Set<string>): Promise<ApiStreamUsageChunk | undefined> {
|
||||
if (this.lastGenerationId) {
|
||||
try {
|
||||
const resolvedFreeModelIds = freeModelIds || (await this.getFreeModelIdSet())
|
||||
const clineAccountAuthToken = await this._authService.getAuthToken()
|
||||
if (!clineAccountAuthToken) {
|
||||
throw new Error(CLINE_ACCOUNT_AUTH_ERROR_MESSAGE)
|
||||
@@ -260,7 +284,7 @@ export class ClineHandler implements ApiHandler {
|
||||
const generation = response.data
|
||||
let totalCost = generation?.total_cost || 0
|
||||
const modelId = this.getModel().id
|
||||
const isFreeModel = CLINE_FREE_MODELS.includes(modelId)
|
||||
const isFreeModel = resolvedFreeModelIds.has(normalizeModelId(modelId))
|
||||
|
||||
if (isFreeModel) {
|
||||
totalCost = 0
|
||||
|
||||
@@ -83,14 +83,20 @@ export class FireworksHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
const usage = chunk.usage as OpenAI.CompletionUsage & {
|
||||
prompt_cache_hit_tokens?: number
|
||||
prompt_cache_miss_tokens?: number
|
||||
prompt_tokens_details?: {
|
||||
cached_tokens?: number
|
||||
}
|
||||
}
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: chunk.usage.prompt_tokens || 0, // (deepseek reports total input AND cache reads/writes, see context caching: https://api-docs.deepseek.com/guides/kv_cache) where the input tokens is the sum of the cache hits/misses, while anthropic reports them as separate tokens. This is important to know for 1) context management truncation algorithm, and 2) cost calculation (NOTE: we report both input and cache stats but for now set input price to 0 since all the cost calculation will be done using cache hits/misses)
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
// @ts-expect-error-next-line
|
||||
cacheReadTokens: chunk.usage.prompt_cache_hit_tokens || 0,
|
||||
// @ts-expect-error-next-line
|
||||
cacheWriteTokens: chunk.usage.prompt_cache_miss_tokens || 0,
|
||||
inputTokens: usage.prompt_tokens || 0,
|
||||
outputTokens: usage.completion_tokens || 0,
|
||||
// Fireworks can return cache hits either as prompt_cache_hit_tokens or prompt_tokens_details.cached_tokens.
|
||||
cacheReadTokens: usage.prompt_cache_hit_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? 0,
|
||||
cacheWriteTokens: usage.prompt_cache_miss_tokens || 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ThinkingLevel,
|
||||
} from "@google/genai"
|
||||
import { GeminiModelId, geminiDefaultModelId, geminiModels, ModelInfo } from "@shared/api"
|
||||
import { GEMINI_FLASH_MAX_OUTPUT_TOKENS, isGeminiFlashModel } from "@utils/model-utils"
|
||||
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
@@ -45,6 +46,18 @@ function mapReasoningEffortToGeminiThinkingLevel(effort: string): ThinkingLevel
|
||||
}
|
||||
}
|
||||
|
||||
function getGeminiMaxOutputTokens(modelId: string, modelMaxTokens?: number): number | undefined {
|
||||
if (!isGeminiFlashModel(modelId)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (modelMaxTokens && modelMaxTokens > 0) {
|
||||
return Math.min(modelMaxTokens, GEMINI_FLASH_MAX_OUTPUT_TOKENS)
|
||||
}
|
||||
|
||||
return GEMINI_FLASH_MAX_OUTPUT_TOKENS
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for Google's Gemini API with optimized caching strategy and accurate cost accounting.
|
||||
*
|
||||
@@ -136,6 +149,9 @@ export class GeminiHandler implements ApiHandler {
|
||||
const client = this.ensureClient()
|
||||
const { id: modelId, info } = this.getModel()
|
||||
const contents = messages.map(convertAnthropicMessageToGemini)
|
||||
// Gemini may emit multiple function calls under the same responseId and without functionCall.id.
|
||||
// Track a local sequence so each emitted tool call has a stable unique ID.
|
||||
const responseToolCallCount = new Map<string, number>()
|
||||
|
||||
// Configure thinking budget/level if supported
|
||||
const _thinkingBudget = this.options.thinkingBudgetTokens ?? 0
|
||||
@@ -152,6 +168,7 @@ export class GeminiHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
// Set up base generation config
|
||||
const maxOutputTokens = getGeminiMaxOutputTokens(modelId, info.maxTokens)
|
||||
const requestConfig: GenerateContentConfig = {
|
||||
// Add base URL if configured
|
||||
httpOptions: this.options.geminiBaseUrl ? { baseUrl: this.options.geminiBaseUrl } : undefined,
|
||||
@@ -159,6 +176,7 @@ export class GeminiHandler implements ApiHandler {
|
||||
// Set temperature (default to 0)
|
||||
// Gemini 3 recommends 1.0
|
||||
temperature: info.temperature ?? 1,
|
||||
...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}),
|
||||
}
|
||||
|
||||
// Add thinking config only if the model supports it
|
||||
@@ -210,6 +228,7 @@ export class GeminiHandler implements ApiHandler {
|
||||
|
||||
let isFirstSdkChunk = true
|
||||
for await (const chunk of result) {
|
||||
const responseKey = chunk.responseId || "gemini-response"
|
||||
if (isFirstSdkChunk) {
|
||||
sdkFirstChunkTime = Date.now()
|
||||
ttftSdkMs = sdkFirstChunkTime - sdkCallStartTime
|
||||
@@ -238,12 +257,21 @@ export class GeminiHandler implements ApiHandler {
|
||||
const functionCall = part.functionCall
|
||||
const args = Object.entries(functionCall.args || {}).filter(([_key, val]) => !!val)
|
||||
if (functionCall.args && args.length > 0) {
|
||||
const existingId = functionCall.id?.trim()
|
||||
const toolCallId =
|
||||
existingId ??
|
||||
(() => {
|
||||
const sequenceNumber = responseToolCallCount.get(responseKey) ?? 0
|
||||
responseToolCallCount.set(responseKey, sequenceNumber + 1)
|
||||
return `${responseKey}-tool-${sequenceNumber}`
|
||||
})()
|
||||
yield {
|
||||
type: "tool_calls",
|
||||
id: chunk.responseId,
|
||||
tool_call: {
|
||||
call_id: toolCallId,
|
||||
function: {
|
||||
id: chunk.responseId,
|
||||
id: toolCallId,
|
||||
name: functionCall.name,
|
||||
arguments: JSON.stringify(functionCall.args),
|
||||
},
|
||||
|
||||
@@ -72,26 +72,24 @@ export async function fetchLiteLlmModelsInfo(baseUrl: string, apiKey: string): P
|
||||
if (response.ok) {
|
||||
const data: LiteLlmModelInfoResponse = await response.json()
|
||||
return data
|
||||
} else {
|
||||
Logger.error("Failed to fetch LiteLLM model info:", response.statusText)
|
||||
// Try with Authorization header instead
|
||||
const retryResponse = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
...buildExternalBasicHeaders(),
|
||||
},
|
||||
})
|
||||
|
||||
if (retryResponse.ok) {
|
||||
const data: LiteLlmModelInfoResponse = await retryResponse.json()
|
||||
return data
|
||||
} else {
|
||||
Logger.error("Failed to fetch LiteLLM model info with Authorization header:", retryResponse.statusText)
|
||||
throw new Error(`Failed to fetch LiteLLM model info: ${retryResponse.statusText}`)
|
||||
}
|
||||
}
|
||||
Logger.error("Failed to fetch LiteLLM model info:", response.statusText)
|
||||
// Try with Authorization header instead
|
||||
const retryResponse = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
...buildExternalBasicHeaders(),
|
||||
},
|
||||
})
|
||||
|
||||
if (retryResponse.ok) {
|
||||
const data: LiteLlmModelInfoResponse = await retryResponse.json()
|
||||
return data
|
||||
}
|
||||
Logger.error("Failed to fetch LiteLLM model info with Authorization header:", retryResponse.statusText)
|
||||
throw new Error(`Failed to fetch LiteLLM model info: ${retryResponse.statusText}`)
|
||||
} catch (error) {
|
||||
Logger.error("Error fetching LiteLLM model info:", error)
|
||||
throw error
|
||||
@@ -102,7 +100,7 @@ export class LiteLlmHandler implements ApiHandler {
|
||||
private options: LiteLlmHandlerOptions
|
||||
private client: OpenAI | undefined
|
||||
private modelInfoCache: LiteLlmModelInfoResponse | undefined
|
||||
private modelInfoCacheTimestamp: number = 0
|
||||
private modelInfoCacheTimestamp = 0
|
||||
private readonly modelInfoCacheTTL = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
constructor(options: LiteLlmHandlerOptions) {
|
||||
@@ -274,7 +272,8 @@ export class LiteLlmHandler implements ApiHandler {
|
||||
},
|
||||
] as any,
|
||||
}
|
||||
} else if (Array.isArray(message.content)) {
|
||||
}
|
||||
if (Array.isArray(message.content)) {
|
||||
// Apply cache control to the last content item in the array
|
||||
return {
|
||||
...message,
|
||||
@@ -377,7 +376,7 @@ export class LiteLlmHandler implements ApiHandler {
|
||||
const cachedModelInfo = StateManager.get().getModelInfo("liteLlm", modelId)
|
||||
|
||||
// Fall back to provided model info or defaults if not in cache
|
||||
const modelInfo = cachedModelInfo || liteLlmModelInfoSaneDefaults
|
||||
const modelInfo = cachedModelInfo || this.options.liteLlmModelInfo || liteLlmModelInfoSaneDefaults
|
||||
|
||||
return {
|
||||
id: modelId,
|
||||
|
||||
@@ -394,18 +394,29 @@ export class OcaHandler implements ApiHandler {
|
||||
strict: tool.function.strict ?? true, // Responses API defaults to strict mode
|
||||
}))
|
||||
|
||||
const responsesParams: OpenAI.Responses.ResponseCreateParamsStreaming = {
|
||||
model: this.options.ocaModelId || liteLlmDefaultModelId,
|
||||
input,
|
||||
stream: true,
|
||||
tools: responseTools,
|
||||
}
|
||||
let temperature: number | undefined = this.options.ocaModelInfo?.temperature ?? 0
|
||||
const maxOutputTokens: number | undefined = this.options.ocaModelInfo?.maxTokens
|
||||
|
||||
const ocaModelInfo = this.options.ocaModelInfo
|
||||
if (!ocaModelInfo) {
|
||||
throw new Error("Oracle Code Assist (OCA) model info is required for Responses API")
|
||||
}
|
||||
if (ocaModelInfo.supportsReasoning) {
|
||||
|
||||
const reasoningOn = !!ocaModelInfo.supportsReasoning
|
||||
if (reasoningOn) {
|
||||
temperature = undefined
|
||||
}
|
||||
|
||||
const responsesParams: OpenAI.Responses.ResponseCreateParamsStreaming = {
|
||||
model: this.options.ocaModelId || liteLlmDefaultModelId,
|
||||
input,
|
||||
stream: true,
|
||||
tools: responseTools,
|
||||
...(typeof temperature === "number" ? { temperature } : {}),
|
||||
...(typeof maxOutputTokens === "number" && maxOutputTokens > 0 ? { max_output_tokens: maxOutputTokens } : {}),
|
||||
}
|
||||
|
||||
if (reasoningOn) {
|
||||
responsesParams.reasoning = { effort: this.options.ocaReasoningEffort as any, summary: "auto" }
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ interface OpenRouterHandlerOptions extends CommonApiHandlerOptions {
|
||||
openRouterProviderSorting?: string
|
||||
reasoningEffort?: string
|
||||
thinkingBudgetTokens?: number
|
||||
enableParallelToolCalling?: boolean
|
||||
}
|
||||
|
||||
export class OpenRouterHandler implements ApiHandler {
|
||||
@@ -68,6 +69,7 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
this.options.thinkingBudgetTokens,
|
||||
this.options.openRouterProviderSorting,
|
||||
tools,
|
||||
this.options.enableParallelToolCalling,
|
||||
)
|
||||
|
||||
let didOutputUsage = false
|
||||
@@ -152,11 +154,16 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
if (!didOutputUsage && chunk.usage) {
|
||||
// @ts-expect-error-next-line -- OpenRouter returns cache_write_tokens for Anthropic models
|
||||
const cacheWriteTokens = chunk.usage.prompt_tokens_details?.cache_write_tokens || 0
|
||||
yield {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheWriteTokens,
|
||||
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
|
||||
inputTokens: (chunk.usage.prompt_tokens || 0) - (chunk.usage.prompt_tokens_details?.cached_tokens || 0),
|
||||
inputTokens:
|
||||
(chunk.usage.prompt_tokens || 0) -
|
||||
(chunk.usage.prompt_tokens_details?.cached_tokens || 0) -
|
||||
(cacheWriteTokens || 0),
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
// @ts-expect-error-next-line
|
||||
totalCost: (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0),
|
||||
@@ -183,7 +190,7 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
// Logger.log("OpenRouter generation details:", generation)
|
||||
return {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheWriteTokens: generation?.native_tokens_cache_write || 0,
|
||||
cacheReadTokens: generation?.native_tokens_cached || 0,
|
||||
// openrouter generation endpoint fails often
|
||||
inputTokens: (generation?.native_tokens_prompt || 0) - (generation?.native_tokens_cached || 0),
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { openAiModelInfoSaneDefaults, type ModelInfo, type WandbModelId, wandbDefaultModelId, wandbModels } from "@shared/api"
|
||||
import OpenAI from "openai"
|
||||
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { createOpenAIClient } from "@/shared/net"
|
||||
import { ApiHandler, CommonApiHandlerOptions } from "../index"
|
||||
import { withRetry } from "../retry"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
|
||||
|
||||
interface WandbHandlerOptions extends CommonApiHandlerOptions {
|
||||
wandbApiKey?: string
|
||||
apiModelId?: string
|
||||
}
|
||||
|
||||
export class WandbHandler implements ApiHandler {
|
||||
private client: OpenAI | undefined
|
||||
|
||||
constructor(private readonly options: WandbHandlerOptions) {}
|
||||
|
||||
private ensureClient(): OpenAI {
|
||||
if (!this.client) {
|
||||
if (!this.options.wandbApiKey) {
|
||||
throw new Error("W&B API key is required")
|
||||
}
|
||||
try {
|
||||
this.client = createOpenAIClient({
|
||||
baseURL: "https://api.inference.wandb.ai/v1",
|
||||
apiKey: this.options.wandbApiKey,
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(`Error creating W&B Inference client: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
return this.client
|
||||
}
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[], tools?: OpenAITool[]): ApiStream {
|
||||
const client = this.ensureClient()
|
||||
const model = this.getModel()
|
||||
|
||||
const stream = await client.chat.completions.create({
|
||||
model: model.id,
|
||||
messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
|
||||
temperature: 0,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
...getOpenAIToolParams(tools),
|
||||
})
|
||||
|
||||
const toolCallProcessor = new ToolCallProcessor()
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices?.[0]?.delta
|
||||
if (delta?.content) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: delta.content,
|
||||
}
|
||||
}
|
||||
|
||||
if (delta && "reasoning" in delta && delta.reasoning) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: typeof delta.reasoning === "string" ? delta.reasoning : JSON.stringify(delta.reasoning),
|
||||
}
|
||||
}
|
||||
|
||||
if (delta?.tool_calls) {
|
||||
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
// W&B Inference returns prompt_tokens_details.cached_tokens in the usage chunk,
|
||||
// but does not currently offer cache-aware billing (cached tokens are billed
|
||||
// at the same rate as regular input tokens). We report inputTokens as the full
|
||||
// prompt_tokens value and do not subtract cached tokens until W&B supports
|
||||
// cache-aware pricing. This may change in a future update.
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getModel(): { id: string; info: ModelInfo } {
|
||||
const modelId = this.options.apiModelId?.trim()
|
||||
|
||||
if (modelId && modelId in wandbModels) {
|
||||
return { id: modelId, info: wandbModels[modelId as WandbModelId] }
|
||||
}
|
||||
|
||||
if (modelId) {
|
||||
return { id: modelId, info: openAiModelInfoSaneDefaults }
|
||||
}
|
||||
|
||||
return { id: wandbDefaultModelId, info: wandbModels[wandbDefaultModelId] }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it } from "mocha"
|
||||
import "should"
|
||||
import type { ModelInfo } from "@shared/api"
|
||||
import sinon from "sinon"
|
||||
import { createOpenRouterStream } from "../openrouter-stream"
|
||||
|
||||
describe("createOpenRouterStream", () => {
|
||||
const createAsyncIterable = () => ({
|
||||
async *[Symbol.asyncIterator]() {},
|
||||
})
|
||||
|
||||
const createClient = () => {
|
||||
const create = sinon.stub().resolves(createAsyncIterable())
|
||||
return {
|
||||
client: {
|
||||
chat: {
|
||||
completions: {
|
||||
create,
|
||||
},
|
||||
},
|
||||
},
|
||||
create,
|
||||
}
|
||||
}
|
||||
|
||||
const createModelInfo = (maxTokens: number): ModelInfo => ({
|
||||
maxTokens,
|
||||
contextWindow: 1_048_576,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
})
|
||||
|
||||
it("caps Gemini Flash OpenRouter requests to 8192 max_tokens", async () => {
|
||||
const { client, create } = createClient()
|
||||
|
||||
await createOpenRouterStream(client as any, "system prompt", [{ role: "user", content: "hello" }] as any, {
|
||||
id: "google/gemini-2.5-flash",
|
||||
info: createModelInfo(65_536),
|
||||
})
|
||||
|
||||
const payload = create.firstCall.args[0] as Record<string, unknown>
|
||||
payload.should.have.property("max_tokens", 8_192)
|
||||
})
|
||||
|
||||
it("keeps lower Gemini Flash max_tokens values when already below 8192", async () => {
|
||||
const { client, create } = createClient()
|
||||
|
||||
await createOpenRouterStream(client as any, "system prompt", [{ role: "user", content: "hello" }] as any, {
|
||||
id: "google/gemini-2.5-flash",
|
||||
info: createModelInfo(4_096),
|
||||
})
|
||||
|
||||
const payload = create.firstCall.args[0] as Record<string, unknown>
|
||||
payload.should.have.property("max_tokens", 4_096)
|
||||
})
|
||||
|
||||
it("does not send max_tokens for non-Gemini models", async () => {
|
||||
const { client, create } = createClient()
|
||||
|
||||
await createOpenRouterStream(client as any, "system prompt", [{ role: "user", content: "hello" }] as any, {
|
||||
id: "anthropic/claude-sonnet-4.5",
|
||||
info: createModelInfo(64_000),
|
||||
})
|
||||
|
||||
const payload = create.firstCall.args[0] as Record<string, unknown>
|
||||
payload.should.not.have.property("max_tokens")
|
||||
})
|
||||
|
||||
it("does not send max_tokens for non-Flash Gemini models", async () => {
|
||||
const { client, create } = createClient()
|
||||
|
||||
await createOpenRouterStream(client as any, "system prompt", [{ role: "user", content: "hello" }] as any, {
|
||||
id: "google/gemini-2.5-pro",
|
||||
info: createModelInfo(65_536),
|
||||
})
|
||||
|
||||
const payload = create.firstCall.args[0] as Record<string, unknown>
|
||||
payload.should.not.have.property("max_tokens")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
import "should"
|
||||
import { getOpenAIToolParams, ToolCallProcessor } from "../tool-call-processor"
|
||||
|
||||
describe("ToolCallProcessor", () => {
|
||||
it("should preserve tool call id/name for interleaved parallel deltas", () => {
|
||||
const processor = new ToolCallProcessor()
|
||||
|
||||
const firstChunk = [
|
||||
{
|
||||
index: 0,
|
||||
id: "call_a",
|
||||
function: { name: "read_file" },
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
id: "call_b",
|
||||
function: { name: "search_files" },
|
||||
},
|
||||
] as any
|
||||
|
||||
const secondChunk = [
|
||||
{
|
||||
index: 1,
|
||||
function: { arguments: '{"path":"src"}' },
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
function: { arguments: '{"path":"README.md"}' },
|
||||
},
|
||||
] as any
|
||||
|
||||
const firstResult = [...processor.processToolCallDeltas(firstChunk)]
|
||||
const secondResult = [...processor.processToolCallDeltas(secondChunk)]
|
||||
|
||||
firstResult.should.have.length(0)
|
||||
secondResult.should.have.length(2)
|
||||
// Intentionally reversed from the setup chunk: output follows incoming
|
||||
// argument-delta order, but reconstruction is correct regardless of arrival
|
||||
// order because id/name/arguments are matched by tool call index.
|
||||
const firstToolCall = secondResult[0]!.tool_call as any
|
||||
const secondToolCall = secondResult[1]!.tool_call as any
|
||||
firstToolCall.function.id.should.equal("call_b")
|
||||
firstToolCall.function.name.should.equal("search_files")
|
||||
firstToolCall.function.arguments.should.equal('{"path":"src"}')
|
||||
secondToolCall.function.id.should.equal("call_a")
|
||||
secondToolCall.function.name.should.equal("read_file")
|
||||
secondToolCall.function.arguments.should.equal('{"path":"README.md"}')
|
||||
})
|
||||
|
||||
it("should clear accumulated state on reset", () => {
|
||||
const processor = new ToolCallProcessor()
|
||||
|
||||
const setupChunk = [
|
||||
{
|
||||
index: 0,
|
||||
id: "call_reset",
|
||||
function: { name: "read_file" },
|
||||
},
|
||||
] as any
|
||||
|
||||
const argsChunk = [
|
||||
{
|
||||
index: 0,
|
||||
function: { arguments: '{"path":"after-reset"}' },
|
||||
},
|
||||
] as any
|
||||
|
||||
;[...processor.processToolCallDeltas(setupChunk)].should.have.length(0)
|
||||
processor.reset()
|
||||
;[...processor.processToolCallDeltas(argsChunk)].should.have.length(0)
|
||||
|
||||
const newSetupChunk = [
|
||||
{
|
||||
index: 0,
|
||||
id: "call_new",
|
||||
function: { name: "write_file" },
|
||||
},
|
||||
] as any
|
||||
|
||||
const newArgsChunk = [
|
||||
{
|
||||
index: 0,
|
||||
function: { arguments: '{"path":"file.txt"}' },
|
||||
},
|
||||
] as any
|
||||
|
||||
;[...processor.processToolCallDeltas(newSetupChunk)].should.have.length(0)
|
||||
;[...processor.processToolCallDeltas(newArgsChunk)].should.have.length(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getOpenAIToolParams", () => {
|
||||
it("should include parallel_tool_calls when enabled", () => {
|
||||
const tools = [
|
||||
{ type: "function", function: { name: "read_file", description: "", parameters: { type: "object" } } },
|
||||
] as any
|
||||
const params = getOpenAIToolParams(tools, true) as any
|
||||
|
||||
params.parallel_tool_calls.should.equal(true)
|
||||
})
|
||||
|
||||
it("should include parallel_tool_calls=false when disabled by default", () => {
|
||||
const tools = [
|
||||
{ type: "function", function: { name: "read_file", description: "", parameters: { type: "object" } } },
|
||||
] as any
|
||||
const params = getOpenAIToolParams(tools, false) as any
|
||||
|
||||
params.parallel_tool_calls.should.equal(false)
|
||||
})
|
||||
|
||||
it("should not include parallel_tool_calls when tools are absent", () => {
|
||||
const params = getOpenAIToolParams(undefined, false) as any
|
||||
|
||||
params.should.not.have.property("parallel_tool_calls")
|
||||
})
|
||||
})
|
||||
@@ -9,7 +9,12 @@ import {
|
||||
openRouterClaudeSonnet461mModelId,
|
||||
} from "@shared/api"
|
||||
import { normalizeOpenaiReasoningEffort } from "@shared/storage/types"
|
||||
import { shouldSkipReasoningForModel, supportsReasoningEffortForModel } from "@utils/model-utils"
|
||||
import {
|
||||
GEMINI_FLASH_MAX_OUTPUT_TOKENS,
|
||||
isGeminiFlashModel,
|
||||
shouldSkipReasoningForModel,
|
||||
supportsReasoningEffortForModel,
|
||||
} from "@utils/model-utils"
|
||||
import OpenAI from "openai"
|
||||
import { ChatCompletionTool } from "openai/resources/chat/completions"
|
||||
import { convertToOpenAiMessages, sanitizeGeminiMessages } from "./openai-format"
|
||||
@@ -25,6 +30,7 @@ export async function createOpenRouterStream(
|
||||
thinkingBudgetTokens?: number,
|
||||
openRouterProviderSorting?: string,
|
||||
tools?: Array<ChatCompletionTool>,
|
||||
enableParallelToolCalling?: boolean,
|
||||
) {
|
||||
// Convert Anthropic messages to OpenAI format
|
||||
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
@@ -46,74 +52,41 @@ export async function createOpenRouterStream(
|
||||
openAiMessages = sanitizeGeminiMessages(openAiMessages, model.id)
|
||||
|
||||
// prompt caching: https://openrouter.ai/docs/prompt-caching
|
||||
// this was initially specifically for claude models (some models may 'support prompt caching' automatically without this)
|
||||
// handles direct model.id match logic
|
||||
switch (model.id) {
|
||||
case "anthropic/claude-opus-4.6":
|
||||
case "anthropic/claude-haiku-4.5":
|
||||
case "anthropic/claude-4.5-haiku":
|
||||
case "anthropic/claude-sonnet-4.6":
|
||||
case "anthropic/claude-4.6-sonnet":
|
||||
case "anthropic/claude-sonnet-4.5":
|
||||
case "anthropic/claude-4.5-sonnet": // OpenRouter accidentally included this in model list for a brief moment, and users may be using this model id. And to support prompt caching, we need to add it here.
|
||||
case "anthropic/claude-sonnet-4":
|
||||
case "anthropic/claude-opus-4.5":
|
||||
case "anthropic/claude-opus-4.1":
|
||||
case "anthropic/claude-opus-4":
|
||||
case "anthropic/claude-3.7-sonnet":
|
||||
case "anthropic/claude-3.7-sonnet:beta":
|
||||
case "anthropic/claude-3.7-sonnet:thinking":
|
||||
case "anthropic/claude-3-7-sonnet":
|
||||
case "anthropic/claude-3-7-sonnet:beta":
|
||||
case "anthropic/claude-3.5-sonnet":
|
||||
case "anthropic/claude-3.5-sonnet:beta":
|
||||
case "anthropic/claude-3.5-sonnet-20240620":
|
||||
case "anthropic/claude-3.5-sonnet-20240620:beta":
|
||||
case "anthropic/claude-3-5-haiku":
|
||||
case "anthropic/claude-3-5-haiku:beta":
|
||||
case "anthropic/claude-3-5-haiku-20241022":
|
||||
case "anthropic/claude-3-5-haiku-20241022:beta":
|
||||
case "anthropic/claude-3-haiku":
|
||||
case "anthropic/claude-3-haiku:beta":
|
||||
case "anthropic/claude-3-opus":
|
||||
case "anthropic/claude-3-opus:beta":
|
||||
case "minimax/minimax-m2":
|
||||
case "minimax/minimax-m2.1":
|
||||
case "minimax/minimax-m2.1-lightning":
|
||||
case "minimax/minimax-m2.5":
|
||||
openAiMessages[0] = {
|
||||
role: "system",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: systemPrompt,
|
||||
// @ts-expect-error-next-line
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
],
|
||||
}
|
||||
// Add cache_control to the last two user messages
|
||||
// (note: this works because we only ever add one user message at a time, but if we added multiple we'd need to mark the user message before the last assistant message)
|
||||
const lastTwoUserMessages = openAiMessages.filter((msg) => msg.role === "user").slice(-2)
|
||||
lastTwoUserMessages.forEach((msg) => {
|
||||
if (typeof msg.content === "string") {
|
||||
msg.content = [{ type: "text", text: msg.content }]
|
||||
}
|
||||
if (Array.isArray(msg.content)) {
|
||||
// NOTE: this is fine since env details will always be added at the end. but if it weren't there, and the user added a image_url type message, it would pop a text part before it and then move it after to the end.
|
||||
let lastTextPart = msg.content.filter((part) => part.type === "text").pop()
|
||||
// Anthropic and MiniMax models require explicit cache_control blocks to enable prompt caching on OpenRouter.
|
||||
// Other providers (OpenAI, Google) handle caching automatically without cache_control blocks.
|
||||
const needsCacheControl = model.id.startsWith("anthropic/") || model.id.startsWith("minimax/")
|
||||
|
||||
if (!lastTextPart) {
|
||||
lastTextPart = { type: "text", text: "..." }
|
||||
msg.content.push(lastTextPart)
|
||||
}
|
||||
if (needsCacheControl) {
|
||||
openAiMessages[0] = {
|
||||
role: "system",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: systemPrompt,
|
||||
// @ts-expect-error-next-line
|
||||
lastTextPart["cache_control"] = { type: "ephemeral" }
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
],
|
||||
}
|
||||
// Add cache_control to the last two user messages
|
||||
// (note: this works because we only ever add one user message at a time, but if we added multiple we'd need to mark the user message before the last assistant message)
|
||||
const lastTwoUserMessages = openAiMessages.filter((msg) => msg.role === "user").slice(-2)
|
||||
lastTwoUserMessages.forEach((msg) => {
|
||||
if (typeof msg.content === "string") {
|
||||
msg.content = [{ type: "text", text: msg.content }]
|
||||
}
|
||||
if (Array.isArray(msg.content)) {
|
||||
// NOTE: this is fine since env details will always be added at the end. but if it weren't there, and the user added a image_url type message, it would pop a text part before it and then move it after to the end.
|
||||
let lastTextPart = msg.content.filter((part) => part.type === "text").pop()
|
||||
|
||||
if (!lastTextPart) {
|
||||
lastTextPart = { type: "text", text: "..." }
|
||||
msg.content.push(lastTextPart)
|
||||
}
|
||||
})
|
||||
break
|
||||
default:
|
||||
break
|
||||
// @ts-expect-error-next-line
|
||||
lastTextPart["cache_control"] = { type: "ephemeral" }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let temperature: number | undefined = 0
|
||||
@@ -180,9 +153,13 @@ export async function createOpenRouterStream(
|
||||
const includeReasoning = !shouldSkipReasoningForModel(model.id) && reasoningEffortValue !== "none"
|
||||
const reasoningPayload =
|
||||
reasoning ?? (reasoningEffortValue && reasoningEffortValue !== "none" ? { effort: reasoningEffortValue } : undefined)
|
||||
const maxTokens = isGeminiFlashModel(model.id)
|
||||
? Math.min(model.info.maxTokens || GEMINI_FLASH_MAX_OUTPUT_TOKENS, GEMINI_FLASH_MAX_OUTPUT_TOKENS)
|
||||
: undefined
|
||||
|
||||
const requestPayload: Record<string, unknown> = {
|
||||
model: model.id,
|
||||
...(maxTokens ? { max_tokens: maxTokens } : {}),
|
||||
temperature: temperature,
|
||||
top_p: topP,
|
||||
messages: openAiMessages,
|
||||
@@ -193,7 +170,7 @@ export async function createOpenRouterStream(
|
||||
...(openRouterProviderSorting && !providerPreferences ? { provider: { sort: openRouterProviderSorting } } : {}),
|
||||
...(providerPreferences ? { provider: providerPreferences } : {}),
|
||||
...(isClaude1m ? { provider: { order: ["anthropic", "google-vertex/global"], allow_fallbacks: false } } : {}),
|
||||
...getOpenAIToolParams(tools),
|
||||
...getOpenAIToolParams(tools, !!enableParallelToolCalling),
|
||||
}
|
||||
|
||||
// @ts-expect-error-next-line
|
||||
|
||||
@@ -12,10 +12,10 @@ import type { ApiStreamToolCallsChunk } from "./stream"
|
||||
* and yields properly formatted tool call chunks when arguments are received.
|
||||
*/
|
||||
export class ToolCallProcessor {
|
||||
private lastToolCall: { id: string; name: string }
|
||||
private toolCallStateByIndex: Map<number, { id: string; name: string }>
|
||||
|
||||
constructor() {
|
||||
this.lastToolCall = { id: "", name: "" }
|
||||
this.toolCallStateByIndex = new Map()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,28 +30,32 @@ export class ToolCallProcessor {
|
||||
return
|
||||
}
|
||||
|
||||
for (const toolCallDelta of toolCallDeltas) {
|
||||
for (const [fallbackIndex, toolCallDelta] of toolCallDeltas.entries()) {
|
||||
// OpenAI-style streams include an index per tool call. Use iteration order as a fallback.
|
||||
const toolCallIndex = toolCallDelta.index ?? fallbackIndex
|
||||
const toolCallState = this.getOrCreateToolCallState(toolCallIndex)
|
||||
|
||||
// Accumulate the tool call ID if present
|
||||
if (toolCallDelta.id) {
|
||||
this.lastToolCall.id = toolCallDelta.id
|
||||
toolCallState.id = toolCallDelta.id
|
||||
}
|
||||
|
||||
// Accumulate the function name if present
|
||||
if (toolCallDelta.function?.name) {
|
||||
Logger.debug(`[ToolCallProcessor] Native Tool Called: ${toolCallDelta.function.name}`)
|
||||
this.lastToolCall.name = toolCallDelta.function.name
|
||||
toolCallState.name = toolCallDelta.function.name
|
||||
}
|
||||
|
||||
// Only yield when we have all required fields: id, name, and arguments
|
||||
if (this.lastToolCall.id && this.lastToolCall.name && toolCallDelta.function?.arguments) {
|
||||
if (toolCallState.id && toolCallState.name && toolCallDelta.function?.arguments) {
|
||||
yield {
|
||||
type: "tool_calls",
|
||||
tool_call: {
|
||||
...toolCallDelta,
|
||||
function: {
|
||||
...toolCallDelta.function,
|
||||
id: this.lastToolCall.id,
|
||||
name: this.lastToolCall.name,
|
||||
id: toolCallState.id,
|
||||
name: toolCallState.name,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -59,29 +63,42 @@ export class ToolCallProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
private getOrCreateToolCallState(index: number): { id: string; name: string } {
|
||||
const existingState = this.toolCallStateByIndex.get(index)
|
||||
if (existingState) {
|
||||
return existingState
|
||||
}
|
||||
|
||||
const initialState = { id: "", name: "" }
|
||||
this.toolCallStateByIndex.set(index, initialState)
|
||||
return initialState
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the internal state. Call this when starting a new message.
|
||||
*/
|
||||
reset(): void {
|
||||
this.lastToolCall = { id: "", name: "" }
|
||||
this.toolCallStateByIndex.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current accumulated tool call state (useful for debugging).
|
||||
*/
|
||||
getState(): { id: string; name: string } {
|
||||
return { ...this.lastToolCall }
|
||||
getState(): Record<number, { id: string; name: string }> {
|
||||
return Object.fromEntries(this.toolCallStateByIndex.entries())
|
||||
}
|
||||
}
|
||||
|
||||
export function getOpenAIToolParams(tools?: OpenAITool[], enableParallelToolCalls: boolean = false) {
|
||||
return tools?.length
|
||||
? {
|
||||
tools,
|
||||
tool_choice: tools ? ("auto" as ChatCompletionToolChoiceOption) : undefined,
|
||||
parallel_tool_calls: enableParallelToolCalls ? true : false,
|
||||
}
|
||||
: {
|
||||
tools: undefined,
|
||||
}
|
||||
export function getOpenAIToolParams(tools?: OpenAITool[], enableParallelToolCalls = false) {
|
||||
if (!tools?.length) {
|
||||
return {
|
||||
tools: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tools,
|
||||
tool_choice: "auto" as ChatCompletionToolChoiceOption,
|
||||
parallel_tool_calls: enableParallelToolCalls,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import { expect } from "chai"
|
||||
import { describe, it } from "mocha"
|
||||
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
|
||||
import { convertOpenAIToolsToAnthropicTools, handleAnthropicMessagesApiStreamResponse } from "../messages_api_support"
|
||||
|
||||
const createAsyncIterable = (events: any[]) =>
|
||||
({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
for (const event of events) {
|
||||
yield event
|
||||
}
|
||||
},
|
||||
}) as any
|
||||
|
||||
async function collectChunks(events: any[]) {
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of handleAnthropicMessagesApiStreamResponse(createAsyncIterable(events))) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
describe("messages_api_support", () => {
|
||||
describe("convertOpenAIToolsToAnthropicTools", () => {
|
||||
it("returns undefined when tools are missing", () => {
|
||||
expect(convertOpenAIToolsToAnthropicTools(undefined)).to.equal(undefined)
|
||||
expect(convertOpenAIToolsToAnthropicTools([])).to.equal(undefined)
|
||||
})
|
||||
|
||||
it("converts function tools and defaults schema type to object", () => {
|
||||
const tools: OpenAITool[] = [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "read_file",
|
||||
description: "Read a file from disk",
|
||||
parameters: {
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const converted = convertOpenAIToolsToAnthropicTools(tools)
|
||||
|
||||
expect(converted).to.deep.equal([
|
||||
{
|
||||
name: "read_file",
|
||||
description: "Read a file from disk",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("filters out invalid tools", () => {
|
||||
const tools = [
|
||||
{
|
||||
type: "other",
|
||||
function: {
|
||||
name: "ignored",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "valid_tool",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
},
|
||||
] as any as OpenAITool[]
|
||||
|
||||
const converted = convertOpenAIToolsToAnthropicTools(tools)
|
||||
|
||||
expect(converted).to.have.length(1)
|
||||
expect(converted?.[0]?.name).to.equal("valid_tool")
|
||||
})
|
||||
})
|
||||
|
||||
describe("handleAnthropicMessagesApiStreamResponse", () => {
|
||||
it("maps usage, reasoning, and text events into ApiStream chunks", async () => {
|
||||
const chunks = await collectChunks([
|
||||
{
|
||||
type: "message_start",
|
||||
message: {
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 2,
|
||||
cache_creation_input_tokens: 4,
|
||||
cache_read_input_tokens: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "content_block_start",
|
||||
content_block: {
|
||||
type: "thinking",
|
||||
thinking: "first thought",
|
||||
signature: "sig-start",
|
||||
},
|
||||
index: 0,
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
delta: {
|
||||
type: "thinking_delta",
|
||||
thinking: " then more",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
delta: {
|
||||
type: "signature_delta",
|
||||
signature: "sig-final",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "content_block_start",
|
||||
content_block: {
|
||||
type: "text",
|
||||
text: "Hello",
|
||||
},
|
||||
index: 0,
|
||||
},
|
||||
{
|
||||
type: "content_block_start",
|
||||
content_block: {
|
||||
type: "text",
|
||||
text: "World",
|
||||
},
|
||||
index: 1,
|
||||
},
|
||||
{
|
||||
type: "message_delta",
|
||||
usage: {
|
||||
output_tokens: 9,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
expect(chunks).to.deep.equal([
|
||||
{
|
||||
type: "usage",
|
||||
inputTokens: 10,
|
||||
outputTokens: 2,
|
||||
cacheWriteTokens: 4,
|
||||
cacheReadTokens: 3,
|
||||
},
|
||||
{
|
||||
type: "reasoning",
|
||||
reasoning: "first thought",
|
||||
signature: "sig-start",
|
||||
},
|
||||
{
|
||||
type: "reasoning",
|
||||
reasoning: " then more",
|
||||
},
|
||||
{
|
||||
type: "reasoning",
|
||||
reasoning: "",
|
||||
signature: "sig-final",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Hello",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "\n",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "World",
|
||||
},
|
||||
{
|
||||
type: "usage",
|
||||
inputTokens: 0,
|
||||
outputTokens: 9,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("emits tool call chunks and resets tool state on block stop", async () => {
|
||||
const chunks = await collectChunks([
|
||||
{
|
||||
type: "content_block_start",
|
||||
content_block: {
|
||||
type: "tool_use",
|
||||
id: "tool_1",
|
||||
name: "read_file",
|
||||
},
|
||||
index: 0,
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
delta: {
|
||||
type: "input_json_delta",
|
||||
partial_json: '{"path":',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "content_block_stop",
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
delta: {
|
||||
type: "input_json_delta",
|
||||
partial_json: '"ignored-after-stop"}',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
expect(chunks).to.have.length(1)
|
||||
expect(chunks[0]).to.deep.equal({
|
||||
type: "tool_calls",
|
||||
tool_call: {
|
||||
id: "tool_1",
|
||||
name: "read_file",
|
||||
arguments: "",
|
||||
function: {
|
||||
id: "tool_1",
|
||||
name: "read_file",
|
||||
arguments: '{"path":',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -42,6 +42,7 @@ export const toolParamNames = [
|
||||
"steps_to_reproduce",
|
||||
"api_request_output",
|
||||
"additional_context",
|
||||
"queries",
|
||||
"needs_more_exploration",
|
||||
"task_progress",
|
||||
"timeout",
|
||||
|
||||
@@ -1,40 +1,13 @@
|
||||
import { RefreshedSkills, SkillInfo } from "@shared/proto/cline/file"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { parseYamlFrontmatter } from "@/core/context/instructions/user-instructions/frontmatter"
|
||||
import { getSkillsDirectoriesForScan } from "@/core/storage/disk"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { fileExistsAtPath, isDirectory } from "@/utils/fs"
|
||||
import { Controller } from ".."
|
||||
|
||||
/**
|
||||
* Parse YAML frontmatter from markdown content.
|
||||
*/
|
||||
function parseFrontmatter(fileContent: string): { data: Record<string, unknown>; content: string } {
|
||||
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/
|
||||
const match = fileContent.match(frontmatterRegex)
|
||||
|
||||
if (!match) {
|
||||
return { data: {}, content: fileContent }
|
||||
}
|
||||
|
||||
const [, yamlContent, body] = match
|
||||
// Simple YAML parsing for name and description
|
||||
const data: Record<string, unknown> = {}
|
||||
const lines = yamlContent.split("\n")
|
||||
for (const line of lines) {
|
||||
const colonIndex = line.indexOf(":")
|
||||
if (colonIndex > 0) {
|
||||
const key = line.slice(0, colonIndex).trim()
|
||||
const value = line
|
||||
.slice(colonIndex + 1)
|
||||
.trim()
|
||||
.replace(/^["']|["']$/g, "")
|
||||
data[key] = value
|
||||
}
|
||||
}
|
||||
return { data, content: body }
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a directory for skill subdirectories containing SKILL.md files.
|
||||
*/
|
||||
@@ -58,7 +31,11 @@ async function scanSkillsDirectory(dirPath: string): Promise<SkillInfo[]> {
|
||||
|
||||
try {
|
||||
const fileContent = await fs.readFile(skillMdPath, "utf-8")
|
||||
const { data: frontmatter } = parseFrontmatter(fileContent)
|
||||
const result = parseYamlFrontmatter(fileContent)
|
||||
if (result.parseError) {
|
||||
Logger.warn("Failed to parse YAML frontmatter:", result.parseError)
|
||||
}
|
||||
const frontmatter = result.data
|
||||
|
||||
// Validate required fields
|
||||
if (!frontmatter.name || typeof frontmatter.name !== "string") continue
|
||||
|
||||
@@ -887,6 +887,7 @@ export class Controller {
|
||||
const lastDismissedCliBannerVersion = this.stateManager.getGlobalStateKey("lastDismissedCliBannerVersion") || 0
|
||||
const dismissedBanners = this.stateManager.getGlobalStateKey("dismissedBanners")
|
||||
const doubleCheckCompletionEnabled = this.stateManager.getGlobalSettingsKey("doubleCheckCompletionEnabled")
|
||||
const showFeatureTips = this.stateManager.getGlobalSettingsKey("showFeatureTips")
|
||||
|
||||
const localClineRulesToggles = this.stateManager.getWorkspaceStateKey("localClineRulesToggles")
|
||||
const localWindsurfRulesToggles = this.stateManager.getWorkspaceStateKey("localWindsurfRulesToggles")
|
||||
@@ -997,6 +998,9 @@ export class Controller {
|
||||
backgroundEditEnabled: this.stateManager.getGlobalSettingsKey("backgroundEditEnabled"),
|
||||
optOutOfRemoteConfig: this.stateManager.getGlobalSettingsKey("optOutOfRemoteConfig"),
|
||||
doubleCheckCompletionEnabled,
|
||||
showFeatureTips,
|
||||
codeIntelligenceEnabled: this.stateManager.getGlobalSettingsKey("codeIntelligenceEnabled"),
|
||||
codeIntelligenceAvailable: HostProvider.psi !== undefined,
|
||||
banners,
|
||||
welcomeBanners,
|
||||
openAiCodexIsAuthenticated,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ensureCacheDirectoryExists, GlobalFileNames } from "@core/storage/disk"
|
||||
import type { ModelInfo } from "@shared/api"
|
||||
import { fileExistsAtPath } from "@utils/fs"
|
||||
import { GEMINI_FLASH_MAX_OUTPUT_TOKENS, isGeminiFlashModel } from "@utils/model-utils"
|
||||
import axios from "axios"
|
||||
import cloneDeep from "clone-deep"
|
||||
import fs from "fs/promises"
|
||||
@@ -240,6 +241,13 @@ async function fetchAndCacheClineModels(): Promise<Record<string, ModelInfo>> {
|
||||
break
|
||||
}
|
||||
|
||||
if (isGeminiFlashModel(rawModel.id)) {
|
||||
modelInfo.maxTokens = Math.min(
|
||||
modelInfo.maxTokens || GEMINI_FLASH_MAX_OUTPUT_TOKENS,
|
||||
GEMINI_FLASH_MAX_OUTPUT_TOKENS,
|
||||
)
|
||||
}
|
||||
|
||||
models[rawModel.id] = modelInfo
|
||||
|
||||
// Add custom :1m model variant for Sonnet models
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ensureCacheDirectoryExists, GlobalFileNames } from "@core/storage/disk"
|
||||
import type { ModelInfo } from "@shared/api"
|
||||
import { GEMINI_FLASH_MAX_OUTPUT_TOKENS, isGeminiFlashModel } from "@utils/model-utils"
|
||||
import axios from "axios"
|
||||
import cloneDeep from "clone-deep"
|
||||
import fs from "fs/promises"
|
||||
@@ -264,6 +265,13 @@ async function fetchAndCacheModels(controller: Controller): Promise<Record<strin
|
||||
break
|
||||
}
|
||||
|
||||
if (isGeminiFlashModel(rawModel.id)) {
|
||||
modelInfo.maxTokens = Math.min(
|
||||
modelInfo.maxTokens || GEMINI_FLASH_MAX_OUTPUT_TOKENS,
|
||||
GEMINI_FLASH_MAX_OUTPUT_TOKENS,
|
||||
)
|
||||
}
|
||||
|
||||
models[rawModel.id] = modelInfo
|
||||
|
||||
// add custom :1m model variant for sonnet
|
||||
|
||||
@@ -179,6 +179,11 @@ export async function updateSettings(controller: Controller, request: UpdateSett
|
||||
}
|
||||
}
|
||||
|
||||
// Update code intelligence setting (JetBrains PSI)
|
||||
if (request.codeIntelligenceEnabled !== undefined) {
|
||||
controller.stateManager.setGlobalState("codeIntelligenceEnabled", !!request.codeIntelligenceEnabled)
|
||||
}
|
||||
|
||||
// Update auto-condense setting
|
||||
if (request.useAutoCondense !== undefined) {
|
||||
if (controller.task) {
|
||||
@@ -344,6 +349,10 @@ export async function updateSettings(controller: Controller, request: UpdateSett
|
||||
controller.stateManager.setGlobalState("doubleCheckCompletionEnabled", request.doubleCheckCompletionEnabled)
|
||||
}
|
||||
|
||||
if (request.showFeatureTips !== undefined) {
|
||||
controller.stateManager.setGlobalState("showFeatureTips", request.showFeatureTips)
|
||||
}
|
||||
|
||||
// Post updated state to webview
|
||||
await controller.postStateToWebview()
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { afterEach, describe, it } from "mocha"
|
||||
import "should"
|
||||
import sinon from "sinon"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import * as HookExecutor from "../hook-executor"
|
||||
import {
|
||||
buildNotificationData,
|
||||
emitNotificationHook,
|
||||
emitTaskCompleteNotification,
|
||||
emitUserAttentionNotification,
|
||||
NOTIFICATION_MESSAGE_MAX_LENGTH,
|
||||
} from "../notification-hook"
|
||||
|
||||
describe("notification-hook", () => {
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
const context = {
|
||||
messageStateHandler: {} as any,
|
||||
taskId: "task-123",
|
||||
hooksEnabled: true,
|
||||
model: { provider: "anthropic", slug: "claude" },
|
||||
}
|
||||
|
||||
it("emits user-attention notifications with normalized payload", async () => {
|
||||
const executeHookStub = sinon.stub(HookExecutor, "executeHook").resolves({ wasCancelled: false })
|
||||
|
||||
await emitUserAttentionNotification(context, {
|
||||
source: "approval_request",
|
||||
message: "Need approval",
|
||||
})
|
||||
|
||||
sinon.assert.calledOnce(executeHookStub)
|
||||
const notification = (
|
||||
executeHookStub.firstCall.args[0].hookInput as { notification: ReturnType<typeof buildNotificationData> }
|
||||
).notification
|
||||
notification.event.should.equal("user_attention")
|
||||
notification.source.should.equal("approval_request")
|
||||
notification.sourceType.should.equal("ask")
|
||||
notification.sourceId.should.equal("approval_request")
|
||||
notification.waitingForUserInput.should.equal(true)
|
||||
notification.requiresUserAction.should.equal(true)
|
||||
notification.severity.should.equal("info")
|
||||
})
|
||||
|
||||
it("emits task-complete notifications with normalized payload", async () => {
|
||||
const executeHookStub = sinon.stub(HookExecutor, "executeHook").resolves({ wasCancelled: false })
|
||||
|
||||
await emitTaskCompleteNotification(context, { message: "All done" })
|
||||
|
||||
const notification = (
|
||||
executeHookStub.firstCall.args[0].hookInput as { notification: ReturnType<typeof buildNotificationData> }
|
||||
).notification
|
||||
notification.event.should.equal("task_complete")
|
||||
notification.source.should.equal("attempt_completion")
|
||||
notification.sourceType.should.equal("tool")
|
||||
notification.sourceId.should.equal("attempt_completion")
|
||||
notification.waitingForUserInput.should.equal(false)
|
||||
notification.requiresUserAction.should.equal(false)
|
||||
})
|
||||
|
||||
it("centralizes truncation and exposes truncation metadata", () => {
|
||||
const notification = buildNotificationData({
|
||||
event: "user_attention",
|
||||
source: "ask",
|
||||
sourceType: "ask",
|
||||
sourceId: "followup",
|
||||
message: "x".repeat(NOTIFICATION_MESSAGE_MAX_LENGTH + 25),
|
||||
waitingForUserInput: true,
|
||||
requiresUserAction: true,
|
||||
})
|
||||
|
||||
notification.messageTruncated.should.equal(true)
|
||||
notification.message.length.should.equal(NOTIFICATION_MESSAGE_MAX_LENGTH + "\n...[truncated]".length)
|
||||
notification.message.should.match(/\.\.\.\[truncated\]$/)
|
||||
})
|
||||
|
||||
it("preserves backward-compatible fields while adding additive fields", () => {
|
||||
const notification = buildNotificationData({
|
||||
event: "user_attention",
|
||||
source: "ask",
|
||||
sourceType: "ask",
|
||||
sourceId: "approval",
|
||||
message: "hello",
|
||||
waitingForUserInput: true,
|
||||
requiresUserAction: true,
|
||||
})
|
||||
|
||||
notification.event.should.equal("user_attention")
|
||||
notification.source.should.equal("ask")
|
||||
notification.message.should.equal("hello")
|
||||
notification.waitingForUserInput.should.equal(true)
|
||||
notification.eventVersion.should.equal("1")
|
||||
notification.eventId.should.not.equal("")
|
||||
notification.messageTruncated.should.equal(false)
|
||||
notification.sourceType.should.equal("ask")
|
||||
notification.sourceId.should.equal("approval")
|
||||
notification.requiresUserAction.should.equal(true)
|
||||
notification.severity.should.equal("info")
|
||||
})
|
||||
|
||||
it("ignores unsupported notification outputs and logs warnings", async () => {
|
||||
const executeHookStub = sinon.stub(HookExecutor, "executeHook").resolves({
|
||||
cancel: true,
|
||||
contextModification: "ignored",
|
||||
wasCancelled: false,
|
||||
})
|
||||
const warnStub = sinon.stub(Logger, "warn")
|
||||
|
||||
await emitNotificationHook(
|
||||
context,
|
||||
buildNotificationData({
|
||||
event: "task_complete",
|
||||
source: "attempt_completion",
|
||||
sourceType: "tool",
|
||||
sourceId: "attempt_completion",
|
||||
message: "done",
|
||||
waitingForUserInput: false,
|
||||
requiresUserAction: false,
|
||||
}),
|
||||
)
|
||||
|
||||
sinon.assert.calledOnce(executeHookStub)
|
||||
sinon.assert.calledTwice(warnStub)
|
||||
})
|
||||
|
||||
it("fails open when hook execution throws", async () => {
|
||||
sinon.stub(HookExecutor, "executeHook").rejects(new Error("boom"))
|
||||
const errorStub = sinon.stub(Logger, "error")
|
||||
|
||||
await emitTaskCompleteNotification(context, { message: "done" })
|
||||
|
||||
sinon.assert.calledOnce(errorStub)
|
||||
})
|
||||
|
||||
it("does nothing when hooks are disabled", async () => {
|
||||
const executeHookStub = sinon.stub(HookExecutor, "executeHook").resolves({ wasCancelled: false })
|
||||
|
||||
await emitTaskCompleteNotification({ ...context, hooksEnabled: false }, { message: "done" })
|
||||
|
||||
sinon.assert.notCalled(executeHookStub)
|
||||
})
|
||||
})
|
||||
@@ -3,14 +3,16 @@ import "should"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import sinon from "sinon"
|
||||
import { HookOutput } from "../../../shared/proto/cline/hooks"
|
||||
import { HookFactory } from "../hook-factory"
|
||||
import { createHookTestEnv, HookTestEnv, loadFixture, stubHookDirs, writeHookScriptForPlatform } from "./test-utils"
|
||||
import { createHookTestEnv, HookTestEnv, stubHookDirs, withFixtureRunner, writeHookScriptForPlatform } from "./test-utils"
|
||||
|
||||
describe("TaskComplete Hook", () => {
|
||||
let tempDir: string
|
||||
let sandbox: sinon.SinonSandbox
|
||||
let getEnv: () => { tempDir: string }
|
||||
let hookTestEnv: HookTestEnv
|
||||
const getErrorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error))
|
||||
|
||||
const writeHookScript = async (hookPath: string, nodeScript: string): Promise<void> => {
|
||||
await writeHookScriptForPlatform(hookPath, nodeScript)
|
||||
@@ -432,72 +434,73 @@ console.log(JSON.stringify({
|
||||
})
|
||||
|
||||
describe("Fixture-Based Tests", () => {
|
||||
it("should work with success fixture", async () => {
|
||||
await loadFixture("hooks/taskcomplete/success", getEnv().tempDir)
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("TaskComplete")
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: "test-task-id",
|
||||
taskComplete: {
|
||||
taskMetadata: {
|
||||
taskId: "test-task-id",
|
||||
ulid: "test-ulid",
|
||||
result: "Test task",
|
||||
command: "",
|
||||
it("should validate representative fixtures end-to-end", async () => {
|
||||
const scenarios: Array<{
|
||||
fixtureName: string
|
||||
resultText: string
|
||||
assert: (result: HookOutput) => void
|
||||
}> = [
|
||||
{
|
||||
fixtureName: "success",
|
||||
resultText: "Test task",
|
||||
assert: (result: HookOutput) => {
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("TaskComplete hook executed successfully")
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("TaskComplete hook executed successfully")
|
||||
})
|
||||
|
||||
it("should work with error fixture", async () => {
|
||||
await loadFixture("hooks/taskcomplete/error", getEnv().tempDir)
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("TaskComplete")
|
||||
|
||||
try {
|
||||
await runner.run({
|
||||
taskId: "test-task-id",
|
||||
taskComplete: {
|
||||
taskMetadata: {
|
||||
taskId: "test-task-id",
|
||||
ulid: "test-ulid",
|
||||
result: "Test task",
|
||||
command: "",
|
||||
},
|
||||
{
|
||||
fixtureName: "context-injection",
|
||||
resultText: "Build a todo app",
|
||||
assert: (result: HookOutput) => {
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("COMPLETED: Build a todo app")
|
||||
},
|
||||
})
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.message.should.match(/TaskComplete.*exited with code 1/)
|
||||
},
|
||||
]
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
await withFixtureRunner(
|
||||
"TaskComplete",
|
||||
`hooks/taskcomplete/${scenario.fixtureName}`,
|
||||
hookTestEnv,
|
||||
async (runner) => {
|
||||
const result = await runner.run({
|
||||
taskId: "test-task-id",
|
||||
taskComplete: {
|
||||
taskMetadata: {
|
||||
taskId: "test-task-id",
|
||||
ulid: "test-ulid",
|
||||
result: scenario.resultText,
|
||||
command: "",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
scenario.assert(result)
|
||||
},
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it("should work with context-injection fixture", async () => {
|
||||
await loadFixture("hooks/taskcomplete/context-injection", getEnv().tempDir)
|
||||
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("TaskComplete")
|
||||
|
||||
const result = await runner.run({
|
||||
taskId: "test-task-id",
|
||||
taskComplete: {
|
||||
taskMetadata: {
|
||||
it("should preserve fixture-based failure behavior", async () => {
|
||||
await withFixtureRunner("TaskComplete", "hooks/taskcomplete/error", hookTestEnv, async (runner) => {
|
||||
try {
|
||||
await runner.run({
|
||||
taskId: "test-task-id",
|
||||
ulid: "test-ulid",
|
||||
result: "Build a todo app",
|
||||
command: "",
|
||||
},
|
||||
},
|
||||
taskComplete: {
|
||||
taskMetadata: {
|
||||
taskId: "test-task-id",
|
||||
ulid: "test-ulid",
|
||||
result: "Test task",
|
||||
command: "",
|
||||
},
|
||||
},
|
||||
})
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: unknown) {
|
||||
getErrorMessage(error).should.match(/TaskComplete.*exited with code 1/)
|
||||
}
|
||||
})
|
||||
|
||||
result.cancel.should.be.false()
|
||||
result.contextModification?.should.equal("COMPLETED: Build a todo app")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user