Compare commits

..

38 Commits

Author SHA1 Message Date
Tony Loehr 9485c05839 fix: handle duplicate tab entries on Windows in getOpenTabs test 2026-03-26 03:01:59 -07:00
Tony Loehr 28152c6d06 fix: add Windows timeout guards to hook tests that spawn child processes 2026-03-26 02:36:02 -07:00
Tony Loehr 7a97ce57ba warning label fix 2026-03-26 02:05:24 -07:00
Tony Loehr 70c602ebb7 agent runtime details 2026-03-26 02:03:40 -07:00
Tony Loehr 023162611d docs: add keyboard shortcuts, settings, onboarding, and remote config to kanban docs 2026-03-26 01:58:28 -07:00
Tony Loehr acb32ef95e Update core-workflow.mdx 2026-03-26 01:46:05 -07:00
Tony Loehr e94c4727a2 Update docs/kanban/overview.mdx
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-26 01:42:11 -07:00
Tony Loehr 54d136df5d Merge branch 'main' into kanban-docs 2026-03-26 08:36:58 +00:00
Saoud Rizwan a4131e57d8 fix(cli): replace --tui hint with kanban description in migration view
The migration announcement didn't explain what Kanban is. Swap the
"run cline --tui" line for a one-liner describing the product so users
know what they're opting into. The --tui escape hatch is still
discoverable via the Exit menu item.
2026-03-25 22:52:22 -07:00
Tony Loehr bef543c37c Merge branch 'main' into kanban-docs 2026-03-26 05:30:27 +00:00
Tony Loehr 484a98a8a5 docs: add Kanban documentation tab 2026-03-25 21:59:46 -07:00
Saoud Rizwan 683dd9a741 chore(cli): bump version to 2.11.0 2026-03-25 19:24:48 -07:00
Saoud Rizwan 0d437f71b0 fix(cli): remove noisy install message when launching kanban 2026-03-25 19:24:20 -07:00
Saoud Rizwan fc77c0faea chore(cli): bump version to 2.10.0 2026-03-25 18:38:24 -07:00
Saoud Rizwan 12c85c4233 Update tips about kanban 2026-03-25 18:26:06 -07:00
Saoud Rizwan d6b7a1ab41 fix(cli): launch kanban directly with package manager fallback 2026-03-25 18:26:06 -07:00
CandiedUniverse 3926e7b404 Stabilize auth e2e onboarding tests and clean up mock server URL parsing (#9974)
* Fix tests that fail when kanban modal is visible

* Fix deprecation warning

* Improve isVisible() as per Greptile suggestion
2026-03-25 16:35:09 -07:00
Tony Loehr 5ba4314b9a feat: add toggle to disable feature tips in chat (#9973) 2026-03-25 15:28:09 -07:00
John Choi 9ccad7f764 refactor: replace hand-rolled YAML parser in refreshSkills with shared helper (#9956)
* refactor: replace hand-rolled YAML parser in refreshSkills with shared helper

The refreshSkills controller had its own line-by-line YAML parser that
only handled simple key: value pairs. Replace it with the shared
parseYamlFrontmatter helper already used by the skills discovery path.

Same output for name and description fields. The shared parser handles
edge cases (arrays, nested values, quoted colons) more robustly.

* refactor: inline parseYamlFrontmatter, remove redundant wrapper

Remove the parseFrontmatter wrapper since only `data` is used by
the caller. Inline the call directly at the use site.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: John Choi <johnchoi@MacBook-Pro.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 09:06:15 -07:00
Saoud Rizwan 3562f54dbf fix(cli): enable shell mode for kanban spawn on Windows
npx.cmd requires shell: true on Windows to resolve correctly.
2026-03-25 07:35:53 -07:00
Octopus d427d5d76a feat: upgrade MiniMax default model to M2.7 (#9886)
* feat: upgrade MiniMax default model to M2.7

- Add MiniMax-M2.7 and MiniMax-M2.7-highspeed to model list
- Set MiniMax-M2.7 as default model
- Keep all previous models as alternatives
- Update provider documentation

* fix: correct M2.7 cache pricing and update prompt caching docs

- Updated cacheReadsPrice from 0.015 to 0.03 for both MiniMax-M2.7 and
  MiniMax-M2.7-highspeed to match M2.5 pricing (same cache read rate
  across M2.x models)
- Updated prompt caching tip to explicitly mention highspeed variants

* fix: correct MiniMax model pricing to match official rates

- M2.7-highspeed/M2.5-highspeed/M2.1-lightning: $0.60/$2.40 (not $0.30/$1.20)
- M2.7 cache: reads $0.06/M (not $0.03), writes $0.375/M (not $0.0375)
- All models: cache writes $0.375/M (not $0.0375)

Ref: https://platform.minimax.io/docs/llms.txt

---------

Co-authored-by: PR Bot <pr-bot@minimaxi.com>
2026-03-25 05:17:13 -07:00
Saoud Rizwan 072e2887b0 fix(cli): add --tui flag to TUI e2e tests to bypass Kanban redirect
The Kanban launch-by-default feature (beb54a4) redirects bare `cline`
invocations to Kanban, which blocks all TUI tests that launch without
`--tui`. Adding the flag ensures tests reach the legacy TUI as expected.
2026-03-25 04:36:12 -07:00
Saoud Rizwan 9c71a6f021 feat(webview): add Cline Kanban launch modal and queue announcements (#9963)
* feat(webview): add dedicated Cline Kanban launch modal

* fix(ci): fetch LFS media assets in publish workflows
2026-03-25 04:32:09 -07:00
Saoud Rizwan 7627a382aa feat(cli): launch kanban by default with migration view (#9914)
* feat(cli): refresh welcome banner and kanban launcher

* feat(cli): launch kanban by default with migration view

* feat(cli): add kanban process lifecycle management

Detach the kanban child process on Unix so it gets its own process
group, forward signals to the group for graceful shutdown, and fall
back to SIGKILL after a 10s timeout. Resolves exit codes from signals
correctly (130 for SIGINT, 143 for SIGTERM).
2026-03-25 02:01:56 -07:00
Tomás Barreiro 978c633c90 Add KanBan to remote config [PF-632] (#9959)
* Add KanBan to remote config

* Update tests
2026-03-25 02:33:52 +01:00
Tony Loehr e807b520e0 feat(cli): add rotating feature tips during thinking/acting phases (#9874)
* feat(cli): add rotating feature tips during thinking/acting phases

Port the FeatureTip component from the VSCode extension (PR #9799) to the
CLI. Shows rotating educational tips below the ThinkingIndicator while Cline
is processing, appearing after a 2-second delay and cycling every 8 seconds.

Includes 17 tips covering:
- Core features: .clinerules, Plan/Act mode, checkpoints, MCP servers
- CLI-specific: /settings, /skills, /history, /compact, /reportbug
- Workflows: kanban, auto-approve toggle, history navigation, @ mentions
- Images via --images flag, browser testing, double-check completion

* Update FeatureTip.tsx

* Apply suggestions from code review

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* fix: resolve FeatureTip.tsx syntax errors (duplicate export, missing tipIndex state)

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-24 17:57:35 -07:00
John Choi f3a3f30db5 feat: add repeated tool call loop detection (#9933)
* feat: add repeated tool call loop detection

Detect when the LLM calls the same tool with identical arguments
repeatedly, which wastes tokens without making progress. This is
the #1 active complaint in the Cline issue tracker (13+ open issues
including #9923, #9916, #9846, #9816).

Two-stage escalation:
- Stage 1 (3 identical calls): inject a warning nudging the LLM
  to try a different approach
- Stage 2 (5 identical calls): trigger the existing
  consecutiveMistakeCount escalation (asks user or fails in YOLO)

Detection uses JSON.stringify with a sorted key replacer for
deterministic comparison. Metadata params like task_progress
(which change every call even when actual tool arguments are
identical) are stripped from the comparison.

Complementary to fileReadCache, which deduplicates file content but
still allows the tool call to succeed and consume a turn. Loop
detection catches the repeated call pattern itself.

Changes:
- loop-detection.ts: shared helper (toolCallSignature, checkRepeatedToolCall)
- TaskState.ts: add lastToolParams, consecutiveIdenticalToolCount
- ToolExecutor.ts: call checkRepeatedToolCall in handleCompleteBlock
- responses.ts: add formatResponse.repeatedToolCall
- loop-detection.test.ts: 5 tests

Manually verified: CLI test confirms soft warning at call 3,
YOLO mode failure at call 5.

Refs: #9923, #9916, #9846, #9816

* fix: address review feedback on loop detection

- Change hardEscalation threshold from >= to === so it fires exactly
  once at count 5, matching softWarning behavior
- Widen toolCallSignature param type to Partial<Record<string, string>>
  to match actual block.params type from ToolExecutor
- Add negative boundary assertions to verify no false positives at
  calls 0, 1, 3, 4

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: reset loop detection state when user continues after mistake_limit_reached

When hard escalation fired at count === 5 and the user clicked
"continue", consecutiveIdenticalToolCount was never reset. The count
would exceed 5 but === 5 never matched again, silently disabling
loop detection for the rest of the task.

Reset consecutiveIdenticalToolCount, lastToolName, and lastToolParams
alongside the existing consecutiveMistakeCount reset so the detector
fully re-arms after the user continues.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: John Choi <johnchoi@MacBook-Pro.local>
2026-03-24 13:40:31 -07:00
Igor Tceglevskii f1c7934064 fix: prevent OOM crash from globby's eager .gitignore scanning in lis… (#9917)
* fix: prevent OOM crash from globby's eager .gitignore scanning in listFiles

Replace globby's gitignore:true (which reads ALL .gitignore files in the
entire tree upfront, including inside gitignored directories) with
incremental .gitignore reading during BFS traversal.

In projects with large gitignored vendored dependencies containing many
nested repos, globby collects thousands of patterns, builds a massive
regex, and V8 runs out of memory during regex compilation (~488MB).

The fix reads .gitignore files only from directories the BFS actually
enters. Gitignored directories are never entered, so their .gitignore
files are never parsed and the pattern count stays small.

- Set gitignore:false, handle .gitignore ourselves
- Read root .gitignore in buildIgnorePatterns() to seed initial patterns
- Read subdirectory .gitignore lazily during globbyLevelByLevel BFS
- Accumulate patterns in currentIgnore so deeper levels respect them
- Add 4 tests: root patterns, file patterns, subdirectory .gitignore,
  and OOM-prevention (no reading inside gitignored dirs)

* Code review followup

* Potential fix for code scanning alert no. 147: Incomplete string escaping or encoding

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-03-22 15:20:57 -07:00
Robin Newhouse 1d42da5248 Add tool-specific error messages for replace_in_file and execute_command (#9710)
Replace generic "missing parameter" errors with targeted guidance for
the two tools observed failing most in SWE-bench (6% of failures).
The new messages include the expected format (SEARCH/REPLACE blocks,
XML example) without the 30-line boilerplate reminder.

Made-with: Cursor
2026-03-21 11:57:00 -07:00
Ara 7f3974a827 fix: read cache_write_tokens from OpenRouter API instead of hardcoding 0 (#9871)
* fix: read cache_write_tokens from OpenRouter API instead of hardcoding 0

- Read prompt_tokens_details.cache_write_tokens from OpenRouter stream usage
  chunks instead of hardcoding cacheWriteTokens to 0
- Read native_tokens_cache_write from generation endpoint fallback
- Replace fragile hardcoded model ID switch statement for cache_control blocks
  with prefix-based matching (anthropic/, minimax/) so new models automatically
  get prompt caching enabled
- Add unit test verifying cache_write_tokens are correctly extracted

Co-authored-by: Ara <arafat.da.khan@gmail.com>

* fix: read cache_write_tokens from OpenRouter API instead of hardcoding 0

- Read prompt_tokens_details.cache_write_tokens from OpenRouter stream usage
  chunks instead of hardcoding cacheWriteTokens to 0
- Read native_tokens_cache_write from generation endpoint fallback
- Replace fragile hardcoded model ID switch statement for cache_control blocks
  with prefix-based matching (anthropic/, minimax/) so new models automatically
  get prompt caching enabled
- Add unit test verifying cache_write_tokens are correctly extracted

Co-authored-by: Ara <arafat.da.khan@gmail.com>

* Release v3.74.0 Notes

* Release v3.74.0 Notes

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: alex-lum <alex@cline.bot>
2026-03-20 15:46:29 -07:00
CandiedUniverse d40ab56aff Changelog and version bump for release (#9910) 2026-03-20 15:29:45 -07:00
CandiedUniverse bbdf445db7 fix: prevent resume asks from unblocking on abort (#9908)
* fix: prevent resume asks from unblocking on abort

* Add test case for resume_completed_task

* Prevent flaky tests
2026-03-20 10:51:16 -07:00
CandiedUniverse d992a3bf21 Stabilize hooks fixture tests with per-scenario isolation (fix flaking tests) (#9868)
* Refactor flaking hooks tests

Fix flaking hooks tests

Strengthen hooks fixture test isolation

* Address Greptile hooks test review

* Harden Windows hook timing tests

* Loosen hook cancellation timing margins

* Fixing more flaking tests
2026-03-20 09:50:14 -07:00
Max ace95988f8 fix: resolve deadlock when clicking New Task/Exit after task completion (#9905)
The presentation scheduler introduced in ff05ec3bb awaits in-flight
flushes during dispose(), but ask() blocks indefinitely on pWaitFor
waiting for user input. When abortTask() sets abort=true and then
awaits scheduler.dispose(), the in-flight flush (blocked on
ask("completion_result")) never resolves, deadlocking the UI.

Add abort flag check to ask()'s pWaitFor condition so blocked asks
unblock immediately when the task is aborted.

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 08:53:21 -07:00
Tony Loehr 54726f1677 docs: fix SDK documentation accuracy and completeness (#9856)
* docs: fix SDK documentation accuracy and completeness

- Fix setPermissionHandler API Reference to use correct async/return signature
  (was showing old callback pattern with (request, resolve) => void)
- Remove non-existent PermissionResolver type from Exported Types table
- Fix PermissionHandler type description to match actual signature
- Add missing hooksDir option to ClineAgentOptions documentation
- Fix newSession() example to use real model IDs
- Replace developer personal path in Full Example with generic path
- Use placeholder for version in initialize() example to avoid staleness
- Expand Stop Reasons table and add note about current implementation
- Add missing key exported types: AcpSessionStatus, AcpSessionState,
  RequestPermissionRequest/Response, PermissionOption, SessionUpdatePayload,
  SessionModelState, ModelInfo, TextContent/ImageContent/AudioContent,
  SetSessionMode/Model request/response types, TranslatedMessage

* docs: improve SDK visibility and disambiguate from API code examples

- Move SDK page higher in Cline CLI nav (after Installation, before Interactive Mode)
- Add sidebarTitle 'SDK (Programmatic Use)' for clearer nav label
- Rename api/sdk-examples to 'Code Examples' to avoid naming confusion with the Cline SDK
- Update API overview card title to match

* Apply suggestions from code review

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* docs: add ClientCapabilities, Error Handling, and BYO API key docs to SDK

- Document clientCapabilities object and its effect on agent behavior
- Add Error Handling section with all throwable errors per method
- Expand BYO API key setup with concrete CLI auth examples

* docs: fix duplicated Stop Reasons table rows from code review

* docs: fix 3 accuracy issues found in source code audit

- Fix protocolVersion: was '0.9.0' (fabricated), actually 1 (number) from @agentclientprotocol/sdk
- Fix clientCapabilities: was claiming they change SDK behavior, but ClineAgent always uses standalone providers (capabilities only matter via AcpAgent stdio wrapper)
- Fix permission options: remove reject_always (never sent by agent, only allow_once/allow_always/reject_once are used)

* docs: clarify custom clineDir usage with CLI --config flag

Address PR review feedback: the BYO auth section mentioned custom
clineDir without showing how to target it from the CLI. Remove the
vague reference and add explicit --config flag documentation with
side-by-side SDK and CLI examples.

* docs: remove misleading 'by default' qualifier from SDK BYO auth section

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-19 21:01:24 -07:00
Saoud Rizwan 6308fef0a9 fix(cli): use kanban@latest to always fetch newest version (#9898)
* fix(cli): use kanban@latest to always fetch newest version

npx -y kanban may use a cached version. Using @latest ensures
users always get the most recent kanban release.

* chore(cli): bump version to 2.8.2 and update changelog
2026-03-19 19:37:53 -07:00
CandiedUniverse b3fc79b8ce Remove example hooks (#9896) 2026-03-19 18:42:55 -07:00
CandiedUniverse ff05ec3bbe Latency improvements for remote workspaces (#9858)
* Implement minimal change set for latency improvements

Cline's code review improvements

Further code review improvements

* fix: address code review issues for presentation scheduler

- Add reset() method to TaskPresentationScheduler to prevent stale
  timer leaks between API request retries within the same task
- Call presentationScheduler.reset() in streaming state reset section
- Fix hadVisibleAssistantContent to track reasoning content, not just
  text, preventing every reasoning chunk from getting immediate priority
- Remove unused 'low' priority from PresentationPriority type and
  associated cadence configuration (YAGNI)
- Trim TaskLatencyTrigger to only values actually used: text/reasoning/tool
- Add JSDoc to isRemoteWorkspaceEnvironment documenting the heuristic
  fallback and its false-positive risk
- Add Logger.warn for invalid cadence env-var overrides
- Add comment at scheduler construction explaining detection promise
  dependency on remoteWorkspaceDetectionPromise
- Add comments to CLI host bridge about intentional remoteName omission
- Expand test coverage: reset(), coalescing, priority upgrade tests

* fix: address code review issues in latency/presentation scheduler

- Fix #1/#3: Set didPresentAnyContent=true for text and tool_calls chunks
  so coalescing actually activates for text-only and tool streams (was
  only set for reasoning chunks, defeating the 40/90ms cadence goal)

- Fix #2: Re-check flushInProgress after awaiting in-flight flush in
  runFlushCycle to prevent two concurrent callers from both proceeding
  past the guard and starting concurrent flushes

- Fix #4: Document flushNow() disposed no-op contract so callers
  understand the finalization guarantee

- Fix #5: Add disposed guard to reset() to prevent post-dispose state
  mutation

- Fix #6: Remove platform/version heuristic from isRemoteWorkspaceEnvironment
  — only use remoteName. The substring match on 'remote' produced false
  positives for version strings like '1.0.0-remote-fix'. Non-VSCode hosts
  should populate remoteName explicitly to opt in to the higher cadence.

- Fix #7: Add remoteWorkspaceDetectionSettled flag and warn if getDelayMs
  is called before detection resolves, making the timing dependency
  explicit and detectable in production logs

- Fix #8: Upgrade disabled-scheduler bypass error from Logger.debug to
  Logger.warn so silent flush failures are visible

- Update latency.test.ts: replace heuristic test with false-positive
  regression tests and add null/empty-field coverage

* fix: address code review issues in latency/presentation scheduler

- Fix flushNow race condition: wait for all in-flight flushes to drain
  before setting pendingPriority so the post-flush continuation cannot
  steal it, guaranteeing at least one flush runs after flushNow() returns
- Add regression test for the flushNow race condition
- Fix disabled-path (CLINE_DISABLE_PRESENTATION_SCHEDULER): use
  presentationScheduler.flushNow() instead of a fire-and-forget void
  call so the presentAssistantMessage lock/pending-updates mechanism
  is respected when the scheduler is bypassed
- Rename didPresentAnyContent -> didScheduleAnyContent to accurately
  reflect that content has been scheduled, not necessarily flushed
- Update stale comment in streaming loop to match new variable semantics
- Upgrade remote workspace detection failure log level from debug to warn
- Document reset() interaction with in-flight flush on already-reset state
- Document empty-string remoteName edge case in getHostVersion.ts

* refactor: polish presentation scheduler for production readiness

- Extract PresentationPriority type to shared presentation-types.ts,
  breaking the latency.ts → TaskPresentationScheduler.ts type coupling
- Remove redundant pendingWhileFlushing flag from TaskPresentationScheduler;
  pendingPriority alone is sufficient for the post-flush continuation check
- Cache env var reads in latency.ts at module load (hot path optimization)
- Inline flushAssistantPresentation() one-liner into the scheduler constructor
- Make processNativeToolCalls protected to enable type-safe test access
- Remove as any cast in Task.processNativeToolCalls.test.ts

* Fixes as per Greptile review feedback

* Further fixes as per Greptile feedback

* Further fixes as per Greptile feedback
2026-03-19 17:05:49 -07:00
92 changed files with 4003 additions and 1145 deletions
-19
View File
@@ -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
-19
View File
@@ -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
-19
View File
@@ -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
-19
View File
@@ -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
-19
View File
@@ -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
View File
@@ -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.mp4 filter=lfs diff=lfs merge=lfs -text
* text=auto eol=lf
+10
View File
@@ -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.mp4"
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 }}
+9
View File
@@ -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.mp4"
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 }}
+14
View File
@@ -1,5 +1,19 @@
# Changelog
## [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
+11
View File
@@ -1,5 +1,16 @@
# 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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cline",
"version": "2.8.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",
+1 -2
View File
@@ -1492,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 -1
View File
@@ -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
+117
View File
@@ -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>
)
})
@@ -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>
)
}
@@ -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
+9 -3
View File
@@ -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>
)
}
-333
View File
@@ -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>
)
}
+3 -1
View File
@@ -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.
})
}
+56 -36
View File
@@ -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()
@@ -80,7 +96,7 @@ describe("CLI Commands", () => {
program
.command("kanban")
.description("Run npx kanban --agent cline")
.description("Run kanban")
.action(() => {})
// Default command for interactive mode
@@ -97,7 +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 npx kanban --agent cline")
.option("--kanban", "Run kanban")
.option("--tui", "Open the legacy terminal UI instead of the kanban experience")
.action(() => {})
})
@@ -114,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)
@@ -237,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")
@@ -269,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")
@@ -284,7 +301,7 @@ 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")
@@ -305,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")
@@ -354,15 +371,13 @@ describe("CLI Commands", () => {
})
it("should default mcp add type to stdio", () => {
const mcpCmd = program.commands.find((c) => c.name() === "mcp")!
const addCmd = mcpCmd.commands.find((c) => c.name() === "add")!
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 mcpCmd = program.commands.find((c) => c.name() === "mcp")!
const addCmd = mcpCmd.commands.find((c) => c.name() === "add")!
const addCmd = getSubcommand("mcp", "add")
addCmd.parse(["linear", "https://mcp.linear.app/mcp", "--type", "http"], { from: "user" })
expect(addCmd.opts().type).toBe("http")
})
@@ -423,6 +438,11 @@ describe("CLI Commands", () => {
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", () => {
@@ -437,8 +457,8 @@ describe("CLI Commands", () => {
})
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")
})
+182 -15
View File
@@ -2,7 +2,7 @@
* Cline CLI - TypeScript implementation with React Ink
*/
import { spawn } from "node:child_process"
import type { ChildProcess } from "node:child_process"
import { exit } from "node:process"
import type { ApiProvider } from "@shared/api"
import { Command } from "commander"
@@ -28,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"
@@ -35,6 +36,20 @@ 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"
@@ -59,6 +74,7 @@ interface TaskOptions {
act?: boolean
plan?: boolean
kanban?: boolean
tui?: boolean
model?: string
verbose?: boolean
cwd?: string
@@ -251,25 +267,72 @@ function getPlainTextModeReason(options: TaskOptions): string {
return getModeSelection(options).reason
}
function getNpxCommand(): string {
return process.platform === "win32" ? "npx.cmd" : "npx"
}
function runKanbanAlias(spawnOptions?: Parameters<typeof spawnKanbanProcess>[0]): void {
const launchKanban = () => {
const child = spawnKanbanProcess(spawnOptions)
activeKanbanProcess = child
function runKanbanAlias(): void {
const child = spawn(getNpxCommand(), ["-y", "kanban", "--agent", "cline"], {
stdio: "inherit",
})
child.on("error", (error) => {
clearActiveKanbanProcess()
const errorMessage = error instanceof Error ? ` ${error.message}` : ""
printWarning(`Failed to run '${KANBAN_LAUNCH_COMMAND}'.${errorMessage}`)
exit(1)
})
child.on("error", () => {
printWarning("Failed to run 'npx kanban --agent cline'. Make sure npx is installed and available in PATH.")
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)
})
child.on("close", (code) => {
exit(code ?? 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)
@@ -347,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.
@@ -402,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)
@@ -899,7 +1029,10 @@ program
.option("-v, --verbose", "Show verbose output")
.action(() => checkForUpdates(CLI_VERSION))
program.command("kanban").description("Run npx kanban --agent cline").action(runKanbanAlias)
program
.command("kanban")
.description(`Run ${KANBAN_LAUNCH_COMMAND}`)
.action(() => runKanbanAlias())
// Dev command with subcommands
const devCommand = program.command("dev").description("Developer tools and utilities")
@@ -1050,17 +1183,23 @@ 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 npx kanban --agent cline")
.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()
runKanbanAlias({ cwd: options.cwd })
return
}
@@ -1084,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)
+265
View File
@@ -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)
})
})
+246
View File
@@ -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()
}
+1 -1
View File
@@ -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>
+2 -2
View File
@@ -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."
---
+109 -15
View File
@@ -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
View File
@@ -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",
+81
View File
@@ -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 |
+120
View File
@@ -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.
+67
View File
@@ -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 --kanban
```
<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 --kanban`
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>
+64
View File
@@ -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 --kanban
```
## How It Works
1. **Run `cline --kanban`** 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.
- [npm Package](https://www.npmjs.com/package/cline) — version history and package details
<Card title="Features" icon="list-check" href="/kanban/features">
Worktrees, auto-commit, task linking, diff viewer, git interface, and more.
</Card>
</Columns>
+8 -5
View File
@@ -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.
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "claude-dev",
"version": "3.74.0",
"version": "3.75.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-dev",
"version": "3.74.0",
"version": "3.75.0",
"license": "Apache-2.0",
"workspaces": [
".",
@@ -162,7 +162,7 @@
},
"cli": {
"name": "cline",
"version": "2.8.0",
"version": "2.9.0",
"cpu": [
"x64",
"arm64"
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "claude-dev",
"displayName": "Cline",
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"version": "3.74.0",
"version": "3.75.0",
"icon": "assets/icons/icon.png",
"workspaces": [
".",
+4 -2
View File
@@ -104,8 +104,8 @@ message Secrets {
optional string oca_refresh_token = 42;
optional string mcp_o_auth_secrets = 43;
optional string cline_api_key = 44;
optional string wandb_api_key = 50;
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
@@ -280,11 +280,12 @@ message Settings {
optional bool worktrees_enabled = 172;
optional bool auto_approve_all_toggled = 174;
optional bool double_check_completion_enabled = 176;
map<string, string> open_ai_headers = 177;
optional string plan_mode_cline_model_id = 178;
optional OpenRouterModelInfo plan_mode_cline_model_info = 179;
optional string act_mode_cline_model_id = 180;
optional OpenRouterModelInfo act_mode_cline_model_info = 181;
map<string, string> open_ai_headers = 177;
optional bool show_feature_tips = 182;
}
message State {
@@ -426,6 +427,7 @@ 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;
}
message UpdateTerminalConnectionTimeoutRequest {
+3
View File
@@ -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 {
@@ -60,6 +60,54 @@ 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
+8 -3
View File
@@ -154,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),
@@ -185,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),
+32 -65
View File
@@ -52,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
+7 -30
View File
@@ -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
+2
View File
@@ -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,7 @@ export class Controller {
backgroundEditEnabled: this.stateManager.getGlobalSettingsKey("backgroundEditEnabled"),
optOutOfRemoteConfig: this.stateManager.getGlobalSettingsKey("optOutOfRemoteConfig"),
doubleCheckCompletionEnabled,
showFeatureTips,
banners,
welcomeBanners,
openAiCodexIsAuthenticated,
@@ -344,6 +344,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()
+62 -59
View File
@@ -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")
})
})
})
+129 -145
View File
@@ -3,8 +3,9 @@ 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, stubHookDirs, writeHookScriptForPlatform } from "./test-utils"
import { createHookTestEnv, HookTestEnv, stubHookDirs, withFixtureRunner, writeHookScriptForPlatform } from "./test-utils"
describe("TaskResume Hook", () => {
let tempDir: string
@@ -12,6 +13,16 @@ describe("TaskResume Hook", () => {
let hookTestEnv: HookTestEnv
const WINDOWS_HOOK_TEST_TIMEOUT_MS = 15000
type FixtureScenario = {
fixtureName: string
lastMessageTs: string
messageCount: string
conversationHistoryDeleted: string
assert: (result: HookOutput) => void
}
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)
}
@@ -146,7 +157,11 @@ console.log(JSON.stringify({
}
})
it("should handle very old timestamps (days ago)", async () => {
it("should handle very old timestamps (days ago)", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskResume")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
@@ -327,7 +342,11 @@ console.log(JSON.stringify({
result.contextModification?.should.equal("STALE_TASK: Long conversation paused for extended time")
})
it("should combine context deletion with other state", async () => {
it("should combine context deletion with other state", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskResume")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
@@ -537,149 +556,114 @@ console.log(JSON.stringify({
})
describe("Fixture-Based Tests", () => {
const loadFixtureAndCreateRunner = async (fixtureName: string) => {
const { loadFixture } = await import("./test-utils")
await loadFixture(`hooks/taskresume/${fixtureName}`, tempDir)
const factory = new HookFactory()
return await factory.create("TaskResume")
}
it("should work with success fixture", async () => {
const runner = await loadFixtureAndCreateRunner("success")
const result = await runner.run({
taskId: "test-task",
taskResume: {
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
previousState: {
lastMessageTs: Date.now().toString(),
messageCount: "5",
conversationHistoryDeleted: "false",
},
},
})
result.cancel.should.be.false()
result.contextModification?.should.equal("TaskResume hook executed successfully")
})
it("should work with recent-resume fixture", async () => {
const runner = await loadFixtureAndCreateRunner("recent-resume")
const twoMinutesAgo = Date.now() - 2 * 60 * 1000
const result = await runner.run({
taskId: "test-task",
taskResume: {
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
previousState: {
lastMessageTs: twoMinutesAgo.toString(),
messageCount: "5",
conversationHistoryDeleted: "false",
},
},
})
result.cancel.should.be.false()
result.contextModification?.should.match(/Recently paused task/)
})
it("should work with long-pause fixture", async () => {
const runner = await loadFixtureAndCreateRunner("long-pause")
const twoDaysAgo = Date.now() - 48 * 60 * 60 * 1000
const result = await runner.run({
taskId: "test-task",
taskResume: {
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
previousState: {
lastMessageTs: twoDaysAgo.toString(),
messageCount: "5",
conversationHistoryDeleted: "false",
},
},
})
result.cancel.should.be.false()
result.contextModification?.should.match(/paused 48 hours ago/)
})
it("should work with context-deleted fixture", async () => {
const runner = await loadFixtureAndCreateRunner("context-deleted")
const result = await runner.run({
taskId: "test-task",
taskResume: {
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
previousState: {
lastMessageTs: Date.now().toString(),
messageCount: "50",
conversationHistoryDeleted: "true",
},
},
})
result.cancel.should.be.false()
result.contextModification?.should.match(/truncated/)
})
it("should work with message-count fixture", async () => {
const runner = await loadFixtureAndCreateRunner("message-count")
const result = await runner.run({
taskId: "test-task",
taskResume: {
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
previousState: {
lastMessageTs: Date.now().toString(),
messageCount: "25",
conversationHistoryDeleted: "false",
},
},
})
result.cancel.should.be.false()
result.contextModification?.should.equal("TASK_CONTEXT: Resuming task with 25 previous messages")
})
it("should work with context-injection fixture", async () => {
const runner = await loadFixtureAndCreateRunner("context-injection")
const result = await runner.run({
taskId: "test-task",
taskResume: {
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
previousState: {
lastMessageTs: Date.now().toString(),
messageCount: "5",
conversationHistoryDeleted: "false",
},
},
})
result.cancel.should.be.false()
result.contextModification?.should.equal("WORKSPACE_RULES: Task test-task resumed - review previous context")
})
it("should work with error fixture", async () => {
const runner = await loadFixtureAndCreateRunner("error")
try {
await runner.run({
taskId: "test-task",
taskResume: {
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
previousState: {
lastMessageTs: Date.now().toString(),
messageCount: "5",
conversationHistoryDeleted: "false",
},
},
})
throw new Error("Should have thrown")
} catch (error: any) {
error.message.should.match(/exited with code 1/)
it("should validate representative fixtures end-to-end", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
const scenarios: FixtureScenario[] = [
{
fixtureName: "success",
lastMessageTs: Date.now().toString(),
messageCount: "5",
conversationHistoryDeleted: "false",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.equal("TaskResume hook executed successfully")
},
},
{
fixtureName: "recent-resume",
lastMessageTs: (Date.now() - 2 * 60 * 1000).toString(),
messageCount: "5",
conversationHistoryDeleted: "false",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.match(/Recently paused task/)
},
},
{
fixtureName: "long-pause",
lastMessageTs: (Date.now() - 48 * 60 * 60 * 1000).toString(),
messageCount: "5",
conversationHistoryDeleted: "false",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.match(/paused 48 hours ago/)
},
},
{
fixtureName: "context-deleted",
lastMessageTs: Date.now().toString(),
messageCount: "50",
conversationHistoryDeleted: "true",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.match(/truncated/)
},
},
{
fixtureName: "message-count",
lastMessageTs: Date.now().toString(),
messageCount: "25",
conversationHistoryDeleted: "false",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.equal("TASK_CONTEXT: Resuming task with 25 previous messages")
},
},
{
fixtureName: "context-injection",
lastMessageTs: Date.now().toString(),
messageCount: "5",
conversationHistoryDeleted: "false",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.equal(
"WORKSPACE_RULES: Task test-task resumed - review previous context",
)
},
},
]
for (const scenario of scenarios) {
await withFixtureRunner("TaskResume", `hooks/taskresume/${scenario.fixtureName}`, hookTestEnv, async (runner) => {
const result = await runner.run({
taskId: "test-task",
taskResume: {
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
previousState: {
lastMessageTs: scenario.lastMessageTs,
messageCount: scenario.messageCount,
conversationHistoryDeleted: scenario.conversationHistoryDeleted,
},
},
})
scenario.assert(result)
})
}
})
it("should preserve fixture-based failure behavior", async () => {
await withFixtureRunner("TaskResume", "hooks/taskresume/error", hookTestEnv, async (runner) => {
try {
await runner.run({
taskId: "test-task",
taskResume: {
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
previousState: {
lastMessageTs: Date.now().toString(),
messageCount: "5",
conversationHistoryDeleted: "false",
},
},
})
throw new Error("Should have thrown")
} catch (error: unknown) {
getErrorMessage(error).should.match(/exited with code 1/)
}
})
})
})
})
+55 -57
View File
@@ -3,14 +3,17 @@ 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("TaskStart Hook", () => {
let tempDir: string
let sandbox: sinon.SinonSandbox
let getEnv: () => { tempDir: string }
let hookTestEnv: HookTestEnv
const WINDOWS_HOOK_TEST_TIMEOUT_MS = 15000
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)
@@ -29,7 +32,11 @@ describe("TaskStart Hook", () => {
})
describe("Hook Input Format", () => {
it("should receive task metadata from startTask", async () => {
it("should receive task metadata from startTask", async function () {
if (process.platform === "win32") {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskStart")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
@@ -421,69 +428,60 @@ console.log(JSON.stringify({
})
describe("Fixture-Based Tests", () => {
it("should work with success fixture", async () => {
await loadFixture("hooks/taskstart/success", getEnv().tempDir)
const factory = new HookFactory()
const runner = await factory.create("TaskStart")
const result = await runner.run({
taskId: "test-task-id",
taskStart: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
initialTask: "Test task",
it("should validate representative fixtures end-to-end", async () => {
const scenarios: Array<{ fixtureName: string; assert: (result: HookOutput) => void }> = [
{
fixtureName: "success",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.equal("TaskStart hook executed successfully")
},
},
})
result.cancel.should.be.false()
result.contextModification?.should.equal("TaskStart hook executed successfully")
})
it("should work with blocking fixture", async () => {
await loadFixture("hooks/taskstart/blocking", getEnv().tempDir)
const factory = new HookFactory()
const runner = await factory.create("TaskStart")
const result = await runner.run({
taskId: "test-task-id",
taskStart: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
initialTask: "Test task",
{
fixtureName: "blocking",
assert: (result: HookOutput) => {
result.cancel.should.be.true()
result.errorMessage?.should.equal("Task execution blocked by hook")
},
},
})
]
result.cancel.should.be.true()
result.errorMessage?.should.equal("Task execution blocked by hook")
})
it("should work with error fixture", async () => {
await loadFixture("hooks/taskstart/error", getEnv().tempDir)
const factory = new HookFactory()
const runner = await factory.create("TaskStart")
try {
await runner.run({
taskId: "test-task-id",
taskStart: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
initialTask: "Test task",
for (const scenario of scenarios) {
await withFixtureRunner("TaskStart", `hooks/taskstart/${scenario.fixtureName}`, hookTestEnv, async (runner) => {
const result = await runner.run({
taskId: "test-task-id",
taskStart: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
initialTask: "Test task",
},
},
},
})
scenario.assert(result)
})
throw new Error("Should have thrown")
} catch (error: any) {
error.message.should.match(/TaskStart.*exited with code 1/)
}
})
it("should preserve fixture-based failure behavior", async () => {
await withFixtureRunner("TaskStart", "hooks/taskstart/error", hookTestEnv, async (runner) => {
try {
await runner.run({
taskId: "test-task-id",
taskStart: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
initialTask: "Test task",
},
},
})
throw new Error("Should have thrown")
} catch (error: unknown) {
getErrorMessage(error).should.match(/TaskStart.*exited with code 1/)
}
})
})
})
})
+47 -1
View File
@@ -7,7 +7,7 @@ import { HookOutput } from "../../../shared/proto/cline/hooks"
import * as diskModule from "../../storage/disk"
import { StateManager } from "../../storage/StateManager"
import { HookDiscoveryCache } from "../HookDiscoveryCache"
import { Hooks, NamedHookInput } from "../hook-factory"
import { HookFactory, Hooks, NamedHookInput } from "../hook-factory"
// Define HookName locally since it's not exported from hook-factory
type HookName = keyof Hooks
@@ -576,3 +576,49 @@ export async function loadFixture(fixtureName: string, destDir: string): Promise
}
}
}
/**
* Creates an isolated hook test environment, loads a fixture into it, creates a runner,
* and guarantees cleanup once the callback completes.
*
* This is useful for fixture suites that want to iterate through multiple scenarios
* without sharing hook directories, discovery cache state, or filesystem artifacts
* between scenarios.
*/
export async function withFixtureRunner<Name extends HookName, TResult>(
hookName: Name,
fixtureName: string,
callback: (runner: Awaited<ReturnType<HookFactory["create"]>>, env: HookTestEnv) => Promise<TResult>,
): Promise<TResult>
export async function withFixtureRunner<Name extends HookName, TResult>(
hookName: Name,
fixtureName: string,
env: HookTestEnv,
callback: (runner: Awaited<ReturnType<HookFactory["create"]>>, env: HookTestEnv) => Promise<TResult>,
): Promise<TResult>
export async function withFixtureRunner<Name extends HookName, TResult>(
hookName: Name,
fixtureName: string,
envOrCallback: HookTestEnv | ((runner: Awaited<ReturnType<HookFactory["create"]>>, env: HookTestEnv) => Promise<TResult>),
maybeCallback?: (runner: Awaited<ReturnType<HookFactory["create"]>>, env: HookTestEnv) => Promise<TResult>,
): Promise<TResult> {
const usingExistingEnv = typeof envOrCallback !== "function"
const env = usingExistingEnv ? envOrCallback : await createHookTestEnv()
const runCallback = usingExistingEnv ? maybeCallback : envOrCallback
if (!runCallback) {
throw new Error("withFixtureRunner requires a callback")
}
try {
await fs.rm(env.hooksDir, { recursive: true, force: true })
await createHooksDirectory(env.tempDir)
resetHookCache()
await loadFixture(fixtureName, env.tempDir)
const factory = new HookFactory()
const runner = await factory.create(hookName)
return await runCallback(runner, env)
} finally {
if (!usingExistingEnv) {
await env.cleanup()
}
}
}
@@ -3,13 +3,23 @@ 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, stubHookDirs, writeHookScriptForPlatform } from "./test-utils"
import { createHookTestEnv, HookTestEnv, stubHookDirs, withFixtureRunner, writeHookScriptForPlatform } from "./test-utils"
describe("UserPromptSubmit Hook", () => {
let tempDir: string
let sandbox: sinon.SinonSandbox
let hookTestEnv: HookTestEnv
const WINDOWS_HOOK_TEST_TIMEOUT_MS = 15000
type FixtureScenario = {
fixtureName: string
prompt: string
assert: (result: HookOutput) => void
}
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)
@@ -245,8 +255,8 @@ process.exit(1)`
},
})
throw new Error("Should have thrown")
} catch (error: any) {
error.message.should.match(/exited with code 1/)
} catch (error: unknown) {
getErrorMessage(error).should.match(/exited with code 1/)
}
})
})
@@ -358,161 +368,127 @@ console.log(JSON.stringify({
describe("Fixture-Based Tests", () => {
// These tests demonstrate using pre-written fixtures from the fixtures directory
// Fixtures serve as both test data and examples for manual testing
const isWindows = process.platform === "win32"
// Helper to load a fixture and create a runner
const loadFixtureAndCreateRunner = async (fixtureName: string) => {
const { loadFixture } = await import("./test-utils")
await loadFixture(`hooks/userpromptsubmit/${fixtureName}`, tempDir)
it("should validate representative fixtures end-to-end", async function () {
if (isWindows) {
this.timeout(WINDOWS_HOOK_TEST_TIMEOUT_MS)
}
const factory = new HookFactory()
return await factory.create("UserPromptSubmit")
}
it("should work with success fixture", async () => {
const runner = await loadFixtureAndCreateRunner("success")
const result = await runner.run({
taskId: "test-task",
userPromptSubmit: {
const scenarios: FixtureScenario[] = [
{
fixtureName: "success",
prompt: "Create a feature",
attachments: [],
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.equal("Prompt approved")
},
},
})
result.cancel.should.be.false()
result.contextModification?.should.equal("Prompt approved")
})
it("should work with blocking fixture", async () => {
const runner = await loadFixtureAndCreateRunner("blocking")
const result = await runner.run({
taskId: "test-task",
userPromptSubmit: {
{
fixtureName: "blocking",
prompt: "Do something forbidden",
attachments: [],
assert: (result: HookOutput) => {
result.cancel.should.be.true()
result.errorMessage?.should.equal("Prompt violates policy")
},
},
})
result.cancel.should.be.true()
result.errorMessage?.should.equal("Prompt violates policy")
})
it("should work with context-injection fixture", async () => {
const runner = await loadFixtureAndCreateRunner("context-injection")
const result = await runner.run({
taskId: "test-task",
userPromptSubmit: {
{
fixtureName: "context-injection",
prompt: "Build something",
attachments: [],
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.equal("CONTEXT_INJECTION: User is in plan mode")
},
},
})
{
fixtureName: "multiline",
prompt: "Line 1\nLine 2\nLine 3",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.equal("Line count: 3")
},
},
{
fixtureName: "special-chars",
prompt: "Test @user #feature $cost",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.equal("Special chars preserved")
},
},
{
fixtureName: "empty-prompt",
prompt: "",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.equal("Prompt length: 0")
},
},
]
result.cancel.should.be.false()
result.contextModification?.should.equal("CONTEXT_INJECTION: User is in plan mode")
if (!isWindows) {
scenarios.push({
fixtureName: "large-prompt",
prompt: "x".repeat(10000),
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.equal("Prompt size: 10000")
},
})
}
for (const scenario of scenarios) {
await withFixtureRunner(
"UserPromptSubmit",
`hooks/userpromptsubmit/${scenario.fixtureName}`,
hookTestEnv,
async (runner) => {
const result = await runner.run({
taskId: "test-task",
userPromptSubmit: {
prompt: scenario.prompt,
attachments: [],
},
})
scenario.assert(result)
},
)
}
})
it("should work with error fixture", async () => {
const runner = await loadFixtureAndCreateRunner("error")
try {
await runner.run({
it("should cover malformed-json fixture path", async () => {
await withFixtureRunner("UserPromptSubmit", "hooks/userpromptsubmit/malformed-json", hookTestEnv, async (runner) => {
const malformedResult = await runner.run({
taskId: "test-task",
userPromptSubmit: {
prompt: "Test",
attachments: [],
},
})
throw new Error("Should have thrown")
} catch (error: any) {
error.message.should.match(/exited with code 1/)
}
malformedResult.cancel.should.be.false()
;(
malformedResult.contextModification === undefined || malformedResult.contextModification === ""
).should.be.true()
})
})
it("should work with malformed-json fixture", async () => {
const runner = await loadFixtureAndCreateRunner("malformed-json")
// When hook exits 0 but has malformed JSON, it returns success without context
const result = await runner.run({
taskId: "test-task",
userPromptSubmit: {
prompt: "Test",
attachments: [],
},
it("should cover failing fixture path", async () => {
await withFixtureRunner("UserPromptSubmit", "hooks/userpromptsubmit/error", hookTestEnv, async (runner) => {
try {
await runner.run({
taskId: "test-task",
userPromptSubmit: {
prompt: "Test",
attachments: [],
},
})
throw new Error("Should have thrown")
} catch (error: unknown) {
getErrorMessage(error).should.match(/exited with code 1/)
}
})
// Hook succeeded (exit 0) but couldn't parse JSON, so returns success without context
result.cancel.should.be.false()
;(result.contextModification === undefined || result.contextModification === "").should.be.true()
})
it("should work with multiline fixture", async () => {
const runner = await loadFixtureAndCreateRunner("multiline")
const result = await runner.run({
taskId: "test-task",
userPromptSubmit: {
prompt: "Line 1\nLine 2\nLine 3",
attachments: [],
},
})
result.cancel.should.be.false()
result.contextModification?.should.equal("Line count: 3")
})
it("should work with large-prompt fixture", async function () {
// On Windows this fixture path duplicates coverage from
// "should handle large prompts" and can be timing-sensitive due to
// PowerShell process startup in CI.
if (process.platform === "win32") {
this.skip()
}
const runner = await loadFixtureAndCreateRunner("large-prompt")
const largePrompt = "x".repeat(10000)
const result = await runner.run({
taskId: "test-task",
userPromptSubmit: {
prompt: largePrompt,
attachments: [],
},
})
result.cancel.should.be.false()
result.contextModification?.should.equal("Prompt size: 10000")
})
it("should work with special-chars fixture", async () => {
const runner = await loadFixtureAndCreateRunner("special-chars")
const result = await runner.run({
taskId: "test-task",
userPromptSubmit: {
prompt: "Test @user #feature $cost",
attachments: [],
},
})
result.cancel.should.be.false()
result.contextModification?.should.equal("Special chars preserved")
})
it("should work with empty-prompt fixture", async () => {
const runner = await loadFixtureAndCreateRunner("empty-prompt")
const result = await runner.run({
taskId: "test-task",
userPromptSubmit: {
prompt: "",
attachments: [],
},
})
result.cancel.should.be.false()
result.contextModification?.should.equal("Prompt length: 0")
})
})
})
@@ -0,0 +1,67 @@
import { describe, it } from "mocha"
import "should"
import { formatResponse } from "../responses"
describe("formatResponse.replaceInFileMissingDiffError", () => {
it("should include the file path in the error message", () => {
const result = formatResponse.replaceInFileMissingDiffError("src/index.ts")
result.should.containEql("src/index.ts")
})
it("should mention that the diff parameter was empty", () => {
const result = formatResponse.replaceInFileMissingDiffError("src/index.ts")
result.should.containEql("'diff' parameter was empty")
})
it("should include the SEARCH/REPLACE block format", () => {
const result = formatResponse.replaceInFileMissingDiffError("src/index.ts")
result.should.containEql("<<<<<<< SEARCH")
result.should.containEql("=======")
result.should.containEql(">>>>>>> REPLACE")
})
it("should include rules about exact matching", () => {
const result = formatResponse.replaceInFileMissingDiffError("src/index.ts")
result.should.containEql("match existing file content exactly")
})
it("should suggest using read_file if unsure", () => {
const result = formatResponse.replaceInFileMissingDiffError("src/index.ts")
result.should.containEql("read_file")
})
it("should NOT include the generic toolUseInstructionsReminder", () => {
const result = formatResponse.replaceInFileMissingDiffError("src/index.ts")
result.should.not.containEql("Reminder: Instructions for Tool Use")
})
it("should work with different file paths", () => {
const result = formatResponse.replaceInFileMissingDiffError("components/App.tsx")
result.should.containEql("components/App.tsx")
})
})
describe("formatResponse.executeCommandMissingCommandError", () => {
it("should mention that the command parameter was empty", () => {
const result = formatResponse.executeCommandMissingCommandError()
result.should.containEql("'command' parameter was empty")
})
it("should include a concrete XML example", () => {
const result = formatResponse.executeCommandMissingCommandError()
result.should.containEql("<execute_command>")
result.should.containEql("<command>")
result.should.containEql("</command>")
result.should.containEql("</execute_command>")
})
it("should include requires_approval in the example", () => {
const result = formatResponse.executeCommandMissingCommandError()
result.should.containEql("<requires_approval>")
})
it("should NOT include the generic toolUseInstructionsReminder", () => {
const result = formatResponse.executeCommandMissingCommandError()
result.should.not.containEql("Reminder: Instructions for Tool Use")
})
})
+30
View File
@@ -96,6 +96,33 @@ Otherwise, if you have not completed the task and do not need additional informa
)
},
replaceInFileMissingDiffError: (relPath: string): string => {
return (
`Failed to edit '${relPath}': The 'diff' parameter was empty.\n\n` +
`The diff parameter must contain SEARCH/REPLACE blocks in this format:\n` +
"<<<<<<< SEARCH\n" +
"exact lines to find\n" +
"=======\n" +
"replacement lines\n" +
">>>>>>> REPLACE\n\n" +
`Rules:\n` +
`- The SEARCH block must match existing file content exactly (including whitespace and indentation)\n` +
`- You can include multiple SEARCH/REPLACE blocks in a single diff parameter\n` +
`- If you're unsure of the exact content, use read_file first to see the current file`
)
},
executeCommandMissingCommandError: (): string => {
return (
"The 'command' parameter was empty. Provide the shell command to execute.\n\n" +
"Example:\n" +
"<execute_command>\n" +
"<command>cd /path && python -m pytest tests/</command>\n" +
"<requires_approval>false</requires_approval>\n" +
"</execute_command>"
)
},
invalidMcpToolArgumentError: (serverName: string, toolName: string) =>
`Invalid JSON argument used with ${serverName} for ${toolName}. Please retry with a properly formatted JSON argument.`,
@@ -279,6 +306,9 @@ Otherwise, if you have not completed the task and do not need additional informa
toolAlreadyUsed: (toolName: string) =>
`Tool [${toolName}] was not executed because a tool has already been used in this message. Only one tool may be used per message. You must assess the first tool's result before proceeding to use the next tool.`,
repeatedToolCall: (toolName: string, count: number) =>
`Tool [${toolName}] has been called ${count} times consecutively with identical arguments. This is not making progress. Please use a different tool or different arguments instead of repeating the same call.`,
clineIgnoreInstructions: (content: string) =>
`# .clineignore\n\n(The following is provided by a root-level .clineignore file where the user has specified files and directories that should not be accessed. When using list_files, you'll notice a ${LOCK_TEXT_SYMBOL} next to files that are blocked. Attempting to access the file's contents e.g. through read_file will result in an error.)\n\n${content}\n.clineignore`,
+253
View File
@@ -0,0 +1,253 @@
import type { PresentationPriority } from "./presentation-types"
export type { PresentationPriority }
type TaskPresentationSchedulerOptions = {
flush: () => Promise<void>
getDelayMs: (priority: PresentationPriority) => number
setTimeoutFn?: typeof setTimeout
clearTimeoutFn?: typeof clearTimeout
onFlushError?: (error: unknown) => void
}
export class TaskPresentationScheduler {
private scheduledTimer: ReturnType<typeof setTimeout> | undefined
private scheduledPriority: PresentationPriority | undefined
private pendingPriority: PresentationPriority | undefined
private flushInProgress = false
private currentFlushCompletion: Promise<{ error?: unknown }> | undefined
private disposed = false
private readonly flush: () => Promise<void>
private readonly getDelayMs: (priority: PresentationPriority) => number
private readonly setTimeoutFn: typeof setTimeout
private readonly clearTimeoutFn: typeof clearTimeout
private readonly onFlushError?: (error: unknown) => void
constructor(options: TaskPresentationSchedulerOptions) {
this.flush = options.flush
this.getDelayMs = options.getDelayMs
this.setTimeoutFn = options.setTimeoutFn ?? setTimeout
this.clearTimeoutFn = options.clearTimeoutFn ?? clearTimeout
this.onFlushError = options.onFlushError
}
requestFlush(priority: PresentationPriority = "normal"): void {
if (this.disposed) {
return
}
this.pendingPriority = this.mergePriority(this.pendingPriority, priority)
if (this.flushInProgress) {
// pendingPriority is already set above; runFlushCycle's post-flush
// continuation will pick it up after the in-flight flush completes.
return
}
if (this.pendingPriority === "immediate") {
if (this.scheduledTimer) {
this.clearTimeoutFn(this.scheduledTimer)
this.scheduledTimer = undefined
this.scheduledPriority = undefined
}
void this.runFlushCycle({ rethrowErrors: false })
return
}
const nextPriority = this.pendingPriority ?? "normal"
if (this.scheduledTimer) {
if (this.scheduledPriority === nextPriority) {
return
}
this.clearTimeoutFn(this.scheduledTimer)
this.scheduledTimer = undefined
this.scheduledPriority = undefined
}
if (!this.pendingPriority) {
return
}
const delayMs = this.getDelayMs(nextPriority)
this.scheduledPriority = nextPriority
this.scheduledTimer = this.setTimeoutFn(() => {
this.scheduledTimer = undefined
this.scheduledPriority = undefined
void this.runFlushCycle({ rethrowErrors: false })
}, delayMs)
}
/**
* Flush immediately and await completion.
*
* Guarantees that at least one flush runs at "immediate" priority after this
* call returns, even if a concurrent flush cycle consumed the pending priority
* before this call could start its own cycle.
*
* If the scheduler has already been disposed this is a no-op and resolves
* without error. Callers that need a guarantee that the final presentation
* was delivered should ensure `dispose()` has not been called before
* invoking `flushNow()` (the task streaming finalization path does this
* correctly because `dispose()` is only called during `abortTask()`).
*/
async flushNow(): Promise<void> {
if (this.disposed) {
return
}
if (this.scheduledTimer) {
this.clearTimeoutFn(this.scheduledTimer)
this.scheduledTimer = undefined
this.scheduledPriority = undefined
}
// If a flush is already in-flight, wait for it to complete. After it
// finishes, the post-flush continuation in runFlushCycle may have already
// consumed our pendingPriority. We therefore set pendingPriority *after*
// the in-flight flush resolves so it cannot be stolen by the continuation.
if (this.flushInProgress) {
await (this.currentFlushCompletion ?? Promise.resolve())
// Another concurrent caller may have started a new flush cycle after
// the same in-flight flush resolved. If one is now in progress, wait
// for it too — we need a flush to run *after* we set pendingPriority.
while (this.flushInProgress) {
await (this.currentFlushCompletion ?? Promise.resolve())
}
}
if (this.disposed) {
return
}
// Now that no flush is in-flight, set pendingPriority and run our own cycle.
this.pendingPriority = this.mergePriority(this.pendingPriority, "immediate")
await this.runFlushCycle({ rethrowErrors: true })
}
/**
* Cancel any pending timers and clear queued state without marking the scheduler
* as disposed. Use this between API request retries within the same task to prevent
* stale timers from firing against reset streaming state.
*
* Note: any flush that is already in-flight when reset() is called will complete
* naturally. The flush callback (presentAssistantMessage) will operate on the
* already-reset task state, but since currentStreamingContentIndex will be 0 and
* assistantMessageContent will be empty, it will hit the out-of-bounds early-return
* path and do nothing harmful.
*/
reset(): void {
if (this.disposed) {
return
}
if (this.scheduledTimer) {
this.clearTimeoutFn(this.scheduledTimer)
this.scheduledTimer = undefined
}
this.scheduledPriority = undefined
this.pendingPriority = undefined
// Note: we intentionally do NOT clear flushInProgress or currentFlushCompletion
// here. If a flush is in-flight it will complete naturally. The reset only
// prevents *new* timer-driven flushes from firing on stale state.
}
async dispose(): Promise<void> {
this.disposed = true
if (this.scheduledTimer) {
this.clearTimeoutFn(this.scheduledTimer)
this.scheduledTimer = undefined
}
this.scheduledPriority = undefined
this.pendingPriority = undefined
const inFlightFlush = this.currentFlushCompletion
if (inFlightFlush) {
await inFlightFlush
}
}
private async runFlushCycle(options: { rethrowErrors: boolean }): Promise<void> {
if (this.disposed) {
return
}
while (true) {
if (this.flushInProgress) {
// flushNow() handles the in-flight case itself before calling runFlushCycle,
// so this branch is only reached from requestFlush() (which returns early when
// flushInProgress is true) — meaning this path should not be hit in practice.
// Guard it defensively anyway.
const inFlightResult = await this.currentFlushCompletion
if (options.rethrowErrors && inFlightResult?.error) {
throw inFlightResult.error
}
// Re-check flushInProgress: another concurrent caller may have already
// started a new flush cycle after the same in-flight flush resolved.
// Without this guard both callers would proceed past the pendingPriority
// check and start concurrent flushes against the same presentation state.
if (this.flushInProgress || this.disposed || !this.pendingPriority) {
return
}
}
if (!this.pendingPriority) {
return
}
this.flushInProgress = true
this.pendingPriority = undefined
this.currentFlushCompletion = (async () => {
try {
await this.flush()
return {}
} catch (error) {
this.onFlushError?.(error)
return { error }
} finally {
this.flushInProgress = false
}
})()
const result = await this.currentFlushCompletion
this.currentFlushCompletion = undefined
if (result.error && options.rethrowErrors) {
throw result.error
}
if (this.disposed) {
return
}
const priorityToRun = this.pendingPriority
if (!priorityToRun) {
return
}
if (priorityToRun !== "immediate") {
this.requestFlush(priorityToRun)
return
}
// Continue the loop synchronously for immediate follow-up work. Because
// there is no await between clearing currentFlushCompletion above and
// re-entering the loop here, no other caller can observe an interleaved
// "idle" state before the immediate flush is started.
}
}
private mergePriority(current: PresentationPriority | undefined, next: PresentationPriority): PresentationPriority {
if (!current) {
return next
}
const rank: Record<PresentationPriority, number> = {
normal: 0,
immediate: 1,
}
return rank[next] > rank[current] ? next : current
}
}
+2
View File
@@ -44,6 +44,8 @@ export class TaskState {
didAlreadyUseTool = false
didEditFile = false
lastToolName = "" // Track last tool used for consecutive call detection
lastToolParams = "" // Canonical signature of last tool's params (via toolCallSignature)
consecutiveIdenticalToolCount = 0 // Consecutive calls with identical tool name + params
// File read deduplication cache - prevents the model from endlessly reading the same files
// Maps absolute file path → { readCount: times read in this task, mtime: last modified timestamp, imageBlock: optional image data for multimodal models }
+20 -1
View File
@@ -20,6 +20,7 @@ import { formatResponse } from "../prompts/responses"
import { StateManager } from "../storage/StateManager"
import { WorkspaceRootManager } from "../workspace"
import { ToolResponse } from "."
import { checkRepeatedToolCall, LOOP_DETECTION_SOFT_THRESHOLD, toolCallSignature } from "./loop-detection"
import { MessageStateHandler } from "./message-state"
import { TaskState } from "./TaskState"
import { AutoApprove } from "./tools/autoApprove"
@@ -575,8 +576,26 @@ export class ToolExecutor {
toolWasExecuted = true
this.pushToolResult(toolResult, block)
// Track the last executed tool for consecutive call detection (used by act_mode_respond)
// --- Repeated tool call loop detection ---
// Must run BEFORE updating lastToolName/lastToolParams so we compare
// against the previous call's values, not the current one.
const currentSignature = toolCallSignature(block.params)
const loopCheck = checkRepeatedToolCall(this.taskState, block.name, currentSignature)
if (loopCheck.softWarning) {
this.taskState.userMessageContent.push({
type: "text",
text: formatResponse.repeatedToolCall(block.name, LOOP_DETECTION_SOFT_THRESHOLD),
})
}
if (loopCheck.hardEscalation) {
this.taskState.consecutiveMistakeCount = this.stateManager.getGlobalSettingsKey("maxConsecutiveMistakes")
}
// Update state AFTER comparison
this.taskState.lastToolName = block.name
this.taskState.lastToolParams = currentSignature
// Check abort before running PostToolUse hook (success path)
if (this.taskState.abort) {
+194
View File
@@ -0,0 +1,194 @@
import { strict as assert } from "node:assert"
import { Task } from "@core/task"
import type { ClineMessage } from "@shared/ExtensionMessage"
import { describe, it } from "mocha"
import sinon from "sinon"
async function flushMicrotasks(iterations = 5) {
for (let i = 0; i < iterations; i++) {
await Promise.resolve()
}
}
function createFakeTask(taskState: {
abort: boolean
askResponse: string | undefined
askResponseText: string | undefined
askResponseImages: string[] | undefined
askResponseFiles: string[] | undefined
lastMessageTs: number | undefined
}) {
const clineMessages: ClineMessage[] = []
const fakeTask = {
taskState,
messageStateHandler: {
addToClineMessages: async (message: ClineMessage) => {
clineMessages.push(message)
},
getClineMessages: () => clineMessages,
},
postStateToWebview: async () => undefined,
runNotificationHook: async () => undefined,
}
return { clineMessages, fakeTask }
}
describe("Task.ask", () => {
it("keeps resume asks waiting for a user response even when the task is aborted", async () => {
const clock = sinon.useFakeTimers()
const taskState: {
abort: boolean
askResponse: string | undefined
askResponseText: string | undefined
askResponseImages: string[] | undefined
askResponseFiles: string[] | undefined
lastMessageTs: number | undefined
} = {
abort: true,
askResponse: undefined,
askResponseText: undefined,
askResponseImages: undefined,
askResponseFiles: undefined,
lastMessageTs: undefined,
}
const { clineMessages, fakeTask } = createFakeTask(taskState)
try {
const askPromise = (
Task.prototype as unknown as {
ask: (type: "resume_task") => Promise<{ response: string; text?: string }>
}
).ask.call(fakeTask, "resume_task")
let settled = false
void askPromise.then(
() => {
settled = true
},
() => {
settled = true
},
)
await flushMicrotasks()
assert.equal(clineMessages.length, 1)
assert.equal(clineMessages[0].ask, "resume_task")
assert.notEqual(taskState.lastMessageTs, undefined)
await clock.tickAsync(1_000)
assert.equal(settled, false)
assert.equal(taskState.askResponse, undefined)
taskState.askResponse = "yesButtonClicked"
taskState.askResponseText = "resume"
await clock.tickAsync(100)
const result = await askPromise
assert.equal(result.response, "yesButtonClicked")
assert.equal(result.text, "resume")
} finally {
clock.restore()
}
})
it("keeps resume-completed asks waiting for a user response even when the task is aborted", async () => {
const clock = sinon.useFakeTimers()
const taskState: {
abort: boolean
askResponse: string | undefined
askResponseText: string | undefined
askResponseImages: string[] | undefined
askResponseFiles: string[] | undefined
lastMessageTs: number | undefined
} = {
abort: true,
askResponse: undefined,
askResponseText: undefined,
askResponseImages: undefined,
askResponseFiles: undefined,
lastMessageTs: undefined,
}
const { clineMessages, fakeTask } = createFakeTask(taskState)
try {
const askPromise = (
Task.prototype as unknown as {
ask: (type: "resume_completed_task") => Promise<{ response: string; text?: string }>
}
).ask.call(fakeTask, "resume_completed_task")
let settled = false
void askPromise.then(
() => {
settled = true
},
() => {
settled = true
},
)
await flushMicrotasks()
assert.equal(clineMessages.length, 1)
assert.equal(clineMessages[0].ask, "resume_completed_task")
assert.notEqual(taskState.lastMessageTs, undefined)
await clock.tickAsync(1_000)
assert.equal(settled, false)
assert.equal(taskState.askResponse, undefined)
taskState.askResponse = "yesButtonClicked"
taskState.askResponseText = "resume completed"
await clock.tickAsync(100)
const result = await askPromise
assert.equal(result.response, "yesButtonClicked")
assert.equal(result.text, "resume completed")
} finally {
clock.restore()
}
})
it("still wakes non-resume asks when abort is triggered after the ask is shown", async () => {
const clock = sinon.useFakeTimers()
const taskState: {
abort: boolean
askResponse: string | undefined
askResponseText: string | undefined
askResponseImages: string[] | undefined
askResponseFiles: string[] | undefined
lastMessageTs: number | undefined
} = {
abort: false,
askResponse: undefined,
askResponseText: undefined,
askResponseImages: undefined,
askResponseFiles: undefined,
lastMessageTs: undefined,
}
const { clineMessages, fakeTask } = createFakeTask(taskState)
try {
const askPromise = (
Task.prototype as unknown as {
ask: (type: "completion_result") => Promise<{ response: string }>
}
).ask.call(fakeTask, "completion_result")
await flushMicrotasks()
assert.equal(clineMessages.length, 1)
assert.equal(clineMessages[0].ask, "completion_result")
const rejectionPromise = assert.rejects(askPromise, /Cline instance aborted/)
taskState.abort = true
await clock.tickAsync(100)
await rejectionPromise
} finally {
clock.restore()
}
})
})
@@ -0,0 +1,77 @@
import { strict as assert } from "node:assert"
import type { ToolUse } from "@core/assistant-message"
import { registerPartialMessageCallback } from "@core/controller/ui/subscribeToPartialMessage"
import { Task } from "@core/task"
import type { ClineMessage } from "@shared/ExtensionMessage"
import { ClineDefaultTool } from "@shared/tools"
import { describe, it } from "mocha"
describe("Task.processNativeToolCalls", () => {
it("finalizes a partial text row before handing off to native tool calls", async () => {
const clineMessages: ClineMessage[] = [
{
ts: 1,
type: "say",
say: "text",
text: "partial text before tool handoff",
partial: true,
},
]
let saveCalls = 0
const emittedPartialMessages: Array<{ partial: boolean; text: string }> = []
const unsubscribe = registerPartialMessageCallback((message) => {
emittedPartialMessages.push({
partial: message.partial,
text: message.text,
})
})
const toolBlocks: ToolUse[] = [
{
type: "tool_use",
name: ClineDefaultTool.ASK,
params: {
question: "Need clarification",
},
partial: true,
isNativeToolCall: true,
call_id: "call-1",
},
]
const fakeTask = {
messageStateHandler: {
getClineMessages: () => clineMessages,
saveClineMessagesAndUpdateHistory: async () => {
saveCalls += 1
},
},
taskState: {
assistantMessageContent: [],
currentStreamingContentIndex: 0,
userMessageContentReady: true,
},
}
try {
await (
Task.prototype as unknown as { processNativeToolCalls: (text: string, blocks: ToolUse[]) => Promise<void> }
).processNativeToolCalls.call(fakeTask, "visible streamed text", toolBlocks)
assert.equal(clineMessages[0].text, "visible streamed text")
assert.equal(clineMessages[0].partial, false)
assert.equal(saveCalls, 1)
assert.deepEqual(emittedPartialMessages, [{ partial: false, text: "visible streamed text" }])
assert.deepEqual(fakeTask.taskState.assistantMessageContent, [
{ type: "text", content: "visible streamed text", partial: false },
...toolBlocks,
])
assert.equal(fakeTask.taskState.currentStreamingContentIndex, 1)
assert.equal(fakeTask.taskState.userMessageContentReady, false)
} finally {
unsubscribe()
}
})
})
@@ -0,0 +1,253 @@
import { describe, it } from "mocha"
import "should"
import sinon from "sinon"
import { TaskPresentationScheduler } from "../TaskPresentationScheduler"
describe("TaskPresentationScheduler", () => {
it("rethrows flush errors from flushNow so callers do not hang on hidden failures", async () => {
const scheduler = new TaskPresentationScheduler({
flush: async () => {
throw new Error("flush failed")
},
getDelayMs: () => 10,
})
await scheduler
.flushNow()
.then(() => {
throw new Error("expected flushNow to reject")
})
.catch((error: Error) => {
error.message.should.equal("flush failed")
})
})
it("coalesces multiple normal-priority requests into a single timer", () => {
const clock = sinon.useFakeTimers()
const flushSpy = sinon.spy(async () => {})
const scheduler = new TaskPresentationScheduler({
flush: flushSpy,
getDelayMs: () => 50,
})
scheduler.requestFlush("normal")
scheduler.requestFlush("normal")
scheduler.requestFlush("normal")
clock.tick(49)
flushSpy.callCount.should.equal(0)
clock.tick(1)
flushSpy.callCount.should.equal(1)
clock.restore()
})
it("waits for an in-flight flush and runs the requested immediate flush before resolving flushNow", async () => {
let resolveFirstFlush: (() => void) | undefined
let flushCount = 0
const scheduler = new TaskPresentationScheduler({
flush: async () => {
flushCount += 1
if (flushCount === 1) {
await new Promise<void>((resolve) => {
resolveFirstFlush = resolve
})
}
},
getDelayMs: () => 0,
})
scheduler.requestFlush("immediate")
await Promise.resolve()
let didResolve = false
const flushNowPromise = scheduler.flushNow().then(() => {
didResolve = true
})
await Promise.resolve()
flushCount.should.equal(1)
didResolve.should.equal(false)
resolveFirstFlush?.()
await flushNowPromise
flushCount.should.equal(2)
didResolve.should.equal(true)
})
it("does not rethrow errors from an overlapping in-flight flush when flushNow is called", async () => {
let rejectFirstFlush: ((error: Error) => void) | undefined
let flushCount = 0
const scheduler = new TaskPresentationScheduler({
flush: async () => {
flushCount += 1
if (flushCount === 1) {
await new Promise<void>((_, reject) => {
rejectFirstFlush = reject
})
}
},
getDelayMs: () => 0,
})
scheduler.requestFlush("immediate")
await Promise.resolve()
let flushNowResolved = false
const flushNowPromise = scheduler.flushNow().then(() => {
flushNowResolved = true
})
rejectFirstFlush?.(new Error("flush failed"))
await flushNowPromise
flushNowResolved.should.equal(true)
flushCount.should.equal(2)
})
it("flushNow guarantees a flush even when the post-flush continuation consumed pendingPriority", async () => {
// Regression test for the race condition where:
// 1. A timer fires → runFlushCycle starts, sets flushInProgress=true, clears pendingPriority
// 2. flushNow() is called → sets pendingPriority="immediate", enters runFlushCycle
// 3. runFlushCycle sees flushInProgress, awaits currentFlushCompletion
// 4. In-flight flush completes → post-flush continuation sees pendingPriority="immediate",
// calls runFlushCycle recursively → clears pendingPriority, runs flush #2
// 5. flushNow()'s runFlushCycle resumes → pendingPriority is now undefined → would return
// without flushing (the bug)
//
// The fix: flushNow() waits for all in-flight flushes to drain *before* setting
// pendingPriority, so the continuation cannot steal it.
let resolveFirstFlush: (() => void) | undefined
let flushCount = 0
const scheduler = new TaskPresentationScheduler({
flush: async () => {
flushCount += 1
if (flushCount === 1) {
// First flush: pause so flushNow() arrives while it's in-flight
await new Promise<void>((resolve) => {
resolveFirstFlush = resolve
})
}
},
getDelayMs: () => 0,
})
// Start the first flush (via immediate requestFlush)
scheduler.requestFlush("immediate")
// Yield so the async flush body starts executing
await Promise.resolve()
await Promise.resolve()
// flushNow() is called while flush #1 is paused mid-execution
let flushNowResolved = false
const flushNowPromise = scheduler.flushNow().then(() => {
flushNowResolved = true
})
// Unblock flush #1
resolveFirstFlush?.()
await flushNowPromise
// flushNow must have triggered a second flush after flush #1 completed
flushNowResolved.should.equal(true)
flushCount.should.equal(2)
})
it("runs an immediate follow-up flush requested during an in-flight flush", async () => {
let resolveFirstFlush: (() => void) | undefined
let flushCount = 0
const scheduler = new TaskPresentationScheduler({
flush: async () => {
flushCount += 1
if (flushCount === 1) {
await new Promise<void>((resolve) => {
resolveFirstFlush = resolve
})
}
},
getDelayMs: () => 0,
})
scheduler.requestFlush("immediate")
await Promise.resolve()
await Promise.resolve()
scheduler.requestFlush("immediate")
resolveFirstFlush?.()
await scheduler.flushNow()
flushCount.should.equal(3)
})
it("reset() cancels pending timers without marking the scheduler as disposed", () => {
const clock = sinon.useFakeTimers()
const flushSpy = sinon.spy(async () => {})
const scheduler = new TaskPresentationScheduler({
flush: flushSpy,
getDelayMs: () => 50,
})
scheduler.requestFlush("normal")
scheduler.reset()
// The pending timer should have been cancelled
clock.tick(100)
flushSpy.callCount.should.equal(0)
// Scheduler should still be usable after reset (not disposed)
scheduler.requestFlush("normal")
clock.tick(50)
flushSpy.callCount.should.equal(1)
clock.restore()
})
it("immediate priority bypasses the timer and flushes synchronously", () => {
const clock = sinon.useFakeTimers()
const flushSpy = sinon.spy(async () => {})
const scheduler = new TaskPresentationScheduler({
flush: flushSpy,
getDelayMs: () => 100,
})
scheduler.requestFlush("immediate")
// immediate fires via void runFlushCycle, which starts synchronously
flushSpy.callCount.should.equal(1)
clock.restore()
})
it("upgrades a pending normal timer to immediate when immediate is requested", () => {
const clock = sinon.useFakeTimers()
const flushSpy = sinon.spy(async () => {})
const scheduler = new TaskPresentationScheduler({
flush: flushSpy,
getDelayMs: () => 100,
})
scheduler.requestFlush("normal")
clock.tick(50)
flushSpy.callCount.should.equal(0)
// Upgrade to immediate — should cancel the timer and flush now
scheduler.requestFlush("immediate")
flushSpy.callCount.should.equal(1)
// Original timer should not fire again
clock.tick(100)
flushSpy.callCount.should.equal(1)
clock.restore()
})
})
+52
View File
@@ -0,0 +1,52 @@
import { describe, it } from "mocha"
import "should"
import { isRemoteWorkspaceEnvironment } from "../latency"
describe("latency", () => {
it("detects remote workspaces from explicit remoteName metadata", () => {
isRemoteWorkspaceEnvironment({
platform: "Visual Studio Code",
version: "1.103.0",
remoteName: "ssh-remote",
}).should.equal(true)
})
it("detects remote workspaces for dev-container and codespaces remoteName values", () => {
isRemoteWorkspaceEnvironment({ remoteName: "dev-container" }).should.equal(true)
isRemoteWorkspaceEnvironment({ remoteName: "codespaces" }).should.equal(true)
})
it("does not classify hosts as remote when remoteName is absent", () => {
isRemoteWorkspaceEnvironment({
platform: "Visual Studio Code",
version: "1.103.0",
remoteName: undefined,
}).should.equal(false)
})
it("does not classify hosts as remote when remoteName is null", () => {
isRemoteWorkspaceEnvironment({
platform: "Visual Studio Code",
version: "1.103.0",
remoteName: null,
}).should.equal(false)
})
it("does not false-positive on platform or version strings containing 'remote'", () => {
// Previously the heuristic would have returned true for these — now it must not.
isRemoteWorkspaceEnvironment({
platform: "Remote IDE",
version: "1.0.0",
}).should.equal(false)
isRemoteWorkspaceEnvironment({
platform: "Visual Studio Code",
version: "1.0.0-remote-fix",
}).should.equal(false)
})
it("does not classify hosts as remote when no fields are provided", () => {
isRemoteWorkspaceEnvironment({}).should.equal(false)
})
})
@@ -0,0 +1,134 @@
import { describe, it } from "mocha"
import "should"
import { checkRepeatedToolCall, toolCallSignature } from "../loop-detection"
import { TaskState } from "../TaskState"
/** Simulate a tool call matching production order in ToolExecutor. */
function simulateToolCall(state: TaskState, toolName: string, params: Record<string, string>, maxMistakes = 3) {
const sig = toolCallSignature(params)
const result = checkRepeatedToolCall(state, toolName, sig)
if (result.softWarning) {
state.userMessageContent.push({ type: "text", text: `[WARNING] loop detected for ${toolName}` })
}
if (result.hardEscalation) {
state.consecutiveMistakeCount = maxMistakes
}
state.lastToolName = toolName
state.lastToolParams = sig
return result
}
describe("toolCallSignature", () => {
it("produces identical output regardless of key order", () => {
toolCallSignature({ b: "2", a: "1" }).should.equal(toolCallSignature({ a: "1", b: "2" }))
})
})
describe("Loop Detection", () => {
it("should warn at 3 identical calls and escalate at 5", () => {
const state = new TaskState()
const results = []
for (let i = 0; i < 5; i++) {
results.push(simulateToolCall(state, "read_file", { path: "src/main.ts" }))
}
results[0].softWarning.should.be.false()
results[1].softWarning.should.be.false()
results[2].softWarning.should.be.true()
results[3].softWarning.should.be.false()
results[3].hardEscalation.should.be.false()
results[4].softWarning.should.be.false()
results[4].hardEscalation.should.be.true()
state.userMessageContent.length.should.equal(1)
state.consecutiveMistakeCount.should.equal(3)
})
it("should reset when tool or params change", () => {
const state = new TaskState()
simulateToolCall(state, "read_file", { path: "a.ts" })
simulateToolCall(state, "read_file", { path: "a.ts" })
simulateToolCall(state, "read_file", { path: "b.ts" }) // different params
state.consecutiveIdenticalToolCount.should.equal(1)
simulateToolCall(state, "read_file", { path: "b.ts" })
simulateToolCall(state, "list_files", { path: "b.ts" }) // different tool
state.consecutiveIdenticalToolCount.should.equal(1)
})
it("should NOT count different tools with same params as identical", () => {
const state = new TaskState()
simulateToolCall(state, "read_file", { path: "src/main.ts" })
simulateToolCall(state, "search_files", { path: "src/main.ts" })
simulateToolCall(state, "list_files", { path: "src/main.ts" })
state.consecutiveIdenticalToolCount.should.equal(1)
})
it("should re-arm after loop detection state is reset", () => {
const state = new TaskState()
// First cycle: escalate at call 5
for (let i = 0; i < 5; i++) {
simulateToolCall(state, "read_file", { path: "src/main.ts" })
}
state.consecutiveIdenticalToolCount.should.equal(5)
state.consecutiveMistakeCount.should.equal(3)
// Simulate what index.ts does when user clicks "continue"
state.consecutiveMistakeCount = 0
state.consecutiveIdenticalToolCount = 0
state.lastToolName = ""
state.lastToolParams = ""
// Second cycle: same tool + params should trigger again
const results = []
for (let i = 0; i < 5; i++) {
results.push(simulateToolCall(state, "read_file", { path: "src/main.ts" }))
}
results[2].softWarning.should.be.true()
results[4].hardEscalation.should.be.true()
state.consecutiveMistakeCount.should.equal(3)
})
it("should work correctly when tool changes after reset", () => {
const state = new TaskState()
// Escalate with one tool
for (let i = 0; i < 5; i++) {
simulateToolCall(state, "read_file", { path: "src/main.ts" })
}
// Reset (user clicks "continue")
state.consecutiveMistakeCount = 0
state.consecutiveIdenticalToolCount = 0
state.lastToolName = ""
state.lastToolParams = ""
// Model switches to a different tool — no false positives
const result = simulateToolCall(state, "list_files", { path: "src/" })
result.softWarning.should.be.false()
result.hardEscalation.should.be.false()
state.consecutiveIdenticalToolCount.should.equal(1)
})
it("should strip task_progress from comparison", () => {
const state = new TaskState()
const results = []
for (let i = 0; i < 5; i++) {
results.push(
simulateToolCall(state, "read_file", {
path: "src/index.ts",
task_progress: `step ${i} of 5`,
}),
)
}
results[2].softWarning.should.be.true()
results[4].hardEscalation.should.be.true()
})
})
+125 -14
View File
@@ -114,9 +114,17 @@ import { Controller } from "../controller"
import { executeHook } from "../hooks/hook-executor"
import { StateManager } from "../storage/StateManager"
import { FocusChainManager } from "./focus-chain"
import {
getPresentationCadenceMs,
isPresentationSchedulingDisabled,
isRemoteWorkspaceEnvironment,
type TaskLatencyTrigger,
} from "./latency"
import { MessageStateHandler } from "./message-state"
import type { PresentationPriority } from "./presentation-types"
import { StreamChunkCoordinator } from "./StreamChunkCoordinator"
import { StreamResponseHandler } from "./StreamResponseHandler"
import { TaskPresentationScheduler } from "./TaskPresentationScheduler"
import { TaskState } from "./TaskState"
import { ToolExecutor } from "./ToolExecutor"
import { detectAvailableCliTools, extractProviderDomainFromUrl, updateApiReqMsg } from "./utils"
@@ -256,6 +264,11 @@ export class Task {
// Command executor for running shell commands (extracted from executeCommandTool)
private commandExecutor!: CommandExecutor
private isRemoteWorkspaceEnvironment = false
private remoteWorkspaceDetectionSettled = false
private readonly remoteWorkspaceDetectionPromise: Promise<void>
private readonly presentationScheduler: TaskPresentationScheduler
private readonly presentationSchedulingDisabled = isPresentationSchedulingDisabled()
constructor(params: TaskParams) {
const {
@@ -283,6 +296,17 @@ export class Task {
this.taskInitializationStartTime = performance.now()
this.taskState = new TaskState()
this.remoteWorkspaceDetectionPromise = HostProvider.env
.getHostVersion({})
.then((hostVersion) => {
this.isRemoteWorkspaceEnvironment = isRemoteWorkspaceEnvironment(hostVersion)
})
.catch((error) => {
Logger.warn(`[Task ${taskId}] Failed to detect remote workspace state: ${error}`)
})
.finally(() => {
this.remoteWorkspaceDetectionSettled = true
})
this.controller = controller
this.mcpHub = mcpHub
this.updateTaskHistory = updateTaskHistory
@@ -531,6 +555,26 @@ export class Task {
this.commandExecutor = new CommandExecutor(commandExecutorConfig, commandExecutorCallbacks)
// Note: the scheduler's getDelayMs reads this.isRemoteWorkspaceEnvironment which is
// populated asynchronously by remoteWorkspaceDetectionPromise. The promise is awaited
// before streaming begins (in recursivelyMakeClineRequests) so the cadence is always
// correct by the time the first flush is scheduled.
this.presentationScheduler = new TaskPresentationScheduler({
flush: () => this.presentAssistantMessage(),
getDelayMs: (priority) => {
if (!this.remoteWorkspaceDetectionSettled) {
// This should never fire in production because recursivelyMakeClineRequests
// awaits remoteWorkspaceDetectionPromise before the first flush is scheduled.
// If it does fire, we fall back to the local cadence (safe default).
Logger.warn(
`[Task ${taskId}] getDelayMs called before remote workspace detection settled — using local cadence as fallback`,
)
}
return getPresentationCadenceMs(this.isRemoteWorkspaceEnvironment, priority)
},
onFlushError: (error) => Logger.debug(`[Task] Failed scheduled presentation flush: ${error}`),
})
this.toolExecutor = new ToolExecutor(
this.taskState,
this.messageStateHandler,
@@ -569,6 +613,44 @@ export class Task {
)
}
private async scheduleAssistantPresentation(
trigger: TaskLatencyTrigger,
priority: PresentationPriority = "normal",
): Promise<void> {
if (this.presentationSchedulingDisabled) {
// Scheduling is disabled: preserve the old per-chunk synchronisation
// semantics by awaiting flushNow() directly, while still routing through
// the scheduler so its serialisation/locking guarantees are respected.
await this.presentationScheduler.flushNow().catch((error) => {
Logger.warn(`[Task] Failed immediate presentation flush: ${error}`)
})
return
}
// Immediate semantic boundaries: first visible token, tool transitions, finalization, and cleanup drains.
Logger.debug(`[Task ${this.taskId}] schedule assistant presentation (${trigger}, ${priority})`)
this.presentationScheduler.requestFlush(priority)
}
private async flushAssistantPresentationOrThrow() {
await this.presentationScheduler.flushNow()
}
private getPresentationPriorityForChunk(args: {
chunkType: "text" | "reasoning" | "tool_calls"
hadVisibleAssistantContent: boolean
}): PresentationPriority {
if (!args.hadVisibleAssistantContent) {
return "immediate"
}
if (args.chunkType === "tool_calls") {
return "immediate"
}
return "normal"
}
// Communicate with webview
// partial has three valid states true (partial message), false (completion of partial message), undefined (individual complete message)
@@ -697,9 +779,17 @@ export class Task {
})
}
await pWaitFor(() => this.taskState.askResponse !== undefined || this.taskState.lastMessageTs !== askTs, {
interval: 100,
})
const shouldWakeOnAbort = type !== "resume_task" && type !== "resume_completed_task"
await pWaitFor(
() =>
this.taskState.askResponse !== undefined ||
this.taskState.lastMessageTs !== askTs ||
(shouldWakeOnAbort && this.taskState.abort),
{ interval: 100 },
)
if (shouldWakeOnAbort && this.taskState.abort) {
throw new Error("Cline instance aborted")
}
if (this.taskState.lastMessageTs !== askTs) {
throw new Error("Current ask promise was ignored") // could happen if we send multiple asks in a row i.e. with command_output. It's important that when we know an ask could fail, it is handled gracefully
}
@@ -1583,6 +1673,7 @@ export class Task {
if (this.FocusChainManager) {
this.FocusChainManager.dispose()
}
await this.presentationScheduler.dispose()
} finally {
// Release task folder lock
if (this.taskLockAcquired) {
@@ -2271,6 +2362,10 @@ export class Task {
throw new Error("Task instance aborted")
}
// Ensure remote workspace detection completes before streaming begins so
// the presentation scheduler uses the correct cadence from the first flush.
await this.remoteWorkspaceDetectionPromise
// Increment API request counter for focus chain list management
this.taskState.apiRequestCount++
this.taskState.apiRequestsSinceLastTodoUpdate++
@@ -2343,6 +2438,10 @@ export class Task {
}
this.taskState.consecutiveMistakeCount = 0
this.taskState.autoRetryAttempts = 0 // need to reset this if the user chooses to manually retry after the mistake limit is reached
// Reset loop detection state so it can re-arm if the model continues looping
this.taskState.consecutiveIdenticalToolCount = 0
this.taskState.lastToolName = ""
this.taskState.lastToolParams = ""
}
// get previous api req's index to check token usage and determine if we need to truncate conversation history
@@ -2700,6 +2799,7 @@ export class Task {
this.taskState.didAutomaticallyRetryFailedApiRequest = false
await this.diffViewProvider.reset()
this.streamHandler.reset()
this.presentationScheduler.reset()
this.taskState.toolUseIdMap.clear()
const { toolUseHandler, reasonsHandler } = this.streamHandler.getHandlers()
@@ -2713,6 +2813,7 @@ export class Task {
this.taskState.isStreaming = true
let didReceiveUsageChunk = false
let didFinalizeReasoningForUi = false
let didScheduleAnyContent = false // Tracks whether any content chunk has been scheduled for presentation (not necessarily flushed yet)
const finalizePendingReasoningMessage = async (thinking: string): Promise<boolean> => {
const pendingReasoningIndex = findLastIndex(
@@ -2764,6 +2865,10 @@ export class Task {
if (!chunk) {
break
}
// Track whether any content chunk has been scheduled for presentation (not necessarily flushed yet).
// Using assistantMessage alone would miss reasoning-only streams where text hasn't
// started yet, causing every reasoning chunk to get "immediate" priority.
const hadVisibleAssistantContent = didScheduleAnyContent
if (!this.taskState.taskFirstTokenTimeMs) {
this.taskState.taskFirstTokenTimeMs = Math.max(0, Date.now() - this.taskState.taskStartTimeMs)
}
@@ -2790,6 +2895,11 @@ export class Task {
await this.say("reasoning", thinkingBlock.thinking, undefined, undefined, true)
}
}
await this.scheduleAssistantPresentation(
"reasoning",
this.getPresentationPriorityForChunk({ chunkType: "reasoning", hadVisibleAssistantContent }),
)
didScheduleAnyContent = true
break
}
@@ -2812,6 +2922,11 @@ export class Task {
}
await this.processNativeToolCalls(assistantTextOnly, toolUseHandler.getPartialToolUsesAsContent())
await this.scheduleAssistantPresentation(
"tool",
this.getPresentationPriorityForChunk({ chunkType: "tool_calls", hadVisibleAssistantContent }),
)
didScheduleAnyContent = true
break
}
case "text": {
@@ -2839,16 +2954,15 @@ export class Task {
if (this.taskState.assistantMessageContent.length > prevLength) {
this.taskState.userMessageContentReady = false // new content we need to present, reset to false in case previous content set this to true
}
await this.scheduleAssistantPresentation(
"text",
this.getPresentationPriorityForChunk({ chunkType: "text", hadVisibleAssistantContent }),
)
didScheduleAnyContent = true
break
}
}
// Present content once per chunk. Calling this from multiple case branches can
// race partial updates and duplicate text rows in the chat.
await this.presentAssistantMessage().catch((error) =>
Logger.debug("[Task] Failed to present message: " + error),
)
if (this.taskState.abort) {
this.api.abort?.()
if (!this.taskState.abandoned) {
@@ -3075,10 +3189,7 @@ export class Task {
// in case there are native tool calls pending
const partialToolBlocks = toolUseHandler.getPartialToolUsesAsContent()?.map((block) => ({ ...block, partial: false }))
await this.processNativeToolCalls(assistantTextOnly, partialToolBlocks)
if (partialBlocks.length > 0) {
await this.presentAssistantMessage() // if there is content to update then it will complete and update this.userMessageContentReady to true, which we pwaitfor before making the next request. all this is really doing is presenting the last partial message that we just set to complete
}
await this.flushAssistantPresentationOrThrow() // finalization is immediate so no coalesced content remains pending
// now add to apiconversationhistory
// need to save assistant responses to file before proceeding to tool use since user can exit at any moment and we wouldn't be able to save the assistant's response
@@ -3338,7 +3449,7 @@ export class Task {
return [processedUserContent, environmentDetails, clinerulesError]
}
async processNativeToolCalls(assistantTextOnly: string, toolBlocks: ToolUse[]) {
protected async processNativeToolCalls(assistantTextOnly: string, toolBlocks: ToolUse[]) {
if (!toolBlocks?.length) {
return
}
+66
View File
@@ -0,0 +1,66 @@
import { Logger } from "@/shared/services/Logger"
import type { PresentationPriority } from "./presentation-types"
export type TaskLatencyTrigger = "text" | "reasoning" | "tool"
function readBooleanEnv(envVarName: string): boolean {
const rawValue = process.env[envVarName]?.toLowerCase()
return rawValue === "1" || rawValue === "true" || rawValue === "yes"
}
function readCadenceOverride(envVarName: string): number | undefined {
const rawValue = process.env[envVarName]
if (!rawValue) {
return undefined
}
const parsed = Number.parseInt(rawValue, 10)
if (!Number.isFinite(parsed) || parsed < 0) {
Logger.warn(`[latency] Ignoring invalid cadence override ${envVarName}="${rawValue}" (must be a non-negative integer)`)
return undefined
}
return parsed
}
// Cadence overrides are read once at module load. Env vars do not change at
// runtime, and getPresentationCadenceMs is called on every flush (hot path).
const localCadenceOverride = readCadenceOverride("CLINE_PRESENTATION_CADENCE_MS")
const remoteCadenceOverride = readCadenceOverride("CLINE_REMOTE_PRESENTATION_CADENCE_MS")
const schedulingDisabled = readBooleanEnv("CLINE_DISABLE_PRESENTATION_SCHEDULER")
/**
* Determines whether the host is connected to a remote workspace.
*
* The primary signal is `remoteName` which is populated from `vscode.env.remoteName`
* (e.g. `"ssh-remote"`, `"dev-container"`, `"codespaces"`). When this field is present
* the host is definitively remote.
*
* For non-VSCode hosts (e.g. JetBrains) that do not populate `remoteName`, this
* function conservatively returns `false` and uses the local cadence. This avoids
* false positives from version strings that happen to contain the word "remote"
* (e.g. `"1.0.0-remote-fix"`). Host bridges for remote-capable environments should
* populate `remoteName` explicitly to opt in to the higher cadence.
*/
export function isRemoteWorkspaceEnvironment(host: { platform?: string; version?: string; remoteName?: string | null }): boolean {
return !!host.remoteName
}
export function isPresentationSchedulingDisabled(): boolean {
return schedulingDisabled
}
export function getPresentationCadenceMs(isRemoteWorkspace: boolean, priority: PresentationPriority): number {
if (priority === "immediate") {
return 0
}
const override = isRemoteWorkspace ? remoteCadenceOverride : localCadenceOverride
if (override !== undefined) {
return override
}
// Default cadences: remote workspaces use a higher interval to reduce
// message-passing overhead over the network.
return isRemoteWorkspace ? 90 : 40
}
+68
View File
@@ -0,0 +1,68 @@
/**
* Repeated tool call loop detection.
*
* Detects when the LLM calls the same tool with identical arguments
* repeatedly, which wastes tokens without making progress.
*
* This is complementary to fileReadCache in ReadFileToolHandler, which
* deduplicates file *content* on cache hits but still allows the tool
* call to succeed and consume a turn. Loop detection catches the
* repeated call pattern itself, regardless of which tool is involved.
*
* Shared between ToolExecutor (production) and tests so the
* comparison algorithm cannot drift between the two.
*/
import type { TaskState } from "./TaskState"
// Soft threshold: inject a warning, giving the LLM one chance to self-correct.
// Hard threshold: escalate to user or fail task. Set higher to avoid false
// positives on tools that may legitimately repeat (e.g., browser_action screenshots).
export const LOOP_DETECTION_SOFT_THRESHOLD = 3
const LOOP_DETECTION_HARD_THRESHOLD = 5
// Params that are metadata/tracking, not tool-relevant input.
// These change between calls even when the user-facing arguments are identical
// (e.g., task_progress updates its checklist each turn).
const IGNORED_PARAMS = new Set(["task_progress"])
/**
* Compute a canonical signature for a tool call's params.
* Strips metadata fields and sorts keys via the JSON.stringify replacer
* so key order doesn't affect comparison.
*
* block.params is Partial<Record<ToolParamName, string>> always flat,
* string-valued, no nesting so the replacer array is sufficient.
*/
export function toolCallSignature(params: Partial<Record<string, string>> | undefined): string {
if (!params) return "{}"
const keys = Object.keys(params)
.filter((k) => !IGNORED_PARAMS.has(k))
.sort()
return JSON.stringify(params, keys)
}
interface LoopDetectionResult {
softWarning: boolean
hardEscalation: boolean
}
/**
* Core loop detection step. Must be called BEFORE updating
* lastToolName / lastToolParams on TaskState.
*
* Compares the current call against the previous state, updates the
* counter, and returns which thresholds (if any) were crossed.
*/
export function checkRepeatedToolCall(state: TaskState, toolName: string, currentSignature: string): LoopDetectionResult {
if (toolName === state.lastToolName && currentSignature === state.lastToolParams) {
state.consecutiveIdenticalToolCount++
} else {
state.consecutiveIdenticalToolCount = 1
}
return {
softWarning: state.consecutiveIdenticalToolCount === LOOP_DETECTION_SOFT_THRESHOLD,
hardEscalation: state.consecutiveIdenticalToolCount === LOOP_DETECTION_HARD_THRESHOLD,
}
}
+9
View File
@@ -0,0 +1,9 @@
/**
* Priority level for a presentation flush request.
*
* - `"immediate"` flush synchronously (delay = 0 ms). Used at semantic
* boundaries: first visible token, tool-call transitions, and finalization.
* - `"normal"` flush after the configured cadence delay, coalescing
* intermediate chunks to reduce message-passing overhead.
*/
export type PresentationPriority = "immediate" | "normal"
@@ -99,7 +99,11 @@ export class ExecuteCommandToolHandler implements IFullyManagedTool {
// Validate required parameters
if (!command) {
config.taskState.consecutiveMistakeCount++
return await config.callbacks.sayAndCreateMissingParamError(this.name, "command")
await config.callbacks.say(
"error",
"Cline tried to use execute_command without value for required parameter 'command'. Retrying...",
)
return formatResponse.toolError(formatResponse.executeCommandMissingCommandError())
}
if (!requiresApprovalRaw) {
@@ -114,7 +114,12 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
if (block.name === "replace_in_file" && !rawDiff) {
config.taskState.consecutiveMistakeCount++
await config.services.diffViewProvider.reset()
return await config.callbacks.sayAndCreateMissingParamError(block.name, "diff")
const relPath = rawRelPath || "unknown"
await config.callbacks.say(
"error",
`Cline tried to use replace_in_file for '${relPath}' without value for required parameter 'diff'. Retrying...`,
)
return formatResponse.toolError(formatResponse.replaceInFileMissingDiffError(relPath))
}
if (block.name === "write_to_file" && !rawContent) {
+2
View File
@@ -115,6 +115,7 @@ export abstract class WebviewProvider {
font-src ${this.getCspSource()} data:;
style-src ${this.getCspSource()} 'unsafe-inline';
img-src ${this.getCspSource()} https: data:;
media-src ${this.getCspSource()} https: data: blob:;
script-src 'nonce-${nonce}' 'unsafe-eval';">
<title>Cline</title>
</head>
@@ -202,6 +203,7 @@ export abstract class WebviewProvider {
`font-src ${this.getCspSource()}`,
`style-src ${this.getCspSource()} 'unsafe-inline' https://* http://${localServerUrl} http://0.0.0.0:${localPort}`,
`img-src ${this.getCspSource()} https: data:`,
`media-src ${this.getCspSource()} https: data: blob: http://${localServerUrl} http://0.0.0.0:${localPort}`,
`script-src 'unsafe-eval' https://* http://${localServerUrl} http://0.0.0.0:${localPort} 'nonce-${nonce}'`,
`connect-src https://* ws://${localServerUrl} ws://0.0.0.0:${localPort} http://${localServerUrl} http://0.0.0.0:${localPort}`,
]
+6
View File
@@ -10,5 +10,11 @@ export async function getHostVersion(_: EmptyRequest): Promise<GetHostVersionRes
version: vscode.version,
clineType: ClineClient.VSCode,
clineVersion: ExtensionRegistryInfo.version,
// vscode.env.remoteName is a non-empty string when connected to a remote workspace
// (e.g. "ssh-remote", "dev-container", "codespaces") and undefined otherwise.
// We coerce falsy values (undefined, null, "") to undefined so the proto optional
// field is absent for local workspaces. An empty string is treated as local — the
// safe direction (false negative rather than false positive).
remoteName: vscode.env.remoteName || undefined,
}
}
@@ -71,15 +71,19 @@ describe("Hostbridge - Window - getOpenTabs", () => {
await createAndOpenTestDocument("open-tabs-1", vscode.ViewColumn.One)
await createAndOpenTestDocument("open-tabs-2", vscode.ViewColumn.Two)
// Wait for tabs to be fully created
// Wait for both expected tabs to appear.
// On Windows, opening in a different ViewColumn can sometimes create
// duplicate tab entries, so we check for unique paths containing both files
// rather than an exact count.
await pWaitFor(
async () => {
const request = GetOpenTabsRequest.create({})
const response = await getOpenTabs(request)
const uniquePaths = [...new Set(response.paths)]
console.log(
`[DEBUG] Waiting for 2 tabs, currently found ${response.paths.length}: ${JSON.stringify(response.paths)}`,
`[DEBUG] Waiting for 2 unique tabs, currently found ${uniquePaths.length}: ${JSON.stringify(uniquePaths)}`,
)
return response.paths.length === 2
return uniquePaths.some((p) => p.includes("open-tabs-1")) && uniquePaths.some((p) => p.includes("open-tabs-2"))
},
{
timeout: 8000,
@@ -89,12 +93,13 @@ describe("Hostbridge - Window - getOpenTabs", () => {
const request = GetOpenTabsRequest.create({})
const response = await getOpenTabs(request)
const uniquePaths = [...new Set(response.paths)]
// Should have 2 tabs open
// Should have both tabs open (deduplicated for Windows ViewColumn quirk)
assert.strictEqual(
response.paths.length,
uniquePaths.length,
2,
`Expected 2 tabs, got ${response.paths.length}. Found tabs: ${JSON.stringify(response.paths)}`,
`Expected 2 unique tabs, got ${uniquePaths.length}. Found tabs: ${JSON.stringify(response.paths)}`,
)
})
@@ -37,3 +37,157 @@ describe("listFiles", () => {
files.map(normalizeForComparison).should.containEql(normalizeForComparison(nestedFile))
})
})
describe("listFiles gitignore handling", () => {
// Each test gets its own isolated subdirectory to avoid cross-test pollution.
// Previous version shared a single tmpDir, which meant later tests could
// overwrite earlier .gitignore files and pass for the wrong reasons.
const baseDir = path.join(os.tmpdir(), `cline-gitignore-test-${Math.random().toString(36).slice(2)}`)
after(async () => {
await fs.rm(baseDir, { recursive: true, force: true }).catch(() => undefined)
})
it("excludes files matching root .gitignore directory patterns", async () => {
// Verifies the most common .gitignore use case: a directory pattern like "some-dir/"
// at the project root excludes that directory and everything inside it.
//
// project/
// .gitignore → "ignored-dir/"
// visible.ts
// ignored-dir/
// secret.ts ← should be excluded
// src/
// app.ts
const project = path.join(baseDir, "test-root-gitignore")
await fs.mkdir(path.join(project, "ignored-dir"), { recursive: true })
await fs.mkdir(path.join(project, "src"), { recursive: true })
await fs.writeFile(path.join(project, ".gitignore"), "ignored-dir/\n")
await fs.writeFile(path.join(project, "visible.ts"), "export const x = 1\n")
await fs.writeFile(path.join(project, "ignored-dir", "secret.ts"), "secret\n")
await fs.writeFile(path.join(project, "src", "app.ts"), "app\n")
const [files] = await listFiles(project, true, 200)
const normalized = files.map(normalizeForComparison)
normalized.should.containEql(normalizeForComparison(path.join(project, "visible.ts")))
normalized.should.containEql(normalizeForComparison(path.join(project, "src", "app.ts")))
const hasIgnoredContent = normalized.some((f) => f.includes("ignored-dir"))
hasIgnoredContent.should.equal(false, "ignored-dir/ contents should be excluded by root .gitignore")
})
it("excludes files matching .gitignore file patterns (not just directories)", async () => {
// The .gitignore parser handles two kinds of patterns differently:
// - Directory patterns ending in "/" → converted to "**/dir/**"
// - File/glob patterns like "*.log" → converted to "**/*.log" + "**/*.log/**"
// This test exercises the file pattern branch.
//
// project/
// .gitignore → "*.log\nsecret.env"
// app.ts
// debug.log ← should be excluded
// src/
// nested.log ← should also be excluded (pattern is global)
// secret.env ← should be excluded
// config.ts
const project = path.join(baseDir, "test-file-patterns")
await fs.mkdir(path.join(project, "src"), { recursive: true })
await fs.writeFile(path.join(project, ".gitignore"), "*.log\nsecret.env\n")
await fs.writeFile(path.join(project, "app.ts"), "app\n")
await fs.writeFile(path.join(project, "debug.log"), "debug output\n")
await fs.writeFile(path.join(project, "src", "nested.log"), "nested log\n")
await fs.writeFile(path.join(project, "src", "secret.env"), "API_KEY=xxx\n")
await fs.writeFile(path.join(project, "src", "config.ts"), "config\n")
const [files] = await listFiles(project, true, 200)
const normalized = files.map(normalizeForComparison)
normalized.should.containEql(normalizeForComparison(path.join(project, "app.ts")))
normalized.should.containEql(normalizeForComparison(path.join(project, "src", "config.ts")))
const hasLogFiles = normalized.some((f) => f.endsWith(".log"))
hasLogFiles.should.equal(false, "*.log files should be excluded")
const hasSecretEnv = normalized.some((f) => f.includes("secret.env"))
hasSecretEnv.should.equal(false, "secret.env should be excluded")
})
it("reads .gitignore from subdirectories during BFS traversal", async () => {
// .gitignore files aren't only at the root — subdirectories can have their own.
// During BFS, when we enter a non-ignored directory, we read its .gitignore
// and add those patterns to the accumulator for all deeper traversal.
//
// project/
// src/
// .gitignore → "generated/"
// code.ts
// generated/
// output.ts ← should be excluded by src/.gitignore
// lib/
// util.ts
const project = path.join(baseDir, "test-subdirectory-gitignore")
const srcDir = path.join(project, "src")
const genDir = path.join(srcDir, "generated")
const libDir = path.join(project, "lib")
await fs.mkdir(genDir, { recursive: true })
await fs.mkdir(libDir, { recursive: true })
await fs.writeFile(path.join(srcDir, ".gitignore"), "generated/\n")
await fs.writeFile(path.join(srcDir, "code.ts"), "code\n")
await fs.writeFile(path.join(genDir, "output.ts"), "generated output\n")
await fs.writeFile(path.join(libDir, "util.ts"), "util\n")
const [files] = await listFiles(project, true, 200)
const normalized = files.map(normalizeForComparison)
normalized.should.containEql(normalizeForComparison(path.join(srcDir, "code.ts")))
normalized.should.containEql(normalizeForComparison(path.join(libDir, "util.ts")))
const hasGeneratedContent = normalized.some((f) => f.includes("generated"))
hasGeneratedContent.should.equal(false, "src/generated/ should be excluded by src/.gitignore")
})
it("does not read .gitignore from inside gitignored directories", async () => {
// This is the core OOM-prevention test.
//
// The crash scenario: a gitignored directory (e.g., third-party/) contains
// hundreds of nested repos, each with their own .gitignore. globby's old
// gitignore:true would read ALL of them upfront, build a massive regex,
// and OOM during V8 regex compilation.
//
// With incremental reading, we never enter third-party/ because the root
// .gitignore excludes it, so we never read any .gitignore files inside it.
//
// NOTE: We intentionally use "third-party/" instead of "vendor/" here because
// "vendor" is in DEFAULT_IGNORE_DIRECTORIES and would be excluded regardless
// of .gitignore. Using a name NOT in that list proves the .gitignore-based
// exclusion is actually working.
//
// project/
// .gitignore → "third-party/"
// app.ts
// third-party/
// .gitignore ← should never be read
// repo1/
// .gitignore ← should never be read
// file.ts
const project = path.join(baseDir, "test-no-read-inside-ignored")
const thirdPartyDir = path.join(project, "third-party")
const repo1Dir = path.join(thirdPartyDir, "repo1")
await fs.mkdir(repo1Dir, { recursive: true })
await fs.writeFile(path.join(project, ".gitignore"), "third-party/\n")
await fs.writeFile(path.join(project, "app.ts"), "app\n")
// These .gitignore files simulate the nested repos that caused OOM
await fs.writeFile(path.join(thirdPartyDir, ".gitignore"), "*.log\nbuild/\n")
await fs.writeFile(path.join(repo1Dir, ".gitignore"), "dist/\ncoverage/\n")
await fs.writeFile(path.join(repo1Dir, "file.ts"), "file\n")
const [files] = await listFiles(project, true, 200)
const normalized = files.map(normalizeForComparison)
normalized.should.containEql(normalizeForComparison(path.join(project, "app.ts")))
const hasThirdPartyContent = normalized.some((f) => f.includes("third-party"))
hasThirdPartyContent.should.equal(false, "third-party/ contents should be excluded — and its .gitignore files never read")
})
})
+93 -10
View File
@@ -1,6 +1,7 @@
import { workspaceResolver } from "@core/workspace"
import { isDirectory } from "@utils/fs"
import { arePathsEqual } from "@utils/path"
import * as fs from "fs/promises"
import { globby, Options } from "globby"
import * as os from "os"
import * as path from "path"
@@ -46,7 +47,56 @@ function isTargetingHiddenDirectory(absolutePath: string): boolean {
return dirName.startsWith(".")
}
function buildIgnorePatterns(absolutePath: string): string[] {
/**
* Read a .gitignore file and convert its patterns to glob ignore patterns.
*
* We do NOT use globby's built-in `gitignore: true` option because it recursively
* reads ALL .gitignore files in the entire directory tree upfront - including those
* inside directories that are themselves gitignored. In projects with large gitignored
* directories containing many nested repos (each with their own .gitignore), this
* causes V8 to run out of memory during regex compilation, crashing the extension host.
*
* Instead, we read .gitignore files incrementally during BFS traversal: only from
* directories we actually enter (which are not ignored), never from ignored directories.
*/
async function readGitignorePatterns(dirPath: string): Promise<string[]> {
try {
const gitignorePath = path.join(dirPath, ".gitignore")
const content = await fs.readFile(gitignorePath, "utf8")
const patterns: string[] = []
for (const line of content.split("\n")) {
const trimmed = line.trim()
// Skip empty lines and comments
if (!trimmed || trimmed.startsWith("#")) {
continue
}
// Skip negation patterns - they're complex to convert and rarely
// critical for the directory listing use case
if (trimmed.startsWith("!")) {
continue
}
// Convert gitignore patterns to glob ignore patterns
if (trimmed.endsWith("/")) {
// Directory pattern: "ignored-dir/" → match the directory itself and its contents.
// Two explicit patterns avoid ambiguity across glob library versions:
const dirName = trimmed.slice(0, -1)
patterns.push(`**/${dirName}`)
patterns.push(`**/${dirName}/**`)
} else {
// File or ambiguous pattern: "*.log" -> "**/*.log" and "**/*.log/**"
patterns.push(`**/${trimmed}`)
patterns.push(`**/${trimmed}/**`)
}
}
return patterns
} catch {
return []
}
}
async function buildIgnorePatterns(absolutePath: string): Promise<string[]> {
const isTargetHidden = isTargetingHiddenDirectory(absolutePath)
const patterns = [...DEFAULT_IGNORE_DIRECTORIES]
@@ -56,7 +106,15 @@ function buildIgnorePatterns(absolutePath: string): string[] {
patterns.push(".*")
}
return patterns.map((dir) => `**/${dir}/**`)
const globPatterns = patterns.map((dir) => `**/${dir}/**`)
// Read root .gitignore to seed the initial ignore patterns.
// Additional .gitignore files from subdirectories are read incrementally
// during BFS traversal in globbyLevelByLevel().
const gitignorePatterns = await readGitignorePatterns(absolutePath)
globPatterns.push(...gitignorePatterns)
return globPatterns
}
export async function listFiles(dirPath: string, recursive: boolean, limit: number): Promise<[string[], boolean]> {
@@ -78,8 +136,8 @@ export async function listFiles(dirPath: string, recursive: boolean, limit: numb
dot: true, // do not ignore hidden files/directories
absolute: true,
markDirectories: true, // Append a / on any directories matched
gitignore: recursive, // globby ignores any files that are gitignored
ignore: recursive ? buildIgnorePatterns(absolutePath) : undefined,
gitignore: false, // We handle .gitignore ourselves incrementally during BFS to avoid OOM
ignore: recursive ? await buildIgnorePatterns(absolutePath) : undefined,
onlyFiles: false, // include directories in results
suppressErrors: true,
}
@@ -95,6 +153,9 @@ Breadth-first traversal of directory structure level by level up to a limit:
- Processes directory patterns level by level
- Captures a representative sample of the directory structure up to the limit
- Minimizes risk of missing deeply nested files
- Reads .gitignore files incrementally from each non-ignored directory entered,
avoiding the OOM crash caused by globby's gitignore:true reading ALL nested
.gitignore files upfront (including those inside gitignored directories)
- Notes:
- Relies on globby to mark directories with /
@@ -104,11 +165,16 @@ Breadth-first traversal of directory structure level by level up to a limit:
async function globbyLevelByLevel(limit: number, options?: Options) {
const results: Set<string> = new Set()
const queue: string[] = ["*"]
// Track all ignore patterns, starting with whatever was passed in options.
// We'll add patterns from .gitignore files as we discover non-ignored directories.
const currentIgnore: string[] = [...((options?.ignore as string[]) ?? [])]
const globbingProcess = async () => {
while (queue.length > 0 && results.size < limit) {
const pattern = queue.shift()!
const filesAtLevel = await globby(pattern, options)
// Use current accumulated ignore patterns for each globby call
const currentOptions = { ...options, ignore: currentIgnore }
const filesAtLevel = await globby(pattern, currentOptions)
for (const file of filesAtLevel) {
if (results.size >= limit) {
@@ -116,11 +182,28 @@ async function globbyLevelByLevel(limit: number, options?: Options) {
}
results.add(file)
if (file.endsWith("/")) {
// Escape parentheses in the path to prevent glob pattern interpretation
// This is crucial for NextJS folder naming conventions which use parentheses like (auth), (dashboard)
// Without escaping, glob treats parentheses as special pattern grouping characters
const escapedFile = file.replace(/\(/g, "\\(").replace(/\)/g, "\\)")
queue.push(`${escapedFile}*`)
// This directory passed the ignore filters, so it's not gitignored.
// Read its .gitignore (if any) and add patterns to the ignore list
// so deeper traversal respects them.
const dirGitignorePatterns = await readGitignorePatterns(file)
if (dirGitignorePatterns.length > 0) {
currentIgnore.push(...dirGitignorePatterns)
}
// Queue as a RELATIVE path to cwd so that ignore patterns (like **/tmp/**)
// are checked against relative entry paths, not absolute ones. Using absolute
// patterns causes false matches when the project is under a directory whose
// name collides with DEFAULT_IGNORE_DIRECTORIES (e.g., /tmp on Linux).
const cwd = options?.cwd?.toString() ?? ""
const relativeDir = path.relative(cwd, file)
// Escape backslashes and parentheses in the path to prevent glob pattern interpretation.
// This is crucial for NextJS folder naming conventions which use parentheses like (auth), (dashboard).
// Without escaping, glob treats backslashes as escapes and parentheses as special pattern grouping characters.
const escapedDir = relativeDir
.replace(/\\/g, "\\\\")
.replace(/\(/g, "\\(")
.replace(/\)/g, "\\)")
queue.push(`${escapedDir}/*`)
}
}
}
+1
View File
@@ -108,6 +108,7 @@ export interface ExtensionState {
backgroundEditEnabled?: boolean
optOutOfRemoteConfig?: boolean
doubleCheckCompletionEnabled?: boolean
showFeatureTips?: boolean
banners?: BannerCardData[]
welcomeBanners?: BannerCardData[]
openAiCodexIsAuthenticated?: boolean
+32 -8
View File
@@ -4724,8 +4724,32 @@ export const qwenCodeDefaultModelId: QwenCodeModelId = "qwen3-coder-plus"
// https://www.minimax.io/platform/document/text_api_intro
// https://www.minimax.io/platform/document/pricing
export type MinimaxModelId = keyof typeof minimaxModels
export const minimaxDefaultModelId: MinimaxModelId = "MiniMax-M2.5"
export const minimaxDefaultModelId: MinimaxModelId = "MiniMax-M2.7"
export const minimaxModels = {
"MiniMax-M2.7": {
maxTokens: 128_000,
contextWindow: 192_000,
supportsImages: false,
supportsPromptCache: true,
supportsReasoning: true,
inputPrice: 0.3,
outputPrice: 1.2,
cacheWritesPrice: 0.375,
cacheReadsPrice: 0.06,
description: "Latest flagship model with enhanced reasoning and coding",
},
"MiniMax-M2.7-highspeed": {
maxTokens: 128_000,
contextWindow: 192_000,
supportsImages: false,
supportsPromptCache: true,
supportsReasoning: true,
inputPrice: 0.6,
outputPrice: 2.4,
cacheWritesPrice: 0.375,
cacheReadsPrice: 0.06,
description: "High-speed version of M2.7 for low-latency scenarios",
},
"MiniMax-M2.5": {
maxTokens: 128_000,
contextWindow: 192_000,
@@ -4734,7 +4758,7 @@ export const minimaxModels = {
supportsReasoning: true,
inputPrice: 0.3,
outputPrice: 1.2,
cacheWritesPrice: 0.0375,
cacheWritesPrice: 0.375,
cacheReadsPrice: 0.03,
},
"MiniMax-M2.5-highspeed": {
@@ -4743,9 +4767,9 @@ export const minimaxModels = {
supportsImages: false,
supportsPromptCache: true,
supportsReasoning: true,
inputPrice: 0.3,
outputPrice: 1.2,
cacheWritesPrice: 0.0375,
inputPrice: 0.6,
outputPrice: 2.4,
cacheWritesPrice: 0.375,
cacheReadsPrice: 0.03,
},
"MiniMax-M2.1": {
@@ -4755,7 +4779,7 @@ export const minimaxModels = {
supportsPromptCache: true,
inputPrice: 0.3,
outputPrice: 1.2,
cacheWritesPrice: 0.0375,
cacheWritesPrice: 0.375,
cacheReadsPrice: 0.03,
},
"MiniMax-M2.1-lightning": {
@@ -4763,9 +4787,9 @@ export const minimaxModels = {
contextWindow: 192_000,
supportsImages: false,
supportsPromptCache: true,
inputPrice: 0.3,
inputPrice: 0.6,
outputPrice: 2.4,
cacheWritesPrice: 0.0375,
cacheWritesPrice: 0.375,
cacheReadsPrice: 0.03,
},
"MiniMax-M2": {
@@ -545,6 +545,7 @@ describe("Remote Config Schema", () => {
const validConfig: RemoteConfig = {
version: "v1",
telemetryEnabled: true,
kanbanEnabled: true,
mcpMarketplaceEnabled: true,
yoloModeAllowed: false,
providerSettings: {
@@ -639,6 +640,7 @@ describe("Remote Config Schema", () => {
const config = {
version: "v1",
telemetryEnabled: true,
kanbanEnabled: true,
mcpMarketplaceEnabled: false,
blockPersonalRemoteMCPServers: true,
allowedMCPServers: [{ id: "https://github.com/mcp/filesystem" }, { id: "https://github.com/mcp/github" }],
@@ -769,6 +771,7 @@ describe("Remote Config Schema", () => {
expect(result.version).to.equal("v1")
expect(result.telemetryEnabled).to.equal(true)
expect(result.yoloModeAllowed).to.equal(true)
expect(result.kanbanEnabled).to.equal(true)
expect(result.mcpMarketplaceEnabled).to.equal(false)
expect(result.allowedMCPServers).to.deep.equal(config.allowedMCPServers)
+1
View File
@@ -182,6 +182,7 @@ export const RemoteConfigSchema = z.object({
// General settings not specific to any provider
telemetryEnabled: z.boolean().optional(),
kanbanEnabled: z.boolean().optional(),
// MCP settings
// If this is false, the MCP marketplace is disabled in the extension
+2
View File
@@ -78,6 +78,7 @@ const GLOBAL_STATE_FIELDS = {
},
isNewUser: { default: true as boolean },
welcomeViewCompleted: { default: undefined as boolean | undefined },
cliKanbanMigrationAnnouncementShown: { default: false as boolean },
mcpDisplayMode: { default: DEFAULT_MCP_DISPLAY_MODE as McpDisplayMode },
workspaceRoots: { default: undefined as WorkspaceRoot[] | undefined },
primaryRootIndex: { default: 0 as number },
@@ -272,6 +273,7 @@ const USER_SETTINGS_FIELDS = {
backgroundEditEnabled: { default: false as boolean },
optOutOfRemoteConfig: { default: false as boolean },
doubleCheckCompletionEnabled: { default: false as boolean },
showFeatureTips: { default: true as boolean },
// OpenTelemetry configuration
openTelemetryEnabled: { default: true as boolean },
+14 -1
View File
@@ -42,11 +42,24 @@ e2e("Views - can set up API keys and navigate to Settings from Chat", async ({ s
await expect(apiKeyInput).not.toBeVisible()
await expect(providerSelectorInput).not.toBeVisible()
// New installs may first show the Kanban launch modal, which blocks the
// update announcement modal until it has been dismissed.
const kanbanDialog = sidebar.getByRole("heading", {
name: "Introducing Cline Kanban",
})
try {
await kanbanDialog.waitFor({ state: "visible", timeout: 5_000 })
await sidebar.getByRole("button", { name: "Close" }).click()
await expect(kanbanDialog).not.toBeVisible()
} catch {
// Kanban modal did not appear during this run.
}
// Verify the "What's New" modal is visible for new installs and can be closed.
const dialog = sidebar.getByRole("heading", {
name: /^🎉 New in v\d/,
})
await expect(dialog).toBeVisible()
await expect(dialog).toBeVisible({ timeout: 10_000 })
await sidebar.getByRole("button", { name: "Close" }).click()
await expect(dialog).not.toBeVisible()
+27 -29
View File
@@ -1,6 +1,5 @@
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"
import type { Socket } from "node:net"
import { parse } from "node:url"
import { v4 as uuidv4 } from "uuid"
import type { BalanceResponse, OrganizationBalanceResponse, UserResponse } from "../../../../shared/ClineAccount"
import { E2E_MOCK_API_RESPONSES, E2E_REGISTERED_MOCK_ENDPOINTS } from "./api"
@@ -122,9 +121,9 @@ export class ClineApiServerMock {
log("Starting global server...")
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
// Parse URL and method
const parsedUrl = parse(req.url || "", true)
const path = parsedUrl.pathname || ""
const query = parsedUrl.query
const parsedUrl = new URL(req.url || "/", MOCK_CLINE_API_SERVER_URL)
const path = parsedUrl.pathname
const query = Object.fromEntries(parsedUrl.searchParams.entries())
const method = req.method || "GET"
// Helper to read request body
@@ -454,36 +453,35 @@ export class ClineApiServerMock {
sendChunk()
return
} else {
const response = {
id: generationId,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model,
choices: [
{
index: 0,
message: {
role: "assistant",
content: "Hello! I'm a mock Cline API response.",
},
finish_reason: "stop",
},
],
usage: {
prompt_tokens: 140,
completion_tokens: responseText.length,
total_tokens: 140 + responseText.length,
cost: (140 + responseText.length) * 0.00015,
},
}
return sendJson(response)
}
const response = {
id: generationId,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model,
choices: [
{
index: 0,
message: {
role: "assistant",
content: "Hello! I'm a mock Cline API response.",
},
finish_reason: "stop",
},
],
usage: {
prompt_tokens: 140,
completion_tokens: responseText.length,
total_tokens: 140 + responseText.length,
cost: (140 + responseText.length) * 0.00015,
},
}
return sendJson(response)
}
// Generation details endpoint
if (endpoint === "/generation" && method === "GET") {
const generationId = query.id as string
const generationId = parsedUrl.searchParams.get("id") || ""
const generation = controller.API_USER.getGeneration(generationId)
if (!generation) {
+7 -4
View File
@@ -227,7 +227,9 @@ setTimeout(() => {
describe("Cancellable Hooks", () => {
it("should support user cancellation for cancellable hooks", async function () {
this.timeout(5000)
this.timeout(isWindows ? 10000 : 5000)
const hookDelayMs = isWindows ? 1500 : 500
const abortDelayMs = isWindows ? 300 : 50
// Create a hook that takes some time to execute
await createHookScript(
@@ -236,7 +238,7 @@ setTimeout(() => {
cancel: false,
},
0,
2000, // 2 second delay
hookDelayMs,
)
let capturedAbortController: AbortController | null = null
@@ -259,10 +261,11 @@ setTimeout(() => {
setActiveHookExecution: async (execution) => {
setHookCalled = true
capturedAbortController = execution.abortController
// Abort after capturing the controller
// Give the spawned hook process enough time to become fully active,
// especially on slower Windows/PowerShell CI runners, before aborting.
setTimeout(() => {
capturedAbortController?.abort()
}, 100)
}, abortDelayMs)
},
clearActiveHookExecution: async () => {
clearHookCalled = true
+12 -12
View File
@@ -21,7 +21,7 @@ import { expectVisible } from "../utils.js"
// ---------------------------------------------------------------------------
test.describe("cline --act", () => {
test.use({
program: { file: CLINE_BIN, args: ["--act"] },
program: { file: CLINE_BIN, args: ["--tui", "--act"] },
...TERMINAL_WIDE,
env: clineEnv("default"),
})
@@ -37,7 +37,7 @@ test.describe("cline --act", () => {
// ---------------------------------------------------------------------------
test.describe("cline --plan", () => {
test.use({
program: { file: CLINE_BIN, args: ["--plan"] },
program: { file: CLINE_BIN, args: ["--tui", "--plan"] },
...TERMINAL_WIDE,
env: clineEnv("default"),
})
@@ -53,7 +53,7 @@ test.describe("cline --plan", () => {
// ---------------------------------------------------------------------------
test.describe("cline --timeout (interactive mode, flag ignored) ⚠️", () => {
test.use({
program: { file: CLINE_BIN, args: ["--timeout", "30"] },
program: { file: CLINE_BIN, args: ["--tui", "--timeout", "30"] },
...TERMINAL_WIDE,
env: clineEnv("default"),
})
@@ -71,7 +71,7 @@ test.describe("cline --model (interactive mode, flag ignored) ⚠️", () => {
test.use({
program: {
file: CLINE_BIN,
args: ["--model", "claude-3-5-haiku-20241022"],
args: ["--tui", "--model", "claude-3-5-haiku-20241022"],
},
...TERMINAL_WIDE,
env: clineEnv("default"),
@@ -88,7 +88,7 @@ test.describe("cline --model (interactive mode, flag ignored) ⚠️", () => {
// ---------------------------------------------------------------------------
test.describe("cline --verbose (interactive mode, flag ignored) ⚠️", () => {
test.use({
program: { file: CLINE_BIN, args: ["--verbose"] },
program: { file: CLINE_BIN, args: ["--tui", "--verbose"] },
...TERMINAL_WIDE,
env: clineEnv("default"),
})
@@ -104,7 +104,7 @@ test.describe("cline --verbose (interactive mode, flag ignored) ⚠️", () => {
// ---------------------------------------------------------------------------
test.describe("cline --cwd <dir>", () => {
test.use({
program: { file: CLINE_BIN, args: ["--cwd", "/tmp"] },
program: { file: CLINE_BIN, args: ["--tui", "--cwd", "/tmp"] },
...TERMINAL_WIDE,
env: clineEnv("default"),
})
@@ -116,7 +116,7 @@ test.describe("cline --cwd <dir>", () => {
test.describe("cline -c <dir> (short alias)", () => {
test.use({
program: { file: CLINE_BIN, args: ["-c", "/tmp"] },
program: { file: CLINE_BIN, args: ["--tui", "-c", "/tmp"] },
...TERMINAL_WIDE,
env: clineEnv("default"),
})
@@ -134,7 +134,7 @@ test.describe("cline --config (claude-sonnet-4.6)", () => {
test.use({
program: {
file: CLINE_BIN,
args: ["--config", "configs/claude-sonnet-4.6"],
args: ["--tui", "--config", "configs/claude-sonnet-4.6"],
},
...TERMINAL_WIDE,
env: clineEnv("claude-sonnet-4.6"),
@@ -152,7 +152,7 @@ test.describe("cline --config (claude-sonnet-4.6)", () => {
// ---------------------------------------------------------------------------
test.describe("cline --thinking ⚠️", () => {
test.use({
program: { file: CLINE_BIN, args: ["--thinking"] },
program: { file: CLINE_BIN, args: ["--tui", "--thinking"] },
...TERMINAL_WIDE,
env: clineEnv("default"),
})
@@ -169,7 +169,7 @@ test.describe("cline --thinking ⚠️", () => {
// ---------------------------------------------------------------------------
test.describe("cline --reasoning-effort ⚠️", () => {
test.use({
program: { file: CLINE_BIN, args: ["--reasoning-effort", "high"] },
program: { file: CLINE_BIN, args: ["--tui", "--reasoning-effort", "high"] },
...TERMINAL_WIDE,
env: clineEnv("default"),
})
@@ -186,7 +186,7 @@ test.describe("cline --max-consecutive-mistakes", () => {
test.use({
program: {
file: CLINE_BIN,
args: ["--max-consecutive-mistakes", "5"],
args: ["--tui", "--max-consecutive-mistakes", "5"],
},
...TERMINAL_WIDE,
env: clineEnv("default"),
@@ -202,7 +202,7 @@ test.describe("cline --max-consecutive-mistakes", () => {
// ---------------------------------------------------------------------------
test.describe("cline --double-check-completion", () => {
test.use({
program: { file: CLINE_BIN, args: ["--double-check-completion"] },
program: { file: CLINE_BIN, args: ["--tui", "--double-check-completion"] },
...TERMINAL_WIDE,
env: clineEnv("default"),
})
+1
View File
@@ -30,6 +30,7 @@ test.describe("root flag descriptions", () => {
"Output messages as JSON",
"first completion attempt",
"ACP",
"legacy terminal UI",
"Resume an existing task",
])
})
+1
View File
@@ -43,6 +43,7 @@ test.describe("cline --help", () => {
"--json",
"--double-check-completion",
"--acp",
"--tui",
"--taskId",
])
})
+1 -1
View File
@@ -5,7 +5,7 @@ import { expectVisible, testEnv, typeAndSubmit } from "./utils.js"
test.describe("cline interactive basics", () => {
test.use({
program: { file: CLINE_BIN, args: [] },
program: { file: CLINE_BIN, args: ["--tui"] },
rows: 50,
columns: 120,
env: testEnv("default"),
+8 -8
View File
@@ -43,7 +43,7 @@ import { expectVisible } from "../helpers/terminal.js"
// ---------------------------------------------------------------------------
test.describe("cline (unauthenticated) — shows auth view", () => {
test.use({
program: { file: CLINE_BIN, args: [] },
program: { file: CLINE_BIN, args: ["--tui"] },
...TERMINAL_WIDE,
env: clineEnv("unauthenticated"),
})
@@ -60,7 +60,7 @@ test.describe("cline (unauthenticated) — shows auth view", () => {
// ---------------------------------------------------------------------------
test.describe("cline (authenticated) — shows chat view", () => {
test.use({
program: { file: CLINE_BIN, args: [] },
program: { file: CLINE_BIN, args: ["--tui"] },
...TERMINAL_WIDE,
env: clineEnv("claude-sonnet-4.6"),
})
@@ -75,7 +75,7 @@ test.describe("cline (authenticated) — shows chat view", () => {
// ---------------------------------------------------------------------------
test.describe("/settings — tab navigation", () => {
test.use({
program: { file: CLINE_BIN, args: [] },
program: { file: CLINE_BIN, args: ["--tui"] },
...TERMINAL_WIDE,
env: clineEnv("claude-sonnet-4.6"),
})
@@ -115,7 +115,7 @@ test.describe("/settings — tab navigation", () => {
// ---------------------------------------------------------------------------
test.describe("/models — model browser", () => {
test.use({
program: { file: CLINE_BIN, args: [] },
program: { file: CLINE_BIN, args: ["--tui"] },
...TERMINAL_WIDE,
env: clineEnv("claude-sonnet-4.6"),
})
@@ -138,7 +138,7 @@ test.describe("/models — model browser", () => {
// ---------------------------------------------------------------------------
test.describe("/history — task history", () => {
test.use({
program: { file: CLINE_BIN, args: [] },
program: { file: CLINE_BIN, args: ["--tui"] },
...TERMINAL_WIDE,
env: clineEnv("claude-sonnet-4.6"),
})
@@ -161,7 +161,7 @@ test.describe("/history — task history", () => {
// ---------------------------------------------------------------------------
test.describe("/skills — skills view", () => {
test.use({
program: { file: CLINE_BIN, args: [] },
program: { file: CLINE_BIN, args: ["--tui"] },
...TERMINAL_WIDE,
env: clineEnv("default"),
})
@@ -184,7 +184,7 @@ test.describe("/skills — skills view", () => {
// ---------------------------------------------------------------------------
test.describe("Plan/Act mode toggle", () => {
test.use({
program: { file: CLINE_BIN, args: [] },
program: { file: CLINE_BIN, args: ["--tui"] },
...TERMINAL_WIDE,
env: clineEnv("default"),
})
@@ -204,7 +204,7 @@ test.describe("Plan/Act mode toggle", () => {
// ---------------------------------------------------------------------------
test.describe("Auto-approve all — Shift+Tab toggle", () => {
test.use({
program: { file: CLINE_BIN, args: [] },
program: { file: CLINE_BIN, args: ["--tui"] },
...TERMINAL_WIDE,
env: clineEnv("default"),
})
+55 -14
View File
@@ -1,7 +1,8 @@
import type { Boolean, EmptyRequest } from "@shared/proto/cline/common"
import { useEffect } from "react"
import { useCallback, useEffect, useState } from "react"
import AccountView from "./components/account/AccountView"
import ChatView from "./components/chat/ChatView"
import ClineKanbanLaunchModal, { CLINE_KANBAN_MODAL_DISMISS_ID } from "./components/common/ClineKanbanLaunchModal"
import HistoryView from "./components/history/HistoryView"
import McpView from "./components/mcp/configuration/McpConfigurationView"
import OnboardingView from "./components/onboarding/OnboardingView"
@@ -11,13 +12,14 @@ import WorktreesView from "./components/worktrees/WorktreesView"
import { useClineAuth } from "./context/ClineAuthContext"
import { useExtensionState } from "./context/ExtensionStateContext"
import { Providers } from "./Providers"
import { UiServiceClient } from "./services/grpc-client"
import { StateServiceClient, UiServiceClient } from "./services/grpc-client"
const AppContent = () => {
const {
didHydrateState,
showWelcome,
shouldShowAnnouncement,
dismissedBanners,
showMcp,
mcpTab,
showSettings,
@@ -37,23 +39,61 @@ const AppContent = () => {
hideWorktrees,
hideAnnouncement,
} = useExtensionState()
const [showKanbanModal, setShowKanbanModal] = useState(false)
const [hasShownKanbanModal, setHasShownKanbanModal] = useState(false)
const { clineUser, organizations, activeOrganization } = useClineAuth()
useEffect(() => {
if (shouldShowAnnouncement) {
setShowAnnouncement(true)
const showUpdateAnnouncementModal = useCallback(() => {
setShowAnnouncement(true)
UiServiceClient.onDidShowAnnouncement({} as EmptyRequest)
.then((response: Boolean) => {
setShouldShowAnnouncement(response.value)
})
.catch((error) => {
console.error("Failed to acknowledge announcement:", error)
})
}, [setShouldShowAnnouncement, setShowAnnouncement])
// Use the gRPC client instead of direct WebviewMessage
UiServiceClient.onDidShowAnnouncement({} as EmptyRequest)
.then((response: Boolean) => {
setShouldShowAnnouncement(response.value)
})
.catch((error) => {
console.error("Failed to acknowledge announcement:", error)
})
useEffect(() => {
if (!didHydrateState || showWelcome || hasShownKanbanModal) {
return
}
}, [shouldShowAnnouncement, setShouldShowAnnouncement, setShowAnnouncement])
const hasDismissedKanbanModal = dismissedBanners?.some((banner) => banner.bannerId === CLINE_KANBAN_MODAL_DISMISS_ID)
if (!hasDismissedKanbanModal) {
setShowKanbanModal(true)
}
setHasShownKanbanModal(true)
}, [didHydrateState, dismissedBanners, hasShownKanbanModal, showWelcome])
// Keep update announcements queued until the Kanban modal has either shown and closed or been skipped.
useEffect(() => {
if (!didHydrateState || showWelcome || !shouldShowAnnouncement || showAnnouncement) {
return
}
const isKanbanModalBlocking = showKanbanModal || !hasShownKanbanModal
if (isKanbanModalBlocking) {
return
}
showUpdateAnnouncementModal()
}, [
didHydrateState,
showWelcome,
shouldShowAnnouncement,
showAnnouncement,
showKanbanModal,
hasShownKanbanModal,
showUpdateAnnouncementModal,
])
const handleCloseKanbanModal = useCallback((doNotShowAgain: boolean) => {
setShowKanbanModal(false)
if (doNotShowAgain) {
StateServiceClient.dismissBanner({ value: CLINE_KANBAN_MODAL_DISMISS_ID }).catch((error) =>
console.error("Failed to persist Cline Kanban modal dismissal:", error),
)
}
}, [])
if (!didHydrateState) {
return null
@@ -65,6 +105,7 @@ const AppContent = () => {
return (
<div className="flex h-screen w-full flex-col">
<ClineKanbanLaunchModal onClose={handleCloseKanbanModal} open={showKanbanModal} />
{showSettings && <SettingsView onDone={hideSettings} targetSection={settingsTargetSection} />}
{showHistory && <HistoryView onDone={hideHistory} />}
{showMcp && <McpView initialTab={mcpTab} onDone={closeMcpView} />}
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fa7c90bf657f317e63dee745e010898ca0cb13ee82e9dbe341ac16199170399a
size 3738635
+2 -1
View File
@@ -155,6 +155,7 @@ export const ChatRowContent = memo(
onRelinquishControl,
vscodeTerminalExecutionMode,
clineMessages,
showFeatureTips,
} = useExtensionState()
const [seeNewChangesDisabled, setSeeNewChangesDisabled] = useState(false)
const [explainChangesDisabled, setExplainChangesDisabled] = useState(false)
@@ -903,7 +904,7 @@ export const ChatRowContent = memo(
showTitle={true}
title={isReasoningStreaming ? "Thinking..." : "Thinking"}
/>
{isReasoningStreaming && <FeatureTip />}
{isReasoningStreaming && showFeatureTips !== false && <FeatureTip />}
</div>
)
}
@@ -43,6 +43,9 @@ const FEATURE_TIPS: FeatureTipItem[] = [
{
text: "Use /reportbug to quickly file a GitHub issue with diagnostic context included.",
},
{
text: 'You can disable these tips in Settings → Features → "Feature Tips".',
},
]
const SHOW_DELAY_MS = 2000
@@ -0,0 +1,121 @@
import { StringRequest } from "@shared/proto/cline/common"
import { VSCodeButton, VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
import React, { useEffect, useMemo, useState } from "react"
import kanbanDemoVideo from "@/assets/cline_kanban_demo.mp4"
import { Dialog, DialogContent } from "@/components/ui/dialog"
import { PLATFORM_CONFIG, PlatformType } from "@/config/platform.config"
import { FileServiceClient, StateServiceClient } from "@/services/grpc-client"
const INSTALL_COMMAND = "npm install -g cline"
const COPIED_TIMEOUT = 1500
const kanbanDemoVideoSrc = kanbanDemoVideo.startsWith("/src/")
? new URL(kanbanDemoVideo, import.meta.url).toString()
: kanbanDemoVideo
export const CLINE_KANBAN_MODAL_DISMISS_ID = "cline-kanban-launch-modal-v1"
interface ClineKanbanLaunchModalProps {
open: boolean
onClose: (doNotShowAgain: boolean) => void
}
export const ClineKanbanLaunchModal: React.FC<ClineKanbanLaunchModalProps> = ({ open, onClose }) => {
const [doNotShowAgain, setDoNotShowAgain] = useState(false)
const [isInstalling, setIsInstalling] = useState(false)
const [copied, setCopied] = useState(false)
const isVsCode = useMemo(() => PLATFORM_CONFIG.type === PlatformType.VSCODE, [])
useEffect(() => {
if (open) {
setCopied(false)
setIsInstalling(false)
}
}, [open])
const handleAction = async () => {
if (isVsCode) {
setIsInstalling(true)
try {
await StateServiceClient.installClineCli({})
} catch (error) {
console.error("Failed to run CLI install command:", error)
} finally {
setIsInstalling(false)
}
return
}
try {
await FileServiceClient.copyToClipboard(StringRequest.create({ value: INSTALL_COMMAND }))
setCopied(true)
setTimeout(() => setCopied(false), COPIED_TIMEOUT)
} catch (error) {
console.error("Failed to copy CLI install command:", error)
}
}
return (
<Dialog onOpenChange={(isOpen) => !isOpen && onClose(doNotShowAgain)} open={open}>
<DialogContent
aria-describedby="cline-kanban-description"
aria-labelledby="cline-kanban-title"
className="pt-4 px-5 pb-4 gap-0 max-w-2xl">
<div className="space-y-3" id="cline-kanban-description">
<div className="pr-6 min-h-6 flex items-center">
<h2
className="m-0 text-lg font-semibold"
id="cline-kanban-title"
style={{ color: "var(--vscode-editor-foreground)" }}>
Introducing Cline Kanban
</h2>
</div>
<video
autoPlay
className="w-full rounded-md border border-[var(--vscode-editorGroup-border)]"
loop
muted
playsInline
src={kanbanDemoVideoSrc}
/>
<p className="text-sm" style={{ color: "var(--vscode-descriptionForeground)" }}>
A replacement for your IDE better suited for running many agents in parallel and reviewing diffs. Enable
auto-commit and link cards together to create dependency chains that complete large amounts of work
autonomously.
</p>
<div className="p-1">
<code className="block rounded-sm px-2 py-1 bg-[var(--vscode-textCodeBlock-background)] text-sm">
{INSTALL_COMMAND}
</code>
<div className="mt-3">
<VSCodeButton disabled={isInstalling} onClick={handleAction}>
{isVsCode
? isInstalling
? "Running install command..."
: "Run in terminal"
: copied
? "Copied"
: "Copy command"}
</VSCodeButton>
</div>
</div>
<div className="pt-2">
<VSCodeCheckbox
checked={doNotShowAgain}
onChange={(e: any) => {
setDoNotShowAgain(e.target.checked === true)
}}>
Do not show again
</VSCodeCheckbox>
</div>
</div>
</DialogContent>
</Dialog>
)
}
export default ClineKanbanLaunchModal
@@ -8,6 +8,7 @@ vi.mock("@/context/ExtensionStateContext", () => ({
useExtensionState: vi.fn(() => ({
enableCheckpointsSetting: true,
hooksEnabled: false,
showFeatureTips: false,
mcpDisplayMode: "rich",
strictPlanModeEnabled: false,
yoloModeToggled: false,
@@ -41,6 +42,18 @@ describe("FeatureSettingsSection", () => {
expect(agentSection?.querySelector("#Hooks")).toBeNull()
})
it("renders Feature Tips toggle in the Editor section", () => {
const { container } = render(<FeatureSettingsSection renderSectionHeader={() => null} />)
expect(screen.getByText("Feature Tips")).toBeTruthy()
const editorSection = container.querySelector("#optional-features")
const agentSection = container.querySelector("#agent-features")
expect(editorSection?.querySelector('[id="Feature Tips"]')).toBeTruthy()
expect(agentSection?.querySelector('[id="Feature Tips"]')).toBeNull()
})
it("calls updateSetting with hooksEnabled when toggled", () => {
const { container } = render(<FeatureSettingsSection renderSectionHeader={() => null} />)
@@ -51,4 +64,15 @@ describe("FeatureSettingsSection", () => {
expect(mockUpdateSetting).toHaveBeenCalledWith("hooksEnabled", true)
})
it("calls updateSetting with showFeatureTips when toggled", () => {
const { container } = render(<FeatureSettingsSection renderSectionHeader={() => null} />)
const featureTipsSwitch = container.querySelector('[id="Feature Tips"]')
expect(featureTipsSwitch).toBeTruthy()
fireEvent.click(featureTipsSwitch as Element)
expect(mockUpdateSetting).toHaveBeenCalledWith("showFeatureTips", true)
})
})
@@ -79,6 +79,13 @@ const agentFeatures: FeatureToggle[] = [
]
const editorFeatures: FeatureToggle[] = [
{
id: "show-feature-tips",
label: "Feature Tips",
description: "Show rotating tips during the thinking phase to help you discover Cline features.",
stateKey: "showFeatureTips",
settingKey: "showFeatureTips",
},
{
id: "background-edit",
label: "Background Edit",
@@ -211,6 +218,7 @@ const FeatureSettingsSection = ({ renderSectionHeader }: FeatureSettingsSectionP
enableParallelToolCalling,
backgroundEditEnabled,
doubleCheckCompletionEnabled,
showFeatureTips,
} = useExtensionState()
const handleFocusChainIntervalChange = useCallback(
@@ -224,6 +232,7 @@ const FeatureSettingsSection = ({ renderSectionHeader }: FeatureSettingsSectionP
// State lookup for mapped features
const featureState: Record<string, boolean | undefined> = {
showFeatureTips,
enableCheckpointsSetting,
strictPlanModeEnabled,
hooksEnabled,
@@ -277,6 +277,7 @@ export const ExtensionStateContextProvider: React.FC<{
lastDismissedCliBannerVersion: 0,
backgroundEditEnabled: false,
doubleCheckCompletionEnabled: false,
showFeatureTips: true,
globalSkillsToggles: {},
localSkillsToggles: {},
+1
View File
@@ -38,6 +38,7 @@ if (!VALID_PLATFORMS.includes(platform)) {
console.log("Building webview for", platform)
export default defineConfig({
base: "./",
optimizeDeps: {
force: true, // Forces re-optimization
},