mirror of
https://github.com/cline/cline.git
synced 2026-09-01 23:19:18 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 58bc26bd01 | |||
| 120c912ff3 | |||
| 39224edaf3 | |||
| a98253516c |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add claude 4.5 haiku
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: finalize document content during approval flow
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Expose --version in cline cli command
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
"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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Log Persistence errors to PostHog
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
feat: add OpenAI Codex (ChatGPT Plus/Pro) provider
|
||||
|
||||
Users with ChatGPT Plus or Pro subscriptions can now use GPT-5 models directly through Cline without needing an API key. Authentication is handled via OAuth through OpenAI's authentication system.
|
||||
|
||||
Models available:
|
||||
- gpt-5.2-codex (default)
|
||||
- gpt-5.1-codex-max
|
||||
- gpt-5.1-codex-mini
|
||||
- gpt-5.2
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix two bugs in DiffViewProvider file editing:
|
||||
|
||||
1. **Line boundary validation**: Add `safelyTruncateDocument()` to prevent out-of-bounds line errors on JetBrains hosts (fixes #8423, #8429). The gRPC protocol strictly validates line numbers, causing "truncateDocument INTERNAL: Wrong line" errors when `truncateDocument()` was called with a line number >= document line count.
|
||||
|
||||
2. **Content concatenation on final update**: When replacing content without a trailing newline, the old content at line N+1 was concatenated to the new content. Fixed by extending the replacement range to cover the entire document on final update.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
docs: fix outdated Ollama model names in documentation
|
||||
|
||||
Updated recommended Ollama models to use correct identifiers:
|
||||
- Changed qwen3-coder-30b to qwen2.5-coder:32b
|
||||
- Changed devstral-small to codellama:34b-code
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
"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.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
"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.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
"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.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
"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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
OpenAI GPT-5 Codex models are now using Apply Patch tool for diff edits.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Disable PostHog telemetry, error tracking, and feature flags in self-hosted mode
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix issue where tool call ids are invalid when switching between models using the chat completion format and the responses api format.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: add chat output on skill use
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Adding telemetry for background exec terminal
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
This pull request introduces comprehensive Jupyter Notebook support for Cline, enabling AI-assisted editing of `.ipynb` files with full cell-level context awareness. The feature allows users to seamlessly work with Jupyter notebooks using Cline's AI capabilities while preserving the notebook's JSON structure.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Remove deprecated zai-glm-4.6 model from Cerebras provider
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Make Sonnet 4.5 the default Amazon Bedrock model id
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: removes retry message from UI after retry succeeds
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Support native tool calling for LM Studio and Ollama provider
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
add claude 4.5 opus into sap provider.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Limite Vertex and LiteLLM options when they're remote configured
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix crash when the Context Menu has a type but no options
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Throttle the remote config fetch
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Improve history view filter menu
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
Add git worktree management UI for running parallel Cline sessions
|
||||
@@ -1,33 +0,0 @@
|
||||
# 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.
|
||||
|
||||
## Adding New API Providers
|
||||
|
||||
When adding a new API provider to the extension, you must also update the CLI:
|
||||
|
||||
1. **Update `cli-ts/src/components/ModelPicker.tsx`**: Add the provider to the `providerModels` map so `getDefaultModelId()` returns the correct default model. Import the models and default ID from `@shared/api`:
|
||||
```typescript
|
||||
import { newProviderDefaultModelId, newProviderModels } from "@/shared/api"
|
||||
|
||||
export const providerModels = {
|
||||
// ...existing providers
|
||||
"new-provider": { models: newProviderModels, defaultId: newProviderDefaultModelId },
|
||||
}
|
||||
```
|
||||
|
||||
2. **Use `applyProviderConfig()` for auth flows**: When implementing OAuth or other auth flows for the provider, use the shared utility at `cli-ts/src/utils/provider-config.ts`:
|
||||
```typescript
|
||||
import { applyProviderConfig } from "../utils/provider-config"
|
||||
|
||||
// After successful auth:
|
||||
await applyProviderConfig({ providerId: "new-provider", controller })
|
||||
```
|
||||
This handles setting provider, default model, API key mapping, state persistence, and rebuilding the API handler.
|
||||
|
||||
3. **Provider-specific auth**: If the provider uses OAuth (like `openai-codex`), add handling in `SettingsPanelContent.tsx`'s `handleProviderSelect` callback. See the existing Codex OAuth flow as a reference.
|
||||
@@ -33,7 +33,13 @@ jobs:
|
||||
node-version: '20.x'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
# Cache root dependencies
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24'
|
||||
cache-dependency-path: cli/go.sum
|
||||
|
||||
# Cache root dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache root dependencies
|
||||
uses: actions/cache@v4
|
||||
id: root-cache
|
||||
@@ -41,37 +47,46 @@ jobs:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
# Cache cli-ts dependencies
|
||||
- name: Cache cli-ts dependencies
|
||||
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache webview-ui dependencies
|
||||
uses: actions/cache@v4
|
||||
id: cli-ts-cache
|
||||
id: webview-cache
|
||||
with:
|
||||
path: cli-ts/node_modules
|
||||
key: ${{ runner.os }}-npm-cli-ts-${{ hashFiles('cli-ts/package-lock.json') }}
|
||||
path: webview-ui/node_modules
|
||||
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
|
||||
|
||||
- name: Install root dependencies
|
||||
if: steps.root-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci --include=optional
|
||||
|
||||
- name: Install 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: Install webview-ui dependencies
|
||||
if: steps.webview-cache.outputs.cache-hit != 'true'
|
||||
run: cd webview-ui && npm ci --include=optional
|
||||
|
||||
- name: Read release version
|
||||
id: version
|
||||
run: |
|
||||
# Read version from cli-ts/package.json
|
||||
VERSION=$(node -p "require('./cli-ts/package.json').version")
|
||||
# Read version from cli/package.json (stable version)
|
||||
VERSION=$(node -p "require('./cli/package.json').version")
|
||||
echo "Release version: $VERSION"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download ripgrep binaries
|
||||
run: npm run download-ripgrep
|
||||
|
||||
- name: Clean previous builds
|
||||
run: rm -rf dist-standalone
|
||||
|
||||
- name: Build and package CLI
|
||||
- name: Generate Protos (First Pass)
|
||||
run: npm run protos && npm run protos-go
|
||||
|
||||
- name: Compile CLI
|
||||
run: npm run compile-cli
|
||||
|
||||
- name: Compile CLI for all platforms
|
||||
run: npm run compile-cli-all-platforms
|
||||
|
||||
- name: Build standalone NPM package
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
@@ -83,18 +98,21 @@ jobs:
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
POSTHOG_TELEMETRY_ENABLED: "true"
|
||||
run: node scripts/package-npm.mjs
|
||||
run: npm run compile-standalone-npm
|
||||
|
||||
- name: Generate Protos (Second Pass - Bug Workaround)
|
||||
run: npm run protos && npm run protos-go
|
||||
|
||||
- name: Verify build output
|
||||
run: |
|
||||
echo "Checking dist-standalone directory..."
|
||||
ls -la dist-standalone/
|
||||
|
||||
echo "Checking dist-standalone/dist..."
|
||||
ls -la dist-standalone/dist/
|
||||
|
||||
|
||||
echo "Verifying CLI binaries..."
|
||||
ls -lh cli/bin/cline-* || echo "Warning: CLI binaries not found"
|
||||
|
||||
echo "Checking package.json in dist-standalone..."
|
||||
cat dist-standalone/package.json
|
||||
cat dist-standalone/package.json | grep version
|
||||
|
||||
- name: Publish to NPM with latest tag
|
||||
env:
|
||||
|
||||
@@ -42,7 +42,14 @@ jobs:
|
||||
node-version: '20.x'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
# Cache root dependencies
|
||||
- name: Setup Go
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24'
|
||||
cache-dependency-path: cli/go.sum
|
||||
|
||||
# Cache root dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache root dependencies
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
uses: actions/cache@v4
|
||||
@@ -51,63 +58,75 @@ jobs:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
# Cache cli-ts dependencies
|
||||
- name: Cache cli-ts dependencies
|
||||
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache webview-ui dependencies
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
uses: actions/cache@v4
|
||||
id: cli-ts-cache
|
||||
id: webview-cache
|
||||
with:
|
||||
path: cli-ts/node_modules
|
||||
key: ${{ runner.os }}-npm-cli-ts-${{ hashFiles('cli-ts/package-lock.json') }}
|
||||
path: webview-ui/node_modules
|
||||
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
|
||||
|
||||
- name: Install root dependencies
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.root-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci --include=optional
|
||||
|
||||
- name: Install 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: Install webview-ui dependencies
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.webview-cache.outputs.cache-hit != 'true'
|
||||
run: cd webview-ui && npm ci --include=optional
|
||||
|
||||
- name: Generate nightly version with timestamp
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
id: version
|
||||
run: |
|
||||
# Read base version from cli-ts/package.json (e.g., "2.0.0")
|
||||
BASE_VERSION=$(node -p "require('./cli-ts/package.json').version")
|
||||
|
||||
# Read base version from cli/package.json (e.g., "1.0.9")
|
||||
BASE_VERSION=$(node -p "require('./cli/package.json').version")
|
||||
|
||||
# Generate timestamp (Unix epoch seconds)
|
||||
TIMESTAMP=$(date +%s)
|
||||
|
||||
# Create unique nightly version: 2.0.0-nightly.1736365200
|
||||
|
||||
# Create unique nightly version: 1.0.9-nightly.1736365200
|
||||
VERSION="${BASE_VERSION}-nightly.${TIMESTAMP}"
|
||||
|
||||
|
||||
echo "Base version: $BASE_VERSION"
|
||||
echo "Generated nightly version: $VERSION"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Update cli-ts/package.json with nightly version
|
||||
- name: Update cli/package.json with nightly version
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: |
|
||||
# Update version with timestamp-based nightly version
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const pkg = JSON.parse(fs.readFileSync('cli-ts/package.json', 'utf8'));
|
||||
const pkg = JSON.parse(fs.readFileSync('cli/package.json', 'utf8'));
|
||||
pkg.version = '${{ steps.version.outputs.version }}';
|
||||
fs.writeFileSync('cli-ts/package.json', JSON.stringify(pkg, null, '\t'));
|
||||
fs.writeFileSync('cli/package.json', JSON.stringify(pkg, null, '\t'));
|
||||
"
|
||||
|
||||
|
||||
echo "Using version ${{ steps.version.outputs.version }} for build"
|
||||
cat cli-ts/package.json | grep '"version"'
|
||||
cat cli/package.json | grep '"version"'
|
||||
|
||||
- name: Download ripgrep binaries
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: npm run download-ripgrep
|
||||
|
||||
- name: Clean previous builds
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: rm -rf dist-standalone
|
||||
|
||||
- name: Build and package CLI
|
||||
- name: Generate Protos (First Pass)
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: npm run protos && npm run protos-go
|
||||
|
||||
- name: Compile CLI
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: npm run compile-cli
|
||||
|
||||
- name: Compile CLI for all platforms
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: npm run compile-cli-all-platforms
|
||||
|
||||
- name: Build standalone NPM package
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
@@ -120,19 +139,23 @@ jobs:
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
POSTHOG_TELEMETRY_ENABLED: "true"
|
||||
run: node scripts/package-npm.mjs
|
||||
run: npm run compile-standalone-npm
|
||||
|
||||
- name: Generate Protos (Second Pass - Bug Workaround)
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: npm run protos && npm run protos-go
|
||||
|
||||
- name: Verify build output
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: |
|
||||
echo "Checking dist-standalone directory..."
|
||||
ls -la dist-standalone/
|
||||
|
||||
echo "Checking dist-standalone/dist..."
|
||||
ls -la dist-standalone/dist/
|
||||
|
||||
|
||||
echo "Verifying CLI binaries..."
|
||||
ls -lh cli/bin/cline-* || echo "Warning: CLI binaries not found"
|
||||
|
||||
echo "Checking package.json in dist-standalone..."
|
||||
cat dist-standalone/package.json
|
||||
cat dist-standalone/package.json | grep version
|
||||
|
||||
- name: Publish to NPM with nightly tag
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
|
||||
@@ -187,11 +187,20 @@ jobs:
|
||||
if: steps.webview-cache.outputs.cache-hit != 'true'
|
||||
run: cd webview-ui && npm ci
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24'
|
||||
cache-dependency-path: cli/go.sum
|
||||
|
||||
- name: Build CLI binaries
|
||||
run: npm run compile-cli-all-platforms
|
||||
|
||||
- name: Download ripgrep binaries
|
||||
run: npm run download-ripgrep
|
||||
|
||||
- name: Compile Standalone
|
||||
run: npm run compile-standalone
|
||||
- name: Compile NPM package
|
||||
run: npm run compile-standalone-npm
|
||||
|
||||
- name: Install testing platform dependencies
|
||||
if: steps.testing-platform-cache.outputs.cache-hit != 'true'
|
||||
@@ -204,7 +213,7 @@ jobs:
|
||||
# This prevents the job from showing as failed and avoids distracting developers
|
||||
# until the integration tests are ready to be enforced.
|
||||
run: |
|
||||
npm run test:tp-orchestrator -- tests/specs/ --count=1 --coverage
|
||||
npm run test:tp-orchestrator -- tests/specs/ --count=1 --coverage || true
|
||||
|
||||
- name: Save Coverage Reports
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -46,4 +46,3 @@ test-results
|
||||
/pkg
|
||||
.secrets
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
+2
-25
@@ -1,33 +1,10 @@
|
||||
# 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]
|
||||
|
||||
@@ -1760,4 +1737,4 @@ Add Opus 4.1 through Claude Code
|
||||
|
||||
## [0.0.6]
|
||||
|
||||
- Initial release
|
||||
- Initial release
|
||||
@@ -1,3 +1,2 @@
|
||||
@.clinerules/general.md
|
||||
@.clinerules/network.md
|
||||
@.clinerules/cli.md
|
||||
|
||||
@@ -137,6 +137,21 @@ For example, when working with a local web server, you can use 'Restore Workspac
|
||||
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
<img align="left" width="360" src="https://github.com/user-attachments/assets/7fdf41e6-281a-4b4b-ac19-020b838b6970">
|
||||
|
||||
### Worktrees: Parallel Development
|
||||
|
||||
Worktrees let you work on multiple branches simultaneously, each in its own folder. This enables Cline to work on tasks in parallel across separate VS Code windows, or lets Cline work independently while you continue coding in your main workspace.
|
||||
|
||||
Cline takes over your VS Code window while working on a task. With worktrees, you can:
|
||||
- **Run Cline in parallel** - Have Cline work on multiple tasks simultaneously
|
||||
- **Keep working while Cline works** - Let Cline handle a task in a separate worktree while you continue coding
|
||||
- **Isolate experimental changes** - Test risky changes in a worktree without affecting your main branch
|
||||
|
||||
<!-- Transparent pixel to create line break after floating image -->
|
||||
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
## Contributing
|
||||
|
||||
To contribute to the project, start with our [Contributing Guide](CONTRIBUTING.md) to learn the basics. You can also join our [Discord](https://discord.gg/cline) to chat with other contributors in the `#contributors` channel. If you're looking for full-time work, check out our open positions on our [careers page](https://cline.bot/join-us)!
|
||||
|
||||
+1
-28
@@ -44,7 +44,7 @@
|
||||
"style": {
|
||||
"useNodejsImportProtocol": "off",
|
||||
"useImportType": "off",
|
||||
"useBlockStatements": "off",
|
||||
"useBlockStatements": "warn",
|
||||
"useNamingConvention": "off",
|
||||
"useThrowOnlyError": "info",
|
||||
"useConsistentArrayType": "off",
|
||||
@@ -147,33 +147,6 @@
|
||||
"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": [
|
||||
"**",
|
||||
"!**/esbuild.*",
|
||||
"!**/*.mts",
|
||||
"!**/webview-ui/**",
|
||||
"!**/evals/**",
|
||||
"!**/standalone/**",
|
||||
"!**/e2e/**",
|
||||
"!**/test/**",
|
||||
"!**/__tests__/**",
|
||||
"!**/*.test.ts",
|
||||
"!**/*.stories.ts",
|
||||
"!src/dev/**",
|
||||
"!**/*.mjs",
|
||||
"!**/*.js",
|
||||
"!**/scripts/**",
|
||||
"!**/*.tsx",
|
||||
"!**/testing-platform/**",
|
||||
// ACP mode must redirect console to stderr - this is intentional
|
||||
"!cli-ts/src/acp/index.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"includes": [
|
||||
"**",
|
||||
|
||||
@@ -1,354 +0,0 @@
|
||||
# Cline CLI (TypeScript)
|
||||
|
||||
A TypeScript CLI implementation of Cline that reuses the core TypeScript codebase. This allows you to run Cline tasks directly from the terminal while sharing the same underlying functionality as the VS Code extension.
|
||||
|
||||
## Features
|
||||
|
||||
- **Reuses Core Codebase**: Shares the same Controller, Task, and API handling as the VS Code extension
|
||||
- **Terminal Output**: Displays Cline messages directly in your terminal with colored output
|
||||
- **Task History**: Access your task history from the command line
|
||||
- **Configurable**: Use custom configuration directories and working directories
|
||||
- **Image Support**: Attach images to your prompts using file paths or inline references
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 20.x or later
|
||||
- npm or yarn
|
||||
- The parent Cline project dependencies installed
|
||||
|
||||
## Installation
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
# Install all dependencies first
|
||||
npm run install:all
|
||||
|
||||
# Ensure protos are generated
|
||||
npm run protos
|
||||
|
||||
# Build the CLI
|
||||
npm run 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
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Interactive Mode (Default)
|
||||
|
||||
When you run `cline` without any command, it launches an interactive welcome prompt:
|
||||
|
||||
```bash
|
||||
# Launch interactive mode
|
||||
cline
|
||||
|
||||
# Or run a task directly
|
||||
cline "Create a hello world function in Python"
|
||||
|
||||
# With options
|
||||
cline -v --thinking "Analyze this codebase"
|
||||
```
|
||||
|
||||
### Commands
|
||||
|
||||
#### `task` (alias: `t`)
|
||||
|
||||
Run a new task with a prompt.
|
||||
|
||||
```bash
|
||||
cline task "Create a hello world function in Python"
|
||||
cline t "Create a hello world function"
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-a, --act` | Run in act mode |
|
||||
| `-p, --plan` | Run in plan mode |
|
||||
| `-y, --yolo` | Enable yolo mode (auto-approve actions) |
|
||||
| `-m, --model <model>` | Model to use for the task |
|
||||
| `-i, --images <paths...>` | Image file paths to include with the task |
|
||||
| `-v, --verbose` | Show verbose output including reasoning |
|
||||
| `-c, --cwd <path>` | Working directory for the task |
|
||||
| `--config <path>` | Path to Cline configuration directory |
|
||||
| `-t, --thinking` | Enable extended thinking (1024 token budget) |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Run in plan mode with verbose output
|
||||
cline task -p -v "Design a REST API"
|
||||
|
||||
# Use a specific model with yolo mode
|
||||
cline task -m claude-sonnet-4-5-20250929 -y "Refactor this function"
|
||||
|
||||
# Include images with your prompt
|
||||
cline task -i screenshot.png diagram.jpg "Fix the UI based on these images"
|
||||
|
||||
# Or use inline image references in the prompt
|
||||
cline task "Fix the layout shown in @./screenshot.png"
|
||||
|
||||
# Enable extended thinking for complex tasks
|
||||
cline task -t "Architect a microservices system"
|
||||
|
||||
# Specify working directory
|
||||
cline task -c /path/to/project "Add unit tests"
|
||||
```
|
||||
|
||||
#### `history` (alias: `h`)
|
||||
|
||||
List task history with pagination support.
|
||||
|
||||
```bash
|
||||
cline history
|
||||
cline h
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-n, --limit <number>` | Number of tasks to show (default: 10) |
|
||||
| `-p, --page <number>` | Page number, 1-based (default: 1) |
|
||||
| `--config <path>` | Path to Cline configuration directory |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Show last 10 tasks (default)
|
||||
cline history
|
||||
|
||||
# Show 20 tasks
|
||||
cline history -n 20
|
||||
|
||||
# Show page 2 with 5 tasks per page
|
||||
cline history -n 5 -p 2
|
||||
```
|
||||
|
||||
#### `config`
|
||||
|
||||
Show current configuration including global and workspace state.
|
||||
|
||||
```bash
|
||||
cline config
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--config <path>` | Path to Cline configuration directory |
|
||||
|
||||
#### `auth`
|
||||
|
||||
Authenticate a provider and configure what model is used.
|
||||
|
||||
```bash
|
||||
cline auth
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-p, --provider <id>` | Provider ID for quick setup (e.g., openai-native, anthropic) |
|
||||
| `-k, --apikey <key>` | API key for the provider |
|
||||
| `-m, --modelid <id>` | Model ID to configure (e.g., gpt-4o, claude-sonnet-4-5-20250929) |
|
||||
| `-b, --baseurl <url>` | Base URL (optional, only for openai provider) |
|
||||
| `-v, --verbose` | Show verbose output |
|
||||
| `-c, --cwd <path>` | Working directory for the task |
|
||||
| `--config <path>` | Path to Cline configuration directory |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Interactive authentication
|
||||
cline auth
|
||||
|
||||
# Quick setup with provider and API key
|
||||
cline auth -p anthropic -k sk-ant-xxxxx
|
||||
|
||||
# Full quick setup with model
|
||||
cline auth -p openai-native -k sk-xxxxx -m gpt-4o
|
||||
|
||||
# OpenAI-compatible provider with custom base URL
|
||||
cline auth -p openai -k your-api-key -b https://api.example.com/v1
|
||||
```
|
||||
|
||||
### Global Options
|
||||
|
||||
These options are available for the default command (running a task directly):
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-v, --verbose` | Show verbose output |
|
||||
| `-c, --cwd <path>` | Working directory |
|
||||
| `--config <path>` | Configuration directory |
|
||||
| `--thinking` | Enable extended thinking (1024 token budget) |
|
||||
|
||||
## Development
|
||||
|
||||
For active development, at the root of this repo:
|
||||
|
||||
1. **Initial setup:**
|
||||
```bash
|
||||
npm run install:all
|
||||
npm run protos
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
# Production build (minified)
|
||||
npm run build:production
|
||||
```
|
||||
|
||||
### Watch Mode
|
||||
|
||||
```bash
|
||||
npm run watch
|
||||
```
|
||||
|
||||
### Type Checking
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## Publish
|
||||
|
||||
#### 1. Publish to npm
|
||||
```bash
|
||||
npm publish
|
||||
```
|
||||
|
||||
#### 2. Update the Homebrew formula
|
||||
```bash
|
||||
npm run update-brew-formula
|
||||
```
|
||||
|
||||
#### 3. Test the formula locally
|
||||
```bash
|
||||
# Create a local tap
|
||||
brew tap-new cline/local
|
||||
cp ./cli-ts/cline.rb "$(brew --repository)/Library/Taps/cline/homebrew-local/Formula/cline.rb"
|
||||
|
||||
# Build from Source
|
||||
brew install --build-from-source cline/local/cline
|
||||
|
||||
# Install from your local tap
|
||||
brew install cline/local/cline
|
||||
|
||||
# Clean up when done
|
||||
brew untap cline/local
|
||||
```
|
||||
|
||||
#### 4. If using a tap, commit and push
|
||||
```bash
|
||||
git add cline.rb
|
||||
git commit -m "Update cline to v2.0.0"
|
||||
git push
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
The CLI reuses the core Cline TypeScript codebase:
|
||||
|
||||
- **Controller** (`@core/controller`): Manages task lifecycle and state
|
||||
- **Task** (`@core/task`): Executes Cline tasks using the AI API
|
||||
- **StateManager** (`@core/storage`): Handles persistent state storage
|
||||
|
||||
CLI-specific implementations:
|
||||
|
||||
- `cli-host-bridge.ts`: CLI implementations of host bridge services
|
||||
- `cli-webview-provider.ts`: WebviewProvider that outputs to terminal
|
||||
- `cli-comment-review.ts`: Comment review controller for terminal
|
||||
- `vscode-context.ts`: Mock VSCode extension context
|
||||
- `display.ts`: Terminal output formatting utilities
|
||||
|
||||
## Configuration
|
||||
|
||||
The CLI stores its data in `~/.cline/data/` by default:
|
||||
|
||||
- `globalState.json`: Global settings and state
|
||||
- `secrets.json`: API keys and secrets
|
||||
- `workspace/`: Workspace-specific state
|
||||
- `tasks/`: Task history and conversation data
|
||||
|
||||
Override with the `--config` option or `CLINE_DIR` environment variable.
|
||||
|
||||
## Comparison with Go CLI
|
||||
|
||||
This TypeScript CLI differs from the Go CLI (`cli/` directory):
|
||||
|
||||
| Feature | Go CLI | TypeScript CLI |
|
||||
|---------|--------|----------------|
|
||||
| Language | Go | TypeScript |
|
||||
| Core sharing | Uses gRPC to communicate | Direct imports |
|
||||
| Startup time | Fast | Moderate |
|
||||
| Dependencies | Standalone binary | Requires Node.js |
|
||||
| Best for | Production deployment | Development, debugging |
|
||||
|
||||
Choose the TypeScript CLI when you need to debug or modify the core Cline logic. Choose the Go CLI for production deployment with faster startup.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Build Errors
|
||||
|
||||
If you encounter build errors, ensure you've:
|
||||
1. Run `npm install` in the repository root
|
||||
2. Run `npm run protos` to generate proto files
|
||||
3. Have all peer dependencies installed
|
||||
|
||||
### Missing Dependencies
|
||||
|
||||
The CLI imports from the parent project. If you see import errors:
|
||||
```bash
|
||||
cd .. # Go to repository root
|
||||
npm install
|
||||
npm run protos
|
||||
```
|
||||
|
||||
### Permission Denied
|
||||
|
||||
Make the CLI executable:
|
||||
```bash
|
||||
chmod +x dist/cli.js
|
||||
```
|
||||
@@ -1,20 +0,0 @@
|
||||
# IMPORTANT: `npm run postpublish` to update this file after publishing a new version of the package
|
||||
class Cline < Formula
|
||||
desc "Autonomous coding agent CLI - capable of creating/editing files, running commands, and more"
|
||||
homepage "https://cline.bot"
|
||||
url "https://registry.npmjs.org/cline/-/cline-2.0.0.tgz" # GET from https://registry.npmjs.org/cline/latest tarball URL
|
||||
sha256 "65bae90401191aeeabfbbc0b315e816aea96742043ba85b90671bf5e19d0761e"
|
||||
license "Apache-2.0"
|
||||
|
||||
depends_on "node@20"
|
||||
|
||||
def install
|
||||
system "npm", "install", *std_npm_args(prefix: false)
|
||||
bin.install_symlink Dir["#{libexec}/bin/*"]
|
||||
end
|
||||
|
||||
test do
|
||||
# Test that the binary exists and is executable
|
||||
assert_match version.to_s, shell_output("#{bin}/cline --version")
|
||||
end
|
||||
end
|
||||
@@ -1,272 +0,0 @@
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import dotenv from "dotenv"
|
||||
import * as esbuild from "esbuild"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const rootDir = path.resolve(__dirname, "..")
|
||||
|
||||
// Load .env from repo root
|
||||
dotenv.config({ path: path.join(rootDir, ".env") })
|
||||
|
||||
const production = process.argv.includes("--production")
|
||||
const watch = process.argv.includes("--watch")
|
||||
|
||||
/**
|
||||
* Plugin to resolve path aliases from the parent project
|
||||
*/
|
||||
const aliasResolverPlugin: esbuild.Plugin = {
|
||||
name: "alias-resolver",
|
||||
setup(build) {
|
||||
const aliases = {
|
||||
"@": path.resolve(rootDir, "src"),
|
||||
"@core": path.resolve(rootDir, "src/core"),
|
||||
"@integrations": path.resolve(rootDir, "src/integrations"),
|
||||
"@services": path.resolve(rootDir, "src/services"),
|
||||
"@shared": path.resolve(rootDir, "src/shared"),
|
||||
"@utils": path.resolve(rootDir, "src/utils"),
|
||||
"@packages": path.resolve(rootDir, "src/packages"),
|
||||
"@hosts": path.resolve(rootDir, "src/hosts"),
|
||||
"@generated": path.resolve(rootDir, "src/generated"),
|
||||
"@api": path.resolve(rootDir, "src/core/api"),
|
||||
}
|
||||
|
||||
// For each alias entry, create a resolver
|
||||
Object.entries(aliases).forEach(([alias, aliasPath]) => {
|
||||
const aliasRegex = new RegExp(`^${alias}($|/.*)`)
|
||||
build.onResolve({ filter: aliasRegex }, (args) => {
|
||||
const importPath = args.path.replace(alias, aliasPath)
|
||||
|
||||
// First, check if the path exists as is
|
||||
if (fs.existsSync(importPath)) {
|
||||
const stats = fs.statSync(importPath)
|
||||
if (stats.isDirectory()) {
|
||||
// If it's a directory, try to find index files
|
||||
const extensions = [".ts", ".tsx", ".js", ".jsx"]
|
||||
for (const ext of extensions) {
|
||||
const indexFile = path.join(importPath, `index${ext}`)
|
||||
if (fs.existsSync(indexFile)) {
|
||||
return { path: indexFile }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// It's a file that exists, so return it
|
||||
return { path: importPath }
|
||||
}
|
||||
}
|
||||
|
||||
// If the path doesn't exist, try appending extensions
|
||||
const extensions = [".ts", ".tsx", ".js", ".jsx"]
|
||||
for (const ext of extensions) {
|
||||
const pathWithExtension = `${importPath}${ext}`
|
||||
if (fs.existsSync(pathWithExtension)) {
|
||||
return { path: pathWithExtension }
|
||||
}
|
||||
}
|
||||
|
||||
// Handle .js -> .ts extension mapping (common in ESM TypeScript projects)
|
||||
if (importPath.endsWith(".js")) {
|
||||
const tsPath = importPath.replace(/\.js$/, ".ts")
|
||||
if (fs.existsSync(tsPath)) {
|
||||
return { path: tsPath }
|
||||
}
|
||||
const tsxPath = importPath.replace(/\.js$/, ".tsx")
|
||||
if (fs.existsSync(tsxPath)) {
|
||||
return { path: tsxPath }
|
||||
}
|
||||
}
|
||||
|
||||
// If nothing worked, return the original path and let esbuild handle the error
|
||||
return { path: importPath }
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin to redirect vscode imports to our shim
|
||||
*/
|
||||
const vscodeStubPlugin: esbuild.Plugin = {
|
||||
name: "vscode-stub",
|
||||
setup(build) {
|
||||
// Redirect 'vscode' imports to our shim
|
||||
build.onResolve({ filter: /^vscode$/ }, () => {
|
||||
return { path: path.join(__dirname, "src", "vscode-shim.ts") }
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const esbuildProblemMatcherPlugin: esbuild.Plugin = {
|
||||
name: "esbuild-problem-matcher",
|
||||
setup(build) {
|
||||
build.onStart(() => {
|
||||
console.log("[cli-ts esbuild] Build started...")
|
||||
})
|
||||
build.onEnd((result) => {
|
||||
result.errors.forEach(({ text, location }) => {
|
||||
console.error(`✘ [ERROR] ${text}`)
|
||||
if (location) {
|
||||
console.error(` ${location.file}:${location.line}:${location.column}:`)
|
||||
}
|
||||
})
|
||||
console.log("[cli-ts esbuild] Build finished")
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// Plugin to stub out optional devtools module
|
||||
const stubOptionalModulesPlugin: esbuild.Plugin = {
|
||||
name: "stub-optional-modules",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^react-devtools-core$/ }, () => {
|
||||
return { path: path.join(__dirname, "src", "stub-devtools.js"), external: false }
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const copyWasmFiles: esbuild.Plugin = {
|
||||
name: "copy-wasm-files",
|
||||
setup(build) {
|
||||
build.onEnd(() => {
|
||||
const destDir = path.join(__dirname, "dist")
|
||||
|
||||
// Ensure dist directory exists
|
||||
if (!fs.existsSync(destDir)) {
|
||||
fs.mkdirSync(destDir, { recursive: true })
|
||||
}
|
||||
|
||||
// tree sitter
|
||||
const sourceDir = path.join(rootDir, "node_modules", "web-tree-sitter")
|
||||
|
||||
// Copy tree-sitter.wasm
|
||||
const treeSitterWasm = path.join(sourceDir, "tree-sitter.wasm")
|
||||
if (fs.existsSync(treeSitterWasm)) {
|
||||
fs.copyFileSync(treeSitterWasm, path.join(destDir, "tree-sitter.wasm"))
|
||||
}
|
||||
|
||||
// Copy language-specific WASM files
|
||||
const languageWasmDir = path.join(rootDir, "node_modules", "tree-sitter-wasms", "out")
|
||||
const languages = [
|
||||
"typescript",
|
||||
"tsx",
|
||||
"python",
|
||||
"rust",
|
||||
"javascript",
|
||||
"go",
|
||||
"cpp",
|
||||
"c",
|
||||
"c_sharp",
|
||||
"ruby",
|
||||
"java",
|
||||
"php",
|
||||
"swift",
|
||||
"kotlin",
|
||||
]
|
||||
|
||||
if (fs.existsSync(languageWasmDir)) {
|
||||
languages.forEach((lang) => {
|
||||
const filename = `tree-sitter-${lang}.wasm`
|
||||
const sourcePath = path.join(languageWasmDir, filename)
|
||||
if (fs.existsSync(sourcePath)) {
|
||||
fs.copyFileSync(sourcePath, path.join(destDir, filename))
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const buildEnvVars: Record<string, string> = {
|
||||
"process.env.IS_STANDALONE": JSON.stringify("true"),
|
||||
"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"
|
||||
}
|
||||
|
||||
const config: esbuild.BuildOptions = {
|
||||
entryPoints: [path.join(__dirname, "src", "index.ts")],
|
||||
bundle: true,
|
||||
minify: production,
|
||||
sourcemap: !production,
|
||||
logLevel: "silent",
|
||||
define: buildEnvVars,
|
||||
tsconfig: path.join(__dirname, "tsconfig.json"),
|
||||
plugins: [copyWasmFiles, aliasResolverPlugin, vscodeStubPlugin, stubOptionalModulesPlugin, esbuildProblemMatcherPlugin],
|
||||
format: "esm",
|
||||
sourcesContent: false,
|
||||
platform: "node",
|
||||
target: "node20",
|
||||
outfile: path.join(__dirname, "dist", "cli.mjs"),
|
||||
// These modules need to load files from the module directory at runtime
|
||||
external: [
|
||||
"@grpc/reflection",
|
||||
"grpc-health-check",
|
||||
"better-sqlite3",
|
||||
"ink",
|
||||
"ink-spinner",
|
||||
"ink-picture",
|
||||
"react",
|
||||
"aws4fetch",
|
||||
"pino",
|
||||
"pino-roll",
|
||||
],
|
||||
supported: { "top-level-await": true },
|
||||
banner: {
|
||||
js: `#!/usr/bin/env node
|
||||
// Suppress all Node.js warnings (deprecation, experimental, etc.)
|
||||
process.emitWarning = () => {};
|
||||
import { createRequire as _createRequire } from 'module';
|
||||
import { fileURLToPath as _fileURLToPath } from 'url';
|
||||
import { dirname as _dirname } from 'path';
|
||||
const require = _createRequire(import.meta.url);
|
||||
const __filename = _fileURLToPath(import.meta.url);
|
||||
const __dirname = _dirname(__filename);`,
|
||||
},
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const ctx = await esbuild.context(config)
|
||||
if (watch) {
|
||||
await ctx.watch()
|
||||
console.log("[cli-ts] Watching for changes...")
|
||||
} else {
|
||||
await ctx.rebuild()
|
||||
await ctx.dispose()
|
||||
|
||||
// Make the output executable
|
||||
const outfile = path.join(__dirname, "dist", "cli.mjs")
|
||||
if (fs.existsSync(outfile)) {
|
||||
fs.chmodSync(outfile, "755")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -1,384 +0,0 @@
|
||||
.\" Automatically generated by Pandoc 3.8.3
|
||||
.\"
|
||||
.TH "CLINE" "1" "January 2026" "Cline CLI 2.0" "User Commands"
|
||||
.SH NAME
|
||||
cline \- AI coding assistant in your terminal
|
||||
.SH SYNOPSIS
|
||||
\f[B]cline\f[R] [\f[I]prompt\f[R]] [\f[I]options\f[R]]
|
||||
.PP
|
||||
\f[B]cline\f[R] \f[I]command\f[R] [\f[I]options\f[R]]
|
||||
[\f[I]arguments\f[R]]
|
||||
.SH DESCRIPTION
|
||||
\f[B]cline\f[R] is a command\-line interface for the Cline AI coding
|
||||
assistant.
|
||||
It provides the same powerful AI capabilities as the VS Code extension,
|
||||
directly in your terminal.
|
||||
.PP
|
||||
Cline is an autonomous AI agent that can read, write, and execute code
|
||||
across your projects.
|
||||
He can create and edit files, run terminal commands, use a headless
|
||||
browser, and more\(emall while asking for your approval before taking
|
||||
actions.
|
||||
.PP
|
||||
The CLI supports both interactive mode (with a rich terminal UI) and
|
||||
plain text mode (for piped input and scripted workflows).
|
||||
.SH MODES OF OPERATION
|
||||
\f[B]Interactive Mode\f[R] : When you run \f[B]cline\f[R] without
|
||||
arguments, it launches an interactive welcome prompt with a rich
|
||||
terminal UI.
|
||||
You can type your task, view conversation history, and interact with
|
||||
Cline in real\-time.
|
||||
.PP
|
||||
\f[B]Task Mode\f[R] : Run \f[B]cline \(lqprompt\(rq\f[R] or \f[B]cline
|
||||
task \(lqprompt\(rq\f[R] to immediately start a task.
|
||||
If stdin is a TTY, you\(cqll see the interactive UI.
|
||||
If stdin is piped or output is redirected, the CLI automatically
|
||||
switches to plain text mode.
|
||||
.PP
|
||||
\f[B]Plain Text Mode\f[R] : Activated automatically when stdin is piped,
|
||||
output is redirected, or \f[B]\-\-json\f[R]/\f[B]\-\-yolo\f[R] flags are
|
||||
used.
|
||||
Outputs clean text without the Ink UI, suitable for scripting and CI/CD
|
||||
pipelines.
|
||||
.SH AGENT BEHAVIOR
|
||||
Cline operates in two primary modes:
|
||||
.PP
|
||||
\f[B]ACT MODE\f[R] : Cline actively uses tools to accomplish tasks.
|
||||
He can read files, write code, execute commands, use a headless browser,
|
||||
and more.
|
||||
This is the default mode for task execution.
|
||||
.PP
|
||||
\f[B]PLAN MODE\f[R] : Cline gathers information and creates a detailed
|
||||
plan before implementation.
|
||||
He explores the codebase, asks clarifying questions, and presents a
|
||||
strategy for user approval before switching to ACT MODE.
|
||||
.SH COMMANDS
|
||||
.SS task (alias: t)
|
||||
Run a new task with a prompt.
|
||||
.PP
|
||||
\f[B]cline task\f[R] \f[I]prompt\f[R] [\f[I]options\f[R]]
|
||||
.PP
|
||||
\f[B]cline t\f[R] \f[I]prompt\f[R] [\f[I]options\f[R]] : Create and run
|
||||
a new task.
|
||||
Options:
|
||||
.PP
|
||||
\f[B]\-a\f[R], \f[B]\-\-act\f[R] : Run in act mode (default)
|
||||
.PP
|
||||
\f[B]\-p\f[R], \f[B]\-\-plan\f[R] : Run in plan mode
|
||||
.PP
|
||||
\f[B]\-y\f[R], \f[B]\-\-yolo\f[R] : Enable yolo/yes mode (auto\-approve
|
||||
all actions, output in plain mode, exit process automatically when task
|
||||
complete)
|
||||
.PP
|
||||
\f[B]\-m\f[R], \f[B]\-\-model\f[R] \f[I]model\f[R] : Model to use for
|
||||
the task
|
||||
.PP
|
||||
\f[B]\-i\f[R], \f[B]\-\-images\f[R] \f[I]paths\&...\f[R] : Image file
|
||||
paths to include with the task
|
||||
.PP
|
||||
\f[B]\-v\f[R], \f[B]\-\-verbose\f[R] : Show verbose output including
|
||||
reasoning
|
||||
.PP
|
||||
\f[B]\-c\f[R], \f[B]\-\-cwd\f[R] \f[I]path\f[R] : Working directory for
|
||||
the task
|
||||
.PP
|
||||
\f[B]\-\-config\f[R] \f[I]path\f[R] : Path to Cline configuration
|
||||
directory
|
||||
.PP
|
||||
\f[B]\-\-thinking\f[R] : Enable extended thinking (1024 token budget)
|
||||
.PP
|
||||
\f[B]\-\-json\f[R] : Output messages as JSON instead of styled text
|
||||
.SS history (alias: h)
|
||||
List task history with pagination.
|
||||
.PP
|
||||
\f[B]cline history\f[R] [\f[I]options\f[R]]
|
||||
.PP
|
||||
\f[B]cline h\f[R] [\f[I]options\f[R]] : Display previous tasks.
|
||||
Options:
|
||||
.PP
|
||||
\f[B]\-n\f[R], \f[B]\-\-limit\f[R] \f[I]number\f[R] : Number of tasks to
|
||||
show (default: 10)
|
||||
.PP
|
||||
\f[B]\-p\f[R], \f[B]\-\-page\f[R] \f[I]number\f[R] : Page number,
|
||||
1\-based (default: 1)
|
||||
.PP
|
||||
\f[B]\-\-config\f[R] \f[I]path\f[R] : Path to Cline configuration
|
||||
directory
|
||||
.SS config
|
||||
Show current configuration.
|
||||
.PP
|
||||
\f[B]cline config\f[R] [\f[I]options\f[R]] : Display global and
|
||||
workspace state.
|
||||
Options:
|
||||
.PP
|
||||
\f[B]\-\-config\f[R] \f[I]path\f[R] : Path to Cline configuration
|
||||
directory
|
||||
.SS auth
|
||||
Authenticate a provider and configure the model.
|
||||
.PP
|
||||
\f[B]cline auth\f[R] [\f[I]options\f[R]] : Launch interactive
|
||||
authentication wizard, or use quick setup flags.
|
||||
Options:
|
||||
.PP
|
||||
\f[B]\-p\f[R], \f[B]\-\-provider\f[R] \f[I]id\f[R] : Provider ID for
|
||||
quick setup (e.g., openai\-native, anthropic, openrouter)
|
||||
.PP
|
||||
\f[B]\-k\f[R], \f[B]\-\-apikey\f[R] \f[I]key\f[R] : API key for the
|
||||
provider
|
||||
.PP
|
||||
\f[B]\-m\f[R], \f[B]\-\-modelid\f[R] \f[I]id\f[R] : Model ID to
|
||||
configure (e.g., gpt\-4o, claude\-sonnet\-4\-5\-20250929)
|
||||
.PP
|
||||
\f[B]\-b\f[R], \f[B]\-\-baseurl\f[R] \f[I]url\f[R] : Base URL (optional,
|
||||
for OpenAI\-compatible providers)
|
||||
.PP
|
||||
\f[B]\-v\f[R], \f[B]\-\-verbose\f[R] : Show verbose output
|
||||
.PP
|
||||
\f[B]\-c\f[R], \f[B]\-\-cwd\f[R] \f[I]path\f[R] : Working directory
|
||||
.PP
|
||||
\f[B]\-\-config\f[R] \f[I]path\f[R] : Path to Cline configuration
|
||||
directory
|
||||
.SS update
|
||||
Check for updates and install if available.
|
||||
.PP
|
||||
\f[B]cline update\f[R] [\f[I]options\f[R]] : Check npm for newer
|
||||
versions.
|
||||
Options:
|
||||
.PP
|
||||
\f[B]\-v\f[R], \f[B]\-\-verbose\f[R] : Show verbose output
|
||||
.SS version
|
||||
Show the CLI version number.
|
||||
.PP
|
||||
\f[B]cline version\f[R]
|
||||
.SS dev
|
||||
Developer tools and utilities.
|
||||
.PP
|
||||
\f[B]cline dev log\f[R] : Open the log file for debugging.
|
||||
.SH DEFAULT COMMAND OPTIONS
|
||||
When running \f[B]cline\f[R] with just a prompt (no subcommand), these
|
||||
options are available:
|
||||
.PP
|
||||
\f[B]\-a\f[R], \f[B]\-\-act\f[R] : Run in act mode (default)
|
||||
.PP
|
||||
\f[B]\-p\f[R], \f[B]\-\-plan\f[R] : Run in plan mode
|
||||
.PP
|
||||
\f[B]\-y\f[R], \f[B]\-\-yolo\f[R] : Enable yolo mode (auto\-approve all
|
||||
actions).
|
||||
Also forces plain text output mode.
|
||||
.PP
|
||||
\f[B]\-m\f[R], \f[B]\-\-model\f[R] \f[I]model\f[R] : Model to use for
|
||||
the task
|
||||
.PP
|
||||
\f[B]\-v\f[R], \f[B]\-\-verbose\f[R] : Show verbose output
|
||||
.PP
|
||||
\f[B]\-c\f[R], \f[B]\-\-cwd\f[R] \f[I]path\f[R] : Working directory
|
||||
.PP
|
||||
\f[B]\-\-config\f[R] \f[I]path\f[R] : Configuration directory
|
||||
.PP
|
||||
\f[B]\-\-thinking\f[R] : Enable extended thinking (1024 token budget)
|
||||
.PP
|
||||
\f[B]\-\-json\f[R] : Output messages as JSON instead of styled text.
|
||||
Forces plain text mode.
|
||||
.SH JSON OUTPUT FORMAT
|
||||
When using \f[B]\-\-json\f[R], each message is output as a JSON object
|
||||
with these fields:
|
||||
.PP
|
||||
\f[B]Required fields:\f[R]
|
||||
.IP \(bu 2
|
||||
\f[B]type\f[R]: \(lqask\(rq or \(lqsay\(rq
|
||||
.IP \(bu 2
|
||||
\f[B]text\f[R]: message text
|
||||
.IP \(bu 2
|
||||
\f[B]ts\f[R]: Unix epoch timestamp in milliseconds
|
||||
.PP
|
||||
\f[B]Optional fields:\f[R]
|
||||
.IP \(bu 2
|
||||
\f[B]reasoning\f[R]: reasoning text
|
||||
.IP \(bu 2
|
||||
\f[B]say\f[R]: say subtype (when type is \(lqsay\(rq)
|
||||
.IP \(bu 2
|
||||
\f[B]ask\f[R]: ask subtype (when type is \(lqask\(rq)
|
||||
.IP \(bu 2
|
||||
\f[B]partial\f[R]: streaming flag
|
||||
.IP \(bu 2
|
||||
\f[B]images\f[R]: list of image URIs
|
||||
.IP \(bu 2
|
||||
\f[B]files\f[R]: list of file paths
|
||||
.SH EXAMPLES
|
||||
.SS Basic Usage
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# Launch interactive mode\f[R]
|
||||
cline
|
||||
|
||||
\f[I]# Run a task directly\f[R]
|
||||
cline \(dqCreate a hello world function in Python\(dq
|
||||
|
||||
\f[I]# Run with verbose output and extended thinking\f[R]
|
||||
cline \-v \-\-thinking \(dqAnalyze this codebase architecture\(dq
|
||||
.EE
|
||||
.SS Mode Selection
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# Run in plan mode (gather info before acting)\f[R]
|
||||
cline \-p \(dqDesign a REST API for user management\(dq
|
||||
|
||||
\f[I]# Run in act mode with auto\-approval (yolo)\f[R]
|
||||
cline \-y \(dqFix the typo in README.md\(dq
|
||||
.EE
|
||||
.SS Using Specific Models
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# Use a specific model\f[R]
|
||||
cline \-m claude\-sonnet\-4\-5\-20250929 \(dqRefactor this function\(dq
|
||||
|
||||
\f[I]# Quick auth setup with model\f[R]
|
||||
cline auth \-p anthropic \-k sk\-ant\-xxxxx \-m claude\-sonnet\-4\-5\-20250929
|
||||
.EE
|
||||
.SS Including Images
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# Include images with explicit flag\f[R]
|
||||
cline task \-i screenshot.png diagram.jpg \(dqFix the UI based on these images\(dq
|
||||
|
||||
\f[I]# Or use inline image references in the prompt\f[R]
|
||||
cline \(dqFix the layout shown in \(at./screenshot.png\(dq
|
||||
.EE
|
||||
.SS Piped Input
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# Pipe file contents to Cline\f[R]
|
||||
cat README.md \f[B]|\f[R] cline \(dqSummarize this document\(dq
|
||||
|
||||
\f[I]# Pipe with additional prompt\f[R]
|
||||
echo \(dqfunction add(a, b) { return a + b }\(dq \f[B]|\f[R] cline \(dqAdd TypeScript types to this\(dq
|
||||
|
||||
\f[I]# Combine piped input with a prompt\f[R]
|
||||
git diff \f[B]|\f[R] cline \(dqReview these changes and suggest improvements\(dq
|
||||
.EE
|
||||
.SS Scripting and Automation
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# JSON output for parsing\f[R]
|
||||
cline \-\-json \(dqWhat files are in this directory?\(dq \f[B]|\f[R] jq \(aq.text\(aq
|
||||
|
||||
\f[I]# Yolo mode for automated workflows (auto\-approves all actions), forces plain text output\f[R]
|
||||
cline \-y \(dqRun the test suite and fix any failures\(dq
|
||||
.EE
|
||||
.SS Task History
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# List recent tasks\f[R]
|
||||
cline history
|
||||
|
||||
\f[I]# Show more tasks with pagination\f[R]
|
||||
cline history \-n 20 \-p 2
|
||||
.EE
|
||||
.SS Authentication
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# Interactive authentication wizard\f[R]
|
||||
cline auth
|
||||
|
||||
\f[I]# Quick setup for Anthropic\f[R]
|
||||
cline auth \-p anthropic \-k sk\-ant\-api\-xxxxx
|
||||
|
||||
\f[I]# Quick setup for OpenAI\f[R]
|
||||
cline auth \-p openai\-native \-k sk\-xxxxx \-m gpt\-4o
|
||||
|
||||
\f[I]# OpenAI\-compatible provider with custom base URL\f[R]
|
||||
cline auth \-p openai \-k your\-api\-key \-b https://api.example.com/v1
|
||||
.EE
|
||||
.SH ENVIRONMENT
|
||||
\f[B]CLINE_DIR\f[R] : Override the default configuration directory.
|
||||
When set, Cline stores all data in this directory instead of
|
||||
\f[CR]\(ti/.cline/data/\f[R].
|
||||
.PP
|
||||
\f[B]CLINE_COMMAND_PERMISSIONS\f[R] : JSON configuration for restricting
|
||||
which shell commands Cline can execute.
|
||||
When set, commands are validated against allow/deny patternks before
|
||||
execution.
|
||||
When not set, all commands are allowed.
|
||||
.PP
|
||||
Format:
|
||||
\f[CR]{\(dqallow\(dq: [\(dqpattern1\(dq, \(dqpattern2\(dq], \(dqdeny\(dq: [\(dqpattern3\(dq], \(dqallowRedirects\(dq: true}\f[R]
|
||||
.PP
|
||||
\f[B]Fields:\f[R]
|
||||
.IP \(bu 2
|
||||
\f[B]allow\f[R] (array of strings): Glob patterns for allowed commands.
|
||||
If specified, only matching commands are permitted.
|
||||
Uses \f[CR]*\f[R] to match any characters and \f[CR]?\f[R] to match a
|
||||
single character.
|
||||
Setting allow on anything will deny all others.
|
||||
.IP \(bu 2
|
||||
\f[B]deny\f[R] (array of strings): Glob patterns for denied commands.
|
||||
Deny rules take precedence over allow rules.
|
||||
.IP \(bu 2
|
||||
\f[B]allowRedirects\f[R] (boolean): Whether to allow shell redirects
|
||||
(\f[CR]>\f[R], \f[CR]>>\f[R], \f[CR]<\f[R], etc.).
|
||||
Defaults to false.
|
||||
.PP
|
||||
\f[B]Rule evaluation:\f[R]
|
||||
.IP "1." 3
|
||||
Check for dangerous characters (backticks outside single quotes,
|
||||
unquoted newlines)
|
||||
.IP "2." 3
|
||||
Parse command into segments split by operators (\f[CR]&&\f[R],
|
||||
\f[CR]||\f[R], \f[CR]|\f[R], \f[CR];\f[R])
|
||||
.IP "3." 3
|
||||
If redirects detected and \f[CR]allowRedirects\f[R] is not true, command
|
||||
is denied
|
||||
.IP "4." 3
|
||||
Each segment is validated against deny rules first, then allow rules
|
||||
.IP "5." 3
|
||||
Subshell contents (\f[CR]$(...)\f[R] and \f[CR](...)\f[R]) are
|
||||
recursively validated
|
||||
.IP "6." 3
|
||||
All segments must pass for the command to be allowed
|
||||
.PP
|
||||
\f[B]Examples:\f[R]
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# Allow only npm and git commands.\f[R]
|
||||
export CLINE_COMMAND_PERMISSIONS=\(aq{\(dqallow\(dq: [\(dqnpm *\(dq, \(dqgit *\(dq]}\(aq
|
||||
|
||||
\f[I]# Allow development commands but deny dangerous ones. Deny not strictly required here since allow is set.\f[R]
|
||||
export CLINE_COMMAND_PERMISSIONS=\(aq{\(dqallow\(dq: [\(dqnpm *\(dq, \(dqgit *\(dq, \(dqnode *\(dq], \(dqdeny\(dq: [\(dqrm \-rf *\(dq, \(dqsudo *\(dq]}\(aq
|
||||
|
||||
\f[I]# Allow file operations with redirects\f[R]
|
||||
export CLINE_COMMAND_PERMISSIONS=\(aq{\(dqallow\(dq: [\(dqcat *\(dq, \(dqecho *\(dq], \(dqallowRedirects\(dq: true}\(aq
|
||||
.EE
|
||||
.SH FILES
|
||||
\f[B]\(ti/.cline/data/\f[R] : Default configuration directory
|
||||
containing:
|
||||
.PP
|
||||
\f[B]globalState.json\f[R] : Global settings and state
|
||||
.PP
|
||||
\f[B]secrets.json\f[R] : API keys and secrets (stored securely)
|
||||
.PP
|
||||
\f[B]workspace/\f[R] : Workspace\-specific state
|
||||
.PP
|
||||
\f[B]tasks/\f[R] : Task history and conversation data
|
||||
.PP
|
||||
\f[B]\(ti/.cline/log/\f[R] : Log files for debugging.
|
||||
View with \f[CR]cline dev log\f[R].
|
||||
.SH BUGS
|
||||
Report bugs at: \c
|
||||
.UR https://github.com/cline/cline/issues
|
||||
.UE \c
|
||||
.PP
|
||||
For real\-time help, join the Discord community at: \c
|
||||
.UR https://discord.gg/cline
|
||||
.UE \c
|
||||
.SH SEE ALSO
|
||||
Full documentation: \c
|
||||
.UR https://docs.cline.bot
|
||||
.UE \c
|
||||
.PP
|
||||
VS Code extension: \c
|
||||
.UR https://marketplace.visualstudio.com/items?itemName=saoudrizwan.claude-dev
|
||||
.UE \c
|
||||
.SH AUTHORS
|
||||
Cline is developed by Cline Bot Inc.\ and the open source community.
|
||||
.SH COPYRIGHT
|
||||
Copyright © 2025 Cline Bot Inc.\ Licensed under the Apache License 2.0.
|
||||
@@ -1,340 +0,0 @@
|
||||
---
|
||||
title: CLINE
|
||||
section: 1
|
||||
header: User Commands
|
||||
footer: Cline CLI 2.0
|
||||
date: January 2026
|
||||
---
|
||||
|
||||
# NAME
|
||||
|
||||
cline - AI coding assistant in your terminal
|
||||
|
||||
# SYNOPSIS
|
||||
|
||||
**cline** [*prompt*] [*options*]
|
||||
|
||||
**cline** *command* [*options*] [*arguments*]
|
||||
|
||||
# DESCRIPTION
|
||||
|
||||
**cline** is a command-line interface for the Cline AI coding assistant. It provides the same powerful AI capabilities as the VS Code extension, directly in your terminal.
|
||||
|
||||
Cline is an autonomous AI agent that can read, write, and execute code across your projects. He can create and edit files, run terminal commands, use a headless browser, and more—all while asking for your approval before taking actions.
|
||||
|
||||
The CLI supports both interactive mode (with a rich terminal UI) and plain text mode (for piped input and scripted workflows).
|
||||
|
||||
# MODES OF OPERATION
|
||||
|
||||
**Interactive Mode** : When you run **cline** without arguments, it launches an interactive welcome prompt with a rich terminal UI. You can type your task, view conversation history, and interact with Cline in real-time.
|
||||
|
||||
**Task Mode** : Run **cline "prompt"** or **cline task "prompt"** to immediately start a task. If stdin is a TTY, you'll see the interactive UI. If stdin is piped or output is redirected, the CLI automatically switches to plain text mode.
|
||||
|
||||
**Plain Text Mode** : Activated automatically when stdin is piped, output is redirected, or **\--json**/**\--yolo** flags are used. Outputs clean text without the Ink UI, suitable for scripting and CI/CD pipelines.
|
||||
|
||||
# AGENT BEHAVIOR
|
||||
|
||||
Cline operates in two primary modes:
|
||||
|
||||
**ACT MODE** : Cline actively uses tools to accomplish tasks. He can read files, write code, execute commands, use a headless browser, and more. This is the default mode for task execution.
|
||||
|
||||
**PLAN MODE** : Cline gathers information and creates a detailed plan before implementation. He explores the codebase, asks clarifying questions, and presents a strategy for user approval before switching to ACT MODE.
|
||||
|
||||
# COMMANDS
|
||||
|
||||
## task (alias: t)
|
||||
|
||||
Run a new task with a prompt.
|
||||
|
||||
**cline task** *prompt* [*options*]
|
||||
|
||||
**cline t** *prompt* [*options*] : Create and run a new task. Options:
|
||||
|
||||
**-a**, **\--act** : Run in act mode (default)
|
||||
|
||||
**-p**, **\--plan** : Run in plan mode
|
||||
|
||||
**-y**, **\--yolo** : Enable yolo/yes mode (auto-approve all actions, output in plain mode, exit process automatically when task complete)
|
||||
|
||||
**-m**, **\--model** *model* : Model to use for the task
|
||||
|
||||
**-i**, **\--images** *paths...* : Image file paths to include with the task
|
||||
|
||||
**-v**, **\--verbose** : Show verbose output including reasoning
|
||||
|
||||
**-c**, **\--cwd** *path* : Working directory for the task
|
||||
|
||||
**\--config** *path* : Path to Cline configuration directory
|
||||
|
||||
**\--thinking** : Enable extended thinking (1024 token budget)
|
||||
|
||||
**\--json** : Output messages as JSON instead of styled text
|
||||
|
||||
## history (alias: h)
|
||||
|
||||
List task history with pagination.
|
||||
|
||||
**cline history** [*options*]
|
||||
|
||||
**cline h** [*options*] : Display previous tasks. Options:
|
||||
|
||||
**-n**, **\--limit** *number* : Number of tasks to show (default: 10)
|
||||
|
||||
**-p**, **\--page** *number* : Page number, 1-based (default: 1)
|
||||
|
||||
**\--config** *path* : Path to Cline configuration directory
|
||||
|
||||
## config
|
||||
|
||||
Show current configuration.
|
||||
|
||||
**cline config** [*options*] : Display global and workspace state. Options:
|
||||
|
||||
**\--config** *path* : Path to Cline configuration directory
|
||||
|
||||
## auth
|
||||
|
||||
Authenticate a provider and configure the model.
|
||||
|
||||
**cline auth** [*options*] : Launch interactive authentication wizard, or use quick setup flags. Options:
|
||||
|
||||
**-p**, **\--provider** *id* : Provider ID for quick setup (e.g., openai-native, anthropic, openrouter)
|
||||
|
||||
**-k**, **\--apikey** *key* : API key for the provider
|
||||
|
||||
**-m**, **\--modelid** *id* : Model ID to configure (e.g., gpt-4o, claude-sonnet-4-5-20250929)
|
||||
|
||||
**-b**, **\--baseurl** *url* : Base URL (optional, for OpenAI-compatible providers)
|
||||
|
||||
**-v**, **\--verbose** : Show verbose output
|
||||
|
||||
**-c**, **\--cwd** *path* : Working directory
|
||||
|
||||
**\--config** *path* : Path to Cline configuration directory
|
||||
|
||||
## update
|
||||
|
||||
Check for updates and install if available.
|
||||
|
||||
**cline update** [*options*] : Check npm for newer versions. Options:
|
||||
|
||||
**-v**, **\--verbose** : Show verbose output
|
||||
|
||||
## version
|
||||
|
||||
Show the CLI version number.
|
||||
|
||||
**cline version**
|
||||
|
||||
## dev
|
||||
|
||||
Developer tools and utilities.
|
||||
|
||||
**cline dev log** : Open the log file for debugging.
|
||||
|
||||
# DEFAULT COMMAND OPTIONS
|
||||
|
||||
When running **cline** with just a prompt (no subcommand), these options are available:
|
||||
|
||||
**-a**, **\--act** : Run in act mode (default)
|
||||
|
||||
**-p**, **\--plan** : Run in plan mode
|
||||
|
||||
**-y**, **\--yolo** : Enable yolo mode (auto-approve all actions). Also forces plain text output mode.
|
||||
|
||||
**-m**, **\--model** *model* : Model to use for the task
|
||||
|
||||
**-v**, **\--verbose** : Show verbose output
|
||||
|
||||
**-c**, **\--cwd** *path* : Working directory
|
||||
|
||||
**\--config** *path* : Configuration directory
|
||||
|
||||
**\--thinking** : Enable extended thinking (1024 token budget)
|
||||
|
||||
**\--json** : Output messages as JSON instead of styled text. Forces plain text mode.
|
||||
|
||||
# JSON OUTPUT FORMAT
|
||||
|
||||
When using **\--json**, each message is output as a JSON object with these fields:
|
||||
|
||||
**Required fields:**
|
||||
|
||||
- **type**: "ask" or "say"
|
||||
- **text**: message text
|
||||
- **ts**: Unix epoch timestamp in milliseconds
|
||||
|
||||
**Optional fields:**
|
||||
|
||||
- **reasoning**: reasoning text
|
||||
- **say**: say subtype (when type is "say")
|
||||
- **ask**: ask subtype (when type is "ask")
|
||||
- **partial**: streaming flag
|
||||
- **images**: list of image URIs
|
||||
- **files**: list of file paths
|
||||
|
||||
# EXAMPLES
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```bash
|
||||
# Launch interactive mode
|
||||
cline
|
||||
|
||||
# Run a task directly
|
||||
cline "Create a hello world function in Python"
|
||||
|
||||
# Run with verbose output and extended thinking
|
||||
cline -v --thinking "Analyze this codebase architecture"
|
||||
```
|
||||
|
||||
## Mode Selection
|
||||
|
||||
```bash
|
||||
# Run in plan mode (gather info before acting)
|
||||
cline -p "Design a REST API for user management"
|
||||
|
||||
# Run in act mode with auto-approval (yolo)
|
||||
cline -y "Fix the typo in README.md"
|
||||
```
|
||||
|
||||
## Using Specific Models
|
||||
|
||||
```bash
|
||||
# Use a specific model
|
||||
cline -m claude-sonnet-4-5-20250929 "Refactor this function"
|
||||
|
||||
# Quick auth setup with model
|
||||
cline auth -p anthropic -k sk-ant-xxxxx -m claude-sonnet-4-5-20250929
|
||||
```
|
||||
|
||||
## Including Images
|
||||
|
||||
```bash
|
||||
# Include images with explicit flag
|
||||
cline task -i screenshot.png diagram.jpg "Fix the UI based on these images"
|
||||
|
||||
# Or use inline image references in the prompt
|
||||
cline "Fix the layout shown in @./screenshot.png"
|
||||
```
|
||||
|
||||
## Piped Input
|
||||
|
||||
```bash
|
||||
# Pipe file contents to Cline
|
||||
cat README.md | cline "Summarize this document"
|
||||
|
||||
# Pipe with additional prompt
|
||||
echo "function add(a, b) { return a + b }" | cline "Add TypeScript types to this"
|
||||
|
||||
# Combine piped input with a prompt
|
||||
git diff | cline "Review these changes and suggest improvements"
|
||||
```
|
||||
|
||||
## Scripting and Automation
|
||||
|
||||
```bash
|
||||
# JSON output for parsing
|
||||
cline --json "What files are in this directory?" | jq '.text'
|
||||
|
||||
# Yolo mode for automated workflows (auto-approves all actions), forces plain text output
|
||||
cline -y "Run the test suite and fix any failures"
|
||||
```
|
||||
|
||||
## Task History
|
||||
|
||||
```bash
|
||||
# List recent tasks
|
||||
cline history
|
||||
|
||||
# Show more tasks with pagination
|
||||
cline history -n 20 -p 2
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
```bash
|
||||
# Interactive authentication wizard
|
||||
cline auth
|
||||
|
||||
# Quick setup for Anthropic
|
||||
cline auth -p anthropic -k sk-ant-api-xxxxx
|
||||
|
||||
# Quick setup for OpenAI
|
||||
cline auth -p openai-native -k sk-xxxxx -m gpt-4o
|
||||
|
||||
# OpenAI-compatible provider with custom base URL
|
||||
cline auth -p openai -k your-api-key -b https://api.example.com/v1
|
||||
```
|
||||
|
||||
# ENVIRONMENT
|
||||
|
||||
**CLINE_DIR** : Override the default configuration directory. When set, Cline stores all data in this directory instead of `~/.cline/data/`.
|
||||
|
||||
**CLINE_COMMAND_PERMISSIONS** : JSON configuration for restricting which shell commands Cline can execute. When set, commands are validated against allow/deny patternks before execution. When not set, all commands are allowed.
|
||||
|
||||
Format: `{"allow": ["pattern1", "pattern2"], "deny": ["pattern3"], "allowRedirects": true}`
|
||||
|
||||
**Fields:**
|
||||
|
||||
- **allow** (array of strings): Glob patterns for allowed commands. If specified, only matching commands are permitted. Uses `*` to match any characters and `?` to match a single character. Setting allow on anything will deny all others.
|
||||
- **deny** (array of strings): Glob patterns for denied commands. Deny rules take precedence over allow rules.
|
||||
- **allowRedirects** (boolean): Whether to allow shell redirects (`>`, `>>`, `<`, etc.). Defaults to false.
|
||||
|
||||
**Rule evaluation:**
|
||||
|
||||
1. Check for dangerous characters (backticks outside single quotes, unquoted newlines)
|
||||
2. Parse command into segments split by operators (`&&`, `||`, `|`, `;`)
|
||||
3. If redirects detected and `allowRedirects` is not true, command is denied
|
||||
4. Each segment is validated against deny rules first, then allow rules
|
||||
5. Subshell contents (`$(...)` and `(...)`) are recursively validated
|
||||
6. All segments must pass for the command to be allowed
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Allow only npm and git commands.
|
||||
export CLINE_COMMAND_PERMISSIONS='{"allow": ["npm *", "git *"]}'
|
||||
|
||||
# Allow development commands but deny dangerous ones. Deny not strictly required here since allow is set.
|
||||
export CLINE_COMMAND_PERMISSIONS='{"allow": ["npm *", "git *", "node *"], "deny": ["rm -rf *", "sudo *"]}'
|
||||
|
||||
# Allow file operations with redirects
|
||||
export CLINE_COMMAND_PERMISSIONS='{"allow": ["cat *", "echo *"], "allowRedirects": true}'
|
||||
```
|
||||
|
||||
|
||||
# CONFIGURATION FILES
|
||||
|
||||
```
|
||||
~/.cline/
|
||||
├── data/ # Default configuration directory
|
||||
│ ├── globalState.json # Global settings and state
|
||||
│ ├── secrets.json # API keys and secrets (stored securely)
|
||||
│ ├── workspace/ # Workspace-specific state
|
||||
│ └── tasks/ # Task history and conversation data
|
||||
└── log/ # Log files for debugging
|
||||
```
|
||||
|
||||
View logs with `cline dev log`.
|
||||
|
||||
|
||||
# BUGS
|
||||
|
||||
Report bugs at: <https://github.com/cline/cline/issues>
|
||||
|
||||
For real-time help, join the Discord community at: <https://discord.gg/cline>
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
Full documentation: <https://docs.cline.bot>
|
||||
|
||||
VS Code extension: <https://marketplace.visualstudio.com/items?itemName=saoudrizwan.claude-dev>
|
||||
|
||||
# AUTHORS
|
||||
|
||||
Cline is developed by Cline Bot Inc. and the open source community.
|
||||
|
||||
# COPYRIGHT
|
||||
|
||||
Copyright © 2025 Cline Bot Inc. Licensed under the Apache License 2.0.
|
||||
Generated
-2950
File diff suppressed because it is too large
Load Diff
@@ -1,81 +0,0 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"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": {
|
||||
"cline": "./dist/cli.mjs"
|
||||
},
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"prepublishOnly": "npm run build:production",
|
||||
"package:brew": "node ./scripts/update-brew-formula.mts",
|
||||
"package": "npm pack --pack-destination ./dist",
|
||||
"build": "node esbuild.mts",
|
||||
"build:production": "node esbuild.mts --production",
|
||||
"watch": "node esbuild.mts --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",
|
||||
"claude",
|
||||
"dev",
|
||||
"mcp",
|
||||
"openrouter",
|
||||
"coding",
|
||||
"agent",
|
||||
"autonomous",
|
||||
"chatgpt",
|
||||
"sonnet",
|
||||
"ai",
|
||||
"llama",
|
||||
"cli"
|
||||
],
|
||||
"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": "^19.2.9",
|
||||
"dotenv": "^16.4.5",
|
||||
"esbuild": "^0.25.0",
|
||||
"ink-testing-library": "^4.0.0",
|
||||
"rimraf": "^6.0.1",
|
||||
"typescript": "^5.4.5",
|
||||
"vitest": "^4.0.17"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.13.1",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.1.0",
|
||||
"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": "^19.2.3"
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execSync } from "node:child_process"
|
||||
import { createHash } from "node:crypto"
|
||||
import { readFile, unlink, writeFile } from "node:fs/promises"
|
||||
import { dirname, join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
const CLI_DIR = join(__dirname, "..")
|
||||
const FORMULA_PATH = join(CLI_DIR, "cline.rb")
|
||||
|
||||
interface PackageJson {
|
||||
version: string
|
||||
}
|
||||
|
||||
async function getLocalVersion(): Promise<string> {
|
||||
const packageJson = JSON.parse(await readFile(join(CLI_DIR, "package.json"), "utf-8")) as PackageJson
|
||||
return packageJson.version
|
||||
}
|
||||
|
||||
async function packAndGetSHA256(version: string): Promise<string> {
|
||||
console.log("Packing local package...")
|
||||
execSync("npm run package", { cwd: CLI_DIR, stdio: "inherit" })
|
||||
|
||||
const tarballPath = join(CLI_DIR, "dist", `cline-cli-${version}.tgz`)
|
||||
console.log(`Computing SHA256 for ${tarballPath}...`)
|
||||
|
||||
const buffer = await readFile(tarballPath)
|
||||
const sha256 = createHash("sha256").update(buffer).digest("hex")
|
||||
|
||||
// Clean up the tarball
|
||||
await unlink(tarballPath)
|
||||
|
||||
return sha256
|
||||
}
|
||||
|
||||
async function updateFormula(version: string, sha256: string) {
|
||||
console.log("Updating Homebrew formula...")
|
||||
|
||||
let formula = await readFile(FORMULA_PATH, "utf-8")
|
||||
|
||||
const tarballUrl = `https://registry.npmjs.org/cline/-/cline-${version}.tgz`
|
||||
|
||||
// Update URL - matches pattern like: url "https://registry.npmjs.org/cline/-/cline-1.0.10.tgz"
|
||||
formula = formula.replace(/url "https:\/\/registry\.npmjs\.org\/cline\/-\/cline-[\d.]+\.tgz"/, `url "${tarballUrl}"`)
|
||||
|
||||
// Update SHA256
|
||||
formula = formula.replace(/sha256 "[a-f0-9]+"/, `sha256 "${sha256}"`)
|
||||
|
||||
await writeFile(FORMULA_PATH, formula, "utf-8")
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const version = await getLocalVersion()
|
||||
console.log(`\nLocal version: ${version}`)
|
||||
|
||||
const sha256 = await packAndGetSHA256(version)
|
||||
console.log(`SHA256: ${sha256}`)
|
||||
|
||||
const tarballUrl = `https://registry.npmjs.org/cline/-/cline-${version}.tgz`
|
||||
console.log(`Tarball URL: ${tarballUrl}`)
|
||||
|
||||
await updateFormula(version, sha256)
|
||||
|
||||
console.log("\n✓ Homebrew formula updated successfully!")
|
||||
console.log("\nNext steps:")
|
||||
console.log("1. Review the changes in cline.rb")
|
||||
console.log("2. Test locally: brew install --build-from-source ./cline.rb")
|
||||
console.log("3. Commit and push to your homebrew tap repository")
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
console.error(`\n✗ Error: ${errorMessage}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -1,248 +0,0 @@
|
||||
/**
|
||||
* ACP-based implementation of DiffViewProvider that uses the ACP client's
|
||||
* filesystem capabilities for reading and writing files.
|
||||
*
|
||||
* This provider attempts to use the ACP client's fs/read_text_file and
|
||||
* fs/write_text_file methods when available, falling back to the
|
||||
* FileEditProvider's local filesystem implementation otherwise.
|
||||
*
|
||||
* @module acp
|
||||
*/
|
||||
|
||||
import type * as acp from "@agentclientprotocol/sdk"
|
||||
import { workspaceResolver } from "@core/workspace"
|
||||
import { createDirectoriesForFile } from "@utils/fs"
|
||||
import { getCwd } from "@utils/path"
|
||||
import * as fs from "fs/promises"
|
||||
import * as iconv from "iconv-lite"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { FileEditProvider } from "@/integrations/editor/FileEditProvider"
|
||||
import { detectEncoding } from "@/integrations/misc/extract-text"
|
||||
import type { FileDiagnostics } from "@/shared/proto/index.cline"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
|
||||
/**
|
||||
* A function that resolves the current session ID.
|
||||
* This is used by ACPDiffViewProvider to get the session ID at runtime,
|
||||
* since the provider may be created before a session exists.
|
||||
*/
|
||||
export type SessionIdResolver = () => string | undefined
|
||||
|
||||
/**
|
||||
* A DiffViewProvider implementation that uses the ACP client's filesystem
|
||||
* capabilities when available, with fallback to local filesystem operations.
|
||||
*
|
||||
* This class extends FileEditProvider and overrides the file I/O methods to
|
||||
* use the ACP protocol's fs/read_text_file and fs/write_text_file requests
|
||||
* when the client supports these capabilities. This allows the editor (client)
|
||||
* to handle file operations, which enables features like:
|
||||
* - Reading unsaved editor state
|
||||
* - Tracking file modifications in the editor
|
||||
* - Proper integration with the client's undo/redo stack
|
||||
*/
|
||||
export class ACPDiffViewProvider extends FileEditProvider {
|
||||
private readonly connection: acp.AgentSideConnection
|
||||
private readonly clientCapabilities: acp.ClientCapabilities | undefined
|
||||
private readonly sessionIdResolver: SessionIdResolver
|
||||
|
||||
/**
|
||||
* Creates a new ACPDiffViewProvider.
|
||||
*
|
||||
* @param connection - The ACP agent-side connection for making requests
|
||||
* @param clientCapabilities - The client's advertised capabilities
|
||||
* @param sessionIdResolver - A function that returns the current session ID
|
||||
*/
|
||||
constructor(
|
||||
connection: acp.AgentSideConnection,
|
||||
clientCapabilities: acp.ClientCapabilities | undefined,
|
||||
sessionIdResolver: SessionIdResolver,
|
||||
) {
|
||||
super()
|
||||
this.connection = connection
|
||||
this.clientCapabilities = clientCapabilities
|
||||
this.sessionIdResolver = sessionIdResolver
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current session ID, or throws if no session is active.
|
||||
*/
|
||||
private getSessionId(): string {
|
||||
const sessionId = this.sessionIdResolver()
|
||||
if (!sessionId) {
|
||||
throw new Error("No active ACP session. Cannot perform file operation.")
|
||||
}
|
||||
return sessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the client supports file read operations.
|
||||
*/
|
||||
private canReadFile(): boolean {
|
||||
return this.clientCapabilities?.fs?.readTextFile === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the client supports file write operations.
|
||||
*/
|
||||
private canWriteFile(): boolean {
|
||||
return this.clientCapabilities?.fs?.writeTextFile === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a file for editing, using ACP fs capabilities when available.
|
||||
*
|
||||
* If the client supports fs/read_text_file, this method will read the file
|
||||
* content via the ACP connection, which may include unsaved editor state.
|
||||
* Otherwise, it falls back to the FileEditProvider's local fs implementation.
|
||||
*/
|
||||
override async open(relPath: string, options?: { displayPath?: string }): Promise<void> {
|
||||
// If we can't read files via ACP, fall back to FileEditProvider
|
||||
if (!this.canReadFile()) {
|
||||
Logger.debug("[ACPDiffViewProvider] Client does not support fs.readTextFile, falling back to local fs")
|
||||
return super.open(relPath, options)
|
||||
}
|
||||
|
||||
// Set up state - this replicates the DiffViewProvider.open() logic
|
||||
// but uses ACP for file reading instead of local fs
|
||||
this.isEditing = true
|
||||
const cwd = await getCwd()
|
||||
const absolutePathResolved = workspaceResolver.resolveWorkspacePath(cwd, relPath, "ACPDiffViewProvider.open.absolutePath")
|
||||
this.absolutePath = typeof absolutePathResolved === "string" ? absolutePathResolved : absolutePathResolved.absolutePath
|
||||
this.relPath = options?.displayPath ?? relPath
|
||||
const fileExists = this.editType === "modify"
|
||||
|
||||
// Read file content
|
||||
if (fileExists) {
|
||||
// Try to save any dirty state in the editor first
|
||||
try {
|
||||
await HostProvider.workspace.saveOpenDocumentIfDirty({
|
||||
filePath: this.absolutePath!,
|
||||
})
|
||||
} catch {
|
||||
// Ignore errors - the host may not support this
|
||||
}
|
||||
|
||||
// Read file content via ACP
|
||||
try {
|
||||
Logger.debug("[ACPDiffViewProvider] Reading file via ACP:", this.absolutePath)
|
||||
|
||||
const response = await this.connection.readTextFile({
|
||||
sessionId: this.getSessionId(),
|
||||
path: this.absolutePath!,
|
||||
})
|
||||
|
||||
this.originalContent = response.content
|
||||
// ACP always returns UTF-8 text content
|
||||
this.fileEncoding = "utf8"
|
||||
|
||||
Logger.debug("[ACPDiffViewProvider] Read file successfully, length:", response.content.length)
|
||||
} catch (error) {
|
||||
// If ACP read fails, fall back to local fs
|
||||
Logger.debug("[ACPDiffViewProvider] ACP read failed, falling back to local fs:", error)
|
||||
|
||||
const fileBuffer = await fs.readFile(this.absolutePath!)
|
||||
this.fileEncoding = await detectEncoding(fileBuffer)
|
||||
this.originalContent = iconv.decode(fileBuffer, this.fileEncoding)
|
||||
}
|
||||
} else {
|
||||
this.originalContent = ""
|
||||
this.fileEncoding = "utf8"
|
||||
}
|
||||
|
||||
// Create directories for new files
|
||||
const createdDirs = await createDirectoriesForFile(this.absolutePath!)
|
||||
// Store for potential cleanup - access via the private field workaround
|
||||
;(this as any).createdDirs = createdDirs
|
||||
|
||||
// Make sure the file exists before we proceed
|
||||
if (!fileExists) {
|
||||
// For new files, write via ACP if possible, otherwise local fs
|
||||
if (this.canWriteFile()) {
|
||||
try {
|
||||
await this.connection.writeTextFile({
|
||||
sessionId: this.getSessionId(),
|
||||
path: this.absolutePath!,
|
||||
content: "",
|
||||
})
|
||||
} catch {
|
||||
// Fall back to local fs
|
||||
await fs.writeFile(this.absolutePath!, "")
|
||||
}
|
||||
} else {
|
||||
await fs.writeFile(this.absolutePath!, "")
|
||||
}
|
||||
}
|
||||
|
||||
// Get diagnostics before editing
|
||||
let preDiagnostics: FileDiagnostics[] = []
|
||||
try {
|
||||
preDiagnostics = (await HostProvider.workspace.getDiagnostics({})).fileDiagnostics
|
||||
} catch {
|
||||
preDiagnostics = []
|
||||
}
|
||||
;(this as any).preDiagnostics = preDiagnostics
|
||||
|
||||
// Call the parent's openDiffEditor to set up in-memory document content
|
||||
await this.openDiffEditor()
|
||||
await this.scrollEditorToLine(0)
|
||||
;(this as any).streamedLines = []
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls the editor to a specific line.
|
||||
* No-op for file-based providers, but needed for protected access.
|
||||
*/
|
||||
protected override async scrollEditorToLine(_line: number): Promise<void> {
|
||||
// No-op: No visual editor to scroll
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the diff editor.
|
||||
*/
|
||||
protected override async openDiffEditor(): Promise<void> {
|
||||
// Set up in-memory document content from the original content
|
||||
// no-op: No visual editor to open
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the document content, using ACP fs capabilities when available.
|
||||
*
|
||||
* If the client supports fs/write_text_file, this method will write the file
|
||||
* content via the ACP connection. Otherwise, it falls back to the
|
||||
* FileEditProvider's local fs implementation.
|
||||
*/
|
||||
protected override async saveDocument(): Promise<Boolean> {
|
||||
// If we can't write files via ACP, fall back to FileEditProvider
|
||||
if (!this.canWriteFile()) {
|
||||
Logger.debug("[ACPDiffViewProvider] Client does not support fs.writeTextFile, falling back to local fs")
|
||||
return super.saveDocument()
|
||||
}
|
||||
|
||||
const content = await this.getContent()
|
||||
if (!this.absolutePath || content === undefined) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
Logger.debug("[ACPDiffViewProvider] Writing file via ACP:", {
|
||||
path: this.absolutePath,
|
||||
contentLength: content.length,
|
||||
})
|
||||
|
||||
await this.connection.writeTextFile({
|
||||
sessionId: this.getSessionId(),
|
||||
path: this.absolutePath,
|
||||
content: content,
|
||||
})
|
||||
|
||||
Logger.debug("[ACPDiffViewProvider] Write file successfully")
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
// If ACP write fails, fall back to local fs
|
||||
Logger.debug("[ACPDiffViewProvider] ACP write failed, falling back to local fs:", error)
|
||||
|
||||
return super.saveDocument()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,402 +0,0 @@
|
||||
/**
|
||||
* ACP Host Bridge Client Provider
|
||||
*
|
||||
* Implements HostBridgeClientProvider for ACP mode, providing stub implementations
|
||||
* of the 4 required service clients. These clients conform to the interfaces in
|
||||
* host-bridge-client-types.ts and will use ACP connection capabilities where applicable.
|
||||
*
|
||||
* @module acp
|
||||
*/
|
||||
|
||||
import type * as acp from "@agentclientprotocol/sdk"
|
||||
import type {
|
||||
DiffServiceClientInterface,
|
||||
EnvServiceClientInterface,
|
||||
WindowServiceClientInterface,
|
||||
WorkspaceServiceClientInterface,
|
||||
} from "@generated/hosts/host-bridge-client-types"
|
||||
import type { HostBridgeClientProvider, StreamingCallbacks } from "@hosts/host-provider-types"
|
||||
import * as proto from "@shared/proto/index"
|
||||
import { ClineClient } from "@/shared/cline"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
|
||||
/**
|
||||
* Function type that resolves the current session ID.
|
||||
* Returns undefined if no session is active.
|
||||
*/
|
||||
export type SessionIdResolver = () => string | undefined
|
||||
|
||||
/**
|
||||
* Function type that resolves the current working directory.
|
||||
* Returns undefined if no cwd is available (will fall back to process.cwd()).
|
||||
*/
|
||||
export type CwdResolver = () => string | undefined
|
||||
|
||||
/**
|
||||
* ACP implementation of DiffService client.
|
||||
*
|
||||
* Handles diff operations for the ACP environment. Most operations are stubs
|
||||
* that will be implemented in the next phase using ACP extension methods or
|
||||
* the fs capabilities (readTextFile/writeTextFile).
|
||||
*/
|
||||
class ACPDiffServiceClient implements DiffServiceClientInterface {
|
||||
async openDiff(_request: proto.host.OpenDiffRequest): Promise<proto.host.OpenDiffResponse> {
|
||||
// Next phase: Could use ACP client capabilities to open a diff view in the editor.
|
||||
// This would involve sending an ACP extension notification/request to the client
|
||||
// to display a side-by-side diff of the original vs modified content.
|
||||
Logger.debug("[ACPDiffServiceClient] openDiff called (stub)")
|
||||
return proto.host.OpenDiffResponse.create({})
|
||||
}
|
||||
|
||||
async getDocumentText(request: proto.host.GetDocumentTextRequest): Promise<proto.host.GetDocumentTextResponse> {
|
||||
// Next phase: Use connection.readTextFile if clientCapabilities.fs.readTextFile is available.
|
||||
// This would read the current document content from the editor, including any unsaved changes.
|
||||
// For now, return empty content.
|
||||
Logger.debug("[ACPDiffServiceClient] getDocumentText called (stub)", { diffId: request.diffId })
|
||||
return proto.host.GetDocumentTextResponse.create({ content: "" })
|
||||
}
|
||||
|
||||
async replaceText(_request: proto.host.ReplaceTextRequest): Promise<proto.host.ReplaceTextResponse> {
|
||||
// Next phase: Use connection.writeTextFile if clientCapabilities.fs.writeTextFile is available.
|
||||
// This would replace text in the document at the specified range.
|
||||
Logger.debug("[ACPDiffServiceClient] replaceText called (stub)")
|
||||
return proto.host.ReplaceTextResponse.create({})
|
||||
}
|
||||
|
||||
async scrollDiff(_request: proto.host.ScrollDiffRequest): Promise<proto.host.ScrollDiffResponse> {
|
||||
// Next phase: Send ACP extension notification to scroll the diff view to a specific line.
|
||||
// No visual editor in ACP mode by default, so this is a no-op.
|
||||
Logger.debug("[ACPDiffServiceClient] scrollDiff called (stub)")
|
||||
return proto.host.ScrollDiffResponse.create({})
|
||||
}
|
||||
|
||||
async truncateDocument(_request: proto.host.TruncateDocumentRequest): Promise<proto.host.TruncateDocumentResponse> {
|
||||
// Next phase: Read file using readTextFile, truncate content, write back using writeTextFile.
|
||||
// This is used to truncate a document to a specific line count.
|
||||
Logger.debug("[ACPDiffServiceClient] truncateDocument called (stub)")
|
||||
return proto.host.TruncateDocumentResponse.create({})
|
||||
}
|
||||
|
||||
async saveDocument(_request: proto.host.SaveDocumentRequest): Promise<proto.host.SaveDocumentResponse> {
|
||||
// Next phase: Use connection.writeTextFile to persist the document to disk.
|
||||
// This saves the current document content to the file system.
|
||||
Logger.debug("[ACPDiffServiceClient] saveDocument called (stub)")
|
||||
return proto.host.SaveDocumentResponse.create({})
|
||||
}
|
||||
|
||||
async closeAllDiffs(_request: proto.host.CloseAllDiffsRequest): Promise<proto.host.CloseAllDiffsResponse> {
|
||||
// Next phase: Send ACP extension notification to close all diff views in the editor.
|
||||
// No visual diff views in ACP mode by default, so this is a no-op.
|
||||
Logger.debug("[ACPDiffServiceClient] closeAllDiffs called (stub)")
|
||||
return proto.host.CloseAllDiffsResponse.create({})
|
||||
}
|
||||
|
||||
async openMultiFileDiff(_request: proto.host.OpenMultiFileDiffRequest): Promise<proto.host.OpenMultiFileDiffResponse> {
|
||||
// Next phase: Send ACP extension notification to open a multi-file diff view.
|
||||
// This would display changes across multiple files in the editor.
|
||||
Logger.debug("[ACPDiffServiceClient] openMultiFileDiff called (stub)")
|
||||
return proto.host.OpenMultiFileDiffResponse.create({})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ACP implementation of EnvService client.
|
||||
*
|
||||
* Handles environment operations like clipboard access, version info, and telemetry.
|
||||
* Most operations are stubs that will be implemented using ACP extension methods.
|
||||
*/
|
||||
class ACPEnvServiceClient implements EnvServiceClientInterface {
|
||||
private readonly version: string
|
||||
|
||||
constructor(
|
||||
_clientCapabilities: acp.ClientCapabilities | undefined,
|
||||
_sessionIdResolver: SessionIdResolver,
|
||||
version: string = "1.0.0",
|
||||
) {
|
||||
this.version = version
|
||||
}
|
||||
|
||||
async debugLog(request: proto.cline.StringRequest): Promise<proto.cline.Empty> {
|
||||
Logger.debug(request.value)
|
||||
return proto.cline.Empty.create()
|
||||
}
|
||||
|
||||
async clipboardWriteText(_request: proto.cline.StringRequest): Promise<proto.cline.Empty> {
|
||||
Logger.debug("[ACPEnvServiceClient] clipboardWriteText called (stub)")
|
||||
return proto.cline.Empty.create()
|
||||
}
|
||||
|
||||
async clipboardReadText(_request: proto.cline.EmptyRequest): Promise<proto.cline.String> {
|
||||
Logger.debug("[ACPEnvServiceClient] clipboardReadText called (stub)")
|
||||
return proto.cline.String.create({ value: "" })
|
||||
}
|
||||
|
||||
async getHostVersion(_request: proto.cline.EmptyRequest): Promise<proto.host.GetHostVersionResponse> {
|
||||
// Return version info for the ACP agent.
|
||||
return proto.host.GetHostVersionResponse.create({
|
||||
version: this.version,
|
||||
platform: "Cline ACP Agent",
|
||||
clineType: ClineClient.Cli,
|
||||
})
|
||||
}
|
||||
|
||||
async getIdeRedirectUri(_request: proto.cline.EmptyRequest): Promise<proto.cline.String> {
|
||||
Logger.debug("[ACPEnvServiceClient] getIdeRedirectUri called (stub)")
|
||||
return proto.cline.String.create({ value: "" })
|
||||
}
|
||||
|
||||
async getTelemetrySettings(_request: proto.cline.EmptyRequest): Promise<proto.host.GetTelemetrySettingsResponse> {
|
||||
// Return telemetry as disabled by default in ACP mode.
|
||||
return proto.host.GetTelemetrySettingsResponse.create({
|
||||
isEnabled: proto.host.Setting.DISABLED,
|
||||
})
|
||||
}
|
||||
|
||||
subscribeToTelemetrySettings(
|
||||
_request: proto.cline.EmptyRequest,
|
||||
callbacks: StreamingCallbacks<proto.host.TelemetrySettingsEvent>,
|
||||
): () => void {
|
||||
// Send initial telemetry settings (disabled) and return unsubscribe function.
|
||||
callbacks.onResponse(
|
||||
proto.host.TelemetrySettingsEvent.create({
|
||||
isEnabled: proto.host.Setting.DISABLED,
|
||||
}),
|
||||
)
|
||||
// Return no-op unsubscribe function
|
||||
return () => {}
|
||||
}
|
||||
|
||||
async shutdown(_request: proto.cline.EmptyRequest): Promise<proto.cline.Empty> {
|
||||
// Next phase: Graceful ACP connection shutdown.
|
||||
// This would cleanly close the ACP connection and release resources.
|
||||
Logger.debug("[ACPEnvServiceClient] shutdown called (stub)")
|
||||
return proto.cline.Empty.create()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ACP implementation of WindowService client.
|
||||
*
|
||||
* Handles window/UI operations like showing documents, dialogs, and messages.
|
||||
* Most operations are stubs that will be implemented using ACP extension methods.
|
||||
*/
|
||||
class ACPWindowServiceClient implements WindowServiceClientInterface {
|
||||
constructor(_clientCapabilities: acp.ClientCapabilities | undefined, _sessionIdResolver: SessionIdResolver) {}
|
||||
|
||||
async showTextDocument(request: proto.host.ShowTextDocumentRequest): Promise<proto.host.TextEditorInfo> {
|
||||
// Next phase: Send ACP extension request to open document in the editor.
|
||||
// This would tell the ACP client to open the specified file.
|
||||
Logger.debug("[ACPWindowServiceClient] showTextDocument called (stub)", { path: request.path })
|
||||
return proto.host.TextEditorInfo.create({
|
||||
documentPath: request.path,
|
||||
})
|
||||
}
|
||||
|
||||
async showOpenDialogue(_request: proto.host.ShowOpenDialogueRequest): Promise<proto.host.SelectedResources> {
|
||||
// Next phase: Send ACP extension request for file picker dialog.
|
||||
// This would display a file open dialog in the ACP client.
|
||||
Logger.debug("[ACPWindowServiceClient] showOpenDialogue called (stub)")
|
||||
return proto.host.SelectedResources.create({ paths: [] })
|
||||
}
|
||||
|
||||
async showMessage(request: proto.host.ShowMessageRequest): Promise<proto.host.SelectedResponse> {
|
||||
// Next phase: Send ACP extension notification to show message in the editor.
|
||||
// This would display an information/warning/error message to the user.
|
||||
Logger.debug("[ACPWindowServiceClient] showMessage called (stub)", {
|
||||
message: request.message,
|
||||
type: request.type,
|
||||
})
|
||||
return proto.host.SelectedResponse.create({})
|
||||
}
|
||||
|
||||
async showInputBox(_request: proto.host.ShowInputBoxRequest): Promise<proto.host.ShowInputBoxResponse> {
|
||||
// Next phase: Send ACP extension request for input dialog.
|
||||
// This would display an input box for user text entry.
|
||||
Logger.debug("[ACPWindowServiceClient] showInputBox called (stub)")
|
||||
return proto.host.ShowInputBoxResponse.create({ response: "" })
|
||||
}
|
||||
|
||||
async showSaveDialog(_request: proto.host.ShowSaveDialogRequest): Promise<proto.host.ShowSaveDialogResponse> {
|
||||
// Next phase: Send ACP extension request for save dialog.
|
||||
// This would display a file save dialog in the ACP client.
|
||||
Logger.debug("[ACPWindowServiceClient] showSaveDialog called (stub)")
|
||||
return proto.host.ShowSaveDialogResponse.create({ selectedPath: "" })
|
||||
}
|
||||
|
||||
async openFile(request: proto.host.OpenFileRequest): Promise<proto.host.OpenFileResponse> {
|
||||
// Next phase: Send ACP extension request to open file in the editor.
|
||||
// This would open the specified file in the ACP client's editor.
|
||||
Logger.debug("[ACPWindowServiceClient] openFile called (stub)", { filePath: request.filePath })
|
||||
return proto.host.OpenFileResponse.create({})
|
||||
}
|
||||
|
||||
async openSettings(_request: proto.host.OpenSettingsRequest): Promise<proto.host.OpenSettingsResponse> {
|
||||
// Next phase: Send ACP extension request to open settings panel.
|
||||
// This would open the settings/preferences in the ACP client.
|
||||
Logger.debug("[ACPWindowServiceClient] openSettings called (stub)")
|
||||
return proto.host.OpenSettingsResponse.create({})
|
||||
}
|
||||
|
||||
async getOpenTabs(_request: proto.host.GetOpenTabsRequest): Promise<proto.host.GetOpenTabsResponse> {
|
||||
// Next phase: Send ACP extension request to list open tabs/documents.
|
||||
// This would return a list of currently open files in the editor.
|
||||
Logger.debug("[ACPWindowServiceClient] getOpenTabs called (stub)")
|
||||
return proto.host.GetOpenTabsResponse.create({ paths: [] })
|
||||
}
|
||||
|
||||
async getVisibleTabs(_request: proto.host.GetVisibleTabsRequest): Promise<proto.host.GetVisibleTabsResponse> {
|
||||
// Next phase: Send ACP extension request to list visible tabs.
|
||||
// This would return a list of visible tabs/panes in the editor.
|
||||
Logger.debug("[ACPWindowServiceClient] getVisibleTabs called (stub)")
|
||||
return proto.host.GetVisibleTabsResponse.create({ paths: [] })
|
||||
}
|
||||
|
||||
async getActiveEditor(_request: proto.host.GetActiveEditorRequest): Promise<proto.host.GetActiveEditorResponse> {
|
||||
// Next phase: Send ACP extension request to get active editor info.
|
||||
// This would return information about the currently focused editor.
|
||||
Logger.debug("[ACPWindowServiceClient] getActiveEditor called (stub)")
|
||||
return proto.host.GetActiveEditorResponse.create({})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ACP implementation of WorkspaceService client.
|
||||
*
|
||||
* Handles workspace operations like getting paths, diagnostics, and terminal commands.
|
||||
* Uses the cwdResolver to get the current working directory, falling back to process.cwd().
|
||||
*/
|
||||
class ACPWorkspaceServiceClient implements WorkspaceServiceClientInterface {
|
||||
private readonly _clientCapabilities: acp.ClientCapabilities | undefined
|
||||
private readonly cwdResolver: CwdResolver
|
||||
|
||||
constructor(
|
||||
clientCapabilities: acp.ClientCapabilities | undefined,
|
||||
_sessionIdResolver: SessionIdResolver,
|
||||
cwdResolver: CwdResolver,
|
||||
) {
|
||||
this._clientCapabilities = clientCapabilities
|
||||
this.cwdResolver = cwdResolver
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current working directory, using the resolver if available,
|
||||
* otherwise falling back to process.cwd().
|
||||
*/
|
||||
private getCwd(): string {
|
||||
return this.cwdResolver() ?? process.cwd()
|
||||
}
|
||||
|
||||
async getWorkspacePaths(_request: proto.host.GetWorkspacePathsRequest): Promise<proto.host.GetWorkspacePathsResponse> {
|
||||
// Return the current working directory from the resolver.
|
||||
const cwd = this.getCwd()
|
||||
Logger.debug("[ACPWorkspaceServiceClient] getWorkspacePaths called", { cwd })
|
||||
return proto.host.GetWorkspacePathsResponse.create({
|
||||
paths: [cwd],
|
||||
})
|
||||
}
|
||||
|
||||
async saveOpenDocumentIfDirty(
|
||||
_request: proto.host.SaveOpenDocumentIfDirtyRequest,
|
||||
): Promise<proto.host.SaveOpenDocumentIfDirtyResponse> {
|
||||
// Next phase: Use ACP extension or fs.writeTextFile to save dirty documents.
|
||||
// This would save any unsaved changes in the specified document.
|
||||
Logger.debug("[ACPWorkspaceServiceClient] saveOpenDocumentIfDirty called (stub)")
|
||||
return proto.host.SaveOpenDocumentIfDirtyResponse.create({})
|
||||
}
|
||||
|
||||
async getDiagnostics(_request: proto.host.GetDiagnosticsRequest): Promise<proto.host.GetDiagnosticsResponse> {
|
||||
// Next phase: Send ACP extension request for diagnostics (errors, warnings).
|
||||
// This would return linting/compilation errors from the ACP client.
|
||||
Logger.debug("[ACPWorkspaceServiceClient] getDiagnostics called (stub)")
|
||||
return proto.host.GetDiagnosticsResponse.create({ fileDiagnostics: [] })
|
||||
}
|
||||
|
||||
async openProblemsPanel(_request: proto.host.OpenProblemsPanelRequest): Promise<proto.host.OpenProblemsPanelResponse> {
|
||||
// Next phase: Send ACP extension notification to open the problems panel.
|
||||
// This would show the diagnostics/problems view in the editor.
|
||||
Logger.debug("[ACPWorkspaceServiceClient] openProblemsPanel called (stub)")
|
||||
return proto.host.OpenProblemsPanelResponse.create({})
|
||||
}
|
||||
|
||||
async openInFileExplorerPanel(
|
||||
request: proto.host.OpenInFileExplorerPanelRequest,
|
||||
): Promise<proto.host.OpenInFileExplorerPanelResponse> {
|
||||
// Next phase: Send ACP extension notification to reveal file in explorer.
|
||||
// This would highlight/reveal the specified path in the file tree.
|
||||
Logger.debug("[ACPWorkspaceServiceClient] openInFileExplorerPanel called (stub)", { path: request.path })
|
||||
return proto.host.OpenInFileExplorerPanelResponse.create({})
|
||||
}
|
||||
|
||||
async openClineSidebarPanel(
|
||||
_request: proto.host.OpenClineSidebarPanelRequest,
|
||||
): Promise<proto.host.OpenClineSidebarPanelResponse> {
|
||||
// Next phase: Send ACP extension notification to open Cline sidebar.
|
||||
// This would show the Cline panel/sidebar in the editor.
|
||||
Logger.debug("[ACPWorkspaceServiceClient] openClineSidebarPanel called (stub)")
|
||||
return proto.host.OpenClineSidebarPanelResponse.create({})
|
||||
}
|
||||
|
||||
async openTerminalPanel(_request: proto.host.OpenTerminalRequest): Promise<proto.host.OpenTerminalResponse> {
|
||||
// Next phase: Send ACP extension notification or use createTerminal capability.
|
||||
// This would open/show the terminal panel in the editor.
|
||||
Logger.debug("[ACPWorkspaceServiceClient] openTerminalPanel called (stub)")
|
||||
return proto.host.OpenTerminalResponse.create({})
|
||||
}
|
||||
|
||||
async executeCommandInTerminal(
|
||||
request: proto.host.ExecuteCommandInTerminalRequest,
|
||||
): Promise<proto.host.ExecuteCommandInTerminalResponse> {
|
||||
// Next phase: Use connection.createTerminal if clientCapabilities.terminal is available.
|
||||
// This would execute the specified command in a terminal via the ACP client.
|
||||
// The ACP SDK provides createTerminal() which returns a TerminalHandle with
|
||||
// methods like currentOutput(), waitForExit(), kill(), and release().
|
||||
Logger.debug("[ACPWorkspaceServiceClient] executeCommandInTerminal called (stub)", {
|
||||
command: request.command,
|
||||
hasTerminalCapability: this._clientCapabilities?.terminal,
|
||||
})
|
||||
return proto.host.ExecuteCommandInTerminalResponse.create({})
|
||||
}
|
||||
|
||||
async openFolder(request: proto.host.OpenFolderRequest): Promise<proto.host.OpenFolderResponse> {
|
||||
// Next phase: Send ACP extension request to change workspace/folder.
|
||||
// This would open a new folder/workspace in the ACP client.
|
||||
Logger.debug("[ACPWorkspaceServiceClient] openFolder called (stub)", { path: request.path })
|
||||
return proto.host.OpenFolderResponse.create({ success: true })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ACP Host Bridge Client Provider
|
||||
*
|
||||
* Provides the 4 service clients required by HostBridgeClientProvider interface,
|
||||
* implemented for the ACP environment. Uses the ACP connection and client capabilities
|
||||
* to delegate operations to the ACP client where possible.
|
||||
*/
|
||||
export class ACPHostBridgeClientProvider implements HostBridgeClientProvider {
|
||||
workspaceClient: WorkspaceServiceClientInterface
|
||||
envClient: EnvServiceClientInterface
|
||||
windowClient: WindowServiceClientInterface
|
||||
diffClient: DiffServiceClientInterface
|
||||
|
||||
/**
|
||||
* Creates a new ACPHostBridgeClientProvider.
|
||||
*
|
||||
* @param connection - The ACP agent-side connection for making requests
|
||||
* @param clientCapabilities - The client's advertised capabilities
|
||||
* @param sessionIdResolver - Function that returns the current session ID
|
||||
* @param cwdResolver - Function that returns the current working directory
|
||||
* @param debug - Whether to enable debug logging
|
||||
* @param version - Version string for getHostVersion (optional)
|
||||
*/
|
||||
constructor(
|
||||
clientCapabilities: acp.ClientCapabilities | undefined,
|
||||
sessionIdResolver: SessionIdResolver,
|
||||
cwdResolver: CwdResolver,
|
||||
version: string = "1.0.0",
|
||||
) {
|
||||
this.workspaceClient = new ACPWorkspaceServiceClient(clientCapabilities, sessionIdResolver, cwdResolver)
|
||||
this.envClient = new ACPEnvServiceClient(clientCapabilities, sessionIdResolver, version)
|
||||
this.windowClient = new ACPWindowServiceClient(clientCapabilities, sessionIdResolver)
|
||||
this.diffClient = new ACPDiffServiceClient()
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
/**
|
||||
* AcpAgent - Thin wrapper that bridges stdio connection to ClineAgent.
|
||||
*
|
||||
* This class wraps the ClineAgent and connects it to an ACP AgentSideConnection
|
||||
* for stdio-based communication. It:
|
||||
* - Wires up the permission handler to call connection.requestPermission()
|
||||
* - Subscribes to ClineAgent session events and forwards them to connection.sessionUpdate()
|
||||
* - Delegates all acp.Agent methods to the internal ClineAgent
|
||||
*
|
||||
* For programmatic usage without stdio, use ClineAgent directly.
|
||||
*
|
||||
* @module acp
|
||||
*/
|
||||
|
||||
import type * as acp from "@agentclientprotocol/sdk"
|
||||
import { Logger } from "@/shared/services/Logger.js"
|
||||
import { ClineAgent } from "../agent/ClineAgent.js"
|
||||
import type { AcpAgentOptions, SessionUpdateType } from "../agent/types.js"
|
||||
|
||||
/**
|
||||
* ACP Agent wrapper that bridges stdio connection to ClineAgent.
|
||||
*
|
||||
* This is the class used by runAcpMode() for stdio-based ACP communication.
|
||||
* It creates an internal ClineAgent and wires up the connection for:
|
||||
* - Permission requests (via connection.requestPermission)
|
||||
* - Session updates (via connection.sessionUpdate)
|
||||
*/
|
||||
export class AcpAgent implements acp.Agent {
|
||||
private readonly connection: acp.AgentSideConnection
|
||||
private readonly clineAgent: ClineAgent
|
||||
|
||||
/** Track which sessions we've subscribed to for event forwarding */
|
||||
private readonly subscribedSessions: Set<string> = new Set()
|
||||
|
||||
constructor(connection: acp.AgentSideConnection, options: AcpAgentOptions) {
|
||||
this.connection = connection
|
||||
|
||||
// Create the internal ClineAgent
|
||||
this.clineAgent = new ClineAgent(options)
|
||||
|
||||
// Wire up the permission handler to use the connection
|
||||
this.clineAgent.setPermissionHandler(async (request, resolve) => {
|
||||
try {
|
||||
Logger.debug("[AcpAgent] Forwarding permission request to connection")
|
||||
const response = await this.connection.requestPermission({
|
||||
sessionId: this.getCurrentSessionId() ?? "",
|
||||
toolCall: request.toolCall,
|
||||
options: request.options,
|
||||
})
|
||||
resolve(response)
|
||||
} catch (error) {
|
||||
Logger.debug("[AcpAgent] Error requesting permission:", error)
|
||||
resolve({ outcome: "rejected" as unknown as acp.RequestPermissionOutcome })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current active session ID from the ClineAgent.
|
||||
*/
|
||||
private getCurrentSessionId(): string | undefined {
|
||||
// Find the session that's currently processing
|
||||
for (const [sessionId, session] of this.clineAgent.sessions) {
|
||||
if (session.controller?.task) {
|
||||
return sessionId
|
||||
}
|
||||
}
|
||||
// Fall back to the first session if none is actively processing
|
||||
const firstSession = this.clineAgent.sessions.keys().next()
|
||||
return firstSession.done ? undefined : firstSession.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to session events and forward them to the connection.
|
||||
*/
|
||||
private subscribeToSessionEvents(sessionId: string): void {
|
||||
if (this.subscribedSessions.has(sessionId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const emitter = this.clineAgent.emitterForSession(sessionId)
|
||||
|
||||
// Forward session update by adding the sessionUpdate discriminator
|
||||
const forwardSessionUpdate = <K extends SessionUpdateType>(eventName: K) => {
|
||||
emitter.on(eventName, (payload: Record<string, unknown>) => {
|
||||
const update = {
|
||||
sessionUpdate: eventName,
|
||||
...payload,
|
||||
} as acp.SessionUpdate
|
||||
this.connection.sessionUpdate({ sessionId, update }).catch((error) => {
|
||||
Logger.error(`[AcpAgent] Error forwarding ${eventName}:`, error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Forward all standard session updates
|
||||
forwardSessionUpdate("agent_message_chunk")
|
||||
forwardSessionUpdate("agent_thought_chunk")
|
||||
forwardSessionUpdate("tool_call")
|
||||
forwardSessionUpdate("tool_call_update")
|
||||
forwardSessionUpdate("available_commands_update")
|
||||
forwardSessionUpdate("plan")
|
||||
forwardSessionUpdate("current_mode_update")
|
||||
forwardSessionUpdate("user_message_chunk")
|
||||
forwardSessionUpdate("config_option_update")
|
||||
forwardSessionUpdate("session_info_update")
|
||||
|
||||
// Handle errors specially (not part of ACP SessionUpdate)
|
||||
emitter.on("error", (error) => {
|
||||
Logger.error("[AcpAgent] Session error:", error)
|
||||
})
|
||||
|
||||
this.subscribedSessions.add(sessionId)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// acp.Agent Interface Implementation - Delegate to ClineAgent
|
||||
// ============================================================
|
||||
|
||||
async initialize(params: acp.InitializeRequest): Promise<acp.InitializeResponse> {
|
||||
return await this.clineAgent.initialize(params, this.connection)
|
||||
}
|
||||
|
||||
async newSession(params: acp.NewSessionRequest): Promise<acp.NewSessionResponse> {
|
||||
const response = await this.clineAgent.newSession(params)
|
||||
// Subscribe to events for this new session
|
||||
this.subscribeToSessionEvents(response.sessionId)
|
||||
return response
|
||||
}
|
||||
|
||||
async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
|
||||
// Ensure we're subscribed to this session's events
|
||||
this.subscribeToSessionEvents(params.sessionId)
|
||||
return this.clineAgent.prompt(params)
|
||||
}
|
||||
|
||||
async cancel(params: acp.CancelNotification): Promise<void> {
|
||||
return this.clineAgent.cancel(params)
|
||||
}
|
||||
|
||||
async setSessionMode(params: acp.SetSessionModeRequest): Promise<acp.SetSessionModeResponse> {
|
||||
return this.clineAgent.setSessionMode(params)
|
||||
}
|
||||
|
||||
async unstable_setSessionModel(params: acp.SetSessionModelRequest): Promise<acp.SetSessionModelResponse> {
|
||||
return this.clineAgent.unstable_setSessionModel(params)
|
||||
}
|
||||
|
||||
async authenticate(params: acp.AuthenticateRequest): Promise<acp.AuthenticateResponse> {
|
||||
return this.clineAgent.authenticate(params)
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
this.subscribedSessions.clear()
|
||||
return this.clineAgent.shutdown()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,143 +0,0 @@
|
||||
/**
|
||||
* Entry point for ACP (Agent Client Protocol) mode.
|
||||
*
|
||||
* When the CLI is invoked with `--acp`, this module sets up the ACP connection
|
||||
* and runs Cline as an ACP-compliant agent communicating over stdio.
|
||||
*
|
||||
* This module exports:
|
||||
* - `ClineAgent` - Decoupled agent for programmatic use (no stdio dependency)
|
||||
* - `AcpAgent` - Thin wrapper that bridges stdio connection to ClineAgent
|
||||
* - `ClineSessionEmitter` - Typed EventEmitter for per-session events
|
||||
* - `runAcpMode` - Function to run Cline in stdio-based ACP mode
|
||||
*
|
||||
* @module acp
|
||||
*/
|
||||
|
||||
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { version as CLI_VERSION } from "../../../package.json"
|
||||
import { AcpAgent } from "./AcpAgent.js"
|
||||
import { nodeToWebReadable, nodeToWebWritable } from "./streamUtils.js"
|
||||
|
||||
// Re-export classes for programmatic use
|
||||
export { ClineAgent } from "../agent/ClineAgent.js"
|
||||
export { ClineSessionEmitter } from "../agent/ClineSessionEmitter.js"
|
||||
// Re-export types
|
||||
export type {
|
||||
AcpAgentOptions,
|
||||
AcpSessionState,
|
||||
ClineAcpSession,
|
||||
ClineAgentOptions,
|
||||
ClineSessionEvents,
|
||||
PermissionHandler,
|
||||
PermissionResolver,
|
||||
} from "../agent/types.js"
|
||||
export { AcpAgent } from "./AcpAgent.js"
|
||||
|
||||
/** Original console methods for restoration if needed */
|
||||
const originalConsole = {
|
||||
log: console.log,
|
||||
info: console.info,
|
||||
warn: console.warn,
|
||||
debug: console.debug,
|
||||
error: console.error,
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect all console output to stderr.
|
||||
*
|
||||
* In ACP mode, stdout is reserved exclusively for JSON-RPC communication.
|
||||
* All logging must go to stderr to avoid corrupting the protocol stream.
|
||||
*/
|
||||
function redirectConsoleToStderr(): void {
|
||||
console.log = (...args) => console.error(...args)
|
||||
console.info = (...args) => console.error(...args)
|
||||
console.warn = (...args) => console.error(...args)
|
||||
console.debug = (...args) => console.error(...args)
|
||||
// console.error already goes to stderr
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore console methods to their original behavior.
|
||||
*/
|
||||
export function restoreConsole(): void {
|
||||
console.log = originalConsole.log
|
||||
console.info = originalConsole.info
|
||||
console.warn = originalConsole.warn
|
||||
console.debug = originalConsole.debug
|
||||
console.error = originalConsole.error
|
||||
}
|
||||
|
||||
export interface AcpModeOptions {
|
||||
/** Path to Cline configuration directory */
|
||||
config?: string
|
||||
/** Working directory (default: process.cwd()) */
|
||||
cwd?: string
|
||||
/** Enable verbose/debug logging to stderr */
|
||||
verbose?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Cline in ACP mode.
|
||||
*
|
||||
* This function:
|
||||
* 1. Redirects console output to stderr (stdout reserved for JSON-RPC)
|
||||
* 2. Sets up the ndJsonStream for stdio communication
|
||||
* 3. Creates the AgentSideConnection with our AcpAgent factory
|
||||
* 4. Initializes the CLI infrastructure (StateManager, Controller, etc.)
|
||||
* 5. Keeps the process alive until the connection closes
|
||||
*
|
||||
* @param options - Configuration options for ACP mode
|
||||
*/
|
||||
export async function runAcpMode(options: AcpModeOptions = {}): Promise<void> {
|
||||
redirectConsoleToStderr()
|
||||
|
||||
const outputStream = nodeToWebWritable(process.stdout)
|
||||
const inputStream = nodeToWebReadable(process.stdin)
|
||||
const stream = ndJsonStream(outputStream, inputStream)
|
||||
let agent: AcpAgent | null = null
|
||||
|
||||
new AgentSideConnection((conn) => {
|
||||
agent = new AcpAgent(conn, {
|
||||
version: CLI_VERSION,
|
||||
debug: Boolean(options.verbose),
|
||||
})
|
||||
return agent
|
||||
}, stream)
|
||||
|
||||
let isShuttingDown = false
|
||||
const shutdown = async () => {
|
||||
if (isShuttingDown) {
|
||||
// Force exit on second signal
|
||||
process.exit(1)
|
||||
}
|
||||
isShuttingDown = true
|
||||
try {
|
||||
await agent?.shutdown()
|
||||
restoreConsole()
|
||||
} catch (error) {
|
||||
Logger.error("[ACP] Error during shutdown:", error)
|
||||
}
|
||||
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.on("SIGINT", shutdown)
|
||||
process.on("SIGTERM", shutdown)
|
||||
|
||||
// Keep the process alive
|
||||
// The ndJsonStream will handle stdin events automatically.
|
||||
// We need to ensure the process doesn't exit while waiting for input.
|
||||
process.stdin.resume()
|
||||
|
||||
// Handle stdin end (client disconnected)
|
||||
process.stdin.on("end", shutdown)
|
||||
|
||||
// Handle stdin errors
|
||||
process.stdin.on("error", async (error) => {
|
||||
Logger.error("[ACP] stdin error:", error)
|
||||
await shutdown()
|
||||
})
|
||||
|
||||
Logger.info("[ACP] Process is now listening for ACP requests on stdin")
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* Stream conversion utilities for ACP mode.
|
||||
*
|
||||
* The ACP SDK's ndJsonStream function expects Web Streams (ReadableStream/WritableStream),
|
||||
* but Node.js provides its own stream types. These utilities convert between them.
|
||||
*
|
||||
* @module acp/streamUtils
|
||||
*/
|
||||
|
||||
import type { Readable, Writable } from "node:stream"
|
||||
|
||||
/**
|
||||
* Convert a Node.js Writable stream to a Web WritableStream.
|
||||
*
|
||||
* Used to convert process.stdout for ACP output.
|
||||
*
|
||||
* @param nodeStream - Node.js Writable stream (e.g., process.stdout)
|
||||
* @returns Web WritableStream compatible with ndJsonStream
|
||||
*/
|
||||
export function nodeToWebWritable(nodeStream: Writable): WritableStream<Uint8Array> {
|
||||
return new WritableStream<Uint8Array>({
|
||||
write(chunk) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
nodeStream.write(Buffer.from(chunk), (err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Node.js Readable stream to a Web ReadableStream.
|
||||
*
|
||||
* Used to convert process.stdin for ACP input.
|
||||
*
|
||||
* @param nodeStream - Node.js Readable stream (e.g., process.stdin)
|
||||
* @returns Web ReadableStream compatible with ndJsonStream
|
||||
*/
|
||||
export function nodeToWebReadable(nodeStream: Readable): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
nodeStream.on("data", (chunk: Buffer) => {
|
||||
controller.enqueue(new Uint8Array(chunk))
|
||||
})
|
||||
nodeStream.on("end", () => controller.close())
|
||||
nodeStream.on("error", (err) => controller.error(err))
|
||||
},
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,274 +0,0 @@
|
||||
/**
|
||||
* Tests for ClineSessionEmitter - Typed EventEmitter for per-session ACP events.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { ClineSessionEmitter } from "./ClineSessionEmitter.js"
|
||||
import type { SessionUpdatePayload } from "./types.js"
|
||||
|
||||
describe("ClineSessionEmitter", () => {
|
||||
let emitter: ClineSessionEmitter
|
||||
|
||||
beforeEach(() => {
|
||||
emitter = new ClineSessionEmitter()
|
||||
})
|
||||
|
||||
describe("on/emit", () => {
|
||||
it("should emit and receive agent_message_chunk events", () => {
|
||||
const listener = vi.fn()
|
||||
const payload: SessionUpdatePayload<"agent_message_chunk"> = {
|
||||
content: { type: "text", text: "Hello, world!" },
|
||||
}
|
||||
|
||||
emitter.on("agent_message_chunk", listener)
|
||||
emitter.emit("agent_message_chunk", payload)
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
expect(listener).toHaveBeenCalledWith(payload)
|
||||
})
|
||||
|
||||
it("should emit and receive agent_thought_chunk events", () => {
|
||||
const listener = vi.fn()
|
||||
const payload: SessionUpdatePayload<"agent_thought_chunk"> = {
|
||||
content: { type: "text", text: "Thinking..." },
|
||||
}
|
||||
|
||||
emitter.on("agent_thought_chunk", listener)
|
||||
emitter.emit("agent_thought_chunk", payload)
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
expect(listener).toHaveBeenCalledWith(payload)
|
||||
})
|
||||
|
||||
it("should emit and receive tool_call events", () => {
|
||||
const listener = vi.fn()
|
||||
const payload: SessionUpdatePayload<"tool_call"> = {
|
||||
toolCallId: "test-tool-call-id",
|
||||
title: "Test Tool Call",
|
||||
status: "in_progress",
|
||||
}
|
||||
|
||||
emitter.on("tool_call", listener)
|
||||
emitter.emit("tool_call", payload)
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
expect(listener).toHaveBeenCalledWith(payload)
|
||||
})
|
||||
|
||||
it("should emit and receive tool_call_update events", () => {
|
||||
const listener = vi.fn()
|
||||
const payload: SessionUpdatePayload<"tool_call_update"> = {
|
||||
toolCallId: "test-tool-call-id",
|
||||
status: "completed",
|
||||
rawOutput: { result: "success" },
|
||||
}
|
||||
|
||||
emitter.on("tool_call_update", listener)
|
||||
emitter.emit("tool_call_update", payload)
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
expect(listener).toHaveBeenCalledWith(payload)
|
||||
})
|
||||
|
||||
it("should emit and receive available_commands_update events", () => {
|
||||
const listener = vi.fn()
|
||||
const payload: SessionUpdatePayload<"available_commands_update"> = {
|
||||
availableCommands: [{ name: "test", description: "Test command" }],
|
||||
}
|
||||
|
||||
emitter.on("available_commands_update", listener)
|
||||
emitter.emit("available_commands_update", payload)
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
expect(listener).toHaveBeenCalledWith(payload)
|
||||
})
|
||||
|
||||
it("should emit and receive current_mode_update events", () => {
|
||||
const listener = vi.fn()
|
||||
const payload: SessionUpdatePayload<"current_mode_update"> = {
|
||||
currentModeId: "act",
|
||||
}
|
||||
|
||||
emitter.on("current_mode_update", listener)
|
||||
emitter.emit("current_mode_update", payload)
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
expect(listener).toHaveBeenCalledWith(payload)
|
||||
})
|
||||
|
||||
it("should emit and receive plan events", () => {
|
||||
const listener = vi.fn()
|
||||
const payload: SessionUpdatePayload<"plan"> = {
|
||||
entries: [{ content: "Step 1", status: "pending", priority: "high" }],
|
||||
}
|
||||
|
||||
emitter.on("plan", listener)
|
||||
emitter.emit("plan", payload)
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
expect(listener).toHaveBeenCalledWith(payload)
|
||||
})
|
||||
|
||||
it("should emit and receive error events", () => {
|
||||
const listener = vi.fn()
|
||||
const error = new Error("Test error")
|
||||
|
||||
emitter.on("error", listener)
|
||||
emitter.emit("error", error)
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
expect(listener).toHaveBeenCalledWith(error)
|
||||
})
|
||||
})
|
||||
|
||||
describe("multiple listeners", () => {
|
||||
it("should support multiple listeners for the same event", () => {
|
||||
const listener1 = vi.fn()
|
||||
const listener2 = vi.fn()
|
||||
const payload: SessionUpdatePayload<"agent_message_chunk"> = {
|
||||
content: { type: "text", text: "Hello" },
|
||||
}
|
||||
|
||||
emitter.on("agent_message_chunk", listener1)
|
||||
emitter.on("agent_message_chunk", listener2)
|
||||
emitter.emit("agent_message_chunk", payload)
|
||||
|
||||
expect(listener1).toHaveBeenCalledTimes(1)
|
||||
expect(listener2).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should call listeners in order of registration", () => {
|
||||
const order: number[] = []
|
||||
const listener1 = vi.fn(() => order.push(1))
|
||||
const listener2 = vi.fn(() => order.push(2))
|
||||
const payload: SessionUpdatePayload<"agent_message_chunk"> = {
|
||||
content: { type: "text", text: "Hello" },
|
||||
}
|
||||
|
||||
emitter.on("agent_message_chunk", listener1)
|
||||
emitter.on("agent_message_chunk", listener2)
|
||||
emitter.emit("agent_message_chunk", payload)
|
||||
|
||||
expect(order).toEqual([1, 2])
|
||||
})
|
||||
})
|
||||
|
||||
describe("off", () => {
|
||||
it("should remove a specific listener", () => {
|
||||
const listener = vi.fn()
|
||||
const payload: SessionUpdatePayload<"agent_message_chunk"> = {
|
||||
content: { type: "text", text: "Hello" },
|
||||
}
|
||||
|
||||
emitter.on("agent_message_chunk", listener)
|
||||
emitter.off("agent_message_chunk", listener)
|
||||
emitter.emit("agent_message_chunk", payload)
|
||||
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should only remove the specified listener", () => {
|
||||
const listener1 = vi.fn()
|
||||
const listener2 = vi.fn()
|
||||
const payload: SessionUpdatePayload<"agent_message_chunk"> = {
|
||||
content: { type: "text", text: "Hello" },
|
||||
}
|
||||
|
||||
emitter.on("agent_message_chunk", listener1)
|
||||
emitter.on("agent_message_chunk", listener2)
|
||||
emitter.off("agent_message_chunk", listener1)
|
||||
emitter.emit("agent_message_chunk", payload)
|
||||
|
||||
expect(listener1).not.toHaveBeenCalled()
|
||||
expect(listener2).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("once", () => {
|
||||
it("should only call the listener once", () => {
|
||||
const listener = vi.fn()
|
||||
const payload: SessionUpdatePayload<"agent_message_chunk"> = {
|
||||
content: { type: "text", text: "Hello" },
|
||||
}
|
||||
|
||||
emitter.once("agent_message_chunk", listener)
|
||||
emitter.emit("agent_message_chunk", payload)
|
||||
emitter.emit("agent_message_chunk", payload)
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("removeAllListeners", () => {
|
||||
it("should remove all listeners for a specific event", () => {
|
||||
const listener1 = vi.fn()
|
||||
const listener2 = vi.fn()
|
||||
const payload: SessionUpdatePayload<"agent_message_chunk"> = {
|
||||
content: { type: "text", text: "Hello" },
|
||||
}
|
||||
|
||||
emitter.on("agent_message_chunk", listener1)
|
||||
emitter.on("agent_message_chunk", listener2)
|
||||
emitter.removeAllListeners("agent_message_chunk")
|
||||
emitter.emit("agent_message_chunk", payload)
|
||||
|
||||
expect(listener1).not.toHaveBeenCalled()
|
||||
expect(listener2).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should remove all listeners when no event is specified", () => {
|
||||
const listener1 = vi.fn()
|
||||
const listener2 = vi.fn()
|
||||
|
||||
emitter.on("agent_message_chunk", listener1)
|
||||
emitter.on("tool_call", listener2)
|
||||
emitter.removeAllListeners()
|
||||
emitter.emit("agent_message_chunk", { content: { type: "text", text: "Hello" } })
|
||||
emitter.emit("tool_call", { toolCallId: "test", title: "Test" })
|
||||
|
||||
expect(listener1).not.toHaveBeenCalled()
|
||||
expect(listener2).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("listenerCount", () => {
|
||||
it("should return the correct number of listeners", () => {
|
||||
const listener1 = vi.fn()
|
||||
const listener2 = vi.fn()
|
||||
|
||||
expect(emitter.listenerCount("agent_message_chunk")).toBe(0)
|
||||
|
||||
emitter.on("agent_message_chunk", listener1)
|
||||
expect(emitter.listenerCount("agent_message_chunk")).toBe(1)
|
||||
|
||||
emitter.on("agent_message_chunk", listener2)
|
||||
expect(emitter.listenerCount("agent_message_chunk")).toBe(2)
|
||||
|
||||
emitter.off("agent_message_chunk", listener1)
|
||||
expect(emitter.listenerCount("agent_message_chunk")).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("chaining", () => {
|
||||
it("should support method chaining", () => {
|
||||
const listener = vi.fn()
|
||||
|
||||
const result = emitter.on("agent_message_chunk", listener).on("error", vi.fn()).off("error", vi.fn())
|
||||
|
||||
expect(result).toBe(emitter)
|
||||
})
|
||||
})
|
||||
|
||||
describe("emit return value", () => {
|
||||
it("should return true when there are listeners", () => {
|
||||
emitter.on("agent_message_chunk", vi.fn())
|
||||
const result = emitter.emit("agent_message_chunk", { content: { type: "text", text: "Hello" } })
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false when there are no listeners", () => {
|
||||
const result = emitter.emit("agent_message_chunk", { content: { type: "text", text: "Hello" } })
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,114 +0,0 @@
|
||||
/**
|
||||
* Typed EventEmitter for per-session ACP events.
|
||||
*
|
||||
* This class provides a type-safe wrapper around Node's EventEmitter
|
||||
* for emitting and subscribing to session-specific ACP events.
|
||||
*
|
||||
* @module acp
|
||||
*/
|
||||
|
||||
import { EventEmitter } from "events"
|
||||
import type { ClineSessionEvents } from "./types.js"
|
||||
|
||||
/**
|
||||
* Type-safe EventEmitter for ClineAgent session events.
|
||||
*
|
||||
* Each session has its own emitter instance, allowing consumers to
|
||||
* subscribe to events for specific sessions without filtering.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const agent = new ClineAgent({ version: "1.0.0" })
|
||||
* const session = await agent.newSession({ cwd: "/path/to/project" })
|
||||
*
|
||||
* // Subscribe to session events
|
||||
* agent.session(session.sessionId).on("agent_message_chunk", (content) => {
|
||||
* console.log("Agent says:", content.text)
|
||||
* })
|
||||
*
|
||||
* agent.session(session.sessionId).on("tool_call", (toolCall) => {
|
||||
* console.log("Tool called:", toolCall.toolName)
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export class ClineSessionEmitter {
|
||||
private readonly emitter: EventEmitter
|
||||
|
||||
constructor() {
|
||||
this.emitter = new EventEmitter()
|
||||
// Increase max listeners since we may have many event types
|
||||
this.emitter.setMaxListeners(20)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a session event.
|
||||
*
|
||||
* @param event - The event name to subscribe to
|
||||
* @param listener - The callback function to invoke when the event is emitted
|
||||
* @returns This emitter instance for chaining
|
||||
*/
|
||||
on<K extends keyof ClineSessionEvents>(event: K, listener: ClineSessionEvents[K]): this {
|
||||
this.emitter.on(event, listener as (...args: unknown[]) => void)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a session event for a single invocation.
|
||||
*
|
||||
* @param event - The event name to subscribe to
|
||||
* @param listener - The callback function to invoke when the event is emitted
|
||||
* @returns This emitter instance for chaining
|
||||
*/
|
||||
once<K extends keyof ClineSessionEvents>(event: K, listener: ClineSessionEvents[K]): this {
|
||||
this.emitter.once(event, listener as (...args: unknown[]) => void)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from a session event.
|
||||
*
|
||||
* @param event - The event name to unsubscribe from
|
||||
* @param listener - The callback function to remove
|
||||
* @returns This emitter instance for chaining
|
||||
*/
|
||||
off<K extends keyof ClineSessionEvents>(event: K, listener: ClineSessionEvents[K]): this {
|
||||
this.emitter.off(event, listener as (...args: unknown[]) => void)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a session event.
|
||||
*
|
||||
* @param event - The event name to emit
|
||||
* @param args - The arguments to pass to the event listeners
|
||||
* @returns True if the event had listeners, false otherwise
|
||||
*/
|
||||
emit<K extends keyof ClineSessionEvents>(event: K, ...args: Parameters<ClineSessionEvents[K]>): boolean {
|
||||
return this.emitter.emit(event, ...args)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all listeners for a specific event or all events.
|
||||
*
|
||||
* @param event - Optional event name to remove listeners for
|
||||
* @returns This emitter instance for chaining
|
||||
*/
|
||||
removeAllListeners<K extends keyof ClineSessionEvents>(event?: K): this {
|
||||
if (event) {
|
||||
this.emitter.removeAllListeners(event)
|
||||
} else {
|
||||
this.emitter.removeAllListeners()
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of listeners for a specific event.
|
||||
*
|
||||
* @param event - The event name to count listeners for
|
||||
* @returns The number of listeners
|
||||
*/
|
||||
listenerCount<K extends keyof ClineSessionEvents>(event: K): number {
|
||||
return this.emitter.listenerCount(event)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,356 +0,0 @@
|
||||
/**
|
||||
* Permission handling for ACP integration.
|
||||
*
|
||||
* This module handles the translation between ACP permission requests/responses
|
||||
* and Cline's internal permission system. It maps ClineAsk types to appropriate
|
||||
* ACP permission options and translates user responses back to Cline's format.
|
||||
*
|
||||
* @module acp/permissionHandler
|
||||
*/
|
||||
|
||||
import type * as acp from "@agentclientprotocol/sdk"
|
||||
import type { ClineAsk } from "@shared/ExtensionMessage"
|
||||
import type { ClineAskResponse } from "@shared/WebviewMessage"
|
||||
import { Logger } from "@/shared/services/Logger.js"
|
||||
import type { AcpSessionState, ClinePermissionOption } from "./types.js"
|
||||
|
||||
/**
|
||||
* Standard permission options for operations that support "always allow".
|
||||
* Used for commands, tools, and MCP server operations.
|
||||
*/
|
||||
const STANDARD_PERMISSION_OPTIONS: ClinePermissionOption[] = [
|
||||
{ kind: "allow_once", optionId: "allow_once", name: "Allow Once" },
|
||||
{ kind: "allow_always", optionId: "allow_always", name: "Always Allow" },
|
||||
{ kind: "reject_once", optionId: "reject_once", name: "Reject" },
|
||||
]
|
||||
|
||||
/**
|
||||
* Permission options for operations that don't support "always allow".
|
||||
* Used for browser actions and other one-time operations.
|
||||
*/
|
||||
const RESTRICTED_PERMISSION_OPTIONS: ClinePermissionOption[] = [
|
||||
{ kind: "allow_once", optionId: "allow_once", name: "Allow Once" },
|
||||
{ kind: "reject_once", optionId: "reject_once", name: "Reject" },
|
||||
]
|
||||
|
||||
/**
|
||||
* Mapping of ClineAsk types to their permission option sets.
|
||||
*/
|
||||
const ASK_TYPE_PERMISSION_MAP: Partial<Record<ClineAsk, ClinePermissionOption[]>> = {
|
||||
// Commands support "always allow" for auto-approval
|
||||
command: STANDARD_PERMISSION_OPTIONS,
|
||||
|
||||
// Tool operations support "always allow"
|
||||
tool: STANDARD_PERMISSION_OPTIONS,
|
||||
|
||||
// MCP server operations support "always allow"
|
||||
use_mcp_server: STANDARD_PERMISSION_OPTIONS,
|
||||
|
||||
// Browser actions are one-time, no "always allow"
|
||||
browser_action_launch: RESTRICTED_PERMISSION_OPTIONS,
|
||||
|
||||
// Command output continuation - simple allow/reject
|
||||
command_output: RESTRICTED_PERMISSION_OPTIONS,
|
||||
}
|
||||
|
||||
/**
|
||||
* ClineAsk types that require permission handling.
|
||||
* Other ask types (like followup, plan_mode_respond) don't need permission UI.
|
||||
*/
|
||||
const PERMISSION_REQUIRING_ASK_TYPES: Set<ClineAsk> = new Set([
|
||||
"command",
|
||||
"tool",
|
||||
"browser_action_launch",
|
||||
"use_mcp_server",
|
||||
"command_output",
|
||||
])
|
||||
|
||||
/**
|
||||
* Result of handling a permission response.
|
||||
*/
|
||||
export interface PermissionHandlerResult {
|
||||
/** Cline's internal response type */
|
||||
response: ClineAskResponse
|
||||
/** Optional text to pass with the response */
|
||||
text?: string
|
||||
/** Whether "always allow" was selected (for auto-approval tracking) */
|
||||
alwaysAllow?: boolean
|
||||
/** Whether the request was cancelled */
|
||||
cancelled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a ClineAsk type requires permission handling.
|
||||
*
|
||||
* @param askType - The ClineAsk type to check
|
||||
* @returns True if the ask type requires permission UI
|
||||
*/
|
||||
export function requiresPermission(askType: ClineAsk): boolean {
|
||||
return PERMISSION_REQUIRING_ASK_TYPES.has(askType)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate permission options for a ClineAsk type.
|
||||
*
|
||||
* @param askType - The ClineAsk type
|
||||
* @returns Array of permission options, or undefined if the ask type doesn't require permission
|
||||
*/
|
||||
export function getPermissionOptionsForAskType(askType: ClineAsk): acp.PermissionOption[] | undefined {
|
||||
const options = ASK_TYPE_PERMISSION_MAP[askType]
|
||||
if (!options) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Convert to ACP PermissionOption format
|
||||
return options.map((opt) => ({
|
||||
kind: opt.kind,
|
||||
optionId: opt.optionId,
|
||||
name: opt.name,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an ACP permission response and translate it to Cline's format.
|
||||
*
|
||||
* @param response - The ACP permission response from the client
|
||||
* @param askType - The original ClineAsk type that triggered the permission request
|
||||
* @returns The translated result for Cline's handleWebviewAskResponse
|
||||
*/
|
||||
export function handlePermissionResponse(response: acp.RequestPermissionResponse, askType: ClineAsk): PermissionHandlerResult {
|
||||
// Check if cancelled
|
||||
if (response.outcome.outcome === "cancelled") {
|
||||
return {
|
||||
response: "noButtonClicked",
|
||||
cancelled: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Get the selected option ID
|
||||
const optionId = response.outcome.optionId
|
||||
|
||||
// Translate the option to Cline's response format
|
||||
switch (optionId) {
|
||||
case "allow_once":
|
||||
return {
|
||||
response: "yesButtonClicked",
|
||||
alwaysAllow: false,
|
||||
}
|
||||
|
||||
case "allow_always":
|
||||
return {
|
||||
response: "yesButtonClicked",
|
||||
alwaysAllow: true,
|
||||
}
|
||||
|
||||
case "reject_once":
|
||||
case "reject_always":
|
||||
return {
|
||||
response: "noButtonClicked",
|
||||
alwaysAllow: false,
|
||||
}
|
||||
|
||||
default:
|
||||
// Unknown option ID - treat as rejection for safety
|
||||
Logger.error(`[permissionHandler] Unknown permission option: ${optionId}`)
|
||||
return {
|
||||
response: "noButtonClicked",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a permission request for an ACP tool call.
|
||||
*
|
||||
* @param toolCall - The ACP tool call that needs permission
|
||||
* @param askType - The Cline ask type
|
||||
* @returns The permission request options, or null if no permission needed
|
||||
*/
|
||||
export function createPermissionRequest(
|
||||
toolCall: acp.ToolCall,
|
||||
askType: ClineAsk,
|
||||
): { toolCall: acp.ToolCall; options: acp.PermissionOption[] } | null {
|
||||
const options = getPermissionOptionsForAskType(askType)
|
||||
if (!options) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
toolCall,
|
||||
options,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track "always allow" decisions for auto-approval.
|
||||
* This maintains a set of tool/command patterns that have been auto-approved.
|
||||
*/
|
||||
export class AutoApprovalTracker {
|
||||
/** Set of auto-approved command prefixes */
|
||||
private autoApprovedCommands: Set<string> = new Set()
|
||||
|
||||
/** Set of auto-approved tool names */
|
||||
private autoApprovedTools: Set<string> = new Set()
|
||||
|
||||
/** Set of auto-approved MCP servers */
|
||||
private autoApprovedMcpServers: Set<string> = new Set()
|
||||
|
||||
/**
|
||||
* Record an "always allow" decision for a permission request.
|
||||
*
|
||||
* @param askType - The Cline ask type that was auto-approved
|
||||
* @param identifier - The identifier for the operation (command, tool name, etc.)
|
||||
*/
|
||||
recordAlwaysAllow(askType: ClineAsk, identifier: string): void {
|
||||
switch (askType) {
|
||||
case "command":
|
||||
// Store the first word of the command as the key
|
||||
const commandPrefix = identifier.split(" ")[0]
|
||||
this.autoApprovedCommands.add(commandPrefix)
|
||||
break
|
||||
|
||||
case "tool":
|
||||
this.autoApprovedTools.add(identifier)
|
||||
break
|
||||
|
||||
case "use_mcp_server":
|
||||
this.autoApprovedMcpServers.add(identifier)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an operation has been auto-approved.
|
||||
*
|
||||
* @param askType - The Cline ask type
|
||||
* @param identifier - The identifier for the operation
|
||||
* @returns True if the operation was previously auto-approved
|
||||
*/
|
||||
isAutoApproved(askType: ClineAsk, identifier: string): boolean {
|
||||
switch (askType) {
|
||||
case "command":
|
||||
const commandPrefix = identifier.split(" ")[0]
|
||||
return this.autoApprovedCommands.has(commandPrefix)
|
||||
|
||||
case "tool":
|
||||
return this.autoApprovedTools.has(identifier)
|
||||
|
||||
case "use_mcp_server":
|
||||
return this.autoApprovedMcpServers.has(identifier)
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all auto-approval records.
|
||||
*/
|
||||
clear(): void {
|
||||
this.autoApprovedCommands.clear()
|
||||
this.autoApprovedTools.clear()
|
||||
this.autoApprovedMcpServers.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a pending permission request for a session.
|
||||
*
|
||||
* This function coordinates the permission flow:
|
||||
* 1. Checks if the operation is already auto-approved
|
||||
* 2. If not, requests permission from the ACP client
|
||||
* 3. Tracks "always allow" decisions
|
||||
* 4. Returns the translated result for Cline
|
||||
*
|
||||
* @param requestPermission - Function to request permission from the ACP client
|
||||
* @param sessionId - The session ID
|
||||
* @param toolCall - The tool call requiring permission
|
||||
* @param askType - The Cline ask type
|
||||
* @param identifier - Identifier for auto-approval tracking
|
||||
* @param autoApprovalTracker - The auto-approval tracker
|
||||
* @returns The permission handler result
|
||||
*/
|
||||
export async function processPermissionRequest(
|
||||
requestPermission: (
|
||||
sessionId: string,
|
||||
toolCall: acp.ToolCall,
|
||||
options: acp.PermissionOption[],
|
||||
) => Promise<acp.RequestPermissionResponse>,
|
||||
sessionId: string,
|
||||
toolCall: acp.ToolCall,
|
||||
askType: ClineAsk,
|
||||
identifier: string,
|
||||
autoApprovalTracker?: AutoApprovalTracker,
|
||||
): Promise<PermissionHandlerResult> {
|
||||
// Check if already auto-approved
|
||||
if (autoApprovalTracker?.isAutoApproved(askType, identifier)) {
|
||||
return {
|
||||
response: "yesButtonClicked",
|
||||
alwaysAllow: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Get permission options for this ask type
|
||||
const options = getPermissionOptionsForAskType(askType)
|
||||
if (!options) {
|
||||
// No permission options defined - allow by default
|
||||
return {
|
||||
response: "yesButtonClicked",
|
||||
}
|
||||
}
|
||||
|
||||
// Request permission from the ACP client
|
||||
const response = await requestPermission(sessionId, toolCall, options)
|
||||
|
||||
// Handle the response
|
||||
const result = handlePermissionResponse(response, askType)
|
||||
|
||||
// Track "always allow" decisions
|
||||
if (result.alwaysAllow && autoApprovalTracker) {
|
||||
autoApprovalTracker.recordAlwaysAllow(askType, identifier)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the identifier for auto-approval tracking from a tool call.
|
||||
*
|
||||
* @param toolCall - The ACP tool call
|
||||
* @param askType - The Cline ask type
|
||||
* @returns The identifier string for auto-approval tracking
|
||||
*/
|
||||
export function getAutoApprovalIdentifier(toolCall: acp.ToolCall, askType: ClineAsk): string {
|
||||
const rawInput = toolCall.rawInput as Record<string, unknown> | undefined
|
||||
|
||||
switch (askType) {
|
||||
case "command":
|
||||
return (rawInput?.command as string) || toolCall.title
|
||||
|
||||
case "tool":
|
||||
// Try to get tool name from raw input or title
|
||||
return (rawInput?.tool as string) || toolCall.title
|
||||
|
||||
case "use_mcp_server":
|
||||
return (rawInput?.serverName as string) || toolCall.title
|
||||
|
||||
default:
|
||||
return toolCall.toolCallId
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the session state's pending tool call after permission is handled.
|
||||
*
|
||||
* @param sessionState - The session state to update
|
||||
* @param toolCallId - The tool call ID that was handled
|
||||
* @param approved - Whether the permission was approved
|
||||
*/
|
||||
export function updateSessionStateAfterPermission(sessionState: AcpSessionState, toolCallId: string, approved: boolean): void {
|
||||
// Remove from pending tool calls
|
||||
sessionState.pendingToolCalls.delete(toolCallId)
|
||||
|
||||
// Clear current tool call ID if it matches
|
||||
if (sessionState.currentToolCallId === toolCallId && !approved) {
|
||||
sessionState.currentToolCallId = undefined
|
||||
}
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
/**
|
||||
* Custom types and extensions for ACP integration with Cline CLI.
|
||||
*
|
||||
* This file extends the base ACP types with Cline-specific functionality.
|
||||
*/
|
||||
|
||||
import type * as acp from "@agentclientprotocol/sdk"
|
||||
import type { Controller } from "@/core/controller"
|
||||
|
||||
// ============================================================
|
||||
// Session Update Type Utilities
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Extract the sessionUpdate discriminator value from a SessionUpdate variant.
|
||||
*/
|
||||
export type SessionUpdateType = acp.SessionUpdate["sessionUpdate"]
|
||||
|
||||
/**
|
||||
* Extract the payload type for a given sessionUpdate discriminator value.
|
||||
* This removes the `sessionUpdate` discriminator field from the type.
|
||||
*/
|
||||
export type SessionUpdatePayload<T extends SessionUpdateType> = Omit<
|
||||
Extract<acp.SessionUpdate, { sessionUpdate: T }>,
|
||||
"sessionUpdate"
|
||||
>
|
||||
|
||||
// ============================================================
|
||||
// Permission Handler Callback Types
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Callback to resolve a permission request with the user's response.
|
||||
*/
|
||||
export type PermissionResolver = (response: acp.RequestPermissionResponse) => void
|
||||
|
||||
/**
|
||||
* Handler function for permission requests.
|
||||
* Called when the agent needs permission for a tool call.
|
||||
* The handler should present the request to the user and call resolve() with their response.
|
||||
*/
|
||||
export type PermissionHandler = (request: Omit<acp.RequestPermissionRequest, "sessionId">, resolve: PermissionResolver) => void
|
||||
|
||||
// ============================================================
|
||||
// Session Event Emitter Types
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Maps ACP SessionUpdate types to their event listener signatures.
|
||||
* Uses the sessionUpdate discriminator to derive event names and payload types.
|
||||
*/
|
||||
export type ClineSessionEvents = {
|
||||
[K in SessionUpdateType]: (payload: SessionUpdatePayload<K>) => void
|
||||
} & {
|
||||
/** Error event for session-level errors (not part of ACP SessionUpdate) */
|
||||
error: (error: Error) => void
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ClineAgent Options (decoupled from connection)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Options for creating a ClineAgent instance (decoupled from connection).
|
||||
*/
|
||||
export interface ClineAgentOptions {
|
||||
/** CLI version string */
|
||||
version: string
|
||||
/** Whether debug logging is enabled */
|
||||
debug?: boolean
|
||||
}
|
||||
|
||||
// Re-export common ACP types for convenience
|
||||
export type {
|
||||
Agent,
|
||||
AgentSideConnection,
|
||||
AudioContent,
|
||||
CancelNotification,
|
||||
ContentBlock,
|
||||
ImageContent,
|
||||
InitializeRequest,
|
||||
InitializeResponse,
|
||||
LoadSessionRequest,
|
||||
LoadSessionResponse,
|
||||
McpServer,
|
||||
ModelInfo,
|
||||
NewSessionRequest,
|
||||
NewSessionResponse,
|
||||
PermissionOption,
|
||||
PermissionOptionKind,
|
||||
PromptRequest,
|
||||
PromptResponse,
|
||||
ReadTextFileRequest,
|
||||
ReadTextFileResponse,
|
||||
RequestPermissionRequest,
|
||||
RequestPermissionResponse,
|
||||
SessionConfigOption,
|
||||
SessionModelState,
|
||||
SessionNotification,
|
||||
SessionUpdate,
|
||||
SetSessionConfigOptionRequest,
|
||||
SetSessionConfigOptionResponse,
|
||||
SetSessionModelRequest,
|
||||
SetSessionModelResponse,
|
||||
SetSessionModeRequest,
|
||||
SetSessionModeResponse,
|
||||
StopReason,
|
||||
TextContent,
|
||||
ToolCall,
|
||||
ToolCallStatus,
|
||||
ToolCallUpdate,
|
||||
ToolKind,
|
||||
WriteTextFileRequest,
|
||||
WriteTextFileResponse,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
|
||||
/**
|
||||
* Cline-specific agent capabilities extending the ACP base capabilities.
|
||||
*/
|
||||
export interface ClineAgentCapabilities {
|
||||
/** Support for loading sessions from disk */
|
||||
loadSession: boolean
|
||||
/** Prompt capabilities for the agent */
|
||||
promptCapabilities: {
|
||||
/** Support for image inputs */
|
||||
image: boolean
|
||||
/** Support for audio inputs */
|
||||
audio: boolean
|
||||
/** Support for embedded context (file resources) */
|
||||
embeddedContext: boolean
|
||||
}
|
||||
/** MCP server passthrough capabilities */
|
||||
mcpCapabilities: {
|
||||
/** Support for HTTP MCP servers */
|
||||
http: boolean
|
||||
/** Support for SSE MCP servers */
|
||||
sse: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cline agent info for ACP initialization response.
|
||||
*/
|
||||
export interface ClineAgentInfo {
|
||||
name: "cline"
|
||||
title: "Cline"
|
||||
version: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended session data stored by Cline for ACP sessions.
|
||||
* Maps to Cline's task history structure.
|
||||
*/
|
||||
export interface ClineAcpSession {
|
||||
/** Unique session/task ID */
|
||||
sessionId: string
|
||||
/** Working directory for the session */
|
||||
cwd: string
|
||||
/** Current mode (plan/act) */
|
||||
mode: "plan" | "act"
|
||||
/** MCP servers passed from the client */
|
||||
mcpServers: acp.McpServer[]
|
||||
/** Timestamp when session was created */
|
||||
createdAt: number
|
||||
/** Timestamp of last activity */
|
||||
lastActivityAt: number
|
||||
/** Whether this session was loaded from history (needs resume on first prompt) */
|
||||
isLoadedFromHistory?: boolean
|
||||
/** Controller instance for this session (manages task execution) */
|
||||
controller?: Controller
|
||||
/** Model ID override for plan mode (format: "provider/modelId") */
|
||||
planModeModelId?: string
|
||||
/** Model ID override for act mode (format: "provider/modelId") */
|
||||
actModeModelId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission option as presented to the ACP client.
|
||||
*/
|
||||
export interface ClinePermissionOption {
|
||||
kind: acp.PermissionOptionKind
|
||||
name: string
|
||||
optionId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping of Cline message types to their ACP session update equivalents.
|
||||
*/
|
||||
export type ClineToAcpUpdateMapping = {
|
||||
/** Text messages from the agent */
|
||||
text: "agent_message_chunk"
|
||||
/** Reasoning/thinking from the agent */
|
||||
reasoning: "agent_thought_chunk"
|
||||
/** Markdown content from the agent */
|
||||
markdown: "agent_message_chunk"
|
||||
/** Tool execution */
|
||||
tool: "tool_call"
|
||||
/** Command execution */
|
||||
command: "tool_call"
|
||||
/** Command output */
|
||||
command_output: "tool_call_update"
|
||||
/** Task completion */
|
||||
completion_result: "end_turn"
|
||||
/** Error messages */
|
||||
error: "tool_call_update" | "error"
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for creating an ACP agent instance.
|
||||
*/
|
||||
export interface AcpAgentOptions {
|
||||
/** CLI version string */
|
||||
version: string
|
||||
/** Whether debug logging is enabled */
|
||||
debug?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of translating a Cline message to ACP session update(s).
|
||||
* A single Cline message may produce multiple ACP updates.
|
||||
*/
|
||||
export interface TranslatedMessage {
|
||||
/** The session updates to send */
|
||||
updates: acp.SessionUpdate[]
|
||||
/** Whether this message requires a permission request */
|
||||
requiresPermission?: boolean
|
||||
/** Permission request details if required */
|
||||
permissionRequest?: Omit<acp.RequestPermissionRequest, "sessionId">
|
||||
/** The toolCallId that was created/used (for tracking across streaming updates) */
|
||||
toolCallId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* State tracking for an active ACP session within Cline.
|
||||
*/
|
||||
export interface AcpSessionState {
|
||||
/** Session ID */
|
||||
sessionId: string
|
||||
/** Whether the session is currently processing a prompt */
|
||||
isProcessing: boolean
|
||||
/** Current tool call ID being executed (if any) */
|
||||
currentToolCallId?: string
|
||||
/** Whether the session has been cancelled */
|
||||
cancelled: boolean
|
||||
/** Accumulated tool calls for permission batching */
|
||||
pendingToolCalls: Map<string, acp.ToolCall>
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
/**
|
||||
* Account info view component
|
||||
* Shows current provider, and for Cline provider: credit balance and organization name
|
||||
*/
|
||||
|
||||
import { Box, Text } from "ink"
|
||||
import React, { useCallback, useEffect, useState } from "react"
|
||||
import { Controller } from "@/core/controller"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { ClineAccountService } from "@/services/account/ClineAccountService"
|
||||
import { AuthService, ClineAccountOrganization } from "@/services/auth/AuthService"
|
||||
import { LoadingSpinner } from "./Spinner"
|
||||
|
||||
interface AccountInfoViewProps {
|
||||
controller: Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Capitalize provider name for display
|
||||
*/
|
||||
function capitalize(str: string): string {
|
||||
return str
|
||||
.split("-")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/**
|
||||
* Format balance as currency (balance is in microcredits, divide by 10000)
|
||||
*/
|
||||
function formatBalance(balance: number | null): string {
|
||||
if (balance === null || balance === undefined) {
|
||||
return "..."
|
||||
}
|
||||
return `$${(balance / 1000000).toFixed(2)}`
|
||||
}
|
||||
|
||||
export const AccountInfoView: React.FC<AccountInfoViewProps> = React.memo(({ controller }) => {
|
||||
const [provider, setProvider] = useState<string | null>(null)
|
||||
const [balance, setBalance] = useState<number | null>(null)
|
||||
const [organization, setOrganization] = useState<ClineAccountOrganization | null>(null)
|
||||
const [email, setEmail] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchAccountInfo = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
// Get current provider from state
|
||||
const stateManager = StateManager.get()
|
||||
const mode = stateManager.getGlobalSettingsKey("mode") as string
|
||||
const providerKey = mode === "act" ? "actModeApiProvider" : "planModeApiProvider"
|
||||
const currentProvider = stateManager.getGlobalSettingsKey(providerKey) as string
|
||||
setProvider(currentProvider || "cline")
|
||||
|
||||
// If using Cline provider, fetch additional info
|
||||
if (currentProvider === "cline") {
|
||||
const authService = AuthService.getInstance(controller)
|
||||
|
||||
// Wait for auth to be restored - poll until we have auth info or timeout
|
||||
let authInfo = authService.getInfo()
|
||||
let attempts = 0
|
||||
const maxAttempts = 20 // 2 seconds max
|
||||
while (!authInfo?.user?.uid && attempts < maxAttempts) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
authInfo = authService.getInfo()
|
||||
attempts++
|
||||
}
|
||||
|
||||
// Get user info
|
||||
if (authInfo?.user?.email) {
|
||||
setEmail(authInfo.user.email)
|
||||
} else {
|
||||
// User not logged in to Cline
|
||||
setEmail(null)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Get organization info
|
||||
const organizations = authService.getUserOrganizations()
|
||||
if (organizations) {
|
||||
const activeOrg = organizations.find((org) => org.active)
|
||||
if (activeOrg) {
|
||||
setOrganization(activeOrg)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch credit balance
|
||||
try {
|
||||
const accountService = ClineAccountService.getInstance()
|
||||
const activeOrgId = authService.getActiveOrganizationId()
|
||||
|
||||
if (activeOrgId) {
|
||||
// Fetch organization balance
|
||||
const orgBalance = await accountService.fetchOrganizationCreditsRPC(activeOrgId)
|
||||
if (orgBalance?.balance !== undefined) {
|
||||
setBalance(orgBalance.balance)
|
||||
}
|
||||
} else {
|
||||
// Fetch personal balance
|
||||
const balanceData = await accountService.fetchBalanceRPC()
|
||||
if (balanceData?.balance !== undefined) {
|
||||
setBalance(balanceData.balance)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Balance fetch failed, but we can still show other info
|
||||
// Don't log to console as it pollutes CLI output
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load account info")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [controller])
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccountInfo()
|
||||
}, [fetchAccountInfo])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box>
|
||||
<LoadingSpinner />
|
||||
<Text color="gray"> Loading account info...</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color="red">Error: {error}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// If not using Cline provider, just show the provider name
|
||||
if (provider !== "cline") {
|
||||
return (
|
||||
<Box>
|
||||
<Text color="gray">Provider: </Text>
|
||||
<Text color="cyan">{capitalize(provider || "Not configured")}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Cline provider but not logged in
|
||||
if (!email) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color="gray">Provider: </Text>
|
||||
<Text color="cyan">Cline</Text>
|
||||
<Text color="gray"> • </Text>
|
||||
<Text color="yellow">Not logged in (run 'cline auth' to sign in)</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Cline provider - show full account info
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text color="gray">Provider: </Text>
|
||||
<Text color="cyan">Cline</Text>
|
||||
{email && (
|
||||
<Box>
|
||||
<Text color="gray"> • </Text>
|
||||
<Text color="white">{email}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
{organization ? (
|
||||
<Box>
|
||||
<Text color="gray">Organization: </Text>
|
||||
<Text color="magenta">{organization.name}</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
<Text color="gray">Account: </Text>
|
||||
<Text color="white">Personal</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Text color="gray"> • Credits: </Text>
|
||||
<Text color="green">{formatBalance(balance)}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
@@ -1,337 +0,0 @@
|
||||
/**
|
||||
* 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 { useTerminalSize } from "../hooks/useTerminalSize"
|
||||
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"
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine which buttons are actually visible based on config
|
||||
* Cancel is hidden in the CLI (ThinkingIndicator handles that with esc)
|
||||
*/
|
||||
export function getVisibleButtons(config: ButtonConfig) {
|
||||
const hiddenActions = ["cancel"]
|
||||
const hasPrimary = !!config.primaryText && !hiddenActions.includes(config.primaryAction || "")
|
||||
const hasSecondary = !!config.secondaryText && !hiddenActions.includes(config.secondaryAction || "")
|
||||
return { hasPrimary, hasSecondary }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
const { hasPrimary, hasSecondary } = getVisibleButtons(config)
|
||||
|
||||
if (!hasPrimary && !hasSecondary) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Calculate button widths based on terminal width
|
||||
const { columns: terminalWidth } = useTerminalSize()
|
||||
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!, hasPrimary ? "2" : "1")}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import { Text } from "ink"
|
||||
import { render } from "ink-testing-library"
|
||||
import React from "react"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { App } from "./App"
|
||||
|
||||
// Mock the child components to isolate App routing logic
|
||||
vi.mock("./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", () => ({
|
||||
HistoryView: ({ items }: any) => React.createElement(Text, null, `HistoryView: ${items?.length || 0} items`),
|
||||
}))
|
||||
|
||||
vi.mock("./ConfigView", () => ({
|
||||
ConfigView: ({ dataDir }: any) => React.createElement(Text, null, `ConfigView: ${dataDir}`),
|
||||
}))
|
||||
|
||||
vi.mock("./AuthView", () => ({
|
||||
AuthView: ({ quickSetup }: any) => React.createElement(Text, null, `AuthView: ${quickSetup?.provider || "no-provider"}`),
|
||||
}))
|
||||
|
||||
vi.mock("../context/TaskContext", () => ({
|
||||
TaskContextProvider: ({ children }: any) => children,
|
||||
}))
|
||||
|
||||
vi.mock("../context/StdinContext", () => ({
|
||||
StdinProvider: ({ children }: any) => children,
|
||||
}))
|
||||
|
||||
// Mock useTerminalSize to prevent EventEmitter memory leak warnings from resize listeners
|
||||
vi.mock("../hooks/useTerminalSize", () => ({
|
||||
useTerminalSize: () => ({ columns: 80, rows: 24, resizeKey: 0 }),
|
||||
}))
|
||||
|
||||
describe("App", () => {
|
||||
const mockController = {
|
||||
dispose: vi.fn(),
|
||||
stateManager: { flushPendingState: vi.fn() },
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("view routing", () => {
|
||||
it("should render ChatView when view is task", () => {
|
||||
const { lastFrame } = render(<App controller={mockController} taskId="test-task" view="task" />)
|
||||
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")
|
||||
})
|
||||
|
||||
it("should render HistoryView when view is history", () => {
|
||||
const historyItems = [
|
||||
{ id: "1", ts: Date.now(), task: "Task 1" },
|
||||
{ id: "2", ts: Date.now(), task: "Task 2" },
|
||||
]
|
||||
const { lastFrame } = render(<App controller={mockController} historyItems={historyItems} view="history" />)
|
||||
expect(lastFrame()).toContain("HistoryView")
|
||||
expect(lastFrame()).toContain("2 items")
|
||||
})
|
||||
|
||||
it("should render ConfigView when view is config", () => {
|
||||
const { lastFrame } = render(
|
||||
<App dataDir="/path/to/config" globalState={{ key: "value" }} view="config" workspaceState={{}} />,
|
||||
)
|
||||
expect(lastFrame()).toContain("ConfigView")
|
||||
expect(lastFrame()).toContain("/path/to/config")
|
||||
})
|
||||
|
||||
it("should render AuthView when view is auth", () => {
|
||||
const { lastFrame } = render(<App authQuickSetup={{ provider: "openai" }} controller={mockController} view="auth" />)
|
||||
expect(lastFrame()).toContain("AuthView")
|
||||
expect(lastFrame()).toContain("openai")
|
||||
})
|
||||
|
||||
it("should render ChatView when view is welcome", () => {
|
||||
const { lastFrame } = render(
|
||||
<App controller={mockController} onWelcomeExit={() => {}} onWelcomeSubmit={() => {}} view="welcome" />,
|
||||
)
|
||||
expect(lastFrame()).toContain("ChatView")
|
||||
})
|
||||
})
|
||||
|
||||
describe("default props", () => {
|
||||
it("should use default verbose=false with jsonOutput", () => {
|
||||
const { lastFrame } = render(<App controller={mockController} jsonOutput={true} view="task" />)
|
||||
expect(lastFrame()).toContain("verbose=false")
|
||||
})
|
||||
|
||||
it("should use empty array for historyItems by default", () => {
|
||||
const { lastFrame } = render(<App controller={mockController} view="history" />)
|
||||
expect(lastFrame()).toContain("0 items")
|
||||
})
|
||||
})
|
||||
|
||||
describe("props passing", () => {
|
||||
it("should pass verbose to TaskJsonView", () => {
|
||||
const { lastFrame } = render(<App controller={mockController} jsonOutput={true} verbose={true} view="task" />)
|
||||
expect(lastFrame()).toContain("verbose=true")
|
||||
})
|
||||
|
||||
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")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,279 +0,0 @@
|
||||
/**
|
||||
* Main App component for Ink CLI
|
||||
* Routes between different views (task, history, config)
|
||||
*/
|
||||
|
||||
import { Box } from "ink"
|
||||
import React, { ReactNode, useCallback, useState } from "react"
|
||||
import { StdinProvider } from "../context/StdinContext"
|
||||
import { TaskContextProvider } from "../context/TaskContext"
|
||||
import { useTerminalSize } from "../hooks/useTerminalSize"
|
||||
import { AuthView } from "./AuthView"
|
||||
import { ChatView } from "./ChatView"
|
||||
import { ConfigView } from "./ConfigView"
|
||||
import { HistoryView } from "./HistoryView"
|
||||
import { TaskJsonView } from "./TaskJsonView"
|
||||
|
||||
export type ViewType = "task" | "history" | "config" | "auth" | "welcome"
|
||||
|
||||
interface HistoryPagination {
|
||||
page: number
|
||||
totalPages: number
|
||||
totalCount: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
interface HookInfo {
|
||||
name: string
|
||||
enabled: boolean
|
||||
absolutePath: string
|
||||
}
|
||||
|
||||
interface WorkspaceHooks {
|
||||
workspaceName: string
|
||||
hooks: HookInfo[]
|
||||
}
|
||||
|
||||
interface SkillInfo {
|
||||
name: string
|
||||
description: string
|
||||
path: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface AppProps {
|
||||
view: ViewType
|
||||
taskId?: string
|
||||
controller?: any
|
||||
// Output Style
|
||||
verbose?: boolean
|
||||
jsonOutput?: boolean
|
||||
// Status Callbacks
|
||||
onComplete?: () => void
|
||||
onError?: () => void
|
||||
// For history view
|
||||
historyItems?: Array<{ id: string; ts: number; task?: string; totalCost?: number; modelId?: string }>
|
||||
historyAllItems?: Array<{ id: string; ts: number; task?: string; totalCost?: number; modelId?: string }>
|
||||
historyPagination?: HistoryPagination
|
||||
onHistoryPageChange?: (page: number) => void
|
||||
// For config view
|
||||
dataDir?: string
|
||||
globalState?: Record<string, any>
|
||||
workspaceState?: Record<string, any>
|
||||
// Rules toggles
|
||||
globalClineRulesToggles?: Record<string, boolean>
|
||||
localClineRulesToggles?: Record<string, boolean>
|
||||
localCursorRulesToggles?: Record<string, boolean>
|
||||
localWindsurfRulesToggles?: Record<string, boolean>
|
||||
localAgentsRulesToggles?: Record<string, boolean>
|
||||
onToggleRule?: (isGlobal: boolean, rulePath: string, enabled: boolean, ruleType: string) => void
|
||||
// Workflow toggles
|
||||
globalWorkflowToggles?: Record<string, boolean>
|
||||
localWorkflowToggles?: Record<string, boolean>
|
||||
onToggleWorkflow?: (isGlobal: boolean, workflowPath: string, enabled: boolean) => void
|
||||
// Hooks
|
||||
hooksEnabled?: boolean
|
||||
globalHooks?: HookInfo[]
|
||||
workspaceHooks?: WorkspaceHooks[]
|
||||
onToggleHook?: (isGlobal: boolean, hookName: string, enabled: boolean, workspaceName?: string) => void
|
||||
// Skills
|
||||
skillsEnabled?: boolean
|
||||
globalSkills?: SkillInfo[]
|
||||
localSkills?: SkillInfo[]
|
||||
onToggleSkill?: (isGlobal: boolean, skillPath: string, enabled: boolean) => void
|
||||
// For auth view
|
||||
authQuickSetup?: {
|
||||
provider?: string
|
||||
apikey?: string
|
||||
modelid?: string
|
||||
baseurl?: string
|
||||
}
|
||||
// For welcome view
|
||||
onWelcomeSubmit?: (prompt: string, imagePaths: string[]) => void
|
||||
onWelcomeExit?: () => void
|
||||
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,
|
||||
historyItems = [],
|
||||
historyAllItems,
|
||||
historyPagination,
|
||||
onHistoryPageChange,
|
||||
dataDir = "",
|
||||
globalState = {},
|
||||
workspaceState = {},
|
||||
// Rules
|
||||
globalClineRulesToggles,
|
||||
localClineRulesToggles,
|
||||
localCursorRulesToggles,
|
||||
localWindsurfRulesToggles,
|
||||
localAgentsRulesToggles,
|
||||
onToggleRule,
|
||||
// Workflows
|
||||
globalWorkflowToggles,
|
||||
localWorkflowToggles,
|
||||
onToggleWorkflow,
|
||||
// Hooks
|
||||
hooksEnabled,
|
||||
globalHooks,
|
||||
workspaceHooks,
|
||||
onToggleHook,
|
||||
// Skills
|
||||
skillsEnabled,
|
||||
globalSkills,
|
||||
localSkills,
|
||||
onToggleSkill,
|
||||
authQuickSetup,
|
||||
onWelcomeSubmit,
|
||||
onWelcomeExit,
|
||||
initialPrompt,
|
||||
initialImages,
|
||||
isRawModeSupported = true,
|
||||
robotTopRow,
|
||||
}) => {
|
||||
const { resizeKey } = useTerminalSize()
|
||||
const [currentView, setCurrentView] = useState<ViewType>(initialView)
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | undefined>(taskId)
|
||||
|
||||
const handleSelectTask = useCallback((taskId: string) => {
|
||||
setSelectedTaskId(taskId)
|
||||
setCurrentView("task")
|
||||
}, [])
|
||||
|
||||
const handleNavigateToWelcome = useCallback(() => {
|
||||
setCurrentView("welcome")
|
||||
}, [])
|
||||
|
||||
// Handle welcome submit when navigating internally (e.g., from auth -> welcome)
|
||||
const _handleInternalWelcomeSubmit = useCallback(
|
||||
async (prompt: string, imagePaths: string[]) => {
|
||||
if (onWelcomeSubmit) {
|
||||
// If external handler provided, use it
|
||||
onWelcomeSubmit(prompt, imagePaths)
|
||||
} else if (controller && prompt.trim()) {
|
||||
// Otherwise, start a task directly via controller
|
||||
setCurrentView("task")
|
||||
// Convert image paths to data URLs if needed
|
||||
const imageDataUrls =
|
||||
imagePaths.length > 0
|
||||
? await Promise.all(
|
||||
imagePaths.map(async (p) => {
|
||||
try {
|
||||
const fs = await import("fs/promises")
|
||||
const path = await import("path")
|
||||
const data = await fs.readFile(p)
|
||||
const ext = path.extname(p).toLowerCase().slice(1)
|
||||
const mimeType = ext === "jpg" ? "jpeg" : ext
|
||||
return `data:image/${mimeType};base64,${data.toString("base64")}`
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}),
|
||||
)
|
||||
: []
|
||||
const validImages = imageDataUrls.filter((img): img is string => img !== null)
|
||||
await controller.initTask(prompt.trim(), validImages.length > 0 ? validImages : undefined)
|
||||
}
|
||||
},
|
||||
[onWelcomeSubmit, controller],
|
||||
)
|
||||
|
||||
let content: ReactNode
|
||||
|
||||
switch (currentView) {
|
||||
case "history":
|
||||
content = (
|
||||
<HistoryView
|
||||
allItems={historyAllItems}
|
||||
controller={controller}
|
||||
items={historyItems}
|
||||
onPageChange={onHistoryPageChange}
|
||||
onSelectTask={handleSelectTask}
|
||||
pagination={historyPagination}
|
||||
/>
|
||||
)
|
||||
break
|
||||
|
||||
case "config":
|
||||
content = (
|
||||
<ConfigView
|
||||
dataDir={dataDir}
|
||||
globalClineRulesToggles={globalClineRulesToggles}
|
||||
globalHooks={globalHooks}
|
||||
globalSkills={globalSkills}
|
||||
globalState={globalState}
|
||||
globalWorkflowToggles={globalWorkflowToggles}
|
||||
hooksEnabled={hooksEnabled}
|
||||
localAgentsRulesToggles={localAgentsRulesToggles}
|
||||
localClineRulesToggles={localClineRulesToggles}
|
||||
localCursorRulesToggles={localCursorRulesToggles}
|
||||
localSkills={localSkills}
|
||||
localWindsurfRulesToggles={localWindsurfRulesToggles}
|
||||
localWorkflowToggles={localWorkflowToggles}
|
||||
onToggleHook={onToggleHook}
|
||||
onToggleRule={onToggleRule}
|
||||
onToggleSkill={onToggleSkill}
|
||||
onToggleWorkflow={onToggleWorkflow}
|
||||
skillsEnabled={skillsEnabled}
|
||||
workspaceHooks={workspaceHooks}
|
||||
workspaceState={workspaceState}
|
||||
/>
|
||||
)
|
||||
break
|
||||
|
||||
case "auth":
|
||||
content = (
|
||||
<AuthView
|
||||
controller={controller}
|
||||
onComplete={onComplete}
|
||||
onError={onError}
|
||||
onNavigateToWelcome={handleNavigateToWelcome}
|
||||
quickSetup={authQuickSetup}
|
||||
/>
|
||||
)
|
||||
break
|
||||
|
||||
case "task":
|
||||
case "welcome":
|
||||
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 (
|
||||
<StdinProvider isRawModeSupported={isRawModeSupported}>
|
||||
<Box key={resizeKey}>{content}</Box>
|
||||
</StdinProvider>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,374 +0,0 @@
|
||||
/**
|
||||
* User input prompt component
|
||||
* Handles different types of user interactions (text input, confirmations, choices)
|
||||
*/
|
||||
|
||||
import type { ClineAsk } from "@shared/ExtensionMessage"
|
||||
import { Box, Text, useApp, useInput } from "ink"
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { 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"
|
||||
|
||||
interface AskPromptProps {
|
||||
onRespond?: (response: string) => void
|
||||
}
|
||||
|
||||
type PromptType = "confirmation" | "text" | "options" | "plan_mode_text" | "completion" | "exit_confirmation" | "none"
|
||||
|
||||
function getPromptType(ask: ClineAsk, text: string): PromptType {
|
||||
switch (ask) {
|
||||
case "followup": {
|
||||
const parts = jsonParseSafe(text, {
|
||||
question: undefined as string | undefined,
|
||||
options: undefined as string[] | undefined,
|
||||
})
|
||||
if (parts.options && parts.options.length > 0) {
|
||||
return "options"
|
||||
}
|
||||
return "text"
|
||||
}
|
||||
case "plan_mode_respond": {
|
||||
const parts = jsonParseSafe(text, {
|
||||
question: undefined as string | undefined,
|
||||
options: undefined as string[] | undefined,
|
||||
})
|
||||
if (parts.options && parts.options.length > 0) {
|
||||
return "options"
|
||||
}
|
||||
// Plan mode without options - allow text input or toggle to Act mode
|
||||
return "plan_mode_text"
|
||||
}
|
||||
case "completion_result":
|
||||
// Task completed - allow follow-up question or exit
|
||||
return "completion"
|
||||
|
||||
case "resume_task":
|
||||
case "resume_completed_task":
|
||||
return "exit_confirmation"
|
||||
|
||||
case "command":
|
||||
case "tool":
|
||||
case "browser_action_launch":
|
||||
case "use_mcp_server":
|
||||
return "confirmation"
|
||||
default:
|
||||
return "none"
|
||||
}
|
||||
}
|
||||
|
||||
export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
|
||||
const { exit } = useApp()
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const controller = useTaskController()
|
||||
const lastAskMessage = useLastCompletedAskMessage()
|
||||
const [textInput, setTextInput] = useState("")
|
||||
const [responded, setResponded] = useState(false)
|
||||
const lastAskTs = useRef<number | null>(null)
|
||||
|
||||
// Reset state when ask message changes
|
||||
useEffect(() => {
|
||||
if (lastAskMessage && lastAskMessage.ts !== lastAskTs.current) {
|
||||
lastAskTs.current = lastAskMessage.ts
|
||||
setTextInput("")
|
||||
setResponded(false)
|
||||
}
|
||||
}, [lastAskMessage])
|
||||
|
||||
const sendResponse = useCallback(
|
||||
async (responseType: string, text?: string) => {
|
||||
if (responded || !controller?.task) {
|
||||
return
|
||||
}
|
||||
setResponded(true)
|
||||
try {
|
||||
await controller.task.handleWebviewAskResponse(responseType, text)
|
||||
onRespond?.(text || responseType)
|
||||
} catch {
|
||||
// Controller may be disposed
|
||||
}
|
||||
},
|
||||
[controller, responded, onRespond],
|
||||
)
|
||||
|
||||
const toggleToActMode = useCallback(async () => {
|
||||
if (responded || !controller) {
|
||||
return
|
||||
}
|
||||
setResponded(true)
|
||||
try {
|
||||
await controller.togglePlanActMode("act")
|
||||
onRespond?.("Switched to Act mode")
|
||||
} catch {
|
||||
// Controller may be disposed
|
||||
}
|
||||
}, [controller, responded, onRespond])
|
||||
|
||||
// Handle keyboard input
|
||||
useInput(
|
||||
(input, key) => {
|
||||
// Filter out mouse escape sequences
|
||||
if (isMouseEscapeSequence(input)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!lastAskMessage || responded) {
|
||||
return
|
||||
}
|
||||
|
||||
const ask = lastAskMessage.ask as ClineAsk
|
||||
const text = lastAskMessage.text || ""
|
||||
const promptType = getPromptType(ask, text)
|
||||
|
||||
if (promptType === "confirmation" || promptType === "exit_confirmation") {
|
||||
// y/n confirmation
|
||||
if (input.toLowerCase() === "y") {
|
||||
sendResponse("yesButtonClicked")
|
||||
} else if (input.toLowerCase() === "n") {
|
||||
if (promptType === "exit_confirmation") {
|
||||
exit()
|
||||
return
|
||||
}
|
||||
sendResponse("noButtonClicked")
|
||||
}
|
||||
} else if (promptType === "options") {
|
||||
// Number selection for options, or free text input
|
||||
const parts = jsonParseSafe(text, { options: [] as string[] })
|
||||
if (key.return) {
|
||||
// Submit free text on Enter
|
||||
if (textInput.trim()) {
|
||||
sendResponse("messageResponse", textInput.trim())
|
||||
}
|
||||
} else if (key.backspace || key.delete) {
|
||||
setTextInput((prev) => prev.slice(0, -1))
|
||||
} else if (input && !key.ctrl && !key.meta) {
|
||||
// Check if it's a number for option selection (only when no text typed yet)
|
||||
const num = parseInt(input, 10)
|
||||
if (textInput === "" && !Number.isNaN(num) && num >= 1 && num <= parts.options.length) {
|
||||
const selectedOption = parts.options[num - 1]
|
||||
sendResponse("messageResponse", selectedOption)
|
||||
} else {
|
||||
// Regular character input for free text
|
||||
setTextInput((prev) => prev + input)
|
||||
}
|
||||
}
|
||||
} else if (promptType === "text") {
|
||||
// Text input mode
|
||||
if (key.return) {
|
||||
// Submit on Enter
|
||||
if (textInput.trim()) {
|
||||
sendResponse("messageResponse", textInput.trim())
|
||||
}
|
||||
} else if (key.backspace || key.delete) {
|
||||
setTextInput((prev) => prev.slice(0, -1))
|
||||
} else if (input && !key.ctrl && !key.meta) {
|
||||
// Regular character input
|
||||
setTextInput((prev) => prev + input)
|
||||
}
|
||||
} else if (promptType === "plan_mode_text") {
|
||||
// Plan mode text input - allows text response or toggle to Act mode
|
||||
if (key.return) {
|
||||
// Submit on Enter
|
||||
if (textInput.trim()) {
|
||||
sendResponse("messageResponse", textInput.trim())
|
||||
} else {
|
||||
// Empty enter = switch to Act mode
|
||||
toggleToActMode()
|
||||
}
|
||||
} else if (key.backspace || key.delete) {
|
||||
setTextInput((prev) => prev.slice(0, -1))
|
||||
} else if (input && !key.ctrl && !key.meta) {
|
||||
// Regular character input
|
||||
setTextInput((prev) => prev + input)
|
||||
}
|
||||
} else if (promptType === "completion") {
|
||||
// Task completed - allow follow-up question or exit
|
||||
if (key.return) {
|
||||
if (textInput.trim()) {
|
||||
// Send follow-up question
|
||||
sendResponse("messageResponse", textInput.trim())
|
||||
} else {
|
||||
// Empty enter = confirm completion (exit)
|
||||
sendResponse("yesButtonClicked")
|
||||
}
|
||||
} else if (key.backspace || key.delete) {
|
||||
setTextInput((prev) => prev.slice(0, -1))
|
||||
} else if (input && !key.ctrl && !key.meta) {
|
||||
// Regular character input
|
||||
setTextInput((prev) => prev + input)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported && !!lastAskMessage && !responded },
|
||||
)
|
||||
|
||||
if (!lastAskMessage || responded) {
|
||||
return null
|
||||
}
|
||||
|
||||
const ask = lastAskMessage.ask as ClineAsk
|
||||
const text = lastAskMessage.text || ""
|
||||
const promptType = getPromptType(ask, text)
|
||||
const icon = getCliMessagePrefixIcon(lastAskMessage)
|
||||
|
||||
if (promptType === "none") {
|
||||
return null
|
||||
}
|
||||
|
||||
switch (ask) {
|
||||
case "followup": {
|
||||
const parts = jsonParseSafe(text, {
|
||||
question: undefined as string | undefined,
|
||||
options: undefined as string[] | undefined,
|
||||
})
|
||||
|
||||
if (parts.options && parts.options.length > 0) {
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color="cyan">Select an option (enter number):</Text>
|
||||
{parts.options.map((opt, idx) => (
|
||||
<Box key={idx} marginLeft={2}>
|
||||
<Text>{`${idx + 1}. ${opt}`}</Text>
|
||||
</Box>
|
||||
))}
|
||||
<Box marginTop={1}>
|
||||
<Text>{icon} </Text>
|
||||
<Text color="cyan">Or type: </Text>
|
||||
<Text>{textInput}</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
<Text color="gray">(Enter number to select, or type response + Enter)</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Text input prompt
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text>{icon} </Text>
|
||||
<Text color="cyan">Reply: </Text>
|
||||
<Text>{textInput}</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
<Text color="gray">(Type your response and press Enter)</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
case "plan_mode_respond": {
|
||||
const parts = jsonParseSafe(text, {
|
||||
question: undefined as string | undefined,
|
||||
options: undefined as string[] | undefined,
|
||||
})
|
||||
|
||||
if (parts.options && parts.options.length > 0) {
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color="cyan">Select an option (enter number):</Text>
|
||||
{parts.options.map((opt, idx) => (
|
||||
<Box key={idx} marginLeft={2}>
|
||||
<Text>{`${idx + 1}. ${opt}`}</Text>
|
||||
</Box>
|
||||
))}
|
||||
<Box marginTop={1}>
|
||||
<Text>{icon} </Text>
|
||||
<Text color="cyan">Or type: </Text>
|
||||
<Text>{textInput}</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
<Text color="gray">(Enter number to select, or type response + Enter)</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Plan mode text input - show option to switch to Act mode
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text>{icon} </Text>
|
||||
<Text color="cyan">Reply: </Text>
|
||||
<Text>{textInput}</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
<Text color="gray">(Type response + Enter, or just Enter to switch to Act mode)</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
case "command":
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text>{icon} </Text>
|
||||
<Text color="yellow"> Execute this command? </Text>
|
||||
<Text color="gray">(y/n)</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
case "tool":
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text>{icon} </Text>
|
||||
<Text color="blue"> Use this tool? </Text>
|
||||
<Text color="gray">(y/n)</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
case "completion_result":
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text>{icon} </Text>
|
||||
<Text color="cyan">Follow-up: </Text>
|
||||
<Text>{textInput}</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
<Text color="gray">(Type follow-up question + Enter, or q to exit)</Text>
|
||||
</Box>
|
||||
)
|
||||
|
||||
case "resume_task":
|
||||
case "resume_completed_task":
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text>{icon} </Text>
|
||||
<Text color="cyan"> Resume task? </Text>
|
||||
<Text color="gray">(y/n)</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
case "browser_action_launch":
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text>{icon} </Text>
|
||||
<Text color="cyan"> Launch browser? </Text>
|
||||
<Text color="gray">(y/n)</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
case "use_mcp_server":
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text>{icon} </Text>
|
||||
<Text color="cyan"> Use MCP server? </Text>
|
||||
<Text color="gray">(y/n)</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,991 +0,0 @@
|
||||
/**
|
||||
* Auth view component
|
||||
* Handles interactive authentication and provider configuration
|
||||
*/
|
||||
|
||||
import { Box, Text, useApp, useInput } from "ink"
|
||||
import Spinner from "ink-spinner"
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
|
||||
import { AuthService } from "@/services/auth/AuthService"
|
||||
import { API_PROVIDERS_LIST, openAiCodexDefaultModelId, openRouterDefaultModelId } from "@/shared/api"
|
||||
import { ProviderToApiKeyMap } from "@/shared/storage"
|
||||
import { openExternal } from "@/utils/env"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { getAllFeaturedModels } from "../constants/featured-models"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { useScrollableList } from "../hooks/useScrollableList"
|
||||
import { type DetectedSources, detectImportSources, type ImportSource } from "../utils/import-configs"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { ApiKeyInput } from "./ApiKeyInput"
|
||||
import { StaticRobotFrame } from "./AsciiMotionCli"
|
||||
import { type BedrockConfig, BedrockSetup } from "./BedrockSetup"
|
||||
import { ImportView } from "./ImportView"
|
||||
import { getDefaultModelId, hasModelPicker, ModelPicker } from "./ModelPicker"
|
||||
import { getProviderLabel, getProviderOrder } from "./ProviderPicker"
|
||||
|
||||
type AuthStep =
|
||||
| "menu"
|
||||
| "provider"
|
||||
| "apikey"
|
||||
| "modelid"
|
||||
| "baseurl"
|
||||
| "saving"
|
||||
| "success"
|
||||
| "error"
|
||||
| "cline_auth"
|
||||
| "cline_model"
|
||||
| "openai_codex_auth"
|
||||
| "bedrock"
|
||||
| "import"
|
||||
|
||||
// Featured models loaded from shared constants
|
||||
const featuredModels = getAllFeaturedModels()
|
||||
|
||||
interface AuthViewProps {
|
||||
controller: any
|
||||
onComplete?: () => void
|
||||
onError?: () => void
|
||||
onNavigateToWelcome?: () => void
|
||||
// Quick setup options
|
||||
quickSetup?: {
|
||||
provider?: string
|
||||
apikey?: string
|
||||
modelid?: string
|
||||
baseurl?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface SelectItem {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Select component with keyboard navigation
|
||||
*/
|
||||
const Select: React.FC<{
|
||||
items: SelectItem[]
|
||||
onSelect: (value: string) => void
|
||||
label?: string
|
||||
}> = ({ items, onSelect, label }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1))
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0))
|
||||
} else if (key.return) {
|
||||
onSelect(items[selectedIndex].value)
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported },
|
||||
)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{label && (
|
||||
<Text bold color="cyan">
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
{items.map((item, index) => (
|
||||
<Box key={item.value}>
|
||||
<Text color={index === selectedIndex ? COLORS.primaryBlue : undefined}>
|
||||
{index === selectedIndex ? "❯ " : " "}
|
||||
{item.label}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
<Text color="gray">(Use arrow keys to navigate, Enter to select)</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Text input component - minimal, just the input field
|
||||
*/
|
||||
const TextInput: React.FC<{
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onSubmit: (value: string) => void
|
||||
placeholder?: string
|
||||
isPassword?: boolean
|
||||
}> = ({ value, onChange, onSubmit, placeholder, isPassword }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
// Filter out mouse escape sequences
|
||||
if (isMouseEscapeSequence(input)) {
|
||||
return
|
||||
}
|
||||
|
||||
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 },
|
||||
)
|
||||
|
||||
const displayValue = isPassword ? "•".repeat(value.length) : value
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color="white">{displayValue || placeholder || ""}</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onError, onNavigateToWelcome, quickSetup }) => {
|
||||
const { exit } = useApp()
|
||||
const [step, setStep] = useState<AuthStep>(quickSetup ? "saving" : "menu")
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>(
|
||||
StateManager.get().getApiConfiguration().actModeApiProvider ||
|
||||
StateManager.get().getApiConfiguration().planModeApiProvider ||
|
||||
"",
|
||||
)
|
||||
const [apiKey, setApiKey] = useState("")
|
||||
const [modelId, setModelId] = useState("")
|
||||
const [baseUrl, setBaseUrl] = useState("")
|
||||
const [errorMessage, setErrorMessage] = useState("")
|
||||
const [authStatus, setAuthStatus] = useState<string>("")
|
||||
const [providerSearch, setProviderSearch] = useState("")
|
||||
const [providerIndex, setProviderIndex] = useState(0)
|
||||
const [clineModelIndex, setClineModelIndex] = useState(0)
|
||||
const [importSources, setImportSources] = useState<DetectedSources>({ codex: false, opencode: false })
|
||||
const [importSource, setImportSource] = useState<ImportSource | null>(null)
|
||||
const [bedrockConfig, setBedrockConfig] = useState<BedrockConfig | null>(null)
|
||||
|
||||
// Use providers.json order, filtered to only available providers
|
||||
const sortedProviders = useMemo(() => {
|
||||
const availableProviders = new Set(API_PROVIDERS_LIST)
|
||||
return getProviderOrder().filter((p) => availableProviders.has(p))
|
||||
}, [])
|
||||
|
||||
// Main menu items - conditionally include import options
|
||||
const mainMenuItems: SelectItem[] = useMemo(() => {
|
||||
const items: SelectItem[] = [{ label: "Sign in with Cline account", value: "cline_auth" }]
|
||||
|
||||
// Add OpenAI Codex option for ChatGPT subscribers
|
||||
items.push({ label: "Sign in with ChatGPT Subscription", value: "openai_codex_auth" })
|
||||
|
||||
// Add import options if detected
|
||||
if (importSources.codex) {
|
||||
items.push({ label: "Import from Codex CLI", value: "import_codex" })
|
||||
}
|
||||
if (importSources.opencode) {
|
||||
items.push({ label: "Import from OpenCode", value: "import_opencode" })
|
||||
}
|
||||
|
||||
items.push({ label: "Use your own API key", value: "configure_byo" })
|
||||
items.push({ label: "Exit", value: "exit" })
|
||||
|
||||
return items
|
||||
}, [importSources])
|
||||
|
||||
// Provider menu items - filtered by search (searches both ID and display name)
|
||||
const providerItems: SelectItem[] = useMemo(() => {
|
||||
const search = providerSearch.toLowerCase()
|
||||
const filtered = providerSearch
|
||||
? sortedProviders.filter(
|
||||
(p) => p.toLowerCase().includes(search) || getProviderLabel(p).toLowerCase().includes(search),
|
||||
)
|
||||
: sortedProviders
|
||||
return filtered.map((p: string) => ({
|
||||
label: getProviderLabel(p),
|
||||
value: p,
|
||||
}))
|
||||
}, [sortedProviders, providerSearch])
|
||||
|
||||
// Use shared scrollable list hook for provider windowing
|
||||
const TOTAL_PROVIDER_ROWS = 8
|
||||
const {
|
||||
visibleStart: providerVisibleStart,
|
||||
visibleCount: providerVisibleCount,
|
||||
showTopIndicator: showProviderTopIndicator,
|
||||
showBottomIndicator: showProviderBottomIndicator,
|
||||
} = useScrollableList(providerItems.length, providerIndex, TOTAL_PROVIDER_ROWS)
|
||||
|
||||
const visibleProviderItems = useMemo(() => {
|
||||
return providerItems.slice(providerVisibleStart, providerVisibleStart + providerVisibleCount)
|
||||
}, [providerItems, providerVisibleStart, providerVisibleCount])
|
||||
|
||||
// Detect import sources on mount
|
||||
useEffect(() => {
|
||||
setImportSources(detectImportSources())
|
||||
}, [])
|
||||
|
||||
// Reset provider index when search changes
|
||||
useEffect(() => {
|
||||
setProviderIndex(0)
|
||||
}, [providerSearch])
|
||||
|
||||
// Set default model when entering model step
|
||||
useEffect(() => {
|
||||
if (step === "modelid" && hasModelPicker(selectedProvider)) {
|
||||
setModelId(getDefaultModelId(selectedProvider))
|
||||
}
|
||||
}, [step, selectedProvider])
|
||||
|
||||
// Handle quick setup
|
||||
useEffect(() => {
|
||||
if (quickSetup && step === "saving") {
|
||||
handleQuickSetup()
|
||||
}
|
||||
}, [quickSetup, step])
|
||||
|
||||
// Subscribe to auth status updates when in cline_auth step
|
||||
useEffect(() => {
|
||||
if (step !== "cline_auth") {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
// Create a streaming response handler that receives auth state updates
|
||||
const responseHandler = async (authState: { user?: { email?: string } }, _isLast?: boolean) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (authState.user && authState.user.email) {
|
||||
// Auth succeeded - save configuration and transition to success
|
||||
const stateManager = StateManager.get()
|
||||
const mode = stateManager.getGlobalSettingsKey("mode") || "act"
|
||||
const providerKey = mode === "act" ? "actModeApiProvider" : "planModeApiProvider"
|
||||
const modelIdKey = mode === "act" ? "actModeApiModelId" : "planModeApiModelId"
|
||||
const config: Record<string, string> = {
|
||||
actModeApiProvider: "cline",
|
||||
[providerKey]: "cline",
|
||||
[modelIdKey]: openRouterDefaultModelId,
|
||||
}
|
||||
stateManager.setApiConfiguration(config)
|
||||
stateManager.flushPendingState()
|
||||
|
||||
setSelectedProvider("cline")
|
||||
setModelId(config[modelIdKey])
|
||||
setStep("cline_model")
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to auth status updates
|
||||
const authService = AuthService.getInstance(controller)
|
||||
authService.subscribeToAuthStatusUpdate(controller, {}, responseHandler, `cli-auth-${Date.now()}`)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [step, controller])
|
||||
|
||||
const handleQuickSetup = async () => {
|
||||
if (!quickSetup) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { provider, apikey, modelid, baseurl } = quickSetup
|
||||
|
||||
// Validate required parameters
|
||||
if (!provider || !apikey || !modelid) {
|
||||
setErrorMessage("Quick setup requires --provider, --apikey, and --modelid flags")
|
||||
setStep("error")
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedProvider = provider.toLowerCase().trim()
|
||||
|
||||
if (!sortedProviders.includes(normalizedProvider)) {
|
||||
setErrorMessage(`Invalid provider '${provider}'. Supported providers: ${sortedProviders.join(", ")}`)
|
||||
setStep("error")
|
||||
return
|
||||
}
|
||||
|
||||
if (normalizedProvider === "bedrock") {
|
||||
setErrorMessage(
|
||||
"Bedrock provider is not supported for quick setup due to complex authentication requirements. Please use interactive setup.",
|
||||
)
|
||||
setStep("error")
|
||||
return
|
||||
}
|
||||
|
||||
if (baseurl && !["openai", "openai-native"].includes(normalizedProvider)) {
|
||||
setErrorMessage("Base URL is only supported for OpenAI and OpenAI-compatible providers")
|
||||
setStep("error")
|
||||
return
|
||||
}
|
||||
|
||||
// Save configuration
|
||||
const stateManager = StateManager.get()
|
||||
const config: Record<string, string> = {
|
||||
actModeApiProvider: normalizedProvider,
|
||||
planModeApiProvider: normalizedProvider,
|
||||
actModeApiModelId: modelid,
|
||||
planModeApiModelId: modelid,
|
||||
}
|
||||
|
||||
// Use provider-specific API key field
|
||||
const keyField = ProviderToApiKeyMap[normalizedProvider]
|
||||
if (keyField) {
|
||||
const fields = Array.isArray(keyField) ? keyField : [keyField]
|
||||
// Set the first key field for the provider
|
||||
config[fields[0]] = apikey
|
||||
} else {
|
||||
// Fallback to generic apiKey
|
||||
config.apiKey = apikey
|
||||
}
|
||||
|
||||
if (baseurl) {
|
||||
config.openAiBaseUrl = baseurl
|
||||
}
|
||||
|
||||
stateManager.setApiConfiguration(config)
|
||||
|
||||
await stateManager.flushPendingState()
|
||||
setSelectedProvider(normalizedProvider)
|
||||
setModelId(modelid)
|
||||
setBaseUrl(baseurl || "")
|
||||
setStep("success")
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : String(error))
|
||||
setStep("error")
|
||||
}
|
||||
}
|
||||
|
||||
// Start OpenAI Codex OAuth flow
|
||||
const startOpenAiCodexAuth = useCallback(async () => {
|
||||
try {
|
||||
// Get the authorization URL and start the callback server
|
||||
const authUrl = openAiCodexOAuthManager.startAuthorizationFlow()
|
||||
|
||||
// Open browser to authorization URL (uses cross-platform 'open' package)
|
||||
await openExternal(authUrl)
|
||||
|
||||
// Wait for the callback
|
||||
await openAiCodexOAuthManager.waitForCallback()
|
||||
|
||||
// Success - save configuration
|
||||
const stateManager = StateManager.get()
|
||||
const mode = stateManager.getGlobalSettingsKey("mode") || "act"
|
||||
const providerKey = mode === "act" ? "actModeApiProvider" : "planModeApiProvider"
|
||||
const modelIdKey = mode === "act" ? "actModeApiModelId" : "planModeApiModelId"
|
||||
const config: Record<string, string> = {
|
||||
actModeApiProvider: "openai-codex",
|
||||
planModeApiProvider: "openai-codex",
|
||||
[providerKey]: "openai-codex",
|
||||
[modelIdKey]: openAiCodexDefaultModelId,
|
||||
}
|
||||
stateManager.setApiConfiguration(config)
|
||||
await stateManager.flushPendingState()
|
||||
|
||||
setSelectedProvider("openai-codex")
|
||||
setModelId(openAiCodexDefaultModelId)
|
||||
setStep("success")
|
||||
} catch (error) {
|
||||
openAiCodexOAuthManager.cancelAuthorizationFlow()
|
||||
setErrorMessage(error instanceof Error ? error.message : String(error))
|
||||
setStep("error")
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Start Cline auth flow
|
||||
const startClineAuth = useCallback(async () => {
|
||||
try {
|
||||
setStep("cline_auth")
|
||||
setAuthStatus("Starting authentication...")
|
||||
await AuthService.getInstance(controller).createAuthRequest()
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : String(error))
|
||||
setStep("error")
|
||||
}
|
||||
}, [controller])
|
||||
|
||||
const handleMainMenuSelect = useCallback(
|
||||
(value: string) => {
|
||||
if (value === "exit") {
|
||||
exit()
|
||||
onComplete?.()
|
||||
} else if (value === "cline_auth") {
|
||||
startClineAuth()
|
||||
} else if (value === "openai_codex_auth") {
|
||||
setStep("openai_codex_auth")
|
||||
startOpenAiCodexAuth()
|
||||
} else if (value === "configure_byo") {
|
||||
setStep("provider")
|
||||
} else if (value === "import_codex") {
|
||||
setImportSource("codex")
|
||||
setStep("import")
|
||||
} else if (value === "import_opencode") {
|
||||
setImportSource("opencode")
|
||||
setStep("import")
|
||||
}
|
||||
},
|
||||
[exit, onComplete, startClineAuth, startOpenAiCodexAuth],
|
||||
)
|
||||
|
||||
const handleProviderSelect = useCallback(
|
||||
(value: string) => {
|
||||
setSelectedProvider(value)
|
||||
if (value === "cline") {
|
||||
startClineAuth()
|
||||
} else if (value === "openai-codex") {
|
||||
setStep("openai_codex_auth")
|
||||
startOpenAiCodexAuth()
|
||||
} else if (value === "bedrock") {
|
||||
setStep("bedrock")
|
||||
} else {
|
||||
setStep("apikey")
|
||||
}
|
||||
},
|
||||
[startClineAuth, startOpenAiCodexAuth],
|
||||
)
|
||||
|
||||
const handleApiKeySubmit = useCallback(
|
||||
(value: string) => {
|
||||
if (!value.trim() || !selectedProvider) {
|
||||
// Don't allow empty
|
||||
return
|
||||
}
|
||||
|
||||
// Store in local state - will be saved via StateManager in saveConfiguration
|
||||
setApiKey(value)
|
||||
setStep("modelid")
|
||||
},
|
||||
[selectedProvider],
|
||||
)
|
||||
|
||||
const saveConfiguration = useCallback(
|
||||
async (model: string, base: string) => {
|
||||
try {
|
||||
const stateManager = StateManager.get()
|
||||
const config: Record<string, string> = {
|
||||
actModeApiProvider: selectedProvider,
|
||||
planModeApiProvider: selectedProvider,
|
||||
actModeApiModelId: model,
|
||||
planModeApiModelId: model,
|
||||
apiProvider: selectedProvider,
|
||||
}
|
||||
|
||||
// Add API key or Bedrock-specific config
|
||||
if (selectedProvider === "bedrock" && bedrockConfig) {
|
||||
const bedrockFields: Record<string, unknown> = {
|
||||
awsAuthentication: bedrockConfig.awsAuthentication,
|
||||
awsRegion: bedrockConfig.awsRegion,
|
||||
awsUseCrossRegionInference: bedrockConfig.awsUseCrossRegionInference,
|
||||
}
|
||||
if (bedrockConfig.awsProfile !== undefined) bedrockFields.awsProfile = bedrockConfig.awsProfile
|
||||
if (bedrockConfig.awsAccessKey) bedrockFields.awsAccessKey = bedrockConfig.awsAccessKey
|
||||
if (bedrockConfig.awsSecretKey) bedrockFields.awsSecretKey = bedrockConfig.awsSecretKey
|
||||
if (bedrockConfig.awsSessionToken) bedrockFields.awsSessionToken = bedrockConfig.awsSessionToken
|
||||
Object.assign(config, bedrockFields)
|
||||
} else if (apiKey) {
|
||||
const keyField = ProviderToApiKeyMap[selectedProvider as keyof typeof ProviderToApiKeyMap]
|
||||
if (keyField) {
|
||||
const fields = Array.isArray(keyField) ? keyField : [keyField]
|
||||
config[fields[0]] = apiKey
|
||||
}
|
||||
}
|
||||
|
||||
if (base) {
|
||||
config.openAiBaseUrl = base
|
||||
}
|
||||
stateManager.setApiConfiguration(config)
|
||||
await stateManager.flushPendingState()
|
||||
|
||||
setStep("success")
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : String(error))
|
||||
setStep("error")
|
||||
}
|
||||
},
|
||||
[selectedProvider, apiKey, bedrockConfig],
|
||||
)
|
||||
|
||||
const handleModelIdSubmit = useCallback(
|
||||
(value: string) => {
|
||||
if (value.trim()) {
|
||||
setModelId(value)
|
||||
}
|
||||
// Only show baseurl step for OpenAI-like providers
|
||||
if (["openai", "openai-native"].includes(selectedProvider)) {
|
||||
setStep("baseurl")
|
||||
} else {
|
||||
setStep("saving")
|
||||
saveConfiguration(value, "")
|
||||
}
|
||||
},
|
||||
[selectedProvider, saveConfiguration],
|
||||
)
|
||||
|
||||
const handleBaseUrlSubmit = useCallback(
|
||||
(value: string) => {
|
||||
setBaseUrl(value)
|
||||
setStep("saving")
|
||||
saveConfiguration(modelId, value)
|
||||
},
|
||||
[modelId, saveConfiguration],
|
||||
)
|
||||
|
||||
const handleClineModelSelect = useCallback(
|
||||
(modelId: string) => {
|
||||
setModelId(modelId)
|
||||
setStep("saving")
|
||||
saveConfiguration(modelId, "")
|
||||
},
|
||||
[saveConfiguration],
|
||||
)
|
||||
|
||||
const handleBedrockComplete = useCallback((config: BedrockConfig) => {
|
||||
setBedrockConfig(config)
|
||||
setStep("modelid")
|
||||
}, [])
|
||||
|
||||
const handleImportComplete = useCallback(() => {
|
||||
setStep("success")
|
||||
}, [])
|
||||
|
||||
const handleImportCancel = useCallback(() => {
|
||||
setImportSource(null)
|
||||
setStep("menu")
|
||||
}, [])
|
||||
|
||||
// Auto-navigate to welcome after success (immediate)
|
||||
useEffect(() => {
|
||||
if (step === "success" && onNavigateToWelcome) {
|
||||
onNavigateToWelcome()
|
||||
}
|
||||
}, [step, onNavigateToWelcome])
|
||||
|
||||
// Error screen menu items
|
||||
const errorMenuItems: SelectItem[] = useMemo(() => {
|
||||
const items: SelectItem[] = [{ label: "Try again", value: "retry" }]
|
||||
if (onNavigateToWelcome) {
|
||||
items.push({ label: "Start a task", value: "welcome" })
|
||||
}
|
||||
items.push({ label: "Exit", value: "exit" })
|
||||
return items
|
||||
}, [onNavigateToWelcome])
|
||||
|
||||
const handleErrorMenuSelect = useCallback(
|
||||
(value: string) => {
|
||||
if (value === "retry") {
|
||||
// Reset state and go back to menu
|
||||
setErrorMessage("")
|
||||
setApiKey("")
|
||||
setModelId("")
|
||||
setBaseUrl("")
|
||||
setSelectedProvider("")
|
||||
setStep("menu")
|
||||
} else if (value === "welcome") {
|
||||
onNavigateToWelcome?.()
|
||||
} else if (value === "exit") {
|
||||
onError?.()
|
||||
exit()
|
||||
}
|
||||
},
|
||||
[onNavigateToWelcome, onError, exit],
|
||||
)
|
||||
|
||||
// Handle going back to previous step
|
||||
const goBack = useCallback(() => {
|
||||
switch (step) {
|
||||
case "provider":
|
||||
setProviderSearch("")
|
||||
setProviderIndex(0)
|
||||
setStep("menu")
|
||||
break
|
||||
case "apikey":
|
||||
setApiKey("")
|
||||
setStep("provider")
|
||||
break
|
||||
case "modelid":
|
||||
setModelId("")
|
||||
// Go back to cline_model if we came from there (Cline provider)
|
||||
if (selectedProvider === "cline") {
|
||||
setStep("cline_model")
|
||||
} else {
|
||||
setStep("apikey")
|
||||
}
|
||||
break
|
||||
case "baseurl":
|
||||
setBaseUrl("")
|
||||
setStep("modelid")
|
||||
break
|
||||
case "cline_auth":
|
||||
setStep("menu")
|
||||
break
|
||||
case "openai_codex_auth":
|
||||
openAiCodexOAuthManager.cancelAuthorizationFlow()
|
||||
setStep("menu")
|
||||
break
|
||||
case "cline_model":
|
||||
setClineModelIndex(0)
|
||||
setStep("menu")
|
||||
break
|
||||
case "bedrock":
|
||||
setBedrockConfig(null)
|
||||
setStep("provider")
|
||||
break
|
||||
case "import":
|
||||
setImportSource(null)
|
||||
setStep("menu")
|
||||
break
|
||||
case "error":
|
||||
setErrorMessage("")
|
||||
setStep("menu")
|
||||
break
|
||||
// menu, saving, success - no back action
|
||||
}
|
||||
}, [step, selectedProvider])
|
||||
|
||||
// Render the auth box content based on current step
|
||||
// Note: "menu" step is rendered separately in the main return for proper menuIndex tracking
|
||||
const renderAuthContent = () => {
|
||||
switch (step) {
|
||||
case "provider": {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="white">Select a provider</Text>
|
||||
<Text> </Text>
|
||||
<Box>
|
||||
<Text color="gray">Search: </Text>
|
||||
<Text color="white">{providerSearch}</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
<Text> </Text>
|
||||
{showProviderTopIndicator && <Text color="gray">... {providerVisibleStart} more above</Text>}
|
||||
{visibleProviderItems.map((item, i) => {
|
||||
const actualIndex = providerVisibleStart + i
|
||||
return (
|
||||
<Box key={item.value}>
|
||||
<Text color={actualIndex === providerIndex ? COLORS.primaryBlue : undefined}>
|
||||
{actualIndex === providerIndex ? "❯ " : " "}
|
||||
{item.label}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
{showProviderBottomIndicator && (
|
||||
<Text color="gray">
|
||||
... {providerItems.length - providerVisibleStart - providerVisibleCount} more below
|
||||
</Text>
|
||||
)}
|
||||
{providerItems.length === 0 && <Text color="gray">No providers match "{providerSearch}"</Text>}
|
||||
<Text> </Text>
|
||||
<Text color="gray">Type to search, arrows to navigate, Enter to select, Esc to go back</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
case "apikey":
|
||||
return (
|
||||
<ApiKeyInput
|
||||
isActive={step === "apikey"}
|
||||
onCancel={goBack}
|
||||
onChange={setApiKey}
|
||||
onSubmit={handleApiKeySubmit}
|
||||
providerName={getProviderLabel(selectedProvider)}
|
||||
value={apiKey}
|
||||
/>
|
||||
)
|
||||
|
||||
case "modelid":
|
||||
// Show model picker for providers with static model lists
|
||||
if (hasModelPicker(selectedProvider)) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="white">Select a model</Text>
|
||||
<Text> </Text>
|
||||
<ModelPicker
|
||||
controller={controller}
|
||||
isActive={step === "modelid"}
|
||||
onChange={setModelId}
|
||||
onSubmit={handleModelIdSubmit}
|
||||
provider={selectedProvider}
|
||||
/>
|
||||
<Text> </Text>
|
||||
<Text color="gray">Type to search, arrows to navigate, Enter to select, Esc to go back</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
// Fall back to text input for providers without static model lists
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="white">Model ID</Text>
|
||||
<Text> </Text>
|
||||
<Text color="gray">e.g., claude-sonnet-4-20250514, gpt-4o</Text>
|
||||
<Text> </Text>
|
||||
<TextInput onChange={setModelId} onSubmit={handleModelIdSubmit} placeholder="model-id" value={modelId} />
|
||||
<Text> </Text>
|
||||
<Text color="gray">Enter to continue, Esc to go back</Text>
|
||||
</Box>
|
||||
)
|
||||
|
||||
case "baseurl":
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="white">Base URL (optional)</Text>
|
||||
<Text> </Text>
|
||||
<Text color="gray">For self-hosted or proxy endpoints</Text>
|
||||
<Text> </Text>
|
||||
<TextInput
|
||||
onChange={setBaseUrl}
|
||||
onSubmit={handleBaseUrlSubmit}
|
||||
placeholder="https://api.example.com/v1"
|
||||
value={baseUrl}
|
||||
/>
|
||||
<Text> </Text>
|
||||
<Text color="gray">Enter to skip or continue, Esc to go back</Text>
|
||||
</Box>
|
||||
)
|
||||
|
||||
case "saving":
|
||||
return (
|
||||
<Box>
|
||||
<Text color={COLORS.primaryBlue}>
|
||||
<Spinner type="dots" />
|
||||
</Text>
|
||||
<Text color="white"> Saving configuration...</Text>
|
||||
</Box>
|
||||
)
|
||||
|
||||
case "cline_auth":
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text color={COLORS.primaryBlue}>
|
||||
<Spinner type="dots" />
|
||||
</Text>
|
||||
<Text color="white"> Waiting for browser sign-in...</Text>
|
||||
</Box>
|
||||
<Text> </Text>
|
||||
<Text color="gray">Complete sign-in in your browser, then return here.</Text>
|
||||
<Text> </Text>
|
||||
<Text color="gray">Esc to cancel</Text>
|
||||
</Box>
|
||||
)
|
||||
|
||||
case "openai_codex_auth":
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text color={COLORS.primaryBlue}>
|
||||
<Spinner type="dots" />
|
||||
</Text>
|
||||
<Text color="white"> Waiting for ChatGPT sign-in...</Text>
|
||||
</Box>
|
||||
<Text> </Text>
|
||||
<Text color="gray">Sign in with your ChatGPT account in the browser.</Text>
|
||||
<Text color="gray">Requires ChatGPT Plus, Pro, or Team subscription.</Text>
|
||||
<Text> </Text>
|
||||
<Text color="gray">Esc to cancel</Text>
|
||||
</Box>
|
||||
)
|
||||
|
||||
case "cline_model": {
|
||||
const allModels = featuredModels
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="white">Choose a model</Text>
|
||||
<Text> </Text>
|
||||
|
||||
{/* Model list */}
|
||||
{allModels.map((model, i) => (
|
||||
<Box flexDirection="column" key={model.id} marginBottom={1}>
|
||||
<Box>
|
||||
<Text color={i === clineModelIndex ? COLORS.primaryBlue : undefined}>
|
||||
{i === clineModelIndex ? "❯ " : " "}
|
||||
</Text>
|
||||
<Text bold color={i === clineModelIndex ? COLORS.primaryBlue : "white"}>
|
||||
{model.name}
|
||||
</Text>
|
||||
{model.label && (
|
||||
<>
|
||||
<Text> </Text>
|
||||
<Text
|
||||
backgroundColor={model.label === "FREE" ? "gray" : COLORS.primaryBlue}
|
||||
color="black">
|
||||
{" "}
|
||||
{model.label}{" "}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
<Box paddingLeft={2}>
|
||||
<Text color="gray">{model.description}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{/* Browse all option */}
|
||||
<Box>
|
||||
<Text color={clineModelIndex === allModels.length ? COLORS.primaryBlue : "gray"}>
|
||||
{clineModelIndex === allModels.length ? "❯ " : " "}
|
||||
Browse all models...
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Text> </Text>
|
||||
<Text color="gray">Arrows to navigate, Enter to select</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
case "bedrock":
|
||||
return (
|
||||
<BedrockSetup
|
||||
isActive={step === "bedrock"}
|
||||
onCancel={() => {
|
||||
setBedrockConfig(null)
|
||||
setStep("provider")
|
||||
}}
|
||||
onComplete={handleBedrockComplete}
|
||||
/>
|
||||
)
|
||||
|
||||
case "import":
|
||||
if (!importSource) {
|
||||
return null
|
||||
}
|
||||
return <ImportView onCancel={handleImportCancel} onComplete={handleImportComplete} source={importSource} />
|
||||
|
||||
case "error":
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color="red">
|
||||
Something went wrong
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text color="yellow">{errorMessage}</Text>
|
||||
<Text> </Text>
|
||||
<Select items={errorMenuItems} onSelect={handleErrorMenuSelect} />
|
||||
</Box>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// For menu step, we need to handle input at the top level
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const [menuIndex, setMenuIndex] = useState(0)
|
||||
|
||||
// Steps that allow going back with escape (apikey handled by ApiKeyInput component)
|
||||
const canGoBack = [
|
||||
"provider",
|
||||
"modelid",
|
||||
"baseurl",
|
||||
"cline_auth",
|
||||
"cline_model",
|
||||
"openai_codex_auth",
|
||||
"bedrock",
|
||||
"error",
|
||||
].includes(step)
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
// Handle escape to go back (except on menu)
|
||||
if (key.escape && canGoBack) {
|
||||
goBack()
|
||||
return
|
||||
}
|
||||
|
||||
if (step === "menu") {
|
||||
if (key.upArrow) {
|
||||
setMenuIndex((prev) => (prev > 0 ? prev - 1 : mainMenuItems.length - 1))
|
||||
} else if (key.downArrow) {
|
||||
setMenuIndex((prev) => (prev < mainMenuItems.length - 1 ? prev + 1 : 0))
|
||||
} else if (key.return) {
|
||||
handleMainMenuSelect(mainMenuItems[menuIndex].value)
|
||||
}
|
||||
} else if (step === "provider") {
|
||||
if (key.upArrow) {
|
||||
setProviderIndex((prev) => (prev > 0 ? prev - 1 : providerItems.length - 1))
|
||||
} else if (key.downArrow) {
|
||||
setProviderIndex((prev) => (prev < providerItems.length - 1 ? prev + 1 : 0))
|
||||
} else if (key.return) {
|
||||
if (providerItems[providerIndex]) {
|
||||
handleProviderSelect(providerItems[providerIndex].value)
|
||||
}
|
||||
} else if (key.backspace || key.delete) {
|
||||
setProviderSearch((prev) => prev.slice(0, -1))
|
||||
} else if (input && !key.ctrl && !key.meta) {
|
||||
setProviderSearch((prev) => prev + input)
|
||||
}
|
||||
} else if (step === "cline_model") {
|
||||
const allModels = featuredModels
|
||||
const maxIndex = allModels.length // includes "Browse all" option
|
||||
|
||||
if (key.upArrow) {
|
||||
setClineModelIndex((prev) => (prev > 0 ? prev - 1 : maxIndex))
|
||||
} else if (key.downArrow) {
|
||||
setClineModelIndex((prev) => (prev < maxIndex ? prev + 1 : 0))
|
||||
} else if (key.return) {
|
||||
if (clineModelIndex === allModels.length) {
|
||||
// "Browse all models" selected
|
||||
setStep("modelid")
|
||||
} else {
|
||||
// Featured model selected
|
||||
handleClineModelSelect(allModels[clineModelIndex].id)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Note: modelid step input is handled by ModelPicker component
|
||||
},
|
||||
{ isActive: isRawModeSupported && (step === "menu" || step === "provider" || step === "cline_model" || canGoBack) },
|
||||
)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingLeft={1} paddingRight={1} width="100%">
|
||||
{/* Cline robot - centered */}
|
||||
<StaticRobotFrame />
|
||||
|
||||
{/* Welcome text - centered */}
|
||||
<Box justifyContent="center" marginTop={1}>
|
||||
<Text bold color="white">
|
||||
Welcome to Cline
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Auth box with border */}
|
||||
<Box
|
||||
borderColor="gray"
|
||||
borderStyle="round"
|
||||
flexDirection="column"
|
||||
marginTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
paddingTop={1}>
|
||||
{step === "menu" ? (
|
||||
<Box flexDirection="column">
|
||||
<Text color="gray">How would you like to get started?</Text>
|
||||
<Text> </Text>
|
||||
{mainMenuItems.map((item, index) => (
|
||||
<Box key={item.value}>
|
||||
<Text color={index === menuIndex ? COLORS.primaryBlue : undefined}>
|
||||
{index === menuIndex ? "❯ " : " "}
|
||||
{item.label}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
<Text> </Text>
|
||||
<Text color="gray">Use arrow keys, Enter to select</Text>
|
||||
</Box>
|
||||
) : (
|
||||
renderAuthContent()
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,381 +0,0 @@
|
||||
import BedrockData from "@shared/providers/bedrock.json"
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useCallback, useMemo, useState } from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { useScrollableList } from "../hooks/useScrollableList"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
|
||||
type AuthMethod = "profile" | "credentials" | "default"
|
||||
|
||||
type BedrockStep = "auth_method" | "profile_name" | "access_key" | "secret_key" | "session_token" | "region" | "options"
|
||||
|
||||
export interface BedrockConfig {
|
||||
awsAuthentication: string
|
||||
awsProfile?: string
|
||||
awsAccessKey?: string
|
||||
awsSecretKey?: string
|
||||
awsSessionToken?: string
|
||||
awsRegion: string
|
||||
awsUseCrossRegionInference: boolean
|
||||
}
|
||||
|
||||
interface BedrockSetupProps {
|
||||
isActive: boolean
|
||||
onComplete: (config: BedrockConfig) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const AUTH_METHODS: { label: string; value: AuthMethod; description: string }[] = [
|
||||
{ label: "AWS Profile", value: "profile", description: "Use a named profile from ~/.aws/credentials" },
|
||||
{ label: "AWS Credentials", value: "credentials", description: "Enter access key, secret key, and optional session token" },
|
||||
{
|
||||
label: "Default credential chain",
|
||||
value: "default",
|
||||
description: "Resolve from env vars, IAM role, or ~/.aws/credentials",
|
||||
},
|
||||
]
|
||||
|
||||
const AWS_REGIONS = BedrockData.regions
|
||||
const REGION_ROWS = 8
|
||||
|
||||
/**
|
||||
* Inline text input for credential fields
|
||||
*/
|
||||
const CredentialInput: React.FC<{
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onSubmit: () => void
|
||||
onCancel: () => void
|
||||
isActive: boolean
|
||||
isPassword?: boolean
|
||||
placeholder?: string
|
||||
hint?: string
|
||||
}> = ({ label, value, onChange, onSubmit, onCancel, isActive, isPassword, placeholder, hint }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (isMouseEscapeSequence(input)) return
|
||||
if (key.escape) {
|
||||
onCancel()
|
||||
} else if (key.return) {
|
||||
onSubmit()
|
||||
} else if (key.backspace || key.delete) {
|
||||
onChange(value.slice(0, -1))
|
||||
} else if (input && !key.ctrl && !key.meta) {
|
||||
onChange(value + input)
|
||||
}
|
||||
},
|
||||
{ isActive: isActive && isRawModeSupported },
|
||||
)
|
||||
|
||||
const displayValue = isPassword && value ? "•".repeat(value.length) : value
|
||||
|
||||
// Combine hint and placeholder into description shown above input
|
||||
const description = hint || (placeholder ? `e.g. ${placeholder}` : undefined)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="white">{label}</Text>
|
||||
{description && <Text color="gray">{description}</Text>}
|
||||
<Text> </Text>
|
||||
<Box>
|
||||
<Text color="white">{displayValue}</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
<Text> </Text>
|
||||
<Text color="gray">Enter to continue, Esc to go back</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const BedrockSetup: React.FC<BedrockSetupProps> = ({ isActive, onComplete, onCancel }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
|
||||
const [step, setStep] = useState<BedrockStep>("auth_method")
|
||||
const [authMethodIndex, setAuthMethodIndex] = useState(0)
|
||||
const [authMethod, setAuthMethod] = useState<AuthMethod>("profile")
|
||||
|
||||
// Credential state
|
||||
const [profileName, setProfileName] = useState("")
|
||||
const [accessKey, setAccessKey] = useState("")
|
||||
const [secretKey, setSecretKey] = useState("")
|
||||
const [sessionToken, setSessionToken] = useState("")
|
||||
|
||||
// Region state
|
||||
const [regionSearch, setRegionSearch] = useState("")
|
||||
const [regionIndex, setRegionIndex] = useState(0)
|
||||
|
||||
// Options state
|
||||
const [crossRegion, setCrossRegion] = useState(false)
|
||||
const [optionIndex, setOptionIndex] = useState(0)
|
||||
|
||||
// Filtered regions
|
||||
const filteredRegions = useMemo(() => {
|
||||
const search = regionSearch.toLowerCase()
|
||||
return search ? AWS_REGIONS.filter((r) => r.includes(search)) : AWS_REGIONS
|
||||
}, [regionSearch])
|
||||
|
||||
const {
|
||||
visibleStart: regionVisibleStart,
|
||||
visibleCount: regionVisibleCount,
|
||||
showTopIndicator: showRegionTop,
|
||||
showBottomIndicator: showRegionBottom,
|
||||
} = useScrollableList(filteredRegions.length, regionIndex, REGION_ROWS)
|
||||
|
||||
const visibleRegions = useMemo(
|
||||
() => filteredRegions.slice(regionVisibleStart, regionVisibleStart + regionVisibleCount),
|
||||
[filteredRegions, regionVisibleStart, regionVisibleCount],
|
||||
)
|
||||
|
||||
const nextStepAfterAuth = useCallback((method: AuthMethod) => {
|
||||
setAuthMethod(method)
|
||||
if (method === "profile") {
|
||||
setStep("profile_name")
|
||||
} else if (method === "credentials") {
|
||||
setStep("access_key")
|
||||
} else {
|
||||
// default chain - skip credentials, go to region
|
||||
setStep("region")
|
||||
}
|
||||
}, [])
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
switch (step) {
|
||||
case "auth_method":
|
||||
onCancel()
|
||||
break
|
||||
case "profile_name":
|
||||
setStep("auth_method")
|
||||
break
|
||||
case "access_key":
|
||||
setStep("auth_method")
|
||||
break
|
||||
case "secret_key":
|
||||
setStep("access_key")
|
||||
break
|
||||
case "session_token":
|
||||
setStep("secret_key")
|
||||
break
|
||||
case "region":
|
||||
if (authMethod === "profile") setStep("profile_name")
|
||||
else if (authMethod === "credentials") setStep("session_token")
|
||||
else setStep("auth_method")
|
||||
break
|
||||
case "options":
|
||||
setStep("region")
|
||||
break
|
||||
}
|
||||
}, [step, authMethod, onCancel])
|
||||
|
||||
const finish = useCallback(() => {
|
||||
const config: BedrockConfig = {
|
||||
awsAuthentication: authMethod === "default" ? "credentials" : authMethod,
|
||||
awsRegion: filteredRegions[regionIndex] || "us-east-1",
|
||||
awsUseCrossRegionInference: crossRegion,
|
||||
}
|
||||
if (authMethod === "profile") {
|
||||
config.awsProfile = profileName || ""
|
||||
} else if (authMethod === "credentials") {
|
||||
config.awsAccessKey = accessKey
|
||||
config.awsSecretKey = secretKey
|
||||
if (sessionToken) config.awsSessionToken = sessionToken
|
||||
}
|
||||
onComplete(config)
|
||||
}, [authMethod, profileName, accessKey, secretKey, sessionToken, filteredRegions, regionIndex, crossRegion, onComplete])
|
||||
|
||||
// Handle input for auth_method, region, and options steps
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (isMouseEscapeSequence(input)) return
|
||||
|
||||
if (step === "auth_method") {
|
||||
if (key.escape) {
|
||||
onCancel()
|
||||
} else if (key.upArrow) {
|
||||
setAuthMethodIndex((prev) => (prev > 0 ? prev - 1 : AUTH_METHODS.length - 1))
|
||||
} else if (key.downArrow) {
|
||||
setAuthMethodIndex((prev) => (prev < AUTH_METHODS.length - 1 ? prev + 1 : 0))
|
||||
} else if (key.return) {
|
||||
nextStepAfterAuth(AUTH_METHODS[authMethodIndex].value)
|
||||
}
|
||||
} else if (step === "region") {
|
||||
if (key.escape) {
|
||||
goBack()
|
||||
} else if (key.upArrow) {
|
||||
setRegionIndex((prev) => (prev > 0 ? prev - 1 : filteredRegions.length - 1))
|
||||
} else if (key.downArrow) {
|
||||
setRegionIndex((prev) => (prev < filteredRegions.length - 1 ? prev + 1 : 0))
|
||||
} else if (key.return && filteredRegions.length > 0) {
|
||||
setStep("options")
|
||||
} else if (key.backspace || key.delete) {
|
||||
setRegionSearch((prev) => prev.slice(0, -1))
|
||||
setRegionIndex(0)
|
||||
} else if (input && !key.ctrl && !key.meta) {
|
||||
setRegionSearch((prev) => prev + input)
|
||||
setRegionIndex(0)
|
||||
}
|
||||
} else if (step === "options") {
|
||||
if (key.escape) {
|
||||
goBack()
|
||||
} else if (key.tab || key.return || input === " ") {
|
||||
// Tab/Enter/Space on checkbox toggles it, on Done button finishes
|
||||
if (optionIndex === 0) {
|
||||
setCrossRegion((prev) => !prev)
|
||||
} else {
|
||||
finish()
|
||||
}
|
||||
} else if (key.upArrow) {
|
||||
setOptionIndex((prev) => (prev > 0 ? prev - 1 : 1))
|
||||
} else if (key.downArrow) {
|
||||
setOptionIndex((prev) => (prev < 1 ? prev + 1 : 0))
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: isActive && isRawModeSupported && (step === "auth_method" || step === "region" || step === "options") },
|
||||
)
|
||||
|
||||
if (step === "auth_method") {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="white">Authentication method</Text>
|
||||
<Text> </Text>
|
||||
{AUTH_METHODS.map((method, i) => (
|
||||
<Box flexDirection="column" key={method.value} marginBottom={i < AUTH_METHODS.length - 1 ? 1 : 0}>
|
||||
<Text color={i === authMethodIndex ? COLORS.primaryBlue : undefined}>
|
||||
{i === authMethodIndex ? "❯ " : " "}
|
||||
{method.label}
|
||||
</Text>
|
||||
<Box paddingLeft={2}>
|
||||
<Text color="gray">{method.description}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
<Text> </Text>
|
||||
<Text color="gray">Arrows to navigate, Enter to select, Esc to go back</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (step === "profile_name") {
|
||||
return (
|
||||
<CredentialInput
|
||||
hint="Leave empty to use the default profile"
|
||||
isActive={isActive}
|
||||
label="AWS Profile Name"
|
||||
onCancel={goBack}
|
||||
onChange={setProfileName}
|
||||
onSubmit={() => setStep("region")}
|
||||
placeholder="default"
|
||||
value={profileName}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (step === "access_key") {
|
||||
return (
|
||||
<CredentialInput
|
||||
isActive={isActive}
|
||||
isPassword
|
||||
label="AWS Access Key"
|
||||
onCancel={goBack}
|
||||
onChange={setAccessKey}
|
||||
onSubmit={() => {
|
||||
if (accessKey.trim()) setStep("secret_key")
|
||||
}}
|
||||
placeholder="Enter access key..."
|
||||
value={accessKey}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (step === "secret_key") {
|
||||
return (
|
||||
<CredentialInput
|
||||
isActive={isActive}
|
||||
isPassword
|
||||
label="AWS Secret Key"
|
||||
onCancel={goBack}
|
||||
onChange={setSecretKey}
|
||||
onSubmit={() => {
|
||||
if (secretKey.trim()) setStep("session_token")
|
||||
}}
|
||||
placeholder="Enter secret key..."
|
||||
value={secretKey}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (step === "session_token") {
|
||||
return (
|
||||
<CredentialInput
|
||||
hint="Optional - for temporary credentials"
|
||||
isActive={isActive}
|
||||
isPassword
|
||||
label="AWS Session Token"
|
||||
onCancel={goBack}
|
||||
onChange={setSessionToken}
|
||||
onSubmit={() => setStep("region")}
|
||||
placeholder="Enter session token (optional)..."
|
||||
value={sessionToken}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (step === "region") {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="white">AWS Region</Text>
|
||||
<Text> </Text>
|
||||
<Box>
|
||||
<Text color="gray">Search: </Text>
|
||||
<Text color="white">{regionSearch}</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
<Text> </Text>
|
||||
{showRegionTop && <Text color="gray">... {regionVisibleStart} more above</Text>}
|
||||
{visibleRegions.map((region, i) => {
|
||||
const actualIndex = regionVisibleStart + i
|
||||
return (
|
||||
<Box key={region}>
|
||||
<Text color={actualIndex === regionIndex ? COLORS.primaryBlue : undefined}>
|
||||
{actualIndex === regionIndex ? "❯ " : " "}
|
||||
{region}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
{showRegionBottom && (
|
||||
<Text color="gray">... {filteredRegions.length - regionVisibleStart - regionVisibleCount} more below</Text>
|
||||
)}
|
||||
{filteredRegions.length === 0 && <Text color="gray">No regions match "{regionSearch}"</Text>}
|
||||
<Text> </Text>
|
||||
<Text color="gray">Type to search, arrows to navigate, Enter to select, Esc to go back</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (step === "options") {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="white">Options</Text>
|
||||
<Text> </Text>
|
||||
<Text color={optionIndex === 0 ? COLORS.primaryBlue : undefined}>
|
||||
{optionIndex === 0 ? "❯ " : " "}
|
||||
{crossRegion ? "[x]" : "[ ]"} Use cross-region inference
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text color={optionIndex === 1 ? COLORS.primaryBlue : undefined}>
|
||||
{optionIndex === 1 ? "❯ " : " "}
|
||||
Done
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text color="gray">Arrows to navigate, Enter to select, Esc to go back</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,597 +0,0 @@
|
||||
/**
|
||||
* Claude Code style chat message component
|
||||
* Renders messages with:
|
||||
* - ❯ for user messages
|
||||
* - ⏺ for assistant messages and tool calls
|
||||
* - ⎿ for tool results (indented)
|
||||
*/
|
||||
|
||||
import { CLINE_ACCOUNT_AUTH_ERROR_MESSAGE } from "@shared/ClineAccount"
|
||||
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 { useTerminalSize } from "../hooks/useTerminalSize"
|
||||
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 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 color="gray">{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 > 120 ? args.command.substring(0, 117) + "..." : 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
|
||||
const { columns: terminalWidth } = useTerminalSize()
|
||||
|
||||
// User messages (task, user_feedback)
|
||||
// If multi-line, extend background to full width for consistent appearance
|
||||
if (say === "task" || say === "user_feedback") {
|
||||
const content = "> " + (text || "")
|
||||
const isMultiLine = content.includes("\n") || content.length > terminalWidth
|
||||
|
||||
if (isMultiLine) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<Box backgroundColor="blackBright" paddingX={1} width="100%">
|
||||
<Text color="white">{content}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box backgroundColor="blackBright" paddingX={1}>
|
||||
<Text color="white">{content}</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>
|
||||
)
|
||||
}
|
||||
|
||||
// Show result content for completed tools, or file path for pending asks
|
||||
const contentLines =
|
||||
isToolSay && toolInfo.result?.trim()
|
||||
? formatToolResult(toolInfo.result, 5)
|
||||
: isToolAsk && filePath
|
||||
? [filePath as string]
|
||||
: []
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor}>
|
||||
<ToolCallText args={toolInfo.args} isAsk={isToolAsk} mode={mode} toolName={toolInfo.toolName} />
|
||||
</DotRow>
|
||||
{contentLines.length > 0 && (
|
||||
<Box flexDirection="column" marginLeft={2} width="100%">
|
||||
{contentLines.map((line, idx) => (
|
||||
<ResultRow isFirst={idx === 0} key={idx}>
|
||||
<Text color="gray">{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, 120)}</Text>
|
||||
</Text>
|
||||
</DotRow>
|
||||
{output && (
|
||||
<Box flexDirection="column" marginLeft={2} width="100%">
|
||||
{formatToolResult(output, 8).map((line, idx) => (
|
||||
<ResultRow isFirst={idx === 0} key={idx}>
|
||||
<Text color="gray">{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 color="gray">{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
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Cline auth error to show sign-in instructions
|
||||
const isClineAuthError = errorMessage.includes(CLINE_ACCOUNT_AUTH_ERROR_MESSAGE)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color="red">
|
||||
<Text color="red" wrap="wrap">
|
||||
<Text bold>Error</Text>: {errorMessage}
|
||||
</Text>
|
||||
</DotRow>
|
||||
{isClineAuthError && (
|
||||
<Box marginLeft={2} marginTop={1}>
|
||||
<Text color="gray">
|
||||
Run <Text color="cyan">/settings</Text> and go to Account to sign in.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</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" : toolColor} 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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// New task request from assistant
|
||||
if (type === "ask" && ask === "new_task" && text) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={COLORS.primaryBlue}>
|
||||
<Text bold color={COLORS.primaryBlue}>
|
||||
Cline wants to start a new task:
|
||||
</Text>
|
||||
</DotRow>
|
||||
<Box flexDirection="column" paddingLeft={2}>
|
||||
<Text color="gray">{text}</Text>
|
||||
</Box>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -1,316 +0,0 @@
|
||||
/**
|
||||
* Tests for ChatView component exit and cleanup behavior
|
||||
*
|
||||
* These tests verify that when the user exits (via Ctrl+C or other means),
|
||||
* the input field is properly hidden before the app terminates.
|
||||
*/
|
||||
|
||||
import { Text } from "ink"
|
||||
import { render } from "ink-testing-library"
|
||||
import React from "react"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { ChatView } from "./ChatView"
|
||||
|
||||
// Helper to wait for async state updates
|
||||
// Using 60ms since handleExit has a 50ms setTimeout
|
||||
const delay = (ms: number = 60) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
// Type for our exit mock function
|
||||
type ExitMockFn = ReturnType<typeof vi.fn> & (() => void)
|
||||
|
||||
// Track shutdown event state
|
||||
const shutdownMockState = {
|
||||
listeners: [] as Array<() => void>,
|
||||
fire: () => {
|
||||
shutdownMockState.listeners.forEach((listener) => listener())
|
||||
},
|
||||
reset: () => {
|
||||
shutdownMockState.listeners = []
|
||||
},
|
||||
}
|
||||
|
||||
// Mock vscode-shim shutdownEvent
|
||||
vi.mock("../vscode-shim", () => ({
|
||||
shutdownEvent: {
|
||||
event: (listener: () => void) => {
|
||||
shutdownMockState.listeners.push(listener)
|
||||
return {
|
||||
dispose: () => {
|
||||
const idx = shutdownMockState.listeners.indexOf(listener)
|
||||
if (idx >= 0) shutdownMockState.listeners.splice(idx, 1)
|
||||
},
|
||||
}
|
||||
},
|
||||
fire: () => shutdownMockState.fire(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock TaskContext
|
||||
vi.mock("../context/TaskContext", () => ({
|
||||
useTaskState: vi.fn(() => ({
|
||||
clineMessages: [],
|
||||
mode: "act",
|
||||
})),
|
||||
useTaskContext: vi.fn(() => ({
|
||||
controller: null,
|
||||
})),
|
||||
}))
|
||||
|
||||
// Mock useIsSpinnerActive hook
|
||||
vi.mock("../hooks/useStateSubscriber", () => ({
|
||||
useIsSpinnerActive: vi.fn(() => ({
|
||||
isActive: false,
|
||||
startTime: null,
|
||||
})),
|
||||
}))
|
||||
|
||||
// Mock StateManager
|
||||
vi.mock("@/core/storage/StateManager", () => ({
|
||||
StateManager: {
|
||||
get: vi.fn(() => ({
|
||||
getGlobalSettingsKey: vi.fn((key: string) => {
|
||||
if (key === "mode") return "act"
|
||||
if (key === "yoloModeToggled") return false
|
||||
if (key === "actModeApiModelId") return "claude-sonnet-4-20250514"
|
||||
return null
|
||||
}),
|
||||
setGlobalState: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock child components that aren't under test
|
||||
vi.mock("./ActionButtons", () => ({
|
||||
ActionButtons: () => React.createElement(Text, null, "ActionButtons"),
|
||||
getButtonConfig: vi.fn(() => ({ enableButtons: false })),
|
||||
}))
|
||||
|
||||
vi.mock("./AsciiMotionCli", () => ({
|
||||
AsciiMotionCli: () => React.createElement(Text, null, "AsciiMotion"),
|
||||
StaticRobotFrame: () => React.createElement(Text, null, "StaticRobot"),
|
||||
}))
|
||||
|
||||
vi.mock("./ChatMessage", () => ({
|
||||
ChatMessage: ({ message }: { message?: { ts?: number } }) => React.createElement(Text, null, `Message: ${message?.ts}`),
|
||||
}))
|
||||
|
||||
vi.mock("./FileMentionMenu", () => ({
|
||||
FileMentionMenu: () => React.createElement(Text, null, "FileMentionMenu"),
|
||||
}))
|
||||
|
||||
vi.mock("./HighlightedInput", () => ({
|
||||
HighlightedInput: ({ text }: { text?: string }) => React.createElement(Text, null, `Input: ${text}`),
|
||||
}))
|
||||
|
||||
vi.mock("./HistoryPanelContent", () => ({
|
||||
HistoryPanelContent: () => React.createElement(Text, null, "HistoryPanel"),
|
||||
}))
|
||||
|
||||
vi.mock("./SettingsPanelContent", () => ({
|
||||
SettingsPanelContent: () => React.createElement(Text, null, "SettingsPanel"),
|
||||
}))
|
||||
|
||||
vi.mock("./SlashCommandMenu", () => ({
|
||||
SlashCommandMenu: () => React.createElement(Text, null, "SlashMenu"),
|
||||
}))
|
||||
|
||||
vi.mock("./ThinkingIndicator", () => ({
|
||||
ThinkingIndicator: () => React.createElement(Text, null, "ThinkingIndicator"),
|
||||
}))
|
||||
|
||||
// Mock utility functions
|
||||
vi.mock("../utils/file-search", () => ({
|
||||
checkAndWarnRipgrepMissing: vi.fn(() => false),
|
||||
extractMentionQuery: vi.fn(() => ({ inMentionMode: false, query: "", atIndex: -1 })),
|
||||
getRipgrepInstallInstructions: vi.fn(() => "brew install ripgrep"),
|
||||
insertMention: vi.fn((text: string) => text),
|
||||
searchWorkspaceFiles: vi.fn(async () => []),
|
||||
}))
|
||||
|
||||
vi.mock("../utils/slash-commands", () => ({
|
||||
extractSlashQuery: vi.fn(() => ({ inSlashMode: false, query: "", slashIndex: -1 })),
|
||||
filterCommands: vi.fn(() => []),
|
||||
insertSlashCommand: vi.fn((text: string) => text),
|
||||
sortCommandsWorkflowsFirst: vi.fn((cmds: unknown[]) => cmds),
|
||||
}))
|
||||
|
||||
vi.mock("../utils/input", () => ({
|
||||
isMouseEscapeSequence: vi.fn(() => false),
|
||||
}))
|
||||
|
||||
vi.mock("../utils/parser", () => ({
|
||||
jsonParseSafe: vi.fn((_text: string, defaultValue: unknown) => defaultValue),
|
||||
parseImagesFromInput: vi.fn((text: string) => ({ prompt: text, imagePaths: [] })),
|
||||
}))
|
||||
|
||||
vi.mock("../utils/tools", () => ({
|
||||
isFileEditTool: vi.fn(() => false),
|
||||
parseToolFromMessage: vi.fn(() => null),
|
||||
}))
|
||||
|
||||
vi.mock("../utils/display", () => ({
|
||||
setTerminalTitle: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("../utils/cursor", () => ({
|
||||
moveCursorUp: vi.fn((_text: string, pos: number) => pos),
|
||||
moveCursorDown: vi.fn((_text: string, pos: number) => pos),
|
||||
}))
|
||||
|
||||
vi.mock("@/core/controller/slash/getAvailableSlashCommands", () => ({
|
||||
getAvailableSlashCommands: vi.fn(async () => ({ commands: [] })),
|
||||
}))
|
||||
|
||||
vi.mock("@/core/controller/task/showTaskWithId", () => ({
|
||||
showTaskWithId: vi.fn(async () => {}),
|
||||
}))
|
||||
|
||||
vi.mock("@shared/combineCommandSequences", () => ({
|
||||
combineCommandSequences: vi.fn((messages: unknown[]) => messages),
|
||||
}))
|
||||
|
||||
vi.mock("@shared/getApiMetrics", () => ({
|
||||
getApiMetrics: vi.fn(() => ({
|
||||
totalTokensIn: 0,
|
||||
totalTokensOut: 0,
|
||||
totalCost: 0,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock("child_process", () => ({
|
||||
execSync: vi.fn(() => "main"),
|
||||
}))
|
||||
|
||||
// Helper to create a typed mock for onExit
|
||||
const createExitMock = (): ExitMockFn => vi.fn() as ExitMockFn
|
||||
|
||||
describe("ChatView Exit and Cleanup", () => {
|
||||
let mockOnExit: ExitMockFn
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
shutdownMockState.reset()
|
||||
mockOnExit = createExitMock()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe("Initial render state", () => {
|
||||
it("should render with input field, footer, and mode toggle visible", () => {
|
||||
const { lastFrame } = render(<ChatView onExit={mockOnExit} />)
|
||||
const frame = lastFrame()
|
||||
|
||||
// Input field visible
|
||||
expect(frame).toContain("Input:")
|
||||
// Footer with help text
|
||||
expect(frame).toContain("@ for files")
|
||||
expect(frame).toContain("/ for commands")
|
||||
// Mode toggle
|
||||
expect(frame).toContain("Plan")
|
||||
expect(frame).toContain("Act")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Ctrl+C exit handling", () => {
|
||||
it("should hide input but keep footer, then call onExit", async () => {
|
||||
const { lastFrame, stdin } = render(<ChatView onExit={mockOnExit} />)
|
||||
|
||||
// Verify UI visible before Ctrl+C
|
||||
expect(lastFrame()).toContain("Input:")
|
||||
expect(lastFrame()).toContain("@ for files")
|
||||
|
||||
// Simulate Ctrl+C
|
||||
stdin.write("\x03")
|
||||
|
||||
// onExit should not be called immediately
|
||||
expect(mockOnExit).not.toHaveBeenCalled()
|
||||
|
||||
// Wait for state update and callback
|
||||
await delay()
|
||||
|
||||
// Input should be hidden, but footer should remain
|
||||
const frameAfter = lastFrame()
|
||||
expect(frameAfter).not.toContain("Input:")
|
||||
expect(frameAfter).toContain("@ for files")
|
||||
|
||||
// onExit should have been called
|
||||
expect(mockOnExit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Shutdown event handling", () => {
|
||||
it("should subscribe on mount and unsubscribe on unmount", () => {
|
||||
const { unmount } = render(<ChatView onExit={mockOnExit} />)
|
||||
expect(shutdownMockState.listeners.length).toBe(1)
|
||||
|
||||
unmount()
|
||||
expect(shutdownMockState.listeners.length).toBe(0)
|
||||
})
|
||||
|
||||
it("should hide UI when shutdown event fires", async () => {
|
||||
const { lastFrame } = render(<ChatView onExit={mockOnExit} />)
|
||||
|
||||
expect(lastFrame()).toContain("Input:")
|
||||
|
||||
shutdownMockState.fire()
|
||||
await delay()
|
||||
|
||||
expect(lastFrame()).not.toContain("Input:")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Edge cases", () => {
|
||||
it("should handle exit when onExit prop is undefined", async () => {
|
||||
const { lastFrame, stdin } = render(<ChatView />)
|
||||
|
||||
stdin.write("\x03")
|
||||
await delay()
|
||||
|
||||
// Should not throw, UI should still hide
|
||||
expect(lastFrame()).not.toContain("Input:")
|
||||
})
|
||||
|
||||
it("should handle multiple Ctrl+C presses gracefully", async () => {
|
||||
const { stdin } = render(<ChatView onExit={mockOnExit} />)
|
||||
|
||||
stdin.write("\x03")
|
||||
stdin.write("\x03")
|
||||
stdin.write("\x03")
|
||||
|
||||
await delay()
|
||||
|
||||
expect(mockOnExit).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("ChatView UI State During Exit", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
shutdownMockState.reset()
|
||||
})
|
||||
|
||||
it("should preserve static content and footer, only hide input during exit", async () => {
|
||||
const onExit = createExitMock()
|
||||
const { lastFrame, stdin } = render(<ChatView onExit={onExit} />)
|
||||
|
||||
// Footer contains auto-approve toggle
|
||||
expect(lastFrame()).toContain("Auto-approve")
|
||||
expect(lastFrame()).toContain("What can I do for you?")
|
||||
expect(lastFrame()).toContain("Input:")
|
||||
|
||||
stdin.write("\x03")
|
||||
await delay()
|
||||
|
||||
const frameAfter = lastFrame()
|
||||
|
||||
// Static content should still be present
|
||||
expect(frameAfter).toContain("What can I do for you?")
|
||||
// Footer should still be present (only input is hidden)
|
||||
expect(frameAfter).toContain("Auto-approve")
|
||||
// Input should be hidden
|
||||
expect(frameAfter).not.toContain("Input:")
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
/**
|
||||
* Checkpoint menu component
|
||||
* Displays available checkpoints and allows user to select one to restore
|
||||
*/
|
||||
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useState } from "react"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
|
||||
export type RestoreType = "task" | "workspace" | "taskAndWorkspace"
|
||||
|
||||
interface CheckpointOption {
|
||||
ts: number
|
||||
hash: string
|
||||
date: Date
|
||||
label: string
|
||||
}
|
||||
|
||||
interface CheckpointMenuProps {
|
||||
messages: ClineMessage[]
|
||||
onSelect: (messageTs: number, restoreType: RestoreType) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract checkpoint options from messages
|
||||
*/
|
||||
function getCheckpointOptions(messages: ClineMessage[]): CheckpointOption[] {
|
||||
const options: CheckpointOption[] = []
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.lastCheckpointHash) {
|
||||
options.push({
|
||||
ts: msg.ts,
|
||||
hash: msg.lastCheckpointHash,
|
||||
date: new Date(msg.ts),
|
||||
label: getCheckpointLabel(msg),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by timestamp descending (newest first)
|
||||
return options.sort((a, b) => b.ts - a.ts)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable label for a checkpoint
|
||||
*/
|
||||
function getCheckpointLabel(msg: ClineMessage): string {
|
||||
if (msg.say === "completion_result") {
|
||||
return "Task completion"
|
||||
}
|
||||
if (msg.say === "checkpoint_created") {
|
||||
return "Checkpoint"
|
||||
}
|
||||
if (msg.say === "api_req_started") {
|
||||
return "API request"
|
||||
}
|
||||
return msg.say || msg.ask || "Message"
|
||||
}
|
||||
|
||||
const RESTORE_TYPE_OPTIONS: { type: RestoreType; label: string; description: string }[] = [
|
||||
{
|
||||
type: "taskAndWorkspace",
|
||||
label: "Task + Workspace",
|
||||
description: "Restore messages and files",
|
||||
},
|
||||
{
|
||||
type: "task",
|
||||
label: "Task Only",
|
||||
description: "Delete messages after this point",
|
||||
},
|
||||
{
|
||||
type: "workspace",
|
||||
label: "Workspace Only",
|
||||
description: "Restore files only",
|
||||
},
|
||||
]
|
||||
|
||||
export const CheckpointMenu: React.FC<CheckpointMenuProps> = ({ messages, onSelect, onCancel }) => {
|
||||
const { 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()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (stage === "checkpoint") {
|
||||
if (key.upArrow) {
|
||||
setSelectedCheckpoint((i) => Math.max(0, i - 1))
|
||||
} else if (key.downArrow) {
|
||||
setSelectedCheckpoint((i) => Math.min(checkpoints.length - 1, i + 1))
|
||||
} else if (key.return && checkpoints.length > 0) {
|
||||
setStage("restoreType")
|
||||
}
|
||||
} else if (stage === "restoreType") {
|
||||
if (key.upArrow) {
|
||||
setSelectedRestoreType((i) => Math.max(0, i - 1))
|
||||
} else if (key.downArrow) {
|
||||
setSelectedRestoreType((i) => Math.min(RESTORE_TYPE_OPTIONS.length - 1, i + 1))
|
||||
} else if (key.return) {
|
||||
const checkpoint = checkpoints[selectedCheckpoint]
|
||||
const restoreType = RESTORE_TYPE_OPTIONS[selectedRestoreType]
|
||||
if (checkpoint && restoreType) {
|
||||
onSelect(checkpoint.ts, restoreType.type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Quick number selection for checkpoints
|
||||
if (stage === "checkpoint") {
|
||||
const num = parseInt(input, 10)
|
||||
if (!Number.isNaN(num) && num >= 1 && num <= checkpoints.length) {
|
||||
setSelectedCheckpoint(num - 1)
|
||||
setStage("restoreType")
|
||||
}
|
||||
}
|
||||
},
|
||||
{ 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">Checkpoints are created at task completion points</Text>
|
||||
<Text color="gray">Press Escape to close</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (stage === "checkpoint") {
|
||||
return (
|
||||
<Box borderColor="cyan" borderStyle="round" flexDirection="column" marginTop={1} paddingLeft={1} paddingRight={1}>
|
||||
<Text bold color="cyan">
|
||||
Restore Checkpoint
|
||||
</Text>
|
||||
<Text color="gray">Select a checkpoint to restore (↑/↓ or number, Enter to select, Escape to cancel)</Text>
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{checkpoints.map((cp, idx) => {
|
||||
const isSelected = idx === selectedCheckpoint
|
||||
const timeStr = cp.date.toLocaleTimeString()
|
||||
const dateStr = cp.date.toLocaleDateString()
|
||||
return (
|
||||
<Box key={cp.ts}>
|
||||
<Text color={isSelected ? "green" : "gray"}>{isSelected ? "> " : " "}</Text>
|
||||
<Text color={isSelected ? "white" : "gray"}>{idx + 1}. </Text>
|
||||
<Text color={isSelected ? "cyan" : undefined}>{cp.label}</Text>
|
||||
<Text color="gray">
|
||||
{" "}
|
||||
- {dateStr} {timeStr}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Stage: restoreType
|
||||
const selectedCp = checkpoints[selectedCheckpoint]
|
||||
return (
|
||||
<Box borderColor="cyan" borderStyle="round" flexDirection="column" marginTop={1} paddingLeft={1} paddingRight={1}>
|
||||
<Text bold color="cyan">
|
||||
Restore Type
|
||||
</Text>
|
||||
<Text color="gray">
|
||||
Restoring to: {selectedCp?.label} ({selectedCp?.date.toLocaleString()})
|
||||
</Text>
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{RESTORE_TYPE_OPTIONS.map((opt, idx) => {
|
||||
const isSelected = idx === selectedRestoreType
|
||||
return (
|
||||
<Box flexDirection="column" key={opt.type} marginBottom={idx < RESTORE_TYPE_OPTIONS.length - 1 ? 1 : 0}>
|
||||
<Box>
|
||||
<Text color={isSelected ? "green" : "gray"}>{isSelected ? "> " : " "}</Text>
|
||||
<Text bold={isSelected} color={isSelected ? "white" : undefined}>
|
||||
{opt.label}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginLeft={4}>
|
||||
<Text color="gray">{opt.description}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
<Text color="gray">(↑/↓ to select, Enter to confirm, Escape to go back)</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
import { Text } from "ink"
|
||||
import { render } from "ink-testing-library"
|
||||
import React from "react"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
// Create stable mock references using vi.hoisted - must be before any imports that use these modules
|
||||
const { mockIsSettingsKey } = vi.hoisted(() => ({
|
||||
mockIsSettingsKey: vi.fn((key: string) => key.startsWith("act") || key.startsWith("plan") || key === "mode"),
|
||||
}))
|
||||
|
||||
vi.mock("./TaskView", () => ({
|
||||
TaskView: ({ taskId, verbose }: any) =>
|
||||
React.createElement(Text, null, `TaskView: ${taskId || "no-id"} verbose=${String(verbose)}`),
|
||||
}))
|
||||
|
||||
// Mock the state-keys module - must be hoisted before ConfigView import
|
||||
vi.mock("@shared/storage/state-keys", () => ({
|
||||
isSettingsKey: mockIsSettingsKey,
|
||||
SETTINGS_DEFAULTS: {
|
||||
mode: "act",
|
||||
actModeApiProvider: "anthropic",
|
||||
},
|
||||
GlobalStateAndSettings: {},
|
||||
GlobalStateAndSettingsKey: {},
|
||||
LocalState: {},
|
||||
LocalStateKey: {},
|
||||
}))
|
||||
|
||||
// Import ConfigView after mocks are set up
|
||||
import { ConfigView } from "./ConfigView"
|
||||
|
||||
describe("ConfigView", () => {
|
||||
const defaultProps = {
|
||||
dataDir: "/home/user/.cline",
|
||||
globalState: {},
|
||||
workspaceState: {},
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("rendering", () => {
|
||||
it("should render the config header", () => {
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} />)
|
||||
expect(lastFrame()).toContain("Configuration")
|
||||
})
|
||||
|
||||
it("should display the data directory", () => {
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} dataDir="/custom/path" />)
|
||||
expect(lastFrame()).toContain("/custom/path")
|
||||
})
|
||||
|
||||
it("should display global state entries", () => {
|
||||
const { lastFrame } = render(
|
||||
<ConfigView
|
||||
{...defaultProps}
|
||||
globalState={{
|
||||
mode: "act",
|
||||
actModeApiProvider: "anthropic",
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
expect(lastFrame()).toContain("mode")
|
||||
expect(lastFrame()).toContain("act")
|
||||
})
|
||||
|
||||
it("should display workspace state entries", () => {
|
||||
const { lastFrame } = render(
|
||||
<ConfigView
|
||||
{...defaultProps}
|
||||
workspaceState={{
|
||||
customSetting: "value",
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
expect(lastFrame()).toContain("customSetting")
|
||||
expect(lastFrame()).toContain("value")
|
||||
})
|
||||
|
||||
it("should show section headers", () => {
|
||||
const { lastFrame } = render(
|
||||
<ConfigView {...defaultProps} globalState={{ mode: "act" }} workspaceState={{ localKey: "localValue" }} />,
|
||||
)
|
||||
expect(lastFrame()).toContain("Global Settings")
|
||||
})
|
||||
})
|
||||
|
||||
describe("value formatting", () => {
|
||||
it("should format boolean values", () => {
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ actModeSomeBool: true }} />)
|
||||
expect(lastFrame()).toContain("true")
|
||||
})
|
||||
|
||||
it("should format number values", () => {
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ actModeNumber: 42 }} />)
|
||||
expect(lastFrame()).toContain("42")
|
||||
})
|
||||
|
||||
it("should truncate long string values", () => {
|
||||
const longString = "x".repeat(100)
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ actModeLongValue: longString }} />)
|
||||
expect(lastFrame()).toContain("...")
|
||||
})
|
||||
|
||||
it("should format object values as JSON", () => {
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ actModeObj: { nested: "value" } }} />)
|
||||
expect(lastFrame()).toContain("nested")
|
||||
})
|
||||
})
|
||||
|
||||
describe("filtering", () => {
|
||||
it("should exclude taskHistory key", () => {
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ taskHistory: [1, 2, 3], mode: "act" }} />)
|
||||
expect(lastFrame()).not.toContain("taskHistory")
|
||||
})
|
||||
|
||||
it("should exclude empty objects", () => {
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ emptyObj: {}, mode: "act" }} />)
|
||||
expect(lastFrame()).not.toContain("emptyObj")
|
||||
})
|
||||
|
||||
it("should exclude empty arrays", () => {
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ emptyArr: [], mode: "act" }} />)
|
||||
expect(lastFrame()).not.toContain("emptyArr")
|
||||
})
|
||||
|
||||
it("should exclude null/undefined values", () => {
|
||||
const { lastFrame } = render(
|
||||
<ConfigView {...defaultProps} globalState={{ nullVal: null, undefinedVal: undefined, mode: "act" }} />,
|
||||
)
|
||||
expect(lastFrame()).not.toContain("nullVal")
|
||||
expect(lastFrame()).not.toContain("undefinedVal")
|
||||
})
|
||||
|
||||
it("should exclude keys ending with Toggles", () => {
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ someToggles: { a: true }, mode: "act" }} />)
|
||||
expect(lastFrame()).not.toContain("someToggles")
|
||||
})
|
||||
|
||||
it("should exclude keys starting with apiConfig_", () => {
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ apiConfig_test: "value", mode: "act" }} />)
|
||||
expect(lastFrame()).not.toContain("apiConfig_test")
|
||||
})
|
||||
})
|
||||
|
||||
describe("keyboard navigation", () => {
|
||||
it("should show navigation help text", () => {
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ mode: "act" }} />)
|
||||
expect(lastFrame()).toContain("Navigate")
|
||||
expect(lastFrame()).toContain("Edit")
|
||||
})
|
||||
|
||||
it("should highlight first item by default", () => {
|
||||
const { lastFrame } = render(
|
||||
<ConfigView {...defaultProps} globalState={{ mode: "act", actModeApiProvider: "anthropic" }} />,
|
||||
)
|
||||
// The selected indicator
|
||||
expect(lastFrame()).toContain("❯")
|
||||
})
|
||||
|
||||
it("should navigate down with arrow key", () => {
|
||||
const { lastFrame, stdin } = render(
|
||||
<ConfigView {...defaultProps} globalState={{ actModeFirst: "a", actModeSecond: "b" }} />,
|
||||
)
|
||||
|
||||
// Press down arrow
|
||||
stdin.write("\x1B[B")
|
||||
|
||||
const frame = lastFrame()
|
||||
expect(frame).toContain("❯")
|
||||
})
|
||||
|
||||
it("should navigate up with arrow key", () => {
|
||||
const { lastFrame, stdin } = render(
|
||||
<ConfigView {...defaultProps} globalState={{ actModeFirst: "a", actModeSecond: "b" }} />,
|
||||
)
|
||||
|
||||
// Press down then up
|
||||
stdin.write("\x1B[B")
|
||||
stdin.write("\x1B[A")
|
||||
|
||||
expect(lastFrame()).toContain("❯")
|
||||
})
|
||||
})
|
||||
|
||||
describe("scrolling", () => {
|
||||
it("should show scroll indicators when list is long", () => {
|
||||
const manyEntries: Record<string, string> = {}
|
||||
for (let i = 0; i < 20; i++) {
|
||||
manyEntries[`actModeKey${i}`] = `value${i}`
|
||||
}
|
||||
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={manyEntries} />)
|
||||
|
||||
expect(lastFrame()).toContain("Showing")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,551 +0,0 @@
|
||||
/**
|
||||
* Interactive config view component for displaying and editing configuration values
|
||||
* Supports tabs for Settings, Rules, Workflows, Hooks, and Skills
|
||||
*/
|
||||
|
||||
import {
|
||||
GlobalStateAndSettings,
|
||||
GlobalStateAndSettingsKey,
|
||||
LocalState,
|
||||
LocalStateKey,
|
||||
SETTINGS_DEFAULTS,
|
||||
} from "@shared/storage/state-keys"
|
||||
import { Box, Text, useApp, useInput } from "ink"
|
||||
import React, { useMemo, useState } from "react"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import {
|
||||
BooleanSelect,
|
||||
buildConfigEntries,
|
||||
buildToggleEntries,
|
||||
ConfigRow,
|
||||
HookInfo,
|
||||
HookRow,
|
||||
MAX_VISIBLE,
|
||||
parseValue,
|
||||
SEPARATOR,
|
||||
SectionHeader,
|
||||
SkillInfo,
|
||||
SkillRow,
|
||||
TABS,
|
||||
TabBar,
|
||||
TabView,
|
||||
TextInput,
|
||||
ToggleEntry,
|
||||
ToggleRow,
|
||||
WorkspaceHooks,
|
||||
} from "./ConfigViewComponents"
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
interface ConfigViewProps {
|
||||
dataDir: string
|
||||
globalState: Record<string, unknown>
|
||||
workspaceState: Record<string, unknown>
|
||||
onUpdateGlobal?: (key: GlobalStateAndSettingsKey, value: GlobalStateAndSettings[GlobalStateAndSettingsKey]) => void
|
||||
onUpdateWorkspace?: (key: LocalStateKey, value: LocalState[LocalStateKey]) => void
|
||||
// Rules toggles
|
||||
globalClineRulesToggles?: Record<string, boolean>
|
||||
localClineRulesToggles?: Record<string, boolean>
|
||||
localCursorRulesToggles?: Record<string, boolean>
|
||||
localWindsurfRulesToggles?: Record<string, boolean>
|
||||
localAgentsRulesToggles?: Record<string, boolean>
|
||||
onToggleRule?: (isGlobal: boolean, rulePath: string, enabled: boolean, ruleType: string) => void
|
||||
// Workflow toggles
|
||||
globalWorkflowToggles?: Record<string, boolean>
|
||||
localWorkflowToggles?: Record<string, boolean>
|
||||
onToggleWorkflow?: (isGlobal: boolean, workflowPath: string, enabled: boolean) => void
|
||||
// Hooks
|
||||
hooksEnabled?: boolean
|
||||
globalHooks?: HookInfo[]
|
||||
workspaceHooks?: WorkspaceHooks[]
|
||||
onToggleHook?: (isGlobal: boolean, hookName: string, enabled: boolean, workspaceName?: string) => void
|
||||
// Skills
|
||||
skillsEnabled?: boolean
|
||||
globalSkills?: SkillInfo[]
|
||||
localSkills?: SkillInfo[]
|
||||
onToggleSkill?: (isGlobal: boolean, skillPath: string, enabled: boolean) => void
|
||||
// Open folder callback
|
||||
onOpenFolder?: (folderType: "rules" | "workflows" | "hooks" | "skills", isGlobal: boolean) => void
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Component
|
||||
// ============================================================================
|
||||
|
||||
export const ConfigView: React.FC<ConfigViewProps> = ({
|
||||
dataDir,
|
||||
globalState,
|
||||
workspaceState,
|
||||
onUpdateGlobal,
|
||||
onUpdateWorkspace,
|
||||
globalClineRulesToggles,
|
||||
localClineRulesToggles,
|
||||
localCursorRulesToggles,
|
||||
localWindsurfRulesToggles,
|
||||
localAgentsRulesToggles,
|
||||
onToggleRule,
|
||||
globalWorkflowToggles,
|
||||
localWorkflowToggles,
|
||||
onToggleWorkflow,
|
||||
hooksEnabled,
|
||||
globalHooks = [],
|
||||
workspaceHooks = [],
|
||||
onToggleHook,
|
||||
skillsEnabled,
|
||||
globalSkills = [],
|
||||
localSkills = [],
|
||||
onToggleSkill,
|
||||
onOpenFolder,
|
||||
}) => {
|
||||
const { exit } = useApp()
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const [currentTab, setCurrentTab] = useState<TabView>("settings")
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [editValue, setEditValue] = useState("")
|
||||
|
||||
// Build entries for settings tab
|
||||
const configEntries = useMemo(
|
||||
() => [...buildConfigEntries(globalState, "global"), ...buildConfigEntries(workspaceState, "workspace")],
|
||||
[globalState, workspaceState],
|
||||
)
|
||||
|
||||
// Build entries for rules tab
|
||||
const ruleEntries = useMemo(() => {
|
||||
const entries: ToggleEntry[] = []
|
||||
entries.push(...buildToggleEntries(globalClineRulesToggles, "global", "cline"))
|
||||
entries.push(...buildToggleEntries(localClineRulesToggles, "workspace", "cline"))
|
||||
entries.push(...buildToggleEntries(localCursorRulesToggles, "workspace", "cursor"))
|
||||
entries.push(...buildToggleEntries(localWindsurfRulesToggles, "workspace", "windsurf"))
|
||||
entries.push(...buildToggleEntries(localAgentsRulesToggles, "workspace", "agents"))
|
||||
return entries
|
||||
}, [
|
||||
globalClineRulesToggles,
|
||||
localClineRulesToggles,
|
||||
localCursorRulesToggles,
|
||||
localWindsurfRulesToggles,
|
||||
localAgentsRulesToggles,
|
||||
])
|
||||
|
||||
// Build entries for workflows tab
|
||||
const workflowEntries = useMemo(() => {
|
||||
const entries: ToggleEntry[] = []
|
||||
entries.push(...buildToggleEntries(globalWorkflowToggles, "global"))
|
||||
entries.push(...buildToggleEntries(localWorkflowToggles, "workspace"))
|
||||
return entries
|
||||
}, [globalWorkflowToggles, localWorkflowToggles])
|
||||
|
||||
// Build flat list of hooks
|
||||
const hookEntries = useMemo(() => {
|
||||
const entries: { hook: HookInfo; isGlobal: boolean; workspaceName?: string }[] = []
|
||||
globalHooks.forEach((hook) => entries.push({ hook, isGlobal: true }))
|
||||
workspaceHooks.forEach((ws) => {
|
||||
ws.hooks.forEach((hook) => entries.push({ hook, isGlobal: false, workspaceName: ws.workspaceName }))
|
||||
})
|
||||
return entries.sort((a, b) => a.hook.name.localeCompare(b.hook.name))
|
||||
}, [globalHooks, workspaceHooks])
|
||||
|
||||
// Build flat list of skills
|
||||
const skillEntries = useMemo(() => {
|
||||
const entries: { skill: SkillInfo; isGlobal: boolean }[] = []
|
||||
globalSkills.forEach((skill) => entries.push({ skill, isGlobal: true }))
|
||||
localSkills.forEach((skill) => entries.push({ skill, isGlobal: false }))
|
||||
return entries.sort((a, b) => a.skill.name.localeCompare(b.skill.name))
|
||||
}, [globalSkills, localSkills])
|
||||
|
||||
// Get current list length based on tab
|
||||
const currentListLength = useMemo(() => {
|
||||
switch (currentTab) {
|
||||
case "settings":
|
||||
return configEntries.length
|
||||
case "rules":
|
||||
return ruleEntries.length
|
||||
case "workflows":
|
||||
return workflowEntries.length
|
||||
case "hooks":
|
||||
return hookEntries.length
|
||||
case "skills":
|
||||
return skillEntries.length
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}, [currentTab, configEntries.length, ruleEntries.length, workflowEntries.length, hookEntries.length, skillEntries.length])
|
||||
|
||||
// Get available tabs
|
||||
const availableTabs = useMemo(() => {
|
||||
return TABS.filter((tab) => {
|
||||
if (tab.requiresFlag === "hooks") {
|
||||
return hooksEnabled
|
||||
}
|
||||
if (tab.requiresFlag === "skills") {
|
||||
return skillsEnabled
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [hooksEnabled, skillsEnabled])
|
||||
|
||||
// Reset selection when changing tabs
|
||||
const handleTabChange = (newTab: TabView) => {
|
||||
setCurrentTab(newTab)
|
||||
setSelectedIndex(0)
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
// Settings tab handlers
|
||||
const selectedConfigEntry = configEntries[selectedIndex]
|
||||
|
||||
const handleSettingsSave = (value: string | boolean) => {
|
||||
if (!selectedConfigEntry) {
|
||||
return
|
||||
}
|
||||
const parsed = typeof value === "boolean" ? value : parseValue(value, selectedConfigEntry.type)
|
||||
|
||||
if (selectedConfigEntry.source === "global" && onUpdateGlobal) {
|
||||
onUpdateGlobal(selectedConfigEntry.key as GlobalStateAndSettingsKey, parsed as never)
|
||||
} else if (selectedConfigEntry.source === "workspace" && onUpdateWorkspace) {
|
||||
onUpdateWorkspace(selectedConfigEntry.key as LocalStateKey, parsed as never)
|
||||
}
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
const handleSettingsReset = () => {
|
||||
if (!selectedConfigEntry?.isEditable || selectedConfigEntry.source !== "global") {
|
||||
return
|
||||
}
|
||||
const defaultValue = (SETTINGS_DEFAULTS as Record<string, unknown>)[selectedConfigEntry.key]
|
||||
if (defaultValue !== undefined && onUpdateGlobal) {
|
||||
onUpdateGlobal(selectedConfigEntry.key as GlobalStateAndSettingsKey, defaultValue as never)
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle handlers for rules/workflows/hooks/skills
|
||||
const handleToggle = () => {
|
||||
if (currentTab === "rules" && ruleEntries[selectedIndex] && onToggleRule) {
|
||||
const entry = ruleEntries[selectedIndex]
|
||||
onToggleRule(entry.source === "global", entry.path, !entry.enabled, entry.ruleType || "cline")
|
||||
} else if (currentTab === "workflows" && workflowEntries[selectedIndex] && onToggleWorkflow) {
|
||||
const entry = workflowEntries[selectedIndex]
|
||||
onToggleWorkflow(entry.source === "global", entry.path, !entry.enabled)
|
||||
} else if (currentTab === "hooks" && hookEntries[selectedIndex] && onToggleHook) {
|
||||
const entry = hookEntries[selectedIndex]
|
||||
onToggleHook(entry.isGlobal, entry.hook.name, !entry.hook.enabled, entry.workspaceName)
|
||||
} else if (currentTab === "skills" && skillEntries[selectedIndex] && onToggleSkill) {
|
||||
const entry = skillEntries[selectedIndex]
|
||||
onToggleSkill(entry.isGlobal, entry.skill.path, !entry.skill.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
// Input handling
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (input.toLowerCase() === "q" || key.escape) {
|
||||
exit()
|
||||
}
|
||||
|
||||
// Tab navigation with Tab key or number keys
|
||||
if (key.tab || (input >= "1" && input <= "5")) {
|
||||
const targetIdx = key.tab
|
||||
? (availableTabs.findIndex((t) => t.key === currentTab) + 1) % availableTabs.length
|
||||
: parseInt(input) - 1
|
||||
if (targetIdx >= 0 && targetIdx < availableTabs.length) {
|
||||
handleTabChange(availableTabs[targetIdx].key)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// List navigation (arrow keys and vim-style j/k)
|
||||
if (key.upArrow || input === "k") {
|
||||
setSelectedIndex((i) => (i > 0 ? i - 1 : currentListLength - 1))
|
||||
} else if (key.downArrow || input === "j") {
|
||||
setSelectedIndex((i) => (i < currentListLength - 1 ? i + 1 : 0))
|
||||
}
|
||||
|
||||
// Tab-specific actions
|
||||
if (currentTab === "settings") {
|
||||
if ((key.return || input === "e") && selectedConfigEntry?.isEditable) {
|
||||
setEditValue(selectedConfigEntry.value !== undefined ? String(selectedConfigEntry.value) : "")
|
||||
setIsEditing(true)
|
||||
} else if (input === "r") {
|
||||
handleSettingsReset()
|
||||
}
|
||||
} else if (key.return || input === " ") {
|
||||
// Toggle for rules/workflows/hooks/skills
|
||||
handleToggle()
|
||||
}
|
||||
|
||||
// Open folder (for rules/workflows/hooks/skills tabs)
|
||||
if (input === "o" && onOpenFolder && currentTab !== "settings") {
|
||||
// Determine if current selection is global or workspace based on the selected entry
|
||||
let isGlobal = true
|
||||
if (currentTab === "rules" && ruleEntries[selectedIndex]) {
|
||||
isGlobal = ruleEntries[selectedIndex].source === "global"
|
||||
} else if (currentTab === "workflows" && workflowEntries[selectedIndex]) {
|
||||
isGlobal = workflowEntries[selectedIndex].source === "global"
|
||||
} else if (currentTab === "hooks" && hookEntries[selectedIndex]) {
|
||||
isGlobal = hookEntries[selectedIndex].isGlobal
|
||||
} else if (currentTab === "skills" && skillEntries[selectedIndex]) {
|
||||
isGlobal = skillEntries[selectedIndex].isGlobal
|
||||
}
|
||||
onOpenFolder(currentTab as "rules" | "workflows" | "hooks" | "skills", isGlobal)
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported && !isEditing },
|
||||
)
|
||||
|
||||
// Scrolling window
|
||||
const halfVisible = Math.floor(MAX_VISIBLE / 2)
|
||||
const startIndex = Math.max(0, Math.min(selectedIndex - halfVisible, currentListLength - MAX_VISIBLE))
|
||||
|
||||
// Edit mode UI (settings only)
|
||||
if (isEditing && selectedConfigEntry && currentTab === "settings") {
|
||||
const header = (
|
||||
<React.Fragment>
|
||||
<Text bold color="white">
|
||||
⚙️ Edit Configuration
|
||||
</Text>
|
||||
<Text color="gray">{SEPARATOR}</Text>
|
||||
</React.Fragment>
|
||||
)
|
||||
|
||||
if (selectedConfigEntry.type === "boolean") {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{header}
|
||||
<BooleanSelect
|
||||
label={selectedConfigEntry.key}
|
||||
onCancel={() => setIsEditing(false)}
|
||||
onSelect={handleSettingsSave}
|
||||
value={Boolean(selectedConfigEntry.value)}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{header}
|
||||
<TextInput
|
||||
label={selectedConfigEntry.key}
|
||||
onCancel={() => setIsEditing(false)}
|
||||
onChange={setEditValue}
|
||||
onSubmit={handleSettingsSave}
|
||||
type={selectedConfigEntry.type}
|
||||
value={editValue}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Render tab content
|
||||
const renderTabContent = () => {
|
||||
switch (currentTab) {
|
||||
case "settings": {
|
||||
const visibleEntries = configEntries.slice(startIndex, startIndex + MAX_VISIBLE)
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Box>
|
||||
<Text>Data directory: </Text>
|
||||
<Text color="blue" underline>
|
||||
{dataDir}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color="gray">{SEPARATOR}</Text>
|
||||
<Box flexDirection="column">
|
||||
{visibleEntries.map((entry, idx) => {
|
||||
const actualIndex = startIndex + idx
|
||||
const prevEntry = visibleEntries[idx - 1]
|
||||
const showHeader = !prevEntry || prevEntry.source !== entry.source
|
||||
|
||||
return (
|
||||
<React.Fragment key={`${entry.source}-${entry.key}`}>
|
||||
{showHeader && (
|
||||
<SectionHeader
|
||||
title={entry.source === "global" ? "Global Settings:" : "Workspace Settings:"}
|
||||
/>
|
||||
)}
|
||||
<ConfigRow entry={entry} isSelected={actualIndex === selectedIndex} />
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
case "rules": {
|
||||
if (ruleEntries.length === 0) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color="gray">
|
||||
No rules configured. Add .clinerules files to your workspace or global config.
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
const visibleEntries = ruleEntries.slice(startIndex, startIndex + MAX_VISIBLE)
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{visibleEntries.map((entry, idx) => {
|
||||
const actualIndex = startIndex + idx
|
||||
const prevEntry = visibleEntries[idx - 1]
|
||||
const showHeader = !prevEntry || prevEntry.source !== entry.source
|
||||
|
||||
return (
|
||||
<React.Fragment key={`${entry.source}-${entry.path}`}>
|
||||
{showHeader && (
|
||||
<SectionHeader title={entry.source === "global" ? "Global Rules:" : "Workspace Rules:"} />
|
||||
)}
|
||||
<ToggleRow entry={entry} isSelected={actualIndex === selectedIndex} showType />
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
case "workflows": {
|
||||
if (workflowEntries.length === 0) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color="gray">No workflows configured. Add workflow files to enable this feature.</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
const visibleEntries = workflowEntries.slice(startIndex, startIndex + MAX_VISIBLE)
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{visibleEntries.map((entry, idx) => {
|
||||
const actualIndex = startIndex + idx
|
||||
const prevEntry = visibleEntries[idx - 1]
|
||||
const showHeader = !prevEntry || prevEntry.source !== entry.source
|
||||
|
||||
return (
|
||||
<React.Fragment key={`${entry.source}-${entry.path}`}>
|
||||
{showHeader && (
|
||||
<SectionHeader
|
||||
title={entry.source === "global" ? "Global Workflows:" : "Workspace Workflows:"}
|
||||
/>
|
||||
)}
|
||||
<ToggleRow entry={entry} isSelected={actualIndex === selectedIndex} />
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
case "hooks": {
|
||||
if (hookEntries.length === 0) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color="gray">No hooks configured. Add hook scripts to enable automation.</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
const visibleEntries = hookEntries.slice(startIndex, startIndex + MAX_VISIBLE)
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{visibleEntries.map((entry, idx) => {
|
||||
const actualIndex = startIndex + idx
|
||||
const prevEntry = visibleEntries[idx - 1]
|
||||
const showHeader =
|
||||
!prevEntry ||
|
||||
prevEntry.isGlobal !== entry.isGlobal ||
|
||||
prevEntry.workspaceName !== entry.workspaceName
|
||||
|
||||
let sectionTitle = "Global Hooks:"
|
||||
if (!entry.isGlobal && entry.workspaceName) {
|
||||
sectionTitle = `${entry.workspaceName} Hooks:`
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment key={`${entry.isGlobal}-${entry.workspaceName || ""}-${entry.hook.name}`}>
|
||||
{showHeader && <SectionHeader title={sectionTitle} />}
|
||||
<HookRow hook={entry.hook} isSelected={actualIndex === selectedIndex} />
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
case "skills": {
|
||||
if (skillEntries.length === 0) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color="gray">No skills configured. Add SKILL.md files to enable skills.</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
const visibleEntries = skillEntries.slice(startIndex, startIndex + MAX_VISIBLE)
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{visibleEntries.map((entry, idx) => {
|
||||
const actualIndex = startIndex + idx
|
||||
const prevEntry = visibleEntries[idx - 1]
|
||||
const showHeader = !prevEntry || prevEntry.isGlobal !== entry.isGlobal
|
||||
|
||||
return (
|
||||
<React.Fragment key={`${entry.isGlobal}-${entry.skill.path}`}>
|
||||
{showHeader && (
|
||||
<SectionHeader title={entry.isGlobal ? "Global Skills:" : "Workspace Skills:"} />
|
||||
)}
|
||||
<SkillRow isSelected={actualIndex === selectedIndex} skill={entry.skill} />
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Help text based on current tab
|
||||
const getHelpText = () => {
|
||||
const base = "↑/↓/j/k Navigate • Tab/1-5 Switch tabs • q/Esc Exit"
|
||||
if (currentTab === "settings") {
|
||||
return `${base} • Enter/e Edit • r Reset`
|
||||
}
|
||||
const openFolder = onOpenFolder ? " • o Open folder" : ""
|
||||
return `${base} • Enter/Space Toggle${openFolder}`
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color="white">
|
||||
⚙️ Cline Configuration
|
||||
</Text>
|
||||
<Text color="gray">{SEPARATOR}</Text>
|
||||
|
||||
<TabBar currentTab={currentTab} hooksEnabled={hooksEnabled} skillsEnabled={skillsEnabled} tabs={TABS} />
|
||||
|
||||
<Text color="gray">{SEPARATOR}</Text>
|
||||
|
||||
{renderTabContent()}
|
||||
|
||||
{currentListLength > MAX_VISIBLE && (
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">
|
||||
{startIndex > 0 ? "↑ " : " "}
|
||||
Showing {startIndex + 1}-{Math.min(startIndex + MAX_VISIBLE, currentListLength)} of {currentListLength}
|
||||
{startIndex + MAX_VISIBLE < currentListLength ? " ↓" : " "}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Text color="gray">{SEPARATOR}</Text>
|
||||
|
||||
<Box flexDirection="column">
|
||||
<Text color="gray">{getHelpText()}</Text>
|
||||
{currentTab === "settings" && selectedConfigEntry && !selectedConfigEntry.isEditable && (
|
||||
<Text color="yellow">This field is read-only ({selectedConfigEntry.type} type or not a setting)</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,381 +0,0 @@
|
||||
/**
|
||||
* Sub-components and types for ConfigView
|
||||
*/
|
||||
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useState } from "react"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
|
||||
// ============================================================================
|
||||
// Types & Constants
|
||||
// ============================================================================
|
||||
|
||||
export type ValueType = "string" | "number" | "boolean" | "object" | "undefined"
|
||||
export type TabView = "settings" | "rules" | "workflows" | "hooks" | "skills"
|
||||
|
||||
export interface ConfigEntry {
|
||||
key: string
|
||||
value: unknown
|
||||
type: ValueType
|
||||
isEditable: boolean
|
||||
source: "global" | "workspace"
|
||||
}
|
||||
|
||||
export interface ToggleEntry {
|
||||
path: string
|
||||
enabled: boolean
|
||||
source: "global" | "workspace" | "remote"
|
||||
ruleType?: string
|
||||
}
|
||||
|
||||
export interface HookInfo {
|
||||
name: string
|
||||
enabled: boolean
|
||||
absolutePath: string
|
||||
}
|
||||
|
||||
export interface WorkspaceHooks {
|
||||
workspaceName: string
|
||||
hooks: HookInfo[]
|
||||
}
|
||||
|
||||
export interface SkillInfo {
|
||||
name: string
|
||||
description: string
|
||||
path: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export const EXCLUDED_KEYS = new Set([
|
||||
"taskHistory",
|
||||
"primaryRootIndex",
|
||||
"subagentsEnabled",
|
||||
"subagentTerminalOutputLineLimit",
|
||||
"welcomeViewCompleted",
|
||||
"isNewUser",
|
||||
])
|
||||
|
||||
export const EDITABLE_TYPES: Set<ValueType> = new Set(["string", "number", "boolean"])
|
||||
export const MAX_VISIBLE = 12
|
||||
export const SEPARATOR = "─".repeat(80)
|
||||
|
||||
export const TABS: { key: TabView; label: string; requiresFlag?: "hooks" | "skills" }[] = [
|
||||
{ key: "settings", label: "Settings" },
|
||||
{ key: "rules", label: "Rules" },
|
||||
{ key: "workflows", label: "Workflows" },
|
||||
{ key: "hooks", label: "Hooks", requiresFlag: "hooks" },
|
||||
{ key: "skills", label: "Skills", requiresFlag: "skills" },
|
||||
]
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
export function getValueType(value: unknown): ValueType {
|
||||
if (value === undefined || value === null) {
|
||||
return "undefined"
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return "boolean"
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return "number"
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return "object"
|
||||
}
|
||||
return "string"
|
||||
}
|
||||
|
||||
export function isExcluded(key: string, value: unknown): boolean {
|
||||
if (EXCLUDED_KEYS.has(key)) {
|
||||
return true
|
||||
}
|
||||
if (key.endsWith("Toggles") || key.endsWith("ModelInfo")) {
|
||||
return true
|
||||
}
|
||||
if (key.startsWith("apiConfig_") || key.startsWith("last")) {
|
||||
return true
|
||||
}
|
||||
if (value === undefined || value === null) {
|
||||
return true
|
||||
}
|
||||
if (typeof value === "object" && Object.keys(value as object).length === 0) {
|
||||
return true
|
||||
}
|
||||
if (Array.isArray(value) && value.length === 0) {
|
||||
return true
|
||||
}
|
||||
if (typeof value === "string" && value.trim() === "") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function formatValue(value: unknown, maxLen = 50): string {
|
||||
if (value === undefined || value === null) {
|
||||
return "<not set>"
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return value ? "true" : "false"
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return String(value)
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
const json = JSON.stringify(value)
|
||||
return json.length > maxLen ? json.slice(0, maxLen - 3) + "..." : json
|
||||
}
|
||||
const str = String(value)
|
||||
return str.length > maxLen ? str.slice(0, maxLen - 3) + "..." : str
|
||||
}
|
||||
|
||||
export function parseValue(input: string, type: ValueType): unknown {
|
||||
if (type === "boolean") {
|
||||
return input.toLowerCase() === "true" || input === "1"
|
||||
}
|
||||
if (type === "number") {
|
||||
const num = parseFloat(input)
|
||||
return Number.isNaN(num) ? 0 : num
|
||||
}
|
||||
if (type === "object") {
|
||||
try {
|
||||
return JSON.parse(input)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
// Import isSettingsKey at module level for proper test mocking
|
||||
import { isSettingsKey } from "@shared/storage/state-keys"
|
||||
|
||||
export function buildConfigEntries(state: Record<string, unknown>, source: "global" | "workspace"): ConfigEntry[] {
|
||||
return Object.entries(state)
|
||||
.filter(([key, value]) => !isExcluded(key, value))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, value]) => {
|
||||
const type = getValueType(value)
|
||||
const isEditable = EDITABLE_TYPES.has(type) && (source === "workspace" || isSettingsKey(key))
|
||||
return { key, value, type, isEditable, source }
|
||||
})
|
||||
}
|
||||
|
||||
export function buildToggleEntries(
|
||||
toggles: Record<string, boolean> | undefined,
|
||||
source: "global" | "workspace" | "remote",
|
||||
ruleType?: string,
|
||||
): ToggleEntry[] {
|
||||
if (!toggles) {
|
||||
return []
|
||||
}
|
||||
return Object.entries(toggles)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([path, enabled]) => ({ path, enabled, source, ruleType }))
|
||||
}
|
||||
|
||||
export function getFileName(path: string): string {
|
||||
return path.split("/").pop() || path
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Sub-components
|
||||
// ============================================================================
|
||||
|
||||
interface TextInputProps {
|
||||
label: string
|
||||
onChange: (value: string) => void
|
||||
onCancel: () => void
|
||||
onSubmit: (value: string) => void
|
||||
type: ValueType
|
||||
value: string
|
||||
}
|
||||
|
||||
export const TextInput: React.FC<TextInputProps> = ({ label, onChange, onCancel, onSubmit, type, value }) => {
|
||||
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}>
|
||||
<Text bold color="cyan">
|
||||
Edit: {label}
|
||||
</Text>
|
||||
<Box>
|
||||
<Text color="white">{value}</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
<Text color="gray">Type: {type} • Enter to save • Esc to cancel</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
interface BooleanSelectProps {
|
||||
label: string
|
||||
onCancel: () => void
|
||||
onSelect: (value: boolean) => void
|
||||
value: boolean
|
||||
}
|
||||
|
||||
export const BooleanSelect: React.FC<BooleanSelectProps> = ({ label, onCancel, onSelect, value }) => {
|
||||
const { 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)
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported },
|
||||
)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold color="cyan">
|
||||
Edit: {label}
|
||||
</Text>
|
||||
<Box flexDirection="column">
|
||||
<Text color={selected ? "green" : undefined}>{selected ? "❯ " : " "}true</Text>
|
||||
<Text color={!selected ? "green" : undefined}>{!selected ? "❯ " : " "}false</Text>
|
||||
</Box>
|
||||
<Text color="gray">↑/↓ to toggle • Enter to save • Esc to cancel</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const ConfigRow: React.FC<{ entry: ConfigEntry; isSelected: boolean }> = ({ entry, isSelected }) => {
|
||||
const valueColor = entry.type === "boolean" ? (entry.value ? "green" : "red") : "white"
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color={isSelected ? "cyan" : undefined}>
|
||||
{isSelected ? "❯ " : " "}
|
||||
<Text color="cyan">{entry.key}</Text>
|
||||
<Text color="gray">: </Text>
|
||||
<Text color={valueColor}>{formatValue(entry.value)}</Text>
|
||||
{!entry.isEditable && <Text color="gray"> (read-only)</Text>}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const ToggleRow: React.FC<{
|
||||
entry: ToggleEntry
|
||||
isSelected: boolean
|
||||
showType?: boolean
|
||||
}> = ({ entry, isSelected, showType }) => {
|
||||
const fileName = getFileName(entry.path)
|
||||
const typeLabel = entry.ruleType ? ` [${entry.ruleType}]` : ""
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color={isSelected ? "cyan" : undefined}>
|
||||
{isSelected ? "❯ " : " "}
|
||||
<Text color={entry.enabled ? "green" : "red"}>{entry.enabled ? "●" : "○"}</Text>
|
||||
<Text> </Text>
|
||||
<Text color="white">{fileName}</Text>
|
||||
{showType && <Text color="gray">{typeLabel}</Text>}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const HookRow: React.FC<{
|
||||
hook: HookInfo
|
||||
isSelected: boolean
|
||||
}> = ({ hook, isSelected }) => {
|
||||
return (
|
||||
<Box>
|
||||
<Text color={isSelected ? "cyan" : undefined}>
|
||||
{isSelected ? "❯ " : " "}
|
||||
<Text color={hook.enabled ? "green" : "red"}>{hook.enabled ? "●" : "○"}</Text>
|
||||
<Text> </Text>
|
||||
<Text color="white">{hook.name}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const SkillRow: React.FC<{
|
||||
skill: SkillInfo
|
||||
isSelected: boolean
|
||||
}> = ({ skill, isSelected }) => {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text color={isSelected ? "cyan" : undefined}>
|
||||
{isSelected ? "❯ " : " "}
|
||||
<Text color={skill.enabled ? "green" : "red"}>{skill.enabled ? "●" : "○"}</Text>
|
||||
<Text> </Text>
|
||||
<Text bold color="white">
|
||||
{skill.name}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
{skill.description && (
|
||||
<Box marginLeft={4}>
|
||||
<Text color="gray">
|
||||
{skill.description.length > 60 ? skill.description.slice(0, 57) + "..." : skill.description}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const TabBar: React.FC<{
|
||||
currentTab: TabView
|
||||
tabs: typeof TABS
|
||||
hooksEnabled?: boolean
|
||||
skillsEnabled?: boolean
|
||||
}> = ({ currentTab, tabs, hooksEnabled, skillsEnabled }) => {
|
||||
const visibleTabs = tabs.filter((tab) => {
|
||||
if (tab.requiresFlag === "hooks") {
|
||||
return hooksEnabled
|
||||
}
|
||||
if (tab.requiresFlag === "skills") {
|
||||
return skillsEnabled
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return (
|
||||
<Box marginBottom={1}>
|
||||
{visibleTabs.map((tab, idx) => (
|
||||
<React.Fragment key={tab.key}>
|
||||
{idx > 0 && <Text color="gray"> │ </Text>}
|
||||
<Text bold={currentTab === tab.key} color={currentTab === tab.key ? "cyan" : "gray"}>
|
||||
{currentTab === tab.key ? `[${tab.label}]` : tab.label}
|
||||
</Text>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const SectionHeader: React.FC<{ title: string }> = ({ title }) => (
|
||||
<Box marginTop={1}>
|
||||
<Text bold color="yellow">
|
||||
{title}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
@@ -1,301 +0,0 @@
|
||||
/**
|
||||
* Stateful wrapper for ConfigView that handles toggle operations
|
||||
*/
|
||||
|
||||
import { exec } from "node:child_process"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
|
||||
import { RuleScope } from "@shared/proto/cline/file"
|
||||
import type { GlobalStateAndSettings, GlobalStateAndSettingsKey, LocalState, LocalStateKey } from "@shared/storage/state-keys"
|
||||
import React, { useCallback, useEffect, useState } from "react"
|
||||
|
||||
import type { Controller } from "@/core/controller"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { StdinProvider } from "../context/StdinContext"
|
||||
import { ConfigView } from "./ConfigView"
|
||||
|
||||
interface HookInfo {
|
||||
name: string
|
||||
enabled: boolean
|
||||
absolutePath: string
|
||||
}
|
||||
|
||||
interface WorkspaceHooks {
|
||||
workspaceName: string
|
||||
hooks: HookInfo[]
|
||||
}
|
||||
|
||||
interface SkillInfo {
|
||||
name: string
|
||||
description: string
|
||||
path: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface ConfigViewWrapperProps {
|
||||
controller: Controller
|
||||
dataDir: string
|
||||
globalState: Record<string, unknown>
|
||||
workspaceState: Record<string, unknown>
|
||||
hooksEnabled: boolean
|
||||
skillsEnabled: boolean
|
||||
isRawModeSupported?: boolean
|
||||
}
|
||||
|
||||
export const ConfigViewWrapper: React.FC<ConfigViewWrapperProps> = ({
|
||||
controller,
|
||||
dataDir,
|
||||
globalState: initialGlobalState,
|
||||
workspaceState: initialWorkspaceState,
|
||||
hooksEnabled,
|
||||
skillsEnabled,
|
||||
isRawModeSupported = true,
|
||||
}) => {
|
||||
// Settings state (managed locally for UI updates)
|
||||
const [globalStateLocal, setGlobalStateLocal] = useState<Record<string, unknown>>(initialGlobalState)
|
||||
const [workspaceStateLocal, setWorkspaceStateLocal] = useState<Record<string, unknown>>(initialWorkspaceState)
|
||||
|
||||
// Rules state
|
||||
const [globalClineRulesToggles, setGlobalClineRulesToggles] = useState<Record<string, boolean>>({})
|
||||
const [localClineRulesToggles, setLocalClineRulesToggles] = useState<Record<string, boolean>>({})
|
||||
const [localCursorRulesToggles, setLocalCursorRulesToggles] = useState<Record<string, boolean>>({})
|
||||
const [localWindsurfRulesToggles, setLocalWindsurfRulesToggles] = useState<Record<string, boolean>>({})
|
||||
const [localAgentsRulesToggles, setLocalAgentsRulesToggles] = useState<Record<string, boolean>>({})
|
||||
|
||||
// Workflow state
|
||||
const [globalWorkflowToggles, setGlobalWorkflowToggles] = useState<Record<string, boolean>>({})
|
||||
const [localWorkflowToggles, setLocalWorkflowToggles] = useState<Record<string, boolean>>({})
|
||||
|
||||
// Hooks state
|
||||
const [globalHooks, setGlobalHooks] = useState<HookInfo[]>([])
|
||||
const [workspaceHooksState, setWorkspaceHooksState] = useState<WorkspaceHooks[]>([])
|
||||
|
||||
// Skills state
|
||||
const [globalSkills, setGlobalSkills] = useState<SkillInfo[]>([])
|
||||
const [localSkills, setLocalSkills] = useState<SkillInfo[]>([])
|
||||
|
||||
// Load initial data
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
const { refreshRules } = await import("@/core/controller/file/refreshRules")
|
||||
const { refreshHooks } = await import("@/core/controller/file/refreshHooks")
|
||||
const { refreshSkills } = await import("@/core/controller/file/refreshSkills")
|
||||
|
||||
const rulesData = await refreshRules(controller, {})
|
||||
setGlobalClineRulesToggles(rulesData.globalClineRulesToggles?.toggles || {})
|
||||
setLocalClineRulesToggles(rulesData.localClineRulesToggles?.toggles || {})
|
||||
setLocalCursorRulesToggles(rulesData.localCursorRulesToggles?.toggles || {})
|
||||
setLocalWindsurfRulesToggles(rulesData.localWindsurfRulesToggles?.toggles || {})
|
||||
setLocalAgentsRulesToggles(rulesData.localAgentsRulesToggles?.toggles || {})
|
||||
setGlobalWorkflowToggles(rulesData.globalWorkflowToggles?.toggles || {})
|
||||
setLocalWorkflowToggles(rulesData.localWorkflowToggles?.toggles || {})
|
||||
|
||||
if (hooksEnabled) {
|
||||
const hooksData = await refreshHooks(controller, {})
|
||||
setGlobalHooks(hooksData.globalHooks || [])
|
||||
setWorkspaceHooksState(hooksData.workspaceHooks || [])
|
||||
}
|
||||
|
||||
if (skillsEnabled) {
|
||||
const skillsData = await refreshSkills(controller)
|
||||
setGlobalSkills(skillsData.globalSkills || [])
|
||||
setLocalSkills(skillsData.localSkills || [])
|
||||
}
|
||||
}
|
||||
loadData()
|
||||
}, [controller, hooksEnabled, skillsEnabled])
|
||||
|
||||
// Toggle handlers
|
||||
const handleToggleRule = useCallback(
|
||||
async (isGlobal: boolean, rulePath: string, enabled: boolean, ruleType: string) => {
|
||||
const { toggleClineRule } = await import("@/core/controller/file/toggleClineRule")
|
||||
|
||||
// Determine scope based on isGlobal and rule type
|
||||
const scope = isGlobal ? RuleScope.GLOBAL : RuleScope.LOCAL
|
||||
|
||||
// For non-cline rules, we need different toggle functions
|
||||
if (ruleType === "cursor") {
|
||||
// Update local state optimistically
|
||||
setLocalCursorRulesToggles((prev) => ({ ...prev, [rulePath]: enabled }))
|
||||
// Cursor rules use toggleCursorRule but we'll just update the state manager directly
|
||||
const toggles = controller.stateManager.getWorkspaceStateKey("localCursorRulesToggles") || {}
|
||||
toggles[rulePath] = enabled
|
||||
controller.stateManager.setWorkspaceState("localCursorRulesToggles", toggles)
|
||||
} else if (ruleType === "windsurf") {
|
||||
setLocalWindsurfRulesToggles((prev) => ({ ...prev, [rulePath]: enabled }))
|
||||
const toggles = controller.stateManager.getWorkspaceStateKey("localWindsurfRulesToggles") || {}
|
||||
toggles[rulePath] = enabled
|
||||
controller.stateManager.setWorkspaceState("localWindsurfRulesToggles", toggles)
|
||||
} else if (ruleType === "agents") {
|
||||
setLocalAgentsRulesToggles((prev) => ({ ...prev, [rulePath]: enabled }))
|
||||
const toggles = controller.stateManager.getWorkspaceStateKey("localAgentsRulesToggles") || {}
|
||||
toggles[rulePath] = enabled
|
||||
controller.stateManager.setWorkspaceState("localAgentsRulesToggles", toggles)
|
||||
} else {
|
||||
// Cline rules
|
||||
const result = await toggleClineRule(controller, { metadata: undefined, rulePath, enabled, scope })
|
||||
if (result.globalClineRulesToggles?.toggles) {
|
||||
setGlobalClineRulesToggles(result.globalClineRulesToggles.toggles)
|
||||
}
|
||||
if (result.localClineRulesToggles?.toggles) {
|
||||
setLocalClineRulesToggles(result.localClineRulesToggles.toggles)
|
||||
}
|
||||
}
|
||||
},
|
||||
[controller],
|
||||
)
|
||||
|
||||
const handleToggleWorkflow = useCallback(
|
||||
async (isGlobal: boolean, workflowPath: string, enabled: boolean) => {
|
||||
const { toggleWorkflow } = await import("@/core/controller/file/toggleWorkflow")
|
||||
const scope = isGlobal ? RuleScope.GLOBAL : RuleScope.LOCAL
|
||||
|
||||
// Optimistic update
|
||||
if (isGlobal) {
|
||||
setGlobalWorkflowToggles((prev) => ({ ...prev, [workflowPath]: enabled }))
|
||||
} else {
|
||||
setLocalWorkflowToggles((prev) => ({ ...prev, [workflowPath]: enabled }))
|
||||
}
|
||||
|
||||
await toggleWorkflow(controller, { metadata: undefined, workflowPath, enabled, scope })
|
||||
},
|
||||
[controller],
|
||||
)
|
||||
|
||||
const handleToggleHook = useCallback(
|
||||
async (isGlobal: boolean, hookName: string, enabled: boolean, workspaceName?: string) => {
|
||||
const { toggleHook } = await import("@/core/controller/file/toggleHook")
|
||||
|
||||
// Optimistic update
|
||||
if (isGlobal) {
|
||||
setGlobalHooks((prev) => prev.map((h) => (h.name === hookName ? { ...h, enabled } : h)))
|
||||
} else {
|
||||
setWorkspaceHooksState((prev) =>
|
||||
prev.map((ws) =>
|
||||
ws.workspaceName === workspaceName
|
||||
? { ...ws, hooks: ws.hooks.map((h) => (h.name === hookName ? { ...h, enabled } : h)) }
|
||||
: ws,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const result = await toggleHook(controller, { metadata: undefined, hookName, isGlobal, enabled, workspaceName })
|
||||
if (result.hooksToggles) {
|
||||
setGlobalHooks(result.hooksToggles.globalHooks || [])
|
||||
setWorkspaceHooksState(result.hooksToggles.workspaceHooks || [])
|
||||
}
|
||||
},
|
||||
[controller],
|
||||
)
|
||||
|
||||
const handleToggleSkill = useCallback(
|
||||
async (isGlobal: boolean, skillPath: string, enabled: boolean) => {
|
||||
const { toggleSkill } = await import("@/core/controller/file/toggleSkill")
|
||||
|
||||
// Optimistic update
|
||||
if (isGlobal) {
|
||||
setGlobalSkills((prev) => prev.map((s) => (s.path === skillPath ? { ...s, enabled } : s)))
|
||||
} else {
|
||||
setLocalSkills((prev) => prev.map((s) => (s.path === skillPath ? { ...s, enabled } : s)))
|
||||
}
|
||||
|
||||
await toggleSkill(controller, { metadata: undefined, skillPath, isGlobal, enabled })
|
||||
},
|
||||
[controller],
|
||||
)
|
||||
|
||||
const handleOpenFolder = useCallback(
|
||||
async (folderType: "rules" | "workflows" | "hooks" | "skills", isGlobal: boolean) => {
|
||||
let folderPath: string
|
||||
|
||||
if (isGlobal) {
|
||||
// Global folders are in dataDir (e.g., ~/.cline/)
|
||||
const subFolder = folderType === "rules" ? "rules" : folderType
|
||||
folderPath = path.join(dataDir, subFolder)
|
||||
} else {
|
||||
// Local folders are in the workspace
|
||||
const workspacePaths = await HostProvider.workspace.getWorkspacePaths({})
|
||||
const primaryWorkspace = workspacePaths.paths[0]
|
||||
if (!primaryWorkspace) {
|
||||
return
|
||||
}
|
||||
// Local rules/workflows/hooks/skills are in .clinerules or .cline
|
||||
const subFolder = folderType === "rules" ? "rules" : folderType
|
||||
folderPath = path.join(primaryWorkspace, ".clinerules", subFolder)
|
||||
}
|
||||
|
||||
// Open folder using platform-specific command
|
||||
const platform = os.platform()
|
||||
let command: string
|
||||
if (platform === "darwin") {
|
||||
command = `open "${folderPath}"`
|
||||
} else if (platform === "win32") {
|
||||
command = `explorer "${folderPath}"`
|
||||
} else {
|
||||
command = `xdg-open "${folderPath}"`
|
||||
}
|
||||
|
||||
exec(command, (error) => {
|
||||
if (error) {
|
||||
// Folder might not exist, try to create and open
|
||||
exec(`mkdir -p "${folderPath}" && ${command}`)
|
||||
}
|
||||
})
|
||||
},
|
||||
[dataDir],
|
||||
)
|
||||
|
||||
// Settings update handlers
|
||||
const handleUpdateGlobal = useCallback(
|
||||
async (key: GlobalStateAndSettingsKey, value: GlobalStateAndSettings[GlobalStateAndSettingsKey]) => {
|
||||
// Update local state for immediate UI feedback
|
||||
setGlobalStateLocal((prev) => ({ ...prev, [key]: value }))
|
||||
// Persist to state manager
|
||||
controller.stateManager.setGlobalState(key, value)
|
||||
await controller.stateManager.flushPendingState()
|
||||
},
|
||||
[controller],
|
||||
)
|
||||
|
||||
const handleUpdateWorkspace = useCallback(
|
||||
async (key: LocalStateKey, value: LocalState[LocalStateKey]) => {
|
||||
// Update local state for immediate UI feedback
|
||||
setWorkspaceStateLocal((prev) => ({ ...prev, [key]: value }))
|
||||
// Persist to state manager
|
||||
controller.stateManager.setWorkspaceState(key, value)
|
||||
await controller.stateManager.flushPendingState()
|
||||
},
|
||||
[controller],
|
||||
)
|
||||
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
/**
|
||||
* 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 {
|
||||
/** Diff content (SEARCH/REPLACE format, ApplyPatch format, or raw content for new files) */
|
||||
content?: string
|
||||
}
|
||||
|
||||
interface DiffLine {
|
||||
type: "add" | "remove" | "context" | "separator"
|
||||
content: string
|
||||
}
|
||||
|
||||
interface ParsedPatch {
|
||||
additions: number
|
||||
deletions: number
|
||||
lines: DiffLine[]
|
||||
}
|
||||
|
||||
// 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++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse ApplyPatch format into diff lines
|
||||
* Format: *** Begin Patch\n*** Update File: path\n+line\n-line\n*** End Patch
|
||||
*/
|
||||
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 result
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse tool content into diff lines
|
||||
* Detects format and delegates to appropriate parser
|
||||
*/
|
||||
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")
|
||||
return {
|
||||
additions: lines.length,
|
||||
deletions: 0,
|
||||
lines: lines.map((line) => ({ type: "add", content: line })),
|
||||
}
|
||||
}
|
||||
|
||||
// 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>
|
||||
)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DiffView component that renders file edits as a diff
|
||||
* Supports SEARCH/REPLACE format and ApplyPatch format
|
||||
*/
|
||||
export const DiffView: React.FC<DiffViewProps> = ({ content }) => {
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = parseToolContent(content)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width="100%">
|
||||
{parsed.lines.map((line, idx) => (
|
||||
<DiffLineRow key={idx} line={line} />
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* File mention menu component for CLI
|
||||
* Displays a list of matching files when user types @
|
||||
*/
|
||||
|
||||
import { Box, Text } from "ink"
|
||||
import React from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { type FileSearchResult, getRipgrepInstallInstructions } from "../utils/file-search"
|
||||
import { getVisibleWindow } from "../utils/slash-commands"
|
||||
|
||||
interface FileMentionMenuProps {
|
||||
results: FileSearchResult[]
|
||||
selectedIndex: number
|
||||
isLoading: boolean
|
||||
query: string
|
||||
showRipgrepWarning?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate path from the left if too long, keeping the filename visible
|
||||
*/
|
||||
function truncatePath(filePath: string, maxLength: number = 50): string {
|
||||
if (filePath.length <= maxLength) {
|
||||
return filePath
|
||||
}
|
||||
return "..." + filePath.slice(-(maxLength - 3))
|
||||
}
|
||||
|
||||
export const FileMentionMenu: React.FC<FileMentionMenuProps> = ({
|
||||
results,
|
||||
selectedIndex,
|
||||
isLoading,
|
||||
query,
|
||||
showRipgrepWarning,
|
||||
}) => {
|
||||
const ripgrepWarning = showRipgrepWarning && (
|
||||
<Box marginTop={1}>
|
||||
<Text color="yellow">ripgrep not found - file search will be slower. </Text>
|
||||
<Text color="gray">Install: {getRipgrepInstallInstructions()}</Text>
|
||||
</Box>
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} paddingLeft={1} paddingRight={1}>
|
||||
<Text color="gray">Searching files...</Text>
|
||||
{ripgrepWarning}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} paddingLeft={1} paddingRight={1}>
|
||||
<Text color="gray">{query ? `No files matching "${query}"` : "Type to search files..."}</Text>
|
||||
{ripgrepWarning}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const { items: visibleResults, startIndex } = getVisibleWindow(results, selectedIndex)
|
||||
const hasMoreBelow = startIndex + visibleResults.length < results.length
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} paddingLeft={1} paddingRight={1}>
|
||||
{visibleResults.map((result, idx) => {
|
||||
const isSelected = startIndex + idx === selectedIndex
|
||||
const displayPath = truncatePath(result.path)
|
||||
|
||||
return (
|
||||
<Box key={result.path}>
|
||||
<Text color={isSelected ? COLORS.primaryBlue : undefined}>
|
||||
{isSelected ? "❯" : " "} {displayPath}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
{hasMoreBelow && <Text color="gray">{" "}▼</Text>}
|
||||
{ripgrepWarning}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
/**
|
||||
* Focus Chain / To-Do List component for CLI
|
||||
* Displays a progress-tracked checklist of tasks
|
||||
*/
|
||||
|
||||
import { isCompletedFocusChainItem, isFocusChainItem, parseFocusChainItem } from "@shared/focus-chain-utils"
|
||||
import { Box, Text } from "ink"
|
||||
import React, { useMemo } from "react"
|
||||
|
||||
interface TodoInfo {
|
||||
currentTodo: { text: string; completed: boolean; index: number } | null
|
||||
currentIndex: number
|
||||
completedCount: number
|
||||
totalCount: number
|
||||
progressPercentage: number
|
||||
}
|
||||
|
||||
interface TodoItem {
|
||||
text: string
|
||||
checked: boolean
|
||||
}
|
||||
|
||||
interface FocusChainProps {
|
||||
focusChainChecklist?: string | null
|
||||
expanded?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the focus chain checklist text into TodoInfo
|
||||
*/
|
||||
function parseCurrentTodoInfo(text: string): TodoInfo | null {
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
|
||||
let completedCount = 0
|
||||
let totalCount = 0
|
||||
let firstIncompleteIndex = -1
|
||||
let firstIncompleteText: string | null = null
|
||||
|
||||
const lines = text.split("\n")
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim()
|
||||
if (isFocusChainItem(line)) {
|
||||
const isCompleted = isCompletedFocusChainItem(line)
|
||||
|
||||
if (isCompleted) {
|
||||
completedCount++
|
||||
} else if (firstIncompleteIndex === -1) {
|
||||
firstIncompleteIndex = totalCount
|
||||
// Extract text after "- [ ] "
|
||||
firstIncompleteText = line.substring(5).trim()
|
||||
}
|
||||
|
||||
totalCount++
|
||||
}
|
||||
}
|
||||
|
||||
if (totalCount === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const currentTodo = firstIncompleteText ? { text: firstIncompleteText, completed: false, index: firstIncompleteIndex } : null
|
||||
|
||||
return {
|
||||
currentTodo,
|
||||
currentIndex: firstIncompleteIndex >= 0 ? firstIncompleteIndex + 1 : totalCount,
|
||||
completedCount,
|
||||
totalCount,
|
||||
progressPercentage: (completedCount / totalCount) * 100,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse all todo items from the checklist
|
||||
*/
|
||||
function parseTodoItems(text: string): TodoItem[] {
|
||||
const items: TodoItem[] = []
|
||||
const lines = text.split("\n")
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim()
|
||||
const parsed = parseFocusChainItem(line)
|
||||
if (parsed) {
|
||||
items.push(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
/**
|
||||
* Render progress bar
|
||||
*/
|
||||
const ProgressBar: React.FC<{ percentage: number; width?: number }> = ({ percentage, width = 20 }) => {
|
||||
const filled = Math.round((percentage / 100) * width)
|
||||
const empty = width - filled
|
||||
const bar = "█".repeat(filled) + "░".repeat(empty)
|
||||
|
||||
return (
|
||||
<Text>
|
||||
<Text color="green">{bar}</Text>
|
||||
<Text dimColor> {Math.round(percentage)}%</Text>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Header view showing current task and progress
|
||||
*/
|
||||
const Header: React.FC<{
|
||||
todoInfo: TodoInfo
|
||||
}> = ({ todoInfo }) => {
|
||||
const { currentTodo, currentIndex, totalCount, completedCount } = todoInfo
|
||||
const isCompleted = completedCount === totalCount
|
||||
|
||||
const displayText = isCompleted ? "All tasks completed!" : currentTodo?.text || "To-Do list"
|
||||
const truncatedText = displayText.length > 50 ? displayText.substring(0, 47) + "..." : displayText
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={isCompleted ? "green" : "cyan"}>
|
||||
[{currentIndex}/{totalCount}]
|
||||
</Text>
|
||||
<Text color={isCompleted ? "green" : undefined}>{truncatedText}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Expanded view showing all todo items
|
||||
*/
|
||||
const ExpandedList: React.FC<{
|
||||
items: TodoItem[]
|
||||
isCompleted: boolean
|
||||
}> = ({ items, isCompleted }) => {
|
||||
return (
|
||||
<Box flexDirection="column" marginLeft={2} marginTop={1}>
|
||||
{items.map((item, index) => (
|
||||
<Box key={index}>
|
||||
<Text color={item.checked ? "green" : "gray"}>{item.checked ? "✓" : "○"} </Text>
|
||||
<Text color={item.checked ? "green" : undefined} dimColor={item.checked}>
|
||||
{item.text}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
{isCompleted && (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor italic>
|
||||
New steps will be generated if you continue the task
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Main FocusChain component for CLI
|
||||
* Shows a progress summary of the current to-do list
|
||||
* Use expanded={true} to show all items (e.g., in verbose mode)
|
||||
*/
|
||||
export const FocusChain: React.FC<FocusChainProps> = ({ focusChainChecklist, expanded = false }) => {
|
||||
const todoInfo = useMemo(
|
||||
() => (focusChainChecklist ? parseCurrentTodoInfo(focusChainChecklist) : null),
|
||||
[focusChainChecklist],
|
||||
)
|
||||
|
||||
const todoItems = useMemo(() => (focusChainChecklist ? parseTodoItems(focusChainChecklist) : []), [focusChainChecklist])
|
||||
|
||||
// No content to display
|
||||
if (!todoInfo) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isCompleted = todoInfo.completedCount === todoInfo.totalCount
|
||||
|
||||
return (
|
||||
<Box borderColor={isCompleted ? "green" : "gray"} borderStyle="round" flexDirection="column" paddingX={1}>
|
||||
<Header todoInfo={todoInfo} />
|
||||
<ProgressBar percentage={todoInfo.progressPercentage} />
|
||||
{expanded && <ExpandedList isCompleted={isCompleted} items={todoItems} />}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
/**
|
||||
* History panel content for inline display in ChatView
|
||||
* Shows task history with search and keyboard navigation
|
||||
*/
|
||||
|
||||
import { StringRequest } from "@shared/proto/cline/common"
|
||||
import { GetTaskHistoryRequest } from "@shared/proto/cline/task"
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import type { Controller } from "@/core/controller"
|
||||
import { getTaskHistory } from "@/core/controller/task/getTaskHistory"
|
||||
import { showTaskWithId } from "@/core/controller/task/showTaskWithId"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { useTerminalSize } from "../hooks/useTerminalSize"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { Panel } from "./Panel"
|
||||
|
||||
interface TaskHistoryItem {
|
||||
id: string
|
||||
ts: number
|
||||
task: string
|
||||
totalCost: number
|
||||
tokensIn: number
|
||||
tokensOut: number
|
||||
isFavorited: boolean
|
||||
}
|
||||
|
||||
interface HistoryPanelContentProps {
|
||||
onClose: () => void
|
||||
onSelectTask: (taskId: string) => void
|
||||
controller: Controller
|
||||
}
|
||||
|
||||
function formatRelativeDate(ts: number): string {
|
||||
const now = Date.now()
|
||||
const diff = now - ts
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
const hours = Math.floor(diff / 3600000)
|
||||
const days = Math.floor(diff / 86400000)
|
||||
|
||||
if (minutes < 1) return "just now"
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
if (days < 7) return `${days}d ago`
|
||||
return new Date(ts).toLocaleDateString()
|
||||
}
|
||||
|
||||
function formatCost(cost: number): string {
|
||||
if (cost === 0) return ""
|
||||
return `$${cost.toFixed(2)}`
|
||||
}
|
||||
|
||||
export const HistoryPanelContent: React.FC<HistoryPanelContentProps> = ({ onClose, onSelectTask, controller }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const { rows: terminalRows } = useTerminalSize()
|
||||
const [items, setItems] = useState<TaskHistoryItem[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// Calculate how many items fit in the panel
|
||||
// Panel has border (2) + header (1) + separator (1) + search bar (1) + hint (1) = 6 lines overhead
|
||||
// Each item takes 2 lines (text + metadata)
|
||||
const panelHeight = Math.min(terminalRows - 6, 20) // Cap panel height
|
||||
const itemHeight = 2
|
||||
const maxVisible = Math.max(1, Math.floor((panelHeight - 4) / itemHeight) - 2) // 4 lines for search + hints + padding
|
||||
|
||||
// Load history
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const request = GetTaskHistoryRequest.create({
|
||||
sortBy: "newest",
|
||||
searchQuery: searchQuery || undefined,
|
||||
})
|
||||
const result = await getTaskHistory(controller, request)
|
||||
setItems(
|
||||
result.tasks.map((t) => ({
|
||||
id: t.id,
|
||||
ts: t.ts,
|
||||
task: t.task,
|
||||
totalCost: t.totalCost,
|
||||
tokensIn: t.tokensIn,
|
||||
tokensOut: t.tokensOut,
|
||||
isFavorited: t.isFavorited,
|
||||
})),
|
||||
)
|
||||
} catch {
|
||||
setItems([])
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
load()
|
||||
}, [controller, searchQuery])
|
||||
|
||||
// Reset selection when search changes
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0)
|
||||
}, [searchQuery])
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (item: TaskHistoryItem) => {
|
||||
try {
|
||||
await showTaskWithId(controller, StringRequest.create({ value: item.id }))
|
||||
onSelectTask(item.id)
|
||||
} catch (error) {
|
||||
console.error("Error opening task:", error)
|
||||
}
|
||||
},
|
||||
[controller, onSelectTask],
|
||||
)
|
||||
|
||||
// Visible window
|
||||
const scrollOffset = useMemo(() => {
|
||||
const half = Math.floor(maxVisible / 2)
|
||||
let start = Math.max(0, selectedIndex - half)
|
||||
const end = Math.min(items.length, start + maxVisible)
|
||||
if (end - start < maxVisible) {
|
||||
start = Math.max(0, end - maxVisible)
|
||||
}
|
||||
return start
|
||||
}, [selectedIndex, maxVisible, items.length])
|
||||
|
||||
const visibleItems = items.slice(scrollOffset, scrollOffset + maxVisible)
|
||||
const showUpIndicator = scrollOffset > 0
|
||||
const showDownIndicator = scrollOffset + maxVisible < items.length
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (isMouseEscapeSequence(input)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (key.escape) {
|
||||
if (searchQuery) {
|
||||
setSearchQuery("")
|
||||
} else {
|
||||
onClose()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (key.return && items[selectedIndex]) {
|
||||
handleSelect(items[selectedIndex])
|
||||
return
|
||||
}
|
||||
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((i) => Math.max(0, i - 1))
|
||||
return
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setSelectedIndex((i) => Math.min(items.length - 1, i + 1))
|
||||
return
|
||||
}
|
||||
|
||||
// Backspace for search
|
||||
if (key.backspace || key.delete) {
|
||||
setSearchQuery((q) => q.slice(0, -1))
|
||||
return
|
||||
}
|
||||
|
||||
// Printable characters for search
|
||||
if (input && !key.ctrl && !key.meta && input.length === 1 && input.charCodeAt(0) >= 32) {
|
||||
setSearchQuery((q) => q + input)
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported },
|
||||
)
|
||||
|
||||
const renderContent = () => {
|
||||
if (loading) {
|
||||
return <Text color="gray">Loading history...</Text>
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return <Text color="gray">{searchQuery ? "No tasks match your search." : "No task history."}</Text>
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="gray">{showUpIndicator ? " ▲" : " "}</Text>
|
||||
{visibleItems.map((item, idx) => {
|
||||
const actualIndex = scrollOffset + idx
|
||||
const isSelected = actualIndex === selectedIndex
|
||||
const taskText = item.task.replace(/\n/g, " ")
|
||||
const meta = [formatRelativeDate(item.ts), formatCost(item.totalCost)].filter(Boolean).join(" · ")
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" key={item.id}>
|
||||
<Box overflow="hidden">
|
||||
<Text color={isSelected ? COLORS.primaryBlue : undefined} wrap="truncate">
|
||||
{isSelected ? "❯ " : " "}
|
||||
{taskText}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color="gray">
|
||||
{" "}
|
||||
{meta}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
<Text color="gray">{showDownIndicator ? " ▼" : " "}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel label="History">
|
||||
<Box>
|
||||
<Text color="gray">Search: </Text>
|
||||
<Text color="white">{searchQuery}</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color="gray">{searchQuery ? "Esc to clear" : "Enter to open · Esc to close"}</Text>
|
||||
</Box>
|
||||
{renderContent()}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
import { Text } from "ink"
|
||||
import { render } from "ink-testing-library"
|
||||
import React from "react"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
// Create stable mock reference using vi.hoisted
|
||||
const { mockShowTaskWithId } = vi.hoisted(() => ({
|
||||
mockShowTaskWithId: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock("./TaskView", () => ({
|
||||
TaskView: ({ taskId, verbose }: any) =>
|
||||
React.createElement(Text, null, `TaskView: ${taskId || "no-id"} verbose=${String(verbose)}`),
|
||||
}))
|
||||
|
||||
// Mock the controller dependencies - must be before importing HistoryView
|
||||
vi.mock("@/core/controller", () => ({
|
||||
Controller: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/core/controller/task/showTaskWithId", () => ({
|
||||
showTaskWithId: mockShowTaskWithId,
|
||||
}))
|
||||
|
||||
vi.mock("@/shared/proto/cline/common", () => ({
|
||||
StringRequest: {
|
||||
create: (data: any) => data,
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock useTerminalSize to prevent EventEmitter memory leak warnings from resize listeners
|
||||
vi.mock("../hooks/useTerminalSize", () => ({
|
||||
useTerminalSize: () => ({ columns: 80, rows: 24, resizeKey: 0 }),
|
||||
}))
|
||||
|
||||
// Import after mocks are set up
|
||||
import { HistoryView } from "./HistoryView"
|
||||
|
||||
describe("HistoryView", () => {
|
||||
const mockController = {
|
||||
dispose: vi.fn(),
|
||||
stateManager: { flushPendingState: vi.fn() },
|
||||
} as any
|
||||
|
||||
const mockItems = [
|
||||
{ id: "task-1", ts: Date.now() - 3600000, task: "First task" },
|
||||
{ id: "task-2", ts: Date.now() - 7200000, task: "Second task" },
|
||||
{ id: "task-3", ts: Date.now() - 10800000, task: "Third task" },
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("rendering", () => {
|
||||
it("should render the history header", () => {
|
||||
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} />)
|
||||
expect(lastFrame()).toContain("Task History")
|
||||
})
|
||||
|
||||
it("should show total count in header", () => {
|
||||
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} />)
|
||||
expect(lastFrame()).toContain("3 total")
|
||||
})
|
||||
|
||||
it("should render task items", () => {
|
||||
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} />)
|
||||
expect(lastFrame()).toContain("First task")
|
||||
expect(lastFrame()).toContain("Second task")
|
||||
expect(lastFrame()).toContain("Third task")
|
||||
})
|
||||
|
||||
it("should show task IDs", () => {
|
||||
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} />)
|
||||
expect(lastFrame()).toContain("task-1")
|
||||
expect(lastFrame()).toContain("task-2")
|
||||
})
|
||||
|
||||
it("should show empty message when no items", () => {
|
||||
const { lastFrame } = render(<HistoryView controller={mockController} items={[]} />)
|
||||
expect(lastFrame()).toContain("No task history available")
|
||||
})
|
||||
|
||||
it("should show navigation help", () => {
|
||||
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} />)
|
||||
expect(lastFrame()).toContain("↑↓")
|
||||
expect(lastFrame()).toContain("Enter")
|
||||
})
|
||||
})
|
||||
|
||||
describe("task details", () => {
|
||||
it("should display task cost when available", () => {
|
||||
const itemsWithCost = [{ id: "task-1", ts: Date.now(), task: "Task", totalCost: 0.0025 }]
|
||||
const { lastFrame } = render(<HistoryView controller={mockController} items={itemsWithCost} />)
|
||||
expect(lastFrame()).toContain("Cost:")
|
||||
expect(lastFrame()).toContain("0.0025")
|
||||
})
|
||||
|
||||
it("should display model ID when available", () => {
|
||||
const itemsWithModel = [{ id: "task-1", ts: Date.now(), task: "Task", modelId: "claude-sonnet-4-20250514" }]
|
||||
const { lastFrame } = render(<HistoryView controller={mockController} items={itemsWithModel} />)
|
||||
expect(lastFrame()).toContain("Model:")
|
||||
expect(lastFrame()).toContain("claude-sonnet-4-20250514")
|
||||
})
|
||||
|
||||
it("should truncate long task descriptions", () => {
|
||||
const longTask = "x".repeat(100)
|
||||
const itemsWithLongTask = [{ id: "task-1", ts: Date.now(), task: longTask }]
|
||||
const { lastFrame } = render(<HistoryView controller={mockController} items={itemsWithLongTask} />)
|
||||
expect(lastFrame()).toContain("...")
|
||||
})
|
||||
|
||||
it("should handle missing task text", () => {
|
||||
const itemsWithoutTask = [{ id: "task-1", ts: Date.now() }]
|
||||
const { lastFrame } = render(<HistoryView controller={mockController} items={itemsWithoutTask} />)
|
||||
expect(lastFrame()).toContain("Unknown task")
|
||||
})
|
||||
})
|
||||
|
||||
describe("selection indicator", () => {
|
||||
it("should show selection indicator on first item by default", () => {
|
||||
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} />)
|
||||
expect(lastFrame()).toContain(">")
|
||||
})
|
||||
})
|
||||
|
||||
describe("keyboard navigation", () => {
|
||||
it("should navigate down with arrow key", () => {
|
||||
const { lastFrame, stdin } = render(<HistoryView controller={mockController} items={mockItems} />)
|
||||
|
||||
// Press down arrow
|
||||
stdin.write("\x1B[B")
|
||||
|
||||
// Should still render properly
|
||||
expect(lastFrame()).toContain("Task History")
|
||||
})
|
||||
|
||||
it("should navigate up with arrow key", () => {
|
||||
const { lastFrame, stdin } = render(<HistoryView controller={mockController} items={mockItems} />)
|
||||
|
||||
// Press down then up
|
||||
stdin.write("\x1B[B")
|
||||
stdin.write("\x1B[A")
|
||||
|
||||
expect(lastFrame()).toContain("Task History")
|
||||
})
|
||||
|
||||
it("should not go below last item", () => {
|
||||
const { lastFrame, stdin } = render(<HistoryView controller={mockController} items={mockItems} />)
|
||||
|
||||
// Press down many times
|
||||
for (let i = 0; i < 10; i++) {
|
||||
stdin.write("\x1B[B")
|
||||
}
|
||||
|
||||
expect(lastFrame()).toContain("Task History")
|
||||
})
|
||||
|
||||
it("should not go above first item", () => {
|
||||
const { lastFrame, stdin } = render(<HistoryView controller={mockController} items={mockItems} />)
|
||||
|
||||
// Press up when already at first
|
||||
stdin.write("\x1B[A")
|
||||
|
||||
expect(lastFrame()).toContain("Task History")
|
||||
})
|
||||
})
|
||||
|
||||
describe("pagination", () => {
|
||||
it("should show pagination info when provided", () => {
|
||||
const pagination = {
|
||||
page: 2,
|
||||
totalPages: 5,
|
||||
totalCount: 50,
|
||||
limit: 10,
|
||||
}
|
||||
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} pagination={pagination} />)
|
||||
expect(lastFrame()).toContain("Page 2 of 5")
|
||||
})
|
||||
|
||||
it("should show correct total count from pagination", () => {
|
||||
const pagination = {
|
||||
page: 1,
|
||||
totalPages: 3,
|
||||
totalCount: 25,
|
||||
limit: 10,
|
||||
}
|
||||
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} pagination={pagination} />)
|
||||
expect(lastFrame()).toContain("25 total")
|
||||
})
|
||||
|
||||
it("should not show page info for single page", () => {
|
||||
const pagination = {
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
totalCount: 3,
|
||||
limit: 10,
|
||||
}
|
||||
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} pagination={pagination} />)
|
||||
expect(lastFrame()).not.toContain("Page 1 of 1")
|
||||
})
|
||||
})
|
||||
|
||||
describe("scrolling", () => {
|
||||
it("should show scroll indicators for long lists", () => {
|
||||
const manyItems = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: `task-${i}`,
|
||||
ts: Date.now() - i * 3600000,
|
||||
task: `Task ${i}`,
|
||||
}))
|
||||
|
||||
const { lastFrame, stdin } = render(<HistoryView controller={mockController} items={manyItems} visibleCount={5} />)
|
||||
|
||||
// Navigate down a bit
|
||||
for (let i = 0; i < 5; i++) {
|
||||
stdin.write("\x1B[B")
|
||||
}
|
||||
|
||||
// Should show "more below" indicator
|
||||
expect(lastFrame()).toContain("more")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,207 +0,0 @@
|
||||
/**
|
||||
* History view component
|
||||
* Displays task history with keyboard navigation
|
||||
*/
|
||||
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useCallback, useState } from "react"
|
||||
import { Controller } from "@/core/controller"
|
||||
import { showTaskWithId } from "@/core/controller/task/showTaskWithId"
|
||||
import { StringRequest } from "@/shared/proto/cline/common"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { useTerminalSize } from "../hooks/useTerminalSize"
|
||||
|
||||
interface TaskHistoryItem {
|
||||
id: string
|
||||
ts: number
|
||||
task?: string
|
||||
totalCost?: number
|
||||
modelId?: string
|
||||
}
|
||||
|
||||
interface HistoryPagination {
|
||||
page: number
|
||||
totalPages: number
|
||||
totalCount: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
interface HistoryViewProps {
|
||||
items: TaskHistoryItem[]
|
||||
visibleCount?: number
|
||||
controller: Controller
|
||||
onSelectTask?: (taskId: string) => void
|
||||
pagination?: HistoryPagination
|
||||
onPageChange?: (page: number) => void
|
||||
/** If provided, all items for internal pagination management */
|
||||
allItems?: TaskHistoryItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Format separator
|
||||
*/
|
||||
function formatSeparator(char: string = "─", width: number = 80): string {
|
||||
return char.repeat(Math.max(width, 10))
|
||||
}
|
||||
|
||||
export const HistoryView: React.FC<HistoryViewProps> = ({
|
||||
items,
|
||||
visibleCount,
|
||||
controller,
|
||||
onSelectTask,
|
||||
pagination,
|
||||
onPageChange,
|
||||
allItems,
|
||||
}) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [internalPage, setInternalPage] = useState(pagination?.page ?? 1)
|
||||
const { rows: terminalRows } = useTerminalSize()
|
||||
|
||||
// Calculate visible count based on terminal height to prevent overflow
|
||||
// Each item takes ~5 lines (date, id, task text, cost/model, margin)
|
||||
// Reserve lines for header (title, hint, pagination, separator) and footer (separator)
|
||||
const headerLines = (pagination?.totalPages ?? 1) > 1 ? 5 : 4
|
||||
const footerLines = 1
|
||||
const availableRows = terminalRows - headerLines - footerLines
|
||||
const itemHeight = 5
|
||||
const dynamicVisibleCount = Math.max(1, Math.floor(availableRows / itemHeight))
|
||||
const effectiveVisibleCount = visibleCount ?? dynamicVisibleCount
|
||||
|
||||
const onSelect = useCallback(
|
||||
(item: TaskHistoryItem) => {
|
||||
// Load the task via controller, then notify parent to switch views
|
||||
showTaskWithId(controller, StringRequest.create({ value: item.id }))
|
||||
.then(() => {
|
||||
onSelectTask?.(item.id)
|
||||
})
|
||||
.catch((error) => console.error("Error showing task:", error))
|
||||
},
|
||||
[controller, onSelectTask],
|
||||
)
|
||||
|
||||
// Use internal pagination if allItems is provided, otherwise use external
|
||||
const useInternalPagination = !!allItems
|
||||
const limit = pagination?.limit ?? 10
|
||||
const totalCount = allItems?.length ?? pagination?.totalCount ?? items.length
|
||||
const totalPages = useInternalPagination ? Math.ceil(totalCount / limit) : (pagination?.totalPages ?? 1)
|
||||
const currentPage = useInternalPagination ? internalPage : (pagination?.page ?? 1)
|
||||
const hasPrevPage = currentPage > 1
|
||||
const hasNextPage = currentPage < totalPages
|
||||
|
||||
// Get current page items
|
||||
const pageItems = useInternalPagination ? (allItems ?? []).slice((currentPage - 1) * limit, currentPage * limit) : items
|
||||
|
||||
const handlePageChange = useCallback(
|
||||
(newPage: number) => {
|
||||
if (useInternalPagination) {
|
||||
setInternalPage(newPage)
|
||||
setSelectedIndex(0)
|
||||
} else if (onPageChange) {
|
||||
onPageChange(newPage)
|
||||
setSelectedIndex(0)
|
||||
}
|
||||
},
|
||||
[useInternalPagination, onPageChange],
|
||||
)
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (key.upArrow || 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)
|
||||
let startIndex = Math.max(0, selectedIndex - halfVisible)
|
||||
const endIndex = Math.min(pageItems.length, startIndex + effectiveVisibleCount)
|
||||
// Adjust start if we're near the end
|
||||
if (endIndex - startIndex < effectiveVisibleCount) {
|
||||
startIndex = Math.max(0, endIndex - effectiveVisibleCount)
|
||||
}
|
||||
const visibleTasks = pageItems.slice(startIndex, endIndex)
|
||||
|
||||
const showUpIndicator = startIndex > 0
|
||||
const showDownIndicator = endIndex < pageItems.length
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color="white">
|
||||
{"📜 Task History (" + totalCount + " total)"}
|
||||
</Text>
|
||||
<Text color="gray">Use ↑↓/j/k to navigate, Enter to select</Text>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Box>
|
||||
<Text color="gray">
|
||||
Page {currentPage} of {totalPages}{" "}
|
||||
</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>
|
||||
|
||||
{pageItems.length === 0 ? (
|
||||
<Text>No task history available.</Text>
|
||||
) : (
|
||||
<Box flexDirection="column">
|
||||
{showUpIndicator && <Text color="gray">{" ↑ " + startIndex + " more above"}</Text>}
|
||||
{visibleTasks.map((task, index) => {
|
||||
const actualIndex = startIndex + index
|
||||
const isSelected = actualIndex === selectedIndex
|
||||
const date = new Date(task.ts).toLocaleString()
|
||||
const taskText = task.task?.substring(0, 60) || "Unknown task"
|
||||
const truncated = (task.task?.length || 0) > 60 ? "..." : ""
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" key={`${task.id}-${actualIndex}`} marginBottom={1}>
|
||||
<Box>
|
||||
<Text color={isSelected ? "green" : undefined}>{isSelected ? "> " : " "}</Text>
|
||||
<Text color="gray">{date}</Text>
|
||||
</Box>
|
||||
<Box marginLeft={4}>
|
||||
<Text color="cyan">{task.id}</Text>
|
||||
</Box>
|
||||
<Box marginLeft={4}>
|
||||
<Text bold={isSelected} color={isSelected ? "white" : undefined}>
|
||||
{taskText}
|
||||
{truncated}
|
||||
</Text>
|
||||
</Box>
|
||||
{typeof task.totalCost === "number" && (
|
||||
<Box marginLeft={4}>
|
||||
<Text color="gray">Cost: ${task.totalCost ? task.totalCost.toFixed(4) : "0"}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{task.modelId && (
|
||||
<Box marginLeft={4}>
|
||||
<Text color="gray">Model: {task.modelId}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
{showDownIndicator && <Text color="gray">{" ↓ " + (pageItems.length - endIndex) + " more below"}</Text>}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Text>{formatSeparator()}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
/**
|
||||
* 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,197 +0,0 @@
|
||||
/**
|
||||
* 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 { refreshOpenRouterModels } from "@/core/controller/models/refreshOpenRouterModels"
|
||||
import {
|
||||
type ApiProvider,
|
||||
anthropicDefaultModelId,
|
||||
anthropicModels,
|
||||
askSageDefaultModelId,
|
||||
askSageModels,
|
||||
basetenDefaultModelId,
|
||||
basetenModels,
|
||||
bedrockDefaultModelId,
|
||||
bedrockModels,
|
||||
cerebrasDefaultModelId,
|
||||
cerebrasModels,
|
||||
claudeCodeDefaultModelId,
|
||||
claudeCodeModels,
|
||||
deepSeekDefaultModelId,
|
||||
deepSeekModels,
|
||||
doubaoDefaultModelId,
|
||||
doubaoModels,
|
||||
fireworksDefaultModelId,
|
||||
fireworksModels,
|
||||
geminiDefaultModelId,
|
||||
geminiModels,
|
||||
groqDefaultModelId,
|
||||
groqModels,
|
||||
huaweiCloudMaasDefaultModelId,
|
||||
huaweiCloudMaasModels,
|
||||
huggingFaceDefaultModelId,
|
||||
huggingFaceModels,
|
||||
internationalQwenDefaultModelId,
|
||||
internationalQwenModels,
|
||||
internationalZAiDefaultModelId,
|
||||
internationalZAiModels,
|
||||
minimaxDefaultModelId,
|
||||
minimaxModels,
|
||||
mistralDefaultModelId,
|
||||
mistralModels,
|
||||
moonshotDefaultModelId,
|
||||
moonshotModels,
|
||||
nebiusDefaultModelId,
|
||||
nebiusModels,
|
||||
nousResearchDefaultModelId,
|
||||
nousResearchModels,
|
||||
openAiCodexDefaultModelId,
|
||||
openAiCodexModels,
|
||||
openAiNativeDefaultModelId,
|
||||
openAiNativeModels,
|
||||
qwenCodeDefaultModelId,
|
||||
qwenCodeModels,
|
||||
sambanovaDefaultModelId,
|
||||
sambanovaModels,
|
||||
sapAiCoreDefaultModelId,
|
||||
sapAiCoreModels,
|
||||
vertexDefaultModelId,
|
||||
vertexModels,
|
||||
xaiDefaultModelId,
|
||||
xaiModels,
|
||||
} from "@/shared/api"
|
||||
import { filterOpenRouterModelIds } from "@/shared/utils/model-filters"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { 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 },
|
||||
asksage: { models: askSageModels, defaultId: askSageDefaultModelId },
|
||||
baseten: { models: basetenModels, defaultId: basetenDefaultModelId },
|
||||
bedrock: { models: bedrockModels, defaultId: bedrockDefaultModelId },
|
||||
cerebras: { models: cerebrasModels, defaultId: cerebrasDefaultModelId },
|
||||
"claude-code": { models: claudeCodeModels, defaultId: claudeCodeDefaultModelId },
|
||||
deepseek: { models: deepSeekModels, defaultId: deepSeekDefaultModelId },
|
||||
doubao: { models: doubaoModels, defaultId: doubaoDefaultModelId },
|
||||
fireworks: { models: fireworksModels, defaultId: fireworksDefaultModelId },
|
||||
gemini: { models: geminiModels, defaultId: geminiDefaultModelId },
|
||||
groq: { models: groqModels, defaultId: groqDefaultModelId },
|
||||
"huawei-cloud-maas": { models: huaweiCloudMaasModels, defaultId: huaweiCloudMaasDefaultModelId },
|
||||
huggingface: { models: huggingFaceModels, defaultId: huggingFaceDefaultModelId },
|
||||
minimax: { models: minimaxModels, defaultId: minimaxDefaultModelId },
|
||||
mistral: { models: mistralModels, defaultId: mistralDefaultModelId },
|
||||
moonshot: { models: moonshotModels, defaultId: moonshotDefaultModelId },
|
||||
nebius: { models: nebiusModels, defaultId: nebiusDefaultModelId },
|
||||
nousResearch: { models: nousResearchModels, defaultId: nousResearchDefaultModelId },
|
||||
"openai-codex": { models: openAiCodexModels, defaultId: openAiCodexDefaultModelId },
|
||||
"openai-native": { models: openAiNativeModels, defaultId: openAiNativeDefaultModelId },
|
||||
qwen: { models: internationalQwenModels, defaultId: internationalQwenDefaultModelId },
|
||||
"qwen-code": { models: qwenCodeModels, defaultId: qwenCodeDefaultModelId },
|
||||
sambanova: { models: sambanovaModels, defaultId: sambanovaDefaultModelId },
|
||||
sapaicore: { models: sapAiCoreModels, defaultId: sapAiCoreDefaultModelId },
|
||||
vertex: { models: vertexModels, defaultId: vertexDefaultModelId },
|
||||
xai: { models: xaiModels, defaultId: xaiDefaultModelId },
|
||||
zai: { models: internationalZAiModels, defaultId: internationalZAiDefaultModelId },
|
||||
}
|
||||
|
||||
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
|
||||
controller: any
|
||||
onChange: (modelId: string) => void
|
||||
onSubmit: (modelId: string) => void
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export const ModelPicker: React.FC<ModelPickerProps> = ({ provider, controller, onChange, onSubmit, isActive = true }) => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [asyncModels, setAsyncModels] = useState<string[]>([])
|
||||
|
||||
// Fetch OpenRouter models when needed using shared core function
|
||||
useEffect(() => {
|
||||
if (usesOpenRouterModels(provider)) {
|
||||
setIsLoading(true)
|
||||
refreshOpenRouterModels(controller)
|
||||
.then((models) => {
|
||||
const modelIds = Object.keys(models).sort((a, b) => a.localeCompare(b))
|
||||
const filtered = filterOpenRouterModelIds(modelIds, provider as ApiProvider)
|
||||
setAsyncModels(filtered)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
}
|
||||
}, [provider, controller])
|
||||
|
||||
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)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
/**
|
||||
* Organization picker component for switching between personal account and organizations
|
||||
*/
|
||||
|
||||
import React, { useMemo } from "react"
|
||||
import type { ClineAccountOrganization } from "@/services/auth/AuthService"
|
||||
import { SelectList, SelectListItem } from "./SelectList"
|
||||
|
||||
interface OrganizationPickerProps {
|
||||
organizations: ClineAccountOrganization[]
|
||||
onSelect: (orgId: string | null) => void // null = personal account
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the primary role for display (prioritize owner > admin > member)
|
||||
*/
|
||||
function getPrimaryRole(roles: string[]): string {
|
||||
if (roles.includes("owner")) return "Owner"
|
||||
if (roles.includes("admin")) return "Admin"
|
||||
if (roles.includes("member")) return "Member"
|
||||
return roles[0] || ""
|
||||
}
|
||||
|
||||
export const OrganizationPicker: React.FC<OrganizationPickerProps> = ({ organizations, onSelect, isActive = true }) => {
|
||||
const items: SelectListItem[] = useMemo(() => {
|
||||
const result: SelectListItem[] = [
|
||||
{
|
||||
id: "personal",
|
||||
label: "Personal",
|
||||
},
|
||||
]
|
||||
|
||||
for (const org of organizations) {
|
||||
const role = getPrimaryRole(org.roles)
|
||||
result.push({
|
||||
id: org.organizationId,
|
||||
label: org.name,
|
||||
suffix: role ? `(${role})` : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}, [organizations])
|
||||
|
||||
return <SelectList isActive={isActive} items={items} onSelect={(item) => onSelect(item.id === "personal" ? null : item.id)} />
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
/**
|
||||
* 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"
|
||||
import { useTerminalSize } from "../hooks/useTerminalSize"
|
||||
|
||||
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 { columns } = useTerminalSize()
|
||||
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(Math.max(columns - 2, 0))}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Content */}
|
||||
<Box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
/**
|
||||
* Provider picker component for API provider selection
|
||||
*/
|
||||
|
||||
import React, { useMemo } from "react"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { API_PROVIDERS_LIST, ApiConfiguration } 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a provider is configured (has required credentials/settings)
|
||||
* Based on webview's getConfiguredProviders logic
|
||||
*/
|
||||
function isProviderConfigured(providerId: string, config: ApiConfiguration): boolean {
|
||||
switch (providerId) {
|
||||
case "cline":
|
||||
return true // Always available
|
||||
case "anthropic":
|
||||
return !!config.apiKey
|
||||
case "openrouter":
|
||||
return !!config.openRouterApiKey
|
||||
case "bedrock":
|
||||
return !!config.awsRegion
|
||||
case "vertex":
|
||||
return !!(config.vertexProjectId && config.vertexRegion)
|
||||
case "gemini":
|
||||
return !!config.geminiApiKey
|
||||
case "openai-native":
|
||||
return !!config.openAiNativeApiKey
|
||||
case "openai-codex":
|
||||
return !!config.openAiCodexRefreshToken
|
||||
case "deepseek":
|
||||
return !!config.deepSeekApiKey
|
||||
case "xai":
|
||||
return !!config.xaiApiKey
|
||||
case "qwen":
|
||||
case "qwen-code":
|
||||
return !!config.qwenApiKey
|
||||
case "doubao":
|
||||
return !!config.doubaoApiKey
|
||||
case "mistral":
|
||||
return !!config.mistralApiKey
|
||||
case "requesty":
|
||||
return !!config.requestyApiKey
|
||||
case "fireworks":
|
||||
return !!config.fireworksApiKey
|
||||
case "together":
|
||||
return !!config.togetherApiKey
|
||||
case "moonshot":
|
||||
return !!config.moonshotApiKey
|
||||
case "nebius":
|
||||
return !!config.nebiusApiKey
|
||||
case "asksage":
|
||||
return !!config.asksageApiKey
|
||||
case "sambanova":
|
||||
return !!config.sambanovaApiKey
|
||||
case "cerebras":
|
||||
return !!config.cerebrasApiKey
|
||||
case "sapaicore":
|
||||
return !!(
|
||||
config.sapAiCoreBaseUrl &&
|
||||
config.sapAiCoreClientId &&
|
||||
config.sapAiCoreClientSecret &&
|
||||
config.sapAiCoreTokenUrl
|
||||
)
|
||||
case "zai":
|
||||
return !!config.zaiApiKey
|
||||
case "groq":
|
||||
return !!config.groqApiKey
|
||||
case "huggingface":
|
||||
return !!config.huggingFaceApiKey
|
||||
case "baseten":
|
||||
return !!config.basetenApiKey
|
||||
case "dify":
|
||||
return !!(config.difyBaseUrl && config.difyApiKey)
|
||||
case "minimax":
|
||||
return !!config.minimaxApiKey
|
||||
case "hicap":
|
||||
return !!config.hicapApiKey
|
||||
case "huawei-cloud-maas":
|
||||
return !!config.huaweiCloudMaasApiKey
|
||||
case "vercel-ai-gateway":
|
||||
return !!config.vercelAiGatewayApiKey
|
||||
case "aihubmix":
|
||||
return !!config.aihubmixApiKey
|
||||
case "nousResearch":
|
||||
return !!config.nousResearchApiKey
|
||||
case "openai":
|
||||
return !!(
|
||||
(config.openAiBaseUrl && config.openAiApiKey) ||
|
||||
config.planModeOpenAiModelId ||
|
||||
config.actModeOpenAiModelId
|
||||
)
|
||||
case "ollama":
|
||||
return !!(config.ollamaBaseUrl || config.planModeOllamaModelId || config.actModeOllamaModelId)
|
||||
case "lmstudio":
|
||||
return !!(config.lmStudioBaseUrl || config.planModeLmStudioModelId || config.actModeLmStudioModelId)
|
||||
case "litellm":
|
||||
return !!(
|
||||
config.liteLlmBaseUrl ||
|
||||
config.liteLlmApiKey ||
|
||||
config.planModeLiteLlmModelId ||
|
||||
config.actModeLiteLlmModelId
|
||||
)
|
||||
case "claude-code":
|
||||
return !!config.claudeCodePath
|
||||
case "oca":
|
||||
return !!config.ocaBaseUrl
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
interface ProviderPickerProps {
|
||||
onSelect: (providerId: string) => void
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export const ProviderPicker: React.FC<ProviderPickerProps> = ({ onSelect, isActive = true }) => {
|
||||
// Get API configuration to check which providers are configured
|
||||
const apiConfig = StateManager.get().getApiConfiguration()
|
||||
|
||||
// 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: isProviderConfigured(providerId, apiConfig) ? "(Configured)" : undefined,
|
||||
}))
|
||||
}, [apiConfig])
|
||||
|
||||
return <SearchableList isActive={isActive} items={items} onSelect={(item) => onSelect(item.id)} />
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
/**
|
||||
* 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 { fuzzyFilter } from "../utils/fuzzy-search"
|
||||
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)
|
||||
|
||||
// Filter items by search using fuzzy matching
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!search) return items
|
||||
// Use custom filter if provided, otherwise use fuzzy search
|
||||
if (filterFn) {
|
||||
return items.filter((item) => filterFn(item, search))
|
||||
}
|
||||
return fuzzyFilter(items, search, (item) => `${item.label} ${item.id}`)
|
||||
}, [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 || key.tab) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* Simple select list component - arrow keys to navigate, Enter to select
|
||||
* No search functionality, just a straightforward list picker
|
||||
*/
|
||||
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import { useState } from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
|
||||
export interface SelectListItem {
|
||||
id: string
|
||||
label: string
|
||||
suffix?: string
|
||||
}
|
||||
|
||||
interface SelectListProps<T extends SelectListItem> {
|
||||
items: T[]
|
||||
onSelect: (item: T) => void
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export function SelectList<T extends SelectListItem>({ items, onSelect, isActive = true }: SelectListProps<T>) {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((i) => (i > 0 ? i - 1 : items.length - 1))
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex((i) => (i < items.length - 1 ? i + 1 : 0))
|
||||
} else if (key.return) {
|
||||
const item = items[selectedIndex]
|
||||
if (item) {
|
||||
onSelect(item)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: isActive && isRawModeSupported },
|
||||
)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{items.map((item, idx) => {
|
||||
const isSelected = idx === selectedIndex
|
||||
return (
|
||||
<Box key={item.id}>
|
||||
<Text color={isSelected ? COLORS.primaryBlue : undefined}>
|
||||
{isSelected ? "❯ " : " "}
|
||||
{item.label}
|
||||
{item.suffix && <Text color="gray"> {item.suffix}</Text>}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
import { Box, Text } from "ink"
|
||||
import React from "react"
|
||||
import { Session } from "@/shared/services/Session"
|
||||
|
||||
/**
|
||||
* Format milliseconds to a human-readable duration string
|
||||
*/
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) {
|
||||
return `${ms}ms`
|
||||
}
|
||||
const seconds = ms / 1000
|
||||
if (seconds < 60) {
|
||||
return `${seconds.toFixed(1)}s`
|
||||
}
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = seconds % 60
|
||||
return `${minutes}m ${remainingSeconds.toFixed(0)}s`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a percentage value
|
||||
*/
|
||||
function formatPercent(value: number, total: number): string {
|
||||
if (total === 0) return "0.0%"
|
||||
return `${((value / total) * 100).toFixed(1)}%`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to a human-readable string (KB, MB, GB)
|
||||
*/
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) {
|
||||
return `${bytes}B`
|
||||
}
|
||||
const kb = bytes / 1024
|
||||
if (kb < 1024) {
|
||||
return `${kb.toFixed(1)}KB`
|
||||
}
|
||||
const mb = kb / 1024
|
||||
if (mb < 1024) {
|
||||
return `${mb.toFixed(1)}MB`
|
||||
}
|
||||
const gb = mb / 1024
|
||||
return `${gb.toFixed(2)}GB`
|
||||
}
|
||||
|
||||
interface SessionSummaryProps {
|
||||
/** Optional width constraint */
|
||||
width?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays session statistics when the CLI exits.
|
||||
* Shows tool call counts, success rate, and timing breakdown.
|
||||
*/
|
||||
export const SessionSummary: React.FC<SessionSummaryProps> = ({ width }) => {
|
||||
const session = Session.get()
|
||||
const stats = session.getStats()
|
||||
const wallTimeMs = session.getWallTimeMs()
|
||||
const agentActiveMs = session.getAgentActiveTimeMs()
|
||||
|
||||
// Don't show if session just started (less than 1 second)
|
||||
if (wallTimeMs < 1000) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Box borderColor="gray" borderStyle="single" flexDirection="column" paddingX={1} width={width}>
|
||||
{/* Header */}
|
||||
<Box marginBottom={1}>
|
||||
<Text bold>Interaction Summary</Text>
|
||||
</Box>
|
||||
|
||||
{/* Session ID */}
|
||||
<Box>
|
||||
<Box width={20}>
|
||||
<Text color="gray">Session ID:</Text>
|
||||
</Box>
|
||||
<Text>{stats.sessionId}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Session Time */}
|
||||
<Box>
|
||||
<Box width={20}>
|
||||
<Text color="gray">Session Time:</Text>
|
||||
</Box>
|
||||
<Text>
|
||||
{session.formatTime(session.getStartTime())} → {session.formatTime(session.getEndTime())}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Tool Calls */}
|
||||
<Box>
|
||||
<Box width={20}>
|
||||
<Text color="gray">Tool Calls:</Text>
|
||||
</Box>
|
||||
<Text>{stats.totalToolCalls}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Performance Header */}
|
||||
<Box marginBottom={0}>
|
||||
<Text bold>Performance</Text>
|
||||
</Box>
|
||||
|
||||
{/* Wall Time */}
|
||||
<Box>
|
||||
<Box width={20}>
|
||||
<Text color="gray">Wall Time:</Text>
|
||||
</Box>
|
||||
<Text>{formatDuration(wallTimeMs)}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Agent Active */}
|
||||
<Box>
|
||||
<Box width={20}>
|
||||
<Text color="gray">Agent Active:</Text>
|
||||
</Box>
|
||||
<Text>{formatDuration(agentActiveMs)}</Text>
|
||||
</Box>
|
||||
|
||||
{/* API Time */}
|
||||
<Box>
|
||||
<Box width={20}>
|
||||
<Text color="gray"> » API Time:</Text>
|
||||
</Box>
|
||||
<Text>
|
||||
{formatDuration(stats.apiTimeMs)} <Text color="gray">({formatPercent(stats.apiTimeMs, agentActiveMs)})</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Tool Time */}
|
||||
<Box marginBottom={1}>
|
||||
<Box width={20}>
|
||||
<Text color="gray"> » Tool Time:</Text>
|
||||
</Box>
|
||||
<Text>
|
||||
{formatDuration(stats.toolTimeMs)}{" "}
|
||||
<Text color="gray">({formatPercent(stats.toolTimeMs, agentActiveMs)})</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Resources Header */}
|
||||
<Box marginBottom={0}>
|
||||
<Text bold>Resources</Text>
|
||||
</Box>
|
||||
|
||||
{/* Memory Usage */}
|
||||
<Box>
|
||||
<Box width={20}>
|
||||
<Text color="gray">Memory (RSS):</Text>
|
||||
</Box>
|
||||
<Text>{formatBytes(stats.resources.rss)}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Peak Memory */}
|
||||
<Box>
|
||||
<Box width={20}>
|
||||
<Text color="gray">Peak Memory:</Text>
|
||||
</Box>
|
||||
<Text>{formatBytes(stats.peakMemoryBytes)}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Heap Usage */}
|
||||
<Box>
|
||||
<Box width={20}>
|
||||
<Text color="gray">Heap Used:</Text>
|
||||
</Box>
|
||||
<Text>
|
||||
{formatBytes(stats.resources.heapUsed)} <Text color="gray">/ {formatBytes(stats.resources.heapTotal)}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* CPU Time */}
|
||||
<Box>
|
||||
<Box width={20}>
|
||||
<Text color="gray">CPU Time:</Text>
|
||||
</Box>
|
||||
<Text>
|
||||
{formatDuration(stats.resources.userCpuMs + stats.resources.systemCpuMs)}{" "}
|
||||
<Text color="gray">
|
||||
(user: {formatDuration(stats.resources.userCpuMs)}, sys: {formatDuration(stats.resources.systemCpuMs)})
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* Loading spinner component using ink-spinner
|
||||
*/
|
||||
|
||||
import { Box, Text } from "ink"
|
||||
import Spinner from "ink-spinner"
|
||||
import React from "react"
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
mode?: "act" | "plan"
|
||||
}
|
||||
|
||||
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({ mode = "act" }) => {
|
||||
const message = mode === "plan" ? "Planning" : "Thinking"
|
||||
return (
|
||||
<Box>
|
||||
<Text color="cyan">
|
||||
<Spinner type="dots" />
|
||||
</Text>
|
||||
<Text color="cyan"> {message}...</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
/**
|
||||
* 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,126 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
@@ -1,328 +0,0 @@
|
||||
/**
|
||||
* Welcome view component
|
||||
* Shows an interactive prompt when user starts cline without a command
|
||||
* Supports file mentions with @
|
||||
*/
|
||||
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { getProviderDefaultModelId, Mode } from "@/shared/storage"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import {
|
||||
checkAndWarnRipgrepMissing,
|
||||
extractMentionQuery,
|
||||
type FileSearchResult,
|
||||
getRipgrepInstallInstructions,
|
||||
insertMention,
|
||||
searchWorkspaceFiles,
|
||||
} from "../utils/file-search"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { parseImagesFromInput } from "../utils/parser"
|
||||
import { AccountInfoView } from "./AccountInfoView"
|
||||
import { FileMentionMenu } from "./FileMentionMenu"
|
||||
|
||||
interface WelcomeViewProps {
|
||||
onSubmit: (prompt: string, imagePaths: string[]) => void
|
||||
onExit?: () => void
|
||||
controller?: any
|
||||
}
|
||||
|
||||
// ASCII art Cline logo
|
||||
const CLINE_LOGO = [
|
||||
" ::::::: ",
|
||||
" ::::::::: ",
|
||||
" ::::::::::::::::: ",
|
||||
" ::::::::::::::::::::::: ",
|
||||
" ::::::::::::::::::::::::: ",
|
||||
" ::::::::::::::::::::::::::: ",
|
||||
" ::::::: ::::::: ::::::: ",
|
||||
" ::::::: ::::: ::::::: ",
|
||||
":::::::: ::::: ::::::::",
|
||||
":::::::: ::::: ::::::::",
|
||||
" ::::::: ::::: ::::::: ",
|
||||
" ::::::: ::::::: ::::::: ",
|
||||
" ::::::::::::::::::::::::::: ",
|
||||
" ::::::::::::::::::::::::: ",
|
||||
" ::::::::::::::::::::::: ",
|
||||
" :::::::::::::::: ",
|
||||
]
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 150
|
||||
const RIPGREP_WARNING_DURATION_MS = 5000
|
||||
const MAX_SEARCH_RESULTS = 15
|
||||
|
||||
export const WelcomeView: React.FC<WelcomeViewProps> = ({ onSubmit, onExit, controller }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const [textInput, setTextInput] = useState("")
|
||||
const [fileResults, setFileResults] = useState<FileSearchResult[]>([])
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [showRipgrepWarning, setShowRipgrepWarning] = useState(false)
|
||||
const [escPressedOnce, setEscPressedOnce] = useState(false)
|
||||
const [mode, setMode] = useState<Mode>(() => {
|
||||
const stateManager = StateManager.get()
|
||||
return stateManager.getGlobalSettingsKey("mode") || "act"
|
||||
})
|
||||
|
||||
const provider = useMemo(() => {
|
||||
const stateManager = StateManager.get()
|
||||
const mode = stateManager.getGlobalSettingsKey("mode") as string
|
||||
const providerKey = mode === "act" ? "actModeApiProvider" : "planModeApiProvider"
|
||||
const currentProvider = stateManager.getGlobalSettingsKey(providerKey) as string
|
||||
return currentProvider || "cline"
|
||||
}, [controller])
|
||||
|
||||
// Get model ID based on current mode
|
||||
const modelId = useMemo(() => {
|
||||
const stateManager = StateManager.get()
|
||||
const modelKey = mode === "act" ? "actModeApiModelId" : "planModeApiModelId"
|
||||
return (stateManager.getGlobalSettingsKey(modelKey) as string) || getProviderDefaultModelId(provider)
|
||||
}, [mode, provider])
|
||||
|
||||
const toggleMode = useCallback(() => {
|
||||
const newMode: Mode = mode === "act" ? "plan" : "act"
|
||||
setMode(newMode)
|
||||
const stateManager = StateManager.get()
|
||||
stateManager.setGlobalState("mode", newMode)
|
||||
}, [mode])
|
||||
|
||||
const refs = useRef({
|
||||
searchTimeout: null as NodeJS.Timeout | null,
|
||||
lastQuery: "",
|
||||
hasCheckedRipgrep: false,
|
||||
})
|
||||
|
||||
const { prompt, imagePaths } = parseImagesFromInput(textInput)
|
||||
|
||||
const mentionInfo = useMemo(() => extractMentionQuery(textInput), [textInput])
|
||||
|
||||
const workspacePath = useMemo(() => {
|
||||
try {
|
||||
const root = controller?.getWorkspaceManagerSync?.()?.getPrimaryRoot?.()
|
||||
if (root?.path) {
|
||||
return root.path
|
||||
}
|
||||
} catch {
|
||||
// Fallback to cwd
|
||||
}
|
||||
return process.cwd()
|
||||
}, [controller])
|
||||
|
||||
// Search for files when in mention mode
|
||||
useEffect(() => {
|
||||
const { current: r } = refs
|
||||
|
||||
if (!mentionInfo.inMentionMode) {
|
||||
setFileResults([])
|
||||
setSelectedIndex(0)
|
||||
if (r.searchTimeout) {
|
||||
clearTimeout(r.searchTimeout)
|
||||
r.searchTimeout = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check for ripgrep on first mention trigger
|
||||
if (!r.hasCheckedRipgrep) {
|
||||
r.hasCheckedRipgrep = true
|
||||
if (checkAndWarnRipgrepMissing()) {
|
||||
setShowRipgrepWarning(true)
|
||||
setTimeout(() => setShowRipgrepWarning(false), RIPGREP_WARNING_DURATION_MS)
|
||||
}
|
||||
}
|
||||
|
||||
const { query } = mentionInfo
|
||||
if (query === r.lastQuery) {
|
||||
return
|
||||
}
|
||||
r.lastQuery = query
|
||||
|
||||
if (r.searchTimeout) {
|
||||
clearTimeout(r.searchTimeout)
|
||||
}
|
||||
setIsSearching(true)
|
||||
|
||||
r.searchTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const results = await searchWorkspaceFiles(query, workspacePath, MAX_SEARCH_RESULTS)
|
||||
setFileResults(results)
|
||||
setSelectedIndex(0)
|
||||
} catch {
|
||||
setFileResults([])
|
||||
} finally {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}, SEARCH_DEBOUNCE_MS)
|
||||
|
||||
return () => {
|
||||
if (r.searchTimeout) {
|
||||
clearTimeout(r.searchTimeout)
|
||||
}
|
||||
}
|
||||
}, [mentionInfo.inMentionMode, mentionInfo.query, workspacePath])
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
// Filter out mouse escape sequences
|
||||
if (isMouseEscapeSequence(input)) {
|
||||
return
|
||||
}
|
||||
|
||||
const inMenu = mentionInfo.inMentionMode && fileResults.length > 0
|
||||
|
||||
// Menu navigation
|
||||
if (inMenu) {
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((i) => (i > 0 ? i - 1 : fileResults.length - 1))
|
||||
return
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setSelectedIndex((i) => (i < fileResults.length - 1 ? i + 1 : 0))
|
||||
return
|
||||
}
|
||||
if (key.tab || key.return) {
|
||||
const file = fileResults[selectedIndex]
|
||||
if (file) {
|
||||
setTextInput(insertMention(textInput, mentionInfo.atIndex, file.path))
|
||||
setFileResults([])
|
||||
setSelectedIndex(0)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (key.escape) {
|
||||
setFileResults([])
|
||||
setSelectedIndex(0)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Normal input handling
|
||||
if (key.tab && !mentionInfo.inMentionMode) {
|
||||
toggleMode()
|
||||
return
|
||||
}
|
||||
if (key.return && !mentionInfo.inMentionMode) {
|
||||
if (prompt.trim() || imagePaths.length > 0) {
|
||||
onSubmit(prompt.trim(), imagePaths)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (key.escape && !mentionInfo.inMentionMode) {
|
||||
if (escPressedOnce) {
|
||||
onExit?.()
|
||||
} else {
|
||||
setEscPressedOnce(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (key.backspace || key.delete) {
|
||||
setTextInput((prev) => prev.slice(0, -1))
|
||||
setEscPressedOnce(false)
|
||||
return
|
||||
}
|
||||
if (input && !key.ctrl && !key.meta && !key.upArrow && !key.downArrow && !key.tab) {
|
||||
setTextInput((prev) => prev + input)
|
||||
setEscPressedOnce(false)
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported },
|
||||
)
|
||||
|
||||
const borderColor = mode === "act" ? "blue" : "yellow"
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width="100%">
|
||||
{/* Account/Provider info at top */}
|
||||
{controller && (
|
||||
<Box marginBottom={1}>
|
||||
<AccountInfoView controller={controller} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Cline logo - centered */}
|
||||
<Box alignItems="center" flexDirection="column">
|
||||
{CLINE_LOGO.map((line, idx) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static array that never changes
|
||||
<Text color="white" key={idx}>
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Main prompt - centered, bold */}
|
||||
<Box justifyContent="center" marginTop={1}>
|
||||
<Text bold color="white">
|
||||
What can I do for you?
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Ripgrep warning if needed */}
|
||||
{showRipgrepWarning && (
|
||||
<Box marginTop={1}>
|
||||
<Text color="yellow">⚠ ripgrep not found - file search will be slower. </Text>
|
||||
<Text color="gray">Install: {getRipgrepInstallInstructions()}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Input field with border */}
|
||||
<Box
|
||||
borderColor={borderColor}
|
||||
borderStyle="round"
|
||||
flexDirection="row"
|
||||
marginTop={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
width="100%">
|
||||
<Text>{textInput}</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
|
||||
{/* Model ID and Mode toggle row */}
|
||||
<Box justifyContent="space-between" width="100%">
|
||||
{/* Model ID on left */}
|
||||
<Text color="gray">{modelId}</Text>
|
||||
|
||||
{/* Mode toggle on right */}
|
||||
<Box gap={1}>
|
||||
<Box>
|
||||
<Text bold={mode === "plan"} color={mode === "plan" ? "yellow" : "gray"}>
|
||||
{mode === "plan" ? "●" : "○"} Plan
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text bold={mode === "act"} color={mode === "act" ? "blue" : "gray"}>
|
||||
{mode === "act" ? "●" : "○"} Act
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color="gray">(Tab)</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* File mention menu - below input */}
|
||||
{mentionInfo.inMentionMode && (
|
||||
<FileMentionMenu
|
||||
isLoading={isSearching}
|
||||
query={mentionInfo.query}
|
||||
results={fileResults}
|
||||
selectedIndex={selectedIndex}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Attached images */}
|
||||
{imagePaths.length > 0 && (
|
||||
<Text color="magenta">
|
||||
📎 {imagePaths.length} image{imagePaths.length > 1 ? "s" : ""} attached
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Help text */}
|
||||
<Box>
|
||||
<Text color="gray">Enter to submit · @ to mention files · </Text>
|
||||
<Text bold={escPressedOnce} color={escPressedOnce ? "white" : "gray"}>
|
||||
{escPressedOnce ? "Press Esc again to exit" : "Esc to exit"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user