mirror of
https://github.com/cline/cline.git
synced 2026-09-01 23:19:18 +08:00
Compare commits
448 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e172ac8d0 | |||
| 4d41766b9f | |||
| e958e29439 | |||
| b337bb706e | |||
| 314e06731a | |||
| 3a6f8b98dd | |||
| 65f9322bd9 | |||
| 33983e80f8 | |||
| 6303951f20 | |||
| fc184e07a2 | |||
| e88f4ea412 | |||
| 8648154a74 | |||
| 133ade3f1a | |||
| 5d91ac037e | |||
| 6179dfe0a4 | |||
| cd9e28e6d0 | |||
| 8d56a697eb | |||
| fd506b14b5 | |||
| 857411c035 | |||
| c59f068584 | |||
| f7561c31fa | |||
| f0a97dafc8 | |||
| 55d268773d | |||
| bf978c2c42 | |||
| 7e06dbbd33 | |||
| d422f689a6 | |||
| 00efcc1c2b | |||
| 8d4822d781 | |||
| dbcaae65d1 | |||
| 66db36ff5b | |||
| a53d17a857 | |||
| 59251e9de6 | |||
| 2df952f571 | |||
| de56305315 | |||
| 5cd22e3f86 | |||
| bd9150837d | |||
| fbb494b88a | |||
| 16b4c48457 | |||
| 6e7dc55781 | |||
| 058c86872b | |||
| d3e8f562bc | |||
| ec879fc549 | |||
| 1594d44190 | |||
| c8419ffa99 | |||
| cf6c80504f | |||
| 230f615927 | |||
| 08e00dc3bf | |||
| c01bf73d3f | |||
| df28359114 | |||
| 75b050de5c | |||
| 0c4acd9457 | |||
| 0dc3d7084d | |||
| 3cfafe6583 | |||
| d3631f0485 | |||
| eb18c556f4 | |||
| b18284ea78 | |||
| e4e99ec711 | |||
| b7b4d0bfe8 | |||
| c12b00e7a9 | |||
| 73bb8c2f64 | |||
| 0e1d513462 | |||
| d8aefbaabd | |||
| d6a1b338bc | |||
| 1ff60522f1 | |||
| 42321e5b95 | |||
| 0091fb6ffb | |||
| b2634d2276 | |||
| 3755817060 | |||
| de3e3fe34a | |||
| e136d75621 | |||
| 8ae18e6a16 | |||
| 113d7fb292 | |||
| 592d045565 | |||
| 8ea54704a6 | |||
| 1734c669a0 | |||
| fb695602a4 | |||
| 5d7f0f04d3 | |||
| 13cf28d8f4 | |||
| 6238fab366 | |||
| 7c26a7d16b | |||
| 7885c75a4f | |||
| 8994d125be | |||
| 79d88f7708 | |||
| 4d092bfba6 | |||
| dc5c6f916b | |||
| 91127ffdb9 | |||
| 50332ec46a | |||
| 11f76f0218 | |||
| 5ccd062839 | |||
| 74f1b9c75d | |||
| 942c78119e | |||
| ae9eceb113 | |||
| dd35448a9d | |||
| 32aa16612d | |||
| bffe5c4d2a | |||
| b18d7012aa | |||
| e0965821fc | |||
| 9e46e9fd22 | |||
| 890a1f7ac8 | |||
| b89a73c193 | |||
| 51927dba33 | |||
| 7634f22104 | |||
| fbf784f78b | |||
| 62bf50a659 | |||
| d850fbc0ad | |||
| 4dd6c6dcc7 | |||
| 8813f8252c | |||
| 3210c4bc4b | |||
| 9f3daa4151 | |||
| ac2db41815 | |||
| df1d33c751 | |||
| 361494d18f | |||
| dca0a8fa3e | |||
| 6d7213dc6a | |||
| 97a35d3868 | |||
| 4ec9155c46 | |||
| bffca989a1 | |||
| 0133b5d030 | |||
| a94c4be438 | |||
| d70792e539 | |||
| e4ddaac627 | |||
| 9478b600aa | |||
| d194e47bf6 | |||
| c9ff9cf1d5 | |||
| d7fa6b33c1 | |||
| 8f521e7ea3 | |||
| 3cb8d0fbcf | |||
| 3130c3c96e | |||
| f0225bc20d | |||
| 5f2bf6329f | |||
| 963abc190e | |||
| 631a7d6566 | |||
| e43ab0ea7a | |||
| 242e3321a2 | |||
| ea6cb4b29e | |||
| 1bf158245c | |||
| 2c75285566 | |||
| 8279f2e145 | |||
| 5b94ba3ef9 | |||
| 703146182a | |||
| 9603643b77 | |||
| c6dce7fb17 | |||
| cc0d4ae6cb | |||
| a9365e30e9 | |||
| efe468d9b1 | |||
| 1b6202604d | |||
| ea1dbd8bea | |||
| e43517519e | |||
| 520d08c5f2 | |||
| 31a55ac87e | |||
| a442983742 | |||
| 7b71eff294 | |||
| 6d1890f8bb | |||
| 11d17fc17e | |||
| 42a3dc6150 | |||
| 4032e51e8d | |||
| 1bbc90487c | |||
| d422ebbb27 | |||
| 7470d234ef | |||
| 21b81f1844 | |||
| 362429a317 | |||
| 09cb9ac9ac | |||
| 94160faeef | |||
| f526f70e3a | |||
| d9b47378c6 | |||
| 0671c59e6d | |||
| bf87887501 | |||
| 66a81a6efa | |||
| eee64c5204 | |||
| 748ba99c1c | |||
| 82b1a01644 | |||
| 1c6307e8ad | |||
| 58d9c0af18 | |||
| bf213c24ea | |||
| 80cceaa3ae | |||
| 359e088eb6 | |||
| 47a464defe | |||
| 38f619cfd9 | |||
| 050773ac31 | |||
| 6d67ff0b94 | |||
| 18f4ef8b49 | |||
| 085e69d142 | |||
| 46aa66ed9d | |||
| 2ebbe954d9 | |||
| 067f5eea09 | |||
| 2a48bad28c | |||
| c6f4584f7d | |||
| a17b31070f | |||
| cad82d518d | |||
| b4d7ec187f | |||
| 42af8414e4 | |||
| 8f6b9e8362 | |||
| f1430359db | |||
| 932695f70b | |||
| 489ee936c2 | |||
| dff7f61175 | |||
| aead42c6b8 | |||
| db50a1c671 | |||
| bb20f60f1d | |||
| a7333b7177 | |||
| f30837a850 | |||
| 5660b2513f | |||
| 64e7e5fa4c | |||
| b34166e99a | |||
| cd2d8f98a7 | |||
| 333468c9b6 | |||
| 0da6ddc001 | |||
| 612130366f | |||
| 85206cfded | |||
| 6f8ed7aa56 | |||
| f2c130a69e | |||
| c870bfb454 | |||
| 436bfdb535 | |||
| e2a3652d26 | |||
| 632aca225c | |||
| a1eaccdaef | |||
| f7e76f9bee | |||
| 0d04205dc4 | |||
| 4b9dbf11a0 | |||
| c5afbd743e | |||
| 6d1bfc3a1b | |||
| d18486a199 | |||
| 7417b1b671 | |||
| e4fbe331f4 | |||
| d122683e91 | |||
| fdc96d0545 | |||
| c0241f4060 | |||
| c3f523427b | |||
| 428e465d4d | |||
| 46ab08c9f8 | |||
| 0fd30e0d20 | |||
| e87a765773 | |||
| e80c3d3cd8 | |||
| 80b6ab6046 | |||
| 5155123b80 | |||
| 30af711d5e | |||
| b218b25e0b | |||
| bf27aa53ef | |||
| 4644a7159a | |||
| 63c63f38f5 | |||
| f1d8ca423d | |||
| 16b8ce7e44 | |||
| 0bbb4299ba | |||
| 197b17b3bd | |||
| 591b840531 | |||
| c35ba37402 | |||
| 850a6ab841 | |||
| 99c85710e9 | |||
| 9fc9785aa2 | |||
| 5c02f6eb13 | |||
| ac01e69555 | |||
| dbc18a8fce | |||
| b77398390b | |||
| c20052de00 | |||
| 1dac507b63 | |||
| e5aa48c2b5 | |||
| 63efb4aa5d | |||
| 5fcf83b627 | |||
| 0b308d610e | |||
| 60d3048aa0 | |||
| aed3ac6597 | |||
| 86e2a3e7ce | |||
| 47856c70d2 | |||
| f1a84ddbde | |||
| ebcc927cc7 | |||
| 1e108d87e0 | |||
| 7b62d7786e | |||
| 042f5c9823 | |||
| 2f8a4525a4 | |||
| 20774a4187 | |||
| be5bda2740 | |||
| 261fd9036f | |||
| 2334d4d531 | |||
| 6390d854f7 | |||
| 7d5c56a55a | |||
| 0dc760ac03 | |||
| 8c1241c8cd | |||
| 17686ae3d9 | |||
| 97460d2952 | |||
| e629ed0ef6 | |||
| 96788e9127 | |||
| d7716a514d | |||
| 7aaa5966d6 | |||
| bb1d068139 | |||
| 450945ae0e | |||
| 191e9635bd | |||
| c13a7a80b3 | |||
| d4a4adfa5f | |||
| 557e20224e | |||
| 0e9a326a6a | |||
| f5ecb6db0c | |||
| 8f1405b881 | |||
| 8a9e03c8ff | |||
| edba02b45e | |||
| cc36c67fc9 | |||
| d77032bc8a | |||
| 608dde94b3 | |||
| 47ff7c1620 | |||
| 1b7f971c34 | |||
| 12eadd3378 | |||
| b3e0ef9ed7 | |||
| fb94d8d3d4 | |||
| d11bd15d60 | |||
| 31c48898a6 | |||
| 5c9901d68a | |||
| 45b79dc3d7 | |||
| 26b6c7bdb6 | |||
| d0678a2ad1 | |||
| 09276ebf43 | |||
| af8b51b189 | |||
| 3e6b3f252b | |||
| f019c365a6 | |||
| f6fe843cfb | |||
| 01f26c21ae | |||
| 031c2f5b05 | |||
| c999e269db | |||
| 032c1bf792 | |||
| fa73d60b7a | |||
| 841bb7f1d7 | |||
| f01428884d | |||
| 2ce5548250 | |||
| 9f32cd247f | |||
| ace48198cc | |||
| 9f0240dfd7 | |||
| 5530cfe375 | |||
| af4d99e0bc | |||
| cd011a0e4a | |||
| d44184ab03 | |||
| 5d96704e92 | |||
| 49812eb332 | |||
| 57b72519ae | |||
| 1ed4d00a16 | |||
| c090f5b1a7 | |||
| 3c917ec99d | |||
| b315be397d | |||
| dc9e7916de | |||
| cb9d1e81b8 | |||
| eb5a452c9c | |||
| 6a90294a2a | |||
| 2e1334c10a | |||
| 18b77ee5e5 | |||
| 4bf8feecac | |||
| 2f3450f667 | |||
| 3e89c28727 | |||
| d065ac7b37 | |||
| 5a0a25601a | |||
| 48d822a030 | |||
| 417104505c | |||
| e21d3ff1bb | |||
| da5477f891 | |||
| 4903dfcb6e | |||
| 37b2f7fbc9 | |||
| 11fbe4b21d | |||
| e0844ac6e2 | |||
| f28d760675 | |||
| 8363d090e0 | |||
| 3409fa7442 | |||
| a20685d289 | |||
| 926c5e189e | |||
| 2a5ca9d312 | |||
| d06717342b | |||
| c904cfe376 | |||
| 924ca1278c | |||
| 2b0c0a659d | |||
| f4477e229d | |||
| 5207d5c68e | |||
| 51b535e2d6 | |||
| 4db61b1b36 | |||
| 6baf611322 | |||
| 97d635d606 | |||
| 8ce476ccaa | |||
| bce403476e | |||
| ac306c3719 | |||
| 1d8a9bf11e | |||
| d811a9d5f3 | |||
| 3abeff0379 | |||
| 2763266424 | |||
| 0c3dfb6aed | |||
| 0e73cd4c9f | |||
| 6bbe5c2499 | |||
| 00e9d6f523 | |||
| 010fd71140 | |||
| 17a95f0021 | |||
| 27c9971774 | |||
| 644d06c487 | |||
| 1be314dfed | |||
| 91940fbb4a | |||
| 279e371cf5 | |||
| 4eda267981 | |||
| 3441363805 | |||
| 5389be991e | |||
| 41fd610203 | |||
| e1e7470fdd | |||
| 2f0e0ee56d | |||
| 7f89ebf276 | |||
| 99ed1b7e86 | |||
| 45c9fcf575 | |||
| 4f73f4460b | |||
| fb872fd316 | |||
| 5292242a8e | |||
| 78b8aed50f | |||
| d01f7b4618 | |||
| dec215cd9c | |||
| 8ce7e132d2 | |||
| 6ab008b204 | |||
| 769523998d | |||
| 64a6bcc39b | |||
| 19ceafd4a4 | |||
| 4b5218b3b8 | |||
| b68d716675 | |||
| f820c6d00a | |||
| 1fcbbaa9d0 | |||
| 4005df6eae | |||
| 0e3cdab82b | |||
| ada6b0c955 | |||
| 921dd2ec8c | |||
| 4b212994a8 | |||
| 3fe3c866ea | |||
| e9136f60cc | |||
| 34bb95e04e | |||
| 7b65db55f1 | |||
| 07ebc2e4bd | |||
| 0193179597 | |||
| 20c0783c97 | |||
| 4df486fa5b | |||
| 5fc6d4e9e3 | |||
| 00d3bd8316 | |||
| 81d4ff947f | |||
| bcb368d097 | |||
| c1ded819bb | |||
| de02befb2f | |||
| 6617901078 | |||
| 31c7176de1 | |||
| 3c97c8dc19 | |||
| 187e40d2da | |||
| 8c38b1ccf8 | |||
| 4947a745c2 | |||
| 9e8c6df6b2 | |||
| 6586195b2d | |||
| 1aa944eff7 | |||
| 8e7b1c6884 | |||
| ee154826b6 | |||
| 852f307268 | |||
| 4e3fe004f4 | |||
| 2b63eed85e | |||
| 2ffdc50ea1 | |||
| 74808431e5 | |||
| 7a523fbaf6 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add claude 4.5 haiku
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: finalize document content during approval flow
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Expose --version in cline cli command
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: prevent duplicate diff errors when parallel tool calling is enabled
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Log Persistence errors to PostHog
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
OpenAI GPT-5 Codex models are now using Apply Patch tool for diff edits.
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: add chat output on skill use
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Adding telemetry for background exec terminal
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: removes retry message from UI after retry succeeds
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Support native tool calling for LM Studio and Ollama provider
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fixing integration tests from testing framework
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
add claude 4.5 opus into sap provider.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Limite Vertex and LiteLLM options when they're remote configured
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix crash when the Context Menu has a type but no options
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Throttle the remote config fetch
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Improve history view filter menu
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add git worktree management UI for running parallel Cline sessions
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../.clinerules/workflows/hotfix-release.md
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.clinerules/workflows/release.md
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Only run in Claude Code remote environments
|
||||
if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cd "$CLAUDE_PROJECT_DIR"
|
||||
|
||||
echo "=== Claude Code for Web Setup ==="
|
||||
echo ""
|
||||
|
||||
# Install latest gh CLI tool
|
||||
echo "Installing GitHub CLI..."
|
||||
GH_VERSION=$(curl -s https://api.github.com/repos/cli/cli/releases/latest | grep '"tag_name"' | cut -d'"' -f4 | sed 's/^v//')
|
||||
curl -sL "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_amd64.tar.gz" -o /tmp/gh.tar.gz
|
||||
tar -xzf /tmp/gh.tar.gz -C /tmp
|
||||
sudo mv "/tmp/gh_${GH_VERSION}_linux_amd64/bin/gh" /usr/local/bin/gh
|
||||
rm -rf /tmp/gh.tar.gz /tmp/gh_${GH_VERSION}_linux_amd64
|
||||
echo "Installed gh version: $(gh --version | head -1)"
|
||||
echo ""
|
||||
|
||||
# Check if GITHUB_TOKEN is set and configure gh
|
||||
if [ -n "${GITHUB_TOKEN:-}" ]; then
|
||||
echo "GITHUB_TOKEN is configured - gh CLI is ready to use"
|
||||
echo ""
|
||||
echo "You can use gh commands directly, for example:"
|
||||
echo " gh issue list --repo cline/cline --limit 5"
|
||||
echo " gh pr list --repo cline/cline --state open"
|
||||
echo " gh issue view 123 --repo cline/cline"
|
||||
echo ""
|
||||
else
|
||||
echo "GITHUB_TOKEN is not set - gh CLI will have limited functionality"
|
||||
echo ""
|
||||
echo "To enable full GitHub API access:"
|
||||
echo "1. Create a Fine-grained Personal Access Token at https://github.com/settings/tokens?type=beta"
|
||||
echo "2. Add it as GITHUB_TOKEN in your Claude Code environment settings"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Install project dependencies
|
||||
echo "Installing dependencies..."
|
||||
npm run install:all
|
||||
|
||||
# Generate gRPC/protobuf types (required for TypeScript)
|
||||
echo "Generating proto types..."
|
||||
npm run protos
|
||||
|
||||
echo ""
|
||||
echo "Session setup complete!"
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/claude-code-for-web-setup.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
---
|
||||
name: create-pull-request
|
||||
description: Create a GitHub pull request following project conventions. Use when the user asks to create a PR, submit changes for review, or open a pull request. Handles commit analysis, branch management, and PR creation using the gh CLI tool.
|
||||
---
|
||||
|
||||
# Create Pull Request
|
||||
|
||||
This skill guides you through creating a well-structured GitHub pull request that follows project conventions and best practices.
|
||||
|
||||
## Prerequisites Check
|
||||
|
||||
Before proceeding, verify the following:
|
||||
|
||||
### 1. Check if `gh` CLI is installed
|
||||
|
||||
```bash
|
||||
gh --version
|
||||
```
|
||||
|
||||
If not installed, inform the user:
|
||||
> The GitHub CLI (`gh`) is required but not installed. Please install it:
|
||||
> - macOS: `brew install gh`
|
||||
> - Other: https://cli.github.com/
|
||||
|
||||
### 2. Check if authenticated with GitHub
|
||||
|
||||
```bash
|
||||
gh auth status
|
||||
```
|
||||
|
||||
If not authenticated, guide the user to run `gh auth login`.
|
||||
|
||||
### 3. Verify clean working directory
|
||||
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
|
||||
If there are uncommitted changes, ask the user whether to:
|
||||
- Commit them as part of this PR
|
||||
- Stash them temporarily
|
||||
- Discard them (with caution)
|
||||
|
||||
## Gather Context
|
||||
|
||||
### 1. Identify the current branch
|
||||
|
||||
```bash
|
||||
git branch --show-current
|
||||
```
|
||||
|
||||
Ensure you're not on `main` or `master`. If so, ask the user to create or switch to a feature branch.
|
||||
|
||||
### 2. Find the base branch
|
||||
|
||||
```bash
|
||||
git remote show origin | grep "HEAD branch"
|
||||
```
|
||||
|
||||
This is typically `main` or `master`.
|
||||
|
||||
### 3. Analyze recent commits relevant to this PR
|
||||
|
||||
```bash
|
||||
git log origin/main..HEAD --oneline --no-decorate
|
||||
```
|
||||
|
||||
Review these commits to understand:
|
||||
- What changes are being introduced
|
||||
- The scope of the PR (single feature/fix or multiple changes)
|
||||
- Whether commits should be squashed or reorganized
|
||||
|
||||
### 4. Review the diff
|
||||
|
||||
```bash
|
||||
git diff origin/main..HEAD --stat
|
||||
```
|
||||
|
||||
This shows which files changed and helps identify the type of change.
|
||||
|
||||
## Information Gathering
|
||||
|
||||
Before creating the PR, you need the following information. Check if it can be inferred from:
|
||||
- Commit messages
|
||||
- Branch name (e.g., `fix/issue-123`, `feature/new-login`)
|
||||
- Changed files and their content
|
||||
|
||||
If any critical information is missing, use `ask_followup_question` to ask the user:
|
||||
|
||||
### Required Information
|
||||
|
||||
1. **Related Issue Number**: Look for patterns like `#123`, `fixes #123`, or `closes #123` in commit messages
|
||||
2. **Description**: What problem does this solve? Why were these changes made?
|
||||
3. **Type of Change**: Bug fix, new feature, breaking change, refactor, cosmetic, documentation, or workflow
|
||||
4. **Test Procedure**: How was this tested? What could break?
|
||||
|
||||
### Example clarifying question
|
||||
|
||||
If the issue number is not found:
|
||||
> I couldn't find a related issue number in the commit messages or branch name. What GitHub issue does this PR address? (Enter the issue number, e.g., "123" or "N/A" for small fixes)
|
||||
|
||||
## Git Best Practices
|
||||
|
||||
Before creating the PR, consider these best practices:
|
||||
|
||||
### Commit Hygiene
|
||||
|
||||
1. **Atomic commits**: Each commit should represent a single logical change
|
||||
2. **Clear commit messages**: Follow conventional commit format when possible
|
||||
3. **No merge commits**: Prefer rebasing over merging to keep history clean
|
||||
|
||||
### Branch Management
|
||||
|
||||
1. **Rebase on latest main** (if needed):
|
||||
```bash
|
||||
git fetch origin
|
||||
git rebase origin/main
|
||||
```
|
||||
|
||||
2. **Squash if appropriate**: If there are many small "WIP" commits, consider interactive rebase:
|
||||
```bash
|
||||
git rebase -i origin/main
|
||||
```
|
||||
Only suggest this if commits appear messy and the user is comfortable with rebasing.
|
||||
|
||||
### Push Changes
|
||||
|
||||
Ensure all commits are pushed:
|
||||
```bash
|
||||
git push origin HEAD
|
||||
```
|
||||
|
||||
If the branch was rebased, you may need:
|
||||
```bash
|
||||
git push origin HEAD --force-with-lease
|
||||
```
|
||||
|
||||
## Create the Pull Request
|
||||
|
||||
**IMPORTANT**: Read and use the PR template at `.github/pull_request_template.md`. The PR body format must **strictly match** the template structure. Do not deviate from the template format.
|
||||
|
||||
When filling out the template:
|
||||
- Replace `#XXXX` with the actual issue number, or keep as `#XXXX` if no issue exists (for small fixes)
|
||||
- Fill in all sections with relevant information gathered from commits and context
|
||||
- Mark the appropriate "Type of Change" checkbox(es)
|
||||
- Complete the "Pre-flight Checklist" items that apply
|
||||
|
||||
### Create PR with gh CLI
|
||||
|
||||
```bash
|
||||
gh pr create --title "PR_TITLE" --body "PR_BODY" --base main
|
||||
```
|
||||
|
||||
Alternatively, create as draft if the user wants review before marking ready:
|
||||
```bash
|
||||
gh pr create --title "PR_TITLE" --body "PR_BODY" --base main --draft
|
||||
```
|
||||
|
||||
## Post-Creation
|
||||
|
||||
After creating the PR:
|
||||
|
||||
1. **Display the PR URL** so the user can review it
|
||||
2. **Remind about CI checks**: Tests and linting will run automatically
|
||||
3. **Suggest next steps**:
|
||||
- Add reviewers if needed: `gh pr edit --add-reviewer USERNAME`
|
||||
- Add labels if needed: `gh pr edit --add-label "bug"`
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **No commits ahead of main**: The branch has no changes to submit
|
||||
- Ask if the user meant to work on a different branch
|
||||
|
||||
2. **Branch not pushed**: Remote doesn't have the branch
|
||||
- Push the branch first: `git push -u origin HEAD`
|
||||
|
||||
3. **PR already exists**: A PR for this branch already exists
|
||||
- Show the existing PR: `gh pr view`
|
||||
- Ask if they want to update it instead
|
||||
|
||||
4. **Merge conflicts**: Branch conflicts with base
|
||||
- Guide user through resolving conflicts or rebasing
|
||||
|
||||
## Summary Checklist
|
||||
|
||||
Before finalizing, ensure:
|
||||
- [ ] `gh` CLI is installed and authenticated
|
||||
- [ ] Working directory is clean
|
||||
- [ ] All commits are pushed
|
||||
- [ ] Branch is up-to-date with base branch
|
||||
- [ ] Related issue number is identified, or placeholder is used
|
||||
- [ ] PR description follows the template exactly
|
||||
- [ ] Appropriate type of change is selected
|
||||
- [ ] Pre-flight checklist items are addressed
|
||||
@@ -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.
|
||||
@@ -0,0 +1,194 @@
|
||||
# Hotfix Release
|
||||
|
||||
Create a hotfix release by cherry-picking specific commits from main onto the latest release tag.
|
||||
|
||||
## Overview
|
||||
|
||||
This workflow helps you:
|
||||
1. Select specific commits from main to include in a hotfix
|
||||
2. Create a release notes commit on main (changelog + version bump)
|
||||
3. Cherry-pick everything onto the latest release tag
|
||||
4. Tag and push the new release
|
||||
|
||||
## Step 1: Setup and Gather Information
|
||||
|
||||
First, ensure we're on main and up to date:
|
||||
|
||||
```bash
|
||||
git checkout main && git pull origin main
|
||||
```
|
||||
|
||||
Get the latest release tag:
|
||||
|
||||
```bash
|
||||
git tag --sort=-v:refname | head -1
|
||||
```
|
||||
|
||||
## Step 2: Present Commits Since Last Release
|
||||
|
||||
Show all commits on main since the last release tag:
|
||||
|
||||
```bash
|
||||
LAST_TAG=$(git tag --sort=-v:refname | head -1)
|
||||
git log ${LAST_TAG}..HEAD --oneline --format="%h %s (%an)"
|
||||
```
|
||||
|
||||
Also get the commit messages already on the tag (to identify previously cherry-picked commits). Note: Run these as separate commands to avoid shell parsing issues with parentheses in author names:
|
||||
|
||||
```bash
|
||||
LAST_TAG=$(git tag --sort=-v:refname | head -1)
|
||||
PREV_TAG=$(git tag --sort=-v:refname | head -2 | tail -1)
|
||||
```
|
||||
|
||||
```bash
|
||||
git log $PREV_TAG..$LAST_TAG --oneline --format="%s"
|
||||
```
|
||||
|
||||
**Present the list** to the user in a numbered format with commit hash, subject, and author. For any commits whose subject line already appears in the tag's history (previously cherry-picked in an earlier hotfix) or are "Release Notes" commits, add `(already in previous hotfix)` or `(release notes - skip)` after them so the user knows to skip those.
|
||||
|
||||
Ask which commits to include in the hotfix.
|
||||
|
||||
Use the ask_followup_question tool to let the user specify which commits they want (by number or hash).
|
||||
|
||||
## Step 3: Analyze Selected Commits
|
||||
|
||||
For each selected commit:
|
||||
1. Get the full commit message: `git show --no-patch --format="%B" <hash>`
|
||||
2. Get the diff to understand the change: `git show <hash> --stat`
|
||||
3. Find the associated PR if any: `gh pr list --search "<hash>" --state merged --json number,title --jq '.[0]'`
|
||||
|
||||
Build a mental model of what these changes do for the changelog.
|
||||
|
||||
## Step 4: Determine New Version Number
|
||||
|
||||
Parse the current version from package.json and the last tag:
|
||||
|
||||
```bash
|
||||
LAST_TAG=$(git tag --sort=-v:refname | head -1)
|
||||
echo "Last release: $LAST_TAG"
|
||||
cat package.json | grep '"version"'
|
||||
```
|
||||
|
||||
Hotfixes always increment the patch version (e.g., 3.40.0 -> 3.40.1, or 3.40.1 -> 3.40.2).
|
||||
|
||||
**Ask the user to confirm the new version number.**
|
||||
|
||||
## Step 5: Create Release Notes Commit on Main
|
||||
|
||||
On the main branch, create a commit that updates:
|
||||
|
||||
1. **CHANGELOG.md** - Add a new section for the hotfix version at the top:
|
||||
```markdown
|
||||
## [3.40.1]
|
||||
|
||||
- Description of fix 1
|
||||
- Description of fix 2
|
||||
```
|
||||
|
||||
Write clear, user-friendly descriptions based on your analysis of the commits.
|
||||
|
||||
2. **package.json** - Update the version field to the new version
|
||||
|
||||
3. **Delete changesets** for the commits being included in the hotfix. This prevents the changeset bot from including duplicate entries in the next regular release.
|
||||
|
||||
Find and delete the changeset files associated with the selected commits:
|
||||
```bash
|
||||
ls .changeset/
|
||||
```
|
||||
|
||||
Each changeset file in `.changeset/` corresponds to a PR. Read them to identify which ones belong to the commits you're hotfixing, then delete those files.
|
||||
|
||||
**Skip running `npm run install:all`** - the automation handles outdated lockfiles.
|
||||
|
||||
Commit with message format: `v{VERSION} Release Notes (hotfix)`
|
||||
|
||||
In the commit body, mention:
|
||||
- This is for a hotfix release
|
||||
- List the cherry-picked commits that will be included
|
||||
|
||||
```bash
|
||||
git add CHANGELOG.md package.json .changeset/
|
||||
git commit -m "v3.40.1 Release Notes (hotfix)
|
||||
|
||||
Hotfix release including:
|
||||
- <commit1-hash>: <description>
|
||||
- <commit2-hash>: <description>
|
||||
"
|
||||
```
|
||||
|
||||
Push to main:
|
||||
|
||||
```bash
|
||||
git push origin main
|
||||
```
|
||||
|
||||
## Step 6: Build the Hotfix on the Tag
|
||||
|
||||
Checkout the last release tag (detached HEAD):
|
||||
|
||||
```bash
|
||||
LAST_TAG=$(git tag --sort=-v:refname | head -1)
|
||||
git checkout $LAST_TAG
|
||||
```
|
||||
|
||||
Cherry-pick the selected commits in order:
|
||||
|
||||
```bash
|
||||
git cherry-pick <commit1-hash>
|
||||
git cherry-pick <commit2-hash>
|
||||
# ... etc
|
||||
```
|
||||
|
||||
Finally, cherry-pick the release notes commit you just pushed to main:
|
||||
|
||||
```bash
|
||||
# Get the hash of the release notes commit (should be HEAD of main)
|
||||
RELEASE_NOTES_COMMIT=$(git rev-parse main)
|
||||
git cherry-pick $RELEASE_NOTES_COMMIT
|
||||
```
|
||||
|
||||
## Step 7: Tag and Push
|
||||
|
||||
After all cherry-picks are applied successfully:
|
||||
|
||||
```bash
|
||||
# Tag the new release
|
||||
git tag v{VERSION}
|
||||
|
||||
# Push the tag to remote
|
||||
git push origin v{VERSION}
|
||||
```
|
||||
|
||||
## Step 8: Return to Main and Summary
|
||||
|
||||
Return to main branch:
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
```
|
||||
|
||||
**Copy a Slack announcement message to clipboard** with the version and PR links for each included fix:
|
||||
|
||||
```
|
||||
VS Code Hotfix v{VERSION} Published
|
||||
|
||||
- Description of fix 1 https://github.com/cline/cline/pull/{PR_NUMBER}
|
||||
- Description of fix 2 https://github.com/cline/cline/pull/{PR_NUMBER}
|
||||
```
|
||||
|
||||
Present a final summary:
|
||||
- New version: v{VERSION}
|
||||
- Tag pushed: yes
|
||||
- Commits included: (list them)
|
||||
- Slack message copied to clipboard: yes
|
||||
|
||||
Remind the user to:
|
||||
1. Manually trigger the publish release GitHub Action at: https://github.com/cline/cline/actions/workflows/publish.yml (paste `v{VERSION}` as the tag)
|
||||
2. Post the Slack message to announce the hotfix
|
||||
|
||||
## Important Notes
|
||||
|
||||
- This workflow does NOT create a release branch - only tags
|
||||
- The release notes commit goes to main first, then gets cherry-picked to the tag
|
||||
- This keeps main's history accurate while allowing hotfix releases from tags
|
||||
- If cherry-pick conflicts occur, resolve them before continuing
|
||||
@@ -0,0 +1,232 @@
|
||||
# Release
|
||||
|
||||
Prepare and publish a release from the open changeset PR.
|
||||
|
||||
## Overview
|
||||
|
||||
This workflow helps you:
|
||||
1. Find and checkout the open changeset PR
|
||||
2. Clean up the changelog (fix version format, wordsmith entries)
|
||||
3. Push changes back to the PR branch
|
||||
4. Merge with proper commit message format
|
||||
5. Tag and push the release (after verifying the commit)
|
||||
6. Trigger the publish workflow
|
||||
7. Update GitHub release notes
|
||||
8. Provide final summary with Slack announcement
|
||||
|
||||
## Step 1: Find the Changeset PR
|
||||
|
||||
Look for the open changeset PR:
|
||||
|
||||
```bash
|
||||
gh pr list --search "Changeset version bump" --state open --json number,title,headRefName,url
|
||||
```
|
||||
|
||||
If no PR is found, inform the user there's no changeset PR ready. They may need to:
|
||||
- Merge PRs with changesets to main first
|
||||
- Manually trigger the Changeset Converter workflow at: https://github.com/cline/cline/actions/workflows/changeset-converter.yml
|
||||
|
||||
## Step 2: Gather PR Information
|
||||
|
||||
Get the PR details:
|
||||
|
||||
```bash
|
||||
PR_NUMBER=<number from step 1>
|
||||
gh pr view $PR_NUMBER --json body,files,headRefName
|
||||
```
|
||||
|
||||
Checkout the PR branch:
|
||||
|
||||
```bash
|
||||
git fetch origin changeset-release/main
|
||||
git checkout changeset-release/main
|
||||
```
|
||||
|
||||
If the branch has diverged from remote, reset to the remote version:
|
||||
|
||||
```bash
|
||||
git reset --hard origin/changeset-release/main
|
||||
```
|
||||
|
||||
## Step 3: Analyze the Changes
|
||||
|
||||
Read the current CHANGELOG.md to see what the automation generated:
|
||||
|
||||
```bash
|
||||
head -50 CHANGELOG.md
|
||||
```
|
||||
|
||||
Get the version from package.json:
|
||||
|
||||
```bash
|
||||
cat package.json | grep '"version"'
|
||||
```
|
||||
|
||||
**Present to the user:**
|
||||
- The version number that will be released
|
||||
- The raw changelog entries from the changeset PR
|
||||
- Whether this is a patch, minor, or major release
|
||||
|
||||
## Step 4: Clean Up the Changelog
|
||||
|
||||
The changelog needs these fixes:
|
||||
|
||||
1. **Add brackets to version number**: Change `## 3.44.1` to `## [3.44.1]`
|
||||
|
||||
2. **No category headers**: Don't use `### Added`, `### Fixed`, etc. Just a flat list of bullet points.
|
||||
|
||||
3. **Order entries from most important to least important**:
|
||||
- Lead with major new features or significant fixes users care about
|
||||
- End with minor fixes or internal changes
|
||||
|
||||
4. **Write user-friendly descriptions**:
|
||||
- This is for end users, not developers—explain what changed in plain language
|
||||
- Remove commit hashes from the beginning of lines (the automation adds these)
|
||||
- Look at the actual commit diffs (`git show <hash>`) and PRs to understand what changed
|
||||
- Write colorful descriptions that explain the value and impact, not just technical details
|
||||
- Consolidate related changes into single entries when appropriate
|
||||
|
||||
**Ask the user** to review the proposed changelog changes before applying them. Show them:
|
||||
- Current (raw) changelog section
|
||||
- Proposed (cleaned) changelog section
|
||||
|
||||
Once approved, apply the changes to CHANGELOG.md.
|
||||
|
||||
## Step 5: Commit and Push Changes
|
||||
|
||||
After making changelog edits:
|
||||
|
||||
```bash
|
||||
git add CHANGELOG.md
|
||||
git commit -m "Clean up changelog formatting"
|
||||
git push origin changeset-release/main
|
||||
```
|
||||
|
||||
## Step 6: Merge the PR
|
||||
|
||||
**Ask the user to confirm** they're ready to merge.
|
||||
|
||||
Merge the PR with the proper commit message format:
|
||||
|
||||
```bash
|
||||
VERSION=<version from package.json>
|
||||
gh pr merge $PR_NUMBER --squash --subject "v${VERSION} Release Notes" --body ""
|
||||
```
|
||||
|
||||
**If merge is blocked by branch protection:**
|
||||
- Users with admin privileges can add the `--admin` flag to bypass
|
||||
- Users without admin privileges need to get the PR approved through normal review first before merging
|
||||
|
||||
## Step 7: Tag the Release
|
||||
|
||||
After the merge completes, checkout main and pull:
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git pull origin main
|
||||
```
|
||||
|
||||
**IMPORTANT: Verify the latest commit is the release commit before tagging:**
|
||||
|
||||
```bash
|
||||
git log -1 --oneline
|
||||
```
|
||||
|
||||
Confirm the commit message matches `v{VERSION} Release Notes` (e.g., `v3.44.1 Release Notes`). Do NOT blindly tag HEAD without verification.
|
||||
|
||||
Once verified, tag and push:
|
||||
|
||||
```bash
|
||||
VERSION=<version>
|
||||
git tag v${VERSION}
|
||||
git push origin v${VERSION}
|
||||
```
|
||||
|
||||
## Step 8: Trigger Publish Workflow
|
||||
|
||||
**Copy the tag to clipboard** so the user can easily paste it into the GitHub Actions workflow:
|
||||
|
||||
```bash
|
||||
echo -n "v{VERSION}" | pbcopy
|
||||
```
|
||||
|
||||
**Tell the user to trigger the publish workflow:**
|
||||
1. Go to: https://github.com/cline/cline/actions/workflows/publish.yml
|
||||
2. Select **"release"** for release-type
|
||||
3. Paste **`v{VERSION}`** as the tag (already in clipboard)
|
||||
|
||||
**Wait for the user** to confirm the publish workflow has completed before proceeding.
|
||||
|
||||
## Step 9: Update GitHub Release Notes
|
||||
|
||||
Once the user confirms the publish workflow is done, fetch the auto-generated release content:
|
||||
|
||||
```bash
|
||||
VERSION=<version>
|
||||
gh release view v${VERSION} --json body --jq '.body'
|
||||
```
|
||||
|
||||
The auto-generated release has:
|
||||
- `## What's Changed` - PR list (we'll replace this with our changelog)
|
||||
- `## New Contributors` - First-time contributors (keep this if present)
|
||||
- `**Full Changelog**` - Comparison link (keep this)
|
||||
|
||||
Build the new release body:
|
||||
1. Start with `## What's Changed` header
|
||||
2. Add our changelog content (from CHANGELOG.md for this version)
|
||||
3. Keep the `## New Contributors` section if it exists
|
||||
4. Keep the `**Full Changelog**` link
|
||||
|
||||
Update the release:
|
||||
|
||||
```bash
|
||||
gh release edit v${VERSION} --notes "<new body content>"
|
||||
```
|
||||
|
||||
Verify the release was updated:
|
||||
|
||||
```bash
|
||||
gh release view v${VERSION}
|
||||
```
|
||||
|
||||
## Step 10: Final Summary
|
||||
|
||||
**Copy a Slack announcement message to clipboard** (include the full changelog, not just highlights):
|
||||
|
||||
```bash
|
||||
echo "VS Code v{VERSION} Released
|
||||
|
||||
- Changelog entry 1
|
||||
- Changelog entry 2
|
||||
- Changelog entry 3" | pbcopy
|
||||
```
|
||||
|
||||
**Present a final summary:**
|
||||
- Version released: v{VERSION}
|
||||
- PR merged: #{PR_NUMBER}
|
||||
- Tag pushed: v{VERSION}
|
||||
- Release: https://github.com/cline/cline/releases/tag/v{VERSION}
|
||||
- Slack message copied to clipboard
|
||||
|
||||
**Final reminder:**
|
||||
Post the Slack message to announce the release
|
||||
|
||||
## Handling Edge Cases
|
||||
|
||||
### No changesets found
|
||||
If the changeset PR body shows no changes, inform the user they need to merge PRs with changesets first.
|
||||
|
||||
### Merge conflicts
|
||||
If there are conflicts on the changeset branch, help the user resolve them:
|
||||
```bash
|
||||
git fetch origin main
|
||||
git rebase origin/main
|
||||
# resolve conflicts
|
||||
git push origin changeset-release/main --force-with-lease
|
||||
```
|
||||
|
||||
### User wants to add more changes
|
||||
If the user wants to include additional PRs before releasing:
|
||||
1. Ask them to merge those PRs to main first
|
||||
2. The changeset automation will update the PR automatically
|
||||
3. Re-run this workflow after the PR is updated
|
||||
+28
-3
@@ -72,12 +72,12 @@ POSTHOG_TELEMETRY_ENABLED=true # Enable PostHog telemetry (default: tru
|
||||
# Example configurations:
|
||||
#
|
||||
# Console debugging (logs only):
|
||||
# OTEL_TELEMETRY_ENABLED=1
|
||||
# OTEL_TELEMETRY_ENABLED=true
|
||||
# OTEL_LOGS_EXPORTER=console
|
||||
# TEL_DEBUG_DIAGNOSTICS=true
|
||||
#
|
||||
# OTLP with gRPC (insecure, for local testing):
|
||||
# OTEL_TELEMETRY_ENABLED=1
|
||||
# OTEL_TELEMETRY_ENABLED=true
|
||||
# OTEL_LOGS_EXPORTER=otlp
|
||||
# OTEL_EXPORTER_OTLP_PROTOCOL=grpc
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4317
|
||||
@@ -85,12 +85,37 @@ POSTHOG_TELEMETRY_ENABLED=true # Enable PostHog telemetry (default: tru
|
||||
# OTEL_EXPORTER_OTLP_HEADERS=authorization=Bearer your-token
|
||||
#
|
||||
# OTLP with HTTP/JSON (production):
|
||||
# OTEL_TELEMETRY_ENABLED=1
|
||||
# OTEL_TELEMETRY_ENABLED=true
|
||||
# OTEL_LOGS_EXPORTER=otlp
|
||||
# OTEL_EXPORTER_OTLP_PROTOCOL=http/json
|
||||
# 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
|
||||
# ============================================================================
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/docs/
|
||||
/.github/ @saoudrizwan @garoth @sjf
|
||||
/README.md @saoudrizwan @nickbaumann98
|
||||
/src/core/storage/ @celestial-vault
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
name: Claude Issue Triage
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
# Manual trigger for backfilling existing issues. Run from terminal:
|
||||
# gh workflow run claude-issue-triage.yml -f issue_number=1234
|
||||
# Or batch process:
|
||||
# gh issue list --state open --limit 10 --json number --jq '.[].number' | while read num; do
|
||||
# gh workflow run claude-issue-triage.yml -f issue_number=$num
|
||||
# sleep 60
|
||||
# done
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
issue_number:
|
||||
description: 'Issue number to triage'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
claude-issue-triage:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
# SECURITY: These permissions are intentionally restrictive.
|
||||
# - contents: read -> Claude can read the codebase but CANNOT write/push any code
|
||||
# - issues: write -> Claude can comment and add labels (the only write access needed)
|
||||
# - pull-requests: read -> Claude can view PR context but CANNOT create PRs
|
||||
# This ensures that even if a malicious user attempts prompt injection via issue content,
|
||||
# Claude cannot modify repository code, create branches, or open PRs.
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Run Issue Response & Triage
|
||||
id: triage
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
allowed_non_write_users: "*"
|
||||
# Allow all tools - security is enforced by GitHub permissions above (contents: read, issues: write)
|
||||
claude_args: --model claude-opus-4-5-20251101 --allowedTools "Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch"
|
||||
prompt: |
|
||||
You're a GitHub issue first responder for the open source Cline repository.
|
||||
|
||||
**Issue:** #${{ github.event.issue.number || inputs.issue_number }}
|
||||
**Title:** ${{ github.event.issue.title || 'See issue details below' }}
|
||||
**Author:** @${{ github.event.issue.user.login || 'See issue details below' }}
|
||||
|
||||
## Your job
|
||||
|
||||
Investigate this issue thoroughly, then post a single helpful comment that helps the user and gives maintainers the context they need.
|
||||
|
||||
## Investigation
|
||||
|
||||
Start by reading the full issue:
|
||||
gh issue view ${{ github.event.issue.number || inputs.issue_number }}
|
||||
|
||||
### Search for duplicates and related issues
|
||||
|
||||
Search thoroughly for existing issues that match this one:
|
||||
gh issue list --search "<keywords from the issue>" --state all --limit 30
|
||||
gh issue list --search "<error messages>" --state all --limit 20
|
||||
gh issue list --search "<affected feature/component>" --state all --limit 20
|
||||
|
||||
For each relevant issue you find, read it including its comments:
|
||||
gh issue view <number> --comments
|
||||
|
||||
You're looking for:
|
||||
- **Duplicates**: Issues describing the same problem. Link to them and explain why you think they're duplicates. If closed, check how they were resolved - the solution might apply here.
|
||||
- **Related issues**: Similar problems or context that could help. Pull useful information from their comments (workarounds others found, debugging steps that helped, maintainer explanations). Link to them and explain the connection.
|
||||
|
||||
If there are closed issues with solutions, surface those solutions prominently - this might immediately solve the user's problem.
|
||||
|
||||
### Analyze recent changes (ALWAYS DO THIS)
|
||||
|
||||
Many issues are regressions from recent releases. **Always** check what changed recently:
|
||||
gh release list --limit 10
|
||||
gh pr list --state merged --limit 50 --json number,title,mergedAt,author,body
|
||||
|
||||
Look for PRs merged in the last few weeks that might correlate with the issue. If you find a likely connection:
|
||||
gh pr view <number>
|
||||
gh pr diff <number>
|
||||
git log --since="1 month ago" --oneline -- <relevant paths>
|
||||
git show <commit>
|
||||
|
||||
**Always include your findings in your comment:**
|
||||
- If you find a regression, call it out explicitly: which PR/commit likely caused it, who authored it, what changed, and suggest a fix direction if you can see one.
|
||||
- If you don't find anything related, still mention it: "I analyzed recent PRs and releases but didn't find any changes that seem related to this issue."
|
||||
|
||||
### Search the codebase
|
||||
|
||||
Find the relevant code:
|
||||
- Use grep/find to locate code related to the issue
|
||||
- Key areas: `src/api/` (providers/models), `src/core/prompts/` (tools/prompts), platform-specific code for VS Code vs JetBrains
|
||||
|
||||
### Find documentation
|
||||
|
||||
Cline docs are at **https://docs.cline.bot/** and built with Mintlify from the `docs/` directory.
|
||||
|
||||
The URL structure maps directly to the file structure:
|
||||
- `docs/getting-started/selecting-your-model.mdx` → https://docs.cline.bot/getting-started/selecting-your-model
|
||||
- `docs/troubleshooting.mdx` → https://docs.cline.bot/troubleshooting
|
||||
- Headings become anchors: `## Which Model` → `#which-model`
|
||||
|
||||
Search the `docs/` directory to find relevant documentation, then construct URLs to link users to:
|
||||
```bash
|
||||
ls docs/
|
||||
grep -r "keyword" docs/ --include="*.mdx" -l
|
||||
```
|
||||
|
||||
### Identify subject matter experts
|
||||
|
||||
For issues that clearly need engineering attention:
|
||||
git log --since="6 months ago" --format="%an" -- <relevant paths> | sort | uniq -c | sort -rn | head -5
|
||||
|
||||
Cross-reference with GitHub usernames. Include in your response (@mention, do NOT assign):
|
||||
|
||||
| SME | Reason |
|
||||
|-----|--------|
|
||||
| @username1 | Authored PR #X which modified this area |
|
||||
| @username2 | Primary contributor to affected file |
|
||||
|
||||
## Weak model detection
|
||||
|
||||
Many issues are caused by users running small or non-frontier models that don't tool-call reliably. Signs include:
|
||||
- Model failing to use tools correctly
|
||||
- Nonsensical or malformed responses
|
||||
- User is running a small/local model or older model version
|
||||
|
||||
If this looks like a weak model issue, kindly suggest they try reproducing with Claude Sonnet and report back if it persists. Link to https://docs.cline.bot/getting-started/selecting-your-model if helpful. Still label and triage normally.
|
||||
|
||||
## Your comment
|
||||
|
||||
Write a single comment as a helpful community member. Be conversational, not robotic. Include what's relevant:
|
||||
|
||||
- **Helpful response** - Answer their question, suggest a fix, provide a workaround. If you found solutions in related closed issues, surface those prominently.
|
||||
- **Duplicates and related issues** - Link to any you found and explain why they're duplicates/related. Summarize useful context from their comments.
|
||||
- **Regression analysis** - If this looks like a regression, explain what change likely caused it, link to the PR/commit, and tag the author.
|
||||
- **Clarifying questions** - If you need more info, ask specific questions. Don't ask for things already provided.
|
||||
- **SME table** - Include the table above if this needs engineering attention. Don't tag people for questions with obvious answers or weak-model issues.
|
||||
- **Context for maintainers** - Relevant code paths, what you found. Keep it concise.
|
||||
- **Docs links** - If there's relevant documentation, link to it naturally in your response as a recommendation (e.g., "For more details, check out [the Ollama setup guide](url)"). Do NOT add a "Sources" section at the end - integrate doc links into your response where they're helpful.
|
||||
- **Possible Duplicates section** - ALWAYS include a "Possible Duplicates" section at the end of your comment listing issues that might be duplicates so maintainers can quickly close if appropriate. If none found, say "No obvious duplicates found."
|
||||
|
||||
## Labels
|
||||
First, retrieve all available labels and read their descriptions to understand what each is for:
|
||||
gh label list --json name,description --limit 100
|
||||
|
||||
Then apply the appropriate labels based on your analysis. Only use labels from the list above—do not create new labels.
|
||||
gh issue edit ${{ github.event.issue.number || inputs.issue_number }} --add-label "label1,label2"
|
||||
|
||||
If your regression analysis found a likely culprit (a recent PR/commit that probably caused this issue), add the "Regression" label:
|
||||
gh issue edit ${{ github.event.issue.number || inputs.issue_number }} --add-label "Regression"
|
||||
|
||||
IMPORTANT: After posting your comment, add the "Bot Responded" label to indicate this issue has received an automated response:
|
||||
gh issue edit ${{ github.event.issue.number || inputs.issue_number }} --add-label "Bot Responded"
|
||||
|
||||
## Remember
|
||||
|
||||
- **This is a one-time automated response** - you will NOT see their reply or respond again. Never say things like "I can help you", "let me know", "once I have that info", or "I can give you more targeted help" - you won't be there to follow up. If you ask clarifying questions, frame them for the maintainers who will follow up, e.g., "If you can share X, that would help the maintainers diagnose this."
|
||||
- Don't be formulaic. Respond to what the issue actually needs.
|
||||
- Surface solutions from past issues - often the fastest path to helping.
|
||||
- Connecting regressions to specific changes is extremely valuable.
|
||||
- Link issues with #number so they're clickable.
|
||||
@@ -0,0 +1,272 @@
|
||||
name: Claude PR Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, ready_for_review]
|
||||
# Manual trigger for backfilling existing PRs. Run from terminal:
|
||||
# gh workflow run claude-pr-review.yml -f pr_number=1234
|
||||
# Or batch process open PRs:
|
||||
# gh pr list --state open --limit 10 --json number --jq '.[].number' | while read num; do
|
||||
# gh workflow run claude-pr-review.yml -f pr_number=$num
|
||||
# sleep 60
|
||||
# done
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'PR number to review'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
claude-pr-review:
|
||||
# Runs on PR opened/ready_for_review (skips drafts) or manual trigger for backfilling
|
||||
if: |
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.draft == false) ||
|
||||
github.event_name == 'workflow_dispatch'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
|
||||
# SECURITY: These permissions are intentionally restrictive.
|
||||
# - contents: read -> Claude can read the codebase but CANNOT write/push any code
|
||||
# - pull-requests: write -> Claude can post reviews and inline suggestions
|
||||
# - issues: read -> Claude can search for related issues
|
||||
# NOTE: Even with pull-requests: write, Claude CANNOT merge PRs because branch protection
|
||||
# requires 1 approval from a Code Owner. The GITHUB_TOKEN cannot bypass this.
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get PR number
|
||||
id: pr
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
|
||||
echo "number=${{ inputs.pr_number }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "number=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Run PR Review
|
||||
id: review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
allowed_non_write_users: "*"
|
||||
claude_args: --model claude-opus-4-5-20251101 --allowedTools "Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch"
|
||||
prompt: |
|
||||
You're a GitHub PR reviewer for the open source Cline repository. Your goal is to give the PR author helpful feedback and give maintainers the context they need to review efficiently.
|
||||
|
||||
PR: #${{ steps.pr.outputs.number }}
|
||||
|
||||
## Gather context
|
||||
|
||||
```bash
|
||||
# Get full PR details
|
||||
gh pr view ${{ steps.pr.outputs.number }} --json number,title,body,author,createdAt,updatedAt,isDraft,labels,commits,files,additions,deletions,changedFiles,baseRefName,headRefName,mergeable,reviewDecision
|
||||
|
||||
# Get the diff
|
||||
gh pr diff ${{ steps.pr.outputs.number }}
|
||||
|
||||
# Check CI status
|
||||
gh pr checks ${{ steps.pr.outputs.number }}
|
||||
|
||||
# Get existing review comments (to understand context and your previous feedback)
|
||||
gh api repos/${{ github.repository }}/pulls/${{ steps.pr.outputs.number }}/comments --jq '.[] | {user: .user.login, body: .body, path: .path, created_at: .created_at}'
|
||||
|
||||
# Get conversation comments
|
||||
gh pr view ${{ steps.pr.outputs.number }} --comments
|
||||
```
|
||||
|
||||
If this is a re-review (workflow_dispatch event):
|
||||
Read your previous comments carefully. Understand what you asked for before.
|
||||
Check if new commits or comments address your previous feedback.
|
||||
|
||||
## Check contributing guidelines
|
||||
|
||||
Flag (but don't block) if:
|
||||
- Missing changeset - For user-facing changes, check if there's a `.changeset/` file:
|
||||
```bash
|
||||
gh pr diff ${{ steps.pr.outputs.number }} --name-only | grep '.changeset/' || echo "No changeset found"
|
||||
```
|
||||
If missing, ask them to run `npm run changeset`
|
||||
- Missing tests - New features should have tests
|
||||
|
||||
## Find related issues and PRs
|
||||
|
||||
Search thoroughly for context that might help with the review:
|
||||
|
||||
```bash
|
||||
# Find related issues for context
|
||||
gh issue list --search "<keywords from the PR>" --state all --limit 30
|
||||
gh issue list --search "<error messages or feature names>" --state all --limit 20
|
||||
|
||||
# Find similar PRs for reference
|
||||
gh pr list --search "<keywords>" --state all --limit 30
|
||||
```
|
||||
|
||||
For each relevant issue or PR you find, read it including comments:
|
||||
```bash
|
||||
gh issue view <number> --comments
|
||||
gh pr view <number> --comments
|
||||
```
|
||||
|
||||
Look for:
|
||||
- Open issues this PR might fix that weren't linked in the description
|
||||
- Similar PRs that went through review - what feedback did they get? What patterns did they follow?
|
||||
- Context from maintainer discussions that could inform your review
|
||||
|
||||
## Find subject matter experts
|
||||
|
||||
For files changed in this PR, find who knows the code best:
|
||||
```bash
|
||||
# Get files changed
|
||||
gh pr diff ${{ steps.pr.outputs.number }} --name-only
|
||||
|
||||
# For each relevant path, find contributors
|
||||
git log --since="6 months ago" --format="%an" -- <path> | sort | uniq -c | sort -rn | head -5
|
||||
```
|
||||
|
||||
Cross-reference git authors with GitHub usernames. Include an SME table in your response:
|
||||
|
||||
| SME | Reason |
|
||||
|-----|--------|
|
||||
| @username1 | Authored PR #X which modified this area |
|
||||
| @username2 | Primary contributor to affected file (15 commits in 6 months) |
|
||||
| @username3 | Reviewed similar PR #Y with extensive feedback |
|
||||
|
||||
## Deep code review
|
||||
|
||||
This is the most important part. Don't just look for syntax issues - understand what the PR is trying to achieve and whether the implementation is the right approach.
|
||||
|
||||
Step 1: Understand the intent
|
||||
Read the PR description and understand what the author is trying to accomplish. What problem are they solving? What feature are they adding?
|
||||
|
||||
Step 2: Form your own opinion first
|
||||
Before analyzing their code, think about how YOU would implement this feature or fix. What files would you touch? What patterns would you follow? What edge cases would you handle?
|
||||
|
||||
Step 3: Compare approaches
|
||||
Now look at their implementation. How does it compare to what you would have done?
|
||||
- Is their approach better in some ways? Note what they did well.
|
||||
- Is their approach missing something? Be specific about what and why.
|
||||
- Are there edge cases they haven't considered?
|
||||
- Does it follow the patterns established in similar parts of the codebase?
|
||||
|
||||
Step 4: Look at the bigger picture
|
||||
- What other files or systems does this change interact with?
|
||||
- Could this break anything else?
|
||||
- Is there additional work needed beyond this PR to complete the feature?
|
||||
- Does this fit well with the overall architecture?
|
||||
|
||||
Step 5: Find reference implementations
|
||||
Look for similar changes in the codebase:
|
||||
```bash
|
||||
git log --oneline --all --grep="<relevant keywords>" | head -20
|
||||
git log --oneline -- <similar files> | head -20
|
||||
```
|
||||
|
||||
If this is adding a new API provider, look at how other providers are implemented.
|
||||
If this is adding a new feature, look at how similar features were added.
|
||||
Note where their implementation aligns with or diverges from established patterns.
|
||||
|
||||
Step 6: Standard code review checks
|
||||
- DRY: Is there duplicated code that could be extracted?
|
||||
- Error handling: Are errors handled appropriately?
|
||||
- Security: Any injection risks, credential exposure, unsafe dependencies?
|
||||
- Performance: Any obvious inefficiencies, memory leaks, N+1 patterns?
|
||||
- Types: Is TypeScript used correctly? Any unsafe type assertions?
|
||||
- Naming: Are variables and functions named clearly?
|
||||
- Comments: Is complex logic explained? Are there outdated comments?
|
||||
|
||||
## Inline code suggestions
|
||||
|
||||
For specific code improvements, use GitHub's suggestion syntax via `gh api`.
|
||||
This creates suggestions the author can commit with one click.
|
||||
|
||||
Single-line suggestion:
|
||||
```bash
|
||||
gh api repos/${{ github.repository }}/pulls/${{ steps.pr.outputs.number }}/reviews \
|
||||
-X POST \
|
||||
-f commit_id="$(gh pr view ${{ steps.pr.outputs.number }} --json headRefOid -q .headRefOid)" \
|
||||
-f event="COMMENT" \
|
||||
-f body="" \
|
||||
-F comments='[
|
||||
{
|
||||
"path": "src/example.ts",
|
||||
"line": 42,
|
||||
"body": "Consider simplifying:\n\n```suggestion\nconst result = items.filter(Boolean);\n```"
|
||||
}
|
||||
]'
|
||||
```
|
||||
|
||||
Multi-line suggestion (replacing lines 40-45):
|
||||
```bash
|
||||
gh api repos/${{ github.repository }}/pulls/${{ steps.pr.outputs.number }}/reviews \
|
||||
-X POST \
|
||||
-f commit_id="$(gh pr view ${{ steps.pr.outputs.number }} --json headRefOid -q .headRefOid)" \
|
||||
-f event="COMMENT" \
|
||||
-f body="" \
|
||||
-F comments='[
|
||||
{
|
||||
"path": "src/example.ts",
|
||||
"start_line": 40,
|
||||
"line": 45,
|
||||
"body": "This can be simplified:\n\n```suggestion\nconst simplified = doThing();\n```"
|
||||
}
|
||||
]'
|
||||
```
|
||||
|
||||
Use inline suggestions for concrete improvements. Use regular comments for questions or broader feedback.
|
||||
|
||||
## Post your review
|
||||
|
||||
After your investigation, post a single helpful comment that helps the author and gives maintainers context.
|
||||
|
||||
Start with a warm thank you for their contribution. Be conversational, not robotic.
|
||||
|
||||
Include what's relevant:
|
||||
- In-depth explanation of what the PR does - Be comprehensive. A maintainer should be able to read this section and fully understand the author's intent, why they made the changes, how they implemented it, and what files/systems are affected. Don't just summarize - explain.
|
||||
- Related issues/PRs you found that provide useful context (link to them)
|
||||
- Your review findings (issues to address, suggestions, etc.)
|
||||
- Clear next steps for the author
|
||||
|
||||
Include a "For Maintainers" section with:
|
||||
- Anything else useful to help the maintainer resolve this PR
|
||||
- Related issues/PRs with context on why they're relevant
|
||||
- Open issues this PR might fix that weren't linked in the description
|
||||
- Your recommendation: merge as-is, needs changes, needs discussion, close, etc.
|
||||
- SME table - who should review this and why
|
||||
|
||||
For the SME table:
|
||||
| SME | Reason |
|
||||
|-----|--------|
|
||||
| @username | Primary contributor to affected files |
|
||||
|
||||
## Update labels
|
||||
|
||||
Add appropriate labels based on your analysis:
|
||||
```bash
|
||||
gh label list --json name,description --limit 100
|
||||
gh pr edit ${{ steps.pr.outputs.number }} --add-label "label1,label2"
|
||||
```
|
||||
|
||||
When done, add the reviewed label:
|
||||
```bash
|
||||
gh pr edit ${{ steps.pr.outputs.number }} --add-label "Bot Reviewed"
|
||||
```
|
||||
|
||||
## Remember
|
||||
|
||||
- This is a one-time automated response - you will NOT see their reply or respond again. Never say things like "let me know if you have questions", "I can help you with", or "feel free to ask" - you won't be there to follow up. Frame any questions for the maintainers who will follow up.
|
||||
- Be helpful and welcoming - Many contributors are new to the project
|
||||
- Be specific - Point to exact lines and suggest fixes, don't give vague feedback
|
||||
- Think deeply - Don't just surface-level review, understand the intent and evaluate the approach
|
||||
- Use inline suggestions - Make it easy for authors to accept changes
|
||||
- You're a first-pass reviewer - A human maintainer will do final approval
|
||||
@@ -0,0 +1,312 @@
|
||||
name: Cline PR Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
[opened, ready_for_review]
|
||||
# Manual trigger for backfilling existing PRs. Run from terminal:
|
||||
# gh workflow run cline-pr-review.yml -f pr_number=1234
|
||||
# Or batch process open PRs:
|
||||
# gh pr list --state open --limit 10 --json number --jq '.[].number' | while read num; do
|
||||
# gh workflow run cline-pr-review.yml -f pr_number=$num
|
||||
# sleep 60
|
||||
# done
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: "PR number to review"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: pr-review-${{ github.event.pull_request.number || inputs.pr_number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
cline-pr-review:
|
||||
# Runs on PR opened/ready_for_review (skips drafts) or manual trigger for backfilling
|
||||
if: |
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.draft == false) ||
|
||||
github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
# SECURITY: These permissions are intentionally restrictive.
|
||||
# - contents: read -> cline can read the codebase but CANNOT write/push any code
|
||||
# - pull-requests: write -> cline can post reviews and inline suggestions
|
||||
# - issues: read -> cline can search for related issues
|
||||
# NOTE: Even with pull-requests: write, cline CANNOT merge PRs because branch protection
|
||||
# requires 1 approval from a Code Owner. The GITHUB_TOKEN cannot bypass this.
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
|
||||
- name: Install and Verify Cline CLI
|
||||
run: |
|
||||
npx cline version # verify installation
|
||||
|
||||
- name: Configure Cline with Anthropic
|
||||
run: |
|
||||
npx cline auth --provider anthropic \
|
||||
--apikey "${{ secrets.ANTHROPIC_API_KEY }}" \
|
||||
--modelid claude-opus-4-5-20251101
|
||||
|
||||
- name: Get PR number
|
||||
id: pr
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
|
||||
echo "number=${{ inputs.pr_number }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "number=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Review PR with Cline
|
||||
env:
|
||||
PR_NUMBER: ${{ steps.pr.outputs.number }}
|
||||
GITHUB_REPO: ${{ github.repository }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CLINE_COMMAND_PERMISSIONS: |
|
||||
{
|
||||
"allow": [
|
||||
"gh pr diff *",
|
||||
"gh pr view *",
|
||||
"gh pr checks *",
|
||||
"gh pr list *",
|
||||
"gh label list *",
|
||||
"gh issue list *",
|
||||
"gh issue view *",
|
||||
"git log *",
|
||||
"gh pr comment ${{ steps.pr.outputs.number }} *",
|
||||
"gh pr edit ${{ steps.pr.outputs.number }} *",
|
||||
"gh api repos/${{ github.repository }}/pulls/${{ steps.pr.outputs.number }}/comments *",
|
||||
"gh api repos/${{ github.repository }}/pulls/${{ steps.pr.outputs.number }}/reviews *"
|
||||
]
|
||||
}
|
||||
run: |
|
||||
npx cline --yolo 'You'\''re a GitHub PR reviewer for the open source Cline repository. Your goal is to give the PR author helpful feedback and give maintainers the context they need to review efficiently.
|
||||
|
||||
PR: #'"${PR_NUMBER}"'
|
||||
|
||||
## Gather context
|
||||
|
||||
```bash
|
||||
# Get full PR details
|
||||
gh pr view '"${PR_NUMBER}"' --json number,title,body,author,createdAt,updatedAt,isDraft,labels,commits,files,additions,deletions,changedFiles,baseRefName,headRefName,mergeable,reviewDecision
|
||||
|
||||
# Get the diff
|
||||
gh pr diff '"${PR_NUMBER}"'
|
||||
|
||||
# Check CI status
|
||||
gh pr checks '"${PR_NUMBER}"'
|
||||
|
||||
# Get existing review comments (to understand context and your previous feedback)
|
||||
gh api repos/'"${GITHUB_REPO}"'/pulls/'"${PR_NUMBER}"'/comments --jq '\''.[] | {user: .user.login, body: .body, path: .path, created_at: .created_at}'\''
|
||||
|
||||
# Get conversation comments
|
||||
gh pr view '"${PR_NUMBER}"' --comments
|
||||
```
|
||||
|
||||
If this is a re-review (workflow_dispatch event):
|
||||
Read your previous comments carefully. Understand what you asked for before.
|
||||
Check if new commits or comments address your previous feedback.
|
||||
|
||||
## Check contributing guidelines
|
||||
|
||||
Flag (but don'\''t block) if:
|
||||
- Missing changeset - For user-facing changes, check if there'\''s a `.changeset/` file:
|
||||
```bash
|
||||
gh pr diff '"${PR_NUMBER}"' --name-only | grep '\''.changeset/'\'' || echo '\''No changeset found'\''
|
||||
```
|
||||
If missing, ask them to run `npm run changeset`
|
||||
- Missing tests - New features should have tests
|
||||
|
||||
## Find related issues and PRs
|
||||
|
||||
Search thoroughly for context that might help with the review:
|
||||
|
||||
```bash
|
||||
# Find related issues for context
|
||||
gh issue list --search '\''<keywords from the PR>'\'' --state all --limit 30
|
||||
gh issue list --search '\''<error messages or feature names>'\'' --state all --limit 20
|
||||
|
||||
# Find similar PRs for reference
|
||||
gh pr list --search '\''<keywords>'\'' --state all --limit 30
|
||||
```
|
||||
|
||||
For each relevant issue or PR you find, read it including comments:
|
||||
```bash
|
||||
gh issue view <number> --comments
|
||||
gh pr view <number> --comments
|
||||
```
|
||||
|
||||
Look for:
|
||||
- Open issues this PR might fix that weren'\''t linked in the description
|
||||
- Similar PRs that went through review - what feedback did they get? What patterns did they follow?
|
||||
- Context from maintainer discussions that could inform your review
|
||||
|
||||
## Find subject matter experts
|
||||
|
||||
For files changed in this PR, find who knows the code best:
|
||||
```bash
|
||||
# Get files changed
|
||||
gh pr diff '"${PR_NUMBER}"' --name-only
|
||||
|
||||
# For each relevant path, find contributors
|
||||
git log --since='\''6 months ago'\'' --format='\''%an'\'' -- <path> | sort | uniq -c | sort -rn | head -5
|
||||
```
|
||||
|
||||
Cross-reference git authors with GitHub usernames. Include an SME table in your response:
|
||||
|
||||
| SME | Reason |
|
||||
|-----|--------|
|
||||
| @username1 | Authored PR #X which modified this area |
|
||||
| @username2 | Primary contributor to affected file (15 commits in 6 months) |
|
||||
| @username3 | Reviewed similar PR #Y with extensive feedback |
|
||||
|
||||
## Bash command usage
|
||||
|
||||
Don'\''t use operators like `|`, `&&`, or `;` - run each command separately and analyze the output.
|
||||
|
||||
When referencing command outputs, quote them properly to avoid formatting issues.
|
||||
|
||||
## Deep code review
|
||||
|
||||
This is the most important part. Don'\''t just look for syntax issues - understand what the PR is trying to achieve and whether the implementation is the right approach.
|
||||
|
||||
Step 1: Understand the intent
|
||||
Read the PR description and understand what the author is trying to accomplish. What problem are they solving? What feature are they adding?
|
||||
|
||||
Step 2: Form your own opinion first
|
||||
Before analyzing their code, think about how YOU would implement this feature or fix. What files would you touch? What patterns would you follow? What edge cases would you handle?
|
||||
|
||||
Step 3: Compare approaches
|
||||
Now look at their implementation. How does it compare to what you would have done?
|
||||
- Is their approach better in some ways? Note what they did well.
|
||||
- Is their approach missing something? Be specific about what and why.
|
||||
- Are there edge cases they haven'\''t considered?
|
||||
- Does it follow the patterns established in similar parts of the codebase?
|
||||
|
||||
Step 4: Look at the bigger picture
|
||||
- What other files or systems does this change interact with?
|
||||
- Could this break anything else?
|
||||
- Is there additional work needed beyond this PR to complete the feature?
|
||||
- Does this fit well with the overall architecture?
|
||||
|
||||
Step 5: Find reference implementations
|
||||
Look for similar changes in the codebase:
|
||||
```bash
|
||||
git log --oneline --all --grep='\''<relevant keywords>'\'' | head -20
|
||||
git log --oneline -- <similar files> | head -20
|
||||
```
|
||||
|
||||
If this is adding a new API provider, look at how other providers are implemented.
|
||||
If this is adding a new feature, look at how similar features were added.
|
||||
Note where their implementation aligns with or diverges from established patterns.
|
||||
|
||||
Step 6: Standard code review checks
|
||||
- DRY: Is there duplicated code that could be extracted?
|
||||
- Error handling: Are errors handled appropriately?
|
||||
- Security: Any injection risks, credential exposure, unsafe dependencies?
|
||||
- Performance: Any obvious inefficiencies, memory leaks, N+1 patterns?
|
||||
- Types: Is TypeScript used correctly? Any unsafe type assertions?
|
||||
- Naming: Are variables and functions named clearly?
|
||||
- Comments: Is complex logic explained? Are there outdated comments?
|
||||
|
||||
## Inline code suggestions
|
||||
|
||||
For specific code improvements, use GitHub'\''s suggestion syntax via `gh api`.
|
||||
This creates suggestions the author can commit with one click.
|
||||
|
||||
Single-line suggestion:
|
||||
```bash
|
||||
gh api repos/'"${GITHUB_REPO}"'/pulls/'"${PR_NUMBER}"'/reviews \
|
||||
-X POST \
|
||||
-f commit_id="$(gh pr view '"${PR_NUMBER}"' --json headRefOid -q .headRefOid)" \
|
||||
-f event='\''COMMENT'\'' \
|
||||
-f body='\'''\'' \
|
||||
-F comments='\''[
|
||||
{
|
||||
"path": "src/example.ts",
|
||||
"line": 42,
|
||||
"body": "Consider simplifying:\n\n```suggestion\nconst result = items.filter(Boolean);\n```"
|
||||
}
|
||||
]'\''
|
||||
```
|
||||
|
||||
Multi-line suggestion (replacing lines 40-45):
|
||||
```bash
|
||||
gh api repos/'"${GITHUB_REPO}"'/pulls/'"${PR_NUMBER}"'/reviews \
|
||||
-X POST \
|
||||
-f commit_id="$(gh pr view '"${PR_NUMBER}"' --json headRefOid -q .headRefOid)" \
|
||||
-f event='\''COMMENT'\'' \
|
||||
-f body='\'''\'' \
|
||||
-F comments='\''[
|
||||
{
|
||||
"path": "src/example.ts",
|
||||
"start_line": 40,
|
||||
"line": 45,
|
||||
"body": "This can be simplified:\n\n```suggestion\nconst simplified = doThing();\n```"
|
||||
}
|
||||
]'\''
|
||||
```
|
||||
|
||||
Use inline suggestions for concrete improvements. Use regular comments for questions or broader feedback.
|
||||
|
||||
## Post your review
|
||||
|
||||
After your investigation, post a single helpful comment that helps the author and gives maintainers context.
|
||||
|
||||
Start with a warm thank you for their contribution. Be conversational, not robotic.
|
||||
|
||||
Include what'\''s relevant:
|
||||
- In-depth explanation of what the PR does - Be comprehensive. A maintainer should be able to read this section and fully understand the author'\''s intent, why they made the changes, how they implemented it, and what files/systems are affected. Don'\''t just summarize - explain.
|
||||
- Related issues/PRs you found that provide useful context (link to them)
|
||||
- Your review findings (issues to address, suggestions, etc.)
|
||||
- Clear next steps for the author
|
||||
|
||||
Include a '\''For Maintainers'\'' section with:
|
||||
- Anything else useful to help the maintainer resolve this PR
|
||||
- Related issues/PRs with context on why they'\''re relevant
|
||||
- Open issues this PR might fix that weren'\''t linked in the description
|
||||
- Your recommendation: merge as-is, needs changes, needs discussion, close, etc.
|
||||
- SME table - who should review this and why
|
||||
|
||||
For the SME table:
|
||||
| SME | Reason |
|
||||
|-----|--------|
|
||||
| @username | Primary contributor to affected files |
|
||||
|
||||
## Update labels
|
||||
|
||||
Add appropriate labels based on your analysis:
|
||||
```bash
|
||||
gh label list --json name,description --limit 100
|
||||
gh pr edit '"${PR_NUMBER}"' --add-label '\''label1,label2'\''
|
||||
```
|
||||
|
||||
When done, add the reviewed label:
|
||||
```bash
|
||||
gh pr edit '"${PR_NUMBER}"' --add-label '\''Bot Reviewed'\''
|
||||
```
|
||||
|
||||
## Remember
|
||||
|
||||
- This is a one-time automated response - you will NOT see their reply or respond again. Never say things like '\''let me know if you have questions'\'', '\''I can help you with'\'', or '\''feel free to ask'\'' - you won'\''t be there to follow up. Frame any questions for the maintainers who will follow up.
|
||||
- Be helpful and welcoming - Many contributors are new to the project
|
||||
- Be specific - Point to exact lines and suggest fixes, don'\''t give vague feedback
|
||||
- Think deeply - Don'\''t just surface-level review, understand the intent and evaluate the approach
|
||||
- Use inline suggestions - Make it easy for authors to accept changes
|
||||
- You'\''re a first-pass reviewer - A human maintainer will do final approval'
|
||||
@@ -0,0 +1,130 @@
|
||||
name: Publish NPM Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
confirm_publish:
|
||||
description: 'Type "publish" to confirm you want to publish to NPM'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
checks: write # Required by test workflow
|
||||
pull-requests: write # Required by test workflow
|
||||
|
||||
jobs:
|
||||
test:
|
||||
uses: ./.github/workflows/test.yml
|
||||
|
||||
publish-npm-release:
|
||||
needs: test
|
||||
name: Publish Cline CLI to NPM
|
||||
if: github.repository == 'cline/cline' && github.ref == 'refs/heads/main' && github.event.inputs.confirm_publish == 'publish'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24'
|
||||
cache-dependency-path: cli/go.sum
|
||||
|
||||
# Cache root dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache root dependencies
|
||||
uses: actions/cache@v4
|
||||
id: root-cache
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache webview-ui dependencies
|
||||
uses: actions/cache@v4
|
||||
id: webview-cache
|
||||
with:
|
||||
path: webview-ui/node_modules
|
||||
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
|
||||
|
||||
- name: Install root dependencies
|
||||
if: steps.root-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci --include=optional
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
if: steps.webview-cache.outputs.cache-hit != 'true'
|
||||
run: cd webview-ui && npm ci --include=optional
|
||||
|
||||
- name: Read release version
|
||||
id: version
|
||||
run: |
|
||||
# Read version from cli/package.json (stable version)
|
||||
VERSION=$(node -p "require('./cli/package.json').version")
|
||||
echo "Release version: $VERSION"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download ripgrep binaries
|
||||
run: npm run download-ripgrep
|
||||
|
||||
- name: Clean previous builds
|
||||
run: rm -rf dist-standalone
|
||||
|
||||
- name: Generate Protos (First Pass)
|
||||
run: npm run protos && npm run protos-go
|
||||
|
||||
- name: Compile CLI
|
||||
run: npm run compile-cli
|
||||
|
||||
- name: Compile CLI for all platforms
|
||||
run: npm run compile-cli-all-platforms
|
||||
|
||||
- name: Build standalone NPM package
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
CLINE_ENVIRONMENT: production
|
||||
OTEL_TELEMETRY_ENABLED: "1"
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
POSTHOG_TELEMETRY_ENABLED: "true"
|
||||
run: npm run compile-standalone-npm
|
||||
|
||||
- name: Generate Protos (Second Pass - Bug Workaround)
|
||||
run: npm run protos && npm run protos-go
|
||||
|
||||
- name: Verify build output
|
||||
run: |
|
||||
echo "Checking dist-standalone directory..."
|
||||
ls -la dist-standalone/
|
||||
|
||||
echo "Verifying CLI binaries..."
|
||||
ls -lh cli/bin/cline-* || echo "Warning: CLI binaries not found"
|
||||
|
||||
echo "Checking package.json in dist-standalone..."
|
||||
cat dist-standalone/package.json | grep version
|
||||
|
||||
- name: Publish to NPM with latest tag
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_RELEASE_TOKEN }}
|
||||
run: |
|
||||
echo "Publishing version ${{ steps.version.outputs.version }} to NPM with tag 'latest'..."
|
||||
cd dist-standalone
|
||||
npm publish --tag latest --access public
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "✅ Successfully published cline@${{ steps.version.outputs.version }} to NPM with tag 'latest'"
|
||||
echo ""
|
||||
echo "📦 Install with: npm install -g cline"
|
||||
echo "🔗 NPM: https://www.npmjs.com/package/cline/v/${{ steps.version.outputs.version }}"
|
||||
@@ -0,0 +1,175 @@
|
||||
name: Publish NPM Nightly
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 12 * * *' # 4 AM PST (UTC-8) = 12 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
checks: write # Required by test workflow
|
||||
pull-requests: write # Required by test workflow
|
||||
|
||||
jobs:
|
||||
test:
|
||||
uses: ./.github/workflows/test.yml
|
||||
|
||||
publish-npm-nightly:
|
||||
needs: test
|
||||
name: Publish Cline CLI (Nightly) to NPM
|
||||
if: github.repository == 'cline/cline' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check for recent commits
|
||||
id: check_commits
|
||||
run: |
|
||||
if [ $(git rev-list --count HEAD --since="24 hours ago") -eq 0 ]; then
|
||||
echo "No commits in last 24 hours, skipping publish"
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "Found recent commits, proceeding with publish"
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Setup Go
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24'
|
||||
cache-dependency-path: cli/go.sum
|
||||
|
||||
# Cache root dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache root dependencies
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
uses: actions/cache@v4
|
||||
id: root-cache
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache webview-ui dependencies
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
uses: actions/cache@v4
|
||||
id: webview-cache
|
||||
with:
|
||||
path: webview-ui/node_modules
|
||||
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
|
||||
|
||||
- name: Install root dependencies
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.root-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci --include=optional
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.webview-cache.outputs.cache-hit != 'true'
|
||||
run: cd webview-ui && npm ci --include=optional
|
||||
|
||||
- name: Generate nightly version with timestamp
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
id: version
|
||||
run: |
|
||||
# Read base version from cli/package.json (e.g., "1.0.9")
|
||||
BASE_VERSION=$(node -p "require('./cli/package.json').version")
|
||||
|
||||
# Generate timestamp (Unix epoch seconds)
|
||||
TIMESTAMP=$(date +%s)
|
||||
|
||||
# Create unique nightly version: 1.0.9-nightly.1736365200
|
||||
VERSION="${BASE_VERSION}-nightly.${TIMESTAMP}"
|
||||
|
||||
echo "Base version: $BASE_VERSION"
|
||||
echo "Generated nightly version: $VERSION"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Update cli/package.json with nightly version
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: |
|
||||
# Update version with timestamp-based nightly version
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const pkg = JSON.parse(fs.readFileSync('cli/package.json', 'utf8'));
|
||||
pkg.version = '${{ steps.version.outputs.version }}';
|
||||
fs.writeFileSync('cli/package.json', JSON.stringify(pkg, null, '\t'));
|
||||
"
|
||||
|
||||
echo "Using version ${{ steps.version.outputs.version }} for build"
|
||||
cat cli/package.json | grep '"version"'
|
||||
|
||||
- name: Download ripgrep binaries
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: npm run download-ripgrep
|
||||
|
||||
- name: Clean previous builds
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: rm -rf dist-standalone
|
||||
|
||||
- name: Generate Protos (First Pass)
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: npm run protos && npm run protos-go
|
||||
|
||||
- name: Compile CLI
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: npm run compile-cli
|
||||
|
||||
- name: Compile CLI for all platforms
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: npm run compile-cli-all-platforms
|
||||
|
||||
- name: Build standalone NPM package
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
CLINE_ENVIRONMENT: production
|
||||
OTEL_TELEMETRY_ENABLED: "1"
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
POSTHOG_TELEMETRY_ENABLED: "true"
|
||||
run: npm run compile-standalone-npm
|
||||
|
||||
- name: Generate Protos (Second Pass - Bug Workaround)
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: npm run protos && npm run protos-go
|
||||
|
||||
- name: Verify build output
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: |
|
||||
echo "Checking dist-standalone directory..."
|
||||
ls -la dist-standalone/
|
||||
|
||||
echo "Verifying CLI binaries..."
|
||||
ls -lh cli/bin/cline-* || echo "Warning: CLI binaries not found"
|
||||
|
||||
echo "Checking package.json in dist-standalone..."
|
||||
cat dist-standalone/package.json | grep version
|
||||
|
||||
- name: Publish to NPM with nightly tag
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_RELEASE_TOKEN }}
|
||||
run: |
|
||||
echo "Publishing version ${{ steps.version.outputs.version }} to NPM with tag 'nightly'..."
|
||||
cd dist-standalone
|
||||
npm publish --tag nightly --access public
|
||||
|
||||
- name: Summary
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: |
|
||||
echo "✅ Successfully published cline@${{ steps.version.outputs.version }} to NPM with tag 'nightly'"
|
||||
echo ""
|
||||
echo "📦 Install with: npm install -g cline@nightly"
|
||||
echo "🔗 NPM: https://www.npmjs.com/package/cline/v/${{ steps.version.outputs.version }}"
|
||||
@@ -36,6 +36,8 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.tag }}
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
@@ -116,22 +118,31 @@ jobs:
|
||||
echo "Successfully published release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
|
||||
fi
|
||||
|
||||
# - name: Get Changelog Entry
|
||||
# id: changelog
|
||||
# uses: mindsers/changelog-reader-action@v2
|
||||
# with:
|
||||
# # This expects a standard Keep a Changelog format
|
||||
# # "latest" means it will read whichever is the most recent version
|
||||
# # set in "## [1.2.3] - 2025-01-28" style
|
||||
# version: latest
|
||||
- name: Get Previous Tag
|
||||
id: prev_tag
|
||||
run: |
|
||||
CURRENT_TAG="${{ steps.validate_tag.outputs.tag }}"
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 "$CURRENT_TAG^" 2>/dev/null || echo "")
|
||||
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get Changelog Entry
|
||||
id: changelog
|
||||
run: |
|
||||
# Get content between first ## [ and second ## [
|
||||
CONTENT=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
|
||||
echo "content<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ steps.validate_tag.outputs.tag }}
|
||||
files: "*.vsix"
|
||||
# body: ${{ steps.changelog.outputs.content }}
|
||||
generate_release_notes: true
|
||||
body: |
|
||||
${{ steps.changelog.outputs.content }}
|
||||
|
||||
**Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...${{ steps.validate_tag.outputs.tag }}
|
||||
prerelease: ${{ github.event.inputs.release-type == 'pre-release' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
name: Trigger Jetbrains Plugin <-> Cline Tests
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
pull_request_target:
|
||||
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,7 +31,39 @@ 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.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
|
||||
|
||||
- name: Trigger IntelliJ Plugin Integration Test
|
||||
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 }}" \
|
||||
@@ -34,20 +75,23 @@ jobs:
|
||||
{
|
||||
"event_type": "cline-pr-check",
|
||||
"client_payload": {
|
||||
"pr_number": "${{ github.event.number }}",
|
||||
"branch_name": "${{ github.head_ref }}",
|
||||
"pr_number": "$PR_NUMBER",
|
||||
"branch_name": $BRANCH_NAME,
|
||||
"action": "${{ github.event.action }}",
|
||||
"sha": "${{ github.event.pull_request.head.sha }}",
|
||||
"pr_title": ${{ toJSON(github.event.pull_request.title) }},
|
||||
"pr_url": "${{ github.event.pull_request.html_url }}"
|
||||
"sha": "$PR_SHA",
|
||||
"pr_title": $PR_TITLE,
|
||||
"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 }}: ${{ github.event.pull_request.title }}"
|
||||
echo " Branch: ${{ github.head_ref }}"
|
||||
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"
|
||||
|
||||
+11
@@ -8,12 +8,14 @@ tmp
|
||||
|
||||
.DS_Store
|
||||
.idea
|
||||
.husky/_/
|
||||
|
||||
pnpm-lock.yaml
|
||||
|
||||
.clineignore
|
||||
.venv
|
||||
.actrc
|
||||
CLAUDE.local.md
|
||||
|
||||
webview-ui/src/**/*.js
|
||||
webview-ui/src/**/*.js.map
|
||||
@@ -27,6 +29,10 @@ coverage-unit
|
||||
|
||||
*evals.env
|
||||
.env
|
||||
.secrets
|
||||
.github/act/.secrets
|
||||
|
||||
.worktrees
|
||||
|
||||
## Generated files ##
|
||||
src/generated/
|
||||
@@ -35,3 +41,8 @@ webview-ui/src/services/grpc-client.ts
|
||||
|
||||
# E2E Tests
|
||||
test-results
|
||||
|
||||
/.github/act
|
||||
/pkg
|
||||
.secrets
|
||||
|
||||
|
||||
Vendored
+16
-4
@@ -12,7 +12,10 @@
|
||||
"args": [
|
||||
"--extensionDevelopmentPath=${workspaceFolder}",
|
||||
"--disable-workspace-trust",
|
||||
"--disable-extensions", // Avoid conflicts with installed extensions
|
||||
"--disable-extension",
|
||||
"saoudrizwan.claude-dev", // Avoid conflicts with installed Cline
|
||||
"--disable-extension",
|
||||
"saoudrizwan.cline-nightly", // Avoid conflicts with installed Cline Nightly
|
||||
"${workspaceFolder}"
|
||||
],
|
||||
"outFiles": [
|
||||
@@ -33,7 +36,10 @@
|
||||
"args": [
|
||||
"--extensionDevelopmentPath=${workspaceFolder}",
|
||||
"--disable-workspace-trust",
|
||||
"--disable-extensions", // Avoid conflicts with installed extensions
|
||||
"--disable-extension",
|
||||
"saoudrizwan.claude-dev", // Avoid conflicts with installed Cline
|
||||
"--disable-extension",
|
||||
"saoudrizwan.cline-nightly", // Avoid conflicts with installed Cline Nightly
|
||||
"${workspaceFolder}"
|
||||
],
|
||||
"outFiles": [
|
||||
@@ -54,7 +60,10 @@
|
||||
"args": [
|
||||
"--extensionDevelopmentPath=${workspaceFolder}",
|
||||
"--disable-workspace-trust",
|
||||
"--disable-extensions", // Avoid conflicts with installed extensions
|
||||
"--disable-extension",
|
||||
"saoudrizwan.claude-dev", // Avoid conflicts with installed Cline
|
||||
"--disable-extension",
|
||||
"saoudrizwan.cline-nightly", // Avoid conflicts with installed Cline Nightly
|
||||
"${workspaceFolder}"
|
||||
],
|
||||
"outFiles": [
|
||||
@@ -77,7 +86,10 @@
|
||||
"--user-data-dir=${workspaceFolder}/dist/tmp/user",
|
||||
"--profile-temp",
|
||||
"--sync=off",
|
||||
"--disable-extensions", // Avoid conflicts with installed extensions
|
||||
"--disable-extension",
|
||||
"saoudrizwan.claude-dev", // Avoid conflicts with installed Cline
|
||||
"--disable-extension",
|
||||
"saoudrizwan.cline-nightly", // Avoid conflicts with installed Cline Nightly
|
||||
"--extensionDevelopmentPath=${workspaceFolder}",
|
||||
"${workspaceFolder}"
|
||||
],
|
||||
|
||||
Vendored
+3
-1
@@ -27,5 +27,7 @@
|
||||
"source.removeUnused.biome": "always",
|
||||
"source.removeUnusedImports": "always",
|
||||
"source.organizeImports.biome": "always"
|
||||
}
|
||||
},
|
||||
// Disable auto-forwarding ports to prevent Simple Browser from opening the Vite dev server
|
||||
"remote.autoForwardPorts": false
|
||||
}
|
||||
|
||||
+2
-3
@@ -1,6 +1,8 @@
|
||||
# Default
|
||||
.vscode/**
|
||||
.vscode-test/**
|
||||
.worktrees/**
|
||||
CLAUDE.local.md
|
||||
out/
|
||||
dist-standalone/
|
||||
node_modules/
|
||||
@@ -40,9 +42,6 @@ buf.yaml
|
||||
.changeset/
|
||||
.clinerules/
|
||||
|
||||
# Include specific file needed for Background Exec mode
|
||||
!standalone/runtime-files/vscode/enhanced-terminal.js
|
||||
|
||||
# Ignore all webview-ui files except the build directory (https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/frameworks/hello-world-react-cra/.vscodeignore)
|
||||
webview-ui/src/**
|
||||
webview-ui/public/**
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
.gitignore
|
||||
+211
-1
@@ -1,5 +1,215 @@
|
||||
# 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
|
||||
|
||||
- Add telemetry to track usage of skills feature
|
||||
- Add version headers to Cline backend requests
|
||||
- Phase in Responses API usage instead of defaulting for every supported model
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix workflow slash command search to be case-insensitive
|
||||
- Fix model display in ModelPickerModal when using LiteLLM
|
||||
- Fix LiteLLM model fetching with default base URL
|
||||
- Fix crash when OpenAI-compatible APIs send usage chunks with empty or null choices arrays at end of streaming
|
||||
- Fix model ID for Kat Coder Pro Free model
|
||||
|
||||
## [3.49.0]
|
||||
|
||||
- Enable configuring an OTEL collector at runtime
|
||||
- Removing Minimax-2.1 from free model list as the free trial has ended
|
||||
- Improved image display in MCP responses
|
||||
- Auto-sync remote MCP servers from remote config to local settings
|
||||
|
||||
## [3.48.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Add Skills system for reusable, on-demand agent instructions
|
||||
- Add new websearch tooling in Cline provider
|
||||
- Add zai-glm-4.7 to Cerebras model list
|
||||
- Add model refresh and improve reasoning support for Vercel AI Gateway
|
||||
|
||||
### Fixed
|
||||
|
||||
- Revert #8341 due to regressions in diff view/document truncation (see #8423, #8429)
|
||||
- Fixed extension crash when using context menu selector
|
||||
|
||||
## [3.47.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Added experimental support for Background Edits (allows editing files in background without opening the diff view)
|
||||
- Updated free model to MiniMax M2.1 (replacing MiniMax M2)
|
||||
- Added support for Azure based identity authentication in OpenAI Compatible provider and Azure OpenAI
|
||||
- Add `supportsReasoning` property to Baseten models
|
||||
|
||||
### Fixed
|
||||
|
||||
- Prevent expired token usage in authenticated requests
|
||||
- Exclude binary files without extensions from diffs
|
||||
- Preserve file endings and trailing newlines
|
||||
- Fix Cerebras rate limiting
|
||||
- Fix Auto Compact for Claude Code provider
|
||||
- Make Workspace and Favorites history filters independent
|
||||
- Fix remote MCP server connection failures (404 response handling)
|
||||
- Disable native tool calling for Deepseek 3.2 speciale
|
||||
- Show notification instead of opening sidebar on update
|
||||
- Fix Baseten model selector
|
||||
|
||||
### Refactored
|
||||
|
||||
- Modify prompts for parallel tool usage in Claude and Gemini 3 models
|
||||
|
||||
## [3.46.1]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Remove GLM 4.6 from free models
|
||||
|
||||
## [3.46.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Added GLM 4.7 model
|
||||
- Enhanced background terminal execution with command tracking, log file output, zombie process prevention (10-minute timeout), and clickable log paths in UI
|
||||
- Apply Patch tool for GPT-5+ models (replacing current diff edit tools)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Duplicate error messages during streaming for Diff Edit tool when Parallel Tool Calling is not enabled
|
||||
- Banner carousel styling and dismiss functionality
|
||||
- Typos in Gemini system prompt overrides
|
||||
- Model picker favorites ordering, star toggle, and keyboard navigation for OpenRouter and Vercel AI Gateway providers
|
||||
- Fetch remote config values from the cache
|
||||
|
||||
### Refactored
|
||||
|
||||
- Anthropic handler to use metadata for reasoning support
|
||||
- Bedrock provider to use metadata for reasoning support
|
||||
|
||||
## [3.45.1]
|
||||
|
||||
- Fixed MCP settings race condition where toggling auto-approve or changing timeout settings would cause the UI to flash and revert
|
||||
|
||||
## [3.45.0]
|
||||
|
||||
- Added Gemini 3 Flash Preview model
|
||||
|
||||
## [3.44.2]
|
||||
|
||||
- Polished the model picker UI with checkmarks for selected models, tooltips on Plan/Act tabs, and consistent arrow pointers across all popup modals
|
||||
- Improved WhatsNew modal responsiveness and cleaned up redundant UI elements
|
||||
- Fixed GLM models outputting garbled text in thinking tags—reasoning is now properly disabled for these models
|
||||
|
||||
## [3.44.1]
|
||||
|
||||
- Fixed a critical bug where local MCP servers stopped connecting after v3.42.0—all user-configured stdio-based MCP servers should now work again
|
||||
- Fixed remotely configured API keys not being extracted correctly for enterprise users
|
||||
- Added support for dynamic tool instructions that adapt based on runtime context, laying groundwork for future context-aware features
|
||||
|
||||
## [3.44.0]
|
||||
|
||||
## Added
|
||||
|
||||
- Updating minor version to show a proper banner for the release
|
||||
|
||||
## [3.43.1]
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix GLM-4.6 Model reference id
|
||||
|
||||
## [3.43.0]
|
||||
|
||||
### Added
|
||||
|
||||
- GLM-4.6
|
||||
- kat-coder-pro
|
||||
- Add parsing of env variable patterns to the mcpconfig.json
|
||||
|
||||
### Fixed
|
||||
|
||||
- TLS Proxy support issues for VSCode
|
||||
- Add supportsReasoning flag to OpenAI reasoning models
|
||||
- Fix thinking not available for some models in the OpenAI provider
|
||||
- Fix invalid signature field issues when switching between Gemini and Anthropic providers
|
||||
- Extract OpenRouter model filtering into reusable utility and use it in different model pickers
|
||||
- Fix a11y for auto approve checkbox
|
||||
- Improve ModelPickerModal provider list layout
|
||||
|
||||
### Refactored
|
||||
|
||||
- Migrate WhatsNewModal to new shared dialogue component
|
||||
|
||||
## [3.42.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Expose `getAvailableSlashCommands` rpc endpoint to UI clients
|
||||
- Made slash command menu and context menu accessible and screenreader-friendly
|
||||
- Made expanding/collapsing UI components accessible
|
||||
|
||||
### Fixed
|
||||
|
||||
- Devstral OpenRouter model ID and routing issues
|
||||
- Incorrect pricing display for Devstral model in the extension
|
||||
|
||||
## [3.41.0]
|
||||
|
||||
### Added
|
||||
|
||||
- OpenAI GPT-5.2
|
||||
- Devstral-2512 (formerly stealth model "Microwave")
|
||||
- Improvements to chat modal model picker
|
||||
- Amazon Nova 2 Lite
|
||||
- DeepSeek 3.2 to native tool calling allow list
|
||||
- Responses API support for Codex models in OpenAI provider (requires native tool calling)
|
||||
- Xmas Special Santa Cline
|
||||
- Welcome screen UI enhancements
|
||||
|
||||
### Fixed
|
||||
|
||||
- Initial checkpoint commit now non-blocking for improved responsiveness in large repositories
|
||||
- Gemini Vertex models erroring when thinking parameters are not supported
|
||||
- Restrictive file permissions for secrets.json
|
||||
- Ollama streaming requests not aborting when task is cancelled
|
||||
|
||||
### Refactored
|
||||
|
||||
- OpenAI provider to centralize temperature configuration and include missing GPT-5 model settings
|
||||
- OpenAI native handler to use metadata for model capabilities
|
||||
- Vertex provider to use metadata for model capabilities
|
||||
|
||||
## [3.40.2]
|
||||
|
||||
- Fix logout on network errors during token refresh (e.g., opening laptop while offline)
|
||||
|
||||
## [3.40.1]
|
||||
|
||||
- Fix cost calculation display for Anthropic API requests
|
||||
|
||||
## [3.40.0]
|
||||
|
||||
- Fix highlighted text flashing when task header is collapsed
|
||||
@@ -1527,4 +1737,4 @@ Add Opus 4.1 through Claude Code
|
||||
|
||||
## [0.0.6]
|
||||
|
||||
- Initial release
|
||||
- Initial release
|
||||
@@ -1,125 +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.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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)
|
||||
})
|
||||
Generated
+2945
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
@@ -0,0 +1,2 @@
|
||||
// Stub for react-devtools-core - not needed in CLI
|
||||
module.exports = {}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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": ".."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"workflowToggles": {},
|
||||
"localClineRulesToggles": {},
|
||||
"localWindsurfRulesToggles": {},
|
||||
"localCursorRulesToggles": {},
|
||||
"localAgentsRulesToggles": {}
|
||||
}
|
||||
@@ -70,3 +70,4 @@ Apache-2.0 - see [LICENSE](https://github.com/cline/cline/blob/main/LICENSE) for
|
||||
- Report issues: [GitHub Issues](https://github.com/cline/cline/issues)
|
||||
- Community: [GitHub Discussions](https://github.com/cline/cline/discussions)
|
||||
- Documentation: [docs.cline.bot](https://docs.cline.bot)
|
||||
- Cline CLI Architecture: [architecture.md](./architecture.md)
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
# Cline CLI Architecture
|
||||
|
||||
The CLI is a **standalone terminal interface** for the Cline AI coding assistant, written in Go. It provides the same autonomous coding capabilities as the VS Code extension but runs entirely in the terminal.
|
||||
|
||||
## High-Level Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ User Terminal │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ cline (Go binary) │
|
||||
│ cmd/cline/main.go │
|
||||
│ • Cobra CLI commands (task, auth, config, instance, etc.) │
|
||||
│ • Interactive input via Bubble Tea │
|
||||
│ • Streaming output with markdown rendering │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
│ gRPC (50052) │ starts subprocess
|
||||
▼ ▼
|
||||
┌─────────────────────────┐ ┌─────────────────────────┐
|
||||
│ cline-core │◄────────────────►│ cline-host │
|
||||
│ (Node.js) │ gRPC (51052) │ (Go binary) │
|
||||
│ │ │ cmd/cline-host/main.go│
|
||||
│ • AI/LLM orchestration │ │ │
|
||||
│ • Tool execution │ │ • Workspace paths │
|
||||
│ • Task state mgmt │ │ • File diff editing │
|
||||
│ • Message handling │ │ • Clipboard access │
|
||||
└─────────────────────────┘ │ • Environment info │
|
||||
│ └─────────────────────────┘
|
||||
│ SQLite (self-registration)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ ~/.cline/data/locks/locks.db │
|
||||
│ (Instance registry - core self-registers on startup) │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Entry Points (`cmd/`)
|
||||
|
||||
### `cmd/cline/main.go` - Main CLI
|
||||
|
||||
Cobra-based CLI with commands:
|
||||
|
||||
- **Root**: `cline [prompt]` - Start a task directly
|
||||
- **task**: Create, send, view, list, pause, restore tasks
|
||||
- **auth**: Authentication setup and provider configuration
|
||||
- **config**: Read/write settings
|
||||
- **instance**: Manage running Cline instances
|
||||
- **logs**: View and clean log files
|
||||
- **doctor**: System health check
|
||||
|
||||
### `cmd/cline-host/main.go` - Host Bridge Service
|
||||
|
||||
Separate gRPC server providing host environment operations to cline-core:
|
||||
|
||||
- Workspace paths
|
||||
- File diff editing
|
||||
- Clipboard access
|
||||
- Shutdown coordination
|
||||
|
||||
---
|
||||
|
||||
## `pkg/cli/` Subsystems
|
||||
|
||||
### 1. `auth/` - Authentication System
|
||||
|
||||
Handles authentication with Cline service and BYO (Bring Your Own) API providers.
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------------- | ------------------------------------------------------------------------ |
|
||||
| `auth_cline_provider.go` | OAuth login flow - opens browser, subscribes to auth callback stream |
|
||||
| `auth_menu.go` | Interactive menu showing auth options based on current state |
|
||||
| `auth_subscription.go` | gRPC stream subscription for auth status updates |
|
||||
| `wizard_byo.go` | Interactive wizard for configuring BYO providers |
|
||||
| `wizard_byo_bedrock.go` | AWS Bedrock-specific credential setup |
|
||||
| `wizard_byo_oca.go` | Oracle Code Assist setup |
|
||||
| `providers_list.go` | Retrieves configured providers from core state |
|
||||
| `providers_byo.go` | Provider selection UI and field configuration |
|
||||
| `models_*.go` | Model listing (static lists + dynamic fetch from OpenRouter/OpenAI/Ollama) |
|
||||
|
||||
**Flow**: User runs `cline auth` → Menu shows options → For BYO: wizard guides through provider/key/model selection → Config saved via gRPC to core.
|
||||
|
||||
---
|
||||
|
||||
### 2. `clerror/` - Error Handling
|
||||
|
||||
Parses and classifies API errors from the Cline service.
|
||||
|
||||
**Error Types:**
|
||||
|
||||
- `ErrorTypeAuth` - 401, bad API key
|
||||
- `ErrorTypeBalance` - Insufficient credits
|
||||
- `ErrorTypeRateLimit` - 429, quota exceeded
|
||||
- `ErrorTypeNetwork` - Connection issues
|
||||
- `ErrorTypeUnknown` - Catch-all
|
||||
|
||||
Extracts billing details (balance, spent, buy credits URL) from error responses.
|
||||
|
||||
---
|
||||
|
||||
### 3. `config/` - Configuration Management
|
||||
|
||||
| File | Purpose |
|
||||
| --------------------- | -------------------------------------------------------------------------- |
|
||||
| `manager.go` | gRPC interface for reading/writing settings via `UpdateSettingsCli` RPC |
|
||||
| `settings_renderer.go`| Pretty-prints config values, censors sensitive fields (keys, secrets) |
|
||||
|
||||
Supports dot-notation paths: `cline config get auto-approval-settings.actions.read-files`
|
||||
|
||||
---
|
||||
|
||||
### 4. `display/` - Terminal Display System
|
||||
|
||||
The most complex subsystem - handles all visual output.
|
||||
|
||||
| File | Purpose |
|
||||
| ----------------------- | -------------------------------------------------------------------- |
|
||||
| `renderer.go` | Central coordinator with lipgloss styles, color methods, markdown delegation |
|
||||
| `streaming.go` | Real-time streaming display with deduplication |
|
||||
| `segment_streamer.go` | Streaming segments (header + body) with context-aware headers |
|
||||
| `typewriter.go` | Character-by-character animation with variable delays |
|
||||
| `markdown_renderer.go` | Glamour wrapper for terminal markdown rendering |
|
||||
| `tool_renderer.go` | Tool operation formatting ("Cline is editing `file.ts`") |
|
||||
| `tool_result_parser.go` | Parses structured tool results (file lists, search results) |
|
||||
| `banner.go` | Session startup banner with version/model/workspace |
|
||||
| `deduplicator.go` | MD5-based deduplication with 2-second window |
|
||||
| `system_renderer.go` | Rich error/warning boxes for balance errors, auth failures |
|
||||
| `ansi.go` | TTY detection, line clearing with escape codes |
|
||||
|
||||
---
|
||||
|
||||
### 5. `global/` - Global State Management
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------ | -------------------------------------------------------------------------- |
|
||||
| `global.go` | Global config (paths, verbosity, output format), initialization |
|
||||
| `registry.go` | Instance discovery via SQLite, health checking, default instance management|
|
||||
| `cline-clients.go` | Starts cline-core + cline-host processes, port allocation, cleanup |
|
||||
|
||||
**Instance lifecycle:**
|
||||
|
||||
1. Find available port pair
|
||||
2. Start `cline-host` on port+1000
|
||||
3. Start `cline-core` on port
|
||||
4. Wait for core to self-register in SQLite
|
||||
5. Set as default if first instance
|
||||
|
||||
---
|
||||
|
||||
### 6. `handlers/` - Message Handlers
|
||||
|
||||
Routes incoming messages from cline-core to appropriate renderers.
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------ | --------------------------------------------------------------------- |
|
||||
| `handler.go` | Handler registry with priority-based routing |
|
||||
| `ask_handlers.go` | Approval requests: tool, command, followup, api_req_failed, etc. |
|
||||
| `say_handlers.go` | Status messages: text, reasoning, command_output, tool, checkpoint, etc. |
|
||||
|
||||
Uses `DisplayContext` providing renderer access, state, and context flags (isLast, isPartial, isStreamingMode).
|
||||
|
||||
---
|
||||
|
||||
### 7. `output/` - Output Coordination
|
||||
|
||||
| File | Purpose |
|
||||
| --------------------- | ----------------------------------------------------------------------- |
|
||||
| `coordinator.go` | Coordinates streaming output with interactive input (saves/restores input state) |
|
||||
| `input_model.go` | Bubble Tea model for rich input (message, approval, feedback types) |
|
||||
| `slash_completion.go` | Autocomplete dropdown for slash commands |
|
||||
|
||||
**Key pattern:** When output needs to print while input is visible, the coordinator saves input state, clears the form, prints, then restores input.
|
||||
|
||||
---
|
||||
|
||||
### 8. `slash/` - Slash Command Registry
|
||||
|
||||
Central registry for commands like `/plan`, `/act`, `/cancel`:
|
||||
|
||||
- **CLI-local commands**: Handled directly by CLI
|
||||
- **Backend commands**: Fetched from core via gRPC, filtered by `CliCompatible` flag
|
||||
|
||||
---
|
||||
|
||||
### 9. `sqlite/` - Instance Locking
|
||||
|
||||
Manages the distributed locking system:
|
||||
|
||||
- **Instance locks**: Track running Cline instances by address
|
||||
- **File locks**: Coordinate file access across instances
|
||||
- SQLite database created by cline-core, CLI reads/writes for discovery
|
||||
|
||||
---
|
||||
|
||||
### 10. `task/` - Task Management
|
||||
|
||||
| File | Purpose |
|
||||
| ----------------------- | -------------------------------------------------------------------- |
|
||||
| `manager.go` | Core orchestrator: create, cancel, resume, restore tasks; stream handling |
|
||||
| `stream_coordinator.go` | Deduplication and turn management for dual streams |
|
||||
| `input_handler.go` | Interactive input during follow mode (polling, approval detection) |
|
||||
| `history_handler.go` | Direct disk access to `taskHistory.json` |
|
||||
| `settings_parser.go` | Parse settings from CLI flags |
|
||||
| `follow_options.go` | Configuration for follow behavior |
|
||||
|
||||
**Streaming:** Task manager subscribes to two gRPC streams:
|
||||
|
||||
1. `SubscribeToState` - Full state updates
|
||||
2. `SubscribeToPartialMessage` - Streaming AI responses
|
||||
|
||||
---
|
||||
|
||||
### 11. `terminal/` - Terminal Handling
|
||||
|
||||
Enhanced keyboard protocol support and terminal configuration:
|
||||
|
||||
- Enables modifyOtherKeys and Kitty keyboard protocol
|
||||
- Detects terminal type (VS Code, iTerm, Ghostty, Kitty, etc.)
|
||||
- Auto-configures shift+enter keybindings for various terminals
|
||||
|
||||
---
|
||||
|
||||
### 12. `types/` - Type Definitions
|
||||
|
||||
| File | Purpose |
|
||||
| -------------- | ----------------------------------------------------------------- |
|
||||
| `messages.go` | `ClineMessage`, `AskType`, `SayType`, `ToolType` enums, proto conversion |
|
||||
| `state.go` | `ConversationState` with thread-safe message access |
|
||||
| `history.go` | `HistoryItem` matching taskHistory.json format |
|
||||
|
||||
---
|
||||
|
||||
### 13. `updater/` - Auto-Update
|
||||
|
||||
Background auto-update checking:
|
||||
|
||||
- 24-hour check interval (cached)
|
||||
- Queries npm registry for newer versions
|
||||
- Supports `latest` and `nightly` channels
|
||||
- Runs `npm install -g cline` to update
|
||||
|
||||
---
|
||||
|
||||
## `pkg/common/` - Shared Types
|
||||
|
||||
| File | Purpose |
|
||||
| --------------- | ------------------------------------------------------------ |
|
||||
| `constants.go` | `SETTINGS_SUBFOLDER`, `DEFAULT_CLINE_CORE_PORT` |
|
||||
| `schema.go` | SQL queries for instance/file locks |
|
||||
| `types.go` | `CoreInstanceInfo`, `LockRow`, `DefaultCoreInstance` |
|
||||
| `utils.go` | Port checking, health checks, address normalization, retry logic |
|
||||
|
||||
---
|
||||
|
||||
## `pkg/generated/` - Auto-Generated
|
||||
|
||||
| File | Purpose |
|
||||
| --------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| `providers.go` | Provider definitions (Anthropic, OpenAI, Bedrock, etc.) with field metadata and model specs - generated from TypeScript sources |
|
||||
| `field_overrides.go` | Manual overrides for field filtering |
|
||||
|
||||
---
|
||||
|
||||
## `pkg/hostbridge/` - CLI-to-Core Bridge
|
||||
|
||||
This is the **reverse bridge** allowing cline-core to request host environment operations:
|
||||
|
||||
| File | Purpose |
|
||||
| ----------------------- | ---------------------------------------------------- |
|
||||
| `grpc_server.go` | Main server registering all services |
|
||||
| `simple_workspace.go` | Workspace service: returns CWD as workspace path |
|
||||
| `diff.go` | In-memory file diff editing with line-based operations |
|
||||
| `env.go` | Clipboard access, version info, shutdown coordination |
|
||||
| `window.go` | UI stubs (no-ops or console output) |
|
||||
|
||||
**Why this exists:** The same cline-core logic runs in VS Code and CLI. In VS Code, the "host" is the extension with editor APIs. In CLI, hostbridge emulates these capabilities with terminal-appropriate implementations.
|
||||
|
||||
---
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
1. **Two-process model:** `cline` CLI manages instances; `cline-core` is the actual AI engine (Node.js). This allows reusing the same core as the VS Code extension.
|
||||
|
||||
2. **Self-registration via SQLite:** `cline-core` registers itself in a SQLite database on startup. The CLI discovers instances by reading this database, enabling multi-instance support.
|
||||
|
||||
3. **Host bridge abstraction:** The `cline-host` process provides platform-specific operations (clipboard, workspace paths) via gRPC, allowing `cline-core` to remain host-agnostic.
|
||||
|
||||
4. **Streaming-first UI:** The CLI uses gRPC streaming to display AI responses in real-time with typewriter-style rendering.
|
||||
|
||||
5. **Dual stream handling:** Task manager subscribes to both state updates and partial messages, using deduplication to prevent duplicate rendering.
|
||||
@@ -14,8 +14,9 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
port int
|
||||
verbose bool
|
||||
port int
|
||||
verbose bool
|
||||
workspaces []string
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -28,6 +29,7 @@ func main() {
|
||||
|
||||
rootCmd.Flags().IntVarP(&port, "port", "p", 51052, "port to listen on")
|
||||
rootCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "verbose logging")
|
||||
rootCmd.Flags().StringSliceVar(&workspaces, "workspace", nil, "workspace paths")
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
@@ -39,7 +41,7 @@ func runServer(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Create gRPC hostbridge service
|
||||
service := hostbridge.NewGrpcServer(port, verbose)
|
||||
service := hostbridge.NewGrpcServer(port, verbose, workspaces)
|
||||
|
||||
// Handle graceful shutdown
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
|
||||
+143
-106
@@ -6,14 +6,16 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
"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"
|
||||
@@ -25,18 +27,20 @@ var (
|
||||
outputFormat string
|
||||
|
||||
// Task creation flags (for root command)
|
||||
images []string
|
||||
files []string
|
||||
mode string
|
||||
settings []string
|
||||
yolo bool
|
||||
oneshot bool
|
||||
images []string
|
||||
files []string
|
||||
mode string
|
||||
settings []string
|
||||
yolo bool
|
||||
oneshot bool
|
||||
workspaces []string
|
||||
)
|
||||
|
||||
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:
|
||||
@@ -68,20 +72,29 @@ 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
|
||||
}
|
||||
|
||||
// Build the full workspace list: cwd first, then additional workspaces
|
||||
allWorkspaces, err := buildWorkspaceList(workspaces)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to build workspace list: %w", err)
|
||||
}
|
||||
|
||||
// If --address flag not provided, start instance BEFORE getting prompt
|
||||
if !cmd.Flags().Changed("address") {
|
||||
if global.Config.Verbose {
|
||||
fmt.Println("Starting new Cline instance...")
|
||||
}
|
||||
instance, err := global.Clients.StartNewInstance(ctx)
|
||||
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
|
||||
@@ -89,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
|
||||
@@ -129,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 == "" {
|
||||
// Pass the mode flag to banner so it shows correct mode
|
||||
prompt, err = promptForInitialTask(ctx, instanceAddress, mode)
|
||||
// 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, mode, allWorkspaces, !bannerShown)
|
||||
bannerShown = true
|
||||
if err != nil {
|
||||
// Check if user cancelled - exit cleanly without error
|
||||
if err == huh.ErrUserAborted {
|
||||
@@ -140,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")
|
||||
}
|
||||
@@ -152,17 +182,20 @@ see the manual page: man cline`,
|
||||
}
|
||||
|
||||
return cli.CreateAndFollowTask(ctx, prompt, cli.TaskOptions{
|
||||
Images: images,
|
||||
Files: files,
|
||||
Mode: mode,
|
||||
Settings: settings,
|
||||
Yolo: yolo,
|
||||
Address: instanceAddress,
|
||||
Verbose: verbose,
|
||||
Images: images,
|
||||
Files: files,
|
||||
Mode: mode,
|
||||
Settings: settings,
|
||||
Yolo: yolo,
|
||||
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)")
|
||||
@@ -175,6 +208,7 @@ see the manual page: man cline`,
|
||||
rootCmd.Flags().BoolVarP(&yolo, "yolo", "y", false, "enable yolo mode (non-interactive)")
|
||||
rootCmd.Flags().BoolVar(&yolo, "no-interactive", false, "enable yolo mode (non-interactive)")
|
||||
rootCmd.Flags().BoolVarP(&oneshot, "oneshot", "o", false, "full autonomous mode")
|
||||
rootCmd.Flags().StringSliceVarP(&workspaces, "workspace", "w", nil, "additional workspace paths (can be specified multiple times)")
|
||||
|
||||
rootCmd.AddCommand(cli.NewTaskCommand())
|
||||
rootCmd.AddCommand(cli.NewInstanceCommand())
|
||||
@@ -189,51 +223,30 @@ see the manual page: man cline`,
|
||||
}
|
||||
}
|
||||
|
||||
func promptForInitialTask(ctx context.Context, instanceAddress, modeFlag string) (string, error) {
|
||||
// Show session banner before the initial input
|
||||
showSessionBanner(ctx, instanceAddress, modeFlag)
|
||||
|
||||
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) {
|
||||
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
|
||||
@@ -244,27 +257,21 @@ func showSessionBanner(ctx context.Context, instanceAddress, modeFlag string) {
|
||||
bannerInfo.Mode = "plan"
|
||||
}
|
||||
|
||||
// Get current working directory (this is what Cline will use)
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
bannerInfo.Workdir = cwd
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,25 +284,22 @@ func showSessionBanner(ctx context.Context, instanceAddress, modeFlag string) {
|
||||
// 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
|
||||
}
|
||||
@@ -345,4 +349,37 @@ func getContentFromStdinAndArgs(args []string) (string, error) {
|
||||
}
|
||||
|
||||
return content.String(), nil
|
||||
}
|
||||
}
|
||||
|
||||
// buildWorkspaceList builds the full workspace list with cwd as the first entry
|
||||
func buildWorkspaceList(additionalWorkspaces []string) ([]string, error) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get current working directory: %w", err)
|
||||
}
|
||||
|
||||
// Start with cwd
|
||||
workspaces := []string{cwd}
|
||||
|
||||
// Add additional workspaces, avoiding duplicates
|
||||
for _, ws := range additionalWorkspaces {
|
||||
// Normalize the path
|
||||
absPath, err := common.AbsPath(ws)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve workspace path %s: %w", ws, err)
|
||||
}
|
||||
|
||||
// Skip if it's the same as cwd
|
||||
if absPath == cwd {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
isDuplicate := slices.Contains(workspaces, absPath)
|
||||
if !isDuplicate {
|
||||
workspaces = append(workspaces, absPath)
|
||||
}
|
||||
}
|
||||
|
||||
return workspaces, nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user