Compare commits

...

128 Commits

Author SHA1 Message Date
abeatrix 1e172ac8d0 Sqlite3 for Global Storage 2026-01-21 18:53:02 -08:00
abeatrix 4d41766b9f fix api key storing process 2026-01-21 17:38:36 -08:00
abeatrix e958e29439 Merge branch 'bee/cli-ts-ink-poc' of https://github.com/cline/cline into bee/cli-ts-ink-poc 2026-01-21 16:33:57 -08:00
abeatrix b337bb706e set up secretStorage 2026-01-21 16:33:51 -08:00
abeatrix 314e06731a fix type 2026-01-21 15:04:08 -08:00
abeatrix 3a6f8b98dd Merge branch 'main' into bee/cli-ts-ink-poc 2026-01-21 14:12:54 -08:00
abeatrix 65f9322bd9 exit askprompt on yolo mode completion 2026-01-21 14:05:37 -08:00
abeatrix 33983e80f8 merge saoudrizwan/cli-ts-ink-poc-with-tui with context 2026-01-21 13:44:50 -08:00
Tomás Barreiro 6303951f20 Use getAllFlagsAndPayloads when fetching PostHog feature flags (#8774)
* Use

* Fix typo

* Remove old code
2026-01-21 13:12:34 -08:00
Ara fc184e07a2 feat: update free onboarding models with Kat Coder Pro and Devstral (#8773)
* feat: update free onboarding models with Kat Coder Pro and Devstral

- Replace xAI Grok Code Fast 1 with KwaiKAT Kat Coder Pro as primary free model
- Add Mistral Devstral 2512 as additional free model option
- Update model specifications (context window, image/cache support)

* Apply suggestions from code review

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-21 12:22:10 -08:00
Ara e88f4ea412 fix: update package name from cline to claude-dev in changesets (#8772)
Rename package identifier in changeset frontmatter to reflect
the correct package name for version tracking.
2026-01-21 11:35:54 -08:00
Saoud Rizwan 8648154a74 fix(cli): use index key for static logo array to avoid duplicate key warnings 2026-01-21 11:23:20 -08:00
Saoud Rizwan 133ade3f1a feat(prompt): add stderr redirect guidance for command execution (#8765)
* feat(prompt): add stderr redirect guidance for command execution

* test: update system prompt snapshots
2026-01-21 11:09:18 -08:00
Saoud Rizwan 5d91ac037e feat(cli): redesign welcome view with ASCII logo and mode toggle
- Add centered ASCII Cline logo
- Add "What can I do for you?" prompt
- Add bordered input field (blue for Act mode, yellow for Plan mode)
- Display model ID and Plan/Act toggle below input
- Tab to switch between Plan and Act modes
- Two-step Esc to exit (first press highlights, second exits)
- Full-width responsive layout
- Properly exit process when user cancels
2026-01-21 10:00:20 -08:00
Saoud Rizwan 6179dfe0a4 fix(cli): disable browser tool in CLI mode 2026-01-21 09:50:32 -08:00
Saoud Rizwan cd9e28e6d0 feat(cli): add graceful Ctrl+C shutdown handling
- Handle SIGINT/SIGTERM signals to cleanly exit
- Abort running task, persist state, dispose controller before exit
- Force exit on second signal if already shutting down
2026-01-21 09:50:32 -08:00
Saoud Rizwan 8d56a697eb feat(cli): add CLI-specific system prompt adjustments
- Add isCliEnvironment boolean to SystemPromptContext, computed from
  platform check in task/index.ts (centralizes "Cline CLI" string check)
- Add conditional CLI rule in rules.ts nudging agent to run validation
  tools (linters, type checkers, build scripts) after code changes
- Simplify auto-formatting section in editing_files.ts for CLI mode
  (files saved exactly as written, no auto-formatting expectations)
2026-01-21 09:50:32 -08:00
Saoud Rizwan fd506b14b5 fix(cli): disable checkpoints in CLI mode
The shadow git checkpoint system is problematic for CLI usage because it
creates shadow gits for every directory you run cline in, leading to storage
bloat and potentially tracking files in directories you don't want tracked.

Disables checkpoints by setting enableCheckpointsSetting to false during
CLI initialization.
2026-01-21 09:50:32 -08:00
Max 857411c035 fix initial prompt bugs (#8428)
* fix initial prompt bugs

- going straight to act wasn't working in interactive mode.
- slash command autocomplete wasn't working in the initial prompt
- refactored some naming to be more clear

* Apply suggestion from @ellipsis-dev[bot]

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* Apply suggestion from @ellipsis-dev[bot]

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* Apply suggestion from @ellipsis-dev[bot]

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

---------

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
Co-authored-by: CandiedUniverse <132302818+candieduniverse@users.noreply.github.com>
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2026-01-21 09:40:08 -08:00
Robin Newhouse c59f068584 fix: improve Jupyter notebook diff view and LLM context handling (#8759)
* fix: restore switchToSpecializedEditor for Jupyter notebook diff views

This restores the notebook diff view functionality that was accidentally
removed during rebase. The method was incorrectly identified as dead code,
but it was being called in update() when isFinal is true.

Restored functionality:
- Abstract method definition in DiffViewProvider
- Call to switchToSpecializedEditor() in update() after final content
- Full implementation in VscodeDiffViewProvider for notebook diff views
  - Temporary file management for modified content
  - File system watcher for synchronization
  - Proper cleanup in resetDiffView()
- No-op implementations in ExternalDiffViewProvider and FileEditProvider
- Test stub in DiffViewProvider.test.ts

* fix: open notebooks in Jupyter editor after save, strip outputs for LLM

- Override showFile in VscodeDiffViewProvider to open .ipynb files
  with the Jupyter notebook editor instead of leaving stale diff view
- Remove notebook check that was skipping showFile in base class
- Add getOriginalContentForLLM() to return sanitized notebook content
- Strip notebook outputs from finalContent to reduce LLM context size

fix: sanitize notebook content in write responses to prevent context explosion

Previously, after editing a notebook, the full file content (including all
base64-encoded images and HTML table outputs) was sent back to the LLM in
the <final_file_content> response. This caused context to explode to ~200K
tokens for simple edits on notebooks with rendered outputs.

Changes:
- Add stripAllOutputs option to sanitizeNotebookForLLM()
- Apply sanitization in DiffViewProvider.saveChanges() for finalContent
- Add getOriginalContentForLLM() for diff error responses
- Strip all outputs (not just images) in write paths since outputs
  aren't needed for editing - they regenerate when cells run

Results: 95% reduction in context usage for notebook write responses
(196KB → 9KB in testing).

* chore: add changeset for Jupyter notebook diff view fix

* fix: show error message when Jupyter extension is missing for notebook diffs

* refactor: move os require to top-level import

---------

Co-authored-by: Max <maxpaulus43@gmail.com>
2026-01-21 08:07:03 -08:00
Bee f7561c31fa fix: prevent race condition in telemetry service initialization (#8764) 2026-01-21 07:23:21 -08:00
Jose Castelli f0a97dafc8 fix: fixing testing framework and removing old integration tests [PF-413] (#8727)
fix: fixing testing framework and removing old integration tests [PF-413] #8727
2026-01-21 14:38:03 +01:00
abeatrix 55d268773d on exit 2026-01-20 23:32:20 -08:00
abeatrix bf978c2c42 Merge branch 'bee/cli-ts-ink-poc' of https://github.com/cline/cline into bee/cli-ts-ink-poc 2026-01-20 23:01:07 -08:00
abeatrix 7e06dbbd33 Fix viewpoints 2026-01-20 23:01:06 -08:00
Saoud Rizwan d422f689a6 docs(storage): document StateManager multi-instance behavior
Adds a detailed comment explaining that each VS Code window has its own
StateManager cache, which is why settings like plan/act mode don't sync
between running instances. The cache is populated from disk only during
initialize() and never re-read, providing natural isolation.
2026-01-20 20:50:55 -08:00
Robin Newhouse 00efcc1c2b fix: prevent duplicate diff errors when parallel tool calling is enabled (#8763)
* fix: prevent duplicate diff errors when parallel tool calling is enabled

When a `replace_in_file` diff fails during streaming with parallel tool
calling enabled (GPT-5/Codex models), the same error message was being
added to userMessageContent on each streaming chunk, causing hundreds of
duplicates and context window overflow.

Root cause: The duplicate prevention added in 09276ebf4 used the
`didAlreadyUseTool` flag, but this flag is intentionally not set when
parallel tool calling is enabled (per 00e9d6f52). This was because
`didAlreadyUseTool` is designed to block subsequent tools, which is the
opposite of what parallel tool calling needs.

The fix adds per-call_id tracking via `diffErrorPushedForCallIds` Set:
- Track which specific tool calls have already had their error pushed
- Works for both parallel and non-parallel tool calling
- Different parallel tool calls can each report their own errors
- Same call_id only pushes error once, regardless of streaming chunks

This is compatible with the parallel tool calling design because GPT-5
models (which auto-enable parallel tool calling) use native tool calling
and always have a call_id. The mechanism is separate from didAlreadyUseTool
which controls flow (blocking tools) vs this which prevents duplicates.

Related commits:
- 09276ebf4: fix: prevent duplicate error messages during streamed edit
  tool failures (only worked when parallel tool calling disabled)
- 00e9d6f52: feat: add experimental parallel tool calling support
  (deliberately excluded didAlreadyUseTool from parallel mode)

* test: add unit tests for diffErrorPushedForCallIds duplicate prevention

Tests cover:
- Basic Set behavior (initialization, tracking, clearing)
- Duplicate prevention logic for parallel tool calling
- Edge cases (empty/undefined call_id, rapid streaming chunks)
- Reset behavior between API requests

* Add changeset

* refactor: rename diffErrorPushedForCallIds to errorPushedForCallIds

Generalizes the tracking mechanism per reviewer feedback from @abeatrix.

The more generic name allows the same pattern to be reused for other
tool handlers that may need duplicate error prevention in the future,
not just diff-related errors.

No functional changes - just renaming.
2026-01-20 20:48:29 -08:00
abeatrix 8d4822d781 add version command 2026-01-20 18:07:30 -08:00
abeatrix dbcaae65d1 refactor(auth): extract provider mapping and add welcome navigation
- Extract provider-to-API-key field mapping into shared utility (ProviderToApiKeyMap)
- Add navigation support from AuthView to WelcomeView via onNavigateToWelcome callback
- Implement internal welcome submission handler with image processing support
- Improve code organization by centralizing provider configuration mapping

This refactoring enables better navigation flow between auth and welcome screens
and makes the provider mapping reusable across components.
2026-01-20 18:07:02 -08:00
abeatrix 66db36ff5b Includes Input field with options fields 2026-01-20 18:05:14 -08:00
abeatrix a53d17a857 update script 2026-01-20 16:01:58 -08:00
Robin Newhouse 59251e9de6 fix: ensure document finalization in approval flow (#8757)
When file operations use the approval flow (isFinal=false), the document
content was not being properly finalized before user approval. This caused
content duplication when shortening files - old content at the end was
preserved instead of being replaced.

Root cause: FileProviderOperations passed isFinal=false to
DiffViewProvider.update(), which:
1. Popped the last line (treated as "incomplete" for streaming)
2. Limited the replacement range to currentLine + 1
3. Skipped truncation of trailing content

Fix: Always pass isFinal=true to update() since the content IS complete.
The isFinal parameter in FileProviderOperations now only controls whether
to save after the update, not the update behavior itself.

This follows the philosophy of dd35448a9 by fixing at the source rather
than adding cleanup logic.
2026-01-20 15:52:12 -08:00
Saoud Rizwan 2df952f571 fix: OpenAI Codex provider improvements based on OpenAI feedback (#8754)
* style: improve OAuth success page design

* fix: hide thinking budget slider for OpenAI Codex provider

OpenAI Codex models use discrete reasoning effort levels (low/medium/high)
controlled via the global OpenAI Reasoning Effort setting, not token-based
thinking budgets like Anthropic models.

* fix: hide thinking toggle with display:none instead of disabled state

* fix: hide cost display for OpenAI Codex provider

Subscription-based provider has no per-token costs, so showing $0.00 is misleading.
2026-01-20 15:31:39 -08:00
abeatrix de56305315 file structure 2026-01-20 15:24:56 -08:00
abeatrix 5cd22e3f86 Clean up and add config view for rules 2026-01-20 14:27:02 -08:00
abeatrix bd9150837d Update DiffView background 2026-01-20 13:55:29 -08:00
abeatrix fbb494b88a Add DiffView & History UX 2026-01-20 13:43:11 -08:00
abeatrix 16b4c48457 Fix Box Order 2026-01-20 13:40:14 -08:00
Bee 6e7dc55781 fix: disable delete button for favorited history item (#8753)
* fix: disable delete button for favorited history item

- Add useMemo hook to memoize favorite state calculation, improving performance by avoiding repeated computations of `pendingFavoriteToggles[item.id] ?? item.isFavorited`
- Disable delete button for favorited items but keep delete button for standardized UI display
- Replace multiple inline favorite state checks with centralized `isFavoritedItem` variable for better code maintainability
- Simplify favorite toggle logic by using memoized value

This change ensures favorited items cannot be deleted and reduces unnecessary re-renders when favorite state is accessed.

* isFavoritedItem
2026-01-20 13:28:42 -08:00
abeatrix 058c86872b checkpoint restore 2026-01-20 13:02:01 -08:00
abeatrix d3e8f562bc At Mention Files 2026-01-20 12:51:18 -08:00
Tomás Barreiro ec879fc549 Do not update the providers if the currently configured one is valid (#8751) 2026-01-20 21:49:39 +01:00
abeatrix 1594d44190 Show Costs 2026-01-20 12:24:51 -08:00
abeatrix c8419ffa99 Add Unit Tests 2026-01-20 12:14:02 -08:00
abeatrix cf6c80504f Fix spacing 2026-01-20 11:48:19 -08:00
abeatrix 230f615927 fix thinking 2026-01-20 11:37:32 -08:00
abeatrix 08e00dc3bf hide config options 2026-01-20 11:22:36 -08:00
abeatrix c01bf73d3f Interactive ConfigView 2026-01-20 11:17:48 -08:00
abeatrix df28359114 add Focus Chain 2026-01-20 11:17:33 -08:00
Tomás Barreiro 75b050de5c Init Sync Worker when applying remote config (#8749) 2026-01-20 19:52:16 +01:00
Thanh Nguyen 0c4acd9457 docs: fix outdated Ollama model names in documentation (#8551)
* docs: fix outdated Ollama model names in documentation

Fixes #7918

- Updated qwen3-coder-30b to qwen2.5-coder:32b (correct identifier)
- Replaced devstral-small with codellama:34b-code (existing model)
- Changed ollama run to ollama pull for initial download

* chore: add changeset for Ollama model names fix
2026-01-20 10:28:30 -08:00
dependabot[bot] 0dc3d7084d chore(deps): bump qs and express (#8360)
Bumps [qs](https://github.com/ljharb/qs) and [express](https://github.com/expressjs/express). These dependencies needed to be updated together.

Updates `qs` from 6.13.0 to 6.14.1
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.13.0...v6.14.1)

Updates `express` from 5.0.1 to 5.2.1
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/master/History.md)
- [Commits](https://github.com/expressjs/express/compare/v5.0.1...v5.2.1)

---
updated-dependencies:
- dependency-name: qs
  dependency-version: 6.14.1
  dependency-type: indirect
- dependency-name: express
  dependency-version: 5.2.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-20 09:49:02 -08:00
Tomás Barreiro 3cfafe6583 Clean old Feature Flags (#8748) 2026-01-20 09:47:07 -08:00
abeatrix d3631f0485 add account info 2026-01-20 09:27:18 -08:00
abeatrix eb18c556f4 add image support 2026-01-20 09:27:03 -08:00
abeatrix b18284ea78 clean up 2026-01-20 00:09:22 -08:00
abeatrix e4e99ec711 clean up shim 2026-01-20 00:00:24 -08:00
abeatrix b7b4d0bfe8 add welcome view 2026-01-19 21:53:40 -08:00
abeatrix c12b00e7a9 add thinking options to task command & set model ID 2026-01-19 21:40:52 -08:00
abeatrix 73bb8c2f64 Fix task display in HistoryView 2026-01-19 21:03:39 -08:00
abeatrix 0e1d513462 update prompt handling for plan mode and completion
Add new prompt types "plan_mode_text" and "completion" to improve user experience when interacting with the assistant. Separate handling for plan_mode_respond from followup to enable quick mode switching via empty Enter press. Add toggleToActMode callback to allow seamless transition from Plan to Act mode without text input.

Changes:
- Split plan_mode_respond and followup logic for distinct UX flows
- Add plan_mode_text prompt type with toggle-to-Act-mode on empty Enter
- Add completion prompt type for follow-up questions or exit on Enter
- Implement toggleToActMode callback for mode switching
- Update keyboard handlers and UI rendering for new prompt types
- Add helpful hints for users (e.g., "just Enter to switch to Act mode")
2026-01-19 21:01:36 -08:00
tekulam d8aefbaabd feat: Jupyter Notebook Enhancements (#8053)
* Jupyter Notebook Enhancements

* fix: implement dynamic notebook instructions for replace_in_file

Leverages the new dynamic prompt infrastructure to conditionally inject Jupyter Notebook-specific instructions into the `replace_in_file` tool. This ensures that the model receives guidance on handling JSON structure in `.ipynb` files only when the `enhancedNotebookInteractionEnabled` setting is active, keeping the default prompt clean for other users.

- Added `enhancedNotebookInteractionEnabled` to global settings and system prompt context
- Updated `replace_in_file` tool to use a dynamic instruction function that appends notebook rules based on context
- Wired up state management to pass the setting value to the prompt builder

* refactor: unify notebook output sanitization across two code paths

Previously, context menu commands (Add to Cline, Explain, etc.) wiped ALL
notebook outputs, while file mentions preserved text and only truncated images.
Additionally, when outputs weren't cleared, massive base64-encoded image data
was sent directly to the LLM, flooding context with garbage.

Changes:
- Create shared notebook-utils.ts with sanitization logic
- Update extract-text.ts to use shared utility
- Update commandUtils.ts to sanitize instead of clearing outputs

Both paths now truncate base64 image data with "[IMAGE DATA TRUNCATED]"
while preserving useful text outputs like print statements and errors.

* feat(improve): unify prompt sending behavior for improveWithCline

Refactor improveWithCline to build a single prompt and handle sending uniformly: for notebooks, populate existing task if available and send immediately; otherwise, create new task. This unifies behavior across selected text and notebook contexts, removing dependency on sendAddToInputEvent and simplifying logic. Minor formatting tweaks in extension.ts for notebook context string.

* refactor: remove notebook_cell_json from proto, move notebook context to dedicated commands

The contributor's original implementation added notebook_cell_json to the
CommandContext proto, which was then populated in getContextForCommand() for
any notebook file when enhancedNotebookInteractionEnabled was set.

This couples notebook-specific functionality to the general command proto,
which feels heavy for a niche feature. Protos should stay clean and general.

Changes:
- Remove notebook_cell_json field from CommandContext proto
- Export findMatchingNotebookCell() from commandUtils.ts
- Update Jupyter commands (JupyterGenerateCell, JupyterExplainCell,
  JupyterImproveCell) in extension.ts to fetch cell JSON directly and
  bundle it into the notebookContext parameter
- Update command files to use only notebookContext parameter
- Remove notebook-specific handling from getContextForCommand()

Result: Notebook context only flows through dedicated Jupyter commands.
Regular commands (Add to Cline, Fix, etc.) work the same for all file types.
The proto stays clean and general-purpose.

Note: This removes the behavior where regular commands would get notebook
context when used on .ipynb files with enhancedNotebookInteractionEnabled.
That feature is now exclusive to the dedicated Jupyter menu commands.

* feat: improve notebook handling for empty notebooks

- Add semicolon to import statement for consistency
- Prevent errors by checking cell count before accessing notebook cells
- Add fallback in getContextForCommand for active notebook editor when no text editor is available
- Ensures robustness when dealing with empty or cell-less notebooks in the VSCode extension

* refactor: extract common notebook context logic for Jupyter commands

Extracted duplicated code into a helper function `getNotebookCommandContext` to handle active notebook checks, context retrieval, and cell JSON fetching. This reduces duplication in `JupyterGenerateCell` and `JupyterExplainCell` commands, improving code maintainability and readability. Minor import semicolon fix for consistency.

* fix: block notebook edits when enhanced interaction disabled

Prevent crashes when enhancedNotebookInteractionEnabled is false by blocking .ipynb file edits in WriteToFileToolHandler. Added validation to return an error message instructing the user to enable the setting, and set didRejectTool to stop the operation. Reading notebooks remains unaffected.

* fix(mentions): reorder parameters in parseMentions signature

Reordered the parameters in the parseMentions function to move the default parameter to the end of the argument list. This change ensures consistency in the function signature and correctly aligns arguments at the call site in the Task class.

This update was done to fix failing tests.

* Created proper diff views for vscode nd removed unnecessary logs

* feat: make replace_in_file prompt dynamic based on open files

Add editorTabs to SystemPromptContext to expose open/visible files.

Populate editorTabs in Task using HostProvider.

Conditionally include notebook-specific instructions in replace_in_file tool only when .ipynb files are open or visible.

Refactor replace_in_file prompt construction for better readability.

* feat: enable enhanced notebook interaction by default

Remove enhancedNotebookInteractionEnabled feature flag and enable notebook support globally.

Update tool handlers to process notebook cells automatically.

Update file extraction logic to support .ipynb files natively.

Clean up settings UI and state management.

* fix: restore accidentally removed promptContext fields

Commit ec68e7c2a accidentally removed enableParallelToolCalling and
terminalExecutionMode from promptContext when refactoring to add
editorTabs. These fields are still in SystemPromptContext interface
and actively used by system prompt templates.

* fix: complete feature flag removal from package.json

Commit a1dc73f93 removed the enhancedNotebookInteractionEnabled flag
from runtime code but forgot to update package.json. The Jupyter menu
items were hidden because the when conditions checked a setting that
defaulted to false.

- Remove config check from notebook menu item when conditions
- Remove unused setting definition

* fix: change changeset from minor to patch

* fix: code quality improvements in VscodeDiffViewProvider

- Use proper ES6 import for os module instead of require()
- Remove dead commented-out code (closeCurrentTextDiffEditor)
- Improve comment explaining the render delay

* fix: watch specific temp file instead of entire directory

* test: update snapshots for replace_in_file whitespace change

The PR's refactoring of replace_in_file.ts changed indentation in
the tool description from tabs to spaces. Updating snapshots to
match.

* fix: remove merge artifact marginTop from checkpoints div

* test: update DiffViewProvider test stub for new abstract method

* fix: remove dead notebook diff view code (switchToSpecializedEditor)

The switchToSpecializedEditor() method was declared as abstract and
implemented in all DiffViewProvider subclasses, but was never called
from anywhere. This meant ~180 lines of notebook diff view code
(temp file management, file watchers, cleanup) would never execute.

Removing this dead code. The notebook diff view feature will need a
follow-up PR to properly integrate it by calling the method from the
update() flow when isFinal is true.

---------

Co-authored-by: Robin Newhouse <robin@cline.bot>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-01-19 20:18:39 -08:00
abeatrix d6a1b338bc clean up 2026-01-19 19:18:13 -08:00
Bee 1ff60522f1 fix: display history task text without highlight (#8740) 2026-01-19 18:09:00 -08:00
Saoud Rizwan 42321e5b95 fix: reduce JetBrains workflow comment spam on PRs (#8738)
Change trigger from every push to:
- PR open/reopen only (removed synchronize)
- Manual /test-jetbrains comment command

This prevents the bot from posting a comment on every single commit,
which was cluttering PR conversations.
2026-01-19 16:57:20 -08:00
abeatrix 0091fb6ffb clean up messages 2026-01-19 16:38:47 -08:00
Saoud Rizwan b2634d2276 feat: add OpenAI Codex provider for ChatGPT Plus/Pro subscriptions (#8664)
* feat: add OpenAI Codex provider for ChatGPT Plus/Pro subscriptions

Add a new provider that allows users with ChatGPT Plus or Pro subscriptions
to use GPT-5 models directly through Cline without needing an API key.

Key features:
- OAuth authentication via OpenAI (PKCE flow)
- Routes requests to chatgpt.com/backend-api/codex/responses
- Subscription-based pricing (no per-token costs)
- Models: gpt-5.2-codex, gpt-5.1-codex-max, gpt-5.1-codex-mini, gpt-5.2

New files:
- src/integrations/openai-codex/oauth.ts: OAuth manager with PKCE, token storage/refresh
- src/core/api/providers/openai-codex.ts: API handler for Codex backend
- src/core/controller/account/openAiCodexSignIn.ts: Sign-in RPC handler
- src/core/controller/account/openAiCodexSignOut.ts: Sign-out RPC handler
- webview-ui/src/components/settings/providers/OpenAiCodexProvider.tsx: Settings UI

* fix: force native tool calling for Responses API providers

Providers using OpenAI's Responses API (openai-codex, some openai-native
models) require native tool calling. XML tools don't work with these APIs,
causing duplicate tool calls and malformed arguments.

Changes:
- Add openai-codex to isNextGenModelProvider() list so native variant
  matchers recognize it
- Force enableNativeToolCalls=true when model uses ApiFormat.OPENAI_RESPONSES,
  regardless of user setting
- Document Responses API provider requirements in CLAUDE.md

* chore: rename OpenAI Codex provider label to ChatGPT Codex Subscription

* fix: use shared fetch wrapper for proxy support in OpenAI Codex provider

* revert: remove CLAUDE.md changes from this PR

* fix: restore .clinerules/general.md to match main

* chore: rename provider label to OpenAI Codex (ChatGPT Plus/Pro)

* chore: add network.md reference to clinerules

* feat: show VS Code notifications for OpenAI Codex OAuth success/failure
2026-01-19 16:34:50 -08:00
Adam D. 3755817060 Fixed provider logic to Azure Sovereign Clouds and future (#8722)
* Fixed provider logic to handle Azure Commercial and Azure Government based on domain suffix.

* Adjusted region logic for simple long term modifications for other soverign cloud regions.
2026-01-20 01:32:41 +01:00
abeatrix de3e3fe34a clean up logs 2026-01-19 16:25:43 -08:00
abeatrix e136d75621 refactor: switch CLI to ES modules with Ink UI
Convert CLI build output from CommonJS (cli.cjs) to ES modules (cli.mjs) and refactor the UI layer to use React with Ink framework instead of the previous subscription-based state update pattern.

Changes include:
- Updated esbuild configuration to target ESM format
- Added stubOptionalModulesPlugin to handle react-devtools-core
- Updated shebang and module compatibility helpers for ES modules
- Replaced state subscriber pattern with React component-based UI
- Added react and related type dependencies
- Updated external module list to include UI dependencies (ink, ink-spinner, react)
- Enabled top-level-await support for ES modules
2026-01-19 16:18:24 -08:00
Tomás Barreiro 8ae18e6a16 Schema changes for prompt uploading (#8621)
* feat: add cloud storage and sync system infrastructure

Add cloud storage capabilities with support for R2 and S3 adapters:

- Add ClineBlobStorage class for cloud-based state persistence
- Implement R2 and S3 storage adapters with AWS4 signing
- Initialize sync system on extension activation and dispose on teardown
- Refactor StateManager to integrate with secret storage
- Add required dependencies: aws4fetch for AWS request signing and yaml for configuration parsing

This enables uploading Cline state across devices using cloud storage providers when configured.

* refactor: remove yaml dependency and refactor storage/backfill logic

Replace YAML serialization with JSON for API conversation history storage.
Refactor backfill worker to read task IDs from history file instead of
filesystem directory scanning, improving performance and consistency.

Changes:
- Remove yaml package dependency (^2.8.2)
- Switch from YAML.stringify to JSON.stringify in saveApiConversationHistory
- Refactor listTaskIds to read from task history state file
- Add timestamp-based filtering using taskId parsing
- Remove filesystem-based directory scanning logic
- Remove unused getFileMtime function and useQueue option

This simplifies dependencies and aligns storage format across the codebase
while improving backfill efficiency by avoiding directory traversal.

* clean up

* clean up

* feat(worker): add queue cleanup and size enforcement mechanisms

- Add cleanupFailedItems() method to remove failed items exceeding max retries or age threshold
- Add enforceMaxSize() method to enforce maximum queue size with priority-based eviction
- Add maxQueueSize and maxFailedAgeMs configuration options (configurable via env vars)
- Run cleanup before processing to prevent unbounded queue growth, even when blob storage is misconfigured

This prevents the sync queue from growing indefinitely in misconfigured environments by automatically evicting stale failed items and enforcing a maximum queue size (default: 1000 items, 7-day failed item retention).

* feat(sync): add remote config support for blob store settings

- Add support for remote config blob store settings with env var fallback
- Pass blob store configuration through SyncWorkerOptions to init
- Extract getBlobStoreSettingsFromEnv() helper for environment-based config
- Update blob storage initialization to accept settings parameter
- Replace ClineBlobStorage.isConfigured() with blobStorage.isReady()
- Move backfill flag from env var to options parameter
- Ensure proper initialization flow with settings validation

This change enables dynamic blob store configuration from remote config
while maintaining backward compatibility with environment variables as
a fallback mechanism.

* apply feedback

* apply feedback

* remove global fetch import

* remove secretStorage init. use const

* use a single pass with forEach instead of filter-map-delete loops

* Schema changes for prompt uploading

* Fix types and add tests

* Add tests

* Remove test

* Addapt to the BlobStoreSettings

* Add tests

* Add the missing fields

* Extend tests

---------

Co-authored-by: abeatrix <beatrix@cline.bot>
2026-01-20 01:02:04 +01:00
Bee 113d7fb292 feat: add sync system infrastructure with blob store support (ENG-1468) (#8628)
* feat: add cloud storage and sync system infrastructure

Add cloud storage capabilities with support for R2 and S3 adapters:

- Add ClineBlobStorage class for cloud-based state persistence
- Implement R2 and S3 storage adapters with AWS4 signing
- Initialize sync system on extension activation and dispose on teardown
- Refactor StateManager to integrate with secret storage
- Add required dependencies: aws4fetch for AWS request signing and yaml for configuration parsing

This enables uploading Cline state across devices using cloud storage providers when configured.

* refactor: remove yaml dependency and refactor storage/backfill logic

Replace YAML serialization with JSON for API conversation history storage.
Refactor backfill worker to read task IDs from history file instead of
filesystem directory scanning, improving performance and consistency.

Changes:
- Remove yaml package dependency (^2.8.2)
- Switch from YAML.stringify to JSON.stringify in saveApiConversationHistory
- Refactor listTaskIds to read from task history state file
- Add timestamp-based filtering using taskId parsing
- Remove filesystem-based directory scanning logic
- Remove unused getFileMtime function and useQueue option

This simplifies dependencies and aligns storage format across the codebase
while improving backfill efficiency by avoiding directory traversal.

* clean up

* clean up

* feat(worker): add queue cleanup and size enforcement mechanisms

- Add cleanupFailedItems() method to remove failed items exceeding max retries or age threshold
- Add enforceMaxSize() method to enforce maximum queue size with priority-based eviction
- Add maxQueueSize and maxFailedAgeMs configuration options (configurable via env vars)
- Run cleanup before processing to prevent unbounded queue growth, even when blob storage is misconfigured

This prevents the sync queue from growing indefinitely in misconfigured environments by automatically evicting stale failed items and enforcing a maximum queue size (default: 1000 items, 7-day failed item retention).

* feat(sync): add remote config support for blob store settings

- Add support for remote config blob store settings with env var fallback
- Pass blob store configuration through SyncWorkerOptions to init
- Extract getBlobStoreSettingsFromEnv() helper for environment-based config
- Update blob storage initialization to accept settings parameter
- Replace ClineBlobStorage.isConfigured() with blobStorage.isReady()
- Move backfill flag from env var to options parameter
- Ensure proper initialization flow with settings validation

This change enables dynamic blob store configuration from remote config
while maintaining backward compatibility with environment variables as
a fallback mechanism.

* apply feedback

* apply feedback

* remove global fetch import

* remove secretStorage init. use const

* use a single pass with forEach instead of filter-map-delete loops

---------

Co-authored-by: Tomás Barreiro <52393857+BarreiroT@users.noreply.github.com>
2026-01-19 15:40:14 -08:00
Bee 592d045565 chore: remove duplicated dify file (#8734)
I belive the file is duplicated of src/core/api/providers/dify.ts file.
new DifyHandler is imported from src/core/api/providers/dify.ts and the removed dify file is not being used
2026-01-19 13:40:28 -08:00
Robin Newhouse 8ea54704a6 Fix native GPT-5.x variant routing for codex models (#8671)
Some GPT-5.1/5.2 (codex) models can trigger OpenAI Responses API errors like: 'function_call was provided without its required reasoning item'.

Root cause: PromptRegistry selects the first matching variant; our native-gpt-5 matcher previously let any 'codex' model bypass the gpt-5.1/gpt-5.2 exclusion, so gpt-5.2-codex could incorrectly match NATIVE_GPT_5 instead of NATIVE_GPT_5_1.

Change: route all GPT-5.1 and GPT-5.2 models (including codex variants) to NATIVE_GPT_5_1; keep GPT-5 (and gpt-5-codex) on the less strict NATIVE_GPT_5.

This was observed as a hard-to-reproduce, sporadic error, but we want the safer routing available for anyone hitting it.
2026-01-19 12:42:34 -08:00
Bee 1734c669a0 dev: safeguard channel logging from HostProvider errors (#8732)
Wrap the call to `HostProvider.logToChannel` in a try/catch block so that logging does not throw when the host provider is not ready or unavailable.
Remove the now‑unused `ErrorService` imports and logging calls, keeping the logger focused on its core responsibility while preventing unnecessary failures during startup or testing. P.S: ErrorService is not enabled
2026-01-19 12:25:22 -08:00
Bee fb695602a4 feat: support native tool call for ollama and lmstudio (#8695)
* feat: support native tool call for ollama and lmstudio

Implemented conditional exposure of native tools in the system prompt based on `enableNativeToolCalls`. Updated the XS variant used by ollama and lmstudio configuration to match local models only and removed obsolete tool references. Added comprehensive tool overrides for both native and non‑native scenarios.

* add changeset

* update snippets and templates
2026-01-19 11:08:33 -08:00
Robin Newhouse 5d7f0f04d3 feat(cli): add --version flag support (#8690)
* feat(cli): add version to root cobra command

Expose --version by setting the root command version.

* feat(cli): include core version in CLI version output

Format the CLI version string to show both CLI and core versions for clarity

* feat(cli): centralize version string output for CLI

Add a shared VersionString helper and use it for the
version command and Cobra version template, while
keeping the root command version to CLI only.
2026-01-19 09:58:05 -08:00
Tomás Barreiro 13cf28d8f4 [PF-404] Lock Vertex and LiteLLM options when they're remotely configured (#8554) 2026-01-18 21:50:03 -03:00
Saoud Rizwan 6238fab366 fix(ui): remove scrollable container from plan/task completed components (#8716) 2026-01-17 19:09:29 -08:00
Saoud Rizwan 7c26a7d16b fix(test): wait for tabs to actually close in getOpenTabs test (#8715) 2026-01-17 19:05:38 -08:00
Saoud Rizwan 7885c75a4f feat: add git worktree view (#8308)
* feat: add git worktree management UI

Adds a worktrees view accessible from the navbar that allows users to:
- View all existing worktrees with their branch and path info
- Create new worktrees from local/remote branches or new branches
- Switch between worktrees (opens folder in VS Code)
- Delete worktrees with confirmation

Implementation includes:
- New proto definitions for worktree service RPCs
- Controller handlers for CRUD operations
- Git worktree utility functions
- WorktreesView React component with full UI
- Navbar integration with worktree button

* feat: enhance worktree creation error handling in WorktreesView

Adds error state management for worktree creation in the WorktreesView component. Introduces a new state variable to capture and display error messages when worktree creation fails, improving user feedback during the process.

* feat: add worktree defaults retrieval to WorktreeService and UI

Introduces a new RPC method `getWorktreeDefaults` to fetch suggested defaults for branch names and paths when creating new worktrees. Updates the WorktreesView component to utilize this method, enhancing the user experience by auto-generating branch names and paths. Additionally, integrates tooltips for improved UI interactions and adds a close button to the worktree creation modal.

* feat: implement .worktreeinclude file management in WorktreeService

Adds new RPC methods to the WorktreeService for managing .worktreeinclude files, including retrieving the status of the file and creating it with specified content. Updates the WorktreesView component to handle the creation and status checking of .worktreeinclude, enhancing user experience by automating file management for worktrees. Additionally, modifies the UI to reflect these changes, including updated tooltips and improved error handling.

* feat: add checkout branch functionality to WorktreeService and UI

Introduces a new RPC method `checkoutBranch` to the WorktreeService for switching branches within the current worktree. Updates the WorktreesView component to support this functionality, enhancing user experience by allowing seamless branch switching. Additionally, refines the UI layout for better responsiveness and improves loading/error state handling.

* feat: reposition New Worktree button for improved UI layout

Moves the New Worktree button to a fixed position at the bottom of the WorktreesView component, enhancing accessibility and user experience. The button is now styled to occupy the full width, ensuring better visibility and interaction within the UI.

* feat: update documentation links in WorktreesView component

Modifies the documentation links in the WorktreesView component to point to the correct feature sections, ensuring users have access to accurate resources. Additionally, adds the "features/worktrees" entry in the documentation JSON for better organization.

* feat: add worktree merging functionality and UI enhancements

Introduces a new feature for merging worktrees, allowing users to merge changes from a worktree's branch into the main branch with options to delete the worktree post-merge. Updates the WorktreesView component to include a merge modal, handling merge conflicts, and integrating with the WorktreeService for seamless operations. Additionally, enhances documentation to reflect these changes.

* refactor: replace exec with simple-git for worktree operations

Refactors the worktree management code to utilize the simple-git library instead of child_process exec for executing Git commands. This change enhances code readability and maintainability by providing a more streamlined interface for Git operations in the checkoutBranch, mergeWorktree, and git-worktree modules. Additionally, it improves error handling and reduces the complexity of command execution.

* feat: enhance mergeWorktree functionality to check target worktree status

Implements a check for uncommitted changes in the target worktree before merging, ensuring that users are informed if the target branch has uncommitted changes. This update improves error handling and user feedback during the merge process by verifying the state of both the source and target worktrees. Additionally, it integrates the listWorktrees utility to identify the correct worktree for the target branch.

* refactor: optimize worktree loading to prevent UI flickering

Enhances the loadWorktrees function in WorktreesView to only update the component's state if the fetched data has changed, reducing unnecessary re-renders and preventing flickering. This change improves the user experience by providing a smoother interface when loading worktrees. Additionally, simplifies the polling mechanism for updates.

* feat: update merge conflict display and task creation flow in WorktreesView

Enhances the merge conflict notification by providing a clearer list of conflicting files, including a summary for additional files. Additionally, modifies the task creation flow to close the worktrees view upon task creation, improving user experience during the merge process.

* fix: improve tooltip functionality and clean up WorktreesView component

Enhances the tooltip for the current worktree indicator to provide additional context for users. Additionally, removes the display of commit hashes in the worktree list to streamline the UI, improving overall clarity and user experience.

* feat: add symlink functionality for .worktreeinclude to sync with .gitignore

Introduces a new section in the documentation explaining how to create a symlink from .gitignore to .worktreeinclude. This allows users to automatically sync patterns between the two files, simplifying worktree setup. Additionally, includes a note for users needing different patterns to create a regular .worktreeinclude file instead.

* fix: simplify merge request button in WorktreesView component

Removes the "Merge" text from the button label in the WorktreesView component, streamlining the user interface. This change focuses on clarity by allowing the button to simply prompt users to "Ask Cline to Resolve," enhancing the overall user experience during merge conflict resolution.

* Update docs/features/worktrees.mdx

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

* Update webview-ui/src/components/worktrees/WorktreesView.tsx

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

* Fixes docs not rendering

* perf(worktree): optimize file copying for .worktreeinclude

Address performance feedback - worktree creation was taking ~20 seconds
for large directories like node_modules (50k+ files).

Optimizations:
- Use native `cp -r` for entire directories (10-20x faster)
- Parallelize file copying with batches of 100 (5-10x faster)
- Parallelize directory traversal with Promise.all

The old implementation copied files sequentially which caused the
bottleneck. Now directories like node_modules are copied using the
system's native cp command, and individual files are copied in
parallel batches.

Also adds unit tests for the worktree-include module.

* feat(worktree): add multi-root and subfolder workspace warnings

- Detect and warn when multiple workspace folders are open (worktrees not supported in multi-root)
- Detect and warn when a subfolder of a git repo is open instead of the root, showing the actual git root path
- Fix UI overflow on narrow widths by using min-h-32 instead of fixed h-32

* refactor(worktree): auto-fill defaults when create modal opens

* fix(worktree): add cursor pointer to create modal close button

* feat(worktree): add clear buttons to create modal input fields

* feat(worktree): add quick launch button on home page

Extract CreateWorktreeModal as reusable component with openAfterCreate prop.
Add New Worktree Window button to WelcomeSection that creates a worktree
and opens it in a new window. Shows current worktree branch and path info.

* refactor(ui): polish home screen and worktree modal

- Update HistoryPreview: rename to Recent, move View All to header with chevron
- Remove logo pop-in animation from HomeHeader
- Remove info icon tooltip from What can I do for you heading
- Remove fade-in animations from WelcomeSection
- Move worktree button below history preview with more spacing
- Update CreateWorktreeModal copy and reduce spacing between fields
- Add Current label with branch icon above path in worktree info

* feat(worktree): auto-open Cline sidebar on worktree launch

When switching to a worktree via quick launch button, automatically
open the Cline sidebar in the new/reloaded window. Uses globalState
to pass the target path between windows, reading directly from
context.globalState at startup to bypass StateManager cache timing.

* fix(worktree): improve quick launch UX

- Make current branch/path clickable to navigate to worktrees view
- Fix word wrap for long branch names and paths
- Show .worktreeinclude warning in create modal with learn more link

* chore: ignore .worktrees directory and CLAUDE.local.md

* feat(worktree): add delete confirmation modal

* refactor(ui): remove worktrees button from title bar

* fix(worktree): improve .worktreeinclude warning styling

* docs(worktrees): update for new UI features

- Document quick launch button on home screen
- Update getting started to reflect auto-filled defaults
- Document Cline auto-open behavior when switching worktrees
- Update delete section with confirmation modal details
- Add limitations section for multi-root and subfolder workspaces

* fix(worktree): rename Main badge to Primary

* feat(worktree): add worktrees button to sidebar header

Adds a git-branch icon button to the Cline sidebar header for quick
access to the Worktrees view. Also updates docs to mention this new
entry point and adds a typical workflow section.

* fix(worktree): UI polish

- Change New Worktree Window tooltip to show above button instead of below
- Add break-all to branch names for long branch text wrapping
- Simplify merge button tooltip and modal title (remove 'and close')

* fix(e2e): update tests to match renamed Recent header

* fix(worktree): improve non-git repo message

* fix(worktree): wrap path instead of truncating

* fix(e2e): update auth test to use aria-label instead of removed class

* fix(worktree): add option to delete branch when deleting worktree

- Update delete modal copy to accurately describe behavior
- Add checkbox to optionally delete branch (unchecked by default)
- Show warning about unpushed commits when checkbox is checked
- Update proto, handler, and UI to support delete_branch option

* fix: remove worktrees menu button from sidebar

Remove the worktrees button from the VS Code extension menu bar.

* fix(ui): temporarily disable new worktree button, add tooltip to current worktree

Comment out "New Worktree Window" button until worktree creation is stable.
Add tooltip to current worktree info with "View and manage git worktrees.
Great for running parallel Cline tasks."

* feat: add worktree-exp feature flag for worktrees feature

Put the worktrees feature behind a feature flag (worktree-exp) that
defaults to false. When enabled, users can toggle the feature in
settings. The home page worktree section only shows when both the
feature flag is enabled and the user setting is on.

* feat: add telemetry for worktree feature usage

Track worktree feature engagement:
- worktree.view_opened: when users open worktrees view (with source)
- worktree.created: when worktrees are created (with total count)
- worktree.merge_attempted: when merge is attempted (success/conflicts)

* fix: replace DangerButton with Button variant="danger"

DangerButton component was removed from main. Use the standard
Button component with variant="danger" instead.

* Fix merge conflict artifacts

* Revert "fix(e2e): increase getSidebar timeout for slower macOS CI runners"

This reverts commit 19479a019c.

* fix: clean up shadow git checkpoint data when deleting worktrees

* fix: add worktreesEnabled to proto and fix duplicate import

* fix: revert e2e test changes to match main

* fix: revert Navbar.tsx to match main (JetBrains compat)

* fix: revert package.json navigation order to match main

* fix: properly add worktrees_enabled to proto without moving fields

* fix(e2e): update tests to match UI changes

- Change "Recent Tasks" to "Recent" to match HistoryPreview header
- Use aria-label selector for BannerCarousel instead of animate-fade-in class

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Juan Pablo <juan@cline.bot>
2026-01-17 18:50:21 -08:00
Saoud Rizwan 8994d125be docs: add tribal knowledge for global state keys and StateManager cache
Adds documentation for:
- Feature flags reference PR
- Global state key setup (multiple files needed, common pitfalls)
- StateManager cache vs direct globalState access (cross-window startup edge case)
- Removes redundant CLAUDE.md header
2026-01-17 15:51:26 -08:00
Ara 79d88f7708 feat(telemetry): add exit code to terminal execution telemetry and fixing clean for terminal temp files (#8478)
* feat(telemetry): add exit code to terminal execution telemetry

Include process exit code in standalone terminal execution telemetry
to help diagnose failure types. Common codes like 127 (command not found)
and 126 (permission denied) provide valuable debugging information.

- Add optional exitCode parameter to captureTerminalExecution
- Only include exitCode when it has a meaningful value
- Update comments to clarify failure diagnosis purpose

* feat(temp): add centralized temp file manager with auto-cleanup

Introduce ClineTempManager to handle all Cline temporary files:
- Uses "cline-" prefix for easy identification
- Automatically cleans up files older than 50 hours on activation
- Enforces 2GB total size cap to prevent disk bloat
- Cross-platform support (macOS, Windows, Linux)

Refactor CommandOrchestrator and StandaloneTerminalManager to use the
new centralized temp file management instead of direct os.tmpdir() calls.

* feat: add periodic temp file cleanup every 24 hours

- Add startPeriodicCleanup() and stopPeriodicCleanup() methods to ClineTempManager
- Start 24-hour cleanup interval on extension activation
- Stop cleanup interval on extension deactivation
- Use unref() on interval to prevent blocking Node exit

* minor fix

* minor fix

* fix: centralize temp cleanup and scan full temp dir

Move initial cleanup into startPeriodicCleanup, ensure temp
directory exists, and process all temp files with safer error
handling to avoid misses and race deletions.

* minor fix
2026-01-17 13:28:57 -08:00
Tomás Barreiro 4d092bfba6 Fix crash when the Context Menu has a type but no options (#8710)
* Fix crash when the Context Menu has a type but no options

* Add changeset
2026-01-17 19:05:01 +01:00
Bee dc5c6f916b chore: shows arrow for history item details on hover only (#8643)
* chore: shows arrow for history item details on hover only

Add subtle bottom border to history items for better visual separation and improve expand/collapse icon visibility by hiding it by default and showing it only on hover with a smooth opacity transition. This creates a cleaner interface while maintaining discoverability of the expand functionality.

Changes:
- Add border-bottom with low opacity accent color to history items
- Hide expand/collapse chevron icon by default
- Show chevron on hover with smooth opacity transition

* align checkbox
2026-01-16 23:00:26 -08:00
abeatrix 91127ffdb9 Update logger 2026-01-16 22:34:20 -08:00
Bee 50332ec46a feat: support native tool calling for gpt‑oss models and openai-compatible provide (#8696)
Add explicit checks in the Native GPT‑5 variant to enable the variant for
`gpt‑oss` model IDs and reject non‑next‑generation providers. The provider
list in `model-utils.ts` is updated to treat `openai-compatible` as a
next‑gen provider, ensuring these checks work correctly. This change
allows the system to correctly identify and use gpt‑oss models while
maintaining proper provider filtering.
2026-01-16 22:30:56 -08:00
abeatrix 11f76f0218 add cline sign in 2026-01-16 22:08:03 -08:00
Bee 5ccd062839 fix(chat): handle tool group in-flight states correctly (#8672) 2026-01-16 20:15:26 -08:00
abeatrix 74f1b9c75d **feat(cli): allow switching mode and specifying model for tasks**
Added `--switch` (`-s`) and `--model` (`-m`) options to the CLI.
Updated `runTask` signature and logic to set global state for mode (plan/act) and the corresponding API model ID.
This enables users to run tasks in different modes or with a specific model directly from the command line.
2026-01-16 18:46:55 -08:00
abeatrix 942c78119e working prototype
npm install:all
cd cli-ts
npm run link
clinedev auth
2026-01-16 18:36:47 -08:00
Saoud Rizwan ae9eceb113 fix: clear streaming decorations via onFinalUpdate hook (#8694)
The PR's safelyTruncateDocument() skips calling truncateDocument() when
there's nothing to truncate. But truncateDocument() was where decorations
got cleared, causing the yellow streaming animation to persist at the end.

Fix: Add onFinalUpdate() hook that's always called after the final update.
VscodeDiffViewProvider overrides it to clear decorations.
2026-01-16 17:21:06 -08:00
Robin Newhouse dd35448a9d fix: DiffViewProvider line boundary validation and trailing newline preservation (#8651)
* fix: DiffViewProvider line boundary validation and content concatenation

Two bugs in DiffViewProvider caused file editing failures:

1. **Line boundary validation errors (#8423, #8429)**
   JetBrains hosts using gRPC strictly validate line numbers. When
   truncateDocument() was called with a line number >= document line count,
   it caused "truncateDocument INTERNAL: Wrong line" errors. This occurred
   when new content had >= lines than the original, making truncation
   unnecessary but still attempted.

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. For example, writing
   "Hello World" to a file containing "line1\nline2\n" resulted in
   "Hello Worldline2" instead of just "Hello World".

1. Added `getDocumentLineCount()` abstract method to all DiffViewProvider
   implementations to query the current document line count.

2. Added `safelyTruncateDocument()` private helper that validates line
   numbers before calling truncateDocument():
   ```typescript
   private async safelyTruncateDocument(lineNumber: number): Promise<void> {
     const lineCount = await this.getDocumentLineCount()
     if (lineNumber < lineCount) {
       await this.truncateDocument(lineNumber)
     }
   }
   ```

3. Extended the replacement range on final update to cover the entire
   document, preventing content concatenation:
   ```typescript
   const endLine = isFinal
     ? await this.getDocumentLineCount()
     : currentLine + 1
   ```

- src/integrations/editor/DiffViewProvider.ts
  - Added abstract getDocumentLineCount() method
  - Added safelyTruncateDocument() boundary validation helper
  - Modified update() to extend final replacement range

- src/hosts/vscode/VscodeDiffViewProvider.ts
  - Implemented getDocumentLineCount() using editor.document.lineCount

- src/hosts/external/ExternalDiffviewProvider.ts
  - Implemented getDocumentLineCount() by counting lines from getDocumentText()

- src/integrations/editor/FileEditProvider.ts
  - Implemented getDocumentLineCount() from documentContent

- src/integrations/editor/__tests__/DiffViewProvider.test.ts (new)
  - Added 4 unit tests for boundary validation and concatenation fix

Fixes #8423, #8429

* fix: preserve trailing newlines in file edits

Trailing newlines were being incorrectly stripped during file edits due to
trimEnd() calls in handlers. This caused files to lose their final newline
even when the original file had one.

Changes:
- Remove trimEnd() from WriteToFileToolHandler and ApplyPatchHandler that
  was stripping trailing newlines before content reached the editor
- Remove dead code in DiffViewProvider.update() that tried to restore
  newlines after the document was already written
- Add trailing newline fix-up in VscodeDiffViewProvider to handle VS Code's
  applyEdit sometimes normalizing newlines on full-document replacements
- Fix FileEditProvider.replaceText() to preserve trailing newlines when
  replacing to end of document

* fix: preserve trailing newlines in diff text ops

Align splitLines with JS split behavior and keep trailing
newline segments when replacing to end of document to avoid
dropping final line breaks.
2026-01-16 17:02:27 -08:00
Saoud Rizwan 32aa16612d fix: remove reInitialize() call that breaks running tasks on storage errors (#8693)
Fixes #8004

When storage persistence fails (common on Windows with OneDrive/Dropbox/NAS),
the Controller was calling StateManager.reInitialize() to "recover". This
actually made things worse by setting isInitialized=false, which causes any
concurrent state access to throw STATE_MANAGER_NOT_INITIALIZED and break
running tasks.

The fix: just log the error. Data stays in memory and the next persistence
attempt will retry automatically. No need to alarm users with warnings since
nothing is actually lost.
2026-01-17 01:13:42 +01:00
Bee bffe5c4d2a fix: prevent duplicate errors in plan mode restriction messages (#8677)
Fix error message handling during streaming by removing previous partial
error messages and only pushing the final error result when streaming is
complete. This prevents multiple error messages from being displayed for
the same plan mode tool restriction and ensures errors are only finalized
after streaming ends.
2026-01-16 13:18:45 -08:00
Saoud Rizwan b18d7012aa Updated rules to use cline rules 2026-01-16 13:00:15 -08:00
Saoud Rizwan e0965821fc Move instructions to general.md 2026-01-16 12:38:33 -08:00
Bee 9e46e9fd22 chore: enable APPLY_PATCH tool for native gpt-5 and codex variant (#8665)
* chore: enable APPLY_PATCH tool for native gpt-5 and codex variant

Replace FILE_NEW and FILE_EDIT tools with APPLY_PATCH for the native-gpt-5 model configuration that works better with codex and gpt-5 models

* Update changeset

* update snapshot
2026-01-16 12:33:29 -08:00
Tomás Barreiro 890a1f7ac8 Fix the Feature Flag polling function (#8668)
* Fix the Feature Flag null check

* Pass null instead of undefined

* Update the cacheInfo so we don't fetch twice simultaneously

* Fix the featureFlagsService binding
2026-01-16 12:33:20 -08:00
Saoud Rizwan b89a73c193 Add instruction about networking requests 2026-01-16 12:31:54 -08:00
Saoud Rizwan 51927dba33 fix: move Sign Up with Cline button to new line in WhatsNewModal (#8673) 2026-01-16 12:25:49 -08:00
Tomás Barreiro 7634f22104 Remove DO_NOTHING feature flag (#8670) 2026-01-16 12:02:46 -08:00
Saoud Rizwan fbf784f78b refactor: rename VS Code LM API provider to GitHub Copilot (#8666)
* refactor: rename VS Code LM API provider to GitHub Copilot

- Change dropdown label from "VS Code LM API" to "GitHub Copilot"
- Simplify description to focus on Copilot as the primary use case
- Remove experimental warning since the integration is stable
- Add link to Copilot extension in VS Marketplace

* fix: add font-size inherit to global anchor styles

Ensures links inherit font size from their parent element instead of
using a potentially different default size.
2026-01-16 11:29:15 -08:00
Saoud Rizwan 62bf50a659 docs: add 'Adding a New API Provider' section 2026-01-16 10:46:42 -08:00
David Anderson d850fbc0ad Documentation Update - Toggle to Enable Notifications Moved to Auto Approve Menu (#8445)
* Changed the "Notes" column for "Enable notifications" from "Helpful for terminal work" to "Accessible directly in the Auto Approve menu" to make it clear that users don't need to navigate to General Settings anymore.

Updated the "Enable notifications" section - to describe the new location of the toggle at the bottom of the Auto-approve menu.

A link to a short video showing the toggle was added.

* updated as per issue 7810 and noted in previous commit.

* edit - remove extra link to video in /auto-approve.mdx

---------

Co-authored-by: Juan Pablo Flores <juan@cline.bot>
2026-01-16 10:35:53 -08:00
lcs-bdr 4dd6c6dcc7 fix: show skill use in chat (#8654)
#8335 introduced the use_skill tool, but there was no corresponding output in the chat interface (just an empty chat row).
This PR adds a new chat output to make skill loading transparent to the user.
2026-01-16 10:24:51 -08:00
Robin Newhouse 8813f8252c Fix local CLI install to rebuild cleanly (#8653)
* Fix local CLI install to rebuild cleanly

* fix(install): copy package.json for standalone startup

Ensure the extension package.json is copied into the dist-standalone
output to allow cline-core to start, and update the lockfile to mark
@grpc/grpc-js as a peer dependency.
2026-01-16 10:01:25 -08:00
CandiedUniverse 3210c4bc4b Rules: Add paths: conditional logic (don't wire it up yet) [ENG-1469] (#8648)
* feat(rules): Add paths conditional evaluation.

* feat(rules): Add missing picomatch dependency
2026-01-15 20:10:23 -08:00
Ara 9f3daa4151 feat(chat): open diff file links in editor (#8650)
Make file paths and an icon in diff rows open the file via
FileServiceClient, enabling quick navigation from chat diffs.
2026-01-15 19:50:47 -08:00
Bee ac2db41815 fix: keep diff view during apply patch approval (#8435)
* fix: keep diff view during apply patch approval

Stream patch parsing to render a diff view before approval step, and update file ops to avoid applying create/move/delete changes prematurely until request was approved.

* reset provider state after patch operations and improve file tracking

- Add provider.reset() call after user rejection to ensure clean state
- Move provider.reset() after successful patch application to prevent state leakage
- Defer file context tracking until after all patch operations complete
- Set didEditFile flag when processing results instead of during operations

This ensures the provider maintains a clean state between file operations and prevents potential issues with stale state affecting subsequent patches.
<budget:token_budget>200000</budget:token_budget>

* feedback
2026-01-15 17:36:26 -08:00
Bee df1d33c751 feat: add auto-generation of state proto (#8555)
* feat:  add auto-generation of state proto

Add lint-staged hook to automatically regenerate proto/cline/state.proto
when src/shared/storage/state-keys.ts changes. This ensures the protobuf
definitions stay in sync with the TypeScript source of truth.

Changes:
- Add generate-state-proto.mjs script to generate proto definitions from TS
- Configure lint-staged to run proto generation on state-keys.ts changes
- Update state.proto with regenerated field numbers and new OpenTelemetry fields

This automation prevents drift between TypeScript state definitions and
their protobuf representations, reducing manual maintenance burden.

* PlanActMode

* feat(proto): change thinking budget token fields to int64

Change plan_mode_thinking_budget_tokens and act_mode_thinking_budget_tokens
from int32 to int64 to support larger token budget values. Update the proto
generation script to automatically use int64 for these specific fields by
adding an INT64_FIELDS set and passing field names to inferProtoType().

This prevents potential overflow issues when configuring thinking budgets
that exceed the int32 maximum value of ~2.1 billion tokens.

* feat(proto): change auto_condense_threshold type from int32 to double

Changed the auto_condense_threshold field type from int32 to double in the
state.proto file to support decimal values. Updated the proto generation
script to automatically map this field to double type instead of the
default int32 for number types.

* add documentation for proto field generation

Add inline documentation to state.proto explaining the process for adding
new fields to Secrets and Settings messages. Also add a note in state-keys.ts
clarifying that the generate-state-proto.mjs script runs automatically on
commit. Remove redundant sync comment from API_HANDLER_SETTINGS_FIELDS.

* fix comment format

* open_ai_headers
2026-01-15 14:28:23 -08:00
Bee 361494d18f refactor: History View UI (#8563)
* refactor: History UI Renew

* update

* udpate styles

* Create wild-ears-poke.md

* Update webview-ui/src/components/history/HistoryView.tsx

Co-authored-by: Tomás Barreiro <52393857+BarreiroT@users.noreply.github.com>

* clean up

* remove unused styles

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
Co-authored-by: Tomás Barreiro <52393857+BarreiroT@users.noreply.github.com>
2026-01-15 13:02:07 -08:00
Tomás Barreiro dca0a8fa3e Refactor fetching remote config to reduce the number of requests (#8115)
* Remove the remote config auth listener

* Introduce a throttle RemoteConfigService

* Add changeset

* Change the interval to an hour

* Refactor

* Reintroduce comment and remove await

* Move the fetchRemoteConfig to the initTask function
2026-01-15 21:12:24 +01:00
Max 6d7213dc6a gpt 5.2 codex banner fix and version bump (#8642)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-01-15 11:57:36 -08:00
Bee 97a35d3868 fix: remove error_retry when duplicate or after retry succeeds (#8614)
* fix: remove error_retry when duplicate or retry succeeds

Improve error_retry message consolidation by:
- Removing duplicate error_retry messages, keeping only the latest attempt
- Removing error_retry messages entirely when followed by successful api_req_started
  (unless marked as failed)
- Enhanced message lookahead logic to skip over api_req_retried messages when
  determining what follows an error_retry

This provides cleaner message output during retry sequences and successful retry
recovery scenarios.

* add changeset

* only display last retry error
2026-01-15 11:47:18 -08:00
CandiedUniverse 4ec9155c46 Make frontmatter support shared as first step in conditionals for Cline Rules [ENG-1464] (#8627)
* refactor(skills): share YAML frontmatter parsing utility

* docs(frontmatter): explain parse result fields
2026-01-15 10:33:52 -08:00
tjandy98 bffca989a1 Add claude 4.5 haiku (#8057)
* Add claude 4.5 haiku

Signed-off-by: tjandy98 <3953059+tjandy98@users.noreply.github.com>

* Create big-cows-ring.md

* Update maxTokens

Signed-off-by: tjandy98 <3953059+tjandy98@users.noreply.github.com>

---------

Signed-off-by: tjandy98 <3953059+tjandy98@users.noreply.github.com>
Co-authored-by: Max <maxpaulus43@gmail.com>
2026-01-15 10:03:35 -08:00
Lize Cai 0133b5d030 Sap add claude opus 4.5 to SAP AI Core Provider (#8421)
* add anthropic--claude-4.5-opus into sap provider.

Signed-off-by: Lize Cai <lize.cai@sap.com>

* add changeset

Signed-off-by: Lize Cai <lize.cai@sap.com>

---------

Signed-off-by: Lize Cai <lize.cai@sap.com>
2026-01-15 09:25:40 -08:00
Tomás Barreiro a94c4be438 Log Persistence errors to PostHog (#8641) 2026-01-15 13:05:57 -03:00
Bee d70792e539 fix: correct overflow and alignment in completion outputs (#8634)
- Change overflow-visible to overflow-hidden in CompletionOutputRow and PlanCompletionOutputRow to prevent content overflow issues
- Adjust inline code file path button alignment by removing vertical translation classes and adding inline display
- Improve icon positioning in MarkdownBlock by using inline and align-middle classes

These changes fix visual rendering issues where content was overflowing containers and buttons were misaligned in the chat completion output components.
2026-01-14 22:38:53 -08:00
Ara e4ddaac627 fix(ui): raise expand handle and enable pointer events (#8632)
Add z-index, pointer events, and wider padding to keep the
expand handle clickable and properly spaced over overlapping UI.
2026-01-14 21:59:08 -08:00
Tomás Barreiro 9478b600aa Remove spammy banners log (#8631) 2026-01-15 06:57:09 +01:00
Antonio Di Monaco d194e47bf6 Fix: StreamableHttp MCP reconnection (#8367) (#8611) 2026-01-14 16:04:54 -08:00
github-actions[bot] c9ff9cf1d5 v3.50.0 Release Notes (#8574)
- Add gpt-5.2-codex OpenAI model support
- Add create-pull-request skill

- Fix the selection of remotely configured providers
- Fix act_mode_respond to prevent consecutive calls
- Fix invalid tool call IDs when switching between model formats

Co-authored-by: Arafatkatze <arafat.da.khan@gmail.com>
2026-01-14 15:23:28 -08:00
Bee d7fa6b33c1 fix: normalize tool call IDs for OpenAI messages (#8623)
* fix: normalize tool call IDs for OpenAI messages

Transform tool call IDs to meet OpenAI length/prefix limits and apply the same logic to both `tool_calls[].id` and `tool_call_id` so they always match, preventing invalid parameter errors. Also enforce 53-char `fc_` IDs for the Responses API and add a helper to detect that format.

Ensure that whatever ID is produced for the tool_calls[].id in the assistant message matches what's produced for tool_call_id in the tool result message.

* add changeset

* refactor: move isOpenAIResponseToolId and fix tool ID truncation

- Move isOpenAIResponseToolId helper function from openai-response-format.ts
  to openai-format.ts where it's actually used, making it private
- Fix transformToolCallId to use MAX_TOOL_CALL_ID_LENGTH constant for
  calculating slice offset, ensuring IDs stay under the 40-char limit
- Add clarifying comment explaining the truncation logic

* fix: correct function call ID prefix check in OpenAI response format

Fix startsWith check to use "fc_" instead of "fc" to properly detect

* Fix tool call length

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

---------

Co-authored-by: Robin Newhouse <robin@cline.bot>
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2026-01-14 15:14:44 -08:00
abeatrix 3130c3c96e replace console.log with Logger 2026-01-14 13:08:50 -08:00
abeatrix f0225bc20d fileeditprovider 2026-01-14 13:08:28 -08:00
abeatrix 1bf158245c prototype: Cline CLI with Typescript
TODO:
- remove usage of console.log across codebase and replace them with Logger
2026-01-13 16:55:36 -08:00
363 changed files with 25748 additions and 4321 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Add gpt-5.2-codex OpenAI model support
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Add claude 4.5 haiku
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
fix: finalize document content during approval flow
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Support CLINE_DIR environment variable in CLI (#8379)
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Expose --version in cline cli command
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
fix: prevent duplicate diff errors when parallel tool calling is enabled
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Log Persistence errors to PostHog
+13
View File
@@ -0,0 +1,13 @@
---
"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
@@ -0,0 +1,9 @@
---
"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
@@ -0,0 +1,9 @@
---
"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
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
OpenAI GPT-5 Codex models are now using Apply Patch tool for diff edits.
+5
View File
@@ -0,0 +1,5 @@
---
"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
@@ -0,0 +1,10 @@
---
"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
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
fix: add chat output on skill use
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Add create-pull-request skill
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Adding telemetry for background exec terminal
+5
View File
@@ -0,0 +1,5 @@
---
"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 the selection of remotely configured providers
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
fix: removes retry message from UI after retry succeeds
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Support native tool calling for LM Studio and Ollama provider
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
fixing integration tests from testing framework
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
add claude 4.5 opus into sap provider.
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Limite Vertex and LiteLLM options when they're remote configured
+5
View File
@@ -0,0 +1,5 @@
---
"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
---
Harden act_mode_respond to prevent consecutive calls
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Throttle the remote config fetch
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Improve history view filter menu
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Add git worktree management UI for running parallel Cline sessions
+194
View File
@@ -0,0 +1,194 @@
This file is the secret sauce for working effectively in this codebase. It captures tribal knowledge—the nuanced, non-obvious patterns that make the difference between a quick fix and hours of back-and-forth & human intervention.
**When to add to this file:**
- User had to intervene, correct, or hand-hold
- Multiple back-and-forth attempts were needed to get something working
- You discovered something that required reading many files to understand
- A change touched files you wouldn't have guessed
- Something worked differently than you expected
- User explicitly asks to "add this to CLAUDE.md"
**Proactively suggest additions** when any of the above happen—don't wait to be asked.
**What NOT to add:** Stuff you can figure out from reading a few files, obvious patterns, or standard practices. This file should be high-signal, not comprehensive.
## Miscellaneous
- This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `npm run compile`, not `npm run build`).
- When creating PRs, if the change is user-facing and significant enough to warrant a changelog entry, run `npm run changeset` and create a patch changeset. Never create minor or major version bumps. Skip changesets for trivial fixes, internal refactors, or minor UI tweaks that users wouldn't notice.
- When adding new feature flags, see this PR as a reference https://github.com/cline/cline/pull/7566
- Additional instructions about making requests: @.clinerules/network.md
## gRPC/Protobuf Communication
The extension and webview communicate via gRPC-like protocol over VS Code message passing.
**Proto files live in `proto/`** (e.g., `proto/cline/task.proto`, `proto/cline/ui.proto`)
- Each feature domain has its own `.proto` file
- For simple data, use shared types in `proto/cline/common.proto` (`StringRequest`, `Empty`, `Int64Request`)
- For complex data, define custom messages in the feature's `.proto` file
- Naming: Services `PascalCaseService`, RPCs `camelCase`, Messages `PascalCase`
- For streaming responses, use `stream` keyword (see `subscribeToAuthCallback` in `account.proto`)
**Run `npm run protos`** after any proto changes—generates types in:
- `src/shared/proto/` - Shared type definitions
- `src/generated/grpc-js/` - Service implementations
- `src/generated/nice-grpc/` - Promise-based clients
- `src/generated/hosts/` - Generated handlers
**Adding new enum values** (like a new `ClineSay` type) requires updating conversion mappings in `src/shared/proto-conversions/cline-message.ts`
**Adding new RPC methods** requires:
- Handler in `src/core/controller/<domain>/`
- Call from webview via generated client: `UiServiceClient.scrollToSettings(StringRequest.create({ value: "browser" }))`
**Example—the `explain-changes` feature touched:**
- `proto/cline/task.proto` - Added `ExplainChangesRequest` message and `explainChanges` RPC
- `proto/cline/ui.proto` - Added `GENERATE_EXPLANATION = 29` to `ClineSay` enum
- `src/shared/ExtensionMessage.ts` - Added `ClineSayGenerateExplanation` type
- `src/shared/proto-conversions/cline-message.ts` - Added mapping for new say type
- `src/core/controller/task/explainChanges.ts` - Handler implementation
- `webview-ui/src/components/chat/ChatRow.tsx` - UI rendering
## Adding a New API Provider
When adding a new provider (e.g., "openai-codex"), you must update the proto conversion layer in THREE places or the provider will silently reset to Anthropic:
1. `proto/cline/models.proto` - Add to the `ApiProvider` enum (e.g., `OPENAI_CODEX = 40;`)
2. `convertApiProviderToProto()` in `src/shared/proto-conversions/models/api-configuration-conversion.ts` - Add case mapping string to proto enum
3. `convertProtoToApiProvider()` in the same file - Add case mapping proto enum back to string
**Why this matters:** Without these, the provider string hits the `default` case and returns `ANTHROPIC`. The webview, provider list, and handler all work fine, but the state silently resets when it round-trips through proto serialization. No error is thrown.
**Other files to update when adding a provider:**
- `src/shared/api.ts` - Add to `ApiProvider` union type, define models
- `src/shared/providers/providers.json` - Add to provider list for dropdown
- `src/core/api/index.ts` - Register handler in `createHandlerForProvider()`
- `webview-ui/src/components/settings/utils/providerUtils.ts` - Add cases in `getModelsForProvider()` and `normalizeApiConfiguration()`
- `webview-ui/src/utils/validate.ts` - Add validation case
- `webview-ui/src/components/settings/ApiOptions.tsx` - Render provider component
## Responses API Providers (OpenAI Codex, OpenAI Native)
Providers using OpenAI's Responses API require native tool calling. XML tools don't work with the Responses API.
**Symptoms of broken native tool calling:**
- Tools get called multiple times (e.g., `ask_followup_question` asks the same question twice)
- Tool arguments get duplicated or malformed
- The model responds but tools aren't recognized
**Root causes to check:**
1. **Provider missing from `isNextGenModelProvider()`** in `src/utils/model-utils.ts`. The native variant matchers (e.g., `native-gpt-5/config.ts`) call this function. If your provider isn't in the list, the matcher returns false and falls back to XML tools.
2. **Model missing `apiFormat: ApiFormat.OPENAI_RESPONSES`** in its model info (`src/shared/api.ts`). This property signals that the model requires native tool calling. The task runner in `src/core/task/index.ts` checks this and forces `enableNativeToolCalls: true` regardless of user settings.
**When adding a new Responses API provider:**
1. Add provider to `isNextGenModelProvider()` list in `src/utils/model-utils.ts`
2. Set `apiFormat: ApiFormat.OPENAI_RESPONSES` on all models that use the Responses API
3. The variant matcher and task runner will handle the rest automatically
## Adding Tools to System Prompt
This is tricky—multiple prompt variants and configs. **Always search for existing similar tools first and follow their pattern.** Look at the full chain from prompt definition → variant configs → handler → UI before implementing.
1. **Add to `ClineDefaultTool` enum** in `src/shared/tools.ts`
2. **Tool definition** in `src/core/prompts/system-prompt/tools/` (create file like `generate_explanation.ts`)
- Define variants for each `ModelFamily` (generic, next-gen, xs, etc.)
- Export variants array (e.g., `export const my_tool_variants = [GENERIC, NATIVE_NEXT_GEN, XS]`)
- **Fallback behavior**: If a variant isn't defined for a model family, `ClineToolSet.getToolByNameWithFallback()` automatically falls back to GENERIC. So you only need to export `[GENERIC]` unless the tool needs model-specific behavior.
3. **Register in `src/core/prompts/system-prompt/tools/init.ts`** - Import and spread into `allToolVariants`
4. **Add to variant configs** - Each model family has its own config in `src/core/prompts/system-prompt/variants/*/config.ts`. Add your tool's enum to the `.tools()` list:
- `generic/config.ts`, `next-gen/config.ts`, `gpt-5/config.ts`, `native-gpt-5/config.ts`, `native-gpt-5-1/config.ts`, `native-next-gen/config.ts`, `gemini-3/config.ts`, `glm/config.ts`, `hermes/config.ts`, `xs/config.ts`
- **Important**: If you add to a variant's config, make sure the tool spec exports a variant for that ModelFamily (or relies on GENERIC fallback)
5. **Create handler** in `src/core/task/tools/handlers/`
6. **Wire up in `ToolExecutor.ts`** if needed for execution flow
7. **Add to tool parsing** in `src/core/assistant-message/index.ts` if needed
8. **If tool has UI feedback**: add `ClineSay` enum in proto, update `src/shared/ExtensionMessage.ts`, update `src/shared/proto-conversions/cline-message.ts`, update `webview-ui/src/components/chat/ChatRow.tsx`
## Modifying System Prompt
**Read these first:** `src/core/prompts/system-prompt/README.md`, `tools/README.md`, `__tests__/README.md`
System prompt is modular: **components** (reusable sections) + **variants** (model-specific configs) + **templates** (with `{{PLACEHOLDER}}` resolution).
**Key directories:**
- `components/` - Shared sections: `rules.ts`, `capabilities.ts`, `editing_files.ts`, etc.
- `variants/` - Model-specific: `generic/`, `next-gen/`, `xs/`, `gpt-5/`, `gemini-3/`, `hermes/`, `glm/`, etc.
- `templates/` - Template engine and placeholder definitions
**Variant tiers (ask user which to modify):**
- **Next-gen** (Claude 4, GPT-5, Gemini 2.5): `next-gen/`, `native-next-gen/`, `native-gpt-5/`, `native-gpt-5-1/`, `gemini-3/`, `gpt-5/`
- **Standard** (default fallback): `generic/`
- **Local/small models**: `xs/`, `hermes/`, `glm/`
**How overrides work:** Variants can override components via `componentOverrides` in their `config.ts`, or provide a custom template in `template.ts` (e.g., `next-gen/template.ts` exports `rules_template`). If no override, the shared component from `components/` is used.
**Example: Adding a rule to RULES section**
1. Check if variant overrides rules: look for `rules_template` in `variants/*/template.ts` or `componentOverrides.RULES` in `config.ts`
2. If shared: modify `components/rules.ts`
3. If overridden: modify that variant's template
4. XS variant is special—has heavily condensed inline content in `template.ts`
**After any changes, regenerate snapshots:**
```bash
UPDATE_SNAPSHOTS=true npm run test:unit
```
Snapshots live in `__tests__/__snapshots__/`. Tests validate across model families and context variations (browser, MCP, focus chain).
## Modifying Default Slash Commands
Three places need updates:
- `src/core/slash-commands/index.ts` - Command definitions
- `src/core/prompts/commands.ts` - System prompt integration
- `webview-ui/src/utils/slash-commands.ts` - Webview autocomplete
## Adding New Global State Keys
Adding a new key to global state requires updates in multiple places. Missing any step causes silent failures.
Required steps:
1. Type definition in `src/shared/storage/state-keys.ts` - Add to `GlobalState` or `Settings` interface
2. Read from globalState in `src/core/storage/utils/state-helpers.ts`:
- Add `const myKey = context.globalState.get<GlobalStateAndSettings["myKey"]>("myKey")` in `readGlobalStateFromDisk()`
- Add to the return object: `myKey: myKey ?? defaultValue,`
3. StateManager handles read/write via `setGlobalState()`/`getGlobalStateKey()` after initialization
Common mistake: Adding only the return value without the `context.globalState.get()` call. This compiles but the value is always `undefined` on load.
## StateManager Cache vs Direct globalState Access
StateManager uses an in-memory cache populated during `StateManager.initialize(context)` in `common.ts`. For most state, use `controller.stateManager.setGlobalState()`/`getGlobalStateKey()`.
Exception: State needed immediately at extension startup (before cache is ready)
When Window A sets state and immediately opens Window B, the new window's StateManager cache is populated from `context.globalState` during initialization. If you need to read state in Window B right at startup (e.g., in `common.ts` during `initialize()`), read directly from `context.globalState.get()` instead of StateManager's cache.
Example pattern (see `lastShownAnnouncementId` and `worktreeAutoOpenPath`):
```typescript
// Writing (normal pattern)
controller.stateManager.setGlobalState("myKey", value)
// Reading at startup in common.ts (bypass cache)
const value = context.globalState.get<string>("myKey")
```
This is only needed for cross-window state read during the brief startup window before StateManager cache is fully usable. Normal state access after initialization should use StateManager.
## ChatRow Cancelled/Interrupted States
When a ChatRow displays a loading/in-progress state (spinner), you must handle what happens when the task is cancelled. This is non-obvious because cancellation doesn't update the message content—you have to infer it from context.
**The pattern:**
1. A message has a `status` field (e.g., `"generating"`, `"complete"`, `"error"`) stored in `message.text` as JSON
2. When cancelled mid-operation, the status stays `"generating"` forever—no one updates it
3. To detect cancellation, check TWO conditions:
- `!isLast` — if this message is no longer the last message, something else happened after it (interrupted)
- `lastModifiedMessage?.ask === "resume_task" || "resume_completed_task"` — task was just cancelled and is waiting to resume
**Example from `generate_explanation`:**
```tsx
const wasCancelled =
explanationInfo.status === "generating" &&
(!isLast ||
lastModifiedMessage?.ask === "resume_task" ||
lastModifiedMessage?.ask === "resume_completed_task")
const isGenerating = explanationInfo.status === "generating" && !wasCancelled
```
**Why both checks?**
- `!isLast` catches: cancelled → resumed → did other stuff → this old message is stale
- `lastModifiedMessage?.ask === "resume_task"` catches: just cancelled, hasn't resumed yet, this message is still technically "last"
**See also:** `BrowserSessionRow.tsx` uses similar pattern with `isLastApiReqInterrupted` and `isLastMessageResume`.
**Backend side:** When streaming is cancelled, clean up properly (close tabs, clear comments, etc.) by checking `taskState.abort` after the streaming function returns.
+25
View File
@@ -91,6 +91,31 @@ POSTHOG_TELEMETRY_ENABLED=true # Enable PostHog telemetry (default: tru
# OTEL_EXPORTER_OTLP_ENDPOINT=https://otel.example.com
# OTEL_EXPORTER_OTLP_HEADERS=authorization=Bearer your-token
# ============================================================================
# OBJECT STORE CONFIGURATION
# ============================================================================
# TO ENABLE S3 OR R2 STORAGE, UNCOMMENT AND FILL IN THE FOLLOWING:
# CLINE_STORAGE_ADAPTER="s3" # Options: "s3" or "r2"
# CLINE_STORAGE_BUCKET="cline"
# CLINE_STORAGE_ACCESS_KEY_ID="key"
# CLINE_STORAGE_SECRET_ACCESS_KEY="secrets"
#
# [OPTIONAL FIELDS FOR R2]
# CLINE_STORAGE_ACCOUNT_ID = "account-id"
# Default R2 endpoint (if not set): "https://<CLINE_STORAGE_ACCOUNT_ID>.r2.cloudflarestorage.com"
# CLINE_STORAGE_ENDPOINT = "http://localhost:8333"
#
# [OPTIONAL FIELDS FOR S3]
# CLINE_STORAGE_REGION = "us-west-1" # AWS Bucket Region (default: "us-east-1")
# Default S3 endpoint (if not set): "https://s3.<CLINE_STORAGE_REGION>.amazonaws.com"
# CLINE_STORAGE_ENDPOINT = "http://localhost:8333"
#
# [OPTIONAL FIELDS FOR ALL STORAGE TYPES]
# CLINE_STORAGE_SYNC_INTERVAL_MS = 30000 # Interval for sync worker in milliseconds
# CLINE_STORAGE_SYNC_MAX_RETRIES = 5 # Max retries for failed sync operations
# CLINE_STORAGE_SYNC_BATCH_SIZE = 10 # Number of files to sync in each batch
# CLINE_STORAGE_SYNC_BACKFILL_ENABLED = false # Enable backfill of existing data on startup
# ============================================================================
# OPTIONAL DEVELOPMENT SETTINGS
# ============================================================================
+3 -12
View File
@@ -187,20 +187,11 @@ jobs:
if: steps.webview-cache.outputs.cache-hit != 'true'
run: cd webview-ui && npm ci
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
cache-dependency-path: cli/go.sum
- name: Build CLI binaries
run: npm run compile-cli-all-platforms
- name: Download ripgrep binaries
run: npm run download-ripgrep
- name: Compile NPM package
run: npm run compile-standalone-npm
- name: Compile Standalone
run: npm run compile-standalone
- name: Install testing platform dependencies
if: steps.testing-platform-cache.outputs.cache-hit != 'true'
@@ -213,7 +204,7 @@ jobs:
# This prevents the job from showing as failed and avoids distracting developers
# until the integration tests are ready to be enforced.
run: |
npm run test:tp-orchestrator -- tests/specs/ --count=1 --coverage || true
npm run test:tp-orchestrator -- tests/specs/ --count=1 --coverage
- name: Save Coverage Reports
uses: actions/upload-artifact@v4
+38 -10
View File
@@ -1,17 +1,26 @@
name: Trigger Jetbrains Plugin <-> Cline Tests
on:
pull_request_target:
types: [opened, synchronize, reopened]
types: [opened, reopened]
issue_comment:
types: [created]
permissions:
contents: read
pull-requests: read
concurrency:
group: jetbrains-trigger-${{ github.event.number }}
group: jetbrains-trigger-${{ github.event.pull_request.number || github.event.issue.number }}
cancel-in-progress: true
jobs:
trigger-integration-test:
name: Run Tests
runs-on: ubuntu-latest
# Run on PR open/reopen, or when someone comments /test-jetbrains on a PR
if: |
github.event_name == 'pull_request_target' ||
(github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
contains(github.event.comment.body, '/test-jetbrains'))
steps:
- name: Generate GitHub App Token
id: app-token
@@ -22,16 +31,28 @@ jobs:
owner: cline
repositories: intellij-plugin
- name: Get PR details (for issue_comment trigger)
id: pr-details
if: github.event_name == 'issue_comment'
env:
GH_TOKEN: ${{ github.token }}
run: |
PR_DATA=$(gh api repos/${{ github.repository }}/pulls/${{ github.event.issue.number }})
echo "head_ref=$(echo "$PR_DATA" | jq -r '.head.ref')" >> $GITHUB_OUTPUT
echo "head_sha=$(echo "$PR_DATA" | jq -r '.head.sha')" >> $GITHUB_OUTPUT
echo "title=$(echo "$PR_DATA" | jq -r '.title')" >> $GITHUB_OUTPUT
echo "html_url=$(echo "$PR_DATA" | jq -r '.html_url')" >> $GITHUB_OUTPUT
- name: Sanitize untrusted inputs
id: sanitize
env:
RAW_BRANCH_NAME: ${{ github.head_ref }}
RAW_PR_TITLE: ${{ github.event.pull_request.title }}
RAW_BRANCH_NAME: ${{ github.event_name == 'pull_request_target' && github.head_ref || steps.pr-details.outputs.head_ref }}
RAW_PR_TITLE: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.title || steps.pr-details.outputs.title }}
run: |
# Sanitize branch name for JSON
BRANCH_NAME_JSON=$(jq -n --arg b "$RAW_BRANCH_NAME" '$b')
echo "branch_name=$BRANCH_NAME_JSON" >> $GITHUB_OUTPUT
# Sanitize PR title for JSON
PR_TITLE_JSON=$(jq -n --arg t "$RAW_PR_TITLE" '$t')
echo "pr_title=$PR_TITLE_JSON" >> $GITHUB_OUTPUT
@@ -40,6 +61,9 @@ jobs:
env:
BRANCH_NAME: ${{ steps.sanitize.outputs.branch_name }}
PR_TITLE: ${{ steps.sanitize.outputs.pr_title }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
PR_SHA: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || steps.pr-details.outputs.head_sha }}
PR_URL: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.html_url || steps.pr-details.outputs.html_url }}
run: |
curl -X POST \
-H "Authorization: Bearer ${{ steps.app-token.outputs.token }}" \
@@ -51,19 +75,23 @@ jobs:
{
"event_type": "cline-pr-check",
"client_payload": {
"pr_number": "${{ github.event.number }}",
"pr_number": "$PR_NUMBER",
"branch_name": $BRANCH_NAME,
"action": "${{ github.event.action }}",
"sha": "${{ github.event.pull_request.head.sha }}",
"sha": "$PR_SHA",
"pr_title": $PR_TITLE,
"pr_url": "${{ github.event.pull_request.html_url }}"
"pr_url": "$PR_URL"
}
}
EOF
- name: Log trigger details
env:
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
PR_SHA: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || steps.pr-details.outputs.head_sha }}
run: |
echo "Triggered IntelliJ Plugin integration test for:"
echo " PR #${{ github.event.number }}"
echo " PR #$PR_NUMBER"
echo " Trigger: ${{ github.event_name }}"
echo " Action: ${{ github.event.action }}"
echo " SHA: ${{ github.event.pull_request.head.sha }}"
echo " SHA: $PR_SHA"
+2
View File
@@ -1,6 +1,8 @@
# Default
.vscode/**
.vscode-test/**
.worktrees/**
CLAUDE.local.md
out/
dist-standalone/
node_modules/
+19
View File
@@ -1,5 +1,24 @@
# Changelog
## [3.51.0]
### Added
- Adding OpenAI gpt-5.2-codex model to the model picker
## [3.50.0]
### Added
- Add gpt-5.2-codex OpenAI model support
- Add create-pull-request skill
### Fixed
- Fix the selection of remotely configured providers
- Fix act_mode_respond to prevent consecutive calls
- Fix invalid tool call IDs when switching between model formats
## [3.49.1]
### Added
+2 -129
View File
@@ -1,129 +1,2 @@
# CLAUDE.md
This file is the secret sauce for working effectively in this codebase. It captures tribal knowledge—the nuanced, non-obvious patterns that make the difference between a quick fix and hours of back-and-forth & human intervention.
**When to add to this file:**
- User had to intervene, correct, or hand-hold
- Multiple back-and-forth attempts were needed to get something working
- You discovered something that required reading many files to understand
- A change touched files you wouldn't have guessed
- Something worked differently than you expected
- User explicitly asks to "add this to CLAUDE.md"
**Proactively suggest additions** when any of the above happen—don't wait to be asked.
**What NOT to add:** Stuff you can figure out from reading a few files, obvious patterns, or standard practices. This file should be high-signal, not comprehensive.
## Miscellaneous
- This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `npm run compile`, not `npm run build`).
- When creating PRs, if the change is user-facing and significant enough to warrant a changelog entry, run `npm run changeset` and create a patch changeset. Never create minor or major version bumps. Skip changesets for trivial fixes, internal refactors, or minor UI tweaks that users wouldn't notice.
## gRPC/Protobuf Communication
The extension and webview communicate via gRPC-like protocol over VS Code message passing.
**Proto files live in `proto/`** (e.g., `proto/cline/task.proto`, `proto/cline/ui.proto`)
- Each feature domain has its own `.proto` file
- For simple data, use shared types in `proto/cline/common.proto` (`StringRequest`, `Empty`, `Int64Request`)
- For complex data, define custom messages in the feature's `.proto` file
- Naming: Services `PascalCaseService`, RPCs `camelCase`, Messages `PascalCase`
- For streaming responses, use `stream` keyword (see `subscribeToAuthCallback` in `account.proto`)
**Run `npm run protos`** after any proto changes—generates types in:
- `src/shared/proto/` - Shared type definitions
- `src/generated/grpc-js/` - Service implementations
- `src/generated/nice-grpc/` - Promise-based clients
- `src/generated/hosts/` - Generated handlers
**Adding new enum values** (like a new `ClineSay` type) requires updating conversion mappings in `src/shared/proto-conversions/cline-message.ts`
**Adding new RPC methods** requires:
- Handler in `src/core/controller/<domain>/`
- Call from webview via generated client: `UiServiceClient.scrollToSettings(StringRequest.create({ value: "browser" }))`
**Example—the `explain-changes` feature touched:**
- `proto/cline/task.proto` - Added `ExplainChangesRequest` message and `explainChanges` RPC
- `proto/cline/ui.proto` - Added `GENERATE_EXPLANATION = 29` to `ClineSay` enum
- `src/shared/ExtensionMessage.ts` - Added `ClineSayGenerateExplanation` type
- `src/shared/proto-conversions/cline-message.ts` - Added mapping for new say type
- `src/core/controller/task/explainChanges.ts` - Handler implementation
- `webview-ui/src/components/chat/ChatRow.tsx` - UI rendering
## Adding Tools to System Prompt
This is tricky—multiple prompt variants and configs. **Always search for existing similar tools first and follow their pattern.** Look at the full chain from prompt definition → variant configs → handler → UI before implementing.
1. **Add to `ClineDefaultTool` enum** in `src/shared/tools.ts`
2. **Tool definition** in `src/core/prompts/system-prompt/tools/` (create file like `generate_explanation.ts`)
- Define variants for each `ModelFamily` (generic, next-gen, xs, etc.)
- Export variants array (e.g., `export const my_tool_variants = [GENERIC, NATIVE_NEXT_GEN, XS]`)
- **Fallback behavior**: If a variant isn't defined for a model family, `ClineToolSet.getToolByNameWithFallback()` automatically falls back to GENERIC. So you only need to export `[GENERIC]` unless the tool needs model-specific behavior.
3. **Register in `src/core/prompts/system-prompt/tools/init.ts`** - Import and spread into `allToolVariants`
4. **Add to variant configs** - Each model family has its own config in `src/core/prompts/system-prompt/variants/*/config.ts`. Add your tool's enum to the `.tools()` list:
- `generic/config.ts`, `next-gen/config.ts`, `gpt-5/config.ts`, `native-gpt-5/config.ts`, `native-gpt-5-1/config.ts`, `native-next-gen/config.ts`, `gemini-3/config.ts`, `glm/config.ts`, `hermes/config.ts`, `xs/config.ts`
- **Important**: If you add to a variant's config, make sure the tool spec exports a variant for that ModelFamily (or relies on GENERIC fallback)
5. **Create handler** in `src/core/task/tools/handlers/`
6. **Wire up in `ToolExecutor.ts`** if needed for execution flow
7. **Add to tool parsing** in `src/core/assistant-message/index.ts` if needed
8. **If tool has UI feedback**: add `ClineSay` enum in proto, update `src/shared/ExtensionMessage.ts`, update `src/shared/proto-conversions/cline-message.ts`, update `webview-ui/src/components/chat/ChatRow.tsx`
## Modifying System Prompt
**Read these first:** `src/core/prompts/system-prompt/README.md`, `tools/README.md`, `__tests__/README.md`
System prompt is modular: **components** (reusable sections) + **variants** (model-specific configs) + **templates** (with `{{PLACEHOLDER}}` resolution).
**Key directories:**
- `components/` - Shared sections: `rules.ts`, `capabilities.ts`, `editing_files.ts`, etc.
- `variants/` - Model-specific: `generic/`, `next-gen/`, `xs/`, `gpt-5/`, `gemini-3/`, `hermes/`, `glm/`, etc.
- `templates/` - Template engine and placeholder definitions
**Variant tiers (ask user which to modify):**
- **Next-gen** (Claude 4, GPT-5, Gemini 2.5): `next-gen/`, `native-next-gen/`, `native-gpt-5/`, `native-gpt-5-1/`, `gemini-3/`, `gpt-5/`
- **Standard** (default fallback): `generic/`
- **Local/small models**: `xs/`, `hermes/`, `glm/`
**How overrides work:** Variants can override components via `componentOverrides` in their `config.ts`, or provide a custom template in `template.ts` (e.g., `next-gen/template.ts` exports `rules_template`). If no override, the shared component from `components/` is used.
**Example: Adding a rule to RULES section**
1. Check if variant overrides rules: look for `rules_template` in `variants/*/template.ts` or `componentOverrides.RULES` in `config.ts`
2. If shared: modify `components/rules.ts`
3. If overridden: modify that variant's template
4. XS variant is special—has heavily condensed inline content in `template.ts`
**After any changes, regenerate snapshots:**
```bash
UPDATE_SNAPSHOTS=true npm run test:unit
```
Snapshots live in `__tests__/__snapshots__/`. Tests validate across model families and context variations (browser, MCP, focus chain).
## Modifying Default Slash Commands
Three places need updates:
- `src/core/slash-commands/index.ts` - Command definitions
- `src/core/prompts/commands.ts` - System prompt integration
- `webview-ui/src/utils/slash-commands.ts` - Webview autocomplete
## ChatRow Cancelled/Interrupted States
When a ChatRow displays a loading/in-progress state (spinner), you must handle what happens when the task is cancelled. This is non-obvious because cancellation doesn't update the message content—you have to infer it from context.
**The pattern:**
1. A message has a `status` field (e.g., `"generating"`, `"complete"`, `"error"`) stored in `message.text` as JSON
2. When cancelled mid-operation, the status stays `"generating"` forever—no one updates it
3. To detect cancellation, check TWO conditions:
- `!isLast` — if this message is no longer the last message, something else happened after it (interrupted)
- `lastModifiedMessage?.ask === "resume_task" || "resume_completed_task"` — task was just cancelled and is waiting to resume
**Example from `generate_explanation`:**
```tsx
const wasCancelled =
explanationInfo.status === "generating" &&
(!isLast ||
lastModifiedMessage?.ask === "resume_task" ||
lastModifiedMessage?.ask === "resume_completed_task")
const isGenerating = explanationInfo.status === "generating" && !wasCancelled
```
**Why both checks?**
- `!isLast` catches: cancelled → resumed → did other stuff → this old message is stale
- `lastModifiedMessage?.ask === "resume_task"` catches: just cancelled, hasn't resumed yet, this message is still technically "last"
**See also:** `BrowserSessionRow.tsx` uses similar pattern with `isLastApiReqInterrupted` and `isLastMessageResume`.
**Backend side:** When streaming is cancelled, clean up properly (close tabs, clear comments, etc.) by checking `taskState.abort` after the streaming function returns.
@.clinerules/general.md
@.clinerules/network.md
+297
View File
@@ -0,0 +1,297 @@
# 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
@@ -0,0 +1,231 @@
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
@@ -0,0 +1,48 @@
{
"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
@@ -0,0 +1,194 @@
/**
* 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
@@ -0,0 +1,105 @@
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
@@ -0,0 +1,248 @@
/**
* 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
@@ -0,0 +1,377 @@
/**
* 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
@@ -0,0 +1,577 @@
/**
* 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
@@ -0,0 +1,208 @@
/**
* 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
@@ -0,0 +1,199 @@
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
@@ -0,0 +1,553 @@
/**
* 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>
)
}
@@ -0,0 +1,384 @@
/**
* 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
@@ -0,0 +1,296 @@
/**
* 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
@@ -0,0 +1,175 @@
/**
* 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
@@ -0,0 +1,99 @@
/**
* 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
@@ -0,0 +1,185 @@
/**
* 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
@@ -0,0 +1,218 @@
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
@@ -0,0 +1,201 @@
/**
* 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
@@ -0,0 +1,34 @@
/**
* 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
@@ -0,0 +1,378 @@
/**
* 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
@@ -0,0 +1,26 @@
/**
* 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
@@ -0,0 +1,184 @@
/**
* 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
@@ -0,0 +1,315 @@
/**
* 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
@@ -0,0 +1,124 @@
/**
* 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
}
@@ -0,0 +1,91 @@
/**
* 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
}
}
@@ -0,0 +1,27 @@
/**
* 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
@@ -0,0 +1,273 @@
/**
* 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
@@ -0,0 +1,158 @@
/**
* 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
@@ -0,0 +1,372 @@
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
@@ -0,0 +1,525 @@
/**
* 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
@@ -0,0 +1,2 @@
// Stub for react-devtools-core - not needed in CLI
module.exports = {}
+35
View File
@@ -0,0 +1,35 @@
/**
* 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
@@ -0,0 +1,418 @@
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
@@ -0,0 +1,460 @@
/**
* 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
@@ -0,0 +1,256 @@
/**
* 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
@@ -0,0 +1,232 @@
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
@@ -0,0 +1,100 @@
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
@@ -0,0 +1,84 @@
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
@@ -0,0 +1,302 @@
/**
* 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
@@ -0,0 +1,286 @@
/**
* 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
@@ -0,0 +1,74 @@
{
"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
@@ -0,0 +1,29 @@
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
@@ -0,0 +1,7 @@
{
"workflowToggles": {},
"localClineRulesToggles": {},
"localWindsurfRulesToggles": {},
"localCursorRulesToggles": {},
"localAgentsRulesToggles": {}
}
+81 -89
View File
@@ -10,11 +10,12 @@ import (
"strings"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"github.com/cline/cli/pkg/cli"
"github.com/cline/cli/pkg/cli/auth"
"github.com/cline/cli/pkg/cli/display"
"github.com/cline/cli/pkg/cli/global"
"github.com/cline/cli/pkg/cli/output"
"github.com/cline/cli/pkg/cli/slash"
"github.com/cline/cli/pkg/common"
"github.com/cline/grpc-go/cline"
"github.com/spf13/cobra"
@@ -37,8 +38,9 @@ var (
func main() {
rootCmd := &cobra.Command{
Use: "cline [prompt]",
Short: "Cline CLI - AI-powered coding assistant",
Use: "cline [prompt]",
Short: "Cline CLI - AI-powered coding assistant",
Version: global.CliVersion,
Long: `A command-line interface for interacting with Cline AI coding assistant.
Start a new task by providing a prompt:
@@ -70,8 +72,6 @@ see the manual page: man cline`,
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
var instanceAddress string
// Validate workspace paths exist
if err := common.ValidateDirsExist(workspaces); err != nil {
return err
@@ -88,13 +88,13 @@ see the manual page: man cline`,
if global.Config.Verbose {
fmt.Println("Starting new Cline instance...")
}
instance, err := global.Clients.StartNewInstance(ctx, allWorkspaces...)
instance, err := global.Instances.StartNewInstance(ctx, allWorkspaces...)
if err != nil {
return fmt.Errorf("failed to start new instance: %w", err)
}
instanceAddress = instance.Address
global.Config.CoreAddress = instance.CoreAddress
if global.Config.Verbose {
fmt.Printf("Started instance at %s\n\n", instanceAddress)
fmt.Printf("Started instance at %s\n\n", global.Config.CoreAddress)
}
// Set up cleanup on exit
@@ -102,38 +102,35 @@ see the manual page: man cline`,
if global.Config.Verbose {
fmt.Println("\nCleaning up instance...")
}
registry := global.Clients.GetRegistry()
if err := global.KillInstanceByAddress(context.Background(), registry, instanceAddress); err != nil {
registry := global.Instances.GetRegistry()
if err := global.KillInstanceByAddress(context.Background(), registry, global.Config.CoreAddress); err != nil {
if global.Config.Verbose {
fmt.Printf("Warning: Failed to clean up instance: %v\n", err)
}
}
}()
}
// Check if user has credentials configured
if !isUserReadyToUse(ctx, instanceAddress) {
// Create renderer for welcome messages
renderer := display.NewRenderer(global.Config.OutputFormat)
fmt.Printf("\n%s\n\n", renderer.Dim("Hey there! Looks like you're new here. Let's get you set up"))
// Check if user has credentials configured
if !isUserReadyToUse(ctx) {
// Create renderer for welcome messages
renderer := display.NewRenderer(global.Config.OutputFormat)
fmt.Printf("\n%s\n\n", renderer.Dim("Hey there! Looks like you're new here. Let's get you set up"))
if err := auth.HandleAuthMenuNoArgs(ctx); err != nil {
// Check if user cancelled - exit cleanly
if err == huh.ErrUserAborted {
return nil
}
return fmt.Errorf("auth setup failed: %w", err)
if err := auth.HandleAuthMenuNoArgs(ctx); err != nil {
// Check if user cancelled - exit cleanly
if err == huh.ErrUserAborted {
return nil
}
// Re-check after auth wizard
if !isUserReadyToUse(ctx, instanceAddress) {
return fmt.Errorf("credentials still not configured - please run 'cline auth' to complete setup")
}
fmt.Printf("\n%s\n\n", renderer.Dim("✓ Setup complete, you can now use the Cline CLI"))
return fmt.Errorf("auth setup failed: %w", err)
}
} else {
// User specified --address flag, use that
instanceAddress = coreAddress
// Re-check after auth wizard
if !isUserReadyToUse(ctx) {
return fmt.Errorf("credentials still not configured - please run 'cline auth' to complete setup")
}
fmt.Printf("\n%s\n\n", renderer.Dim("✓ Setup complete, you can now use the Cline CLI"))
}
// Get content from both args and stdin
@@ -142,10 +139,13 @@ see the manual page: man cline`,
return fmt.Errorf("failed to read prompt: %w", err)
}
// If no prompt from args or stdin, show interactive input
if prompt == "" {
// If no prompt (or just a mode switch with no message), show interactive input
// Loop to allow mode switches without a message
bannerShown := false
for prompt == "" {
// Pass the mode flag and workspaces to banner so it shows correct info
prompt, err = promptForInitialTask(ctx, instanceAddress, mode, allWorkspaces)
prompt, err = promptForInitialTask(ctx, mode, allWorkspaces, !bannerShown)
bannerShown = true
if err != nil {
// Check if user cancelled - exit cleanly without error
if err == huh.ErrUserAborted {
@@ -153,6 +153,23 @@ see the manual page: man cline`,
}
return err
}
// Check if user entered a mode switch command
if newMode, remaining, isModeSwitch := slash.ParseModeSwitch(prompt); isModeSwitch {
mode = newMode
prompt = remaining
// If just a mode switch with no message, continue loop to re-prompt
if prompt == "" {
renderer := display.NewRenderer(global.Config.OutputFormat)
if mode == "act" {
fmt.Printf("\n%s\n\n", renderer.Success("Switched to act mode"))
} else {
fmt.Printf("\n%s\n\n", renderer.Success("Switched to plan mode"))
}
continue
}
}
if prompt == "" {
return fmt.Errorf("prompt required")
}
@@ -170,13 +187,15 @@ see the manual page: man cline`,
Mode: mode,
Settings: settings,
Yolo: yolo,
Address: instanceAddress,
Address: global.Config.CoreAddress,
Verbose: verbose,
Workspaces: allWorkspaces,
})
},
}
rootCmd.SetVersionTemplate(cli.VersionString())
rootCmd.PersistentFlags().StringVar(&coreAddress, "address", fmt.Sprintf("localhost:%d", common.DEFAULT_CLINE_CORE_PORT), "Cline Core gRPC address")
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
rootCmd.PersistentFlags().StringVarP(&outputFormat, "output-format", "F", "rich", "output format (rich|json|plain)")
@@ -204,51 +223,30 @@ see the manual page: man cline`,
}
}
func promptForInitialTask(ctx context.Context, instanceAddress, modeFlag string, workspaces []string) (string, error) {
// Show session banner before the initial input
showSessionBanner(ctx, instanceAddress, modeFlag, workspaces)
var prompt string
// Create custom theme with mode-colored cursor and title
theme := huh.ThemeCharm()
// Set cursor and title color based on mode
modeColor := lipgloss.Color("3") // Yellow for plan
if modeFlag == "act" {
modeColor = lipgloss.Color("39") // Blue for act
func promptForInitialTask(ctx context.Context, modeFlag string, workspaces []string, showBanner bool) (string, error) {
// Show session banner before the initial input (only on first prompt)
if showBanner {
showSessionBanner(ctx, modeFlag, workspaces)
}
theme.Focused.TextInput.Cursor = theme.Focused.TextInput.Cursor.Foreground(modeColor)
theme.Focused.Title = theme.Focused.Title.Foreground(modeColor)
form := huh.NewForm(
huh.NewGroup(
huh.NewText().
Title("Start a new Cline task").
Description("What would you like Cline to help you with?").
Placeholder("e.g., Create a REST API with authentication...").
Lines(5).
Value(&prompt),
),
).WithWidth(48).WithTheme(theme)
err := form.Run()
prompt, err := output.PromptForInitialTask(
"Start a new Cline task",
"/plan or /act to switch modes\nctrl+e to open editor\ntab to autocomplete commands",
modeFlag,
slash.NewRegistry(ctx),
)
if err != nil {
// Check if user cancelled with Control-C
if err == huh.ErrUserAborted {
// Return a special error that indicates clean cancellation
// This allows deferred cleanup to run
if err == output.ErrUserAborted {
return "", huh.ErrUserAborted
}
return "", err
}
return strings.TrimSpace(prompt), nil
return prompt, nil
}
// showSessionBanner displays session info before initial prompt
func showSessionBanner(ctx context.Context, instanceAddress, modeFlag string, workspaces []string) {
func showSessionBanner(ctx context.Context, modeFlag string, workspaces []string) {
bannerInfo := display.BannerInfo{
Version: global.CliVersion,
Mode: modeFlag, // Use the mode from command flag, not state
@@ -262,21 +260,18 @@ func showSessionBanner(ctx context.Context, instanceAddress, modeFlag string, wo
bannerInfo.Workdirs = workspaces
// Get provider/model using auth functions (same logic as auth menu)
manager, err := cli.NewTaskManagerForAddress(ctx, instanceAddress)
if err == nil {
if providerList, err := auth.GetProviderConfigurations(ctx, manager); err == nil {
// Show provider/model for the mode we'll be using
var providerDisplay *auth.ProviderDisplay
if bannerInfo.Mode == "plan" && providerList.PlanProvider != nil {
providerDisplay = providerList.PlanProvider
} else if bannerInfo.Mode == "act" && providerList.ActProvider != nil {
providerDisplay = providerList.ActProvider
}
if providerList, err := auth.GetProviderConfigurations(ctx); err == nil {
// Show provider/model for the mode we'll be using
var providerDisplay *auth.ProviderDisplay
if bannerInfo.Mode == "plan" && providerList.PlanProvider != nil {
providerDisplay = providerList.PlanProvider
} else if bannerInfo.Mode == "act" && providerList.ActProvider != nil {
providerDisplay = providerList.ActProvider
}
if providerDisplay != nil {
bannerInfo.Provider = auth.GetProviderIDForEnum(providerDisplay.Provider)
bannerInfo.ModelID = providerDisplay.ModelID
}
if providerDisplay != nil {
bannerInfo.Provider = auth.GetProviderIDForEnum(providerDisplay.Provider)
bannerInfo.ModelID = providerDisplay.ModelID
}
}
@@ -289,25 +284,22 @@ func showSessionBanner(ctx context.Context, instanceAddress, modeFlag string, wo
// isUserReadyToUse checks if the user has completed initial setup
// Returns true if welcomeViewCompleted flag is set OR user is authenticated
// Matches extension logic: welcomeViewCompleted = Boolean(globalState.welcomeViewCompleted || user?.uid)
func isUserReadyToUse(ctx context.Context, instanceAddress string) bool {
manager, err := cli.NewTaskManagerForAddress(ctx, instanceAddress)
func isUserReadyToUse(ctx context.Context) bool {
grpcClient, err := global.GetClientForAddress(ctx, global.Config.CoreAddress)
if err != nil {
return false
}
// Get state
state, err := manager.GetClient().State.GetLatestState(ctx, &cline.EmptyRequest{})
state, err := grpcClient.State.GetLatestState(ctx, &cline.EmptyRequest{})
if err != nil {
return false
}
// Parse state JSON
stateMap := make(map[string]interface{})
if err := json.Unmarshal([]byte(state.StateJson), &stateMap); err != nil {
return false
}
// Check 1: welcomeViewCompleted flag
if welcomeCompleted, ok := stateMap["welcomeViewCompleted"].(bool); ok && welcomeCompleted {
return true
}
+10 -10
View File
@@ -23,7 +23,7 @@ func TestMultiInstanceDefaultUnchanged(t *testing.T) {
if len(out1.CoreInstances) != 1 {
t.Fatalf("expected 1 instance, got %d", len(out1.CoreInstances))
}
firstAddr := out1.CoreInstances[0].Address
firstAddr := out1.CoreInstances[0].CoreAddress
waitForAddressHealthy(t, firstAddr, defaultTimeout)
// Start second instance
@@ -56,29 +56,29 @@ func TestDefaultJsonUpdateAfterRemoval(t *testing.T) {
// Choose second as new default
target := out.CoreInstances[1]
waitForAddressHealthy(t, target.Address, defaultTimeout)
waitForAddressHealthy(t, target.CoreAddress, defaultTimeout)
// Set as default
_ = mustRunCLI(ctx, t, "instance", "use", target.Address)
_ = mustRunCLI(ctx, t, "instance", "use", target.CoreAddress)
// Verify default switched
out = listInstancesJSON(ctx, t)
if out.DefaultInstance != target.Address {
t.Fatalf("default_instance not updated to %s (got %s)", target.Address, out.DefaultInstance)
if out.DefaultInstance != target.CoreAddress {
t.Fatalf("default_instance not updated to %s (got %s)", target.CoreAddress, out.DefaultInstance)
}
// Kill the default instance using runtime PID discovery
corePID := getCorePID(t, target.Address)
corePID := getCorePID(t, target.CoreAddress)
if corePID <= 0 {
t.Fatalf("could not find PID for core process at %s", target.Address)
t.Fatalf("could not find PID for core process at %s", target.CoreAddress)
}
t.Logf("Killing cline-core process PID %d for instance %s", corePID, target.Address)
t.Logf("Killing cline-core process PID %d for instance %s", corePID, target.CoreAddress)
if err := syscall.Kill(corePID, syscall.SIGKILL); err != nil {
t.Fatalf("kill pid %d: %v", corePID, err)
}
// Wait for removal
waitForAddressRemoved(t, target.Address, longTimeout)
waitForAddressRemoved(t, target.CoreAddress, longTimeout)
// Clean up dangling host process (SIGKILL leaves these behind by design)
t.Logf("Cleaning up dangling host process on port %d", target.HostPort())
@@ -91,7 +91,7 @@ func TestDefaultJsonUpdateAfterRemoval(t *testing.T) {
if len(out.CoreInstances) > 0 {
found := false
for _, it := range out.CoreInstances {
if out.DefaultInstance == it.Address {
if out.DefaultInstance == it.CoreAddress {
found = true
break
}
+3 -3
View File
@@ -117,7 +117,7 @@ func listInstancesJSON(ctx context.Context, t *testing.T) common.InstancesOutput
func hasAddress(in common.InstancesOutput, addr string) bool {
for _, it := range in.CoreInstances {
if it.Address == addr {
if it.CoreAddress == addr {
return true
}
}
@@ -126,7 +126,7 @@ func hasAddress(in common.InstancesOutput, addr string) bool {
func getByAddress(in common.InstancesOutput, addr string) (common.CoreInstanceInfo, bool) {
for _, it := range in.CoreInstances {
if it.Address == addr {
if it.CoreAddress == addr {
return it, true
}
}
@@ -313,7 +313,7 @@ func getCorePIDViaRPC(t *testing.T, address string) int {
defer cancel()
// Get client for the address
client, err := global.Clients.GetRegistry().GetClient(ctx, address)
client, err := global.Instances.GetRegistry().GetClient(ctx, address)
if err != nil {
t.Logf("Warning: failed to get client for %s, falling back to lsof: %v", address, err)
return getCorePIDViaLsof(t, address)
+10 -10
View File
@@ -26,7 +26,7 @@ func TestMixedLocalhostVs127Coexist(t *testing.T) {
t.Fatalf("expected at least 1 instance")
}
inst := out.CoreInstances[0]
waitForAddressHealthy(t, inst.Address, defaultTimeout)
waitForAddressHealthy(t, inst.CoreAddress, defaultTimeout)
// Manually add a SQLite entry for the same port but 127.0.0.1 host
addr127 := fmt.Sprintf("127.0.0.1:%d", inst.CorePort())
@@ -37,12 +37,12 @@ func TestMixedLocalhostVs127Coexist(t *testing.T) {
}
// Verify both addresses appear and are healthy
waitForAddressHealthy(t, inst.Address, defaultTimeout)
waitForAddressHealthy(t, inst.CoreAddress, defaultTimeout)
waitForAddressHealthy(t, addr127, defaultTimeout)
out = listInstancesJSON(ctx, t)
if !hasAddress(out, inst.Address) || !hasAddress(out, addr127) {
t.Fatalf("expected both %s and %s present", inst.Address, addr127)
if !hasAddress(out, inst.CoreAddress) || !hasAddress(out, addr127) {
t.Fatalf("expected both %s and %s present", inst.CoreAddress, addr127)
}
}
@@ -58,7 +58,7 @@ func TestStartStopStress(t *testing.T) {
before := listInstancesJSON(ctx, t)
beforeSet := map[string]struct{}{}
for _, it := range before.CoreInstances {
beforeSet[it.Address] = struct{}{}
beforeSet[it.CoreAddress] = struct{}{}
}
// Start a new instance
@@ -69,8 +69,8 @@ func TestStartStopStress(t *testing.T) {
waitFor(t, defaultTimeout, func() (bool, string) {
after := listInstancesJSON(ctx, t)
for _, it := range after.CoreInstances {
if _, ok := beforeSet[it.Address]; !ok {
newAddr = it.Address
if _, ok := beforeSet[it.CoreAddress]; !ok {
newAddr = it.CoreAddress
return true, ""
}
}
@@ -88,12 +88,12 @@ func TestStartStopStress(t *testing.T) {
}
// Get PID using runtime discovery
corePID := getCorePID(t, info.Address)
corePID := getCorePID(t, info.CoreAddress)
if corePID <= 0 {
t.Fatalf("could not find PID for new instance at %s", info.Address)
t.Fatalf("could not find PID for new instance at %s", info.CoreAddress)
}
t.Logf("Killing new instance %s (PID %d) for iteration %d", info.Address, corePID, i)
t.Logf("Killing new instance %s (PID %d) for iteration %d", info.CoreAddress, corePID, i)
if err := syscall.Kill(corePID, syscall.SIGKILL); err != nil {
t.Fatalf("kill pid %d: %v", corePID, err)
}
+1 -1
View File
@@ -55,7 +55,7 @@ func readInstancesFromSQLite(t *testing.T, clineDir string) []common.CoreInstanc
// Create InstanceInfo
info := common.CoreInstanceInfo{
Address: heldBy,
CoreAddress: heldBy,
HostServiceAddress: lockTarget,
Status: grpc_health_v1.HealthCheckResponse_UNKNOWN, // Will be updated by health check
LastSeen: time.Unix(lockedAt/1000, 0), // Convert from milliseconds
+14 -14
View File
@@ -29,7 +29,7 @@ func TestStartAndList(t *testing.T) {
t.Fatalf("expected 1 instance, got %d", len(out.CoreInstances))
}
addr := out.CoreInstances[0].Address
addr := out.CoreInstances[0].CoreAddress
t.Logf("Instance address: %s, status: %s", addr, out.CoreInstances[0].Status)
t.Logf("Waiting for address %s to become healthy...", addr)
@@ -44,8 +44,8 @@ func TestStartAndList(t *testing.T) {
if out.DefaultInstance == "" {
t.Fatalf("default_instance not set")
}
if out.DefaultInstance != out.CoreInstances[0].Address {
t.Fatalf("expected default_instance=%s, got %s", out.CoreInstances[0].Address, out.DefaultInstance)
if out.DefaultInstance != out.CoreInstances[0].CoreAddress {
t.Fatalf("expected default_instance=%s, got %s", out.CoreInstances[0].CoreAddress, out.DefaultInstance)
}
t.Logf("TestStartAndList completed successfully")
@@ -64,7 +64,7 @@ func TestTaskNewDefault(t *testing.T) {
if len(out.CoreInstances) != 1 {
t.Fatalf("expected 1 instance, got %d", len(out.CoreInstances))
}
addr := out.CoreInstances[0].Address
addr := out.CoreInstances[0].CoreAddress
waitForAddressHealthy(t, addr, defaultTimeout)
// Create a new task at default (success is sufficient)
@@ -108,21 +108,21 @@ func TestCrashCleanup(t *testing.T) {
// Test 1: Graceful shutdown (SIGTERM) - should clean up both processes
gracefulTarget := out.CoreInstances[0]
waitForAddressHealthy(t, gracefulTarget.Address, defaultTimeout)
waitForAddressHealthy(t, gracefulTarget.CoreAddress, defaultTimeout)
// Get PID using runtime discovery
gracefulPID := getCorePID(t, gracefulTarget.Address)
gracefulPID := getCorePID(t, gracefulTarget.CoreAddress)
if gracefulPID <= 0 {
t.Fatalf("could not find PID for graceful target at %s", gracefulTarget.Address)
t.Fatalf("could not find PID for graceful target at %s", gracefulTarget.CoreAddress)
}
t.Logf("Testing graceful shutdown (SIGTERM) for instance %s (PID %d)", gracefulTarget.Address, gracefulPID)
t.Logf("Testing graceful shutdown (SIGTERM) for instance %s (PID %d)", gracefulTarget.CoreAddress, gracefulPID)
if err := syscall.Kill(gracefulPID, syscall.SIGTERM); err != nil {
t.Fatalf("kill SIGTERM pid %d: %v", gracefulPID, err)
}
// Wait for registry cleanup
waitForAddressRemoved(t, gracefulTarget.Address, longTimeout)
waitForAddressRemoved(t, gracefulTarget.CoreAddress, longTimeout)
// Verify both core and host ports are freed (no dangling processes)
waitForPortsClosed(t, gracefulTarget.CorePort(), gracefulTarget.HostPort(), defaultTimeout)
@@ -132,21 +132,21 @@ func TestCrashCleanup(t *testing.T) {
// Test 2: Crash cleanup (SIGKILL) - creates dangling host process that we must clean up
crashTarget := out.CoreInstances[1]
waitForAddressHealthy(t, crashTarget.Address, defaultTimeout)
waitForAddressHealthy(t, crashTarget.CoreAddress, defaultTimeout)
// Get PID using runtime discovery
crashPID := getCorePID(t, crashTarget.Address)
crashPID := getCorePID(t, crashTarget.CoreAddress)
if crashPID <= 0 {
t.Fatalf("could not find PID for crash target at %s", crashTarget.Address)
t.Fatalf("could not find PID for crash target at %s", crashTarget.CoreAddress)
}
t.Logf("Testing crash cleanup (SIGKILL) for instance %s (PID %d)", crashTarget.Address, crashPID)
t.Logf("Testing crash cleanup (SIGKILL) for instance %s (PID %d)", crashTarget.CoreAddress, crashPID)
if err := syscall.Kill(crashPID, syscall.SIGKILL); err != nil {
t.Fatalf("kill SIGKILL pid %d: %v", crashPID, err)
}
// Wait for registry cleanup
waitForAddressRemoved(t, crashTarget.Address, longTimeout)
waitForAddressRemoved(t, crashTarget.CoreAddress, longTimeout)
// Verify the instance is removed from SQLite (no file to check anymore)
// The waitForAddressRemoved already confirms the instance is gone from the registry
+8 -10
View File
@@ -46,21 +46,21 @@ const (
// It spawns a fresh instance for auth operations and cleans it up when done
func RunAuthFlow(ctx context.Context, args []string) error {
// Spawn a fresh instance for auth operations
instanceInfo, err := global.Clients.StartNewInstance(ctx)
instanceInfo, err := global.Instances.StartNewInstance(ctx)
if err != nil {
return fmt.Errorf("failed to start auth instance: %w", err)
}
// Cleanup when done (success, error, or panic)
defer func() {
verboseLog("Shutting down auth instance at %s", instanceInfo.Address)
if err := global.KillInstanceByAddress(context.Background(), global.Clients.GetRegistry(), instanceInfo.Address); err != nil {
verboseLog("Shutting down auth instance at %s", instanceInfo.CoreAddress)
if err := global.KillInstanceByAddress(context.Background(), global.Instances.GetRegistry(), instanceInfo.CoreAddress); err != nil {
verboseLog("Warning: Failed to kill auth instance: %v", err)
}
}()
// Store instance address in context for all auth handlers to use
authCtx := context.WithValue(ctx, authInstanceAddressKey, instanceInfo.Address)
authCtx := context.WithValue(ctx, authInstanceAddressKey, instanceInfo.CoreAddress)
// Route to existing auth flow
return HandleAuthCommand(authCtx, args)
@@ -108,12 +108,10 @@ func HandleAuthMenuNoArgs(ctx context.Context) error {
// Get current provider config for display
var currentProvider string
var currentModel string
if manager, err := createTaskManager(ctx); err == nil {
if providerList, err := GetProviderConfigurations(ctx, manager); err == nil {
if providerList.ActProvider != nil {
currentProvider = GetProviderDisplayName(providerList.ActProvider.Provider)
currentModel = providerList.ActProvider.ModelID
}
if providerList, err := GetProviderConfigurations(ctx); err == nil {
if providerList.ActProvider != nil {
currentProvider = GetProviderDisplayName(providerList.ActProvider.Provider)
currentModel = providerList.ActProvider.ModelID
}
}
+6 -2
View File
@@ -29,13 +29,17 @@ type ProviderListResult struct {
}
// GetProviderConfigurations retrieves and parses provider configurations from Cline Core state
func GetProviderConfigurations(ctx context.Context, manager *task.Manager) (*ProviderListResult, error) {
func GetProviderConfigurations(ctx context.Context) (*ProviderListResult, error) {
if global.Config.Verbose {
fmt.Println("[DEBUG] Retrieving provider configurations from Cline Core")
}
// Get latest state from Cline Core
state, err := manager.GetClient().State.GetLatestState(ctx, &cline.EmptyRequest{})
grpcClient, err := global.GetDefaultClient(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get provider configs due to unable to get gRPC client: %w", err)
}
state, err := grpcClient.State.GetLatestState(ctx, &cline.EmptyRequest{})
if err != nil {
return nil, fmt.Errorf("failed to get state: %w", err)
}
+5 -6
View File
@@ -215,7 +215,7 @@ func (pw *ProviderWizard) handleAddOcaProvider() error {
// handleListProviders retrieves and displays configured providers
func (pw *ProviderWizard) handleListProviders() error {
result, err := GetProviderConfigurations(pw.ctx, pw.manager)
result, err := GetProviderConfigurations(pw.ctx)
if err != nil {
return fmt.Errorf("failed to retrieve provider configurations: %w", err)
}
@@ -413,7 +413,7 @@ func (pw *ProviderWizard) manualModelEntry(provider cline.ApiProvider) (string,
// handleChangeModel allows changing the model for any configured provider
func (pw *ProviderWizard) handleChangeModel() error {
// Step 1: Get current provider configurations
result, err := GetProviderConfigurations(pw.ctx, pw.manager)
result, err := GetProviderConfigurations(pw.ctx)
if err != nil {
return fmt.Errorf("failed to retrieve provider configurations: %w", err)
}
@@ -585,11 +585,11 @@ func getProviderModelIDFromState(stateData map[string]interface{}, provider clin
return ""
}
// getProviderAPIKeyFromState retrieves the API key for a specific provider from state
// getProviderAPIKeyFromState retrieves the API key for a specific provider from state
func getProviderAPIKeyFromState(stateData map[string]interface{}, provider cline.ApiProvider) string {
// OCA uses account authentication, not API keys. Consider it "present" if authenticated.
if provider == cline.ApiProvider_OCA {
if state, _ := GetLatestOCAState(context.TODO(), 2 * time.Second); state != nil && state.User != nil {
if state, _ := GetLatestOCAState(context.TODO(), 2*time.Second); state != nil && state.User != nil {
// Return a sentinel non-empty string so upstream checks pass.
return "OCA_AUTH_VERIFIED"
}
@@ -648,7 +648,7 @@ func convertMapToOpenRouterModelInfo(data map[string]interface{}) *cline.OpenRou
// handleRemoveProvider allows removing a configured provider by clearing its API key
func (pw *ProviderWizard) handleRemoveProvider() error {
// Step 1: Get current provider configurations
result, err := GetProviderConfigurations(pw.ctx, pw.manager)
result, err := GetProviderConfigurations(pw.ctx)
if err != nil {
return fmt.Errorf("failed to retrieve provider configurations: %w", err)
}
@@ -748,7 +748,6 @@ func (pw *ProviderWizard) clearProviderAPIKey(provider cline.ApiProvider) error
return RemoveProviderPartial(pw.ctx, pw.manager, provider)
}
func signOutOca(ctx context.Context) error {
client, err := global.GetDefaultClient(ctx)
if err != nil {
+3 -3
View File
@@ -40,7 +40,7 @@ func ensureConfigManager(ctx context.Context, address string) error {
}
// Always set the instance we're using as the default
registry := global.Clients.GetRegistry()
registry := global.Instances.GetRegistry()
if err := registry.SetDefaultInstance(instanceAddress); err != nil {
// Log warning but don't fail - this is not critical
fmt.Printf("Warning: failed to set default instance: %v\n", err)
@@ -123,11 +123,11 @@ func setCommand() *cobra.Command {
Use: "set <key=value> [key=value...]",
Aliases: []string{"s"},
Short: "Set configuration variables",
Long: `Set one or more global configuration variables using key=value format.
Long: `Set one or more global configuration variables using key=value format.
This command merges the provided settings with existing values, preserving
unspecified fields. Only the fields you explicitly set will be updated.`,
Args: cobra.MinimumNArgs(1),
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
+2 -2
View File
@@ -32,8 +32,8 @@ func NewManager(ctx context.Context, address string) (*Manager, error) {
// Get the actual address being used
clientAddress := address
if address == "" && global.Clients != nil {
clientAddress = global.Clients.GetRegistry().GetDefaultInstance()
if address == "" && global.Instances != nil {
clientAddress = global.Instances.GetRegistry().GetDefaultInstance()
}
return &Manager{
@@ -14,21 +14,20 @@ import (
"github.com/cline/grpc-go/cline"
)
// ClineClients manages Cline instances using the new registry system
type ClineClients struct {
registry *ClientRegistry
type ClineInstances struct {
registry *InstanceRegistry
}
// NewClineClients creates a new ClineClients instance
func NewClineClients(configPath string) *ClineClients {
registry := NewClientRegistry(configPath)
return &ClineClients{
// NewClineInstances creates a new ClineInstances instance
func NewClineInstances(configPath string) *ClineInstances {
registry := NewInstanceRegistry(configPath)
return &ClineInstances{
registry: registry,
}
}
// Initialize performs cleanup of stale instances
func (c *ClineClients) Initialize(ctx context.Context) error {
func (c *ClineInstances) Initialize(ctx context.Context) error {
// Clean up stale entries (direct SQLite operations)
_ = c.registry.CleanupStaleInstances(ctx)
@@ -36,7 +35,8 @@ func (c *ClineClients) Initialize(ctx context.Context) error {
}
// StartNewInstance starts a new Cline instance and waits for cline-core to self-register
func (c *ClineClients) StartNewInstance(ctx context.Context, workspaces ...string) (*common.CoreInstanceInfo, error) {
// An "instance" is a pair of cline-core and cline-host processes
func (c *ClineInstances) StartNewInstance(ctx context.Context, workspaces ...string) (*common.CoreInstanceInfo, error) {
// Find available ports
corePort, hostPort, err := common.FindAvailablePortPair()
if err != nil {
@@ -102,7 +102,7 @@ func (c *ClineClients) StartNewInstance(ctx context.Context, workspaces ...strin
if Config.Verbose {
fmt.Println("Services started and registered successfully!")
fmt.Printf(" Address: %s\n", instance.Address)
fmt.Printf(" Address: %s\n", instance.CoreAddress)
fmt.Printf(" Core Port: %d\n", instance.CorePort())
fmt.Printf(" Host Bridge Port: %d\n", instance.HostPort())
fmt.Printf(" Process PID: %d\n", coreCmd.Process.Pid)
@@ -120,7 +120,7 @@ func (c *ClineClients) StartNewInstance(ctx context.Context, workspaces ...strin
}
// StartNewInstanceAtPort starts a new Cline instance at the specified port and waits for self-registration
func (c *ClineClients) StartNewInstanceAtPort(ctx context.Context, corePort int, workspaces ...string) (*common.CoreInstanceInfo, error) {
func (c *ClineInstances) StartNewInstanceAtPort(ctx context.Context, corePort int, workspaces ...string) (*common.CoreInstanceInfo, error) {
// Find available host port (core port + 1000)
hostPort := corePort + 1000
coreAddress := fmt.Sprintf("localhost:%d", corePort)
@@ -189,7 +189,7 @@ func (c *ClineClients) StartNewInstanceAtPort(ctx context.Context, corePort int,
if Config.Verbose {
fmt.Println("Services started and registered successfully!")
fmt.Printf(" Address: %s\n", instance.Address)
fmt.Printf(" Address: %s\n", instance.CoreAddress)
fmt.Printf(" Core Port: %d\n", instance.CorePort())
fmt.Printf(" Host Bridge Port: %d\n", instance.HostPort())
fmt.Printf(" Process PID: %d\n", coreCmd.Process.Pid)
@@ -207,12 +207,12 @@ func (c *ClineClients) StartNewInstanceAtPort(ctx context.Context, corePort int,
}
// GetRegistry returns the client registry
func (c *ClineClients) GetRegistry() *ClientRegistry {
func (c *ClineInstances) GetRegistry() *InstanceRegistry {
return c.registry
}
// EnsureInstanceAtAddress ensures an instance exists at the given address, starting one if needed
func (c *ClineClients) EnsureInstanceAtAddress(ctx context.Context, address string) error {
func (c *ClineInstances) EnsureInstanceAtAddress(ctx context.Context, address string) error {
// Expect host:port everywhere
normalized := address
if normalized == "" {
@@ -230,7 +230,7 @@ func (c *ClineClients) EnsureInstanceAtAddress(ctx context.Context, address stri
return fmt.Errorf("invalid address format %s", address)
}
// Use IPv6-compatible localhost detection
// Use IPv6-compatible localhost detection
if common.IsLocalAddress(host) {
_, err := c.StartNewInstanceAtPort(ctx, port)
if err != nil {
@@ -305,7 +305,7 @@ func startClineHost(hostPort int, workspaces []string) (*exec.Cmd, error) {
}
// KillInstanceByAddress kills a Cline instance by its address
func KillInstanceByAddress(ctx context.Context, registry *ClientRegistry, address string) error {
func KillInstanceByAddress(ctx context.Context, registry *InstanceRegistry, address string) error {
// Check if the instance exists in the registry
_, err := registry.GetInstance(address)
if err != nil {
@@ -355,9 +355,9 @@ func KillInstanceByAddress(ctx context.Context, registry *ClientRegistry, addres
defaultInstance := registry.GetDefaultInstance()
if defaultInstance == address || defaultInstance == "" {
if len(instances) > 0 {
if err := registry.SetDefaultInstance(instances[0].Address); err == nil {
if err := registry.SetDefaultInstance(instances[0].CoreAddress); err == nil {
if Config.Verbose {
fmt.Printf("Updated default instance to: %s\n", instances[0].Address)
fmt.Printf("Updated default instance to: %s\n", instances[0].CoreAddress)
}
}
}
+12 -11
View File
@@ -22,8 +22,8 @@ type GlobalConfig struct {
}
var (
Config *GlobalConfig
Clients *ClineClients
Config *GlobalConfig
Instances *ClineInstances
// Version info - set at build time via ldflags
// Version is the Cline Core version (from root package.json)
@@ -61,11 +61,11 @@ func InitializeGlobalConfig(cfg *GlobalConfig) error {
// Otherwise lipgloss auto-detects terminal capabilities (default behavior)
Config = cfg
Clients = NewClineClients(cfg.ConfigPath)
Instances = NewClineInstances(cfg.ConfigPath)
// Initialize the clients registry
ctx := context.Background()
if err := Clients.Initialize(ctx); err != nil {
if err := Instances.Initialize(ctx); err != nil {
return fmt.Errorf("failed to initialize clients: %w", err)
}
@@ -76,25 +76,25 @@ func InitializeGlobalConfig(cfg *GlobalConfig) error {
func GetDefaultClient(ctx context.Context) (*client.ClineClient, error) {
if Config.CoreAddress != "" && Config.CoreAddress != fmt.Sprintf("localhost:%d", common.DEFAULT_CLINE_CORE_PORT) {
// User specified a specific address, use that
return Clients.GetRegistry().GetClient(ctx, Config.CoreAddress)
return Instances.GetRegistry().GetClient(ctx, Config.CoreAddress)
}
// Use the default instance from registry
return Clients.GetRegistry().GetDefaultClient(ctx)
return Instances.GetRegistry().GetDefaultClient(ctx)
}
// GetClientForAddress returns a client for a specific address
func GetClientForAddress(ctx context.Context, address string) (*client.ClineClient, error) {
return Clients.GetRegistry().GetClient(ctx, address)
return Instances.GetRegistry().GetClient(ctx, address)
}
// EnsureDefaultInstance ensures a default instance exists
func EnsureDefaultInstance(ctx context.Context) error {
if Clients == nil {
if Instances == nil {
return fmt.Errorf("global clients not initialized")
}
registry := Clients.GetRegistry()
registry := Instances.GetRegistry()
// First, check if there are any instances already registered in SQLite
instances := registry.ListInstances()
@@ -108,11 +108,12 @@ func EnsureDefaultInstance(ctx context.Context) error {
if registry.GetDefaultInstance() == "" {
// No instances exist, start a new one
// Note: StartNewInstance will automatically set it as default since it's the first instance
_, err := Clients.StartNewInstance(ctx)
_, err := Instances.StartNewInstance(ctx)
if err != nil {
return fmt.Errorf("failed to start new default instance: %w", err)
}
}
return nil
}
}
+27 -28
View File
@@ -18,28 +18,28 @@ import (
"google.golang.org/grpc/health/grpc_health_v1"
)
// ClientRegistry manages Cline client connections using direct SQLite operations
type ClientRegistry struct {
// InstanceRegistry manages Cline client connections using direct SQLite operations
type InstanceRegistry struct {
lockManager *sqlite.LockManager
configPath string
}
// NewClientRegistry creates a new client registry
func NewClientRegistry(configPath string) *ClientRegistry {
// NewInstanceRegistry creates a new instance registry
func NewInstanceRegistry(configPath string) *InstanceRegistry {
lockManager, err := sqlite.NewLockManager(configPath)
if err != nil {
// Log error but continue - we can still function without SQLite
log.Fatalf("Warning: Failed to initialize SQLite lock manager: %v\n", err)
}
return &ClientRegistry{
return &InstanceRegistry{
lockManager: lockManager,
configPath: configPath,
}
}
// GetDefaultInstance returns the default instance address from settings file
func (r *ClientRegistry) GetDefaultInstance() string {
func (r *InstanceRegistry) GetDefaultInstance() string {
defaultAddr, err := sqlite.GetDefaultInstance(r.configPath)
if err != nil {
return ""
@@ -48,7 +48,7 @@ func (r *ClientRegistry) GetDefaultInstance() string {
}
// SetDefaultInstance sets the default instance (writes default.json)
func (r *ClientRegistry) SetDefaultInstance(address string) error {
func (r *InstanceRegistry) SetDefaultInstance(address string) error {
// Verify the instance exists in SQLite
if r.lockManager != nil {
exists, err := r.lockManager.HasInstanceAtAddress(address)
@@ -64,7 +64,7 @@ func (r *ClientRegistry) SetDefaultInstance(address string) error {
}
// GetInstance returns instance information directly from SQLite
func (r *ClientRegistry) GetInstance(address string) (*common.CoreInstanceInfo, error) {
func (r *InstanceRegistry) GetInstance(address string) (*common.CoreInstanceInfo, error) {
if r.lockManager == nil {
return nil, fmt.Errorf("lock manager not available")
}
@@ -73,7 +73,7 @@ func (r *ClientRegistry) GetInstance(address string) (*common.CoreInstanceInfo,
}
// GetClient returns a connected client for the given address (created on-demand)
func (r *ClientRegistry) GetClient(ctx context.Context, address string) (*client.ClineClient, error) {
func (r *InstanceRegistry) GetClient(ctx context.Context, address string) (*client.ClineClient, error) {
// Verify instance exists in SQLite
if r.lockManager != nil {
exists, err := r.lockManager.HasInstanceAtAddress(address)
@@ -104,7 +104,7 @@ func (r *ClientRegistry) GetClient(ctx context.Context, address string) (*client
}
// GetDefaultClient returns a client for the default instance
func (r *ClientRegistry) GetDefaultClient(ctx context.Context) (*client.ClineClient, error) {
func (r *InstanceRegistry) GetDefaultClient(ctx context.Context) (*client.ClineClient, error) {
defaultAddr := r.GetDefaultInstance()
if defaultAddr == "" {
return nil, fmt.Errorf("no default instance configured")
@@ -117,7 +117,7 @@ func (r *ClientRegistry) GetDefaultClient(ctx context.Context) (*client.ClineCli
// Database is unavailable - Return error instead of attempting cleanup
return nil, fmt.Errorf("cannot verify default instance: database unavailable: %w", err)
}
if !exists {
// Instance doesn't exist in database but config file references it
// This is a stale config - remove it and try to find another instance
@@ -127,14 +127,14 @@ func (r *ClientRegistry) GetDefaultClient(ctx context.Context) (*client.ClineCli
} else {
fmt.Printf("Removed stale default instance config (instance %s not found in database)\n", defaultAddr)
}
// Try to find and set a new default instance
instances := r.ListInstances()
if len(instances) > 0 {
if err := r.EnsureDefaultInstance(instances); err != nil {
return nil, fmt.Errorf("failed to set new default instance: %w", err)
}
// Retry with the new default
newDefaultAddr := r.GetDefaultInstance()
if newDefaultAddr != "" {
@@ -142,7 +142,7 @@ func (r *ClientRegistry) GetDefaultClient(ctx context.Context) (*client.ClineCli
return r.GetClient(ctx, newDefaultAddr)
}
}
return nil, fmt.Errorf("no default instance configured")
}
}
@@ -151,7 +151,7 @@ func (r *ClientRegistry) GetDefaultClient(ctx context.Context) (*client.ClineCli
}
// ListInstances returns all registered instances directly from SQLite
func (r *ClientRegistry) ListInstances() []*common.CoreInstanceInfo {
func (r *InstanceRegistry) ListInstances() []*common.CoreInstanceInfo {
if r.lockManager == nil {
return []*common.CoreInstanceInfo{}
}
@@ -170,7 +170,7 @@ func (r *ClientRegistry) ListInstances() []*common.CoreInstanceInfo {
}
// HasInstanceAtAddress checks if an instance exists at the given address (delegates to SQLite)
func (r *ClientRegistry) HasInstanceAtAddress(address string) bool {
func (r *InstanceRegistry) HasInstanceAtAddress(address string) bool {
if r.lockManager == nil {
return false
}
@@ -185,7 +185,7 @@ func (r *ClientRegistry) HasInstanceAtAddress(address string) bool {
}
// CleanupStaleInstances removes stale instances using direct SQLite operations
func (r *ClientRegistry) CleanupStaleInstances(ctx context.Context) error {
func (r *InstanceRegistry) CleanupStaleInstances(ctx context.Context) error {
if r.lockManager == nil {
return nil
}
@@ -202,15 +202,15 @@ func (r *ClientRegistry) CleanupStaleInstances(ctx context.Context) error {
// Try to gracefully shutdown the paired host process before cleanup
fmt.Printf("Attempting to shutdown dangling host service %s for stale cline core instance %s\n",
instance.HostServiceAddress, instance.Address)
instance.HostServiceAddress, instance.CoreAddress)
r.tryShutdownHostProcess(instance.HostServiceAddress)
// Remove from SQLite database
if err := r.lockManager.RemoveInstanceLock(instance.Address); err != nil {
return fmt.Errorf("failed to remove stale instance %s: %w", instance.Address, err)
if err := r.lockManager.RemoveInstanceLock(instance.CoreAddress); err != nil {
return fmt.Errorf("failed to remove stale instance %s: %w", instance.CoreAddress, err)
}
fmt.Printf("Removed stale instance: %s\n", instance.Address)
fmt.Printf("Removed stale instance: %s\n", instance.CoreAddress)
}
}
@@ -218,8 +218,7 @@ func (r *ClientRegistry) CleanupStaleInstances(ctx context.Context) error {
}
// tryShutdownHostProcess attempts to gracefully shutdown a host process via RPC
// Best effort, don't throw errors i guess
func (r *ClientRegistry) tryShutdownHostProcess(hostServiceAddress string) {
func (r *InstanceRegistry) tryShutdownHostProcess(hostServiceAddress string) {
err := common.RetryOperation(3, 2*time.Second, func() error {
// Create context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
@@ -252,7 +251,7 @@ func (r *ClientRegistry) tryShutdownHostProcess(hostServiceAddress string) {
}
// ListInstancesCleaned performs cleanup and returns instances with health checks
func (r *ClientRegistry) ListInstancesCleaned(ctx context.Context) ([]*common.CoreInstanceInfo, error) {
func (r *InstanceRegistry) ListInstancesCleaned(ctx context.Context) ([]*common.CoreInstanceInfo, error) {
// 1. Clean up stale entries (best-effort)
_ = r.CleanupStaleInstances(ctx)
@@ -268,7 +267,7 @@ func (r *ClientRegistry) ListInstancesCleaned(ctx context.Context) ([]*common.Co
}
// EnsureDefaultInstance ensures a default instance is set if instances exist but no default is configured
func (r *ClientRegistry) EnsureDefaultInstance(instances []*common.CoreInstanceInfo) error {
func (r *InstanceRegistry) EnsureDefaultInstance(instances []*common.CoreInstanceInfo) error {
currentDefault := r.GetDefaultInstance()
// If we have no instances, clear any stale default and remove settings file
@@ -283,13 +282,13 @@ func (r *ClientRegistry) EnsureDefaultInstance(instances []*common.CoreInstanceI
// If we have instances but no default, pick the first one
if currentDefault == "" {
return sqlite.SetDefaultInstance(r.configPath, instances[0].Address)
return sqlite.SetDefaultInstance(r.configPath, instances[0].CoreAddress)
}
// Validate current default still exists in the instances
defaultExists := false
for _, instance := range instances {
if instance.Address == currentDefault {
if instance.CoreAddress == currentDefault {
defaultExists = true
break
}
@@ -297,7 +296,7 @@ func (r *ClientRegistry) EnsureDefaultInstance(instances []*common.CoreInstanceI
if !defaultExists {
// Current default doesn't exist, pick a new one from available instances
return sqlite.SetDefaultInstance(r.configPath, instances[0].Address)
return sqlite.SetDefaultInstance(r.configPath, instances[0].CoreAddress)
}
return nil
+25 -25
View File
@@ -92,12 +92,12 @@ func newInstanceKillCommand() *cobra.Command {
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
if global.Clients == nil {
if global.Instances == nil {
return fmt.Errorf("clients not initialized")
}
ctx := cmd.Context()
registry := global.Clients.GetRegistry()
registry := global.Instances.GetRegistry()
if killAllCLI {
return killAllCLIInstances(ctx, registry)
@@ -112,7 +112,7 @@ func newInstanceKillCommand() *cobra.Command {
return cmd
}
func killAllCLIInstances(ctx context.Context, registry *global.ClientRegistry) error {
func killAllCLIInstances(ctx context.Context, registry *global.InstanceRegistry) error {
// Get all instances from registry
instances, err := registry.ListInstancesCleaned(ctx)
if err != nil {
@@ -135,7 +135,7 @@ func killAllCLIInstances(ctx context.Context, registry *global.ClientRegistry) e
cliInstances = append(cliInstances, instance)
} else {
skippedNonCLI++
fmt.Printf("⊘ Skipping %s instance: %s\n", platform, instance.Address)
fmt.Printf("⊘ Skipping %s instance: %s\n", platform, instance.CoreAddress)
}
}
}
@@ -160,16 +160,16 @@ func killAllCLIInstances(ctx context.Context, registry *global.ClientRegistry) e
// Kill all CLI instances
for _, instance := range cliInstances {
result := killInstanceProcess(ctx, registry, instance.Address)
result := killInstanceProcess(ctx, registry, instance.CoreAddress)
killResults = append(killResults, result)
if result.err != nil {
fmt.Printf("✗ Failed to kill %s: %v\n", instance.Address, result.err)
fmt.Printf("✗ Failed to kill %s: %v\n", instance.CoreAddress, result.err)
} else if result.alreadyDead {
fmt.Printf("⚠ Instance %s appears to be already dead\n", instance.Address)
fmt.Printf("⚠ Instance %s appears to be already dead\n", instance.CoreAddress)
} else {
fmt.Printf("✓ Killed %s (PID %d)\n", instance.Address, result.pid)
killedAddresses[instance.Address] = true
fmt.Printf("✓ Killed %s (PID %d)\n", instance.CoreAddress, result.pid)
killedAddresses[instance.CoreAddress] = true
}
}
@@ -190,8 +190,8 @@ func killAllCLIInstances(ctx context.Context, registry *global.ClientRegistry) e
// Check if any of the killed instances are still in the registry
stillPresent := []string{}
for _, remaining := range remainingInstances {
if killedAddresses[remaining.Address] {
stillPresent = append(stillPresent, remaining.Address)
if killedAddresses[remaining.CoreAddress] {
stillPresent = append(stillPresent, remaining.CoreAddress)
}
}
@@ -247,7 +247,7 @@ type killResult struct {
err error
}
func killInstanceProcess(ctx context.Context, registry *global.ClientRegistry, address string) killResult {
func killInstanceProcess(ctx context.Context, registry *global.InstanceRegistry, address string) killResult {
// Get gRPC client and process info
client, err := registry.GetClient(ctx, address)
if err != nil {
@@ -276,12 +276,12 @@ func newInstanceListCommand() *cobra.Command {
Short: "List all registered Cline instances",
Long: `List all registered Cline instances with their status and connection details.`,
RunE: func(cmd *cobra.Command, args []string) error {
if global.Clients == nil {
if global.Instances == nil {
return fmt.Errorf("clients not initialized")
}
ctx := cmd.Context()
registry := global.Clients.GetRegistry()
registry := global.Instances.GetRegistry()
// Load, cleanup stale local entries, and update health
instances, err := registry.ListInstancesCleaned(ctx)
@@ -310,7 +310,7 @@ func newInstanceListCommand() *cobra.Command {
var rows []instanceRow
for _, instance := range instances {
isDefault := ""
if instance.Address == defaultInstance {
if instance.CoreAddress == defaultInstance {
isDefault = "✓"
}
@@ -324,7 +324,7 @@ func newInstanceListCommand() *cobra.Command {
platform := platformNA
if instance.Status == grpc_health_v1.HealthCheckResponse_SERVING {
// Get PID from core
if client, err := registry.GetClient(ctx, instance.Address); err == nil {
if client, err := registry.GetClient(ctx, instance.CoreAddress); err == nil {
if processInfo, err := client.State.GetProcessInfo(ctx, &cline.EmptyRequest{}); err == nil {
pid = fmt.Sprintf("%d", processInfo.ProcessId)
// Update version from RPC if available
@@ -341,7 +341,7 @@ func newInstanceListCommand() *cobra.Command {
}
rows = append(rows, instanceRow{
address: instance.Address,
address: instance.CoreAddress,
status: instance.Status.String(),
version: instance.Version,
lastSeen: lastSeen,
@@ -428,11 +428,11 @@ func newInstanceDefaultCommand() *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error {
address := args[0]
if global.Clients == nil {
if global.Instances == nil {
return fmt.Errorf("clients not initialized")
}
registry := global.Clients.GetRegistry()
registry := global.Instances.GetRegistry()
// Verify the instance exists
_, err := registry.GetInstance(address)
@@ -464,34 +464,34 @@ func newInstanceNewCommand() *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
if global.Clients == nil {
if global.Instances == nil {
return fmt.Errorf("clients not initialized")
}
fmt.Println("Starting new Cline instance...")
instance, err := global.Clients.StartNewInstance(ctx)
instance, err := global.Instances.StartNewInstance(ctx)
if err != nil {
return fmt.Errorf("failed to start instance: %w", err)
}
fmt.Printf("Successfully started new instance:\n")
fmt.Printf(" Address: %s\n", instance.Address)
fmt.Printf(" Address: %s\n", instance.CoreAddress)
fmt.Printf(" Core Port: %d\n", instance.CorePort())
fmt.Printf(" Host Bridge Port: %d\n", instance.HostPort())
registry := global.Clients.GetRegistry()
registry := global.Instances.GetRegistry()
// If --default flag provided, set this instance as the default
if setDefault {
if err := registry.SetDefaultInstance(instance.Address); err != nil {
if err := registry.SetDefaultInstance(instance.CoreAddress); err != nil {
fmt.Printf("Warning: Failed to set as default: %v\n", err)
} else {
fmt.Printf(" Status: Set as default instance\n")
}
} else {
// Otherwise, check if EnsureDefaultInstance already set it as default
if registry.GetDefaultInstance() == instance.Address {
if registry.GetDefaultInstance() == instance.CoreAddress {
fmt.Printf(" Status: Default instance\n")
}
}
+71
View File
@@ -547,3 +547,74 @@ func (m *InputModel) openEditor() tea.Cmd {
func (m *InputModel) SetSlashRegistry(registry *slash.Registry) {
m.completion.SetRegistry(registry)
}
// initialPromptWrapper wraps InputModel to capture the submit result for initial task prompts
type initialPromptWrapper struct {
model *InputModel
result string
cancelled bool
}
func (w *initialPromptWrapper) Init() tea.Cmd {
return w.model.Init()
}
func (w *initialPromptWrapper) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case InputSubmitMsg:
w.result = msg.Value
clearCodes := w.model.ClearScreen()
if clearCodes != "" {
fmt.Print(clearCodes)
}
return w, tea.Quit
case InputCancelMsg:
w.cancelled = true
clearCodes := w.model.ClearScreen()
if clearCodes != "" {
fmt.Print(clearCodes)
}
return w, tea.Quit
}
// Forward to wrapped model
_, cmd := w.model.Update(msg)
return w, cmd
}
func (w *initialPromptWrapper) View() string {
return w.model.View()
}
// ErrUserAborted is returned when the user cancels the input prompt
var ErrUserAborted = fmt.Errorf("user aborted")
// PromptForInitialTask displays an interactive prompt for the initial task with slash command autocomplete.
// Returns the entered text, or ErrUserAborted if cancelled.
func PromptForInitialTask(title, placeholder, mode string, registry *slash.Registry) (string, error) {
model := NewInputModelWithRegistry(
InputTypeMessage,
title,
placeholder,
mode,
registry,
)
wrapper := &initialPromptWrapper{
model: &model,
}
p := tea.NewProgram(wrapper)
_, err := p.Run()
if err != nil {
return "", fmt.Errorf("input prompt failed: %w", err)
}
if wrapper.cancelled {
return "", ErrUserAborted
}
return strings.TrimSpace(wrapper.result), nil
}
+39 -22
View File
@@ -2,10 +2,11 @@ package slash
import (
"context"
"fmt"
"strings"
"sync"
"github.com/cline/grpc-go/client"
"github.com/cline/cli/pkg/cli/global"
"github.com/cline/grpc-go/cline"
)
@@ -32,25 +33,31 @@ var cliLocalCommands = []Command{
}
// NewRegistry creates a new slash command registry
func NewRegistry() *Registry {
return &Registry{
commands: make([]Command, 0),
func NewRegistry(ctx context.Context) *Registry {
defaultCommands := append([]Command{}, cliLocalCommands...)
r := &Registry{
commands: defaultCommands,
}
r.FetchFromBackend(ctx)
return r
}
// FetchFromBackend fetches available commands from cline-core backend
func (r *Registry) FetchFromBackend(ctx context.Context, c *client.ClineClient) error {
resp, err := c.Slash.GetAvailableSlashCommands(ctx, &cline.EmptyRequest{})
if err != nil {
return err
func (r *Registry) FetchFromBackend(ctx context.Context) error {
grpcClient, err := global.GetDefaultClient(ctx)
if err != nil && global.Config.Verbose {
fmt.Printf("Warning: could not get gRPC client: %v\n", err)
return nil
}
resp, err := grpcClient.Slash.GetAvailableSlashCommands(ctx, &cline.EmptyRequest{})
if err != nil && global.Config.Verbose {
fmt.Printf("Warning: could not get gRPC client: %v\n", err)
return nil
}
r.mu.Lock()
defer r.mu.Unlock()
// Start with CLI-local commands
r.commands = append([]Command{}, cliLocalCommands...)
// Add backend commands (only CLI-compatible ones)
for _, cmd := range resp.Commands {
if cmd.CliCompatible {
@@ -66,17 +73,6 @@ func (r *Registry) FetchFromBackend(ctx context.Context, c *client.ClineClient)
return nil
}
// GetCommands returns all available commands
func (r *Registry) GetCommands() []Command {
r.mu.RLock()
defer r.mu.RUnlock()
// Return a copy to avoid race conditions
result := make([]Command, len(r.commands))
copy(result, r.commands)
return result
}
// GetMatching returns commands that start with the given prefix (case-insensitive)
func (r *Registry) GetMatching(prefix string) []Command {
r.mu.RLock()
@@ -123,3 +119,24 @@ func (r *Registry) HasCommands() bool {
defer r.mu.RUnlock()
return len(r.commands) > 0
}
// ParseModeSwitch checks if message starts with /act or /plan and extracts the mode and remaining message.
// Returns (mode, remainingMessage, isModeSwitch).
// This is a package-level function so it can be used both during initial task creation
// and during interactive input handling.
func ParseModeSwitch(message string) (string, string, bool) {
trimmed := strings.TrimSpace(message)
lower := strings.ToLower(trimmed)
if strings.HasPrefix(lower, "/plan") {
remaining := strings.TrimSpace(trimmed[5:])
return "plan", remaining, true
}
if strings.HasPrefix(lower, "/act") {
remaining := strings.TrimSpace(trimmed[4:])
return "act", remaining, true
}
return "", message, false
}
+7 -7
View File
@@ -19,20 +19,20 @@ import (
// Handles localhost/127.0.0.1 equivalence by returning both forms.
func normalizeAddressVariants(address string) []string {
variants := []string{address}
// Extract host and port
host, port, err := net.SplitHostPort(address)
if err != nil {
return variants
}
// Add the alternate form for localhost/127.0.0.1
if host == "localhost" {
variants = append(variants, net.JoinHostPort("127.0.0.1", port))
} else if host == "127.0.0.1" {
variants = append(variants, net.JoinHostPort("localhost", port))
}
return variants
}
@@ -182,7 +182,7 @@ func (lm *LockManager) GetInstanceInfo(address string) (*common.CoreInstanceInfo
query := common.SelectInstanceLockByHolderSQL
variants := normalizeAddressVariants(address)
var heldBy, lockTarget string
var lockedAt int64
var lastErr error
@@ -193,7 +193,7 @@ func (lm *LockManager) GetInstanceInfo(address string) (*common.CoreInstanceInfo
if err == nil {
// Found it!
return &common.CoreInstanceInfo{
Address: heldBy,
CoreAddress: heldBy,
HostServiceAddress: lockTarget,
Status: grpc_health_v1.HealthCheckResponse_UNKNOWN,
LastSeen: time.Unix(lockedAt/1000, 0),
@@ -204,7 +204,7 @@ func (lm *LockManager) GetInstanceInfo(address string) (*common.CoreInstanceInfo
lastErr = err
}
}
// None of the variants were found
if lastErr != nil {
return nil, fmt.Errorf("failed to query instance: %w", lastErr)
@@ -235,7 +235,7 @@ func (lm *LockManager) ListInstancesWithHealthCheck(ctx context.Context) ([]*com
}
info := &common.CoreInstanceInfo{
Address: lock.HeldBy,
CoreAddress: lock.HeldBy,
HostServiceAddress: lock.LockTarget,
Status: status,
LastSeen: time.Unix(lock.LockedAt/1000, 0),
+5 -10
View File
@@ -80,7 +80,7 @@ func ensureTaskManager(ctx context.Context, address string) error {
}
// Always set the instance we're using as the default
registry := global.Clients.GetRegistry()
registry := global.Instances.GetRegistry()
if err := registry.SetDefaultInstance(instanceAddress); err != nil {
// Log warning but don't fail - this is not critical
fmt.Printf("Warning: failed to set default instance: %v\n", err)
@@ -91,10 +91,10 @@ func ensureTaskManager(ctx context.Context, address string) error {
// ensureInstanceAtAddress ensures an instance exists at the given address
func ensureInstanceAtAddress(ctx context.Context, address string) error {
if global.Clients == nil {
if global.Instances == nil {
return fmt.Errorf("global clients not initialized")
}
return global.Clients.EnsureInstanceAtAddress(ctx, address)
return global.Instances.EnsureInstanceAtAddress(ctx, address)
}
func newTaskNewCommand() *cobra.Command {
@@ -117,7 +117,7 @@ func newTaskNewCommand() *cobra.Command {
ctx := cmd.Context()
// Check if an instance exists when no address specified
if address == "" && global.Clients.GetRegistry().GetDefaultInstance() == "" {
if address == "" && global.Instances.GetRegistry().GetDefaultInstance() == "" {
fmt.Println("No instances available for creating tasks")
return nil
}
@@ -230,7 +230,7 @@ func newTaskSendCommand() *cobra.Command {
ctx := cmd.Context()
// Check if an instance exists when no address specified
if address == "" && global.Clients.GetRegistry().GetDefaultInstance() == "" {
if address == "" && global.Instances.GetRegistry().GetDefaultInstance() == "" {
fmt.Println("No instances available for sending messages")
return nil
}
@@ -620,11 +620,6 @@ func CleanupTaskManager() {
}
}
// NewTaskManagerForAddress is an exported wrapper around task.NewManagerForAddress
func NewTaskManagerForAddress(ctx context.Context, address string) (*task.Manager, error) {
return task.NewManagerForAddress(ctx, address)
}
// CreateAndFollowTask creates a new task and immediately follows it in interactive mode
// This is used by the root command to provide a streamlined UX
func CreateAndFollowTask(ctx context.Context, prompt string, opts TaskOptions) error {
+28 -43
View File
@@ -13,38 +13,41 @@ import (
"github.com/charmbracelet/lipgloss"
"github.com/cline/cli/pkg/cli/global"
"github.com/cline/cli/pkg/cli/output"
"github.com/cline/cli/pkg/cli/slash"
"github.com/cline/cli/pkg/cli/types"
)
// InputHandler manages interactive user input during follow mode
type InputHandler struct {
manager *Manager
coordinator *StreamCoordinator
cancelFunc context.CancelFunc
mu sync.RWMutex
isRunning bool
pollTicker *time.Ticker
program *tea.Program
programRunning bool
programDoneChan chan struct{} // Signals when program actually exits
resultChan chan output.InputSubmitMsg
cancelChan chan struct{}
feedbackApproval bool // Track if we're in feedback after approval
feedbackApproved bool // Track the approval decision
approvalMessage *types.ClineMessage // Store the approval message for determining action
ctx context.Context // Context for restart callback
manager *Manager
coordinator *StreamCoordinator
cancelFunc context.CancelFunc
mu sync.RWMutex
isRunning bool
pollTicker *time.Ticker
program *tea.Program
programRunning bool
programDoneChan chan struct{} // Signals when program actually exits
resultChan chan output.InputSubmitMsg
cancelChan chan struct{}
feedbackApproval bool // Track if we're in feedback after approval
feedbackApproved bool // Track the approval decision
approvalMessage *types.ClineMessage // Store the approval message for determining action
slashCommandRegistry *slash.Registry // Slash command registry for autocomplete
ctx context.Context // Context for restart callback
}
// NewInputHandler creates a new input handler
func NewInputHandler(manager *Manager, coordinator *StreamCoordinator, cancelFunc context.CancelFunc) *InputHandler {
return &InputHandler{
manager: manager,
coordinator: coordinator,
cancelFunc: cancelFunc,
isRunning: false,
pollTicker: time.NewTicker(500 * time.Millisecond),
resultChan: make(chan output.InputSubmitMsg, 1),
cancelChan: make(chan struct{}, 1),
manager: manager,
coordinator: coordinator,
cancelFunc: cancelFunc,
isRunning: false,
pollTicker: time.NewTicker(500 * time.Millisecond),
resultChan: make(chan output.InputSubmitMsg, 1),
slashCommandRegistry: slash.NewRegistry(context.Background()),
cancelChan: make(chan struct{}, 1),
}
}
@@ -163,7 +166,7 @@ func (ih *InputHandler) Start(ctx context.Context, errChan chan error) {
if shouldSend {
// Check for mode switch commands first
newMode, remainingMessage, isModeSwitch := ih.parseModeSwitch(message)
newMode, remainingMessage, isModeSwitch := slash.ParseModeSwitch(message)
if isModeSwitch {
// Create styles for mode switch messages (respect global color profile)
actStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Bold(true)
@@ -286,7 +289,7 @@ func (ih *InputHandler) promptForInput(ctx context.Context) (string, bool, error
"Cline is ready for your message...",
"/plan or /act to switch modes\nctrl+e to open editor\ntab to autocomplete commands",
currentMode,
ih.manager.GetSlashRegistry(),
ih.slashCommandRegistry,
)
return ih.runInputProgram(ctx, model)
@@ -302,7 +305,7 @@ func (ih *InputHandler) promptForApproval(ctx context.Context, msg *types.ClineM
"Let Cline use this tool?",
"",
ih.manager.GetCurrentMode(),
ih.manager.GetSlashRegistry(), // Pass registry for feedback input after approval
ih.slashCommandRegistry,
)
message, shouldSend, err := ih.runInputProgram(ctx, model)
@@ -478,24 +481,6 @@ func (w *inputProgramWrapper) View() string {
return w.model.View()
}
// parseModeSwitch checks if message starts with /act or /plan and extracts the mode and remaining message
func (ih *InputHandler) parseModeSwitch(message string) (string, string, bool) {
trimmed := strings.TrimSpace(message)
lower := strings.ToLower(trimmed)
if strings.HasPrefix(lower, "/plan") {
remaining := strings.TrimSpace(trimmed[5:])
return "plan", remaining, true
}
if strings.HasPrefix(lower, "/act") {
remaining := strings.TrimSpace(trimmed[4:])
return "act", remaining, true
}
return "", message, false
}
// handleSpecialCommand processes special commands like /cancel, /exit
func (ih *InputHandler) handleSpecialCommand(ctx context.Context, message string) bool {
switch strings.ToLower(strings.TrimSpace(message)) {
+7 -34
View File
@@ -14,7 +14,6 @@ import (
"github.com/cline/cli/pkg/cli/display"
"github.com/cline/cli/pkg/cli/global"
"github.com/cline/cli/pkg/cli/handlers"
"github.com/cline/cli/pkg/cli/slash"
"github.com/cline/cli/pkg/cli/types"
"github.com/cline/grpc-go/client"
"github.com/cline/grpc-go/cline"
@@ -38,7 +37,6 @@ type Manager struct {
systemRenderer *display.SystemMessageRenderer
streamingDisplay *display.StreamingDisplay
handlerRegistry *handlers.HandlerRegistry
slashRegistry *slash.Registry
isStreamingMode bool
isInteractive bool
currentMode string // "plan" or "act"
@@ -68,7 +66,6 @@ func NewManager(client *client.ClineClient) *Manager {
systemRenderer: systemRenderer,
streamingDisplay: streamingDisplay,
handlerRegistry: registry,
slashRegistry: slash.NewRegistry(),
currentMode: "plan", // Default mode
}
}
@@ -83,9 +80,6 @@ func NewManagerForAddress(ctx context.Context, address string) (*Manager, error)
manager := NewManager(client)
manager.clientAddress = address
// Fetch slash commands from backend (non-blocking, errors are logged)
manager.fetchSlashCommands(ctx)
return manager, nil
}
@@ -99,29 +93,13 @@ func NewManagerForDefault(ctx context.Context) (*Manager, error) {
manager := NewManager(client)
// Get the default instance address
if global.Clients != nil {
manager.clientAddress = global.Clients.GetRegistry().GetDefaultInstance()
if global.Instances != nil {
manager.clientAddress = global.Instances.GetRegistry().GetDefaultInstance()
}
// Fetch slash commands from backend (non-blocking, errors are logged)
manager.fetchSlashCommands(ctx)
return manager, nil
}
// fetchSlashCommands fetches available slash commands from the backend
// This is non-blocking and errors are logged but don't prevent manager creation
func (m *Manager) fetchSlashCommands(ctx context.Context) {
if err := m.slashRegistry.FetchFromBackend(ctx, m.client); err != nil {
if global.Config.Verbose {
m.renderer.RenderDebug("Failed to fetch slash commands: %v", err)
}
// Non-fatal: CLI-local commands are still available
} else if global.Config.Verbose {
m.renderer.RenderDebug("Loaded %d slash commands", len(m.slashRegistry.GetCommands()))
}
}
// SwitchToInstance switches the manager to use a different Cline instance
func (m *Manager) SwitchToInstance(ctx context.Context, address string) error {
m.mu.Lock()
@@ -1206,11 +1184,11 @@ func (m *Manager) displayMessage(msg *types.ClineMessage, isLast, isPartial bool
m.mu.RUnlock()
dc := &handlers.DisplayContext{
State: m.state,
Renderer: m.renderer,
ToolRenderer: m.toolRenderer,
HookRenderer: m.hookRenderer,
SystemRenderer: m.systemRenderer,
State: m.state,
Renderer: m.renderer,
ToolRenderer: m.toolRenderer,
HookRenderer: m.hookRenderer,
SystemRenderer: m.systemRenderer,
IsLast: isLast,
IsPartial: isPartial,
Verbose: global.Config.Verbose,
@@ -1320,11 +1298,6 @@ func (m *Manager) GetCurrentMode() string {
return m.currentMode
}
// GetSlashRegistry returns the slash command registry
func (m *Manager) GetSlashRegistry() *slash.Registry {
return m.slashRegistry
}
// extractModeFromState extracts the current mode from state JSON
func (m *Manager) extractModeFromState(stateJson string) string {
var rawState map[string]interface{}

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