Compare commits

..

67 Commits

Author SHA1 Message Date
abeatrix fae0c03b7a time-line display 2026-01-30 23:01:49 +08:00
abeatrix 6106758e95 parallel subagents 2026-01-30 22:32:51 +08:00
abeatrix c78835945f Fix Subagent stream rendering issues 2026-01-28 23:34:41 +09:00
abeatrix 2f0dbb2293 feat: add subagent framework with ClineAgent base class
Implement a flexible subagent system that enables autonomous agentic loops:

- Add ClineAgent abstract base class for creating domain-specific agents
  - Supports configurable max iterations and tool registration
  - Tracks costs across all active agent instances
  - Handles tool execution with automatic retry logic
  - Provides hooks for context management and result formatting

- Implement SubAgentToolHandler for spawning child agents
  - Enables hierarchical agent delegation
  - Supports custom prompts and tool configurations

- Extract webfetch functionality into reusable utility
  - Refactor WebFetchToolHandler to use shared webfetch function
  - Enable webfetch usage in subagent contexts

- Add comprehensive type definitions for agent configuration
  - Define AgentContext, AgentActions, and iteration update types
  - Support flexible tool result handling

This framework allows creating specialized agents that can autonomously
iterate on tasks, use tools, and delegate to other agents while maintaining
cost tracking and proper error handling.
2026-01-27 21:01:20 -08:00
github-actions[bot] 06b05ddfe9 Changeset version bump (#8895)
* changeset version bump

* Updating CHANGELOG.md format

* release(3.55.0): Version bump and update WhatsNewModal

* feat(settings): Support linking to recommended or free model picker.

* Send to cline provider

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: cline-test <132302818+candieduniverse@users.noreply.github.com>
Co-authored-by: Arafatkatze <arafat.da.khan@gmail.com>
2026-01-27 19:02:59 -08:00
Jose R. Perez 2670a4a171 feat: updated welcome card content and added ability to close each card (#8900)
* feat: updated welcome card content and added ability to close each card

* feat: change set

* feat: fix
2026-01-27 18:35:18 -08:00
Renee Huang 1699c9a63a wording for Codex login (#8835) 2026-01-27 18:29:34 -08:00
Tomás Barreiro 808dd42ae9 Lock the LiteLLM api key input when it's remotely configured (#8899) 2026-01-28 02:34:06 +01:00
Ara 71af56f493 feat: add Arcee AI Trinity Large Preview to free models (#8897)
Add arcee-ai/trinity-large-preview:free as a new free model option:
- Add to onboarding models with 131k context window and score of 88
- Include in OpenRouterModelPicker free models list
- Update filter to preserve Trinity Large models like Minimax models
2026-01-27 15:51:39 -08:00
Juan Pablo Flores 1167b4f3a6 feat(rules): Conditional rules docs (#8874)
* docs(rules): Initial thoughts on docs for conditional rules.

* docs: restructure Cline Rules documentation into nested structure

Reorganize Cline Rules documentation by:
- Creating a "Cline Rules" group with overview and conditional-rules pages
- Moving conditional-rules.mdx into features/cline-rules/ subdirectory
- Adding URL redirects for backward compatibility
- Streamlining conditional-rules content for clarity and conciseness
- Adding cross-reference link to the overview page

This improves documentation navigation by grouping related rule concepts together and makes the content more accessible with clearer, more concise explanations.

* docs(cline-rules): consolidate rule file format documentation

Reorganize and expand the documentation for supported rule file formats:

- Add new "Supported Rule Files" section with comprehensive table
- Document cross-tool compatibility (Cursor, Windsurf, AGENTS.md)
- Clarify file priority and loading behavior
- Remove separate AGENTS.md section and integrate into unified table

This improves discoverability by showing all supported formats in one
place and makes it clearer how Cline works with rules from different AI
coding tools.

* docs(rules): remove context management note from overview

---------

Co-authored-by: cline-test <132302818+candieduniverse@users.noreply.github.com>
2026-01-27 14:17:29 -08:00
WangXiaolong e8d6370b0c feat(deepseek): add native tool calling support and reasoning_content handling (#7888)
* feat(deepseek): add native tool calling support and reasoning_content passback

- Add DeepSeek to isNextGenModelProvider list to enable native tool calling
- Add isDeepSeekModelFamily function for model identification
- Add addReasoningContent function for DeepSeek Reasoner's reasoning_content field
  - Pass back reasoning_content during tool calling within the same turn
  - Clear reasoning_content when starting a new conversation turn
- Compliant with DeepSeek API documentation for thinking mode with tool calling

* Update src/core/api/transform/r1-format.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update comments for user message handling logic

Clarify reasoning for handling user messages in comments.

* Update src/core/api/transform/r1-format.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: format code for consistency in isNextGenModelFamily function

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Ara <arafat.da.khan@gmail.com>
2026-01-27 13:24:20 -08:00
Toby White e243376a39 feat: add MCP prompts support (#8066)
* feat: add MCP prompts support

Implement support for MCP prompts as defined in the MCP spec (2025-06-18):
- Add McpPrompt and McpPromptArgument types to shared types
- Update proto definitions with prompt messages
- Update McpHub to fetch prompts list and get individual prompts
- Add prompts to system prompt component for AI awareness
- Add McpPromptRow UI component for displaying prompts
- Update ServerRow with Prompts tab showing available prompts
- Add slash command integration (/mcp:<server>:<prompt>)
- Update regex patterns to support colons in command names

MCP prompts are user-controlled templates that can be invoked via
slash commands to inject contextual messages into the conversation.

* style: alphabetize imports in mcp-server-conversion.ts

Reorder imports to follow project convention of alphabetical ordering.

* feat: add MCP prompts to slash command autocomplete

Wire up mcpServers to SlashCommandMenu so MCP prompt commands appear
in the autocomplete dropdown with their own "MCP Prompts" section.

* test: add unit tests for MCP prompt slash commands

- Add webview slash-commands.test.ts testing getMcpPromptCommands,
  getMatchingSlashCommands, and validateSlashCommand with MCP servers
- Add backend slash-commands tests for formatMcpPromptResponse and
  parseSlashCommands MCP handling
- Export formatMcpPromptResponse for testability
- Add "mcp_prompt" to telemetry captureSlashCommandUsed types

* test: update snapshots and fix backend tests for MCP prompts

- Update system prompt snapshots to include MCP prompts section
- Remove backend tests requiring StateManager initialization
  (tests for unknown server, no fetcher, fetcher errors)
- Core MCP prompt functionality is covered by remaining tests

* fix: change test status to valid 'connecting' value

* chore: remove commented debug line from prompts fetching

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: use Logger instead of console.error for lint compliance

* fix: wire up mcpPromptFetcher callback to parseSlashCommands

The MCP prompt slash commands were not working because the
mcpPromptFetcher callback was never passed to parseSlashCommands.
This adds the callback that wraps mcpHub.getPrompt() to actually
fetch and inject prompt content when using /mcp:server:prompt.

* fix: resolve MCP prompts keyboard navigation and edge cases

- Add mcpServers param to keyboard handler's getMatchingSlashCommands calls
  to fix arrow key navigation and Enter/Tab selection for MCP prompts
- Add null check for connection.client in McpHub.getPrompt()
- Add debug logging when MCP prompt fetch returns null
- Fix regex in shouldShowSlashCommandsMenu to include colons for MCP format

* chore: add changeset for MCP prompts feature

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Robin Newhouse <robin@cline.bot>
2026-01-27 13:12:02 -08:00
Tomás Barreiro 4f1be9d512 Replace POSTHOG_TELEMETRY_ENABLED with CLINE_TELEMETRY_DISABLED (#8818)
* Replace `POSTHOG_TELEMETRY_ENABLED` with `CLINE_TELEMETRY_DISABLED`

* Update cli/pkg/hostbridge/env.go

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-27 18:17:06 +01:00
Ara 94d36ce719 feat(ui): reduce font size for thinking content (#8892)
Add text-xs class to reasoning content in ThinkingRow component
for improved visual hierarchy and readability.
2026-01-27 08:28:39 -08:00
Bee 5df7498f03 refactor: simplify ThinkingRow expansion state management (#8735)
* refactor: simplify ThinkingRow expansion state management

Remove the responseStarted prop and complex logic that conditionally controlled ThinkingRow visibility during streaming. Simplify to allow ThinkingRow to remain expandable throughout the entire streaming lifecycle instead of forcing it expanded during reasoning and then collapsing after response starts.

Changes:
- Remove ApiReqState type and responseStarted tracking
- Eliminate showStreamingThinking and showCollapsedThinking logic
- Use consistent isExpanded state based only on user toggle
- Always show ThinkingRow title

* remove unused responseStarted

* feat(ui): update thinking UI with improved expand/collapse controls

Changes:
- Replace "Thinking..." with "Working..." status text in non-plan mode
- Switch from ChevronRight to ChevronUp/Down icons for better UX
- Redesign thinking section header with cleaner layout
- Remove preview text when collapsed, show only "Thinking" label
- Add consistent border styling to thinking content
- Implement per-tool thinking expand/collapse state management
- Update icon sizing and styling for better visual consistency

This improves the user experience by making the thinking/reasoning sections more intuitive to expand and collapse, with clearer visual indicators and a more polished appearance.

* add blur

* feat: chevron fix, reasoning change, slight style change

* feat: spacing issues

* keep thinking row expanded during stream

* Reasoning -> Thoughts

* feat: Inline reading of files vs having reading then read list items seperately

* feat: remove extra reading state

* feat: removed reasoning from file expandable file state

---------

Co-authored-by: Jose R. Perez <trupix@gmail.com>
2026-01-27 07:53:03 -08:00
Juan Pablo Flores c2b87252ac Fixes Cannot restore checkpoint more than once #8866 (#8873) 2026-01-27 07:46:43 -08:00
github-actions[bot] be353bb3da v3.54.0 Release Notes (#8840)
- Native tool calls support for Ollama provider
- Sonnet 4.5 is now the default Amazon Bedrock model id

- Prevent infinite retry loops when replace_in_file fails repeatedly. The system now detects repeated failures and provides better guidance to break out of retry cycles.
- Skip diff error UI handling during streaming to prevent flickering. Error handling is deferred until streaming completes.
- Strip notebook cell outputs when extracting text content from Jupyter notebooks, significantly reducing context size sent to the LLM.
- Throttle diff view updates during streaming to reduce UI flickering and improve performance.

- Removed Devstral-2512 free from the free models list
- Removed deprecated zai-glm-4.6 model from Cerebras provider

Co-authored-by: Arafatkatze <arafat.da.khan@gmail.com>
2026-01-27 07:44:49 -08:00
Ara 0a7791de1f feat: remove Mistral Devstral 2512 from free models list (#8889)
Remove mistralai/devstral-2512:free from:
- Onboarding models configuration
- Free models picker in settings
- OpenRouter model filter exception list

The Devstral model is no longer included as a free tier option.
2026-01-27 07:16:55 -08:00
Robin Newhouse 7cca102e14 fix: apply_patch tool now works with OCA provider's gpt5 model ID (#8875) 2026-01-26 22:54:21 -08:00
Bee 4f591de6a9 feat: Adds support for native tool calls to Ollama provider (#8871)
* feat: add support for tool calls in Ollama API

Enhanced OllamaHandler to support tool calls by adding a 'tools' parameter to createMessage. Implements processing of tool call deltas using ToolCallProcessor, enabling handling of function calls made by the model. Added necessary imports for ChatCompletionTool and ToolCallProcessor types.

* add changeset
2026-01-26 17:50:52 -08:00
Tomás Barreiro 60436b3ddf Do not call feature_flag_called event if the value hasn't changed (#8867)
* Do not call feature_flag_called event if the value hasn't changed

* Send the feature flag called on startup
2026-01-26 10:51:33 -08:00
Saoud Rizwan df5954052d feat: disable extended thinking by default (#8863) 2026-01-26 10:21:21 -08:00
Igor Tceglevskii a66a2784c3 feat: disable PostHog services in self-hosted mode (#8842)
- Skip PostHog client initialization when running in self-hosted mode
- Return no-op config from ErrorProviderFactory and FeatureFlagsProviderFactory
- Add comprehensive tests for self-hosted mode PostHog disabling behavior

This ensures no telemetry or analytics data is sent when users run
the extension in a self-hosted environment.
2026-01-26 07:51:02 -08:00
Bee 47031cea25 feat: add debugLog RPC for host bridge logging (#8841)
* feat: add appendOutputLog RPC for host bridge logging

Add new appendOutputLog RPC endpoint to EnvService proto definition
and refactor VSCode output channel creation to use a dedicated factory
function. This enables structured logging through the host bridge
service instead of direct Logger calls.

* rename appendOutputLog to debugLog and add subscriber pattern

- Rename `appendOutputLog` RPC to `debugLog` with documentation
- Refactor Logger to use subscriber pattern instead of single output
- Update HostProvider to use env.debugLog directly for logging
- Remove redundant logger callback from setupHostProvider

* feat: add multi-subscriber support for Logger output

- Rename Logger.setOutput to Logger.subscribe to better reflect behavior
- Subscribe both output channel and debug logger to receive log messages
- Enable logging to multiple destinations simultaneously

* update mock
2026-01-23 18:18:24 -08:00
Robin Newhouse e29740479e fix: skip diff error UI handling during streaming to prevent flickering (#8788)
* fix: skip diff error UI handling during streaming to prevent flickering

During streaming, handlePartialBlock is called repeatedly, and if the diff
application fails (e.g., search string not found), all the error handling code
was running on every chunk. This caused:
- consecutiveMistakeCount to rapidly increment
- diff_error messages to be added/removed repeatedly
- revertChanges/reset to be called repeatedly
- rapid flickering of the diff viewer

Now we return early from the catch block when block.partial is true, skipping
all error UI handling. The error is only processed once on the final block.

* chore: add changeset for diff error suppression

* test: add unit tests for partial block streaming behavior

Adds tests verifying that error handling is skipped during streaming
(block.partial=true) to prevent counter rapid increment and UI flickering.

* chore: remove unused errorPushedForCallIds tracking

This mechanism was replaced by the simpler block.partial check for
skipping error handling during streaming. Remove the dead code.
2026-01-23 16:55:32 -08:00
Ara 74f607ff8e chore(release): bump version to 3.53.1 (#8839)
- Fix bug in responses API
- Update changeset package name from "cline" to "claude-dev"
- Update version in package.json and package-lock.json
2026-01-23 15:46:59 -08:00
Robin Newhouse 0edf6d777b fix: prevent infinite retry loops when replace_in_file fails repeatedly (#8787)
* fix: prevent infinite retry loops when replace_in_file fails repeatedly

The consecutiveMistakeCount was being reset to 0 at the START of each
WriteToFileToolHandler execution, before the tooManyMistakes check could
see accumulated failures. This allowed the model to retry failing
replace_in_file operations indefinitely, causing context explosion.

Changes:
- Move counter reset from before operation to after successful saveChanges()
- Add consecutiveMistakeCount++ in the diff error catch block
- Fix typo: "his thought process" → "Cline's thought process"

* chore: add changeset for retry loop prevention

* test: add unit tests for consecutiveMistakeCount behavior

Verify the fix for infinite retry loops by testing that:
- Counter is NOT reset at the start of operations
- Counter IS reset only after successful saveChanges()
- Counter IS incremented on diff errors
- Repeated failures accumulate so tooManyMistakes can trigger
2026-01-23 15:37:01 -08:00
Robin Newhouse de630c64d4 fix: throttle diff view updates during streaming (#8785)
* fix: throttle diff view updates during streaming

Skip redundant rapid updates to reduce performance issues in large
streams (e.g., notebooks) and reset throttle state on cleanup.

* chore: add changeset for diff throttling fix

* test: add unit tests for diff view update throttling

Add comprehensive tests for the throttling behavior introduced in the
streaming diff updates fix. Tests cover empty content, unchanged content,
time-based throttling, final update bypass, and state reset.
2026-01-23 15:36:47 -08:00
Bee 3ff63562c8 chore: migrate host logging to shared Logger service (#8820)
* chore: migrate host logging to shared Logger service

- Replace HostProvider.logToChannel usage with Logger.log/error
  in controller, webview, and checkpoint migration code
- Remove redundant, low-value log statements from Cline API
  methods to reduce noise
- Centralize logging through shared Logger service for more
  consistent, structured logging and easier maintenance
- Remove redundant , low-value log statements from StateManager where
  we logged error that would be throw and get logged again

* Update src/integrations/checkpoints/CheckpointMigration.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix

* update tests

* update tests

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-23 15:27:11 -08:00
Saoud Rizwan 4d6f908fbd fix: add null check when filtering tools by type in Responses API providers (#8837)
Users reported seeing this error with the OpenAI Codex provider:
{"message":"Cannot read properties of undefined (reading 'type')","modelId":"gpt-5.2-codex"}

The issue occurs when filtering tools before sending to the Responses API.
The filter accessed .type without checking if the tool element was defined:

  tools.filter((tool) => tool.type === "function")

If the tools array contains any undefined elements, this throws. Fixed by
adding optional chaining:

  tools.filter((tool) => tool?.type === "function")

Applied the same fix to all three providers using the Responses API:
- openai-codex.ts (ChatGPT Plus/Pro subscriptions)
- openai-native.ts (OpenAI API with Responses format)
- oca.ts (OpenAI-compatible API with Responses format)
2026-01-23 14:48:51 -08:00
Igor Tceglevskii 6521fdcc94 disable telemetry for self-hosted environments (#8790) 2026-01-23 14:16:50 -08:00
Igor Tceglevskii 1393eace27 Endpoint configuration file (#8645) 2026-01-23 13:40:31 -08:00
github-actions[bot] eebb99c1e3 Changeset version bump (#8800)
* changeset version bump

* Updating CHANGELOG.md format

* update changelog and banner for release

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-01-23 13:12:29 -08:00
Ara 9ed44f7a83 feat(cerebras): use model-specific temperature configuration (#8833)
- Extract model retrieval to avoid multiple function calls
- Use temperature from model.info with fallback to 0 instead of hardcoded value
- Allows temperature to be configured per model rather than using a fixed value

This change enables more flexible temperature configuration for different Cerebras models while maintaining backward compatibility with a default value of 0.
2026-01-23 12:50:57 -08:00
Ara 6a95cc5f19 feat: add default temperature to Cerebras model configuration (#8832)
Set default temperature value of 0.9 for Cerebras model in the model
configuration. This establishes a consistent default sampling temperature
for the model's response generation behavior.
2026-01-23 12:42:26 -08:00
er-ri 1d0637f39c fix: add support for haiku 4.5 in JP_SUPPORTED_CRIS_MODELS and enable global endpoint support (#8298) 2026-01-23 11:46:02 -08:00
AJ Juaire f2c16bae7a Make the default bedrock model Sonnet 4.5 (#8830) 2026-01-23 11:45:49 -08:00
Ara 204f15ce1c Remove free period on grok (#8831) 2026-01-23 11:27:42 -08:00
Robin Newhouse 8118e11596 fix(extract-text): strip notebook outputs to reduce context size (#8784)
* fix(extract-text): strip notebook outputs to reduce context size

* chore: add changeset for notebook outputs fix
2026-01-22 17:50:31 -08:00
Bee f7b593df35 chore: remove noisy log when checking file outside workspace (#8814)
* chore: remove noisy log when checking file outside workspace

Removes a `Logger.error` call in `ifFileExistsRelativePath` that triggered whenever a file path was checked without an active workspace. This log was creating excessive noise during long conversations where many files were mentioned but no workspace was open.

* update test
2026-01-22 17:01:22 -08:00
Saoud Rizwan 2e0358a7a1 fix: disable browser tool by default (#8815)
The browser tool conflicts with the new websearch tool. Disabling it by
default provides a better out-of-box experience.
2026-01-22 16:58:29 -08:00
Bee 0fbc10f807 chore: remove unhelpful and noisy log statements - part 1 (#8813)
* chore: remove unhelpful and noisy log statements - part 1

Removes excessive debug and info logs across several services to reduce console noise, specifically:
- Deletes `[DEBUG]` logs for request registration, subscription setup/cleanup, and event dispatching in the gRPC controller and UI handlers.
- Removes verbose file cleanup logs in `ClineTempManager` and process termination logs in `AudioRecordingService`.
- Simplifies the success log in `refreshOpenRouterModels` by removing the large JSON payload dump.
- Upgrades the log level from `debug` to `error` for request cleanup failures in `GrpcRequestRegistry` to ensure exceptions are properly highlighted.

* removes subscription logs
2026-01-22 16:44:05 -08:00
Bee c093ca1760 refactor: replace console with Logger service (#8741)
* chore: add grit rule to enforce Logger service over console calls

Add a new Grit linting rule that detects direct console method usage
(log, debug, error, warn, info) and prompts developers to use the
Logger service instead for consistent logging practices.

The rule is configured in biome.jsonc to apply to most source files
while excluding test files, webview-ui, evals, standalone, e2e tests,
and scripts where direct console usage may be acceptable.

* support variadic args

* wip: migrate console to Logger

* migrate rest of console logger

* Switch to Logger

* Migrations

* shared

* use shared

* revert format change

* Update tests to stub Logger instead of console

* verbose in dev mode
2026-01-22 13:16:37 -08:00
Seb Duerr dc85299a73 Remove deprecated zai-glm-4.6 model from Cerebras provider (#8717) 2026-01-22 12:58:07 -08:00
ClineXDiego 4533ed3ea8 fix: support drag & drop files from SSH remote workspaces (#8804)
Add vscode-remote: scheme to the valid URI filter for drag & drop operations.
This allows files from SSH Remote workspaces to be dropped into the chat.

Fixes #7606
2026-01-22 12:37:11 -08:00
Max 66d7664d1b improve cline command permission parsing logic (#8544)
- cline command permission flag can now parse subshells correctly and
validate that subshells don't contain disallowed commands.

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-01-22 11:11:53 -08:00
dependabot[bot] 50aeb7098f chore(deps): bump undici (#8783)
Bumps [undici](https://github.com/nodejs/undici) to 6.23.0 and updates ancestor dependency . These dependencies need to be updated together.


Updates `undici` from 6.22.0 to 6.23.0
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v6.22.0...v6.23.0)

Updates `undici` from 7.16.0 to 7.19.0
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v6.22.0...v6.23.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 6.23.0
  dependency-type: indirect
- dependency-name: undici
  dependency-version: 7.19.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: ClineXDiego <diego@cline.bot>
2026-01-22 10:50:49 -08:00
github-actions[bot] c2e6eafb45 v3.52.0 Release Notes (#8633)
- Users with ChatGPT Plus or Pro subscriptions can now use GPT-5 models directly through Cline without needing an API key. Authentication is handled via OAuth through OpenAI's authentication system.
- Grok models are now moving out of free tier and into paid plans.
- Introduces comprehensive Jupyter Notebook support for Cline, enabling AI-assisted editing of `.ipynb` files with full cell-level context awareness.

- Bugs in DiffViewProvider for file editing
- Ollama's recommended models to use correct identifiers

Co-authored-by: Arafatkatze <arafat.da.khan@gmail.com>
2026-01-22 10:48:32 -08:00
Juan Pablo Flores 0220628c39 Adds Open AI Codex docs (#8791)
* feat(docs): add OpenAI Codex provider setup instructions and update model selection guidance

* fix(docs): improve clarity and formatting in OpenAI Codex documentation

* Update docs/provider-config/openai-codex.mdx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update docs/provider-config/openai-codex.mdx

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Tony Loehr <turingxo@gmail.com>
2026-01-22 09:30:12 -08:00
CandiedUniverse 5052220195 feat(hooks): Make hooks always enabled and remove its feature setting. [CLINE-1179] (#8777)
* feat(hooks): Standardize on calling getHooksEnabledSafe().

* feat(hooks): Hard-code getHookEnabledSafe() to return true unless on Windows.

* feat(hooks): Remove hooks setting from the CLI.

* feat(hooks): Remove hooks toggle from the Feature Settings UI.

* feat(hooks): Remove hooksEnabled toggles from settings/task APIs.

* feat(hooks): Stop using hooksEnabled setting.

* feat(hooks): npm run changeset

* feat(hooks): Simplify getHooksEnabledSafe() function signature.

* feat(hooks): Remove hooks setting migration.

feat(hooks): Remove hooksEnabled from updateSettingsCli() conversion.

feat(hooks): Use 'reserved' for removed fields in UpdateSettingsRequest protobuf.
2026-01-22 09:26:16 -08:00
CandiedUniverse abf3081e56 Rules: Wire up conditional rules functionality [ENG-1470] (#8669)
* feat(rules): Write technical design / implementation plan doc.

* update frontmatter plan

* feat(rules): Initial implementation based on plan doc.

* feat(rules): Add tool-call path harvesting for path-scoped Cline Rules.

* chore(rules): exclude internal paths-frontmatter plan doc from PR

* fix(rules): use latest user message for paths frontmatter context

* feat(rules): Implement conditional_rules_applied say type.

* feat(rules): changes as per Cline's code review feedback

* feat(rules): npm run changeset

* feat(rules): Changes as per ellipsis-dev feedback.

* feat(rules): Changes as per code review feedback (i.e. don't bloat the task context).

* feat(rules): Fix failing unit tests.
2026-01-22 09:25:59 -08:00
Michael Gutin 125cb78a31 Switch background element now has a 3:1 contrast ration with thumb and rule row background in vs code light and dark themes (#8747) 2026-01-22 12:16:51 -05:00
CandiedUniverse 17539228ea feat(cli): Add note about ctrl+c to exit the CLI. [CLINE-1162] (#8796)
* feat(cli): Add note about ctrl+c to exit the CLI.

* feat(cli): npm run changeset
2026-01-22 09:01:57 -08:00
Ara bdb7cc36fa feat(DiffEditRow): add button to open file in editor (#8564)
* refactor(diff): return result object with line tracking metadata

Change constructNewFileContent to return an object containing newContent
and line number information instead of just the string content. Add
charIndexToLineNumber helper function to support tracking where changes
occur in the file.

Update all callers and tests to access the newContent property from
the result object.

* minor fix

* fix: hide line numbers when not available from backend

* chore: add changeset

* feat: add startLineNumbers support to ApplyPatchHandler

* fix: split V4A @@ chunks into separate Patch objects for proper line numbers

* fix(DiffEditRow): preserve +/- prefix in diff line display for backwards compatibility

* fix line numbers
2026-01-21 21:38:45 -08:00
Tony Loehr d3e1065707 added jupyter docs (#8742)
* added jupyter docs

* fix jupyter docs and add gifs

* fix jupyter docs 2

* fix jupyter gif placement
2026-01-21 16:15:26 -08:00
abeatrix d9c6ba57f7 timeout ripgrep 2026-01-07 14:58:52 -08:00
abeatrix 90dae0f820 type 2026-01-07 14:58:32 -08:00
abeatrix 7e9e714cef generic 2025-12-31 14:26:36 -08:00
abeatrix 4e0d242453 split 2025-12-31 14:23:20 -08:00
abeatrix c3b31bc225 Includes Subagent cost in total cost 2025-12-31 12:39:19 -08:00
abeatrix 6775ce0085 use --no-hidden flag for search 2025-12-30 11:06:19 -08:00
abeatrix 603470b1c1 Merge branch 'main' into bee/agent-poc 2025-12-29 20:55:17 -08:00
abeatrix 7f1486a975 Merge branch 'main' into bee/agent-poc 2025-12-29 14:15:31 -08:00
abeatrix f0af58437b track serach queries and live updates 2025-12-19 16:43:48 -08:00
abeatrix 2ad585caea update to gemini 3 flash 2025-12-19 10:57:00 -08:00
abeatrix ceaa889acf wip 2025-12-06 05:41:11 -08:00
abeatrix 391c07c998 pod - agent 2025-12-06 05:40:48 -08:00
541 changed files with 10064 additions and 16623 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Add claude 4.5 haiku
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
fix: finalize document content during approval flow
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Lock the LiteLLM Api Key input when it's remotely configured
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
updated welcome card content and added ability to close each card
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Expose --version in cline cli command
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
fix: prevent duplicate diff errors when parallel tool calling is enabled
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Log Persistence errors to PostHog
-13
View File
@@ -1,13 +0,0 @@
---
"claude-dev": patch
---
feat: add OpenAI Codex (ChatGPT Plus/Pro) provider
Users with ChatGPT Plus or Pro subscriptions can now use GPT-5 models directly through Cline without needing an API key. Authentication is handled via OAuth through OpenAI's authentication system.
Models available:
- gpt-5.2-codex (default)
- gpt-5.1-codex-max
- gpt-5.1-codex-mini
- gpt-5.2
@@ -1,9 +0,0 @@
---
"claude-dev": patch
---
Fix two bugs in DiffViewProvider file editing:
1. **Line boundary validation**: Add `safelyTruncateDocument()` to prevent out-of-bounds line errors on JetBrains hosts (fixes #8423, #8429). The gRPC protocol strictly validates line numbers, causing "truncateDocument INTERNAL: Wrong line" errors when `truncateDocument()` was called with a line number >= document line count.
2. **Content concatenation on final update**: When replacing content without a trailing newline, the old content at line N+1 was concatenated to the new content. Fixed by extending the replacement range to cover the entire document on final update.
-9
View File
@@ -1,9 +0,0 @@
---
"claude-dev": patch
---
docs: fix outdated Ollama model names in documentation
Updated recommended Ollama models to use correct identifiers:
- Changed qwen3-coder-30b to qwen2.5-coder:32b
- Changed devstral-small to codellama:34b-code
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
OpenAI GPT-5 Codex models are now using Apply Patch tool for diff edits.
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Fix issue where tool call ids are invalid when switching between models using the chat completion format and the responses api format.
-10
View File
@@ -1,10 +0,0 @@
---
"claude-dev": patch
---
fix: improve Jupyter notebook diff view and reduce LLM context for notebook edits
- Restore switchToSpecializedEditor() for Jupyter notebook diff views that was accidentally removed during rebase
- Open .ipynb files in Jupyter notebook editor after save instead of leaving stale diff view
- Strip notebook outputs from content sent to LLM, reducing context by 95% (196KB → 9KB)
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
fix: add chat output on skill use
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Adding telemetry for background exec terminal
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
This pull request introduces comprehensive Jupyter Notebook support for Cline, enabling AI-assisted editing of `.ipynb` files with full cell-level context awareness. The feature allows users to seamlessly work with Jupyter notebooks using Cline's AI capabilities while preserving the notebook's JSON structure.
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
fix: removes retry message from UI after retry succeeds
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Support native tool calling for LM Studio and Ollama provider
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
fixing integration tests from testing framework
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
add claude 4.5 opus into sap provider.
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Limite Vertex and LiteLLM options when they're remote configured
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Fix crash when the Context Menu has a type but no options
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Throttle the remote config fetch
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Improve history view filter menu
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Add git worktree management UI for running parallel Cline sessions
-7
View File
@@ -16,13 +16,6 @@
TELEMETRY_SERVICE_API_KEY=your-posthog-telemetry-api-key
ERROR_SERVICE_API_KEY=your-posthog-error-tracking-api-key
# ============================================================================
# TELEMETRY PROVIDER CONTROL
# ============================================================================
# Control which telemetry providers are active
POSTHOG_TELEMETRY_ENABLED=true # Enable PostHog telemetry (default: true)
# Set to false to disable Telemetry completely
# ============================================================================
# OPENTELEMETRY (Optional - for advanced telemetry)
# ============================================================================
-1
View File
@@ -97,7 +97,6 @@ jobs:
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
POSTHOG_TELEMETRY_ENABLED: "true"
run: npm run compile-standalone-npm
- name: Generate Protos (Second Pass - Bug Workaround)
-1
View File
@@ -138,7 +138,6 @@ jobs:
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
POSTHOG_TELEMETRY_ENABLED: "true"
run: npm run compile-standalone-npm
- name: Generate Protos (Second Pass - Bug Workaround)
+52 -2
View File
@@ -1,10 +1,60 @@
# Changelog
## [3.55.0]
- Add new model: Arcee Trinity Large Preview
- Add new model: Moonshot Kimi K2.5
- Add MCP prompts support - prompts from connected MCP servers now appear in slash command autocomplete as `/mcp:<server>:<prompt>`
## [3.54.0]
### Added
- Native tool calls support for Ollama provider
- Sonnet 4.5 is now the default Amazon Bedrock model id
### Fixed
- Prevent infinite retry loops when replace_in_file fails repeatedly. The system now detects repeated failures and provides better guidance to break out of retry cycles.
- Skip diff error UI handling during streaming to prevent flickering. Error handling is deferred until streaming completes.
- Strip notebook cell outputs when extracting text content from Jupyter notebooks, significantly reducing context size sent to the LLM.
- Throttle diff view updates during streaming to reduce UI flickering and improve performance.
### Changed
- Removed Mistral's Devstral-2512 free from the free models list
- Removed deprecated zai-glm-4.6 model from Cerebras provider
## [3.53.1]
### Fixed
- Bug in responses API
## [3.53.0]
### Fixed
- Removed grok model from free tier
## [3.52.0]
### Added
- Users with ChatGPT Plus or Pro subscriptions can now use GPT-5 models directly through Cline without needing an API key. Authentication is handled via OAuth through OpenAI's authentication system.
- Grok models are now moving out of free tier and into paid plans.
- Introduces comprehensive Jupyter Notebook support for Cline, enabling AI-assisted editing of `.ipynb` files with full cell-level context awareness.
### Fixed
- Bugs in DiffViewProvider for file editing
- Ollama's recommended models to use correct identifiers
## [3.51.0]
### Added
- Adding OpenAI gpt-5.2-codex model to the model picker
- Adding OpenAI gpt-5.2-codex model to the model picker
## [3.50.0]
@@ -1737,4 +1787,4 @@ Add Opus 4.1 through Claude Code
## [0.0.6]
- Initial release
- Initial release
+23
View File
@@ -147,6 +147,29 @@
"src/dev/grit/vscode-api.grit"
]
},
{
// Do not use console logging directly, use the Logger service instead.
"plugins": [
"src/dev/grit/console-log.grit"
],
"includes": [
"**",
"!**/webview-ui/**",
"!**/evals/**",
"!**/standalone/**",
"!**/e2e/**",
"!**/test/**",
"!**/__tests__/**",
"!**/*.test.ts",
"!**/*.stories.ts",
"!src/dev/**",
"!**/*.mjs",
"!**/*.js",
"!**/scripts/**",
"!**/*.tsx",
"!**/testing-platform/**"
]
},
{
"includes": [
"**",
-297
View File
@@ -1,297 +0,0 @@
# Cline CLI (TypeScript)
A TypeScript CLI implementation of Cline that reuses the core TypeScript codebase. This allows you to run Cline tasks directly from the terminal while sharing the same underlying functionality as the VS Code extension.
## Features
- **Reuses Core Codebase**: Shares the same Controller, Task, and API handling as the VS Code extension
- **Terminal Output**: Displays Cline messages directly in your terminal with colored output
- **Task History**: Access your task history from the command line
- **Configurable**: Use custom configuration directories and working directories
- **Image Support**: Attach images to your prompts using file paths or inline references
## Prerequisites
- Node.js 20.x or later
- npm or yarn
- The parent Cline project dependencies installed
## Installation
From the repository root:
```bash
# Install all dependencies first
npm run install:all
# Ensure protos are generated
npm run protos
# Build the CLI
npm run compile-cli-ts
```
Or install the CLI globally:
```bash
cd cli-ts
npm install
npm run link
```
## Usage
### Interactive Mode (Default)
When you run `cline` without any command, it launches an interactive welcome prompt:
```bash
# Launch interactive mode
cline
# Or run a task directly
cline "Create a hello world function in Python"
# With options
cline -v --thinking "Analyze this codebase"
```
### Commands
#### `task` (alias: `t`)
Run a new task with a prompt.
```bash
cline task "Create a hello world function in Python"
cline t "Create a hello world function"
```
**Options:**
| Option | Description |
|--------|-------------|
| `-a, --act` | Run in act mode |
| `-p, --plan` | Run in plan mode |
| `-y, --yolo` | Enable yolo mode (auto-approve actions) |
| `-m, --model <model>` | Model to use for the task |
| `-i, --images <paths...>` | Image file paths to include with the task |
| `-v, --verbose` | Show verbose output including reasoning |
| `-c, --cwd <path>` | Working directory for the task |
| `--config <path>` | Path to Cline configuration directory |
| `-t, --thinking` | Enable extended thinking (1024 token budget) |
**Examples:**
```bash
# Run in plan mode with verbose output
cline task -p -v "Design a REST API"
# Use a specific model with yolo mode
cline task -m claude-sonnet-4-5-20250929 -y "Refactor this function"
# Include images with your prompt
cline task -i screenshot.png diagram.jpg "Fix the UI based on these images"
# Or use inline image references in the prompt
cline task "Fix the layout shown in @./screenshot.png"
# Enable extended thinking for complex tasks
cline task -t "Architect a microservices system"
# Specify working directory
cline task -c /path/to/project "Add unit tests"
```
#### `history` (alias: `h`)
List task history with pagination support.
```bash
cline history
cline h
```
**Options:**
| Option | Description |
|--------|-------------|
| `-n, --limit <number>` | Number of tasks to show (default: 10) |
| `-p, --page <number>` | Page number, 1-based (default: 1) |
| `--config <path>` | Path to Cline configuration directory |
**Examples:**
```bash
# Show last 10 tasks (default)
cline history
# Show 20 tasks
cline history -n 20
# Show page 2 with 5 tasks per page
cline history -n 5 -p 2
```
#### `config`
Show current configuration including global and workspace state.
```bash
cline config
```
**Options:**
| Option | Description |
|--------|-------------|
| `--config <path>` | Path to Cline configuration directory |
#### `auth`
Authenticate a provider and configure what model is used.
```bash
cline auth
```
**Options:**
| Option | Description |
|--------|-------------|
| `-p, --provider <id>` | Provider ID for quick setup (e.g., openai-native, anthropic) |
| `-k, --apikey <key>` | API key for the provider |
| `-m, --modelid <id>` | Model ID to configure (e.g., gpt-4o, claude-sonnet-4-5-20250929) |
| `-b, --baseurl <url>` | Base URL (optional, only for openai provider) |
| `-v, --verbose` | Show verbose output |
| `-c, --cwd <path>` | Working directory for the task |
| `--config <path>` | Path to Cline configuration directory |
**Examples:**
```bash
# Interactive authentication
cline auth
# Quick setup with provider and API key
cline auth -p anthropic -k sk-ant-xxxxx
# Full quick setup with model
cline auth -p openai-native -k sk-xxxxx -m gpt-4o
# OpenAI-compatible provider with custom base URL
cline auth -p openai -k your-api-key -b https://api.example.com/v1
```
### Global Options
These options are available for the default command (running a task directly):
| Option | Description |
|--------|-------------|
| `-i, --images <paths...>` | Image file paths to include with the task |
| `-v, --verbose` | Show verbose output |
| `-c, --cwd <path>` | Working directory |
| `--config <path>` | Configuration directory |
| `--thinking` | Enable extended thinking (1024 token budget) |
## Development
```bash
# Build and link the package to your terminal
npm run link
# Set your provider (No Cline provider support yet)
cline auth
# Run a task
cline task "Tell me about this codebase"
```
### Build
```bash
# Development build with source maps
npm run build
# Production build (minified)
npm run build:production
```
### Watch Mode
```bash
npm run watch
```
### Type Checking
```bash
npm run typecheck
```
## Architecture
The CLI reuses the core Cline TypeScript codebase:
- **Controller** (`@core/controller`): Manages task lifecycle and state
- **Task** (`@core/task`): Executes Cline tasks using the AI API
- **StateManager** (`@core/storage`): Handles persistent state storage
CLI-specific implementations:
- `cli-host-bridge.ts`: CLI implementations of host bridge services
- `cli-webview-provider.ts`: WebviewProvider that outputs to terminal
- `cli-comment-review.ts`: Comment review controller for terminal
- `vscode-context.ts`: Mock VSCode extension context
- `display.ts`: Terminal output formatting utilities
## Configuration
The CLI stores its data in `~/.cline/data/` by default:
- `globalState.json`: Global settings and state
- `secrets.json`: API keys and secrets
- `workspace/`: Workspace-specific state
- `tasks/`: Task history and conversation data
Override with the `--config` option or `CLINE_DIR` environment variable.
## Comparison with Go CLI
This TypeScript CLI differs from the Go CLI (`cli/` directory):
| Feature | Go CLI | TypeScript CLI |
|---------|--------|----------------|
| Language | Go | TypeScript |
| Core sharing | Uses gRPC to communicate | Direct imports |
| Startup time | Fast | Moderate |
| Dependencies | Standalone binary | Requires Node.js |
| Best for | Production deployment | Development, debugging |
Choose the TypeScript CLI when you need to debug or modify the core Cline logic. Choose the Go CLI for production deployment with faster startup.
## Troubleshooting
### Build Errors
If you encounter build errors, ensure you've:
1. Run `npm install` in the repository root
2. Run `npm run protos` to generate proto files
3. Have all peer dependencies installed
### Missing Dependencies
The CLI imports from the parent project. If you see import errors:
```bash
cd .. # Go to repository root
npm install
npm run protos
```
### Permission Denied
Make the CLI executable:
```bash
chmod +x dist/cli.js
```
-231
View File
@@ -1,231 +0,0 @@
import fs from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
import * as esbuild from "esbuild"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const rootDir = path.resolve(__dirname, "..")
const production = process.argv.includes("--production")
const watch = process.argv.includes("--watch")
/**
* Plugin to resolve path aliases from the parent project
* @type {import('esbuild').Plugin}
*/
const aliasResolverPlugin = {
name: "alias-resolver",
setup(build) {
const aliases = {
"@": path.resolve(rootDir, "src"),
"@core": path.resolve(rootDir, "src/core"),
"@integrations": path.resolve(rootDir, "src/integrations"),
"@services": path.resolve(rootDir, "src/services"),
"@shared": path.resolve(rootDir, "src/shared"),
"@utils": path.resolve(rootDir, "src/utils"),
"@packages": path.resolve(rootDir, "src/packages"),
"@hosts": path.resolve(rootDir, "src/hosts"),
"@generated": path.resolve(rootDir, "src/generated"),
"@api": path.resolve(rootDir, "src/core/api"),
}
// For each alias entry, create a resolver
Object.entries(aliases).forEach(([alias, aliasPath]) => {
const aliasRegex = new RegExp(`^${alias}($|/.*)`)
build.onResolve({ filter: aliasRegex }, (args) => {
const importPath = args.path.replace(alias, aliasPath)
// First, check if the path exists as is
if (fs.existsSync(importPath)) {
const stats = fs.statSync(importPath)
if (stats.isDirectory()) {
// If it's a directory, try to find index files
const extensions = [".ts", ".tsx", ".js", ".jsx"]
for (const ext of extensions) {
const indexFile = path.join(importPath, `index${ext}`)
if (fs.existsSync(indexFile)) {
return { path: indexFile }
}
}
} else {
// It's a file that exists, so return it
return { path: importPath }
}
}
// If the path doesn't exist, try appending extensions
const extensions = [".ts", ".tsx", ".js", ".jsx"]
for (const ext of extensions) {
const pathWithExtension = `${importPath}${ext}`
if (fs.existsSync(pathWithExtension)) {
return { path: pathWithExtension }
}
}
// If nothing worked, return the original path and let esbuild handle the error
return { path: importPath }
})
})
},
}
/**
* Plugin to redirect vscode imports to our shim
* @type {import('esbuild').Plugin}
*/
const vscodeStubPlugin = {
name: "vscode-stub",
setup(build) {
// Redirect 'vscode' imports to our shim
build.onResolve({ filter: /^vscode$/ }, (args) => {
return { path: path.join(__dirname, "src", "vscode-shim.ts") }
})
},
}
const esbuildProblemMatcherPlugin = {
name: "esbuild-problem-matcher",
setup(build) {
build.onStart(() => {
console.log("[cli-ts] Build started...")
})
build.onEnd((result) => {
result.errors.forEach(({ text, location }) => {
console.error(`✘ [ERROR] ${text}`)
if (location) {
console.error(` ${location.file}:${location.line}:${location.column}:`)
}
})
console.log("[cli-ts] Build finished")
})
},
}
// Plugin to stub out optional devtools module
const stubOptionalModulesPlugin = {
name: "stub-optional-modules",
setup(build) {
build.onResolve({ filter: /^react-devtools-core$/ }, () => {
return { path: path.join(__dirname, "src", "stub-devtools.js"), external: false }
})
},
}
const copyWasmFiles = {
name: "copy-wasm-files",
setup(build) {
build.onEnd(() => {
const destDir = path.join(__dirname, "dist")
// Ensure dist directory exists
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true })
}
// tree sitter
const sourceDir = path.join(rootDir, "node_modules", "web-tree-sitter")
// Copy tree-sitter.wasm
const treeSitterWasm = path.join(sourceDir, "tree-sitter.wasm")
if (fs.existsSync(treeSitterWasm)) {
fs.copyFileSync(treeSitterWasm, path.join(destDir, "tree-sitter.wasm"))
}
// Copy language-specific WASM files
const languageWasmDir = path.join(rootDir, "node_modules", "tree-sitter-wasms", "out")
const languages = [
"typescript",
"tsx",
"python",
"rust",
"javascript",
"go",
"cpp",
"c",
"c_sharp",
"ruby",
"java",
"php",
"swift",
"kotlin",
]
if (fs.existsSync(languageWasmDir)) {
languages.forEach((lang) => {
const filename = `tree-sitter-${lang}.wasm`
const sourcePath = path.join(languageWasmDir, filename)
if (fs.existsSync(sourcePath)) {
fs.copyFileSync(sourcePath, path.join(destDir, filename))
}
})
}
})
},
}
const buildEnvVars = {
"process.env.IS_STANDALONE": JSON.stringify("true"),
"process.env.IS_CLI": JSON.stringify("true"),
}
if (production) {
buildEnvVars["process.env.IS_DEV"] = "false"
}
// Set the environment
if (process.env.CLINE_ENVIRONMENT) {
buildEnvVars["process.env.CLINE_ENVIRONMENT"] = JSON.stringify(process.env.CLINE_ENVIRONMENT)
}
const config = {
entryPoints: [path.join(__dirname, "src", "index.ts")],
bundle: true,
minify: production,
sourcemap: !production,
logLevel: "silent",
define: buildEnvVars,
tsconfig: path.join(__dirname, "tsconfig.json"),
plugins: [copyWasmFiles, aliasResolverPlugin, vscodeStubPlugin, stubOptionalModulesPlugin, esbuildProblemMatcherPlugin],
format: "esm",
sourcesContent: false,
platform: "node",
target: "node20",
outfile: path.join(__dirname, "dist", "cli.mjs"),
// These modules need to load files from the module directory at runtime
external: ["@grpc/reflection", "grpc-health-check", "better-sqlite3", "ink", "ink-spinner", "react"],
supported: { "top-level-await": true },
banner: {
js: `#!/usr/bin/env node
// Suppress all Node.js warnings (deprecation, experimental, etc.)
process.emitWarning = () => {};
import { createRequire as _createRequire } from 'module';
import { fileURLToPath as _fileURLToPath } from 'url';
import { dirname as _dirname } from 'path';
const require = _createRequire(import.meta.url);
const __filename = _fileURLToPath(import.meta.url);
const __dirname = _dirname(__filename);`,
},
}
async function main() {
const ctx = await esbuild.context(config)
if (watch) {
await ctx.watch()
console.log("[cli-ts] Watching for changes...")
} else {
await ctx.rebuild()
await ctx.dispose()
// Make the output executable
const outfile = path.join(__dirname, "dist", "cli.mjs")
if (fs.existsSync(outfile)) {
fs.chmodSync(outfile, "755")
}
}
}
main().catch((e) => {
console.error(e)
process.exit(1)
})
-2945
View File
File diff suppressed because it is too large Load Diff
-48
View File
@@ -1,48 +0,0 @@
{
"name": "@cline/cli",
"version": "1.0.0",
"description": "Cline CLI - TypeScript implementation that reuses core Cline functionality",
"main": "dist/cli.mjs",
"bin": {
"clinedev": "./dist/cli.mjs"
},
"type": "module",
"scripts": {
"build": "node esbuild.mjs",
"build:production": "node esbuild.mjs --production",
"watch": "node esbuild.mjs --watch",
"dev": "npm run watch",
"clean": "rimraf dist",
"typecheck": "tsc --noEmit",
"link": "npm run build && npm link",
"test": "vitest",
"test:run": "vitest run"
},
"keywords": [
"cline",
"cli",
"ai",
"coding-assistant"
],
"author": "Cline Bot Inc.",
"license": "Apache-2.0",
"devDependencies": {
"@types/node": "20.x",
"@types/prompts": "^2.4.9",
"@types/react": "^18.3.27",
"esbuild": "^0.25.0",
"ink-testing-library": "^4.0.0",
"rimraf": "^6.0.1",
"typescript": "^5.4.5",
"vitest": "^4.0.17"
},
"dependencies": {
"chalk": "^5.3.0",
"commander": "^12.1.0",
"ink": "^5.0.1",
"ink-spinner": "^5.0.0",
"ora": "^8.0.1",
"prompts": "^2.4.2",
"react": "^18.3.0"
}
}
-194
View File
@@ -1,194 +0,0 @@
/**
* Account info view component
* Shows current provider, and for Cline provider: credit balance and organization name
*/
import { Box, Text } from "ink"
import React, { useCallback, useEffect, useState } from "react"
import { Controller } from "@/core/controller"
import { StateManager } from "@/core/storage/StateManager"
import { ClineAccountService } from "@/services/account/ClineAccountService"
import { AuthService, ClineAccountOrganization } from "@/services/auth/AuthService"
import { LoadingSpinner } from "./Spinner"
interface AccountInfoViewProps {
controller: Controller
}
/**
* Capitalize provider name for display
*/
function capitalize(str: string): string {
return str
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ")
}
/**
* Format balance as currency (balance is in microcredits, divide by 10000)
*/
function formatBalance(balance: number | null): string {
if (balance === null || balance === undefined) {
return "..."
}
return `$${(balance / 1000000).toFixed(2)}`
}
export const AccountInfoView: React.FC<AccountInfoViewProps> = ({ controller }) => {
const [provider, setProvider] = useState<string | null>(null)
const [balance, setBalance] = useState<number | null>(null)
const [organization, setOrganization] = useState<ClineAccountOrganization | null>(null)
const [email, setEmail] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const fetchAccountInfo = useCallback(async () => {
try {
setIsLoading(true)
setError(null)
// Get current provider from state
const stateManager = StateManager.get()
const mode = stateManager.getGlobalSettingsKey("mode") as string
const providerKey = mode === "act" ? "actModeApiProvider" : "planModeApiProvider"
const currentProvider = stateManager.getGlobalSettingsKey(providerKey) as string
setProvider(currentProvider || "cline")
// If using Cline provider, fetch additional info
if (currentProvider === "cline") {
const authService = AuthService.getInstance(controller)
// Wait for auth to be restored - poll until we have auth info or timeout
let authInfo = authService.getInfo()
let attempts = 0
const maxAttempts = 20 // 2 seconds max
while (!authInfo?.user?.uid && attempts < maxAttempts) {
await new Promise((resolve) => setTimeout(resolve, 100))
authInfo = authService.getInfo()
attempts++
}
// Get user info
if (authInfo?.user?.email) {
setEmail(authInfo.user.email)
} else {
// User not logged in to Cline
setEmail(null)
setIsLoading(false)
return
}
// Get organization info
const organizations = authService.getUserOrganizations()
if (organizations) {
const activeOrg = organizations.find((org) => org.active)
if (activeOrg) {
setOrganization(activeOrg)
}
}
// Fetch credit balance
try {
const accountService = ClineAccountService.getInstance()
const activeOrgId = authService.getActiveOrganizationId()
if (activeOrgId) {
// Fetch organization balance
const orgBalance = await accountService.fetchOrganizationCreditsRPC(activeOrgId)
if (orgBalance?.balance !== undefined) {
setBalance(orgBalance.balance)
}
} else {
// Fetch personal balance
const balanceData = await accountService.fetchBalanceRPC()
if (balanceData?.balance !== undefined) {
setBalance(balanceData.balance)
}
}
} catch {
// Balance fetch failed, but we can still show other info
// Don't log to console as it pollutes CLI output
}
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load account info")
} finally {
setIsLoading(false)
}
}, [controller])
useEffect(() => {
fetchAccountInfo()
}, [fetchAccountInfo])
if (isLoading) {
return (
<Box>
<LoadingSpinner />
<Text color="gray"> Loading account info...</Text>
</Box>
)
}
if (error) {
return (
<Box>
<Text color="red">Error: {error}</Text>
</Box>
)
}
// If not using Cline provider, just show the provider name
if (provider !== "cline") {
return (
<Box>
<Text color="gray">Provider: </Text>
<Text color="cyan">{capitalize(provider || "Not configured")}</Text>
</Box>
)
}
// Cline provider but not logged in
if (!email) {
return (
<Box>
<Text color="gray">Provider: </Text>
<Text color="cyan">Cline</Text>
<Text color="gray"> </Text>
<Text color="yellow">Not logged in (run 'cline auth' to sign in)</Text>
</Box>
)
}
// Cline provider - show full account info
return (
<Box flexDirection="column">
<Box>
<Text color="gray">Provider: </Text>
<Text color="cyan">Cline</Text>
{email && (
<Box>
<Text color="gray"> </Text>
<Text color="white">{email}</Text>
</Box>
)}
</Box>
<Box>
{organization ? (
<Box>
<Text color="gray">Organization: </Text>
<Text color="magenta">{organization.name}</Text>
</Box>
) : (
<Box>
<Text color="gray">Account: </Text>
<Text color="white">Personal</Text>
</Box>
)}
<Text color="gray"> Credits: </Text>
<Text color="green">{formatBalance(balance)}</Text>
</Box>
</Box>
)
}
-105
View File
@@ -1,105 +0,0 @@
import { Text } from "ink"
import { render } from "ink-testing-library"
import React from "react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { App } from "./App"
// Mock the child components to isolate App routing logic
vi.mock("./TaskView", () => ({
TaskView: ({ taskId, verbose }: any) =>
React.createElement(Text, null, `TaskView: ${taskId || "no-id"} verbose=${String(verbose)}`),
}))
vi.mock("./HistoryView", () => ({
HistoryView: ({ items }: any) => React.createElement(Text, null, `HistoryView: ${items?.length || 0} items`),
}))
vi.mock("./ConfigView", () => ({
ConfigView: ({ dataDir }: any) => React.createElement(Text, null, `ConfigView: ${dataDir}`),
}))
vi.mock("./AuthView", () => ({
AuthView: ({ quickSetup }: any) => React.createElement(Text, null, `AuthView: ${quickSetup?.provider || "no-provider"}`),
}))
vi.mock("./WelcomeView", () => ({
WelcomeView: () => React.createElement(Text, null, "WelcomeView"),
}))
vi.mock("../context/TaskContext", () => ({
TaskContextProvider: ({ children }: any) => children,
}))
describe("App", () => {
const mockController = {
dispose: vi.fn(),
stateManager: { flushPendingState: vi.fn() },
}
beforeEach(() => {
vi.clearAllMocks()
})
describe("view routing", () => {
it("should render TaskView when view is task", () => {
const { lastFrame } = render(<App controller={mockController} taskId="test-task" view="task" />)
expect(lastFrame()).toContain("TaskView")
expect(lastFrame()).toContain("test-task")
})
it("should render HistoryView when view is history", () => {
const historyItems = [
{ id: "1", ts: Date.now(), task: "Task 1" },
{ id: "2", ts: Date.now(), task: "Task 2" },
]
const { lastFrame } = render(<App controller={mockController} historyItems={historyItems} view="history" />)
expect(lastFrame()).toContain("HistoryView")
expect(lastFrame()).toContain("2 items")
})
it("should render ConfigView when view is config", () => {
const { lastFrame } = render(
<App dataDir="/path/to/config" globalState={{ key: "value" }} view="config" workspaceState={{}} />,
)
expect(lastFrame()).toContain("ConfigView")
expect(lastFrame()).toContain("/path/to/config")
})
it("should render AuthView when view is auth", () => {
const { lastFrame } = render(<App authQuickSetup={{ provider: "openai" }} controller={mockController} view="auth" />)
expect(lastFrame()).toContain("AuthView")
expect(lastFrame()).toContain("openai")
})
it("should render WelcomeView when view is welcome", () => {
const { lastFrame } = render(
<App controller={mockController} onWelcomeExit={() => {}} onWelcomeSubmit={() => {}} view="welcome" />,
)
expect(lastFrame()).toContain("WelcomeView")
})
})
describe("default props", () => {
it("should use default verbose=false", () => {
const { lastFrame } = render(<App controller={mockController} view="task" />)
expect(lastFrame()).toContain("verbose=false")
})
it("should use empty array for historyItems by default", () => {
const { lastFrame } = render(<App controller={mockController} view="history" />)
expect(lastFrame()).toContain("0 items")
})
})
describe("props passing", () => {
it("should pass verbose to TaskView", () => {
const { lastFrame } = render(<App controller={mockController} verbose={true} view="task" />)
expect(lastFrame()).toContain("verbose=true")
})
it("should pass taskId to TaskView", () => {
const { lastFrame } = render(<App controller={mockController} taskId="my-task-123" view="task" />)
expect(lastFrame()).toContain("my-task-123")
})
})
})
-248
View File
@@ -1,248 +0,0 @@
/**
* Main App component for Ink CLI
* Routes between different views (task, history, config)
*/
import { Box } from "ink"
import React, { ReactNode, useCallback, useState } from "react"
import { TaskContextProvider } from "../context/TaskContext"
import { AuthView } from "./AuthView"
import { ConfigView } from "./ConfigView"
import { HistoryView } from "./HistoryView"
import { TaskView } from "./TaskView"
import { WelcomeView } from "./WelcomeView"
export type ViewType = "task" | "history" | "config" | "auth" | "welcome"
interface HistoryPagination {
page: number
totalPages: number
totalCount: number
limit: number
}
interface HookInfo {
name: string
enabled: boolean
absolutePath: string
}
interface WorkspaceHooks {
workspaceName: string
hooks: HookInfo[]
}
interface SkillInfo {
name: string
description: string
path: string
enabled: boolean
}
interface AppProps {
view: ViewType
taskId?: string
verbose?: boolean
controller?: any
onComplete?: () => void
onError?: () => void
// For history view
historyItems?: Array<{ id: string; ts: number; task?: string; totalCost?: number; modelId?: string }>
historyAllItems?: Array<{ id: string; ts: number; task?: string; totalCost?: number; modelId?: string }>
historyPagination?: HistoryPagination
onHistoryPageChange?: (page: number) => void
// For config view
dataDir?: string
globalState?: Record<string, any>
workspaceState?: Record<string, any>
// Rules toggles
globalClineRulesToggles?: Record<string, boolean>
localClineRulesToggles?: Record<string, boolean>
localCursorRulesToggles?: Record<string, boolean>
localWindsurfRulesToggles?: Record<string, boolean>
localAgentsRulesToggles?: Record<string, boolean>
onToggleRule?: (isGlobal: boolean, rulePath: string, enabled: boolean, ruleType: string) => void
// Workflow toggles
globalWorkflowToggles?: Record<string, boolean>
localWorkflowToggles?: Record<string, boolean>
onToggleWorkflow?: (isGlobal: boolean, workflowPath: string, enabled: boolean) => void
// Hooks
hooksEnabled?: boolean
globalHooks?: HookInfo[]
workspaceHooks?: WorkspaceHooks[]
onToggleHook?: (isGlobal: boolean, hookName: string, enabled: boolean, workspaceName?: string) => void
// Skills
skillsEnabled?: boolean
globalSkills?: SkillInfo[]
localSkills?: SkillInfo[]
onToggleSkill?: (isGlobal: boolean, skillPath: string, enabled: boolean) => void
// For auth view
authQuickSetup?: {
provider?: string
apikey?: string
modelid?: string
baseurl?: string
}
// For welcome view
onWelcomeSubmit?: (prompt: string, imagePaths: string[]) => void
onWelcomeExit?: () => void
}
export const App: React.FC<AppProps> = ({
view: initialView,
taskId,
verbose = false,
controller,
onComplete,
onError,
historyItems = [],
historyAllItems,
historyPagination,
onHistoryPageChange,
dataDir = "",
globalState = {},
workspaceState = {},
// Rules
globalClineRulesToggles,
localClineRulesToggles,
localCursorRulesToggles,
localWindsurfRulesToggles,
localAgentsRulesToggles,
onToggleRule,
// Workflows
globalWorkflowToggles,
localWorkflowToggles,
onToggleWorkflow,
// Hooks
hooksEnabled,
globalHooks,
workspaceHooks,
onToggleHook,
// Skills
skillsEnabled,
globalSkills,
localSkills,
onToggleSkill,
authQuickSetup,
onWelcomeSubmit,
onWelcomeExit,
}) => {
const [currentView, setCurrentView] = useState<ViewType>(initialView)
const [selectedTaskId, setSelectedTaskId] = useState<string | undefined>(taskId)
const handleSelectTask = useCallback((taskId: string) => {
setSelectedTaskId(taskId)
setCurrentView("task")
}, [])
const handleNavigateToWelcome = useCallback(() => {
setCurrentView("welcome")
}, [])
// Handle welcome submit when navigating internally (e.g., from auth -> welcome)
const handleInternalWelcomeSubmit = useCallback(
async (prompt: string, imagePaths: string[]) => {
if (onWelcomeSubmit) {
// If external handler provided, use it
onWelcomeSubmit(prompt, imagePaths)
} else if (controller && prompt.trim()) {
// Otherwise, start a task directly via controller
setCurrentView("task")
// Convert image paths to data URLs if needed
const imageDataUrls =
imagePaths.length > 0
? await Promise.all(
imagePaths.map(async (p) => {
try {
const fs = await import("fs/promises")
const path = await import("path")
const data = await fs.readFile(p)
const ext = path.extname(p).toLowerCase().slice(1)
const mimeType = ext === "jpg" ? "jpeg" : ext
return `data:image/${mimeType};base64,${data.toString("base64")}`
} catch {
return null
}
}),
)
: []
const validImages = imageDataUrls.filter((img): img is string => img !== null)
await controller.initTask(prompt.trim(), validImages.length > 0 ? validImages : undefined)
}
},
[onWelcomeSubmit, controller],
)
let content: ReactNode
switch (currentView) {
case "task":
content = (
<TaskContextProvider controller={controller}>
<TaskView onComplete={onComplete} onError={onError} taskId={selectedTaskId} verbose={verbose} />
</TaskContextProvider>
)
break
case "history":
content = (
<HistoryView
allItems={historyAllItems}
controller={controller}
items={historyItems}
onPageChange={onHistoryPageChange}
onSelectTask={handleSelectTask}
pagination={historyPagination}
/>
)
break
case "config":
content = (
<ConfigView
dataDir={dataDir}
globalClineRulesToggles={globalClineRulesToggles}
globalHooks={globalHooks}
globalSkills={globalSkills}
globalState={globalState}
globalWorkflowToggles={globalWorkflowToggles}
hooksEnabled={hooksEnabled}
localAgentsRulesToggles={localAgentsRulesToggles}
localClineRulesToggles={localClineRulesToggles}
localCursorRulesToggles={localCursorRulesToggles}
localSkills={localSkills}
localWindsurfRulesToggles={localWindsurfRulesToggles}
localWorkflowToggles={localWorkflowToggles}
onToggleHook={onToggleHook}
onToggleRule={onToggleRule}
onToggleSkill={onToggleSkill}
onToggleWorkflow={onToggleWorkflow}
skillsEnabled={skillsEnabled}
workspaceHooks={workspaceHooks}
workspaceState={workspaceState}
/>
)
break
case "auth":
content = (
<AuthView
controller={controller}
onComplete={onComplete}
onError={onError}
onNavigateToWelcome={handleNavigateToWelcome}
quickSetup={authQuickSetup}
/>
)
break
case "welcome":
content = <WelcomeView controller={controller} onExit={onWelcomeExit} onSubmit={handleInternalWelcomeSubmit} />
break
default:
content = null
}
return <Box>{content}</Box>
}
-377
View File
@@ -1,377 +0,0 @@
/**
* User input prompt component
* Handles different types of user interactions (text input, confirmations, choices)
*/
import type { ClineAsk } from "@shared/ExtensionMessage"
import { Box, Text, useApp, useInput } from "ink"
import React, { useCallback, useEffect, useRef, useState } from "react"
import { useTaskController } from "../context/TaskContext"
import { useLastCompletedAskMessage } from "../hooks/useStateSubscriber"
import { jsonParseSafe } from "../utils/parser"
import { getCliMessagePrefixIcon } from "./MessageRow"
interface AskPromptProps {
onRespond?: (response: string) => void
}
type PromptType = "confirmation" | "text" | "options" | "plan_mode_text" | "completion" | "exit_confirmation" | "none"
function getPromptType(ask: ClineAsk, text: string): PromptType {
switch (ask) {
case "followup": {
const parts = jsonParseSafe(text, {
question: undefined as string | undefined,
options: undefined as string[] | undefined,
})
if (parts.options && parts.options.length > 0) {
return "options"
}
return "text"
}
case "plan_mode_respond": {
const parts = jsonParseSafe(text, {
question: undefined as string | undefined,
options: undefined as string[] | undefined,
})
if (parts.options && parts.options.length > 0) {
return "options"
}
// Plan mode without options - allow text input or toggle to Act mode
return "plan_mode_text"
}
case "completion_result":
// Task completed - allow follow-up question or exit
return "completion"
case "resume_task":
case "resume_completed_task":
return "exit_confirmation"
case "command":
case "tool":
case "browser_action_launch":
case "use_mcp_server":
return "confirmation"
default:
return "none"
}
}
export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
const { exit } = useApp()
const controller = useTaskController()
const lastAskMessage = useLastCompletedAskMessage()
const [textInput, setTextInput] = useState("")
const [responded, setResponded] = useState(false)
const lastAskTs = useRef<number | null>(null)
// Reset state when ask message changes
useEffect(() => {
if (lastAskMessage && lastAskMessage.ts !== lastAskTs.current) {
lastAskTs.current = lastAskMessage.ts
setTextInput("")
setResponded(false)
}
}, [lastAskMessage])
const sendResponse = useCallback(
async (responseType: string, text?: string) => {
if (responded || !controller?.task) {
return
}
setResponded(true)
try {
await controller.task.handleWebviewAskResponse(responseType, text)
onRespond?.(text || responseType)
} catch {
// Controller may be disposed
}
},
[controller, responded, onRespond],
)
const toggleToActMode = useCallback(async () => {
if (responded || !controller) {
return
}
setResponded(true)
try {
await controller.togglePlanActMode("act")
onRespond?.("Switched to Act mode")
} catch {
// Controller may be disposed
}
}, [controller, responded, onRespond])
// Handle keyboard input
useInput(
(input, key) => {
if (!lastAskMessage || responded) {
return
}
const ask = lastAskMessage.ask as ClineAsk
const text = lastAskMessage.text || ""
const promptType = getPromptType(ask, text)
if (promptType === "confirmation" || promptType === "exit_confirmation") {
// y/n confirmation
if (input.toLowerCase() === "y") {
sendResponse("yesButtonClicked")
} else if (input.toLowerCase() === "n") {
if (promptType === "exit_confirmation") {
exit()
return
}
sendResponse("noButtonClicked")
}
} else if (promptType === "options") {
// Number selection for options, or free text input
const parts = jsonParseSafe(text, { options: [] as string[] })
if (key.return) {
// Submit free text on Enter
if (textInput.trim()) {
sendResponse("messageResponse", textInput.trim())
}
} else if (key.backspace || key.delete) {
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)
if (textInput === "" && !Number.isNaN(num) && num >= 1 && num <= parts.options.length) {
const selectedOption = parts.options[num - 1]
sendResponse("optionSelected", selectedOption)
} else {
// Regular character input for free text
setTextInput((prev) => prev + input)
}
}
} else if (promptType === "text") {
// Text input mode
if (key.return) {
// Submit on Enter
if (textInput.trim()) {
sendResponse("messageResponse", textInput.trim())
}
} else if (key.backspace || key.delete) {
setTextInput((prev) => prev.slice(0, -1))
} else if (input && !key.ctrl && !key.meta) {
// Regular character input
setTextInput((prev) => prev + input)
}
} else if (promptType === "plan_mode_text") {
// Plan mode text input - allows text response or toggle to Act mode
if (key.return) {
// Submit on Enter
if (textInput.trim()) {
sendResponse("messageResponse", textInput.trim())
} else {
// Empty enter = switch to Act mode
toggleToActMode()
}
} else if (key.backspace || key.delete) {
setTextInput((prev) => prev.slice(0, -1))
} else if (input && !key.ctrl && !key.meta) {
// Regular character input
setTextInput((prev) => prev + input)
}
} else if (promptType === "completion") {
// Task completed - allow follow-up question or exit
if (key.return) {
if (textInput.trim()) {
// Send follow-up question
sendResponse("messageResponse", textInput.trim())
} else {
// Empty enter = confirm completion (exit)
sendResponse("yesButtonClicked")
}
} else if (key.backspace || key.delete) {
setTextInput((prev) => prev.slice(0, -1))
} else if (input && !key.ctrl && !key.meta) {
// Regular character input
setTextInput((prev) => prev + input)
}
}
},
{ isActive: !!lastAskMessage && !responded },
)
if (!lastAskMessage || responded) {
return null
}
const ask = lastAskMessage.ask as ClineAsk
const text = lastAskMessage.text || ""
const promptType = getPromptType(ask, text)
const icon = getCliMessagePrefixIcon(lastAskMessage)
if (promptType === "none") {
return null
}
switch (ask) {
case "followup": {
const parts = jsonParseSafe(text, {
question: undefined as string | undefined,
options: undefined as string[] | undefined,
})
if (parts.options && parts.options.length > 0) {
return (
<Box flexDirection="column" marginTop={1}>
<Text color="cyan">Select an option (enter number):</Text>
{parts.options.map((opt, idx) => (
<Box key={idx} marginLeft={2}>
<Text>{`${idx + 1}. ${opt}`}</Text>
</Box>
))}
<Box marginTop={1}>
<Text>{icon} </Text>
<Text color="cyan">Or type: </Text>
<Text>{textInput}</Text>
<Text color="gray"></Text>
</Box>
<Text color="gray" dimColor>
(Enter number to select, or type response + Enter)
</Text>
</Box>
)
}
// Text input prompt
return (
<Box flexDirection="column" marginTop={1}>
<Box>
<Text>{icon} </Text>
<Text color="cyan">Reply: </Text>
<Text>{textInput}</Text>
<Text color="gray"></Text>
</Box>
<Text color="gray" dimColor>
(Type your response and press Enter)
</Text>
</Box>
)
}
case "plan_mode_respond": {
const parts = jsonParseSafe(text, {
question: undefined as string | undefined,
options: undefined as string[] | undefined,
})
if (parts.options && parts.options.length > 0) {
return (
<Box flexDirection="column" marginTop={1}>
<Text color="cyan">Select an option (enter number):</Text>
{parts.options.map((opt, idx) => (
<Box key={idx} marginLeft={2}>
<Text>{`${idx + 1}. ${opt}`}</Text>
</Box>
))}
<Box marginTop={1}>
<Text>{icon} </Text>
<Text color="cyan">Or type: </Text>
<Text>{textInput}</Text>
<Text color="gray"></Text>
</Box>
<Text color="gray" dimColor>
(Enter number to select, or type response + Enter)
</Text>
</Box>
)
}
// Plan mode text input - show option to switch to Act mode
return (
<Box flexDirection="column" marginTop={1}>
<Box>
<Text>{icon} </Text>
<Text color="cyan">Reply: </Text>
<Text>{textInput}</Text>
<Text color="gray"></Text>
</Box>
<Text color="gray" dimColor>
(Type response + Enter, or just Enter to switch to Act mode)
</Text>
</Box>
)
}
case "command":
return (
<Box flexDirection="column" marginTop={1}>
<Box>
<Text>{icon} </Text>
<Text color="yellow"> Execute this command? </Text>
<Text color="gray">(y/n)</Text>
</Box>
</Box>
)
case "tool":
return (
<Box flexDirection="column" marginTop={1}>
<Box>
<Text>{icon} </Text>
<Text color="blue"> Use this tool? </Text>
<Text color="gray">(y/n)</Text>
</Box>
</Box>
)
case "completion_result":
return (
<Box flexDirection="column" marginTop={1}>
<Box>
<Text>{icon} </Text>
<Text color="cyan">Follow-up: </Text>
<Text>{textInput}</Text>
<Text color="gray"></Text>
</Box>
<Text color="gray" dimColor>
(Type follow-up question + Enter, or q to exit)
</Text>
</Box>
)
case "resume_task":
case "resume_completed_task":
return (
<Box flexDirection="column" marginTop={1}>
<Box>
<Text>{icon} </Text>
<Text color="cyan"> Resume task? </Text>
<Text color="gray">(y/n)</Text>
</Box>
</Box>
)
case "browser_action_launch":
return (
<Box flexDirection="column" marginTop={1}>
<Box>
<Text>{icon} </Text>
<Text color="cyan"> Launch browser? </Text>
<Text color="gray">(y/n)</Text>
</Box>
</Box>
)
case "use_mcp_server":
return (
<Box flexDirection="column" marginTop={1}>
<Box>
<Text>{icon} </Text>
<Text color="cyan"> Use MCP server? </Text>
<Text color="gray">(y/n)</Text>
</Box>
</Box>
)
default:
return null
}
}
-577
View File
@@ -1,577 +0,0 @@
/**
* Auth view component
* Handles interactive authentication and provider configuration
*/
import { Box, Text, useApp, useInput } from "ink"
import React, { useCallback, useEffect, useMemo, useState } from "react"
import { StateManager } from "@/core/storage/StateManager"
import { AuthService } from "@/services/auth/AuthService"
import { API_PROVIDERS_LIST } from "@/shared/api"
import { secretStorage } from "@/shared/storage/ClineSecretStorage"
import { ProviderToApiKeyMap } from "../utils/provider-map"
import { LoadingSpinner } from "./Spinner"
type AuthStep = "menu" | "provider" | "apikey" | "modelid" | "baseurl" | "saving" | "success" | "error" | "cline_auth"
interface AuthViewProps {
controller: any
onComplete?: () => void
onError?: () => void
onNavigateToWelcome?: () => void
// Quick setup options
quickSetup?: {
provider?: string
apikey?: string
modelid?: string
baseurl?: string
}
}
interface SelectItem {
label: string
value: string
}
/**
* Format separator
*/
function formatSeparator(char: string = "─", width: number = 60): string {
return char.repeat(Math.max(width, 10))
}
/**
* Capitalize provider name for display
*/
function capitalize(str: string): string {
return str
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ")
}
/**
* Select component with keyboard navigation
*/
const Select: React.FC<{
items: SelectItem[]
onSelect: (value: string) => void
label?: string
}> = ({ items, onSelect, label }) => {
const [selectedIndex, setSelectedIndex] = useState(0)
useInput((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) {
onSelect(items[selectedIndex].value)
}
})
return (
<Box flexDirection="column">
{label && (
<Text bold color="cyan">
{label}
</Text>
)}
{items.map((item, index) => (
<Box key={item.value}>
<Text color={index === selectedIndex ? "green" : undefined}>
{index === selectedIndex ? " " : " "}
{item.label}
</Text>
</Box>
))}
<Text color="gray" dimColor>
(Use arrow keys to navigate, Enter to select)
</Text>
</Box>
)
}
/**
* Text input component
*/
const TextInput: React.FC<{
value: string
onChange: (value: string) => void
onSubmit: (value: string) => void
label: string
placeholder?: string
isPassword?: boolean
}> = ({ value, onChange, onSubmit, label, placeholder, isPassword }) => {
useInput((input, key) => {
if (key.return) {
onSubmit(value)
} else if (key.backspace || key.delete) {
onChange(value.slice(0, -1))
} else if (input && !key.ctrl && !key.meta) {
onChange(value + input)
}
})
const displayValue = isPassword ? "•".repeat(value.length) : value
return (
<Box flexDirection="column">
<Text bold color="cyan">
{label}
</Text>
<Box>
<Text color="white">{displayValue || placeholder || ""}</Text>
<Text color="gray"></Text>
</Box>
<Text color="gray" dimColor>
(Type your input and press Enter{value ? "" : ", or press Enter to skip"})
</Text>
</Box>
)
}
export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onError, onNavigateToWelcome, quickSetup }) => {
const { exit } = useApp()
const [step, setStep] = useState<AuthStep>(quickSetup ? "saving" : "menu")
const [selectedProvider, setSelectedProvider] = useState<string>(
StateManager.get().getApiConfiguration().actModeApiProvider ||
StateManager.get().getApiConfiguration().planModeApiProvider ||
"",
)
const [apiKey, setApiKey] = useState("")
const [modelId, setModelId] = useState("")
const [baseUrl, setBaseUrl] = useState("")
const [errorMessage, setErrorMessage] = useState("")
const [authStatus, setAuthStatus] = useState<string>("")
// Sort providers alphabetically
const sortedProviders = useMemo(() => API_PROVIDERS_LIST.slice().sort(), [])
// Get configured providers (those with API keys set)
const configuredProviders = useMemo(() => {
try {
const config = StateManager.get().getApiConfiguration()
const configured = new Set<string>()
for (const provider of sortedProviders) {
const keyField = ProviderToApiKeyMap[provider]
if (!keyField) {
continue
}
const fields = Array.isArray(keyField) ? keyField : [keyField]
const hasKey = fields.some((field) => {
const value = (config as Record<string, unknown>)[field]
return value !== undefined && value !== null && value !== ""
})
if (hasKey) {
configured.add(provider)
}
}
return configured
} catch {
return new Set<string>()
}
}, [sortedProviders, ProviderToApiKeyMap])
// Main menu items
const mainMenuItems: SelectItem[] = [
{ label: "Sign in to Cline", value: "cline_auth" },
{ label: "Configure BYO API provider", value: "configure_byo" },
{ label: "Exit", value: "exit" },
]
// Provider menu items
const providerItems: SelectItem[] = useMemo(
() =>
sortedProviders.map((p: string) => ({
label: `${capitalize(p)}${configuredProviders.has(p) ? " (configured)" : ""}`,
value: p,
})),
[sortedProviders, configuredProviders],
)
// Handle quick setup
useEffect(() => {
if (quickSetup && step === "saving") {
handleQuickSetup()
}
}, [quickSetup, step])
// Subscribe to auth status updates when in cline_auth step
useEffect(() => {
if (step !== "cline_auth") {
return
}
let cancelled = false
// Create a streaming response handler that receives auth state updates
const responseHandler = async (authState: { user?: { email?: string } }, _isLast?: boolean) => {
if (cancelled) {
return
}
if (authState.user && authState.user.email) {
// Auth succeeded - save configuration and transition to success
const stateManager = StateManager.get()
const config: Record<string, string> = {
actModeApiProvider: "cline",
planModeApiProvider: "cline",
actModeApiModelId: "anthropic/claude-sonnet-4.5",
planModeApiModelId: "anthropic/claude-sonnet-4.5",
apiProvider: "cline",
}
stateManager.setApiConfiguration(config)
stateManager.flushPendingState()
setSelectedProvider("cline")
setModelId("anthropic/claude-sonnet-4.5")
setStep("success")
}
}
// Subscribe to auth status updates
const authService = AuthService.getInstance(controller)
authService.subscribeToAuthStatusUpdate(controller, {}, responseHandler, `cli-auth-${Date.now()}`)
return () => {
cancelled = true
}
}, [step, controller])
const handleQuickSetup = async () => {
if (!quickSetup) {
return
}
try {
const { provider, apikey, modelid, baseurl } = quickSetup
// Validate required parameters
if (!provider || !apikey || !modelid) {
setErrorMessage("Quick setup requires --provider, --apikey, and --modelid flags")
setStep("error")
return
}
const normalizedProvider = provider.toLowerCase().trim()
if (!sortedProviders.includes(normalizedProvider)) {
setErrorMessage(`Invalid provider '${provider}'. Supported providers: ${sortedProviders.join(", ")}`)
setStep("error")
return
}
if (normalizedProvider === "bedrock") {
setErrorMessage(
"Bedrock provider is not supported for quick setup due to complex authentication requirements. Please use interactive setup.",
)
setStep("error")
return
}
if (baseurl && !["openai", "openai-native"].includes(normalizedProvider)) {
setErrorMessage("Base URL is only supported for OpenAI and OpenAI-compatible providers")
setStep("error")
return
}
// Save configuration
const stateManager = StateManager.get()
const config: Record<string, string> = {
actModeApiProvider: normalizedProvider,
planModeApiProvider: normalizedProvider,
actModeApiModelId: modelid,
planModeApiModelId: modelid,
}
// Use provider-specific API key field
const keyField = ProviderToApiKeyMap[normalizedProvider]
if (keyField) {
const fields = Array.isArray(keyField) ? keyField : [keyField]
// Set the first key field for the provider
config[fields[0]] = apikey
} else {
// Fallback to generic apiKey
config.apiKey = apikey
}
if (baseurl) {
config.openAiBaseUrl = baseurl
}
stateManager.setApiConfiguration(config)
await stateManager.flushPendingState()
setSelectedProvider(normalizedProvider)
setModelId(modelid)
setBaseUrl(baseurl || "")
setStep("success")
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : String(error))
setStep("error")
}
}
const handleMainMenuSelect = useCallback(
(value: string) => {
if (value === "exit") {
exit()
onComplete?.()
} else if (value === "cline_auth") {
setStep("cline_auth")
setAuthStatus("Starting authentication...")
AuthService.getInstance(controller).createAuthRequest()
} else if (value === "configure_byo") {
setStep("provider")
}
},
[exit, onComplete, controller],
)
const handleProviderSelect = useCallback(
(value: string) => {
setSelectedProvider(value)
if (value === "cline") {
setStep("cline_auth")
setAuthStatus("Starting authentication...")
AuthService.getInstance(controller).createAuthRequest()
} else {
setStep("apikey")
}
},
[controller],
)
const handleApiKeySubmit = useCallback(
(value: string) => {
if (!value.trim() || !selectedProvider) {
// Don't allow empty
return
}
// Use provider-specific API key field
const foundKey = ProviderToApiKeyMap[selectedProvider] || "apiKey"
const providerKey = Array.isArray(foundKey) ? foundKey[0] : foundKey
secretStorage.store(providerKey, value)
setApiKey(value)
setStep("modelid")
},
[selectedProvider],
)
const handleModelIdSubmit = useCallback(
(value: string) => {
if (value.trim()) {
setModelId(value)
}
// Only show baseurl step for OpenAI-like providers
if (["openai", "openai-native"].includes(selectedProvider)) {
setStep("baseurl")
} else {
setStep("saving")
saveConfiguration(value, "")
}
},
[selectedProvider],
)
const handleBaseUrlSubmit = useCallback(
(value: string) => {
setBaseUrl(value)
setStep("saving")
saveConfiguration(modelId, value)
},
[modelId],
)
const saveConfiguration = useCallback(
async (model: string, base: string) => {
try {
const stateManager = StateManager.get()
const config: Record<string, string> = {
actModeApiProvider: selectedProvider,
planModeApiProvider: selectedProvider,
actModeApiModelId: model,
planModeApiModelId: model,
apiProvider: selectedProvider,
}
if (base) {
config.openAiBaseUrl = base
}
stateManager.setApiConfiguration(config)
stateManager.flushPendingState()
setStep("success")
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : String(error))
setStep("error")
}
},
[selectedProvider],
)
// Success screen menu items
const successMenuItems: SelectItem[] = useMemo(() => {
const items: SelectItem[] = []
if (onNavigateToWelcome) {
items.push({ label: "Start a task", value: "welcome" })
}
items.push({ label: "Exit", value: "exit" })
return items
}, [onNavigateToWelcome])
// Error screen menu items
const errorMenuItems: SelectItem[] = useMemo(() => {
const items: SelectItem[] = [{ label: "Try again", value: "retry" }]
if (onNavigateToWelcome) {
items.push({ label: "Start a task", value: "welcome" })
}
items.push({ label: "Exit", value: "exit" })
return items
}, [onNavigateToWelcome])
const handleSuccessMenuSelect = useCallback(
(value: string) => {
if (value === "welcome") {
onNavigateToWelcome?.()
} else if (value === "exit") {
onComplete?.()
exit()
}
},
[onNavigateToWelcome, onComplete, exit],
)
const handleErrorMenuSelect = useCallback(
(value: string) => {
if (value === "retry") {
// Reset state and go back to menu
setErrorMessage("")
setApiKey("")
setModelId("")
setBaseUrl("")
setSelectedProvider("")
setStep("menu")
} else if (value === "welcome") {
onNavigateToWelcome?.()
} else if (value === "exit") {
onError?.()
exit()
}
},
[onNavigateToWelcome, onError, exit],
)
return (
<Box flexDirection="column">
<Text bold color="white">
🔐 Cline Authentication
</Text>
<Text color="gray">{formatSeparator()}</Text>
<Text> </Text>
{step === "menu" && (
<Select items={mainMenuItems} label="What would you like to do?" onSelect={handleMainMenuSelect} />
)}
{step === "provider" && <Select items={providerItems} label="Select a provider:" onSelect={handleProviderSelect} />}
{step === "apikey" && (
<TextInput
isPassword={true}
label="Enter your API key:"
onChange={setApiKey}
onSubmit={handleApiKeySubmit}
value={apiKey}
/>
)}
{step === "modelid" && (
<TextInput
label="Enter the model ID (e.g., gpt-4, claude-sonnet-4.5):"
onChange={setModelId}
onSubmit={handleModelIdSubmit}
placeholder="model-id"
value={modelId}
/>
)}
{step === "baseurl" && (
<TextInput
label="Enter base URL (optional, press Enter to skip):"
onChange={setBaseUrl}
onSubmit={handleBaseUrlSubmit}
placeholder="https://api.example.com/v1"
value={baseUrl}
/>
)}
{step === "saving" && (
<Box>
<LoadingSpinner />
<Text color="cyan"> Saving configuration...</Text>
</Box>
)}
{step === "cline_auth" && (
<Box flexDirection="column">
<Box>
<LoadingSpinner />
<Text color="cyan"> {authStatus || "Authenticating with Cline..."}</Text>
</Box>
<Text color="gray" dimColor>
A browser window should open. Complete the sign-in process there.
</Text>
</Box>
)}
{step === "success" && (
<Box flexDirection="column">
<Text bold color="green">
Successfully configured authentication
</Text>
<Text color="gray">{formatSeparator()}</Text>
<Box flexDirection="column" marginLeft={2}>
<Text>
<Text color="cyan">Provider:</Text> {capitalize(selectedProvider)}
</Text>
<Text>
<Text color="cyan">Model:</Text> {modelId}
</Text>
{baseUrl && (
<Text>
<Text color="cyan">Base URL:</Text> {baseUrl}
</Text>
)}
<Text>
<Text color="cyan">API Key:</Text> Configured
</Text>
</Box>
<Text color="gray">{formatSeparator()}</Text>
<Text color="white">You can now use Cline with this provider.</Text>
<Text> </Text>
<Select items={successMenuItems} label="What would you like to do?" onSelect={handleSuccessMenuSelect} />
</Box>
)}
{step === "error" && (
<Box flexDirection="column">
<Text bold color="red">
Configuration failed
</Text>
<Text color="gray">{formatSeparator()}</Text>
<Text color="red">{errorMessage}</Text>
<Text> </Text>
<Select items={errorMenuItems} label="What would you like to do?" onSelect={handleErrorMenuSelect} />
</Box>
)}
</Box>
)
}
-208
View File
@@ -1,208 +0,0 @@
/**
* Checkpoint menu component
* Displays available checkpoints and allows user to select one to restore
*/
import type { ClineMessage } from "@shared/ExtensionMessage"
import { Box, Text, useInput } from "ink"
import React, { useState } from "react"
export type RestoreType = "task" | "workspace" | "taskAndWorkspace"
interface CheckpointOption {
ts: number
hash: string
date: Date
label: string
}
interface CheckpointMenuProps {
messages: ClineMessage[]
onSelect: (messageTs: number, restoreType: RestoreType) => void
onCancel: () => void
}
/**
* Extract checkpoint options from messages
*/
function getCheckpointOptions(messages: ClineMessage[]): CheckpointOption[] {
const options: CheckpointOption[] = []
for (const msg of messages) {
if (msg.lastCheckpointHash) {
options.push({
ts: msg.ts,
hash: msg.lastCheckpointHash,
date: new Date(msg.ts),
label: getCheckpointLabel(msg),
})
}
}
// Sort by timestamp descending (newest first)
return options.sort((a, b) => b.ts - a.ts)
}
/**
* Get a human-readable label for a checkpoint
*/
function getCheckpointLabel(msg: ClineMessage): string {
if (msg.say === "completion_result") {
return "Task completion"
}
if (msg.say === "checkpoint_created") {
return "Checkpoint"
}
if (msg.say === "api_req_started") {
return "API request"
}
return msg.say || msg.ask || "Message"
}
const RESTORE_TYPE_OPTIONS: { type: RestoreType; label: string; description: string }[] = [
{
type: "taskAndWorkspace",
label: "Task + Workspace",
description: "Restore messages and files",
},
{
type: "task",
label: "Task Only",
description: "Delete messages after this point",
},
{
type: "workspace",
label: "Workspace Only",
description: "Restore files only",
},
]
export const CheckpointMenu: React.FC<CheckpointMenuProps> = ({ messages, onSelect, onCancel }) => {
const checkpoints = getCheckpointOptions(messages)
const [selectedCheckpoint, setSelectedCheckpoint] = useState(0)
const [selectedRestoreType, setSelectedRestoreType] = useState(0)
const [stage, setStage] = useState<"checkpoint" | "restoreType">("checkpoint")
useInput((input, key) => {
if (key.escape) {
if (stage === "restoreType") {
setStage("checkpoint")
} else {
onCancel()
}
return
}
if (stage === "checkpoint") {
if (key.upArrow) {
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) {
setStage("restoreType")
}
} else if (stage === "restoreType") {
if (key.upArrow) {
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) {
const checkpoint = checkpoints[selectedCheckpoint]
const restoreType = RESTORE_TYPE_OPTIONS[selectedRestoreType]
if (checkpoint && restoreType) {
onSelect(checkpoint.ts, restoreType.type)
}
}
}
// Quick number selection for checkpoints
if (stage === "checkpoint") {
const num = parseInt(input, 10)
if (!Number.isNaN(num) && num >= 1 && num <= checkpoints.length) {
setSelectedCheckpoint(num - 1)
setStage("restoreType")
}
}
})
if (checkpoints.length === 0) {
return (
<Box borderColor="yellow" borderStyle="round" flexDirection="column" marginTop={1} paddingLeft={1} paddingRight={1}>
<Text color="yellow">No checkpoints available</Text>
<Text color="gray" dimColor>
Checkpoints are created at task completion points
</Text>
<Text color="gray" dimColor>
Press Escape to close
</Text>
</Box>
)
}
if (stage === "checkpoint") {
return (
<Box borderColor="cyan" borderStyle="round" flexDirection="column" marginTop={1} paddingLeft={1} paddingRight={1}>
<Text bold color="cyan">
Restore Checkpoint
</Text>
<Text color="gray" dimColor>
Select a checkpoint to restore (/ or number, Enter to select, Escape to cancel)
</Text>
<Box flexDirection="column" marginTop={1}>
{checkpoints.map((cp, idx) => {
const isSelected = idx === selectedCheckpoint
const timeStr = cp.date.toLocaleTimeString()
const dateStr = cp.date.toLocaleDateString()
return (
<Box key={cp.ts}>
<Text color={isSelected ? "green" : "gray"}>{isSelected ? "> " : " "}</Text>
<Text color={isSelected ? "white" : "gray"}>{idx + 1}. </Text>
<Text color={isSelected ? "cyan" : undefined}>{cp.label}</Text>
<Text color="gray"> - </Text>
<Text dimColor>
{dateStr} {timeStr}
</Text>
</Box>
)
})}
</Box>
</Box>
)
}
// Stage: restoreType
const selectedCp = checkpoints[selectedCheckpoint]
return (
<Box borderColor="cyan" borderStyle="round" flexDirection="column" marginTop={1} paddingLeft={1} paddingRight={1}>
<Text bold color="cyan">
Restore Type
</Text>
<Text color="gray" dimColor>
Restoring to: {selectedCp?.label} ({selectedCp?.date.toLocaleString()})
</Text>
<Box flexDirection="column" marginTop={1}>
{RESTORE_TYPE_OPTIONS.map((opt, idx) => {
const isSelected = idx === selectedRestoreType
return (
<Box flexDirection="column" key={opt.type} marginBottom={idx < RESTORE_TYPE_OPTIONS.length - 1 ? 1 : 0}>
<Box>
<Text color={isSelected ? "green" : "gray"}>{isSelected ? "> " : " "}</Text>
<Text bold={isSelected} color={isSelected ? "white" : undefined}>
{opt.label}
</Text>
</Box>
<Box marginLeft={4}>
<Text color="gray" dimColor>
{opt.description}
</Text>
</Box>
</Box>
)
})}
</Box>
<Text color="gray" dimColor marginTop={1}>
(/ to select, Enter to confirm, Escape to go back)
</Text>
</Box>
)
}
-199
View File
@@ -1,199 +0,0 @@
import { Text } from "ink"
import { render } from "ink-testing-library"
import React from "react"
import { beforeEach, describe, expect, it, vi } from "vitest"
// Create stable mock references using vi.hoisted - must be before any imports that use these modules
const { mockIsSettingsKey } = vi.hoisted(() => ({
mockIsSettingsKey: vi.fn((key: string) => key.startsWith("act") || key.startsWith("plan") || key === "mode"),
}))
vi.mock("./TaskView", () => ({
TaskView: ({ taskId, verbose }: any) =>
React.createElement(Text, null, `TaskView: ${taskId || "no-id"} verbose=${String(verbose)}`),
}))
// Mock the state-keys module - must be hoisted before ConfigView import
vi.mock("@shared/storage/state-keys", () => ({
isSettingsKey: mockIsSettingsKey,
SETTINGS_DEFAULTS: {
mode: "act",
actModeApiProvider: "anthropic",
},
GlobalStateAndSettings: {},
GlobalStateAndSettingsKey: {},
LocalState: {},
LocalStateKey: {},
}))
// Import ConfigView after mocks are set up
import { ConfigView } from "./ConfigView"
describe("ConfigView", () => {
const defaultProps = {
dataDir: "/home/user/.cline",
globalState: {},
workspaceState: {},
}
beforeEach(() => {
vi.clearAllMocks()
})
describe("rendering", () => {
it("should render the config header", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} />)
expect(lastFrame()).toContain("Configuration")
})
it("should display the data directory", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} dataDir="/custom/path" />)
expect(lastFrame()).toContain("/custom/path")
})
it("should display global state entries", () => {
const { lastFrame } = render(
<ConfigView
{...defaultProps}
globalState={{
mode: "act",
actModeApiProvider: "anthropic",
}}
/>,
)
expect(lastFrame()).toContain("mode")
expect(lastFrame()).toContain("act")
})
it("should display workspace state entries", () => {
const { lastFrame } = render(
<ConfigView
{...defaultProps}
workspaceState={{
customSetting: "value",
}}
/>,
)
expect(lastFrame()).toContain("customSetting")
expect(lastFrame()).toContain("value")
})
it("should show section headers", () => {
const { lastFrame } = render(
<ConfigView {...defaultProps} globalState={{ mode: "act" }} workspaceState={{ localKey: "localValue" }} />,
)
expect(lastFrame()).toContain("Global Settings")
})
})
describe("value formatting", () => {
it("should format boolean values", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ actModeSomeBool: true }} />)
expect(lastFrame()).toContain("true")
})
it("should format number values", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ actModeNumber: 42 }} />)
expect(lastFrame()).toContain("42")
})
it("should truncate long string values", () => {
const longString = "x".repeat(100)
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ actModeLongValue: longString }} />)
expect(lastFrame()).toContain("...")
})
it("should format object values as JSON", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ actModeObj: { nested: "value" } }} />)
expect(lastFrame()).toContain("nested")
})
})
describe("filtering", () => {
it("should exclude taskHistory key", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ taskHistory: [1, 2, 3], mode: "act" }} />)
expect(lastFrame()).not.toContain("taskHistory")
})
it("should exclude empty objects", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ emptyObj: {}, mode: "act" }} />)
expect(lastFrame()).not.toContain("emptyObj")
})
it("should exclude empty arrays", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ emptyArr: [], mode: "act" }} />)
expect(lastFrame()).not.toContain("emptyArr")
})
it("should exclude null/undefined values", () => {
const { lastFrame } = render(
<ConfigView {...defaultProps} globalState={{ nullVal: null, undefinedVal: undefined, mode: "act" }} />,
)
expect(lastFrame()).not.toContain("nullVal")
expect(lastFrame()).not.toContain("undefinedVal")
})
it("should exclude keys ending with Toggles", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ someToggles: { a: true }, mode: "act" }} />)
expect(lastFrame()).not.toContain("someToggles")
})
it("should exclude keys starting with apiConfig_", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ apiConfig_test: "value", mode: "act" }} />)
expect(lastFrame()).not.toContain("apiConfig_test")
})
})
describe("keyboard navigation", () => {
it("should show navigation help text", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ mode: "act" }} />)
expect(lastFrame()).toContain("Navigate")
expect(lastFrame()).toContain("Edit")
})
it("should highlight first item by default", () => {
const { lastFrame } = render(
<ConfigView {...defaultProps} globalState={{ mode: "act", actModeApiProvider: "anthropic" }} />,
)
// The selected indicator
expect(lastFrame()).toContain("")
})
it("should navigate down with arrow key", () => {
const { lastFrame, stdin } = render(
<ConfigView {...defaultProps} globalState={{ actModeFirst: "a", actModeSecond: "b" }} />,
)
// Press down arrow
stdin.write("\x1B[B")
const frame = lastFrame()
expect(frame).toContain("")
})
it("should navigate up with arrow key", () => {
const { lastFrame, stdin } = render(
<ConfigView {...defaultProps} globalState={{ actModeFirst: "a", actModeSecond: "b" }} />,
)
// Press down then up
stdin.write("\x1B[B")
stdin.write("\x1B[A")
expect(lastFrame()).toContain("")
})
})
describe("scrolling", () => {
it("should show scroll indicators when list is long", () => {
const manyEntries: Record<string, string> = {}
for (let i = 0; i < 20; i++) {
manyEntries[`actModeKey${i}`] = `value${i}`
}
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={manyEntries} />)
expect(lastFrame()).toContain("Showing")
})
})
})
-553
View File
@@ -1,553 +0,0 @@
/**
* Interactive config view component for displaying and editing configuration values
* Supports tabs for Settings, Rules, Workflows, Hooks, and Skills
*/
import {
GlobalStateAndSettings,
GlobalStateAndSettingsKey,
LocalState,
LocalStateKey,
SETTINGS_DEFAULTS,
} from "@shared/storage/state-keys"
import { Box, Text, useApp, useInput } from "ink"
import React, { useMemo, useState } from "react"
import {
BooleanSelect,
buildConfigEntries,
buildToggleEntries,
ConfigRow,
HookInfo,
HookRow,
MAX_VISIBLE,
parseValue,
SEPARATOR,
SectionHeader,
SkillInfo,
SkillRow,
TABS,
TabBar,
TabView,
TextInput,
ToggleEntry,
ToggleRow,
WorkspaceHooks,
} from "./ConfigViewComponents"
// ============================================================================
// Types
// ============================================================================
interface ConfigViewProps {
dataDir: string
globalState: Record<string, unknown>
workspaceState: Record<string, unknown>
onUpdateGlobal?: (key: GlobalStateAndSettingsKey, value: GlobalStateAndSettings[GlobalStateAndSettingsKey]) => void
onUpdateWorkspace?: (key: LocalStateKey, value: LocalState[LocalStateKey]) => void
// Rules toggles
globalClineRulesToggles?: Record<string, boolean>
localClineRulesToggles?: Record<string, boolean>
localCursorRulesToggles?: Record<string, boolean>
localWindsurfRulesToggles?: Record<string, boolean>
localAgentsRulesToggles?: Record<string, boolean>
onToggleRule?: (isGlobal: boolean, rulePath: string, enabled: boolean, ruleType: string) => void
// Workflow toggles
globalWorkflowToggles?: Record<string, boolean>
localWorkflowToggles?: Record<string, boolean>
onToggleWorkflow?: (isGlobal: boolean, workflowPath: string, enabled: boolean) => void
// Hooks
hooksEnabled?: boolean
globalHooks?: HookInfo[]
workspaceHooks?: WorkspaceHooks[]
onToggleHook?: (isGlobal: boolean, hookName: string, enabled: boolean, workspaceName?: string) => void
// Skills
skillsEnabled?: boolean
globalSkills?: SkillInfo[]
localSkills?: SkillInfo[]
onToggleSkill?: (isGlobal: boolean, skillPath: string, enabled: boolean) => void
// Open folder callback
onOpenFolder?: (folderType: "rules" | "workflows" | "hooks" | "skills", isGlobal: boolean) => void
}
// ============================================================================
// Main Component
// ============================================================================
export const ConfigView: React.FC<ConfigViewProps> = ({
dataDir,
globalState,
workspaceState,
onUpdateGlobal,
onUpdateWorkspace,
globalClineRulesToggles,
localClineRulesToggles,
localCursorRulesToggles,
localWindsurfRulesToggles,
localAgentsRulesToggles,
onToggleRule,
globalWorkflowToggles,
localWorkflowToggles,
onToggleWorkflow,
hooksEnabled,
globalHooks = [],
workspaceHooks = [],
onToggleHook,
skillsEnabled,
globalSkills = [],
localSkills = [],
onToggleSkill,
onOpenFolder,
}) => {
const { exit } = useApp()
const [currentTab, setCurrentTab] = useState<TabView>("settings")
const [isEditing, setIsEditing] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(0)
const [editValue, setEditValue] = useState("")
// Build entries for settings tab
const configEntries = useMemo(
() => [...buildConfigEntries(globalState, "global"), ...buildConfigEntries(workspaceState, "workspace")],
[globalState, workspaceState],
)
// Build entries for rules tab
const ruleEntries = useMemo(() => {
const entries: ToggleEntry[] = []
entries.push(...buildToggleEntries(globalClineRulesToggles, "global", "cline"))
entries.push(...buildToggleEntries(localClineRulesToggles, "workspace", "cline"))
entries.push(...buildToggleEntries(localCursorRulesToggles, "workspace", "cursor"))
entries.push(...buildToggleEntries(localWindsurfRulesToggles, "workspace", "windsurf"))
entries.push(...buildToggleEntries(localAgentsRulesToggles, "workspace", "agents"))
return entries
}, [
globalClineRulesToggles,
localClineRulesToggles,
localCursorRulesToggles,
localWindsurfRulesToggles,
localAgentsRulesToggles,
])
// Build entries for workflows tab
const workflowEntries = useMemo(() => {
const entries: ToggleEntry[] = []
entries.push(...buildToggleEntries(globalWorkflowToggles, "global"))
entries.push(...buildToggleEntries(localWorkflowToggles, "workspace"))
return entries
}, [globalWorkflowToggles, localWorkflowToggles])
// Build flat list of hooks
const hookEntries = useMemo(() => {
const entries: { hook: HookInfo; isGlobal: boolean; workspaceName?: string }[] = []
globalHooks.forEach((hook) => entries.push({ hook, isGlobal: true }))
workspaceHooks.forEach((ws) => {
ws.hooks.forEach((hook) => entries.push({ hook, isGlobal: false, workspaceName: ws.workspaceName }))
})
return entries.sort((a, b) => a.hook.name.localeCompare(b.hook.name))
}, [globalHooks, workspaceHooks])
// Build flat list of skills
const skillEntries = useMemo(() => {
const entries: { skill: SkillInfo; isGlobal: boolean }[] = []
globalSkills.forEach((skill) => entries.push({ skill, isGlobal: true }))
localSkills.forEach((skill) => entries.push({ skill, isGlobal: false }))
return entries.sort((a, b) => a.skill.name.localeCompare(b.skill.name))
}, [globalSkills, localSkills])
// Get current list length based on tab
const currentListLength = useMemo(() => {
switch (currentTab) {
case "settings":
return configEntries.length
case "rules":
return ruleEntries.length
case "workflows":
return workflowEntries.length
case "hooks":
return hookEntries.length
case "skills":
return skillEntries.length
default:
return 0
}
}, [currentTab, configEntries.length, ruleEntries.length, workflowEntries.length, hookEntries.length, skillEntries.length])
// Get available tabs
const availableTabs = useMemo(() => {
return TABS.filter((tab) => {
if (tab.requiresFlag === "hooks") {
return hooksEnabled
}
if (tab.requiresFlag === "skills") {
return skillsEnabled
}
return true
})
}, [hooksEnabled, skillsEnabled])
// Reset selection when changing tabs
const handleTabChange = (newTab: TabView) => {
setCurrentTab(newTab)
setSelectedIndex(0)
setIsEditing(false)
}
// Settings tab handlers
const selectedConfigEntry = configEntries[selectedIndex]
const handleSettingsSave = (value: string | boolean) => {
if (!selectedConfigEntry) {
return
}
const parsed = typeof value === "boolean" ? value : parseValue(value, selectedConfigEntry.type)
if (selectedConfigEntry.source === "global" && onUpdateGlobal) {
onUpdateGlobal(selectedConfigEntry.key as GlobalStateAndSettingsKey, parsed as never)
} else if (selectedConfigEntry.source === "workspace" && onUpdateWorkspace) {
onUpdateWorkspace(selectedConfigEntry.key as LocalStateKey, parsed as never)
}
setIsEditing(false)
}
const handleSettingsReset = () => {
if (!selectedConfigEntry?.isEditable || selectedConfigEntry.source !== "global") {
return
}
const defaultValue = (SETTINGS_DEFAULTS as Record<string, unknown>)[selectedConfigEntry.key]
if (defaultValue !== undefined && onUpdateGlobal) {
onUpdateGlobal(selectedConfigEntry.key as GlobalStateAndSettingsKey, defaultValue as never)
}
}
// Toggle handlers for rules/workflows/hooks/skills
const handleToggle = () => {
if (currentTab === "rules" && ruleEntries[selectedIndex] && onToggleRule) {
const entry = ruleEntries[selectedIndex]
onToggleRule(entry.source === "global", entry.path, !entry.enabled, entry.ruleType || "cline")
} else if (currentTab === "workflows" && workflowEntries[selectedIndex] && onToggleWorkflow) {
const entry = workflowEntries[selectedIndex]
onToggleWorkflow(entry.source === "global", entry.path, !entry.enabled)
} else if (currentTab === "hooks" && hookEntries[selectedIndex] && onToggleHook) {
const entry = hookEntries[selectedIndex]
onToggleHook(entry.isGlobal, entry.hook.name, !entry.hook.enabled, entry.workspaceName)
} else if (currentTab === "skills" && skillEntries[selectedIndex] && onToggleSkill) {
const entry = skillEntries[selectedIndex]
onToggleSkill(entry.isGlobal, entry.skill.path, !entry.skill.enabled)
}
}
// Input handling
useInput(
(input, key) => {
if (input.toLowerCase() === "q" || key.escape) {
exit()
}
// Tab navigation with Tab key or number keys
if (key.tab || (input >= "1" && input <= "5")) {
const targetIdx = key.tab
? (availableTabs.findIndex((t) => t.key === currentTab) + 1) % availableTabs.length
: parseInt(input) - 1
if (targetIdx >= 0 && targetIdx < availableTabs.length) {
handleTabChange(availableTabs[targetIdx].key)
}
return
}
// List navigation
if (key.upArrow) {
setSelectedIndex((i) => (i > 0 ? i - 1 : currentListLength - 1))
} else if (key.downArrow) {
setSelectedIndex((i) => (i < currentListLength - 1 ? i + 1 : 0))
}
// Tab-specific actions
if (currentTab === "settings") {
if ((key.return || input === "e") && selectedConfigEntry?.isEditable) {
setEditValue(selectedConfigEntry.value !== undefined ? String(selectedConfigEntry.value) : "")
setIsEditing(true)
} else if (input === "r") {
handleSettingsReset()
}
} else if (key.return || input === " ") {
// Toggle for rules/workflows/hooks/skills
handleToggle()
}
// Open folder (for rules/workflows/hooks/skills tabs)
if (input === "o" && onOpenFolder && currentTab !== "settings") {
// Determine if current selection is global or workspace based on the selected entry
let isGlobal = true
if (currentTab === "rules" && ruleEntries[selectedIndex]) {
isGlobal = ruleEntries[selectedIndex].source === "global"
} else if (currentTab === "workflows" && workflowEntries[selectedIndex]) {
isGlobal = workflowEntries[selectedIndex].source === "global"
} else if (currentTab === "hooks" && hookEntries[selectedIndex]) {
isGlobal = hookEntries[selectedIndex].isGlobal
} else if (currentTab === "skills" && skillEntries[selectedIndex]) {
isGlobal = skillEntries[selectedIndex].isGlobal
}
onOpenFolder(currentTab as "rules" | "workflows" | "hooks" | "skills", isGlobal)
}
},
{ isActive: !isEditing },
)
// Scrolling window
const halfVisible = Math.floor(MAX_VISIBLE / 2)
const startIndex = Math.max(0, Math.min(selectedIndex - halfVisible, currentListLength - MAX_VISIBLE))
// Edit mode UI (settings only)
if (isEditing && selectedConfigEntry && currentTab === "settings") {
const header = (
<React.Fragment>
<Text bold color="white">
Edit Configuration
</Text>
<Text color="gray">{SEPARATOR}</Text>
</React.Fragment>
)
if (selectedConfigEntry.type === "boolean") {
return (
<Box flexDirection="column">
{header}
<BooleanSelect
label={selectedConfigEntry.key}
onCancel={() => setIsEditing(false)}
onSelect={handleSettingsSave}
value={Boolean(selectedConfigEntry.value)}
/>
</Box>
)
}
return (
<Box flexDirection="column">
{header}
<TextInput
label={selectedConfigEntry.key}
onCancel={() => setIsEditing(false)}
onChange={setEditValue}
onSubmit={handleSettingsSave}
type={selectedConfigEntry.type}
value={editValue}
/>
</Box>
)
}
// Render tab content
const renderTabContent = () => {
switch (currentTab) {
case "settings": {
const visibleEntries = configEntries.slice(startIndex, startIndex + MAX_VISIBLE)
return (
<React.Fragment>
<Box>
<Text>Data directory: </Text>
<Text color="blue" underline>
{dataDir}
</Text>
</Box>
<Text color="gray">{SEPARATOR}</Text>
<Box flexDirection="column">
{visibleEntries.map((entry, idx) => {
const actualIndex = startIndex + idx
const prevEntry = visibleEntries[idx - 1]
const showHeader = !prevEntry || prevEntry.source !== entry.source
return (
<React.Fragment key={`${entry.source}-${entry.key}`}>
{showHeader && (
<SectionHeader
title={entry.source === "global" ? "Global Settings:" : "Workspace Settings:"}
/>
)}
<ConfigRow entry={entry} isSelected={actualIndex === selectedIndex} />
</React.Fragment>
)
})}
</Box>
</React.Fragment>
)
}
case "rules": {
if (ruleEntries.length === 0) {
return (
<Box>
<Text color="gray">
No rules configured. Add .clinerules files to your workspace or global config.
</Text>
</Box>
)
}
const visibleEntries = ruleEntries.slice(startIndex, startIndex + MAX_VISIBLE)
return (
<Box flexDirection="column">
{visibleEntries.map((entry, idx) => {
const actualIndex = startIndex + idx
const prevEntry = visibleEntries[idx - 1]
const showHeader = !prevEntry || prevEntry.source !== entry.source
return (
<React.Fragment key={`${entry.source}-${entry.path}`}>
{showHeader && (
<SectionHeader title={entry.source === "global" ? "Global Rules:" : "Workspace Rules:"} />
)}
<ToggleRow entry={entry} isSelected={actualIndex === selectedIndex} showType />
</React.Fragment>
)
})}
</Box>
)
}
case "workflows": {
if (workflowEntries.length === 0) {
return (
<Box>
<Text color="gray">No workflows configured. Add workflow files to enable this feature.</Text>
</Box>
)
}
const visibleEntries = workflowEntries.slice(startIndex, startIndex + MAX_VISIBLE)
return (
<Box flexDirection="column">
{visibleEntries.map((entry, idx) => {
const actualIndex = startIndex + idx
const prevEntry = visibleEntries[idx - 1]
const showHeader = !prevEntry || prevEntry.source !== entry.source
return (
<React.Fragment key={`${entry.source}-${entry.path}`}>
{showHeader && (
<SectionHeader
title={entry.source === "global" ? "Global Workflows:" : "Workspace Workflows:"}
/>
)}
<ToggleRow entry={entry} isSelected={actualIndex === selectedIndex} />
</React.Fragment>
)
})}
</Box>
)
}
case "hooks": {
if (hookEntries.length === 0) {
return (
<Box>
<Text color="gray">No hooks configured. Add hook scripts to enable automation.</Text>
</Box>
)
}
const visibleEntries = hookEntries.slice(startIndex, startIndex + MAX_VISIBLE)
return (
<Box flexDirection="column">
{visibleEntries.map((entry, idx) => {
const actualIndex = startIndex + idx
const prevEntry = visibleEntries[idx - 1]
const showHeader =
!prevEntry ||
prevEntry.isGlobal !== entry.isGlobal ||
prevEntry.workspaceName !== entry.workspaceName
let sectionTitle = "Global Hooks:"
if (!entry.isGlobal && entry.workspaceName) {
sectionTitle = `${entry.workspaceName} Hooks:`
}
return (
<React.Fragment key={`${entry.isGlobal}-${entry.workspaceName || ""}-${entry.hook.name}`}>
{showHeader && <SectionHeader title={sectionTitle} />}
<HookRow hook={entry.hook} isSelected={actualIndex === selectedIndex} />
</React.Fragment>
)
})}
</Box>
)
}
case "skills": {
if (skillEntries.length === 0) {
return (
<Box>
<Text color="gray">No skills configured. Add SKILL.md files to enable skills.</Text>
</Box>
)
}
const visibleEntries = skillEntries.slice(startIndex, startIndex + MAX_VISIBLE)
return (
<Box flexDirection="column">
{visibleEntries.map((entry, idx) => {
const actualIndex = startIndex + idx
const prevEntry = visibleEntries[idx - 1]
const showHeader = !prevEntry || prevEntry.isGlobal !== entry.isGlobal
return (
<React.Fragment key={`${entry.isGlobal}-${entry.skill.path}`}>
{showHeader && (
<SectionHeader title={entry.isGlobal ? "Global Skills:" : "Workspace Skills:"} />
)}
<SkillRow isSelected={actualIndex === selectedIndex} skill={entry.skill} />
</React.Fragment>
)
})}
</Box>
)
}
default:
return null
}
}
// Help text based on current tab
const getHelpText = () => {
const base = "↑/↓ Navigate • Tab/1-5 Switch tabs • q/Esc Exit"
if (currentTab === "settings") {
return `${base} • Enter/e Edit • r Reset`
}
const openFolder = onOpenFolder ? " • o Open folder" : ""
return `${base} • Enter/Space Toggle${openFolder}`
}
return (
<Box flexDirection="column">
<Text bold color="white">
Cline Configuration
</Text>
<Text color="gray">{SEPARATOR}</Text>
<TabBar currentTab={currentTab} hooksEnabled={hooksEnabled} skillsEnabled={skillsEnabled} tabs={TABS} />
<Text color="gray">{SEPARATOR}</Text>
{renderTabContent()}
{currentListLength > MAX_VISIBLE && (
<Box marginTop={1}>
<Text color="gray" dimColor>
{startIndex > 0 ? "↑ " : " "}
Showing {startIndex + 1}-{Math.min(startIndex + MAX_VISIBLE, currentListLength)} of {currentListLength}
{startIndex + MAX_VISIBLE < currentListLength ? " ↓" : " "}
</Text>
</Box>
)}
<Text color="gray">{SEPARATOR}</Text>
<Box flexDirection="column">
<Text color="gray" dimColor>
{getHelpText()}
</Text>
{currentTab === "settings" && selectedConfigEntry && !selectedConfigEntry.isEditable && (
<Text color="yellow" dimColor>
This field is read-only ({selectedConfigEntry.type} type or not a setting)
</Text>
)}
</Box>
</Box>
)
}
@@ -1,384 +0,0 @@
/**
* Sub-components and types for ConfigView
*/
import { Box, Text, useInput } from "ink"
import React, { useState } from "react"
// ============================================================================
// Types & Constants
// ============================================================================
export type ValueType = "string" | "number" | "boolean" | "object" | "undefined"
export type TabView = "settings" | "rules" | "workflows" | "hooks" | "skills"
export interface ConfigEntry {
key: string
value: unknown
type: ValueType
isEditable: boolean
source: "global" | "workspace"
}
export interface ToggleEntry {
path: string
enabled: boolean
source: "global" | "workspace" | "remote"
ruleType?: string
}
export interface HookInfo {
name: string
enabled: boolean
absolutePath: string
}
export interface WorkspaceHooks {
workspaceName: string
hooks: HookInfo[]
}
export interface SkillInfo {
name: string
description: string
path: string
enabled: boolean
}
export const EXCLUDED_KEYS = new Set([
"taskHistory",
"primaryRootIndex",
"subagentsEnabled",
"subagentTerminalOutputLineLimit",
"welcomeViewCompleted",
"isNewUser",
])
export const EDITABLE_TYPES: Set<ValueType> = new Set(["string", "number", "boolean"])
export const MAX_VISIBLE = 12
export const SEPARATOR = "─".repeat(80)
export const TABS: { key: TabView; label: string; requiresFlag?: "hooks" | "skills" }[] = [
{ key: "settings", label: "Settings" },
{ key: "rules", label: "Rules" },
{ key: "workflows", label: "Workflows" },
{ key: "hooks", label: "Hooks", requiresFlag: "hooks" },
{ key: "skills", label: "Skills", requiresFlag: "skills" },
]
// ============================================================================
// Helper Functions
// ============================================================================
export function getValueType(value: unknown): ValueType {
if (value === undefined || value === null) {
return "undefined"
}
if (typeof value === "boolean") {
return "boolean"
}
if (typeof value === "number") {
return "number"
}
if (typeof value === "object") {
return "object"
}
return "string"
}
export function isExcluded(key: string, value: unknown): boolean {
if (EXCLUDED_KEYS.has(key)) {
return true
}
if (key.endsWith("Toggles") || key.endsWith("ModelInfo")) {
return true
}
if (key.startsWith("apiConfig_") || key.startsWith("last")) {
return true
}
if (value === undefined || value === null) {
return true
}
if (typeof value === "object" && Object.keys(value as object).length === 0) {
return true
}
if (Array.isArray(value) && value.length === 0) {
return true
}
if (typeof value === "string" && value.trim() === "") {
return true
}
return false
}
export function formatValue(value: unknown, maxLen = 50): string {
if (value === undefined || value === null) {
return "<not set>"
}
if (typeof value === "boolean") {
return value ? "true" : "false"
}
if (typeof value === "number") {
return String(value)
}
if (typeof value === "object") {
const json = JSON.stringify(value)
return json.length > maxLen ? json.slice(0, maxLen - 3) + "..." : json
}
const str = String(value)
return str.length > maxLen ? str.slice(0, maxLen - 3) + "..." : str
}
export function parseValue(input: string, type: ValueType): unknown {
if (type === "boolean") {
return input.toLowerCase() === "true" || input === "1"
}
if (type === "number") {
const num = parseFloat(input)
return Number.isNaN(num) ? 0 : num
}
if (type === "object") {
try {
return JSON.parse(input)
} catch {
return {}
}
}
return input
}
// Import isSettingsKey at module level for proper test mocking
import { isSettingsKey } from "@shared/storage/state-keys"
export function buildConfigEntries(state: Record<string, unknown>, source: "global" | "workspace"): ConfigEntry[] {
return Object.entries(state)
.filter(([key, value]) => !isExcluded(key, value))
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => {
const type = getValueType(value)
const isEditable = EDITABLE_TYPES.has(type) && (source === "workspace" || isSettingsKey(key))
return { key, value, type, isEditable, source }
})
}
export function buildToggleEntries(
toggles: Record<string, boolean> | undefined,
source: "global" | "workspace" | "remote",
ruleType?: string,
): ToggleEntry[] {
if (!toggles) {
return []
}
return Object.entries(toggles)
.sort(([a], [b]) => a.localeCompare(b))
.map(([path, enabled]) => ({ path, enabled, source, ruleType }))
}
export function getFileName(path: string): string {
return path.split("/").pop() || path
}
// ============================================================================
// Sub-components
// ============================================================================
interface TextInputProps {
label: string
onChange: (value: string) => void
onCancel: () => void
onSubmit: (value: string) => void
type: ValueType
value: string
}
export const TextInput: React.FC<TextInputProps> = ({ label, onChange, onCancel, onSubmit, type, value }) => {
useInput((input, key) => {
if (key.escape) {
onCancel()
} else if (key.return) {
onSubmit(value)
} else if (key.backspace || key.delete) {
onChange(value.slice(0, -1))
} else if (input && !key.ctrl && !key.meta) {
onChange(value + input)
}
})
return (
<Box flexDirection="column" marginTop={1}>
<Text bold color="cyan">
Edit: {label}
</Text>
<Box>
<Text color="white">{value}</Text>
<Text color="gray"></Text>
</Box>
<Text color="gray" dimColor>
Type: {type} Enter to save Esc to cancel
</Text>
</Box>
)
}
interface BooleanSelectProps {
label: string
onCancel: () => void
onSelect: (value: boolean) => void
value: boolean
}
export const BooleanSelect: React.FC<BooleanSelectProps> = ({ label, onCancel, onSelect, value }) => {
const [selected, setSelected] = useState(value)
useInput((_input, key) => {
if (key.escape) {
onCancel()
} else if (key.return) {
onSelect(selected)
} else if (key.upArrow || key.downArrow) {
setSelected((prev) => !prev)
}
})
return (
<Box flexDirection="column" marginTop={1}>
<Text bold color="cyan">
Edit: {label}
</Text>
<Box flexDirection="column">
<Text color={selected ? "green" : undefined}>{selected ? " " : " "}true</Text>
<Text color={!selected ? "green" : undefined}>{!selected ? " " : " "}false</Text>
</Box>
<Text color="gray" dimColor>
/ to toggle Enter to save Esc to cancel
</Text>
</Box>
)
}
export const ConfigRow: React.FC<{ entry: ConfigEntry; isSelected: boolean }> = ({ entry, isSelected }) => {
const valueColor = entry.type === "boolean" ? (entry.value ? "green" : "red") : "white"
return (
<Box>
<Text color={isSelected ? "cyan" : undefined}>
{isSelected ? " " : " "}
<Text color="cyan">{entry.key}</Text>
<Text color="gray">: </Text>
<Text color={valueColor}>{formatValue(entry.value)}</Text>
{!entry.isEditable && (
<Text color="gray" dimColor>
{" "}
(read-only)
</Text>
)}
</Text>
</Box>
)
}
export const ToggleRow: React.FC<{
entry: ToggleEntry
isSelected: boolean
showType?: boolean
}> = ({ entry, isSelected, showType }) => {
const fileName = getFileName(entry.path)
const typeLabel = entry.ruleType ? ` [${entry.ruleType}]` : ""
return (
<Box>
<Text color={isSelected ? "cyan" : undefined}>
{isSelected ? " " : " "}
<Text color={entry.enabled ? "green" : "red"}>{entry.enabled ? "●" : "○"}</Text>
<Text> </Text>
<Text color="white">{fileName}</Text>
{showType && (
<Text color="gray" dimColor>
{typeLabel}
</Text>
)}
</Text>
</Box>
)
}
export const HookRow: React.FC<{
hook: HookInfo
isSelected: boolean
}> = ({ hook, isSelected }) => {
return (
<Box>
<Text color={isSelected ? "cyan" : undefined}>
{isSelected ? " " : " "}
<Text color={hook.enabled ? "green" : "red"}>{hook.enabled ? "●" : "○"}</Text>
<Text> </Text>
<Text color="white">{hook.name}</Text>
</Text>
</Box>
)
}
export const SkillRow: React.FC<{
skill: SkillInfo
isSelected: boolean
}> = ({ skill, isSelected }) => {
return (
<Box flexDirection="column">
<Box>
<Text color={isSelected ? "cyan" : undefined}>
{isSelected ? " " : " "}
<Text color={skill.enabled ? "green" : "red"}>{skill.enabled ? "●" : "○"}</Text>
<Text> </Text>
<Text bold color="white">
{skill.name}
</Text>
</Text>
</Box>
{skill.description && (
<Box marginLeft={4}>
<Text color="gray" dimColor>
{skill.description.length > 60 ? skill.description.slice(0, 57) + "..." : skill.description}
</Text>
</Box>
)}
</Box>
)
}
export const TabBar: React.FC<{
currentTab: TabView
tabs: typeof TABS
hooksEnabled?: boolean
skillsEnabled?: boolean
}> = ({ currentTab, tabs, hooksEnabled, skillsEnabled }) => {
const visibleTabs = tabs.filter((tab) => {
if (tab.requiresFlag === "hooks") {
return hooksEnabled
}
if (tab.requiresFlag === "skills") {
return skillsEnabled
}
return true
})
return (
<Box marginBottom={1}>
{visibleTabs.map((tab, idx) => (
<React.Fragment key={tab.key}>
{idx > 0 && <Text color="gray"> </Text>}
<Text bold={currentTab === tab.key} color={currentTab === tab.key ? "cyan" : "gray"}>
{currentTab === tab.key ? `[${tab.label}]` : tab.label}
</Text>
</React.Fragment>
))}
</Box>
)
}
export const SectionHeader: React.FC<{ title: string }> = ({ title }) => (
<Box marginTop={1}>
<Text bold color="yellow">
{title}
</Text>
</Box>
)
-296
View File
@@ -1,296 +0,0 @@
/**
* Stateful wrapper for ConfigView that handles toggle operations
*/
import { exec } from "node:child_process"
import os from "node:os"
import path from "node:path"
import { RuleScope } from "@shared/proto/cline/file"
import type { GlobalStateAndSettings, GlobalStateAndSettingsKey, LocalState, LocalStateKey } from "@shared/storage/state-keys"
import React, { useCallback, useEffect, useState } from "react"
import type { Controller } from "@/core/controller"
import { HostProvider } from "@/hosts/host-provider"
import { ConfigView } from "./ConfigView"
interface HookInfo {
name: string
enabled: boolean
absolutePath: string
}
interface WorkspaceHooks {
workspaceName: string
hooks: HookInfo[]
}
interface SkillInfo {
name: string
description: string
path: string
enabled: boolean
}
interface ConfigViewWrapperProps {
controller: Controller
dataDir: string
globalState: Record<string, unknown>
workspaceState: Record<string, unknown>
hooksEnabled: boolean
skillsEnabled: boolean
}
export const ConfigViewWrapper: React.FC<ConfigViewWrapperProps> = ({
controller,
dataDir,
globalState: initialGlobalState,
workspaceState: initialWorkspaceState,
hooksEnabled,
skillsEnabled,
}) => {
// Settings state (managed locally for UI updates)
const [globalStateLocal, setGlobalStateLocal] = useState<Record<string, unknown>>(initialGlobalState)
const [workspaceStateLocal, setWorkspaceStateLocal] = useState<Record<string, unknown>>(initialWorkspaceState)
// Rules state
const [globalClineRulesToggles, setGlobalClineRulesToggles] = useState<Record<string, boolean>>({})
const [localClineRulesToggles, setLocalClineRulesToggles] = useState<Record<string, boolean>>({})
const [localCursorRulesToggles, setLocalCursorRulesToggles] = useState<Record<string, boolean>>({})
const [localWindsurfRulesToggles, setLocalWindsurfRulesToggles] = useState<Record<string, boolean>>({})
const [localAgentsRulesToggles, setLocalAgentsRulesToggles] = useState<Record<string, boolean>>({})
// Workflow state
const [globalWorkflowToggles, setGlobalWorkflowToggles] = useState<Record<string, boolean>>({})
const [localWorkflowToggles, setLocalWorkflowToggles] = useState<Record<string, boolean>>({})
// Hooks state
const [globalHooks, setGlobalHooks] = useState<HookInfo[]>([])
const [workspaceHooksState, setWorkspaceHooksState] = useState<WorkspaceHooks[]>([])
// Skills state
const [globalSkills, setGlobalSkills] = useState<SkillInfo[]>([])
const [localSkills, setLocalSkills] = useState<SkillInfo[]>([])
// Load initial data
useEffect(() => {
const loadData = async () => {
const { refreshRules } = await import("@/core/controller/file/refreshRules")
const { refreshHooks } = await import("@/core/controller/file/refreshHooks")
const { refreshSkills } = await import("@/core/controller/file/refreshSkills")
const rulesData = await refreshRules(controller, {})
setGlobalClineRulesToggles(rulesData.globalClineRulesToggles?.toggles || {})
setLocalClineRulesToggles(rulesData.localClineRulesToggles?.toggles || {})
setLocalCursorRulesToggles(rulesData.localCursorRulesToggles?.toggles || {})
setLocalWindsurfRulesToggles(rulesData.localWindsurfRulesToggles?.toggles || {})
setLocalAgentsRulesToggles(rulesData.localAgentsRulesToggles?.toggles || {})
setGlobalWorkflowToggles(rulesData.globalWorkflowToggles?.toggles || {})
setLocalWorkflowToggles(rulesData.localWorkflowToggles?.toggles || {})
if (hooksEnabled) {
const hooksData = await refreshHooks(controller, {})
setGlobalHooks(hooksData.globalHooks || [])
setWorkspaceHooksState(hooksData.workspaceHooks || [])
}
if (skillsEnabled) {
const skillsData = await refreshSkills(controller)
setGlobalSkills(skillsData.globalSkills || [])
setLocalSkills(skillsData.localSkills || [])
}
}
loadData()
}, [controller, hooksEnabled, skillsEnabled])
// Toggle handlers
const handleToggleRule = useCallback(
async (isGlobal: boolean, rulePath: string, enabled: boolean, ruleType: string) => {
const { toggleClineRule } = await import("@/core/controller/file/toggleClineRule")
// Determine scope based on isGlobal and rule type
const scope = isGlobal ? RuleScope.GLOBAL : RuleScope.LOCAL
// For non-cline rules, we need different toggle functions
if (ruleType === "cursor") {
// Update local state optimistically
setLocalCursorRulesToggles((prev) => ({ ...prev, [rulePath]: enabled }))
// Cursor rules use toggleCursorRule but we'll just update the state manager directly
const toggles = controller.stateManager.getWorkspaceStateKey("localCursorRulesToggles") || {}
toggles[rulePath] = enabled
controller.stateManager.setWorkspaceState("localCursorRulesToggles", toggles)
} else if (ruleType === "windsurf") {
setLocalWindsurfRulesToggles((prev) => ({ ...prev, [rulePath]: enabled }))
const toggles = controller.stateManager.getWorkspaceStateKey("localWindsurfRulesToggles") || {}
toggles[rulePath] = enabled
controller.stateManager.setWorkspaceState("localWindsurfRulesToggles", toggles)
} else if (ruleType === "agents") {
setLocalAgentsRulesToggles((prev) => ({ ...prev, [rulePath]: enabled }))
const toggles = controller.stateManager.getWorkspaceStateKey("localAgentsRulesToggles") || {}
toggles[rulePath] = enabled
controller.stateManager.setWorkspaceState("localAgentsRulesToggles", toggles)
} else {
// Cline rules
const result = await toggleClineRule(controller, { metadata: undefined, rulePath, enabled, scope })
if (result.globalClineRulesToggles?.toggles) {
setGlobalClineRulesToggles(result.globalClineRulesToggles.toggles)
}
if (result.localClineRulesToggles?.toggles) {
setLocalClineRulesToggles(result.localClineRulesToggles.toggles)
}
}
},
[controller],
)
const handleToggleWorkflow = useCallback(
async (isGlobal: boolean, workflowPath: string, enabled: boolean) => {
const { toggleWorkflow } = await import("@/core/controller/file/toggleWorkflow")
const scope = isGlobal ? RuleScope.GLOBAL : RuleScope.LOCAL
// Optimistic update
if (isGlobal) {
setGlobalWorkflowToggles((prev) => ({ ...prev, [workflowPath]: enabled }))
} else {
setLocalWorkflowToggles((prev) => ({ ...prev, [workflowPath]: enabled }))
}
await toggleWorkflow(controller, { metadata: undefined, workflowPath, enabled, scope })
},
[controller],
)
const handleToggleHook = useCallback(
async (isGlobal: boolean, hookName: string, enabled: boolean, workspaceName?: string) => {
const { toggleHook } = await import("@/core/controller/file/toggleHook")
// Optimistic update
if (isGlobal) {
setGlobalHooks((prev) => prev.map((h) => (h.name === hookName ? { ...h, enabled } : h)))
} else {
setWorkspaceHooksState((prev) =>
prev.map((ws) =>
ws.workspaceName === workspaceName
? { ...ws, hooks: ws.hooks.map((h) => (h.name === hookName ? { ...h, enabled } : h)) }
: ws,
),
)
}
const result = await toggleHook(controller, { metadata: undefined, hookName, isGlobal, enabled, workspaceName })
if (result.hooksToggles) {
setGlobalHooks(result.hooksToggles.globalHooks || [])
setWorkspaceHooksState(result.hooksToggles.workspaceHooks || [])
}
},
[controller],
)
const handleToggleSkill = useCallback(
async (isGlobal: boolean, skillPath: string, enabled: boolean) => {
const { toggleSkill } = await import("@/core/controller/file/toggleSkill")
// Optimistic update
if (isGlobal) {
setGlobalSkills((prev) => prev.map((s) => (s.path === skillPath ? { ...s, enabled } : s)))
} else {
setLocalSkills((prev) => prev.map((s) => (s.path === skillPath ? { ...s, enabled } : s)))
}
await toggleSkill(controller, { metadata: undefined, skillPath, isGlobal, enabled })
},
[controller],
)
const handleOpenFolder = useCallback(
async (folderType: "rules" | "workflows" | "hooks" | "skills", isGlobal: boolean) => {
let folderPath: string
if (isGlobal) {
// Global folders are in dataDir (e.g., ~/.cline/)
const subFolder = folderType === "rules" ? "rules" : folderType
folderPath = path.join(dataDir, subFolder)
} else {
// Local folders are in the workspace
const workspacePaths = await HostProvider.workspace.getWorkspacePaths({})
const primaryWorkspace = workspacePaths.paths[0]
if (!primaryWorkspace) {
return
}
// Local rules/workflows/hooks/skills are in .clinerules or .cline
const subFolder = folderType === "rules" ? "rules" : folderType
folderPath = path.join(primaryWorkspace, ".clinerules", subFolder)
}
// Open folder using platform-specific command
const platform = os.platform()
let command: string
if (platform === "darwin") {
command = `open "${folderPath}"`
} else if (platform === "win32") {
command = `explorer "${folderPath}"`
} else {
command = `xdg-open "${folderPath}"`
}
exec(command, (error) => {
if (error) {
// Folder might not exist, try to create and open
exec(`mkdir -p "${folderPath}" && ${command}`)
}
})
},
[dataDir],
)
// Settings update handlers
const handleUpdateGlobal = useCallback(
async (key: GlobalStateAndSettingsKey, value: GlobalStateAndSettings[GlobalStateAndSettingsKey]) => {
// Update local state for immediate UI feedback
setGlobalStateLocal((prev) => ({ ...prev, [key]: value }))
// Persist to state manager
controller.stateManager.setGlobalState(key, value)
await controller.stateManager.flushPendingState()
},
[controller],
)
const handleUpdateWorkspace = useCallback(
async (key: LocalStateKey, value: LocalState[LocalStateKey]) => {
// Update local state for immediate UI feedback
setWorkspaceStateLocal((prev) => ({ ...prev, [key]: value }))
// Persist to state manager
controller.stateManager.setWorkspaceState(key, value)
await controller.stateManager.flushPendingState()
},
[controller],
)
return (
<ConfigView
dataDir={dataDir}
globalClineRulesToggles={globalClineRulesToggles}
globalHooks={globalHooks}
globalSkills={globalSkills}
globalState={globalStateLocal}
globalWorkflowToggles={globalWorkflowToggles}
hooksEnabled={hooksEnabled}
localAgentsRulesToggles={localAgentsRulesToggles}
localClineRulesToggles={localClineRulesToggles}
localCursorRulesToggles={localCursorRulesToggles}
localSkills={localSkills}
localWindsurfRulesToggles={localWindsurfRulesToggles}
localWorkflowToggles={localWorkflowToggles}
onOpenFolder={handleOpenFolder}
onToggleHook={handleToggleHook}
onToggleRule={handleToggleRule}
onToggleSkill={handleToggleSkill}
onToggleWorkflow={handleToggleWorkflow}
onUpdateGlobal={handleUpdateGlobal}
onUpdateWorkspace={handleUpdateWorkspace}
skillsEnabled={skillsEnabled}
workspaceHooks={workspaceHooksState}
workspaceState={workspaceStateLocal}
/>
)
}
-175
View File
@@ -1,175 +0,0 @@
/**
* DiffView component for displaying file diffs in Ink
* Shows unified diff output with colored lines for additions/deletions
*/
import { Box, Text } from "ink"
import React from "react"
interface DiffViewProps {
/** File path being displayed */
path: string
/** For newFileCreated: the full content of the new file */
content?: string
/** For editedExistingFile: the unified diff string */
diff?: string
/** Maximum lines to display before truncating */
maxLines?: number
}
interface DiffLine {
type: "add" | "remove" | "context" | "header"
lineNumber?: number
content: string
}
/**
* Parse a unified diff string into structured lines
*/
function parseDiff(diff: string): DiffLine[] {
const lines = diff.split("\n")
const result: DiffLine[] = []
let oldLine = 0
let newLine = 0
for (const line of lines) {
if (line.startsWith("@@")) {
// Parse hunk header like @@ -1,5 +1,7 @@
const match = line.match(/@@ -(\d+),?\d* \+(\d+),?\d* @@/)
if (match) {
oldLine = parseInt(match[1], 10)
newLine = parseInt(match[2], 10)
}
result.push({ type: "header", content: line })
} else if (line.startsWith("+") && !line.startsWith("+++")) {
result.push({ type: "add", lineNumber: newLine, content: line.slice(1) })
newLine++
} else if (line.startsWith("-") && !line.startsWith("---")) {
result.push({ type: "remove", lineNumber: oldLine, content: line.slice(1) })
oldLine++
} else if (line.startsWith(" ")) {
result.push({ type: "context", lineNumber: newLine, content: line.slice(1) })
oldLine++
newLine++
} else if (line.startsWith("---") || line.startsWith("+++")) {
// File headers - skip or show as header
result.push({ type: "header", content: line })
}
}
return result
}
/**
* Format line number with padding
*/
function formatLineNumber(num: number | undefined, width: number): string {
if (num === undefined) {
return " ".repeat(width)
}
return String(num).padStart(width, " ")
}
/**
* Renders a new file with all lines shown as additions
*/
const NewFileView: React.FC<{ path: string; content: string; maxLines: number }> = ({ path, content, maxLines }) => {
const lines = content.split("\n")
const displayLines = lines.slice(0, maxLines)
const lineNumWidth = String(lines.length).length
// Calculate max line length for padding
const maxLineLength = Math.max(...displayLines.map((l) => l.length), 40)
return (
<Box flexDirection="column">
<Text bold color="green">
+ {path} (new file)
</Text>
{displayLines.map((line, idx) => (
<Box key={idx}>
<Text dimColor>{formatLineNumber(idx + 1, lineNumWidth)} </Text>
<Text backgroundColor="rgb(117, 176, 111)" color="white">
+{line.padEnd(maxLineLength)}
</Text>
</Box>
))}
{lines.length > maxLines && <Text dimColor>... and {lines.length - maxLines} more lines</Text>}
</Box>
)
}
/**
* Renders a unified diff with colored additions and deletions
*/
const UnifiedDiffView: React.FC<{ path: string; diff: string; maxLines: number }> = ({ path, diff, maxLines }) => {
const diffLines = parseDiff(diff)
const displayLines = diffLines.slice(0, maxLines)
const maxLineNum = Math.max(...diffLines.filter((l) => l.lineNumber !== undefined).map((l) => l.lineNumber!), 0)
const lineNumWidth = String(maxLineNum).length || 3
// Calculate max line length for padding
const maxLineLength = Math.max(...diffLines.map((l) => l.content.length), 40)
return (
<Box flexDirection="column">
<Text bold color="blue">
~ {path} (modified)
</Text>
{displayLines.map((line, idx) => {
switch (line.type) {
case "header":
return (
<Text color="cyan" key={idx}>
{line.content}
</Text>
)
case "add":
return (
<Box key={idx}>
<Text dimColor>{formatLineNumber(line.lineNumber, lineNumWidth)} </Text>
<Text backgroundColor="rgb(117, 176, 111)" color="white">
+{line.content.padEnd(maxLineLength)}
</Text>
</Box>
)
case "remove":
return (
<Box key={idx}>
<Text dimColor>{formatLineNumber(line.lineNumber, lineNumWidth)} </Text>
<Text backgroundColor="rgb(246, 48, 73)" color="white">
-{line.content.padEnd(maxLineLength)}
</Text>
</Box>
)
case "context":
return (
<Box key={idx}>
<Text dimColor>{formatLineNumber(line.lineNumber, lineNumWidth)} </Text>
<Text> {line.content.padEnd(maxLineLength)}</Text>
</Box>
)
default:
return null
}
})}
{diffLines.length > maxLines && <Text dimColor>... and {diffLines.length - maxLines} more lines</Text>}
</Box>
)
}
/**
* DiffView component that renders either a new file or a unified diff
*/
export const DiffView: React.FC<DiffViewProps> = ({ path, content, diff, maxLines = 20 }) => {
// For new files, show all content as additions
if (content && !diff) {
return <NewFileView content={content} maxLines={maxLines} path={path} />
}
// For edited files, show the unified diff
if (diff) {
return <UnifiedDiffView diff={diff} maxLines={maxLines} path={path} />
}
// Fallback if neither content nor diff is provided
return <Text color="blue">{path} (no diff available)</Text>
}
-99
View File
@@ -1,99 +0,0 @@
/**
* File mention menu component for CLI
* Displays a list of matching files when user types @
*/
import { Box, Text } from "ink"
import React from "react"
import type { FileSearchResult } from "../utils/file-search"
interface FileMentionMenuProps {
results: FileSearchResult[]
selectedIndex: number
isLoading: boolean
query: string
}
/**
* Truncate path from the left if too long, keeping the filename visible
*/
function truncatePath(filePath: string, maxLength: number = 50): string {
if (filePath.length <= maxLength) {
return filePath
}
return "..." + filePath.slice(-(maxLength - 3))
}
export const FileMentionMenu: React.FC<FileMentionMenuProps> = ({ results, selectedIndex, isLoading, query }) => {
if (isLoading) {
return (
<Box flexDirection="column" marginBottom={1} paddingLeft={1} paddingRight={1}>
<Text color="gray">Searching files...</Text>
</Box>
)
}
if (results.length === 0) {
return (
<Box flexDirection="column" marginBottom={1} paddingLeft={1} paddingRight={1}>
<Text color="gray">{query ? `No files matching "${query}"` : "Type to search files..."}</Text>
</Box>
)
}
// Show max 8 items, centered around selected item
const maxVisible = 8
let startIndex = 0
let endIndex = results.length
if (results.length > maxVisible) {
// Center the selected item in the visible window
const halfWindow = Math.floor(maxVisible / 2)
startIndex = Math.max(0, selectedIndex - halfWindow)
endIndex = Math.min(results.length, startIndex + maxVisible)
// Adjust if we're near the end
if (endIndex - startIndex < maxVisible) {
startIndex = Math.max(0, endIndex - maxVisible)
}
}
const visibleResults = results.slice(startIndex, endIndex)
return (
<Box flexDirection="column" marginBottom={1} paddingLeft={1} paddingRight={1}>
{startIndex > 0 && (
<Text color="gray" dimColor>
{startIndex} more...
</Text>
)}
{visibleResults.map((result, idx) => {
const actualIndex = startIndex + idx
const isSelected = actualIndex === selectedIndex
const displayPath = truncatePath(result.path)
return (
<Box key={result.path}>
<Text backgroundColor={isSelected ? "blue" : undefined} color={isSelected ? "white" : undefined}>
{isSelected ? " " : " "}
{displayPath}
</Text>
</Box>
)
})}
{endIndex < results.length && (
<Text color="gray" dimColor>
{results.length - endIndex} more...
</Text>
)}
<Box>
<Text color="cyan" dimColor>
/ to select, Tab/Enter to insert
</Text>
</Box>
</Box>
)
}
-185
View File
@@ -1,185 +0,0 @@
/**
* Focus Chain / To-Do List component for CLI
* Displays a progress-tracked checklist of tasks
*/
import { isCompletedFocusChainItem, isFocusChainItem, parseFocusChainItem } from "@shared/focus-chain-utils"
import { Box, Text } from "ink"
import React, { useMemo } from "react"
interface TodoInfo {
currentTodo: { text: string; completed: boolean; index: number } | null
currentIndex: number
completedCount: number
totalCount: number
progressPercentage: number
}
interface TodoItem {
text: string
checked: boolean
}
interface FocusChainProps {
focusChainChecklist?: string | null
expanded?: boolean
}
/**
* Parse the focus chain checklist text into TodoInfo
*/
function parseCurrentTodoInfo(text: string): TodoInfo | null {
if (!text) {
return null
}
let completedCount = 0
let totalCount = 0
let firstIncompleteIndex = -1
let firstIncompleteText: string | null = null
const lines = text.split("\n")
for (const rawLine of lines) {
const line = rawLine.trim()
if (isFocusChainItem(line)) {
const isCompleted = isCompletedFocusChainItem(line)
if (isCompleted) {
completedCount++
} else if (firstIncompleteIndex === -1) {
firstIncompleteIndex = totalCount
// Extract text after "- [ ] "
firstIncompleteText = line.substring(5).trim()
}
totalCount++
}
}
if (totalCount === 0) {
return null
}
const currentTodo = firstIncompleteText ? { text: firstIncompleteText, completed: false, index: firstIncompleteIndex } : null
return {
currentTodo,
currentIndex: firstIncompleteIndex >= 0 ? firstIncompleteIndex + 1 : totalCount,
completedCount,
totalCount,
progressPercentage: (completedCount / totalCount) * 100,
}
}
/**
* Parse all todo items from the checklist
*/
function parseTodoItems(text: string): TodoItem[] {
const items: TodoItem[] = []
const lines = text.split("\n")
for (const rawLine of lines) {
const line = rawLine.trim()
const parsed = parseFocusChainItem(line)
if (parsed) {
items.push(parsed)
}
}
return items
}
/**
* Render progress bar
*/
const ProgressBar: React.FC<{ percentage: number; width?: number }> = ({ percentage, width = 20 }) => {
const filled = Math.round((percentage / 100) * width)
const empty = width - filled
const bar = "█".repeat(filled) + "░".repeat(empty)
return (
<Text>
<Text color="green">{bar}</Text>
<Text dimColor> {Math.round(percentage)}%</Text>
</Text>
)
}
/**
* Header view showing current task and progress
*/
const Header: React.FC<{
todoInfo: TodoInfo
}> = ({ todoInfo }) => {
const { currentTodo, currentIndex, totalCount, completedCount } = todoInfo
const isCompleted = completedCount === totalCount
const displayText = isCompleted ? "All tasks completed!" : currentTodo?.text || "To-Do list"
const truncatedText = displayText.length > 50 ? displayText.substring(0, 47) + "..." : displayText
return (
<Box flexDirection="row" gap={1}>
<Text color={isCompleted ? "green" : "cyan"}>
[{currentIndex}/{totalCount}]
</Text>
<Text color={isCompleted ? "green" : undefined}>{truncatedText}</Text>
</Box>
)
}
/**
* Expanded view showing all todo items
*/
const ExpandedList: React.FC<{
items: TodoItem[]
isCompleted: boolean
}> = ({ items, isCompleted }) => {
return (
<Box flexDirection="column" marginLeft={2} marginTop={1}>
{items.map((item, index) => (
<Box key={index}>
<Text color={item.checked ? "green" : "gray"}>{item.checked ? "✓" : "○"} </Text>
<Text color={item.checked ? "green" : undefined} dimColor={item.checked}>
{item.text}
</Text>
</Box>
))}
{isCompleted && (
<Box marginTop={1}>
<Text dimColor italic>
New steps will be generated if you continue the task
</Text>
</Box>
)}
</Box>
)
}
/**
* Main FocusChain component for CLI
* Shows a progress summary of the current to-do list
* Use expanded={true} to show all items (e.g., in verbose mode)
*/
export const FocusChain: React.FC<FocusChainProps> = ({ focusChainChecklist, expanded = false }) => {
const todoInfo = useMemo(
() => (focusChainChecklist ? parseCurrentTodoInfo(focusChainChecklist) : null),
[focusChainChecklist],
)
const todoItems = useMemo(() => (focusChainChecklist ? parseTodoItems(focusChainChecklist) : []), [focusChainChecklist])
// No content to display
if (!todoInfo) {
return null
}
const isCompleted = todoInfo.completedCount === todoInfo.totalCount
return (
<Box borderColor={isCompleted ? "green" : "gray"} borderStyle="round" flexDirection="column" paddingX={1}>
<Header todoInfo={todoInfo} />
<ProgressBar percentage={todoInfo.progressPercentage} />
{expanded && <ExpandedList isCompleted={isCompleted} items={todoItems} />}
</Box>
)
}
-218
View File
@@ -1,218 +0,0 @@
import { Text } from "ink"
import { render } from "ink-testing-library"
import React from "react"
import { beforeEach, describe, expect, it, vi } from "vitest"
// Create stable mock reference using vi.hoisted
const { mockShowTaskWithId } = vi.hoisted(() => ({
mockShowTaskWithId: vi.fn().mockResolvedValue(undefined),
}))
vi.mock("./TaskView", () => ({
TaskView: ({ taskId, verbose }: any) =>
React.createElement(Text, null, `TaskView: ${taskId || "no-id"} verbose=${String(verbose)}`),
}))
// Mock the controller dependencies - must be before importing HistoryView
vi.mock("@/core/controller", () => ({
Controller: vi.fn(),
}))
vi.mock("@/core/controller/task/showTaskWithId", () => ({
showTaskWithId: mockShowTaskWithId,
}))
vi.mock("@/shared/proto/cline/common", () => ({
StringRequest: {
create: (data: any) => data,
},
}))
// Import after mocks are set up
import { HistoryView } from "./HistoryView"
describe("HistoryView", () => {
const mockController = {
dispose: vi.fn(),
stateManager: { flushPendingState: vi.fn() },
} as any
const mockItems = [
{ id: "task-1", ts: Date.now() - 3600000, task: "First task" },
{ id: "task-2", ts: Date.now() - 7200000, task: "Second task" },
{ id: "task-3", ts: Date.now() - 10800000, task: "Third task" },
]
beforeEach(() => {
vi.clearAllMocks()
})
describe("rendering", () => {
it("should render the history header", () => {
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} />)
expect(lastFrame()).toContain("Task History")
})
it("should show total count in header", () => {
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} />)
expect(lastFrame()).toContain("3 total")
})
it("should render task items", () => {
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} />)
expect(lastFrame()).toContain("First task")
expect(lastFrame()).toContain("Second task")
expect(lastFrame()).toContain("Third task")
})
it("should show task IDs", () => {
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} />)
expect(lastFrame()).toContain("task-1")
expect(lastFrame()).toContain("task-2")
})
it("should show empty message when no items", () => {
const { lastFrame } = render(<HistoryView controller={mockController} items={[]} />)
expect(lastFrame()).toContain("No task history available")
})
it("should show navigation help", () => {
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} />)
expect(lastFrame()).toContain("↑↓")
expect(lastFrame()).toContain("Enter")
})
})
describe("task details", () => {
it("should display task cost when available", () => {
const itemsWithCost = [{ id: "task-1", ts: Date.now(), task: "Task", totalCost: 0.0025 }]
const { lastFrame } = render(<HistoryView controller={mockController} items={itemsWithCost} />)
expect(lastFrame()).toContain("Cost:")
expect(lastFrame()).toContain("0.0025")
})
it("should display model ID when available", () => {
const itemsWithModel = [{ id: "task-1", ts: Date.now(), task: "Task", modelId: "claude-sonnet-4-20250514" }]
const { lastFrame } = render(<HistoryView controller={mockController} items={itemsWithModel} />)
expect(lastFrame()).toContain("Model:")
expect(lastFrame()).toContain("claude-sonnet-4-20250514")
})
it("should truncate long task descriptions", () => {
const longTask = "x".repeat(100)
const itemsWithLongTask = [{ id: "task-1", ts: Date.now(), task: longTask }]
const { lastFrame } = render(<HistoryView controller={mockController} items={itemsWithLongTask} />)
expect(lastFrame()).toContain("...")
})
it("should handle missing task text", () => {
const itemsWithoutTask = [{ id: "task-1", ts: Date.now() }]
const { lastFrame } = render(<HistoryView controller={mockController} items={itemsWithoutTask} />)
expect(lastFrame()).toContain("Unknown task")
})
})
describe("selection indicator", () => {
it("should show selection indicator on first item by default", () => {
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} />)
expect(lastFrame()).toContain(">")
})
})
describe("keyboard navigation", () => {
it("should navigate down with arrow key", () => {
const { lastFrame, stdin } = render(<HistoryView controller={mockController} items={mockItems} />)
// Press down arrow
stdin.write("\x1B[B")
// Should still render properly
expect(lastFrame()).toContain("Task History")
})
it("should navigate up with arrow key", () => {
const { lastFrame, stdin } = render(<HistoryView controller={mockController} items={mockItems} />)
// Press down then up
stdin.write("\x1B[B")
stdin.write("\x1B[A")
expect(lastFrame()).toContain("Task History")
})
it("should not go below last item", () => {
const { lastFrame, stdin } = render(<HistoryView controller={mockController} items={mockItems} />)
// Press down many times
for (let i = 0; i < 10; i++) {
stdin.write("\x1B[B")
}
expect(lastFrame()).toContain("Task History")
})
it("should not go above first item", () => {
const { lastFrame, stdin } = render(<HistoryView controller={mockController} items={mockItems} />)
// Press up when already at first
stdin.write("\x1B[A")
expect(lastFrame()).toContain("Task History")
})
})
describe("pagination", () => {
it("should show pagination info when provided", () => {
const pagination = {
page: 2,
totalPages: 5,
totalCount: 50,
limit: 10,
}
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} pagination={pagination} />)
expect(lastFrame()).toContain("Page 2 of 5")
})
it("should show correct total count from pagination", () => {
const pagination = {
page: 1,
totalPages: 3,
totalCount: 25,
limit: 10,
}
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} pagination={pagination} />)
expect(lastFrame()).toContain("25 total")
})
it("should not show page info for single page", () => {
const pagination = {
page: 1,
totalPages: 1,
totalCount: 3,
limit: 10,
}
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} pagination={pagination} />)
expect(lastFrame()).not.toContain("Page 1 of 1")
})
})
describe("scrolling", () => {
it("should show scroll indicators for long lists", () => {
const manyItems = Array.from({ length: 20 }, (_, i) => ({
id: `task-${i}`,
ts: Date.now() - i * 3600000,
task: `Task ${i}`,
}))
const { lastFrame, stdin } = render(<HistoryView controller={mockController} items={manyItems} visibleCount={5} />)
// Navigate down a bit
for (let i = 0; i < 5; i++) {
stdin.write("\x1B[B")
}
// Should show "more below" indicator
expect(lastFrame()).toContain("more")
})
})
})
-201
View File
@@ -1,201 +0,0 @@
/**
* History view component
* Displays task history with keyboard navigation
*/
import { Box, Text, useInput, useStdout } from "ink"
import React, { useCallback, useState } from "react"
import { Controller } from "@/core/controller"
import { showTaskWithId } from "@/core/controller/task/showTaskWithId"
import { StringRequest } from "@/shared/proto/cline/common"
interface TaskHistoryItem {
id: string
ts: number
task?: string
totalCost?: number
modelId?: string
}
interface HistoryPagination {
page: number
totalPages: number
totalCount: number
limit: number
}
interface HistoryViewProps {
items: TaskHistoryItem[]
visibleCount?: number
controller: Controller
onSelectTask?: (taskId: string) => void
pagination?: HistoryPagination
onPageChange?: (page: number) => void
/** If provided, all items for internal pagination management */
allItems?: TaskHistoryItem[]
}
/**
* Format separator
*/
function formatSeparator(char: string = "─", width: number = 80): string {
return char.repeat(Math.max(width, 10))
}
export const HistoryView: React.FC<HistoryViewProps> = ({
items,
visibleCount,
controller,
onSelectTask,
pagination,
onPageChange,
allItems,
}) => {
const [selectedIndex, setSelectedIndex] = useState(0)
const [internalPage, setInternalPage] = useState(pagination?.page ?? 1)
const { stdout } = useStdout()
// Calculate visible count based on terminal height to prevent overflow
// Each item takes ~5 lines (date, id, task text, cost/model, margin)
// Reserve lines for header (title, hint, pagination, separator) and footer (separator)
const terminalRows = stdout?.rows ?? 24
const headerLines = (pagination?.totalPages ?? 1) > 1 ? 5 : 4
const footerLines = 1
const availableRows = terminalRows - headerLines - footerLines
const itemHeight = 5
const dynamicVisibleCount = Math.max(1, Math.floor(availableRows / itemHeight))
const effectiveVisibleCount = visibleCount ?? dynamicVisibleCount
const onSelect = useCallback(
(item: TaskHistoryItem) => {
// Load the task via controller, then notify parent to switch views
showTaskWithId(controller, StringRequest.create({ value: item.id }))
.then(() => {
onSelectTask?.(item.id)
})
.catch((error) => console.error("Error showing task:", error))
},
[controller, onSelectTask],
)
// Use internal pagination if allItems is provided, otherwise use external
const useInternalPagination = !!allItems
const limit = pagination?.limit ?? 10
const totalCount = allItems?.length ?? pagination?.totalCount ?? items.length
const totalPages = useInternalPagination ? Math.ceil(totalCount / limit) : (pagination?.totalPages ?? 1)
const currentPage = useInternalPagination ? internalPage : (pagination?.page ?? 1)
const hasPrevPage = currentPage > 1
const hasNextPage = currentPage < totalPages
// Get current page items
const pageItems = useInternalPagination ? (allItems ?? []).slice((currentPage - 1) * limit, currentPage * limit) : items
const handlePageChange = useCallback(
(newPage: number) => {
if (useInternalPagination) {
setInternalPage(newPage)
setSelectedIndex(0)
} else if (onPageChange) {
onPageChange(newPage)
setSelectedIndex(0)
}
},
[useInternalPagination, onPageChange],
)
useInput((input, key) => {
if (key.upArrow) {
setSelectedIndex((prev) => Math.max(0, prev - 1))
} else if (key.downArrow) {
setSelectedIndex((prev) => Math.min(pageItems.length - 1, prev + 1))
} else if (key.return && pageItems[selectedIndex]) {
onSelect(pageItems[selectedIndex])
} else if (key.leftArrow && hasPrevPage) {
handlePageChange(currentPage - 1)
} else if (key.rightArrow && hasNextPage) {
handlePageChange(currentPage + 1)
} else if (input === "n" && hasNextPage) {
handlePageChange(currentPage + 1)
} else if (input === "p" && hasPrevPage) {
handlePageChange(currentPage - 1)
}
})
// Calculate visible window around selected item
const halfVisible = Math.floor(effectiveVisibleCount / 2)
let startIndex = Math.max(0, selectedIndex - halfVisible)
const endIndex = Math.min(pageItems.length, startIndex + effectiveVisibleCount)
// Adjust start if we're near the end
if (endIndex - startIndex < effectiveVisibleCount) {
startIndex = Math.max(0, endIndex - effectiveVisibleCount)
}
const visibleTasks = pageItems.slice(startIndex, endIndex)
const showUpIndicator = startIndex > 0
const showDownIndicator = endIndex < pageItems.length
return (
<Box flexDirection="column">
<Text bold color="white">
{"📜 Task History (" + totalCount + " total)"}
</Text>
<Text dimColor>Use to navigate, Enter to select</Text>
{totalPages > 1 && (
<Box>
<Text dimColor>
Page {currentPage} of {totalPages}{" "}
</Text>
{hasPrevPage ? <Text color="blue">[ prev] </Text> : <Text dimColor>[ prev] </Text>}
{hasNextPage ? <Text color="blue">[next ]</Text> : <Text dimColor>[next ]</Text>}
</Box>
)}
<Text>{formatSeparator()}</Text>
{pageItems.length === 0 ? (
<Text>No task history available.</Text>
) : (
<Box flexDirection="column">
{showUpIndicator && <Text dimColor>{" ↑ " + startIndex + " more above"}</Text>}
{visibleTasks.map((task, index) => {
const actualIndex = startIndex + index
const isSelected = actualIndex === selectedIndex
const date = new Date(task.ts).toLocaleString()
const taskText = task.task?.substring(0, 60) || "Unknown task"
const truncated = (task.task?.length || 0) > 60 ? "..." : ""
return (
<Box flexDirection="column" key={`${task.id}-${actualIndex}`} marginBottom={1}>
<Box>
<Text color={isSelected ? "green" : undefined}>{isSelected ? "> " : " "}</Text>
<Text dimColor>{date}</Text>
</Box>
<Box marginLeft={4}>
<Text color="cyan">{task.id}</Text>
</Box>
<Box marginLeft={4}>
<Text bold={isSelected} color={isSelected ? "white" : undefined}>
{taskText}
{truncated}
</Text>
</Box>
{typeof task.totalCost === "number" && (
<Box marginLeft={4}>
<Text dimColor>Cost: ${task.totalCost ? task.totalCost.toFixed(4) : "0"}</Text>
</Box>
)}
{task.modelId && (
<Box marginLeft={4}>
<Text dimColor>Model: {task.modelId}</Text>
</Box>
)}
</Box>
)
})}
{showDownIndicator && <Text dimColor>{" ↓ " + (items.length - endIndex) + " more below"}</Text>}
</Box>
)}
<Text>{formatSeparator()}</Text>
</Box>
)
}
-34
View File
@@ -1,34 +0,0 @@
/**
* Message list component
* Renders all messages from the task
*/
import { Box } from "ink"
import React from "react"
import { useTaskState } from "../context/TaskContext"
import { MessageRow } from "./MessageRow"
interface MessageListProps {
verbose?: boolean
}
export const MessageList: React.FC<MessageListProps> = ({ verbose = false }) => {
const state = useTaskState()
const messages = state.clineMessages || []
// Filter out some noisy messages when not verbose
const messagesToShow = verbose
? messages
: messages.filter((m) => {
// Show everything in non-verbose mode for now
return true
})
return (
<Box flexDirection="column">
{messagesToShow.map((message, idx) => (
<MessageRow key={`${message.ts}-${idx}`} message={message} verbose={verbose} />
))}
</Box>
)
}
-378
View File
@@ -1,378 +0,0 @@
/**
* Individual message row component
* Renders a single ClineMessage based on its type
*/
import type { ClineAsk, ClineMessage, ClineSay } from "@shared/ExtensionMessage"
import { Box, Text } from "ink"
import React from "react"
import { jsonParseSafe } from "../utils/parser"
import { DiffView } from "./DiffView"
interface MessageRowProps {
message: ClineMessage
verbose?: boolean
}
/**
* Get emoji icon for message type
*/
export function getCliMessagePrefixIcon(message: ClineMessage): string {
if (message.type === "ask") {
switch (message.ask) {
case "followup":
return "❓"
case "command":
case "command_output":
return "⚙️"
case "tool":
return "🔧"
case "completion_result":
return "✅"
case "api_req_failed":
return "❌"
case "resume_task":
case "resume_completed_task":
return "▶️"
case "browser_action_launch":
return "🌐"
case "use_mcp_server":
return "🔌"
case "plan_mode_respond":
return "📋"
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 " "
}
}
}
/**
* Format timestamp
*/
function formatTimestamp(ts: number): string {
const date = new Date(ts)
return date.toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})
}
/**
* Render ask message based on type
*/
const AskMessageContent: React.FC<{ message: ClineMessage; verbose?: boolean }> = ({ message, verbose }) => {
const ask = message.ask as ClineAsk
const text = message.text || ""
switch (ask) {
case "followup":
case "plan_mode_respond": {
const parts = jsonParseSafe(text, {
response: undefined as string | undefined,
question: undefined as string | undefined,
})
if (parts.question) {
return (
<Text>
<Text color="cyan">Question:</Text> {parts.question}
</Text>
)
}
if (parts.response) {
return (
<Text>
<Text color="cyan">[{ask}]</Text> {parts.response}
</Text>
)
}
return null
}
case "command":
return (
<Text>
<Text color="magenta">Execute command?</Text> <Text dimColor>{text}</Text>
</Text>
)
case "tool":
return (
<Text>
<Text color="blue">Use tool?</Text> {text}
</Text>
)
case "completion_result":
return (
<Text>
<Text color="green">Task completed</Text> {text ? `- ${text}` : ""}
</Text>
)
case "api_req_failed":
return (
<Text>
<Text color="red">API request failed</Text> {text}
</Text>
)
case "resume_task":
case "resume_completed_task":
return (
<Text>
<Text color="cyan">Resume task?</Text> {text}
</Text>
)
case "browser_action_launch":
return (
<Text>
<Text color="cyan">Launch browser?</Text> {text}
</Text>
)
case "use_mcp_server":
return (
<Text>
<Text color="cyan">Use MCP server?</Text> {text}
</Text>
)
default:
return verbose ? (
<Text>
<Text color="gray">[ASK:{ask}]</Text> {text}
</Text>
) : null
}
}
/**
* Render say message based on type
*/
const SayMessageContent: React.FC<{ message: ClineMessage; verbose?: boolean }> = ({ message, verbose }) => {
const say = message.say as ClineSay
const text = message.text || ""
switch (say) {
case "task":
return (
<Text bold>
<Text color="white">Task:</Text> {text}
</Text>
)
case "text":
return <Text>{text}</Text>
case "reasoning":
return (
<Text color="yellow">
<Text italic>{text}</Text>
</Text>
)
case "error":
return (
<Text color="red">
<Text bold>Error:</Text> {text}
</Text>
)
case "completion_result":
return (
<Text color="green">
<Text bold>Completed:</Text> {text}
</Text>
)
case "user_feedback":
return (
<Text>
<Text color="green">User:</Text> {text}
</Text>
)
case "command":
return (
<Text>
<Text color="magenta">Command:</Text> <Text dimColor>{text}</Text>
</Text>
)
case "command_output": {
const lines = text.split("\n")
const displayLines = lines.slice(0, 10)
return (
<Box flexDirection="column">
<Text dimColor>Output:</Text>
{displayLines.map((line, idx) => (
<Text dimColor key={idx}>
{line}
</Text>
))}
{lines.length > 10 && <Text dimColor> ... and {lines.length - 10} more lines</Text>}
</Box>
)
}
case "tool": {
const { tool, content, path } = jsonParseSafe(text, {
tool: undefined as string | undefined,
content: undefined as string | undefined,
path: undefined as string | undefined,
diff: undefined as string | undefined,
})
if (path) {
if (tool === "newFileCreated") {
return <DiffView content={content} path={path} />
}
// if (tool === "editedExistingFile") {
// return <DiffView diff={diff} path={path} />
// }
}
return (
<Text>
<Text color="blue">{text}</Text>
</Text>
)
}
case "api_req_started": {
const { cost, tokensOut, cacheWrites, cacheReads, tokensIn } = jsonParseSafe(text, {
cost: 0 as number,
tokensIn: 0 as number,
tokensOut: 0 as number,
cacheWrites: 0 as number,
cacheReads: 0 as number,
})
return verbose ? (
<Text dimColor>{text}</Text>
) : (
<Text dimColor>
Cost: {cost} | Tokens In: {tokensIn} | Tokens Out: {tokensOut} | Cache Writes: {cacheWrites} | Cache Reads:{" "}
{cacheReads}
</Text>
)
}
case "api_req_finished":
return null
case "checkpoint_created":
return <Text dimColor>Checkpoint created: {message.lastCheckpointHash}</Text>
case "info":
return <Text color="cyan">{text}</Text>
case "browser_action":
case "browser_action_launch":
return (
<Text>
<Text color="cyan">Browser:</Text> {text}
</Text>
)
case "browser_action_result":
return <Text dimColor>Browser result {text ? `- ${text.substring(0, 100)}...` : ""}</Text>
case "mcp_server_request_started":
return <Text color="cyan">MCP request started {text}</Text>
case "mcp_server_response":
return <Text color="cyan">MCP response {text ? text.substring(0, 200) : ""}</Text>
default:
return verbose ? (
<Text dimColor>
[SAY:{say}] {text}
</Text>
) : null
}
}
export const MessageRow: React.FC<MessageRowProps> = ({ message, verbose = false }) => {
const icon = getCliMessagePrefixIcon(message)
const timestamp = formatTimestamp(message.ts)
// Don't render silent messages
if (message.say === "api_req_finished") {
return null
}
if (message.say === "text" && message.text?.trim() === "") {
return null
}
const content =
message.type === "ask" ? (
<AskMessageContent message={message} verbose={verbose} />
) : (
<SayMessageContent message={message} verbose={verbose} />
)
// command_output and tool return a Box, which can't be nested inside Text
if (message.say === "command_output" || message.say === "tool") {
return (
<Box flexDirection="column">
<Box>
<Text dimColor>{timestamp} </Text>
<Text>{icon} </Text>
</Box>
{content}
</Box>
)
}
return (
<Box flexDirection="column">
<Box>
<Text dimColor>{timestamp} </Text>
<Text>{icon} </Text>
{content}
</Box>
</Box>
)
}
-26
View File
@@ -1,26 +0,0 @@
/**
* Loading spinner component using ink-spinner
*/
import { Box, Text } from "ink"
import Spinner from "ink-spinner"
import React from "react"
interface LoadingSpinnerProps {
message?: string
}
const LOADING_TEXT_IDEAS = ["Thinking", "Loading", "Processing", "Working", "Calculating", "Analyzing", "Exploring"]
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
message = LOADING_TEXT_IDEAS[Math.floor(Math.random() * LOADING_TEXT_IDEAS.length)],
}) => {
return (
<Box>
<Text color="cyan">
<Spinner type="dots" />
</Text>
<Text color="cyan"> {message}...</Text>
</Box>
)
}
-184
View File
@@ -1,184 +0,0 @@
/**
* Task view component
* Main view for running a task - displays messages and handles user input
*/
import { exit } from "node:process"
import { CheckpointRestoreRequest } from "@shared/proto/cline/checkpoints"
import { Box, Text, useInput } from "ink"
import React, { useCallback, useEffect, useMemo, useState } from "react"
import { checkpointRestore } from "@/core/controller/checkpoints/checkpointRestore"
import { StateManager } from "@/core/storage/StateManager"
import { useTaskContext, useTaskState } from "../context/TaskContext"
import { useCompletionSignals, useIsSpinnerActive } from "../hooks/useStateSubscriber"
import { AskPrompt } from "./AskPrompt"
import { CheckpointMenu, RestoreType } from "./CheckpointMenu"
import { FocusChain } from "./FocusChain"
import { MessageList } from "./MessageList"
import { LoadingSpinner } from "./Spinner"
interface TaskViewProps {
taskId?: string
verbose?: boolean
onComplete?: () => void
onError?: () => void
}
/**
* Format separator line
*/
function formatSeparator(char: string = "═", width: number = 60): string {
return char.repeat(Math.max(width, 10))
}
export const TaskView: React.FC<TaskViewProps> = ({ taskId: _taskId, verbose = false, onComplete, onError }) => {
const state = useTaskState()
const { isTaskComplete, getCompletionMessage } = useCompletionSignals()
const isSpinnerActive = useIsSpinnerActive()
const { setIsComplete, lastError, controller } = useTaskContext()
const [showCheckpointMenu, setShowCheckpointMenu] = useState(false)
const [restoreStatus, setRestoreStatus] = useState<"idle" | "restoring" | "success" | "error">("idle")
const [restoreMessage, setRestoreMessage] = useState<string | null>(null)
const yolo = useMemo(() => StateManager.get().getGlobalSettingsKey("yoloModeToggled"), [])
// Handle task completion
useEffect(() => {
if (isTaskComplete()) {
setIsComplete(true)
// Check if it's an error
const completionMsg = getCompletionMessage()
if (completionMsg?.say === "error" || completionMsg?.ask === "api_req_failed") {
onError?.()
} else {
onComplete?.()
}
if (yolo) {
exit()
}
}
}, [isTaskComplete, setIsComplete, onComplete, onError, getCompletionMessage])
// Handle checkpoint restore
const handleCheckpointRestore = useCallback(
async (messageTs: number, restoreType: RestoreType) => {
setShowCheckpointMenu(false)
setRestoreStatus("restoring")
setRestoreMessage(`Restoring checkpoint (${restoreType})...`)
try {
await checkpointRestore(
controller,
CheckpointRestoreRequest.create({
number: messageTs,
restoreType: restoreType,
}),
)
setRestoreStatus("success")
setRestoreMessage("Checkpoint restored successfully")
// Clear success message after a delay
setTimeout(() => {
setRestoreStatus("idle")
setRestoreMessage(null)
}, 3000)
} catch (error) {
setRestoreStatus("error")
setRestoreMessage(`Failed to restore: ${error instanceof Error ? error.message : String(error)}`)
// Clear error message after a delay
setTimeout(() => {
setRestoreStatus("idle")
setRestoreMessage(null)
}, 5000)
}
},
[controller],
)
// Handle Ctrl+R to open checkpoint menu
useInput(
(input, key) => {
// Ctrl+R to open checkpoint menu
if (key.ctrl && input === "r") {
setShowCheckpointMenu(true)
return
}
},
{ isActive: !showCheckpointMenu },
)
return (
<Box flexDirection="column">
{/* Task header */}
{state.currentTaskItem && (
<Box flexDirection="column" marginBottom={1}>
<Text>{formatSeparator("═")}</Text>
<Text bold color="white">
📋 Task: {state.currentTaskItem.id}
</Text>
{state.currentTaskItem.task && (
<Text dimColor>
{state.currentTaskItem.task.substring(0, 80)}
{state.currentTaskItem.task.length > 80 ? "..." : ""}
</Text>
)}
<Box>
<Text>{formatSeparator("═")}</Text>
</Box>
<Text color="gray" dimColor>
(Ctrl+R to restore checkpoint)
</Text>
</Box>
)}
{/* Error message if any */}
{lastError && (
<Box flexDirection="column" marginBottom={1}>
<Text bold color="red">
Error: {lastError}
</Text>
</Box>
)}
{/* Restore status message */}
{restoreMessage && (
<Box flexDirection="column" marginBottom={1}>
<Text bold color={restoreStatus === "error" ? "red" : restoreStatus === "success" ? "green" : "yellow"}>
{restoreStatus === "restoring" ? "⏳ " : restoreStatus === "success" ? "✓ " : "✗ "}
{restoreMessage}
</Text>
</Box>
)}
{/* Checkpoint menu */}
{showCheckpointMenu && (
<CheckpointMenu
messages={state.clineMessages || []}
onCancel={() => setShowCheckpointMenu(false)}
onSelect={handleCheckpointRestore}
/>
)}
{/* Focus Chain / To-Do List */}
{state.currentFocusChainChecklist && (
<Box marginBottom={1}>
<FocusChain focusChainChecklist={state.currentFocusChainChecklist} />
</Box>
)}
{/* Messages list */}
<MessageList verbose={verbose} />
{/* Loading spinner */}
{isSpinnerActive && (
<Box marginTop={1}>
<LoadingSpinner />
</Box>
)}
{/* User input prompt */}
{!yolo && <AskPrompt />}
</Box>
)
}
-315
View File
@@ -1,315 +0,0 @@
/**
* Welcome view component
* Shows an interactive prompt when user starts cline without a command
* Supports file mentions with @
*/
import type { Mode } from "@shared/storage/types"
import { Box, Text, useInput } from "ink"
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { StateManager } from "@/core/storage/StateManager"
import {
checkAndWarnRipgrepMissing,
extractMentionQuery,
type FileSearchResult,
getRipgrepInstallInstructions,
insertMention,
searchWorkspaceFiles,
} from "../utils/file-search"
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 [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"
})
// Get model ID based on current mode
const modelId = useMemo(() => {
const stateManager = StateManager.get()
const modelKey = mode === "act" ? "actModeApiModelId" : "planModeApiModelId"
return (stateManager.getGlobalSettingsKey(modelKey) as string) || "claude-sonnet-4-20250514"
}, [mode])
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) => {
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)
}
})
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">
{/* biome-ignore lint/suspicious/noArrayIndexKey: static array that never changes */}
{CLINE_LOGO.map((line, idx) => (
<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 color="gray"></Text>
</Box>
{/* Model ID and Mode toggle row */}
<Box justifyContent="space-between" width="100%">
{/* Model ID on left */}
<Text color="gray" dimColor>
{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" dimColor>
(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" dimColor>
Enter to submit · @ to mention files ·{" "}
</Text>
<Text bold={escPressedOnce} color={escPressedOnce ? "white" : "gray"} dimColor={!escPressedOnce}>
{escPressedOnce ? "Press Esc again to exit" : "Esc to exit"}
</Text>
</Box>
</Box>
)
}
-124
View File
@@ -1,124 +0,0 @@
/**
* React Context for task state management in CLI
* Provides access to ExtensionState and task controller
*/
import { registerPartialMessageCallback } from "@core/controller/ui/subscribeToPartialMessage"
import type { ClineMessage, ExtensionState } from "@shared/ExtensionMessage"
import { convertProtoToClineMessage } from "@shared/proto-conversions/cline-message"
import React, { createContext, ReactNode, useContext, useEffect, useRef, useState } from "react"
interface TaskContextType {
state: Partial<ExtensionState>
controller: any
isComplete: boolean
setIsComplete: (complete: boolean) => void
lastError: string | null
setLastError: (error: string | null) => void
}
const TaskContext = createContext<TaskContextType | undefined>(undefined)
interface TaskContextProviderProps {
controller: any
children: ReactNode
}
export const TaskContextProvider: React.FC<TaskContextProviderProps> = ({ controller, children }) => {
const [state, setState] = useState<Partial<ExtensionState>>(
() =>
({
clineMessages: [],
currentTaskItem: null,
}) as unknown as Partial<ExtensionState>,
)
const [isComplete, setIsComplete] = useState(false)
const [lastError, setLastError] = useState<string | null>(null)
// Use ref to track latest state for partial message callback
const stateRef = useRef(state)
stateRef.current = state
// Subscribe to controller state updates
useEffect(() => {
const originalPostState = controller.postStateToWebview.bind(controller)
const handleStateUpdate = async () => {
try {
const newState = await controller.getStateToPostToWebview()
setState(newState)
} catch (error) {
setLastError(error instanceof Error ? error.message : String(error))
}
}
// Override postStateToWebview to update React state
controller.postStateToWebview = async () => {
await originalPostState()
await handleStateUpdate()
}
// Subscribe to partial message events (for streaming updates)
const unsubscribePartial = registerPartialMessageCallback((protoMessage) => {
const updatedMessage = convertProtoToClineMessage(protoMessage) as ClineMessage
setState((prevState) => {
const messages = prevState.clineMessages || []
// Find and update the message by timestamp
const index = messages.findIndex((m) => m.ts === updatedMessage.ts)
if (index >= 0) {
const newMessages = [...messages]
newMessages[index] = updatedMessage
return { ...prevState, clineMessages: newMessages }
}
return prevState
})
})
// Get initial state
handleStateUpdate()
// Cleanup
return () => {
controller.postStateToWebview = originalPostState
unsubscribePartial()
}
}, [controller])
const value: TaskContextType = {
state,
controller,
isComplete,
setIsComplete,
lastError,
setLastError,
}
return <TaskContext.Provider value={value}>{children}</TaskContext.Provider>
}
/**
* Hook to access task context
*/
export const useTaskContext = (): TaskContextType => {
const context = useContext(TaskContext)
if (!context) {
throw new Error("useTaskContext must be used within TaskContextProvider")
}
return context
}
/**
* Hook to access task state only
*/
export const useTaskState = (): Partial<ExtensionState> => {
const { state } = useTaskContext()
return state
}
/**
* Hook to access controller
*/
export const useTaskController = () => {
const { controller } = useTaskContext()
return controller
}
@@ -1,91 +0,0 @@
/**
* CLI-specific CommentReviewController implementation
* Handles code review comments in CLI mode
*/
import { CommentReviewController, type OnReplyCallback, type ReviewComment } from "@/integrations/editor/CommentReviewController"
import { print, style } from "../utils/display"
export class CliCommentReviewController extends CommentReviewController {
private comments: Map<string, string[]> = new Map()
private streamingComment: { filePath: string; startLine: number; endLine: number; content: string } | null = null
setOnReplyCallback(_callback: OnReplyCallback): void {
// No-op - CLI doesn't support interactive replies
}
async ensureCommentsViewDisabled(): Promise<void> {
// No-op - no comments view in CLI
}
addReviewComment(comment: ReviewComment): void {
const key = `${comment.filePath}:${comment.startLine}:${comment.endLine}`
const existing = this.comments.get(key) || []
existing.push(comment.comment)
this.comments.set(key, existing)
print(style.info(`Comment on ${comment.filePath}:${comment.startLine + 1}`))
print(style.dim(` ${comment.comment}`))
}
startStreamingComment(
filePath: string,
startLine: number,
endLine: number,
_relativePath?: string,
_fileContent?: string,
_revealComment?: boolean,
): void {
this.streamingComment = { filePath, startLine, endLine, content: "" }
print(style.info(`Comment on ${filePath}:${startLine + 1}`))
}
appendToStreamingComment(chunk: string): void {
if (this.streamingComment) {
this.streamingComment.content += chunk
process.stdout.write(chunk)
}
}
endStreamingComment(): void {
if (this.streamingComment) {
const key = `${this.streamingComment.filePath}:${this.streamingComment.startLine}:${this.streamingComment.endLine}`
const existing = this.comments.get(key) || []
existing.push(this.streamingComment.content)
this.comments.set(key, existing)
print("") // newline after streaming
this.streamingComment = null
}
}
addReviewComments(comments: ReviewComment[]): void {
for (const comment of comments) {
this.addReviewComment(comment)
}
}
clearAllComments(): void {
this.comments.clear()
}
clearCommentsForFile(filePath: string): void {
for (const key of this.comments.keys()) {
if (key.startsWith(filePath)) {
this.comments.delete(key)
}
}
}
getThreadCount(): number {
return this.comments.size
}
async closeDiffViews(): Promise<void> {
// No-op - no diff views in CLI
}
dispose(): void {
this.comments.clear()
this.streamingComment = null
}
}
@@ -1,27 +0,0 @@
/**
* CLI-specific WebviewProvider implementation
* Instead of rendering to a webview, this outputs to the terminal
*/
import type * as vscode from "vscode"
import { WebviewProvider } from "@/core/webview"
export class CliWebviewProvider extends WebviewProvider {
constructor(context: vscode.ExtensionContext) {
super(context)
}
override getWebviewUrl(path: string): string {
// CLI doesn't have webview URLs
return `file://${path}`
}
override getCspSource(): string {
return "'self'"
}
override isVisible(): boolean {
// CLI is always "visible"
return true
}
}
-273
View File
@@ -1,273 +0,0 @@
/**
* CLI-specific Host Bridge implementations
* These provide stub implementations for the host bridge interfaces that work in CLI mode
*/
import type {
DiffServiceClientInterface,
EnvServiceClientInterface,
WindowServiceClientInterface,
WorkspaceServiceClientInterface,
} from "@generated/hosts/host-bridge-client-types"
import type { HostBridgeClientProvider, StreamingCallbacks } from "@hosts/host-provider-types"
import * as proto from "@shared/proto/index"
import { printError, printInfo, printWarning } from "../utils/display"
/**
* CLI implementation of DiffService - handles diff operations for terminal
*
* In CLI mode, actual file editing is handled by FileEditProvider (which extends DiffViewProvider).
* This service client handles the host bridge interface for UI-related diff operations.
* Most operations are no-ops since the CLI doesn't have a visual diff editor.
*/
export class CliDiffServiceClient implements DiffServiceClientInterface {
async openDiff(_request: proto.host.OpenDiffRequest): Promise<proto.host.OpenDiffResponse> {
// In CLI mode, diff operations are handled by FileEditProvider directly.
// This is a no-op since we don't have a visual diff editor.
return proto.host.OpenDiffResponse.create({})
}
async getDocumentText(_request: proto.host.GetDocumentTextRequest): Promise<proto.host.GetDocumentTextResponse> {
// In CLI mode, document text is managed by FileEditProvider directly.
// Return empty content since we don't track document state here.
return proto.host.GetDocumentTextResponse.create({ content: "" })
}
async replaceText(_request: proto.host.ReplaceTextRequest): Promise<proto.host.ReplaceTextResponse> {
// No-op in CLI - actual file editing is handled by FileEditProvider
return proto.host.ReplaceTextResponse.create({})
}
async scrollDiff(_request: proto.host.ScrollDiffRequest): Promise<proto.host.ScrollDiffResponse> {
// No-op in CLI - no visual editor to scroll
return proto.host.ScrollDiffResponse.create({})
}
async truncateDocument(_request: proto.host.TruncateDocumentRequest): Promise<proto.host.TruncateDocumentResponse> {
// No-op in CLI - actual file editing is handled by FileEditProvider
return proto.host.TruncateDocumentResponse.create({})
}
async saveDocument(_request: proto.host.SaveDocumentRequest): Promise<proto.host.SaveDocumentResponse> {
// No-op in CLI - actual file saving is handled by FileEditProvider
return proto.host.SaveDocumentResponse.create({})
}
async closeAllDiffs(_request: proto.host.CloseAllDiffsRequest): Promise<proto.host.CloseAllDiffsResponse> {
// No-op in CLI - no visual diff views to close
return proto.host.CloseAllDiffsResponse.create({})
}
async openMultiFileDiff(request: proto.host.OpenMultiFileDiffRequest): Promise<proto.host.OpenMultiFileDiffResponse> {
// In CLI mode, we display a summary of the multi-file diff
const title = request.title || "Multi-file diff"
const diffs = request.diffs || []
if (diffs.length > 0) {
printInfo(`📝 ${title}: ${diffs.length} file(s) changed`)
for (const diff of diffs) {
printInfo(` - ${diff.filePath}`)
}
}
return proto.host.OpenMultiFileDiffResponse.create({})
}
}
/**
* CLI implementation of EnvService - handles environment operations
*/
export class CliEnvServiceClient implements EnvServiceClientInterface {
private clipboardContent: string = ""
async clipboardWriteText(request: proto.cline.StringRequest): Promise<proto.cline.Empty> {
this.clipboardContent = request.value || ""
printInfo(`📋 Copied to clipboard`)
return proto.cline.Empty.create()
}
async clipboardReadText(_request: proto.cline.EmptyRequest): Promise<proto.cline.String> {
return proto.cline.String.create({ value: this.clipboardContent })
}
async getHostVersion(_request: proto.cline.EmptyRequest): Promise<proto.host.GetHostVersionResponse> {
return proto.host.GetHostVersionResponse.create({
version: "1.0.0",
platform: "Cline CLI",
})
}
async getIdeRedirectUri(_request: proto.cline.EmptyRequest): Promise<proto.cline.String> {
// CLI doesn't have IDE redirect
return proto.cline.String.create({ value: "" })
}
async getTelemetrySettings(_request: proto.cline.EmptyRequest): Promise<proto.host.GetTelemetrySettingsResponse> {
return proto.host.GetTelemetrySettingsResponse.create({
isEnabled: proto.host.Setting.DISABLED,
})
}
subscribeToTelemetrySettings(
_request: proto.cline.EmptyRequest,
callbacks: StreamingCallbacks<proto.host.TelemetrySettingsEvent>,
): () => void {
// Send initial settings
callbacks.onResponse(
proto.host.TelemetrySettingsEvent.create({
isEnabled: proto.host.Setting.DISABLED,
}),
)
// Return unsubscribe function
return () => {}
}
async shutdown(_request: proto.cline.EmptyRequest): Promise<proto.cline.Empty> {
printInfo("Shutting down...")
return proto.cline.Empty.create()
}
}
/**
* CLI implementation of WindowService - handles window/UI operations
*/
export class CliWindowServiceClient implements WindowServiceClientInterface {
async showTextDocument(request: proto.host.ShowTextDocumentRequest): Promise<proto.host.TextEditorInfo> {
printInfo(`📄 Opening file: ${request.path}`)
return proto.host.TextEditorInfo.create({
documentPath: request.path,
})
}
async showOpenDialogue(_request: proto.host.ShowOpenDialogueRequest): Promise<proto.host.SelectedResources> {
printWarning("Open dialog not available in CLI mode")
return proto.host.SelectedResources.create({ paths: [] })
}
async showMessage(request: proto.host.ShowMessageRequest): Promise<proto.host.SelectedResponse> {
const message = request.message || ""
const type = request.type
switch (type) {
case proto.host.ShowMessageType.ERROR:
printError(message)
break
case proto.host.ShowMessageType.WARNING:
printWarning(message)
break
case proto.host.ShowMessageType.INFORMATION:
default:
printInfo(message)
break
}
return proto.host.SelectedResponse.create({})
}
async showInputBox(_request: proto.host.ShowInputBoxRequest): Promise<proto.host.ShowInputBoxResponse> {
// In CLI mode, we could use readline, but for now return empty
printWarning("Input box not available in CLI mode")
return proto.host.ShowInputBoxResponse.create({ response: "" })
}
async showSaveDialog(_request: proto.host.ShowSaveDialogRequest): Promise<proto.host.ShowSaveDialogResponse> {
printWarning("Save dialog not available in CLI mode")
return proto.host.ShowSaveDialogResponse.create({ selectedPath: "" })
}
async openFile(request: proto.host.OpenFileRequest): Promise<proto.host.OpenFileResponse> {
printInfo(`📂 Opening: ${request.filePath}`)
return proto.host.OpenFileResponse.create({})
}
async openSettings(_request: proto.host.OpenSettingsRequest): Promise<proto.host.OpenSettingsResponse> {
printInfo("Settings can be configured in ~/.cline/data/globalState.json")
return proto.host.OpenSettingsResponse.create({})
}
async getOpenTabs(_request: proto.host.GetOpenTabsRequest): Promise<proto.host.GetOpenTabsResponse> {
// CLI doesn't have tabs
return proto.host.GetOpenTabsResponse.create({ paths: [] })
}
async getVisibleTabs(_request: proto.host.GetVisibleTabsRequest): Promise<proto.host.GetVisibleTabsResponse> {
return proto.host.GetVisibleTabsResponse.create({ paths: [] })
}
async getActiveEditor(_request: proto.host.GetActiveEditorRequest): Promise<proto.host.GetActiveEditorResponse> {
return proto.host.GetActiveEditorResponse.create({})
}
}
/**
* CLI implementation of WorkspaceService - handles workspace operations
*/
export class CliWorkspaceServiceClient implements WorkspaceServiceClientInterface {
private workspacePath: string
constructor(workspacePath: string = process.cwd()) {
this.workspacePath = workspacePath
}
setWorkspacePath(path: string) {
this.workspacePath = path
}
async getWorkspacePaths(_request: proto.host.GetWorkspacePathsRequest): Promise<proto.host.GetWorkspacePathsResponse> {
return proto.host.GetWorkspacePathsResponse.create({
paths: [this.workspacePath],
})
}
async saveOpenDocumentIfDirty(
_request: proto.host.SaveOpenDocumentIfDirtyRequest,
): Promise<proto.host.SaveOpenDocumentIfDirtyResponse> {
return proto.host.SaveOpenDocumentIfDirtyResponse.create({})
}
async getDiagnostics(_request: proto.host.GetDiagnosticsRequest): Promise<proto.host.GetDiagnosticsResponse> {
// In CLI mode, we could run linters here
return proto.host.GetDiagnosticsResponse.create({ fileDiagnostics: [] })
}
async openProblemsPanel(_request: proto.host.OpenProblemsPanelRequest): Promise<proto.host.OpenProblemsPanelResponse> {
printInfo("Run linters to see problems")
return proto.host.OpenProblemsPanelResponse.create({})
}
async openInFileExplorerPanel(
request: proto.host.OpenInFileExplorerPanelRequest,
): Promise<proto.host.OpenInFileExplorerPanelResponse> {
printInfo(`📁 ${request.path}`)
return proto.host.OpenInFileExplorerPanelResponse.create({})
}
async openClineSidebarPanel(
_request: proto.host.OpenClineSidebarPanelRequest,
): Promise<proto.host.OpenClineSidebarPanelResponse> {
// No sidebar in CLI
return proto.host.OpenClineSidebarPanelResponse.create({})
}
async openTerminalPanel(_request: proto.host.OpenTerminalRequest): Promise<proto.host.OpenTerminalResponse> {
printInfo("Terminal is already available in CLI mode")
return proto.host.OpenTerminalResponse.create({})
}
async executeCommandInTerminal(
request: proto.host.ExecuteCommandInTerminalRequest,
): Promise<proto.host.ExecuteCommandInTerminalResponse> {
printInfo(`⚙️ Executing: ${request.command}`)
return proto.host.ExecuteCommandInTerminalResponse.create({})
}
}
/**
* Create a CLI host bridge provider
*/
export function createCliHostBridgeProvider(workspacePath?: string): HostBridgeClientProvider {
return {
workspaceClient: new CliWorkspaceServiceClient(workspacePath),
envClient: new CliEnvServiceClient(),
windowClient: new CliWindowServiceClient(),
diffClient: new CliDiffServiceClient(),
}
}
-158
View File
@@ -1,158 +0,0 @@
/**
* Custom hook to subscribe to controller state updates
* Handles the diff/merge logic for streaming text and message tracking
*/
import type { ClineMessage } from "@shared/ExtensionMessage"
import { useCallback, useRef } from "react"
import { useTaskContext } from "../context/TaskContext"
interface ProcessedState {
processedAskMessages: Set<number>
processedSayMessages: Set<number>
}
/**
* Hook to track which ask/say messages have been processed
* This prevents duplicate prompts for the same ask message
*/
export const useProcessedMessages = () => {
const processedRef = useRef<ProcessedState>({
processedAskMessages: new Set(),
processedSayMessages: new Set(),
})
return processedRef.current
}
/**
* Detect if a message has just been completed (is asking for user input)
*/
export const useCompletedAskMessages = () => {
const { state, controller } = useTaskContext()
const processed = useProcessedMessages()
const getCompletedAskMessages = useCallback(() => {
const completedAsks: ClineMessage[] = []
if (!state.clineMessages) {
return completedAsks
}
for (let i = 0; i < state.clineMessages.length; i++) {
const message = state.clineMessages[i]
if (message.type === "ask" && !message.partial && !processed.processedAskMessages.has(i)) {
completedAsks.push(message)
processed.processedAskMessages.add(i)
}
}
return completedAsks
}, [state.clineMessages, processed])
return getCompletedAskMessages
}
/**
* Get the last completed ask message (for rendering current input prompt)
*/
export const useLastCompletedAskMessage = () => {
const { state } = useTaskContext()
const processed = useProcessedMessages()
const getLastCompletedAskMessage = useCallback((): ClineMessage | null => {
if (!state.clineMessages) {
return null
}
// Find the last ask message that is complete
for (let i = state.clineMessages.length - 1; i >= 0; i--) {
const message = state.clineMessages[i]
if (message.type === "ask" && !message.partial) {
return message
}
}
return null
}, [state.clineMessages])
return getLastCompletedAskMessage()
}
/**
* Get messages that should trigger the completion detection
*/
export const useCompletionSignals = () => {
const { state } = useTaskContext()
const isTaskComplete = useCallback((): boolean => {
if (!state.clineMessages || state.clineMessages.length === 0) {
return false
}
const lastMessage = state.clineMessages[state.clineMessages.length - 1]
if (!lastMessage) {
return false
}
// Check for completion signals
if (lastMessage.say === "completion_result" || lastMessage.ask === "completion_result") {
return true
}
// Check for error signals
if (lastMessage.say === "error" || lastMessage.ask === "api_req_failed") {
return true
}
return false
}, [state.clineMessages])
const getCompletionMessage = useCallback((): ClineMessage | null => {
if (!state.clineMessages || state.clineMessages.length === 0) {
return null
}
return state.clineMessages[state.clineMessages.length - 1] || null
}, [state.clineMessages])
return {
isTaskComplete,
getCompletionMessage,
}
}
/**
* Check if spinner should be shown (when API is thinking)
*/
export const useIsSpinnerActive = (): boolean => {
const { state } = useTaskContext()
if (!state.clineMessages || state.clineMessages.length === 0) {
return false
}
// If the last message is a completed ask message, don't show spinner (waiting for user input)
const lastMessage = state.clineMessages[state.clineMessages.length - 1]
if (lastMessage?.type === "ask" && !lastMessage.partial) {
return false
}
// Look for most recent api_req_started that isn't followed by api_req_finished
for (let i = state.clineMessages.length - 1; i >= 0; i--) {
const msg = state.clineMessages[i]
if (msg.say === "api_req_started") {
// Check if there's an api_req_finished after this
let hasFinished = false
for (let j = i + 1; j < state.clineMessages.length; j++) {
if (state.clineMessages[j].say === "api_req_finished") {
hasFinished = true
break
}
}
return !hasFinished
}
}
return false
}
-372
View File
@@ -1,372 +0,0 @@
import { Command } from "commander"
import { beforeEach, describe, expect, it } from "vitest"
/**
* Tests for CLI command parsing and structure
* These tests verify the commander.js command definitions without
* actually running the commands (which would require full infrastructure)
*/
describe("CLI Commands", () => {
let program: Command
beforeEach(() => {
// Create a fresh program instance for each test
program = new Command()
program.name("cline").description("Cline CLI - AI coding assistant").version("0.0.0")
program.enablePositionalOptions()
// Define commands matching index.ts
program
.command("task")
.alias("t")
.description("Run a new task")
.argument("<prompt>", "The task prompt")
.option("-a, --act", "Run in act mode")
.option("-p, --plan", "Run in plan mode")
.option("-y, --yolo", "Enable yolo mode")
.option("-m, --model <model>", "Model to use")
.option("-i, --images <paths...>", "Image file paths")
.option("-v, --verbose", "Show verbose output")
.option("-c, --cwd <path>", "Working directory")
.option("--config <path>", "Configuration directory")
.option("--thinking", "Enable extended thinking")
.action(() => {})
program
.command("history")
.alias("h")
.description("List task history")
.option("-n, --limit <number>", "Number of tasks to show", "10")
.option("-p, --page <number>", "Page number", "1")
.option("--config <path>", "Configuration directory")
.action(() => {})
program
.command("config")
.description("Show current configuration")
.option("--config <path>", "Configuration directory")
.action(() => {})
program
.command("auth")
.description("Authenticate a provider")
.option("-p, --provider <id>", "Provider ID")
.option("-k, --apikey <key>", "API key")
.option("-m, --modelid <id>", "Model ID")
.option("-b, --baseurl <url>", "Base URL")
.option("-v, --verbose", "Verbose output")
.option("-c, --cwd <path>", "Working directory")
.option("--config <path>", "Configuration directory")
.action(() => {})
// Default command for interactive mode
program
.argument("[prompt]", "Task prompt")
.option("-i, --images <paths...>", "Image file paths")
.option("-v, --verbose", "Verbose output")
.option("-c, --cwd <path>", "Working directory")
.option("--config <path>", "Configuration directory")
.option("--thinking", "Enable extended thinking")
.action(() => {})
})
describe("task command", () => {
it("should parse task command with prompt", () => {
const args = ["node", "cli", "task", "write hello world"]
program.parse(args)
// Command should be parsed without error
})
it("should parse task alias", () => {
const args = ["node", "cli", "t", "write hello world"]
program.parse(args)
})
it("should parse --act flag", () => {
const taskCmd = program.commands.find((c) => c.name() === "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 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 args = ["test prompt", "--yolo"]
taskCmd.parse(args, { from: "user" })
expect(taskCmd.opts().yolo).toBe(true)
})
it("should parse --model option", () => {
const taskCmd = program.commands.find((c) => c.name() === "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 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 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 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 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 args = ["test prompt", "--thinking"]
taskCmd.parse(args, { from: "user" })
expect(taskCmd.opts().thinking).toBe(true)
})
it("should parse short flags", () => {
const taskCmd = program.commands.find((c) => c.name() === "task")!
const args = ["test prompt", "-a", "-v", "-m", "gpt-4"]
taskCmd.parse(args, { from: "user" })
expect(taskCmd.opts().act).toBe(true)
expect(taskCmd.opts().verbose).toBe(true)
expect(taskCmd.opts().model).toBe("gpt-4")
})
})
describe("history command", () => {
it("should have default limit of 10", () => {
const historyCmd = program.commands.find((c) => c.name() === "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")!
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 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 args = ["--page", "3"]
historyCmd.parse(args, { from: "user" })
expect(historyCmd.opts().page).toBe("3")
})
it("should parse history alias", () => {
const args = ["node", "cli", "h"]
program.parse(args)
// Alias should work
})
it("should parse short flags", () => {
const historyCmd = program.commands.find((c) => c.name() === "history")!
const args = ["-n", "5", "-p", "2"]
historyCmd.parse(args, { from: "user" })
expect(historyCmd.opts().limit).toBe("5")
expect(historyCmd.opts().page).toBe("2")
})
})
describe("config command", () => {
it("should parse config command", () => {
const args = ["node", "cli", "config"]
program.parse(args)
})
it("should parse --config option", () => {
const configCmd = program.commands.find((c) => c.name() === "config")!
const args = ["--config", "/custom/path"]
configCmd.parse(args, { from: "user" })
expect(configCmd.opts().config).toBe("/custom/path")
})
})
describe("auth command", () => {
it("should parse auth command", () => {
const args = ["node", "cli", "auth"]
program.parse(args)
})
it("should parse --provider option", () => {
const authCmd = program.commands.find((c) => c.name() === "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 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 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 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 args = ["-p", "anthropic", "-k", "key123", "-m", "claude-sonnet-4-20250514"]
authCmd.parse(args, { from: "user" })
expect(authCmd.opts().provider).toBe("anthropic")
expect(authCmd.opts().apikey).toBe("key123")
expect(authCmd.opts().modelid).toBe("claude-sonnet-4-20250514")
})
})
describe("default command (interactive mode)", () => {
it("should parse optional prompt argument", () => {
const args = ["node", "cli", "do something"]
program.parse(args)
})
it("should parse without prompt (interactive mode)", () => {
const args = ["node", "cli"]
program.parse(args)
})
it("should parse --images option", () => {
program.parse(["node", "cli", "--images", "img.png"])
expect(program.opts().images).toEqual(["img.png"])
})
it("should parse --verbose flag", () => {
program.parse(["node", "cli", "--verbose"])
expect(program.opts().verbose).toBe(true)
})
it("should parse --thinking flag", () => {
program.parse(["node", "cli", "--thinking"])
expect(program.opts().thinking).toBe(true)
})
})
describe("command structure", () => {
it("should have all expected commands", () => {
const commandNames = program.commands.map((c) => c.name())
expect(commandNames).toContain("task")
expect(commandNames).toContain("history")
expect(commandNames).toContain("config")
expect(commandNames).toContain("auth")
})
it("should have correct aliases", () => {
const taskCmd = program.commands.find((c) => c.name() === "task")!
const historyCmd = program.commands.find((c) => c.name() === "history")!
expect(taskCmd.aliases()).toContain("t")
expect(historyCmd.aliases()).toContain("h")
})
it("should have descriptions for all commands", () => {
for (const cmd of program.commands) {
expect(cmd.description()).toBeTruthy()
}
})
})
})
describe("getProviderModelIdKey", () => {
// Test the provider model ID key mapping logic
const providerKeyMap: Record<string, string> = {
openrouter: "OpenRouterModelId",
cline: "OpenRouterModelId",
openai: "OpenAiModelId",
ollama: "OllamaModelId",
lmstudio: "LmStudioModelId",
litellm: "LiteLlmModelId",
requesty: "RequestyModelId",
together: "TogetherModelId",
fireworks: "FireworksModelId",
sapaicore: "SapAiCoreModelId",
groq: "GroqModelId",
baseten: "BasetenModelId",
huggingface: "HuggingFaceModelId",
}
function getProviderModelIdKey(provider: string, mode: "act" | "plan"): string | null {
const prefix = mode === "act" ? "actMode" : "planMode"
const keySuffix = providerKeyMap[provider]
if (keySuffix) {
return `${prefix}${keySuffix}`
}
return null
}
it("should return correct key for openrouter in act mode", () => {
expect(getProviderModelIdKey("openrouter", "act")).toBe("actModeOpenRouterModelId")
})
it("should return correct key for openrouter in plan mode", () => {
expect(getProviderModelIdKey("openrouter", "plan")).toBe("planModeOpenRouterModelId")
})
it("should return same key for cline as openrouter", () => {
expect(getProviderModelIdKey("cline", "act")).toBe("actModeOpenRouterModelId")
})
it("should return correct key for openai", () => {
expect(getProviderModelIdKey("openai", "act")).toBe("actModeOpenAiModelId")
})
it("should return correct key for ollama", () => {
expect(getProviderModelIdKey("ollama", "act")).toBe("actModeOllamaModelId")
})
it("should return null for anthropic (uses generic key)", () => {
expect(getProviderModelIdKey("anthropic", "act")).toBeNull()
})
it("should return null for gemini (uses generic key)", () => {
expect(getProviderModelIdKey("gemini", "act")).toBeNull()
})
it("should return null for bedrock (uses generic key)", () => {
expect(getProviderModelIdKey("bedrock", "act")).toBeNull()
})
it("should return null for unknown providers", () => {
expect(getProviderModelIdKey("unknown-provider", "act")).toBeNull()
})
})
-525
View File
@@ -1,525 +0,0 @@
/**
* Cline CLI - TypeScript implementation with React Ink
*/
import path from "node:path"
import { exit } from "node:process"
import type { ApiProvider } from "@shared/api"
import { Command } from "commander"
import { render } from "ink"
import React from "react"
import { Controller } from "@/core/controller"
import { StateManager } from "@/core/storage/StateManager"
import { AuthHandler } from "@/hosts/external/AuthHandler"
import { HostProvider } from "@/hosts/host-provider"
import { FileEditProvider } from "@/integrations/editor/FileEditProvider"
import { StandaloneTerminalManager } from "@/integrations/terminal/standalone/StandaloneTerminalManager"
import { ErrorService } from "@/services/error/ErrorService"
import { initializeDistinctId } from "@/services/logging/distinctId"
import { App } from "./components/App"
import { createCliHostBridgeProvider } from "./controllers"
import { CliCommentReviewController } from "./controllers/CliCommentReviewController"
import { CliWebviewProvider } from "./controllers/CliWebviewProvider"
import { restoreConsole } from "./utils/console"
import { print, printError, printInfo, printWarning, separator } from "./utils/display"
import { parseImagesFromInput, processImagePaths } from "./utils/parser"
import { getProviderModelIdKey } from "./utils/provider-map"
import { initializeCliContext } from "./vscode-context"
const VERSION = "0.0.0"
// Track active context for graceful shutdown
let activeContext: CliContext | null = null
let isShuttingDown = false
function setupSignalHandlers() {
const shutdown = async (signal: string) => {
if (isShuttingDown) {
// Force exit on second signal
process.exit(1)
}
isShuttingDown = true
printWarning(`\n${signal} received, shutting down...`)
try {
if (activeContext) {
const task = activeContext.controller.task
if (task) {
task.abortTask()
}
await activeContext.controller.stateManager.flushPendingState()
await activeContext.controller.dispose()
}
await ErrorService.get().dispose()
} catch {
// Best effort cleanup
}
process.exit(0)
}
process.on("SIGINT", () => shutdown("SIGINT"))
process.on("SIGTERM", () => shutdown("SIGTERM"))
}
setupSignalHandlers()
interface CliContext {
extensionContext: any
dataDir: string
extensionDir: string
workspacePath: string
controller: Controller
}
interface InitOptions {
config?: string
cwd?: string
verbose?: boolean
enableAuth?: boolean
}
/**
* Initialize all CLI infrastructure and return context needed for commands
*/
async function initializeCli(options: InitOptions): Promise<CliContext> {
const workspacePath = options.cwd || process.cwd()
const { extensionContext, DATA_DIR, EXTENSION_DIR } = initializeCliContext({
clineDir: options.config,
workspaceDir: workspacePath,
})
if (options.enableAuth) {
AuthHandler.getInstance().setEnabled(true)
}
const logToChannel = options.verbose ? (message: string) => printInfo(message) : () => {}
HostProvider.initialize(
() => new CliWebviewProvider(extensionContext),
() => new FileEditProvider(),
() => new CliCommentReviewController(),
() => new StandaloneTerminalManager(),
createCliHostBridgeProvider(workspacePath),
logToChannel,
async () => (options.enableAuth ? AuthHandler.getInstance().getCallbackUrl() : ""),
async (name: string) => path.join(process.cwd(), name),
EXTENSION_DIR,
DATA_DIR,
)
await ErrorService.initialize()
await StateManager.initialize(extensionContext)
const webview = HostProvider.get().createWebviewProvider() as CliWebviewProvider
const controller = webview.controller
await initializeDistinctId(extensionContext)
const ctx = { extensionContext, dataDir: DATA_DIR, extensionDir: EXTENSION_DIR, workspacePath, controller }
activeContext = ctx
return ctx
}
/**
* Run an Ink app with proper cleanup handling
*/
async function runInkApp(element: React.ReactElement, cleanup: () => Promise<void>): Promise<void> {
const { waitUntilExit, unmount } = render(element)
try {
await waitUntilExit()
} finally {
try {
unmount()
} catch {
// Already unmounted
}
restoreConsole()
await cleanup()
}
}
/**
* Wait for a condition with timeout
*/
function waitForCondition(check: () => boolean, timeoutMs: number, intervalMs: number = 100): Promise<boolean> {
return new Promise((resolve) => {
const startTime = Date.now()
const poll = () => {
if (check()) {
resolve(true)
return
}
if (Date.now() - startTime > timeoutMs) {
resolve(false)
return
}
setTimeout(poll, intervalMs)
}
poll()
})
}
/**
* Run a task with the given prompt
*/
async function runTask(
prompt: string,
options: {
act?: boolean
plan?: boolean
model?: string
verbose?: boolean
cwd?: string
config?: string
thinking?: boolean
yolo?: boolean
images?: string[]
},
existingContext?: CliContext,
) {
const ctx = existingContext || (await initializeCli(options))
// Parse images from the prompt text (e.g., @/path/to/image.png)
const { prompt: cleanPrompt, imagePaths: parsedImagePaths } = parseImagesFromInput(prompt)
// Combine parsed image paths with explicit --images option
const allImagePaths = [...(options.images || []), ...parsedImagePaths]
// Convert image file paths to base64 data URLs
const imageDataUrls = await processImagePaths(allImagePaths)
// Use clean prompt (with image refs removed)
const taskPrompt = cleanPrompt || prompt
if (options.plan) {
StateManager.get().setGlobalState("mode", "plan")
} else if (options.act) {
StateManager.get().setGlobalState("mode", "act")
}
if (options.model) {
const selectedMode = (StateManager.get().getGlobalSettingsKey("mode") || "act") as "act" | "plan"
// Get the current provider for the selected mode
const providerKey = selectedMode === "act" ? "actModeApiProvider" : "planModeApiProvider"
const currentProvider = StateManager.get().getGlobalSettingsKey(providerKey) as ApiProvider
// Update the generic model ID for the current mode
const modelKey = selectedMode === "act" ? "actModeApiModelId" : "planModeApiModelId"
StateManager.get().setGlobalState(modelKey, options.model)
// Also update the provider-specific model ID key if applicable
const providerModelKey = getProviderModelIdKey(currentProvider, selectedMode)
if (providerModelKey) {
StateManager.get().setGlobalState(providerModelKey, options.model)
}
}
// Set thinking budget based on --thinking flag
const thinkingBudget = options.thinking ? 1024 : 0
const currentMode = StateManager.get().getGlobalSettingsKey("mode") || "act"
const thinkingKey = currentMode === "act" ? "actModeThinkingBudgetTokens" : "planModeThinkingBudgetTokens"
StateManager.get().setGlobalState(thinkingKey, thinkingBudget)
// Set yolo mode based on --yolo flag
if (options.yolo) {
StateManager.get().setGlobalState("yoloModeToggled", true)
}
await StateManager.get().flushPendingState()
printInfo(`Starting Cline task...`)
printInfo(`Working directory: ${ctx.workspacePath}`)
if (imageDataUrls.length > 0) {
printInfo(`Images attached: ${imageDataUrls.length}`)
}
print(separator())
let isComplete = false
let taskError = false
const { waitUntilExit, unmount } = render(
React.createElement(App, {
view: "task",
taskId: taskPrompt.substring(0, 30),
verbose: options.verbose,
controller: ctx.controller,
onComplete: () => {
isComplete = true
},
onError: () => {
taskError = true
isComplete = true
},
}),
)
await ctx.controller.initTask(taskPrompt, imageDataUrls.length > 0 ? imageDataUrls : undefined)
const completed = await waitForCondition(() => isComplete, 10 * 60 * 1000)
if (!completed) {
printError("Task timeout")
}
// Brief delay for final render
await new Promise((resolve) => setTimeout(resolve, 100))
try {
await waitUntilExit()
if (taskError) {
process.exit(1)
}
} catch (error) {
printError(`Task failed: ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
} finally {
try {
unmount()
} catch {
// Already unmounted
}
restoreConsole()
await ctx.controller.stateManager.flushPendingState()
await ctx.controller.dispose()
await ErrorService.get().dispose()
exit(0)
}
}
/**
* List task history
*/
async function listHistory(options: { config?: string; limit?: number; page?: number }) {
const ctx = await initializeCli(options)
const taskHistory = StateManager.get().getGlobalStateKey("taskHistory") || []
// Sort by timestamp (newest first) before pagination
const sortedHistory = [...taskHistory].sort((a: any, b: any) => (b.ts || 0) - (a.ts || 0))
const limit = typeof options.limit === "string" ? parseInt(options.limit, 10) : options.limit || 10
const initialPage = typeof options.page === "string" ? parseInt(options.page, 10) : options.page || 1
const totalCount = sortedHistory.length
const totalPages = Math.ceil(totalCount / limit)
if (sortedHistory.length === 0) {
printInfo("No task history found.")
await ctx.controller.stateManager.flushPendingState()
await ctx.controller.dispose()
await ErrorService.get().dispose()
exit(0)
return
}
await runInkApp(
React.createElement(App, {
view: "history",
historyItems: [],
historyAllItems: sortedHistory,
controller: ctx.controller,
historyPagination: { page: initialPage, totalPages, totalCount, limit },
}),
async () => {
await ctx.controller.stateManager.flushPendingState()
await ctx.controller.dispose()
await ErrorService.get().dispose()
exit(0)
},
)
}
/**
* Show current configuration
*/
async function showConfig(options: { config?: string }) {
const ctx = await initializeCli(options)
const stateManager = StateManager.get()
// Dynamically import the wrapper to avoid circular dependencies
const { ConfigViewWrapper } = await import("./components/ConfigViewWrapper")
// Check feature flags
const hooksEnabled = stateManager.getGlobalSettingsKey("hooksEnabled") ?? false
const skillsEnabled = stateManager.getGlobalSettingsKey("skillsEnabled") ?? false
await runInkApp(
React.createElement(ConfigViewWrapper, {
controller: ctx.controller,
dataDir: ctx.dataDir,
globalState: stateManager.getAllGlobalStateEntries(),
workspaceState: stateManager.getAllWorkspaceStateEntries(),
hooksEnabled,
skillsEnabled,
}),
async () => {
await ctx.controller.stateManager.flushPendingState()
await ctx.controller.dispose()
await ErrorService.get().dispose()
exit(0)
},
)
}
/**
* Run authentication flow
*/
async function runAuth(options: {
provider?: string
apikey?: string
modelid?: string
baseurl?: string
verbose?: boolean
cwd?: string
config?: string
}) {
const ctx = await initializeCli({ ...options, enableAuth: true })
const hasQuickSetupFlags = options.provider || options.apikey || options.modelid || options.baseurl
const quickSetup = hasQuickSetupFlags
? { provider: options.provider, apikey: options.apikey, modelid: options.modelid, baseurl: options.baseurl }
: undefined
let authError = false
await runInkApp(
React.createElement(App, {
view: "auth",
controller: ctx.controller,
onComplete: () => {
exit(0)
},
onError: () => {
authError = true
},
authQuickSetup: quickSetup,
}),
async () => {
await ctx.controller.stateManager.flushPendingState()
await ctx.controller.dispose()
await ErrorService.get().dispose()
exit(0)
},
)
if (authError) {
process.exit(1)
}
}
// Setup CLI commands
const program = new Command()
program.name("cline").description("Cline CLI - AI coding assistant in your terminal").version(VERSION)
// Enable positional options to avoid conflicts between root and subcommand options with the same name
program.enablePositionalOptions()
program
.command("task")
.alias("t")
.description("Run a new task")
.argument("<prompt>", "The task prompt")
.option("-a, --act", "Run in act mode")
.option("-p, --plan", "Run in plan mode")
.option("-y, --yolo", "Enable yolo mode (auto-approve actions)")
.option("-m, --model <model>", "Model to use for the task")
.option("-i, --images <paths...>", "Image file paths to include with the task")
.option("-v, --verbose", "Show verbose output")
.option("-c, --cwd <path>", "Working directory for the task")
.option("--config <path>", "Path to Cline configuration directory")
.option("--thinking", "Enable extended thinking (1024 token budget)")
.action((prompt, options) => runTask(prompt, options))
program
.command("history")
.alias("h")
.description("List task history")
.option("-n, --limit <number>", "Number of tasks to show", "10")
.option("-p, --page <number>", "Page number (1-based)", "1")
.option("--config <path>", "Path to Cline configuration directory")
.action(listHistory)
program
.command("config")
.description("Show current configuration")
.option("--config <path>", "Path to Cline configuration directory")
.action(showConfig)
program
.command("auth")
.description("Authenticate a provider and configure what model is used")
.option("-p, --provider <id>", "Provider ID for quick setup (e.g., openai-native, anthropic)")
.option("-k, --apikey <key>", "API key for the provider")
.option("-m, --modelid <id>", "Model ID to configure (e.g., gpt-4o, claude-sonnet-4-5-20250929)")
.option("-b, --baseurl <url>", "Base URL (optional, only for openai provider)")
.option("-v, --verbose", "Show verbose output")
.option("-c, --cwd <path>", "Working directory for the task")
.option("--config <path>", "Path to Cline configuration directory")
.action(runAuth)
program
.command("version")
.description("Show Cline CLI version number")
.action(() => printInfo(`Cline CLI version: ${VERSION}`))
/**
* Show welcome prompt and run task with user input
*/
async function showWelcome(options: { verbose?: boolean; cwd?: string; config?: string; thinking?: boolean }) {
const ctx = await initializeCli({ ...options, enableAuth: true })
let submittedPrompt: string | null = null
let submittedImagePaths: string[] = []
const { waitUntilExit, unmount } = render(
React.createElement(App, {
view: "welcome",
controller: ctx.controller,
onWelcomeSubmit: (prompt: string, imagePaths: string[]) => {
submittedPrompt = prompt
submittedImagePaths = imagePaths
unmount()
},
onWelcomeExit: () => {
unmount()
exit(0)
},
}),
)
try {
await waitUntilExit()
} catch {
// App unmounted after prompt submission
}
restoreConsole()
if (submittedPrompt || submittedImagePaths.length > 0) {
// Run the task with the submitted prompt and images, reusing the existing context
await runTask(submittedPrompt || "", { ...options, images: submittedImagePaths }, ctx)
} else {
// User exited without submitting - clean up and exit
await ctx.controller.stateManager.flushPendingState()
await ctx.controller.dispose()
await ErrorService.get().dispose()
exit(0)
}
}
// Interactive mode (default when no command given)
program
.argument("[prompt]", "Task prompt (starts task immediately)")
.option("-i, --images <paths...>", "Image file paths to include with the task")
.option("-v, --verbose", "Show verbose output")
.option("-c, --cwd <path>", "Working directory")
.option("--config <path>", "Configuration directory")
.option("--thinking", "Enable extended thinking (1024 token budget)")
.action(async (prompt, options) => {
if (prompt) {
await runTask(prompt, options)
} else {
// Show welcome prompt if no prompt given
await showWelcome(options)
}
})
// Parse and run
program.parse()
-2
View File
@@ -1,2 +0,0 @@
// Stub for react-devtools-core - not needed in CLI
module.exports = {}
-35
View File
@@ -1,35 +0,0 @@
/**
* Console management for CLI
*
* Captures original console methods BEFORE any core modules are imported,
* so CLI output works even when console.log is suppressed.
*/
// Capture original console methods immediately
export const originalConsoleLog = console.log.bind(console)
export const originalConsoleError = console.error.bind(console)
export const originalConsoleWarn = console.warn.bind(console)
export const originalConsoleInfo = console.info.bind(console)
export const originalConsoleDebug = console.debug.bind(console)
// Check for verbose flag early (before commander parses)
const isVerbose = process.argv.includes("-v") || process.argv.includes("--verbose")
// Suppress console output unless verbose mode
if (!isVerbose) {
console.log = () => {}
console.warn = () => {}
console.error = () => {}
console.debug = () => {}
}
/**
* Restore original console methods (for cleanup)
*/
export function restoreConsole() {
console.log = originalConsoleLog
console.error = originalConsoleError
console.warn = originalConsoleWarn
console.info = originalConsoleInfo
console.debug = originalConsoleDebug
}
-418
View File
@@ -1,418 +0,0 @@
import type { ClineMessage, ExtensionState } from "@shared/ExtensionMessage"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { colorize, formatMessage, formatState, formatTimestamp, Spinner, separator, style, taskHeader } from "./display"
describe("display", () => {
describe("colorize", () => {
it("should wrap text with color codes", () => {
const result = colorize("test", "\x1b[31m")
expect(result).toBe("\x1b[31mtest\x1b[0m")
})
it("should combine multiple color codes", () => {
const result = colorize("test", "\x1b[1m", "\x1b[31m")
expect(result).toBe("\x1b[1m\x1b[31mtest\x1b[0m")
})
it("should handle empty text", () => {
const result = colorize("", "\x1b[31m")
expect(result).toBe("\x1b[31m\x1b[0m")
})
})
describe("style helpers", () => {
it("should apply bold style", () => {
const result = style.bold("text")
expect(result).toContain("text")
expect(result).toContain("\x1b[1m")
})
it("should apply dim style", () => {
const result = style.dim("text")
expect(result).toContain("text")
expect(result).toContain("\x1b[2m")
})
it("should apply error style", () => {
const result = style.error("error message")
expect(result).toContain("error message")
expect(result).toContain("\x1b[31m") // red
})
it("should apply success style", () => {
const result = style.success("success")
expect(result).toContain("success")
expect(result).toContain("\x1b[32m") // green
})
it("should apply info style", () => {
const result = style.info("info")
expect(result).toContain("info")
expect(result).toContain("\x1b[36m") // cyan
})
it("should apply warning style", () => {
const result = style.warning("warning")
expect(result).toContain("warning")
expect(result).toContain("\x1b[33m") // yellow
})
})
describe("formatTimestamp", () => {
it("should format timestamp as HH:MM:SS", () => {
// Create a known timestamp: Jan 1, 2024 15:30:45 UTC
const ts = new Date("2024-01-01T15:30:45Z").getTime()
const result = formatTimestamp(ts)
// Result depends on local timezone, but should be HH:MM:SS format
expect(result).toMatch(/^\d{2}:\d{2}:\d{2}$/)
})
it("should handle zero timestamp", () => {
const result = formatTimestamp(0)
expect(result).toMatch(/^\d{2}:\d{2}:\d{2}$/)
})
})
describe("formatMessage", () => {
const createMessage = (overrides: Partial<ClineMessage>): ClineMessage =>
({
ts: Date.now(),
type: "say",
say: "text",
text: "test message",
...overrides,
}) as ClineMessage
describe("say messages", () => {
it("should format text message", () => {
const message = createMessage({ say: "text", text: "Hello world" })
const result = formatMessage(message)
expect(result).toContain("Hello world")
})
it("should format task message", () => {
const message = createMessage({ say: "task", text: "New task" })
const result = formatMessage(message)
expect(result).toContain("Task:")
expect(result).toContain("New task")
})
it("should format error message", () => {
const message = createMessage({ say: "error", text: "Something went wrong" })
const result = formatMessage(message)
expect(result).toContain("Error:")
expect(result).toContain("Something went wrong")
})
it("should format completion_result message", () => {
const message = createMessage({ say: "completion_result", text: "Done!" })
const result = formatMessage(message)
expect(result).toContain("Completed:")
})
it("should format reasoning message", () => {
const message = createMessage({ say: "reasoning", text: "Let me think..." })
const result = formatMessage(message)
expect(result).toContain("Thinking:")
expect(result).toContain("Let me think...")
})
it("should format command message", () => {
const message = createMessage({ say: "command", text: "npm install" })
const result = formatMessage(message)
expect(result).toContain("Command:")
expect(result).toContain("npm install")
})
it("should truncate long command output", () => {
const longOutput = "x".repeat(600)
const message = createMessage({ say: "command_output", text: longOutput })
const result = formatMessage(message)
expect(result).toContain("Output:")
expect(result).toContain("...")
expect(result.length).toBeLessThan(longOutput.length + 100)
})
it("should format user_feedback message", () => {
const message = createMessage({ say: "user_feedback", text: "User said something" })
const result = formatMessage(message)
expect(result).toContain("User:")
})
it("should format tool message", () => {
const message = createMessage({ say: "tool", text: "read_file" })
const result = formatMessage(message)
expect(result).toContain("Tool:")
})
it("should format browser_action message", () => {
const message = createMessage({ say: "browser_action", text: "click button" })
const result = formatMessage(message)
expect(result).toContain("Browser:")
})
it("should format api_req_started in verbose mode", () => {
const message = createMessage({ say: "api_req_started", text: "" })
const result = formatMessage(message, true)
expect(result).toContain("API request started")
})
it("should format checkpoint_created message", () => {
const message = createMessage({ say: "checkpoint_created", text: "Saved" })
const result = formatMessage(message)
expect(result).toContain("Checkpoint created")
})
it("should format info message", () => {
const message = createMessage({ say: "info", text: "Information" })
const result = formatMessage(message)
expect(result).toContain("Information")
})
it("should show unknown say types in verbose mode", () => {
const message = createMessage({ say: "unknown_type" as any, text: "test" })
const resultNormal = formatMessage(message, false)
const resultVerbose = formatMessage(message, true)
expect(resultNormal).toBe("")
expect(resultVerbose).toContain("[SAY:unknown_type]")
})
})
describe("ask messages", () => {
it("should format followup question", () => {
const message = createMessage({
type: "ask",
ask: "followup",
text: JSON.stringify({ question: "What do you want?" }),
})
const result = formatMessage(message)
expect(result).toContain("Question:")
expect(result).toContain("What do you want?")
})
it("should handle non-JSON followup text", () => {
const message = createMessage({
type: "ask",
ask: "followup",
text: "Plain text question",
})
const result = formatMessage(message)
expect(result).toContain("Plain text question")
})
it("should format command ask", () => {
const message = createMessage({
type: "ask",
ask: "command",
text: "rm -rf /",
})
const result = formatMessage(message)
expect(result).toContain("Execute command?")
expect(result).toContain("rm -rf /")
})
it("should format tool ask", () => {
const message = createMessage({
type: "ask",
ask: "tool",
text: "write_to_file",
})
const result = formatMessage(message)
expect(result).toContain("Use tool?")
})
it("should format completion_result ask", () => {
const message = createMessage({
type: "ask",
ask: "completion_result",
text: "Task completed successfully",
})
const result = formatMessage(message)
expect(result).toContain("Task completed")
})
it("should format api_req_failed ask", () => {
const message = createMessage({
type: "ask",
ask: "api_req_failed",
text: "Rate limit exceeded",
})
const result = formatMessage(message)
expect(result).toContain("API request failed")
expect(result).toContain("Rate limit exceeded")
})
it("should format resume_task ask", () => {
const message = createMessage({
type: "ask",
ask: "resume_task",
text: "",
})
const result = formatMessage(message)
expect(result).toContain("Resume task?")
})
it("should format browser_action_launch ask", () => {
const message = createMessage({
type: "ask",
ask: "browser_action_launch",
text: "https://example.com",
})
const result = formatMessage(message)
expect(result).toContain("Launch browser?")
})
it("should format use_mcp_server ask", () => {
const message = createMessage({
type: "ask",
ask: "use_mcp_server",
text: "server-name",
})
const result = formatMessage(message)
expect(result).toContain("Use MCP server?")
})
it("should show unknown ask types in verbose mode", () => {
const message = createMessage({
type: "ask",
ask: "unknown_ask" as any,
text: "test",
})
const resultNormal = formatMessage(message, false)
const resultVerbose = formatMessage(message, true)
expect(resultNormal).toBe("")
expect(resultVerbose).toContain("[ASK:unknown_ask]")
})
})
})
describe("separator", () => {
it("should create a separator with default char and width", () => {
const result = separator()
expect(result).toContain("─".repeat(60))
})
it("should use custom character", () => {
const result = separator("=", 10)
expect(result).toContain("=".repeat(10))
})
it("should use custom width", () => {
const result = separator("-", 20)
expect(result).toContain("-".repeat(20))
})
})
describe("taskHeader", () => {
it("should format task header with ID", () => {
const result = taskHeader("task-123")
expect(result).toContain("Task: task-123")
})
it("should include task description", () => {
const result = taskHeader("task-123", "Build a website")
expect(result).toContain("task-123")
expect(result).toContain("Build a website")
})
it("should truncate long task descriptions", () => {
const longTask = "x".repeat(100)
const result = taskHeader("task-123", longTask)
expect(result).toContain("...")
})
})
describe("formatState", () => {
it("should format state with messages", () => {
const state: Partial<ExtensionState> = {
clineMessages: [{ ts: Date.now(), type: "say", say: "text", text: "Hello" } as ClineMessage],
}
const result = formatState(state as ExtensionState)
expect(result).toContain("Hello")
})
it("should include task header when currentTaskItem exists", () => {
const state: Partial<ExtensionState> = {
currentTaskItem: {
id: "task-1",
ts: Date.now(),
task: "Do something",
tokensIn: 10,
tokensOut: 20,
modelId: "gpt-4",
totalCost: 0.0025,
},
clineMessages: [],
}
const result = formatState(state as ExtensionState)
expect(result).toContain("Task: task-1")
})
it("should handle empty messages array", () => {
const state: Partial<ExtensionState> = {
clineMessages: [],
}
const result = formatState(state as ExtensionState)
expect(result).toBe("")
})
it("should handle undefined messages", () => {
const state: Partial<ExtensionState> = {}
const result = formatState(state as ExtensionState)
expect(result).toBe("")
})
})
describe("Spinner", () => {
let spinner: Spinner
let writeSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
spinner = new Spinner()
writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true)
vi.useFakeTimers()
})
afterEach(() => {
spinner.stop()
vi.restoreAllMocks()
vi.useRealTimers()
})
it("should start spinning with message", () => {
spinner.start("Loading...")
vi.advanceTimersByTime(80)
expect(writeSpy).toHaveBeenCalled()
const calls = writeSpy.mock.calls.map((c: any[]) => c[0])
expect(calls.some((c: any) => typeof c === "string" && c.includes("Loading..."))).toBe(true)
})
it("should update message", () => {
spinner.start("Initial")
spinner.update("Updated")
vi.advanceTimersByTime(80)
const calls = writeSpy.mock.calls.map((c: any[]) => c[0])
expect(calls.some((c: any) => typeof c === "string" && c.includes("Updated"))).toBe(true)
})
it("should stop with final message", () => {
spinner.start("Loading...")
spinner.stop("Done!")
const calls = writeSpy.mock.calls.map((c: any[]) => c[0])
expect(calls.some((c: any) => typeof c === "string" && c.includes("Done!"))).toBe(true)
})
it("should clear line when stopped without message", () => {
spinner.start("Loading...")
spinner.stop()
expect(writeSpy).toHaveBeenCalled()
})
it("should show failure message", () => {
spinner.start("Loading...")
spinner.fail("Failed!")
const calls = writeSpy.mock.calls.map((c: any[]) => c[0])
expect(calls.some((c: any) => typeof c === "string" && c.includes("Failed!"))).toBe(true)
})
})
})
-460
View File
@@ -1,460 +0,0 @@
/**
* Terminal display utilities for rendering Cline messages in the CLI
*/
import type { ClineAsk, ClineMessage, ClineSay, ExtensionState } from "@shared/ExtensionMessage"
import { originalConsoleError, originalConsoleLog } from "./console"
// ANSI color codes for terminal output
const colors = {
reset: "\x1b[0m",
bold: "\x1b[1m",
dim: "\x1b[2m",
italic: "\x1b[3m",
underline: "\x1b[4m",
// Foreground colors
black: "\x1b[30m",
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
magenta: "\x1b[35m",
cyan: "\x1b[36m",
white: "\x1b[37m",
// Bright foreground colors
brightBlack: "\x1b[90m",
brightRed: "\x1b[91m",
brightGreen: "\x1b[92m",
brightYellow: "\x1b[93m",
brightBlue: "\x1b[94m",
brightMagenta: "\x1b[95m",
brightCyan: "\x1b[96m",
brightWhite: "\x1b[97m",
// Background colors
bgBlack: "\x1b[40m",
bgRed: "\x1b[41m",
bgGreen: "\x1b[42m",
bgYellow: "\x1b[43m",
bgBlue: "\x1b[44m",
bgMagenta: "\x1b[45m",
bgCyan: "\x1b[46m",
bgWhite: "\x1b[47m",
}
export function colorize(text: string, ...colorCodes: string[]): string {
return colorCodes.join("") + text + colors.reset
}
// Helper functions for common color combinations
export const style = {
bold: (text: string) => colorize(text, colors.bold),
dim: (text: string) => colorize(text, colors.dim),
italic: (text: string) => colorize(text, colors.italic),
error: (text: string) => colorize(text, colors.red, colors.bold),
warning: (text: string) => colorize(text, colors.yellow),
success: (text: string) => colorize(text, colors.green),
info: (text: string) => colorize(text, colors.cyan),
// Message type colors
task: (text: string) => colorize(text, colors.brightWhite, colors.bold),
tool: (text: string) => colorize(text, colors.blue),
command: (text: string) => colorize(text, colors.magenta),
api: (text: string) => colorize(text, colors.brightBlack),
user: (text: string) => colorize(text, colors.green),
assistant: (text: string) => colorize(text, colors.cyan),
// Special formatting
path: (text: string) => colorize(text, colors.underline, colors.blue),
code: (text: string) => colorize(text, colors.bgBlack, colors.brightWhite),
}
/**
* Format a timestamp for display
*/
export function formatTimestamp(ts: number): string {
const date = new Date(ts)
return date.toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})
}
/**
* Get a prefix icon for different message types
*/
function getMessageIcon(message: ClineMessage): string {
if (message.type === "ask") {
switch (message.ask) {
case "followup":
return "❓"
case "command":
case "command_output":
return "⚙️ "
case "tool":
return "🔧"
case "completion_result":
return "✅"
case "api_req_failed":
return "❌"
case "resume_task":
case "resume_completed_task":
return "▶️ "
case "browser_action_launch":
return "🌐"
case "use_mcp_server":
return "🔌"
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 "️ "
default:
return " "
}
}
}
/**
* Format a ClineMessage for terminal display
*/
export function formatMessage(message: ClineMessage, verbose: boolean = false): string {
const icon = getMessageIcon(message)
const timestamp = formatTimestamp(message.ts)
const lines: string[] = []
const prefix = `${style.dim(timestamp)} ${icon}`
if (message.type === "ask") {
lines.push(formatAskMessage(message, prefix, verbose))
} else {
lines.push(formatSayMessage(message, prefix, verbose))
}
return lines.filter(Boolean).join("\n")
}
function formatAskMessage(message: ClineMessage, prefix: string, verbose: boolean): string {
const ask = message.ask as ClineAsk
switch (ask) {
case "followup": {
// Parse JSON question format
let question = message.text || ""
try {
const parsed = JSON.parse(message.text || "{}")
question = parsed.question || question
} catch {
// Fallback to raw text if not JSON
question = message.text || ""
}
return `${prefix} ${style.info("Question:")} ${question}`
}
case "command":
return `${prefix} ${style.command("Execute command?")} ${style.code(message.text || "")}`
case "tool":
return `${prefix} ${style.tool("Use tool?")} ${message.text || ""}`
case "completion_result":
return `${prefix} ${style.success("Task completed")} ${message.text ? `- ${message.text}` : ""}`
case "api_req_failed":
return `${prefix} ${style.error("API request failed")} ${message.text || ""}`
case "resume_task":
case "resume_completed_task":
return `${prefix} ${style.info("Resume task?")} ${message.text || ""}`
case "browser_action_launch":
return `${prefix} ${style.info("Launch browser?")} ${message.text || ""}`
case "use_mcp_server":
return `${prefix} ${style.info("Use MCP server?")} ${message.text || ""}`
case "plan_mode_respond":
return `${prefix} ${style.info("Plan mode response:")} ${message.text || ""}`
default:
return verbose ? `${prefix} [ASK:${ask}] ${message.text || ""}` : ""
}
}
function formatSayMessage(message: ClineMessage, prefix: string, verbose: boolean): string {
const say = message.say as ClineSay
switch (say) {
case "task":
return `${prefix} ${style.task("Task:")} ${message.text || ""}`
case "text":
return `${prefix} ${style.assistant(message.text || "")}`
case "reasoning":
return `${prefix} ${style.dim("Thinking:")} ${style.italic(message.text || "")}`
case "error":
return `${prefix} ${style.error("Error:")} ${message.text || ""}`
case "completion_result":
return `${prefix} ${style.success("✓ Completed:")} ${message.text || ""}`
case "user_feedback":
return `${prefix} ${style.user("User:")} ${message.text || ""}`
case "command":
return `${prefix} ${style.command("Command:")} ${style.code(message.text || "")}`
case "command_output":
const output = message.text || ""
const truncated = output.length > 500 ? output.substring(0, 500) + "..." : output
return `${prefix} ${style.dim("Output:")} ${truncated}`
case "tool":
return `${prefix} ${style.tool("Tool:")} ${message.text || ""}`
case "browser_action":
case "browser_action_launch":
return `${prefix} ${style.info("Browser:")} ${message.text || ""}`
case "browser_action_result":
return `${prefix} ${style.dim("Browser result")} ${message.text ? `- ${message.text.substring(0, 100)}...` : ""}`
case "mcp_server_request_started":
return `${prefix} ${style.info("MCP request started")} ${message.text || ""}`
case "mcp_server_response":
return `${prefix} ${style.info("MCP response")} ${message.text ? message.text.substring(0, 200) : ""}`
case "api_req_started":
return verbose ? `${prefix} ${style.api("API request started")}` : `${message.text || ""}`
case "api_req_finished":
return verbose ? `${prefix} ${style.api("API request finished")}` : ""
case "checkpoint_created":
return `${prefix} ${style.success("Checkpoint created")} ${message.text || ""}`
case "info":
return `${prefix} ${style.info(message.text || "")}`
case "hook_status":
return `${prefix} ${style.dim("Hook:")} ${message.text || ""}`
case "task_progress":
return `${prefix} ${style.info("Progress:")} ${message.text || ""}`
default:
return verbose ? `${prefix} [SAY:${say}] ${message.text || ""}` : ""
}
}
/**
* Display a horizontal separator
*/
export function separator(char: string = "─", width: number = 60): string {
return style.dim(char.repeat(width))
}
/**
* Display the task header
*/
export function taskHeader(taskId: string, task?: string): string {
const lines = [
separator("═"),
style.bold(` Task: ${taskId}`),
task ? ` ${style.dim(task.substring(0, 80))}${task.length > 80 ? "..." : ""}` : "",
separator("═"),
]
return lines.filter(Boolean).join("\n")
}
/**
* Format the current state for display
*/
export function formatState(state: ExtensionState, verbose: boolean = false): string {
const lines: string[] = []
if (state.currentTaskItem) {
lines.push(taskHeader(state.currentTaskItem.id, state.currentTaskItem.task))
}
// Show messages
if (state.clineMessages && state.clineMessages.length > 0) {
const messagesToShow = verbose
? state.clineMessages
: state.clineMessages.filter((m) => {
// Filter out noisy messages in non-verbose mode
// if (m.say === "api_req_started" || m.say === "api_req_finished") return false
return true
})
for (const message of messagesToShow) {
const formatted = formatMessage(message, verbose)
if (formatted) {
lines.push(formatted)
}
}
}
return lines.join("\n")
}
/**
* Display a spinner with message
*/
export class Spinner {
private frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
private frameIndex = 0
private interval: NodeJS.Timeout | null = null
private message: string = ""
start(message: string) {
this.message = message
this.interval = setInterval(() => {
const frame = this.frames[this.frameIndex]
process.stdout.write(`\r${style.info(frame)} ${this.message}`)
this.frameIndex = (this.frameIndex + 1) % this.frames.length
}, 80)
}
update(message: string) {
this.message = message
}
stop(finalMessage?: string) {
if (this.interval) {
clearInterval(this.interval)
this.interval = null
}
if (finalMessage) {
process.stdout.write(`\r${style.success("✓")} ${finalMessage}\n`)
} else {
process.stdout.write("\r" + " ".repeat(this.message.length + 4) + "\r")
}
}
fail(message?: string) {
if (this.interval) {
clearInterval(this.interval)
this.interval = null
}
if (message) {
process.stdout.write(`\r${style.error("✗")} ${message}\n`)
}
}
}
/**
* Clear the current line
*/
export function clearLine() {
process.stdout.write("\r\x1b[K")
}
/**
* Move cursor up n lines
*/
export function cursorUp(n: number = 1) {
process.stdout.write(`\x1b[${n}A`)
}
/**
* Print a message to stdout with newline
* Uses original console.log to work even when console is suppressed
*/
export function print(message: string) {
originalConsoleLog(message)
}
/**
* Print an error message to stderr
* Uses original console.error to work even when console is suppressed
*/
export function printError(message: string) {
originalConsoleError(style.error(message))
}
/**
* Print a success message
*/
export function printSuccess(message: string) {
originalConsoleLog(style.success(message))
}
/**
* Print an info message
*/
export function printInfo(message: string) {
originalConsoleLog(style.info(message))
}
/**
* Print a warning message
*/
export function printWarning(message: string) {
originalConsoleLog(style.warning(message))
}
/**
* Prompt user for input from stdin
*/
export async function promptUser(question: string): Promise<string> {
const readline = await import("readline")
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
})
return new Promise((resolve) => {
rl.question(style.info(question) + " ", (answer: string) => {
rl.close()
resolve(answer.trim())
})
})
}
/**
* Prompt user for yes/no confirmation
*/
export async function promptConfirmation(question: string): Promise<boolean> {
const answer = await promptUser(`${question} ${style.dim("(y/n)")}`)
return answer.toLowerCase() === "y" || answer.toLowerCase() === "yes"
}
-256
View File
@@ -1,256 +0,0 @@
/**
* File search utility for CLI
* Uses ripgrep if available, otherwise falls back to Node.js fs.readdir
* FZF is used for fuzzy matching
*/
import { execFileSync, spawn } from "node:child_process"
import { promises as fs } from "node:fs"
import { basename, dirname, join, relative } from "node:path"
import { createInterface } from "node:readline"
import type { Fzf, FzfResultItem } from "fzf"
export interface FileSearchResult {
path: string
type: "file" | "folder"
label: string
}
const EXCLUDED_DIRS = new Set([
"node_modules",
".git",
".github",
"out",
"dist",
"__pycache__",
".venv",
".env",
"venv",
"env",
".cache",
"tmp",
"temp",
".next",
"coverage",
"build",
])
const RG_EXCLUDE_GLOB = "!**/{node_modules,.git,.github,out,dist,__pycache__,.venv,.env,venv,env,.cache,tmp,temp}/**"
// Cached state
let ripgrepAvailable: boolean | null = null
let ripgrepWarningShown = false
let fzfModule: { Fzf: typeof Fzf; byLengthAsc: any } | null = null
function checkRipgrep(): boolean {
if (ripgrepAvailable !== null) {
return ripgrepAvailable
}
try {
execFileSync("which", ["rg"], { stdio: "ignore" })
ripgrepAvailable = true
} catch {
ripgrepAvailable = false
}
return ripgrepAvailable
}
function addParentDirs(relativePath: string, dirSet: Set<string>): void {
let dir = dirname(relativePath)
while (dir && dir !== "." && dir !== "/") {
dirSet.add(dir)
dir = dirname(dir)
}
}
function dirsToResults(dirSet: Set<string>): FileSearchResult[] {
return Array.from(dirSet, (p) => ({ path: p, type: "folder" as const, label: basename(p) }))
}
async function listFilesWithNodeFs(workspacePath: string, limit: number): Promise<FileSearchResult[]> {
const files: FileSearchResult[] = []
const dirs = new Set<string>()
async function walk(dir: string): Promise<void> {
if (files.length >= limit) {
return
}
try {
const entries = await fs.readdir(dir, { withFileTypes: true })
for (const entry of entries) {
if (files.length >= limit) {
break
}
const name = entry.name
if (entry.isDirectory() && EXCLUDED_DIRS.has(name)) {
continue
}
if (name.startsWith(".") && !name.startsWith(".cline")) {
continue
}
const fullPath = join(dir, name)
const relativePath = relative(workspacePath, fullPath)
if (entry.isDirectory()) {
dirs.add(relativePath)
await walk(fullPath)
} else if (entry.isFile()) {
files.push({ path: relativePath, type: "file", label: name })
addParentDirs(relativePath, dirs)
}
}
} catch {
return
}
}
await walk(workspacePath)
return [...files, ...dirsToResults(dirs)]
}
async function listFilesWithRipgrep(workspacePath: string, limit: number): Promise<FileSearchResult[]> {
return new Promise((resolve, reject) => {
const rg = spawn("rg", ["--files", "--follow", "--hidden", "-g", RG_EXCLUDE_GLOB, workspacePath])
const rl = createInterface({ input: rg.stdout })
const files: FileSearchResult[] = []
const dirs = new Set<string>()
let stderr = ""
rl.on("line", (line) => {
if (files.length >= limit) {
rl.close()
rg.kill()
return
}
const relativePath = relative(workspacePath, line)
files.push({ path: relativePath, type: "file", label: basename(relativePath) })
addParentDirs(relativePath, dirs)
})
rg.stderr.on("data", (data) => {
stderr += data
})
rl.on("close", () => {
if (stderr && files.length === 0) {
reject(new Error(`ripgrep error: ${stderr.trim()}`))
} else {
resolve([...files, ...dirsToResults(dirs)])
}
})
rg.on("error", (err) => reject(new Error(`ripgrep error: ${err.message}`)))
})
}
export function checkAndWarnRipgrepMissing(): boolean {
if (!checkRipgrep() && !ripgrepWarningShown) {
ripgrepWarningShown = true
return true
}
return false
}
export function getRipgrepInstallInstructions(): string {
switch (process.platform) {
case "darwin":
return "brew install ripgrep"
case "linux":
return "apt install ripgrep # or: yum install ripgrep"
case "win32":
return "choco install ripgrep # or: scoop install ripgrep"
default:
return "https://github.com/BurntSushi/ripgrep#installation"
}
}
export async function listWorkspaceFiles(workspacePath: string, limit = 5000): Promise<FileSearchResult[]> {
if (checkRipgrep()) {
try {
return await listFilesWithRipgrep(workspacePath, limit)
} catch {
ripgrepAvailable = false
}
}
return listFilesWithNodeFs(workspacePath, limit)
}
function countGaps(positions: Iterable<number>): number {
let gaps = 0
let prev = -Infinity
for (const pos of positions) {
if (prev !== -Infinity && pos - prev > 1) {
gaps++
}
prev = pos
}
return gaps
}
const orderByMatchScore = (a: FzfResultItem<FileSearchResult>, b: FzfResultItem<FileSearchResult>) =>
countGaps(a.positions) - countGaps(b.positions)
export async function searchWorkspaceFiles(
query: string,
workspacePath: string,
limit = 15,
selectedType?: "file" | "folder",
): Promise<FileSearchResult[]> {
try {
let items = await listWorkspaceFiles(workspacePath, 5000)
if (selectedType) {
items = items.filter((item) => item.type === selectedType)
}
if (!query.trim()) {
return items.slice(0, limit)
}
// Lazy load fzf module
if (!fzfModule) {
fzfModule = await import("fzf")
}
const fzf = new fzfModule.Fzf(items, {
selector: (item: FileSearchResult) => `${item.label} ${item.path}`,
tiebreakers: [orderByMatchScore, fzfModule.byLengthAsc],
limit: limit * 2,
})
return fzf
.find(query)
.slice(0, limit)
.map((r) => r.item)
} catch (error) {
console.error("File search error:", error)
return []
}
}
export function extractMentionQuery(text: string): { inMentionMode: boolean; query: string; atIndex: number } {
const lastAtIndex = text.lastIndexOf("@")
if (lastAtIndex === -1 || (lastAtIndex > 0 && !/\s/.test(text[lastAtIndex - 1]))) {
return { inMentionMode: false, query: "", atIndex: -1 }
}
const afterAt = text.slice(lastAtIndex + 1)
if (afterAt.includes(" ")) {
return { inMentionMode: false, query: "", atIndex: -1 }
}
return { inMentionMode: true, query: afterAt, atIndex: lastAtIndex }
}
export function insertMention(text: string, atIndex: number, filePath: string): string {
const endIndex = text.indexOf(" ", atIndex)
const end = endIndex === -1 ? text.length : endIndex
const mention = filePath.includes(" ") ? `@"${filePath}"` : `@${filePath}`
return text.slice(0, atIndex) + mention + " " + text.slice(end).trimStart()
}
-232
View File
@@ -1,232 +0,0 @@
import fs from "node:fs"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { imageFileToDataUrl, isImagePath, jsonParseSafe, parseImagesFromInput, processImagePaths } from "./parser"
describe("parser", () => {
describe("jsonParseSafe", () => {
it("should parse valid JSON", () => {
const result = jsonParseSafe('{"key": "value"}', {})
expect(result).toEqual({ key: "value" })
})
it("should return default value for invalid JSON", () => {
const defaultValue = { fallback: true }
const result = jsonParseSafe("not valid json", defaultValue)
expect(result).toEqual(defaultValue)
})
it("should parse arrays", () => {
const result = jsonParseSafe("[1, 2, 3]", [])
expect(result).toEqual([1, 2, 3])
})
it("should handle empty string", () => {
const result = jsonParseSafe("", "default")
expect(result).toBe("default")
})
it("should parse nested objects", () => {
const json = '{"outer": {"inner": "value"}}'
const result = jsonParseSafe(json, {})
expect(result).toEqual({ outer: { inner: "value" } })
})
})
describe("isImagePath", () => {
it("should return true for .png files", () => {
expect(isImagePath("/path/to/image.png")).toBe(true)
})
it("should return true for .jpg files", () => {
expect(isImagePath("/path/to/image.jpg")).toBe(true)
})
it("should return true for .jpeg files", () => {
expect(isImagePath("/path/to/image.jpeg")).toBe(true)
})
it("should return true for .gif files", () => {
expect(isImagePath("/path/to/image.gif")).toBe(true)
})
it("should return true for .webp files", () => {
expect(isImagePath("/path/to/image.webp")).toBe(true)
})
it("should return false for non-image files", () => {
expect(isImagePath("/path/to/file.txt")).toBe(false)
expect(isImagePath("/path/to/file.pdf")).toBe(false)
expect(isImagePath("/path/to/file.js")).toBe(false)
})
it("should handle uppercase extensions", () => {
expect(isImagePath("/path/to/image.PNG")).toBe(true)
expect(isImagePath("/path/to/image.JPG")).toBe(true)
})
it("should handle mixed case extensions", () => {
expect(isImagePath("/path/to/image.Png")).toBe(true)
})
})
describe("parseImagesFromInput", () => {
it("should extract image paths with @ prefix", () => {
const input = "analyze this image @/path/to/image.png"
const result = parseImagesFromInput(input)
expect(result.imagePaths).toContain("/path/to/image.png")
expect(result.prompt).toBe("analyze this image")
})
it("should extract multiple images", () => {
const input = "compare @/img1.png and @/img2.jpg"
const result = parseImagesFromInput(input)
expect(result.imagePaths).toContain("/img1.png")
expect(result.imagePaths).toContain("/img2.jpg")
})
it("should handle standalone image paths", () => {
const input = "look at /path/to/image.png please"
const result = parseImagesFromInput(input)
expect(result.imagePaths).toContain("/path/to/image.png")
})
it("should return empty array when no images", () => {
const input = "just some text without images"
const result = parseImagesFromInput(input)
expect(result.imagePaths).toEqual([])
expect(result.prompt).toBe("just some text without images")
})
it("should handle image at start of input", () => {
const input = "@/start.png is the image"
const result = parseImagesFromInput(input)
expect(result.imagePaths).toContain("/start.png")
})
it("should handle all supported image extensions", () => {
const input = "@/a.png @/b.jpg @/c.jpeg @/d.gif @/e.webp"
const result = parseImagesFromInput(input)
expect(result.imagePaths).toHaveLength(5)
})
it("should not duplicate image paths", () => {
const input = "@/same.png /same.png"
const result = parseImagesFromInput(input)
// Both patterns match the same path, should not duplicate
expect(result.imagePaths.filter((p) => p === "/same.png").length).toBeLessThanOrEqual(2)
})
it("should clean up extra whitespace in prompt", () => {
const input = "text @/image.png more text"
const result = parseImagesFromInput(input)
expect(result.prompt).toBe("text more text")
})
})
describe("imageFileToDataUrl", () => {
beforeEach(() => {
vi.spyOn(fs.promises, "readFile")
})
afterEach(() => {
vi.restoreAllMocks()
})
it("should convert png to data URL", async () => {
const mockBuffer = Buffer.from("fake png data")
vi.mocked(fs.promises.readFile).mockResolvedValue(mockBuffer)
const result = await imageFileToDataUrl("/path/to/image.png")
expect(result).toMatch(/^data:image\/png;base64,/)
expect(result).toContain(mockBuffer.toString("base64"))
})
it("should use correct MIME type for jpeg", async () => {
const mockBuffer = Buffer.from("fake jpeg data")
vi.mocked(fs.promises.readFile).mockResolvedValue(mockBuffer)
const result = await imageFileToDataUrl("/path/to/image.jpg")
expect(result).toMatch(/^data:image\/jpeg;base64,/)
})
it("should use correct MIME type for gif", async () => {
const mockBuffer = Buffer.from("fake gif data")
vi.mocked(fs.promises.readFile).mockResolvedValue(mockBuffer)
const result = await imageFileToDataUrl("/path/to/image.gif")
expect(result).toMatch(/^data:image\/gif;base64,/)
})
it("should use correct MIME type for webp", async () => {
const mockBuffer = Buffer.from("fake webp data")
vi.mocked(fs.promises.readFile).mockResolvedValue(mockBuffer)
const result = await imageFileToDataUrl("/path/to/image.webp")
expect(result).toMatch(/^data:image\/webp;base64,/)
})
})
describe("processImagePaths", () => {
beforeEach(() => {
vi.spyOn(fs, "existsSync")
vi.spyOn(fs.promises, "readFile")
})
afterEach(() => {
vi.restoreAllMocks()
})
it("should process existing image files", async () => {
vi.mocked(fs.existsSync).mockReturnValue(true)
vi.mocked(fs.promises.readFile).mockResolvedValue(Buffer.from("image data"))
const result = await processImagePaths(["/path/to/image.png"])
expect(result).toHaveLength(1)
expect(result[0]).toMatch(/^data:image\/png;base64,/)
})
it("should skip non-existent files", async () => {
vi.mocked(fs.existsSync).mockReturnValue(false)
const result = await processImagePaths(["/nonexistent/image.png"])
expect(result).toHaveLength(0)
})
it("should skip non-image files", async () => {
vi.mocked(fs.existsSync).mockReturnValue(true)
const result = await processImagePaths(["/path/to/file.txt"])
expect(result).toHaveLength(0)
})
it("should process multiple images", async () => {
vi.mocked(fs.existsSync).mockReturnValue(true)
vi.mocked(fs.promises.readFile).mockResolvedValue(Buffer.from("image data"))
const result = await processImagePaths(["/img1.png", "/img2.jpg", "/img3.gif"])
expect(result).toHaveLength(3)
})
it("should handle read errors gracefully", async () => {
vi.mocked(fs.existsSync).mockReturnValue(true)
vi.mocked(fs.promises.readFile).mockRejectedValue(new Error("Read error"))
const result = await processImagePaths(["/path/to/image.png"])
expect(result).toHaveLength(0)
})
it("should handle empty input", async () => {
const result = await processImagePaths([])
expect(result).toEqual([])
})
})
})
-100
View File
@@ -1,100 +0,0 @@
import fs from "node:fs"
import path from "node:path"
export function jsonParseSafe<T>(data: string, defaultValue: T): T {
try {
return JSON.parse(data) as T
} catch {
return defaultValue
}
}
const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"])
/**
* Check if a file path is an image based on extension
*/
export function isImagePath(filePath: string): boolean {
const ext = path.extname(filePath).toLowerCase()
return IMAGE_EXTENSIONS.has(ext)
}
/**
* Get MIME type for an image extension
*/
function getMimeType(ext: string): string {
const mimeTypes: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
}
return mimeTypes[ext.toLowerCase()] || "image/png"
}
/**
* Convert an image file path to a base64 data URL
*/
export async function imageFileToDataUrl(filePath: string): Promise<string> {
const resolvedPath = path.resolve(filePath)
const ext = path.extname(resolvedPath).toLowerCase()
const mimeType = getMimeType(ext)
const buffer = await fs.promises.readFile(resolvedPath)
const base64 = buffer.toString("base64")
return `data:${mimeType};base64,${base64}`
}
/**
* Parse input text and extract image file paths.
* Supports formats like: "prompt text @/path/to/image.png" or just file paths
* Returns the clean prompt text and array of image paths
*/
export function parseImagesFromInput(input: string): { prompt: string; imagePaths: string[] } {
const imagePaths: string[] = []
// Match @/path/to/image.ext patterns (with space or at start)
const atPathPattern = /(?:^|\s)@(\/[^\s]+\.(?:png|jpg|jpeg|gif|webp))/gi
let match: RegExpExecArray | null
while ((match = atPathPattern.exec(input)) !== null) {
imagePaths.push(match[1])
}
// Also match standalone absolute paths that look like images
const standalonePathPattern = /(?:^|\s)(\/[^\s]+\.(?:png|jpg|jpeg|gif|webp))(?:\s|$)/gi
while ((match = standalonePathPattern.exec(input)) !== null) {
const p = match[1]
if (!imagePaths.includes(p)) {
imagePaths.push(p)
}
}
// Remove the image references from the prompt
const prompt = input.replace(atPathPattern, " ").replace(standalonePathPattern, " ").replace(/\s+/g, " ").trim()
return { prompt, imagePaths }
}
/**
* Process image file paths into base64 data URLs
* Returns only successfully converted images
*/
export async function processImagePaths(imagePaths: string[]): Promise<string[]> {
const dataUrls: string[] = []
for (const imagePath of imagePaths) {
try {
const resolvedPath = path.resolve(imagePath)
if (fs.existsSync(resolvedPath) && isImagePath(resolvedPath)) {
const dataUrl = await imageFileToDataUrl(resolvedPath)
dataUrls.push(dataUrl)
}
} catch {
// Skip files that can't be read
}
}
return dataUrls
}
-84
View File
@@ -1,84 +0,0 @@
import { ApiProvider } from "@/shared/api"
// Map providers to their specific model ID keys
// Note: "cline" provider uses the same model ID key as "openrouter"
const ProviderKeyMap: Partial<Record<ApiProvider, string>> = {
openrouter: "OpenRouterModelId",
cline: "OpenRouterModelId", // Cline provider uses OpenRouter model IDs
openai: "OpenAiModelId",
ollama: "OllamaModelId",
lmstudio: "LmStudioModelId",
litellm: "LiteLlmModelId",
requesty: "RequestyModelId",
together: "TogetherModelId",
fireworks: "FireworksModelId",
sapaicore: "SapAiCoreModelId",
groq: "GroqModelId",
baseten: "BasetenModelId",
huggingface: "HuggingFaceModelId",
"huawei-cloud-maas": "HuaweiCloudMaasModelId",
oca: "OcaModelId",
aihubmix: "AihubmixModelId",
hicap: "HicapModelId",
nousResearch: "NousResearchModelId",
"vercel-ai-gateway": "VercelAiGatewayModelId",
} as const
export const ProviderToApiKeyMap: Partial<Record<ApiProvider, string | string[]>> = {
anthropic: "apiKey",
openrouter: "openRouterApiKey",
bedrock: ["awsAccessKey", "awsBedrockApiKey"],
openai: "openAiApiKey",
gemini: "geminiApiKey",
"openai-native": "openAiNativeApiKey",
ollama: "ollamaApiKey",
requesty: "requestyApiKey",
together: "togetherApiKey",
deepseek: "deepSeekApiKey",
qwen: "qwenApiKey",
"qwen-code": "qwenApiKey",
doubao: "doubaoApiKey",
mistral: "mistralApiKey",
litellm: "liteLlmApiKey",
moonshot: "moonshotApiKey",
nebius: "nebiusApiKey",
fireworks: "fireworksApiKey",
asksage: "asksageApiKey",
xai: "xaiApiKey",
sambanova: "sambanovaApiKey",
cerebras: "cerebrasApiKey",
groq: "groqApiKey",
huggingface: "huggingFaceApiKey",
"huawei-cloud-maas": "huaweiCloudMaasApiKey",
dify: "difyApiKey",
baseten: "basetenApiKey",
"vercel-ai-gateway": "vercelAiGatewayApiKey",
zai: "zaiApiKey",
oca: "ocaApiKey",
aihubmix: "aihubmixApiKey",
minimax: "minimaxApiKey",
hicap: "hicapApiKey",
nousResearch: "nousResearchApiKey",
sapaicore: ["sapAiCoreClientId", "sapAiCoreClientSecret"],
cline: "clineAccountId",
} as const
/**
* Get the provider-specific model ID key for a given provider and mode.
* Different providers store their model IDs in different state keys.
*/
export function getProviderModelIdKey(
provider: ApiProvider,
mode: "act" | "plan",
): keyof import("@shared/storage/state-keys").Settings | null {
const prefix = mode === "act" ? "actMode" : "planMode"
const keySuffix = ProviderKeyMap[provider]
if (keySuffix) {
return `${prefix}${keySuffix}` as keyof import("@shared/storage/state-keys").Settings
}
// For providers without a specific key (anthropic, gemini, bedrock, etc.),
// they use the generic actModeApiModelId/planModeApiModelId
return null
}
-302
View File
@@ -1,302 +0,0 @@
/**
* VSCode context stub for CLI mode
* Provides mock implementations of VSCode extension context
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
import os from "os"
import path from "path"
import type { Memento, SecretStorage } from "vscode"
import { ExtensionRegistryInfo } from "@/registry"
import { ClineClient, ClineExtensionContext } from "@/shared/clients"
import { globalStorage } from "@/shared/storage"
import { ExtensionKind, ExtensionMode, URI } from "./vscode-shim"
const SETTINGS_SUBFOLDER = "data"
/**
* CLI-specific state overrides.
* These values are always returned regardless of what's stored,
* and writes to these keys are silently ignored.
*/
const CLI_STATE_OVERRIDES: Record<string, any> = {
// CLI always uses background execution, not VSCode terminal
vscodeTerminalExecutionMode: "backgroundExec",
backgroundEditEnabled: true,
multiRootEnabled: false,
enableCheckpointsSetting: false,
browserSettings: {
disableToolUse: true,
},
}
/**
* Simple file-based Memento store for persisting state
*/
class MementoStore implements Memento {
private data: Record<string, any> = {}
private filePath: string
constructor(filePath: string) {
this.filePath = filePath
this.load()
}
private load() {
try {
if (existsSync(this.filePath)) {
const content = readFileSync(this.filePath, "utf8")
this.data = JSON.parse(content)
}
} catch (error) {
console.error(`Failed to load state from ${this.filePath}:`, error)
this.data = {}
}
}
private save() {
try {
mkdirSync(path.dirname(this.filePath), { recursive: true })
writeFileSync(this.filePath, JSON.stringify(this.data, null, 2))
} catch (error) {
console.error(`Failed to save state to ${this.filePath}:`, error)
}
}
keys(): readonly string[] {
return Object.keys(this.data)
}
get<T>(key: string): T | undefined
get<T>(key: string, defaultValue: T): T
get<T>(key: string, defaultValue?: T): T | undefined {
// Return CLI overrides for locked keys
if (key in CLI_STATE_OVERRIDES) {
return CLI_STATE_OVERRIDES[key] as T
}
const value = this.data[key]
return value !== undefined ? value : defaultValue
}
async update(key: string, value: any): Promise<void> {
// Silently ignore writes to CLI-locked keys
if (key in CLI_STATE_OVERRIDES) {
return
}
if (value === undefined) {
delete this.data[key]
} else {
this.data[key] = value
}
this.save()
}
setKeysForSync(_keys: readonly string[]): void {
// No-op for CLI
}
}
/**
* Simple file-based secret storage
*/
class SecretStore implements SecretStorage {
private data: Record<string, string> = {}
private filePath: string
private onDidChangeEmitter = {
event: () => ({ dispose: () => {} }),
fire: (_e: any) => {},
dispose: () => {},
}
onDidChange = this.onDidChangeEmitter.event
constructor(filePath: string) {
this.filePath = filePath
this.load()
}
private load() {
try {
if (existsSync(this.filePath)) {
const content = readFileSync(this.filePath, "utf8")
this.data = JSON.parse(content)
}
} catch {
this.data = {}
}
}
private save() {
try {
mkdirSync(path.dirname(this.filePath), { recursive: true })
writeFileSync(this.filePath, JSON.stringify(this.data, null, 2))
} catch (error) {
console.error(`Failed to save secrets:`, error)
}
}
async get(key: string): Promise<string | undefined> {
return this.data[key]
}
async store(key: string, value: string): Promise<void> {
this.data[key] = value
this.save()
}
async delete(key: string): Promise<void> {
delete this.data[key]
this.save()
}
}
/**
* Mock environment variable collection
*/
class EnvironmentVariableCollection {
private variables: Map<string, any> = new Map()
persistent = true
description = "CLI Environment Variables"
entries(): IterableIterator<[string, any]> {
return this.variables.entries()
}
replace(variable: string, value: string) {
this.variables.set(variable, { value, type: "replace" })
}
append(variable: string, value: string) {
this.variables.set(variable, { value, type: "append" })
}
prepend(variable: string, value: string) {
this.variables.set(variable, { value, type: "prepend" })
}
get(variable: string) {
return this.variables.get(variable)
}
forEach(callback: (variable: string, mutator: any, collection: any) => void) {
this.variables.forEach((mutator, variable) => {
callback(variable, mutator, this)
})
}
delete(variable: string) {
return this.variables.delete(variable)
}
clear() {
this.variables.clear()
}
getScoped(_scope: any) {
return this
}
}
function readJson(filePath: string): any {
try {
if (existsSync(filePath)) {
return JSON.parse(readFileSync(filePath, "utf8"))
}
} catch {
// Return empty object if file doesn't exist
}
return {}
}
export interface CliContextConfig {
clineDir?: string
/** The workspace directory being worked in (for hashing into storage path) */
workspaceDir?: string
}
/**
* Create a short hash of a string for use in directory names
*/
function hashString(str: string): string {
let hash = 0
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i)
hash = (hash << 5) - hash + char
hash = hash & hash // Convert to 32bit integer
}
return Math.abs(hash).toString(16).substring(0, 8)
}
/**
* Initialize the VSCode-like context for CLI mode
*/
export function initializeCliContext(config: CliContextConfig = {}) {
const CLINE_DIR = config.clineDir || process.env.CLINE_DIR || path.join(os.homedir(), ".cline")
const DATA_DIR = path.join(CLINE_DIR, SETTINGS_SUBFOLDER)
// Workspace storage should always be under ~/.cline/data/workspaces/<hash>/
// where hash is derived from the workspace path to keep workspaces isolated
const workspacePath = config.workspaceDir || process.cwd()
const workspaceHash = hashString(workspacePath)
const WORKSPACE_STORAGE_DIR = process.env.WORKSPACE_STORAGE_DIR || path.join(DATA_DIR, "workspaces", workspaceHash)
// Ensure directories exist
mkdirSync(DATA_DIR, { recursive: true })
mkdirSync(WORKSPACE_STORAGE_DIR, { recursive: true })
console.log(`[CLI] Using data directory: ${DATA_DIR}`)
// For CLI, extension dir is the root of the project (parent of cli-ts)
const EXTENSION_DIR = path.resolve(__dirname, "..", "..")
const EXTENSION_MODE = process.env.IS_DEV === "true" ? ExtensionMode.Development : ExtensionMode.Production
const extension: ClineExtensionContext["extension"] = {
id: ExtensionRegistryInfo.id,
isActive: true,
extensionPath: EXTENSION_DIR,
extensionUri: URI.file(EXTENSION_DIR),
packageJSON: readJson(path.join(EXTENSION_DIR, "package.json")),
exports: undefined,
activate: async () => {},
extensionKind: ExtensionKind.UI,
}
const extensionContext: ClineExtensionContext = {
name: ClineClient.Cli,
extension: extension,
extensionMode: EXTENSION_MODE,
// Set up KV stores
globalState: (globalStorage.init("cli") as any) || new MementoStore(path.join(DATA_DIR, "globalState.json")),
secrets: new SecretStore(path.join(DATA_DIR, "secrets.json")),
// Set up URIs
storageUri: URI.file(WORKSPACE_STORAGE_DIR),
storagePath: WORKSPACE_STORAGE_DIR,
globalStorageUri: URI.file(DATA_DIR),
globalStoragePath: DATA_DIR,
// Logs
logUri: URI.file(DATA_DIR),
logPath: DATA_DIR,
extensionUri: URI.file(EXTENSION_DIR),
extensionPath: EXTENSION_DIR,
asAbsolutePath: (relPath: string) => path.join(EXTENSION_DIR, relPath),
subscriptions: [],
environmentVariableCollection: new EnvironmentVariableCollection() as any,
// Workspace state
workspaceState: new MementoStore(path.join(WORKSPACE_STORAGE_DIR, "workspaceState.json")),
}
return {
extensionContext,
DATA_DIR,
EXTENSION_DIR,
WORKSPACE_STORAGE_DIR,
}
}
-286
View File
@@ -1,286 +0,0 @@
/**
* VSCode namespace shim for CLI mode
* Provides minimal stubs for VSCode types and enums used by the codebase
*/
// Re-export common types from vscode-uri for URI handling
export { URI } from "vscode-uri"
// Extension mode enum
export enum ExtensionMode {
Production = 1,
Development = 2,
Test = 3,
}
// Extension kind enum
export enum ExtensionKind {
UI = 1,
Workspace = 2,
}
// Diagnostic severity enum
export enum DiagnosticSeverity {
Error = 0,
Warning = 1,
Information = 2,
Hint = 3,
}
// End of line enum
export enum EndOfLine {
LF = 1,
CRLF = 2,
}
// Position class
export class Position {
constructor(
public readonly line: number,
public readonly character: number,
) {}
isAfter(other: Position): boolean {
return this.line > other.line || (this.line === other.line && this.character > other.character)
}
isAfterOrEqual(other: Position): boolean {
return this.line > other.line || (this.line === other.line && this.character >= other.character)
}
isBefore(other: Position): boolean {
return this.line < other.line || (this.line === other.line && this.character < other.character)
}
isBeforeOrEqual(other: Position): boolean {
return this.line < other.line || (this.line === other.line && this.character <= other.character)
}
isEqual(other: Position): boolean {
return this.line === other.line && this.character === other.character
}
translate(lineDelta?: number, characterDelta?: number): Position {
return new Position(this.line + (lineDelta || 0), this.character + (characterDelta || 0))
}
with(line?: number, character?: number): Position {
return new Position(line ?? this.line, character ?? this.character)
}
compareTo(other: Position): number {
if (this.line < other.line) {
return -1
}
if (this.line > other.line) {
return 1
}
if (this.character < other.character) {
return -1
}
if (this.character > other.character) {
return 1
}
return 0
}
}
// Range class
export class Range {
public readonly start: Position
public readonly end: Position
constructor(start: Position, end: Position)
constructor(startLine: number, startCharacter: number, endLine: number, endCharacter: number)
constructor(
startOrStartLine: Position | number,
endOrStartCharacter: Position | number,
endLine?: number,
endCharacter?: number,
) {
if (typeof startOrStartLine === "number") {
this.start = new Position(startOrStartLine, endOrStartCharacter as number)
this.end = new Position(endLine!, endCharacter!)
} else {
this.start = startOrStartLine
this.end = endOrStartCharacter as Position
}
}
get isEmpty(): boolean {
return this.start.isEqual(this.end)
}
get isSingleLine(): boolean {
return this.start.line === this.end.line
}
contains(positionOrRange: Position | Range): boolean {
if (positionOrRange instanceof Range) {
return this.contains(positionOrRange.start) && this.contains(positionOrRange.end)
}
return positionOrRange.isAfterOrEqual(this.start) && positionOrRange.isBeforeOrEqual(this.end)
}
isEqual(other: Range): boolean {
return this.start.isEqual(other.start) && this.end.isEqual(other.end)
}
intersection(range: Range): Range | undefined {
const start = Position.prototype.isAfter.call(this.start, range.start) ? this.start : range.start
const end = Position.prototype.isBefore.call(this.end, range.end) ? this.end : range.end
if (start.isAfter(end)) {
return undefined
}
return new Range(start, end)
}
union(other: Range): Range {
const start = this.start.isBefore(other.start) ? this.start : other.start
const end = this.end.isAfter(other.end) ? this.end : other.end
return new Range(start, end)
}
with(start?: Position, end?: Position): Range {
return new Range(start ?? this.start, end ?? this.end)
}
}
// Selection class (extends Range)
export class Selection extends Range {
public readonly anchor: Position
public readonly active: Position
constructor(anchor: Position, active: Position)
constructor(anchorLine: number, anchorCharacter: number, activeLine: number, activeCharacter: number)
constructor(
anchorOrAnchorLine: Position | number,
activeOrAnchorCharacter: Position | number,
activeLine?: number,
activeCharacter?: number,
) {
let anchor: Position
let active: Position
if (typeof anchorOrAnchorLine === "number") {
anchor = new Position(anchorOrAnchorLine, activeOrAnchorCharacter as number)
active = new Position(activeLine!, activeCharacter!)
} else {
anchor = anchorOrAnchorLine
active = activeOrAnchorCharacter as Position
}
super(anchor.isBefore(active) ? anchor : active, anchor.isBefore(active) ? active : anchor)
this.anchor = anchor
this.active = active
}
get isReversed(): boolean {
return this.anchor.isAfter(this.active)
}
}
// Cancellation token
export interface CancellationToken {
isCancellationRequested: boolean
onCancellationRequested: any
}
// Event emitter (simplified)
export class EventEmitter<T> {
private listeners: Array<(e: T) => void> = []
event = (listener: (e: T) => void) => {
this.listeners.push(listener)
return {
dispose: () => {
const index = this.listeners.indexOf(listener)
if (index >= 0) {
this.listeners.splice(index, 1)
}
},
}
}
fire(data: T): void {
for (const listener of this.listeners) {
listener(data)
}
}
dispose(): void {
this.listeners = []
}
}
// Disposable
export class Disposable {
constructor(private callOnDispose: () => void) {}
static from(...disposables: { dispose(): any }[]): Disposable {
return new Disposable(() => {
for (const d of disposables) {
d.dispose()
}
})
}
dispose(): void {
this.callOnDispose()
}
}
// Minimal workspace namespace
export const workspace = {
workspaceFolders: undefined as any[] | undefined,
getWorkspaceFolder: (_uri: any) => undefined,
onDidChangeWorkspaceFolders: () => ({ dispose: () => {} }),
fs: {
readFile: async (_uri: any): Promise<Uint8Array> => new Uint8Array(),
writeFile: async (_uri: any, _content: Uint8Array): Promise<void> => {},
delete: async (_uri: any): Promise<void> => {},
stat: async (_uri: any): Promise<any> => ({ type: 1, size: 0 }),
readDirectory: async (_uri: any): Promise<any[]> => [],
createDirectory: async (_uri: any): Promise<void> => {},
},
}
// Minimal window namespace
export const window = {
showInformationMessage: async (message: string) => {
console.log(`[INFO] ${message}`)
return undefined
},
showWarningMessage: async (message: string) => {
console.warn(`[WARN] ${message}`)
return undefined
},
showErrorMessage: async (message: string) => {
console.error(`[ERROR] ${message}`)
return undefined
},
createOutputChannel: (_name: string) => ({
appendLine: (line: string) => console.log(line),
append: (text: string) => process.stdout.write(text),
clear: () => {},
show: () => {},
hide: () => {},
dispose: () => {},
}),
terminals: [] as any[],
activeTerminal: undefined as any,
createTerminal: (_options?: any) => ({
name: "CLI Terminal",
processId: Promise.resolve(process.pid),
sendText: (text: string) => console.log(`[Terminal] ${text}`),
show: () => {},
hide: () => {},
dispose: () => {},
}),
}
// Export types that are commonly used
export type ExtensionContext = any
export type Memento = any
export type SecretStorage = any
// biome-ignore lint/correctness/noUnusedVariables: placeholder
export type Extension<T> = any
-74
View File
@@ -1,74 +0,0 @@
{
"compilerOptions": {
"esModuleInterop": true,
"experimentalDecorators": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"jsx": "react",
"jsxFactory": "React.createElement",
"lib": [
"es2022"
],
"module": "esnext",
"moduleResolution": "Bundler",
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noUnusedLocals": false,
"resolveJsonModule": true,
"rootDir": ".",
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"target": "es2022",
"useDefineForClassFields": true,
"useUnknownInCatchVariables": false,
"ignoreDeprecations": "6.0",
"baseUrl": "..",
"paths": {
"@/*": [
"src/*"
],
"@api/*": [
"src/core/api/*"
],
"@core/*": [
"src/core/*"
],
"@generated/*": [
"src/generated/*"
],
"@hosts/*": [
"src/hosts/*"
],
"@integrations/*": [
"src/integrations/*"
],
"@packages/*": [
"src/packages/*"
],
"@services/*": [
"src/services/*"
],
"@shared/*": [
"src/shared/*"
],
"@utils/*": [
"src/utils/*"
]
},
"outDir": "dist"
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist"
],
"references": [
{
"path": ".."
}
]
}
-29
View File
@@ -1,29 +0,0 @@
import path from "path"
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["src/**/*.test.{ts,tsx}", "tests/**/*.test.{ts,tsx}"],
coverage: {
reporter: ["text", "json", "html"],
exclude: ["node_modules/", "dist/"],
},
},
resolve: {
alias: {
// Match tsconfig paths - baseUrl is parent directory
"@": path.resolve(__dirname, "../src"),
"@api": path.resolve(__dirname, "../src/core/api"),
"@core": path.resolve(__dirname, "../src/core"),
"@generated": path.resolve(__dirname, "../src/generated"),
"@hosts": path.resolve(__dirname, "../src/hosts"),
"@integrations": path.resolve(__dirname, "../src/integrations"),
"@packages": path.resolve(__dirname, "../src/packages"),
"@services": path.resolve(__dirname, "../src/services"),
"@shared": path.resolve(__dirname, "../src/shared"),
"@utils": path.resolve(__dirname, "../src/utils"),
},
},
})
-7
View File
@@ -1,7 +0,0 @@
{
"workflowToggles": {},
"localClineRulesToggles": {},
"localWindsurfRulesToggles": {},
"localCursorRulesToggles": {},
"localAgentsRulesToggles": {}
}
+1 -1
View File
@@ -231,7 +231,7 @@ func promptForInitialTask(ctx context.Context, modeFlag string, workspaces []str
prompt, err := output.PromptForInitialTask(
"Start a new Cline task",
"/plan or /act to switch modes\nctrl+e to open editor\ntab to autocomplete commands",
"/plan or /act to switch modes\ntab to autocomplete commands\nctrl+e to open editor\nctrl+c to exit",
modeFlag,
slash.NewRegistry(ctx),
)
+58
View File
@@ -298,6 +298,64 @@ cline task view
\f[I]# Start interactive chat with this task\f[R]
cline task chat
.EE
.SH ENVIRONMENT
.TP
\f[B]CLINE_COMMAND_PERMISSIONS\f[R]
JSON configuration for restricting which shell commands Cline can
execute.
When set, commands are validated against allow/deny patterns before
execution.
When not set, all commands are allowed (backward compatibility).
.RS
.PP
Format:
\f[CR]{"allow": ["pattern1", "pattern2"], "deny": ["pattern3"], "allowRedirects": true}\f[R]
.PP
\f[B]Fields:\f[R]
.IP \(bu 2
\f[B]allow\f[R] (array of strings): Glob patterns for allowed commands.
If specified, only matching commands are permitted.
Uses \f[CR]*\f[R] to match any characters and \f[CR]?\f[R] to match a
single character.
.IP \(bu 2
\f[B]deny\f[R] (array of strings): Glob patterns for denied commands.
Deny rules take precedence over allow rules.
.IP \(bu 2
\f[B]allowRedirects\f[R] (boolean): Whether to allow shell redirects
(\f[CR]>\f[R], \f[CR]>>\f[R], \f[CR]<\f[R], etc.).
Defaults to false.
.PP
\f[B]Rule evaluation:\f[R]
.IP "1." 3
Check for dangerous characters (backticks outside single quotes,
unquoted newlines)
.IP "2." 3
Parse command into segments split by operators (\f[CR]&&\f[R],
\f[CR]||\f[R], \f[CR]|\f[R], \f[CR];\f[R])
.IP "3." 3
If redirects detected and \f[CR]allowRedirects\f[R] is not true, command
is denied
.IP "4." 3
Each segment is validated against deny rules first, then allow rules
.IP "5." 3
Subshell contents (\f[CR]$(...)\f[R] and \f[CR](...)\f[R]) are
recursively validated
.IP "6." 3
All segments must pass for the command to be allowed
.PP
\f[B]Examples:\f[R]
.IP
.EX
\f[I]# Allow only npm and git commands\f[R]
export CLINE_COMMAND_PERMISSIONS=\(aq{"allow": ["npm *", "git *"]}\(aq
\f[I]# Allow development commands but deny dangerous ones\f[R]
export CLINE_COMMAND_PERMISSIONS=\(aq{"allow": ["npm *", "git *", "node *"], "deny": ["rm \-rf *", "sudo *"]}\(aq
\f[I]# Allow file operations with redirects\f[R]
export CLINE_COMMAND_PERMISSIONS=\(aq{"allow": ["cat *", "echo *"], "allowRedirects": true}\(aq
.EE
.RE
.SH ARCHITECTURE
Cline operates on a three\-layer architecture:
.TP
+36
View File
@@ -323,6 +323,42 @@ cline task view
cline task chat
```
# ENVIRONMENT
**CLINE_COMMAND_PERMISSIONS**
: JSON configuration for restricting which shell commands Cline can execute. When set, commands are validated against allow/deny patterns before execution. When not set, all commands are allowed.
Format: `{"allow": ["pattern1", "pattern2"], "deny": ["pattern3"], "allowRedirects": true}`
**Fields:**
- **allow** (array of strings): Glob patterns for allowed commands. If specified, only matching commands are permitted. Uses `*` to match any characters and `?` to match a single character. Setting allow on anything will deny all others.
- **deny** (array of strings): Glob patterns for denied commands. Deny rules take precedence over allow rules.
- **allowRedirects** (boolean): Whether to allow shell redirects (`>`, `>>`, `<`, etc.). Defaults to false.
**Rule evaluation:**
1. Check for dangerous characters (backticks outside single quotes, unquoted newlines)
2. Parse command into segments split by operators (`&&`, `||`, `|`, `;`)
3. If redirects detected and `allowRedirects` is not true, command is denied
4. Each segment is validated against deny rules first, then allow rules
5. Subshell contents (`$(...)` and `(...)`) are recursively validated
6. All segments must pass for the command to be allowed
**Examples:**
```bash
# Allow only npm and git commands.
export CLINE_COMMAND_PERMISSIONS='{"allow": ["npm *", "git *"]}'
# Allow development commands but deny dangerous ones. Deny not strictly required here since allow is set.
export CLINE_COMMAND_PERMISSIONS='{"allow": ["npm *", "git *", "node *"], "deny": ["rm -rf *", "sudo *"]}'
# Allow file operations with redirects
export CLINE_COMMAND_PERMISSIONS='{"allow": ["cat *", "echo *"], "allowRedirects": true}'
```
# ARCHITECTURE
Cline operates on a three-layer architecture:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cline",
"version": "1.0.9",
"version": "1.0.10",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"main": "cline-core.js",
"bin": {
-1
View File
@@ -110,7 +110,6 @@ func (m *Manager) ListSettings(ctx context.Context) error {
"dictationSettings",
"autoCondenseThreshold",
"autoApprovalSettings",
"hooksEnabled",
}
// Render each field using the renderer
+1 -1
View File
@@ -79,7 +79,7 @@ func RenderField(key string, value interface{}, censor bool) error {
"planActSeparateModelsSetting", "enableCheckpointsSetting",
"terminalReuseEnabled", "mcpResponsesCollapsed", "strictPlanModeEnabled",
"useAutoCondense", "yoloModeToggled", "shellIntegrationTimeout",
"terminalOutputLineLimit", "autoCondenseThreshold", "hooksEnabled":
"terminalOutputLineLimit", "autoCondenseThreshold":
fmt.Printf("%s: %s\n", camelToKebab(key), formatValue(value, key, censor))
return nil
+1 -1
View File
@@ -348,7 +348,7 @@ func (m *InputModel) handleSubmit() (tea.Model, tea.Cmd) {
return ChangeInputTypeMsg{
InputType: InputTypeFeedback,
Title: "Your feedback",
Placeholder: "/plan or /act to switch modes\nctrl+e to open editor",
Placeholder: "/plan or /act to switch modes\nctrl+e to open editor\nctrl+c to exit",
}
}
}
+1 -1
View File
@@ -287,7 +287,7 @@ func (ih *InputHandler) promptForInput(ctx context.Context) (string, bool, error
model := output.NewInputModelWithRegistry(
output.InputTypeMessage,
"Cline is ready for your message...",
"/plan or /act to switch modes\nctrl+e to open editor\ntab to autocomplete commands",
"/plan or /act to switch modes\ntab to autocomplete commands\nctrl+e to open editor\nctrl+c to exit",
currentMode,
ih.slashCommandRegistry,
)
-6
View File
@@ -290,12 +290,6 @@ func setSimpleField(settings *cline.Settings, key, value string) error {
return err
}
settings.ActModeAwsBedrockCustomSelected = boolPtr(val)
case "hooks_enabled":
val, err := parseBool(value)
if err != nil {
return err
}
settings.HooksEnabled = boolPtr(val)
case "azure_identity":
val, err := parseBool(value)
if err != nil {
+7 -4
View File
@@ -104,14 +104,18 @@ func (s *EnvService) Shutdown(ctx context.Context, req *cline.EmptyRequest) (*cl
return &cline.Empty{}, nil
}
func (s *EnvService) isTelemetryEnabled() bool {
// In CLI mode, check the CLINE_TELEMETRY_DISABLED environment variable
return os.Getenv("CLINE_TELEMETRY_DISABLED") != "true"
}
// GetTelemetrySettings returns the telemetry settings for CLI mode
func (s *EnvService) GetTelemetrySettings(ctx context.Context, req *cline.EmptyRequest) (*host.GetTelemetrySettingsResponse, error) {
if s.verbose {
log.Printf("GetTelemetrySettings called")
}
// In CLI mode, check the POSTHOG_TELEMETRY_ENABLED environment variable
telemetryEnabled := os.Getenv("POSTHOG_TELEMETRY_ENABLED") == "true"
telemetryEnabled := s.isTelemetryEnabled()
var setting host.Setting
if telemetryEnabled {
@@ -133,8 +137,7 @@ func (s *EnvService) SubscribeToTelemetrySettings(req *cline.EmptyRequest, strea
log.Printf("SubscribeToTelemetrySettings called")
}
// Send initial telemetry state
telemetryEnabled := os.Getenv("POSTHOG_TELEMETRY_ENABLED") == "true"
telemetryEnabled := s.isTelemetryEnabled()
var setting host.Setting
if telemetryEnabled {
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 MiB

+17 -1
View File
@@ -117,7 +117,13 @@
"features/auto-compact",
"features/background-edit",
"features/checkpoints",
"features/cline-rules",
{
"group": "Cline Rules",
"pages": [
"features/cline-rules/overview",
"features/cline-rules/conditional-rules"
]
},
{
"group": "Commands & Shortcuts",
"pages": [
@@ -148,6 +154,7 @@
"features/hooks/samples"
]
},
"features/jupyter-notebooks",
"features/multiroot-workspace",
"features/plan-and-act",
"features/skills",
@@ -198,6 +205,7 @@
"provider-config/anthropic",
"provider-config/claude-code",
"provider-config/openai",
"provider-config/openai-codex",
"provider-config/openrouter",
"provider-config/cerebras",
"provider-config/deepseek",
@@ -422,6 +430,14 @@
{
"source": "/enterprise-solutions/team-management/roles-and-permissions",
"destination": "/enterprise-solutions/team-management/managing-members"
},
{
"source": "/features/cline-rules",
"destination": "/features/cline-rules/overview"
},
{
"source": "/features/conditional-rules",
"destination": "/features/cline-rules/conditional-rules"
}
],
"search": {
-188
View File
@@ -1,188 +0,0 @@
Cline Rules allow you to provide Cline with system-level guidance. Think of them as a persistent way to include context and preferences for your projects or globally for every conversation.
## Creating a Rule
You can create a rule by clicking the `+` button in the Rules tab. This will open a new file in your IDE which you can use to write your rule.
<Frame>
<img src="https://storage.googleapis.com/cline_public_images/docs/assets/cline-rules.png" alt="Create a Rule" />
</Frame>
Once you save the file:
- Your rule will be stored in the `.clinerules/` directory in your project (if it's a Workspace Rule)
- Or in the Global Rules directory (if it's a Global Rule):
### Global Rules Directory Location
The location of your Global Rules directory depends on your operating system:
| Operating System | Default Location | Notes |
|------------------|------------------|-------|
| **Windows** | `Documents\Cline\Rules` | Uses system Documents folder |
| **macOS** | `~/Documents/Cline/Rules` | Uses user Documents folder |
| **Linux/WSL** | `~/Documents/Cline/Rules` | May fall back to `~/Cline/Rules` on some systems |
> **Note for Linux/WSL users**: If you don't find your global rules in `~/Documents/Cline/Rules`, check `~/Cline/Rules` as the location may vary depending on your system configuration and whether the Documents directory exists.
You can also have Cline create a rule for you by using the [`/newrule` slash command](/features/slash-commands/new-rule) in the chat.
```markdown Example Cline Rule Structure [expandable]
# Project Guidelines
## Documentation Requirements
- Update relevant documentation in /docs when modifying features
- Keep README.md in sync with new capabilities
- Maintain changelog entries in CHANGELOG.md
## Architecture Decision Records
Create ADRs in /docs/adr for:
- Major dependency changes
- Architectural pattern changes
- New integration patterns
- Database schema changes
Follow template in /docs/adr/template.md
## Code Style & Patterns
- Generate API clients using OpenAPI Generator
- Use TypeScript axios template
- Place generated code in /src/generated
- Prefer composition over inheritance
- Use repository pattern for data access
- Follow error handling pattern in /src/utils/errors.ts
## Testing Standards
- Unit tests required for business logic
- Integration tests for API endpoints
- E2E tests for critical user flows
```
### Key Benefits
1. **Version Controlled**: The `.clinerules` file becomes part of your project's source code
2. **Team Consistency**: Ensures consistent behavior across all team members
3. **Project-Specific**: Rules and standards tailored to each project's needs
4. **Institutional Knowledge**: Maintains project standards and practices in code
Place the `.clinerules` file in your project's root directory:
```
your-project/
├── .clinerules
├── src/
├── docs/
└── ...
```
Cline's system prompt, on the other hand, is not user-editable ([here's where you can find it](https://github.com/cline/cline/blob/main/src/core/prompts/system.ts)). For a broader look at prompt engineering best practices, check out [this resource](https://docs.anthropic.com/en/docs/build-with-claude/prompt-engineering/overview).
### AGENTS.md Standard Support
Cline also supports the [AGENTS.md](https://agents.md/) standard as a fallback
(in addition to Cline Rules) by automatically detecting `AGENTS.md` files in
your workspace root. This allows you to use the same rules file across different AI
coding tools.
```
your-project/
├── AGENTS.md
├── src/
└── ...
```
### Tips for Writing Effective Cline Rules
- Be Clear and Concise: Use simple language and avoid ambiguity.
- Focus on Desired Outcomes: Describe the results you want, not the specific steps.
- Test and Iterate: Experiment to find what works best for your workflow.
### .clinerules/ Folder System
```
your-project/
├── .clinerules/ # Folder containing active rules
│ ├── 01-coding.md # Core coding standards
│ ├── 02-documentation.md # Documentation requirements
│ └── current-sprint.md # Rules specific to current work
├── src/
└── ...
```
Cline automatically processes **all Markdown files** inside the `.clinerules/` directory, combining them into a unified set of rules. The numeric prefixes (optional) help organize files in a logical sequence.
#### Using a Rules Bank
For projects with multiple contexts or teams, maintain a rules bank directory:
```
your-project/
├── .clinerules/ # Active rules - automatically applied
│ ├── 01-coding.md
│ └── client-a.md
├── clinerules-bank/ # Repository of available but inactive rules
│ ├── clients/ # Client-specific rule sets
│ │ ├── client-a.md
│ │ └── client-b.md
│ ├── frameworks/ # Framework-specific rules
│ │ ├── react.md
│ │ └── vue.md
│ └── project-types/ # Project type standards
│ ├── api-service.md
│ └── frontend-app.md
└── ...
```
#### Benefits of the Folder Approach
1. **Contextual Activation**: Copy only relevant rules from the bank to the active folder
2. **Easier Maintenance**: Update individual rule files without affecting others
3. **Team Flexibility**: Different team members can activate rules specific to their current task
4. **Reduced Noise**: Keep the active ruleset focused and relevant
#### Usage Examples
Switch between client projects:
```bash
# Switch to Client B project
rm .clinerules/client-a.md
cp clinerules-bank/clients/client-b.md .clinerules/
```
Adapt to different tech stacks:
```bash
# Frontend React project
cp clinerules-bank/frameworks/react.md .clinerules/
```
#### Implementation Tips
- Keep individual rule files focused on specific concerns
- Use descriptive filenames that clearly indicate the rule's purpose
- Consider git-ignoring the active `.clinerules/` folder while tracking the `clinerules-bank/`
- Create team scripts to quickly activate common rule combinations
The folder system transforms your Cline rules from a static document into a dynamic knowledge system that adapts to your team's changing contexts and requirements.
### Managing Rules with the Toggleable Popover
To make managing both single `.clinerules` files and the folder system even easier, Cline v3.13 introduces a dedicated popover UI directly accessible from the chat interface.
Located conveniently under the chat input field, this popover allows you to:
- **Instantly See Active Rules:** View which global rules (from your user settings) and workspace rules (`.clinerules` file or folder contents) are currently active.
- **Quickly Toggle Rules:** Enable or disable specific rule files within your workspace `.clinerules/` folder with a single click. This is perfect for activating context-specific rules (like `react-rules.md` or `memory-bank.md`) only when needed.
- **Easily Add/Manage Rules:** Quickly create a workspace `.clinerules` file or folder if one doesn't exist, or add new rule files to an existing folder.
This UI significantly simplifies switching contexts and managing different sets of instructions without needing to manually edit files or configurations during a conversation.
<Frame>
<img src="https://storage.googleapis.com/cline_public_images/docs/assets/image%20(1).png" alt="Cline Logo" />
</Frame>
@@ -0,0 +1,267 @@
---
title: "Conditional Rules"
sidebarTitle: "Conditional Rules"
description: "Activate rules automatically based on which files you're working with"
---
Conditional rules let you scope rules to specific parts of your codebase. Rules activate only when you're working with matching files, keeping your context focused and relevant.
For an introduction to Cline Rules, see the [Overview](/features/cline-rules/overview).
- **Without conditionals**: every rule loads for every request.
- **With conditionals**, rules activate only when your current files match their defined scope.
For example, React component rules should appear when you're working with React components, not when you're editing Python or documentation.
## How It Works
Conditional rules use YAML frontmatter at the top of your rule files. When Cline processes a request, it gathers context from your current work (open files, visible tabs, mentioned paths, edited files), evaluates each rule's conditions, and activates matching rules.
<Note>
When a conditional rule activates, you'll see a notification: **"Conditional rules applied: workspace:frontend-rules.md"**
</Note>
## Writing Conditional Rules
Add YAML frontmatter to the top of any rule file in your `.clinerules/` directory:
```yaml
---
paths:
- "src/components/**"
- "src/hooks/**"
---
# React Component Guidelines
When creating or modifying React components:
- Use functional components with React hooks
- Extract reusable logic into custom React hooks
- Keep components focused on a single responsibility
```
The `---` markers delimit the frontmatter. Everything after the closing `---` is your rule content.
### The `paths` Conditional
Currently, `paths` is the supported conditional. It takes an array of glob patterns:
```yaml
---
paths:
- "src/**" # All files under src/
- "*.config.js" # Config files in root
- "packages/*/src/" # Monorepo package sources
---
```
**Glob pattern syntax:**
- `*` matches any characters except `/`
- `**` matches any characters including `/` (recursive)
- `?` matches a single character
- `[abc]` matches any character in the brackets
- `{a,b}` matches either pattern
**Examples:**
| Pattern | Matches |
|---------|---------|
| `src/**/*.ts` | All TypeScript files under `src/` |
| `*.md` | Markdown files in root only |
| `**/*.test.ts` | Test files anywhere in the project |
| `packages/{web,api}/**` | Files in web or api packages |
| `src/components/*.tsx` | TSX files directly in components (not nested) |
### Behavior Details
**Multiple patterns**: A rule activates if any pattern matches any file in your context.
```yaml
---
paths:
- "frontend/**"
- "mobile/**"
---
# Activates when working in frontend OR mobile
```
**No frontmatter**: Rules without frontmatter are always active.
**Empty paths array**: `paths: []` means the rule never activates. Use this to temporarily disable a rule.
**Invalid YAML**: If frontmatter can't be parsed, Cline fails open: the rule activates with raw content visible to help debugging.
## What Counts as "Current Context"
Cline evaluates rules based on:
1. **Your message**: File paths mentioned in your prompt (e.g., "update `src/App.tsx`")
2. **Open tabs**: Files currently open in your editor
3. **Visible files**: Files visible in your active editor panes
4. **Edited files**: Files Cline has created, modified, or deleted during the task
5. **Pending operations**: Files Cline is about to edit
Conditional rules can activate on your first message, when relevant files are open, or mid-task when Cline starts working with matching files.
<Tip>
Be explicit about file paths in your prompts. "Update `src/services/user.ts`" reliably triggers path-based rules; "update the user service" may not.
</Tip>
## Practical Examples
Copy these patterns and adapt them to your project structure.
### Frontend vs Backend Rules
Keep frontend and backend rules separate to avoid noise. Frontend rules only load when working with UI code, backend rules only load when working with API or service code.
```yaml
# .clinerules/frontend.md
---
paths:
- "src/components/**"
- "src/pages/**"
- "src/hooks/**"
---
# Frontend Guidelines
- Use Tailwind CSS for styling
- Prefer server components where possible
- Keep client components small and focused
```
```yaml
# .clinerules/backend.md
---
paths:
- "src/api/**"
- "src/services/**"
- "src/db/**"
---
# Backend Guidelines
- Use dependency injection for services
- All database queries go through repositories
- Return typed errors, not thrown exceptions
```
### Test File Rules
Enforce testing standards automatically. This rule activates only when you're writing or modifying tests, so testing guidance appears exactly when you need it.
```yaml
# .clinerules/testing.md
---
paths:
- "**/*.test.ts"
- "**/*.spec.ts"
- "**/__tests__/**"
---
# Testing Standards
- Use descriptive test names: "should [expected behavior] when [condition]"
- One assertion per test when possible
- Mock external dependencies, not internal modules
- Use factories for test data, not fixtures
```
### Documentation Rules
Apply documentation standards only when editing docs. Prevents style rules from cluttering your context when you're writing code.
```yaml
# .clinerules/docs.md
---
paths:
- "docs/**"
- "**/*.md"
- "**/*.mdx"
---
# Documentation Guidelines
- Use sentence case for headings
- Include code examples for all features
- Keep paragraphs short (3-4 sentences max)
- Link to related documentation
```
## Combining with Rule Toggles
Conditional rules work alongside the rule toggle UI. Toggle off a conditional rule to disable it entirely (it won't activate even if paths match). Toggle on to let it activate when conditions are met.
This provides two levels of control: manual toggles and automatic condition-based activation.
## Tips for Effective Conditional Rules
### Start Broad, Then Narrow
Begin with broader patterns and refine as you learn what works:
```yaml
# Start here
paths:
- "src/**"
# Then narrow down
paths:
- "src/features/auth/**"
```
### Use Descriptive Filenames
Name your rule files to indicate their scope:
```
.clinerules/
├── api-endpoints.md # Rules for API code
├── database-models.md # Rules for DB layer
├── react-components.md # Rules for React
└── universal.md # No frontmatter = always active
```
### Keep Universal Rules Separate
Put always-on rules (coding standards, project conventions) in files without frontmatter. Reserve conditional rules for context-specific guidance.
### Test Your Patterns
Not sure if a pattern matches? Create a simple test rule:
```yaml
---
paths:
- "your/pattern/here/**"
---
TEST: This rule should activate for your/pattern/here files.
```
Then work with a file in that path and check if you see the activation notification.
## Troubleshooting
**Rule not activating:**
- Check that file paths in your context match the glob pattern
- Verify the rule is toggled on in the rules panel
- Ensure YAML frontmatter has proper `---` delimiters
**Rule activating unexpectedly:**
- Review glob patterns: `**` is recursive and may match more than intended
- Check for open files that match the pattern
- File paths mentioned in your message also count as context
**Frontmatter showing in output:**
- YAML couldn't be parsed
- Check for syntax errors (unquoted special characters, improper indentation)
## Related
- [Cline Rules Overview](/features/cline-rules/overview) - Complete rules system guide
- [Skills](/features/skills) - Load instructions on demand with `/skill` command
- [Workflows](/features/slash-commands/workflows/index) - Define explicit task automation
- [@ Mentions](/features/at-mentions/overview) - Add files to context explicitly
- [Understanding Context Management](/prompting/understanding-context-management) - How Cline manages context window
+205
View File
@@ -0,0 +1,205 @@
---
title: "Cline Rules"
sidebarTitle: "Overview"
description: "Add persistent instructions and context to guide Cline's behavior"
---
Cline Rules provide system-level guidance for your projects. Rules persist across conversations, ensuring consistent behavior without repeating instructions in every chat.
## How It Works
Rules are loaded when Cline starts a task. Here's what happens:
**Loading order**: Cline checks for rules in this sequence:
1. `.clinerules/` folder (all `.md` files inside)
2. Single `.clinerules` file
3. `AGENTS.md` file
**Scope precedence**: Workspace rules override global rules when both define the same guidance.
**Multiple files**: When using a `.clinerules/` folder, all Markdown files are combined into one ruleset. Numeric prefixes (like `01-`, `02-`) control the order.
**Conditional activation**: Rules with YAML frontmatter activate only when you're working with matching files. See [Conditional Rules](/features/cline-rules/conditional-rules) for details.
## Supported Rule Files
Cline reads rules from multiple file formats in your workspace root, letting you share rules across different AI coding tools:
| File/Folder | Source | Notes |
|-------------|--------|-------|
| `.clinerules/` | Cline | Folder with `.md` files (recommended) |
| `.cursor/rules/` | Cursor | Folder with `.mdc` files |
| `.windsurf/rules` | Windsurf | Folder with multiple `md` files |
| `AGENTS.md` | Universal | Follows [agents.md](https://agents.md/) standard, searched recursively |
Cline prioritizes `.clinerules` when present. Other formats load only if no `.clinerules` exists (except `AGENTS.md`, which always searches subdirectories). All rules appear in the Rules popover where you can toggle them.
## Creating Rules
Click the `+` button in the Rules tab to create a new rule. This opens a file in your editor where you write your guidance.
<Frame>
<img src="https://storage.googleapis.com/cline_public_images/docs/assets/cline-rules.png" alt="Create a Rule" />
</Frame>
When you save the file, it's stored in:
- **Workspace rules**: `.clinerules/` in your project root
- **Global rules**: Platform-specific location (see table below)
You can also use the [`/newrule` slash command](/features/slash-commands/new-rule) to have Cline generate a rule based on your description.
### Global Rules Location
| Operating System | Default Location |
|------------------|------------------|
| **Windows** | `Documents\Cline\Rules` |
| **macOS** | `~/Documents/Cline/Rules` |
| **Linux/WSL** | `~/Documents/Cline/Rules` or `~/Cline/Rules` |
<Note>
Linux/WSL users: Check both locations if you don't find global rules in `~/Documents/Cline/Rules`.
</Note>
## Managing Rules
The Rules popover (below the chat input) shows active rules and lets you toggle them on or off.
<Frame>
<img src="https://storage.googleapis.com/cline_public_images/docs/assets/image%20(1).png" alt="Rules Popover" />
</Frame>
The popover displays:
- **Global rules**: From your user-level Rules directory
- **Workspace rules**: From `.clinerules/` in your project
Toggle any rule to enable or disable it. Disabled rules won't load, even if they match conditions.
## When to Use Rules
Rules work best for persistent project context:
- **Code standards**: Formatting preferences, naming conventions, project-specific patterns
- **Documentation requirements**: Where to add docs, what format to follow
- **Architecture decisions**: Design patterns, dependency rules, module boundaries
- **Team conventions**: PR processes, branch naming, commit message format
- **Technology constraints**: Required libraries, banned APIs, version requirements
Rules are less effective for:
- One-time instructions (just say it in the chat)
- Complex multi-step workflows (use [Workflows](/features/slash-commands/workflows/index) instead)
- Dynamic decisions that depend on runtime context
## Example Rule
```markdown
# Backend API Guidelines
## Route Handlers
- Use async/await, not callbacks
- Validate request bodies with Zod schemas
- Return typed errors from `src/errors.ts`
- All routes require authentication unless in `publicRoutes` array
## Database Access
- All queries go through repository classes in `src/repositories/`
- Use transactions for multi-table updates
- Never expose raw database errors to clients
## Testing
- Unit tests for business logic in `src/services/`
- Integration tests for route handlers in `src/routes/`
- Mock external APIs, not internal modules
```
This rule provides clear, actionable guidance without explaining obvious concepts or using vague language.
## Using a Folder Structure
For projects with many rules, organize them in a `.clinerules/` folder:
```
your-project/
├── .clinerules/
│ ├── 01-coding-standards.md
│ ├── 02-documentation.md
│ └── 03-testing.md
├── src/
└── ...
```
Cline loads all Markdown files in `.clinerules/` automatically. The numeric prefixes help you control ordering, but they're optional.
### Organizing a Rules Bank
Maintain a separate folder for rules you might need but don't always want active:
```
your-project/
├── .clinerules/ # Active rules
│ ├── 01-coding.md
│ └── client-a.md
├── clinerules-bank/ # Available but inactive
│ ├── clients/
│ │ ├── client-a.md
│ │ └── client-b.md
│ └── frameworks/
│ ├── react.md
│ └── vue.md
└── ...
```
Copy files from the bank to `.clinerules/` when you need them. This keeps your active context lean while maintaining a library of reusable guidance.
Switch contexts with simple file operations:
```bash
# Switch to Client B
rm .clinerules/client-a.md
cp clinerules-bank/clients/client-b.md .clinerules/
```
<Tip>
Consider git-ignoring `.clinerules/` while tracking `clinerules-bank/` so team members can activate the rules relevant to their current work.
</Tip>
## Conditional Rules
Scope rules to specific file patterns using YAML frontmatter. This keeps React guidance out of Python code and backend rules away from frontend work.
```yaml
---
paths:
- "src/components/**"
- "src/hooks/**"
---
# React Guidelines
Use functional components with hooks. Extract reusable logic into custom hooks.
```
This rule activates only when working with files matching those patterns. Read the [Conditional Rules guide](/features/cline-rules/conditional-rules) for pattern syntax, behavior details, and more examples.
## Tips for Effective Rules
**Be specific**: "Use async/await for all database calls" beats "write good async code."
**Show patterns**: Include file paths and real examples. "Follow the error handling in `src/utils/errors.ts`" gives Cline a concrete reference.
**Focus on outcomes**: Describe what you want, not step-by-step instructions. Let Cline figure out how.
**Test and refine**: Start with core standards. Add rules when you find yourself repeating the same feedback.
**Use conditional rules**: Load guidance only when relevant. This keeps context efficient and reduces noise.
## Related
- [Conditional Rules](/features/cline-rules/conditional-rules) - Activate rules based on file patterns
- [Skills](/features/skills) - Load instructions on demand with `/skill` command
- [Workflows](/features/slash-commands/workflows/index) - Define explicit task automation
- [New Rule Slash Command](/features/slash-commands/new-rule) - Generate rules with AI assistance
- [Plan and Act Mode](/features/plan-and-act) - Use different rules for planning vs execution
+112
View File
@@ -0,0 +1,112 @@
---
title: "Jupyter Notebooks"
description: "AI-assisted editing of Jupyter notebooks with cell-level context awareness"
---
# Jupyter Notebooks
Cline provides comprehensive support for Jupyter notebooks (`.ipynb` files), enabling AI-assisted editing with full cell-level context awareness. This feature was developed in collaboration with Amazon to bring AI coding assistance to data science workflows.
## Getting started
Jupyter notebook support is a built-in feature of Cline. To use it, you just need to have the Jupyter notebook extension enabled in VS Code. Once you open any `.ipynb` file, you'll see AI-assisted buttons in your notebook interface.
## How to use
### Generate Cell
Click the sparkle icon (✨) in the notebook toolbar to generate new cells with AI assistance.
![Generate Cell demo](../assets/jupyter-generate-cell.gif)
**How it works:** The AI receives context from surrounding cells, so it understands the variables and imports already in scope. This means you can reference existing DataFrames, functions, and other objects without re-explaining them.
**Example prompt:** "Create a visualization showing the correlation matrix of numeric columns with a heatmap"
The cell is inserted with proper notebook JSON structure, preserving metadata and ready to execute.
### Explain Cell
Click the Explain button in any cell's title bar to get a detailed explanation of what the cell does.
This is useful for:
- Revisiting old notebooks
- Onboarding to a teammate's analysis
- Understanding complex transformations
**How it works:** Cline extracts the full cell context, including outputs, so explanations can reference actual results like column names, row counts, and computed values.
### Improve Cell
Click the Improve button in any cell's title bar to enhance existing cells with AI suggestions.
![Explain and Improve Cell demo](../assets/jupyter-explain-improve-cell.gif)
Use this to:
- Optimize slow pandas operations
- Add error handling
- Refactor for clarity
- Convert loops to vectorized operations
**How it works:** Cline suggests improvements while preserving the cell's position and metadata in the notebook structure. The AI explains what was changed and why.
## How cell context works
Unlike traditional file editing, Jupyter notebooks are JSON documents containing arrays of cells. Each cell has its own type, source content, metadata, execution count, and outputs.
When you use a Jupyter command, Cline extracts structured context that includes:
- **Cell type** (code, markdown, or raw)
- **Source content** as an array of lines
- **Cell metadata** and configuration
- **Execution count** for code cells
- **Outputs** including data, text, and error traces
This structured representation allows the AI to understand not just the code, but its context within the notebook and its actual output.
### JSON structure preservation
Cline is designed to work carefully with the cell JSON structure, aiming to:
- Keep cell boundaries intact
- Preserve execution counts
- Maintain cell metadata
- Keep outputs associated with their source cells
The AI is specifically prompted to preserve notebook structure, though you should always review changes to ensure your notebook format remains correct.
## Keyboard shortcuts
You can bind any of these commands to keyboard shortcuts for faster access:
1. Open VS Code keyboard shortcuts (Cmd/Ctrl + K, Cmd/Ctrl + S)
2. Search for `cline.jupyterGenerateCell`, `cline.jupyterExplainCell`, or `cline.jupyterImproveCell`
3. Assign your preferred key combinations
## Tips for best results
**For Generate Cell:**
- Be specific about what you want the cell to do
- Reference existing variables by name (the AI can see them)
- Mention preferred libraries if you have a preference (e.g., "use seaborn" or "use plotly")
**For Explain Cell:**
- Works best on cells that have been executed (outputs provide additional context)
- Good for complex chained operations like pandas groupby/merge sequences
**For Improve Cell:**
- Mention what aspect you want to improve (performance, readability, error handling)
- The AI will explain the changes it suggests
## Limitations
- Notebook support requires the Jupyter notebook extension to be enabled in VS Code
- Cell context extraction depends on VS Code's notebook API
- Very large notebooks may require more context than some models can handle efficiently
## Related
- [Cline Tools Guide](/exploring-clines-tools/cline-tools-guide) -- Overview of all Cline commands
- [Model Selection Guide](/core-features/model-selection-guide) -- Choosing the right model for your workflow
+19 -2
View File
@@ -25,11 +25,28 @@ The easiest way to get started is with **Cline** as your provider:
## Alternative: Use Another Provider
If you prefer to use your own API keys, you can select from providers like:
If you prefer to use your own accounts or API keys, you have several options:
### Sign in with OpenAI (Recommended)
The easiest way to use OpenAI models is with **OpenAI Codex**—no API keys needed:
1. Select **"OpenAI Codex"** from the API Provider dropdown
2. Click **"Sign in with OpenAI"**
3. Authorize Cline in your browser
4. Choose your model
<Card title="OpenAI Codex Setup Guide" icon="key" href="/provider-config/openai-codex">
See the full setup guide with screenshots and troubleshooting tips.
</Card>
### Other Providers
You can also use API keys with these providers:
- **OpenRouter** - Great value, multiple models
- **Anthropic** - Direct access to Claude models
- **OpenAI** - Access to GPT models
- **OpenAI** - Access to GPT models via API key
- **Google Gemini** - Google's AI models
- **Ollama** - Run models locally on your computer
-1
View File
@@ -18,7 +18,6 @@ Cerebras delivers the world's fastest AI inference through their revolutionary w
Cline supports the following Cerebras models:
- `zai-glm-4.6` - Fast general-purpose model on Cerebras (up to 1,000 tokens/s). To be deprecated soon.
- `zai-glm-4.7` - Highly capable general-purpose model on Cerebras (up to 1,000 tokens/s), competitive with leading proprietary models on coding tasks.
- `qwen-3-235b-a22b-instruct-2507` - Advanced instruction-following model
- `qwen-3-235b-a22b-thinking-2507` - Reasoning model with step-by-step thinking
+105
View File
@@ -0,0 +1,105 @@
---
title: "OpenAI Codex"
sidebarTitle: "OpenAI Codex"
description: "Connect your OpenAI account to Cline via OAuth for seamless access to OpenAI models."
---
OpenAI Codex lets you connect your OpenAI account directly to Cline using OAuth, meaning you don't need to manage API keys. Simply sign in with your OpenAI account through a one-click browser authentication, and you'll automatically have access to all the models available on your OpenAI plan. No additional configuration required.
<Tip>
If you prefer to use API keys instead, see the [OpenAI (API Key)](/provider-config/openai) provider configuration.
</Tip>
## Prerequisites
- An [OpenAI account](https://platform.openai.com/signup)
- An active OpenAI subscription or API access plan
<Note>
The models available to you depend on your OpenAI account's subscription tier. See [OpenAI's pricing page](https://openai.com/pricing) for details on what's included in each plan.
</Note>
## Connecting Your OpenAI Account
<Steps>
<Step title="Open Cline Settings">
Click the settings gear icon in the top-right corner of the Cline extension.
</Step>
<Step title="Select OpenAI Codex Provider">
Choose **"OpenAI Codex"** from the "API Provider" dropdown menu.
</Step>
<Step title="Sign In with OpenAI">
Click the **"Sign in with OpenAI"** button. This will open a browser window for authentication.
</Step>
<Step title="Authorize Cline">
In the browser window that opens:
1. Sign in to your OpenAI account (if not already signed in)
2. Review the permissions Cline is requesting
3. Click **"Authorize"** to grant access
</Step>
<Step title="Select Your Model">
Once authorized, you'll be returned to Cline. Choose your desired model from the **"Model"** dropdown.
</Step>
</Steps>
<Note>
If a model doesn't appear in your dropdown, it may not be available on your OpenAI subscription tier.
</Note>
For the most current list of available models and their capabilities, see the official [OpenAI Models documentation](https://platform.openai.com/docs/models).
## Managing Your Connection
### Disconnecting Your Account
To disconnect your OpenAI account from Cline:
1. Open Cline Settings
2. With OpenAI Codex selected as the provider, click **"Sign out"**
3. Confirm the disconnection
This removes the OAuth connection. You can reconnect at any time by signing in again.
### Token Refresh
OAuth tokens are automatically refreshed by Cline. If you encounter authentication errors:
1. Try disconnecting and reconnecting your account
2. Ensure your OpenAI account is in good standing
3. Check that your subscription is active
## Troubleshooting
### "Authentication failed" error
- Ensure you're signing in with the correct OpenAI account
- Check that your OpenAI subscription is active
- Try clearing your browser cache and signing in again
### Models not appearing
- The available models depend on your OpenAI subscription tier
- Some models may require specific plan upgrades
- Check [OpenAI's pricing page](https://openai.com/pricing) for model availability by plan
### OAuth window doesn't open
- Check if pop-ups are blocked in your browser
- Try using a different browser
- Ensure you have a stable internet connection
### Connection keeps expiring
- This is rare but can happen if your OpenAI account session expired
- Disconnect and reconnect to refresh your authentication
## Good to Know
Cline only requests the permissions necessary to make API calls on your behalf. Your OpenAI credentials are never stored by Cline.
When using OpenAI Codex, Cline accesses models through your OpenAI/ChatGPT subscription. There is no separate per-token API billing or consumption of OpenAI API credits for this provider; usage and limits are governed by your ChatGPT (or OpenAI account) subscription plan, not by Cline.
## Related Documentation
- [OpenAI (API Key)](/provider-config/openai): Alternative configuration using API keys
- [Model Selection Guide](/core-features/model-selection-guide): Help choosing the right model
-4
View File
@@ -144,10 +144,6 @@ if (process.env.ERROR_SERVICE_API_KEY) {
buildEnvVars["process.env.ERROR_SERVICE_API_KEY"] = JSON.stringify(process.env.ERROR_SERVICE_API_KEY)
}
if (process.env.POSTHOG_TELEMETRY_ENABLED) {
buildEnvVars["process.env.POSTHOG_TELEMETRY_ENABLED"] = JSON.stringify(process.env.POSTHOG_TELEMETRY_ENABLED)
}
// OpenTelemetry configuration (injected at build time from GitHub secrets)
// These provide production defaults that can be overridden at runtime via environment variables
if (process.env.OTEL_TELEMETRY_ENABLED) {
+48 -65
View File
@@ -1,12 +1,12 @@
{
"name": "claude-dev",
"version": "3.51.0",
"version": "3.55.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-dev",
"version": "3.51.0",
"version": "3.55.0",
"license": "Apache-2.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.37.0",
@@ -1184,6 +1184,7 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@@ -2646,6 +2647,7 @@
"node_modules/@grpc/grpc-js": {
"version": "1.9.15",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@grpc/proto-loader": "^0.7.8",
"@types/node": ">=12.12.47"
@@ -3229,6 +3231,7 @@
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz",
"integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@hono/node-server": "^1.19.7",
"ajv": "^8.17.1",
@@ -3297,6 +3300,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -4912,8 +4916,7 @@
"optional": true,
"os": [
"android"
],
"peer": true
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.52.4",
@@ -4926,8 +4929,7 @@
"optional": true,
"os": [
"android"
],
"peer": true
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.52.4",
@@ -4940,8 +4942,7 @@
"optional": true,
"os": [
"darwin"
],
"peer": true
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.52.4",
@@ -4954,8 +4955,7 @@
"optional": true,
"os": [
"darwin"
],
"peer": true
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.52.4",
@@ -4968,8 +4968,7 @@
"optional": true,
"os": [
"freebsd"
],
"peer": true
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.52.4",
@@ -4982,8 +4981,7 @@
"optional": true,
"os": [
"freebsd"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.52.4",
@@ -4996,8 +4994,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.52.4",
@@ -5010,8 +5007,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.52.4",
@@ -5024,8 +5020,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.52.4",
@@ -5038,8 +5033,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.52.4",
@@ -5052,8 +5046,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.52.4",
@@ -5066,8 +5059,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.52.4",
@@ -5080,8 +5072,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.52.4",
@@ -5094,8 +5085,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.52.4",
@@ -5108,8 +5098,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.52.4",
@@ -5122,8 +5111,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.52.4",
@@ -5136,8 +5124,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.52.4",
@@ -5150,8 +5137,7 @@
"optional": true,
"os": [
"openharmony"
],
"peer": true
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.52.4",
@@ -5164,8 +5150,7 @@
"optional": true,
"os": [
"win32"
],
"peer": true
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.52.4",
@@ -5178,8 +5163,7 @@
"optional": true,
"os": [
"win32"
],
"peer": true
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.52.4",
@@ -5192,8 +5176,7 @@
"optional": true,
"os": [
"win32"
],
"peer": true
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.52.4",
@@ -5206,8 +5189,7 @@
"optional": true,
"os": [
"win32"
],
"peer": true
]
},
"node_modules/@sap-ai-sdk/ai-api": {
"version": "2.1.0",
@@ -6770,8 +6752,7 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@types/get-folder-size": {
"version": "3.0.4",
@@ -6803,6 +6784,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.21.tgz",
"integrity": "sha512-CsGG2P3I5y48RPMfprQGfy4JPRZ6csfC3ltBZSRItG3ngggmNY/qs2uZKp4p9VbrpqNNSMzUZNFZKzgOGnd/VA==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -7519,6 +7501,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -8320,6 +8303,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001741",
@@ -8654,9 +8638,9 @@
}
},
"node_modules/cheerio/node_modules/undici": {
"version": "6.22.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.22.0.tgz",
"integrity": "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==",
"version": "6.23.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
"license": "MIT",
"engines": {
"node": ">=18.17"
@@ -9525,7 +9509,8 @@
},
"node_modules/devtools-protocol": {
"version": "0.0.1342118",
"license": "BSD-3-Clause"
"license": "BSD-3-Clause",
"peer": true
},
"node_modules/diff": {
"version": "5.2.0",
@@ -12476,6 +12461,7 @@
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"license": "MIT",
"peer": true,
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
@@ -12709,6 +12695,7 @@
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
"integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==",
"license": "MPL-2.0",
"peer": true,
"dependencies": {
"detect-libc": "^2.0.3"
},
@@ -14860,9 +14847,9 @@
}
},
"node_modules/open-graph-scraper/node_modules/undici": {
"version": "6.22.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.22.0.tgz",
"integrity": "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==",
"version": "6.23.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
"license": "MIT",
"engines": {
"node": ">=18.17"
@@ -15430,6 +15417,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -15611,7 +15599,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -15632,7 +15619,6 @@
}
],
"license": "MIT",
"peer": true,
"bin": {
"nanoid": "bin/nanoid.cjs"
},
@@ -16322,7 +16308,6 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz",
"integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -17800,7 +17785,6 @@
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
@@ -17817,7 +17801,6 @@
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12.0.0"
},
@@ -18178,6 +18161,7 @@
"version": "5.5.3",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -18250,9 +18234,9 @@
"license": "MIT"
},
"node_modules/undici": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz",
"integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==",
"version": "7.19.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.19.0.tgz",
"integrity": "sha512-Heho1hJD81YChi+uS2RkSjcVO+EQLmLSyUlHyp7Y/wFbxQaGb4WXVKD073JytrjXJVkSZVzoE2MCSOKugFGtOQ==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
@@ -18430,7 +18414,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -18505,7 +18488,6 @@
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12.0.0"
},
@@ -19193,6 +19175,7 @@
"node_modules/zod": {
"version": "3.25.76",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+1 -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.51.0",
"version": "3.55.0",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.84.0"
@@ -381,10 +381,6 @@
"compile-standalone": "npm run check-types && npm run lint && node esbuild.mjs --standalone",
"compile-standalone-npm": "npm run protos && npm run protos-go && npm run check-types && npm run lint && node esbuild.mjs --standalone",
"compile-cli": "scripts/build-cli.sh",
"compile-cli-ts": "cd cli-ts && npm run link",
"compile-cli-ts:production": "cd cli-ts && npm run build:production",
"watch-cli-ts": "cd cli-ts && npm run watch",
"dev:cli-ts": "npm run compile-cli-ts && npm run watch-cli-ts",
"compile-cli-all-platforms": "scripts/build-cli-all-platforms.sh",
"compile-cli-man-page": "pandoc cli/man/cline.1.md -s -t man -o cli/man/cline.1",
"test:install": "bash scripts/test-install.sh",
+14
View File
@@ -75,6 +75,19 @@ message McpResourceTemplate {
optional string description = 4;
}
message McpPromptArgument {
string name = 1;
optional string description = 2;
optional bool required = 3;
}
message McpPrompt {
string name = 1;
optional string title = 2;
optional string description = 3;
repeated McpPromptArgument arguments = 4;
}
enum McpServerStatus {
// Protobuf enums (in proto3) must have a zero value defined, which serves as the default if the field isn't explicitly set.
// To align with the required nature of the TypeScript type and avoid an unnecessary UNSPECIFIED state, we map one of the existing statuses to this zero value.
@@ -95,6 +108,7 @@ message McpServer {
optional int32 timeout = 9;
optional bool oauth_required = 10;
optional string oauth_auth_status = 11;
repeated McpPrompt prompts = 12;
}
message McpServers {
+2 -2
View File
@@ -256,7 +256,6 @@ message Settings {
optional FocusChainSettings focus_chain_settings = 149;
optional string custom_prompt = 150;
optional double auto_condense_threshold = 151;
optional bool hooks_enabled = 152;
optional bool subagents_enabled = 153;
optional bool enable_parallel_tool_calling = 154;
optional bool background_edit_enabled = 155;
@@ -384,6 +383,8 @@ message UpdateTaskSettingsRequest {
// Message for updating settings
message UpdateSettingsRequest {
reserved 26; // was hooks_enabled (removed - now always enabled on macOS/Linux)
Metadata metadata = 1;
optional ModelsApiConfiguration api_configuration = 2;
optional string telemetry_setting = 3;
@@ -408,7 +409,6 @@ message UpdateSettingsRequest {
optional DictationSettings dictation_settings = 23;
optional double auto_condense_threshold = 24;
optional bool multi_root_enabled = 25;
optional bool hooks_enabled = 26;
optional string vscode_terminal_execution_mode = 27;
optional int32 max_consecutive_mistakes = 28;
optional bool subagents_enabled = 29;
+1
View File
@@ -70,6 +70,7 @@ enum ClineSay {
HOOK_STATUS = 30;
HOOK_OUTPUT_STREAM = 31;
COMMAND_PERMISSION_DENIED = 32;
CONDITIONAL_RULES_APPLIED = 33;
}
// Enum for ClineSayTool tool types

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