Compare commits

...

71 Commits

Author SHA1 Message Date
Dominic Cooney eab96e6a8a Simplify symbol formatting: omit () from all symbol names
Appending () is language-specific and can mislead for TypeScript,
Obj-C, etc. The kind suffix (— function, — class, etc.) already
communicates the symbol type clearly.
2026-03-27 15:15:48 +09:00
Dominic Cooney 00526721d7 Gate telemetry behind isCategoryEnabled('code_intelligence')
Without this, the code_intelligence category in the telemetry defaults
map had no effect — captureToolUsage doesn't check per-category gating.
Now telemetry is only emitted when the category is enabled, matching the
pattern used by browser, checkpoints, skills, focus_chain, and subagents.
2026-03-27 15:03:36 +09:00
Dominic Cooney 9852d459ba Address review feedback: fix validation ordering and symbol formatting
- Move consecutiveMistakeCount reset after parseQueries validation so
  empty-but-present queries (only comments/whitespace) correctly
  increment the mistake counter instead of silently resetting it
- Move say() call after query parsing so no dangling tool-start UI
  message appears when queries are invalid
- Only append () to callable symbol kinds (function, method, constructor)
  instead of unconditionally on all symbols, preventing misleading
  output like MyClass() or MY_CONSTANT()
2026-03-27 14:53:33 +09:00
Dominic Cooney 9153f36989 Fix cross-platform path handling in CodeIntelligenceToolHandler
Use toPosix() to normalize Windows backslash paths before splitting
in shortenPath(), ensuring consistent display on all platforms (Windows
JetBrains included).
2026-03-27 14:04:51 +09:00
Dominic Cooney 08047d6efd Add code intelligence settings UI, tool gating, and tests
- Add codeIntelligenceEnabled setting to state-keys, proto, and updateSettings
- Gate code_intelligence tool behind codeIntelligenceAvailable context flag
- Add settings toggle in Experimental section, only visible when PSI available
- Wire codeIntelligenceEnabled/Available through Controller -> webview state
- Add telemetry capture for code-intelligence tool usage
- Add CodeIntelligenceToolHandler unit tests (9 tests)
- Add code-intelligence context variation to system prompt integration tests
- Generate 12 new snapshots for code-intelligence across all model families
2026-03-27 13:51:27 +09:00
Dominic Cooney b970af74ca Add a code intelligence service. 2026-03-27 13:51:27 +09:00
Dominic Cooney a915122c6d fix: Transcode kanban demo video from H.264/MP4 to VP9/WebM (#9997)
JetBrains IDEs use JCEF (Chromium Embedded Framework) for webviews,
which often does not include H.264 decoding due to licensing
restrictions. This causes the kanban demo video to fail to play.

Transcode the video from H.264/MP4 to VP9/WebM, which is a royalty-free
codec universally supported in Chromium derivatives. This also reduces
the file size from 3.6MB to ~900KB.

Updated all references:
- Source component import (ClineKanbanLaunchModal.tsx)
- Git LFS tracking (.gitattributes)
- CI workflow LFS checks (publish.yml, publish-nightly.yml)
2026-03-27 07:39:05 +09:00
Robin Newhouse 835ed94736 fix: exclude new_task tool from system prompt in yolo/headless mode (#9958) 2026-03-26 12:10:47 -07:00
Robin Newhouse 00ca3d13fa test(oca): add messages api routing and stream helper tests (#9494) 2026-03-26 12:02:13 -07:00
CandiedUniverse 3f3a87aed9 Hooks: Notification hook polish (#9909)
* Create implementation plan doc

* Implement notification hook helper

* Remove generated proto artifacts from branch

* Remove implementation plan doc

* Update src/core/hooks/__tests__/notification-hook.test.ts

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

* Update src/core/hooks/notification-hook.ts

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

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Tony Loehr <turingxo@gmail.com>
2026-03-26 10:10:15 -07:00
John Simone 7a0f11837e Add Kanban docs (#9988)
* add kanban docs

* fix rendering error in overview, drop kanban flags from commands

* fix stray link

---------

Co-authored-by: Renee Huang <100229782+reneehuang1@users.noreply.github.com>
2026-03-26 08:12:09 -07:00
Max 03d2d01eed chore: bump version to 3.76.0 (#9986) 2026-03-26 07:52:28 -07:00
Dominic Cooney db1b1c45bd chore: update dependencies flagged by npm audit (#9980) 2026-03-26 21:03:35 +09: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
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
Ara bde7049c01 Release v3.74.0 Notes (#9879) 2026-03-18 19:08:50 -07:00
Ara 7cd06744ad feat: implement dynamic free model detection for Cline API (#9878)
Replace hardcoded free models list with runtime resolution from
recommended models. The handler now dynamically fetches free model
IDs using refreshClineRecommendedModels with fallback to static
defaults, and normalizes model IDs for consistent comparison.
2026-03-18 18:55:06 -07:00
shey-cline c88d3238cf Replace Error Message When Not Logged In to Cline (#9632) 2026-03-18 16:01:06 -07:00
jessy-cline 852ca2348f fix(ui): align ClineRulesToggleModal padding with ServersToggleModal (#9870)
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-03-18 14:13:39 -07:00
CandiedUniverse 7091ccf2c7 Fix flaky CLI Enter-key handling across Windows/test environments (#9867)
* fix(cli): normalize enter key handling in ink inputs

* fix(cli): pass input through auth select enter handling
2026-03-18 12:20:25 -07:00
Mathis ad6c33ac5b feat: add file read deduplication cache to prevent repeated reads (#9836)
* feat: add file read deduplication cache to prevent repeated reads

- Add fileReadCache to TaskState for tracking read files per task
- ReadFileToolHandler checks cache before reading, returns cached content on repeat reads
- Warns model after 3+ reads of same file to stop re-reading
- WriteToFileToolHandler and ApplyPatchHandler invalidate cache on file changes
- Reduces wasted API tokens from models reading same files repeatedly

* fix: address file read cache gaps - image blocks, execute_command, redundant invalidation

* Update src/core/task/tools/handlers/ExecuteCommandToolHandler.ts

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

* PR changes

- __`TaskState.ts`__ — Simplified cache type from `{ content: string; readCount: number; imageBlock? }` to `{ readCount: number; mtime: number; imageBlock? }`. Dropped `content` to save memory; added `mtime` for external change detection.

- __`ReadFileToolHandler.ts`__ — Four improvements:

  - __Removed redundant `.set()` call__ — reviewer was correct that objects are modified by reference in Map
  - __Added mtime-based cache validation__ — on cache hit, `stat()` the file and compare mtime. If the file was modified externally (user edited in their editor), the cache entry is evicted and a fresh read occurs
  - __Dropped content from cache__ — cache now only stores metadata (readCount, mtime, imageBlock). On cache hits, the file is re-read from disk, addressing memory concerns
  - __Softened readCount >= 3 warning__ — removed the aggressive "Do NOT read this file again" language; now says "Please use the information you already have and proceed with your task"

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-18 10:38:23 -07:00
Tony Loehr 71e312e92a feat: add feature tips tooltip during thinking state (#9799)
* feat: add feature tips tooltip during thinking state

Show rotating feature tips below the Thinking indicator to keep users engaged and educate them on features like Double-Check Completion, .clinerules, Plan Mode, MCP Servers, checkpoints, and more.

- New FeatureTip component with 12 curated tips
- 2s delayed appearance, 8s cycling with fade transitions
- Visible throughout entire thinking/reasoning phase
- Proper timer cleanup on unmount

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

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

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

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

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

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

* fix: remove unused useMemo import from FeatureTip

* fix: increase test delays in QuitCommand.test.tsx for Windows CI reliability

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

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

* fix: add smooth fade-in for first tooltip appearance

The first tooltip was appearing abruptly because the element went from
not-in-DOM (return null) to opacity-100 instantly. Added hasFadedIn state
with requestAnimationFrame to ensure the CSS transition applies on initial
render, giving the first tip a smooth 300ms fade-in.

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-17 13:51:37 -07:00
Charlotte Stinson 5903840f79 Added Fields to Responses API for the Oracle Code Assist (OCA) Provider (#9852)
* added fields to responses api

* fix temp problem

* cleaning up

* clean modelId

* Apply suggestion from @greptile-apps[bot]

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

---------

Co-authored-by: Charlotte Stinson <charlottestinson@Charlottes-MacBook-Pro.local>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-17 13:41:39 -07:00
ClineXDiego 0c677b63db fix(browser): skip WebP for GLM and Devstral models running through llama.cpp (#9837)
llama.cpp's STB image library doesn't support WebP format. Users running
GLM 4.6V, GLM 4.5, and Devstral models via llama.cpp server (openai-compatible
endpoint) were hitting a 400 error when using the browser tool because Cline
sends screenshots as WebP by default.

modelDoesntSupportWebp() only checked for Grok models. Extend it to also
cover GLM and Devstral model families using the existing family detection
functions. Also update isGLMModelFamily() to handle space-separated model IDs
like 'GLM 4.6V' (the format llama.cpp server reports for this model).

Fixes #8203
2026-03-17 14:57:40 -03:00
ClineXDiego b741135f85 fix(litellm): respect user-configured context window in getModel() (#9834) 2026-03-17 05:36:44 -07:00
Ara 2eab216815 fix(wandb): honor explicit model IDs outside static catalog (#9839) 2026-03-16 16:40:28 -07:00
Ara e57174eec8 fix(fireworks): add missing serverless models and pricing (#9810)
* feat: refresh Fireworks serverless model defaults

* fix(fireworks): handle cached token usage and write pricing

* fixing maxtokens for gemini family

* feat(fireworks): add missing serverless models and pricing
2026-03-16 14:28:23 -07:00
Max ad4631c682 bump cline version (#9833) 2026-03-16 13:36:41 -07:00
Mathis aae23e8685 fix: Claude Code provider failing with 4.6 models and newer CLI versions (#9783)
* fix: Claude Code provider failing with 4.6 models and newer CLI versions

- Update --disallowedTools list to match current Claude Code CLI tools
  (12 new tools were unblocked, causing models to use native tool_use
  instead of Cline's XML tools)
- Fix rate_limit_event handling for new CLI format (top-level type
  instead of system subtype)
- Handle unknown content block types and new message types gracefully
- Fix assistantHasContent check to account for tool calls accumulated
  via toolUseHandler even when useNativeToolCalls is false

* resolved .include mismatch to .containEql

* Update src/integrations/claude-code/types.ts

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

* Removed `LegacyRateLimitEvent` type and its union reference from `types.ts`

* Fixed loop with async tool calls in new claude code.

* PR review feedback fixes

__Fix #2 (claude-code.ts):__ Cleaned up the error field check — replaced verbose `"error" in message` guard + ternary chain with a simpler `message.error` check using optional chaining and nullish coalescing (`message.content?.[0]` + `?? fallback`).

__Fix #3 (claude-code.ts):__ Replaced `message.content.length > 0 ? message.content[0] : undefined` with `message.content?.[0]` using optional chaining for the `stop_reason` block.

__Fix #4 (claude-code.ts):__ Replaced repeated `(content as any)` casts in the `default` switch case with a single typed cast: `const unknownBlock = content as { type: string; text?: string }`, making the code cleaner and safer.

__Fix #5 (ApplyPatchHandler.ts):__ Replaced both `await import("node:path")` and `require("node:path")` dynamic imports with a static `import { resolve as resolvePath } from "node:path"` at the top of the file.

* Remove file read deduplication feature (moved to separate PR)

* Remove ReadFileToolHandler file-not-found test (moved to separate PR)

* Add LegacyRateLimitEvent type for older CLI format

* Restore ReadFileToolHandler.ts and test from upstream/main (fix stale local main revert)

* Revert ReadFileToolHandler.ts to match fork main (no try/catch, no test file)

* manually reverting back

* Update src/core/api/providers/claude-code.ts

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

* Restore ReadFileToolHandler.ts and test to match cline/cline upstream main

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-16 13:01:55 -07:00
dependabot[bot] 0ff27591d5 chore(deps-dev): bump lodash (#8803)
Bumps the npm_and_yarn group with 1 update in the /testing-platform directory: [lodash](https://github.com/lodash/lodash).


Updates `lodash` from 4.17.21 to 4.17.23
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.17.23)

---
updated-dependencies:
- dependency-name: lodash
  dependency-version: 4.17.23
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-16 12:25:46 -07:00
dependabot[bot] 23127f22de chore(deps): bump qs from 6.14.1 to 6.15.0 (#9343)
Bumps [qs](https://github.com/ljharb/qs) from 6.14.1 to 6.15.0.
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.14.1...v6.15.0)

---
updated-dependencies:
- dependency-name: qs
  dependency-version: 6.15.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-16 12:00:02 -07:00
CandiedUniverse 57ed14d1b1 Fix issue with Windows notification (#9743)
* Fix issue with Windows notification

Fix Windows proto tooling

Fix Windows unit test path normalization

Revert "Fix Windows unit test path normalization"

This reverts commit 73400a3ca6f0300d009f7c8238a016d769186f3a.

Remove package-lock.json changes

* Remove unnecessary changes

* Use command approval string for notifications

* Address PR feedback on Windows notifications

* Fix Windows protoc path for CI

* Polish notification safety and test coverage

* Fix unfound tests in CI

* Harden Windows notifications and protoc execution

* Fix Windows path normalization in glob test

* Fix as per Greptile feedback
2026-03-16 09:44:20 -07:00
aikido-autofix[bot] a769585406 fix(security): update dependencies (#9812)
Co-authored-by: aikido-autofix[bot] <119856028+aikido-autofix[bot]@users.noreply.github.com>
Co-authored-by: Max <maxpaulus43@gmail.com>
2026-03-16 09:15:40 -07:00
CandiedUniverse c44b29b002 Fix Windows CLI tests related to /q and /exit (#9747)
* Fix flaky Windows CLI quit slash tests

* Refine CLI slash command handling and test stability

* Address Greptile cleanup feedback
2026-03-16 07:59:26 -07:00
dependabot[bot] bb4e397a51 chore(deps): bump undici from 7.20.0 to 7.24.3 (#9825)
Bumps [undici](https://github.com/nodejs/undici) from 7.20.0 to 7.24.3.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.20.0...v7.24.3)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 7.24.3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-16 07:52:50 -07:00
Saoud Rizwan 9824d8d476 Bump CLI version from 2.7.0 to 2.7.1 2026-03-15 20:36:02 -07:00
Saoud Rizwan a46c5288ca Fix Notification hook getting called by command output asks 2026-03-15 20:08:06 -07:00
Mohammad Bakir 91b947de69 feat: Add W&B Inference by Coreweave as provider (#9800)
* feat(wandb): add W&B Inference by CoreWeave provider

Adds support for W&B Inference as an API provider using a W&B API key.
Implements a provider handler with OpenAI-compatible streaming and a static
model catalog, and wires the provider through the API layer, configuration
schema, storage, CLI model picker, and settings UI.

* Updated input/output price of NVIDIA-Nemotron

* Updated helpText

* handle reasoning tokens in streaming respons

* Added clarifying comment on how W&B token usage is reported and why cached tokens

* fix: restore proto field numbers changed by generation script

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

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

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-13 16:06:35 -07:00
Saoud Rizwan 7b25a21b26 fix(cli): add -y flag to npx kanban to auto-confirm install 2026-03-13 15:47:18 -07:00
Ara 1d1071dcf5 fix: consolidate Parallel tool-calling fixes (#9738)
* fix: consolidate parallel tool-calling fixes

* test(snapshot): fix vertex gemini3 snapshot newline

* fix gemini toolcall id collision (#9768)

* test(snapshot): fix vertex gemini3 snapshot newline

* fix(gemini): prevent native tool-call ID collisions

* fixing maxtokens for gemini family

* test(gemini): assert fallback tool call ids
2026-03-13 09:20:23 -07:00
Dominic Cooney 8d07b7d6cb fix: catch errors in path-based tool handlers instead of crashing (#9732)
* fix: catch errors in path-based tool handlers instead of crashing

ListCodeDefinitionNamesToolHandler, ListFilesToolHandler, and
SearchFilesToolHandler let exceptions from their core operations
propagate through ToolExecutor's re-throw path, crashing the CLI
process. This is the same class of bug fixed for ReadFileToolHandler
in #9730.

Changes per handler:
- Wrap the core operation in try/catch, returning formatResponse.toolError()
  on failure so the model can see the error and recover gracefully.
- Move consecutiveMistakeCount reset to after a successful operation so
  repeated failures accumulate toward the yolo-mode mistake limit.
- Increment consecutiveMistakeCount on caught errors.

Add end-to-end tests exercising each handler with a mock TaskConfig,
covering: non-existent paths, missing parameters, failure accumulation,
and success-based counter reset.

* address review: expand try/catch scope, add stub-based tests

- Include resolveWorkspacePath inside try/catch in
  ListCodeDefinitionNamesToolHandler and ListFilesToolHandler (matching
  SearchFilesToolHandler's pattern) so path resolution failures are
  also caught gracefully.
- Fix trivially-true assertion in file-not-a-directory test.
- Add 6 new stub-based tests that force core operations to throw:
  parseSourceCodeForDefinitionsTopLevel, listFiles, and
  determineSearchPaths — verifying the catch paths return
  formatResponse.toolError() and increment consecutiveMistakeCount.
- Total: 19 passing tests (up from 13).

* address review: move clineignore check before IO in ListFilesToolHandler

Move the .clineignore access validation before resolveWorkspacePath and
listFiles so blocked paths are rejected without incurring IO cost.
Also ensures consecutiveMistakeCount is only reset after all
validations and the core operation succeed.

* address review: increment counter on clineignore denial

Clineignore denial in ListFilesToolHandler now increments
consecutiveMistakeCount so repeated attempts at blocked paths
accumulate toward the yolo-mode mistake limit. Added 2 tests
verifying single and repeated clineignore denials.
Total: 21 passing tests.

* fix: increment consecutiveMistakeCount when SearchFilesToolHandler searches fail

Previously, SearchFilesToolHandler's executeSearch() caught regexSearchFiles
errors and returned {success: false}, but the handler unconditionally reset
consecutiveMistakeCount to 0 even when ALL searches failed. This contradicted
the PR's goal of accumulating failures toward the yolo-mode mistake limit.

Now we check if any search succeeded before resetting the counter:
- If at least one search succeeded: reset to 0 (existing behavior for successes)
- If all searches failed: increment the counter (new fix)

Also added comprehensive test coverage for this scenario, including tests for:
- regexSearchFiles throwing errors
- Repeated search failures accumulating
- Successful search resetting the counter after failures

* fix: detect error strings in ListCodeDefinitionNamesToolHandler

parseSourceCodeForDefinitionsTopLevel returns error strings instead of
throwing exceptions for file paths and non-existent directories. The
handler now detects these error conditions and increments
consecutiveMistakeCount so repeated failures accumulate correctly.

This addresses Greptile's feedback that the counter was unconditionally
resetting to 0 for all real-world failure modes of this handler.
2026-03-12 20:58:06 -07:00
Dominic Cooney aacc69a558 fix: catch file-not-found in ReadFileToolHandler instead of crashing CLI (#9730)
* fix: catch extractFileContent errors in ReadFileToolHandler

When extractFileContent throws (e.g. file not found), the exception
propagated through ToolExecutor which re-threw it, crashing the CLI
process with exit code 1.

Now file read errors are caught and returned as formatResponse.toolError()
so the model can see the error and recover gracefully (e.g. try a
different file path) instead of terminating the entire task.

Also increments consecutiveMistakeCount so the yolo-mode mistake limit
still functions correctly.
2026-03-13 10:52:32 +09:00
Max a27bedffb6 max/cli tui e2e tests (#9753)
* add tui UI tests

using microsoft/tui-test library, can run many headless versions of
cline and execute ui tests (requires Node <= 20)

improve brittle sleep calls

* add cli-tui-tests github action

---------

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-03-12 16:06:25 -07:00
Diego Ferreyra bbeecefe84 docs: fix broken Oracle Code Assist website link (#9782)
The Oracle Code Assist URL moved from /artificial-intelligence/code-assist/
to /application-development/code-assist/. The old URL returns a 404.

Fixes #9776

Co-authored-by: gatof81 <gatof81@users.noreply.github.com>
2026-03-12 11:56:34 -03:00
alex-lum 50b57f472f feat(telemetry): add provider to task.tokens event (#9762) 2026-03-11 17:30:03 -07:00
243 changed files with 18347 additions and 2724 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.webm filter=lfs diff=lfs merge=lfs -text
* text=auto eol=lf
+83
View File
@@ -0,0 +1,83 @@
name: CLI TUI Tests
on:
pull_request:
branches:
- main
workflow_dispatch:
workflow_call:
permissions:
contents: read
jobs:
cli-tui-tests:
name: CLI TUI Tests
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install dependencies
run: npm ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build CLI
run: npm run cli:build
- name: Run TUI Tests
id: tui_tests
run: |
npm run test:e2e:cli:tui 2>&1 | tee tui-test-output.log
exit_code=${PIPESTATUS[0]}
echo "tui_exit_code=$exit_code" >> $GITHUB_OUTPUT
exit $exit_code
- name: Write failure summary
if: always() && steps.tui_tests.outcome != 'success' && steps.tui_tests.outcome != 'skipped'
run: |
echo "## ❌ CLI TUI Tests Failed" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Step outcome:** \`${{ steps.tui_tests.outcome }}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Test Output" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
if [ -f tui-test-output.log ]; then
cat tui-test-output.log >> $GITHUB_STEP_SUMMARY
else
echo "(no test output captured — process may have been killed before output was flushed)" >> $GITHUB_STEP_SUMMARY
fi
echo '```' >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Debugging" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- **TUI traces** are attached as artifacts below — download and inspect them to see terminal state at the point of failure." >> $GITHUB_STEP_SUMMARY
echo "- **To view a trace replay/Run a TUI Trace: ** run \`npx tui-test show-trace path/to/trace/file\` in your terminal" >> $GITHUB_STEP_SUMMARY
echo "- **Full test log** is also attached as an artifact." >> $GITHUB_STEP_SUMMARY
echo "- Tests run with \`retries: 2\` so any failure shown is a consistent failure, not a flake." >> $GITHUB_STEP_SUMMARY
- name: Upload TUI traces
if: always() && steps.tui_tests.outcome != 'success' && steps.tui_tests.outcome != 'skipped'
uses: actions/upload-artifact@v4
with:
name: tui-test-traces
path: tests/e2e/cli/tui-traces/
retention-days: 14
if-no-files-found: warn
- name: Upload test log
if: always() && steps.tui_tests.outcome != 'success' && steps.tui_tests.outcome != 'skipped'
uses: actions/upload-artifact@v4
with:
name: tui-test-log
path: tui-test-output.log
retention-days: 14
if-no-files-found: warn
@@ -30,7 +30,11 @@ permissions:
pull-requests: write # Required by nested reusable test workflow
jobs:
cli-tui-tests:
uses: ./.github/workflows/cli-tui-tests.yml
publish-main:
needs: cli-tui-tests
if: |
github.repository == 'cline/cline' && (
github.event_name == 'workflow_dispatch' &&
@@ -44,6 +48,7 @@ jobs:
confirm_publish: ${{ github.event.inputs.confirm_publish }}
publish-nightly:
needs: cli-tui-tests
if: |
github.repository == 'cline/cline' && (
github.event_name == 'schedule' ||
+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.webm"
if grep -q "git-lfs.github.com/spec/v1" "$FILE"; then
echo "Error: $FILE is still a Git LFS pointer in CI checkout"
exit 1
fi
- name: Publish Extension as Pre-release
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
+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.webm"
if grep -q "git-lfs.github.com/spec/v1" "$FILE"; then
echo "Error: $FILE is still a Git LFS pointer in CI checkout"
exit 1
fi
- name: Package and Publish Extension
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
+5
View File
@@ -51,3 +51,8 @@ test-results
# Smoke test results (generated)
evals/smoke-tests/results/
.tui-test
secrets.json
tui-traces
tests/**/cache
+53
View File
@@ -1,5 +1,58 @@
# Changelog
## [3.76.0]
### Added
- Add Cline Kanban launch modal in webview; CLI now launches Kanban by default with a migration view
- Add toggle to disable feature tips in chat
- Add repeated tool call loop detection to prevent infinite loops wasting tokens
### Fixed
- Fix CLI Kanban spawn on Windows by enabling shell mode for `npx.cmd`
## [3.75.0]
### Added
- Latency improvements for remote workspaces
### Fixed
- Stabilize flaky hooks tests
### Changed
- Remove example hooks in favor of reading the docs
## [3.74.0]
### Added
- Implement dynamic free model detection for Cline API
- Add file read deduplication cache to prevent repeated reads
- Add feature tips tooltip during thinking state
### Fixed
- Replace error message when not logged in to Cline
- Align ClineRulesToggleModal padding with ServersToggleModal
- Skip WebP for GLM and Devstral models running through llama.cpp
- Respect user-configured context window in LiteLLM getModel()
- Honor explicit model IDs outside static catalog in W&B provider
- Add missing Fireworks serverless models and pricing
## [3.73.0]
### Added
- Added W&B Inference by CoreWeave as a new API provider with 17 models
- Improved parallel tool calling support for OpenRouter and Cline providers
### Fixed
- Claude Code Provider: handle rate limit events, empty content arrays, error results, and unknown content types without crashing
- Tool handlers (`read_file`, `list_files`, `list_code_definition_names`, `search_files`) now return graceful errors instead of crashing
## [3.72.0]
### Added
+40
View File
@@ -1,5 +1,45 @@
# cline
## [2.9.0]
### Added
- Latency improvements for remote workspaces
## [2.8.2]
### Fixed
- Use `kanban@latest` in `cline kanban` to always fetch the newest version
## [2.8.1]
### Added
- Implement dynamic free model detection for Cline API
- Add file read deduplication cache to prevent repeated reads
- Add feature tips tooltip during thinking state
### Fixed
- Fix flaky CLI Enter-key handling across Windows/test environments
- Replace error message when not logged in to Cline
- Align ClineRulesToggleModal padding with ServersToggleModal
- Skip WebP for GLM and Devstral models running through llama.cpp
- Respect user-configured context window in LiteLLM getModel()
- Honor explicit model IDs outside static catalog in W&B provider
- Add missing Fireworks serverless models and pricing
## [2.8.0]
### Added
- Added W&B Inference by CoreWeave as a new API provider with 17 models including DeepSeek-V3.1, Llama 4, and Qwen3-Coder
- Added CLI TUI end-to-end test suite
### Fixed
- Claude Code: handle rate limit events, empty content arrays, error results, and unknown content types without crashing
- CLI: `/q` and `/exit` slash commands now execute immediately on Enter without requiring the slash menu to be visible
- CLI: slash command filtering now prioritizes exact and prefix matches over fuzzy matches
## [2.7.0]
### Added
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cline",
"version": "2.7.0",
"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",
+2 -2
View File
@@ -7,7 +7,7 @@ import { Box, Text, useInput } from "ink"
import React from "react"
import { COLORS } from "../constants/colors"
import { useStdinContext } from "../context/StdinContext"
import { isMouseEscapeSequence } from "../utils/input"
import { isEnterKey, isMouseEscapeSequence } from "../utils/input"
interface ApiKeyInputProps {
providerName: string
@@ -39,7 +39,7 @@ export const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
onCancel()
return
}
if (key.return) {
if (isEnterKey(input, key)) {
onSubmit(value)
return
}
+43 -44
View File
@@ -9,7 +9,7 @@ import React, { useCallback, useEffect, useRef, useState } from "react"
import { useStdinContext } from "../context/StdinContext"
import { useTaskController } from "../context/TaskContext"
import { useLastCompletedAskMessage } from "../hooks/useStateSubscriber"
import { isMouseEscapeSequence } from "../utils/input"
import { isEnterKey, isMouseEscapeSequence } from "../utils/input"
import { jsonParseSafe } from "../utils/parser"
interface AskPromptProps {
@@ -136,7 +136,7 @@ export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
} else if (promptType === "options") {
// Number selection for options, or free text input
const parts = jsonParseSafe(text, { options: [] as string[] })
if (key.return) {
if (isEnterKey(input, key)) {
// Submit free text on Enter
if (textInput.trim()) {
sendResponse("messageResponse", textInput.trim())
@@ -145,7 +145,7 @@ export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
setTextInput((prev) => prev.slice(0, -1))
} else if (input && !key.ctrl && !key.meta) {
// Check if it's a number for option selection (only when no text typed yet)
const num = parseInt(input, 10)
const num = Number.parseInt(input, 10)
if (textInput === "" && !Number.isNaN(num) && num >= 1 && num <= parts.options.length) {
const selectedOption = parts.options[num - 1]
sendResponse("messageResponse", selectedOption)
@@ -156,7 +156,7 @@ export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
}
} else if (promptType === "text") {
// Text input mode
if (key.return) {
if (isEnterKey(input, key)) {
// Submit on Enter
if (textInput.trim()) {
sendResponse("messageResponse", textInput.trim())
@@ -169,7 +169,7 @@ export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
}
} else if (promptType === "plan_mode_text") {
// Plan mode text input - allows text response or toggle to Act mode
if (key.return) {
if (isEnterKey(input, key)) {
// Submit on Enter
if (textInput.trim()) {
sendResponse("messageResponse", textInput.trim())
@@ -185,7 +185,7 @@ export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
}
} else if (promptType === "completion") {
// Task completed - allow follow-up question or exit
if (key.return) {
if (isEnterKey(input, key)) {
if (textInput.trim()) {
// Send follow-up question
sendResponse("messageResponse", textInput.trim())
@@ -401,43 +401,42 @@ function getCliMessagePrefixIcon(message: ClineMessage): string {
default:
return "❔"
}
} else {
switch (message.say) {
case "task":
return "📋"
case "error":
return "❌"
case "text":
return "💬"
case "reasoning":
return "🧠"
case "completion_result":
return "✅"
case "user_feedback":
return "👤"
case "command":
case "command_output":
return "⚙️"
case "tool":
return "🔧"
case "browser_action":
case "browser_action_launch":
case "browser_action_result":
return "🌐"
case "mcp_server_request_started":
case "mcp_server_response":
return "🔌"
case "api_req_started":
case "api_req_finished":
return "🔄"
case "checkpoint_created":
return "💾"
case "info":
return "️"
case "generate_explanation":
return "📝"
default:
return " "
}
}
switch (message.say) {
case "task":
return "📋"
case "error":
return "❌"
case "text":
return "💬"
case "reasoning":
return "🧠"
case "completion_result":
return "✅"
case "user_feedback":
return "👤"
case "command":
case "command_output":
return "⚙️"
case "tool":
return "🔧"
case "browser_action":
case "browser_action_launch":
case "browser_action_result":
return "🌐"
case "mcp_server_request_started":
case "mcp_server_response":
return "🔌"
case "api_req_started":
case "api_req_finished":
return "🔄"
case "checkpoint_created":
return "💾"
case "info":
return "️"
case "generate_explanation":
return "📝"
default:
return " "
}
}
+7 -7
View File
@@ -19,7 +19,7 @@ import { useClineFeaturedModels } from "../hooks/useClineFeaturedModels"
import { useOcaAuth } from "../hooks/useOcaAuth"
import { useScrollableList } from "../hooks/useScrollableList"
import { type DetectedSources, detectImportSources, type ImportSource } from "../utils/import-configs"
import { isMouseEscapeSequence } from "../utils/input"
import { isEnterKey, isMouseEscapeSequence } from "../utils/input"
import { applyBedrockConfig, applyProviderConfig } from "../utils/provider-config"
import { useValidProviders } from "../utils/providers"
import { ApiKeyInput } from "./ApiKeyInput"
@@ -79,12 +79,12 @@ const Select: React.FC<{
const [selectedIndex, setSelectedIndex] = useState(0)
useInput(
(_, key) => {
(input, key) => {
if (key.upArrow) {
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1))
} else if (key.downArrow) {
setSelectedIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0))
} else if (key.return) {
} else if (isEnterKey(input, key)) {
onSelect(items[selectedIndex].value)
}
},
@@ -130,7 +130,7 @@ const TextInput: React.FC<{
return
}
if (key.return) {
if (isEnterKey(input, key)) {
onSubmit(value)
} else if (key.backspace || key.delete) {
onChange(value.slice(0, -1))
@@ -853,7 +853,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
setMenuIndex((prev) => (prev > 0 ? prev - 1 : mainMenuItems.length - 1))
} else if (key.downArrow) {
setMenuIndex((prev) => (prev < mainMenuItems.length - 1 ? prev + 1 : 0))
} else if (key.return) {
} else if (isEnterKey(input, key)) {
handleMainMenuSelect(mainMenuItems[menuIndex].value)
}
} else if (step === "provider") {
@@ -861,7 +861,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
setProviderIndex((prev) => (prev > 0 ? prev - 1 : providerItems.length - 1))
} else if (key.downArrow) {
setProviderIndex((prev) => (prev < providerItems.length - 1 ? prev + 1 : 0))
} else if (key.return) {
} else if (isEnterKey(input, key)) {
if (providerItems[providerIndex]) {
handleProviderSelect(providerItems[providerIndex].value)
}
@@ -877,7 +877,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
setClineModelIndex((prev) => (prev > 0 ? prev - 1 : maxIndex))
} else if (key.downArrow) {
setClineModelIndex((prev) => (prev < maxIndex ? prev + 1 : 0))
} else if (key.return) {
} else if (isEnterKey(input, key)) {
if (isBrowseAllSelected(clineModelIndex, featuredModels)) {
setStep("modelid")
} else {
@@ -9,6 +9,7 @@ import { Box, Text, useInput } from "ink"
import React, { useCallback, useState } from "react"
import { COLORS } from "../constants/colors"
import { useStdinContext } from "../context/StdinContext"
import { isEnterKey } from "../utils/input"
import { getModelList } from "./ModelPicker"
import { SearchableList } from "./SearchableList"
@@ -43,7 +44,7 @@ export const BedrockCustomModelFlow: React.FC<BedrockCustomModelFlowProps> = ({
if (step === "arn_input") {
if (key.escape) {
onCancel()
} else if (key.return) {
} else if (isEnterKey(input, key)) {
handleArnSubmit()
} else if (key.backspace || key.delete) {
setCustomArn((prev) => prev.slice(0, -1))
+11 -7
View File
@@ -12,7 +12,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { ChatView } from "./ChatView"
// Helper to wait for async state updates
const delay = (ms: number = 60) => new Promise((resolve) => setTimeout(resolve, ms))
const delay = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms))
// Type for our exit mock function
type ExitMockFn = ReturnType<typeof vi.fn> & (() => void)
@@ -126,12 +126,16 @@ vi.mock("../utils/file-search", () => ({
searchWorkspaceFiles: vi.fn(async () => []),
}))
vi.mock("../utils/slash-commands", () => ({
extractSlashQuery: vi.fn(() => ({ inSlashMode: false, query: "", slashIndex: -1 })),
filterCommands: vi.fn(() => []),
insertSlashCommand: vi.fn((text: string) => text),
sortCommandsWorkflowsFirst: vi.fn((cmds: unknown[]) => cmds),
}))
vi.mock("../utils/slash-commands", async (importOriginal) => {
const actual = await importOriginal<typeof import("../utils/slash-commands")>()
return {
...actual,
extractSlashQuery: vi.fn(() => ({ inSlashMode: false, query: "", slashIndex: -1 })),
filterCommands: vi.fn(() => []),
insertSlashCommand: vi.fn((text: string) => text),
sortCommandsWorkflowsFirst: vi.fn((cmds: unknown[]) => cmds),
}
})
vi.mock("../utils/input", () => ({
isMouseEscapeSequence: vi.fn(() => false),
+100 -71
View File
@@ -108,7 +108,6 @@ import type { ClineAsk, ClineMessage } from "@shared/ExtensionMessage"
import { getApiMetrics, getLastApiReqTotalTokens } from "@shared/getApiMetrics"
import { EmptyRequest, StringRequest } from "@shared/proto/cline/common"
import type { SlashCommandInfo } from "@shared/proto/cline/slash"
import { CLI_ONLY_COMMANDS } from "@shared/slashCommands"
import { getProviderDefaultModelId, getProviderModelIdKey } from "@shared/storage"
import type { Mode } from "@shared/storage/types"
import { execSync } from "child_process"
@@ -137,7 +136,14 @@ import {
} from "../utils/file-search"
import { isMouseEscapeSequence } from "../utils/input"
import { jsonParseSafe, parseImagesFromInput } from "../utils/parser"
import { extractSlashQuery, filterCommands, insertSlashCommand, sortCommandsWorkflowsFirst } from "../utils/slash-commands"
import {
createCliOnlySlashCommands,
extractSlashQuery,
filterCommands,
getStandaloneSlashCommandToExecute,
insertSlashCommand,
sortCommandsWorkflowsFirst,
} from "../utils/slash-commands"
import { waitFor } from "../utils/timeout"
import { isFileEditTool, parseToolFromMessage } from "../utils/tools"
import { shutdownEvent } from "../vscode-shim"
@@ -403,7 +409,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
const PASTE_UPDATE_DEBOUNCE_MS = 50 // Debounce visual updates to avoid flicker
// Slash command state
const [availableCommands, setAvailableCommands] = useState<SlashCommandInfo[]>([])
const [availableCommands, setAvailableCommands] = useState<SlashCommandInfo[]>(() => createCliOnlySlashCommands())
const [selectedSlashIndex, setSelectedSlashIndex] = useState(0)
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false)
const lastSlashIndexRef = useRef<number>(-1)
@@ -614,16 +620,15 @@ export const ChatView: React.FC<ChatViewProps> = ({
try {
const response = await getAvailableSlashCommands(ctrl, EmptyRequest.create())
const cliCommands = response.commands.filter((cmd) => cmd.cliCompatible !== false)
// Add CLI-only commands (like /settings) that are handled locally
const cliOnlyCommands: SlashCommandInfo[] = CLI_ONLY_COMMANDS.map((cmd) => ({
name: cmd.name,
description: cmd.description || "",
section: cmd.section || "default",
cliCompatible: true,
}))
// Add CLI-only commands (like /settings) that are handled locally.
// Seed these synchronously on first render so locally handled commands like
// /q and /exit are immediately available, even before the async command
// fetch completes. This avoids a race that can make the quit command tests
// flaky on slower Windows CI runners.
const cliOnlyCommands = createCliOnlySlashCommands()
setAvailableCommands([...cliOnlyCommands, ...sortCommandsWorkflowsFirst(cliCommands)])
} catch {
// Fallback: commands will be empty, menu won't show
// Keep CLI-only commands available even if backend command loading fails.
}
}
loadCommands()
@@ -843,6 +848,77 @@ export const ChatView: React.FC<ChatViewProps> = ({
}, 150)
}, [inkExit, onExit])
const handleCliOnlySlashCommand = useCallback(
(commandName: string): boolean => {
if (commandName === "help") {
setActivePanel({ type: "help" })
setTextInput("")
setCursorPos(0)
setSelectedSlashIndex(0)
setSlashMenuDismissed(true)
return true
}
if (commandName === "settings") {
setActivePanel({ type: "settings" })
setTextInput("")
setCursorPos(0)
setSelectedSlashIndex(0)
setSlashMenuDismissed(true)
return true
}
if (commandName === "models") {
const apiConfig = StateManager.get().getApiConfiguration()
const provider =
mode === "act"
? apiConfig.actModeApiProvider || apiConfig.planModeApiProvider
: apiConfig.planModeApiProvider || apiConfig.actModeApiProvider
const initialMode = !provider ? undefined : provider === "cline" ? "featured-models" : "model-picker"
const initialModelKey = mode === "act" ? "actModelId" : "planModelId"
setActivePanel({ type: "settings", initialMode, initialModelKey })
setTextInput("")
setCursorPos(0)
setSelectedSlashIndex(0)
setSlashMenuDismissed(true)
return true
}
if (commandName === "history") {
setActivePanel({ type: "history" })
setTextInput("")
setCursorPos(0)
setSelectedSlashIndex(0)
setSlashMenuDismissed(true)
return true
}
if (commandName === "skills") {
setActivePanel({ type: "skills" })
setTextInput("")
setCursorPos(0)
setSelectedSlashIndex(0)
setSlashMenuDismissed(true)
return true
}
if (commandName === "clear") {
void clearViewAndResetTask()
setSelectedSlashIndex(0)
setSlashMenuDismissed(true)
return true
}
if (commandName === "exit" || commandName === "q") {
handleExit()
return true
}
return false
},
[clearViewAndResetTask, handleExit, mode, setCursorPos, setTextInput],
)
// Get button config based on the last message state
const buttonConfig = useMemo(() => {
const lastMsg = messages[messages.length - 1] as ClineMessage | undefined
@@ -1102,6 +1178,17 @@ export const ChatView: React.FC<ChatViewProps> = ({
const inSlashMenu = slashInfo.inSlashMode && filteredCommands.length > 0 && !slashMenuDismissed
const inFileMenu = mentionInfo.inMentionMode && fileResults.length > 0 && !inSlashMenu
const standaloneSlashCommand = getStandaloneSlashCommandToExecute({
prompt,
inSlashMode: slashInfo.inSlashMode,
hasSlashMenu: inSlashMenu,
hasPendingAsk: !!pendingAsk,
isSpinnerActive,
})
if (key.return && standaloneSlashCommand && handleCliOnlySlashCommand(standaloneSlashCommand)) {
return
}
// 5. Slash command menu navigation (takes priority over file menu)
if (inSlashMenu) {
@@ -1116,64 +1203,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
if (key.tab || key.return) {
const cmd = filteredCommands[selectedSlashIndex]
if (cmd) {
// Handle CLI-only commands locally
if (cmd.name === "help") {
setActivePanel({ type: "help" })
setTextInput("")
setCursorPos(0)
setSelectedSlashIndex(0)
setSlashMenuDismissed(true)
return
}
if (cmd.name === "settings") {
setActivePanel({ type: "settings" })
setTextInput("")
setCursorPos(0)
setSelectedSlashIndex(0)
setSlashMenuDismissed(true)
return
}
if (cmd.name === "models") {
const apiConfig = StateManager.get().getApiConfiguration()
// Use current mode's provider to determine picker type
const provider =
mode === "act"
? apiConfig.actModeApiProvider || apiConfig.planModeApiProvider
: apiConfig.planModeApiProvider || apiConfig.actModeApiProvider
const initialMode = !provider ? undefined : provider === "cline" ? "featured-models" : "model-picker"
// Set model for current mode (plan or act)
const initialModelKey = mode === "act" ? "actModelId" : "planModelId"
setActivePanel({ type: "settings", initialMode, initialModelKey })
setTextInput("")
setCursorPos(0)
setSelectedSlashIndex(0)
setSlashMenuDismissed(true)
return
}
if (cmd.name === "history") {
setActivePanel({ type: "history" })
setTextInput("")
setCursorPos(0)
setSelectedSlashIndex(0)
setSlashMenuDismissed(true)
return
}
if (cmd.name === "skills") {
setActivePanel({ type: "skills" })
setTextInput("")
setCursorPos(0)
setSelectedSlashIndex(0)
setSlashMenuDismissed(true)
return
}
if (cmd.name === "clear") {
clearViewAndResetTask()
setSelectedSlashIndex(0)
setSlashMenuDismissed(true)
return
}
if (cmd.name === "exit" || cmd.name === "q") {
handleExit()
if (handleCliOnlySlashCommand(cmd.name)) {
return
}
const newText = insertSlashCommand(textInput, slashInfo.slashIndex, cmd.name)
@@ -1462,13 +1492,12 @@ export const ChatView: React.FC<ChatViewProps> = ({
if (item.type === "header") {
// Show static robot frame in header (first frame, looking straight ahead)
return (
<Box flexDirection="column" key="header">
<Box flexDirection="column" key="header" marginBottom={1}>
<StaticRobotFrame />
<Text> </Text>
<Text bold color="white">
{centerText("What can I do for you?")}
</Text>
<Text> </Text>
</Box>
)
}
+4 -3
View File
@@ -7,6 +7,7 @@ import type { ClineMessage } from "@shared/ExtensionMessage"
import { Box, Text, useInput } from "ink"
import React, { useState } from "react"
import { useStdinContext } from "../context/StdinContext"
import { isEnterKey } from "../utils/input"
export type RestoreType = "task" | "workspace" | "taskAndWorkspace"
@@ -101,7 +102,7 @@ export const CheckpointMenu: React.FC<CheckpointMenuProps> = ({ messages, onSele
setSelectedCheckpoint((i) => Math.max(0, i - 1))
} else if (key.downArrow) {
setSelectedCheckpoint((i) => Math.min(checkpoints.length - 1, i + 1))
} else if (key.return && checkpoints.length > 0) {
} else if (isEnterKey(input, key) && checkpoints.length > 0) {
setStage("restoreType")
}
} else if (stage === "restoreType") {
@@ -109,7 +110,7 @@ export const CheckpointMenu: React.FC<CheckpointMenuProps> = ({ messages, onSele
setSelectedRestoreType((i) => Math.max(0, i - 1))
} else if (key.downArrow) {
setSelectedRestoreType((i) => Math.min(RESTORE_TYPE_OPTIONS.length - 1, i + 1))
} else if (key.return) {
} else if (isEnterKey(input, key)) {
const checkpoint = checkpoints[selectedCheckpoint]
const restoreType = RESTORE_TYPE_OPTIONS[selectedRestoreType]
if (checkpoint && restoreType) {
@@ -120,7 +121,7 @@ export const CheckpointMenu: React.FC<CheckpointMenuProps> = ({ messages, onSele
// Quick number selection for checkpoints
if (stage === "checkpoint") {
const num = parseInt(input, 10)
const num = Number.parseInt(input, 10)
if (!Number.isNaN(num) && num >= 1 && num <= checkpoints.length) {
setSelectedCheckpoint(num - 1)
setStage("restoreType")
+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>
)
})
+2 -2
View File
@@ -13,7 +13,7 @@ import { showTaskWithId } from "@/core/controller/task/showTaskWithId"
import { COLORS } from "../constants/colors"
import { useStdinContext } from "../context/StdinContext"
import { useTerminalSize } from "../hooks/useTerminalSize"
import { isMouseEscapeSequence } from "../utils/input"
import { isEnterKey, isMouseEscapeSequence } from "../utils/input"
import { Panel } from "./Panel"
interface TaskHistoryItem {
@@ -142,7 +142,7 @@ export const HistoryPanelContent: React.FC<HistoryPanelContentProps> = ({ onClos
return
}
if (key.return && items[selectedIndex]) {
if (isEnterKey(input, key) && items[selectedIndex]) {
handleSelect(items[selectedIndex])
return
}
+3 -2
View File
@@ -10,6 +10,7 @@ import { showTaskWithId } from "@/core/controller/task/showTaskWithId"
import { StringRequest } from "@/shared/proto/cline/common"
import { useStdinContext } from "../context/StdinContext"
import { useTerminalSize } from "../hooks/useTerminalSize"
import { isEnterKey } from "../utils/input"
interface TaskHistoryItem {
id: string
@@ -40,7 +41,7 @@ interface HistoryViewProps {
/**
* Format separator
*/
function formatSeparator(char: string = "─", width: number = 80): string {
function formatSeparator(char = "─", width = 80): string {
return char.repeat(Math.max(width, 10))
}
@@ -111,7 +112,7 @@ export const HistoryView: React.FC<HistoryViewProps> = ({
setSelectedIndex((prev) => Math.max(0, prev - 1))
} else if (key.downArrow || input === "j") {
setSelectedIndex((prev) => Math.min(pageItems.length - 1, prev + 1))
} else if (key.return && pageItems[selectedIndex]) {
} else if (isEnterKey(input, key) && pageItems[selectedIndex]) {
onSelect(pageItems[selectedIndex])
} else if (key.leftArrow && hasPrevPage) {
handlePageChange(currentPage - 1)
+4 -3
View File
@@ -16,6 +16,7 @@ import {
importFromCodex,
importFromOpenCode,
} from "../utils/import-configs"
import { isEnterKey } from "../utils/input"
import { applyProviderConfig } from "../utils/provider-config"
type ImportStep = "select" | "confirm" | "saving" | "error"
@@ -95,13 +96,13 @@ export const ImportView: React.FC<ImportViewProps> = ({ source, onComplete, onCa
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : keys.length - 1))
} else if (key.downArrow) {
setSelectedIndex((prev) => (prev < keys.length - 1 ? prev + 1 : 0))
} else if (key.return) {
} else if (isEnterKey(input, key)) {
setStep("confirm")
}
} else if (step === "confirm") {
if (key.upArrow || key.downArrow) {
setConfirmIndex((prev) => (prev === 0 ? 1 : 0))
} else if (key.return) {
} else if (isEnterKey(input, key)) {
if (confirmIndex === 0) {
handleConfirm()
} else {
@@ -109,7 +110,7 @@ export const ImportView: React.FC<ImportViewProps> = ({ source, onComplete, onCa
}
}
} else if (step === "error") {
if (key.return) {
if (isEnterKey(input, key)) {
onCancel()
}
}
@@ -0,0 +1,27 @@
import { render } from "ink-testing-library"
import { createElement } from "react"
import { describe, expect, it, vi } from "vitest"
import { KanbanMigrationView } from "./KanbanMigrationView"
describe("KanbanMigrationView", () => {
it("renders the migration options", () => {
const onSelect = vi.fn()
const { lastFrame } = render(createElement(KanbanMigrationView, { isRawModeSupported: true, onSelect }))
expect(lastFrame()).toContain("Cline is moving out of the terminal. Introducing Cline Kanban.")
expect(lastFrame()).toContain("Open the new experience")
expect(lastFrame()).toContain("Launch Cline Kanban and start there by default.")
expect(lastFrame()).toContain("cline --tui")
expect(lastFrame()).toContain("Close and rerun with cline --tui if you want the old CLI.")
expect(lastFrame()).toContain("Exit")
})
it("selects the highlighted option with Enter", () => {
const onSelect = vi.fn()
const { stdin } = render(createElement(KanbanMigrationView, { isRawModeSupported: true, onSelect }))
stdin.write("\r")
expect(onSelect).toHaveBeenCalledWith("kanban")
})
})
@@ -0,0 +1,95 @@
import { Box, Text, useApp, useInput } from "ink"
import React, { useMemo, useState } from "react"
import { COLORS } from "../constants/colors"
import { StdinProvider, useStdinContext } from "../context/StdinContext"
import { isEnterKey } from "../utils/input"
import { type KanbanMigrationAction } from "../utils/kanban"
import { StaticRobotFrame } from "./AsciiMotionCli"
import { ErrorBoundary } from "./ErrorBoundary"
interface KanbanMigrationViewProps {
isRawModeSupported: boolean
onSelect: (action: KanbanMigrationAction) => void
}
interface MigrationMenuItem {
label: string
description: string
value: KanbanMigrationAction
}
const InternalKanbanMigrationView: React.FC<Pick<KanbanMigrationViewProps, "onSelect">> = ({ onSelect }) => {
const { exit } = useApp()
const { isRawModeSupported } = useStdinContext()
const items = useMemo<MigrationMenuItem[]>(
() => [
{
label: "Open the new experience",
description: "Launch Cline Kanban and start there by default.",
value: "kanban",
},
{
label: "Exit",
description: "Close and rerun with cline --tui if you want the old CLI.",
value: "exit",
},
],
[],
)
const [selectedIndex, setSelectedIndex] = useState(0)
useInput(
(input, key) => {
if (key.escape) {
onSelect("exit")
exit()
} else if (key.upArrow) {
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1))
} else if (key.downArrow) {
setSelectedIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0))
} else if (isEnterKey(input, key)) {
onSelect(items[selectedIndex].value)
exit()
}
},
{ isActive: isRawModeSupported },
)
return (
<Box flexDirection="column" width="100%">
<StaticRobotFrame />
<Text> </Text>
<Text bold color="white">
Cline is moving out of the terminal. Introducing Cline Kanban.
</Text>
<Text color="gray">A board for orchestrating coding agents across worktrees, right from your browser.</Text>
<Text> </Text>
{items.map((item, index) => {
const isSelected = index === selectedIndex
return (
<Box flexDirection="column" key={item.value} marginBottom={1}>
<Text color={isSelected ? COLORS.primaryBlue : undefined}>
{isSelected ? " " : " "}
{item.label}
</Text>
<Text color="gray"> {item.description}</Text>
</Box>
)
})}
<Text> </Text>
<Text color="gray">Use arrow keys to navigate, Enter to select, Esc or Ctrl+C to exit</Text>
</Box>
)
}
export const KanbanMigrationView: React.FC<KanbanMigrationViewProps> = ({ isRawModeSupported, onSelect }) => {
const { exit } = useApp()
return (
<ErrorBoundary exit={exit}>
<StdinProvider isRawModeSupported={isRawModeSupported}>
<InternalKanbanMigrationView onSelect={onSelect} />
</StdinProvider>
</ErrorBoundary>
)
}
+3
View File
@@ -62,6 +62,8 @@ import {
sapAiCoreModels,
vertexDefaultModelId,
vertexModels,
wandbDefaultModelId,
wandbModels,
xaiDefaultModelId,
xaiModels,
} from "@/shared/api"
@@ -101,6 +103,7 @@ export const providerModels: Record<string, { models: Record<string, unknown>; d
sambanova: { models: sambanovaModels, defaultId: sambanovaDefaultModelId },
sapaicore: { models: sapAiCoreModels, defaultId: sapAiCoreDefaultModelId },
vertex: { models: vertexModels, defaultId: vertexDefaultModelId },
wandb: { models: wandbModels, defaultId: wandbDefaultModelId },
xai: { models: xaiModels, defaultId: xaiDefaultModelId },
zai: { models: internationalZAiModels, defaultId: internationalZAiDefaultModelId },
}
+26 -100
View File
@@ -1,112 +1,38 @@
import { render } from "ink-testing-library"
// biome-ignore lint/correctness/noUnusedImports: React must be in scope for JSX in this test file.
import React from "react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { CLI_ONLY_COMMANDS } from "@shared/slashCommands"
import { describe, expect, it } from "vitest"
import { filterCommands, getStandaloneSlashCommandName, getStandaloneSlashCommandToExecute } from "../utils/slash-commands"
// Mock ink's useApp
const mockExit = vi.fn()
vi.mock("ink", async (importOriginal) => {
const actual = await importOriginal<typeof import("ink")>()
return {
...actual,
useApp: () => ({ exit: mockExit }),
}
})
// Mock child_process
vi.mock("child_process", () => ({
execSync: vi.fn().mockReturnValue(""),
exec: vi.fn(),
const cliOnlySlashCommands = CLI_ONLY_COMMANDS.map((cmd) => ({
name: cmd.name,
description: cmd.description || "",
section: cmd.section || "default",
cliCompatible: true,
}))
// Mock dependencies
vi.mock("@/core/controller/slash/getAvailableSlashCommands", () => ({
getAvailableSlashCommands: vi.fn().mockResolvedValue({ commands: [] }),
}))
vi.mock("@/core/storage/StateManager", () => ({
StateManager: {
get: () => ({
getGlobalSettingsKey: vi.fn().mockReturnValue("act"),
getGlobalStateKey: vi.fn().mockReturnValue([]),
getApiConfiguration: vi.fn().mockReturnValue({}),
}),
},
}))
vi.mock("@/services/telemetry", () => ({
telemetryService: {
captureHostEvent: vi.fn(),
},
}))
vi.mock("@shared/services/Session", () => ({
Session: {
get: () => ({
getStats: vi.fn().mockReturnValue({}),
}),
},
}))
vi.mock("../context/TaskContext", () => ({
useTaskContext: () => ({
controller: {},
clearState: vi.fn(),
}),
useTaskState: () => ({
clineMessages: [],
}),
}))
vi.mock("../hooks/useStateSubscriber", () => ({
useIsSpinnerActive: () => ({ isActive: false, startTime: 0 }),
}))
import { ChatView } from "./ChatView"
// Helper to wait for async state updates
const delay = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms))
describe("Quit Command (/q and /exit)", () => {
const mockOnExit = vi.fn()
it("prioritizes /q as the selected slash command for an exact q query", () => {
const result = filterCommands(cliOnlySlashCommands, "q")
beforeEach(() => {
vi.clearAllMocks()
expect(result[0]?.name).toBe("q")
})
it("should exit the application when /q is selected from slash menu", async () => {
const { stdin } = render(<ChatView onExit={mockOnExit} />)
await delay()
// Type /q
stdin.write("/q")
await delay()
// Press Enter
stdin.write("\r")
// handleExit has a 150ms timeout
await delay(200)
expect(mockExit).toHaveBeenCalled()
expect(mockOnExit).toHaveBeenCalled()
it("detects /q as a standalone slash command", () => {
expect(getStandaloneSlashCommandName("/q")).toBe("q")
})
it("should exit the application when /exit is selected from slash menu", async () => {
const { stdin } = render(<ChatView onExit={mockOnExit} />)
await delay()
it("detects /exit as a standalone slash command", () => {
expect(getStandaloneSlashCommandName("/exit")).toBe("exit")
})
// Type /exit
stdin.write("/exit")
await delay()
// Press Enter
stdin.write("\r")
// handleExit has a 150ms timeout
await delay(200)
expect(mockExit).toHaveBeenCalled()
expect(mockOnExit).toHaveBeenCalled()
it("resolves /q to direct execution when no slash menu is active", () => {
expect(
getStandaloneSlashCommandToExecute({
prompt: "/q",
inSlashMode: true,
hasSlashMenu: false,
hasPendingAsk: false,
isSpinnerActive: false,
}),
).toBe("q")
})
})
+2 -1
View File
@@ -8,6 +8,7 @@ import { Box, Text, useInput } from "ink"
import React, { useState } from "react"
import { COLORS } from "../constants/colors"
import { useStdinContext } from "../context/StdinContext"
import { isEnterKey } from "../utils/input"
export interface SelectListItem {
id: string
@@ -31,7 +32,7 @@ export function SelectList<T extends SelectListItem>({ items, onSelect, isActive
setSelectedIndex((i) => (i > 0 ? i - 1 : items.length - 1))
} else if (key.downArrow) {
setSelectedIndex((i) => (i < items.length - 1 ? i + 1 : 0))
} else if (key.return) {
} else if (isEnterKey(_input, key)) {
const item = items[selectedIndex]
if (item) {
onSelect(item)
@@ -129,6 +129,12 @@ const FEATURE_SETTINGS = {
label: "Double-check completion",
description: "Reject first completion attempt and require re-verification",
},
showFeatureTips: {
stateKey: "showFeatureTips",
default: true,
label: "Feature tips",
description: "Show tips during thinking phases",
},
} as const
type FeatureKey = keyof typeof FEATURE_SETTINGS
@@ -120,7 +120,9 @@ describe("SkillsPanelContent", () => {
await delay()
// Navigate down to marketplace (past the one skill)
stdin.write("\x1B[B") // Down arrow
// Use vim-style navigation here because it's more deterministic in the
// full suite than raw arrow escape sequences on Windows.
stdin.write("j")
await delay()
stdin.write("\r") // Enter
+2 -2
View File
@@ -12,7 +12,7 @@ import { refreshSkills } from "@/core/controller/file/refreshSkills"
import { toggleSkill } from "@/core/controller/file/toggleSkill"
import { COLORS } from "../constants/colors"
import { useStdinContext } from "../context/StdinContext"
import { isMouseEscapeSequence } from "../utils/input"
import { isEnterKey, isMouseEscapeSequence } from "../utils/input"
import { Panel } from "./Panel"
const SKILLS_MARKETPLACE_URL = "https://skills.sh/"
@@ -143,7 +143,7 @@ export const SkillsPanelContent: React.FC<SkillsPanelContentProps> = ({ controll
}
// Actions
if (key.return) {
if (isEnterKey(input, key)) {
if (isMarketplaceSelected) {
openMarketplace()
} else {
+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(), ["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)
+10
View File
@@ -15,3 +15,13 @@ export function isMouseEscapeSequence(input: string): boolean {
// They contain [< followed by numbers, semicolons, and end with M or m
return input.includes("[<") && /\[<\d+;\d+;\d+[Mm]/.test(input)
}
/**
* Ink's key metadata can be inconsistent across platforms/test environments for Enter.
* In particular, some Windows CI/test runs surface Enter as raw "\r" input without
* setting key.return. Treat either representation as Enter so keyboard handlers remain
* stable in production and in tests across platforms.
*/
export function isEnterKey(input: string, key: { return?: boolean }): boolean {
return key.return === true || input === "\r" || input === "\n"
}
+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()
}
+54
View File
@@ -0,0 +1,54 @@
import type { SlashCommandInfo } from "@shared/proto/cline/slash"
import { describe, expect, it } from "vitest"
import { filterCommands, getStandaloneSlashCommandToExecute } from "./slash-commands"
const createCommand = (name: string): SlashCommandInfo => ({
name,
description: `${name} command`,
section: "default",
cliCompatible: true,
})
describe("filterCommands", () => {
it("prioritizes exact matches ahead of fuzzy matches", () => {
const commands = [createCommand("help"), createCommand("history"), createCommand("q")]
const result = filterCommands(commands, "q")
expect(result.map((command) => command.name)[0]).toBe("q")
})
it("prioritizes prefix matches ahead of fuzzy matches", () => {
const commands = [createCommand("history"), createCommand("help"), createCommand("exit")]
const result = filterCommands(commands, "hi")
expect(result.map((command) => command.name)[0]).toBe("history")
})
})
describe("getStandaloneSlashCommandToExecute", () => {
it("ignores standalone execution when slash menu is visible", () => {
expect(
getStandaloneSlashCommandToExecute({
prompt: "/q",
inSlashMode: true,
hasSlashMenu: true,
hasPendingAsk: false,
isSpinnerActive: false,
}),
).toBeNull()
})
it("returns standalone command when enter should execute it directly", () => {
expect(
getStandaloneSlashCommandToExecute({
prompt: "/exit",
inSlashMode: false,
hasSlashMenu: false,
hasPendingAsk: false,
isSpinnerActive: false,
}),
).toBe("exit")
})
})
+75 -2
View File
@@ -4,6 +4,7 @@
*/
import type { SlashCommandInfo } from "@shared/proto/cline/slash"
import { CLI_ONLY_COMMANDS } from "@shared/slashCommands"
import { fuzzyFilter } from "./fuzzy-search"
export interface SlashQueryInfo {
@@ -17,12 +18,29 @@ export interface VisibleWindow<T> {
startIndex: number
}
export interface StandaloneSlashCommandExecutionInput {
prompt: string
inSlashMode: boolean
hasSlashMenu: boolean
hasPendingAsk: boolean
isSpinnerActive: boolean
}
export function createCliOnlySlashCommands(): SlashCommandInfo[] {
return CLI_ONLY_COMMANDS.map((cmd) => ({
name: cmd.name,
description: cmd.description || "",
section: cmd.section || "default",
cliCompatible: true,
}))
}
/**
* Calculate visible window for a scrollable list menu.
* Centers the selected item in the visible window when possible.
* Returns the visible items and the start index for selection tracking.
*/
export function getVisibleWindow<T>(items: T[], selectedIndex: number, maxVisible: number = 5): VisibleWindow<T> {
export function getVisibleWindow<T>(items: T[], selectedIndex: number, maxVisible = 5): VisibleWindow<T> {
if (items.length <= maxVisible) {
return { items, startIndex: 0 }
}
@@ -91,6 +109,42 @@ export function extractSlashQuery(text: string, cursorPosition?: number): SlashQ
}
}
/**
* Detect a standalone slash command (for example "/q" or "/exit")
* that should be executed immediately when enter is pressed.
*/
export function getStandaloneSlashCommandName(text: string): string | null {
const match = text.trim().match(/^\/([a-zA-Z0-9_.-]+)$/)
return match?.[1] ?? null
}
/**
* Resolve whether pressing Enter should execute a standalone CLI slash command.
* This keeps ChatView's key handling deterministic and easy to test.
*/
export function getStandaloneSlashCommandToExecute({
prompt,
inSlashMode,
hasSlashMenu,
hasPendingAsk,
isSpinnerActive,
}: StandaloneSlashCommandExecutionInput): string | null {
const standaloneSlashCommand = getStandaloneSlashCommandName(prompt)
if (!standaloneSlashCommand) {
return null
}
if (hasPendingAsk || isSpinnerActive) {
return null
}
if (inSlashMode && hasSlashMenu) {
return null
}
return standaloneSlashCommand
}
/**
* Filter commands using fuzzy matching
*/
@@ -98,7 +152,26 @@ export function filterCommands(commands: SlashCommandInfo[], query: string): Sla
if (!query) {
return commands
}
return fuzzyFilter(commands, query, (cmd) => cmd.name)
const normalizedQuery = query.toLowerCase()
const exactMatches: SlashCommandInfo[] = []
const prefixMatches: SlashCommandInfo[] = []
const remaining: SlashCommandInfo[] = []
for (const command of commands) {
const normalizedName = command.name.toLowerCase()
if (normalizedName === normalizedQuery) {
exactMatches.push(command)
continue
}
if (normalizedName.startsWith(normalizedQuery)) {
prefixMatches.push(command)
continue
}
remaining.push(command)
}
return [...exactMatches, ...prefixMatches, ...fuzzyFilter(remaining, query, (cmd) => cmd.name)]
}
/**
+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
```
<Tip>
This launches a local web server and opens the Kanban board in your default browser.
</Tip>
## First Launch
1. Open your terminal and `cd` to the root of any git repository
2. Run `cline`
3. Your browser opens to the Kanban board
### Onboarding
On your first launch, Kanban walks you through a short setup:
1. **Pick a project directory** — a directory picker opens so you can select (or confirm) the repository you want to work in
2. **Choose your agent** — select which coding agent to use for tasks (Cline, Claude Code, or Codex)
After onboarding, you land on the board and can start creating tasks immediately. No account creation, API keys, or configuration files required.
## Creating Your First Task
Once the board is open:
1. **Create a card** — click the add button to create a new task card
2. **Write a task description** — describe what you want the agent to do
3. **Hit play** — Kanban creates an ephemeral git worktree for the task and starts an agent in its own terminal
The card updates in real time, showing the agent's latest message or tool call so you can monitor progress from the board.
<Tip>
You can also use the **sidebar chat** to create tasks. Open the chat and ask the agent to break down work into multiple task cards — it can create, link, and start tasks directly on the board.
</Tip>
## Next Steps
<Columns cols={2}>
<Card title="Core Workflow" icon="arrows-spin" href="/kanban/core-workflow">
Learn the full workflow: create → link → start → review → ship.
</Card>
<Card title="Features" icon="list-check" href="/kanban/features">
Explore worktrees, auto-commit, task linking, the diff viewer, and more.
</Card>
</Columns>
+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
```
## How It Works
1. **Run `cline`** from the root of any git repo — a local web server opens in your browser
2. **Create task cards** manually or ask the sidebar chat agent to break work into tasks
3. **Hit play** on a card — Kanban creates an ephemeral worktree and starts an agent
4. **Monitor progress** — each card shows the agent's latest message or tool call
5. **Review diffs** — click a card to see all changes, leave inline comments to steer the agent
6. **Ship it** — hit Commit or Open PR, then trash the card to clean up the worktree
## Key Capabilities
<CardGroup cols={3}>
<Card title="Parallel Execution" icon="clone">
Each task runs in its own git worktree with its own terminal. Multiple agents work simultaneously without stepping on each other.
</Card>
<Card title="Unified Task Board" icon="table-columns">
Create, triage, link, and monitor all agent tasks from a single browser-based kanban board.
</Card>
<Card title="Works With Existing Agents" icon="plug">
Compatible with CLI agents you already use — Cline, Claude Code, and Codex. Kanban uses experimental features that bypass permissions and runtime hooks for more agent autonomy.
</Card>
</CardGroup>
## Links
- [GitHub Repository](https://github.com/cline/kanban) — source code, issues, and feature requests
- [npm Package](https://www.npmjs.com/package/kanban) — version history and package details
- [Cline App](https://app.cline.bot) — account management
- [Discord #kanban](https://discord.gg/cline) — feedback and discussion
## Next Steps
<Columns cols={2}>
<Card title="Getting Started" icon="rocket" href="/kanban/getting-started">
Install Kanban and launch your first board.
</Card>
<Card title="Core Workflow" icon="arrows-spin" href="/kanban/core-workflow">
The full workflow from creating tasks to shipping PRs.
</Card>
<Card title="Features" icon="list-check" href="/kanban/features">
Worktrees, auto-commit, task linking, diff viewer, git interface, and more.
</Card>
</Columns>
+10 -6
View File
@@ -18,18 +18,22 @@ Fireworks AI is a leading infrastructure platform for generative AI that focuses
Cline supports the following Fireworks AI models:
- `accounts/fireworks/models/kimi-k2-instruct-0905` (Default) - Kimi K2 with 262K context, prompt caching ($0.60/$2.50 per 1M tokens)
- `accounts/fireworks/models/qwen3-235b-a22b-instruct-2507` - Latest Qwen3 thinking model (256K context, $0.22/$0.88 per 1M tokens)
- `accounts/fireworks/models/qwen3-coder-480b-a35b-instruct` - Qwen3's most agentic code model (256K context, $0.45/$1.80 per 1M tokens)
- `accounts/fireworks/models/deepseek-r1-0528` - DeepSeek R1 reasoning model (160K context, $3.00/$8.00 per 1M tokens)
- `accounts/fireworks/models/deepseek-v3` - DeepSeek V3 general-purpose model (128K context, $0.90/$0.90 per 1M tokens)
- `accounts/fireworks/models/kimi-k2p5` (Default) - Kimi K2.5 flagship agentic model with multimodal support (262K context, prompt caching, $0.60/$3.00 per 1M tokens)
- `accounts/fireworks/models/qwen3-vl-30b-a3b-thinking` - Qwen3-VL reasoning model with image support (262K context, prompt caching, $0.15/$0.60 per 1M tokens)
- `accounts/fireworks/models/qwen3-vl-30b-a3b-instruct` - Qwen3-VL instruct model with image support (262K context, $0.15/$0.60 per 1M tokens)
- `accounts/fireworks/models/deepseek-v3p2` - DeepSeek V3.2 model (164K context, prompt caching, $0.56/$1.68 per 1M tokens)
- `accounts/fireworks/models/glm-4p7` - GLM-4.7 model (203K context, prompt caching, $0.60/$2.20 per 1M tokens)
- `accounts/fireworks/models/glm-5` - GLM-5 model (203K context, prompt caching, $1.00/$3.20 per 1M tokens)
- `accounts/fireworks/models/minimax-m2p5` - MiniMax M2.5 model (197K context, prompt caching, $0.30/$1.20 per 1M tokens)
- `accounts/fireworks/models/minimax-m2p1` - MiniMax M2.1 model (197K context, prompt caching, $0.30/$1.20 per 1M tokens)
- `accounts/fireworks/models/gpt-oss-120b` - OpenAI gpt-oss-120b model (131K context, prompt caching, $0.15/$0.60 per 1M tokens)
### Configuration in Cline
1. **Open Cline Settings:** Click the settings icon (⚙️) in the Cline panel.
2. **Select Provider:** Choose "Fireworks" from the "API Provider" dropdown.
3. **Enter API Key:** Paste your Fireworks API key into the "Fireworks API Key" field.
4. **Enter Model ID:** Specify the model you want to use (e.g., "accounts/fireworks/models/llama-v3p1-70b-instruct").
4. **Enter Model ID:** Specify the model you want to use (e.g., "accounts/fireworks/models/kimi-k2p5").
5. **Configure Tokens:** Optionally set max completion tokens and context window size.
### Fireworks AI's Performance Focus
+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.
+1 -1
View File
@@ -5,7 +5,7 @@ description: "Learn how to configure and use Oracle Code Assist with Cline. Acce
Oracle Code Assist provides AI-powered coding assistance through Oracle Cloud Infrastructure (OCI) Generative AI service.
**Website:** [https://www.oracle.com/artificial-intelligence/code-assist/](https://www.oracle.com/artificial-intelligence/code-assist/)
**Website:** [https://www.oracle.com/application-development/code-assist/](https://www.oracle.com/application-development/code-assist/)
### Getting Started
+1506 -756
View File
File diff suppressed because it is too large Load Diff
+11 -5
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.72.0",
"version": "3.76.0",
"icon": "assets/icons/icon.png",
"workspaces": [
".",
@@ -428,6 +428,7 @@
"test:e2e:build": "vsce package --allow-package-secrets sendgrid --out dist/e2e.vsix",
"test:e2e": "playwright install && npm run test:e2e:build && node src/test/e2e/utils/build.mjs && playwright test",
"test:e2e:optimal": "npm run test:e2e:build && node src/test/e2e/utils/build.mjs && playwright test",
"test:e2e:cli:tui": "cd tests/e2e/cli && tui-test",
"test:e2e:ui": "npx tsx scripts/interactive-playwright.ts",
"install:all": "npm install && cd webview-ui && npm install",
"dev:webview": "cd webview-ui && npm run dev",
@@ -460,6 +461,7 @@
"devDependencies": {
"@biomejs/biome": "^2.3.14",
"@bufbuild/buf": "^1.54.0",
"@microsoft/tui-test": "0.0.1",
"@types/better-sqlite3": "^7.6.13",
"@types/chai": "^5.0.1",
"@types/clone-deep": "^4.0.4",
@@ -473,10 +475,10 @@
"@types/proxyquire": "^1.3.31",
"@types/shell-quote": "^1.7.5",
"@types/should": "^11.2.0",
"@types/sinon": "^17.0.4",
"@types/sinon": "^21.0.0",
"@types/turndown": "^5.0.5",
"@types/vscode": "1.84.0",
"@vscode/test-cli": "^0.0.10",
"@vscode/test-cli": "^0.0.12",
"@vscode/test-electron": "^2.5.2",
"@vscode/vsce": "^3.6.0",
"c8": "^10.1.3",
@@ -496,7 +498,7 @@
"proxyquire": "^2.1.3",
"rimraf": "^6.0.1",
"should": "^13.2.3",
"sinon": "^19.0.2",
"sinon": "^21.0.3",
"tree-kill": "^1.2.2",
"ts-node": "^10.9.2",
"ts-proto": "^2.6.1",
@@ -604,7 +606,11 @@
"tar-fs": ">=3.1.1",
"tar": "^7.5.2",
"vite": "^7.1.11",
"js-yaml": "^4.1.1"
"js-yaml": "^4.1.1",
"serialize-javascript": ">=7.0.3",
"mocha": {
"diff": ">=8.0.3"
}
},
"c8": {
"reporter": [
+7
View File
@@ -67,6 +67,13 @@ message NotificationData {
string source = 2;
string message = 3;
bool waiting_for_user_input = 4;
string event_version = 5;
string event_id = 6;
bool message_truncated = 7;
string source_type = 8;
string source_id = 9;
bool requires_user_action = 10;
string severity = 11;
}
// Data for TaskStart hook
+2
View File
@@ -460,6 +460,7 @@ enum ApiProvider {
AIHUBMIX = 38;
NOUSRESEARCH = 39;
OPENAI_CODEX = 40;
WANDB = 41;
}
enum ApiFormat {
@@ -598,6 +599,7 @@ message ModelsApiConfiguration {
optional string aihubmix_app_code = 84;
optional string nous_research_api_key = 85;
optional bool azure_identity = 86;
optional string wandb_api_key = 87;
// Plan mode configurations
optional ApiProvider plan_mode_api_provider = 100;
+5
View File
@@ -105,6 +105,7 @@ message Secrets {
optional string mcp_o_auth_secrets = 43;
optional string cline_api_key = 44;
optional string openai_codex_oauth_credentials = 48;
optional string wandb_api_key = 50;
}
// NOTE: Add new fields under API_HANDLER_SETTINGS_FIELDS or USER_SETTINGS_FIELDS
@@ -284,6 +285,8 @@ message Settings {
optional OpenRouterModelInfo plan_mode_cline_model_info = 179;
optional string act_mode_cline_model_id = 180;
optional OpenRouterModelInfo act_mode_cline_model_info = 181;
optional bool show_feature_tips = 182;
optional bool code_intelligence_enabled = 183;
}
message State {
@@ -425,6 +428,8 @@ message UpdateSettingsRequest {
optional bool opt_out_of_remote_config = 39;
optional bool worktrees_enabled = 40;
optional bool double_check_completion_enabled = 41;
optional bool show_feature_tips = 42;
optional bool code_intelligence_enabled = 43;
}
message UpdateTerminalConnectionTimeoutRequest {
+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 {
+145
View File
@@ -0,0 +1,145 @@
syntax = "proto3";
package host;
option go_package = "github.com/cline/grpc-go/host";
option java_multiple_files = true;
option java_package = "bot.cline.host.proto";
// Provides PSI-based code intelligence capabilities.
// All operations require smart mode (indexing complete) unless noted.
service PsiService {
// Check if smart mode is active. Ultra-lightweight (<1ms).
// Can be called frequently (e.g., for every environment_details build).
rpc getIndexingStatus(GetIndexingStatusRequest) returns (GetIndexingStatusResponse);
// Search for symbols by name (like shift-shift "Go to Symbol").
// Partially DumbAware — may return limited results during indexing.
rpc searchSymbols(SearchSymbolsRequest) returns (SymbolQueryResponse);
// Resolve the definition(s) of the symbol at the given position.
rpc getDefinition(SymbolQuery) returns (SymbolQueryResponse);
// Find all references/usages of the symbol.
rpc getReferences(SymbolQuery) returns (SymbolQueryResponse);
// Find callables (methods/functions) that reference/call this symbol.
// Works for any symbol type — for methods this finds callers,
// for classes this finds instantiation sites, etc.
rpc getCallers(SymbolQuery) returns (SymbolQueryResponse);
// Find symbols referenced/called within the body of the given callable.
rpc getCallees(SymbolQuery) returns (SymbolQueryResponse);
// Get the type hierarchy (supertypes and subtypes) for a class/interface.
rpc getTypeHierarchy(SymbolQuery) returns (TypeHierarchyResponse);
}
// ─── Indexing Status ───────────────────────────────────────
message GetIndexingStatusRequest {}
message GetIndexingStatusResponse {
// True when indexing is complete and PSI operations are available.
bool is_smart_mode = 1;
}
// ─── Symbol Query (shared input for most operations) ──────
message SymbolQuery {
// The text of the symbol to find (e.g., "resetBoard", "Player").
// Always required.
string symbol_text = 1;
// Absolute file path. Optional — if omitted, all matching symbols
// in the project are searched.
optional string file_path = 2;
// 1-based line number within the file. Optional — used for
// disambiguation when the same symbol appears multiple times.
optional int32 line = 3;
// Maximum number of results to return per definition group.
// Default: 50.
optional int32 max_results = 4;
}
// ─── Symbol Result ─────────────────────────────────────────
message SymbolResult {
// Absolute file path where this result is located.
string file_path = 1;
// 1-based line number.
int32 line = 2;
// The full text of the source line (trimmed of leading/trailing whitespace).
string line_content = 3;
// The name of the symbol at this location.
string symbol_name = 4;
// The kind of symbol: "class", "method", "function", "field",
// "property", "variable", "interface", "enum", "constructor",
// "parameter", "type_alias", etc.
string kind = 5;
// Name of the enclosing class/function/module, if any.
string container_name = 6;
// 1-based line of the container's definition.
// 0 if there is no container (e.g., top-level symbol).
int32 container_line = 7;
// File path of the container (may differ from file_path for inner classes etc.)
string container_file_path = 8;
}
// ─── Responses ─────────────────────────────────────────────
message SymbolQueryResponse {
// Empty string on success. Descriptive error message on failure.
string error = 1;
// Results, potentially grouped by definition when the query
// matched multiple definitions.
repeated SymbolResultGroup groups = 2;
}
message SymbolResultGroup {
// The definition this group of results relates to.
// For "definition" queries, this is the definition itself.
// For "references"/"callers"/"callees", this is the symbol being queried.
SymbolResult definition = 1;
// The results for this definition.
repeated SymbolResult results = 2;
// True if results were truncated due to max_results.
bool truncated = 3;
}
message TypeHierarchyResponse {
string error = 1;
// The queried type.
SymbolResult target = 2;
// Supertypes (parent classes/interfaces), ordered from direct parent to root.
repeated SymbolResult supertypes = 3;
// Direct subtypes (implementing classes, subclasses).
repeated SymbolResult subtypes = 4;
bool subtypes_truncated = 5;
}
// ─── Search Symbols ────────────────────────────────────────
message SearchSymbolsRequest {
// The search pattern (supports partial/fuzzy matching like shift-shift).
string pattern = 1;
// Maximum results. Default: 20.
optional int32 max_results = 2;
}
+32 -21
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env node
import chalk from "chalk"
import { execSync } from "child_process"
import { execFileSync, execSync } from "child_process"
import fsSync from "fs"
import * as fs from "fs/promises"
import { globby } from "globby"
import { createRequire } from "module"
@@ -12,7 +13,20 @@ import { main as generateHostBridgeClient } from "./generate-host-bridge-client.
import { main as generateProtoBusSetup } from "./generate-protobus-setup.mjs"
const require = createRequire(import.meta.url)
const PROTOC = path.join(require.resolve("grpc-tools"), "../bin/protoc")
const isWindows = process.platform === "win32"
const GRPC_TOOLS_PROTOC = path.join(require.resolve("grpc-tools"), "../bin", isWindows ? "protoc.exe" : "protoc")
// Legacy compatibility: some older/local Windows setups provision protoc into tmp-protoc.
// Prefer that path when present, but fall back to the grpc-tools bundled binary used by CI/npm installs.
const LEGACY_WINDOWS_PROTOC = path.resolve("tmp-protoc/bin/protoc.exe")
const PROTOC = isWindows && fsSync.existsSync(LEGACY_WINDOWS_PROTOC) ? LEGACY_WINDOWS_PROTOC : GRPC_TOOLS_PROTOC
if (!fsSync.existsSync(PROTOC)) {
const windowsHint = isWindows
? ` Neither ${LEGACY_WINDOWS_PROTOC} nor the grpc-tools bundled protoc at ${GRPC_TOOLS_PROTOC} exists.`
: ""
console.error(chalk.red(`protoc not found at ${PROTOC}.${windowsHint}`))
process.exit(1)
}
const PROTO_DIR = path.resolve("proto")
const TS_OUT_DIR = path.resolve("src/shared/proto")
@@ -20,7 +34,6 @@ const GRPC_JS_OUT_DIR = path.resolve("src/generated/grpc-js")
const NICE_JS_OUT_DIR = path.resolve("src/generated/nice-grpc")
const DESCRIPTOR_OUT_DIR = path.resolve("dist-standalone/proto")
const isWindows = process.platform === "win32"
const TS_PROTO_PLUGIN = isWindows
? path.resolve("node_modules/.bin/protoc-gen-ts_proto.cmd") // Use the .bin directory path for Windows
: require.resolve("ts-proto/protoc-gen-ts_proto")
@@ -62,16 +75,16 @@ async function compileProtos() {
tsProtoc(NICE_JS_OUT_DIR, protoFiles, ["outputServices=nice-grpc,useExactTypes=false", ...TS_PROTO_OPTIONS])
const descriptorFile = path.join(DESCRIPTOR_OUT_DIR, "descriptor_set.pb")
const descriptorProtocCommand = [
PROTOC,
`--proto_path="${PROTO_DIR}"`,
`--descriptor_set_out="${descriptorFile}"`,
const descriptorProtocArgs = [
`--proto_path=${PROTO_DIR}`,
`--descriptor_set_out=${descriptorFile}`,
"--include_imports",
...protoFiles,
].join(" ")
]
try {
log_verbose(chalk.cyan("Generating descriptor set..."))
execSync(descriptorProtocCommand, { stdio: "inherit" })
log_verbose(`${PROTOC} ${descriptorProtocArgs.join(" ")}`)
execFileSync(PROTOC, descriptorProtocArgs, { stdio: "inherit" })
} catch (error) {
console.error(chalk.red("Error generating descriptor set for proto file:"), error)
process.exit(1)
@@ -81,20 +94,18 @@ async function compileProtos() {
log_verbose(chalk.green(`TypeScript files generated in: ${TS_OUT_DIR}`))
}
async function tsProtoc(outDir, protoFiles, protoOptions) {
// Build the protoc command with proper path handling for cross-platform
const command = [
PROTOC,
`--proto_path="${PROTO_DIR}"`,
`--plugin=protoc-gen-ts_proto="${TS_PROTO_PLUGIN}"`,
`--ts_proto_out="${outDir}"`,
`--ts_proto_opt=${protoOptions.join(",")} `,
...protoFiles.map((s) => `"${s}"`),
].join(" ")
function tsProtoc(outDir, protoFiles, protoOptions) {
const args = [
`--proto_path=${PROTO_DIR}`,
`--plugin=protoc-gen-ts_proto=${TS_PROTO_PLUGIN}`,
`--ts_proto_out=${outDir}`,
`--ts_proto_opt=${protoOptions.join(",")}`,
...protoFiles,
]
try {
log_verbose(chalk.cyan(`Generating TypeScript code in ${outDir} for:\n${protoFiles.join("\n")}...`))
log_verbose(command)
execSync(command, { stdio: "inherit" })
log_verbose(`${PROTOC} ${args.join(" ")}`)
execFileSync(PROTOC, args, { stdio: "inherit" })
} catch (error) {
console.error(chalk.red("Error generating TypeScript for proto files:"), error)
process.exit(1)
+53 -3
View File
@@ -230,6 +230,40 @@ function snakeToCamel(str) {
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())
}
/**
* Normalize a property key to the camelCase form parsed from proto field names.
* Example: "openai-codex-oauth-credentials" -> "openaiCodexOauthCredentials".
* We do this before looking up existing field numbers so regenerated proto fields
* reuse their old numbers instead of being treated as new fields.
*/
function normalizePropertyNameForProtoLookup(fieldName) {
return snakeToCamel(toProtoFieldName(fieldName))
}
/**
* Fail fast when two distinct property keys collapse to the same normalized lookup key.
* Collisions make field-number preservation ambiguous and can cause proto renumbering.
*/
function assertNoNormalizedFieldNameCollisions(fields) {
const normalizedToOriginalNames = new Map()
for (const field of fields) {
const normalizedName = normalizePropertyNameForProtoLookup(field.name)
const names = normalizedToOriginalNames.get(normalizedName) ?? new Set()
names.add(field.name)
normalizedToOriginalNames.set(normalizedName, names)
}
for (const [normalizedName, names] of normalizedToOriginalNames) {
if (names.size > 1) {
const sortedNames = [...names].sort()
throw new Error(
`Field-name collision after proto normalization: ${sortedNames.join(", ")} all normalize to "${normalizedName}". Rename one field to keep proto field-number mapping unambiguous.`,
)
}
}
}
/**
* Parse field numbers from an existing proto message definition
* Returns a map of camelCase field names to their field numbers
@@ -248,7 +282,8 @@ function parseProtoMessageFieldNumbers(protoContent, messageName) {
const messageBody = match[1]
// Match field definitions: optional/required/repeated type name = number;
const fieldRegex = /(?:optional|required|repeated)?\s*\w+\s+(\w+)\s*=\s*(\d+)\s*;/g
// Supports scalar/message types and map fields.
const fieldRegex = /(?:optional|required|repeated)?\s*(?:map<[^>]+>|[\w.]+)\s+(\w+)\s*=\s*(\d+)\s*;/g
const matches = messageBody.matchAll(fieldRegex)
for (const fieldMatch of matches) {
@@ -287,6 +322,8 @@ function assignFieldNumbers(fields, existingNumbers, startNumber = 1) {
const result = {}
let nextNumber = startNumber
assertNoNormalizedFieldNameCollisions(fields)
// Find the highest existing number
for (const num of Object.values(existingNumbers)) {
if (num >= nextNumber) {
@@ -296,8 +333,21 @@ function assignFieldNumbers(fields, existingNumbers, startNumber = 1) {
// Preserve existing assignments
for (const field of fields) {
if (existingNumbers[field.name] !== undefined) {
result[field.name] = existingNumbers[field.name]
// Normalize before lookup so keys like "openai-codex-oauth-credentials"
// reuse existing proto numbers instead of being treated as new fields.
const normalizedFieldName = normalizePropertyNameForProtoLookup(field.name)
const normalizedFieldNumber = existingNumbers[normalizedFieldName]
const rawFieldNumber = existingNumbers[field.name]
if (normalizedFieldNumber !== undefined && rawFieldNumber !== undefined && normalizedFieldNumber !== rawFieldNumber) {
throw new Error(
`Ambiguous proto field-number mapping for "${field.name}": normalized key "${normalizedFieldName}" -> ${normalizedFieldNumber}, raw key "${field.name}" -> ${rawFieldNumber}.`,
)
}
const existingFieldNumber = normalizedFieldNumber ?? rawFieldNumber
if (existingFieldNumber !== undefined) {
result[field.name] = existingFieldNumber
}
}
+9
View File
@@ -42,6 +42,7 @@ import { TogetherHandler } from "./providers/together"
import { VercelAIGatewayHandler } from "./providers/vercel-ai-gateway"
import { VertexHandler } from "./providers/vertex"
import { VsCodeLmHandler } from "./providers/vscode-lm"
import { WandbHandler } from "./providers/wandb"
import { XAIHandler } from "./providers/xai"
import { ZAiHandler } from "./providers/zai"
import { ApiStream, ApiStreamUsageChunk } from "./transform/stream"
@@ -97,6 +98,7 @@ function createHandlerForProvider(
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
thinkingBudgetTokens:
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
enableParallelToolCalling: options.enableParallelToolCalling,
})
case "bedrock":
return new AwsBedrockHandler({
@@ -271,6 +273,7 @@ function createHandlerForProvider(
openRouterProviderSorting: options.openRouterProviderSorting,
openRouterModelId: clineModelId,
openRouterModelInfo: clineModelInfo,
enableParallelToolCalling: options.enableParallelToolCalling,
})
}
case "litellm":
@@ -450,6 +453,12 @@ function createHandlerForProvider(
nousResearchApiKey: options.nousResearchApiKey,
apiModelId: mode === "plan" ? options.planModeNousResearchModelId : options.actModeNousResearchModelId,
})
case "wandb":
return new WandbHandler({
onRetryAttempt: options.onRetryAttempt,
wandbApiKey: options.wandbApiKey,
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
})
default:
return new AnthropicHandler({
onRetryAttempt: options.onRetryAttempt,
@@ -226,6 +226,136 @@ describe("ClaudeCodeHandler", () => {
})
})
describe("error handling", () => {
it("should not crash when assistant message has empty content array", async () => {
const runClaudeCodeModule = await import("@/integrations/claude-code/run")
const runClaudeCodeStub = sandbox.stub(runClaudeCodeModule, "runClaudeCode")
async function* mockGenerator() {
yield {
type: "assistant",
message: {
content: [], // empty content — triggered TypeError in older code
usage: {
input_tokens: 10,
output_tokens: 0,
},
stop_reason: "end_turn",
},
}
yield {
type: "result",
result: {},
total_cost_usd: 0,
}
}
runClaudeCodeStub.returns(mockGenerator() as any)
const chunks: any[] = []
// Should not throw
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
chunks.push(chunk)
}
const usageChunk = chunks.find((c) => c.type === "usage")
usageChunk.should.be.ok()
usageChunk.inputTokens.should.equal(10)
})
it("should throw when result has is_error=true (e.g. rate limit with no assistant message)", async () => {
const runClaudeCodeModule = await import("@/integrations/claude-code/run")
const runClaudeCodeStub = sandbox.stub(runClaudeCodeModule, "runClaudeCode")
async function* mockGenerator() {
yield {
type: "system",
subtype: "init",
apiKeySource: "none",
}
yield {
type: "system",
subtype: "rate_limit_event",
message: "Rate limit hit",
retryAfterSeconds: 30,
}
// No assistant message — CLI hit rate limit and gave up
yield {
type: "result",
subtype: "error",
is_error: true,
result: "Rate limit exceeded",
total_cost_usd: 0,
duration_ms: 1000,
duration_api_ms: 500,
num_turns: 0,
session_id: "test",
}
}
runClaudeCodeStub.returns(mockGenerator() as any)
let thrownError: Error | undefined
try {
for await (const _ of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
// consume
}
} catch (err) {
thrownError = err as Error
}
thrownError!.message.should.containEql("Rate limit exceeded")
})
it("should ignore rate_limit_event system messages without throwing", async () => {
const runClaudeCodeModule = await import("@/integrations/claude-code/run")
const runClaudeCodeStub = sandbox.stub(runClaudeCodeModule, "runClaudeCode")
async function* mockGenerator() {
yield {
type: "system",
subtype: "init",
apiKeySource: "none",
}
// Newer Claude Code CLI emits this during rate limiting
yield {
type: "system",
subtype: "rate_limit_event",
message: "Rate limit hit, retrying...",
retryAfterSeconds: 30,
}
yield {
type: "assistant",
message: {
content: [{ type: "text", text: "Response after retry" }],
usage: { input_tokens: 20, output_tokens: 10 },
stop_reason: "end_turn",
},
}
yield {
type: "result",
result: {},
total_cost_usd: 0,
}
}
runClaudeCodeStub.returns(mockGenerator() as any)
const textChunks: string[] = []
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
if (chunk.type === "text") textChunks.push(chunk.text)
}
textChunks.should.deepEqual(["Response after retry"])
})
})
describe("getModel", () => {
it("should return the correct model when specified", () => {
const handler = new ClaudeCodeHandler({
+36 -2
View File
@@ -1,6 +1,8 @@
import "should"
import { openRouterDefaultModelInfo } from "@shared/api"
import sinon from "sinon"
import { ClineAccountService } from "@/services/account/ClineAccountService"
import { AuthService } from "@/services/auth/AuthService"
import { ClineHandler } from "../cline"
describe("ClineHandler", () => {
@@ -14,9 +16,14 @@ describe("ClineHandler", () => {
},
})
const createHandler = (options: ConstructorParameters<typeof ClineHandler>[0]) => {
sinon.stub(ClineAccountService, "getInstance").returns({} as any)
sinon.stub(AuthService, "getInstance").returns({} as any)
return new ClineHandler(options)
}
it("should handle usage-only chunks when delta is missing", async () => {
const handler = Object.create(ClineHandler.prototype) as ClineHandler
;(handler as any).options = {}
const handler = createHandler({})
const fakeClient = {
chat: {
completions: {
@@ -56,4 +63,31 @@ describe("ClineHandler", () => {
},
])
})
it("should forward enableParallelToolCalling to OpenRouter payload", async () => {
const handler = createHandler({ enableParallelToolCalling: true })
const createStub = sinon.stub().resolves(createAsyncIterable([]))
const fakeClient = {
chat: {
completions: {
create: createStub,
},
},
}
sinon.stub(handler as any, "ensureClient").resolves(fakeClient as any)
sinon.stub(handler, "getModel").returns({
id: "openai/gpt-4o-mini",
info: openRouterDefaultModelInfo,
})
const tools = [
{ type: "function", function: { name: "read_file", description: "", parameters: { type: "object" } } },
] as any
for await (const _chunk of handler.createMessage("system", [{ role: "user", content: "hi" }], tools)) {
// drain stream
}
const payload = createStub.firstCall.args[0]
payload.parallel_tool_calls.should.equal(true)
})
})
@@ -52,4 +52,46 @@ describe("FireworksHandler", () => {
},
])
})
it("should read cache hits from prompt_tokens_details when hit tokens are not present", async () => {
const handler = new FireworksHandler({
fireworksApiKey: "test-api-key",
fireworksModelId: "accounts/fireworks/models/llama-v3p1-8b-instruct",
})
const fakeClient = {
chat: {
completions: {
create: sinon.stub().resolves(
createAsyncIterable([
{
choices: [{}],
usage: {
prompt_tokens: 60,
completion_tokens: 12,
prompt_tokens_details: { cached_tokens: 20 },
prompt_cache_miss_tokens: 40,
},
},
]),
),
},
},
}
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
const chunks: any[] = []
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
chunks.push(chunk)
}
chunks.should.deepEqual([
{
type: "usage",
inputTokens: 60,
outputTokens: 12,
cacheReadTokens: 20,
cacheWriteTokens: 40,
},
])
})
})
@@ -74,4 +74,117 @@ describe("GeminiHandler", () => {
const requestArgs = generateContentStream.firstCall.args[0] as Record<string, any>
requestArgs.config.should.not.have.property("maxOutputTokens")
})
it("should emit unique tool call IDs when multiple function calls share one responseId", async () => {
const handler = new GeminiHandler({
geminiApiKey: "test-api-key",
})
const fakeClient = {
models: {
generateContentStream: sinon.stub().resolves(
createAsyncIterable([
{
responseId: "resp_1",
candidates: [
{
content: {
parts: [
{
functionCall: {
name: "read_file",
args: { path: ".nvmrc" },
},
},
],
},
},
],
},
{
responseId: "resp_1",
candidates: [
{
content: {
parts: [
{
functionCall: {
name: "read_file",
args: { path: ".gitattributes" },
},
},
],
},
},
],
},
]),
),
},
}
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
const tools = [{ name: "read_file", description: "read file", parameters: { type: "OBJECT" } }] as any
const chunks: any[] = []
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }], tools)) {
if (chunk.type === "tool_calls") {
chunks.push(chunk)
}
}
chunks.should.have.length(2)
chunks[0].tool_call.function.id.should.equal("resp_1-tool-0")
chunks[1].tool_call.function.id.should.equal("resp_1-tool-1")
chunks[0].tool_call.call_id.should.equal(chunks[0].tool_call.function.id)
chunks[1].tool_call.call_id.should.equal(chunks[1].tool_call.function.id)
JSON.parse(chunks[0].tool_call.function.arguments).path.should.equal(".nvmrc")
JSON.parse(chunks[1].tool_call.function.arguments).path.should.equal(".gitattributes")
})
it("should preserve Gemini-provided functionCall.id when present", async () => {
const handler = new GeminiHandler({
geminiApiKey: "test-api-key",
})
const fakeClient = {
models: {
generateContentStream: sinon.stub().resolves(
createAsyncIterable([
{
responseId: "resp_2",
candidates: [
{
content: {
parts: [
{
functionCall: {
id: "call_alpha",
name: "read_file",
args: { path: ".nvmrc" },
},
},
],
},
},
],
},
]),
),
},
}
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
const tools = [{ name: "read_file", description: "read file", parameters: { type: "OBJECT" } }] as any
const chunks: any[] = []
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }], tools)) {
if (chunk.type === "tool_calls") {
chunks.push(chunk)
}
}
chunks.should.have.length(1)
chunks[0].tool_call.function.id.should.equal("call_alpha")
chunks[0].tool_call.call_id.should.equal("call_alpha")
JSON.parse(chunks[0].tool_call.function.arguments).path.should.equal(".nvmrc")
})
})
@@ -1,7 +1,9 @@
import { LiteLlmHandler, type LiteLlmModelInfoResponse } from "@core/api/providers/litellm"
import { convertToOpenAiMessages } from "@core/api/transform/openai-format"
import { liteLlmModelInfoSaneDefaults } from "@shared/api" // used in getModel tests
import { expect } from "chai"
import sinon from "sinon"
import { StateManager } from "@/core/storage/StateManager" // used in getModel tests
import { ClineStorageMessage } from "@/shared/messages/content"
import { mockFetchForTesting } from "@/shared/net"
@@ -246,4 +248,79 @@ describe("LiteLlmHandler", () => {
})
})
})
describe("getModel", () => {
let stateManagerStub: sinon.SinonStub
beforeEach(() => {
stateManagerStub = sinon.stub(StateManager, "get").returns({
getModelInfo: () => null,
} as any)
})
afterEach(() => {
stateManagerStub.restore()
})
it("returns sane defaults when no liteLlmModelInfo option is provided", () => {
const h = new LiteLlmHandler({
liteLlmApiKey: "test",
liteLlmModelId: "some-model",
})
const model = h.getModel()
expect(model.id).to.equal("some-model")
expect(model.info.contextWindow).to.equal(liteLlmModelInfoSaneDefaults.contextWindow)
})
it("returns user-configured model info when liteLlmModelInfo is provided and no cache exists", () => {
const h = new LiteLlmHandler({
liteLlmApiKey: "test",
liteLlmModelId: "claude-sonnet-4-6",
liteLlmModelInfo: {
contextWindow: 1_000_000,
maxTokens: 8192,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 3,
outputPrice: 15,
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
},
})
const model = h.getModel()
expect(model.id).to.equal("claude-sonnet-4-6")
expect(model.info.contextWindow).to.equal(1_000_000)
})
it("prefers StateManager cached model info over user-configured liteLlmModelInfo", () => {
const cachedInfo = {
contextWindow: 200_000,
maxTokens: 4096,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 0,
outputPrice: 0,
}
stateManagerStub.returns({
getModelInfo: () => cachedInfo,
} as any)
const h = new LiteLlmHandler({
liteLlmApiKey: "test",
liteLlmModelId: "claude-sonnet-4-6",
liteLlmModelInfo: {
contextWindow: 1_000_000,
maxTokens: 8192,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 3,
outputPrice: 15,
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
},
})
const model = h.getModel()
expect(model.info.contextWindow).to.equal(200_000)
})
})
})
@@ -0,0 +1,93 @@
import { expect } from "chai"
import { afterEach, describe, it } from "mocha"
import sinon from "sinon"
import { ClineStorageMessage } from "@/shared/messages/content"
import { ApiFormat } from "@/shared/proto/index.cline"
import { OcaHandler } from "../oca"
const messages: ClineStorageMessage[] = [{ role: "user", content: "Hello" }]
async function collectChunks(stream: AsyncGenerator<any>) {
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
return chunks
}
describe("OcaHandler.createMessage", () => {
afterEach(() => {
sinon.restore()
})
it("routes OPENAI_RESPONSES models to createMessageResponsesApi", async () => {
const handler = new OcaHandler({
ocaModelInfo: { apiFormat: ApiFormat.OPENAI_RESPONSES } as any,
})
const chatStub = sinon.stub(handler as any, "createMessageChatApi").callsFake(async function* () {
yield { type: "text", text: "chat" }
})
const responsesStub = sinon.stub(handler as any, "createMessageResponsesApi").callsFake(async function* () {
yield { type: "text", text: "responses" }
})
const messagesStub = sinon.stub(handler as any, "createMessageMessagesApi").callsFake(async function* () {
yield { type: "text", text: "messages" }
})
const chunks = await collectChunks(handler.createMessage("system", messages))
expect(chunks).to.deep.equal([{ type: "text", text: "responses" }])
sinon.assert.notCalled(chatStub)
sinon.assert.calledOnce(responsesStub)
sinon.assert.notCalled(messagesStub)
})
it("routes ANTHROPIC_CHAT models to createMessageMessagesApi", async () => {
const handler = new OcaHandler({
ocaModelInfo: { apiFormat: ApiFormat.ANTHROPIC_CHAT } as any,
})
const chatStub = sinon.stub(handler as any, "createMessageChatApi").callsFake(async function* () {
yield { type: "text", text: "chat" }
})
const responsesStub = sinon.stub(handler as any, "createMessageResponsesApi").callsFake(async function* () {
yield { type: "text", text: "responses" }
})
const messagesStub = sinon.stub(handler as any, "createMessageMessagesApi").callsFake(async function* () {
yield { type: "text", text: "messages" }
})
const chunks = await collectChunks(handler.createMessage("system", messages))
expect(chunks).to.deep.equal([{ type: "text", text: "messages" }])
sinon.assert.notCalled(chatStub)
sinon.assert.notCalled(responsesStub)
sinon.assert.calledOnce(messagesStub)
})
it("defaults to createMessageChatApi for OPENAI_CHAT and undefined apiFormat", async () => {
for (const apiFormat of [ApiFormat.OPENAI_CHAT, undefined]) {
const handler = new OcaHandler({
ocaModelInfo: { apiFormat } as any,
})
const chatStub = sinon.stub(handler as any, "createMessageChatApi").callsFake(async function* () {
yield { type: "text", text: "chat" }
})
const responsesStub = sinon.stub(handler as any, "createMessageResponsesApi").callsFake(async function* () {
yield { type: "text", text: "responses" }
})
const messagesStub = sinon.stub(handler as any, "createMessageMessagesApi").callsFake(async function* () {
yield { type: "text", text: "messages" }
})
const chunks = await collectChunks(handler.createMessage("system", messages))
expect(chunks).to.deep.equal([{ type: "text", text: "chat" }])
sinon.assert.calledOnce(chatStub)
sinon.assert.notCalled(responsesStub)
sinon.assert.notCalled(messagesStub)
}
})
})
@@ -14,6 +14,8 @@ describe("OpenRouterHandler", () => {
},
})
const tools = [{ type: "function", function: { name: "read_file", description: "", parameters: { type: "object" } } }] as any
it("should handle usage-only chunks when delta is missing", async () => {
const handler = new OpenRouterHandler({
openRouterApiKey: "test-api-key",
@@ -57,4 +59,106 @@ describe("OpenRouterHandler", () => {
},
])
})
it("should read cache_write_tokens from prompt_tokens_details", async () => {
const handler = new OpenRouterHandler({
openRouterApiKey: "test-api-key",
})
const fakeClient = {
chat: {
completions: {
create: sinon.stub().resolves(
createAsyncIterable([
{
choices: [{}],
usage: {
prompt_tokens: 1000,
completion_tokens: 200,
prompt_tokens_details: {
cached_tokens: 500,
cache_write_tokens: 300,
},
},
},
]),
),
},
},
}
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
sinon.stub(handler, "getModel").returns({
id: "anthropic/claude-sonnet-4.6",
info: openRouterDefaultModelInfo,
})
const chunks: any[] = []
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
chunks.push(chunk)
}
chunks.should.deepEqual([
{
type: "usage",
cacheWriteTokens: 300,
cacheReadTokens: 500,
inputTokens: 200,
outputTokens: 200,
totalCost: 0,
},
])
})
type ParallelToolCallsTestCase = {
modelId: string
enableParallelToolCalling: boolean
expectedParallelToolCalls: boolean
}
const parallelToolCallsTestCases: ParallelToolCallsTestCase[] = [
{
modelId: "openai/gpt-4o-mini",
enableParallelToolCalling: true,
expectedParallelToolCalls: true,
},
{
modelId: "openai/gpt-4o-mini",
enableParallelToolCalling: false,
expectedParallelToolCalls: false,
},
{
modelId: "google/gemini-3-flash-preview",
enableParallelToolCalling: true,
expectedParallelToolCalls: true,
},
]
for (const testCase of parallelToolCallsTestCases) {
const settingLabel = testCase.enableParallelToolCalling ? "enabled" : "disabled"
it(`should set parallel_tool_calls=${testCase.expectedParallelToolCalls} for ${testCase.modelId} when setting is ${settingLabel}`, async () => {
const handler = new OpenRouterHandler({
openRouterApiKey: "test-api-key",
enableParallelToolCalling: testCase.enableParallelToolCalling,
})
const createStub = sinon.stub().resolves(createAsyncIterable([]))
const fakeClient = {
chat: {
completions: {
create: createStub,
},
},
}
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
sinon.stub(handler, "getModel").returns({
id: testCase.modelId,
info: openRouterDefaultModelInfo,
})
for await (const _chunk of handler.createMessage("system", [{ role: "user", content: "hi" }], tools)) {
// drain stream
}
const payload = createStub.firstCall.args[0]
payload.parallel_tool_calls.should.equal(testCase.expectedParallelToolCalls)
})
}
})
@@ -0,0 +1,42 @@
import "should"
import { openAiModelInfoSaneDefaults, wandbDefaultModelId, wandbModels } from "@shared/api"
import { WandbHandler } from "../wandb"
describe("WandbHandler", () => {
it("returns known catalog model metadata when model id is recognized", () => {
const modelId = "meta-llama/Llama-3.3-70B-Instruct"
const handler = new WandbHandler({
wandbApiKey: "test-api-key",
apiModelId: modelId,
})
const model = handler.getModel()
model.id.should.equal(modelId)
model.info.should.deepEqual(wandbModels[modelId])
})
it("passes through an explicit unknown model id instead of silently falling back", () => {
const unknownModelId = "moonshotai/Kimi-K2.5"
const handler = new WandbHandler({
wandbApiKey: "test-api-key",
apiModelId: unknownModelId,
})
const model = handler.getModel()
model.id.should.equal(unknownModelId)
model.info.should.deepEqual(openAiModelInfoSaneDefaults)
})
it("uses the default W&B model when no model id is configured", () => {
const handler = new WandbHandler({
wandbApiKey: "test-api-key",
})
const model = handler.getModel()
model.id.should.equal(wandbDefaultModelId)
model.info.should.deepEqual(wandbModels[wandbDefaultModelId])
})
})
+64 -7
View File
@@ -2,6 +2,7 @@ import { filterMessagesForClaudeCode } from "@/integrations/claude-code/message-
import { runClaudeCode } from "@/integrations/claude-code/run"
import { ClaudeCodeModelId, claudeCodeDefaultModelId, claudeCodeModels } from "@/shared/api"
import { ClineStorageMessage } from "@/shared/messages/content"
import { Logger } from "@/shared/services/Logger"
import { type ApiHandler, CommonApiHandlerOptions } from ".."
import { withRetry } from "../retry"
import { type ApiStream, ApiStreamUsageChunk } from "../transform/stream"
@@ -58,20 +59,47 @@ export class ClaudeCodeHandler implements ApiHandler {
continue
}
if (chunk.type === "system" && chunk.subtype === "init") {
// Based on my tests, subscription usage sets the `apiKeySource` to "none"
isPaidUsage = chunk.apiKeySource !== "none"
// Handle system init messages
if (chunk.type === "system" && "subtype" in chunk) {
if (chunk.subtype === "init") {
// Based on my tests, subscription usage sets the `apiKeySource` to "none"
isPaidUsage = (chunk as any).apiKeySource !== "none"
}
// Also handles legacy rate_limit_event format (type: "system", subtype: "rate_limit_event")
// by falling through — no special handling needed.
continue
}
// Handle rate_limit_event (newer CLI format: top-level type)
if (chunk.type === "rate_limit_event") {
// Rate limit events are informational. Log them but don't yield anything.
// If the rate limit blocks the response, the stream will end without
// assistant messages and the task loop will handle the empty response.
Logger.log("Claude Code rate limit event:", JSON.stringify(chunk))
continue
}
// Skip user messages (tool results from Claude Code's own tool execution)
if (chunk.type === "user") {
continue
}
if (chunk.type === "assistant" && "message" in chunk) {
const message = chunk.message
if (message.stop_reason !== null) {
const content = "text" in message.content[0] ? message.content[0] : undefined
// Check for error field on the message (newer CLI format)
if (message.error) {
const firstContent = message.content?.[0]
const errorText = firstContent && "text" in firstContent ? firstContent.text : undefined
throw new Error(errorText ?? `Claude Code error: ${message.error}`)
}
const isError = content && content.text.startsWith(`API Error`)
if (isError) {
if (message.stop_reason !== null) {
const firstContent = message.content?.[0]
const content = firstContent && "text" in firstContent ? firstContent : undefined
// Check if content exists before accessing its properties
if (content && content.text.startsWith(`API Error`)) {
// Error messages are formatted as: `API Error: <<status code>> <<json>>`
const errorMessageStart = content.text.indexOf("{")
const errorMessage = content.text.slice(errorMessageStart)
@@ -126,6 +154,21 @@ export class ClaudeCodeHandler implements ApiHandler {
},
}
break
default: {
// Handle unknown content block types gracefully.
// Newer Anthropic models or CLI versions may introduce new content types
// (e.g., server_tool_use, mcp_tool_use). Log them instead of silently dropping.
const unknownBlock = content as { type: string; text?: string }
Logger.warn(`Unhandled content type in Claude Code response: ${unknownBlock.type}`)
// If the unknown block has a text-like field, try to yield it as text
if (typeof unknownBlock.text === "string") {
yield {
type: "text",
text: unknownBlock.text,
}
}
break
}
}
}
@@ -142,10 +185,24 @@ export class ClaudeCodeHandler implements ApiHandler {
}
if (chunk.type === "result" && "result" in chunk) {
if (chunk.is_error) {
throw new Error(`Claude Code returned an error: ${chunk.result}`)
}
usage.totalCost = isPaidUsage ? chunk.total_cost_usd : 0
yield usage
continue
}
// ErrorMessage — log it explicitly and skip
if ((chunk as any).type === "error") {
Logger.warn("Claude Code emitted an error-type chunk:", JSON.stringify(chunk))
continue
}
// Any completely unrecognized chunk type — log and skip
Logger.warn(`Unrecognized Claude Code chunk type: ${(chunk as any).type}`)
}
}
+29 -5
View File
@@ -4,10 +4,12 @@ import axios from "axios"
import OpenAI from "openai"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import { ClineEnv } from "@/config"
import { refreshClineRecommendedModels } from "@/core/controller/models/refreshClineRecommendedModels"
import { ClineAccountService } from "@/services/account/ClineAccountService"
import { AuthService } from "@/services/auth/AuthService"
import { buildClineExtraHeaders } from "@/services/EnvUtils"
import { CLINE_ACCOUNT_AUTH_ERROR_MESSAGE } from "@/shared/ClineAccount"
import { CLINE_RECOMMENDED_MODELS_FALLBACK } from "@/shared/cline/recommended-models"
import type { ClineStorageMessage } from "@/shared/messages/content"
import { fetch, getAxiosSettings } from "@/shared/net"
import { Logger } from "@/shared/services/Logger"
@@ -28,9 +30,14 @@ interface ClineHandlerOptions extends CommonApiHandlerOptions {
openRouterModelInfo?: ModelInfo
clineAccountId?: string
clineApiKey?: string
enableParallelToolCalling?: boolean
}
const CLINE_FREE_MODELS = ["minimax/minimax-m2.5", "kwaipilot/kat-coder-pro", "z-ai/glm-5"]
function normalizeModelId(modelId: string): string {
return modelId.trim().toLowerCase()
}
const CLINE_FREE_MODEL_IDS = new Set(CLINE_RECOMMENDED_MODELS_FALLBACK.free.map((model) => normalizeModelId(model.id)))
export class ClineHandler implements ApiHandler {
private options: ClineHandlerOptions
@@ -49,6 +56,20 @@ export class ClineHandler implements ApiHandler {
this._authService = AuthService.getInstance()
}
private async getFreeModelIdSet(): Promise<Set<string>> {
try {
const models = await refreshClineRecommendedModels()
const freeModelIds = models.free.map((model) => normalizeModelId(model.id)).filter((modelId) => modelId.length > 0)
if (freeModelIds.length > 0) {
return new Set(freeModelIds)
}
} catch (error) {
Logger.error("Error resolving Cline free model IDs from recommended models:", error)
}
return CLINE_FREE_MODEL_IDS
}
private async ensureClient(): Promise<OpenAI> {
const clineAccountAuthToken = this.options.clineApiKey || (await this._authService.getAuthToken())
if (!clineAccountAuthToken) {
@@ -111,6 +132,7 @@ export class ClineHandler implements ApiHandler {
this.lastRequestId = undefined
let didOutputUsage = false
const freeModelIds = await this.getFreeModelIdSet()
const stream = await createOpenRouterStream(
client,
@@ -121,6 +143,7 @@ export class ClineHandler implements ApiHandler {
this.options.thinkingBudgetTokens,
this.options.openRouterProviderSorting,
tools,
this.options.enableParallelToolCalling,
)
const toolCallProcessor = new ToolCallProcessor()
@@ -206,7 +229,7 @@ export class ClineHandler implements ApiHandler {
// @ts-expect-error-next-line
let totalCost = (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0)
const modelId = this.getModel().id
const isFreeModel = CLINE_FREE_MODELS.includes(modelId)
const isFreeModel = freeModelIds.has(normalizeModelId(modelId))
if (isFreeModel) {
totalCost = 0
@@ -227,7 +250,7 @@ export class ClineHandler implements ApiHandler {
// Fallback to generation endpoint if usage chunk not returned
if (!didOutputUsage) {
Logger.warn("Cline API did not return usage chunk, fetching from generation endpoint")
const apiStreamUsage = await this.getApiStreamUsage()
const apiStreamUsage = await this.getApiStreamUsage(freeModelIds)
if (apiStreamUsage) {
yield apiStreamUsage
}
@@ -238,9 +261,10 @@ export class ClineHandler implements ApiHandler {
}
}
async getApiStreamUsage(): Promise<ApiStreamUsageChunk | undefined> {
async getApiStreamUsage(freeModelIds?: Set<string>): Promise<ApiStreamUsageChunk | undefined> {
if (this.lastGenerationId) {
try {
const resolvedFreeModelIds = freeModelIds || (await this.getFreeModelIdSet())
const clineAccountAuthToken = await this._authService.getAuthToken()
if (!clineAccountAuthToken) {
throw new Error(CLINE_ACCOUNT_AUTH_ERROR_MESSAGE)
@@ -260,7 +284,7 @@ export class ClineHandler implements ApiHandler {
const generation = response.data
let totalCost = generation?.total_cost || 0
const modelId = this.getModel().id
const isFreeModel = CLINE_FREE_MODELS.includes(modelId)
const isFreeModel = resolvedFreeModelIds.has(normalizeModelId(modelId))
if (isFreeModel) {
totalCost = 0
+12 -6
View File
@@ -83,14 +83,20 @@ export class FireworksHandler implements ApiHandler {
}
if (chunk.usage) {
const usage = chunk.usage as OpenAI.CompletionUsage & {
prompt_cache_hit_tokens?: number
prompt_cache_miss_tokens?: number
prompt_tokens_details?: {
cached_tokens?: number
}
}
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0, // (deepseek reports total input AND cache reads/writes, see context caching: https://api-docs.deepseek.com/guides/kv_cache) where the input tokens is the sum of the cache hits/misses, while anthropic reports them as separate tokens. This is important to know for 1) context management truncation algorithm, and 2) cost calculation (NOTE: we report both input and cache stats but for now set input price to 0 since all the cost calculation will be done using cache hits/misses)
outputTokens: chunk.usage.completion_tokens || 0,
// @ts-expect-error-next-line
cacheReadTokens: chunk.usage.prompt_cache_hit_tokens || 0,
// @ts-expect-error-next-line
cacheWriteTokens: chunk.usage.prompt_cache_miss_tokens || 0,
inputTokens: usage.prompt_tokens || 0,
outputTokens: usage.completion_tokens || 0,
// Fireworks can return cache hits either as prompt_cache_hit_tokens or prompt_tokens_details.cached_tokens.
cacheReadTokens: usage.prompt_cache_hit_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? 0,
cacheWriteTokens: usage.prompt_cache_miss_tokens || 0,
}
}
}
+14 -1
View File
@@ -149,6 +149,9 @@ export class GeminiHandler implements ApiHandler {
const client = this.ensureClient()
const { id: modelId, info } = this.getModel()
const contents = messages.map(convertAnthropicMessageToGemini)
// Gemini may emit multiple function calls under the same responseId and without functionCall.id.
// Track a local sequence so each emitted tool call has a stable unique ID.
const responseToolCallCount = new Map<string, number>()
// Configure thinking budget/level if supported
const _thinkingBudget = this.options.thinkingBudgetTokens ?? 0
@@ -225,6 +228,7 @@ export class GeminiHandler implements ApiHandler {
let isFirstSdkChunk = true
for await (const chunk of result) {
const responseKey = chunk.responseId || "gemini-response"
if (isFirstSdkChunk) {
sdkFirstChunkTime = Date.now()
ttftSdkMs = sdkFirstChunkTime - sdkCallStartTime
@@ -253,12 +257,21 @@ export class GeminiHandler implements ApiHandler {
const functionCall = part.functionCall
const args = Object.entries(functionCall.args || {}).filter(([_key, val]) => !!val)
if (functionCall.args && args.length > 0) {
const existingId = functionCall.id?.trim()
const toolCallId =
existingId ??
(() => {
const sequenceNumber = responseToolCallCount.get(responseKey) ?? 0
responseToolCallCount.set(responseKey, sequenceNumber + 1)
return `${responseKey}-tool-${sequenceNumber}`
})()
yield {
type: "tool_calls",
id: chunk.responseId,
tool_call: {
call_id: toolCallId,
function: {
id: chunk.responseId,
id: toolCallId,
name: functionCall.name,
arguments: JSON.stringify(functionCall.args),
},
+21 -22
View File
@@ -72,26 +72,24 @@ export async function fetchLiteLlmModelsInfo(baseUrl: string, apiKey: string): P
if (response.ok) {
const data: LiteLlmModelInfoResponse = await response.json()
return data
} else {
Logger.error("Failed to fetch LiteLLM model info:", response.statusText)
// Try with Authorization header instead
const retryResponse = await fetch(url, {
method: "GET",
headers: {
accept: "application/json",
Authorization: `Bearer ${apiKey}`,
...buildExternalBasicHeaders(),
},
})
if (retryResponse.ok) {
const data: LiteLlmModelInfoResponse = await retryResponse.json()
return data
} else {
Logger.error("Failed to fetch LiteLLM model info with Authorization header:", retryResponse.statusText)
throw new Error(`Failed to fetch LiteLLM model info: ${retryResponse.statusText}`)
}
}
Logger.error("Failed to fetch LiteLLM model info:", response.statusText)
// Try with Authorization header instead
const retryResponse = await fetch(url, {
method: "GET",
headers: {
accept: "application/json",
Authorization: `Bearer ${apiKey}`,
...buildExternalBasicHeaders(),
},
})
if (retryResponse.ok) {
const data: LiteLlmModelInfoResponse = await retryResponse.json()
return data
}
Logger.error("Failed to fetch LiteLLM model info with Authorization header:", retryResponse.statusText)
throw new Error(`Failed to fetch LiteLLM model info: ${retryResponse.statusText}`)
} catch (error) {
Logger.error("Error fetching LiteLLM model info:", error)
throw error
@@ -102,7 +100,7 @@ export class LiteLlmHandler implements ApiHandler {
private options: LiteLlmHandlerOptions
private client: OpenAI | undefined
private modelInfoCache: LiteLlmModelInfoResponse | undefined
private modelInfoCacheTimestamp: number = 0
private modelInfoCacheTimestamp = 0
private readonly modelInfoCacheTTL = 5 * 60 * 1000 // 5 minutes
constructor(options: LiteLlmHandlerOptions) {
@@ -274,7 +272,8 @@ export class LiteLlmHandler implements ApiHandler {
},
] as any,
}
} else if (Array.isArray(message.content)) {
}
if (Array.isArray(message.content)) {
// Apply cache control to the last content item in the array
return {
...message,
@@ -377,7 +376,7 @@ export class LiteLlmHandler implements ApiHandler {
const cachedModelInfo = StateManager.get().getModelInfo("liteLlm", modelId)
// Fall back to provided model info or defaults if not in cache
const modelInfo = cachedModelInfo || liteLlmModelInfoSaneDefaults
const modelInfo = cachedModelInfo || this.options.liteLlmModelInfo || liteLlmModelInfoSaneDefaults
return {
id: modelId,
+18 -7
View File
@@ -394,18 +394,29 @@ export class OcaHandler implements ApiHandler {
strict: tool.function.strict ?? true, // Responses API defaults to strict mode
}))
const responsesParams: OpenAI.Responses.ResponseCreateParamsStreaming = {
model: this.options.ocaModelId || liteLlmDefaultModelId,
input,
stream: true,
tools: responseTools,
}
let temperature: number | undefined = this.options.ocaModelInfo?.temperature ?? 0
const maxOutputTokens: number | undefined = this.options.ocaModelInfo?.maxTokens
const ocaModelInfo = this.options.ocaModelInfo
if (!ocaModelInfo) {
throw new Error("Oracle Code Assist (OCA) model info is required for Responses API")
}
if (ocaModelInfo.supportsReasoning) {
const reasoningOn = !!ocaModelInfo.supportsReasoning
if (reasoningOn) {
temperature = undefined
}
const responsesParams: OpenAI.Responses.ResponseCreateParamsStreaming = {
model: this.options.ocaModelId || liteLlmDefaultModelId,
input,
stream: true,
tools: responseTools,
...(typeof temperature === "number" ? { temperature } : {}),
...(typeof maxOutputTokens === "number" && maxOutputTokens > 0 ? { max_output_tokens: maxOutputTokens } : {}),
}
if (reasoningOn) {
responsesParams.reasoning = { effort: this.options.ocaReasoningEffort as any, summary: "auto" }
}
+10 -3
View File
@@ -22,6 +22,7 @@ interface OpenRouterHandlerOptions extends CommonApiHandlerOptions {
openRouterProviderSorting?: string
reasoningEffort?: string
thinkingBudgetTokens?: number
enableParallelToolCalling?: boolean
}
export class OpenRouterHandler implements ApiHandler {
@@ -68,6 +69,7 @@ export class OpenRouterHandler implements ApiHandler {
this.options.thinkingBudgetTokens,
this.options.openRouterProviderSorting,
tools,
this.options.enableParallelToolCalling,
)
let didOutputUsage = false
@@ -152,11 +154,16 @@ export class OpenRouterHandler implements ApiHandler {
}
if (!didOutputUsage && chunk.usage) {
// @ts-expect-error-next-line -- OpenRouter returns cache_write_tokens for Anthropic models
const cacheWriteTokens = chunk.usage.prompt_tokens_details?.cache_write_tokens || 0
yield {
type: "usage",
cacheWriteTokens: 0,
cacheWriteTokens,
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
inputTokens: (chunk.usage.prompt_tokens || 0) - (chunk.usage.prompt_tokens_details?.cached_tokens || 0),
inputTokens:
(chunk.usage.prompt_tokens || 0) -
(chunk.usage.prompt_tokens_details?.cached_tokens || 0) -
(cacheWriteTokens || 0),
outputTokens: chunk.usage.completion_tokens || 0,
// @ts-expect-error-next-line
totalCost: (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0),
@@ -183,7 +190,7 @@ export class OpenRouterHandler implements ApiHandler {
// Logger.log("OpenRouter generation details:", generation)
return {
type: "usage",
cacheWriteTokens: 0,
cacheWriteTokens: generation?.native_tokens_cache_write || 0,
cacheReadTokens: generation?.native_tokens_cached || 0,
// openrouter generation endpoint fails often
inputTokens: (generation?.native_tokens_prompt || 0) - (generation?.native_tokens_cached || 0),
+102
View File
@@ -0,0 +1,102 @@
import { openAiModelInfoSaneDefaults, type ModelInfo, type WandbModelId, wandbDefaultModelId, wandbModels } from "@shared/api"
import OpenAI from "openai"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import { ClineStorageMessage } from "@/shared/messages/content"
import { createOpenAIClient } from "@/shared/net"
import { ApiHandler, CommonApiHandlerOptions } from "../index"
import { withRetry } from "../retry"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
interface WandbHandlerOptions extends CommonApiHandlerOptions {
wandbApiKey?: string
apiModelId?: string
}
export class WandbHandler implements ApiHandler {
private client: OpenAI | undefined
constructor(private readonly options: WandbHandlerOptions) {}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.wandbApiKey) {
throw new Error("W&B API key is required")
}
try {
this.client = createOpenAIClient({
baseURL: "https://api.inference.wandb.ai/v1",
apiKey: this.options.wandbApiKey,
})
} catch (error) {
throw new Error(`Error creating W&B Inference client: ${error instanceof Error ? error.message : String(error)}`)
}
}
return this.client
}
@withRetry()
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[], tools?: OpenAITool[]): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
const stream = await client.chat.completions.create({
model: model.id,
messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
temperature: 0,
stream: true,
stream_options: { include_usage: true },
...getOpenAIToolParams(tools),
})
const toolCallProcessor = new ToolCallProcessor()
for await (const chunk of stream) {
const delta = chunk.choices?.[0]?.delta
if (delta?.content) {
yield {
type: "text",
text: delta.content,
}
}
if (delta && "reasoning" in delta && delta.reasoning) {
yield {
type: "reasoning",
reasoning: typeof delta.reasoning === "string" ? delta.reasoning : JSON.stringify(delta.reasoning),
}
}
if (delta?.tool_calls) {
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
}
if (chunk.usage) {
// W&B Inference returns prompt_tokens_details.cached_tokens in the usage chunk,
// but does not currently offer cache-aware billing (cached tokens are billed
// at the same rate as regular input tokens). We report inputTokens as the full
// prompt_tokens value and do not subtract cached tokens until W&B supports
// cache-aware pricing. This may change in a future update.
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
}
}
}
}
getModel(): { id: string; info: ModelInfo } {
const modelId = this.options.apiModelId?.trim()
if (modelId && modelId in wandbModels) {
return { id: modelId, info: wandbModels[modelId as WandbModelId] }
}
if (modelId) {
return { id: modelId, info: openAiModelInfoSaneDefaults }
}
return { id: wandbDefaultModelId, info: wandbModels[wandbDefaultModelId] }
}
}
@@ -0,0 +1,116 @@
import "should"
import { getOpenAIToolParams, ToolCallProcessor } from "../tool-call-processor"
describe("ToolCallProcessor", () => {
it("should preserve tool call id/name for interleaved parallel deltas", () => {
const processor = new ToolCallProcessor()
const firstChunk = [
{
index: 0,
id: "call_a",
function: { name: "read_file" },
},
{
index: 1,
id: "call_b",
function: { name: "search_files" },
},
] as any
const secondChunk = [
{
index: 1,
function: { arguments: '{"path":"src"}' },
},
{
index: 0,
function: { arguments: '{"path":"README.md"}' },
},
] as any
const firstResult = [...processor.processToolCallDeltas(firstChunk)]
const secondResult = [...processor.processToolCallDeltas(secondChunk)]
firstResult.should.have.length(0)
secondResult.should.have.length(2)
// Intentionally reversed from the setup chunk: output follows incoming
// argument-delta order, but reconstruction is correct regardless of arrival
// order because id/name/arguments are matched by tool call index.
const firstToolCall = secondResult[0]!.tool_call as any
const secondToolCall = secondResult[1]!.tool_call as any
firstToolCall.function.id.should.equal("call_b")
firstToolCall.function.name.should.equal("search_files")
firstToolCall.function.arguments.should.equal('{"path":"src"}')
secondToolCall.function.id.should.equal("call_a")
secondToolCall.function.name.should.equal("read_file")
secondToolCall.function.arguments.should.equal('{"path":"README.md"}')
})
it("should clear accumulated state on reset", () => {
const processor = new ToolCallProcessor()
const setupChunk = [
{
index: 0,
id: "call_reset",
function: { name: "read_file" },
},
] as any
const argsChunk = [
{
index: 0,
function: { arguments: '{"path":"after-reset"}' },
},
] as any
;[...processor.processToolCallDeltas(setupChunk)].should.have.length(0)
processor.reset()
;[...processor.processToolCallDeltas(argsChunk)].should.have.length(0)
const newSetupChunk = [
{
index: 0,
id: "call_new",
function: { name: "write_file" },
},
] as any
const newArgsChunk = [
{
index: 0,
function: { arguments: '{"path":"file.txt"}' },
},
] as any
;[...processor.processToolCallDeltas(newSetupChunk)].should.have.length(0)
;[...processor.processToolCallDeltas(newArgsChunk)].should.have.length(1)
})
})
describe("getOpenAIToolParams", () => {
it("should include parallel_tool_calls when enabled", () => {
const tools = [
{ type: "function", function: { name: "read_file", description: "", parameters: { type: "object" } } },
] as any
const params = getOpenAIToolParams(tools, true) as any
params.parallel_tool_calls.should.equal(true)
})
it("should include parallel_tool_calls=false when disabled by default", () => {
const tools = [
{ type: "function", function: { name: "read_file", description: "", parameters: { type: "object" } } },
] as any
const params = getOpenAIToolParams(tools, false) as any
params.parallel_tool_calls.should.equal(false)
})
it("should not include parallel_tool_calls when tools are absent", () => {
const params = getOpenAIToolParams(undefined, false) as any
params.should.not.have.property("parallel_tool_calls")
})
})
+34 -66
View File
@@ -30,6 +30,7 @@ export async function createOpenRouterStream(
thinkingBudgetTokens?: number,
openRouterProviderSorting?: string,
tools?: Array<ChatCompletionTool>,
enableParallelToolCalling?: boolean,
) {
// Convert Anthropic messages to OpenAI format
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
@@ -51,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
@@ -202,7 +170,7 @@ export async function createOpenRouterStream(
...(openRouterProviderSorting && !providerPreferences ? { provider: { sort: openRouterProviderSorting } } : {}),
...(providerPreferences ? { provider: providerPreferences } : {}),
...(isClaude1m ? { provider: { order: ["anthropic", "google-vertex/global"], allow_fallbacks: false } } : {}),
...getOpenAIToolParams(tools),
...getOpenAIToolParams(tools, !!enableParallelToolCalling),
}
// @ts-expect-error-next-line
+38 -21
View File
@@ -12,10 +12,10 @@ import type { ApiStreamToolCallsChunk } from "./stream"
* and yields properly formatted tool call chunks when arguments are received.
*/
export class ToolCallProcessor {
private lastToolCall: { id: string; name: string }
private toolCallStateByIndex: Map<number, { id: string; name: string }>
constructor() {
this.lastToolCall = { id: "", name: "" }
this.toolCallStateByIndex = new Map()
}
/**
@@ -30,28 +30,32 @@ export class ToolCallProcessor {
return
}
for (const toolCallDelta of toolCallDeltas) {
for (const [fallbackIndex, toolCallDelta] of toolCallDeltas.entries()) {
// OpenAI-style streams include an index per tool call. Use iteration order as a fallback.
const toolCallIndex = toolCallDelta.index ?? fallbackIndex
const toolCallState = this.getOrCreateToolCallState(toolCallIndex)
// Accumulate the tool call ID if present
if (toolCallDelta.id) {
this.lastToolCall.id = toolCallDelta.id
toolCallState.id = toolCallDelta.id
}
// Accumulate the function name if present
if (toolCallDelta.function?.name) {
Logger.debug(`[ToolCallProcessor] Native Tool Called: ${toolCallDelta.function.name}`)
this.lastToolCall.name = toolCallDelta.function.name
toolCallState.name = toolCallDelta.function.name
}
// Only yield when we have all required fields: id, name, and arguments
if (this.lastToolCall.id && this.lastToolCall.name && toolCallDelta.function?.arguments) {
if (toolCallState.id && toolCallState.name && toolCallDelta.function?.arguments) {
yield {
type: "tool_calls",
tool_call: {
...toolCallDelta,
function: {
...toolCallDelta.function,
id: this.lastToolCall.id,
name: this.lastToolCall.name,
id: toolCallState.id,
name: toolCallState.name,
},
},
}
@@ -59,29 +63,42 @@ export class ToolCallProcessor {
}
}
private getOrCreateToolCallState(index: number): { id: string; name: string } {
const existingState = this.toolCallStateByIndex.get(index)
if (existingState) {
return existingState
}
const initialState = { id: "", name: "" }
this.toolCallStateByIndex.set(index, initialState)
return initialState
}
/**
* Reset the internal state. Call this when starting a new message.
*/
reset(): void {
this.lastToolCall = { id: "", name: "" }
this.toolCallStateByIndex.clear()
}
/**
* Get the current accumulated tool call state (useful for debugging).
*/
getState(): { id: string; name: string } {
return { ...this.lastToolCall }
getState(): Record<number, { id: string; name: string }> {
return Object.fromEntries(this.toolCallStateByIndex.entries())
}
}
export function getOpenAIToolParams(tools?: OpenAITool[], enableParallelToolCalls: boolean = false) {
return tools?.length
? {
tools,
tool_choice: tools ? ("auto" as ChatCompletionToolChoiceOption) : undefined,
parallel_tool_calls: enableParallelToolCalls ? true : false,
}
: {
tools: undefined,
}
export function getOpenAIToolParams(tools?: OpenAITool[], enableParallelToolCalls = false) {
if (!tools?.length) {
return {
tools: undefined,
}
}
return {
tools,
tool_choice: "auto" as ChatCompletionToolChoiceOption,
parallel_tool_calls: enableParallelToolCalls,
}
}
@@ -0,0 +1,243 @@
import { expect } from "chai"
import { describe, it } from "mocha"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import { convertOpenAIToolsToAnthropicTools, handleAnthropicMessagesApiStreamResponse } from "../messages_api_support"
const createAsyncIterable = (events: any[]) =>
({
async *[Symbol.asyncIterator]() {
for (const event of events) {
yield event
}
},
}) as any
async function collectChunks(events: any[]) {
const chunks: any[] = []
for await (const chunk of handleAnthropicMessagesApiStreamResponse(createAsyncIterable(events))) {
chunks.push(chunk)
}
return chunks
}
describe("messages_api_support", () => {
describe("convertOpenAIToolsToAnthropicTools", () => {
it("returns undefined when tools are missing", () => {
expect(convertOpenAIToolsToAnthropicTools(undefined)).to.equal(undefined)
expect(convertOpenAIToolsToAnthropicTools([])).to.equal(undefined)
})
it("converts function tools and defaults schema type to object", () => {
const tools: OpenAITool[] = [
{
type: "function",
function: {
name: "read_file",
description: "Read a file from disk",
parameters: {
properties: {
path: { type: "string" },
},
required: ["path"],
},
},
},
]
const converted = convertOpenAIToolsToAnthropicTools(tools)
expect(converted).to.deep.equal([
{
name: "read_file",
description: "Read a file from disk",
input_schema: {
type: "object",
properties: {
path: { type: "string" },
},
required: ["path"],
},
},
])
})
it("filters out invalid tools", () => {
const tools = [
{
type: "other",
function: {
name: "ignored",
},
},
{
type: "function",
function: {
name: "",
},
},
{
type: "function",
function: {
name: "valid_tool",
parameters: { type: "object", properties: {} },
},
},
] as any as OpenAITool[]
const converted = convertOpenAIToolsToAnthropicTools(tools)
expect(converted).to.have.length(1)
expect(converted?.[0]?.name).to.equal("valid_tool")
})
})
describe("handleAnthropicMessagesApiStreamResponse", () => {
it("maps usage, reasoning, and text events into ApiStream chunks", async () => {
const chunks = await collectChunks([
{
type: "message_start",
message: {
usage: {
input_tokens: 10,
output_tokens: 2,
cache_creation_input_tokens: 4,
cache_read_input_tokens: 3,
},
},
},
{
type: "content_block_start",
content_block: {
type: "thinking",
thinking: "first thought",
signature: "sig-start",
},
index: 0,
},
{
type: "content_block_delta",
delta: {
type: "thinking_delta",
thinking: " then more",
},
},
{
type: "content_block_delta",
delta: {
type: "signature_delta",
signature: "sig-final",
},
},
{
type: "content_block_start",
content_block: {
type: "text",
text: "Hello",
},
index: 0,
},
{
type: "content_block_start",
content_block: {
type: "text",
text: "World",
},
index: 1,
},
{
type: "message_delta",
usage: {
output_tokens: 9,
},
},
])
expect(chunks).to.deep.equal([
{
type: "usage",
inputTokens: 10,
outputTokens: 2,
cacheWriteTokens: 4,
cacheReadTokens: 3,
},
{
type: "reasoning",
reasoning: "first thought",
signature: "sig-start",
},
{
type: "reasoning",
reasoning: " then more",
},
{
type: "reasoning",
reasoning: "",
signature: "sig-final",
},
{
type: "text",
text: "Hello",
},
{
type: "text",
text: "\n",
},
{
type: "text",
text: "World",
},
{
type: "usage",
inputTokens: 0,
outputTokens: 9,
},
])
})
it("emits tool call chunks and resets tool state on block stop", async () => {
const chunks = await collectChunks([
{
type: "content_block_start",
content_block: {
type: "tool_use",
id: "tool_1",
name: "read_file",
},
index: 0,
},
{
type: "content_block_delta",
delta: {
type: "input_json_delta",
partial_json: '{"path":',
},
},
{
type: "content_block_stop",
},
{
type: "content_block_delta",
delta: {
type: "input_json_delta",
partial_json: '"ignored-after-stop"}',
},
},
])
expect(chunks).to.have.length(1)
expect(chunks[0]).to.deep.equal({
type: "tool_calls",
tool_call: {
id: "tool_1",
name: "read_file",
arguments: "",
function: {
id: "tool_1",
name: "read_file",
arguments: '{"path":',
},
},
})
})
})
})
+1
View File
@@ -42,6 +42,7 @@ export const toolParamNames = [
"steps_to_reproduce",
"api_request_output",
"additional_context",
"queries",
"needs_more_exploration",
"task_progress",
"timeout",
+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
+4
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,9 @@ export class Controller {
backgroundEditEnabled: this.stateManager.getGlobalSettingsKey("backgroundEditEnabled"),
optOutOfRemoteConfig: this.stateManager.getGlobalSettingsKey("optOutOfRemoteConfig"),
doubleCheckCompletionEnabled,
showFeatureTips,
codeIntelligenceEnabled: this.stateManager.getGlobalSettingsKey("codeIntelligenceEnabled"),
codeIntelligenceAvailable: HostProvider.psi !== undefined,
banners,
welcomeBanners,
openAiCodexIsAuthenticated,
@@ -179,6 +179,11 @@ export async function updateSettings(controller: Controller, request: UpdateSett
}
}
// Update code intelligence setting (JetBrains PSI)
if (request.codeIntelligenceEnabled !== undefined) {
controller.stateManager.setGlobalState("codeIntelligenceEnabled", !!request.codeIntelligenceEnabled)
}
// Update auto-condense setting
if (request.useAutoCondense !== undefined) {
if (controller.task) {
@@ -344,6 +349,10 @@ export async function updateSettings(controller: Controller, request: UpdateSett
controller.stateManager.setGlobalState("doubleCheckCompletionEnabled", request.doubleCheckCompletionEnabled)
}
if (request.showFeatureTips !== undefined) {
controller.stateManager.setGlobalState("showFeatureTips", request.showFeatureTips)
}
// Post updated state to webview
await controller.postStateToWebview()
@@ -0,0 +1,144 @@
import { afterEach, describe, it } from "mocha"
import "should"
import sinon from "sinon"
import { Logger } from "@/shared/services/Logger"
import * as HookExecutor from "../hook-executor"
import {
buildNotificationData,
emitNotificationHook,
emitTaskCompleteNotification,
emitUserAttentionNotification,
NOTIFICATION_MESSAGE_MAX_LENGTH,
} from "../notification-hook"
describe("notification-hook", () => {
afterEach(() => {
sinon.restore()
})
const context = {
messageStateHandler: {} as any,
taskId: "task-123",
hooksEnabled: true,
model: { provider: "anthropic", slug: "claude" },
}
it("emits user-attention notifications with normalized payload", async () => {
const executeHookStub = sinon.stub(HookExecutor, "executeHook").resolves({ wasCancelled: false })
await emitUserAttentionNotification(context, {
source: "approval_request",
message: "Need approval",
})
sinon.assert.calledOnce(executeHookStub)
const notification = (
executeHookStub.firstCall.args[0].hookInput as { notification: ReturnType<typeof buildNotificationData> }
).notification
notification.event.should.equal("user_attention")
notification.source.should.equal("approval_request")
notification.sourceType.should.equal("ask")
notification.sourceId.should.equal("approval_request")
notification.waitingForUserInput.should.equal(true)
notification.requiresUserAction.should.equal(true)
notification.severity.should.equal("info")
})
it("emits task-complete notifications with normalized payload", async () => {
const executeHookStub = sinon.stub(HookExecutor, "executeHook").resolves({ wasCancelled: false })
await emitTaskCompleteNotification(context, { message: "All done" })
const notification = (
executeHookStub.firstCall.args[0].hookInput as { notification: ReturnType<typeof buildNotificationData> }
).notification
notification.event.should.equal("task_complete")
notification.source.should.equal("attempt_completion")
notification.sourceType.should.equal("tool")
notification.sourceId.should.equal("attempt_completion")
notification.waitingForUserInput.should.equal(false)
notification.requiresUserAction.should.equal(false)
})
it("centralizes truncation and exposes truncation metadata", () => {
const notification = buildNotificationData({
event: "user_attention",
source: "ask",
sourceType: "ask",
sourceId: "followup",
message: "x".repeat(NOTIFICATION_MESSAGE_MAX_LENGTH + 25),
waitingForUserInput: true,
requiresUserAction: true,
})
notification.messageTruncated.should.equal(true)
notification.message.length.should.equal(NOTIFICATION_MESSAGE_MAX_LENGTH + "\n...[truncated]".length)
notification.message.should.match(/\.\.\.\[truncated\]$/)
})
it("preserves backward-compatible fields while adding additive fields", () => {
const notification = buildNotificationData({
event: "user_attention",
source: "ask",
sourceType: "ask",
sourceId: "approval",
message: "hello",
waitingForUserInput: true,
requiresUserAction: true,
})
notification.event.should.equal("user_attention")
notification.source.should.equal("ask")
notification.message.should.equal("hello")
notification.waitingForUserInput.should.equal(true)
notification.eventVersion.should.equal("1")
notification.eventId.should.not.equal("")
notification.messageTruncated.should.equal(false)
notification.sourceType.should.equal("ask")
notification.sourceId.should.equal("approval")
notification.requiresUserAction.should.equal(true)
notification.severity.should.equal("info")
})
it("ignores unsupported notification outputs and logs warnings", async () => {
const executeHookStub = sinon.stub(HookExecutor, "executeHook").resolves({
cancel: true,
contextModification: "ignored",
wasCancelled: false,
})
const warnStub = sinon.stub(Logger, "warn")
await emitNotificationHook(
context,
buildNotificationData({
event: "task_complete",
source: "attempt_completion",
sourceType: "tool",
sourceId: "attempt_completion",
message: "done",
waitingForUserInput: false,
requiresUserAction: false,
}),
)
sinon.assert.calledOnce(executeHookStub)
sinon.assert.calledTwice(warnStub)
})
it("fails open when hook execution throws", async () => {
sinon.stub(HookExecutor, "executeHook").rejects(new Error("boom"))
const errorStub = sinon.stub(Logger, "error")
await emitTaskCompleteNotification(context, { message: "done" })
sinon.assert.calledOnce(errorStub)
})
it("does nothing when hooks are disabled", async () => {
const executeHookStub = sinon.stub(HookExecutor, "executeHook").resolves({ wasCancelled: false })
await emitTaskCompleteNotification({ ...context, hooksEnabled: false }, { message: "done" })
sinon.assert.notCalled(executeHookStub)
})
})
+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")
})
})
})
+124 -144
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'));
@@ -537,149 +552,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/)
}
})
})
})
})
+49 -56
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("TaskStart 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)
@@ -421,69 +423,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")
})
})
})
+122
View File
@@ -0,0 +1,122 @@
import type { MessageStateHandler } from "@core/task/message-state"
import type { NotificationData } from "@shared/proto/cline/hooks"
import { ulid } from "ulid"
import { Logger } from "@/shared/services/Logger"
import * as HookExecutor from "./hook-executor"
import type { HookModelInputContext } from "./hook-factory"
export const NOTIFICATION_MESSAGE_MAX_LENGTH = 8000
const NOTIFICATION_EVENT_VERSION = "1"
const NOTIFICATION_SEVERITY_INFO = "info"
type NotificationExecutionContext = {
messageStateHandler: MessageStateHandler
taskId: string
hooksEnabled: boolean
model?: HookModelInputContext
}
type BaseNotificationInput = {
event: string
source: string
message: string
waitingForUserInput: boolean
sourceType: string
sourceId: string
requiresUserAction: boolean
severity?: string
eventId?: string
}
export function buildNotificationData(input: BaseNotificationInput): NotificationData {
const message = input.message
const messageTruncated = message.length > NOTIFICATION_MESSAGE_MAX_LENGTH
return {
event: input.event,
source: input.source,
message: messageTruncated ? `${message.slice(0, NOTIFICATION_MESSAGE_MAX_LENGTH)}\n...[truncated]` : message,
waitingForUserInput: input.waitingForUserInput,
eventVersion: NOTIFICATION_EVENT_VERSION,
eventId: input.eventId ?? ulid(),
messageTruncated,
sourceType: input.sourceType,
sourceId: input.sourceId,
requiresUserAction: input.requiresUserAction,
severity: input.severity ?? NOTIFICATION_SEVERITY_INFO,
}
}
export async function emitNotificationHook(context: NotificationExecutionContext, notification: NotificationData): Promise<void> {
if (!context.hooksEnabled) {
return
}
try {
const result = await HookExecutor.executeHook({
hookName: "Notification",
hookInput: {
notification,
},
isCancellable: false,
say: async () => undefined,
messageStateHandler: context.messageStateHandler,
taskId: context.taskId,
hooksEnabled: context.hooksEnabled,
model: context.model,
})
if (result.cancel) {
Logger.warn("[Notification Hook] Ignoring unsupported cancel output")
}
if (result.contextModification) {
Logger.warn("[Notification Hook] Ignoring unsupported contextModification output")
}
} catch (error) {
Logger.error("[Notification Hook] Failed (non-fatal):", error)
}
}
export async function emitUserAttentionNotification(
context: NotificationExecutionContext,
input: {
source: string
message: string
waitingForUserInput?: boolean
requiresUserAction?: boolean
},
): Promise<void> {
const notification = buildNotificationData({
event: "user_attention",
source: input.source,
sourceType: "ask",
sourceId: input.source,
message: input.message,
waitingForUserInput: input.waitingForUserInput ?? true,
requiresUserAction: input.requiresUserAction ?? true,
severity: NOTIFICATION_SEVERITY_INFO,
})
await emitNotificationHook(context, notification)
}
export async function emitTaskCompleteNotification(
context: NotificationExecutionContext,
input: {
message: string
},
): Promise<void> {
const notification = buildNotificationData({
event: "task_complete",
source: "attempt_completion",
sourceType: "tool",
sourceId: "attempt_completion",
message: input.message,
waitingForUserInput: false,
requiresUserAction: false,
severity: NOTIFICATION_SEVERITY_INFO,
})
await emitNotificationHook(context, notification)
}
+22 -2
View File
@@ -326,7 +326,14 @@ function getNotificationTemplate(): string {
# event: string,
# source: string,
# message: string,
# waitingForUserInput: boolean
# waitingForUserInput: boolean,
# eventVersion: string,
# eventId: string,
# messageTruncated: boolean,
# sourceType: string,
# sourceId: string,
# requiresUserAction: boolean,
# severity: string
# },
# clineVersion,
# timestamp,
@@ -337,6 +344,11 @@ function getNotificationTemplate(): string {
# Typical events:
# - user_attention (ask prompt requiring user input)
# - task_complete (task reached completion)
#
# Notification hooks are observation-only:
# - cancel is ignored by the caller
# - contextModification is ignored by the caller
# - hook failures are non-fatal
INPUT=$(cat)
@@ -344,13 +356,21 @@ if command -v jq &> /dev/null; then
EVENT=$(echo "$INPUT" | jq -r '.notification.event // "unknown"')
SOURCE=$(echo "$INPUT" | jq -r '.notification.source // "unknown"')
WAITING=$(echo "$INPUT" | jq -r '.notification.waitingForUserInput // false')
EVENT_VERSION=$(echo "$INPUT" | jq -r '.notification.eventVersion // "unknown"')
SOURCE_TYPE=$(echo "$INPUT" | jq -r '.notification.sourceType // "unknown"')
REQUIRES_ACTION=$(echo "$INPUT" | jq -r '.notification.requiresUserAction // false')
SEVERITY=$(echo "$INPUT" | jq -r '.notification.severity // "info"')
else
EVENT="unknown"
SOURCE="unknown"
WAITING="false"
EVENT_VERSION="unknown"
SOURCE_TYPE="unknown"
REQUIRES_ACTION="false"
SEVERITY="info"
fi
echo "[Notification] event=$EVENT source=$SOURCE waitingForUserInput=$WAITING" >&2
echo "[Notification] event=$EVENT source=$SOURCE sourceType=$SOURCE_TYPE waitingForUserInput=$WAITING requiresUserAction=$REQUIRES_ACTION severity=$SEVERITY eventVersion=$EVENT_VERSION" >&2
echo "{\"cancel\":false,\"contextModification\":\"\",\"errorMessage\":\"\"}"
`
@@ -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`,

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