mirror of
https://github.com/cline/cline.git
synced 2026-09-01 23:19:18 +08:00
Compare commits
146 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 525e154684 | |||
| f5fb30e98a | |||
| 67ec7bb8b2 | |||
| 2c42b46e71 | |||
| af19e0d560 | |||
| 27c7720f71 | |||
| 9b27ac8cb9 | |||
| be9fbb01bc | |||
| 82331aff2d | |||
| 7d9c69687f | |||
| 0916c78d52 | |||
| b31c3c5c1d | |||
| 484807c472 | |||
| 23490f7e6f | |||
| 8ea0543b89 | |||
| c7c9813056 | |||
| ff2bdde179 | |||
| a27e0bddbf | |||
| 32199574a9 | |||
| fccb633d08 | |||
| f5679b5c91 | |||
| ee88ddd678 | |||
| ee50091c9a | |||
| f6cc991b48 | |||
| 02b6c1fc91 | |||
| 5df13d8f32 | |||
| 918e32edee | |||
| 06d8e7ae9f | |||
| 2b35590de8 | |||
| 2788d85556 | |||
| 60436b3ddf | |||
| df5954052d | |||
| a66a2784c3 | |||
| 4ffb28377a | |||
| fa570fa500 | |||
| 23f64d24c6 | |||
| f4c79e2d21 | |||
| 10449e458e | |||
| 2ac5a0ea1f | |||
| cb34c643d7 | |||
| d9f4946665 | |||
| cb2355d881 | |||
| 04ce2878aa | |||
| c52d47decd | |||
| 26ca343dae | |||
| 81781a6143 | |||
| e2c92051f4 | |||
| f09a5d79ef | |||
| 8822846800 | |||
| c2a7836d0c | |||
| e84ff527c4 | |||
| 349d7cf24b | |||
| d5af7a2bad | |||
| 77a4bfa3f8 | |||
| d0d9ae5e9a | |||
| 0d188ade2f | |||
| d043ba9ec9 | |||
| dc35c62d5d | |||
| 2dc54861ba | |||
| 5cff5f3f24 | |||
| 39bb06bb93 | |||
| 599e236546 | |||
| 06f015c9d0 | |||
| 49bdafe10e | |||
| 955b06d4fa | |||
| 34ac2176e6 | |||
| c5e87a800b | |||
| 92c9cc5ddc | |||
| dd0b7697f2 | |||
| 9d68aa8b8e | |||
| c59fe3e62c | |||
| 7602f7b7c6 | |||
| a1debe1328 | |||
| 990a00d70f | |||
| a125a61cbd | |||
| e3e6e1ef77 | |||
| 09e897c8f2 | |||
| 6caeeb5da6 | |||
| 40550af42c | |||
| e85e4d6f0c | |||
| d26f76c6e4 | |||
| 5c6f725079 | |||
| 4a456e2145 | |||
| c029e0ffe4 | |||
| cff2217283 | |||
| 904617b573 | |||
| 47031cea25 | |||
| e29740479e | |||
| 74f607ff8e | |||
| 0edf6d777b | |||
| de630c64d4 | |||
| 3ff63562c8 | |||
| 20fae7e14b | |||
| 699ed190b7 | |||
| 4d6f908fbd | |||
| 28ae6d6ca1 | |||
| 6521fdcc94 | |||
| 1393eace27 | |||
| beca76fb32 | |||
| eebb99c1e3 | |||
| 9ed44f7a83 | |||
| 6a95cc5f19 | |||
| 8de9935f9a | |||
| 6bc700e3db | |||
| 1d0637f39c | |||
| f2c16bae7a | |||
| 204f15ce1c | |||
| 2f17a1a341 | |||
| 639121b644 | |||
| 1187852bed | |||
| 7acc036d91 | |||
| 1fea5aaa63 | |||
| 8de7294de8 | |||
| 2e4ded7f14 | |||
| ac94eb4cf4 | |||
| 73635cf68f | |||
| 9eb0da6c2b | |||
| 59c10df046 | |||
| db4d9a267a | |||
| 734dc6e57f | |||
| 27befb16f5 | |||
| 1c32b08644 | |||
| 5d81bf0fe2 | |||
| f44771acd5 | |||
| 8118e11596 | |||
| f7b593df35 | |||
| 2e0358a7a1 | |||
| 0fbc10f807 | |||
| 786728e0d4 | |||
| 9714cf8861 | |||
| c093ca1760 | |||
| dc85299a73 | |||
| 4533ed3ea8 | |||
| 66d7664d1b | |||
| 50aeb7098f | |||
| c2e6eafb45 | |||
| 0220628c39 | |||
| 5052220195 | |||
| abf3081e56 | |||
| 125cb78a31 | |||
| 17539228ea | |||
| c5db44964e | |||
| ed38739d65 | |||
| bdb7cc36fa | |||
| 50bc90835c | |||
| d3e1065707 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add claude 4.5 haiku
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: finalize document content during approval flow
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Expose --version in cline cli command
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add endpoint configuration file support for on-premise deployments
|
||||
|
||||
Enterprise customers can now configure custom API endpoints by creating a `~/.cline/endpoints.json` file with custom URLs for `appBaseUrl`, `apiBaseUrl`, and `mcpBaseUrl`. When this file is present, Cline runs in on-premise mode with the custom endpoints.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: prevent duplicate diff errors when parallel tool calling is enabled
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Log Persistence errors to PostHog
|
||||
@@ -1,13 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
feat: add OpenAI Codex (ChatGPT Plus/Pro) provider
|
||||
|
||||
Users with ChatGPT Plus or Pro subscriptions can now use GPT-5 models directly through Cline without needing an API key. Authentication is handled via OAuth through OpenAI's authentication system.
|
||||
|
||||
Models available:
|
||||
- gpt-5.2-codex (default)
|
||||
- gpt-5.1-codex-max
|
||||
- gpt-5.1-codex-mini
|
||||
- gpt-5.2
|
||||
@@ -1,9 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix two bugs in DiffViewProvider file editing:
|
||||
|
||||
1. **Line boundary validation**: Add `safelyTruncateDocument()` to prevent out-of-bounds line errors on JetBrains hosts (fixes #8423, #8429). The gRPC protocol strictly validates line numbers, causing "truncateDocument INTERNAL: Wrong line" errors when `truncateDocument()` was called with a line number >= document line count.
|
||||
|
||||
2. **Content concatenation on final update**: When replacing content without a trailing newline, the old content at line N+1 was concatenated to the new content. Fixed by extending the replacement range to cover the entire document on final update.
|
||||
@@ -1,9 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
docs: fix outdated Ollama model names in documentation
|
||||
|
||||
Updated recommended Ollama models to use correct identifiers:
|
||||
- Changed qwen3-coder-30b to qwen2.5-coder:32b
|
||||
- Changed devstral-small to codellama:34b-code
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: prevent infinite retry loops when replace_in_file fails repeatedly
|
||||
|
||||
Add safeguards to prevent the LLM from getting stuck in infinite retry loops when `replace_in_file` operations fail repeatedly. The system now detects repeated failures and provides better guidance to break out of retry cycles.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: skip diff error UI handling during streaming to prevent flickering
|
||||
|
||||
Suppress diff view error notifications while content is actively streaming to prevent visual flickering and improve user experience. Error handling is deferred until streaming completes.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix(extract-text): strip notebook outputs to reduce context size
|
||||
|
||||
Strip notebook cell outputs when extracting text content from Jupyter notebooks, significantly reducing the amount of context sent to the LLM while preserving the essential code and markdown content.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: throttle diff view updates during streaming
|
||||
|
||||
Add throttling to diff view updates during content streaming to reduce UI flickering and improve performance. Updates are now batched at reasonable intervals instead of firing on every token received.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
OpenAI GPT-5 Codex models are now using Apply Patch tool for diff edits.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Disable PostHog telemetry, error tracking, and feature flags in self-hosted mode
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix issue where tool call ids are invalid when switching between models using the chat completion format and the responses api format.
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: improve Jupyter notebook diff view and reduce LLM context for notebook edits
|
||||
|
||||
- Restore switchToSpecializedEditor() for Jupyter notebook diff views that was accidentally removed during rebase
|
||||
- Open .ipynb files in Jupyter notebook editor after save instead of leaving stale diff view
|
||||
- Strip notebook outputs from content sent to LLM, reducing context by 95% (196KB → 9KB)
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: add chat output on skill use
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Adding telemetry for background exec terminal
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
This pull request introduces comprehensive Jupyter Notebook support for Cline, enabling AI-assisted editing of `.ipynb` files with full cell-level context awareness. The feature allows users to seamlessly work with Jupyter notebooks using Cline's AI capabilities while preserving the notebook's JSON structure.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Remove deprecated zai-glm-4.6 model from Cerebras provider
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Make Sonnet 4.5 the default Amazon Bedrock model id
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: removes retry message from UI after retry succeeds
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Support native tool calling for LM Studio and Ollama provider
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fixing integration tests from testing framework
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
add claude 4.5 opus into sap provider.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Limite Vertex and LiteLLM options when they're remote configured
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix crash when the Context Menu has a type but no options
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Disable PostHog and build-time OpenTelemetry telemetry in self-hosted/on-premise mode. Enterprise customers running self-hosted deployments will no longer send any telemetry to Cline's collectors. Runtime environment OTEL and remote config OTEL remain available for enterprises to configure their own telemetry collection.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Throttle the remote config fetch
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Improve history view filter menu
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add git worktree management UI for running parallel Cline sessions
|
||||
@@ -0,0 +1,8 @@
|
||||
# CLI Development
|
||||
|
||||
The CLI lives in `cli-ts/` and uses React Ink for terminal UI.
|
||||
|
||||
- If needed, look at `cli-ts/src/constants/colors.ts` for re-used terminal colors, e.g. `COLORS.primaryBlue` highlight color (selections, spinners, success states).
|
||||
- Never use `dimColor` with gray (e.g. `<Text color="gray" dimColor>`) - it's too hard to read. Use `color="gray"` for secondary text and normal foreground (no color) for primary text.
|
||||
- When thinking about how to handle state or messages from core, look at webview for how it communicates with the vs code extension.
|
||||
- When updating the webview, consider and suggest to the user to update the CLI TUI since we want to provide a similar experience to our terminal users as we do our vs code extension users.
|
||||
@@ -33,13 +33,7 @@ jobs:
|
||||
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
|
||||
# Cache root dependencies
|
||||
- name: Cache root dependencies
|
||||
uses: actions/cache@v4
|
||||
id: root-cache
|
||||
@@ -47,46 +41,37 @@ jobs:
|
||||
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
|
||||
# Cache cli-ts dependencies
|
||||
- name: Cache cli-ts dependencies
|
||||
uses: actions/cache@v4
|
||||
id: webview-cache
|
||||
id: cli-ts-cache
|
||||
with:
|
||||
path: webview-ui/node_modules
|
||||
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
|
||||
path: cli-ts/node_modules
|
||||
key: ${{ runner.os }}-npm-cli-ts-${{ hashFiles('cli-ts/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: Install cli-ts dependencies
|
||||
if: steps.cli-ts-cache.outputs.cache-hit != 'true'
|
||||
run: cd cli-ts && npm ci
|
||||
|
||||
- name: Generate Protos
|
||||
run: npm run protos
|
||||
|
||||
- name: Read release version
|
||||
id: version
|
||||
run: |
|
||||
# Read version from cli/package.json (stable version)
|
||||
VERSION=$(node -p "require('./cli/package.json').version")
|
||||
# Read version from cli-ts/package.json
|
||||
VERSION=$(node -p "require('./cli-ts/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
|
||||
- name: Build and package CLI
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
@@ -98,21 +83,18 @@ jobs:
|
||||
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
|
||||
run: node scripts/package-npm.mjs
|
||||
|
||||
- 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 dist-standalone/dist..."
|
||||
ls -la dist-standalone/dist/
|
||||
|
||||
echo "Checking package.json in dist-standalone..."
|
||||
cat dist-standalone/package.json | grep version
|
||||
cat dist-standalone/package.json
|
||||
|
||||
- name: Publish to NPM with latest tag
|
||||
env:
|
||||
|
||||
@@ -42,14 +42,7 @@ jobs:
|
||||
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
|
||||
# Cache root dependencies
|
||||
- name: Cache root dependencies
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
uses: actions/cache@v4
|
||||
@@ -58,75 +51,63 @@ jobs:
|
||||
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
|
||||
# Cache cli-ts dependencies
|
||||
- name: Cache cli-ts dependencies
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
uses: actions/cache@v4
|
||||
id: webview-cache
|
||||
id: cli-ts-cache
|
||||
with:
|
||||
path: webview-ui/node_modules
|
||||
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
|
||||
path: cli-ts/node_modules
|
||||
key: ${{ runner.os }}-npm-cli-ts-${{ hashFiles('cli-ts/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: Install cli-ts dependencies
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.cli-ts-cache.outputs.cache-hit != 'true'
|
||||
run: cd cli-ts && npm ci
|
||||
|
||||
- name: Generate Protos
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: npm run protos
|
||||
|
||||
- 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")
|
||||
|
||||
# Read base version from cli-ts/package.json (e.g., "2.0.0")
|
||||
BASE_VERSION=$(node -p "require('./cli-ts/package.json').version")
|
||||
|
||||
# Generate timestamp (Unix epoch seconds)
|
||||
TIMESTAMP=$(date +%s)
|
||||
|
||||
# Create unique nightly version: 1.0.9-nightly.1736365200
|
||||
|
||||
# Create unique nightly version: 2.0.0-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
|
||||
- name: Update cli-ts/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'));
|
||||
const pkg = JSON.parse(fs.readFileSync('cli-ts/package.json', 'utf8'));
|
||||
pkg.version = '${{ steps.version.outputs.version }}';
|
||||
fs.writeFileSync('cli/package.json', JSON.stringify(pkg, null, '\t'));
|
||||
fs.writeFileSync('cli-ts/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
|
||||
echo "Using version ${{ steps.version.outputs.version }} for build"
|
||||
cat cli-ts/package.json | grep '"version"'
|
||||
|
||||
- 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
|
||||
- name: Build and package CLI
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
@@ -139,23 +120,19 @@ jobs:
|
||||
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
|
||||
run: node scripts/package-npm.mjs
|
||||
|
||||
- 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 dist-standalone/dist..."
|
||||
ls -la dist-standalone/dist/
|
||||
|
||||
echo "Checking package.json in dist-standalone..."
|
||||
cat dist-standalone/package.json | grep version
|
||||
cat dist-standalone/package.json
|
||||
|
||||
- name: Publish to NPM with nightly tag
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
|
||||
@@ -46,3 +46,4 @@ test-results
|
||||
/pkg
|
||||
.secrets
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
+25
-2
@@ -1,10 +1,33 @@
|
||||
# Changelog
|
||||
|
||||
## [3.53.1]
|
||||
|
||||
### Fixed
|
||||
- Bug in responses API
|
||||
|
||||
## [3.53.0]
|
||||
|
||||
### Fixed
|
||||
- Removed grok model from free tier
|
||||
|
||||
## [3.52.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Users with ChatGPT Plus or Pro subscriptions can now use GPT-5 models directly through Cline without needing an API key. Authentication is handled via OAuth through OpenAI's authentication system.
|
||||
- Grok models are now moving out of free tier and into paid plans.
|
||||
- Introduces comprehensive Jupyter Notebook support for Cline, enabling AI-assisted editing of `.ipynb` files with full cell-level context awareness.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Bugs in DiffViewProvider for file editing
|
||||
- Ollama's recommended models to use correct identifiers
|
||||
|
||||
## [3.51.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Adding OpenAI gpt-5.2-codex model to the model picker
|
||||
- Adding OpenAI gpt-5.2-codex model to the model picker
|
||||
|
||||
## [3.50.0]
|
||||
|
||||
@@ -1737,4 +1760,4 @@ Add Opus 4.1 through Claude Code
|
||||
|
||||
## [0.0.6]
|
||||
|
||||
- Initial release
|
||||
- Initial release
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
@.clinerules/general.md
|
||||
@.clinerules/network.md
|
||||
@.clinerules/cli.md
|
||||
|
||||
+24
-1
@@ -44,7 +44,7 @@
|
||||
"style": {
|
||||
"useNodejsImportProtocol": "off",
|
||||
"useImportType": "off",
|
||||
"useBlockStatements": "warn",
|
||||
"useBlockStatements": "off",
|
||||
"useNamingConvention": "off",
|
||||
"useThrowOnlyError": "info",
|
||||
"useConsistentArrayType": "off",
|
||||
@@ -147,6 +147,29 @@
|
||||
"src/dev/grit/vscode-api.grit"
|
||||
]
|
||||
},
|
||||
{
|
||||
// Do not use console logging directly, use the Logger service instead.
|
||||
"plugins": [
|
||||
"src/dev/grit/console-log.grit"
|
||||
],
|
||||
"includes": [
|
||||
"**",
|
||||
"!**/webview-ui/**",
|
||||
"!**/evals/**",
|
||||
"!**/standalone/**",
|
||||
"!**/e2e/**",
|
||||
"!**/test/**",
|
||||
"!**/__tests__/**",
|
||||
"!**/*.test.ts",
|
||||
"!**/*.stories.ts",
|
||||
"!src/dev/**",
|
||||
"!**/*.mjs",
|
||||
"!**/*.js",
|
||||
"!**/scripts/**",
|
||||
"!**/*.tsx",
|
||||
"!**/testing-platform/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"includes": [
|
||||
"**",
|
||||
|
||||
+33
-11
@@ -28,12 +28,19 @@ npm run install:all
|
||||
npm run protos
|
||||
|
||||
# Build the CLI
|
||||
npm run compile-cli-ts
|
||||
npm run build:cli
|
||||
```
|
||||
|
||||
Or install the CLI globally:
|
||||
|
||||
```bash
|
||||
# Install all dependencies first
|
||||
npm run install:all
|
||||
|
||||
# Ensure protos are generated
|
||||
npm run protos
|
||||
|
||||
# Build and link the CLI globally
|
||||
cd cli-ts
|
||||
npm install
|
||||
npm run link
|
||||
@@ -189,7 +196,6 @@ 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 |
|
||||
@@ -197,20 +203,36 @@ These options are available for the default command (running a task directly):
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Build and link the package to your terminal
|
||||
npm run link
|
||||
For active development, at the root of this repo:
|
||||
|
||||
# Set your provider (No Cline provider support yet)
|
||||
cline auth
|
||||
1. **Initial setup:**
|
||||
```bash
|
||||
npm run install:all
|
||||
npm run protos
|
||||
```
|
||||
|
||||
# Run a task
|
||||
cline task "Tell me about this codebase"
|
||||
```
|
||||
2. **Make changes to cli-ts:**
|
||||
```bash
|
||||
npm run cli:dev
|
||||
# or
|
||||
cd cli-ts && npm run dev
|
||||
```
|
||||
|
||||
3. **Test your changes:**
|
||||
```bash
|
||||
cline [your-command] # In a new Terminal
|
||||
```
|
||||
|
||||
4. **When done:**
|
||||
```bash
|
||||
npm run cli:unlink
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
cd cli-ts
|
||||
|
||||
# Development build with source maps
|
||||
npm run build
|
||||
|
||||
@@ -294,4 +316,4 @@ npm run protos
|
||||
Make the CLI executable:
|
||||
```bash
|
||||
chmod +x dist/cli.js
|
||||
```
|
||||
```
|
||||
|
||||
+36
-7
@@ -1,3 +1,5 @@
|
||||
import "dotenv/config"
|
||||
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
@@ -78,7 +80,7 @@ const vscodeStubPlugin = {
|
||||
name: "vscode-stub",
|
||||
setup(build) {
|
||||
// Redirect 'vscode' imports to our shim
|
||||
build.onResolve({ filter: /^vscode$/ }, (args) => {
|
||||
build.onResolve({ filter: /^vscode$/ }, () => {
|
||||
return { path: path.join(__dirname, "src", "vscode-shim.ts") }
|
||||
})
|
||||
},
|
||||
@@ -169,15 +171,31 @@ const buildEnvVars = {
|
||||
"process.env.IS_CLI": JSON.stringify("true"),
|
||||
}
|
||||
|
||||
const buildTimeEnvs = [
|
||||
"TELEMETRY_SERVICE_API_KEY",
|
||||
"ERROR_SERVICE_API_KEY",
|
||||
"POSTHOG_TELEMETRY_ENABLED",
|
||||
"OTEL_TELEMETRY_ENABLED",
|
||||
"OTEL_LOGS_EXPORTER",
|
||||
"OTEL_METRICS_EXPORTER",
|
||||
"OTEL_EXPORTER_OTLP_PROTOCOL",
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_HEADERS",
|
||||
"OTEL_METRIC_EXPORT_INTERVAL",
|
||||
"CLINE_ENVIRONMENT",
|
||||
]
|
||||
|
||||
buildTimeEnvs.forEach((envVar) => {
|
||||
if (process.env[envVar]) {
|
||||
console.log(`[cli-ts esbuild] ${envVar} env var is set`)
|
||||
buildEnvVars[`process.env.${envVar}`] = JSON.stringify(process.env[envVar])
|
||||
}
|
||||
})
|
||||
|
||||
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,
|
||||
@@ -193,7 +211,18 @@ const config = {
|
||||
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"],
|
||||
external: [
|
||||
"@grpc/reflection",
|
||||
"grpc-health-check",
|
||||
"better-sqlite3",
|
||||
"ink",
|
||||
"ink-spinner",
|
||||
"ink-picture",
|
||||
"react",
|
||||
"aws4fetch",
|
||||
"pino",
|
||||
"pino-roll",
|
||||
],
|
||||
supported: { "top-level-await": true },
|
||||
banner: {
|
||||
js: `#!/usr/bin/env node
|
||||
|
||||
Generated
+206
-201
@@ -9,13 +9,14 @@
|
||||
"version": "1.0.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"aws4fetch": "^1.0.20",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.1.0",
|
||||
"ink": "^5.0.1",
|
||||
"ink": "npm:@jrichman/ink@6.4.7",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"ora": "^8.0.1",
|
||||
"prompts": "^2.4.2",
|
||||
"react": "^18.3.0"
|
||||
"react": "^19.2.3"
|
||||
},
|
||||
"bin": {
|
||||
"clinedev": "dist/cli.mjs"
|
||||
@@ -23,7 +24,7 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "20.x",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@types/react": "^18.3.27",
|
||||
"@types/react": "^19.2.9",
|
||||
"esbuild": "^0.25.0",
|
||||
"ink-testing-library": "^4.0.0",
|
||||
"rimraf": "^6.0.1",
|
||||
@@ -32,16 +33,31 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@alcalzone/ansi-tokenize": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz",
|
||||
"integrity": "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==",
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.3.tgz",
|
||||
"integrity": "sha512-jsElTJ0sQ4wHRz+C45tfect76BwbTbgkgKByOzpCN9xG61N5V6u/glvg1CsNJhq2xJIFpKHSwG3D2wPPuEYOrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.1",
|
||||
"is-fullwidth-code-point": "^4.0.0"
|
||||
"is-fullwidth-code-point": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.13.1"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@alcalzone/ansi-tokenize/node_modules/is-fullwidth-code-point": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
|
||||
"integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "^1.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
@@ -517,9 +533,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.2.tgz",
|
||||
"integrity": "sha512-21J6xzayjy3O6NdnlO6aXi/urvSRjm6nCI6+nF6ra2YofKruGixN9kfT+dt55HVNwfDmpDHJcaS3JuP/boNnlA==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz",
|
||||
"integrity": "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -531,9 +547,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.2.tgz",
|
||||
"integrity": "sha512-eXBg7ibkNUZ+sTwbFiDKou0BAckeV6kIigK7y5Ko4mB/5A1KLhuzEKovsmfvsL8mQorkoincMFGnQuIT92SKqA==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.56.0.tgz",
|
||||
"integrity": "sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -545,9 +561,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.2.tgz",
|
||||
"integrity": "sha512-UCbaTklREjrc5U47ypLulAgg4njaqfOVLU18VrCrI+6E5MQjuG0lSWaqLlAJwsD7NpFV249XgB0Bi37Zh5Sz4g==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.56.0.tgz",
|
||||
"integrity": "sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -559,9 +575,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.2.tgz",
|
||||
"integrity": "sha512-dP67MA0cCMHFT2g5XyjtpVOtp7y4UyUxN3dhLdt11at5cPKnSm4lY+EhwNvDXIMzAMIo2KU+mc9wxaAQJTn7sQ==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.56.0.tgz",
|
||||
"integrity": "sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -573,9 +589,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.2.tgz",
|
||||
"integrity": "sha512-WDUPLUwfYV9G1yxNRJdXcvISW15mpvod1Wv3ok+Ws93w1HjIVmCIFxsG2DquO+3usMNCpJQ0wqO+3GhFdl6Fow==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.56.0.tgz",
|
||||
"integrity": "sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -587,9 +603,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.2.tgz",
|
||||
"integrity": "sha512-Ng95wtHVEulRwn7R0tMrlUuiLVL/HXA8Lt/MYVpy88+s5ikpntzZba1qEulTuPnPIZuOPcW9wNEiqvZxZmgmqQ==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.56.0.tgz",
|
||||
"integrity": "sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -601,9 +617,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.2.tgz",
|
||||
"integrity": "sha512-AEXMESUDWWGqD6LwO/HkqCZgUE1VCJ1OhbvYGsfqX2Y6w5quSXuyoy/Fg3nRqiwro+cJYFxiw5v4kB2ZDLhxrw==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.56.0.tgz",
|
||||
"integrity": "sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -615,9 +631,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.2.tgz",
|
||||
"integrity": "sha512-ZV7EljjBDwBBBSv570VWj0hiNTdHt9uGznDtznBB4Caj3ch5rgD4I2K1GQrtbvJ/QiB+663lLgOdcADMNVC29Q==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.56.0.tgz",
|
||||
"integrity": "sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -629,9 +645,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.2.tgz",
|
||||
"integrity": "sha512-uvjwc8NtQVPAJtq4Tt7Q49FOodjfbf6NpqXyW/rjXoV+iZ3EJAHLNAnKT5UJBc6ffQVgmXTUL2ifYiLABlGFqA==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.56.0.tgz",
|
||||
"integrity": "sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -643,9 +659,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.2.tgz",
|
||||
"integrity": "sha512-s3KoWVNnye9mm/2WpOZ3JeUiediUVw6AvY/H7jNA6qgKA2V2aM25lMkVarTDfiicn/DLq3O0a81jncXszoyCFA==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.56.0.tgz",
|
||||
"integrity": "sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -657,9 +673,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.2.tgz",
|
||||
"integrity": "sha512-gi21faacK+J8aVSyAUptML9VQN26JRxe484IbF+h3hpG+sNVoMXPduhREz2CcYr5my0NE3MjVvQ5bMKX71pfVA==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.56.0.tgz",
|
||||
"integrity": "sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -671,9 +687,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.2.tgz",
|
||||
"integrity": "sha512-qSlWiXnVaS/ceqXNfnoFZh4IiCA0EwvCivivTGbEu1qv2o+WTHpn1zNmCTAoOG5QaVr2/yhCoLScQtc/7RxshA==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.56.0.tgz",
|
||||
"integrity": "sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -685,9 +701,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.2.tgz",
|
||||
"integrity": "sha512-rPyuLFNoF1B0+wolH277E780NUKf+KoEDb3OyoLbAO18BbeKi++YN6gC/zuJoPPDlQRL3fIxHxCxVEWiem2yXw==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.56.0.tgz",
|
||||
"integrity": "sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -699,9 +715,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.2.tgz",
|
||||
"integrity": "sha512-g+0ZLMook31iWV4PvqKU0i9E78gaZgYpSrYPed/4Bu+nGTgfOPtfs1h11tSSRPXSjC5EzLTjV/1A7L2Vr8pJoQ==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.56.0.tgz",
|
||||
"integrity": "sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -713,9 +729,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.2.tgz",
|
||||
"integrity": "sha512-i+sGeRGsjKZcQRh3BRfpLsM3LX3bi4AoEVqmGDyc50L6KfYsN45wVCSz70iQMwPWr3E5opSiLOwsC9WB4/1pqg==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.56.0.tgz",
|
||||
"integrity": "sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -727,9 +743,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.2.tgz",
|
||||
"integrity": "sha512-C1vLcKc4MfFV6I0aWsC7B2Y9QcsiEcvKkfxprwkPfLaN8hQf0/fKHwSF2lcYzA9g4imqnhic729VB9Fo70HO3Q==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.56.0.tgz",
|
||||
"integrity": "sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -741,9 +757,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.2.tgz",
|
||||
"integrity": "sha512-68gHUK/howpQjh7g7hlD9DvTTt4sNLp1Bb+Yzw2Ki0xvscm2cOdCLZNJNhd2jW8lsTPrHAHuF751BygifW4bkQ==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.56.0.tgz",
|
||||
"integrity": "sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -755,9 +771,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.2.tgz",
|
||||
"integrity": "sha512-1e30XAuaBP1MAizaOBApsgeGZge2/Byd6wV4a8oa6jPdHELbRHBiw7wvo4dp7Ie2PE8TZT4pj9RLGZv9N4qwlw==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.56.0.tgz",
|
||||
"integrity": "sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -769,9 +785,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.2.tgz",
|
||||
"integrity": "sha512-4BJucJBGbuGnH6q7kpPqGJGzZnYrpAzRd60HQSt3OpX/6/YVgSsJnNzR8Ot74io50SeVT4CtCWe/RYIAymFPwA==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.56.0.tgz",
|
||||
"integrity": "sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -783,9 +799,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.2.tgz",
|
||||
"integrity": "sha512-cT2MmXySMo58ENv8p6/O6wI/h/gLnD3D6JoajwXFZH6X9jz4hARqUhWpGuQhOgLNXscfZYRQMJvZDtWNzMAIDw==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.56.0.tgz",
|
||||
"integrity": "sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -797,9 +813,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.2.tgz",
|
||||
"integrity": "sha512-sZnyUgGkuzIXaK3jNMPmUIyJrxu/PjmATQrocpGA1WbCPX8H5tfGgRSuYtqBYAvLuIGp8SPRb1O4d1Fkb5fXaQ==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.56.0.tgz",
|
||||
"integrity": "sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -811,9 +827,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.2.tgz",
|
||||
"integrity": "sha512-sDpFbenhmWjNcEbBcoTV0PWvW5rPJFvu+P7XoTY0YLGRupgLbFY0XPfwIbJOObzO7QgkRDANh65RjhPmgSaAjQ==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.56.0.tgz",
|
||||
"integrity": "sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -825,9 +841,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.2.tgz",
|
||||
"integrity": "sha512-GvJ03TqqaweWCigtKQVBErw2bEhu1tyfNQbarwr94wCGnczA9HF8wqEe3U/Lfu6EdeNP0p6R+APeHVwEqVxpUQ==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.56.0.tgz",
|
||||
"integrity": "sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -839,9 +855,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.2.tgz",
|
||||
"integrity": "sha512-KvXsBvp13oZz9JGe5NYS7FNizLe99Ny+W8ETsuCyjXiKdiGrcz2/J/N8qxZ/RSwivqjQguug07NLHqrIHrqfYw==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.56.0.tgz",
|
||||
"integrity": "sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -853,9 +869,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.2.tgz",
|
||||
"integrity": "sha512-xNO+fksQhsAckRtDSPWaMeT1uIM+JrDRXlerpnWNXhn1TdB3YZ6uKBMBTKP0eX9XtYEP978hHk1f8332i2AW8Q==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.56.0.tgz",
|
||||
"integrity": "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -899,9 +915,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.29",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.29.tgz",
|
||||
"integrity": "sha512-YrT9ArrGaHForBaCNwFjoqJWmn8G1Pr7+BH/vwyLHciA9qT/wSiuOhxGCT50JA5xLvFBd6PIiGkE3afxcPE1nw==",
|
||||
"version": "20.19.30",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz",
|
||||
"integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -919,21 +935,13 @@
|
||||
"kleur": "^3.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.27",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||
"devOptional": true,
|
||||
"version": "19.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz",
|
||||
"integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
@@ -1109,6 +1117,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/aws4fetch": {
|
||||
"version": "1.0.20",
|
||||
"resolved": "https://registry.npmjs.org/aws4fetch/-/aws4fetch-1.0.20.tgz",
|
||||
"integrity": "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
@@ -1236,7 +1250,7 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
@@ -1265,9 +1279,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.43.0",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.43.0.tgz",
|
||||
"integrity": "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==",
|
||||
"version": "1.44.0",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
|
||||
"integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
@@ -1421,43 +1435,43 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ink": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ink/-/ink-5.2.1.tgz",
|
||||
"integrity": "sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg==",
|
||||
"name": "@jrichman/ink",
|
||||
"version": "6.4.7",
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.7.tgz",
|
||||
"integrity": "sha512-QHyxhNF5VonF5cRmdAJD/UPucB9nRx3FozWMjQrDGfBxfAL9lpyu72/MlFPgloS1TMTGsOt7YN6dTPPA6mh0Aw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@alcalzone/ansi-tokenize": "^0.1.3",
|
||||
"@alcalzone/ansi-tokenize": "^0.2.1",
|
||||
"ansi-escapes": "^7.0.0",
|
||||
"ansi-styles": "^6.2.1",
|
||||
"auto-bind": "^5.0.1",
|
||||
"chalk": "^5.3.0",
|
||||
"chalk": "^5.6.0",
|
||||
"cli-boxes": "^3.0.0",
|
||||
"cli-cursor": "^4.0.0",
|
||||
"cli-truncate": "^4.0.0",
|
||||
"code-excerpt": "^4.0.0",
|
||||
"es-toolkit": "^1.22.0",
|
||||
"es-toolkit": "^1.39.10",
|
||||
"indent-string": "^5.0.0",
|
||||
"is-in-ci": "^1.0.0",
|
||||
"is-in-ci": "^2.0.0",
|
||||
"mnemonist": "^0.40.3",
|
||||
"patch-console": "^2.0.0",
|
||||
"react-reconciler": "^0.29.0",
|
||||
"scheduler": "^0.23.0",
|
||||
"react-reconciler": "^0.32.0",
|
||||
"signal-exit": "^3.0.7",
|
||||
"slice-ansi": "^7.1.0",
|
||||
"stack-utils": "^2.0.6",
|
||||
"string-width": "^7.2.0",
|
||||
"string-width": "^8.1.0",
|
||||
"type-fest": "^4.27.0",
|
||||
"widest-line": "^5.0.0",
|
||||
"wrap-ansi": "^9.0.0",
|
||||
"ws": "^8.18.0",
|
||||
"yoga-layout": "~3.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18.0.0",
|
||||
"react": ">=18.0.0",
|
||||
"react-devtools-core": "^4.19.1"
|
||||
"@types/react": ">=19.0.0",
|
||||
"react": ">=19.0.0",
|
||||
"react-devtools-core": "^6.1.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
@@ -1502,6 +1516,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ink/node_modules/string-width": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz",
|
||||
"integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "^1.3.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz",
|
||||
@@ -1515,15 +1545,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/is-in-ci": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz",
|
||||
"integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz",
|
||||
"integrity": "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"is-in-ci": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
@@ -1553,12 +1583,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/kleur": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
|
||||
@@ -1596,18 +1620,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
|
||||
@@ -1675,6 +1687,15 @@
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/mnemonist": {
|
||||
"version": "0.40.3",
|
||||
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz",
|
||||
"integrity": "sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"obliterator": "^2.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
@@ -1694,6 +1715,12 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/obliterator": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
|
||||
"integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||
@@ -1904,31 +1931,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-reconciler": {
|
||||
"version": "0.29.2",
|
||||
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz",
|
||||
"integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==",
|
||||
"version": "0.32.0",
|
||||
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.32.0.tgz",
|
||||
"integrity": "sha512-2NPMOzgTlG0ZWdIf3qG+dcbLSoAc/uLfOwckc3ofy5sSK0pLJqnQLpUFxvGcN2rlXSjnVtGeeFLNimCQEj5gOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.3.1"
|
||||
"react": "^19.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/restore-cursor": {
|
||||
@@ -1968,9 +1991,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.2.tgz",
|
||||
"integrity": "sha512-PggGy4dhwx5qaW+CKBilA/98Ql9keyfnb7lh4SR6shQ91QQQi1ORJ1v4UinkdP2i87OBs9AQFooQylcrrRfIcg==",
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.56.0.tgz",
|
||||
"integrity": "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1984,42 +2007,39 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.55.2",
|
||||
"@rollup/rollup-android-arm64": "4.55.2",
|
||||
"@rollup/rollup-darwin-arm64": "4.55.2",
|
||||
"@rollup/rollup-darwin-x64": "4.55.2",
|
||||
"@rollup/rollup-freebsd-arm64": "4.55.2",
|
||||
"@rollup/rollup-freebsd-x64": "4.55.2",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.55.2",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.55.2",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.55.2",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.55.2",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.55.2",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.55.2",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.55.2",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.55.2",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.55.2",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.55.2",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.55.2",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.55.2",
|
||||
"@rollup/rollup-linux-x64-musl": "4.55.2",
|
||||
"@rollup/rollup-openbsd-x64": "4.55.2",
|
||||
"@rollup/rollup-openharmony-arm64": "4.55.2",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.55.2",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.55.2",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.55.2",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.55.2",
|
||||
"@rollup/rollup-android-arm-eabi": "4.56.0",
|
||||
"@rollup/rollup-android-arm64": "4.56.0",
|
||||
"@rollup/rollup-darwin-arm64": "4.56.0",
|
||||
"@rollup/rollup-darwin-x64": "4.56.0",
|
||||
"@rollup/rollup-freebsd-arm64": "4.56.0",
|
||||
"@rollup/rollup-freebsd-x64": "4.56.0",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.56.0",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.56.0",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.56.0",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.56.0",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.56.0",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.56.0",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.56.0",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.56.0",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.56.0",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.56.0",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.56.0",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.56.0",
|
||||
"@rollup/rollup-linux-x64-musl": "4.56.0",
|
||||
"@rollup/rollup-openbsd-x64": "4.56.0",
|
||||
"@rollup/rollup-openharmony-arm64": "4.56.0",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.56.0",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.56.0",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.56.0",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.56.0",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
"version": "0.26.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
|
||||
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
@@ -2882,21 +2902,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/widest-line": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz",
|
||||
"integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"string-width": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
|
||||
|
||||
+39
-10
@@ -1,35 +1,59 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"version": "1.0.0",
|
||||
"description": "Cline CLI - TypeScript implementation that reuses core Cline functionality",
|
||||
"version": "2.0.0",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"main": "dist/cli.mjs",
|
||||
"bin": {
|
||||
"clinedev": "./dist/cli.mjs"
|
||||
"cline": "./dist/cli.mjs"
|
||||
},
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node esbuild.mjs",
|
||||
"build:production": "node esbuild.mjs --production",
|
||||
"watch": "node esbuild.mjs --watch",
|
||||
"dev": "npm run watch",
|
||||
"dev": "IS_DEV=true && npm run link && npm run watch ; npm run unlink",
|
||||
"clean": "rimraf dist",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"link": "npm run build && npm link",
|
||||
"unlink": "npm unlink -g @cline/cli && npm unlink -g cline",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"keywords": [
|
||||
"cline",
|
||||
"cli",
|
||||
"claude",
|
||||
"dev",
|
||||
"mcp",
|
||||
"openrouter",
|
||||
"coding",
|
||||
"agent",
|
||||
"autonomous",
|
||||
"chatgpt",
|
||||
"sonnet",
|
||||
"ai",
|
||||
"coding-assistant"
|
||||
"llama",
|
||||
"cli"
|
||||
],
|
||||
"author": "Cline Bot Inc.",
|
||||
"author": {
|
||||
"name": "Cline Bot Inc."
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cline/cline"
|
||||
},
|
||||
"homepage": "https://cline.bot",
|
||||
"bugs": {
|
||||
"url": "https://github.com/cline/cline/issues"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "20.x",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@types/react": "^18.3.27",
|
||||
"@types/react": "^19.2.9",
|
||||
"dotenv": "^16.4.5",
|
||||
"esbuild": "^0.25.0",
|
||||
"ink-testing-library": "^4.0.0",
|
||||
"rimraf": "^6.0.1",
|
||||
@@ -37,12 +61,17 @@
|
||||
"vitest": "^4.0.17"
|
||||
},
|
||||
"dependencies": {
|
||||
"aws4fetch": "^1.0.20",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.1.0",
|
||||
"ink": "^5.0.1",
|
||||
"ink": "npm:@jrichman/ink@6.4.7",
|
||||
"ink-picture": "^1.3.3",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"ora": "^8.0.1",
|
||||
"nanoid": "^5.1.6",
|
||||
"pino": "^10.0.0",
|
||||
"pino-roll": "^4.0.0",
|
||||
"prompts": "^2.4.2",
|
||||
"react": "^18.3.0"
|
||||
"react": "^19.2.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ function formatBalance(balance: number | null): string {
|
||||
return `$${(balance / 1000000).toFixed(2)}`
|
||||
}
|
||||
|
||||
export const AccountInfoView: React.FC<AccountInfoViewProps> = ({ controller }) => {
|
||||
export const AccountInfoView: React.FC<AccountInfoViewProps> = React.memo(({ controller }) => {
|
||||
const [provider, setProvider] = useState<string | null>(null)
|
||||
const [balance, setBalance] = useState<number | null>(null)
|
||||
const [organization, setOrganization] = useState<ClineAccountOrganization | null>(null)
|
||||
@@ -191,4 +191,4 @@ export const AccountInfoView: React.FC<AccountInfoViewProps> = ({ controller })
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Action buttons component for CLI
|
||||
* Shows primary/secondary buttons above the input field
|
||||
* Supports keyboard navigation (1/2 for buttons, arrows to navigate, esc to cancel)
|
||||
*/
|
||||
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { Box, Text } from "ink"
|
||||
import React from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { isFileSaveTool, parseToolFromMessage } from "../utils/tools"
|
||||
|
||||
/**
|
||||
* Button action types that determine the behavior
|
||||
*/
|
||||
export type ButtonActionType =
|
||||
| "approve" // Send yesButtonClicked
|
||||
| "reject" // Send noButtonClicked
|
||||
| "proceed" // Send messageResponse or yesButtonClicked
|
||||
| "new_task" // Start a new task
|
||||
| "cancel" // Cancel streaming
|
||||
| "retry" // Retry the last action
|
||||
|
||||
/**
|
||||
* Button configuration for different message states
|
||||
*/
|
||||
export interface ButtonConfig {
|
||||
sendingDisabled: boolean
|
||||
enableButtons: boolean
|
||||
primaryText?: string
|
||||
secondaryText?: string
|
||||
primaryAction?: ButtonActionType
|
||||
secondaryAction?: ButtonActionType
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized button state configurations based on task lifecycle
|
||||
*/
|
||||
const BUTTON_CONFIGS: Record<string, ButtonConfig> = {
|
||||
// Error recovery states
|
||||
api_req_failed: {
|
||||
sendingDisabled: true,
|
||||
enableButtons: true,
|
||||
primaryText: "Retry",
|
||||
secondaryText: "Start New Task",
|
||||
primaryAction: "retry",
|
||||
secondaryAction: "new_task",
|
||||
},
|
||||
mistake_limit_reached: {
|
||||
sendingDisabled: false,
|
||||
enableButtons: true,
|
||||
primaryText: "Proceed Anyways",
|
||||
secondaryText: "Start New Task",
|
||||
primaryAction: "proceed",
|
||||
secondaryAction: "new_task",
|
||||
},
|
||||
|
||||
// Tool approval states
|
||||
tool_approve: {
|
||||
sendingDisabled: false,
|
||||
enableButtons: true,
|
||||
primaryText: "Approve",
|
||||
secondaryText: "Reject",
|
||||
primaryAction: "approve",
|
||||
secondaryAction: "reject",
|
||||
},
|
||||
tool_save: {
|
||||
sendingDisabled: false,
|
||||
enableButtons: true,
|
||||
primaryText: "Save",
|
||||
secondaryText: "Reject",
|
||||
primaryAction: "approve",
|
||||
secondaryAction: "reject",
|
||||
},
|
||||
|
||||
// Command execution states
|
||||
command: {
|
||||
sendingDisabled: false,
|
||||
enableButtons: true,
|
||||
primaryText: "Run Command",
|
||||
secondaryText: "Reject",
|
||||
primaryAction: "approve",
|
||||
secondaryAction: "reject",
|
||||
},
|
||||
command_output: {
|
||||
sendingDisabled: false,
|
||||
enableButtons: true,
|
||||
primaryText: "Proceed While Running",
|
||||
secondaryText: undefined,
|
||||
primaryAction: "proceed",
|
||||
secondaryAction: undefined,
|
||||
},
|
||||
|
||||
// Browser and external tool states
|
||||
browser_action_launch: {
|
||||
sendingDisabled: false,
|
||||
enableButtons: true,
|
||||
primaryText: "Approve",
|
||||
secondaryText: "Reject",
|
||||
primaryAction: "approve",
|
||||
secondaryAction: "reject",
|
||||
},
|
||||
use_mcp_server: {
|
||||
sendingDisabled: false,
|
||||
enableButtons: true,
|
||||
primaryText: "Approve",
|
||||
secondaryText: "Reject",
|
||||
primaryAction: "approve",
|
||||
secondaryAction: "reject",
|
||||
},
|
||||
followup: {
|
||||
sendingDisabled: false,
|
||||
enableButtons: false,
|
||||
primaryText: undefined,
|
||||
secondaryText: undefined,
|
||||
primaryAction: undefined,
|
||||
secondaryAction: undefined,
|
||||
},
|
||||
plan_mode_respond: {
|
||||
sendingDisabled: false,
|
||||
enableButtons: false,
|
||||
primaryText: undefined,
|
||||
secondaryText: undefined,
|
||||
primaryAction: undefined,
|
||||
secondaryAction: undefined,
|
||||
},
|
||||
|
||||
// Task lifecycle states
|
||||
completion_result: {
|
||||
sendingDisabled: false,
|
||||
enableButtons: true,
|
||||
primaryText: "Start New Task",
|
||||
secondaryText: undefined,
|
||||
primaryAction: "new_task",
|
||||
secondaryAction: undefined,
|
||||
},
|
||||
resume_task: {
|
||||
sendingDisabled: false,
|
||||
enableButtons: true,
|
||||
primaryText: "Resume Task",
|
||||
secondaryText: "Exit",
|
||||
primaryAction: "proceed",
|
||||
secondaryAction: "reject",
|
||||
},
|
||||
resume_completed_task: {
|
||||
sendingDisabled: false,
|
||||
enableButtons: true,
|
||||
primaryText: "Start New Task",
|
||||
secondaryText: "Exit",
|
||||
primaryAction: "new_task",
|
||||
secondaryAction: "reject",
|
||||
},
|
||||
new_task: {
|
||||
sendingDisabled: false,
|
||||
enableButtons: true,
|
||||
primaryText: "Start New Task with Context",
|
||||
secondaryText: undefined,
|
||||
primaryAction: "new_task",
|
||||
secondaryAction: undefined,
|
||||
},
|
||||
|
||||
// Streaming/partial states
|
||||
partial: {
|
||||
sendingDisabled: true,
|
||||
enableButtons: true,
|
||||
primaryText: undefined,
|
||||
secondaryText: "Cancel",
|
||||
primaryAction: undefined,
|
||||
secondaryAction: "cancel",
|
||||
},
|
||||
|
||||
// Default states
|
||||
default: {
|
||||
sendingDisabled: false,
|
||||
enableButtons: false,
|
||||
primaryText: undefined,
|
||||
secondaryText: undefined,
|
||||
primaryAction: undefined,
|
||||
secondaryAction: undefined,
|
||||
},
|
||||
api_req_active: {
|
||||
sendingDisabled: true,
|
||||
enableButtons: true,
|
||||
primaryText: undefined,
|
||||
secondaryText: "Cancel",
|
||||
primaryAction: undefined,
|
||||
secondaryAction: "cancel",
|
||||
},
|
||||
}
|
||||
|
||||
const errorTypes = ["api_req_failed", "mistake_limit_reached"]
|
||||
|
||||
/**
|
||||
* Get button configuration based on message type and state
|
||||
*/
|
||||
export function getButtonConfig(message: ClineMessage | undefined, isStreaming: boolean = false): ButtonConfig {
|
||||
if (!message) {
|
||||
return BUTTON_CONFIGS.default
|
||||
}
|
||||
|
||||
const isError = message?.ask ? errorTypes.includes(message.ask) : false
|
||||
|
||||
// Special case: command_output should show "Proceed While Running" button even while streaming
|
||||
if (message.type === "ask" && message.ask === "command_output") {
|
||||
return BUTTON_CONFIGS.command_output
|
||||
}
|
||||
|
||||
// Handle partial/streaming messages first
|
||||
if (isStreaming && !isError) {
|
||||
return BUTTON_CONFIGS.partial
|
||||
}
|
||||
|
||||
// Handle ask messages (user interaction required)
|
||||
if (message.type === "ask") {
|
||||
switch (message.ask) {
|
||||
// Error recovery states
|
||||
case "api_req_failed":
|
||||
return BUTTON_CONFIGS.api_req_failed
|
||||
case "mistake_limit_reached":
|
||||
return BUTTON_CONFIGS.mistake_limit_reached
|
||||
|
||||
// Tool approval (most common)
|
||||
case "tool": {
|
||||
const toolInfo = parseToolFromMessage(message.text)
|
||||
if (toolInfo && isFileSaveTool(toolInfo.toolName)) {
|
||||
return BUTTON_CONFIGS.tool_save
|
||||
}
|
||||
return BUTTON_CONFIGS.tool_approve
|
||||
}
|
||||
|
||||
// Command execution
|
||||
case "command":
|
||||
return BUTTON_CONFIGS.command
|
||||
case "command_output":
|
||||
return BUTTON_CONFIGS.command_output
|
||||
|
||||
// Standard approvals
|
||||
case "followup":
|
||||
return BUTTON_CONFIGS.followup
|
||||
case "browser_action_launch":
|
||||
return BUTTON_CONFIGS.browser_action_launch
|
||||
case "use_mcp_server":
|
||||
return BUTTON_CONFIGS.use_mcp_server
|
||||
case "plan_mode_respond":
|
||||
return BUTTON_CONFIGS.plan_mode_respond
|
||||
|
||||
// Task lifecycle
|
||||
case "completion_result":
|
||||
return BUTTON_CONFIGS.completion_result
|
||||
case "resume_task":
|
||||
return BUTTON_CONFIGS.resume_task
|
||||
case "resume_completed_task":
|
||||
return BUTTON_CONFIGS.resume_completed_task
|
||||
case "new_task":
|
||||
return BUTTON_CONFIGS.new_task
|
||||
|
||||
default:
|
||||
return BUTTON_CONFIGS.tool_approve
|
||||
}
|
||||
}
|
||||
|
||||
// Handle say messages
|
||||
if (message.type === "say" && message.say === "api_req_started") {
|
||||
return BUTTON_CONFIGS.api_req_active
|
||||
}
|
||||
|
||||
if (message.type === "say" && message.say === "command_output") {
|
||||
return BUTTON_CONFIGS.command_output
|
||||
}
|
||||
|
||||
return BUTTON_CONFIGS.partial
|
||||
}
|
||||
|
||||
interface ActionButtonsProps {
|
||||
config: ButtonConfig
|
||||
mode?: "act" | "plan"
|
||||
}
|
||||
|
||||
/**
|
||||
* Action buttons component
|
||||
* Shows primary and/or secondary buttons based on config
|
||||
* Buttons take full width (one button = full, two buttons = half each)
|
||||
* Does not show cancel-only buttons (ThinkingIndicator handles that with esc)
|
||||
*/
|
||||
export const ActionButtons: React.FC<ActionButtonsProps> = ({ config, mode = "act" }) => {
|
||||
if (!config.enableButtons) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Don't show cancel buttons (ThinkingIndicator handles esc to interrupt)
|
||||
// Don't show new_task buttons (CLI doesn't handle starting new tasks)
|
||||
const hiddenActions = ["cancel", "new_task"]
|
||||
const hasPrimary = !!config.primaryText && !hiddenActions.includes(config.primaryAction || "")
|
||||
const hasSecondary = !!config.secondaryText && !hiddenActions.includes(config.secondaryAction || "")
|
||||
|
||||
if (!hasPrimary && !hasSecondary) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Calculate button widths based on terminal width
|
||||
const terminalWidth = process.stdout.columns || 80
|
||||
const buttonCount = (hasPrimary ? 1 : 0) + (hasSecondary ? 1 : 0)
|
||||
const gapWidth = buttonCount > 1 ? 1 : 0 // 1 char gap between buttons
|
||||
const availableWidth = terminalWidth - 2 - gapWidth // 1 space padding on each side
|
||||
const buttonWidth = Math.floor(availableWidth / buttonCount)
|
||||
|
||||
const modeColor = mode === "plan" ? "yellow" : COLORS.primaryBlue
|
||||
|
||||
const renderButton = (text: string, shortcut: string) => {
|
||||
const label = ` ${text} (${shortcut}) `
|
||||
const padding = Math.max(0, buttonWidth - label.length)
|
||||
const leftPad = Math.floor(padding / 2)
|
||||
const rightPad = padding - leftPad
|
||||
const paddedLabel = " ".repeat(leftPad) + label + " ".repeat(rightPad)
|
||||
|
||||
return (
|
||||
<Text backgroundColor={modeColor} color="black">
|
||||
{paddedLabel}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" gap={1} marginLeft={1} width="100%">
|
||||
{hasPrimary && renderButton(config.primaryText!, "1")}
|
||||
{hasSecondary && renderButton(config.secondaryText!, "2")}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Reusable API key input component
|
||||
* Shows a password-masked input field for entering API keys
|
||||
*/
|
||||
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
|
||||
interface ApiKeyInputProps {
|
||||
providerName: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onSubmit: (value: string) => void
|
||||
onCancel: () => void
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
|
||||
providerName,
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
isActive = true,
|
||||
}) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
// Filter out mouse escape sequences
|
||||
if (isMouseEscapeSequence(input)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (key.escape) {
|
||||
onCancel()
|
||||
return
|
||||
}
|
||||
if (key.return) {
|
||||
onSubmit(value)
|
||||
return
|
||||
}
|
||||
if (key.backspace || key.delete) {
|
||||
onChange(value.slice(0, -1))
|
||||
return
|
||||
}
|
||||
if (input && !key.ctrl && !key.meta) {
|
||||
onChange(value + input)
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported && isActive },
|
||||
)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={COLORS.primaryBlue}>
|
||||
{providerName} API Key
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">Paste your API key below</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color="white">{"•".repeat(value.length)}</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">Enter to save, Esc to cancel</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -5,9 +5,14 @@ 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("./ChatView", () => ({
|
||||
ChatView: ({ taskId, controller }: any) =>
|
||||
React.createElement(Text, null, `ChatView: ${taskId || "no-id"} controller=${controller ? "present" : "none"}`),
|
||||
}))
|
||||
|
||||
vi.mock("./TaskJsonView", () => ({
|
||||
TaskJsonView: ({ taskId, verbose }: any) =>
|
||||
React.createElement(Text, null, `TaskJsonView: ${taskId || "no-id"} verbose=${String(verbose)}`),
|
||||
}))
|
||||
|
||||
vi.mock("./HistoryView", () => ({
|
||||
@@ -22,14 +27,14 @@ 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,
|
||||
}))
|
||||
|
||||
vi.mock("../context/StdinContext", () => ({
|
||||
StdinProvider: ({ children }: any) => children,
|
||||
}))
|
||||
|
||||
describe("App", () => {
|
||||
const mockController = {
|
||||
dispose: vi.fn(),
|
||||
@@ -41,9 +46,15 @@ describe("App", () => {
|
||||
})
|
||||
|
||||
describe("view routing", () => {
|
||||
it("should render TaskView when view is task", () => {
|
||||
it("should render ChatView when view is task", () => {
|
||||
const { lastFrame } = render(<App controller={mockController} taskId="test-task" view="task" />)
|
||||
expect(lastFrame()).toContain("TaskView")
|
||||
expect(lastFrame()).toContain("ChatView")
|
||||
expect(lastFrame()).toContain("test-task")
|
||||
})
|
||||
|
||||
it("should render TaskJsonView when view is task with jsonOutput", () => {
|
||||
const { lastFrame } = render(<App controller={mockController} jsonOutput={true} taskId="test-task" view="task" />)
|
||||
expect(lastFrame()).toContain("TaskJsonView")
|
||||
expect(lastFrame()).toContain("test-task")
|
||||
})
|
||||
|
||||
@@ -71,17 +82,17 @@ describe("App", () => {
|
||||
expect(lastFrame()).toContain("openai")
|
||||
})
|
||||
|
||||
it("should render WelcomeView when view is welcome", () => {
|
||||
it("should render ChatView when view is welcome", () => {
|
||||
const { lastFrame } = render(
|
||||
<App controller={mockController} onWelcomeExit={() => {}} onWelcomeSubmit={() => {}} view="welcome" />,
|
||||
)
|
||||
expect(lastFrame()).toContain("WelcomeView")
|
||||
expect(lastFrame()).toContain("ChatView")
|
||||
})
|
||||
})
|
||||
|
||||
describe("default props", () => {
|
||||
it("should use default verbose=false", () => {
|
||||
const { lastFrame } = render(<App controller={mockController} view="task" />)
|
||||
it("should use default verbose=false with jsonOutput", () => {
|
||||
const { lastFrame } = render(<App controller={mockController} jsonOutput={true} view="task" />)
|
||||
expect(lastFrame()).toContain("verbose=false")
|
||||
})
|
||||
|
||||
@@ -92,14 +103,19 @@ describe("App", () => {
|
||||
})
|
||||
|
||||
describe("props passing", () => {
|
||||
it("should pass verbose to TaskView", () => {
|
||||
const { lastFrame } = render(<App controller={mockController} verbose={true} view="task" />)
|
||||
it("should pass verbose to TaskJsonView", () => {
|
||||
const { lastFrame } = render(<App controller={mockController} jsonOutput={true} verbose={true} view="task" />)
|
||||
expect(lastFrame()).toContain("verbose=true")
|
||||
})
|
||||
|
||||
it("should pass taskId to TaskView", () => {
|
||||
it("should pass taskId to ChatView", () => {
|
||||
const { lastFrame } = render(<App controller={mockController} taskId="my-task-123" view="task" />)
|
||||
expect(lastFrame()).toContain("my-task-123")
|
||||
})
|
||||
|
||||
it("should pass controller to ChatView", () => {
|
||||
const { lastFrame } = render(<App controller={mockController} view="task" />)
|
||||
expect(lastFrame()).toContain("controller=present")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,12 +5,13 @@
|
||||
|
||||
import { Box } from "ink"
|
||||
import React, { ReactNode, useCallback, useState } from "react"
|
||||
import { StdinProvider } from "../context/StdinContext"
|
||||
import { TaskContextProvider } from "../context/TaskContext"
|
||||
import { AuthView } from "./AuthView"
|
||||
import { ChatView } from "./ChatView"
|
||||
import { ConfigView } from "./ConfigView"
|
||||
import { HistoryView } from "./HistoryView"
|
||||
import { TaskView } from "./TaskView"
|
||||
import { WelcomeView } from "./WelcomeView"
|
||||
import { TaskJsonView } from "./TaskJsonView"
|
||||
|
||||
export type ViewType = "task" | "history" | "config" | "auth" | "welcome"
|
||||
|
||||
@@ -42,8 +43,11 @@ interface SkillInfo {
|
||||
interface AppProps {
|
||||
view: ViewType
|
||||
taskId?: string
|
||||
verbose?: boolean
|
||||
controller?: any
|
||||
// Output Style
|
||||
verbose?: boolean
|
||||
jsonOutput?: boolean
|
||||
// Status Callbacks
|
||||
onComplete?: () => void
|
||||
onError?: () => void
|
||||
// For history view
|
||||
@@ -86,12 +90,19 @@ interface AppProps {
|
||||
// For welcome view
|
||||
onWelcomeSubmit?: (prompt: string, imagePaths: string[]) => void
|
||||
onWelcomeExit?: () => void
|
||||
initialPrompt?: string
|
||||
initialImages?: string[]
|
||||
// Stdin support
|
||||
isRawModeSupported?: boolean
|
||||
// Robot position (calculated before Ink mounts)
|
||||
robotTopRow?: number
|
||||
}
|
||||
|
||||
export const App: React.FC<AppProps> = ({
|
||||
view: initialView,
|
||||
taskId,
|
||||
verbose = false,
|
||||
jsonOutput = false,
|
||||
controller,
|
||||
onComplete,
|
||||
onError,
|
||||
@@ -126,6 +137,10 @@ export const App: React.FC<AppProps> = ({
|
||||
authQuickSetup,
|
||||
onWelcomeSubmit,
|
||||
onWelcomeExit,
|
||||
initialPrompt,
|
||||
initialImages,
|
||||
isRawModeSupported = true,
|
||||
robotTopRow,
|
||||
}) => {
|
||||
const [currentView, setCurrentView] = useState<ViewType>(initialView)
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | undefined>(taskId)
|
||||
@@ -140,7 +155,7 @@ export const App: React.FC<AppProps> = ({
|
||||
}, [])
|
||||
|
||||
// Handle welcome submit when navigating internally (e.g., from auth -> welcome)
|
||||
const handleInternalWelcomeSubmit = useCallback(
|
||||
const _handleInternalWelcomeSubmit = useCallback(
|
||||
async (prompt: string, imagePaths: string[]) => {
|
||||
if (onWelcomeSubmit) {
|
||||
// If external handler provided, use it
|
||||
@@ -176,14 +191,6 @@ export const App: React.FC<AppProps> = ({
|
||||
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
|
||||
@@ -236,13 +243,35 @@ export const App: React.FC<AppProps> = ({
|
||||
)
|
||||
break
|
||||
|
||||
case "task":
|
||||
case "welcome":
|
||||
content = <WelcomeView controller={controller} onExit={onWelcomeExit} onSubmit={handleInternalWelcomeSubmit} />
|
||||
content = (
|
||||
<TaskContextProvider controller={controller}>
|
||||
{jsonOutput ? (
|
||||
<TaskJsonView onComplete={onComplete} onError={onError} taskId={selectedTaskId} verbose={verbose} />
|
||||
) : (
|
||||
<ChatView
|
||||
controller={controller}
|
||||
initialImages={initialImages}
|
||||
initialPrompt={initialPrompt}
|
||||
onComplete={onComplete}
|
||||
onError={onError}
|
||||
onExit={onWelcomeExit}
|
||||
robotTopRow={robotTopRow}
|
||||
taskId={selectedTaskId}
|
||||
/>
|
||||
)}
|
||||
</TaskContextProvider>
|
||||
)
|
||||
break
|
||||
|
||||
default:
|
||||
content = null
|
||||
}
|
||||
|
||||
return <Box>{content}</Box>
|
||||
return (
|
||||
<StdinProvider isRawModeSupported={isRawModeSupported}>
|
||||
<Box>{content}</Box>
|
||||
</StdinProvider>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,8 +6,10 @@
|
||||
import type { ClineAsk } from "@shared/ExtensionMessage"
|
||||
import { Box, Text, useApp, useInput } from "ink"
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { useTaskController } from "../context/TaskContext"
|
||||
import { useLastCompletedAskMessage } from "../hooks/useStateSubscriber"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { jsonParseSafe } from "../utils/parser"
|
||||
import { getCliMessagePrefixIcon } from "./MessageRow"
|
||||
|
||||
@@ -60,7 +62,7 @@ function getPromptType(ask: ClineAsk, text: string): PromptType {
|
||||
|
||||
export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
|
||||
const { exit } = useApp()
|
||||
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const controller = useTaskController()
|
||||
const lastAskMessage = useLastCompletedAskMessage()
|
||||
const [textInput, setTextInput] = useState("")
|
||||
@@ -108,6 +110,11 @@ export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
|
||||
// Handle keyboard input
|
||||
useInput(
|
||||
(input, key) => {
|
||||
// Filter out mouse escape sequences
|
||||
if (isMouseEscapeSequence(input)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!lastAskMessage || responded) {
|
||||
return
|
||||
}
|
||||
@@ -142,7 +149,7 @@ export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
|
||||
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)
|
||||
sendResponse("messageResponse", selectedOption)
|
||||
} else {
|
||||
// Regular character input for free text
|
||||
setTextInput((prev) => prev + input)
|
||||
@@ -195,7 +202,7 @@ export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: !!lastAskMessage && !responded },
|
||||
{ isActive: isRawModeSupported && !!lastAskMessage && !responded },
|
||||
)
|
||||
|
||||
if (!lastAskMessage || responded) {
|
||||
@@ -231,11 +238,9 @@ export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
|
||||
<Text>{icon} </Text>
|
||||
<Text color="cyan">Or type: </Text>
|
||||
<Text>{textInput}</Text>
|
||||
<Text color="gray">▌</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
<Text color="gray" dimColor>
|
||||
(Enter number to select, or type response + Enter)
|
||||
</Text>
|
||||
<Text color="gray">(Enter number to select, or type response + Enter)</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -247,11 +252,9 @@ export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
|
||||
<Text>{icon} </Text>
|
||||
<Text color="cyan">Reply: </Text>
|
||||
<Text>{textInput}</Text>
|
||||
<Text color="gray">▌</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
<Text color="gray" dimColor>
|
||||
(Type your response and press Enter)
|
||||
</Text>
|
||||
<Text color="gray">(Type your response and press Enter)</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -275,11 +278,9 @@ export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
|
||||
<Text>{icon} </Text>
|
||||
<Text color="cyan">Or type: </Text>
|
||||
<Text>{textInput}</Text>
|
||||
<Text color="gray">▌</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
<Text color="gray" dimColor>
|
||||
(Enter number to select, or type response + Enter)
|
||||
</Text>
|
||||
<Text color="gray">(Enter number to select, or type response + Enter)</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -291,11 +292,9 @@ export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
|
||||
<Text>{icon} </Text>
|
||||
<Text color="cyan">Reply: </Text>
|
||||
<Text>{textInput}</Text>
|
||||
<Text color="gray">▌</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
<Text color="gray" dimColor>
|
||||
(Type response + Enter, or just Enter to switch to Act mode)
|
||||
</Text>
|
||||
<Text color="gray">(Type response + Enter, or just Enter to switch to Act mode)</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -329,11 +328,9 @@ export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
|
||||
<Text>{icon} </Text>
|
||||
<Text color="cyan">Follow-up: </Text>
|
||||
<Text>{textInput}</Text>
|
||||
<Text color="gray">▌</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
<Text color="gray" dimColor>
|
||||
(Type follow-up question + Enter, or q to exit)
|
||||
</Text>
|
||||
<Text color="gray">(Type follow-up question + Enter, or q to exit)</Text>
|
||||
</Box>
|
||||
)
|
||||
|
||||
|
||||
+634
-238
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,557 @@
|
||||
/**
|
||||
* Claude Code style chat message component
|
||||
* Renders messages with:
|
||||
* - ❯ for user messages
|
||||
* - ⏺ for assistant messages and tool calls
|
||||
* - ⎿ for tool results (indented)
|
||||
*/
|
||||
|
||||
import { COMMAND_OUTPUT_STRING } from "@shared/combineCommandSequences"
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { Box, Text } from "ink"
|
||||
import React from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { jsonParseSafe } from "../utils/parser"
|
||||
import { getToolDescription, isFileEditTool, parseToolFromMessage } from "../utils/tools"
|
||||
import { DiffView } from "./DiffView"
|
||||
|
||||
/**
|
||||
* Render inline markdown: **bold**, *italic*, `code`
|
||||
* Returns array of React nodes with appropriate styling
|
||||
*/
|
||||
function renderInlineMarkdown(text: string): React.ReactNode[] {
|
||||
const nodes: React.ReactNode[] = []
|
||||
// Match **bold**, *italic*, or `code` - order matters (** before *)
|
||||
const regex = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g
|
||||
let lastIndex = 0
|
||||
let match
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
// Add text before match
|
||||
if (match.index > lastIndex) {
|
||||
nodes.push(text.slice(lastIndex, match.index))
|
||||
}
|
||||
|
||||
const fullMatch = match[0]
|
||||
const key = `md-${match.index}`
|
||||
|
||||
if (fullMatch.startsWith("**") && fullMatch.endsWith("**")) {
|
||||
// Bold
|
||||
nodes.push(
|
||||
<Text bold key={key}>
|
||||
{fullMatch.slice(2, -2)}
|
||||
</Text>,
|
||||
)
|
||||
} else if (fullMatch.startsWith("*") && fullMatch.endsWith("*")) {
|
||||
// Italic
|
||||
nodes.push(
|
||||
<Text italic key={key}>
|
||||
{fullMatch.slice(1, -1)}
|
||||
</Text>,
|
||||
)
|
||||
} else if (fullMatch.startsWith("`") && fullMatch.endsWith("`")) {
|
||||
// Inline code
|
||||
nodes.push(
|
||||
<Text dimColor key={key}>
|
||||
{fullMatch.slice(1, -1)}
|
||||
</Text>,
|
||||
)
|
||||
}
|
||||
|
||||
lastIndex = regex.lastIndex
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (lastIndex < text.length) {
|
||||
nodes.push(text.slice(lastIndex))
|
||||
}
|
||||
|
||||
return nodes.length > 0 ? nodes : [text]
|
||||
}
|
||||
|
||||
/**
|
||||
* Render text with inline markdown support
|
||||
*/
|
||||
const MarkdownText: React.FC<{ children: string; color?: string }> = ({ children, color }) => {
|
||||
const nodes = renderInlineMarkdown(children)
|
||||
return <Text color={color}>{nodes}</Text>
|
||||
}
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: ClineMessage
|
||||
isStreaming?: boolean
|
||||
mode?: "act" | "plan"
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-column layout for messages with a dot prefix.
|
||||
* Keeps content from wrapping under the dot.
|
||||
*
|
||||
* For this to work properly, parent containers must have width="100%"
|
||||
* so flexGrow={1} on the content box has a reference width to fill.
|
||||
*/
|
||||
const DotRow: React.FC<{ children: React.ReactNode; color?: string }> = ({ children, color }) => (
|
||||
<Box flexDirection="row">
|
||||
<Box width={2}>
|
||||
<Text color={color}>⏺</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>{children}</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
/**
|
||||
* Two-column layout for tool results with ⎿ prefix.
|
||||
* Keeps content from wrapping under the prefix.
|
||||
*/
|
||||
const ResultRow: React.FC<{ children: React.ReactNode; isFirst?: boolean }> = ({ children, isFirst }) => (
|
||||
<Box flexDirection="row">
|
||||
<Box width={3}>
|
||||
<Text dimColor>{isFirst ? "⎿ " : " "}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>{children}</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
/**
|
||||
* Get the primary argument to display for a tool (file path, command, url, etc.)
|
||||
*/
|
||||
function getToolMainArg(_toolName: string, args: Record<string, unknown>): string {
|
||||
// File path
|
||||
if (typeof args.path === "string") return args.path
|
||||
if (typeof args.file_path === "string") return args.file_path
|
||||
|
||||
// Command - truncate long commands
|
||||
if (typeof args.command === "string") {
|
||||
return args.command.length > 60 ? args.command.substring(0, 57) + "..." : args.command
|
||||
}
|
||||
|
||||
// Search regex
|
||||
if (typeof args.regex === "string") return args.regex
|
||||
|
||||
// URL
|
||||
if (typeof args.url === "string") return args.url
|
||||
|
||||
// Search query
|
||||
if (typeof args.query === "string") return args.query
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a tool call in webview style: "Cline wants to read this file:" / "Cline read this file:"
|
||||
*/
|
||||
const ToolCallText: React.FC<{
|
||||
toolName: string
|
||||
args: Record<string, unknown>
|
||||
mode?: "act" | "plan"
|
||||
isAsk?: boolean
|
||||
}> = ({ toolName, args, mode, isAsk = false }) => {
|
||||
const desc = getToolDescription(toolName)
|
||||
const actionText = isAsk ? desc.ask : desc.say
|
||||
const mainArg = getToolMainArg(toolName, args)
|
||||
const toolColor = mode === "plan" ? "yellow" : COLORS.primaryBlue
|
||||
|
||||
return (
|
||||
<Text>
|
||||
<Text color={toolColor}>Cline {actionText}</Text>
|
||||
{mainArg && (
|
||||
<Text>
|
||||
<Text color={toolColor}>: </Text>
|
||||
<Text>{mainArg}</Text>
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text with ellipsis
|
||||
*/
|
||||
function truncate(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text
|
||||
return text.substring(0, maxLength - 3) + "..."
|
||||
}
|
||||
|
||||
/**
|
||||
* Format tool result for display
|
||||
*/
|
||||
function formatToolResult(result: string, maxLines: number = 5): string[] {
|
||||
const lines = result.split("\n")
|
||||
if (lines.length <= maxLines) {
|
||||
return lines
|
||||
}
|
||||
const displayLines = lines.slice(0, maxLines)
|
||||
displayLines.push(`... ${lines.length - maxLines} more lines`)
|
||||
return displayLines
|
||||
}
|
||||
|
||||
export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode }) => {
|
||||
const { type, ask, say, text } = message
|
||||
const toolColor = mode === "plan" ? "yellow" : COLORS.primaryBlue
|
||||
|
||||
// User messages (task, user_feedback)
|
||||
if (say === "task" || say === "user_feedback") {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box backgroundColor="blackBright" paddingRight={1}>
|
||||
<Text color="white" dimColor>
|
||||
{" "}
|
||||
>{" "}
|
||||
</Text>
|
||||
<Text color="white">{text}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Assistant text response (hide reasoning traces - they're verbose and clutter the UI)
|
||||
if (say === "reasoning") {
|
||||
return null
|
||||
}
|
||||
if (say === "text") {
|
||||
if (!text?.trim()) return null
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow>
|
||||
<Text>{text}</Text>
|
||||
</DotRow>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Tool calls (ask) and tool results (say)
|
||||
const isToolAsk = type === "ask" && ask === "tool"
|
||||
const isToolSay = say === "tool"
|
||||
if ((isToolAsk || isToolSay) && text) {
|
||||
const toolInfo = parseToolFromMessage(text)
|
||||
if (toolInfo) {
|
||||
const filePath = toolInfo.args.path || toolInfo.args.file_path
|
||||
|
||||
// File edit tools - show diff
|
||||
if (isFileEditTool(toolInfo.toolName) && filePath && toolInfo.args.content) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor}>
|
||||
<ToolCallText args={toolInfo.args} isAsk={isToolAsk} mode={mode} toolName={toolInfo.toolName} />
|
||||
</DotRow>
|
||||
<Box marginLeft={2}>
|
||||
<DiffView content={toolInfo.args.content} />
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Only show result content for completed tools (say), not for pending asks
|
||||
const resultLines = isToolSay && toolInfo.result?.trim() ? formatToolResult(toolInfo.result, 5) : []
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor}>
|
||||
<ToolCallText args={toolInfo.args} isAsk={isToolAsk} mode={mode} toolName={toolInfo.toolName} />
|
||||
</DotRow>
|
||||
{resultLines.length > 0 && (
|
||||
<Box flexDirection="column" marginLeft={2} width="100%">
|
||||
{resultLines.map((line, idx) => (
|
||||
<ResultRow isFirst={idx === 0} key={idx}>
|
||||
<Text dimColor>{line}</Text>
|
||||
</ResultRow>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
// Fallback for unparseable tool messages
|
||||
if (isToolSay) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor}>
|
||||
<Text color={toolColor}>{truncate(text, 100)}</Text>
|
||||
</DotRow>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Command execution (ask or say) - now includes combined output
|
||||
if ((type === "ask" && ask === "command") || say === "command") {
|
||||
if (!text) return null
|
||||
|
||||
// Parse command and output from combined text
|
||||
const outputIndex = text.indexOf(COMMAND_OUTPUT_STRING)
|
||||
const command = outputIndex === -1 ? text : text.slice(0, outputIndex).trim()
|
||||
const output = outputIndex === -1 ? "" : text.slice(outputIndex + COMMAND_OUTPUT_STRING.length).trim()
|
||||
|
||||
const isAsk = type === "ask"
|
||||
const label = isAsk ? "Cline wants to execute this command: " : "Cline executed this command: "
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor}>
|
||||
<Text>
|
||||
<Text color={toolColor}>{label}</Text>
|
||||
<Text>{truncate(command, 60)}</Text>
|
||||
</Text>
|
||||
</DotRow>
|
||||
{output && (
|
||||
<Box flexDirection="column" marginLeft={2} width="100%">
|
||||
{formatToolResult(output, 8).map((line, idx) => (
|
||||
<ResultRow isFirst={idx === 0} key={idx}>
|
||||
<Text dimColor>{line}</Text>
|
||||
</ResultRow>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Command output - should not appear after combineCommandSequences, but handle as fallback
|
||||
if (say === "command_output" && text) {
|
||||
const lines = formatToolResult(text, 8)
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<Box flexDirection="column" marginLeft={2} width="100%">
|
||||
{lines.map((line, idx) => (
|
||||
<ResultRow isFirst={idx === 0} key={idx}>
|
||||
<Text dimColor>{line}</Text>
|
||||
</ResultRow>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Error messages
|
||||
if (say === "error" || (type === "ask" && ask === "api_req_failed")) {
|
||||
// Try to parse error message if it's JSON
|
||||
let errorMessage = text || "Unknown error"
|
||||
if (text) {
|
||||
const parsed = jsonParseSafe(text, { message: undefined as string | undefined })
|
||||
if (parsed.message) {
|
||||
errorMessage = parsed.message
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color="red">
|
||||
<Text bold color="red">
|
||||
Error
|
||||
</Text>
|
||||
<Text color="red">: {errorMessage}</Text>
|
||||
</DotRow>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Error retry messages
|
||||
if (say === "error_retry" && text) {
|
||||
const retryInfo = jsonParseSafe(text, {
|
||||
failed: false,
|
||||
attempt: 0,
|
||||
maxAttempts: 3,
|
||||
errorMessage: undefined as string | undefined,
|
||||
})
|
||||
|
||||
// Parse nested errorMessage if it's a JSON string
|
||||
let errorMsg = "Request failed"
|
||||
if (retryInfo.errorMessage) {
|
||||
try {
|
||||
const errorObj = jsonParseSafe(retryInfo.errorMessage, { message: undefined as string | undefined })
|
||||
errorMsg = errorObj.message || retryInfo.errorMessage
|
||||
} catch {
|
||||
errorMsg = retryInfo.errorMessage
|
||||
}
|
||||
}
|
||||
|
||||
if (retryInfo.failed) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color="red">
|
||||
<Text bold color="red">
|
||||
Failed
|
||||
</Text>
|
||||
<Text color="red"> after {retryInfo.maxAttempts} retries</Text>
|
||||
</DotRow>
|
||||
<Box marginLeft={2}>
|
||||
<Text color="red" dimColor>
|
||||
{errorMsg}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color="yellow">
|
||||
<Text bold color="yellow">
|
||||
Retrying
|
||||
</Text>
|
||||
<Text color="yellow">
|
||||
... (attempt {retryInfo.attempt}/{retryInfo.maxAttempts})
|
||||
</Text>
|
||||
</DotRow>
|
||||
<Box marginLeft={2}>
|
||||
<Text color="yellow" dimColor>
|
||||
{errorMsg}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Completion result
|
||||
// Only render ask: "completion_result" if it has text - the empty ask is just for UI confirmation
|
||||
if (say === "completion_result" || (type === "ask" && ask === "completion_result" && text)) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color="green">
|
||||
<Text color="green">Task completed</Text>
|
||||
</DotRow>
|
||||
{text && (
|
||||
<Box marginLeft={2}>
|
||||
<MarkdownText color="greenBright">{text}</MarkdownText>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// API request info (show cost/tokens inline)
|
||||
if (say === "api_req_started" && text) {
|
||||
// Skip showing these - they're summarized in the status bar
|
||||
return null
|
||||
}
|
||||
|
||||
// Browser actions
|
||||
if (say === "browser_action" || say === "browser_action_launch") {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor}>
|
||||
<Text>
|
||||
<Text color={toolColor}>Cline used the browser</Text>
|
||||
{text && (
|
||||
<Text>
|
||||
<Text color={toolColor}>: </Text>
|
||||
<Text>{truncate(text, 50)}</Text>
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</DotRow>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// MCP server
|
||||
if (say === "mcp_server_request_started") {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor}>
|
||||
<Text>
|
||||
<Text color={toolColor}>Cline is using an MCP tool</Text>
|
||||
{text && (
|
||||
<Text>
|
||||
<Text color={toolColor}>: </Text>
|
||||
<Text>{truncate(text, 50)}</Text>
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</DotRow>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Info messages
|
||||
if (say === "info") {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color="gray">
|
||||
<Text color="gray">{text}</Text>
|
||||
</DotRow>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Followup questions from assistant
|
||||
if (type === "ask" && ask === "followup" && text) {
|
||||
const parsed = jsonParseSafe(text, {
|
||||
question: undefined as string | undefined,
|
||||
options: undefined as string[] | undefined,
|
||||
selected: undefined as string | undefined,
|
||||
})
|
||||
if (parsed.question) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow>
|
||||
<MarkdownText>{parsed.question}</MarkdownText>
|
||||
</DotRow>
|
||||
{parsed.options && parsed.options.length > 0 && (
|
||||
<Box flexDirection="column" paddingLeft={2}>
|
||||
{parsed.options.map((opt, idx) => {
|
||||
const isSelected = parsed.selected === opt
|
||||
return (
|
||||
<Text color={isSelected ? "green" : "gray"} key={opt}>
|
||||
{isSelected ? "✓" : `${idx + 1}.`} {opt}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Plan mode response
|
||||
if (type === "ask" && ask === "plan_mode_respond" && text) {
|
||||
const parsed = jsonParseSafe(text, { response: undefined as string | undefined })
|
||||
if (parsed.response) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color="yellow">
|
||||
<MarkdownText color="yellow">{parsed.response}</MarkdownText>
|
||||
</DotRow>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Skip other message types
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a list of messages in Claude Code style
|
||||
*/
|
||||
interface ChatMessageListProps {
|
||||
messages: ClineMessage[]
|
||||
maxMessages?: number
|
||||
}
|
||||
|
||||
export const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, maxMessages }) => {
|
||||
// Filter out messages we don't want to display
|
||||
const displayMessages = messages.filter((m) => {
|
||||
// Skip api_req_finished, they're just markers
|
||||
if (m.say === "api_req_finished") return false
|
||||
// Skip empty text messages
|
||||
if (m.say === "text" && !m.text?.trim()) return false
|
||||
// Skip checkpoint messages
|
||||
if (m.say === "checkpoint_created") return false
|
||||
return true
|
||||
})
|
||||
|
||||
// Optionally limit number of messages shown
|
||||
const messagesToShow = maxMessages ? displayMessages.slice(-maxMessages) : displayMessages
|
||||
|
||||
// Check if last message is streaming
|
||||
const lastMessage = messagesToShow[messagesToShow.length - 1]
|
||||
const isLastStreaming = lastMessage?.partial === true
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{messagesToShow.map((msg, idx) => (
|
||||
<ChatMessage isStreaming={idx === messagesToShow.length - 1 && isLastStreaming} key={msg.ts} message={msg} />
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Reusable Checkbox component for settings panels
|
||||
*/
|
||||
|
||||
import { Box, Text } from "ink"
|
||||
import React from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
|
||||
interface CheckboxProps {
|
||||
/** Label displayed next to the checkbox */
|
||||
label: string
|
||||
/** Current checked state */
|
||||
checked: boolean
|
||||
/** Whether this checkbox is currently selected/focused */
|
||||
isSelected?: boolean
|
||||
/** Optional description shown below the label */
|
||||
description?: string
|
||||
}
|
||||
|
||||
export const Checkbox: React.FC<CheckboxProps> = ({ label, checked, isSelected = false, description }) => {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text>
|
||||
<Text bold color={isSelected ? COLORS.primaryBlue : undefined}>
|
||||
{isSelected ? "❯" : " "}{" "}
|
||||
</Text>
|
||||
<Text color={isSelected || checked ? COLORS.primaryBlue : "gray"}>{checked ? "[✓]" : "[ ]"}</Text>
|
||||
<Text color={isSelected ? COLORS.primaryBlue : "white"}> {label}</Text>
|
||||
{isSelected && <Text color="gray"> (Tab to toggle)</Text>}
|
||||
</Text>
|
||||
{description && (
|
||||
<Box marginLeft={6}>
|
||||
<Text color="gray">{description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useState } from "react"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
|
||||
export type RestoreType = "task" | "workspace" | "taskAndWorkspace"
|
||||
|
||||
@@ -78,63 +79,63 @@ const RESTORE_TYPE_OPTIONS: { type: RestoreType; label: string; description: str
|
||||
]
|
||||
|
||||
export const CheckpointMenu: React.FC<CheckpointMenuProps> = ({ messages, onSelect, onCancel }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
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()
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (key.escape) {
|
||||
if (stage === "restoreType") {
|
||||
setStage("checkpoint")
|
||||
} else {
|
||||
onCancel()
|
||||
}
|
||||
return
|
||||
}
|
||||
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)
|
||||
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")
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
{ isActive: isRawModeSupported },
|
||||
)
|
||||
|
||||
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>
|
||||
<Text color="gray">Checkpoints are created at task completion points</Text>
|
||||
<Text color="gray">Press Escape to close</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -145,9 +146,7 @@ export const CheckpointMenu: React.FC<CheckpointMenuProps> = ({ messages, onSele
|
||||
<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>
|
||||
<Text color="gray">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
|
||||
@@ -158,9 +157,9 @@ export const CheckpointMenu: React.FC<CheckpointMenuProps> = ({ messages, onSele
|
||||
<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 color="gray">
|
||||
{" "}
|
||||
- {dateStr} {timeStr}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
@@ -177,7 +176,7 @@ export const CheckpointMenu: React.FC<CheckpointMenuProps> = ({ messages, onSele
|
||||
<Text bold color="cyan">
|
||||
Restore Type
|
||||
</Text>
|
||||
<Text color="gray" dimColor>
|
||||
<Text color="gray">
|
||||
Restoring to: {selectedCp?.label} ({selectedCp?.date.toLocaleString()})
|
||||
</Text>
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
@@ -192,17 +191,13 @@ export const CheckpointMenu: React.FC<CheckpointMenuProps> = ({ messages, onSele
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginLeft={4}>
|
||||
<Text color="gray" dimColor>
|
||||
{opt.description}
|
||||
</Text>
|
||||
<Text color="gray">{opt.description}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
<Text color="gray" dimColor marginTop={1}>
|
||||
(↑/↓ to select, Enter to confirm, Escape to go back)
|
||||
</Text>
|
||||
<Text color="gray">(↑/↓ to select, Enter to confirm, Escape to go back)</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@shared/storage/state-keys"
|
||||
import { Box, Text, useApp, useInput } from "ink"
|
||||
import React, { useMemo, useState } from "react"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import {
|
||||
BooleanSelect,
|
||||
buildConfigEntries,
|
||||
@@ -99,6 +100,7 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
|
||||
onOpenFolder,
|
||||
}) => {
|
||||
const { exit } = useApp()
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const [currentTab, setCurrentTab] = useState<TabView>("settings")
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
@@ -253,10 +255,10 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
|
||||
return
|
||||
}
|
||||
|
||||
// List navigation
|
||||
if (key.upArrow) {
|
||||
// List navigation (arrow keys and vim-style j/k)
|
||||
if (key.upArrow || input === "k") {
|
||||
setSelectedIndex((i) => (i > 0 ? i - 1 : currentListLength - 1))
|
||||
} else if (key.downArrow) {
|
||||
} else if (key.downArrow || input === "j") {
|
||||
setSelectedIndex((i) => (i < currentListLength - 1 ? i + 1 : 0))
|
||||
}
|
||||
|
||||
@@ -289,7 +291,7 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
|
||||
onOpenFolder(currentTab as "rules" | "workflows" | "hooks" | "skills", isGlobal)
|
||||
}
|
||||
},
|
||||
{ isActive: !isEditing },
|
||||
{ isActive: isRawModeSupported && !isEditing },
|
||||
)
|
||||
|
||||
// Scrolling window
|
||||
@@ -505,7 +507,7 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
|
||||
|
||||
// Help text based on current tab
|
||||
const getHelpText = () => {
|
||||
const base = "↑/↓ Navigate • Tab/1-5 Switch tabs • q/Esc Exit"
|
||||
const base = "↑/↓/j/k Navigate • Tab/1-5 Switch tabs • q/Esc Exit"
|
||||
if (currentTab === "settings") {
|
||||
return `${base} • Enter/e Edit • r Reset`
|
||||
}
|
||||
@@ -528,7 +530,7 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
|
||||
|
||||
{currentListLength > MAX_VISIBLE && (
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray" dimColor>
|
||||
<Text color="gray">
|
||||
{startIndex > 0 ? "↑ " : " "}
|
||||
Showing {startIndex + 1}-{Math.min(startIndex + MAX_VISIBLE, currentListLength)} of {currentListLength}
|
||||
{startIndex + MAX_VISIBLE < currentListLength ? " ↓" : " "}
|
||||
@@ -539,13 +541,9 @@ export const ConfigView: React.FC<ConfigViewProps> = ({
|
||||
<Text color="gray">{SEPARATOR}</Text>
|
||||
|
||||
<Box flexDirection="column">
|
||||
<Text color="gray" dimColor>
|
||||
{getHelpText()}
|
||||
</Text>
|
||||
<Text color="gray">{getHelpText()}</Text>
|
||||
{currentTab === "settings" && selectedConfigEntry && !selectedConfigEntry.isEditable && (
|
||||
<Text color="yellow" dimColor>
|
||||
This field is read-only ({selectedConfigEntry.type} type or not a setting)
|
||||
</Text>
|
||||
<Text color="yellow">This field is read-only ({selectedConfigEntry.type} type or not a setting)</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useState } from "react"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
|
||||
// ============================================================================
|
||||
// Types & Constants
|
||||
@@ -192,17 +193,22 @@ interface TextInputProps {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
|
||||
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)
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported },
|
||||
)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
@@ -211,11 +217,9 @@ export const TextInput: React.FC<TextInputProps> = ({ label, onChange, onCancel,
|
||||
</Text>
|
||||
<Box>
|
||||
<Text color="white">{value}</Text>
|
||||
<Text color="gray">▌</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
<Text color="gray" dimColor>
|
||||
Type: {type} • Enter to save • Esc to cancel
|
||||
</Text>
|
||||
<Text color="gray">Type: {type} • Enter to save • Esc to cancel</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -228,17 +232,21 @@ interface BooleanSelectProps {
|
||||
}
|
||||
|
||||
export const BooleanSelect: React.FC<BooleanSelectProps> = ({ label, onCancel, onSelect, value }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
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)
|
||||
}
|
||||
})
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
if (key.escape) {
|
||||
onCancel()
|
||||
} else if (key.return) {
|
||||
onSelect(selected)
|
||||
} else if (key.upArrow || key.downArrow) {
|
||||
setSelected((prev) => !prev)
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported },
|
||||
)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
@@ -249,9 +257,7 @@ export const BooleanSelect: React.FC<BooleanSelectProps> = ({ label, onCancel, o
|
||||
<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>
|
||||
<Text color="gray">↑/↓ to toggle • Enter to save • Esc to cancel</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -266,12 +272,7 @@ export const ConfigRow: React.FC<{ entry: ConfigEntry; isSelected: boolean }> =
|
||||
<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>
|
||||
)}
|
||||
{!entry.isEditable && <Text color="gray"> (read-only)</Text>}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
@@ -292,11 +293,7 @@ export const ToggleRow: React.FC<{
|
||||
<Text color={entry.enabled ? "green" : "red"}>{entry.enabled ? "●" : "○"}</Text>
|
||||
<Text> </Text>
|
||||
<Text color="white">{fileName}</Text>
|
||||
{showType && (
|
||||
<Text color="gray" dimColor>
|
||||
{typeLabel}
|
||||
</Text>
|
||||
)}
|
||||
{showType && <Text color="gray">{typeLabel}</Text>}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
@@ -336,7 +333,7 @@ export const SkillRow: React.FC<{
|
||||
</Box>
|
||||
{skill.description && (
|
||||
<Box marginLeft={4}>
|
||||
<Text color="gray" dimColor>
|
||||
<Text color="gray">
|
||||
{skill.description.length > 60 ? skill.description.slice(0, 57) + "..." : skill.description}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -12,6 +12,7 @@ import React, { useCallback, useEffect, useState } from "react"
|
||||
|
||||
import type { Controller } from "@/core/controller"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { StdinProvider } from "../context/StdinContext"
|
||||
import { ConfigView } from "./ConfigView"
|
||||
|
||||
interface HookInfo {
|
||||
@@ -39,6 +40,7 @@ interface ConfigViewWrapperProps {
|
||||
workspaceState: Record<string, unknown>
|
||||
hooksEnabled: boolean
|
||||
skillsEnabled: boolean
|
||||
isRawModeSupported?: boolean
|
||||
}
|
||||
|
||||
export const ConfigViewWrapper: React.FC<ConfigViewWrapperProps> = ({
|
||||
@@ -48,6 +50,7 @@ export const ConfigViewWrapper: React.FC<ConfigViewWrapperProps> = ({
|
||||
workspaceState: initialWorkspaceState,
|
||||
hooksEnabled,
|
||||
skillsEnabled,
|
||||
isRawModeSupported = true,
|
||||
}) => {
|
||||
// Settings state (managed locally for UI updates)
|
||||
const [globalStateLocal, setGlobalStateLocal] = useState<Record<string, unknown>>(initialGlobalState)
|
||||
@@ -267,30 +270,32 @@ export const ConfigViewWrapper: React.FC<ConfigViewWrapperProps> = ({
|
||||
)
|
||||
|
||||
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}
|
||||
/>
|
||||
<StdinProvider isRawModeSupported={isRawModeSupported}>
|
||||
<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}
|
||||
/>
|
||||
</StdinProvider>
|
||||
)
|
||||
}
|
||||
|
||||
+173
-132
@@ -1,59 +1,93 @@
|
||||
/**
|
||||
* DiffView component for displaying file diffs in Ink
|
||||
* Shows unified diff output with colored lines for additions/deletions
|
||||
* Supports SEARCH/REPLACE format and ApplyPatch format
|
||||
*/
|
||||
|
||||
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 */
|
||||
/** Diff content (SEARCH/REPLACE format, ApplyPatch format, or raw content for new files) */
|
||||
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
|
||||
type: "add" | "remove" | "context" | "separator"
|
||||
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
|
||||
interface ParsedPatch {
|
||||
additions: number
|
||||
deletions: number
|
||||
lines: DiffLine[]
|
||||
}
|
||||
|
||||
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)
|
||||
// Constants for format markers
|
||||
const MARKERS = {
|
||||
SEARCH_BLOCK: "------- SEARCH",
|
||||
SEARCH_SEPARATOR: "=======",
|
||||
REPLACE_BLOCK: "+++++++ REPLACE",
|
||||
NEW_BEGIN: "*** Begin Patch",
|
||||
NEW_END: "*** End Patch",
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Parse SEARCH/REPLACE format into diff lines
|
||||
* Format: ------- SEARCH\n...\n=======\n...\n+++++++ REPLACE
|
||||
*/
|
||||
function parseSearchReplaceFormat(content: string): ParsedPatch {
|
||||
const result: ParsedPatch = { additions: 0, deletions: 0, lines: [] }
|
||||
|
||||
// Find all SEARCH blocks
|
||||
const searchRegex = /-{7,} SEARCH/g
|
||||
const searchPositions: number[] = []
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = searchRegex.exec(content)) !== null) {
|
||||
searchPositions.push(match.index)
|
||||
}
|
||||
|
||||
for (let i = 0; i < searchPositions.length; i++) {
|
||||
// Add separator between blocks
|
||||
if (i > 0) {
|
||||
result.lines.push({ type: "separator", content: "" })
|
||||
}
|
||||
|
||||
const start = searchPositions[i]
|
||||
const end = i < searchPositions.length - 1 ? searchPositions[i + 1] : content.length
|
||||
const blockContent = content.substring(start, end)
|
||||
|
||||
// Extract content after SEARCH marker
|
||||
const afterSearch = blockContent.substring(MARKERS.SEARCH_BLOCK.length).replace(/^\r?\n/, "")
|
||||
const separatorIndex = afterSearch.indexOf(MARKERS.SEARCH_SEPARATOR)
|
||||
|
||||
if (separatorIndex === -1) {
|
||||
// Still streaming - only SEARCH block available
|
||||
const searchContent = afterSearch.trimEnd()
|
||||
for (const line of searchContent.split("\n")) {
|
||||
result.lines.push({ type: "remove", content: line })
|
||||
result.deletions++
|
||||
}
|
||||
} else {
|
||||
// Extract SEARCH block (deletions)
|
||||
const searchContent = afterSearch.substring(0, separatorIndex).replace(/\r?\n$/, "")
|
||||
for (const line of searchContent.split("\n")) {
|
||||
result.lines.push({ type: "remove", content: line })
|
||||
result.deletions++
|
||||
}
|
||||
|
||||
// Extract REPLACE block (additions)
|
||||
const afterSeparator = afterSearch.substring(separatorIndex + MARKERS.SEARCH_SEPARATOR.length).replace(/^\r?\n/, "")
|
||||
const replaceEndIndex = afterSeparator.indexOf(MARKERS.REPLACE_BLOCK)
|
||||
const replaceContent =
|
||||
replaceEndIndex !== -1
|
||||
? afterSeparator.substring(0, replaceEndIndex).replace(/\r?\n$/, "")
|
||||
: afterSeparator.trimEnd()
|
||||
|
||||
for (const line of replaceContent.split("\n")) {
|
||||
result.lines.push({ type: "add", content: line })
|
||||
result.additions++
|
||||
}
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,115 +95,122 @@ function parseDiff(diff: string): DiffLine[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Format line number with padding
|
||||
* Parse ApplyPatch format into diff lines
|
||||
* Format: *** Begin Patch\n*** Update File: path\n+line\n-line\n*** End Patch
|
||||
*/
|
||||
function formatLineNumber(num: number | undefined, width: number): string {
|
||||
if (num === undefined) {
|
||||
return " ".repeat(width)
|
||||
function parseApplyPatchFormat(content: string): ParsedPatch {
|
||||
const result: ParsedPatch = { additions: 0, deletions: 0, lines: [] }
|
||||
|
||||
const beginIndex = content.indexOf(MARKERS.NEW_BEGIN)
|
||||
if (beginIndex === -1) return result
|
||||
|
||||
const endIndex = content.indexOf(MARKERS.NEW_END)
|
||||
const contentStart = beginIndex + MARKERS.NEW_BEGIN.length
|
||||
const contentEnd = endIndex !== -1 ? endIndex : content.length
|
||||
const patchContent = content.substring(contentStart, contentEnd).trim()
|
||||
|
||||
for (const line of patchContent.split("\n")) {
|
||||
// Skip file header lines
|
||||
if (line.match(/^\*\*\* (Add|Update|Delete) File:/)) continue
|
||||
if (line.trim() === "@@") continue
|
||||
|
||||
if (line.startsWith("+")) {
|
||||
const hasSpace = line.startsWith("+ ")
|
||||
result.lines.push({ type: "add", content: hasSpace ? line.slice(2) : line.slice(1) })
|
||||
result.additions++
|
||||
} else if (line.startsWith("-")) {
|
||||
const hasSpace = line.startsWith("- ")
|
||||
result.lines.push({ type: "remove", content: hasSpace ? line.slice(2) : line.slice(1) })
|
||||
result.deletions++
|
||||
} else if (line.trim()) {
|
||||
// Context line
|
||||
result.lines.push({ type: "context", content: line })
|
||||
}
|
||||
}
|
||||
return String(num).padStart(width, " ")
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a new file with all lines shown as additions
|
||||
* Parse tool content into diff lines
|
||||
* Detects format and delegates to appropriate parser
|
||||
*/
|
||||
const NewFileView: React.FC<{ path: string; content: string; maxLines: number }> = ({ path, content, maxLines }) => {
|
||||
function parseToolContent(content: string): ParsedPatch {
|
||||
// Try SEARCH/REPLACE format first
|
||||
if (content.includes(MARKERS.SEARCH_BLOCK)) {
|
||||
return parseSearchReplaceFormat(content)
|
||||
}
|
||||
|
||||
// Try ApplyPatch format
|
||||
if (content.includes(MARKERS.NEW_BEGIN)) {
|
||||
return parseApplyPatchFormat(content)
|
||||
}
|
||||
|
||||
// Fallback: treat as new file (all additions)
|
||||
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 {
|
||||
additions: lines.length,
|
||||
deletions: 0,
|
||||
lines: lines.map((line) => ({ type: "add", content: line })),
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
// Dim diff colors similar to IDE diff views
|
||||
const DIFF_COLORS = {
|
||||
addBg: "rgb(35, 61, 41)", // dark muted green
|
||||
addFg: "rgb(156, 204, 122)", // light green text
|
||||
removeBg: "rgb(62, 36, 36)", // dark muted red
|
||||
removeFg: "rgb(224, 139, 139)", // light red/pink text
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Render a diff line with full-width background color highlighting
|
||||
* Uses Box with backgroundColor to handle wrapping properly
|
||||
*/
|
||||
const DiffLineRow: React.FC<{ line: DiffLine }> = ({ line }) => {
|
||||
if (line.type === "separator") {
|
||||
return <Text> </Text>
|
||||
}
|
||||
|
||||
const prefix = line.type === "add" ? "+" : line.type === "remove" ? "-" : " "
|
||||
const content = prefix + line.content
|
||||
|
||||
switch (line.type) {
|
||||
case "add":
|
||||
return (
|
||||
<Box backgroundColor={DIFF_COLORS.addBg} width="100%">
|
||||
<Text color={DIFF_COLORS.addFg}>{content}</Text>
|
||||
</Box>
|
||||
))}
|
||||
{lines.length > maxLines && <Text dimColor>... and {lines.length - maxLines} more lines</Text>}
|
||||
</Box>
|
||||
)
|
||||
)
|
||||
case "remove":
|
||||
return (
|
||||
<Box backgroundColor={DIFF_COLORS.removeBg} width="100%">
|
||||
<Text color={DIFF_COLORS.removeFg}>{content}</Text>
|
||||
</Box>
|
||||
)
|
||||
case "context":
|
||||
return <Text dimColor>{content}</Text>
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a unified diff with colored additions and deletions
|
||||
* DiffView component that renders file edits as a diff
|
||||
* Supports SEARCH/REPLACE format and ApplyPatch format
|
||||
*/
|
||||
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)
|
||||
export const DiffView: React.FC<DiffViewProps> = ({ content }) => {
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = parseToolContent(content)
|
||||
|
||||
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 flexDirection="column" width="100%">
|
||||
{parsed.lines.map((line, idx) => (
|
||||
<DiffLineRow key={idx} line={line} />
|
||||
))}
|
||||
</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>
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
|
||||
import { Box, Text } from "ink"
|
||||
import React from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import type { FileSearchResult } from "../utils/file-search"
|
||||
import { getVisibleWindow } from "../utils/slash-commands"
|
||||
|
||||
interface FileMentionMenuProps {
|
||||
results: FileSearchResult[]
|
||||
@@ -41,59 +43,24 @@ export const FileMentionMenu: React.FC<FileMentionMenuProps> = ({ results, selec
|
||||
)
|
||||
}
|
||||
|
||||
// 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)
|
||||
const { items: visibleResults, startIndex } = getVisibleWindow(results, selectedIndex)
|
||||
const hasMoreBelow = startIndex + visibleResults.length < results.length
|
||||
|
||||
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 isSelected = startIndex + idx === selectedIndex
|
||||
const displayPath = truncatePath(result.path)
|
||||
|
||||
return (
|
||||
<Box key={result.path}>
|
||||
<Text backgroundColor={isSelected ? "blue" : undefined} color={isSelected ? "white" : undefined}>
|
||||
{isSelected ? "❯ " : " "}
|
||||
{displayPath}
|
||||
<Text color={isSelected ? COLORS.primaryBlue : 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>
|
||||
{hasMoreBelow && <Text color="gray">{" "}▼</Text>}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Highlighted input component for CLI
|
||||
* Renders text with @ mentions and / commands highlighted, plus a movable cursor
|
||||
*/
|
||||
|
||||
import { mentionRegexGlobal } from "@shared/context-mentions"
|
||||
import { Text } from "ink"
|
||||
import React from "react"
|
||||
|
||||
interface HighlightedInputProps {
|
||||
text: string
|
||||
cursorPos?: number
|
||||
availableCommands?: string[]
|
||||
}
|
||||
|
||||
// Regex for / commands (at start or after whitespace)
|
||||
const slashCommandRegex = /(^|\s)(\/[a-zA-Z0-9_.-]+)/g
|
||||
|
||||
interface Segment {
|
||||
text: string
|
||||
type: "normal" | "mention" | "command"
|
||||
startIndex: number
|
||||
}
|
||||
|
||||
function parseInput(text: string, availableCommands?: string[]): Segment[] {
|
||||
const highlights: { start: number; end: number; type: "mention" | "command" }[] = []
|
||||
|
||||
// Find all mentions
|
||||
mentionRegexGlobal.lastIndex = 0
|
||||
let match
|
||||
while ((match = mentionRegexGlobal.exec(text)) !== null) {
|
||||
highlights.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
type: "mention",
|
||||
})
|
||||
}
|
||||
|
||||
// Find first slash command only (must be complete and valid)
|
||||
slashCommandRegex.lastIndex = 0
|
||||
const slashMatch = slashCommandRegex.exec(text)
|
||||
if (slashMatch) {
|
||||
const prefix = slashMatch[1] || ""
|
||||
const commandText = slashMatch[2] // e.g., "/help"
|
||||
const commandName = commandText.slice(1) // e.g., "help"
|
||||
const commandStart = slashMatch.index + prefix.length
|
||||
const commandEnd = commandStart + commandText.length
|
||||
|
||||
// Only highlight if command exists in available commands (or if no list provided)
|
||||
if (!availableCommands || availableCommands.includes(commandName)) {
|
||||
highlights.push({
|
||||
start: commandStart,
|
||||
end: commandEnd,
|
||||
type: "command",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort highlights by start position
|
||||
highlights.sort((a, b) => a.start - b.start)
|
||||
|
||||
// Build segments
|
||||
const segments: Segment[] = []
|
||||
let lastIndex = 0
|
||||
|
||||
for (const highlight of highlights) {
|
||||
// Skip overlapping highlights
|
||||
if (highlight.start < lastIndex) continue
|
||||
|
||||
// Add normal text before this highlight
|
||||
if (highlight.start > lastIndex) {
|
||||
segments.push({
|
||||
text: text.slice(lastIndex, highlight.start),
|
||||
type: "normal",
|
||||
startIndex: lastIndex,
|
||||
})
|
||||
}
|
||||
|
||||
// Add highlighted segment
|
||||
segments.push({
|
||||
text: text.slice(highlight.start, highlight.end),
|
||||
type: highlight.type,
|
||||
startIndex: highlight.start,
|
||||
})
|
||||
|
||||
lastIndex = highlight.end
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (lastIndex < text.length) {
|
||||
segments.push({
|
||||
text: text.slice(lastIndex),
|
||||
type: "normal",
|
||||
startIndex: lastIndex,
|
||||
})
|
||||
}
|
||||
|
||||
// If no segments (empty or whitespace only), add a single normal segment
|
||||
if (segments.length === 0 && text.length > 0) {
|
||||
segments.push({
|
||||
text: text,
|
||||
type: "normal",
|
||||
startIndex: 0,
|
||||
})
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
export const HighlightedInput: React.FC<HighlightedInputProps> = ({ text, cursorPos, availableCommands }) => {
|
||||
// If no cursor position provided, just render text with highlights (backward compatible)
|
||||
if (cursorPos === undefined) {
|
||||
if (!text) return null
|
||||
const segments = parseInput(text, availableCommands)
|
||||
return (
|
||||
<Text>
|
||||
{segments.map((segment, idx) => {
|
||||
if (segment.type === "mention" || segment.type === "command") {
|
||||
return (
|
||||
<Text backgroundColor="gray" key={idx}>
|
||||
{segment.text}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
return <Text key={idx}>{segment.text}</Text>
|
||||
})}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
// With cursor position - render cursor within the text
|
||||
const safeCursorPos = Math.min(Math.max(0, cursorPos), text.length)
|
||||
const segments = parseInput(text, availableCommands)
|
||||
|
||||
// Render segments with cursor
|
||||
const renderSegmentWithCursor = (segment: Segment, segmentIdx: number) => {
|
||||
const segmentStart = segment.startIndex
|
||||
const segmentEnd = segmentStart + segment.text.length
|
||||
const isHighlighted = segment.type === "mention" || segment.type === "command"
|
||||
|
||||
// Check if cursor is within this segment
|
||||
if (safeCursorPos >= segmentStart && safeCursorPos < segmentEnd) {
|
||||
// Cursor is in this segment - split it
|
||||
const localCursorPos = safeCursorPos - segmentStart
|
||||
const beforeCursor = segment.text.slice(0, localCursorPos)
|
||||
const cursorChar = segment.text[localCursorPos]
|
||||
const afterCursor = segment.text.slice(localCursorPos + 1)
|
||||
|
||||
if (isHighlighted) {
|
||||
return (
|
||||
<Text key={segmentIdx}>
|
||||
{beforeCursor && <Text backgroundColor="gray">{beforeCursor}</Text>}
|
||||
<Text backgroundColor="gray" inverse>
|
||||
{cursorChar}
|
||||
</Text>
|
||||
{afterCursor && <Text backgroundColor="gray">{afterCursor}</Text>}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Text key={segmentIdx}>
|
||||
{beforeCursor}
|
||||
<Text inverse>{cursorChar}</Text>
|
||||
{afterCursor}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
// Cursor not in this segment - render normally
|
||||
if (isHighlighted) {
|
||||
return (
|
||||
<Text backgroundColor="gray" key={segmentIdx}>
|
||||
{segment.text}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
return <Text key={segmentIdx}>{segment.text}</Text>
|
||||
}
|
||||
|
||||
// Check if cursor is at the end (past all text)
|
||||
const cursorAtEnd = safeCursorPos >= text.length
|
||||
|
||||
return (
|
||||
<Text>
|
||||
{segments.map((segment, idx) => renderSegmentWithCursor(segment, idx))}
|
||||
{cursorAtEnd && <Text inverse> </Text>}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ 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"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
|
||||
interface TaskHistoryItem {
|
||||
id: string
|
||||
@@ -51,6 +52,7 @@ export const HistoryView: React.FC<HistoryViewProps> = ({
|
||||
onPageChange,
|
||||
allItems,
|
||||
}) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [internalPage, setInternalPage] = useState(pagination?.page ?? 1)
|
||||
const { stdout } = useStdout()
|
||||
@@ -103,23 +105,26 @@ export const HistoryView: React.FC<HistoryViewProps> = ({
|
||||
[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)
|
||||
}
|
||||
})
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (key.upArrow || input === "k") {
|
||||
setSelectedIndex((prev) => Math.max(0, prev - 1))
|
||||
} else if (key.downArrow || input === "j") {
|
||||
setSelectedIndex((prev) => Math.min(pageItems.length - 1, prev + 1))
|
||||
} else if (key.return && pageItems[selectedIndex]) {
|
||||
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)
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported },
|
||||
)
|
||||
|
||||
// Calculate visible window around selected item
|
||||
const halfVisible = Math.floor(effectiveVisibleCount / 2)
|
||||
@@ -139,14 +144,15 @@ export const HistoryView: React.FC<HistoryViewProps> = ({
|
||||
<Text bold color="white">
|
||||
{"📜 Task History (" + totalCount + " total)"}
|
||||
</Text>
|
||||
<Text dimColor>Use ↑↓ to navigate, Enter to select</Text>
|
||||
<Text color="gray">Use ↑↓/j/k to navigate, Enter to select</Text>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
<Text color="gray">
|
||||
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>}
|
||||
{hasPrevPage ? <Text color="blue">[← prev] </Text> : <Text color="gray">[← prev] </Text>}
|
||||
{hasNextPage ? <Text color="blue">[next →]</Text> : <Text color="gray">[next →]</Text>}
|
||||
</Box>
|
||||
)}
|
||||
<Text>{formatSeparator()}</Text>
|
||||
@@ -155,7 +161,7 @@ export const HistoryView: React.FC<HistoryViewProps> = ({
|
||||
<Text>No task history available.</Text>
|
||||
) : (
|
||||
<Box flexDirection="column">
|
||||
{showUpIndicator && <Text dimColor>{" ↑ " + startIndex + " more above"}</Text>}
|
||||
{showUpIndicator && <Text color="gray">{" ↑ " + startIndex + " more above"}</Text>}
|
||||
{visibleTasks.map((task, index) => {
|
||||
const actualIndex = startIndex + index
|
||||
const isSelected = actualIndex === selectedIndex
|
||||
@@ -167,7 +173,7 @@ export const HistoryView: React.FC<HistoryViewProps> = ({
|
||||
<Box flexDirection="column" key={`${task.id}-${actualIndex}`} marginBottom={1}>
|
||||
<Box>
|
||||
<Text color={isSelected ? "green" : undefined}>{isSelected ? "> " : " "}</Text>
|
||||
<Text dimColor>{date}</Text>
|
||||
<Text color="gray">{date}</Text>
|
||||
</Box>
|
||||
<Box marginLeft={4}>
|
||||
<Text color="cyan">{task.id}</Text>
|
||||
@@ -180,18 +186,18 @@ export const HistoryView: React.FC<HistoryViewProps> = ({
|
||||
</Box>
|
||||
{typeof task.totalCost === "number" && (
|
||||
<Box marginLeft={4}>
|
||||
<Text dimColor>Cost: ${task.totalCost ? task.totalCost.toFixed(4) : "0"}</Text>
|
||||
<Text color="gray">Cost: ${task.totalCost ? task.totalCost.toFixed(4) : "0"}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{task.modelId && (
|
||||
<Box marginLeft={4}>
|
||||
<Text dimColor>Model: {task.modelId}</Text>
|
||||
<Text color="gray">Model: {task.modelId}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
{showDownIndicator && <Text dimColor>{" ↓ " + (items.length - endIndex) + " more below"}</Text>}
|
||||
{showDownIndicator && <Text color="gray">{" ↓ " + (pageItems.length - endIndex) + " more below"}</Text>}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Import view component
|
||||
* Handles importing API keys from competing CLI agents (Codex, OpenCode)
|
||||
*/
|
||||
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useCallback, useEffect, useState } from "react"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import {
|
||||
getProviderDisplayName,
|
||||
getSourceDisplayName,
|
||||
type ImportedKey,
|
||||
type ImportSource,
|
||||
importFromCodex,
|
||||
importFromOpenCode,
|
||||
} from "../utils/import-configs"
|
||||
|
||||
type ImportStep = "select" | "confirm" | "saving" | "error"
|
||||
|
||||
interface ImportViewProps {
|
||||
source: ImportSource
|
||||
onComplete: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export const ImportView: React.FC<ImportViewProps> = ({ source, onComplete, onCancel }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const [step, setStep] = useState<ImportStep>("select")
|
||||
const [keys, setKeys] = useState<ImportedKey[]>([])
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [confirmIndex, setConfirmIndex] = useState(0)
|
||||
const [errorMessage, setErrorMessage] = useState("")
|
||||
|
||||
// Load keys on mount
|
||||
useEffect(() => {
|
||||
const result = source === "codex" ? importFromCodex() : importFromOpenCode()
|
||||
if (result && result.keys.length > 0) {
|
||||
setKeys(result.keys)
|
||||
if (result.keys.length === 1) {
|
||||
// Only one key, go straight to confirm
|
||||
setStep("confirm")
|
||||
}
|
||||
} else {
|
||||
setErrorMessage(`Could not read API keys from ${getSourceDisplayName(source)} config`)
|
||||
setStep("error")
|
||||
}
|
||||
}, [source])
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
try {
|
||||
setStep("saving")
|
||||
|
||||
const selectedKey = keys[selectedIndex]
|
||||
if (!selectedKey) {
|
||||
setErrorMessage("No key selected")
|
||||
setStep("error")
|
||||
return
|
||||
}
|
||||
|
||||
const stateManager = StateManager.get()
|
||||
const config: Record<string, string> = {
|
||||
actModeApiProvider: selectedKey.provider,
|
||||
planModeApiProvider: selectedKey.provider,
|
||||
apiProvider: selectedKey.provider,
|
||||
}
|
||||
|
||||
// Set API key
|
||||
config[selectedKey.keyField] = selectedKey.key
|
||||
|
||||
// Set model ID if available
|
||||
if (selectedKey.modelId) {
|
||||
config.actModeApiModelId = selectedKey.modelId
|
||||
config.planModeApiModelId = selectedKey.modelId
|
||||
}
|
||||
|
||||
stateManager.setApiConfiguration(config)
|
||||
await stateManager.flushPendingState()
|
||||
|
||||
onComplete()
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : String(error))
|
||||
setStep("error")
|
||||
}
|
||||
}, [keys, selectedIndex, onComplete])
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (key.escape) {
|
||||
if (step === "confirm" && keys.length > 1) {
|
||||
setStep("select")
|
||||
setConfirmIndex(0)
|
||||
} else if (step === "error") {
|
||||
onCancel()
|
||||
} else {
|
||||
onCancel()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (step === "select") {
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : keys.length - 1))
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex((prev) => (prev < keys.length - 1 ? prev + 1 : 0))
|
||||
} else if (key.return) {
|
||||
setStep("confirm")
|
||||
}
|
||||
} else if (step === "confirm") {
|
||||
if (key.upArrow || key.downArrow) {
|
||||
setConfirmIndex((prev) => (prev === 0 ? 1 : 0))
|
||||
} else if (key.return) {
|
||||
if (confirmIndex === 0) {
|
||||
handleConfirm()
|
||||
} else {
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
} else if (step === "error") {
|
||||
if (key.return) {
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported && step !== "saving" },
|
||||
)
|
||||
|
||||
const sourceName = getSourceDisplayName(source)
|
||||
|
||||
if (step === "select") {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="white">Select which key to import from {sourceName}</Text>
|
||||
<Text> </Text>
|
||||
{keys.map((k, i) => (
|
||||
<Box key={`${k.provider}-${i}`}>
|
||||
<Text color={i === selectedIndex ? COLORS.primaryBlue : undefined}>
|
||||
{i === selectedIndex ? "❯ " : " "}
|
||||
{getProviderDisplayName(k.provider)}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
<Text> </Text>
|
||||
<Text color="gray">Arrows to navigate, Enter to select, Esc to go back</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (step === "confirm") {
|
||||
const selectedKey = keys[selectedIndex]
|
||||
const providerName = selectedKey ? getProviderDisplayName(selectedKey.provider) : ""
|
||||
const maskedKey = selectedKey ? `${selectedKey.key.slice(0, 8)}...${selectedKey.key.slice(-4)}` : ""
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="white">Import API key from {sourceName}?</Text>
|
||||
<Text> </Text>
|
||||
<Box>
|
||||
<Text color="gray">Provider: </Text>
|
||||
<Text color="white">{providerName}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color="gray">API Key: </Text>
|
||||
<Text color="white">{maskedKey}</Text>
|
||||
</Box>
|
||||
{selectedKey?.modelId && (
|
||||
<Box>
|
||||
<Text color="gray">Model: </Text>
|
||||
<Text color="white">{selectedKey.modelId}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Text> </Text>
|
||||
<Box>
|
||||
<Text color={confirmIndex === 0 ? COLORS.primaryBlue : undefined}>
|
||||
{confirmIndex === 0 ? "❯ " : " "}
|
||||
Confirm import
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={confirmIndex === 1 ? COLORS.primaryBlue : undefined}>
|
||||
{confirmIndex === 1 ? "❯ " : " "}
|
||||
Cancel
|
||||
</Text>
|
||||
</Box>
|
||||
<Text> </Text>
|
||||
<Text color="gray">Enter to confirm, Esc to go back</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (step === "saving") {
|
||||
return (
|
||||
<Box>
|
||||
<Text color="white">Importing configuration...</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (step === "error") {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color="red">
|
||||
Something went wrong
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text color="yellow">{errorMessage}</Text>
|
||||
<Text> </Text>
|
||||
<Text color="gray">Press Enter or Esc to go back</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Language picker component for user preference
|
||||
*/
|
||||
|
||||
import React, { useMemo } from "react"
|
||||
import { SearchableList, SearchableListItem } from "./SearchableList"
|
||||
|
||||
// Available languages - English names only to avoid Unicode rendering issues
|
||||
const LANGUAGES = [
|
||||
"English",
|
||||
"Arabic",
|
||||
"Czech",
|
||||
"French",
|
||||
"German",
|
||||
"Hindi",
|
||||
"Hungarian",
|
||||
"Italian",
|
||||
"Japanese",
|
||||
"Korean",
|
||||
"Polish",
|
||||
"Portuguese (Brazil)",
|
||||
"Portuguese (Portugal)",
|
||||
"Russian",
|
||||
"Simplified Chinese",
|
||||
"Spanish",
|
||||
"Traditional Chinese",
|
||||
"Turkish",
|
||||
]
|
||||
|
||||
interface LanguagePickerProps {
|
||||
onSelect: (language: string) => void
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export const LanguagePicker: React.FC<LanguagePickerProps> = ({ onSelect, isActive = true }) => {
|
||||
const items: SearchableListItem[] = useMemo(
|
||||
() =>
|
||||
LANGUAGES.map((lang) => ({
|
||||
id: lang,
|
||||
label: lang,
|
||||
})),
|
||||
[],
|
||||
)
|
||||
|
||||
return <SearchableList isActive={isActive} items={items} onSelect={(item) => onSelect(item.id)} />
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Message list component
|
||||
* Renders all messages from the task
|
||||
*/
|
||||
|
||||
import { Box } from "ink"
|
||||
import React from "react"
|
||||
import { useTaskState } from "../context/TaskContext"
|
||||
import { MessageRow } from "./MessageRow"
|
||||
|
||||
interface MessageListProps {
|
||||
verbose?: boolean
|
||||
}
|
||||
|
||||
export const MessageList: React.FC<MessageListProps> = ({ verbose = false }) => {
|
||||
const state = useTaskState()
|
||||
const messages = state.clineMessages || []
|
||||
|
||||
// Filter out some noisy messages when not verbose
|
||||
const messagesToShow = verbose
|
||||
? messages
|
||||
: messages.filter((m) => {
|
||||
// Show everything in non-verbose mode for now
|
||||
return true
|
||||
})
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{messagesToShow.map((message, idx) => (
|
||||
<MessageRow key={`${message.ts}-${idx}`} message={message} verbose={verbose} />
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,378 +0,0 @@
|
||||
/**
|
||||
* Individual message row component
|
||||
* Renders a single ClineMessage based on its type
|
||||
*/
|
||||
|
||||
import type { ClineAsk, ClineMessage, ClineSay } from "@shared/ExtensionMessage"
|
||||
import { Box, Text } from "ink"
|
||||
import React from "react"
|
||||
import { jsonParseSafe } from "../utils/parser"
|
||||
import { DiffView } from "./DiffView"
|
||||
|
||||
interface MessageRowProps {
|
||||
message: ClineMessage
|
||||
verbose?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Get emoji icon for message type
|
||||
*/
|
||||
export function getCliMessagePrefixIcon(message: ClineMessage): string {
|
||||
if (message.type === "ask") {
|
||||
switch (message.ask) {
|
||||
case "followup":
|
||||
return "❓"
|
||||
case "command":
|
||||
case "command_output":
|
||||
return "⚙️"
|
||||
case "tool":
|
||||
return "🔧"
|
||||
case "completion_result":
|
||||
return "✅"
|
||||
case "api_req_failed":
|
||||
return "❌"
|
||||
case "resume_task":
|
||||
case "resume_completed_task":
|
||||
return "▶️"
|
||||
case "browser_action_launch":
|
||||
return "🌐"
|
||||
case "use_mcp_server":
|
||||
return "🔌"
|
||||
case "plan_mode_respond":
|
||||
return "📋"
|
||||
default:
|
||||
return "❔"
|
||||
}
|
||||
} else {
|
||||
switch (message.say) {
|
||||
case "task":
|
||||
return "📋"
|
||||
case "error":
|
||||
return "❌"
|
||||
case "text":
|
||||
return "💬"
|
||||
case "reasoning":
|
||||
return "🧠"
|
||||
case "completion_result":
|
||||
return "✅"
|
||||
case "user_feedback":
|
||||
return "👤"
|
||||
case "command":
|
||||
case "command_output":
|
||||
return "⚙️"
|
||||
case "tool":
|
||||
return "🔧"
|
||||
case "browser_action":
|
||||
case "browser_action_launch":
|
||||
case "browser_action_result":
|
||||
return "🌐"
|
||||
case "mcp_server_request_started":
|
||||
case "mcp_server_response":
|
||||
return "🔌"
|
||||
case "api_req_started":
|
||||
case "api_req_finished":
|
||||
return "🔄"
|
||||
case "checkpoint_created":
|
||||
return "💾"
|
||||
case "info":
|
||||
return "ℹ️"
|
||||
case "generate_explanation":
|
||||
return "📝"
|
||||
default:
|
||||
return " "
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp
|
||||
*/
|
||||
function formatTimestamp(ts: number): string {
|
||||
const date = new Date(ts)
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Render ask message based on type
|
||||
*/
|
||||
const AskMessageContent: React.FC<{ message: ClineMessage; verbose?: boolean }> = ({ message, verbose }) => {
|
||||
const ask = message.ask as ClineAsk
|
||||
const text = message.text || ""
|
||||
|
||||
switch (ask) {
|
||||
case "followup":
|
||||
case "plan_mode_respond": {
|
||||
const parts = jsonParseSafe(text, {
|
||||
response: undefined as string | undefined,
|
||||
question: undefined as string | undefined,
|
||||
})
|
||||
|
||||
if (parts.question) {
|
||||
return (
|
||||
<Text>
|
||||
<Text color="cyan">Question:</Text> {parts.question}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
if (parts.response) {
|
||||
return (
|
||||
<Text>
|
||||
<Text color="cyan">[{ask}]</Text> {parts.response}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
case "command":
|
||||
return (
|
||||
<Text>
|
||||
<Text color="magenta">Execute command?</Text> <Text dimColor>{text}</Text>
|
||||
</Text>
|
||||
)
|
||||
|
||||
case "tool":
|
||||
return (
|
||||
<Text>
|
||||
<Text color="blue">Use tool?</Text> {text}
|
||||
</Text>
|
||||
)
|
||||
|
||||
case "completion_result":
|
||||
return (
|
||||
<Text>
|
||||
<Text color="green">Task completed</Text> {text ? `- ${text}` : ""}
|
||||
</Text>
|
||||
)
|
||||
|
||||
case "api_req_failed":
|
||||
return (
|
||||
<Text>
|
||||
<Text color="red">API request failed</Text> {text}
|
||||
</Text>
|
||||
)
|
||||
|
||||
case "resume_task":
|
||||
case "resume_completed_task":
|
||||
return (
|
||||
<Text>
|
||||
<Text color="cyan">Resume task?</Text> {text}
|
||||
</Text>
|
||||
)
|
||||
|
||||
case "browser_action_launch":
|
||||
return (
|
||||
<Text>
|
||||
<Text color="cyan">Launch browser?</Text> {text}
|
||||
</Text>
|
||||
)
|
||||
|
||||
case "use_mcp_server":
|
||||
return (
|
||||
<Text>
|
||||
<Text color="cyan">Use MCP server?</Text> {text}
|
||||
</Text>
|
||||
)
|
||||
|
||||
default:
|
||||
return verbose ? (
|
||||
<Text>
|
||||
<Text color="gray">[ASK:{ask}]</Text> {text}
|
||||
</Text>
|
||||
) : null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render say message based on type
|
||||
*/
|
||||
const SayMessageContent: React.FC<{ message: ClineMessage; verbose?: boolean }> = ({ message, verbose }) => {
|
||||
const say = message.say as ClineSay
|
||||
const text = message.text || ""
|
||||
|
||||
switch (say) {
|
||||
case "task":
|
||||
return (
|
||||
<Text bold>
|
||||
<Text color="white">Task:</Text> {text}
|
||||
</Text>
|
||||
)
|
||||
|
||||
case "text":
|
||||
return <Text>{text}</Text>
|
||||
|
||||
case "reasoning":
|
||||
return (
|
||||
<Text color="yellow">
|
||||
<Text italic>{text}</Text>
|
||||
</Text>
|
||||
)
|
||||
|
||||
case "error":
|
||||
return (
|
||||
<Text color="red">
|
||||
<Text bold>Error:</Text> {text}
|
||||
</Text>
|
||||
)
|
||||
|
||||
case "completion_result":
|
||||
return (
|
||||
<Text color="green">
|
||||
<Text bold>Completed:</Text> {text}
|
||||
</Text>
|
||||
)
|
||||
|
||||
case "user_feedback":
|
||||
return (
|
||||
<Text>
|
||||
<Text color="green">User:</Text> {text}
|
||||
</Text>
|
||||
)
|
||||
|
||||
case "command":
|
||||
return (
|
||||
<Text>
|
||||
<Text color="magenta">Command:</Text> <Text dimColor>{text}</Text>
|
||||
</Text>
|
||||
)
|
||||
|
||||
case "command_output": {
|
||||
const lines = text.split("\n")
|
||||
const displayLines = lines.slice(0, 10)
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text dimColor>Output:</Text>
|
||||
{displayLines.map((line, idx) => (
|
||||
<Text dimColor key={idx}>
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
{lines.length > 10 && <Text dimColor> ... and {lines.length - 10} more lines</Text>}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
case "tool": {
|
||||
const { tool, content, path } = jsonParseSafe(text, {
|
||||
tool: undefined as string | undefined,
|
||||
content: undefined as string | undefined,
|
||||
path: undefined as string | undefined,
|
||||
diff: undefined as string | undefined,
|
||||
})
|
||||
if (path) {
|
||||
if (tool === "newFileCreated") {
|
||||
return <DiffView content={content} path={path} />
|
||||
}
|
||||
// if (tool === "editedExistingFile") {
|
||||
// return <DiffView diff={diff} path={path} />
|
||||
// }
|
||||
}
|
||||
|
||||
return (
|
||||
<Text>
|
||||
<Text color="blue">{text}</Text>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
case "api_req_started": {
|
||||
const { cost, tokensOut, cacheWrites, cacheReads, tokensIn } = jsonParseSafe(text, {
|
||||
cost: 0 as number,
|
||||
tokensIn: 0 as number,
|
||||
tokensOut: 0 as number,
|
||||
cacheWrites: 0 as number,
|
||||
cacheReads: 0 as number,
|
||||
})
|
||||
return verbose ? (
|
||||
<Text dimColor>{text}</Text>
|
||||
) : (
|
||||
<Text dimColor>
|
||||
Cost: {cost} | Tokens In: {tokensIn} | Tokens Out: {tokensOut} | Cache Writes: {cacheWrites} | Cache Reads:{" "}
|
||||
{cacheReads}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
case "api_req_finished":
|
||||
return null
|
||||
|
||||
case "checkpoint_created":
|
||||
return <Text dimColor>Checkpoint created: {message.lastCheckpointHash}</Text>
|
||||
|
||||
case "info":
|
||||
return <Text color="cyan">{text}</Text>
|
||||
|
||||
case "browser_action":
|
||||
case "browser_action_launch":
|
||||
return (
|
||||
<Text>
|
||||
<Text color="cyan">Browser:</Text> {text}
|
||||
</Text>
|
||||
)
|
||||
|
||||
case "browser_action_result":
|
||||
return <Text dimColor>Browser result {text ? `- ${text.substring(0, 100)}...` : ""}</Text>
|
||||
|
||||
case "mcp_server_request_started":
|
||||
return <Text color="cyan">MCP request started {text}</Text>
|
||||
|
||||
case "mcp_server_response":
|
||||
return <Text color="cyan">MCP response {text ? text.substring(0, 200) : ""}</Text>
|
||||
|
||||
default:
|
||||
return verbose ? (
|
||||
<Text dimColor>
|
||||
[SAY:{say}] {text}
|
||||
</Text>
|
||||
) : null
|
||||
}
|
||||
}
|
||||
|
||||
export const MessageRow: React.FC<MessageRowProps> = ({ message, verbose = false }) => {
|
||||
const icon = getCliMessagePrefixIcon(message)
|
||||
const timestamp = formatTimestamp(message.ts)
|
||||
|
||||
// Don't render silent messages
|
||||
if (message.say === "api_req_finished") {
|
||||
return null
|
||||
}
|
||||
|
||||
if (message.say === "text" && message.text?.trim() === "") {
|
||||
return null
|
||||
}
|
||||
|
||||
const content =
|
||||
message.type === "ask" ? (
|
||||
<AskMessageContent message={message} verbose={verbose} />
|
||||
) : (
|
||||
<SayMessageContent message={message} verbose={verbose} />
|
||||
)
|
||||
|
||||
// command_output and tool return a Box, which can't be nested inside Text
|
||||
if (message.say === "command_output" || message.say === "tool") {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text dimColor>{timestamp} </Text>
|
||||
<Text>{icon} </Text>
|
||||
</Box>
|
||||
{content}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text dimColor>{timestamp} </Text>
|
||||
<Text>{icon} </Text>
|
||||
{content}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Model picker component for model selection
|
||||
* Supports static model lists and async loading for OpenRouter
|
||||
*/
|
||||
|
||||
import { Box, Text } from "ink"
|
||||
import Spinner from "ink-spinner"
|
||||
import React, { useEffect, useMemo, useState } from "react"
|
||||
import {
|
||||
anthropicDefaultModelId,
|
||||
anthropicModels,
|
||||
bedrockDefaultModelId,
|
||||
bedrockModels,
|
||||
deepSeekDefaultModelId,
|
||||
deepSeekModels,
|
||||
geminiDefaultModelId,
|
||||
geminiModels,
|
||||
groqDefaultModelId,
|
||||
groqModels,
|
||||
mistralDefaultModelId,
|
||||
mistralModels,
|
||||
openAiNativeDefaultModelId,
|
||||
openAiNativeModels,
|
||||
xaiDefaultModelId,
|
||||
xaiModels,
|
||||
} from "@/shared/api"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { fetchOpenRouterModels, getOpenRouterDefaultModelId, usesOpenRouterModels } from "../utils/openrouter-models"
|
||||
import { SearchableList, SearchableListItem } from "./SearchableList"
|
||||
|
||||
// Map providers to their static model lists and defaults
|
||||
export const providerModels: Record<string, { models: Record<string, unknown>; defaultId: string }> = {
|
||||
anthropic: { models: anthropicModels, defaultId: anthropicDefaultModelId },
|
||||
"openai-native": { models: openAiNativeModels, defaultId: openAiNativeDefaultModelId },
|
||||
gemini: { models: geminiModels, defaultId: geminiDefaultModelId },
|
||||
bedrock: { models: bedrockModels, defaultId: bedrockDefaultModelId },
|
||||
deepseek: { models: deepSeekModels, defaultId: deepSeekDefaultModelId },
|
||||
mistral: { models: mistralModels, defaultId: mistralDefaultModelId },
|
||||
groq: { models: groqModels, defaultId: groqDefaultModelId },
|
||||
xai: { models: xaiModels, defaultId: xaiDefaultModelId },
|
||||
}
|
||||
|
||||
export function hasStaticModels(provider: string): boolean {
|
||||
return provider in providerModels
|
||||
}
|
||||
|
||||
export function hasModelPicker(provider: string): boolean {
|
||||
return hasStaticModels(provider) || usesOpenRouterModels(provider)
|
||||
}
|
||||
|
||||
export function getDefaultModelId(provider: string): string {
|
||||
if (usesOpenRouterModels(provider)) {
|
||||
return getOpenRouterDefaultModelId()
|
||||
}
|
||||
return providerModels[provider]?.defaultId || ""
|
||||
}
|
||||
|
||||
export function getModelList(provider: string): string[] {
|
||||
if (!hasStaticModels(provider)) return []
|
||||
return Object.keys(providerModels[provider].models)
|
||||
}
|
||||
|
||||
interface ModelPickerProps {
|
||||
provider: string
|
||||
onChange: (modelId: string) => void
|
||||
onSubmit: (modelId: string) => void
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export const ModelPicker: React.FC<ModelPickerProps> = ({ provider, onChange, onSubmit, isActive = true }) => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [asyncModels, setAsyncModels] = useState<string[]>([])
|
||||
|
||||
// Fetch OpenRouter models when needed
|
||||
useEffect(() => {
|
||||
if (usesOpenRouterModels(provider)) {
|
||||
setIsLoading(true)
|
||||
fetchOpenRouterModels()
|
||||
.then((models) => {
|
||||
setAsyncModels(models)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
}
|
||||
}, [provider])
|
||||
|
||||
const modelList = useMemo(() => {
|
||||
if (usesOpenRouterModels(provider)) {
|
||||
return asyncModels
|
||||
}
|
||||
return getModelList(provider)
|
||||
}, [provider, asyncModels])
|
||||
|
||||
const items: SearchableListItem[] = useMemo(() => {
|
||||
return modelList.map((modelId) => ({
|
||||
id: modelId,
|
||||
label: modelId,
|
||||
}))
|
||||
}, [modelList])
|
||||
|
||||
// For providers without a model picker, render nothing
|
||||
if (!hasModelPicker(provider)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Show loading state for async providers
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color={COLORS.primaryBlue}>
|
||||
<Spinner type="dots" />
|
||||
</Text>
|
||||
<Text color="gray"> Loading models...</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// If async fetch returned no models, render nothing
|
||||
if (usesOpenRouterModels(provider) && modelList.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<SearchableList
|
||||
isActive={isActive}
|
||||
items={items}
|
||||
onSelect={(item) => {
|
||||
onChange(item.id)
|
||||
onSubmit(item.id)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Reusable bottom panel component
|
||||
* Used for displaying contextual UI below the chat input (settings, etc.)
|
||||
*/
|
||||
|
||||
import { Box, Text } from "ink"
|
||||
import React, { ReactNode } from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
|
||||
export interface PanelTab {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface PanelProps {
|
||||
/** Label for the panel (e.g., "Settings") */
|
||||
label: string
|
||||
/** Optional tabs configuration */
|
||||
tabs?: PanelTab[]
|
||||
/** Current tab key - required when tabs are provided */
|
||||
currentTab?: string
|
||||
/** Panel content */
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export const Panel: React.FC<PanelProps> = ({ label, tabs, currentTab, children }) => {
|
||||
const currentTabIndex = currentTab && tabs ? tabs.findIndex((t) => t.key === currentTab) : 0
|
||||
|
||||
return (
|
||||
<Box borderColor={COLORS.primaryBlue} borderStyle="round" flexDirection="column" width="100%">
|
||||
{/* Header */}
|
||||
<Box paddingLeft={1} paddingRight={1}>
|
||||
<Text bold color={COLORS.primaryBlue}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text color="gray"> (Esc to close)</Text>
|
||||
</Box>
|
||||
|
||||
{/* Tab bar if tabs are provided */}
|
||||
{tabs && tabs.length > 0 && (
|
||||
<Box paddingLeft={1} paddingRight={1}>
|
||||
{tabs.map((tab, idx) => {
|
||||
const isActive = idx === currentTabIndex
|
||||
return (
|
||||
<Text
|
||||
bold={isActive}
|
||||
color={isActive ? COLORS.primaryBlue : "white"}
|
||||
inverse={isActive}
|
||||
key={tab.key}>
|
||||
{` ${tab.label} `}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
<Text color="gray"> (←/→)</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Separator line */}
|
||||
<Box>
|
||||
<Text bold color={COLORS.primaryBlue}>
|
||||
{"─".repeat(process.stdout.columns - 2)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Content */}
|
||||
<Box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Provider picker component for API provider selection
|
||||
*/
|
||||
|
||||
import React, { useMemo } from "react"
|
||||
import { API_PROVIDERS_LIST } from "@/shared/api"
|
||||
import providersData from "@/shared/providers/providers.json"
|
||||
import { SearchableList, SearchableListItem } from "./SearchableList"
|
||||
|
||||
// Create a lookup map from provider value to display label
|
||||
const providerLabels: Record<string, string> = Object.fromEntries(
|
||||
providersData.list.map((p: { value: string; label: string }) => [p.value, p.label]),
|
||||
)
|
||||
|
||||
// Get provider order from providers.json (same order as webview)
|
||||
const providerOrder: string[] = providersData.list.map((p: { value: string }) => p.value)
|
||||
|
||||
export function getProviderLabel(providerId: string): string {
|
||||
return providerLabels[providerId] || providerId
|
||||
}
|
||||
|
||||
export function getProviderOrder(): string[] {
|
||||
return providerOrder
|
||||
}
|
||||
|
||||
interface ProviderPickerProps {
|
||||
onSelect: (providerId: string) => void
|
||||
isActive?: boolean
|
||||
configuredProviders?: Set<string>
|
||||
}
|
||||
|
||||
export const ProviderPicker: React.FC<ProviderPickerProps> = ({ onSelect, isActive = true, configuredProviders = new Set() }) => {
|
||||
// Use providers.json order, filtered to only available providers
|
||||
const items: SearchableListItem[] = useMemo(() => {
|
||||
const availableProviders = new Set(API_PROVIDERS_LIST)
|
||||
const sorted = providerOrder.filter((p) => availableProviders.has(p))
|
||||
|
||||
return sorted.map((providerId) => ({
|
||||
id: providerId,
|
||||
label: getProviderLabel(providerId),
|
||||
suffix: configuredProviders.has(providerId) ? "(configured)" : undefined,
|
||||
}))
|
||||
}, [configuredProviders])
|
||||
|
||||
return <SearchableList isActive={isActive} items={items} onSelect={(item) => onSelect(item.id)} />
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Generic searchable list component with keyboard navigation
|
||||
* Used by ProviderPicker, ModelPicker, LanguagePicker, etc.
|
||||
*/
|
||||
|
||||
import { Box, Text, useInput } from "ink"
|
||||
// biome-ignore lint/correctness/noUnusedImports: React is needed for JSX at runtime
|
||||
import React, { useEffect, useMemo, useState } from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { useScrollableList } from "../hooks/useScrollableList"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
|
||||
export interface SearchableListItem {
|
||||
id: string
|
||||
label: string
|
||||
suffix?: string // Optional suffix like "(configured)" or "(current)"
|
||||
}
|
||||
|
||||
interface SearchableListProps<T extends SearchableListItem> {
|
||||
items: T[]
|
||||
onSelect: (item: T) => void
|
||||
isActive?: boolean
|
||||
maxRows?: number
|
||||
filterFn?: (item: T, search: string) => boolean
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_ROWS = 8
|
||||
|
||||
export function SearchableList<T extends SearchableListItem>({
|
||||
items,
|
||||
onSelect,
|
||||
isActive = true,
|
||||
maxRows = DEFAULT_MAX_ROWS,
|
||||
filterFn,
|
||||
}: SearchableListProps<T>) {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const [search, setSearch] = useState("")
|
||||
const [index, setIndex] = useState(0)
|
||||
|
||||
// Default filter: search in id and label
|
||||
const defaultFilter = (item: T, searchStr: string) => {
|
||||
const searchLower = searchStr.toLowerCase()
|
||||
return item.id.toLowerCase().includes(searchLower) || item.label.toLowerCase().includes(searchLower)
|
||||
}
|
||||
|
||||
// Filter items by search
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!search) return items
|
||||
const filter = filterFn || defaultFilter
|
||||
return items.filter((item) => filter(item, search))
|
||||
}, [items, search, filterFn])
|
||||
|
||||
// Use shared scrollable list hook for windowing
|
||||
const { visibleStart, visibleCount, showTopIndicator, showBottomIndicator } = useScrollableList(
|
||||
filteredItems.length,
|
||||
index,
|
||||
maxRows,
|
||||
)
|
||||
|
||||
const visibleItems = useMemo(() => {
|
||||
return filteredItems.slice(visibleStart, visibleStart + visibleCount)
|
||||
}, [filteredItems, visibleStart, visibleCount])
|
||||
|
||||
// Reset index when search changes
|
||||
useEffect(() => {
|
||||
setIndex(0)
|
||||
}, [search])
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
// Filter out mouse escape sequences
|
||||
if (isMouseEscapeSequence(input)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (key.upArrow) {
|
||||
setIndex((prev) => Math.max(0, prev - 1))
|
||||
} else if (key.downArrow) {
|
||||
setIndex((prev) => Math.min(filteredItems.length - 1, prev + 1))
|
||||
} else if (key.return) {
|
||||
if (filteredItems[index]) {
|
||||
onSelect(filteredItems[index])
|
||||
}
|
||||
} else if (key.backspace || key.delete) {
|
||||
setSearch((prev) => prev.slice(0, -1))
|
||||
} else if (input && !key.ctrl && !key.meta && !key.escape) {
|
||||
setSearch((prev) => prev + input)
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported && isActive },
|
||||
)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text color="gray">Search: </Text>
|
||||
<Text color="white">{search}</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
<Text> </Text>
|
||||
{showTopIndicator && <Text color="gray">... {visibleStart} more above</Text>}
|
||||
{visibleItems.map((item, i) => {
|
||||
const actualIndex = visibleStart + i
|
||||
const isSelected = actualIndex === index
|
||||
return (
|
||||
<Box key={item.id}>
|
||||
<Text color={isSelected ? COLORS.primaryBlue : undefined}>
|
||||
{isSelected ? "> " : " "}
|
||||
{item.label}
|
||||
{item.suffix && <Text color="gray"> {item.suffix}</Text>}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
{showBottomIndicator && <Text color="gray">... {filteredItems.length - visibleStart - visibleCount} more below</Text>}
|
||||
{filteredItems.length === 0 && <Text color="gray">No matches for "{search}"</Text>}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Slash command menu component for CLI
|
||||
* Displays a list of matching slash commands when user types /
|
||||
*/
|
||||
|
||||
import type { SlashCommandInfo } from "@shared/proto/cline/slash"
|
||||
import { Box, Text } from "ink"
|
||||
import React from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { getVisibleWindow } from "../utils/slash-commands"
|
||||
|
||||
interface SlashCommandMenuProps {
|
||||
commands: SlashCommandInfo[]
|
||||
selectedIndex: number
|
||||
query: string
|
||||
}
|
||||
|
||||
export const SlashCommandMenu: React.FC<SlashCommandMenuProps> = ({ commands, selectedIndex, query }) => {
|
||||
if (commands.length === 0) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} paddingLeft={1} paddingRight={1}>
|
||||
<Text color="gray">{query ? `No commands matching "/${query}"` : "Type to search commands..."}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const { items: visibleCommands, startIndex } = getVisibleWindow(commands, selectedIndex)
|
||||
const hasMoreBelow = startIndex + visibleCommands.length < commands.length
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} paddingLeft={1} paddingRight={1}>
|
||||
{visibleCommands.map((cmd, idx) => {
|
||||
const isSelected = startIndex + idx === selectedIndex
|
||||
// Only show description for default commands (not workflows)
|
||||
const showDescription = cmd.section === "default" || !cmd.section
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" key={cmd.name}>
|
||||
<Box>
|
||||
<Text color={isSelected ? COLORS.primaryBlue : undefined}>
|
||||
{isSelected ? "❯" : " "} /{cmd.name}
|
||||
</Text>
|
||||
</Box>
|
||||
{showDescription && cmd.description && (
|
||||
<Box paddingLeft={3}>
|
||||
<Text color="gray">{cmd.description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
{hasMoreBelow && <Text color="gray">{" "}▼</Text>}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -7,14 +7,11 @@ import Spinner from "ink-spinner"
|
||||
import React from "react"
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
message?: string
|
||||
mode?: "act" | "plan"
|
||||
}
|
||||
|
||||
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)],
|
||||
}) => {
|
||||
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({ mode = "act" }) => {
|
||||
const message = mode === "plan" ? "Planning" : "Thinking"
|
||||
return (
|
||||
<Box>
|
||||
<Text color="cyan">
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Status bar component
|
||||
* Shows git branch, model, context window usage, token count, and cost
|
||||
*/
|
||||
|
||||
import { execSync } from "child_process"
|
||||
import { Box, Text } from "ink"
|
||||
import React, { useEffect, useState } from "react"
|
||||
|
||||
interface StatusBarProps {
|
||||
modelId: string
|
||||
tokensIn?: number
|
||||
tokensOut?: number
|
||||
totalCost?: number
|
||||
contextWindowSize?: number
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current git branch name
|
||||
*/
|
||||
function getGitBranch(cwd?: string): string | null {
|
||||
try {
|
||||
const branch = execSync("git rev-parse --abbrev-ref HEAD", {
|
||||
cwd: cwd || process.cwd(),
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim()
|
||||
return branch
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get directory basename
|
||||
*/
|
||||
function getDirName(cwd?: string): string {
|
||||
const path = cwd || process.cwd()
|
||||
return path.split("/").pop() || path
|
||||
}
|
||||
|
||||
/**
|
||||
* Format number with commas
|
||||
*/
|
||||
function formatNumber(num: number): string {
|
||||
return num.toLocaleString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a progress bar for context window usage
|
||||
*/
|
||||
function createContextBar(used: number, total: number, width: number = 8): string {
|
||||
const ratio = Math.min(used / total, 1)
|
||||
const filled = Math.round(ratio * width)
|
||||
const empty = width - filled
|
||||
return "█".repeat(filled) + "░".repeat(empty)
|
||||
}
|
||||
|
||||
export const StatusBar: React.FC<StatusBarProps> = ({
|
||||
modelId,
|
||||
tokensIn = 0,
|
||||
tokensOut = 0,
|
||||
totalCost = 0,
|
||||
contextWindowSize = 200000, // Default Claude context window
|
||||
cwd,
|
||||
}) => {
|
||||
const [branch, setBranch] = useState<string | null>(null)
|
||||
const dirName = getDirName(cwd)
|
||||
|
||||
useEffect(() => {
|
||||
setBranch(getGitBranch(cwd))
|
||||
}, [cwd])
|
||||
|
||||
const totalTokens = tokensIn + tokensOut
|
||||
const contextBar = createContextBar(totalTokens, contextWindowSize)
|
||||
|
||||
// Format model ID for display (shorten if needed)
|
||||
const displayModel = modelId.length > 20 ? modelId.substring(0, 17) + "..." : modelId
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box gap={1}>
|
||||
{/* Directory and branch */}
|
||||
<Text color="gray">
|
||||
{dirName}
|
||||
{branch && (
|
||||
<Text color="gray">
|
||||
{" "}
|
||||
(<Text color="cyan">{branch}</Text>)
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
<Text color="gray">|</Text>
|
||||
|
||||
{/* Model and context bar */}
|
||||
<Text color="white">{displayModel}</Text>
|
||||
<Text color="blue">{contextBar}</Text>
|
||||
<Text color="gray">({formatNumber(totalTokens)})</Text>
|
||||
<Text color="gray">|</Text>
|
||||
|
||||
{/* Cost */}
|
||||
<Text color="green">${totalCost.toFixed(4)}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* JSON Task view component
|
||||
* Outputs task messages as JSON instead of rich styled text
|
||||
*/
|
||||
|
||||
import { Box } from "ink"
|
||||
import React, { useEffect, useRef } from "react"
|
||||
import { useTaskContext, useTaskState } from "../context/TaskContext"
|
||||
import { useCompletionSignals } from "../hooks/useStateSubscriber"
|
||||
import { originalConsoleLog } from "../utils/console"
|
||||
|
||||
interface TaskJsonViewProps {
|
||||
taskId?: string
|
||||
verbose?: boolean
|
||||
onComplete?: () => void
|
||||
onError?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Output a JSON line to stdout
|
||||
*/
|
||||
function outputJson(data: object) {
|
||||
originalConsoleLog(JSON.stringify(data))
|
||||
}
|
||||
|
||||
export const TaskJsonView: React.FC<TaskJsonViewProps> = ({ taskId: _taskId, verbose = false, onComplete, onError }) => {
|
||||
const state = useTaskState()
|
||||
const { isTaskComplete, getCompletionMessage } = useCompletionSignals()
|
||||
const { setIsComplete } = useTaskContext()
|
||||
// Track outputted messages by timestamp (don't re-output on updates)
|
||||
const outputtedMessages = useRef<Set<number>>(new Set())
|
||||
const hasOutputtedCompletion = useRef(false)
|
||||
|
||||
// Determine the role for a message
|
||||
const getRole = (message: { type: string; ask?: string; say?: string }, index: number): "user" | "assistant" | "system" => {
|
||||
// User feedback messages
|
||||
if (message.say === "user_feedback" || message.say === "user_feedback_diff") {
|
||||
return "user"
|
||||
}
|
||||
// First text message is the user's task
|
||||
if (message.say === "text" && index === 0) {
|
||||
return "user"
|
||||
}
|
||||
// System messages
|
||||
if (message.say === "api_req_started" || message.say === "api_req_finished") {
|
||||
return "system"
|
||||
}
|
||||
// Default: assistant
|
||||
return "assistant"
|
||||
}
|
||||
|
||||
// Output messages as JSON when they arrive
|
||||
useEffect(() => {
|
||||
const messages = state.clineMessages || []
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i]
|
||||
|
||||
// Skip partial messages - wait for complete message
|
||||
if (message.partial) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if we already outputted this timestamp
|
||||
if (outputtedMessages.current.has(message.ts)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Filter out noisy messages in non-verbose mode
|
||||
if (!verbose) {
|
||||
if (message.say === "api_req_started" || message.say === "api_req_finished") {
|
||||
outputtedMessages.current.add(message.ts)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const role = getRole(message, i)
|
||||
|
||||
// Output the message as JSON
|
||||
outputJson({
|
||||
type: "message",
|
||||
timestamp: message.ts,
|
||||
role,
|
||||
messageType: message.type,
|
||||
...(message.ask && { ask: message.ask }),
|
||||
...(message.say && { say: message.say }),
|
||||
...(message.text && { text: message.text }),
|
||||
...(message.reasoning && { reasoning: message.reasoning }),
|
||||
...(message.images && message.images.length > 0 && { images: message.images }),
|
||||
...(message.files && message.files.length > 0 && { files: message.files }),
|
||||
})
|
||||
|
||||
outputtedMessages.current.add(message.ts)
|
||||
}
|
||||
}, [state.clineMessages, verbose])
|
||||
|
||||
// Handle task completion
|
||||
useEffect(() => {
|
||||
if (isTaskComplete() && !hasOutputtedCompletion.current) {
|
||||
hasOutputtedCompletion.current = true
|
||||
setIsComplete(true)
|
||||
|
||||
const completionMsg = getCompletionMessage()
|
||||
const isError = completionMsg?.say === "error" || completionMsg?.ask === "api_req_failed"
|
||||
|
||||
// Output completion status
|
||||
outputJson({
|
||||
type: "completion",
|
||||
status: isError ? "error" : "success",
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
if (isError) {
|
||||
onError?.()
|
||||
} else {
|
||||
onComplete?.()
|
||||
}
|
||||
|
||||
// Don't exit automatically - let the parent handle cleanup
|
||||
}
|
||||
}, [isTaskComplete, setIsComplete, onComplete, onError, getCompletionMessage])
|
||||
|
||||
// Render nothing visible - all output goes to stdout as JSON
|
||||
return <Box />
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
/**
|
||||
* Task view component
|
||||
* Main view for running a task - displays messages and handles user input
|
||||
*/
|
||||
|
||||
import { exit } from "node:process"
|
||||
import { CheckpointRestoreRequest } from "@shared/proto/cline/checkpoints"
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { checkpointRestore } from "@/core/controller/checkpoints/checkpointRestore"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { useTaskContext, useTaskState } from "../context/TaskContext"
|
||||
import { useCompletionSignals, useIsSpinnerActive } from "../hooks/useStateSubscriber"
|
||||
import { AskPrompt } from "./AskPrompt"
|
||||
import { CheckpointMenu, RestoreType } from "./CheckpointMenu"
|
||||
import { FocusChain } from "./FocusChain"
|
||||
import { MessageList } from "./MessageList"
|
||||
import { LoadingSpinner } from "./Spinner"
|
||||
|
||||
interface TaskViewProps {
|
||||
taskId?: string
|
||||
verbose?: boolean
|
||||
onComplete?: () => void
|
||||
onError?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Format separator line
|
||||
*/
|
||||
function formatSeparator(char: string = "═", width: number = 60): string {
|
||||
return char.repeat(Math.max(width, 10))
|
||||
}
|
||||
|
||||
export const TaskView: React.FC<TaskViewProps> = ({ taskId: _taskId, verbose = false, onComplete, onError }) => {
|
||||
const state = useTaskState()
|
||||
const { isTaskComplete, getCompletionMessage } = useCompletionSignals()
|
||||
const isSpinnerActive = useIsSpinnerActive()
|
||||
const { setIsComplete, lastError, controller } = useTaskContext()
|
||||
const [showCheckpointMenu, setShowCheckpointMenu] = useState(false)
|
||||
const [restoreStatus, setRestoreStatus] = useState<"idle" | "restoring" | "success" | "error">("idle")
|
||||
const [restoreMessage, setRestoreMessage] = useState<string | null>(null)
|
||||
|
||||
const yolo = useMemo(() => StateManager.get().getGlobalSettingsKey("yoloModeToggled"), [])
|
||||
|
||||
// Handle task completion
|
||||
useEffect(() => {
|
||||
if (isTaskComplete()) {
|
||||
setIsComplete(true)
|
||||
|
||||
// Check if it's an error
|
||||
const completionMsg = getCompletionMessage()
|
||||
if (completionMsg?.say === "error" || completionMsg?.ask === "api_req_failed") {
|
||||
onError?.()
|
||||
} else {
|
||||
onComplete?.()
|
||||
}
|
||||
|
||||
if (yolo) {
|
||||
exit()
|
||||
}
|
||||
}
|
||||
}, [isTaskComplete, setIsComplete, onComplete, onError, getCompletionMessage])
|
||||
|
||||
// Handle checkpoint restore
|
||||
const handleCheckpointRestore = useCallback(
|
||||
async (messageTs: number, restoreType: RestoreType) => {
|
||||
setShowCheckpointMenu(false)
|
||||
setRestoreStatus("restoring")
|
||||
setRestoreMessage(`Restoring checkpoint (${restoreType})...`)
|
||||
|
||||
try {
|
||||
await checkpointRestore(
|
||||
controller,
|
||||
CheckpointRestoreRequest.create({
|
||||
number: messageTs,
|
||||
restoreType: restoreType,
|
||||
}),
|
||||
)
|
||||
setRestoreStatus("success")
|
||||
setRestoreMessage("Checkpoint restored successfully")
|
||||
// Clear success message after a delay
|
||||
setTimeout(() => {
|
||||
setRestoreStatus("idle")
|
||||
setRestoreMessage(null)
|
||||
}, 3000)
|
||||
} catch (error) {
|
||||
setRestoreStatus("error")
|
||||
setRestoreMessage(`Failed to restore: ${error instanceof Error ? error.message : String(error)}`)
|
||||
// Clear error message after a delay
|
||||
setTimeout(() => {
|
||||
setRestoreStatus("idle")
|
||||
setRestoreMessage(null)
|
||||
}, 5000)
|
||||
}
|
||||
},
|
||||
[controller],
|
||||
)
|
||||
|
||||
// Handle Ctrl+R to open checkpoint menu
|
||||
useInput(
|
||||
(input, key) => {
|
||||
// Ctrl+R to open checkpoint menu
|
||||
if (key.ctrl && input === "r") {
|
||||
setShowCheckpointMenu(true)
|
||||
return
|
||||
}
|
||||
},
|
||||
{ isActive: !showCheckpointMenu },
|
||||
)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* Task header */}
|
||||
{state.currentTaskItem && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text>{formatSeparator("═")}</Text>
|
||||
<Text bold color="white">
|
||||
📋 Task: {state.currentTaskItem.id}
|
||||
</Text>
|
||||
{state.currentTaskItem.task && (
|
||||
<Text dimColor>
|
||||
{state.currentTaskItem.task.substring(0, 80)}
|
||||
{state.currentTaskItem.task.length > 80 ? "..." : ""}
|
||||
</Text>
|
||||
)}
|
||||
<Box>
|
||||
<Text>{formatSeparator("═")}</Text>
|
||||
</Box>
|
||||
<Text color="gray" dimColor>
|
||||
(Ctrl+R to restore checkpoint)
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Error message if any */}
|
||||
{lastError && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text bold color="red">
|
||||
Error: {lastError}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Restore status message */}
|
||||
{restoreMessage && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text bold color={restoreStatus === "error" ? "red" : restoreStatus === "success" ? "green" : "yellow"}>
|
||||
{restoreStatus === "restoring" ? "⏳ " : restoreStatus === "success" ? "✓ " : "✗ "}
|
||||
{restoreMessage}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Checkpoint menu */}
|
||||
{showCheckpointMenu && (
|
||||
<CheckpointMenu
|
||||
messages={state.clineMessages || []}
|
||||
onCancel={() => setShowCheckpointMenu(false)}
|
||||
onSelect={handleCheckpointRestore}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Focus Chain / To-Do List */}
|
||||
{state.currentFocusChainChecklist && (
|
||||
<Box marginBottom={1}>
|
||||
<FocusChain focusChainChecklist={state.currentFocusChainChecklist} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Messages list */}
|
||||
<MessageList verbose={verbose} />
|
||||
|
||||
{/* Loading spinner */}
|
||||
{isSpinnerActive && (
|
||||
<Box marginTop={1}>
|
||||
<LoadingSpinner />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* User input prompt */}
|
||||
{!yolo && <AskPrompt />}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Acting/Planning indicator with spinner, shimmer effect, and elapsed time
|
||||
*/
|
||||
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useEffect, useMemo, useState } from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
|
||||
interface ThinkingIndicatorProps {
|
||||
mode?: "act" | "plan"
|
||||
startTime?: number // Unix timestamp when thinking started
|
||||
onCancel?: () => void // Called when user presses esc to interrupt
|
||||
}
|
||||
|
||||
// Spinner frames (dots style from ink-spinner)
|
||||
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
||||
|
||||
const SHIMMER_WIDTH = 3 // How many characters are "bright" at once
|
||||
|
||||
/**
|
||||
* Format elapsed time as "1m 5s" or "45s"
|
||||
*/
|
||||
function formatElapsedTime(ms: number): string {
|
||||
const totalSeconds = Math.floor(ms / 1000)
|
||||
const minutes = Math.floor(totalSeconds / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${seconds}s`
|
||||
}
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
/**
|
||||
* Render text with a shimmer effect using bold for bright characters
|
||||
*/
|
||||
const ShimmerText: React.FC<{ text: string; color: string; shimmerPos: number }> = ({ text, color, shimmerPos }) => {
|
||||
const chars = text.split("")
|
||||
return (
|
||||
<Text>
|
||||
{chars.map((char, i) => {
|
||||
const distFromShimmer = Math.abs(i - shimmerPos)
|
||||
const isBright = distFromShimmer <= SHIMMER_WIDTH
|
||||
return (
|
||||
<Text bold={isBright} color={color} dimColor={!isBright} key={i}>
|
||||
{char}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
export const ThinkingIndicator: React.FC<ThinkingIndicatorProps> = ({ mode = "act", startTime, onCancel }) => {
|
||||
const message = mode === "plan" ? "Planning" : "Acting"
|
||||
const color = mode === "plan" ? "yellow" : COLORS.primaryBlue
|
||||
|
||||
// Handle esc key to cancel
|
||||
useInput((_input, key) => {
|
||||
if (key.escape && onCancel) {
|
||||
onCancel()
|
||||
}
|
||||
})
|
||||
|
||||
// Spinner frame index
|
||||
const [spinnerFrame, setSpinnerFrame] = useState(0)
|
||||
|
||||
// Shimmer position
|
||||
const [shimmerPos, setShimmerPos] = useState(-SHIMMER_WIDTH)
|
||||
|
||||
// Elapsed time state
|
||||
const [elapsedMs, setElapsedMs] = useState(0)
|
||||
|
||||
const spinnerChar = SPINNER_FRAMES[spinnerFrame]
|
||||
const fullText = `${spinnerChar} ${message}...`
|
||||
|
||||
// Animate spinner
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setSpinnerFrame((prev) => (prev + 1) % SPINNER_FRAMES.length)
|
||||
}, 80)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
// Animate shimmer
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setShimmerPos((prev) => {
|
||||
const next = prev + 1
|
||||
if (next > fullText.length + SHIMMER_WIDTH) {
|
||||
return -SHIMMER_WIDTH
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, 100)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [fullText.length])
|
||||
|
||||
// Update elapsed time
|
||||
useEffect(() => {
|
||||
if (!startTime) return
|
||||
|
||||
const updateElapsed = () => {
|
||||
setElapsedMs(Date.now() - startTime)
|
||||
}
|
||||
|
||||
updateElapsed() // Initial update
|
||||
const interval = setInterval(updateElapsed, 1000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [startTime])
|
||||
|
||||
const elapsedStr = useMemo(() => {
|
||||
if (!startTime) return null
|
||||
return formatElapsedTime(elapsedMs)
|
||||
}, [startTime, elapsedMs])
|
||||
|
||||
return (
|
||||
<Box paddingLeft={1}>
|
||||
<ShimmerText color={color} shimmerPos={shimmerPos} text={fullText} />
|
||||
{elapsedStr && <Text color="gray"> ({elapsedStr} · esc to interrupt)</Text>}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -4,10 +4,11 @@
|
||||
* 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 { getProviderDefaultModelId, Mode } from "@/shared/storage"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import {
|
||||
checkAndWarnRipgrepMissing,
|
||||
extractMentionQuery,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
insertMention,
|
||||
searchWorkspaceFiles,
|
||||
} from "../utils/file-search"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { parseImagesFromInput } from "../utils/parser"
|
||||
import { AccountInfoView } from "./AccountInfoView"
|
||||
import { FileMentionMenu } from "./FileMentionMenu"
|
||||
@@ -51,6 +53,7 @@ const RIPGREP_WARNING_DURATION_MS = 5000
|
||||
const MAX_SEARCH_RESULTS = 15
|
||||
|
||||
export const WelcomeView: React.FC<WelcomeViewProps> = ({ onSubmit, onExit, controller }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const [textInput, setTextInput] = useState("")
|
||||
const [fileResults, setFileResults] = useState<FileSearchResult[]>([])
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
@@ -62,12 +65,20 @@ export const WelcomeView: React.FC<WelcomeViewProps> = ({ onSubmit, onExit, cont
|
||||
return stateManager.getGlobalSettingsKey("mode") || "act"
|
||||
})
|
||||
|
||||
const provider = useMemo(() => {
|
||||
const stateManager = StateManager.get()
|
||||
const mode = stateManager.getGlobalSettingsKey("mode") as string
|
||||
const providerKey = mode === "act" ? "actModeApiProvider" : "planModeApiProvider"
|
||||
const currentProvider = stateManager.getGlobalSettingsKey(providerKey) as string
|
||||
return currentProvider || "cline"
|
||||
}, [controller])
|
||||
|
||||
// Get model ID based on current mode
|
||||
const modelId = useMemo(() => {
|
||||
const stateManager = StateManager.get()
|
||||
const modelKey = mode === "act" ? "actModeApiModelId" : "planModeApiModelId"
|
||||
return (stateManager.getGlobalSettingsKey(modelKey) as string) || "claude-sonnet-4-20250514"
|
||||
}, [mode])
|
||||
return (stateManager.getGlobalSettingsKey(modelKey) as string) || getProviderDefaultModelId(provider)
|
||||
}, [mode, provider])
|
||||
|
||||
const toggleMode = useCallback(() => {
|
||||
const newMode: Mode = mode === "act" ? "plan" : "act"
|
||||
@@ -151,64 +162,72 @@ export const WelcomeView: React.FC<WelcomeViewProps> = ({ onSubmit, onExit, cont
|
||||
}
|
||||
}, [mentionInfo.inMentionMode, mentionInfo.query, workspacePath])
|
||||
|
||||
useInput((input, key) => {
|
||||
const inMenu = mentionInfo.inMentionMode && fileResults.length > 0
|
||||
useInput(
|
||||
(input, key) => {
|
||||
// Filter out mouse escape sequences
|
||||
if (isMouseEscapeSequence(input)) {
|
||||
return
|
||||
}
|
||||
|
||||
// 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))
|
||||
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) {
|
||||
setFileResults([])
|
||||
setSelectedIndex(0)
|
||||
if (key.escape && !mentionInfo.inMentionMode) {
|
||||
if (escPressedOnce) {
|
||||
onExit?.()
|
||||
} else {
|
||||
setEscPressedOnce(true)
|
||||
}
|
||||
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)
|
||||
if (key.backspace || key.delete) {
|
||||
setTextInput((prev) => prev.slice(0, -1))
|
||||
setEscPressedOnce(false)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
if (key.escape && !mentionInfo.inMentionMode) {
|
||||
if (escPressedOnce) {
|
||||
onExit?.()
|
||||
} else {
|
||||
setEscPressedOnce(true)
|
||||
if (input && !key.ctrl && !key.meta && !key.upArrow && !key.downArrow && !key.tab) {
|
||||
setTextInput((prev) => prev + input)
|
||||
setEscPressedOnce(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (key.backspace || key.delete) {
|
||||
setTextInput((prev) => prev.slice(0, -1))
|
||||
setEscPressedOnce(false)
|
||||
return
|
||||
}
|
||||
if (input && !key.ctrl && !key.meta && !key.upArrow && !key.downArrow && !key.tab) {
|
||||
setTextInput((prev) => prev + input)
|
||||
setEscPressedOnce(false)
|
||||
}
|
||||
})
|
||||
},
|
||||
{ isActive: isRawModeSupported },
|
||||
)
|
||||
|
||||
const borderColor = mode === "act" ? "blue" : "yellow"
|
||||
|
||||
@@ -223,8 +242,8 @@ export const WelcomeView: React.FC<WelcomeViewProps> = ({ onSubmit, onExit, cont
|
||||
|
||||
{/* Cline logo - centered */}
|
||||
<Box alignItems="center" flexDirection="column">
|
||||
{/* biome-ignore lint/suspicious/noArrayIndexKey: static array that never changes */}
|
||||
{CLINE_LOGO.map((line, idx) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static array that never changes
|
||||
<Text color="white" key={idx}>
|
||||
{line}
|
||||
</Text>
|
||||
@@ -256,15 +275,13 @@ export const WelcomeView: React.FC<WelcomeViewProps> = ({ onSubmit, onExit, cont
|
||||
paddingRight={1}
|
||||
width="100%">
|
||||
<Text>{textInput}</Text>
|
||||
<Text color="gray">▌</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
|
||||
{/* Model ID and Mode toggle row */}
|
||||
<Box justifyContent="space-between" width="100%">
|
||||
{/* Model ID on left */}
|
||||
<Text color="gray" dimColor>
|
||||
{modelId}
|
||||
</Text>
|
||||
<Text color="gray">{modelId}</Text>
|
||||
|
||||
{/* Mode toggle on right */}
|
||||
<Box gap={1}>
|
||||
@@ -278,9 +295,7 @@ export const WelcomeView: React.FC<WelcomeViewProps> = ({ onSubmit, onExit, cont
|
||||
{mode === "act" ? "●" : "○"} Act
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color="gray" dimColor>
|
||||
(Tab)
|
||||
</Text>
|
||||
<Text color="gray">(Tab)</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -303,9 +318,7 @@ export const WelcomeView: React.FC<WelcomeViewProps> = ({ onSubmit, onExit, cont
|
||||
|
||||
{/* Help text */}
|
||||
<Box>
|
||||
<Text color="gray" dimColor>
|
||||
Enter to submit · @ to mention files ·{" "}
|
||||
</Text>
|
||||
<Text color="gray">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>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Color constants for the CLI
|
||||
* Using hex values for consistent rendering across terminals
|
||||
*/
|
||||
|
||||
export const COLORS = {
|
||||
// Primary brand color - light purple-blue
|
||||
primaryBlue: "#B1B9F9",
|
||||
|
||||
// Plan mode color
|
||||
planYellow: "yellow",
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Get the appropriate color for the current mode
|
||||
*/
|
||||
export function getModeColor(mode: "act" | "plan"): string {
|
||||
return mode === "plan" ? COLORS.planYellow : COLORS.primaryBlue
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Featured models shown in the Cline model picker during onboarding
|
||||
* These are curated models that work well with Cline
|
||||
*/
|
||||
|
||||
export interface FeaturedModel {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export const FEATURED_MODELS = {
|
||||
recommended: [
|
||||
{
|
||||
id: "anthropic/claude-opus-4.5",
|
||||
name: "Claude Opus 4.5",
|
||||
description: "State-of-the-art for complex coding",
|
||||
label: "Best",
|
||||
},
|
||||
{
|
||||
id: "openai/gpt-5.2-codex",
|
||||
name: "GPT 5.2 Codex",
|
||||
description: "OpenAI's latest with strong coding abilities",
|
||||
label: "New",
|
||||
},
|
||||
{
|
||||
id: "google/gemini-3-pro-preview",
|
||||
name: "Gemini 3 Pro",
|
||||
description: "1M context window for large codebases",
|
||||
label: "Trending",
|
||||
},
|
||||
] as FeaturedModel[],
|
||||
free: [
|
||||
{
|
||||
id: "kwaipilot/kat-coder-pro",
|
||||
name: "KAT Coder Pro",
|
||||
description: "Advanced agentic coding model",
|
||||
label: "FREE",
|
||||
},
|
||||
{
|
||||
id: "mistralai/devstral-2512:free",
|
||||
name: "Devstral",
|
||||
description: "Mistral's coding model",
|
||||
label: "FREE",
|
||||
},
|
||||
] as FeaturedModel[],
|
||||
}
|
||||
|
||||
export function getAllFeaturedModels(): FeaturedModel[] {
|
||||
return [...FEATURED_MODELS.recommended, ...FEATURED_MODELS.free]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Context for tracking stdin raw mode support
|
||||
* Used to conditionally disable input handling when stdin doesn't support raw mode
|
||||
* (e.g., when input is piped: echo "..." | clinedev)
|
||||
*/
|
||||
|
||||
import React, { createContext, type ReactNode, useContext } from "react"
|
||||
|
||||
interface StdinContextValue {
|
||||
/**
|
||||
* Whether stdin supports raw mode (keyboard input handling)
|
||||
* Will be false when input is piped or stdin is not a TTY
|
||||
*/
|
||||
isRawModeSupported: boolean
|
||||
}
|
||||
|
||||
const StdinContext = createContext<StdinContextValue>({ isRawModeSupported: true })
|
||||
|
||||
export const useStdinContext = () => useContext(StdinContext)
|
||||
|
||||
interface StdinProviderProps {
|
||||
children: ReactNode
|
||||
isRawModeSupported: boolean
|
||||
}
|
||||
|
||||
export const StdinProvider: React.FC<StdinProviderProps> = ({ children, isRawModeSupported }) => {
|
||||
return <StdinContext.Provider value={{ isRawModeSupported }}>{children}</StdinContext.Provider>
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if stdin supports raw mode
|
||||
* Returns false when input is piped or stdin is not a TTY
|
||||
*/
|
||||
export function checkRawModeSupport(): boolean {
|
||||
return Boolean(process.stdin.isTTY && typeof process.stdin.setRawMode === "function")
|
||||
}
|
||||
@@ -46,6 +46,13 @@ export const TaskContextProvider: React.FC<TaskContextProviderProps> = ({ contro
|
||||
const handleStateUpdate = async () => {
|
||||
try {
|
||||
const newState = await controller.getStateToPostToWebview()
|
||||
// Ignore transient empty messages state during cancel/reinit
|
||||
// When clearTask() runs, messages briefly become [] before new task loads them
|
||||
const hadMessages = (stateRef.current.clineMessages?.length ?? 0) > 0
|
||||
const hasMessages = (newState.clineMessages?.length ?? 0) > 0
|
||||
if (hadMessages && !hasMessages) {
|
||||
return
|
||||
}
|
||||
setState(newState)
|
||||
} catch (error) {
|
||||
setLastError(error instanceof Error ? error.message : String(error))
|
||||
|
||||
@@ -11,6 +11,8 @@ import type {
|
||||
} from "@generated/hosts/host-bridge-client-types"
|
||||
import type { HostBridgeClientProvider, StreamingCallbacks } from "@hosts/host-provider-types"
|
||||
import * as proto from "@shared/proto/index"
|
||||
import { ClineClient } from "@/shared/cline"
|
||||
import { version as CLI_VERSION } from "../../package.json"
|
||||
import { printError, printInfo, printWarning } from "../utils/display"
|
||||
|
||||
/**
|
||||
@@ -78,6 +80,8 @@ export class CliDiffServiceClient implements DiffServiceClientInterface {
|
||||
export class CliEnvServiceClient implements EnvServiceClientInterface {
|
||||
private clipboardContent: string = ""
|
||||
|
||||
private telemetrySetting = proto.host.Setting.ENABLED
|
||||
|
||||
async clipboardWriteText(request: proto.cline.StringRequest): Promise<proto.cline.Empty> {
|
||||
this.clipboardContent = request.value || ""
|
||||
printInfo(`📋 Copied to clipboard`)
|
||||
@@ -90,8 +94,9 @@ export class CliEnvServiceClient implements EnvServiceClientInterface {
|
||||
|
||||
async getHostVersion(_request: proto.cline.EmptyRequest): Promise<proto.host.GetHostVersionResponse> {
|
||||
return proto.host.GetHostVersionResponse.create({
|
||||
version: "1.0.0",
|
||||
platform: "Cline CLI",
|
||||
version: CLI_VERSION,
|
||||
platform: "Cline CLI - Node.js",
|
||||
clineType: ClineClient.Cli,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -102,7 +107,7 @@ export class CliEnvServiceClient implements EnvServiceClientInterface {
|
||||
|
||||
async getTelemetrySettings(_request: proto.cline.EmptyRequest): Promise<proto.host.GetTelemetrySettingsResponse> {
|
||||
return proto.host.GetTelemetrySettingsResponse.create({
|
||||
isEnabled: proto.host.Setting.DISABLED,
|
||||
isEnabled: this.telemetrySetting,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -113,13 +118,21 @@ export class CliEnvServiceClient implements EnvServiceClientInterface {
|
||||
// Send initial settings
|
||||
callbacks.onResponse(
|
||||
proto.host.TelemetrySettingsEvent.create({
|
||||
isEnabled: proto.host.Setting.DISABLED,
|
||||
isEnabled: this.telemetrySetting,
|
||||
}),
|
||||
)
|
||||
// Return unsubscribe function
|
||||
return () => {}
|
||||
}
|
||||
|
||||
debugLog(request: proto.cline.StringRequest): Promise<proto.cline.Empty> {
|
||||
const message = request.value || ""
|
||||
if (process.env.IS_DEV) {
|
||||
printInfo(`[DebugLog] ${message}`)
|
||||
}
|
||||
return Promise.resolve(proto.cline.Empty.create())
|
||||
}
|
||||
|
||||
async shutdown(_request: proto.cline.EmptyRequest): Promise<proto.cline.Empty> {
|
||||
printInfo("Shutting down...")
|
||||
return proto.cline.Empty.create()
|
||||
@@ -258,6 +271,13 @@ export class CliWorkspaceServiceClient implements WorkspaceServiceClientInterfac
|
||||
printInfo(`⚙️ Executing: ${request.command}`)
|
||||
return proto.host.ExecuteCommandInTerminalResponse.create({})
|
||||
}
|
||||
|
||||
async openFolder(request: proto.host.OpenFolderRequest): Promise<proto.host.OpenFolderResponse> {
|
||||
const path = request.path || ""
|
||||
this.workspacePath = path
|
||||
printInfo(`📂 Opening folder: ${path}`)
|
||||
return proto.host.OpenFolderResponse.create({ success: true })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Shared hook for scrollable list windowing in terminal UIs
|
||||
* Used by AuthView (provider list) and ModelPicker (model list)
|
||||
*/
|
||||
|
||||
import { useMemo } from "react"
|
||||
|
||||
interface ScrollableListResult {
|
||||
visibleStart: number
|
||||
visibleCount: number
|
||||
showTopIndicator: boolean
|
||||
showBottomIndicator: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate visible window for a scrollable list
|
||||
* Keeps the selected item in view while showing scroll indicators
|
||||
*
|
||||
* @param itemCount - Total number of items in the list
|
||||
* @param selectedIndex - Currently selected item index
|
||||
* @param maxRows - Maximum rows to display (indicators take up row space when shown)
|
||||
*/
|
||||
export function useScrollableList(itemCount: number, selectedIndex: number, maxRows: number): ScrollableListResult {
|
||||
return useMemo(() => {
|
||||
if (itemCount <= maxRows) {
|
||||
return {
|
||||
visibleStart: 0,
|
||||
visibleCount: itemCount,
|
||||
showTopIndicator: false,
|
||||
showBottomIndicator: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if we need indicators based on index position
|
||||
const needsTopIndicator = selectedIndex > 0
|
||||
const needsBottomIndicator = selectedIndex < itemCount - 1
|
||||
|
||||
// Calculate how many items we can show (subtract space for indicators)
|
||||
let itemSlots = maxRows
|
||||
if (needsTopIndicator && selectedIndex >= maxRows - 1) itemSlots--
|
||||
if (needsBottomIndicator && selectedIndex <= itemCount - maxRows) itemSlots--
|
||||
|
||||
// Calculate start position keeping selected item in view
|
||||
const maxStart = itemCount - itemSlots
|
||||
const idealStart = selectedIndex - Math.floor(itemSlots / 2)
|
||||
const start = Math.max(0, Math.min(idealStart, maxStart))
|
||||
|
||||
const showTop = start > 0
|
||||
const showBottom = start + itemSlots < itemCount
|
||||
|
||||
// Recalculate item slots based on actual indicators shown
|
||||
let finalItemSlots = maxRows
|
||||
if (showTop) finalItemSlots--
|
||||
if (showBottom) finalItemSlots--
|
||||
|
||||
const finalStart = Math.max(0, Math.min(selectedIndex - Math.floor(finalItemSlots / 2), itemCount - finalItemSlots))
|
||||
|
||||
return {
|
||||
visibleStart: finalStart,
|
||||
visibleCount: finalItemSlots,
|
||||
showTopIndicator: finalStart > 0,
|
||||
showBottomIndicator: finalStart + finalItemSlots < itemCount,
|
||||
}
|
||||
}, [itemCount, selectedIndex, maxRows])
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export const useProcessedMessages = () => {
|
||||
* Detect if a message has just been completed (is asking for user input)
|
||||
*/
|
||||
export const useCompletedAskMessages = () => {
|
||||
const { state, controller } = useTaskContext()
|
||||
const { state } = useTaskContext()
|
||||
const processed = useProcessedMessages()
|
||||
|
||||
const getCompletedAskMessages = useCallback(() => {
|
||||
@@ -124,18 +124,19 @@ export const useCompletionSignals = () => {
|
||||
|
||||
/**
|
||||
* Check if spinner should be shown (when API is thinking)
|
||||
* Returns an object with isActive flag and startTime timestamp
|
||||
*/
|
||||
export const useIsSpinnerActive = (): boolean => {
|
||||
export const useIsSpinnerActive = (): { isActive: boolean; startTime?: number } => {
|
||||
const { state } = useTaskContext()
|
||||
|
||||
if (!state.clineMessages || state.clineMessages.length === 0) {
|
||||
return false
|
||||
return { isActive: 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
|
||||
return { isActive: false }
|
||||
}
|
||||
|
||||
// Look for most recent api_req_started that isn't followed by api_req_finished
|
||||
@@ -150,9 +151,12 @@ export const useIsSpinnerActive = (): boolean => {
|
||||
break
|
||||
}
|
||||
}
|
||||
return !hasFinished
|
||||
if (!hasFinished) {
|
||||
return { isActive: true, startTime: msg.ts }
|
||||
}
|
||||
return { isActive: false }
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
return { isActive: false }
|
||||
}
|
||||
|
||||
+428
-117
@@ -8,25 +8,45 @@ import type { ApiProvider } from "@shared/api"
|
||||
import { Command } from "commander"
|
||||
import { render } from "ink"
|
||||
import React from "react"
|
||||
import { ClineEndpoint } from "@/config"
|
||||
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 { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
|
||||
import { StandaloneTerminalManager } from "@/integrations/terminal/standalone/StandaloneTerminalManager"
|
||||
import { BannerService } from "@/services/banner/BannerService"
|
||||
import { ErrorService } from "@/services/error/ErrorService"
|
||||
import { initializeDistinctId } from "@/services/logging/distinctId"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { getProviderModelIdKey, ProviderToApiKeyMap } from "@/shared/storage"
|
||||
import { secretStorage } from "@/shared/storage/ClineSecretStorage"
|
||||
import { version as CLI_VERSION } from "../package.json"
|
||||
import { App } from "./components/App"
|
||||
import { checkRawModeSupport } from "./context/StdinContext"
|
||||
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 { calculateRobotTopRow, queryCursorPos } from "./utils/cursor-position"
|
||||
import { print, printInfo, printWarning } from "./utils/display"
|
||||
import {
|
||||
addMcpServer,
|
||||
formatServerConfig,
|
||||
listMcpServers,
|
||||
type McpServerConfig,
|
||||
parseKeyValuePairs,
|
||||
removeMcpServer,
|
||||
setMcpServerDisabled,
|
||||
updateMcpServerAutoApprove,
|
||||
} from "./utils/mcp"
|
||||
import { parseImagesFromInput, processImagePaths } from "./utils/parser"
|
||||
import { getProviderModelIdKey } from "./utils/provider-map"
|
||||
import { readStdinIfPiped } from "./utils/piped"
|
||||
import { runPlainTextTask } from "./utils/plain-text-task"
|
||||
import { initializeCliContext } from "./vscode-context"
|
||||
|
||||
const VERSION = "0.0.0"
|
||||
import { window } from "./vscode-shim"
|
||||
|
||||
// Track active context for graceful shutdown
|
||||
let activeContext: CliContext | null = null
|
||||
@@ -39,6 +59,7 @@ function setupSignalHandlers() {
|
||||
process.exit(1)
|
||||
}
|
||||
isShuttingDown = true
|
||||
|
||||
printWarning(`\n${signal} received, shutting down...`)
|
||||
|
||||
try {
|
||||
@@ -59,6 +80,19 @@ function setupSignalHandlers() {
|
||||
|
||||
process.on("SIGINT", () => shutdown("SIGINT"))
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"))
|
||||
|
||||
// Suppress known abort errors from unhandled rejections
|
||||
// These occur when task is cancelled and async operations throw "Cline instance aborted"
|
||||
process.on("unhandledRejection", (reason: unknown) => {
|
||||
const message = reason instanceof Error ? reason.message : String(reason)
|
||||
// Silently ignore abort-related errors - they're expected during task cancellation
|
||||
if (message.includes("aborted") || message.includes("abort")) {
|
||||
return
|
||||
}
|
||||
// For other unhandled rejections, log to file via Logger (if available)
|
||||
// This won't show in terminal but will be in log files for debugging
|
||||
Logger.error("Unhandled rejection:", reason)
|
||||
})
|
||||
}
|
||||
|
||||
setupSignalHandlers()
|
||||
@@ -88,14 +122,19 @@ async function initializeCli(options: InitOptions): Promise<CliContext> {
|
||||
workspaceDir: workspacePath,
|
||||
})
|
||||
|
||||
await ClineEndpoint.initialize()
|
||||
await initializeDistinctId(extensionContext)
|
||||
|
||||
if (options.enableAuth) {
|
||||
AuthHandler.getInstance().setEnabled(true)
|
||||
}
|
||||
|
||||
const logToChannel = options.verbose ? (message: string) => printInfo(message) : () => {}
|
||||
const outputChannel = window.createOutputChannel("Cline CLI")
|
||||
outputChannel.appendLine(`Cline CLI initialized. Data dir: ${DATA_DIR}, Extension dir: ${EXTENSION_DIR}`)
|
||||
const logToChannel = (message: string) => outputChannel.appendLine(message)
|
||||
|
||||
HostProvider.initialize(
|
||||
() => new CliWebviewProvider(extensionContext),
|
||||
() => new CliWebviewProvider(extensionContext as any),
|
||||
() => new FileEditProvider(),
|
||||
() => new CliCommentReviewController(),
|
||||
() => new StandaloneTerminalManager(),
|
||||
@@ -108,12 +147,20 @@ async function initializeCli(options: InitOptions): Promise<CliContext> {
|
||||
)
|
||||
|
||||
await ErrorService.initialize()
|
||||
await StateManager.initialize(extensionContext)
|
||||
await StateManager.initialize(extensionContext as any)
|
||||
|
||||
// Initialize OpenAI Codex OAuth manager with extension context for secrets storage
|
||||
openAiCodexOAuthManager.initialize(extensionContext)
|
||||
|
||||
// Configure the shared Logging class to use HostProvider's output channel
|
||||
Logger.subscribe((msg: string) => HostProvider.get().logToChannel(msg))
|
||||
|
||||
const webview = HostProvider.get().createWebviewProvider() as CliWebviewProvider
|
||||
const controller = webview.controller
|
||||
|
||||
await initializeDistinctId(extensionContext)
|
||||
BannerService.initialize(webview.controller)
|
||||
|
||||
telemetryService.captureHostEvent("cline_cli", "initialized")
|
||||
|
||||
const ctx = { extensionContext, dataDir: DATA_DIR, extensionDir: EXTENSION_DIR, workspacePath, controller }
|
||||
activeContext = ctx
|
||||
@@ -124,6 +171,10 @@ async function initializeCli(options: InitOptions): Promise<CliContext> {
|
||||
* Run an Ink app with proper cleanup handling
|
||||
*/
|
||||
async function runInkApp(element: React.ReactElement, cleanup: () => Promise<void>): Promise<void> {
|
||||
// Note: incrementalRendering is disabled because it causes UI glitches on terminal resize.
|
||||
// Ink's incremental rendering tries to erase N lines based on previous output height,
|
||||
// but when the terminal shrinks, this leaves artifacts. Gemini CLI only enables
|
||||
// incrementalRendering when alternateBuffer is also enabled (which we don't use).
|
||||
const { waitUntilExit, unmount } = render(element)
|
||||
|
||||
try {
|
||||
@@ -140,28 +191,7 @@ async function runInkApp(element: React.ReactElement, cleanup: () => Promise<voi
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Run a task with the given prompt - uses welcome view for consistent behavior
|
||||
*/
|
||||
async function runTask(
|
||||
prompt: string,
|
||||
@@ -175,10 +205,12 @@ async function runTask(
|
||||
thinking?: boolean
|
||||
yolo?: boolean
|
||||
images?: string[]
|
||||
json?: boolean
|
||||
stdinWasPiped?: boolean
|
||||
},
|
||||
existingContext?: CliContext,
|
||||
) {
|
||||
const ctx = existingContext || (await initializeCli(options))
|
||||
const ctx = existingContext || (await initializeCli({ ...options, enableAuth: true }))
|
||||
|
||||
// Parse images from the prompt text (e.g., @/path/to/image.png)
|
||||
const { prompt: cleanPrompt, imagePaths: parsedImagePaths } = parseImagesFromInput(prompt)
|
||||
@@ -191,10 +223,15 @@ async function runTask(
|
||||
// Use clean prompt (with image refs removed)
|
||||
const taskPrompt = cleanPrompt || prompt
|
||||
|
||||
// Task without prompt starts in interactive mode
|
||||
telemetryService.captureHostEvent("task_command", prompt ? "task" : "interactive")
|
||||
|
||||
if (options.plan) {
|
||||
StateManager.get().setGlobalState("mode", "plan")
|
||||
telemetryService.captureHostEvent("mode_flag", "plan")
|
||||
} else if (options.act) {
|
||||
StateManager.get().setGlobalState("mode", "act")
|
||||
telemetryService.captureHostEvent("mode_flag", "act")
|
||||
}
|
||||
|
||||
if (options.model) {
|
||||
@@ -213,6 +250,7 @@ async function runTask(
|
||||
if (providerModelKey) {
|
||||
StateManager.get().setGlobalState(providerModelKey, options.model)
|
||||
}
|
||||
telemetryService.captureHostEvent("model_flag", options.model)
|
||||
}
|
||||
|
||||
// Set thinking budget based on --thinking flag
|
||||
@@ -220,70 +258,94 @@ async function runTask(
|
||||
const currentMode = StateManager.get().getGlobalSettingsKey("mode") || "act"
|
||||
const thinkingKey = currentMode === "act" ? "actModeThinkingBudgetTokens" : "planModeThinkingBudgetTokens"
|
||||
StateManager.get().setGlobalState(thinkingKey, thinkingBudget)
|
||||
if (options.thinking) {
|
||||
telemetryService.captureHostEvent("thinking_flag", "true")
|
||||
}
|
||||
|
||||
// Set yolo mode based on --yolo flag
|
||||
if (options.yolo) {
|
||||
StateManager.get().setGlobalState("yoloModeToggled", true)
|
||||
telemetryService.captureHostEvent("yolo_flag", "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())
|
||||
// Detect if output is a TTY (interactive terminal) or redirected to a file/pipe
|
||||
const isTTY = process.stdout.isTTY === true
|
||||
|
||||
let isComplete = false
|
||||
let taskError = false
|
||||
// Use plain text mode when output is redirected, stdin was piped, or JSON mode is enabled
|
||||
// Ink requires raw mode on stdin which isn't available when stdin is piped
|
||||
// Note: we use the stdinWasPiped flag passed from the caller because process.stdin.isTTY
|
||||
// may not be reliable after stdin has been consumed by readStdinIfPiped()
|
||||
if (!isTTY || options.stdinWasPiped || options.json) {
|
||||
// Check if auth is configured before attempting to run the task
|
||||
// In plain text mode we can't show the interactive auth flow
|
||||
const hasAuth = await isAuthConfigured()
|
||||
if (!hasAuth) {
|
||||
printWarning("Not authenticated. Please run 'cline auth' first to configure your API credentials.")
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
await ctx.controller.dispose()
|
||||
await ErrorService.get().dispose()
|
||||
exit(1)
|
||||
}
|
||||
|
||||
const { waitUntilExit, unmount } = render(
|
||||
React.createElement(App, {
|
||||
view: "task",
|
||||
taskId: taskPrompt.substring(0, 30),
|
||||
verbose: options.verbose,
|
||||
const reason = options.json ? "json" : options.stdinWasPiped ? "piped_stdin" : "redirected_output"
|
||||
telemetryService.captureHostEvent("plain_text_mode", reason)
|
||||
// Plain text mode: no Ink rendering, just clean text output
|
||||
const success = await runPlainTextTask({
|
||||
controller: ctx.controller,
|
||||
onComplete: () => {
|
||||
isComplete = true
|
||||
},
|
||||
onError: () => {
|
||||
taskError = true
|
||||
isComplete = true
|
||||
},
|
||||
}),
|
||||
)
|
||||
prompt: taskPrompt,
|
||||
imageDataUrls: imageDataUrls.length > 0 ? imageDataUrls : undefined,
|
||||
verbose: options.verbose,
|
||||
jsonOutput: options.json,
|
||||
})
|
||||
|
||||
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()
|
||||
// Cleanup
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
await ctx.controller.dispose()
|
||||
await ErrorService.get().dispose()
|
||||
exit(0)
|
||||
exit(success ? 0 : 1)
|
||||
}
|
||||
|
||||
// Use welcome view for consistent rendering (same as interactive mode)
|
||||
// Query cursor position BEFORE Ink mounts to know where robot will render
|
||||
const cursorPos = await queryCursorPos(process.stdin, process.stdout)
|
||||
const terminalRows = process.stdout.rows ?? 24
|
||||
const robotTopRow = calculateRobotTopRow(cursorPos, terminalRows)
|
||||
|
||||
let taskError = false
|
||||
|
||||
// Render the welcome view with optional initial prompt/images
|
||||
// If prompt provided (cline task "prompt"), ChatView will auto-submit
|
||||
// If no prompt (cline interactive), user will type it in
|
||||
await runInkApp(
|
||||
React.createElement(App, {
|
||||
view: "welcome",
|
||||
verbose: options.verbose,
|
||||
controller: ctx.controller,
|
||||
isRawModeSupported: checkRawModeSupport(),
|
||||
robotTopRow,
|
||||
initialPrompt: taskPrompt || undefined,
|
||||
initialImages: imageDataUrls.length > 0 ? imageDataUrls : undefined,
|
||||
onError: () => {
|
||||
taskError = true
|
||||
},
|
||||
onWelcomeExit: () => {
|
||||
// User pressed Esc
|
||||
exit(0)
|
||||
},
|
||||
}),
|
||||
async () => {
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
await ctx.controller.dispose()
|
||||
await ErrorService.get().dispose()
|
||||
if (taskError) {
|
||||
printWarning("Task ended with errors.")
|
||||
exit(1)
|
||||
}
|
||||
exit(0)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -300,6 +362,8 @@ async function listHistory(options: { config?: string; limit?: number; page?: nu
|
||||
const totalCount = sortedHistory.length
|
||||
const totalPages = Math.ceil(totalCount / limit)
|
||||
|
||||
telemetryService.captureHostEvent("history_command", "executed")
|
||||
|
||||
if (sortedHistory.length === 0) {
|
||||
printInfo("No task history found.")
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
@@ -316,6 +380,7 @@ async function listHistory(options: { config?: string; limit?: number; page?: nu
|
||||
historyAllItems: sortedHistory,
|
||||
controller: ctx.controller,
|
||||
historyPagination: { page: initialPage, totalPages, totalCount, limit },
|
||||
isRawModeSupported: checkRawModeSupport(),
|
||||
}),
|
||||
async () => {
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
@@ -337,17 +402,19 @@ async function showConfig(options: { config?: string }) {
|
||||
const { ConfigViewWrapper } = await import("./components/ConfigViewWrapper")
|
||||
|
||||
// Check feature flags
|
||||
const hooksEnabled = stateManager.getGlobalSettingsKey("hooksEnabled") ?? false
|
||||
const skillsEnabled = stateManager.getGlobalSettingsKey("skillsEnabled") ?? false
|
||||
|
||||
telemetryService.captureHostEvent("config_command", "executed")
|
||||
|
||||
await runInkApp(
|
||||
React.createElement(ConfigViewWrapper, {
|
||||
controller: ctx.controller,
|
||||
dataDir: ctx.dataDir,
|
||||
globalState: stateManager.getAllGlobalStateEntries(),
|
||||
workspaceState: stateManager.getAllWorkspaceStateEntries(),
|
||||
hooksEnabled,
|
||||
hooksEnabled: true,
|
||||
skillsEnabled,
|
||||
isRawModeSupported: checkRawModeSupport(),
|
||||
}),
|
||||
async () => {
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
@@ -377,16 +444,21 @@ async function runAuth(options: {
|
||||
? { provider: options.provider, apikey: options.apikey, modelid: options.modelid, baseurl: options.baseurl }
|
||||
: undefined
|
||||
|
||||
telemetryService.captureHostEvent("auth_command", hasQuickSetupFlags ? "quick_setup" : "interactive")
|
||||
|
||||
let authError = false
|
||||
|
||||
await runInkApp(
|
||||
React.createElement(App, {
|
||||
view: "auth",
|
||||
controller: ctx.controller,
|
||||
isRawModeSupported: checkRawModeSupport(),
|
||||
onComplete: () => {
|
||||
telemetryService.captureHostEvent("auth", "completed")
|
||||
exit(0)
|
||||
},
|
||||
onError: () => {
|
||||
telemetryService.captureHostEvent("auth", "error")
|
||||
authError = true
|
||||
},
|
||||
authQuickSetup: quickSetup,
|
||||
@@ -407,7 +479,7 @@ async function runAuth(options: {
|
||||
// Setup CLI commands
|
||||
const program = new Command()
|
||||
|
||||
program.name("cline").description("Cline CLI - AI coding assistant in your terminal").version(VERSION)
|
||||
program.name("cline").description("Cline CLI - AI coding assistant in your terminal").version(CLI_VERSION)
|
||||
|
||||
// Enable positional options to avoid conflicts between root and subcommand options with the same name
|
||||
program.enablePositionalOptions()
|
||||
@@ -421,11 +493,11 @@ program
|
||||
.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)")
|
||||
.option("--json", "Output messages as JSON instead of styled text")
|
||||
.action((prompt, options) => runTask(prompt, options))
|
||||
|
||||
program
|
||||
@@ -458,63 +530,302 @@ program
|
||||
program
|
||||
.command("version")
|
||||
.description("Show Cline CLI version number")
|
||||
.action(() => printInfo(`Cline CLI version: ${VERSION}`))
|
||||
.action(() => printInfo(`Cline CLI version: ${CLI_VERSION}`))
|
||||
|
||||
// MCP Server management commands
|
||||
const mcpCommand = program.command("mcp").description("Manage MCP (Model Context Protocol) servers")
|
||||
|
||||
mcpCommand
|
||||
.command("add <name>")
|
||||
.description("Add a new MCP server")
|
||||
.option("--command <cmd>", "Command to run (for stdio transport)")
|
||||
.option("--args <args...>", "Arguments for the command")
|
||||
.option("--cwd <path>", "Working directory for the command")
|
||||
.option("--env <pairs...>", "Environment variables (KEY=VALUE format)")
|
||||
.option("--url <url>", "Server URL (for sse/http transport)")
|
||||
.option("--headers <pairs...>", "HTTP headers (KEY=VALUE format)")
|
||||
.option("--type <type>", "Transport type: stdio, sse, or http", "stdio")
|
||||
.option("--timeout <seconds>", "Network timeout in seconds")
|
||||
.option("--allow <tools...>", "Tools to auto-approve")
|
||||
.option("--config <path>", "Path to Cline configuration directory")
|
||||
.action(async (name, options) => {
|
||||
const dataDir = options.config ? path.join(options.config, "data") : undefined
|
||||
|
||||
let config: McpServerConfig
|
||||
|
||||
if (options.url) {
|
||||
// SSE or Streamable HTTP server
|
||||
const type = options.type === "http" ? "streamableHttp" : "sse"
|
||||
config = {
|
||||
type,
|
||||
url: options.url,
|
||||
headers: options.headers ? parseKeyValuePairs(options.headers) : undefined,
|
||||
autoApprove: options.allow,
|
||||
timeout: options.timeout ? parseInt(options.timeout, 10) : undefined,
|
||||
} as McpServerConfig
|
||||
} else if (options.command) {
|
||||
// STDIO server
|
||||
config = {
|
||||
type: "stdio",
|
||||
command: options.command,
|
||||
args: options.args,
|
||||
cwd: options.cwd,
|
||||
env: options.env ? parseKeyValuePairs(options.env) : undefined,
|
||||
autoApprove: options.allow,
|
||||
timeout: options.timeout ? parseInt(options.timeout, 10) : undefined,
|
||||
}
|
||||
} else {
|
||||
printWarning("Error: Either --command (for stdio) or --url (for sse/http) is required")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
try {
|
||||
await addMcpServer(name, config, dataDir)
|
||||
printInfo(`Added MCP server: ${name}`)
|
||||
} catch (error: any) {
|
||||
printWarning(`Failed to add server: ${error.message}`)
|
||||
exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
mcpCommand
|
||||
.command("remove <name>")
|
||||
.alias("rm")
|
||||
.description("Remove an MCP server")
|
||||
.option("--config <path>", "Path to Cline configuration directory")
|
||||
.action(async (name, options) => {
|
||||
const dataDir = options.config ? path.join(options.config, "data") : undefined
|
||||
|
||||
const removed = await removeMcpServer(name, dataDir)
|
||||
if (removed) {
|
||||
printInfo(`Removed MCP server: ${name}`)
|
||||
} else {
|
||||
printWarning(`Server not found: ${name}`)
|
||||
exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
mcpCommand
|
||||
.command("disable <name>")
|
||||
.description("Disable an MCP server")
|
||||
.option("--config <path>", "Path to Cline configuration directory")
|
||||
.action(async (name, options) => {
|
||||
const dataDir = options.config ? path.join(options.config, "data") : undefined
|
||||
|
||||
const success = await setMcpServerDisabled(name, true, dataDir)
|
||||
if (success) {
|
||||
printInfo(`Disabled MCP server: ${name}`)
|
||||
} else {
|
||||
printWarning(`Server not found: ${name}`)
|
||||
exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
mcpCommand
|
||||
.command("enable <name>")
|
||||
.description("Enable an MCP server")
|
||||
.option("--config <path>", "Path to Cline configuration directory")
|
||||
.action(async (name, options) => {
|
||||
const dataDir = options.config ? path.join(options.config, "data") : undefined
|
||||
|
||||
const success = await setMcpServerDisabled(name, false, dataDir)
|
||||
if (success) {
|
||||
printInfo(`Enabled MCP server: ${name}`)
|
||||
} else {
|
||||
printWarning(`Server not found: ${name}`)
|
||||
exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
mcpCommand
|
||||
.command("list")
|
||||
.alias("ls")
|
||||
.description("List all configured MCP servers")
|
||||
.option("--json", "Output as JSON")
|
||||
.option("--config <path>", "Path to Cline configuration directory")
|
||||
.action(async (options) => {
|
||||
const dataDir = options.config ? path.join(options.config, "data") : undefined
|
||||
|
||||
const servers = await listMcpServers(dataDir)
|
||||
|
||||
if (servers.length === 0) {
|
||||
printInfo("No MCP servers configured.")
|
||||
return
|
||||
}
|
||||
|
||||
if (options.json) {
|
||||
print(JSON.stringify(servers, null, 2))
|
||||
} else {
|
||||
for (const { name, config } of servers) {
|
||||
print(formatServerConfig(name, config))
|
||||
print("")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
mcpCommand
|
||||
.command("allow <name>")
|
||||
.description("Manage auto-approve tools for an MCP server")
|
||||
.option("--add <tools...>", "Add tools to auto-approve list")
|
||||
.option("--remove <tools...>", "Remove tools from auto-approve list")
|
||||
.option("--set <tools...>", "Set the auto-approve list (replaces existing)")
|
||||
.option("--clear", "Clear all auto-approve tools")
|
||||
.option("--config <path>", "Path to Cline configuration directory")
|
||||
.action(async (name, options) => {
|
||||
const dataDir = options.config ? path.join(options.config, "data") : undefined
|
||||
|
||||
let action: "add" | "remove" | "set" | "clear"
|
||||
let tools: string[] = []
|
||||
|
||||
if (options.clear) {
|
||||
action = "clear"
|
||||
} else if (options.set) {
|
||||
action = "set"
|
||||
tools = options.set
|
||||
} else if (options.add) {
|
||||
action = "add"
|
||||
tools = options.add
|
||||
} else if (options.remove) {
|
||||
action = "remove"
|
||||
tools = options.remove
|
||||
} else {
|
||||
printWarning("Error: Specify --add, --remove, --set, or --clear")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
const success = await updateMcpServerAutoApprove(name, action, tools, dataDir)
|
||||
if (success) {
|
||||
if (action === "clear") {
|
||||
printInfo(`Cleared auto-approve tools for: ${name}`)
|
||||
} else {
|
||||
printInfo(`Updated auto-approve tools for: ${name}`)
|
||||
}
|
||||
} else {
|
||||
printWarning(`Server not found: ${name}`)
|
||||
exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Show welcome prompt and run task with user input
|
||||
* Check if the user has authentication configured.
|
||||
* Returns true if they have either:
|
||||
* - Cline provider with stored auth data
|
||||
* - OpenAI Codex provider with OAuth credentials
|
||||
* - BYO provider with an API key configured
|
||||
*/
|
||||
async function isAuthConfigured(): Promise<boolean> {
|
||||
const stateManager = StateManager.get()
|
||||
const mode = stateManager.getGlobalSettingsKey("mode") as string
|
||||
const providerKey = mode === "act" ? "actModeApiProvider" : "planModeApiProvider"
|
||||
const currentProvider = (stateManager.getGlobalSettingsKey(providerKey) as string) || "cline"
|
||||
|
||||
if (currentProvider === "cline") {
|
||||
// For Cline provider, check if we have stored auth data
|
||||
const authData = await secretStorage.get("cline:clineAccountId")
|
||||
return !!authData
|
||||
}
|
||||
|
||||
if (currentProvider === "openai-codex") {
|
||||
// For OpenAI Codex, check if OAuth credentials are stored
|
||||
const isAuthenticated = await openAiCodexOAuthManager.isAuthenticated()
|
||||
return isAuthenticated
|
||||
}
|
||||
|
||||
// For BYO providers, check if the API key is configured
|
||||
const keyField = ProviderToApiKeyMap[currentProvider as keyof typeof ProviderToApiKeyMap]
|
||||
if (!keyField) {
|
||||
return false
|
||||
}
|
||||
|
||||
const fields = Array.isArray(keyField) ? keyField : [keyField]
|
||||
for (const field of fields) {
|
||||
const value = await secretStorage.get(field)
|
||||
if (value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Show welcome prompt and wait for user input
|
||||
* If auth is not configured, show auth flow first
|
||||
*/
|
||||
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[] = []
|
||||
// Check if auth is configured
|
||||
const hasAuth = await isAuthConfigured()
|
||||
|
||||
const { waitUntilExit, unmount } = render(
|
||||
// Query cursor position BEFORE Ink mounts
|
||||
const cursorPos = await queryCursorPos(process.stdin, process.stdout)
|
||||
const terminalRows = process.stdout.rows ?? 24
|
||||
const robotTopRow = calculateRobotTopRow(cursorPos, terminalRows)
|
||||
|
||||
let hadError = false
|
||||
|
||||
await runInkApp(
|
||||
React.createElement(App, {
|
||||
view: "welcome",
|
||||
// Start with auth view if not configured, otherwise welcome
|
||||
view: hasAuth ? "welcome" : "auth",
|
||||
verbose: options.verbose,
|
||||
controller: ctx.controller,
|
||||
onWelcomeSubmit: (prompt: string, imagePaths: string[]) => {
|
||||
submittedPrompt = prompt
|
||||
submittedImagePaths = imagePaths
|
||||
unmount()
|
||||
},
|
||||
isRawModeSupported: checkRawModeSupport(),
|
||||
robotTopRow,
|
||||
onWelcomeExit: () => {
|
||||
unmount()
|
||||
exit(0)
|
||||
},
|
||||
onError: () => {
|
||||
hadError = true
|
||||
},
|
||||
}),
|
||||
async () => {
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
await ctx.controller.dispose()
|
||||
await ErrorService.get().dispose()
|
||||
exit(hadError ? 1 : 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("-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("-v, --verbose", "Show verbose output")
|
||||
.option("-c, --cwd <path>", "Working directory")
|
||||
.option("--config <path>", "Configuration directory")
|
||||
.option("--thinking", "Enable extended thinking (1024 token budget)")
|
||||
.option("--json", "Output messages as JSON instead of styled text")
|
||||
.action(async (prompt, options) => {
|
||||
if (prompt) {
|
||||
await runTask(prompt, options)
|
||||
// Always check for piped stdin content
|
||||
const stdinInput = await readStdinIfPiped()
|
||||
|
||||
// Combine stdin content with prompt argument
|
||||
let effectivePrompt = prompt
|
||||
if (stdinInput) {
|
||||
if (effectivePrompt) {
|
||||
// Prepend stdin content to the prompt
|
||||
effectivePrompt = `${stdinInput}\n\n${effectivePrompt}`
|
||||
} else {
|
||||
effectivePrompt = stdinInput
|
||||
}
|
||||
|
||||
telemetryService.captureHostEvent("piped", "detached")
|
||||
|
||||
// Debug: show that we received piped input
|
||||
if (options.verbose) {
|
||||
process.stderr.write(`[debug] Received ${stdinInput.length} bytes from stdin\n`)
|
||||
}
|
||||
}
|
||||
|
||||
if (effectivePrompt) {
|
||||
// Pass stdinWasPiped flag so runTask knows to use plain text mode
|
||||
await runTask(effectivePrompt, { ...options, stdinWasPiped: !!stdinInput })
|
||||
} else {
|
||||
// Show welcome prompt if no prompt given
|
||||
await showWelcome(options)
|
||||
|
||||
@@ -21,6 +21,7 @@ if (!isVerbose) {
|
||||
console.warn = () => {}
|
||||
console.error = () => {}
|
||||
console.debug = () => {}
|
||||
console.info = () => {}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Query terminal cursor position before Ink mounts
|
||||
* Must be called BEFORE render() to avoid escape sequence leaking into Ink's input handling
|
||||
*/
|
||||
|
||||
export async function queryCursorPos(
|
||||
stdin: NodeJS.ReadStream,
|
||||
stdout: NodeJS.WriteStream,
|
||||
{ timeoutMs = 75 } = {},
|
||||
): Promise<{ row: number; col: number } | null> {
|
||||
if (!stdin.isTTY || !stdout.isTTY || typeof (stdin as any).setRawMode !== "function") return null
|
||||
|
||||
const ttyIn = stdin as any as { isRaw?: boolean; setRawMode: (b: boolean) => void; on: any; off: any }
|
||||
const wasRaw = !!ttyIn.isRaw
|
||||
|
||||
ttyIn.setRawMode(true)
|
||||
stdin.resume()
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
let buf = ""
|
||||
const onData = (chunk: Buffer) => {
|
||||
buf += chunk.toString("utf8")
|
||||
const m = buf.match(/\x1b\[(\d+);(\d+)R/)
|
||||
if (!m) return
|
||||
cleanup()
|
||||
resolve({ row: Number(m[1]), col: Number(m[2]) })
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer)
|
||||
stdin.off("data", onData)
|
||||
try {
|
||||
ttyIn.setRawMode(wasRaw)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
cleanup()
|
||||
resolve(null)
|
||||
}, timeoutMs)
|
||||
|
||||
stdin.on("data", onData)
|
||||
stdout.write("\x1b[6n") // DSR: cursor position
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate where the robot will be rendered on screen
|
||||
*/
|
||||
export function calculateRobotTopRow(cursorPos: { row: number } | null, terminalRows: number): number {
|
||||
const robotHeight = 12
|
||||
const firstFrameHeight = robotHeight + 8 // robot + welcome text + margins + input + footer
|
||||
|
||||
const startRow = cursorPos?.row ?? 1
|
||||
// If content doesn't fit below cursor, terminal scrolls
|
||||
return Math.max(1, Math.min(startRow, terminalRows - firstFrameHeight + 1))
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Cursor movement utilities for multi-line text input
|
||||
*/
|
||||
|
||||
/**
|
||||
* Move cursor up one line, preserving column position where possible
|
||||
*/
|
||||
export function moveCursorUp(text: string, cursorPos: number): number {
|
||||
const textBeforeCursor = text.slice(0, cursorPos)
|
||||
const lastNewline = textBeforeCursor.lastIndexOf("\n")
|
||||
|
||||
if (lastNewline === -1) {
|
||||
// Already on first line, move to start
|
||||
return 0
|
||||
}
|
||||
|
||||
const currentCol = cursorPos - lastNewline - 1
|
||||
const prevLineStart = textBeforeCursor.lastIndexOf("\n", lastNewline - 1) + 1
|
||||
const prevLineLength = lastNewline - prevLineStart
|
||||
const newCol = Math.min(currentCol, prevLineLength)
|
||||
|
||||
return prevLineStart + newCol
|
||||
}
|
||||
|
||||
/**
|
||||
* Move cursor down one line, preserving column position where possible
|
||||
*/
|
||||
export function moveCursorDown(text: string, cursorPos: number): number {
|
||||
const textBeforeCursor = text.slice(0, cursorPos)
|
||||
const lastNewline = textBeforeCursor.lastIndexOf("\n")
|
||||
const currentCol = lastNewline === -1 ? cursorPos : cursorPos - lastNewline - 1
|
||||
const nextNewline = text.indexOf("\n", cursorPos)
|
||||
|
||||
if (nextNewline === -1) {
|
||||
// Already on last line, move to end
|
||||
return text.length
|
||||
}
|
||||
|
||||
const nextLineStart = nextNewline + 1
|
||||
const nextLineEnd = text.indexOf("\n", nextLineStart)
|
||||
const nextLineLength = nextLineEnd === -1 ? text.length - nextLineStart : nextLineEnd - nextLineStart
|
||||
const newCol = Math.min(currentCol, nextLineLength)
|
||||
|
||||
return nextLineStart + newCol
|
||||
}
|
||||
@@ -8,6 +8,7 @@ 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"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
|
||||
export interface FileSearchResult {
|
||||
path: string
|
||||
@@ -228,7 +229,7 @@ export async function searchWorkspaceFiles(
|
||||
.slice(0, limit)
|
||||
.map((r) => r.item)
|
||||
} catch (error) {
|
||||
console.error("File search error:", error)
|
||||
Logger.error("File search error:", error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -251,6 +252,8 @@ export function extractMentionQuery(text: string): { inMentionMode: boolean; que
|
||||
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}`
|
||||
// Ensure path starts with / for proper mention format
|
||||
const normalizedPath = filePath.startsWith("/") ? filePath : `/${filePath}`
|
||||
const mention = normalizedPath.includes(" ") ? `@"${normalizedPath}"` : `@${normalizedPath}`
|
||||
return text.slice(0, atIndex) + mention + " " + text.slice(end).trimStart()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Utility to detect and import API keys from competing CLI agents (Codex, OpenCode)
|
||||
*/
|
||||
|
||||
import * as fs from "fs"
|
||||
import * as os from "os"
|
||||
import * as path from "path"
|
||||
import providersData from "@/shared/providers/providers.json"
|
||||
|
||||
// Import source types
|
||||
export type ImportSource = "codex" | "opencode"
|
||||
|
||||
// Imported key structure
|
||||
export interface ImportedKey {
|
||||
provider: string // Cline provider ID
|
||||
keyField: string // Cline API key field name
|
||||
key: string // The API key value
|
||||
modelId?: string // Optional default model ID
|
||||
}
|
||||
|
||||
// Import result
|
||||
export interface ImportResult {
|
||||
source: ImportSource
|
||||
keys: ImportedKey[]
|
||||
}
|
||||
|
||||
// Available import sources that were detected
|
||||
export interface DetectedSources {
|
||||
codex: boolean
|
||||
opencode: boolean
|
||||
}
|
||||
|
||||
// Build provider labels map from providers.json (single source of truth)
|
||||
const providerLabels: Record<string, string> = Object.fromEntries(
|
||||
providersData.list.map((p: { value: string; label: string }) => [p.value, p.label]),
|
||||
)
|
||||
|
||||
/**
|
||||
* Get possible data directories for OpenCode
|
||||
* OpenCode uses XDG_DATA_HOME on all platforms, defaulting to ~/.local/share/opencode
|
||||
* Returns array of paths to check (in order of preference)
|
||||
*/
|
||||
function getOpenCodeDataDirs(): string[] {
|
||||
const home = os.homedir()
|
||||
const paths: string[] = []
|
||||
|
||||
// XDG path (used by OpenCode on all platforms)
|
||||
if (process.env.XDG_DATA_HOME) {
|
||||
paths.push(path.join(process.env.XDG_DATA_HOME, "opencode"))
|
||||
}
|
||||
paths.push(path.join(home, ".local", "share", "opencode"))
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect which CLI agents have config files with API keys
|
||||
*/
|
||||
export function detectImportSources(): DetectedSources {
|
||||
return {
|
||||
codex: hasCodexConfig(),
|
||||
opencode: hasOpenCodeConfig(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Codex config exists with API keys
|
||||
*/
|
||||
function hasCodexConfig(): boolean {
|
||||
try {
|
||||
const authPath = path.join(os.homedir(), ".codex", "auth.json")
|
||||
if (!fs.existsSync(authPath)) {
|
||||
return false
|
||||
}
|
||||
const content = fs.readFileSync(authPath, "utf-8")
|
||||
const data = JSON.parse(content)
|
||||
// Check if there's at least one key
|
||||
return Object.keys(data).length > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if OpenCode config exists with API keys
|
||||
*/
|
||||
function hasOpenCodeConfig(): boolean {
|
||||
for (const dir of getOpenCodeDataDirs()) {
|
||||
try {
|
||||
const authPath = path.join(dir, "auth.json")
|
||||
if (!fs.existsSync(authPath)) {
|
||||
continue
|
||||
}
|
||||
const content = fs.readFileSync(authPath, "utf-8")
|
||||
const data = JSON.parse(content)
|
||||
// Check if there's at least one key
|
||||
if (Object.keys(data).length > 0) {
|
||||
return true
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the OpenCode auth.json path (first existing one)
|
||||
*/
|
||||
function findOpenCodeAuthPath(): string | null {
|
||||
for (const dir of getOpenCodeDataDirs()) {
|
||||
const authPath = path.join(dir, "auth.json")
|
||||
if (fs.existsSync(authPath)) {
|
||||
return authPath
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Codex key names to Cline providers
|
||||
*/
|
||||
const CODEX_KEY_MAP: Record<string, { provider: string; keyField: string; modelId?: string }> = {
|
||||
OPENAI_API_KEY: { provider: "openai-native", keyField: "openAiNativeApiKey", modelId: "gpt-4o" },
|
||||
ANTHROPIC_API_KEY: { provider: "anthropic", keyField: "apiKey", modelId: "claude-sonnet-4-20250514" },
|
||||
}
|
||||
|
||||
/**
|
||||
* Map OpenCode provider IDs to Cline providers
|
||||
*/
|
||||
const OPENCODE_PROVIDER_MAP: Record<string, { provider: string; keyField: string; modelId?: string }> = {
|
||||
openai: { provider: "openai-native", keyField: "openAiNativeApiKey", modelId: "gpt-4o" },
|
||||
anthropic: { provider: "anthropic", keyField: "apiKey", modelId: "claude-sonnet-4-20250514" },
|
||||
gemini: { provider: "gemini", keyField: "geminiApiKey", modelId: "gemini-2.0-flash-001" },
|
||||
mistral: { provider: "mistral", keyField: "mistralApiKey" },
|
||||
groq: { provider: "groq", keyField: "groqApiKey" },
|
||||
deepseek: { provider: "deepseek", keyField: "deepSeekApiKey" },
|
||||
xai: { provider: "xai", keyField: "xaiApiKey" },
|
||||
openrouter: { provider: "openrouter", keyField: "openRouterApiKey" },
|
||||
}
|
||||
|
||||
/**
|
||||
* Import keys from Codex CLI
|
||||
*/
|
||||
export function importFromCodex(): ImportResult | null {
|
||||
try {
|
||||
const authPath = path.join(os.homedir(), ".codex", "auth.json")
|
||||
if (!fs.existsSync(authPath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(authPath, "utf-8")
|
||||
const data = JSON.parse(content) as Record<string, string>
|
||||
|
||||
const keys: ImportedKey[] = []
|
||||
|
||||
for (const [envKey, apiKey] of Object.entries(data)) {
|
||||
const mapping = CODEX_KEY_MAP[envKey]
|
||||
if (mapping && apiKey) {
|
||||
keys.push({
|
||||
provider: mapping.provider,
|
||||
keyField: mapping.keyField,
|
||||
key: apiKey,
|
||||
modelId: mapping.modelId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (keys.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { source: "codex", keys }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// OpenCode auth entry structure
|
||||
interface OpenCodeAuthEntry {
|
||||
type: "api" | "oauth"
|
||||
key?: string
|
||||
access?: string
|
||||
refresh?: string
|
||||
expires?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Import keys from OpenCode CLI
|
||||
*/
|
||||
export function importFromOpenCode(): ImportResult | null {
|
||||
try {
|
||||
const authPath = findOpenCodeAuthPath()
|
||||
if (!authPath) {
|
||||
return null
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(authPath, "utf-8")
|
||||
const data = JSON.parse(content) as Record<string, OpenCodeAuthEntry>
|
||||
|
||||
const keys: ImportedKey[] = []
|
||||
|
||||
for (const [providerId, authEntry] of Object.entries(data)) {
|
||||
// Only import API type keys (not OAuth)
|
||||
if (authEntry.type !== "api" || !authEntry.key) {
|
||||
continue
|
||||
}
|
||||
|
||||
const mapping = OPENCODE_PROVIDER_MAP[providerId]
|
||||
if (mapping) {
|
||||
keys.push({
|
||||
provider: mapping.provider,
|
||||
keyField: mapping.keyField,
|
||||
key: authEntry.key,
|
||||
modelId: mapping.modelId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (keys.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { source: "opencode", keys }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable source name
|
||||
*/
|
||||
export function getSourceDisplayName(source: ImportSource): string {
|
||||
switch (source) {
|
||||
case "codex":
|
||||
return "OpenAI Codex CLI"
|
||||
case "opencode":
|
||||
return "OpenCode"
|
||||
default:
|
||||
return source
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider display name from providers.json
|
||||
*/
|
||||
export function getProviderDisplayName(provider: string): string {
|
||||
return providerLabels[provider] || provider
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Input filtering utilities for CLI components
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if input contains mouse escape sequences from terminal mouse tracking.
|
||||
* AsciiMotionCli enables mouse tracking which generates sequences like [<35;46;17M
|
||||
* These should be filtered out of text input handlers.
|
||||
*/
|
||||
export function isMouseEscapeSequence(input: string): boolean {
|
||||
// Mouse events look like: [<35;46;17M or contain escape characters
|
||||
return input.includes("\x1b") || input.includes("[<") || /\d+;\d+[Mm]/.test(input)
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* MCP Server configuration utilities for CLI
|
||||
*/
|
||||
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { CLINE_CLI_DIR } from "./path"
|
||||
|
||||
const MCP_SETTINGS_FILENAME = "cline_mcp_settings.json"
|
||||
|
||||
export interface StdioServerConfig {
|
||||
type?: "stdio"
|
||||
command: string
|
||||
args?: string[]
|
||||
cwd?: string
|
||||
env?: Record<string, string>
|
||||
autoApprove?: string[]
|
||||
disabled?: boolean
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
export interface SseServerConfig {
|
||||
type: "sse"
|
||||
url: string
|
||||
headers?: Record<string, string>
|
||||
autoApprove?: string[]
|
||||
disabled?: boolean
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
export interface StreamableHttpServerConfig {
|
||||
type: "streamableHttp"
|
||||
url: string
|
||||
headers?: Record<string, string>
|
||||
autoApprove?: string[]
|
||||
disabled?: boolean
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
export type McpServerConfig = StdioServerConfig | SseServerConfig | StreamableHttpServerConfig
|
||||
|
||||
export interface McpSettings {
|
||||
mcpServers: Record<string, McpServerConfig>
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the MCP settings file
|
||||
*/
|
||||
export function getMcpSettingsPath(dataDir?: string): string {
|
||||
return path.join(dataDir ?? CLINE_CLI_DIR.data, MCP_SETTINGS_FILENAME)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read MCP settings from disk
|
||||
*/
|
||||
export async function readMcpSettings(dataDir?: string): Promise<McpSettings> {
|
||||
const settingsPath = getMcpSettingsPath(dataDir)
|
||||
try {
|
||||
const content = await fs.readFile(settingsPath, "utf-8")
|
||||
return JSON.parse(content)
|
||||
} catch (error: any) {
|
||||
if (error.code === "ENOENT") {
|
||||
return { mcpServers: {} }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write MCP settings to disk
|
||||
*/
|
||||
export async function writeMcpSettings(settings: McpSettings, dataDir?: string): Promise<void> {
|
||||
const settingsPath = getMcpSettingsPath(dataDir)
|
||||
// Ensure directory exists
|
||||
await fs.mkdir(path.dirname(settingsPath), { recursive: true })
|
||||
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2))
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update an MCP server
|
||||
*/
|
||||
export async function addMcpServer(name: string, config: McpServerConfig, dataDir?: string): Promise<void> {
|
||||
const settings = await readMcpSettings(dataDir)
|
||||
settings.mcpServers[name] = config
|
||||
await writeMcpSettings(settings, dataDir)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an MCP server
|
||||
*/
|
||||
export async function removeMcpServer(name: string, dataDir?: string): Promise<boolean> {
|
||||
const settings = await readMcpSettings(dataDir)
|
||||
if (!(name in settings.mcpServers)) {
|
||||
return false
|
||||
}
|
||||
delete settings.mcpServers[name]
|
||||
await writeMcpSettings(settings, dataDir)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable an MCP server
|
||||
*/
|
||||
export async function setMcpServerDisabled(name: string, disabled: boolean, dataDir?: string): Promise<boolean> {
|
||||
const settings = await readMcpSettings(dataDir)
|
||||
if (!(name in settings.mcpServers)) {
|
||||
return false
|
||||
}
|
||||
settings.mcpServers[name].disabled = disabled
|
||||
await writeMcpSettings(settings, dataDir)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single MCP server config
|
||||
*/
|
||||
export async function getMcpServer(name: string, dataDir?: string): Promise<McpServerConfig | undefined> {
|
||||
const settings = await readMcpSettings(dataDir)
|
||||
return settings.mcpServers[name]
|
||||
}
|
||||
|
||||
/**
|
||||
* List all MCP servers
|
||||
*/
|
||||
export async function listMcpServers(dataDir?: string): Promise<Array<{ name: string; config: McpServerConfig }>> {
|
||||
const settings = await readMcpSettings(dataDir)
|
||||
return Object.entries(settings.mcpServers).map(([name, config]) => ({ name, config }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Update auto-approve tools for an MCP server
|
||||
*/
|
||||
export async function updateMcpServerAutoApprove(
|
||||
name: string,
|
||||
action: "add" | "remove" | "set" | "clear",
|
||||
tools: string[],
|
||||
dataDir?: string,
|
||||
): Promise<boolean> {
|
||||
const settings = await readMcpSettings(dataDir)
|
||||
if (!(name in settings.mcpServers)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const server = settings.mcpServers[name]
|
||||
const currentTools = server.autoApprove ?? []
|
||||
|
||||
switch (action) {
|
||||
case "add":
|
||||
server.autoApprove = [...new Set([...currentTools, ...tools])]
|
||||
break
|
||||
case "remove":
|
||||
server.autoApprove = currentTools.filter((t) => !tools.includes(t))
|
||||
break
|
||||
case "set":
|
||||
server.autoApprove = tools
|
||||
break
|
||||
case "clear":
|
||||
server.autoApprove = []
|
||||
break
|
||||
}
|
||||
|
||||
await writeMcpSettings(settings, dataDir)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse key=value pairs from command line arguments
|
||||
*/
|
||||
export function parseKeyValuePairs(pairs: string[]): Record<string, string> {
|
||||
const result: Record<string, string> = {}
|
||||
for (const pair of pairs) {
|
||||
const eqIndex = pair.indexOf("=")
|
||||
if (eqIndex === -1) {
|
||||
throw new Error(`Invalid key=value format: ${pair}`)
|
||||
}
|
||||
const key = pair.substring(0, eqIndex)
|
||||
const value = pair.substring(eqIndex + 1)
|
||||
result[key] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Format server config for display
|
||||
*/
|
||||
export function formatServerConfig(name: string, config: McpServerConfig): string {
|
||||
const lines: string[] = []
|
||||
const status = config.disabled ? " (disabled)" : ""
|
||||
|
||||
if ("command" in config && config.command) {
|
||||
// STDIO server
|
||||
lines.push(`${name}${status} [stdio]`)
|
||||
lines.push(` command: ${config.command}`)
|
||||
if (config.args?.length) {
|
||||
lines.push(` args: ${config.args.join(" ")}`)
|
||||
}
|
||||
if (config.cwd) {
|
||||
lines.push(` cwd: ${config.cwd}`)
|
||||
}
|
||||
if (config.env && Object.keys(config.env).length > 0) {
|
||||
lines.push(
|
||||
` env: ${Object.entries(config.env)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(", ")}`,
|
||||
)
|
||||
}
|
||||
} else if ("url" in config && config.url) {
|
||||
// SSE or Streamable HTTP server
|
||||
const type = config.type === "streamableHttp" ? "http" : "sse"
|
||||
lines.push(`${name}${status} [${type}]`)
|
||||
lines.push(` url: ${config.url}`)
|
||||
if (config.headers && Object.keys(config.headers).length > 0) {
|
||||
const headerKeys = Object.keys(config.headers).join(", ")
|
||||
lines.push(` headers: ${headerKeys}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (config.timeout) {
|
||||
lines.push(` timeout: ${config.timeout}s`)
|
||||
}
|
||||
if (config.autoApprove?.length) {
|
||||
lines.push(` auto-approve: ${config.autoApprove.join(", ")}`)
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Utility to fetch and cache OpenRouter models for the CLI
|
||||
*/
|
||||
|
||||
import { openRouterDefaultModelId } from "@/shared/api"
|
||||
import { fetch } from "@/shared/net"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
|
||||
interface OpenRouterModel {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
// In-memory cache
|
||||
let cachedModels: string[] | null = null
|
||||
let fetchPromise: Promise<string[]> | null = null
|
||||
|
||||
/**
|
||||
* Fetch OpenRouter models from the API
|
||||
* Returns cached results if available, or fetches from API
|
||||
*/
|
||||
export async function fetchOpenRouterModels(): Promise<string[]> {
|
||||
// Return cached models if available
|
||||
if (cachedModels) {
|
||||
return cachedModels
|
||||
}
|
||||
|
||||
// If already fetching, wait for that promise
|
||||
if (fetchPromise) {
|
||||
return fetchPromise
|
||||
}
|
||||
|
||||
// Start fetching
|
||||
fetchPromise = (async () => {
|
||||
try {
|
||||
const response = await fetch("https://openrouter.ai/api/v1/models")
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
if (data?.data) {
|
||||
const models = (data.data as OpenRouterModel[]).map((m) => m.id).sort((a, b) => a.localeCompare(b))
|
||||
cachedModels = models
|
||||
return models
|
||||
}
|
||||
return []
|
||||
} catch (error) {
|
||||
Logger.debug("Failed to fetch OpenRouter models:", error)
|
||||
return []
|
||||
} finally {
|
||||
fetchPromise = null
|
||||
}
|
||||
})()
|
||||
|
||||
return fetchPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default OpenRouter model ID
|
||||
*/
|
||||
export function getOpenRouterDefaultModelId(): string {
|
||||
return openRouterDefaultModelId
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if provider uses OpenRouter models (openrouter or cline)
|
||||
*/
|
||||
export function usesOpenRouterModels(provider: string): boolean {
|
||||
return provider === "openrouter" || provider === "cline"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
|
||||
const data = process.env.CLINE_DATA_DIR ?? path.join(os.homedir(), ".cline", "data")
|
||||
|
||||
const log = process.env.CLINE_LOG_DIR ?? path.join(data, "logs")
|
||||
|
||||
export const CLINE_CLI_DIR = {
|
||||
data,
|
||||
log,
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import { Readable } from "node:stream"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { readStdinIfPiped } from "./piped"
|
||||
|
||||
describe("readStdinIfPiped", () => {
|
||||
let mockStdin: Readable & { isTTY?: boolean }
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a mock readable stream
|
||||
mockStdin = new Readable({
|
||||
read() {},
|
||||
}) as Readable & { isTTY?: boolean }
|
||||
|
||||
// Mock process.stdin by stubbing its properties
|
||||
vi.spyOn(process, "stdin", "get").mockReturnValue(mockStdin as any)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe("TTY detection", () => {
|
||||
it("should return null when stdin is a TTY (interactive terminal)", async () => {
|
||||
mockStdin.isTTY = true
|
||||
const result = await readStdinIfPiped()
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should attempt to read when stdin is not a TTY (piped input)", async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
// Simulate immediate end event (no data)
|
||||
setImmediate(() => {
|
||||
mockStdin.emit("end")
|
||||
})
|
||||
|
||||
const result = await readStdinIfPiped()
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("piped input scenarios", () => {
|
||||
interface TestCase {
|
||||
name: string
|
||||
input: string | string[]
|
||||
expected: string | null
|
||||
description?: string
|
||||
}
|
||||
|
||||
const testCases: TestCase[] = [
|
||||
{
|
||||
name: "single line input",
|
||||
input: "echo hello",
|
||||
expected: "echo hello",
|
||||
description: "should read and return single line",
|
||||
},
|
||||
{
|
||||
name: "multi-line input",
|
||||
input: ["line 1", "line 2", "line 3"],
|
||||
expected: "line 1\nline 2\nline 3",
|
||||
description: "should read and join multiple lines",
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
expected: null,
|
||||
description: "should return null for empty input",
|
||||
},
|
||||
{
|
||||
name: "whitespace only",
|
||||
input: " \n \t \n ",
|
||||
expected: null,
|
||||
description: "should return null for whitespace-only input",
|
||||
},
|
||||
{
|
||||
name: "leading and trailing whitespace",
|
||||
input: " hello world \n",
|
||||
expected: "hello world",
|
||||
description: "should trim leading and trailing whitespace",
|
||||
},
|
||||
{
|
||||
name: "large input",
|
||||
input: "a".repeat(10000),
|
||||
expected: "a".repeat(10000),
|
||||
description: "should handle large input",
|
||||
},
|
||||
{
|
||||
name: "special characters",
|
||||
input: "!@#$%^&*(){}[]|\\:;\"'<>?,./",
|
||||
expected: "!@#$%^&*(){}[]|\\:;\"'<>?,./",
|
||||
description: "should preserve special characters",
|
||||
},
|
||||
{
|
||||
name: "unicode characters",
|
||||
input: "Hello 世界 🌍 مرحبا",
|
||||
expected: "Hello 世界 🌍 مرحبا",
|
||||
description: "should handle unicode characters correctly",
|
||||
},
|
||||
{
|
||||
name: "JSON input",
|
||||
input: '{"key": "value", "nested": {"data": 123}}',
|
||||
expected: '{"key": "value", "nested": {"data": 123}}',
|
||||
description: "should preserve JSON structure",
|
||||
},
|
||||
{
|
||||
name: "code snippet",
|
||||
input: ["function test() {", ' console.log("hello")', "}"],
|
||||
expected: 'function test() {\n console.log("hello")\n}',
|
||||
description: "should preserve code structure with indentation",
|
||||
},
|
||||
]
|
||||
|
||||
testCases.forEach(({ name, input, expected, description }) => {
|
||||
it(`${name}${description ? ` - ${description}` : ""}`, async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
// Simulate piped data
|
||||
setImmediate(() => {
|
||||
const data = Array.isArray(input) ? input.join("\n") : input
|
||||
mockStdin.push(data)
|
||||
mockStdin.push(null) // Signal end of stream
|
||||
})
|
||||
|
||||
const result = await readStdinIfPiped()
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("chunked data", () => {
|
||||
it("should accumulate data from multiple chunks", async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
setImmediate(() => {
|
||||
mockStdin.emit("data", "chunk1 ")
|
||||
mockStdin.emit("data", "chunk2 ")
|
||||
mockStdin.emit("data", "chunk3")
|
||||
mockStdin.emit("end")
|
||||
})
|
||||
|
||||
const result = await readStdinIfPiped()
|
||||
expect(result).toBe("chunk1 chunk2 chunk3")
|
||||
})
|
||||
|
||||
it("should handle rapid successive chunks", async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
setImmediate(() => {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
mockStdin.emit("data", `${i} `)
|
||||
}
|
||||
mockStdin.emit("end")
|
||||
})
|
||||
|
||||
const result = await readStdinIfPiped()
|
||||
expect(result).toContain("0 ")
|
||||
expect(result).toContain("99")
|
||||
})
|
||||
})
|
||||
|
||||
describe("timeout behavior", () => {
|
||||
it("should timeout after 100ms if no data received", async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
// Don't emit any events - let it timeout
|
||||
|
||||
const startTime = Date.now()
|
||||
const result = await readStdinIfPiped()
|
||||
const elapsed = Date.now() - startTime
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(elapsed).toBeGreaterThanOrEqual(95) // Allow small margin
|
||||
expect(elapsed).toBeLessThan(150)
|
||||
})
|
||||
|
||||
it("should return data received before timeout", async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
setTimeout(() => {
|
||||
mockStdin.emit("data", "quick data")
|
||||
// Don't emit end - let it timeout
|
||||
}, 50)
|
||||
|
||||
const result = await readStdinIfPiped()
|
||||
expect(result).toBe("quick data")
|
||||
})
|
||||
|
||||
it("should not timeout if end event is received", async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
// Delay end event but emit it before timeout
|
||||
setTimeout(() => {
|
||||
mockStdin.emit("data", "delayed data")
|
||||
mockStdin.emit("end")
|
||||
}, 50)
|
||||
|
||||
const result = await readStdinIfPiped()
|
||||
expect(result).toBe("delayed data")
|
||||
})
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should return null on stdin error", async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
setImmediate(() => {
|
||||
mockStdin.emit("error", new Error("stdin read error"))
|
||||
})
|
||||
|
||||
const result = await readStdinIfPiped()
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should handle error after partial data received", async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
setImmediate(() => {
|
||||
mockStdin.emit("data", "partial data")
|
||||
mockStdin.emit("error", new Error("read error"))
|
||||
})
|
||||
|
||||
const result = await readStdinIfPiped()
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should clean up listeners on error", async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
setImmediate(() => {
|
||||
mockStdin.emit("error", new Error("test error"))
|
||||
})
|
||||
|
||||
await readStdinIfPiped()
|
||||
|
||||
// Note: Implementation uses removeAllListeners() without event names
|
||||
// which should remove all listeners, but in practice there may be one remaining
|
||||
// This is acceptable behavior for error handling
|
||||
expect(mockStdin.listenerCount("data")).toBeLessThanOrEqual(1)
|
||||
expect(mockStdin.listenerCount("end")).toBeLessThanOrEqual(1)
|
||||
expect(mockStdin.listenerCount("error")).toBeLessThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("listener cleanup", () => {
|
||||
it("should remove all listeners on successful completion", async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
setImmediate(() => {
|
||||
mockStdin.emit("data", "test data")
|
||||
mockStdin.emit("end")
|
||||
})
|
||||
|
||||
await readStdinIfPiped()
|
||||
|
||||
// Note: Implementation doesn't explicitly clean up listeners on normal end,
|
||||
// so some listeners may remain attached. This is acceptable for one-time use.
|
||||
expect(mockStdin.listenerCount("data")).toBeLessThanOrEqual(1)
|
||||
expect(mockStdin.listenerCount("end")).toBeLessThanOrEqual(1)
|
||||
expect(mockStdin.listenerCount("error")).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
it("should remove all listeners on timeout", async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
// Let it timeout
|
||||
await readStdinIfPiped()
|
||||
|
||||
// Verify listeners are cleaned up
|
||||
expect(mockStdin.listenerCount("data")).toBe(0)
|
||||
expect(mockStdin.listenerCount("end")).toBe(0)
|
||||
expect(mockStdin.listenerCount("error")).toBe(0)
|
||||
})
|
||||
|
||||
it("should clear timeout when data ends normally", async () => {
|
||||
mockStdin.isTTY = false
|
||||
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout")
|
||||
|
||||
setImmediate(() => {
|
||||
mockStdin.emit("data", "test")
|
||||
mockStdin.emit("end")
|
||||
})
|
||||
|
||||
await readStdinIfPiped()
|
||||
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should clear timeout when error occurs", async () => {
|
||||
mockStdin.isTTY = false
|
||||
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout")
|
||||
|
||||
setImmediate(() => {
|
||||
mockStdin.emit("error", new Error("test"))
|
||||
})
|
||||
|
||||
await readStdinIfPiped()
|
||||
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("encoding", () => {
|
||||
it("should handle UTF-8 encoded data", async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
setImmediate(() => {
|
||||
// The function sets utf8 encoding
|
||||
mockStdin.setEncoding("utf8")
|
||||
mockStdin.emit("data", "UTF-8: café ☕")
|
||||
mockStdin.emit("end")
|
||||
})
|
||||
|
||||
const result = await readStdinIfPiped()
|
||||
expect(result).toBe("UTF-8: café ☕")
|
||||
})
|
||||
})
|
||||
|
||||
describe("stdin resume", () => {
|
||||
it("should call resume on stdin when not TTY", async () => {
|
||||
mockStdin.isTTY = false
|
||||
const resumeSpy = vi.spyOn(mockStdin, "resume")
|
||||
|
||||
setImmediate(() => {
|
||||
mockStdin.emit("end")
|
||||
})
|
||||
|
||||
await readStdinIfPiped()
|
||||
|
||||
expect(resumeSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should not call resume when TTY", async () => {
|
||||
mockStdin.isTTY = true
|
||||
const resumeSpy = vi.spyOn(mockStdin, "resume")
|
||||
|
||||
const result = await readStdinIfPiped()
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(resumeSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("real-world use cases", () => {
|
||||
interface UseCaseTest {
|
||||
name: string
|
||||
input: string
|
||||
expected: string | null
|
||||
}
|
||||
|
||||
const useCases: UseCaseTest[] = [
|
||||
{
|
||||
name: "git diff output",
|
||||
input: "diff --git a/file.ts b/file.ts\nindex 123..456\n--- a/file.ts\n+++ b/file.ts",
|
||||
expected: "diff --git a/file.ts b/file.ts\nindex 123..456\n--- a/file.ts\n+++ b/file.ts",
|
||||
},
|
||||
{
|
||||
name: "curl JSON response",
|
||||
input: '{"status": "ok", "data": [1, 2, 3]}',
|
||||
expected: '{"status": "ok", "data": [1, 2, 3]}',
|
||||
},
|
||||
{
|
||||
name: "cat file contents",
|
||||
input: "export function test() {\n return true\n}",
|
||||
expected: "export function test() {\n return true\n}",
|
||||
},
|
||||
{
|
||||
name: "echo command with newline",
|
||||
input: "Hello World\n",
|
||||
expected: "Hello World",
|
||||
},
|
||||
{
|
||||
name: "command output with ANSI codes",
|
||||
input: "\x1b[32mSUCCESS\x1b[0m",
|
||||
expected: "\x1b[32mSUCCESS\x1b[0m",
|
||||
},
|
||||
]
|
||||
|
||||
useCases.forEach(({ name, input, expected }) => {
|
||||
it(`should handle ${name}`, async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
setImmediate(() => {
|
||||
mockStdin.push(input)
|
||||
mockStdin.push(null)
|
||||
})
|
||||
|
||||
const result = await readStdinIfPiped()
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as fs from "node:fs"
|
||||
|
||||
/**
|
||||
* Read piped input from stdin (non-blocking)
|
||||
*/
|
||||
export async function readStdinIfPiped(): Promise<string | null> {
|
||||
// Check if stdin is a TTY (interactive) or piped
|
||||
if (process.stdin.isTTY) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
// Use synchronous read for reliability with piped input
|
||||
// fd 0 is stdin
|
||||
const data = fs.readFileSync(0, "utf8")
|
||||
return data.trim() || null
|
||||
} catch {
|
||||
// Fallback to async approach if sync read fails
|
||||
return new Promise((resolve) => {
|
||||
let data = ""
|
||||
process.stdin.setEncoding("utf8")
|
||||
|
||||
// Set a timeout in case stdin is not actually providing data
|
||||
const timeout = setTimeout(() => {
|
||||
process.stdin.removeAllListeners()
|
||||
resolve(data.trim() || null)
|
||||
}, 1000)
|
||||
|
||||
process.stdin.on("data", (chunk) => {
|
||||
data += chunk
|
||||
})
|
||||
|
||||
process.stdin.on("end", () => {
|
||||
clearTimeout(timeout)
|
||||
resolve(data.trim() || null)
|
||||
})
|
||||
|
||||
process.stdin.on("error", () => {
|
||||
clearTimeout(timeout)
|
||||
resolve(null)
|
||||
})
|
||||
|
||||
// Resume stdin in case it's paused
|
||||
process.stdin.resume()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Plain-text task runner for non-TTY environments (piped output, file redirection)
|
||||
* Outputs clean text without ANSI codes or Ink rendering
|
||||
*/
|
||||
|
||||
/* eslint-disable no-console */
|
||||
// Console output is intentional here for plain text mode
|
||||
|
||||
import { registerPartialMessageCallback } from "@core/controller/ui/subscribeToPartialMessage"
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import type { Controller } from "@/core/controller"
|
||||
|
||||
export interface PlainTextTaskOptions {
|
||||
controller: Controller
|
||||
prompt: string
|
||||
imageDataUrls?: string[]
|
||||
verbose?: boolean
|
||||
jsonOutput?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a task with plain text output (no Ink, no ANSI codes)
|
||||
* Returns true if task completed successfully, false if error
|
||||
*/
|
||||
export async function runPlainTextTask(options: PlainTextTaskOptions): Promise<boolean> {
|
||||
const { controller, prompt, imageDataUrls, verbose, jsonOutput } = options
|
||||
|
||||
// Track completion state
|
||||
let isComplete = false
|
||||
let hasError = false
|
||||
const processedMessages = new Map<number, number>() // index -> last output text length
|
||||
let lastStreamingMessageIndex = -1 // track open streaming line that needs closing
|
||||
|
||||
// Subscribe to state updates
|
||||
const originalPostState = controller.postStateToWebview.bind(controller)
|
||||
|
||||
const handleStateUpdate = async () => {
|
||||
try {
|
||||
const state = await controller.getStateToPostToWebview()
|
||||
const messages = state.clineMessages || []
|
||||
|
||||
// Process new messages
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i]
|
||||
const currentTextLength = message.text?.length ?? 0
|
||||
const lastOutputLength = processedMessages.get(i) ?? 0
|
||||
|
||||
// Skip if no new content to output
|
||||
if (currentTextLength <= lastOutputLength) continue
|
||||
|
||||
// Close previous streaming line if we're moving to a different message
|
||||
if (lastStreamingMessageIndex >= 0 && lastStreamingMessageIndex !== i && !jsonOutput) {
|
||||
process.stdout.write("\n")
|
||||
lastStreamingMessageIndex = -1
|
||||
}
|
||||
|
||||
processedMessages.set(i, currentTextLength)
|
||||
|
||||
// Output the message
|
||||
if (jsonOutput) {
|
||||
process.stdout.write(JSON.stringify(message) + "\n")
|
||||
} else {
|
||||
const isStreaming = outputMessageAsText(message, verbose || false, lastOutputLength)
|
||||
// Track streaming state for text messages
|
||||
if (isStreaming) {
|
||||
lastStreamingMessageIndex = i
|
||||
} else {
|
||||
lastStreamingMessageIndex = -1
|
||||
}
|
||||
}
|
||||
|
||||
// Check for completion
|
||||
if (
|
||||
message.say === "completion_result" ||
|
||||
message.ask === "completion_result" ||
|
||||
message.say === "error" ||
|
||||
message.ask === "api_req_failed"
|
||||
) {
|
||||
isComplete = true
|
||||
if (message.say === "error" || message.ask === "api_req_failed") {
|
||||
hasError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close streaming line on completion
|
||||
if (isComplete && lastStreamingMessageIndex >= 0 && !jsonOutput) {
|
||||
process.stdout.write("\n")
|
||||
lastStreamingMessageIndex = -1
|
||||
}
|
||||
} catch (error) {
|
||||
if (jsonOutput) {
|
||||
process.stdout.write(
|
||||
JSON.stringify({ type: "error", message: error instanceof Error ? error.message : String(error) }) + "\n",
|
||||
)
|
||||
} else {
|
||||
process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}` + "\n")
|
||||
}
|
||||
hasError = true
|
||||
isComplete = true
|
||||
}
|
||||
}
|
||||
|
||||
// Override postStateToWebview to capture state updates
|
||||
controller.postStateToWebview = async () => {
|
||||
await originalPostState()
|
||||
await handleStateUpdate()
|
||||
}
|
||||
|
||||
// Subscribe to partial message updates (for streaming)
|
||||
const unsubscribePartial = registerPartialMessageCallback(() => {
|
||||
// Partial updates are handled via postStateToWebview
|
||||
})
|
||||
|
||||
try {
|
||||
// Get initial state
|
||||
await handleStateUpdate()
|
||||
|
||||
// Start the task
|
||||
await controller.initTask(prompt, imageDataUrls)
|
||||
|
||||
// Wait for completion with timeout
|
||||
const timeout = 10 * 60 * 1000 // 10 minutes
|
||||
const startTime = Date.now()
|
||||
|
||||
while (!isComplete && Date.now() - startTime < timeout) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
|
||||
if (!isComplete) {
|
||||
// Close any open streaming line before error message
|
||||
if (lastStreamingMessageIndex >= 0 && !jsonOutput) {
|
||||
process.stdout.write("\n")
|
||||
lastStreamingMessageIndex = -1
|
||||
}
|
||||
if (jsonOutput) {
|
||||
process.stdout.write(JSON.stringify({ type: "error", message: "Task timeout" }) + "\n")
|
||||
} else {
|
||||
process.stderr.write("Error: Task timeout" + "\n")
|
||||
}
|
||||
hasError = true
|
||||
}
|
||||
} catch (error) {
|
||||
if (jsonOutput) {
|
||||
process.stdout.write(
|
||||
JSON.stringify({ type: "error", message: error instanceof Error ? error.message : String(error) }) + "\n",
|
||||
)
|
||||
} else {
|
||||
process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}` + "\n")
|
||||
}
|
||||
hasError = true
|
||||
} finally {
|
||||
// Close any open streaming line
|
||||
if (lastStreamingMessageIndex >= 0 && !jsonOutput) {
|
||||
process.stdout.write("\n")
|
||||
}
|
||||
// Restore original postStateToWebview
|
||||
controller.postStateToWebview = originalPostState
|
||||
unsubscribePartial()
|
||||
}
|
||||
|
||||
return !hasError
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Cline message as plain text
|
||||
* @param previousLength - Length of text already output for this message (for streaming)
|
||||
* @returns true if this is a streaming message (caller should track for newline), false otherwise
|
||||
*/
|
||||
function outputMessageAsText(message: ClineMessage, verbose: boolean, previousLength: number = 0): boolean {
|
||||
const timestamp = new Date(message.ts || Date.now()).toLocaleTimeString()
|
||||
const fullText = message.text ?? ""
|
||||
|
||||
if (!fullText) {
|
||||
// Skip partial messages without text
|
||||
return false
|
||||
}
|
||||
|
||||
// For streaming text continuations, output only new content
|
||||
if (previousLength > 0 && message.type === "say" && message.say === "text") {
|
||||
process.stdout.write(fullText.slice(previousLength))
|
||||
return true // Still streaming
|
||||
}
|
||||
|
||||
if (message.type === "say") {
|
||||
if (message.say === "task") {
|
||||
process.stdout.write(`[${timestamp}] Task: ${fullText}\n`)
|
||||
} else if (message.say === "text") {
|
||||
// First output of text message - write prefix but no newline (streaming)
|
||||
process.stdout.write(`[${timestamp}] ${fullText}`)
|
||||
return true // Streaming - newline will be added when stream ends
|
||||
} else if (message.say === "completion_result" && fullText) {
|
||||
process.stdout.write(`[${timestamp}] Completed: ${fullText}\n`)
|
||||
} else if (message.say === "error") {
|
||||
process.stderr.write(`[${timestamp}] Error: ${fullText}\n`)
|
||||
} else if (message.say === "api_req_started") {
|
||||
if (verbose) {
|
||||
process.stdout.write(`[${timestamp}] API request started\n`)
|
||||
}
|
||||
} else if (message.say === "api_req_finished") {
|
||||
if (verbose) {
|
||||
process.stdout.write(`[${timestamp}] API request finished\n`)
|
||||
}
|
||||
} else if (verbose) {
|
||||
process.stdout.write(`[${timestamp}] ${message.say}: ${fullText}\n`)
|
||||
}
|
||||
} else if (message.type === "ask") {
|
||||
if (message.ask === "completion_result") {
|
||||
process.stdout.write(`[${timestamp}] Task completed\n`)
|
||||
} else if (message.ask === "api_req_failed") {
|
||||
process.stderr.write(`[${timestamp}] API request failed: ${fullText}\n`)
|
||||
} else if (message.ask === "tool" || message.ask === "command" || message.ask === "browser_action_launch") {
|
||||
// These require approval - in non-interactive mode, warn the user
|
||||
process.stderr.write(`[${timestamp}] Waiting for approval (use --yolo for auto-approve): ${message.ask}\n`)
|
||||
} else if (verbose) {
|
||||
process.stdout.write(`[${timestamp}] Question: ${fullText}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Slash command utilities for CLI
|
||||
* Handles detection, filtering, and insertion of slash commands
|
||||
*/
|
||||
|
||||
import type { SlashCommandInfo } from "@shared/proto/cline/slash"
|
||||
|
||||
export interface SlashQueryInfo {
|
||||
inSlashMode: boolean
|
||||
query: string
|
||||
slashIndex: number
|
||||
}
|
||||
|
||||
export interface VisibleWindow<T> {
|
||||
items: T[]
|
||||
startIndex: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate visible window for a scrollable list menu.
|
||||
* Centers the selected item in the visible window when possible.
|
||||
* Returns the visible items and the start index for selection tracking.
|
||||
*/
|
||||
export function getVisibleWindow<T>(items: T[], selectedIndex: number, maxVisible: number = 5): VisibleWindow<T> {
|
||||
if (items.length <= maxVisible) {
|
||||
return { items, startIndex: 0 }
|
||||
}
|
||||
|
||||
const halfWindow = Math.floor(maxVisible / 2)
|
||||
let startIndex = Math.max(0, selectedIndex - halfWindow)
|
||||
const endIndex = Math.min(items.length, startIndex + maxVisible)
|
||||
|
||||
// Adjust if we're near the end
|
||||
if (endIndex - startIndex < maxVisible) {
|
||||
startIndex = Math.max(0, endIndex - maxVisible)
|
||||
}
|
||||
|
||||
return { items: items.slice(startIndex, endIndex), startIndex }
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort commands with workflows (custom section) first, then default commands.
|
||||
*/
|
||||
export function sortCommandsWorkflowsFirst(commands: SlashCommandInfo[]): SlashCommandInfo[] {
|
||||
return [...commands.filter((cmd) => cmd.section === "custom"), ...commands.filter((cmd) => cmd.section !== "custom")]
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract slash command query from input text.
|
||||
* Returns info about whether we're in slash mode and what the query is.
|
||||
*/
|
||||
export function extractSlashQuery(text: string): SlashQueryInfo {
|
||||
// Find the last slash in the text
|
||||
const slashIndex = text.lastIndexOf("/")
|
||||
|
||||
if (slashIndex === -1) {
|
||||
return { inSlashMode: false, query: "", slashIndex: -1 }
|
||||
}
|
||||
|
||||
// Slash must be at start or preceded by whitespace
|
||||
const charBeforeSlash = slashIndex > 0 ? text[slashIndex - 1] : null
|
||||
if (charBeforeSlash !== null && !/\s/.test(charBeforeSlash)) {
|
||||
return { inSlashMode: false, query: "", slashIndex: -1 }
|
||||
}
|
||||
|
||||
// Get text after the slash
|
||||
const textAfterSlash = text.slice(slashIndex + 1)
|
||||
|
||||
// If there's whitespace after slash, we're not in slash mode anymore
|
||||
if (/\s/.test(textAfterSlash)) {
|
||||
return { inSlashMode: false, query: "", slashIndex: -1 }
|
||||
}
|
||||
|
||||
// Check if there's already a completed slash command earlier in the text
|
||||
// (only first slash command per message is processed)
|
||||
const firstSlashCommandRegex = /(^|\s)\/[a-zA-Z0-9_.-]+\s/
|
||||
const textBeforeCurrentSlash = text.slice(0, slashIndex)
|
||||
if (firstSlashCommandRegex.test(textBeforeCurrentSlash)) {
|
||||
return { inSlashMode: false, query: "", slashIndex: -1 }
|
||||
}
|
||||
|
||||
return {
|
||||
inSlashMode: true,
|
||||
query: textAfterSlash,
|
||||
slashIndex,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter commands that match the query prefix (case-insensitive)
|
||||
*/
|
||||
export function filterCommands(commands: SlashCommandInfo[], query: string): SlashCommandInfo[] {
|
||||
if (!query) {
|
||||
return commands
|
||||
}
|
||||
return commands.filter((cmd) => cmd.name.toLowerCase().startsWith(query.toLowerCase()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a slash command at the given slash index, replacing any partial query
|
||||
*/
|
||||
export function insertSlashCommand(text: string, slashIndex: number, commandName: string): string {
|
||||
const beforeSlash = text.slice(0, slashIndex)
|
||||
// Insert command with trailing space
|
||||
return `${beforeSlash}/${commandName} `
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Shared tool utilities for CLI components
|
||||
* Centralizes tool name handling and categorization
|
||||
*/
|
||||
|
||||
/**
|
||||
* Tools that perform file edits (create, modify, delete)
|
||||
* Used to determine when to show DiffView and skip dynamic rendering
|
||||
*/
|
||||
export const FILE_EDIT_TOOLS = new Set([
|
||||
"editedExistingFile",
|
||||
"newFileCreated",
|
||||
"replace_in_file",
|
||||
"write_to_file",
|
||||
"fileDeleted",
|
||||
])
|
||||
|
||||
/**
|
||||
* Tools that save/modify files (subset used for "Save" button label)
|
||||
*/
|
||||
export const FILE_SAVE_TOOLS = new Set(["editedExistingFile", "newFileCreated", "fileDeleted"])
|
||||
|
||||
/**
|
||||
* Check if a tool name is a file edit tool
|
||||
*/
|
||||
export function isFileEditTool(toolName: string | undefined): boolean {
|
||||
if (!toolName) return false
|
||||
return FILE_EDIT_TOOLS.has(toolName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool name is a file save tool (for button labeling)
|
||||
*/
|
||||
export function isFileSaveTool(toolName: string | undefined): boolean {
|
||||
if (!toolName) return false
|
||||
return FILE_SAVE_TOOLS.has(toolName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize tool name to snake_case for consistent lookups
|
||||
* Handles both camelCase (readFile) and snake_case (read_file) inputs
|
||||
*/
|
||||
export function normalizeToolName(toolName: string): string {
|
||||
// Convert camelCase to snake_case
|
||||
return toolName.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool descriptions for display
|
||||
* Uses snake_case keys - use normalizeToolName() before lookup
|
||||
*/
|
||||
export const TOOL_DESCRIPTIONS: Record<string, { ask: string; say: string }> = {
|
||||
// File operations
|
||||
read_file: { ask: "wants to read this file", say: "read this file" },
|
||||
write_to_file: { ask: "wants to create a new file", say: "created a new file" },
|
||||
new_file_created: { ask: "wants to create a new file", say: "created a new file" },
|
||||
replace_in_file: { ask: "wants to edit this file", say: "edited this file" },
|
||||
edited_existing_file: { ask: "wants to edit this file", say: "edited this file" },
|
||||
|
||||
// Directory operations
|
||||
list_files: { ask: "wants to view files in this directory", say: "viewed files in this directory" },
|
||||
list_files_top_level: { ask: "wants to view files in this directory", say: "viewed files in this directory" },
|
||||
list_files_recursive: {
|
||||
ask: "wants to recursively view all files in this directory",
|
||||
say: "recursively viewed all files in this directory",
|
||||
},
|
||||
list_code_definition_names: {
|
||||
ask: "wants to view code definitions in this directory",
|
||||
say: "viewed code definitions in this directory",
|
||||
},
|
||||
search_files: { ask: "wants to search files", say: "searched files" },
|
||||
|
||||
// Command execution
|
||||
execute_command: { ask: "wants to execute this command", say: "executed this command" },
|
||||
|
||||
// Browser
|
||||
browser_action: { ask: "wants to use the browser", say: "used the browser" },
|
||||
|
||||
// MCP
|
||||
use_mcp_tool: { ask: "wants to use an MCP tool", say: "used an MCP tool" },
|
||||
access_mcp_resource: { ask: "wants to access an MCP resource", say: "accessed an MCP resource" },
|
||||
|
||||
// Web
|
||||
web_fetch: { ask: "wants to fetch content from this URL", say: "fetched content from this URL" },
|
||||
web_search: { ask: "wants to search the web", say: "searched the web" },
|
||||
|
||||
// Other
|
||||
ask_followup_question: { ask: "wants to ask a question", say: "asked a question" },
|
||||
attempt_completion: { ask: "wants to complete the task", say: "completed the task" },
|
||||
new_task: { ask: "wants to create a new task", say: "created a new task" },
|
||||
focus_chain: { ask: "wants to update the todo list", say: "updated the todo list" },
|
||||
}
|
||||
|
||||
/**
|
||||
* Default description for unknown tools
|
||||
*/
|
||||
export const DEFAULT_TOOL_DESCRIPTION = {
|
||||
ask: "wants to use a tool",
|
||||
say: "used a tool",
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tool description with normalized lookup
|
||||
*/
|
||||
export function getToolDescription(toolName: string): { ask: string; say: string } {
|
||||
const normalized = normalizeToolName(toolName)
|
||||
return TOOL_DESCRIPTIONS[normalized] || DEFAULT_TOOL_DESCRIPTION
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parse JSON from message text
|
||||
* Returns the parsed object or a default value if parsing fails
|
||||
*/
|
||||
export function parseMessageJson<T>(text: string | undefined, defaultValue: T): T {
|
||||
if (!text) return defaultValue
|
||||
try {
|
||||
return JSON.parse(text) as T
|
||||
} catch {
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse tool info from message text
|
||||
*/
|
||||
export function parseToolFromMessage(
|
||||
text: string | undefined,
|
||||
): { toolName: string; args: Record<string, unknown>; result?: string } | null {
|
||||
if (!text) return null
|
||||
try {
|
||||
const parsed = JSON.parse(text)
|
||||
if (parsed.tool) {
|
||||
return {
|
||||
toolName: parsed.tool,
|
||||
args: parsed,
|
||||
result: parsed.content || parsed.output,
|
||||
}
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user