mirror of
https://github.com/cline/cline.git
synced 2026-09-01 23:19:18 +08:00
Compare commits
216 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cfe25729c0 | |||
| 4818f53ae3 | |||
| f4680b9eac | |||
| c9f928e485 | |||
| 49b8569281 | |||
| c19d76cd80 | |||
| e0e9f8a862 | |||
| 79a5052869 | |||
| a5c048f738 | |||
| f0514758a8 | |||
| 499f7bafbb | |||
| 415653d0c8 | |||
| 5fa12f389c | |||
| b1295471a0 | |||
| 303d99ff81 | |||
| 543a228fd9 | |||
| 5cdacca80c | |||
| 0b5024dc42 | |||
| 23a2c24366 | |||
| 58cfed5613 | |||
| 20bdc3cead | |||
| 525d1b6e21 | |||
| d5169f32f7 | |||
| e23b953a02 | |||
| cd8690b939 | |||
| fd961c2cbc | |||
| 1691177bee | |||
| 488adb42a2 | |||
| b59aa52c9e | |||
| 06f40a75c6 | |||
| 49eb4f95da | |||
| ec0fde8602 | |||
| bee130950e | |||
| 1fb423bc8e | |||
| 7905376e20 | |||
| 01740fbda9 | |||
| d4622a75df | |||
| dd145671af | |||
| 1bb2962b26 | |||
| 7afd207b4d | |||
| 28c7ffd216 | |||
| dbc0e8aa1d | |||
| 714236a233 | |||
| 6c152bc307 | |||
| a9f7e014f9 | |||
| f9e758a29e | |||
| 74591cb254 | |||
| 9c273551e2 | |||
| 9d40ee4529 | |||
| 2140db1aae | |||
| 6714587e6e | |||
| 30dcfe05c3 | |||
| ce1d3bc5b9 | |||
| f5fb30e98a | |||
| 67ec7bb8b2 | |||
| 2c42b46e71 | |||
| af19e0d560 | |||
| 27c7720f71 | |||
| 9b27ac8cb9 | |||
| be9fbb01bc | |||
| 82331aff2d | |||
| 7d9c69687f | |||
| 0916c78d52 | |||
| b31c3c5c1d | |||
| 484807c472 | |||
| 23490f7e6f | |||
| 8ea0543b89 | |||
| c7c9813056 | |||
| ff2bdde179 | |||
| a27e0bddbf | |||
| 32199574a9 | |||
| fccb633d08 | |||
| f5679b5c91 | |||
| ee88ddd678 | |||
| ee50091c9a | |||
| f6cc991b48 | |||
| 02b6c1fc91 | |||
| 5df13d8f32 | |||
| 918e32edee | |||
| 06d8e7ae9f | |||
| 2b35590de8 | |||
| 2788d85556 | |||
| 4ffb28377a | |||
| fa570fa500 | |||
| 23f64d24c6 | |||
| f4c79e2d21 | |||
| 10449e458e | |||
| 2ac5a0ea1f | |||
| cb34c643d7 | |||
| d9f4946665 | |||
| cb2355d881 | |||
| 04ce2878aa | |||
| c52d47decd | |||
| 26ca343dae | |||
| 81781a6143 | |||
| e2c92051f4 | |||
| f09a5d79ef | |||
| 8822846800 | |||
| c2a7836d0c | |||
| e84ff527c4 | |||
| 349d7cf24b | |||
| d5af7a2bad | |||
| 77a4bfa3f8 | |||
| d0d9ae5e9a | |||
| 0d188ade2f | |||
| d043ba9ec9 | |||
| dc35c62d5d | |||
| 2dc54861ba | |||
| 5cff5f3f24 | |||
| 39bb06bb93 | |||
| 599e236546 | |||
| 06f015c9d0 | |||
| 49bdafe10e | |||
| 955b06d4fa | |||
| 34ac2176e6 | |||
| c5e87a800b | |||
| 92c9cc5ddc | |||
| dd0b7697f2 | |||
| 9d68aa8b8e | |||
| c59fe3e62c | |||
| 7602f7b7c6 | |||
| a1debe1328 | |||
| 990a00d70f | |||
| a125a61cbd | |||
| e3e6e1ef77 | |||
| 09e897c8f2 | |||
| 6caeeb5da6 | |||
| 40550af42c | |||
| e85e4d6f0c | |||
| d26f76c6e4 | |||
| 5c6f725079 | |||
| 4a456e2145 | |||
| c029e0ffe4 | |||
| cff2217283 | |||
| 904617b573 | |||
| 20fae7e14b | |||
| 699ed190b7 | |||
| 28ae6d6ca1 | |||
| beca76fb32 | |||
| 8de9935f9a | |||
| 6bc700e3db | |||
| 2f17a1a341 | |||
| 639121b644 | |||
| 1187852bed | |||
| 7acc036d91 | |||
| 1fea5aaa63 | |||
| 8de7294de8 | |||
| 2e4ded7f14 | |||
| ac94eb4cf4 | |||
| 73635cf68f | |||
| 9eb0da6c2b | |||
| 59c10df046 | |||
| db4d9a267a | |||
| 734dc6e57f | |||
| 27befb16f5 | |||
| 1c32b08644 | |||
| 5d81bf0fe2 | |||
| f44771acd5 | |||
| 786728e0d4 | |||
| 9714cf8861 | |||
| c5db44964e | |||
| ed38739d65 | |||
| 50bc90835c | |||
| 4d41766b9f | |||
| e958e29439 | |||
| b337bb706e | |||
| 314e06731a | |||
| 3a6f8b98dd | |||
| 65f9322bd9 | |||
| 33983e80f8 | |||
| 8648154a74 | |||
| 5d91ac037e | |||
| 6179dfe0a4 | |||
| cd9e28e6d0 | |||
| 8d56a697eb | |||
| fd506b14b5 | |||
| 55d268773d | |||
| bf978c2c42 | |||
| 7e06dbbd33 | |||
| 8d4822d781 | |||
| dbcaae65d1 | |||
| 66db36ff5b | |||
| a53d17a857 | |||
| de56305315 | |||
| 5cd22e3f86 | |||
| bd9150837d | |||
| fbb494b88a | |||
| 16b4c48457 | |||
| 058c86872b | |||
| d3e8f562bc | |||
| 1594d44190 | |||
| c8419ffa99 | |||
| cf6c80504f | |||
| 230f615927 | |||
| 08e00dc3bf | |||
| c01bf73d3f | |||
| df28359114 | |||
| d3631f0485 | |||
| eb18c556f4 | |||
| b18284ea78 | |||
| e4e99ec711 | |||
| b7b4d0bfe8 | |||
| c12b00e7a9 | |||
| 73bb8c2f64 | |||
| 0e1d513462 | |||
| d6a1b338bc | |||
| 0091fb6ffb | |||
| de3e3fe34a | |||
| e136d75621 | |||
| 91127ffdb9 | |||
| 11f76f0218 | |||
| 74f1b9c75d | |||
| 942c78119e | |||
| 3130c3c96e | |||
| f0225bc20d | |||
| 1bf158245c |
@@ -0,0 +1,33 @@
|
||||
# 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,13 +33,7 @@ jobs:
|
||||
node-version: '20.x'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24'
|
||||
cache-dependency-path: cli/go.sum
|
||||
|
||||
# Cache root dependencies - only reuse if package-lock.json exactly matches
|
||||
# Cache root dependencies
|
||||
- name: Cache root dependencies
|
||||
uses: actions/cache@v4
|
||||
id: root-cache
|
||||
@@ -47,46 +41,37 @@ jobs:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache webview-ui dependencies
|
||||
# Cache cli-ts dependencies
|
||||
- name: Cache cli-ts dependencies
|
||||
uses: actions/cache@v4
|
||||
id: webview-cache
|
||||
id: cli-ts-cache
|
||||
with:
|
||||
path: webview-ui/node_modules
|
||||
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
|
||||
path: cli-ts/node_modules
|
||||
key: ${{ runner.os }}-npm-cli-ts-${{ hashFiles('cli-ts/package-lock.json') }}
|
||||
|
||||
- name: Install root dependencies
|
||||
if: steps.root-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci --include=optional
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
if: steps.webview-cache.outputs.cache-hit != 'true'
|
||||
run: cd webview-ui && npm ci --include=optional
|
||||
- name: Install cli-ts dependencies
|
||||
if: steps.cli-ts-cache.outputs.cache-hit != 'true'
|
||||
run: cd cli-ts && npm ci
|
||||
|
||||
- name: Generate Protos
|
||||
run: npm run protos
|
||||
|
||||
- name: Read release version
|
||||
id: version
|
||||
run: |
|
||||
# Read version from cli/package.json (stable version)
|
||||
VERSION=$(node -p "require('./cli/package.json').version")
|
||||
# Read version from cli-ts/package.json
|
||||
VERSION=$(node -p "require('./cli-ts/package.json').version")
|
||||
echo "Release version: $VERSION"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download ripgrep binaries
|
||||
run: npm run download-ripgrep
|
||||
|
||||
- name: Clean previous builds
|
||||
run: rm -rf dist-standalone
|
||||
|
||||
- name: Generate Protos (First Pass)
|
||||
run: npm run protos && npm run protos-go
|
||||
|
||||
- name: Compile CLI
|
||||
run: npm run compile-cli
|
||||
|
||||
- name: Compile CLI for all platforms
|
||||
run: npm run compile-cli-all-platforms
|
||||
|
||||
- name: Build standalone NPM package
|
||||
- name: Build and package CLI
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
@@ -98,21 +83,18 @@ jobs:
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
POSTHOG_TELEMETRY_ENABLED: "true"
|
||||
run: npm run compile-standalone-npm
|
||||
|
||||
- name: Generate Protos (Second Pass - Bug Workaround)
|
||||
run: npm run protos && npm run protos-go
|
||||
run: node scripts/package-npm.mjs
|
||||
|
||||
- name: Verify build output
|
||||
run: |
|
||||
echo "Checking dist-standalone directory..."
|
||||
ls -la dist-standalone/
|
||||
|
||||
echo "Verifying CLI binaries..."
|
||||
ls -lh cli/bin/cline-* || echo "Warning: CLI binaries not found"
|
||||
|
||||
|
||||
echo "Checking dist-standalone/dist..."
|
||||
ls -la dist-standalone/dist/
|
||||
|
||||
echo "Checking package.json in dist-standalone..."
|
||||
cat dist-standalone/package.json | grep version
|
||||
cat dist-standalone/package.json
|
||||
|
||||
- name: Publish to NPM with latest tag
|
||||
env:
|
||||
|
||||
@@ -42,14 +42,7 @@ jobs:
|
||||
node-version: '20.x'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Setup Go
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24'
|
||||
cache-dependency-path: cli/go.sum
|
||||
|
||||
# Cache root dependencies - only reuse if package-lock.json exactly matches
|
||||
# Cache root dependencies
|
||||
- name: Cache root dependencies
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
uses: actions/cache@v4
|
||||
@@ -58,75 +51,63 @@ jobs:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache webview-ui dependencies
|
||||
# Cache cli-ts dependencies
|
||||
- name: Cache cli-ts dependencies
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
uses: actions/cache@v4
|
||||
id: webview-cache
|
||||
id: cli-ts-cache
|
||||
with:
|
||||
path: webview-ui/node_modules
|
||||
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
|
||||
path: cli-ts/node_modules
|
||||
key: ${{ runner.os }}-npm-cli-ts-${{ hashFiles('cli-ts/package-lock.json') }}
|
||||
|
||||
- name: Install root dependencies
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.root-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci --include=optional
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.webview-cache.outputs.cache-hit != 'true'
|
||||
run: cd webview-ui && npm ci --include=optional
|
||||
- name: Install cli-ts dependencies
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.cli-ts-cache.outputs.cache-hit != 'true'
|
||||
run: cd cli-ts && npm ci
|
||||
|
||||
- name: Generate Protos
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: npm run protos
|
||||
|
||||
- name: Generate nightly version with timestamp
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
id: version
|
||||
run: |
|
||||
# Read base version from cli/package.json (e.g., "1.0.9")
|
||||
BASE_VERSION=$(node -p "require('./cli/package.json').version")
|
||||
|
||||
# Read base version from cli-ts/package.json (e.g., "2.0.0")
|
||||
BASE_VERSION=$(node -p "require('./cli-ts/package.json').version")
|
||||
|
||||
# Generate timestamp (Unix epoch seconds)
|
||||
TIMESTAMP=$(date +%s)
|
||||
|
||||
# Create unique nightly version: 1.0.9-nightly.1736365200
|
||||
|
||||
# Create unique nightly version: 2.0.0-nightly.1736365200
|
||||
VERSION="${BASE_VERSION}-nightly.${TIMESTAMP}"
|
||||
|
||||
|
||||
echo "Base version: $BASE_VERSION"
|
||||
echo "Generated nightly version: $VERSION"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Update cli/package.json with nightly version
|
||||
- name: Update cli-ts/package.json with nightly version
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: |
|
||||
# Update version with timestamp-based nightly version
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const pkg = JSON.parse(fs.readFileSync('cli/package.json', 'utf8'));
|
||||
const pkg = JSON.parse(fs.readFileSync('cli-ts/package.json', 'utf8'));
|
||||
pkg.version = '${{ steps.version.outputs.version }}';
|
||||
fs.writeFileSync('cli/package.json', JSON.stringify(pkg, null, '\t'));
|
||||
fs.writeFileSync('cli-ts/package.json', JSON.stringify(pkg, null, '\t'));
|
||||
"
|
||||
|
||||
echo "Using version ${{ steps.version.outputs.version }} for build"
|
||||
cat cli/package.json | grep '"version"'
|
||||
|
||||
- name: Download ripgrep binaries
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: npm run download-ripgrep
|
||||
echo "Using version ${{ steps.version.outputs.version }} for build"
|
||||
cat cli-ts/package.json | grep '"version"'
|
||||
|
||||
- name: Clean previous builds
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: rm -rf dist-standalone
|
||||
|
||||
- name: Generate Protos (First Pass)
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: npm run protos && npm run protos-go
|
||||
|
||||
- name: Compile CLI
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: npm run compile-cli
|
||||
|
||||
- name: Compile CLI for all platforms
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: npm run compile-cli-all-platforms
|
||||
|
||||
- name: Build standalone NPM package
|
||||
- name: Build and package CLI
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
@@ -139,23 +120,19 @@ jobs:
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
POSTHOG_TELEMETRY_ENABLED: "true"
|
||||
run: npm run compile-standalone-npm
|
||||
|
||||
- name: Generate Protos (Second Pass - Bug Workaround)
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: npm run protos && npm run protos-go
|
||||
run: node scripts/package-npm.mjs
|
||||
|
||||
- name: Verify build output
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: |
|
||||
echo "Checking dist-standalone directory..."
|
||||
ls -la dist-standalone/
|
||||
|
||||
echo "Verifying CLI binaries..."
|
||||
ls -lh cli/bin/cline-* || echo "Warning: CLI binaries not found"
|
||||
|
||||
|
||||
echo "Checking dist-standalone/dist..."
|
||||
ls -la dist-standalone/dist/
|
||||
|
||||
echo "Checking package.json in dist-standalone..."
|
||||
cat dist-standalone/package.json | grep version
|
||||
cat dist-standalone/package.json
|
||||
|
||||
- name: Publish to NPM with nightly tag
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
|
||||
@@ -46,3 +46,4 @@ test-results
|
||||
/pkg
|
||||
.secrets
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
@.clinerules/general.md
|
||||
@.clinerules/network.md
|
||||
@.clinerules/cli.md
|
||||
|
||||
+3
-1
@@ -44,7 +44,7 @@
|
||||
"style": {
|
||||
"useNodejsImportProtocol": "off",
|
||||
"useImportType": "off",
|
||||
"useBlockStatements": "warn",
|
||||
"useBlockStatements": "off",
|
||||
"useNamingConvention": "off",
|
||||
"useThrowOnlyError": "info",
|
||||
"useConsistentArrayType": "off",
|
||||
@@ -154,6 +154,8 @@
|
||||
],
|
||||
"includes": [
|
||||
"**",
|
||||
"!**/esbuild.*",
|
||||
"!**/*.mts",
|
||||
"!**/webview-ui/**",
|
||||
"!**/evals/**",
|
||||
"!**/standalone/**",
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
# 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
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
# 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
|
||||
@@ -0,0 +1,260 @@
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
// 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.log(e)
|
||||
process.exit(1)
|
||||
})
|
||||
Generated
+2950
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"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": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Account info view component
|
||||
* Shows current provider, and for Cline provider: credit balance and organization name
|
||||
*/
|
||||
|
||||
import { Box, Text } from "ink"
|
||||
import React, { useCallback, useEffect, useState } from "react"
|
||||
import { Controller } from "@/core/controller"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { ClineAccountService } from "@/services/account/ClineAccountService"
|
||||
import { AuthService, ClineAccountOrganization } from "@/services/auth/AuthService"
|
||||
import { LoadingSpinner } from "./Spinner"
|
||||
|
||||
interface AccountInfoViewProps {
|
||||
controller: Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Capitalize provider name for display
|
||||
*/
|
||||
function capitalize(str: string): string {
|
||||
return str
|
||||
.split("-")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/**
|
||||
* Format balance as currency (balance is in microcredits, divide by 10000)
|
||||
*/
|
||||
function formatBalance(balance: number | null): string {
|
||||
if (balance === null || balance === undefined) {
|
||||
return "..."
|
||||
}
|
||||
return `$${(balance / 1000000).toFixed(2)}`
|
||||
}
|
||||
|
||||
export const AccountInfoView: React.FC<AccountInfoViewProps> = 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>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Reusable API key input component
|
||||
* Shows a password-masked input field for entering API keys
|
||||
*/
|
||||
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
|
||||
interface ApiKeyInputProps {
|
||||
providerName: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onSubmit: (value: string) => void
|
||||
onCancel: () => void
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
|
||||
providerName,
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
isActive = true,
|
||||
}) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
// Filter out mouse escape sequences
|
||||
if (isMouseEscapeSequence(input)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (key.escape) {
|
||||
onCancel()
|
||||
return
|
||||
}
|
||||
if (key.return) {
|
||||
onSubmit(value)
|
||||
return
|
||||
}
|
||||
if (key.backspace || key.delete) {
|
||||
onChange(value.slice(0, -1))
|
||||
return
|
||||
}
|
||||
if (input && !key.ctrl && !key.meta) {
|
||||
onChange(value + input)
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported && isActive },
|
||||
)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={COLORS.primaryBlue}>
|
||||
{providerName} API Key
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">Paste your API key below</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color="white">{"•".repeat(value.length)}</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">Enter to save, Esc to cancel</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
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")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* 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
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,991 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,597 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* 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
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Reusable Checkbox component for settings panels
|
||||
*/
|
||||
|
||||
import { Box, Text } from "ink"
|
||||
import React from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
|
||||
interface CheckboxProps {
|
||||
/** Label displayed next to the checkbox */
|
||||
label: string
|
||||
/** Current checked state */
|
||||
checked: boolean
|
||||
/** Whether this checkbox is currently selected/focused */
|
||||
isSelected?: boolean
|
||||
/** Optional description shown below the label */
|
||||
description?: string
|
||||
}
|
||||
|
||||
export const Checkbox: React.FC<CheckboxProps> = ({ label, checked, isSelected = false, description }) => {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text>
|
||||
<Text bold color={isSelected ? COLORS.primaryBlue : undefined}>
|
||||
{isSelected ? "❯" : " "}{" "}
|
||||
</Text>
|
||||
<Text color={isSelected || checked ? COLORS.primaryBlue : "gray"}>{checked ? "[✓]" : "[ ]"}</Text>
|
||||
<Text color={isSelected ? COLORS.primaryBlue : "white"}> {label}</Text>
|
||||
{isSelected && <Text color="gray"> (Tab to toggle)</Text>}
|
||||
</Text>
|
||||
{description && (
|
||||
<Box marginLeft={6}>
|
||||
<Text color="gray">{description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { Text } from "ink"
|
||||
import { render } from "ink-testing-library"
|
||||
import React from "react"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
// Create stable mock references using vi.hoisted - must be before any imports that use these modules
|
||||
const { mockIsSettingsKey } = vi.hoisted(() => ({
|
||||
mockIsSettingsKey: vi.fn((key: string) => key.startsWith("act") || key.startsWith("plan") || key === "mode"),
|
||||
}))
|
||||
|
||||
vi.mock("./TaskView", () => ({
|
||||
TaskView: ({ taskId, verbose }: any) =>
|
||||
React.createElement(Text, null, `TaskView: ${taskId || "no-id"} verbose=${String(verbose)}`),
|
||||
}))
|
||||
|
||||
// Mock the state-keys module - must be hoisted before ConfigView import
|
||||
vi.mock("@shared/storage/state-keys", () => ({
|
||||
isSettingsKey: mockIsSettingsKey,
|
||||
SETTINGS_DEFAULTS: {
|
||||
mode: "act",
|
||||
actModeApiProvider: "anthropic",
|
||||
},
|
||||
GlobalStateAndSettings: {},
|
||||
GlobalStateAndSettingsKey: {},
|
||||
LocalState: {},
|
||||
LocalStateKey: {},
|
||||
}))
|
||||
|
||||
// Import ConfigView after mocks are set up
|
||||
import { ConfigView } from "./ConfigView"
|
||||
|
||||
describe("ConfigView", () => {
|
||||
const defaultProps = {
|
||||
dataDir: "/home/user/.cline",
|
||||
globalState: {},
|
||||
workspaceState: {},
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("rendering", () => {
|
||||
it("should render the config header", () => {
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} />)
|
||||
expect(lastFrame()).toContain("Configuration")
|
||||
})
|
||||
|
||||
it("should display the data directory", () => {
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} dataDir="/custom/path" />)
|
||||
expect(lastFrame()).toContain("/custom/path")
|
||||
})
|
||||
|
||||
it("should display global state entries", () => {
|
||||
const { lastFrame } = render(
|
||||
<ConfigView
|
||||
{...defaultProps}
|
||||
globalState={{
|
||||
mode: "act",
|
||||
actModeApiProvider: "anthropic",
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
expect(lastFrame()).toContain("mode")
|
||||
expect(lastFrame()).toContain("act")
|
||||
})
|
||||
|
||||
it("should display workspace state entries", () => {
|
||||
const { lastFrame } = render(
|
||||
<ConfigView
|
||||
{...defaultProps}
|
||||
workspaceState={{
|
||||
customSetting: "value",
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
expect(lastFrame()).toContain("customSetting")
|
||||
expect(lastFrame()).toContain("value")
|
||||
})
|
||||
|
||||
it("should show section headers", () => {
|
||||
const { lastFrame } = render(
|
||||
<ConfigView {...defaultProps} globalState={{ mode: "act" }} workspaceState={{ localKey: "localValue" }} />,
|
||||
)
|
||||
expect(lastFrame()).toContain("Global Settings")
|
||||
})
|
||||
})
|
||||
|
||||
describe("value formatting", () => {
|
||||
it("should format boolean values", () => {
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ actModeSomeBool: true }} />)
|
||||
expect(lastFrame()).toContain("true")
|
||||
})
|
||||
|
||||
it("should format number values", () => {
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ actModeNumber: 42 }} />)
|
||||
expect(lastFrame()).toContain("42")
|
||||
})
|
||||
|
||||
it("should truncate long string values", () => {
|
||||
const longString = "x".repeat(100)
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ actModeLongValue: longString }} />)
|
||||
expect(lastFrame()).toContain("...")
|
||||
})
|
||||
|
||||
it("should format object values as JSON", () => {
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ actModeObj: { nested: "value" } }} />)
|
||||
expect(lastFrame()).toContain("nested")
|
||||
})
|
||||
})
|
||||
|
||||
describe("filtering", () => {
|
||||
it("should exclude taskHistory key", () => {
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ taskHistory: [1, 2, 3], mode: "act" }} />)
|
||||
expect(lastFrame()).not.toContain("taskHistory")
|
||||
})
|
||||
|
||||
it("should exclude empty objects", () => {
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ emptyObj: {}, mode: "act" }} />)
|
||||
expect(lastFrame()).not.toContain("emptyObj")
|
||||
})
|
||||
|
||||
it("should exclude empty arrays", () => {
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ emptyArr: [], mode: "act" }} />)
|
||||
expect(lastFrame()).not.toContain("emptyArr")
|
||||
})
|
||||
|
||||
it("should exclude null/undefined values", () => {
|
||||
const { lastFrame } = render(
|
||||
<ConfigView {...defaultProps} globalState={{ nullVal: null, undefinedVal: undefined, mode: "act" }} />,
|
||||
)
|
||||
expect(lastFrame()).not.toContain("nullVal")
|
||||
expect(lastFrame()).not.toContain("undefinedVal")
|
||||
})
|
||||
|
||||
it("should exclude keys ending with Toggles", () => {
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ someToggles: { a: true }, mode: "act" }} />)
|
||||
expect(lastFrame()).not.toContain("someToggles")
|
||||
})
|
||||
|
||||
it("should exclude keys starting with apiConfig_", () => {
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ apiConfig_test: "value", mode: "act" }} />)
|
||||
expect(lastFrame()).not.toContain("apiConfig_test")
|
||||
})
|
||||
})
|
||||
|
||||
describe("keyboard navigation", () => {
|
||||
it("should show navigation help text", () => {
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ mode: "act" }} />)
|
||||
expect(lastFrame()).toContain("Navigate")
|
||||
expect(lastFrame()).toContain("Edit")
|
||||
})
|
||||
|
||||
it("should highlight first item by default", () => {
|
||||
const { lastFrame } = render(
|
||||
<ConfigView {...defaultProps} globalState={{ mode: "act", actModeApiProvider: "anthropic" }} />,
|
||||
)
|
||||
// The selected indicator
|
||||
expect(lastFrame()).toContain("❯")
|
||||
})
|
||||
|
||||
it("should navigate down with arrow key", () => {
|
||||
const { lastFrame, stdin } = render(
|
||||
<ConfigView {...defaultProps} globalState={{ actModeFirst: "a", actModeSecond: "b" }} />,
|
||||
)
|
||||
|
||||
// Press down arrow
|
||||
stdin.write("\x1B[B")
|
||||
|
||||
const frame = lastFrame()
|
||||
expect(frame).toContain("❯")
|
||||
})
|
||||
|
||||
it("should navigate up with arrow key", () => {
|
||||
const { lastFrame, stdin } = render(
|
||||
<ConfigView {...defaultProps} globalState={{ actModeFirst: "a", actModeSecond: "b" }} />,
|
||||
)
|
||||
|
||||
// Press down then up
|
||||
stdin.write("\x1B[B")
|
||||
stdin.write("\x1B[A")
|
||||
|
||||
expect(lastFrame()).toContain("❯")
|
||||
})
|
||||
})
|
||||
|
||||
describe("scrolling", () => {
|
||||
it("should show scroll indicators when list is long", () => {
|
||||
const manyEntries: Record<string, string> = {}
|
||||
for (let i = 0; i < 20; i++) {
|
||||
manyEntries[`actModeKey${i}`] = `value${i}`
|
||||
}
|
||||
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={manyEntries} />)
|
||||
|
||||
expect(lastFrame()).toContain("Showing")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,551 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Focus Chain / To-Do List component for CLI
|
||||
* Displays a progress-tracked checklist of tasks
|
||||
*/
|
||||
|
||||
import { isCompletedFocusChainItem, isFocusChainItem, parseFocusChainItem } from "@shared/focus-chain-utils"
|
||||
import { Box, Text } from "ink"
|
||||
import React, { useMemo } from "react"
|
||||
|
||||
interface TodoInfo {
|
||||
currentTodo: { text: string; completed: boolean; index: number } | null
|
||||
currentIndex: number
|
||||
completedCount: number
|
||||
totalCount: number
|
||||
progressPercentage: number
|
||||
}
|
||||
|
||||
interface TodoItem {
|
||||
text: string
|
||||
checked: boolean
|
||||
}
|
||||
|
||||
interface FocusChainProps {
|
||||
focusChainChecklist?: string | null
|
||||
expanded?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the focus chain checklist text into TodoInfo
|
||||
*/
|
||||
function parseCurrentTodoInfo(text: string): TodoInfo | null {
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
|
||||
let completedCount = 0
|
||||
let totalCount = 0
|
||||
let firstIncompleteIndex = -1
|
||||
let firstIncompleteText: string | null = null
|
||||
|
||||
const lines = text.split("\n")
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim()
|
||||
if (isFocusChainItem(line)) {
|
||||
const isCompleted = isCompletedFocusChainItem(line)
|
||||
|
||||
if (isCompleted) {
|
||||
completedCount++
|
||||
} else if (firstIncompleteIndex === -1) {
|
||||
firstIncompleteIndex = totalCount
|
||||
// Extract text after "- [ ] "
|
||||
firstIncompleteText = line.substring(5).trim()
|
||||
}
|
||||
|
||||
totalCount++
|
||||
}
|
||||
}
|
||||
|
||||
if (totalCount === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const currentTodo = firstIncompleteText ? { text: firstIncompleteText, completed: false, index: firstIncompleteIndex } : null
|
||||
|
||||
return {
|
||||
currentTodo,
|
||||
currentIndex: firstIncompleteIndex >= 0 ? firstIncompleteIndex + 1 : totalCount,
|
||||
completedCount,
|
||||
totalCount,
|
||||
progressPercentage: (completedCount / totalCount) * 100,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse all todo items from the checklist
|
||||
*/
|
||||
function parseTodoItems(text: string): TodoItem[] {
|
||||
const items: TodoItem[] = []
|
||||
const lines = text.split("\n")
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim()
|
||||
const parsed = parseFocusChainItem(line)
|
||||
if (parsed) {
|
||||
items.push(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
/**
|
||||
* Render progress bar
|
||||
*/
|
||||
const ProgressBar: React.FC<{ percentage: number; width?: number }> = ({ percentage, width = 20 }) => {
|
||||
const filled = Math.round((percentage / 100) * width)
|
||||
const empty = width - filled
|
||||
const bar = "█".repeat(filled) + "░".repeat(empty)
|
||||
|
||||
return (
|
||||
<Text>
|
||||
<Text color="green">{bar}</Text>
|
||||
<Text dimColor> {Math.round(percentage)}%</Text>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Header view showing current task and progress
|
||||
*/
|
||||
const Header: React.FC<{
|
||||
todoInfo: TodoInfo
|
||||
}> = ({ todoInfo }) => {
|
||||
const { currentTodo, currentIndex, totalCount, completedCount } = todoInfo
|
||||
const isCompleted = completedCount === totalCount
|
||||
|
||||
const displayText = isCompleted ? "All tasks completed!" : currentTodo?.text || "To-Do list"
|
||||
const truncatedText = displayText.length > 50 ? displayText.substring(0, 47) + "..." : displayText
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={isCompleted ? "green" : "cyan"}>
|
||||
[{currentIndex}/{totalCount}]
|
||||
</Text>
|
||||
<Text color={isCompleted ? "green" : undefined}>{truncatedText}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Expanded view showing all todo items
|
||||
*/
|
||||
const ExpandedList: React.FC<{
|
||||
items: TodoItem[]
|
||||
isCompleted: boolean
|
||||
}> = ({ items, isCompleted }) => {
|
||||
return (
|
||||
<Box flexDirection="column" marginLeft={2} marginTop={1}>
|
||||
{items.map((item, index) => (
|
||||
<Box key={index}>
|
||||
<Text color={item.checked ? "green" : "gray"}>{item.checked ? "✓" : "○"} </Text>
|
||||
<Text color={item.checked ? "green" : undefined} dimColor={item.checked}>
|
||||
{item.text}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
{isCompleted && (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor italic>
|
||||
New steps will be generated if you continue the task
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Main FocusChain component for CLI
|
||||
* Shows a progress summary of the current to-do list
|
||||
* Use expanded={true} to show all items (e.g., in verbose mode)
|
||||
*/
|
||||
export const FocusChain: React.FC<FocusChainProps> = ({ focusChainChecklist, expanded = false }) => {
|
||||
const todoInfo = useMemo(
|
||||
() => (focusChainChecklist ? parseCurrentTodoInfo(focusChainChecklist) : null),
|
||||
[focusChainChecklist],
|
||||
)
|
||||
|
||||
const todoItems = useMemo(() => (focusChainChecklist ? parseTodoItems(focusChainChecklist) : []), [focusChainChecklist])
|
||||
|
||||
// No content to display
|
||||
if (!todoInfo) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isCompleted = todoInfo.completedCount === todoInfo.totalCount
|
||||
|
||||
return (
|
||||
<Box borderColor={isCompleted ? "green" : "gray"} borderStyle="round" flexDirection="column" paddingX={1}>
|
||||
<Header todoInfo={todoInfo} />
|
||||
<ProgressBar percentage={todoInfo.progressPercentage} />
|
||||
{expanded && <ExpandedList isCompleted={isCompleted} items={todoItems} />}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Highlighted input component for CLI
|
||||
* Renders text with @ mentions and / commands highlighted, plus a movable cursor
|
||||
*/
|
||||
|
||||
import { mentionRegexGlobal } from "@shared/context-mentions"
|
||||
import { Text } from "ink"
|
||||
import React from "react"
|
||||
|
||||
interface HighlightedInputProps {
|
||||
text: string
|
||||
cursorPos?: number
|
||||
availableCommands?: string[]
|
||||
}
|
||||
|
||||
// Regex for / commands (at start or after whitespace)
|
||||
const slashCommandRegex = /(^|\s)(\/[a-zA-Z0-9_.-]+)/g
|
||||
|
||||
interface Segment {
|
||||
text: string
|
||||
type: "normal" | "mention" | "command"
|
||||
startIndex: number
|
||||
}
|
||||
|
||||
function parseInput(text: string, availableCommands?: string[]): Segment[] {
|
||||
const highlights: { start: number; end: number; type: "mention" | "command" }[] = []
|
||||
|
||||
// Find all mentions
|
||||
mentionRegexGlobal.lastIndex = 0
|
||||
let match
|
||||
while ((match = mentionRegexGlobal.exec(text)) !== null) {
|
||||
highlights.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
type: "mention",
|
||||
})
|
||||
}
|
||||
|
||||
// Find first slash command only (must be complete and valid)
|
||||
slashCommandRegex.lastIndex = 0
|
||||
const slashMatch = slashCommandRegex.exec(text)
|
||||
if (slashMatch) {
|
||||
const prefix = slashMatch[1] || ""
|
||||
const commandText = slashMatch[2] // e.g., "/help"
|
||||
const commandName = commandText.slice(1) // e.g., "help"
|
||||
const commandStart = slashMatch.index + prefix.length
|
||||
const commandEnd = commandStart + commandText.length
|
||||
|
||||
// Only highlight if command exists in available commands (or if no list provided)
|
||||
if (!availableCommands || availableCommands.includes(commandName)) {
|
||||
highlights.push({
|
||||
start: commandStart,
|
||||
end: commandEnd,
|
||||
type: "command",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort highlights by start position
|
||||
highlights.sort((a, b) => a.start - b.start)
|
||||
|
||||
// Build segments
|
||||
const segments: Segment[] = []
|
||||
let lastIndex = 0
|
||||
|
||||
for (const highlight of highlights) {
|
||||
// Skip overlapping highlights
|
||||
if (highlight.start < lastIndex) continue
|
||||
|
||||
// Add normal text before this highlight
|
||||
if (highlight.start > lastIndex) {
|
||||
segments.push({
|
||||
text: text.slice(lastIndex, highlight.start),
|
||||
type: "normal",
|
||||
startIndex: lastIndex,
|
||||
})
|
||||
}
|
||||
|
||||
// Add highlighted segment
|
||||
segments.push({
|
||||
text: text.slice(highlight.start, highlight.end),
|
||||
type: highlight.type,
|
||||
startIndex: highlight.start,
|
||||
})
|
||||
|
||||
lastIndex = highlight.end
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (lastIndex < text.length) {
|
||||
segments.push({
|
||||
text: text.slice(lastIndex),
|
||||
type: "normal",
|
||||
startIndex: lastIndex,
|
||||
})
|
||||
}
|
||||
|
||||
// If no segments (empty or whitespace only), add a single normal segment
|
||||
if (segments.length === 0 && text.length > 0) {
|
||||
segments.push({
|
||||
text: text,
|
||||
type: "normal",
|
||||
startIndex: 0,
|
||||
})
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
export const HighlightedInput: React.FC<HighlightedInputProps> = ({ text, cursorPos, availableCommands }) => {
|
||||
// If no cursor position provided, just render text with highlights (backward compatible)
|
||||
if (cursorPos === undefined) {
|
||||
if (!text) return null
|
||||
const segments = parseInput(text, availableCommands)
|
||||
return (
|
||||
<Text>
|
||||
{segments.map((segment, idx) => {
|
||||
if (segment.type === "mention" || segment.type === "command") {
|
||||
return (
|
||||
<Text backgroundColor="gray" key={idx}>
|
||||
{segment.text}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
return <Text key={idx}>{segment.text}</Text>
|
||||
})}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
// With cursor position - render cursor within the text
|
||||
const safeCursorPos = Math.min(Math.max(0, cursorPos), text.length)
|
||||
const segments = parseInput(text, availableCommands)
|
||||
|
||||
// Render segments with cursor
|
||||
const renderSegmentWithCursor = (segment: Segment, segmentIdx: number) => {
|
||||
const segmentStart = segment.startIndex
|
||||
const segmentEnd = segmentStart + segment.text.length
|
||||
const isHighlighted = segment.type === "mention" || segment.type === "command"
|
||||
|
||||
// Check if cursor is within this segment
|
||||
if (safeCursorPos >= segmentStart && safeCursorPos < segmentEnd) {
|
||||
// Cursor is in this segment - split it
|
||||
const localCursorPos = safeCursorPos - segmentStart
|
||||
const beforeCursor = segment.text.slice(0, localCursorPos)
|
||||
const cursorChar = segment.text[localCursorPos]
|
||||
const afterCursor = segment.text.slice(localCursorPos + 1)
|
||||
|
||||
if (isHighlighted) {
|
||||
return (
|
||||
<Text key={segmentIdx}>
|
||||
{beforeCursor && <Text backgroundColor="gray">{beforeCursor}</Text>}
|
||||
<Text backgroundColor="gray" inverse>
|
||||
{cursorChar}
|
||||
</Text>
|
||||
{afterCursor && <Text backgroundColor="gray">{afterCursor}</Text>}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Text key={segmentIdx}>
|
||||
{beforeCursor}
|
||||
<Text inverse>{cursorChar}</Text>
|
||||
{afterCursor}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
// Cursor not in this segment - render normally
|
||||
if (isHighlighted) {
|
||||
return (
|
||||
<Text backgroundColor="gray" key={segmentIdx}>
|
||||
{segment.text}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
return <Text key={segmentIdx}>{segment.text}</Text>
|
||||
}
|
||||
|
||||
// Check if cursor is at the end (past all text)
|
||||
const cursorAtEnd = safeCursorPos >= text.length
|
||||
|
||||
return (
|
||||
<Text>
|
||||
{segments.map((segment, idx) => renderSegmentWithCursor(segment, idx))}
|
||||
{cursorAtEnd && <Text inverse> </Text>}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
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")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Import view component
|
||||
* Handles importing API keys from competing CLI agents (Codex, OpenCode)
|
||||
*/
|
||||
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useCallback, useEffect, useState } from "react"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import {
|
||||
getProviderDisplayName,
|
||||
getSourceDisplayName,
|
||||
type ImportedKey,
|
||||
type ImportSource,
|
||||
importFromCodex,
|
||||
importFromOpenCode,
|
||||
} from "../utils/import-configs"
|
||||
|
||||
type ImportStep = "select" | "confirm" | "saving" | "error"
|
||||
|
||||
interface ImportViewProps {
|
||||
source: ImportSource
|
||||
onComplete: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export const ImportView: React.FC<ImportViewProps> = ({ source, onComplete, onCancel }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const [step, setStep] = useState<ImportStep>("select")
|
||||
const [keys, setKeys] = useState<ImportedKey[]>([])
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [confirmIndex, setConfirmIndex] = useState(0)
|
||||
const [errorMessage, setErrorMessage] = useState("")
|
||||
|
||||
// Load keys on mount
|
||||
useEffect(() => {
|
||||
const result = source === "codex" ? importFromCodex() : importFromOpenCode()
|
||||
if (result && result.keys.length > 0) {
|
||||
setKeys(result.keys)
|
||||
if (result.keys.length === 1) {
|
||||
// Only one key, go straight to confirm
|
||||
setStep("confirm")
|
||||
}
|
||||
} else {
|
||||
setErrorMessage(`Could not read API keys from ${getSourceDisplayName(source)} config`)
|
||||
setStep("error")
|
||||
}
|
||||
}, [source])
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
try {
|
||||
setStep("saving")
|
||||
|
||||
const selectedKey = keys[selectedIndex]
|
||||
if (!selectedKey) {
|
||||
setErrorMessage("No key selected")
|
||||
setStep("error")
|
||||
return
|
||||
}
|
||||
|
||||
const stateManager = StateManager.get()
|
||||
const config: Record<string, string> = {
|
||||
actModeApiProvider: selectedKey.provider,
|
||||
planModeApiProvider: selectedKey.provider,
|
||||
apiProvider: selectedKey.provider,
|
||||
}
|
||||
|
||||
// Set API key
|
||||
config[selectedKey.keyField] = selectedKey.key
|
||||
|
||||
// Set model ID if available
|
||||
if (selectedKey.modelId) {
|
||||
config.actModeApiModelId = selectedKey.modelId
|
||||
config.planModeApiModelId = selectedKey.modelId
|
||||
}
|
||||
|
||||
stateManager.setApiConfiguration(config)
|
||||
await stateManager.flushPendingState()
|
||||
|
||||
onComplete()
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : String(error))
|
||||
setStep("error")
|
||||
}
|
||||
}, [keys, selectedIndex, onComplete])
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (key.escape) {
|
||||
if (step === "confirm" && keys.length > 1) {
|
||||
setStep("select")
|
||||
setConfirmIndex(0)
|
||||
} else if (step === "error") {
|
||||
onCancel()
|
||||
} else {
|
||||
onCancel()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (step === "select") {
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : keys.length - 1))
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex((prev) => (prev < keys.length - 1 ? prev + 1 : 0))
|
||||
} else if (key.return) {
|
||||
setStep("confirm")
|
||||
}
|
||||
} else if (step === "confirm") {
|
||||
if (key.upArrow || key.downArrow) {
|
||||
setConfirmIndex((prev) => (prev === 0 ? 1 : 0))
|
||||
} else if (key.return) {
|
||||
if (confirmIndex === 0) {
|
||||
handleConfirm()
|
||||
} else {
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
} else if (step === "error") {
|
||||
if (key.return) {
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported && step !== "saving" },
|
||||
)
|
||||
|
||||
const sourceName = getSourceDisplayName(source)
|
||||
|
||||
if (step === "select") {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="white">Select which key to import from {sourceName}</Text>
|
||||
<Text> </Text>
|
||||
{keys.map((k, i) => (
|
||||
<Box key={`${k.provider}-${i}`}>
|
||||
<Text color={i === selectedIndex ? COLORS.primaryBlue : undefined}>
|
||||
{i === selectedIndex ? "❯ " : " "}
|
||||
{getProviderDisplayName(k.provider)}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
<Text> </Text>
|
||||
<Text color="gray">Arrows to navigate, Enter to select, Esc to go back</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (step === "confirm") {
|
||||
const selectedKey = keys[selectedIndex]
|
||||
const providerName = selectedKey ? getProviderDisplayName(selectedKey.provider) : ""
|
||||
const maskedKey = selectedKey ? `${selectedKey.key.slice(0, 8)}...${selectedKey.key.slice(-4)}` : ""
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="white">Import API key from {sourceName}?</Text>
|
||||
<Text> </Text>
|
||||
<Box>
|
||||
<Text color="gray">Provider: </Text>
|
||||
<Text color="white">{providerName}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color="gray">API Key: </Text>
|
||||
<Text color="white">{maskedKey}</Text>
|
||||
</Box>
|
||||
{selectedKey?.modelId && (
|
||||
<Box>
|
||||
<Text color="gray">Model: </Text>
|
||||
<Text color="white">{selectedKey.modelId}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Text> </Text>
|
||||
<Box>
|
||||
<Text color={confirmIndex === 0 ? COLORS.primaryBlue : undefined}>
|
||||
{confirmIndex === 0 ? "❯ " : " "}
|
||||
Confirm import
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={confirmIndex === 1 ? COLORS.primaryBlue : undefined}>
|
||||
{confirmIndex === 1 ? "❯ " : " "}
|
||||
Cancel
|
||||
</Text>
|
||||
</Box>
|
||||
<Text> </Text>
|
||||
<Text color="gray">Enter to confirm, Esc to go back</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (step === "saving") {
|
||||
return (
|
||||
<Box>
|
||||
<Text color="white">Importing configuration...</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (step === "error") {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color="red">
|
||||
Something went wrong
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text color="yellow">{errorMessage}</Text>
|
||||
<Text> </Text>
|
||||
<Text color="gray">Press Enter or Esc to go back</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Language picker component for user preference
|
||||
*/
|
||||
|
||||
import React, { useMemo } from "react"
|
||||
import { SearchableList, SearchableListItem } from "./SearchableList"
|
||||
|
||||
// Available languages - English names only to avoid Unicode rendering issues
|
||||
const LANGUAGES = [
|
||||
"English",
|
||||
"Arabic",
|
||||
"Czech",
|
||||
"French",
|
||||
"German",
|
||||
"Hindi",
|
||||
"Hungarian",
|
||||
"Italian",
|
||||
"Japanese",
|
||||
"Korean",
|
||||
"Polish",
|
||||
"Portuguese (Brazil)",
|
||||
"Portuguese (Portugal)",
|
||||
"Russian",
|
||||
"Simplified Chinese",
|
||||
"Spanish",
|
||||
"Traditional Chinese",
|
||||
"Turkish",
|
||||
]
|
||||
|
||||
interface LanguagePickerProps {
|
||||
onSelect: (language: string) => void
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export const LanguagePicker: React.FC<LanguagePickerProps> = ({ onSelect, isActive = true }) => {
|
||||
const items: SearchableListItem[] = useMemo(
|
||||
() =>
|
||||
LANGUAGES.map((lang) => ({
|
||||
id: lang,
|
||||
label: lang,
|
||||
})),
|
||||
[],
|
||||
)
|
||||
|
||||
return <SearchableList isActive={isActive} items={items} onSelect={(item) => onSelect(item.id)} />
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* 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)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 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)} />
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* 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)} />
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
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
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Slash command menu component for CLI
|
||||
* Displays a list of matching slash commands when user types /
|
||||
*/
|
||||
|
||||
import type { SlashCommandInfo } from "@shared/proto/cline/slash"
|
||||
import { Box, Text } from "ink"
|
||||
import React from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { getVisibleWindow } from "../utils/slash-commands"
|
||||
|
||||
interface SlashCommandMenuProps {
|
||||
commands: SlashCommandInfo[]
|
||||
selectedIndex: number
|
||||
query: string
|
||||
}
|
||||
|
||||
export const SlashCommandMenu: React.FC<SlashCommandMenuProps> = ({ commands, selectedIndex, query }) => {
|
||||
if (commands.length === 0) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} paddingLeft={1} paddingRight={1}>
|
||||
<Text color="gray">{query ? `No commands matching "/${query}"` : "Type to search commands..."}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const { items: visibleCommands, startIndex } = getVisibleWindow(commands, selectedIndex)
|
||||
const hasMoreBelow = startIndex + visibleCommands.length < commands.length
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} paddingLeft={1} paddingRight={1}>
|
||||
{visibleCommands.map((cmd, idx) => {
|
||||
const isSelected = startIndex + idx === selectedIndex
|
||||
// Only show description for default commands (not workflows)
|
||||
const showDescription = cmd.section === "default" || !cmd.section
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" key={cmd.name}>
|
||||
<Box>
|
||||
<Text color={isSelected ? COLORS.primaryBlue : undefined}>
|
||||
{isSelected ? "❯" : " "} /{cmd.name}
|
||||
</Text>
|
||||
</Box>
|
||||
{showDescription && cmd.description && (
|
||||
<Box paddingLeft={3}>
|
||||
<Text color="gray">{cmd.description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
{hasMoreBelow && <Text color="gray">{" "}▼</Text>}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Status bar component
|
||||
* Shows git branch, model, context window usage, token count, and cost
|
||||
*/
|
||||
|
||||
import { execSync } from "child_process"
|
||||
import { Box, Text } from "ink"
|
||||
import React, { useEffect, useState } from "react"
|
||||
|
||||
interface StatusBarProps {
|
||||
modelId: string
|
||||
tokensIn?: number
|
||||
tokensOut?: number
|
||||
totalCost?: number
|
||||
contextWindowSize?: number
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current git branch name
|
||||
*/
|
||||
function getGitBranch(cwd?: string): string | null {
|
||||
try {
|
||||
const branch = execSync("git rev-parse --abbrev-ref HEAD", {
|
||||
cwd: cwd || process.cwd(),
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim()
|
||||
return branch
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get directory basename
|
||||
*/
|
||||
function getDirName(cwd?: string): string {
|
||||
const path = cwd || process.cwd()
|
||||
return path.split("/").pop() || path
|
||||
}
|
||||
|
||||
/**
|
||||
* Format number with commas
|
||||
*/
|
||||
function formatNumber(num: number): string {
|
||||
return num.toLocaleString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a progress bar for context window usage
|
||||
*/
|
||||
function createContextBar(used: number, total: number, width: number = 8): string {
|
||||
const ratio = Math.min(used / total, 1)
|
||||
const filled = Math.round(ratio * width)
|
||||
const empty = width - filled
|
||||
return "█".repeat(filled) + "░".repeat(empty)
|
||||
}
|
||||
|
||||
export const StatusBar: React.FC<StatusBarProps> = ({
|
||||
modelId,
|
||||
tokensIn = 0,
|
||||
tokensOut = 0,
|
||||
totalCost = 0,
|
||||
contextWindowSize = 200000, // Default Claude context window
|
||||
cwd,
|
||||
}) => {
|
||||
const [branch, setBranch] = useState<string | null>(null)
|
||||
const dirName = getDirName(cwd)
|
||||
|
||||
useEffect(() => {
|
||||
setBranch(getGitBranch(cwd))
|
||||
}, [cwd])
|
||||
|
||||
const totalTokens = tokensIn + tokensOut
|
||||
const contextBar = createContextBar(totalTokens, contextWindowSize)
|
||||
|
||||
// Format model ID for display (shorten if needed)
|
||||
const displayModel = modelId.length > 20 ? modelId.substring(0, 17) + "..." : modelId
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box gap={1}>
|
||||
{/* Directory and branch */}
|
||||
<Text color="gray">
|
||||
{dirName}
|
||||
{branch && (
|
||||
<Text color="gray">
|
||||
{" "}
|
||||
(<Text color="cyan">{branch}</Text>)
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
<Text color="gray">|</Text>
|
||||
|
||||
{/* Model and context bar */}
|
||||
<Text color="white">{displayModel}</Text>
|
||||
<Text color="blue">{contextBar}</Text>
|
||||
<Text color="gray">({formatNumber(totalTokens)})</Text>
|
||||
<Text color="gray">|</Text>
|
||||
|
||||
{/* Cost */}
|
||||
<Text color="green">${totalCost.toFixed(4)}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* JSON Task view component
|
||||
* Outputs task messages as JSON instead of rich styled text
|
||||
*/
|
||||
|
||||
import { Box } from "ink"
|
||||
import React, { useEffect, useRef } from "react"
|
||||
import { useTaskContext, useTaskState } from "../context/TaskContext"
|
||||
import { useCompletionSignals } from "../hooks/useStateSubscriber"
|
||||
import { originalConsoleLog } from "../utils/console"
|
||||
|
||||
interface TaskJsonViewProps {
|
||||
taskId?: string
|
||||
verbose?: boolean
|
||||
onComplete?: () => void
|
||||
onError?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Output a JSON line to stdout
|
||||
*/
|
||||
function outputJson(data: object) {
|
||||
originalConsoleLog(JSON.stringify(data))
|
||||
}
|
||||
|
||||
export const TaskJsonView: React.FC<TaskJsonViewProps> = ({ taskId: _taskId, verbose = false, onComplete, onError }) => {
|
||||
const state = useTaskState()
|
||||
const { isTaskComplete, getCompletionMessage } = useCompletionSignals()
|
||||
const { setIsComplete } = useTaskContext()
|
||||
// Track outputted messages by timestamp (don't re-output on updates)
|
||||
const outputtedMessages = useRef<Set<number>>(new Set())
|
||||
const hasOutputtedCompletion = useRef(false)
|
||||
|
||||
// Determine the role for a message
|
||||
const getRole = (message: { type: string; ask?: string; say?: string }, index: number): "user" | "assistant" | "system" => {
|
||||
// User feedback messages
|
||||
if (message.say === "user_feedback" || message.say === "user_feedback_diff") {
|
||||
return "user"
|
||||
}
|
||||
// First text message is the user's task
|
||||
if (message.say === "text" && index === 0) {
|
||||
return "user"
|
||||
}
|
||||
// System messages
|
||||
if (message.say === "api_req_started" || message.say === "api_req_finished") {
|
||||
return "system"
|
||||
}
|
||||
// Default: assistant
|
||||
return "assistant"
|
||||
}
|
||||
|
||||
// Output messages as JSON when they arrive
|
||||
useEffect(() => {
|
||||
const messages = state.clineMessages || []
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i]
|
||||
|
||||
// Skip partial messages - wait for complete message
|
||||
if (message.partial) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if we already outputted this timestamp
|
||||
if (outputtedMessages.current.has(message.ts)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Filter out noisy messages in non-verbose mode
|
||||
if (!verbose) {
|
||||
if (message.say === "api_req_started" || message.say === "api_req_finished") {
|
||||
outputtedMessages.current.add(message.ts)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const role = getRole(message, i)
|
||||
|
||||
// Output the message as JSON
|
||||
outputJson({
|
||||
type: "message",
|
||||
timestamp: message.ts,
|
||||
role,
|
||||
messageType: message.type,
|
||||
...(message.ask && { ask: message.ask }),
|
||||
...(message.say && { say: message.say }),
|
||||
...(message.text && { text: message.text }),
|
||||
...(message.reasoning && { reasoning: message.reasoning }),
|
||||
...(message.images && message.images.length > 0 && { images: message.images }),
|
||||
...(message.files && message.files.length > 0 && { files: message.files }),
|
||||
})
|
||||
|
||||
outputtedMessages.current.add(message.ts)
|
||||
}
|
||||
}, [state.clineMessages, verbose])
|
||||
|
||||
// Handle task completion
|
||||
useEffect(() => {
|
||||
if (isTaskComplete() && !hasOutputtedCompletion.current) {
|
||||
hasOutputtedCompletion.current = true
|
||||
setIsComplete(true)
|
||||
|
||||
const completionMsg = getCompletionMessage()
|
||||
const isError = completionMsg?.say === "error" || completionMsg?.ask === "api_req_failed"
|
||||
|
||||
// Output completion status
|
||||
outputJson({
|
||||
type: "completion",
|
||||
status: isError ? "error" : "success",
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
if (isError) {
|
||||
onError?.()
|
||||
} else {
|
||||
onComplete?.()
|
||||
}
|
||||
|
||||
// Don't exit automatically - let the parent handle cleanup
|
||||
}
|
||||
}, [isTaskComplete, setIsComplete, onComplete, onError, getCompletionMessage])
|
||||
|
||||
// Render nothing visible - all output goes to stdout as JSON
|
||||
return <Box />
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Acting/Planning indicator with spinner, shimmer effect, and elapsed time
|
||||
*/
|
||||
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useEffect, useMemo, useState } from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
|
||||
interface ThinkingIndicatorProps {
|
||||
mode?: "act" | "plan"
|
||||
startTime?: number // Unix timestamp when thinking started
|
||||
onCancel?: () => void // Called when user presses esc to interrupt
|
||||
}
|
||||
|
||||
// Spinner frames (dots style from ink-spinner)
|
||||
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
||||
|
||||
const SHIMMER_WIDTH = 3 // How many characters are "bright" at once
|
||||
|
||||
/**
|
||||
* Format elapsed time as "1m 5s" or "45s"
|
||||
*/
|
||||
function formatElapsedTime(ms: number): string {
|
||||
const totalSeconds = Math.floor(ms / 1000)
|
||||
const minutes = Math.floor(totalSeconds / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${seconds}s`
|
||||
}
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
/**
|
||||
* Render text with a shimmer effect using bold for bright characters
|
||||
*/
|
||||
const ShimmerText: React.FC<{ text: string; color: string; shimmerPos: number }> = ({ text, color, shimmerPos }) => {
|
||||
const chars = text.split("")
|
||||
return (
|
||||
<Text>
|
||||
{chars.map((char, i) => {
|
||||
const distFromShimmer = Math.abs(i - shimmerPos)
|
||||
const isBright = distFromShimmer <= SHIMMER_WIDTH
|
||||
return (
|
||||
<Text bold={isBright} color={color} dimColor={!isBright} key={i}>
|
||||
{char}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
export const ThinkingIndicator: React.FC<ThinkingIndicatorProps> = ({ mode = "act", startTime, onCancel }) => {
|
||||
const message = mode === "plan" ? "Planning" : "Acting"
|
||||
const color = mode === "plan" ? "yellow" : COLORS.primaryBlue
|
||||
|
||||
// Handle esc key to cancel
|
||||
useInput((_input, key) => {
|
||||
if (key.escape && onCancel) {
|
||||
onCancel()
|
||||
}
|
||||
})
|
||||
|
||||
// Spinner frame index
|
||||
const [spinnerFrame, setSpinnerFrame] = useState(0)
|
||||
|
||||
// Shimmer position
|
||||
const [shimmerPos, setShimmerPos] = useState(-SHIMMER_WIDTH)
|
||||
|
||||
// Elapsed time state
|
||||
const [elapsedMs, setElapsedMs] = useState(0)
|
||||
|
||||
const spinnerChar = SPINNER_FRAMES[spinnerFrame]
|
||||
const fullText = `${spinnerChar} ${message}...`
|
||||
|
||||
// Animate spinner
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setSpinnerFrame((prev) => (prev + 1) % SPINNER_FRAMES.length)
|
||||
}, 80)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
// Animate shimmer
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setShimmerPos((prev) => {
|
||||
const next = prev + 1
|
||||
if (next > fullText.length + SHIMMER_WIDTH) {
|
||||
return -SHIMMER_WIDTH
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, 100)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [fullText.length])
|
||||
|
||||
// Update elapsed time
|
||||
useEffect(() => {
|
||||
if (!startTime) return
|
||||
|
||||
const updateElapsed = () => {
|
||||
setElapsedMs(Date.now() - startTime)
|
||||
}
|
||||
|
||||
updateElapsed() // Initial update
|
||||
const interval = setInterval(updateElapsed, 1000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [startTime])
|
||||
|
||||
const elapsedStr = useMemo(() => {
|
||||
if (!startTime) return null
|
||||
return formatElapsedTime(elapsedMs)
|
||||
}, [startTime, elapsedMs])
|
||||
|
||||
return (
|
||||
<Box paddingLeft={1}>
|
||||
<ShimmerText color={color} shimmerPos={shimmerPos} text={fullText} />
|
||||
{elapsedStr && <Text color="gray"> ({elapsedStr} · esc to interrupt)</Text>}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Color constants for the CLI
|
||||
* Using hex values for consistent rendering across terminals
|
||||
*/
|
||||
|
||||
export const COLORS = {
|
||||
// Primary brand color - light purple-blue
|
||||
primaryBlue: "#B1B9F9",
|
||||
|
||||
// Plan mode color
|
||||
planYellow: "yellow",
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Get the appropriate color for the current mode
|
||||
*/
|
||||
export function getModeColor(mode: "act" | "plan"): string {
|
||||
return mode === "plan" ? COLORS.planYellow : COLORS.primaryBlue
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Featured models shown in the Cline model picker during onboarding
|
||||
* These are curated models that work well with Cline
|
||||
*/
|
||||
|
||||
export interface FeaturedModel {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export const FEATURED_MODELS = {
|
||||
recommended: [
|
||||
{
|
||||
id: "anthropic/claude-opus-4.5",
|
||||
name: "Claude Opus 4.5",
|
||||
description: "State-of-the-art for complex coding",
|
||||
label: "Best",
|
||||
},
|
||||
{
|
||||
id: "openai/gpt-5.2-codex",
|
||||
name: "GPT 5.2 Codex",
|
||||
description: "OpenAI's latest with strong coding abilities",
|
||||
label: "New",
|
||||
},
|
||||
{
|
||||
id: "google/gemini-3-pro-preview",
|
||||
name: "Gemini 3 Pro",
|
||||
description: "1M context window for large codebases",
|
||||
label: "Trending",
|
||||
},
|
||||
] as FeaturedModel[],
|
||||
free: [
|
||||
{
|
||||
id: "kwaipilot/kat-coder-pro",
|
||||
name: "KAT Coder Pro",
|
||||
description: "Advanced agentic coding model",
|
||||
label: "FREE",
|
||||
},
|
||||
{
|
||||
id: "mistralai/devstral-2512:free",
|
||||
name: "Devstral",
|
||||
description: "Mistral's coding model",
|
||||
label: "FREE",
|
||||
},
|
||||
] as FeaturedModel[],
|
||||
}
|
||||
|
||||
export function getAllFeaturedModels(): FeaturedModel[] {
|
||||
return [...FEATURED_MODELS.recommended, ...FEATURED_MODELS.free]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Context for tracking stdin raw mode support
|
||||
* Used to conditionally disable input handling when stdin doesn't support raw mode
|
||||
* (e.g., when input is piped: echo "..." | clinedev)
|
||||
*/
|
||||
|
||||
import React, { createContext, type ReactNode, useContext } from "react"
|
||||
|
||||
interface StdinContextValue {
|
||||
/**
|
||||
* Whether stdin supports raw mode (keyboard input handling)
|
||||
* Will be false when input is piped or stdin is not a TTY
|
||||
*/
|
||||
isRawModeSupported: boolean
|
||||
}
|
||||
|
||||
const StdinContext = createContext<StdinContextValue>({ isRawModeSupported: true })
|
||||
|
||||
export const useStdinContext = () => useContext(StdinContext)
|
||||
|
||||
interface StdinProviderProps {
|
||||
children: ReactNode
|
||||
isRawModeSupported: boolean
|
||||
}
|
||||
|
||||
export const StdinProvider: React.FC<StdinProviderProps> = ({ children, isRawModeSupported }) => {
|
||||
return <StdinContext.Provider value={{ isRawModeSupported }}>{children}</StdinContext.Provider>
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if stdin supports raw mode
|
||||
* Returns false when input is piped or stdin is not a TTY
|
||||
*/
|
||||
export function checkRawModeSupport(): boolean {
|
||||
return Boolean(process.stdin.isTTY && typeof process.stdin.setRawMode === "function")
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* React Context for task state management in CLI
|
||||
* Provides access to ExtensionState and task controller
|
||||
*/
|
||||
|
||||
import { registerPartialMessageCallback } from "@core/controller/ui/subscribeToPartialMessage"
|
||||
import type { ClineMessage, ExtensionState } from "@shared/ExtensionMessage"
|
||||
import { convertProtoToClineMessage } from "@shared/proto-conversions/cline-message"
|
||||
import React, { createContext, ReactNode, useContext, useEffect, useRef, useState } from "react"
|
||||
|
||||
interface TaskContextType {
|
||||
state: Partial<ExtensionState>
|
||||
controller: any
|
||||
isComplete: boolean
|
||||
setIsComplete: (complete: boolean) => void
|
||||
lastError: string | null
|
||||
setLastError: (error: string | null) => void
|
||||
clearState: () => void
|
||||
}
|
||||
|
||||
const TaskContext = createContext<TaskContextType | undefined>(undefined)
|
||||
|
||||
interface TaskContextProviderProps {
|
||||
controller: any
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export const TaskContextProvider: React.FC<TaskContextProviderProps> = ({ controller, children }) => {
|
||||
const [state, setState] = useState<Partial<ExtensionState>>(
|
||||
() =>
|
||||
({
|
||||
clineMessages: [],
|
||||
currentTaskItem: null,
|
||||
}) as unknown as Partial<ExtensionState>,
|
||||
)
|
||||
const [isComplete, setIsComplete] = useState(false)
|
||||
const [lastError, setLastError] = useState<string | null>(null)
|
||||
|
||||
// Use ref to track latest state for partial message callback
|
||||
const stateRef = useRef(state)
|
||||
stateRef.current = state
|
||||
|
||||
// Subscribe to controller state updates
|
||||
useEffect(() => {
|
||||
const originalPostState = controller.postStateToWebview.bind(controller)
|
||||
|
||||
const handleStateUpdate = async () => {
|
||||
try {
|
||||
const newState = await controller.getStateToPostToWebview()
|
||||
// Ignore transient empty messages state during cancel/reinit
|
||||
// When clearTask() runs, messages briefly become [] before new task loads them
|
||||
const hadMessages = (stateRef.current.clineMessages?.length ?? 0) > 0
|
||||
const hasMessages = (newState.clineMessages?.length ?? 0) > 0
|
||||
if (hadMessages && !hasMessages) {
|
||||
return
|
||||
}
|
||||
setState(newState)
|
||||
} catch (error) {
|
||||
setLastError(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
// Override postStateToWebview to update React state
|
||||
controller.postStateToWebview = async () => {
|
||||
await originalPostState()
|
||||
await handleStateUpdate()
|
||||
}
|
||||
|
||||
// Subscribe to partial message events (for streaming updates)
|
||||
const unsubscribePartial = registerPartialMessageCallback((protoMessage) => {
|
||||
const updatedMessage = convertProtoToClineMessage(protoMessage) as ClineMessage
|
||||
setState((prevState) => {
|
||||
const messages = prevState.clineMessages || []
|
||||
// Find and update the message by timestamp
|
||||
const index = messages.findIndex((m) => m.ts === updatedMessage.ts)
|
||||
if (index >= 0) {
|
||||
const newMessages = [...messages]
|
||||
newMessages[index] = updatedMessage
|
||||
return { ...prevState, clineMessages: newMessages }
|
||||
}
|
||||
return prevState
|
||||
})
|
||||
})
|
||||
|
||||
// Get initial state
|
||||
handleStateUpdate()
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
controller.postStateToWebview = originalPostState
|
||||
unsubscribePartial()
|
||||
}
|
||||
}, [controller])
|
||||
|
||||
// Force clear state (bypasses the empty messages check for intentional clears like /clear)
|
||||
const clearState = () => {
|
||||
setState({
|
||||
clineMessages: [],
|
||||
currentTaskItem: null,
|
||||
} as unknown as Partial<ExtensionState>)
|
||||
}
|
||||
|
||||
const value: TaskContextType = {
|
||||
state,
|
||||
controller,
|
||||
isComplete,
|
||||
setIsComplete,
|
||||
lastError,
|
||||
setLastError,
|
||||
clearState,
|
||||
}
|
||||
|
||||
return <TaskContext.Provider value={value}>{children}</TaskContext.Provider>
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access task context
|
||||
*/
|
||||
export const useTaskContext = (): TaskContextType => {
|
||||
const context = useContext(TaskContext)
|
||||
if (!context) {
|
||||
throw new Error("useTaskContext must be used within TaskContextProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access task state only
|
||||
*/
|
||||
export const useTaskState = (): Partial<ExtensionState> => {
|
||||
const { state } = useTaskContext()
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access controller
|
||||
*/
|
||||
export const useTaskController = () => {
|
||||
const { controller } = useTaskContext()
|
||||
return controller
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* CLI-specific CommentReviewController implementation
|
||||
* Handles code review comments in CLI mode
|
||||
*/
|
||||
|
||||
import { CommentReviewController, type OnReplyCallback, type ReviewComment } from "@/integrations/editor/CommentReviewController"
|
||||
import { print, style } from "../utils/display"
|
||||
|
||||
export class CliCommentReviewController extends CommentReviewController {
|
||||
private comments: Map<string, string[]> = new Map()
|
||||
private streamingComment: { filePath: string; startLine: number; endLine: number; content: string } | null = null
|
||||
|
||||
setOnReplyCallback(_callback: OnReplyCallback): void {
|
||||
// No-op - CLI doesn't support interactive replies
|
||||
}
|
||||
|
||||
async ensureCommentsViewDisabled(): Promise<void> {
|
||||
// No-op - no comments view in CLI
|
||||
}
|
||||
|
||||
addReviewComment(comment: ReviewComment): void {
|
||||
const key = `${comment.filePath}:${comment.startLine}:${comment.endLine}`
|
||||
const existing = this.comments.get(key) || []
|
||||
existing.push(comment.comment)
|
||||
this.comments.set(key, existing)
|
||||
|
||||
print(style.info(`Comment on ${comment.filePath}:${comment.startLine + 1}`))
|
||||
print(style.dim(` ${comment.comment}`))
|
||||
}
|
||||
|
||||
startStreamingComment(
|
||||
filePath: string,
|
||||
startLine: number,
|
||||
endLine: number,
|
||||
_relativePath?: string,
|
||||
_fileContent?: string,
|
||||
_revealComment?: boolean,
|
||||
): void {
|
||||
this.streamingComment = { filePath, startLine, endLine, content: "" }
|
||||
print(style.info(`Comment on ${filePath}:${startLine + 1}`))
|
||||
}
|
||||
|
||||
appendToStreamingComment(chunk: string): void {
|
||||
if (this.streamingComment) {
|
||||
this.streamingComment.content += chunk
|
||||
process.stdout.write(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
endStreamingComment(): void {
|
||||
if (this.streamingComment) {
|
||||
const key = `${this.streamingComment.filePath}:${this.streamingComment.startLine}:${this.streamingComment.endLine}`
|
||||
const existing = this.comments.get(key) || []
|
||||
existing.push(this.streamingComment.content)
|
||||
this.comments.set(key, existing)
|
||||
print("") // newline after streaming
|
||||
this.streamingComment = null
|
||||
}
|
||||
}
|
||||
|
||||
addReviewComments(comments: ReviewComment[]): void {
|
||||
for (const comment of comments) {
|
||||
this.addReviewComment(comment)
|
||||
}
|
||||
}
|
||||
|
||||
clearAllComments(): void {
|
||||
this.comments.clear()
|
||||
}
|
||||
|
||||
clearCommentsForFile(filePath: string): void {
|
||||
for (const key of this.comments.keys()) {
|
||||
if (key.startsWith(filePath)) {
|
||||
this.comments.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getThreadCount(): number {
|
||||
return this.comments.size
|
||||
}
|
||||
|
||||
async closeDiffViews(): Promise<void> {
|
||||
// No-op - no diff views in CLI
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.comments.clear()
|
||||
this.streamingComment = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* CLI-specific WebviewProvider implementation
|
||||
* Instead of rendering to a webview, this outputs to the terminal
|
||||
*/
|
||||
|
||||
import type * as vscode from "vscode"
|
||||
import { WebviewProvider } from "@/core/webview"
|
||||
|
||||
export class CliWebviewProvider extends WebviewProvider {
|
||||
constructor(context: vscode.ExtensionContext) {
|
||||
super(context)
|
||||
}
|
||||
|
||||
override getWebviewUrl(path: string): string {
|
||||
// CLI doesn't have webview URLs
|
||||
return `file://${path}`
|
||||
}
|
||||
|
||||
override getCspSource(): string {
|
||||
return "'self'"
|
||||
}
|
||||
|
||||
override isVisible(): boolean {
|
||||
// CLI is always "visible"
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* CLI-specific Host Bridge implementations
|
||||
* These provide stub implementations for the host bridge interfaces that work in CLI mode
|
||||
*/
|
||||
|
||||
import type {
|
||||
DiffServiceClientInterface,
|
||||
EnvServiceClientInterface,
|
||||
WindowServiceClientInterface,
|
||||
WorkspaceServiceClientInterface,
|
||||
} from "@generated/hosts/host-bridge-client-types"
|
||||
import type { HostBridgeClientProvider, StreamingCallbacks } from "@hosts/host-provider-types"
|
||||
import * as proto from "@shared/proto/index"
|
||||
import { ClineClient } from "@/shared/cline"
|
||||
import { version as CLI_VERSION } from "../../package.json"
|
||||
import { printError, printInfo, printWarning } from "../utils/display"
|
||||
|
||||
/**
|
||||
* CLI implementation of DiffService - handles diff operations for terminal
|
||||
*
|
||||
* In CLI mode, actual file editing is handled by FileEditProvider (which extends DiffViewProvider).
|
||||
* This service client handles the host bridge interface for UI-related diff operations.
|
||||
* Most operations are no-ops since the CLI doesn't have a visual diff editor.
|
||||
*/
|
||||
export class CliDiffServiceClient implements DiffServiceClientInterface {
|
||||
async openDiff(_request: proto.host.OpenDiffRequest): Promise<proto.host.OpenDiffResponse> {
|
||||
// In CLI mode, diff operations are handled by FileEditProvider directly.
|
||||
// This is a no-op since we don't have a visual diff editor.
|
||||
return proto.host.OpenDiffResponse.create({})
|
||||
}
|
||||
|
||||
async getDocumentText(_request: proto.host.GetDocumentTextRequest): Promise<proto.host.GetDocumentTextResponse> {
|
||||
// In CLI mode, document text is managed by FileEditProvider directly.
|
||||
// Return empty content since we don't track document state here.
|
||||
return proto.host.GetDocumentTextResponse.create({ content: "" })
|
||||
}
|
||||
|
||||
async replaceText(_request: proto.host.ReplaceTextRequest): Promise<proto.host.ReplaceTextResponse> {
|
||||
// No-op in CLI - actual file editing is handled by FileEditProvider
|
||||
return proto.host.ReplaceTextResponse.create({})
|
||||
}
|
||||
|
||||
async scrollDiff(_request: proto.host.ScrollDiffRequest): Promise<proto.host.ScrollDiffResponse> {
|
||||
// No-op in CLI - no visual editor to scroll
|
||||
return proto.host.ScrollDiffResponse.create({})
|
||||
}
|
||||
|
||||
async truncateDocument(_request: proto.host.TruncateDocumentRequest): Promise<proto.host.TruncateDocumentResponse> {
|
||||
// No-op in CLI - actual file editing is handled by FileEditProvider
|
||||
return proto.host.TruncateDocumentResponse.create({})
|
||||
}
|
||||
|
||||
async saveDocument(_request: proto.host.SaveDocumentRequest): Promise<proto.host.SaveDocumentResponse> {
|
||||
// No-op in CLI - actual file saving is handled by FileEditProvider
|
||||
return proto.host.SaveDocumentResponse.create({})
|
||||
}
|
||||
|
||||
async closeAllDiffs(_request: proto.host.CloseAllDiffsRequest): Promise<proto.host.CloseAllDiffsResponse> {
|
||||
// No-op in CLI - no visual diff views to close
|
||||
return proto.host.CloseAllDiffsResponse.create({})
|
||||
}
|
||||
|
||||
async openMultiFileDiff(request: proto.host.OpenMultiFileDiffRequest): Promise<proto.host.OpenMultiFileDiffResponse> {
|
||||
// In CLI mode, we display a summary of the multi-file diff
|
||||
const title = request.title || "Multi-file diff"
|
||||
const diffs = request.diffs || []
|
||||
if (diffs.length > 0) {
|
||||
printInfo(`📝 ${title}: ${diffs.length} file(s) changed`)
|
||||
for (const diff of diffs) {
|
||||
printInfo(` - ${diff.filePath}`)
|
||||
}
|
||||
}
|
||||
return proto.host.OpenMultiFileDiffResponse.create({})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI implementation of EnvService - handles environment operations
|
||||
*/
|
||||
export class CliEnvServiceClient implements EnvServiceClientInterface {
|
||||
private clipboardContent: string = ""
|
||||
|
||||
private telemetrySetting = proto.host.Setting.ENABLED
|
||||
|
||||
async clipboardWriteText(request: proto.cline.StringRequest): Promise<proto.cline.Empty> {
|
||||
this.clipboardContent = request.value || ""
|
||||
printInfo(`📋 Copied to clipboard`)
|
||||
return proto.cline.Empty.create()
|
||||
}
|
||||
|
||||
async clipboardReadText(_request: proto.cline.EmptyRequest): Promise<proto.cline.String> {
|
||||
return proto.cline.String.create({ value: this.clipboardContent })
|
||||
}
|
||||
|
||||
async getHostVersion(_request: proto.cline.EmptyRequest): Promise<proto.host.GetHostVersionResponse> {
|
||||
return proto.host.GetHostVersionResponse.create({
|
||||
version: CLI_VERSION,
|
||||
platform: "Cline CLI - Node.js",
|
||||
clineType: ClineClient.Cli,
|
||||
})
|
||||
}
|
||||
|
||||
async getIdeRedirectUri(_request: proto.cline.EmptyRequest): Promise<proto.cline.String> {
|
||||
// CLI doesn't have IDE redirect
|
||||
return proto.cline.String.create({ value: "" })
|
||||
}
|
||||
|
||||
async getTelemetrySettings(_request: proto.cline.EmptyRequest): Promise<proto.host.GetTelemetrySettingsResponse> {
|
||||
return proto.host.GetTelemetrySettingsResponse.create({
|
||||
isEnabled: this.telemetrySetting,
|
||||
})
|
||||
}
|
||||
|
||||
subscribeToTelemetrySettings(
|
||||
_request: proto.cline.EmptyRequest,
|
||||
callbacks: StreamingCallbacks<proto.host.TelemetrySettingsEvent>,
|
||||
): () => void {
|
||||
// Send initial settings
|
||||
callbacks.onResponse(
|
||||
proto.host.TelemetrySettingsEvent.create({
|
||||
isEnabled: this.telemetrySetting,
|
||||
}),
|
||||
)
|
||||
// Return unsubscribe function
|
||||
return () => {}
|
||||
}
|
||||
|
||||
debugLog(request: proto.cline.StringRequest): Promise<proto.cline.Empty> {
|
||||
const message = request.value || ""
|
||||
if (process.env.IS_DEV) {
|
||||
printInfo(`[DebugLog] ${message}`)
|
||||
}
|
||||
return Promise.resolve(proto.cline.Empty.create())
|
||||
}
|
||||
|
||||
async shutdown(_request: proto.cline.EmptyRequest): Promise<proto.cline.Empty> {
|
||||
printInfo("Shutting down...")
|
||||
return proto.cline.Empty.create()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI implementation of WindowService - handles window/UI operations
|
||||
*/
|
||||
export class CliWindowServiceClient implements WindowServiceClientInterface {
|
||||
async showTextDocument(request: proto.host.ShowTextDocumentRequest): Promise<proto.host.TextEditorInfo> {
|
||||
printInfo(`📄 Opening file: ${request.path}`)
|
||||
return proto.host.TextEditorInfo.create({
|
||||
documentPath: request.path,
|
||||
})
|
||||
}
|
||||
|
||||
async showOpenDialogue(_request: proto.host.ShowOpenDialogueRequest): Promise<proto.host.SelectedResources> {
|
||||
printWarning("Open dialog not available in CLI mode")
|
||||
return proto.host.SelectedResources.create({ paths: [] })
|
||||
}
|
||||
|
||||
async showMessage(request: proto.host.ShowMessageRequest): Promise<proto.host.SelectedResponse> {
|
||||
const message = request.message || ""
|
||||
const type = request.type
|
||||
|
||||
switch (type) {
|
||||
case proto.host.ShowMessageType.ERROR:
|
||||
printError(message)
|
||||
break
|
||||
case proto.host.ShowMessageType.WARNING:
|
||||
printWarning(message)
|
||||
break
|
||||
case proto.host.ShowMessageType.INFORMATION:
|
||||
default:
|
||||
printInfo(message)
|
||||
break
|
||||
}
|
||||
|
||||
return proto.host.SelectedResponse.create({})
|
||||
}
|
||||
|
||||
async showInputBox(_request: proto.host.ShowInputBoxRequest): Promise<proto.host.ShowInputBoxResponse> {
|
||||
// In CLI mode, we could use readline, but for now return empty
|
||||
printWarning("Input box not available in CLI mode")
|
||||
return proto.host.ShowInputBoxResponse.create({ response: "" })
|
||||
}
|
||||
|
||||
async showSaveDialog(_request: proto.host.ShowSaveDialogRequest): Promise<proto.host.ShowSaveDialogResponse> {
|
||||
printWarning("Save dialog not available in CLI mode")
|
||||
return proto.host.ShowSaveDialogResponse.create({ selectedPath: "" })
|
||||
}
|
||||
|
||||
async openFile(request: proto.host.OpenFileRequest): Promise<proto.host.OpenFileResponse> {
|
||||
printInfo(`📂 Opening: ${request.filePath}`)
|
||||
return proto.host.OpenFileResponse.create({})
|
||||
}
|
||||
|
||||
async openSettings(_request: proto.host.OpenSettingsRequest): Promise<proto.host.OpenSettingsResponse> {
|
||||
printInfo("Settings can be configured in ~/.cline/data/globalState.json")
|
||||
return proto.host.OpenSettingsResponse.create({})
|
||||
}
|
||||
|
||||
async getOpenTabs(_request: proto.host.GetOpenTabsRequest): Promise<proto.host.GetOpenTabsResponse> {
|
||||
// CLI doesn't have tabs
|
||||
return proto.host.GetOpenTabsResponse.create({ paths: [] })
|
||||
}
|
||||
|
||||
async getVisibleTabs(_request: proto.host.GetVisibleTabsRequest): Promise<proto.host.GetVisibleTabsResponse> {
|
||||
return proto.host.GetVisibleTabsResponse.create({ paths: [] })
|
||||
}
|
||||
|
||||
async getActiveEditor(_request: proto.host.GetActiveEditorRequest): Promise<proto.host.GetActiveEditorResponse> {
|
||||
return proto.host.GetActiveEditorResponse.create({})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI implementation of WorkspaceService - handles workspace operations
|
||||
*/
|
||||
export class CliWorkspaceServiceClient implements WorkspaceServiceClientInterface {
|
||||
private workspacePath: string
|
||||
|
||||
constructor(workspacePath: string = process.cwd()) {
|
||||
this.workspacePath = workspacePath
|
||||
}
|
||||
|
||||
setWorkspacePath(path: string) {
|
||||
this.workspacePath = path
|
||||
}
|
||||
|
||||
async getWorkspacePaths(_request: proto.host.GetWorkspacePathsRequest): Promise<proto.host.GetWorkspacePathsResponse> {
|
||||
return proto.host.GetWorkspacePathsResponse.create({
|
||||
paths: [this.workspacePath],
|
||||
})
|
||||
}
|
||||
|
||||
async saveOpenDocumentIfDirty(
|
||||
_request: proto.host.SaveOpenDocumentIfDirtyRequest,
|
||||
): Promise<proto.host.SaveOpenDocumentIfDirtyResponse> {
|
||||
return proto.host.SaveOpenDocumentIfDirtyResponse.create({})
|
||||
}
|
||||
|
||||
async getDiagnostics(_request: proto.host.GetDiagnosticsRequest): Promise<proto.host.GetDiagnosticsResponse> {
|
||||
// In CLI mode, we could run linters here
|
||||
return proto.host.GetDiagnosticsResponse.create({ fileDiagnostics: [] })
|
||||
}
|
||||
|
||||
async openProblemsPanel(_request: proto.host.OpenProblemsPanelRequest): Promise<proto.host.OpenProblemsPanelResponse> {
|
||||
printInfo("Run linters to see problems")
|
||||
return proto.host.OpenProblemsPanelResponse.create({})
|
||||
}
|
||||
|
||||
async openInFileExplorerPanel(
|
||||
request: proto.host.OpenInFileExplorerPanelRequest,
|
||||
): Promise<proto.host.OpenInFileExplorerPanelResponse> {
|
||||
printInfo(`📁 ${request.path}`)
|
||||
return proto.host.OpenInFileExplorerPanelResponse.create({})
|
||||
}
|
||||
|
||||
async openClineSidebarPanel(
|
||||
_request: proto.host.OpenClineSidebarPanelRequest,
|
||||
): Promise<proto.host.OpenClineSidebarPanelResponse> {
|
||||
// No sidebar in CLI
|
||||
return proto.host.OpenClineSidebarPanelResponse.create({})
|
||||
}
|
||||
|
||||
async openTerminalPanel(_request: proto.host.OpenTerminalRequest): Promise<proto.host.OpenTerminalResponse> {
|
||||
printInfo("Terminal is already available in CLI mode")
|
||||
return proto.host.OpenTerminalResponse.create({})
|
||||
}
|
||||
|
||||
async executeCommandInTerminal(
|
||||
request: proto.host.ExecuteCommandInTerminalRequest,
|
||||
): Promise<proto.host.ExecuteCommandInTerminalResponse> {
|
||||
printInfo(`⚙️ Executing: ${request.command}`)
|
||||
return proto.host.ExecuteCommandInTerminalResponse.create({})
|
||||
}
|
||||
|
||||
async openFolder(request: proto.host.OpenFolderRequest): Promise<proto.host.OpenFolderResponse> {
|
||||
const path = request.path || ""
|
||||
this.workspacePath = path
|
||||
printInfo(`📂 Opening folder: ${path}`)
|
||||
return proto.host.OpenFolderResponse.create({ success: true })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a CLI host bridge provider
|
||||
*/
|
||||
export function createCliHostBridgeProvider(workspacePath?: string): HostBridgeClientProvider {
|
||||
return {
|
||||
workspaceClient: new CliWorkspaceServiceClient(workspacePath),
|
||||
envClient: new CliEnvServiceClient(),
|
||||
windowClient: new CliWindowServiceClient(),
|
||||
diffClient: new CliDiffServiceClient(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Shared hook for scrollable list windowing in terminal UIs
|
||||
* Used by AuthView (provider list) and ModelPicker (model list)
|
||||
*/
|
||||
|
||||
import { useMemo } from "react"
|
||||
|
||||
interface ScrollableListResult {
|
||||
visibleStart: number
|
||||
visibleCount: number
|
||||
showTopIndicator: boolean
|
||||
showBottomIndicator: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate visible window for a scrollable list
|
||||
* Keeps the selected item in view while showing scroll indicators
|
||||
*
|
||||
* @param itemCount - Total number of items in the list
|
||||
* @param selectedIndex - Currently selected item index
|
||||
* @param maxRows - Maximum rows to display (indicators take up row space when shown)
|
||||
*/
|
||||
export function useScrollableList(itemCount: number, selectedIndex: number, maxRows: number): ScrollableListResult {
|
||||
return useMemo(() => {
|
||||
if (itemCount <= maxRows) {
|
||||
return {
|
||||
visibleStart: 0,
|
||||
visibleCount: itemCount,
|
||||
showTopIndicator: false,
|
||||
showBottomIndicator: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if we need indicators based on index position
|
||||
const needsTopIndicator = selectedIndex > 0
|
||||
const needsBottomIndicator = selectedIndex < itemCount - 1
|
||||
|
||||
// Calculate how many items we can show (subtract space for indicators)
|
||||
let itemSlots = maxRows
|
||||
if (needsTopIndicator && selectedIndex >= maxRows - 1) itemSlots--
|
||||
if (needsBottomIndicator && selectedIndex <= itemCount - maxRows) itemSlots--
|
||||
|
||||
// Calculate start position keeping selected item in view
|
||||
const maxStart = itemCount - itemSlots
|
||||
const idealStart = selectedIndex - Math.floor(itemSlots / 2)
|
||||
const start = Math.max(0, Math.min(idealStart, maxStart))
|
||||
|
||||
const showTop = start > 0
|
||||
const showBottom = start + itemSlots < itemCount
|
||||
|
||||
// Recalculate item slots based on actual indicators shown
|
||||
let finalItemSlots = maxRows
|
||||
if (showTop) finalItemSlots--
|
||||
if (showBottom) finalItemSlots--
|
||||
|
||||
const finalStart = Math.max(0, Math.min(selectedIndex - Math.floor(finalItemSlots / 2), itemCount - finalItemSlots))
|
||||
|
||||
return {
|
||||
visibleStart: finalStart,
|
||||
visibleCount: finalItemSlots,
|
||||
showTopIndicator: finalStart > 0,
|
||||
showBottomIndicator: finalStart + finalItemSlots < itemCount,
|
||||
}
|
||||
}, [itemCount, selectedIndex, maxRows])
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Custom hook to subscribe to controller state updates
|
||||
* Handles the diff/merge logic for streaming text and message tracking
|
||||
*/
|
||||
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { useCallback, useRef } from "react"
|
||||
import { useTaskContext } from "../context/TaskContext"
|
||||
|
||||
interface ProcessedState {
|
||||
processedAskMessages: Set<number>
|
||||
processedSayMessages: Set<number>
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to track which ask/say messages have been processed
|
||||
* This prevents duplicate prompts for the same ask message
|
||||
*/
|
||||
export const useProcessedMessages = () => {
|
||||
const processedRef = useRef<ProcessedState>({
|
||||
processedAskMessages: new Set(),
|
||||
processedSayMessages: new Set(),
|
||||
})
|
||||
|
||||
return processedRef.current
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if a message has just been completed (is asking for user input)
|
||||
*/
|
||||
export const useCompletedAskMessages = () => {
|
||||
const { state } = useTaskContext()
|
||||
const processed = useProcessedMessages()
|
||||
|
||||
const getCompletedAskMessages = useCallback(() => {
|
||||
const completedAsks: ClineMessage[] = []
|
||||
|
||||
if (!state.clineMessages) {
|
||||
return completedAsks
|
||||
}
|
||||
|
||||
for (let i = 0; i < state.clineMessages.length; i++) {
|
||||
const message = state.clineMessages[i]
|
||||
if (message.type === "ask" && !message.partial && !processed.processedAskMessages.has(i)) {
|
||||
completedAsks.push(message)
|
||||
processed.processedAskMessages.add(i)
|
||||
}
|
||||
}
|
||||
|
||||
return completedAsks
|
||||
}, [state.clineMessages, processed])
|
||||
|
||||
return getCompletedAskMessages
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last completed ask message (for rendering current input prompt)
|
||||
*/
|
||||
export const useLastCompletedAskMessage = () => {
|
||||
const { state } = useTaskContext()
|
||||
const processed = useProcessedMessages()
|
||||
|
||||
const getLastCompletedAskMessage = useCallback((): ClineMessage | null => {
|
||||
if (!state.clineMessages) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Find the last ask message that is complete
|
||||
for (let i = state.clineMessages.length - 1; i >= 0; i--) {
|
||||
const message = state.clineMessages[i]
|
||||
if (message.type === "ask" && !message.partial) {
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}, [state.clineMessages])
|
||||
|
||||
return getLastCompletedAskMessage()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get messages that should trigger the completion detection
|
||||
*/
|
||||
export const useCompletionSignals = () => {
|
||||
const { state } = useTaskContext()
|
||||
|
||||
const isTaskComplete = useCallback((): boolean => {
|
||||
if (!state.clineMessages || state.clineMessages.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const lastMessage = state.clineMessages[state.clineMessages.length - 1]
|
||||
if (!lastMessage) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for completion signals
|
||||
if (lastMessage.say === "completion_result" || lastMessage.ask === "completion_result") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for error signals
|
||||
if (lastMessage.say === "error" || lastMessage.ask === "api_req_failed") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}, [state.clineMessages])
|
||||
|
||||
const getCompletionMessage = useCallback((): ClineMessage | null => {
|
||||
if (!state.clineMessages || state.clineMessages.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return state.clineMessages[state.clineMessages.length - 1] || null
|
||||
}, [state.clineMessages])
|
||||
|
||||
return {
|
||||
isTaskComplete,
|
||||
getCompletionMessage,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if spinner should be shown (when API is thinking)
|
||||
* Returns an object with isActive flag and startTime timestamp
|
||||
*/
|
||||
export const useIsSpinnerActive = (): { isActive: boolean; startTime?: number } => {
|
||||
const { state } = useTaskContext()
|
||||
|
||||
if (!state.clineMessages || state.clineMessages.length === 0) {
|
||||
return { isActive: false }
|
||||
}
|
||||
|
||||
// If the last message is a completed ask message, don't show spinner (waiting for user input)
|
||||
const lastMessage = state.clineMessages[state.clineMessages.length - 1]
|
||||
if (lastMessage?.type === "ask" && !lastMessage.partial) {
|
||||
return { isActive: false }
|
||||
}
|
||||
|
||||
// Look for most recent api_req_started that isn't followed by api_req_finished
|
||||
for (let i = state.clineMessages.length - 1; i >= 0; i--) {
|
||||
const msg = state.clineMessages[i]
|
||||
if (msg.say === "api_req_started") {
|
||||
// Check if there's an api_req_finished after this
|
||||
let hasFinished = false
|
||||
for (let j = i + 1; j < state.clineMessages.length; j++) {
|
||||
if (state.clineMessages[j].say === "api_req_finished") {
|
||||
hasFinished = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!hasFinished) {
|
||||
return { isActive: true, startTime: msg.ts }
|
||||
}
|
||||
return { isActive: false }
|
||||
}
|
||||
}
|
||||
|
||||
return { isActive: false }
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useStdout } from "ink"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
|
||||
/**
|
||||
* Reactive terminal size hook with resize recovery.
|
||||
*
|
||||
* WHY THIS EXISTS:
|
||||
* Ink tracks how many lines it rendered last frame (`previousLineCount` in log-update.js,
|
||||
* `lastOutputHeight` in ink.js). On re-render it erases that many lines then writes new
|
||||
* output. When the terminal resizes, text wrapping changes so the actual number of lines
|
||||
* on screen no longer matches what Ink thinks it rendered. This causes cascading visual
|
||||
* artifacts: old content doesn't get fully erased, and new content renders on top of it.
|
||||
*
|
||||
* We tried several approaches that didn't work:
|
||||
* - Writing \x1b[2J\x1b[H before state update: Ink overwrites the clear with its own
|
||||
* stale-count erasure immediately after.
|
||||
* - Calling Ink's clear() via prependListener on resize: clear() itself uses the stale
|
||||
* previousLineCount to erase, so it erases the wrong number of lines too.
|
||||
* - Patching Ink's resized() to reset lastOutputHeight: The dynamic region renders
|
||||
* cleanly but Static content (already printed to scrollback) is gone and Ink won't
|
||||
* re-render it since it tracks which Static items have been rendered by key.
|
||||
*
|
||||
* WHAT WORKS (borrowed from Gemini CLI's approach):
|
||||
* 1. Debounce resize events (300ms) so we wait until the user stops dragging
|
||||
* 2. Clear the entire terminal including scrollback (\x1b[2J\x1b[3J\x1b[H)
|
||||
* 3. Increment a `resizeKey` used as a React key on the content tree, forcing React
|
||||
* to unmount and remount everything from scratch. This resets Ink's internal tracking
|
||||
* AND re-renders Static content since the components are brand new instances.
|
||||
*
|
||||
* Gemini CLI does the same thing in AppContainer.tsx: debounce 300ms, then
|
||||
* stdout.write(ansiEscapes.clearTerminal) + setHistoryRemountKey(prev => prev + 1).
|
||||
*
|
||||
* USAGE:
|
||||
* - `columns`/`rows`: Current terminal dimensions, updated live during resize
|
||||
* - `resizeKey`: Increments after resize settles. Use as a React `key` on the root
|
||||
* content wrapper to force full remount.
|
||||
*/
|
||||
export function useTerminalSize() {
|
||||
const { stdout } = useStdout()
|
||||
const [size, setSize] = useState({
|
||||
columns: process.stdout.columns || 80,
|
||||
rows: process.stdout.rows || 24,
|
||||
})
|
||||
const [resizeKey, setResizeKey] = useState(0)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const refreshAfterResize = useCallback(() => {
|
||||
// Clear terminal + scrollback to wipe stale content from old width
|
||||
// \x1b[2J clears visible screen, \x1b[3J clears scrollback, \x1b[H moves cursor home
|
||||
stdout?.write("\x1b[2J\x1b[3J\x1b[H")
|
||||
// Increment key to force React remount
|
||||
setResizeKey((prev) => prev + 1)
|
||||
}, [stdout])
|
||||
|
||||
useEffect(() => {
|
||||
function updateSize() {
|
||||
setSize({
|
||||
columns: process.stdout.columns || 80,
|
||||
rows: process.stdout.rows || 24,
|
||||
})
|
||||
|
||||
// Debounce: wait 300ms after last resize event to do full recovery
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current)
|
||||
}
|
||||
debounceRef.current = setTimeout(() => {
|
||||
refreshAfterResize()
|
||||
debounceRef.current = null
|
||||
}, 300)
|
||||
}
|
||||
process.stdout.on("resize", updateSize)
|
||||
return () => {
|
||||
process.stdout.off("resize", updateSize)
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current)
|
||||
}
|
||||
}
|
||||
}, [refreshAfterResize])
|
||||
|
||||
return { ...size, resizeKey }
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import { Command } from "commander"
|
||||
import { beforeEach, describe, expect, it } from "vitest"
|
||||
|
||||
/**
|
||||
* Tests for CLI command parsing and structure
|
||||
* These tests verify the commander.js command definitions without
|
||||
* actually running the commands (which would require full infrastructure)
|
||||
*/
|
||||
|
||||
describe("CLI Commands", () => {
|
||||
let program: Command
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a fresh program instance for each test
|
||||
program = new Command()
|
||||
program.name("cline").description("Cline CLI - AI coding assistant").version("0.0.0")
|
||||
program.enablePositionalOptions()
|
||||
|
||||
// Define commands matching index.ts
|
||||
program
|
||||
.command("task")
|
||||
.alias("t")
|
||||
.description("Run a new task")
|
||||
.argument("<prompt>", "The task prompt")
|
||||
.option("-a, --act", "Run in act mode")
|
||||
.option("-p, --plan", "Run in plan mode")
|
||||
.option("-y, --yolo", "Enable yolo mode")
|
||||
.option("-m, --model <model>", "Model to use")
|
||||
.option("-i, --images <paths...>", "Image file paths")
|
||||
.option("-v, --verbose", "Show verbose output")
|
||||
.option("-c, --cwd <path>", "Working directory")
|
||||
.option("--config <path>", "Configuration directory")
|
||||
.option("--thinking", "Enable extended thinking")
|
||||
.action(() => {})
|
||||
|
||||
program
|
||||
.command("history")
|
||||
.alias("h")
|
||||
.description("List task history")
|
||||
.option("-n, --limit <number>", "Number of tasks to show", "10")
|
||||
.option("-p, --page <number>", "Page number", "1")
|
||||
.option("--config <path>", "Configuration directory")
|
||||
.action(() => {})
|
||||
|
||||
program
|
||||
.command("config")
|
||||
.description("Show current configuration")
|
||||
.option("--config <path>", "Configuration directory")
|
||||
.action(() => {})
|
||||
|
||||
program
|
||||
.command("auth")
|
||||
.description("Authenticate a provider")
|
||||
.option("-p, --provider <id>", "Provider ID")
|
||||
.option("-k, --apikey <key>", "API key")
|
||||
.option("-m, --modelid <id>", "Model ID")
|
||||
.option("-b, --baseurl <url>", "Base URL")
|
||||
.option("-v, --verbose", "Verbose output")
|
||||
.option("-c, --cwd <path>", "Working directory")
|
||||
.option("--config <path>", "Configuration directory")
|
||||
.action(() => {})
|
||||
|
||||
// Default command for interactive mode
|
||||
program
|
||||
.argument("[prompt]", "Task prompt")
|
||||
.option("-i, --images <paths...>", "Image file paths")
|
||||
.option("-v, --verbose", "Verbose output")
|
||||
.option("-c, --cwd <path>", "Working directory")
|
||||
.option("--config <path>", "Configuration directory")
|
||||
.option("--thinking", "Enable extended thinking")
|
||||
.action(() => {})
|
||||
})
|
||||
|
||||
describe("task command", () => {
|
||||
it("should parse task command with prompt", () => {
|
||||
const args = ["node", "cli", "task", "write hello world"]
|
||||
program.parse(args)
|
||||
// Command should be parsed without error
|
||||
})
|
||||
|
||||
it("should parse task alias", () => {
|
||||
const args = ["node", "cli", "t", "write hello world"]
|
||||
program.parse(args)
|
||||
})
|
||||
|
||||
it("should parse --act flag", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const args = ["test prompt", "--act"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().act).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --plan flag", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const args = ["test prompt", "--plan"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().plan).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --yolo flag", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const args = ["test prompt", "--yolo"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().yolo).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --model option", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const args = ["test prompt", "--model", "claude-sonnet-4-20250514"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().model).toBe("claude-sonnet-4-20250514")
|
||||
})
|
||||
|
||||
it("should parse --images option with multiple paths", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const args = ["test prompt", "--images", "/path/to/img1.png", "/path/to/img2.jpg"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().images).toEqual(["/path/to/img1.png", "/path/to/img2.jpg"])
|
||||
})
|
||||
|
||||
it("should parse --verbose flag", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const args = ["test prompt", "--verbose"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().verbose).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --cwd option", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const args = ["test prompt", "--cwd", "/some/path"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().cwd).toBe("/some/path")
|
||||
})
|
||||
|
||||
it("should parse --config option", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const args = ["test prompt", "--config", "/custom/config"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().config).toBe("/custom/config")
|
||||
})
|
||||
|
||||
it("should parse --thinking flag", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const args = ["test prompt", "--thinking"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().thinking).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse short flags", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const args = ["test prompt", "-a", "-v", "-m", "gpt-4"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().act).toBe(true)
|
||||
expect(taskCmd.opts().verbose).toBe(true)
|
||||
expect(taskCmd.opts().model).toBe("gpt-4")
|
||||
})
|
||||
})
|
||||
|
||||
describe("history command", () => {
|
||||
it("should have default limit of 10", () => {
|
||||
const historyCmd = program.commands.find((c) => c.name() === "history")!
|
||||
historyCmd.parse([], { from: "user" })
|
||||
expect(historyCmd.opts().limit).toBe("10")
|
||||
})
|
||||
|
||||
it("should have default page of 1", () => {
|
||||
const historyCmd = program.commands.find((c) => c.name() === "history")!
|
||||
historyCmd.parse([], { from: "user" })
|
||||
expect(historyCmd.opts().page).toBe("1")
|
||||
})
|
||||
|
||||
it("should parse --limit option", () => {
|
||||
const historyCmd = program.commands.find((c) => c.name() === "history")!
|
||||
const args = ["--limit", "20"]
|
||||
historyCmd.parse(args, { from: "user" })
|
||||
expect(historyCmd.opts().limit).toBe("20")
|
||||
})
|
||||
|
||||
it("should parse --page option", () => {
|
||||
const historyCmd = program.commands.find((c) => c.name() === "history")!
|
||||
const args = ["--page", "3"]
|
||||
historyCmd.parse(args, { from: "user" })
|
||||
expect(historyCmd.opts().page).toBe("3")
|
||||
})
|
||||
|
||||
it("should parse history alias", () => {
|
||||
const args = ["node", "cli", "h"]
|
||||
program.parse(args)
|
||||
// Alias should work
|
||||
})
|
||||
|
||||
it("should parse short flags", () => {
|
||||
const historyCmd = program.commands.find((c) => c.name() === "history")!
|
||||
const args = ["-n", "5", "-p", "2"]
|
||||
historyCmd.parse(args, { from: "user" })
|
||||
expect(historyCmd.opts().limit).toBe("5")
|
||||
expect(historyCmd.opts().page).toBe("2")
|
||||
})
|
||||
})
|
||||
|
||||
describe("config command", () => {
|
||||
it("should parse config command", () => {
|
||||
const args = ["node", "cli", "config"]
|
||||
program.parse(args)
|
||||
})
|
||||
|
||||
it("should parse --config option", () => {
|
||||
const configCmd = program.commands.find((c) => c.name() === "config")!
|
||||
const args = ["--config", "/custom/path"]
|
||||
configCmd.parse(args, { from: "user" })
|
||||
expect(configCmd.opts().config).toBe("/custom/path")
|
||||
})
|
||||
})
|
||||
|
||||
describe("auth command", () => {
|
||||
it("should parse auth command", () => {
|
||||
const args = ["node", "cli", "auth"]
|
||||
program.parse(args)
|
||||
})
|
||||
|
||||
it("should parse --provider option", () => {
|
||||
const authCmd = program.commands.find((c) => c.name() === "auth")!
|
||||
const args = ["--provider", "openai"]
|
||||
authCmd.parse(args, { from: "user" })
|
||||
expect(authCmd.opts().provider).toBe("openai")
|
||||
})
|
||||
|
||||
it("should parse --apikey option", () => {
|
||||
const authCmd = program.commands.find((c) => c.name() === "auth")!
|
||||
const args = ["--apikey", "sk-test-key"]
|
||||
authCmd.parse(args, { from: "user" })
|
||||
expect(authCmd.opts().apikey).toBe("sk-test-key")
|
||||
})
|
||||
|
||||
it("should parse --modelid option", () => {
|
||||
const authCmd = program.commands.find((c) => c.name() === "auth")!
|
||||
const args = ["--modelid", "gpt-4"]
|
||||
authCmd.parse(args, { from: "user" })
|
||||
expect(authCmd.opts().modelid).toBe("gpt-4")
|
||||
})
|
||||
|
||||
it("should parse --baseurl option", () => {
|
||||
const authCmd = program.commands.find((c) => c.name() === "auth")!
|
||||
const args = ["--baseurl", "https://api.example.com"]
|
||||
authCmd.parse(args, { from: "user" })
|
||||
expect(authCmd.opts().baseurl).toBe("https://api.example.com")
|
||||
})
|
||||
|
||||
it("should parse short flags", () => {
|
||||
const authCmd = program.commands.find((c) => c.name() === "auth")!
|
||||
const args = ["-p", "anthropic", "-k", "key123", "-m", "claude-sonnet-4-20250514"]
|
||||
authCmd.parse(args, { from: "user" })
|
||||
expect(authCmd.opts().provider).toBe("anthropic")
|
||||
expect(authCmd.opts().apikey).toBe("key123")
|
||||
expect(authCmd.opts().modelid).toBe("claude-sonnet-4-20250514")
|
||||
})
|
||||
})
|
||||
|
||||
describe("default command (interactive mode)", () => {
|
||||
it("should parse optional prompt argument", () => {
|
||||
const args = ["node", "cli", "do something"]
|
||||
program.parse(args)
|
||||
})
|
||||
|
||||
it("should parse without prompt (interactive mode)", () => {
|
||||
const args = ["node", "cli"]
|
||||
program.parse(args)
|
||||
})
|
||||
|
||||
it("should parse --images option", () => {
|
||||
program.parse(["node", "cli", "--images", "img.png"])
|
||||
expect(program.opts().images).toEqual(["img.png"])
|
||||
})
|
||||
|
||||
it("should parse --verbose flag", () => {
|
||||
program.parse(["node", "cli", "--verbose"])
|
||||
expect(program.opts().verbose).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --thinking flag", () => {
|
||||
program.parse(["node", "cli", "--thinking"])
|
||||
expect(program.opts().thinking).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("command structure", () => {
|
||||
it("should have all expected commands", () => {
|
||||
const commandNames = program.commands.map((c) => c.name())
|
||||
expect(commandNames).toContain("task")
|
||||
expect(commandNames).toContain("history")
|
||||
expect(commandNames).toContain("config")
|
||||
expect(commandNames).toContain("auth")
|
||||
})
|
||||
|
||||
it("should have correct aliases", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const historyCmd = program.commands.find((c) => c.name() === "history")!
|
||||
expect(taskCmd.aliases()).toContain("t")
|
||||
expect(historyCmd.aliases()).toContain("h")
|
||||
})
|
||||
|
||||
it("should have descriptions for all commands", () => {
|
||||
for (const cmd of program.commands) {
|
||||
expect(cmd.description()).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("getProviderModelIdKey", () => {
|
||||
// Test the provider model ID key mapping logic
|
||||
const providerKeyMap: Record<string, string> = {
|
||||
openrouter: "OpenRouterModelId",
|
||||
cline: "OpenRouterModelId",
|
||||
openai: "OpenAiModelId",
|
||||
ollama: "OllamaModelId",
|
||||
lmstudio: "LmStudioModelId",
|
||||
litellm: "LiteLlmModelId",
|
||||
requesty: "RequestyModelId",
|
||||
together: "TogetherModelId",
|
||||
fireworks: "FireworksModelId",
|
||||
sapaicore: "SapAiCoreModelId",
|
||||
groq: "GroqModelId",
|
||||
baseten: "BasetenModelId",
|
||||
huggingface: "HuggingFaceModelId",
|
||||
}
|
||||
|
||||
function getProviderModelIdKey(provider: string, mode: "act" | "plan"): string | null {
|
||||
const prefix = mode === "act" ? "actMode" : "planMode"
|
||||
const keySuffix = providerKeyMap[provider]
|
||||
if (keySuffix) {
|
||||
return `${prefix}${keySuffix}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
it("should return correct key for openrouter in act mode", () => {
|
||||
expect(getProviderModelIdKey("openrouter", "act")).toBe("actModeOpenRouterModelId")
|
||||
})
|
||||
|
||||
it("should return correct key for openrouter in plan mode", () => {
|
||||
expect(getProviderModelIdKey("openrouter", "plan")).toBe("planModeOpenRouterModelId")
|
||||
})
|
||||
|
||||
it("should return same key for cline as openrouter", () => {
|
||||
expect(getProviderModelIdKey("cline", "act")).toBe("actModeOpenRouterModelId")
|
||||
})
|
||||
|
||||
it("should return correct key for openai", () => {
|
||||
expect(getProviderModelIdKey("openai", "act")).toBe("actModeOpenAiModelId")
|
||||
})
|
||||
|
||||
it("should return correct key for ollama", () => {
|
||||
expect(getProviderModelIdKey("ollama", "act")).toBe("actModeOllamaModelId")
|
||||
})
|
||||
|
||||
it("should return null for anthropic (uses generic key)", () => {
|
||||
expect(getProviderModelIdKey("anthropic", "act")).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for gemini (uses generic key)", () => {
|
||||
expect(getProviderModelIdKey("gemini", "act")).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for bedrock (uses generic key)", () => {
|
||||
expect(getProviderModelIdKey("bedrock", "act")).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for unknown providers", () => {
|
||||
expect(getProviderModelIdKey("unknown-provider", "act")).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,693 @@
|
||||
/**
|
||||
* Cline CLI - TypeScript implementation with React Ink
|
||||
*/
|
||||
|
||||
import path from "node:path"
|
||||
import { exit } from "node:process"
|
||||
import type { ApiProvider } from "@shared/api"
|
||||
import { Command } from "commander"
|
||||
import { render } from "ink"
|
||||
import React from "react"
|
||||
import { ClineEndpoint } from "@/config"
|
||||
import { Controller } from "@/core/controller"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { AuthHandler } from "@/hosts/external/AuthHandler"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { FileEditProvider } from "@/integrations/editor/FileEditProvider"
|
||||
import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
|
||||
import { StandaloneTerminalManager } from "@/integrations/terminal/standalone/StandaloneTerminalManager"
|
||||
import { BannerService } from "@/services/banner/BannerService"
|
||||
import { ErrorService } from "@/services/error/ErrorService"
|
||||
import { initializeDistinctId } from "@/services/logging/distinctId"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { Session } from "@/shared/services/Session"
|
||||
import { getProviderModelIdKey, ProviderToApiKeyMap } from "@/shared/storage"
|
||||
import { secretStorage } from "@/shared/storage/ClineSecretStorage"
|
||||
import { version as CLI_VERSION } from "../package.json"
|
||||
import { App } from "./components/App"
|
||||
import { checkRawModeSupport } from "./context/StdinContext"
|
||||
import { createCliHostBridgeProvider } from "./controllers"
|
||||
import { CliCommentReviewController } from "./controllers/CliCommentReviewController"
|
||||
import { CliWebviewProvider } from "./controllers/CliWebviewProvider"
|
||||
import { restoreConsole } from "./utils/console"
|
||||
import { calculateRobotTopRow, queryCursorPos } from "./utils/cursor-position"
|
||||
import { printInfo, printWarning } from "./utils/display"
|
||||
import { parseImagesFromInput, processImagePaths } from "./utils/parser"
|
||||
import { CLINE_CLI_DIR } from "./utils/path"
|
||||
import { readStdinIfPiped } from "./utils/piped"
|
||||
import { runPlainTextTask } from "./utils/plain-text-task"
|
||||
import { printSessionSummary } from "./utils/session-summary"
|
||||
import { checkForUpdates } from "./utils/update"
|
||||
import { initializeCliContext } from "./vscode-context"
|
||||
import { CLI_LOG_FILE, shutdownEvent, window } from "./vscode-shim"
|
||||
|
||||
// Track active context for graceful shutdown
|
||||
let activeContext: CliContext | null = null
|
||||
let isShuttingDown = false
|
||||
|
||||
function setupSignalHandlers() {
|
||||
const shutdown = async (signal: string) => {
|
||||
if (isShuttingDown) {
|
||||
// Force exit on second signal
|
||||
process.exit(1)
|
||||
}
|
||||
isShuttingDown = true
|
||||
|
||||
// Notify components to hide UI before shutdown
|
||||
shutdownEvent.fire()
|
||||
|
||||
// Clear several lines to remove the input field and footer from display
|
||||
// Move cursor up and clear lines (input box + footer rows)
|
||||
const linesToClear = 8 // Input box (3 lines with border) + footer (4-5 lines)
|
||||
process.stdout.write(`\x1b[${linesToClear}A\x1b[J`)
|
||||
|
||||
printWarning(`${signal} received, shutting down...`)
|
||||
|
||||
try {
|
||||
if (activeContext) {
|
||||
const task = activeContext.controller.task
|
||||
if (task) {
|
||||
task.abortTask()
|
||||
}
|
||||
await activeContext.controller.stateManager.flushPendingState()
|
||||
await activeContext.controller.dispose()
|
||||
}
|
||||
await ErrorService.get().dispose()
|
||||
} catch {
|
||||
// Best effort cleanup
|
||||
}
|
||||
|
||||
// Print session summary before exit
|
||||
printSessionSummary()
|
||||
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => shutdown("SIGINT"))
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"))
|
||||
|
||||
// Suppress known abort errors from unhandled rejections
|
||||
// These occur when task is cancelled and async operations throw "Cline instance aborted"
|
||||
process.on("unhandledRejection", (reason: unknown) => {
|
||||
const message = reason instanceof Error ? reason.message : String(reason)
|
||||
// Silently ignore abort-related errors - they're expected during task cancellation
|
||||
if (message.includes("aborted") || message.includes("abort")) {
|
||||
Logger.info("Suppressed unhandled rejection due to abort:", message)
|
||||
return
|
||||
}
|
||||
// For other unhandled rejections, log to file via Logger (if available)
|
||||
// This won't show in terminal but will be in log files for debugging
|
||||
Logger.error("Unhandled rejection:", reason)
|
||||
})
|
||||
}
|
||||
|
||||
setupSignalHandlers()
|
||||
|
||||
interface CliContext {
|
||||
extensionContext: any
|
||||
dataDir: string
|
||||
extensionDir: string
|
||||
workspacePath: string
|
||||
controller: Controller
|
||||
}
|
||||
|
||||
interface InitOptions {
|
||||
config?: string
|
||||
cwd?: string
|
||||
verbose?: boolean
|
||||
enableAuth?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all CLI infrastructure and return context needed for commands
|
||||
*/
|
||||
async function initializeCli(options: InitOptions): Promise<CliContext> {
|
||||
const workspacePath = options.cwd || process.cwd()
|
||||
const { extensionContext, DATA_DIR, EXTENSION_DIR } = initializeCliContext({
|
||||
clineDir: options.config,
|
||||
workspaceDir: workspacePath,
|
||||
})
|
||||
|
||||
await ClineEndpoint.initialize()
|
||||
await initializeDistinctId(extensionContext)
|
||||
|
||||
// Initialize/reset session tracking for this CLI run
|
||||
Session.reset()
|
||||
|
||||
if (options.enableAuth) {
|
||||
AuthHandler.getInstance().setEnabled(true)
|
||||
}
|
||||
|
||||
const outputChannel = window.createOutputChannel("Cline CLI")
|
||||
outputChannel.appendLine(
|
||||
`Cline CLI initialized. Data dir: ${DATA_DIR}, Extension dir: ${EXTENSION_DIR}, Log dir: ${CLINE_CLI_DIR.log}`,
|
||||
)
|
||||
const logToChannel = (message: string) => outputChannel.appendLine(message)
|
||||
|
||||
HostProvider.initialize(
|
||||
() => new CliWebviewProvider(extensionContext as any),
|
||||
() => new FileEditProvider(),
|
||||
() => new CliCommentReviewController(),
|
||||
() => new StandaloneTerminalManager(),
|
||||
createCliHostBridgeProvider(workspacePath),
|
||||
logToChannel,
|
||||
async () => (options.enableAuth ? AuthHandler.getInstance().getCallbackUrl() : ""),
|
||||
async (name: string) => path.join(process.cwd(), name),
|
||||
EXTENSION_DIR,
|
||||
DATA_DIR,
|
||||
)
|
||||
|
||||
await ErrorService.initialize()
|
||||
await StateManager.initialize(extensionContext as any)
|
||||
|
||||
// Initialize OpenAI Codex OAuth manager with extension context for secrets storage
|
||||
openAiCodexOAuthManager.initialize(extensionContext)
|
||||
|
||||
// Configure the shared Logging class to use HostProvider's output channel
|
||||
Logger.subscribe((msg: string) => HostProvider.get().logToChannel(msg))
|
||||
|
||||
const webview = HostProvider.get().createWebviewProvider() as CliWebviewProvider
|
||||
const controller = webview.controller
|
||||
|
||||
BannerService.initialize(webview.controller)
|
||||
|
||||
telemetryService.captureHostEvent("cline_cli", "initialized")
|
||||
|
||||
const ctx = { extensionContext, dataDir: DATA_DIR, extensionDir: EXTENSION_DIR, workspacePath, controller }
|
||||
activeContext = ctx
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an Ink app with proper cleanup handling
|
||||
*/
|
||||
async function runInkApp(element: React.ReactElement, cleanup: () => Promise<void>): Promise<void> {
|
||||
// Note: incrementalRendering is disabled because it causes UI glitches on terminal resize.
|
||||
// Ink's incremental rendering tries to erase N lines based on previous output height,
|
||||
// but when the terminal shrinks, this leaves artifacts. Gemini CLI only enables
|
||||
// incrementalRendering when alternateBuffer is also enabled (which we don't use).
|
||||
//
|
||||
// exitOnCtrlC: false - We handle Ctrl+C ourselves so we can clean up UI before exiting.
|
||||
// The app components listen for Ctrl+C via useInput and call their exit handlers.
|
||||
const { waitUntilExit, unmount } = render(element, { exitOnCtrlC: false })
|
||||
|
||||
try {
|
||||
await waitUntilExit()
|
||||
} finally {
|
||||
try {
|
||||
unmount()
|
||||
} catch {
|
||||
// Already unmounted
|
||||
}
|
||||
restoreConsole()
|
||||
await cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a task with the given prompt - uses welcome view for consistent behavior
|
||||
*/
|
||||
async function runTask(
|
||||
prompt: string,
|
||||
options: {
|
||||
act?: boolean
|
||||
plan?: boolean
|
||||
model?: string
|
||||
verbose?: boolean
|
||||
cwd?: string
|
||||
config?: string
|
||||
thinking?: boolean
|
||||
yolo?: boolean
|
||||
images?: string[]
|
||||
json?: boolean
|
||||
stdinWasPiped?: boolean
|
||||
},
|
||||
existingContext?: CliContext,
|
||||
) {
|
||||
const ctx = existingContext || (await initializeCli({ ...options, enableAuth: true }))
|
||||
|
||||
// Parse images from the prompt text (e.g., @/path/to/image.png)
|
||||
const { prompt: cleanPrompt, imagePaths: parsedImagePaths } = parseImagesFromInput(prompt)
|
||||
|
||||
// Combine parsed image paths with explicit --images option
|
||||
const allImagePaths = [...(options.images || []), ...parsedImagePaths]
|
||||
// Convert image file paths to base64 data URLs
|
||||
const imageDataUrls = await processImagePaths(allImagePaths)
|
||||
|
||||
// Use clean prompt (with image refs removed)
|
||||
const taskPrompt = cleanPrompt || prompt
|
||||
|
||||
// Task without prompt starts in interactive mode
|
||||
telemetryService.captureHostEvent("task_command", prompt ? "task" : "interactive")
|
||||
|
||||
if (options.plan) {
|
||||
StateManager.get().setGlobalState("mode", "plan")
|
||||
telemetryService.captureHostEvent("mode_flag", "plan")
|
||||
} else if (options.act) {
|
||||
StateManager.get().setGlobalState("mode", "act")
|
||||
telemetryService.captureHostEvent("mode_flag", "act")
|
||||
}
|
||||
|
||||
if (options.model) {
|
||||
const selectedMode = (StateManager.get().getGlobalSettingsKey("mode") || "act") as "act" | "plan"
|
||||
|
||||
// Get the current provider for the selected mode
|
||||
const providerKey = selectedMode === "act" ? "actModeApiProvider" : "planModeApiProvider"
|
||||
const currentProvider = StateManager.get().getGlobalSettingsKey(providerKey) as ApiProvider
|
||||
|
||||
// Update the generic model ID for the current mode
|
||||
const modelKey = selectedMode === "act" ? "actModeApiModelId" : "planModeApiModelId"
|
||||
StateManager.get().setGlobalState(modelKey, options.model)
|
||||
|
||||
// Also update the provider-specific model ID key if applicable
|
||||
const providerModelKey = getProviderModelIdKey(currentProvider, selectedMode)
|
||||
if (providerModelKey) {
|
||||
StateManager.get().setGlobalState(providerModelKey, options.model)
|
||||
}
|
||||
telemetryService.captureHostEvent("model_flag", options.model)
|
||||
}
|
||||
|
||||
// Set thinking budget based on --thinking flag
|
||||
const thinkingBudget = options.thinking ? 1024 : 0
|
||||
const currentMode = StateManager.get().getGlobalSettingsKey("mode") || "act"
|
||||
const thinkingKey = currentMode === "act" ? "actModeThinkingBudgetTokens" : "planModeThinkingBudgetTokens"
|
||||
StateManager.get().setGlobalState(thinkingKey, thinkingBudget)
|
||||
if (options.thinking) {
|
||||
telemetryService.captureHostEvent("thinking_flag", "true")
|
||||
}
|
||||
|
||||
// Set yolo mode based on --yolo flag
|
||||
if (options.yolo) {
|
||||
StateManager.get().setGlobalState("yoloModeToggled", true)
|
||||
telemetryService.captureHostEvent("yolo_flag", "true")
|
||||
}
|
||||
|
||||
await StateManager.get().flushPendingState()
|
||||
|
||||
// Detect if output is a TTY (interactive terminal) or redirected to a file/pipe
|
||||
const isTTY = process.stdout.isTTY === true
|
||||
|
||||
// Use plain text mode when output is redirected, stdin was piped, or JSON mode is enabled
|
||||
// Ink requires raw mode on stdin which isn't available when stdin is piped
|
||||
// Note: we use the stdinWasPiped flag passed from the caller because process.stdin.isTTY
|
||||
// may not be reliable after stdin has been consumed by readStdinIfPiped()
|
||||
if (!isTTY || options.stdinWasPiped || options.json) {
|
||||
// Check if auth is configured before attempting to run the task
|
||||
// In plain text mode we can't show the interactive auth flow
|
||||
const hasAuth = await isAuthConfigured()
|
||||
if (!hasAuth) {
|
||||
printWarning("Not authenticated. Please run 'cline auth' first to configure your API credentials.")
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
await ctx.controller.dispose()
|
||||
await ErrorService.get().dispose()
|
||||
exit(1)
|
||||
}
|
||||
|
||||
const reason = options.json ? "json" : options.stdinWasPiped ? "piped_stdin" : "redirected_output"
|
||||
telemetryService.captureHostEvent("plain_text_mode", reason)
|
||||
// Plain text mode: no Ink rendering, just clean text output
|
||||
const success = await runPlainTextTask({
|
||||
controller: ctx.controller,
|
||||
prompt: taskPrompt,
|
||||
imageDataUrls: imageDataUrls.length > 0 ? imageDataUrls : undefined,
|
||||
verbose: options.verbose,
|
||||
jsonOutput: options.json,
|
||||
})
|
||||
|
||||
// Cleanup
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
await ctx.controller.dispose()
|
||||
await ErrorService.get().dispose()
|
||||
exit(success ? 0 : 1)
|
||||
}
|
||||
|
||||
// Use welcome view for consistent rendering (same as interactive mode)
|
||||
// Query cursor position BEFORE Ink mounts to know where robot will render
|
||||
const cursorPos = await queryCursorPos(process.stdin, process.stdout)
|
||||
const terminalRows = process.stdout.rows ?? 24
|
||||
const robotTopRow = calculateRobotTopRow(cursorPos, terminalRows)
|
||||
|
||||
let taskError = false
|
||||
|
||||
// Render the welcome view with optional initial prompt/images
|
||||
// If prompt provided (cline task "prompt"), ChatView will auto-submit
|
||||
// If no prompt (cline interactive), user will type it in
|
||||
await runInkApp(
|
||||
React.createElement(App, {
|
||||
view: "welcome",
|
||||
verbose: options.verbose,
|
||||
controller: ctx.controller,
|
||||
isRawModeSupported: checkRawModeSupport(),
|
||||
robotTopRow,
|
||||
initialPrompt: taskPrompt || undefined,
|
||||
initialImages: imageDataUrls.length > 0 ? imageDataUrls : undefined,
|
||||
onError: () => {
|
||||
taskError = true
|
||||
},
|
||||
onWelcomeExit: () => {
|
||||
// User pressed Esc
|
||||
exit(0)
|
||||
},
|
||||
}),
|
||||
async () => {
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
await ctx.controller.dispose()
|
||||
await ErrorService.get().dispose()
|
||||
if (taskError) {
|
||||
printWarning("Task ended with errors.")
|
||||
exit(1)
|
||||
}
|
||||
exit(0)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* List task history
|
||||
*/
|
||||
async function listHistory(options: { config?: string; limit?: number; page?: number }) {
|
||||
const ctx = await initializeCli(options)
|
||||
|
||||
const taskHistory = StateManager.get().getGlobalStateKey("taskHistory") || []
|
||||
// Sort by timestamp (newest first) before pagination
|
||||
const sortedHistory = [...taskHistory].sort((a: any, b: any) => (b.ts || 0) - (a.ts || 0))
|
||||
const limit = typeof options.limit === "string" ? parseInt(options.limit, 10) : options.limit || 10
|
||||
const initialPage = typeof options.page === "string" ? parseInt(options.page, 10) : options.page || 1
|
||||
const totalCount = sortedHistory.length
|
||||
const totalPages = Math.ceil(totalCount / limit)
|
||||
|
||||
telemetryService.captureHostEvent("history_command", "executed")
|
||||
|
||||
if (sortedHistory.length === 0) {
|
||||
printInfo("No task history found.")
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
await ctx.controller.dispose()
|
||||
await ErrorService.get().dispose()
|
||||
exit(0)
|
||||
}
|
||||
|
||||
await runInkApp(
|
||||
React.createElement(App, {
|
||||
view: "history",
|
||||
historyItems: [],
|
||||
historyAllItems: sortedHistory,
|
||||
controller: ctx.controller,
|
||||
historyPagination: { page: initialPage, totalPages, totalCount, limit },
|
||||
isRawModeSupported: checkRawModeSupport(),
|
||||
}),
|
||||
async () => {
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
await ctx.controller.dispose()
|
||||
await ErrorService.get().dispose()
|
||||
exit(0)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show current configuration
|
||||
*/
|
||||
async function showConfig(options: { config?: string }) {
|
||||
const ctx = await initializeCli(options)
|
||||
const stateManager = StateManager.get()
|
||||
|
||||
// Dynamically import the wrapper to avoid circular dependencies
|
||||
const { ConfigViewWrapper } = await import("./components/ConfigViewWrapper")
|
||||
|
||||
// Check feature flags
|
||||
const skillsEnabled = stateManager.getGlobalSettingsKey("skillsEnabled") ?? false
|
||||
|
||||
telemetryService.captureHostEvent("config_command", "executed")
|
||||
|
||||
await runInkApp(
|
||||
React.createElement(ConfigViewWrapper, {
|
||||
controller: ctx.controller,
|
||||
dataDir: ctx.dataDir,
|
||||
globalState: stateManager.getAllGlobalStateEntries(),
|
||||
workspaceState: stateManager.getAllWorkspaceStateEntries(),
|
||||
hooksEnabled: true,
|
||||
skillsEnabled,
|
||||
isRawModeSupported: checkRawModeSupport(),
|
||||
}),
|
||||
async () => {
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
await ctx.controller.dispose()
|
||||
await ErrorService.get().dispose()
|
||||
exit(0)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run authentication flow
|
||||
*/
|
||||
async function runAuth(options: {
|
||||
provider?: string
|
||||
apikey?: string
|
||||
modelid?: string
|
||||
baseurl?: string
|
||||
verbose?: boolean
|
||||
cwd?: string
|
||||
config?: string
|
||||
}) {
|
||||
const ctx = await initializeCli({ ...options, enableAuth: true })
|
||||
|
||||
const hasQuickSetupFlags = options.provider || options.apikey || options.modelid || options.baseurl
|
||||
const quickSetup = hasQuickSetupFlags
|
||||
? { provider: options.provider, apikey: options.apikey, modelid: options.modelid, baseurl: options.baseurl }
|
||||
: undefined
|
||||
|
||||
telemetryService.captureHostEvent("auth_command", hasQuickSetupFlags ? "quick_setup" : "interactive")
|
||||
|
||||
let authError = false
|
||||
|
||||
await runInkApp(
|
||||
React.createElement(App, {
|
||||
view: "auth",
|
||||
controller: ctx.controller,
|
||||
isRawModeSupported: checkRawModeSupport(),
|
||||
onComplete: () => {
|
||||
telemetryService.captureHostEvent("auth", "completed")
|
||||
exit(0)
|
||||
},
|
||||
onError: () => {
|
||||
telemetryService.captureHostEvent("auth", "error")
|
||||
authError = true
|
||||
},
|
||||
authQuickSetup: quickSetup,
|
||||
}),
|
||||
async () => {
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
await ctx.controller.dispose()
|
||||
await ErrorService.get().dispose()
|
||||
exit(0)
|
||||
},
|
||||
)
|
||||
|
||||
if (authError) {
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Setup CLI commands
|
||||
const program = new Command()
|
||||
|
||||
program.name("cline").description("Cline CLI - AI coding assistant in your terminal").version(CLI_VERSION)
|
||||
|
||||
// Enable positional options to avoid conflicts between root and subcommand options with the same name
|
||||
program.enablePositionalOptions()
|
||||
|
||||
program
|
||||
.command("task")
|
||||
.alias("t")
|
||||
.description("Run a new task")
|
||||
.argument("<prompt>", "The task prompt")
|
||||
.option("-a, --act", "Run in act mode")
|
||||
.option("-p, --plan", "Run in plan mode")
|
||||
.option("-y, --yolo", "Enable yolo mode (auto-approve actions)")
|
||||
.option("-m, --model <model>", "Model to use for the task")
|
||||
.option("-v, --verbose", "Show verbose output")
|
||||
.option("-c, --cwd <path>", "Working directory for the task")
|
||||
.option("--config <path>", "Path to Cline configuration directory")
|
||||
.option("--thinking", "Enable extended thinking (1024 token budget)")
|
||||
.option("--json", "Output messages as JSON instead of styled text")
|
||||
.action((prompt, options) => runTask(prompt, options))
|
||||
|
||||
program
|
||||
.command("history")
|
||||
.alias("h")
|
||||
.description("List task history")
|
||||
.option("-n, --limit <number>", "Number of tasks to show", "10")
|
||||
.option("-p, --page <number>", "Page number (1-based)", "1")
|
||||
.option("--config <path>", "Path to Cline configuration directory")
|
||||
.action(listHistory)
|
||||
|
||||
program
|
||||
.command("config")
|
||||
.description("Show current configuration")
|
||||
.option("--config <path>", "Path to Cline configuration directory")
|
||||
.action(showConfig)
|
||||
|
||||
program
|
||||
.command("auth")
|
||||
.description("Authenticate a provider and configure what model is used")
|
||||
.option("-p, --provider <id>", "Provider ID for quick setup (e.g., openai-native, anthropic)")
|
||||
.option("-k, --apikey <key>", "API key for the provider")
|
||||
.option("-m, --modelid <id>", "Model ID to configure (e.g., gpt-4o, claude-sonnet-4-5-20250929)")
|
||||
.option("-b, --baseurl <url>", "Base URL (optional, only for openai provider)")
|
||||
.option("-v, --verbose", "Show verbose output")
|
||||
.option("-c, --cwd <path>", "Working directory for the task")
|
||||
.option("--config <path>", "Path to Cline configuration directory")
|
||||
.action(runAuth)
|
||||
|
||||
program
|
||||
.command("version")
|
||||
.description("Show Cline CLI version number")
|
||||
.action(() => printInfo(`Cline CLI version: ${CLI_VERSION}`))
|
||||
|
||||
program
|
||||
.command("update")
|
||||
.description("Check for updates and install if available")
|
||||
.option("-v, --verbose", "Show verbose output")
|
||||
.action(() => checkForUpdates(CLI_VERSION))
|
||||
|
||||
// Dev command with subcommands
|
||||
const devCommand = program.command("dev").description("Developer tools and utilities")
|
||||
|
||||
devCommand
|
||||
.command("log")
|
||||
.description("Open the log file")
|
||||
.action(async () => {
|
||||
const { openExternal } = await import("@/utils/env")
|
||||
await openExternal(CLI_LOG_FILE)
|
||||
})
|
||||
|
||||
/**
|
||||
* Check if the user has authentication configured.
|
||||
* Returns true if they have either:
|
||||
* - Cline provider with stored auth data
|
||||
* - OpenAI Codex provider with OAuth credentials
|
||||
* - BYO provider with an API key configured
|
||||
*/
|
||||
async function isAuthConfigured(): Promise<boolean> {
|
||||
const stateManager = StateManager.get()
|
||||
const mode = stateManager.getGlobalSettingsKey("mode") as string
|
||||
const providerKey = mode === "act" ? "actModeApiProvider" : "planModeApiProvider"
|
||||
const currentProvider = (stateManager.getGlobalSettingsKey(providerKey) as string) || "cline"
|
||||
|
||||
if (currentProvider === "cline") {
|
||||
// For Cline provider, check if we have stored auth data
|
||||
const authData = await secretStorage.get("cline:clineAccountId")
|
||||
return !!authData
|
||||
}
|
||||
|
||||
if (currentProvider === "openai-codex") {
|
||||
// For OpenAI Codex, check if OAuth credentials are stored
|
||||
const isAuthenticated = await openAiCodexOAuthManager.isAuthenticated()
|
||||
return isAuthenticated
|
||||
}
|
||||
|
||||
// For BYO providers, check if the API key is configured
|
||||
const keyField = ProviderToApiKeyMap[currentProvider as keyof typeof ProviderToApiKeyMap]
|
||||
if (!keyField) {
|
||||
return false
|
||||
}
|
||||
|
||||
const fields = Array.isArray(keyField) ? keyField : [keyField]
|
||||
for (const field of fields) {
|
||||
const value = await secretStorage.get(field)
|
||||
if (value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Show welcome prompt and wait for user input
|
||||
* If auth is not configured, show auth flow first
|
||||
*/
|
||||
async function showWelcome(options: { verbose?: boolean; cwd?: string; config?: string; thinking?: boolean }) {
|
||||
const ctx = await initializeCli({ ...options, enableAuth: true })
|
||||
|
||||
// Check if auth is configured
|
||||
const hasAuth = await isAuthConfigured()
|
||||
|
||||
// Query cursor position BEFORE Ink mounts
|
||||
const cursorPos = await queryCursorPos(process.stdin, process.stdout)
|
||||
const terminalRows = process.stdout.rows ?? 24
|
||||
const robotTopRow = calculateRobotTopRow(cursorPos, terminalRows)
|
||||
|
||||
let hadError = false
|
||||
|
||||
await runInkApp(
|
||||
React.createElement(App, {
|
||||
// Start with auth view if not configured, otherwise welcome
|
||||
view: hasAuth ? "welcome" : "auth",
|
||||
verbose: options.verbose,
|
||||
controller: ctx.controller,
|
||||
isRawModeSupported: checkRawModeSupport(),
|
||||
robotTopRow,
|
||||
onWelcomeExit: () => {
|
||||
exit(0)
|
||||
},
|
||||
onError: () => {
|
||||
hadError = true
|
||||
},
|
||||
}),
|
||||
async () => {
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
await ctx.controller.dispose()
|
||||
await ErrorService.get().dispose()
|
||||
exit(hadError ? 1 : 0)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Interactive mode (default when no command given)
|
||||
program
|
||||
.argument("[prompt]", "Task prompt (starts task immediately)")
|
||||
.option("-a, --act", "Run in act mode")
|
||||
.option("-p, --plan", "Run in plan mode")
|
||||
.option("-y, --yolo", "Enable yolo mode (auto-approve actions)")
|
||||
.option("-m, --model <model>", "Model to use for the task")
|
||||
.option("-v, --verbose", "Show verbose output")
|
||||
.option("-c, --cwd <path>", "Working directory")
|
||||
.option("--config <path>", "Configuration directory")
|
||||
.option("--thinking", "Enable extended thinking (1024 token budget)")
|
||||
.option("--json", "Output messages as JSON instead of styled text")
|
||||
.action(async (prompt, options) => {
|
||||
// Always check for piped stdin content
|
||||
const stdinInput = await readStdinIfPiped()
|
||||
|
||||
// Combine stdin content with prompt argument
|
||||
let effectivePrompt = prompt
|
||||
if (stdinInput) {
|
||||
if (effectivePrompt) {
|
||||
// Prepend stdin content to the prompt
|
||||
effectivePrompt = `${stdinInput}\n\n${effectivePrompt}`
|
||||
} else {
|
||||
effectivePrompt = stdinInput
|
||||
}
|
||||
|
||||
telemetryService.captureHostEvent("piped", "detached")
|
||||
|
||||
// Debug: show that we received piped input
|
||||
if (options.verbose) {
|
||||
process.stderr.write(`[debug] Received ${stdinInput.length} bytes from stdin\n`)
|
||||
}
|
||||
}
|
||||
|
||||
if (effectivePrompt) {
|
||||
// Pass stdinWasPiped flag so runTask knows to use plain text mode
|
||||
await runTask(effectivePrompt, { ...options, stdinWasPiped: !!stdinInput })
|
||||
} else {
|
||||
// Show welcome prompt if no prompt given
|
||||
await showWelcome(options)
|
||||
}
|
||||
})
|
||||
|
||||
// Parse and run
|
||||
program.parse()
|
||||
@@ -0,0 +1,2 @@
|
||||
// Stub for react-devtools-core - not needed in CLI
|
||||
module.exports = {}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Console management for CLI
|
||||
*
|
||||
* Captures original console methods BEFORE any core modules are imported,
|
||||
* so CLI output works even when console.log is suppressed.
|
||||
*/
|
||||
|
||||
// Capture original console methods immediately
|
||||
export const originalConsoleLog = console.log.bind(console)
|
||||
export const originalConsoleError = console.error.bind(console)
|
||||
export const originalConsoleWarn = console.warn.bind(console)
|
||||
export const originalConsoleInfo = console.info.bind(console)
|
||||
export const originalConsoleDebug = console.debug.bind(console)
|
||||
|
||||
// Check for verbose flag early (before commander parses)
|
||||
const isVerbose = process.argv.includes("-v") || process.argv.includes("--verbose")
|
||||
|
||||
// Suppress console output unless verbose mode
|
||||
if (!isVerbose) {
|
||||
console.log = () => {}
|
||||
console.warn = () => {}
|
||||
console.error = () => {}
|
||||
console.debug = () => {}
|
||||
console.info = () => {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore original console methods (for cleanup)
|
||||
*/
|
||||
export function restoreConsole() {
|
||||
console.log = originalConsoleLog
|
||||
console.error = originalConsoleError
|
||||
console.warn = originalConsoleWarn
|
||||
console.info = originalConsoleInfo
|
||||
console.debug = originalConsoleDebug
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Query terminal cursor position before Ink mounts
|
||||
* Must be called BEFORE render() to avoid escape sequence leaking into Ink's input handling
|
||||
*/
|
||||
|
||||
export async function queryCursorPos(
|
||||
stdin: NodeJS.ReadStream,
|
||||
stdout: NodeJS.WriteStream,
|
||||
{ timeoutMs = 75 } = {},
|
||||
): Promise<{ row: number; col: number } | null> {
|
||||
if (!stdin.isTTY || !stdout.isTTY || typeof (stdin as any).setRawMode !== "function") return null
|
||||
|
||||
const ttyIn = stdin as any as { isRaw?: boolean; setRawMode: (b: boolean) => void; on: any; off: any }
|
||||
const wasRaw = !!ttyIn.isRaw
|
||||
|
||||
ttyIn.setRawMode(true)
|
||||
stdin.resume()
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
let buf = ""
|
||||
const onData = (chunk: Buffer) => {
|
||||
buf += chunk.toString("utf8")
|
||||
const m = buf.match(/\x1b\[(\d+);(\d+)R/)
|
||||
if (!m) return
|
||||
cleanup()
|
||||
resolve({ row: Number(m[1]), col: Number(m[2]) })
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer)
|
||||
stdin.off("data", onData)
|
||||
try {
|
||||
ttyIn.setRawMode(wasRaw)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
cleanup()
|
||||
resolve(null)
|
||||
}, timeoutMs)
|
||||
|
||||
stdin.on("data", onData)
|
||||
stdout.write("\x1b[6n") // DSR: cursor position
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate where the robot will be rendered on screen
|
||||
*/
|
||||
export function calculateRobotTopRow(cursorPos: { row: number } | null, terminalRows: number): number {
|
||||
const robotHeight = 12
|
||||
const firstFrameHeight = robotHeight + 8 // robot + welcome text + margins + input + footer
|
||||
|
||||
const startRow = cursorPos?.row ?? 1
|
||||
// If content doesn't fit below cursor, terminal scrolls
|
||||
return Math.max(1, Math.min(startRow, terminalRows - firstFrameHeight + 1))
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Cursor movement utilities for multi-line text input
|
||||
*/
|
||||
|
||||
/**
|
||||
* Move cursor up one line, preserving column position where possible
|
||||
*/
|
||||
export function moveCursorUp(text: string, cursorPos: number): number {
|
||||
const textBeforeCursor = text.slice(0, cursorPos)
|
||||
const lastNewline = textBeforeCursor.lastIndexOf("\n")
|
||||
|
||||
if (lastNewline === -1) {
|
||||
// Already on first line, move to start
|
||||
return 0
|
||||
}
|
||||
|
||||
const currentCol = cursorPos - lastNewline - 1
|
||||
const prevLineStart = textBeforeCursor.lastIndexOf("\n", lastNewline - 1) + 1
|
||||
const prevLineLength = lastNewline - prevLineStart
|
||||
const newCol = Math.min(currentCol, prevLineLength)
|
||||
|
||||
return prevLineStart + newCol
|
||||
}
|
||||
|
||||
/**
|
||||
* Move cursor down one line, preserving column position where possible
|
||||
*/
|
||||
export function moveCursorDown(text: string, cursorPos: number): number {
|
||||
const textBeforeCursor = text.slice(0, cursorPos)
|
||||
const lastNewline = textBeforeCursor.lastIndexOf("\n")
|
||||
const currentCol = lastNewline === -1 ? cursorPos : cursorPos - lastNewline - 1
|
||||
const nextNewline = text.indexOf("\n", cursorPos)
|
||||
|
||||
if (nextNewline === -1) {
|
||||
// Already on last line, move to end
|
||||
return text.length
|
||||
}
|
||||
|
||||
const nextLineStart = nextNewline + 1
|
||||
const nextLineEnd = text.indexOf("\n", nextLineStart)
|
||||
const nextLineLength = nextLineEnd === -1 ? text.length - nextLineStart : nextLineEnd - nextLineStart
|
||||
const newCol = Math.min(currentCol, nextLineLength)
|
||||
|
||||
return nextLineStart + newCol
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
import type { ClineMessage, ExtensionState } from "@shared/ExtensionMessage"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { colorize, formatMessage, formatState, formatTimestamp, Spinner, separator, style, taskHeader } from "./display"
|
||||
|
||||
describe("display", () => {
|
||||
describe("colorize", () => {
|
||||
it("should wrap text with color codes", () => {
|
||||
const result = colorize("test", "\x1b[31m")
|
||||
expect(result).toBe("\x1b[31mtest\x1b[0m")
|
||||
})
|
||||
|
||||
it("should combine multiple color codes", () => {
|
||||
const result = colorize("test", "\x1b[1m", "\x1b[31m")
|
||||
expect(result).toBe("\x1b[1m\x1b[31mtest\x1b[0m")
|
||||
})
|
||||
|
||||
it("should handle empty text", () => {
|
||||
const result = colorize("", "\x1b[31m")
|
||||
expect(result).toBe("\x1b[31m\x1b[0m")
|
||||
})
|
||||
})
|
||||
|
||||
describe("style helpers", () => {
|
||||
it("should apply bold style", () => {
|
||||
const result = style.bold("text")
|
||||
expect(result).toContain("text")
|
||||
expect(result).toContain("\x1b[1m")
|
||||
})
|
||||
|
||||
it("should apply dim style", () => {
|
||||
const result = style.dim("text")
|
||||
expect(result).toContain("text")
|
||||
expect(result).toContain("\x1b[2m")
|
||||
})
|
||||
|
||||
it("should apply error style", () => {
|
||||
const result = style.error("error message")
|
||||
expect(result).toContain("error message")
|
||||
expect(result).toContain("\x1b[31m") // red
|
||||
})
|
||||
|
||||
it("should apply success style", () => {
|
||||
const result = style.success("success")
|
||||
expect(result).toContain("success")
|
||||
expect(result).toContain("\x1b[32m") // green
|
||||
})
|
||||
|
||||
it("should apply info style", () => {
|
||||
const result = style.info("info")
|
||||
expect(result).toContain("info")
|
||||
expect(result).toContain("\x1b[36m") // cyan
|
||||
})
|
||||
|
||||
it("should apply warning style", () => {
|
||||
const result = style.warning("warning")
|
||||
expect(result).toContain("warning")
|
||||
expect(result).toContain("\x1b[33m") // yellow
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatTimestamp", () => {
|
||||
it("should format timestamp as HH:MM:SS", () => {
|
||||
// Create a known timestamp: Jan 1, 2024 15:30:45 UTC
|
||||
const ts = new Date("2024-01-01T15:30:45Z").getTime()
|
||||
const result = formatTimestamp(ts)
|
||||
// Result depends on local timezone, but should be HH:MM:SS format
|
||||
expect(result).toMatch(/^\d{2}:\d{2}:\d{2}$/)
|
||||
})
|
||||
|
||||
it("should handle zero timestamp", () => {
|
||||
const result = formatTimestamp(0)
|
||||
expect(result).toMatch(/^\d{2}:\d{2}:\d{2}$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatMessage", () => {
|
||||
const createMessage = (overrides: Partial<ClineMessage>): ClineMessage =>
|
||||
({
|
||||
ts: Date.now(),
|
||||
type: "say",
|
||||
say: "text",
|
||||
text: "test message",
|
||||
...overrides,
|
||||
}) as ClineMessage
|
||||
|
||||
describe("say messages", () => {
|
||||
it("should format text message", () => {
|
||||
const message = createMessage({ say: "text", text: "Hello world" })
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("Hello world")
|
||||
})
|
||||
|
||||
it("should format task message", () => {
|
||||
const message = createMessage({ say: "task", text: "New task" })
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("Task:")
|
||||
expect(result).toContain("New task")
|
||||
})
|
||||
|
||||
it("should format error message", () => {
|
||||
const message = createMessage({ say: "error", text: "Something went wrong" })
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("Error:")
|
||||
expect(result).toContain("Something went wrong")
|
||||
})
|
||||
|
||||
it("should format completion_result message", () => {
|
||||
const message = createMessage({ say: "completion_result", text: "Done!" })
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("Completed:")
|
||||
})
|
||||
|
||||
it("should format reasoning message", () => {
|
||||
const message = createMessage({ say: "reasoning", text: "Let me think..." })
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("Thinking:")
|
||||
expect(result).toContain("Let me think...")
|
||||
})
|
||||
|
||||
it("should format command message", () => {
|
||||
const message = createMessage({ say: "command", text: "npm install" })
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("Command:")
|
||||
expect(result).toContain("npm install")
|
||||
})
|
||||
|
||||
it("should truncate long command output", () => {
|
||||
const longOutput = "x".repeat(600)
|
||||
const message = createMessage({ say: "command_output", text: longOutput })
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("Output:")
|
||||
expect(result).toContain("...")
|
||||
expect(result.length).toBeLessThan(longOutput.length + 100)
|
||||
})
|
||||
|
||||
it("should format user_feedback message", () => {
|
||||
const message = createMessage({ say: "user_feedback", text: "User said something" })
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("User:")
|
||||
})
|
||||
|
||||
it("should format tool message", () => {
|
||||
const message = createMessage({ say: "tool", text: "read_file" })
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("Tool:")
|
||||
})
|
||||
|
||||
it("should format browser_action message", () => {
|
||||
const message = createMessage({ say: "browser_action", text: "click button" })
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("Browser:")
|
||||
})
|
||||
|
||||
it("should format api_req_started in verbose mode", () => {
|
||||
const message = createMessage({ say: "api_req_started", text: "" })
|
||||
const result = formatMessage(message, true)
|
||||
expect(result).toContain("API request started")
|
||||
})
|
||||
|
||||
it("should format checkpoint_created message", () => {
|
||||
const message = createMessage({ say: "checkpoint_created", text: "Saved" })
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("Checkpoint created")
|
||||
})
|
||||
|
||||
it("should format info message", () => {
|
||||
const message = createMessage({ say: "info", text: "Information" })
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("Information")
|
||||
})
|
||||
|
||||
it("should show unknown say types in verbose mode", () => {
|
||||
const message = createMessage({ say: "unknown_type" as any, text: "test" })
|
||||
const resultNormal = formatMessage(message, false)
|
||||
const resultVerbose = formatMessage(message, true)
|
||||
expect(resultNormal).toBe("")
|
||||
expect(resultVerbose).toContain("[SAY:unknown_type]")
|
||||
})
|
||||
})
|
||||
|
||||
describe("ask messages", () => {
|
||||
it("should format followup question", () => {
|
||||
const message = createMessage({
|
||||
type: "ask",
|
||||
ask: "followup",
|
||||
text: JSON.stringify({ question: "What do you want?" }),
|
||||
})
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("Question:")
|
||||
expect(result).toContain("What do you want?")
|
||||
})
|
||||
|
||||
it("should handle non-JSON followup text", () => {
|
||||
const message = createMessage({
|
||||
type: "ask",
|
||||
ask: "followup",
|
||||
text: "Plain text question",
|
||||
})
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("Plain text question")
|
||||
})
|
||||
|
||||
it("should format command ask", () => {
|
||||
const message = createMessage({
|
||||
type: "ask",
|
||||
ask: "command",
|
||||
text: "rm -rf /",
|
||||
})
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("Execute command?")
|
||||
expect(result).toContain("rm -rf /")
|
||||
})
|
||||
|
||||
it("should format tool ask", () => {
|
||||
const message = createMessage({
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
text: "write_to_file",
|
||||
})
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("Use tool?")
|
||||
})
|
||||
|
||||
it("should format completion_result ask", () => {
|
||||
const message = createMessage({
|
||||
type: "ask",
|
||||
ask: "completion_result",
|
||||
text: "Task completed successfully",
|
||||
})
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("Task completed")
|
||||
})
|
||||
|
||||
it("should format api_req_failed ask", () => {
|
||||
const message = createMessage({
|
||||
type: "ask",
|
||||
ask: "api_req_failed",
|
||||
text: "Rate limit exceeded",
|
||||
})
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("API request failed")
|
||||
expect(result).toContain("Rate limit exceeded")
|
||||
})
|
||||
|
||||
it("should format resume_task ask", () => {
|
||||
const message = createMessage({
|
||||
type: "ask",
|
||||
ask: "resume_task",
|
||||
text: "",
|
||||
})
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("Resume task?")
|
||||
})
|
||||
|
||||
it("should format browser_action_launch ask", () => {
|
||||
const message = createMessage({
|
||||
type: "ask",
|
||||
ask: "browser_action_launch",
|
||||
text: "https://example.com",
|
||||
})
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("Launch browser?")
|
||||
})
|
||||
|
||||
it("should format use_mcp_server ask", () => {
|
||||
const message = createMessage({
|
||||
type: "ask",
|
||||
ask: "use_mcp_server",
|
||||
text: "server-name",
|
||||
})
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("Use MCP server?")
|
||||
})
|
||||
|
||||
it("should show unknown ask types in verbose mode", () => {
|
||||
const message = createMessage({
|
||||
type: "ask",
|
||||
ask: "unknown_ask" as any,
|
||||
text: "test",
|
||||
})
|
||||
const resultNormal = formatMessage(message, false)
|
||||
const resultVerbose = formatMessage(message, true)
|
||||
expect(resultNormal).toBe("")
|
||||
expect(resultVerbose).toContain("[ASK:unknown_ask]")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("separator", () => {
|
||||
it("should create a separator with default char and width", () => {
|
||||
const result = separator()
|
||||
expect(result).toContain("─".repeat(60))
|
||||
})
|
||||
|
||||
it("should use custom character", () => {
|
||||
const result = separator("=", 10)
|
||||
expect(result).toContain("=".repeat(10))
|
||||
})
|
||||
|
||||
it("should use custom width", () => {
|
||||
const result = separator("-", 20)
|
||||
expect(result).toContain("-".repeat(20))
|
||||
})
|
||||
})
|
||||
|
||||
describe("taskHeader", () => {
|
||||
it("should format task header with ID", () => {
|
||||
const result = taskHeader("task-123")
|
||||
expect(result).toContain("Task: task-123")
|
||||
})
|
||||
|
||||
it("should include task description", () => {
|
||||
const result = taskHeader("task-123", "Build a website")
|
||||
expect(result).toContain("task-123")
|
||||
expect(result).toContain("Build a website")
|
||||
})
|
||||
|
||||
it("should truncate long task descriptions", () => {
|
||||
const longTask = "x".repeat(100)
|
||||
const result = taskHeader("task-123", longTask)
|
||||
expect(result).toContain("...")
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatState", () => {
|
||||
it("should format state with messages", () => {
|
||||
const state: Partial<ExtensionState> = {
|
||||
clineMessages: [{ ts: Date.now(), type: "say", say: "text", text: "Hello" } as ClineMessage],
|
||||
}
|
||||
const result = formatState(state as ExtensionState)
|
||||
expect(result).toContain("Hello")
|
||||
})
|
||||
|
||||
it("should include task header when currentTaskItem exists", () => {
|
||||
const state: Partial<ExtensionState> = {
|
||||
currentTaskItem: {
|
||||
id: "task-1",
|
||||
ts: Date.now(),
|
||||
task: "Do something",
|
||||
tokensIn: 10,
|
||||
tokensOut: 20,
|
||||
modelId: "gpt-4",
|
||||
totalCost: 0.0025,
|
||||
},
|
||||
clineMessages: [],
|
||||
}
|
||||
const result = formatState(state as ExtensionState)
|
||||
expect(result).toContain("Task: task-1")
|
||||
})
|
||||
|
||||
it("should handle empty messages array", () => {
|
||||
const state: Partial<ExtensionState> = {
|
||||
clineMessages: [],
|
||||
}
|
||||
const result = formatState(state as ExtensionState)
|
||||
expect(result).toBe("")
|
||||
})
|
||||
|
||||
it("should handle undefined messages", () => {
|
||||
const state: Partial<ExtensionState> = {}
|
||||
const result = formatState(state as ExtensionState)
|
||||
expect(result).toBe("")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Spinner", () => {
|
||||
let spinner: Spinner
|
||||
let writeSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
spinner = new Spinner()
|
||||
writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true)
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
spinner.stop()
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("should start spinning with message", () => {
|
||||
spinner.start("Loading...")
|
||||
vi.advanceTimersByTime(80)
|
||||
expect(writeSpy).toHaveBeenCalled()
|
||||
const calls = writeSpy.mock.calls.map((c: any[]) => c[0])
|
||||
expect(calls.some((c: any) => typeof c === "string" && c.includes("Loading..."))).toBe(true)
|
||||
})
|
||||
|
||||
it("should update message", () => {
|
||||
spinner.start("Initial")
|
||||
spinner.update("Updated")
|
||||
vi.advanceTimersByTime(80)
|
||||
const calls = writeSpy.mock.calls.map((c: any[]) => c[0])
|
||||
expect(calls.some((c: any) => typeof c === "string" && c.includes("Updated"))).toBe(true)
|
||||
})
|
||||
|
||||
it("should stop with final message", () => {
|
||||
spinner.start("Loading...")
|
||||
spinner.stop("Done!")
|
||||
const calls = writeSpy.mock.calls.map((c: any[]) => c[0])
|
||||
expect(calls.some((c: any) => typeof c === "string" && c.includes("Done!"))).toBe(true)
|
||||
})
|
||||
|
||||
it("should clear line when stopped without message", () => {
|
||||
spinner.start("Loading...")
|
||||
spinner.stop()
|
||||
expect(writeSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should show failure message", () => {
|
||||
spinner.start("Loading...")
|
||||
spinner.fail("Failed!")
|
||||
const calls = writeSpy.mock.calls.map((c: any[]) => c[0])
|
||||
expect(calls.some((c: any) => typeof c === "string" && c.includes("Failed!"))).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,472 @@
|
||||
/**
|
||||
* Terminal display utilities for rendering Cline messages in the CLI
|
||||
*/
|
||||
|
||||
import type { ClineAsk, ClineMessage, ClineSay, ExtensionState } from "@shared/ExtensionMessage"
|
||||
import { originalConsoleError, originalConsoleLog } from "./console"
|
||||
|
||||
// ANSI color codes for terminal output
|
||||
const colors = {
|
||||
reset: "\x1b[0m",
|
||||
bold: "\x1b[1m",
|
||||
dim: "\x1b[2m",
|
||||
italic: "\x1b[3m",
|
||||
underline: "\x1b[4m",
|
||||
|
||||
// Foreground colors
|
||||
black: "\x1b[30m",
|
||||
red: "\x1b[31m",
|
||||
green: "\x1b[32m",
|
||||
yellow: "\x1b[33m",
|
||||
blue: "\x1b[34m",
|
||||
magenta: "\x1b[35m",
|
||||
cyan: "\x1b[36m",
|
||||
white: "\x1b[37m",
|
||||
|
||||
// Bright foreground colors
|
||||
brightBlack: "\x1b[90m",
|
||||
brightRed: "\x1b[91m",
|
||||
brightGreen: "\x1b[92m",
|
||||
brightYellow: "\x1b[93m",
|
||||
brightBlue: "\x1b[94m",
|
||||
brightMagenta: "\x1b[95m",
|
||||
brightCyan: "\x1b[96m",
|
||||
brightWhite: "\x1b[97m",
|
||||
|
||||
// Background colors
|
||||
bgBlack: "\x1b[40m",
|
||||
bgRed: "\x1b[41m",
|
||||
bgGreen: "\x1b[42m",
|
||||
bgYellow: "\x1b[43m",
|
||||
bgBlue: "\x1b[44m",
|
||||
bgMagenta: "\x1b[45m",
|
||||
bgCyan: "\x1b[46m",
|
||||
bgWhite: "\x1b[47m",
|
||||
}
|
||||
|
||||
export function colorize(text: string, ...colorCodes: string[]): string {
|
||||
return colorCodes.join("") + text + colors.reset
|
||||
}
|
||||
|
||||
// Helper functions for common color combinations
|
||||
export const style = {
|
||||
bold: (text: string) => colorize(text, colors.bold),
|
||||
dim: (text: string) => colorize(text, colors.dim),
|
||||
italic: (text: string) => colorize(text, colors.italic),
|
||||
|
||||
error: (text: string) => colorize(text, colors.red, colors.bold),
|
||||
warning: (text: string) => colorize(text, colors.yellow),
|
||||
success: (text: string) => colorize(text, colors.green),
|
||||
info: (text: string) => colorize(text, colors.cyan),
|
||||
|
||||
// Message type colors
|
||||
task: (text: string) => colorize(text, colors.brightWhite, colors.bold),
|
||||
tool: (text: string) => colorize(text, colors.blue),
|
||||
command: (text: string) => colorize(text, colors.magenta),
|
||||
api: (text: string) => colorize(text, colors.brightBlack),
|
||||
user: (text: string) => colorize(text, colors.green),
|
||||
assistant: (text: string) => colorize(text, colors.cyan),
|
||||
|
||||
// Special formatting
|
||||
path: (text: string) => colorize(text, colors.underline, colors.blue),
|
||||
code: (text: string) => colorize(text, colors.bgBlack, colors.brightWhite),
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a timestamp for display
|
||||
*/
|
||||
export function formatTimestamp(ts: number): string {
|
||||
const date = new Date(ts)
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a prefix icon for different message types
|
||||
*/
|
||||
function getMessageIcon(message: ClineMessage): string {
|
||||
if (message.type === "ask") {
|
||||
switch (message.ask) {
|
||||
case "followup":
|
||||
return "❓"
|
||||
case "command":
|
||||
case "command_output":
|
||||
return "⚙️ "
|
||||
case "tool":
|
||||
return "🔧"
|
||||
case "completion_result":
|
||||
return "✅"
|
||||
case "api_req_failed":
|
||||
return "❌"
|
||||
case "resume_task":
|
||||
case "resume_completed_task":
|
||||
return "▶️ "
|
||||
case "browser_action_launch":
|
||||
return "🌐"
|
||||
case "use_mcp_server":
|
||||
return "🔌"
|
||||
default:
|
||||
return "❔"
|
||||
}
|
||||
} else {
|
||||
switch (message.say) {
|
||||
case "task":
|
||||
return "📋"
|
||||
case "error":
|
||||
return "❌"
|
||||
case "text":
|
||||
return "💬"
|
||||
case "reasoning":
|
||||
return "🧠"
|
||||
case "completion_result":
|
||||
return "✅"
|
||||
case "user_feedback":
|
||||
return "👤"
|
||||
case "command":
|
||||
case "command_output":
|
||||
return "⚙️ "
|
||||
case "tool":
|
||||
return "🔧"
|
||||
case "browser_action":
|
||||
case "browser_action_launch":
|
||||
case "browser_action_result":
|
||||
return "🌐"
|
||||
case "mcp_server_request_started":
|
||||
case "mcp_server_response":
|
||||
return "🔌"
|
||||
case "api_req_started":
|
||||
case "api_req_finished":
|
||||
return "🔄"
|
||||
case "checkpoint_created":
|
||||
return "💾"
|
||||
case "info":
|
||||
return "ℹ️ "
|
||||
default:
|
||||
return " "
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a ClineMessage for terminal display
|
||||
*/
|
||||
export function formatMessage(message: ClineMessage, verbose: boolean = false): string {
|
||||
const icon = getMessageIcon(message)
|
||||
const timestamp = formatTimestamp(message.ts)
|
||||
const lines: string[] = []
|
||||
|
||||
const prefix = `${style.dim(timestamp)} ${icon}`
|
||||
|
||||
if (message.type === "ask") {
|
||||
lines.push(formatAskMessage(message, prefix, verbose))
|
||||
} else {
|
||||
lines.push(formatSayMessage(message, prefix, verbose))
|
||||
}
|
||||
|
||||
return lines.filter(Boolean).join("\n")
|
||||
}
|
||||
|
||||
function formatAskMessage(message: ClineMessage, prefix: string, verbose: boolean): string {
|
||||
const ask = message.ask as ClineAsk
|
||||
|
||||
switch (ask) {
|
||||
case "followup": {
|
||||
// Parse JSON question format
|
||||
let question = message.text || ""
|
||||
try {
|
||||
const parsed = JSON.parse(message.text || "{}")
|
||||
question = parsed.question || question
|
||||
} catch {
|
||||
// Fallback to raw text if not JSON
|
||||
question = message.text || ""
|
||||
}
|
||||
return `${prefix} ${style.info("Question:")} ${question}`
|
||||
}
|
||||
|
||||
case "command":
|
||||
return `${prefix} ${style.command("Execute command?")} ${style.code(message.text || "")}`
|
||||
|
||||
case "tool":
|
||||
return `${prefix} ${style.tool("Use tool?")} ${message.text || ""}`
|
||||
|
||||
case "completion_result":
|
||||
return `${prefix} ${style.success("Task completed")} ${message.text ? `- ${message.text}` : ""}`
|
||||
|
||||
case "api_req_failed":
|
||||
return `${prefix} ${style.error("API request failed")} ${message.text || ""}`
|
||||
|
||||
case "resume_task":
|
||||
case "resume_completed_task":
|
||||
return `${prefix} ${style.info("Resume task?")} ${message.text || ""}`
|
||||
|
||||
case "browser_action_launch":
|
||||
return `${prefix} ${style.info("Launch browser?")} ${message.text || ""}`
|
||||
|
||||
case "use_mcp_server":
|
||||
return `${prefix} ${style.info("Use MCP server?")} ${message.text || ""}`
|
||||
|
||||
case "plan_mode_respond":
|
||||
return `${prefix} ${style.info("Plan mode response:")} ${message.text || ""}`
|
||||
|
||||
default:
|
||||
return verbose ? `${prefix} [ASK:${ask}] ${message.text || ""}` : ""
|
||||
}
|
||||
}
|
||||
|
||||
function formatSayMessage(message: ClineMessage, prefix: string, verbose: boolean): string {
|
||||
const say = message.say as ClineSay
|
||||
|
||||
switch (say) {
|
||||
case "task":
|
||||
return `${prefix} ${style.task("Task:")} ${message.text || ""}`
|
||||
|
||||
case "text":
|
||||
return `${prefix} ${style.assistant(message.text || "")}`
|
||||
|
||||
case "reasoning":
|
||||
return `${prefix} ${style.dim("Thinking:")} ${style.italic(message.text || "")}`
|
||||
|
||||
case "error":
|
||||
return `${prefix} ${style.error("Error:")} ${message.text || ""}`
|
||||
|
||||
case "completion_result":
|
||||
return `${prefix} ${style.success("✓ Completed:")} ${message.text || ""}`
|
||||
|
||||
case "user_feedback":
|
||||
return `${prefix} ${style.user("User:")} ${message.text || ""}`
|
||||
|
||||
case "command":
|
||||
return `${prefix} ${style.command("Command:")} ${style.code(message.text || "")}`
|
||||
|
||||
case "command_output":
|
||||
const output = message.text || ""
|
||||
const truncated = output.length > 500 ? output.substring(0, 500) + "..." : output
|
||||
return `${prefix} ${style.dim("Output:")} ${truncated}`
|
||||
|
||||
case "tool":
|
||||
return `${prefix} ${style.tool("Tool:")} ${message.text || ""}`
|
||||
|
||||
case "browser_action":
|
||||
case "browser_action_launch":
|
||||
return `${prefix} ${style.info("Browser:")} ${message.text || ""}`
|
||||
|
||||
case "browser_action_result":
|
||||
return `${prefix} ${style.dim("Browser result")} ${message.text ? `- ${message.text.substring(0, 100)}...` : ""}`
|
||||
|
||||
case "mcp_server_request_started":
|
||||
return `${prefix} ${style.info("MCP request started")} ${message.text || ""}`
|
||||
|
||||
case "mcp_server_response":
|
||||
return `${prefix} ${style.info("MCP response")} ${message.text ? message.text.substring(0, 200) : ""}`
|
||||
|
||||
case "api_req_started":
|
||||
return verbose ? `${prefix} ${style.api("API request started")}` : `${message.text || ""}`
|
||||
|
||||
case "api_req_finished":
|
||||
return verbose ? `${prefix} ${style.api("API request finished")}` : ""
|
||||
|
||||
case "checkpoint_created":
|
||||
return `${prefix} ${style.success("Checkpoint created")} ${message.text || ""}`
|
||||
|
||||
case "info":
|
||||
return `${prefix} ${style.info(message.text || "")}`
|
||||
|
||||
case "hook_status":
|
||||
return `${prefix} ${style.dim("Hook:")} ${message.text || ""}`
|
||||
|
||||
case "task_progress":
|
||||
return `${prefix} ${style.info("Progress:")} ${message.text || ""}`
|
||||
|
||||
default:
|
||||
return verbose ? `${prefix} [SAY:${say}] ${message.text || ""}` : ""
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a horizontal separator
|
||||
*/
|
||||
export function separator(char: string = "─", width: number = 60): string {
|
||||
return style.dim(char.repeat(width))
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the task header
|
||||
*/
|
||||
export function taskHeader(taskId: string, task?: string): string {
|
||||
const lines = [
|
||||
separator("═"),
|
||||
style.bold(` Task: ${taskId}`),
|
||||
task ? ` ${style.dim(task.substring(0, 80))}${task.length > 80 ? "..." : ""}` : "",
|
||||
separator("═"),
|
||||
]
|
||||
return lines.filter(Boolean).join("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the current state for display
|
||||
*/
|
||||
export function formatState(state: ExtensionState, verbose: boolean = false): string {
|
||||
const lines: string[] = []
|
||||
|
||||
if (state.currentTaskItem) {
|
||||
lines.push(taskHeader(state.currentTaskItem.id, state.currentTaskItem.task))
|
||||
}
|
||||
|
||||
// Show messages
|
||||
if (state.clineMessages && state.clineMessages.length > 0) {
|
||||
const messagesToShow = verbose
|
||||
? state.clineMessages
|
||||
: state.clineMessages.filter((m) => {
|
||||
// Filter out noisy messages in non-verbose mode
|
||||
// if (m.say === "api_req_started" || m.say === "api_req_finished") return false
|
||||
return true
|
||||
})
|
||||
|
||||
for (const message of messagesToShow) {
|
||||
const formatted = formatMessage(message, verbose)
|
||||
if (formatted) {
|
||||
lines.push(formatted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a spinner with message
|
||||
*/
|
||||
export class Spinner {
|
||||
private frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
||||
private frameIndex = 0
|
||||
private interval: NodeJS.Timeout | null = null
|
||||
private message: string = ""
|
||||
|
||||
start(message: string) {
|
||||
this.message = message
|
||||
this.interval = setInterval(() => {
|
||||
const frame = this.frames[this.frameIndex]
|
||||
process.stdout.write(`\r${style.info(frame)} ${this.message}`)
|
||||
this.frameIndex = (this.frameIndex + 1) % this.frames.length
|
||||
}, 80)
|
||||
}
|
||||
|
||||
update(message: string) {
|
||||
this.message = message
|
||||
}
|
||||
|
||||
stop(finalMessage?: string) {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval)
|
||||
this.interval = null
|
||||
}
|
||||
if (finalMessage) {
|
||||
process.stdout.write(`\r${style.success("✓")} ${finalMessage}\n`)
|
||||
} else {
|
||||
process.stdout.write("\r" + " ".repeat(this.message.length + 4) + "\r")
|
||||
}
|
||||
}
|
||||
|
||||
fail(message?: string) {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval)
|
||||
this.interval = null
|
||||
}
|
||||
if (message) {
|
||||
process.stdout.write(`\r${style.error("✗")} ${message}\n`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current line
|
||||
*/
|
||||
export function clearLine() {
|
||||
process.stdout.write("\r\x1b[K")
|
||||
}
|
||||
|
||||
/**
|
||||
* Move cursor up n lines
|
||||
*/
|
||||
export function cursorUp(n: number = 1) {
|
||||
process.stdout.write(`\x1b[${n}A`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a message to stdout with newline
|
||||
* Uses original console.log to work even when console is suppressed
|
||||
*/
|
||||
export function print(message: string) {
|
||||
originalConsoleLog(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Print an error message to stderr
|
||||
* Uses original console.error to work even when console is suppressed
|
||||
*/
|
||||
export function printError(message: string) {
|
||||
originalConsoleError(style.error(message))
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a success message
|
||||
*/
|
||||
export function printSuccess(message: string) {
|
||||
originalConsoleLog(style.success(message))
|
||||
}
|
||||
|
||||
/**
|
||||
* Print an info message
|
||||
*/
|
||||
export function printInfo(message: string) {
|
||||
originalConsoleLog(style.info(message))
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a warning message
|
||||
*/
|
||||
export function printWarning(message: string) {
|
||||
originalConsoleLog(style.warning(message))
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt user for input from stdin
|
||||
*/
|
||||
export async function promptUser(question: string): Promise<string> {
|
||||
const readline = await import("readline")
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
})
|
||||
|
||||
return new Promise((resolve) => {
|
||||
rl.question(style.info(question) + " ", (answer: string) => {
|
||||
rl.close()
|
||||
resolve(answer.trim())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt user for yes/no confirmation
|
||||
*/
|
||||
export async function promptConfirmation(question: string): Promise<boolean> {
|
||||
const answer = await promptUser(`${question} ${style.dim("(y/n)")}`)
|
||||
return answer.toLowerCase() === "y" || answer.toLowerCase() === "yes"
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the terminal session title using OSC escape sequence.
|
||||
* Works in most modern terminal emulators (iTerm2, Terminal.app, GNOME Terminal, etc.)
|
||||
*/
|
||||
export function setTerminalTitle(title: string): void {
|
||||
if (process.stdout.isTTY) {
|
||||
const maxLength = 80
|
||||
const truncated = title.length > maxLength ? title.slice(0, maxLength) + "..." : title
|
||||
process.stdout.write(`\x1b]0;${truncated}\x07`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* File search utility for CLI
|
||||
* Uses ripgrep if available, otherwise falls back to Node.js fs.readdir
|
||||
* FZF is used for fuzzy matching
|
||||
*/
|
||||
import { execFileSync, spawn } from "node:child_process"
|
||||
import { promises as fs } from "node:fs"
|
||||
import { basename, dirname, join, relative } from "node:path"
|
||||
import { createInterface } from "node:readline"
|
||||
import type { Fzf, FzfResultItem } from "fzf"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
|
||||
export interface FileSearchResult {
|
||||
path: string
|
||||
type: "file" | "folder"
|
||||
label: string
|
||||
}
|
||||
|
||||
const EXCLUDED_DIRS = new Set([
|
||||
"node_modules",
|
||||
".git",
|
||||
".github",
|
||||
"out",
|
||||
"dist",
|
||||
"__pycache__",
|
||||
".venv",
|
||||
".env",
|
||||
"venv",
|
||||
"env",
|
||||
".cache",
|
||||
"tmp",
|
||||
"temp",
|
||||
".next",
|
||||
"coverage",
|
||||
"build",
|
||||
])
|
||||
|
||||
const RG_EXCLUDE_GLOB = "!**/{node_modules,.git,.github,out,dist,__pycache__,.venv,.env,venv,env,.cache,tmp,temp}/**"
|
||||
|
||||
// Cached state
|
||||
let ripgrepAvailable: boolean | null = null
|
||||
let ripgrepWarningShown = false
|
||||
let fzfModule: { Fzf: typeof Fzf; byLengthAsc: any } | null = null
|
||||
|
||||
function checkRipgrep(): boolean {
|
||||
if (ripgrepAvailable !== null) {
|
||||
return ripgrepAvailable
|
||||
}
|
||||
try {
|
||||
execFileSync("which", ["rg"], { stdio: "ignore" })
|
||||
ripgrepAvailable = true
|
||||
} catch {
|
||||
ripgrepAvailable = false
|
||||
}
|
||||
return ripgrepAvailable
|
||||
}
|
||||
|
||||
function addParentDirs(relativePath: string, dirSet: Set<string>): void {
|
||||
let dir = dirname(relativePath)
|
||||
while (dir && dir !== "." && dir !== "/") {
|
||||
dirSet.add(dir)
|
||||
dir = dirname(dir)
|
||||
}
|
||||
}
|
||||
|
||||
function dirsToResults(dirSet: Set<string>): FileSearchResult[] {
|
||||
return Array.from(dirSet, (p) => ({ path: p, type: "folder" as const, label: basename(p) }))
|
||||
}
|
||||
|
||||
async function listFilesWithNodeFs(workspacePath: string, limit: number): Promise<FileSearchResult[]> {
|
||||
const files: FileSearchResult[] = []
|
||||
const dirs = new Set<string>()
|
||||
|
||||
async function walk(dir: string): Promise<void> {
|
||||
if (files.length >= limit) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
|
||||
for (const entry of entries) {
|
||||
if (files.length >= limit) {
|
||||
break
|
||||
}
|
||||
|
||||
const name = entry.name
|
||||
if (entry.isDirectory() && EXCLUDED_DIRS.has(name)) {
|
||||
continue
|
||||
}
|
||||
if (name.startsWith(".") && !name.startsWith(".cline")) {
|
||||
continue
|
||||
}
|
||||
|
||||
const fullPath = join(dir, name)
|
||||
const relativePath = relative(workspacePath, fullPath)
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
dirs.add(relativePath)
|
||||
await walk(fullPath)
|
||||
} else if (entry.isFile()) {
|
||||
files.push({ path: relativePath, type: "file", label: name })
|
||||
addParentDirs(relativePath, dirs)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await walk(workspacePath)
|
||||
return [...files, ...dirsToResults(dirs)]
|
||||
}
|
||||
|
||||
async function listFilesWithRipgrep(workspacePath: string, limit: number): Promise<FileSearchResult[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const rg = spawn("rg", ["--files", "--follow", "--hidden", "-g", RG_EXCLUDE_GLOB, workspacePath])
|
||||
const rl = createInterface({ input: rg.stdout })
|
||||
|
||||
const files: FileSearchResult[] = []
|
||||
const dirs = new Set<string>()
|
||||
let stderr = ""
|
||||
|
||||
rl.on("line", (line) => {
|
||||
if (files.length >= limit) {
|
||||
rl.close()
|
||||
rg.kill()
|
||||
return
|
||||
}
|
||||
|
||||
const relativePath = relative(workspacePath, line)
|
||||
files.push({ path: relativePath, type: "file", label: basename(relativePath) })
|
||||
addParentDirs(relativePath, dirs)
|
||||
})
|
||||
|
||||
rg.stderr.on("data", (data) => {
|
||||
stderr += data
|
||||
})
|
||||
|
||||
rl.on("close", () => {
|
||||
if (stderr && files.length === 0) {
|
||||
reject(new Error(`ripgrep error: ${stderr.trim()}`))
|
||||
} else {
|
||||
resolve([...files, ...dirsToResults(dirs)])
|
||||
}
|
||||
})
|
||||
|
||||
rg.on("error", (err) => reject(new Error(`ripgrep error: ${err.message}`)))
|
||||
})
|
||||
}
|
||||
|
||||
export function checkAndWarnRipgrepMissing(): boolean {
|
||||
if (!checkRipgrep() && !ripgrepWarningShown) {
|
||||
ripgrepWarningShown = true
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function getRipgrepInstallInstructions(): string {
|
||||
switch (process.platform) {
|
||||
case "darwin":
|
||||
return "brew install ripgrep"
|
||||
case "linux":
|
||||
return "apt install ripgrep # or: yum install ripgrep"
|
||||
case "win32":
|
||||
return "choco install ripgrep # or: scoop install ripgrep"
|
||||
default:
|
||||
return "https://github.com/BurntSushi/ripgrep#installation"
|
||||
}
|
||||
}
|
||||
|
||||
export async function listWorkspaceFiles(workspacePath: string, limit = 5000): Promise<FileSearchResult[]> {
|
||||
if (checkRipgrep()) {
|
||||
try {
|
||||
return await listFilesWithRipgrep(workspacePath, limit)
|
||||
} catch {
|
||||
ripgrepAvailable = false
|
||||
}
|
||||
}
|
||||
return listFilesWithNodeFs(workspacePath, limit)
|
||||
}
|
||||
|
||||
function countGaps(positions: Iterable<number>): number {
|
||||
let gaps = 0
|
||||
let prev = -Infinity
|
||||
for (const pos of positions) {
|
||||
if (prev !== -Infinity && pos - prev > 1) {
|
||||
gaps++
|
||||
}
|
||||
prev = pos
|
||||
}
|
||||
return gaps
|
||||
}
|
||||
|
||||
const orderByMatchScore = (a: FzfResultItem<FileSearchResult>, b: FzfResultItem<FileSearchResult>) =>
|
||||
countGaps(a.positions) - countGaps(b.positions)
|
||||
|
||||
export async function searchWorkspaceFiles(
|
||||
query: string,
|
||||
workspacePath: string,
|
||||
limit = 15,
|
||||
selectedType?: "file" | "folder",
|
||||
): Promise<FileSearchResult[]> {
|
||||
try {
|
||||
let items = await listWorkspaceFiles(workspacePath, 5000)
|
||||
|
||||
if (selectedType) {
|
||||
items = items.filter((item) => item.type === selectedType)
|
||||
}
|
||||
|
||||
if (!query.trim()) {
|
||||
return items.slice(0, limit)
|
||||
}
|
||||
|
||||
// Lazy load fzf module
|
||||
if (!fzfModule) {
|
||||
fzfModule = await import("fzf")
|
||||
}
|
||||
|
||||
const fzf = new fzfModule.Fzf(items, {
|
||||
selector: (item: FileSearchResult) => `${item.label} ${item.path}`,
|
||||
tiebreakers: [orderByMatchScore, fzfModule.byLengthAsc],
|
||||
limit: limit * 2,
|
||||
})
|
||||
|
||||
return fzf
|
||||
.find(query)
|
||||
.slice(0, limit)
|
||||
.map((r) => r.item)
|
||||
} catch (error) {
|
||||
Logger.error("File search error:", error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function extractMentionQuery(text: string): { inMentionMode: boolean; query: string; atIndex: number } {
|
||||
const lastAtIndex = text.lastIndexOf("@")
|
||||
|
||||
if (lastAtIndex === -1 || (lastAtIndex > 0 && !/\s/.test(text[lastAtIndex - 1]))) {
|
||||
return { inMentionMode: false, query: "", atIndex: -1 }
|
||||
}
|
||||
|
||||
const afterAt = text.slice(lastAtIndex + 1)
|
||||
if (afterAt.includes(" ")) {
|
||||
return { inMentionMode: false, query: "", atIndex: -1 }
|
||||
}
|
||||
|
||||
return { inMentionMode: true, query: afterAt, atIndex: lastAtIndex }
|
||||
}
|
||||
|
||||
export function insertMention(text: string, atIndex: number, filePath: string): string {
|
||||
const endIndex = text.indexOf(" ", atIndex)
|
||||
const end = endIndex === -1 ? text.length : endIndex
|
||||
// Ensure path starts with / for proper mention format
|
||||
const normalizedPath = filePath.startsWith("/") ? filePath : `/${filePath}`
|
||||
const mention = normalizedPath.includes(" ") ? `@"${normalizedPath}"` : `@${normalizedPath}`
|
||||
return text.slice(0, atIndex) + mention + " " + text.slice(end).trimStart()
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Fuzzy search utility using fzf
|
||||
*/
|
||||
|
||||
import { Fzf } from "fzf"
|
||||
|
||||
/**
|
||||
* Filter items using fuzzy matching
|
||||
* @param items - Array of items to filter
|
||||
* @param query - Search query string
|
||||
* @param selector - Function to extract searchable string from each item
|
||||
* @returns Filtered and sorted items (best matches first)
|
||||
*/
|
||||
export function fuzzyFilter<T>(items: readonly T[], query: string, selector: (item: T) => string): T[] {
|
||||
if (!query) return [...items]
|
||||
const fzf = new Fzf(items, { selector })
|
||||
return fzf.find(query).map((result) => result.item)
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Utility to detect and import API keys from competing CLI agents (Codex, OpenCode)
|
||||
*/
|
||||
|
||||
import * as fs from "fs"
|
||||
import * as os from "os"
|
||||
import * as path from "path"
|
||||
import providersData from "@/shared/providers/providers.json"
|
||||
|
||||
// Import source types
|
||||
export type ImportSource = "codex" | "opencode"
|
||||
|
||||
// Imported key structure
|
||||
export interface ImportedKey {
|
||||
provider: string // Cline provider ID
|
||||
keyField: string // Cline API key field name
|
||||
key: string // The API key value
|
||||
modelId?: string // Optional default model ID
|
||||
}
|
||||
|
||||
// Import result
|
||||
export interface ImportResult {
|
||||
source: ImportSource
|
||||
keys: ImportedKey[]
|
||||
}
|
||||
|
||||
// Available import sources that were detected
|
||||
export interface DetectedSources {
|
||||
codex: boolean
|
||||
opencode: boolean
|
||||
}
|
||||
|
||||
// Build provider labels map from providers.json (single source of truth)
|
||||
const providerLabels: Record<string, string> = Object.fromEntries(
|
||||
providersData.list.map((p: { value: string; label: string }) => [p.value, p.label]),
|
||||
)
|
||||
|
||||
/**
|
||||
* Get possible data directories for OpenCode
|
||||
* OpenCode uses XDG_DATA_HOME on all platforms, defaulting to ~/.local/share/opencode
|
||||
* Returns array of paths to check (in order of preference)
|
||||
*/
|
||||
function getOpenCodeDataDirs(): string[] {
|
||||
const home = os.homedir()
|
||||
const paths: string[] = []
|
||||
|
||||
// XDG path (used by OpenCode on all platforms)
|
||||
if (process.env.XDG_DATA_HOME) {
|
||||
paths.push(path.join(process.env.XDG_DATA_HOME, "opencode"))
|
||||
}
|
||||
paths.push(path.join(home, ".local", "share", "opencode"))
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect which CLI agents have config files with API keys
|
||||
*/
|
||||
export function detectImportSources(): DetectedSources {
|
||||
return {
|
||||
codex: hasCodexConfig(),
|
||||
opencode: hasOpenCodeConfig(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Codex config exists with API keys
|
||||
*/
|
||||
function hasCodexConfig(): boolean {
|
||||
try {
|
||||
const authPath = path.join(os.homedir(), ".codex", "auth.json")
|
||||
if (!fs.existsSync(authPath)) {
|
||||
return false
|
||||
}
|
||||
const content = fs.readFileSync(authPath, "utf-8")
|
||||
const data = JSON.parse(content)
|
||||
// Check if there's at least one key
|
||||
return Object.keys(data).length > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if OpenCode config exists with API keys
|
||||
*/
|
||||
function hasOpenCodeConfig(): boolean {
|
||||
for (const dir of getOpenCodeDataDirs()) {
|
||||
try {
|
||||
const authPath = path.join(dir, "auth.json")
|
||||
if (!fs.existsSync(authPath)) {
|
||||
continue
|
||||
}
|
||||
const content = fs.readFileSync(authPath, "utf-8")
|
||||
const data = JSON.parse(content)
|
||||
// Check if there's at least one key
|
||||
if (Object.keys(data).length > 0) {
|
||||
return true
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the OpenCode auth.json path (first existing one)
|
||||
*/
|
||||
function findOpenCodeAuthPath(): string | null {
|
||||
for (const dir of getOpenCodeDataDirs()) {
|
||||
const authPath = path.join(dir, "auth.json")
|
||||
if (fs.existsSync(authPath)) {
|
||||
return authPath
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Codex key names to Cline providers
|
||||
*/
|
||||
const CODEX_KEY_MAP: Record<string, { provider: string; keyField: string; modelId?: string }> = {
|
||||
OPENAI_API_KEY: { provider: "openai-native", keyField: "openAiNativeApiKey", modelId: "gpt-4o" },
|
||||
ANTHROPIC_API_KEY: { provider: "anthropic", keyField: "apiKey", modelId: "claude-sonnet-4-20250514" },
|
||||
}
|
||||
|
||||
/**
|
||||
* Map OpenCode provider IDs to Cline providers
|
||||
*/
|
||||
const OPENCODE_PROVIDER_MAP: Record<string, { provider: string; keyField: string; modelId?: string }> = {
|
||||
openai: { provider: "openai-native", keyField: "openAiNativeApiKey", modelId: "gpt-4o" },
|
||||
anthropic: { provider: "anthropic", keyField: "apiKey", modelId: "claude-sonnet-4-20250514" },
|
||||
gemini: { provider: "gemini", keyField: "geminiApiKey", modelId: "gemini-2.0-flash-001" },
|
||||
mistral: { provider: "mistral", keyField: "mistralApiKey" },
|
||||
groq: { provider: "groq", keyField: "groqApiKey" },
|
||||
deepseek: { provider: "deepseek", keyField: "deepSeekApiKey" },
|
||||
xai: { provider: "xai", keyField: "xaiApiKey" },
|
||||
openrouter: { provider: "openrouter", keyField: "openRouterApiKey" },
|
||||
}
|
||||
|
||||
/**
|
||||
* Import keys from Codex CLI
|
||||
*/
|
||||
export function importFromCodex(): ImportResult | null {
|
||||
try {
|
||||
const authPath = path.join(os.homedir(), ".codex", "auth.json")
|
||||
if (!fs.existsSync(authPath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(authPath, "utf-8")
|
||||
const data = JSON.parse(content) as Record<string, string>
|
||||
|
||||
const keys: ImportedKey[] = []
|
||||
|
||||
for (const [envKey, apiKey] of Object.entries(data)) {
|
||||
const mapping = CODEX_KEY_MAP[envKey]
|
||||
if (mapping && apiKey) {
|
||||
keys.push({
|
||||
provider: mapping.provider,
|
||||
keyField: mapping.keyField,
|
||||
key: apiKey,
|
||||
modelId: mapping.modelId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (keys.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { source: "codex", keys }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// OpenCode auth entry structure
|
||||
interface OpenCodeAuthEntry {
|
||||
type: "api" | "oauth"
|
||||
key?: string
|
||||
access?: string
|
||||
refresh?: string
|
||||
expires?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Import keys from OpenCode CLI
|
||||
*/
|
||||
export function importFromOpenCode(): ImportResult | null {
|
||||
try {
|
||||
const authPath = findOpenCodeAuthPath()
|
||||
if (!authPath) {
|
||||
return null
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(authPath, "utf-8")
|
||||
const data = JSON.parse(content) as Record<string, OpenCodeAuthEntry>
|
||||
|
||||
const keys: ImportedKey[] = []
|
||||
|
||||
for (const [providerId, authEntry] of Object.entries(data)) {
|
||||
// Only import API type keys (not OAuth)
|
||||
if (authEntry.type !== "api" || !authEntry.key) {
|
||||
continue
|
||||
}
|
||||
|
||||
const mapping = OPENCODE_PROVIDER_MAP[providerId]
|
||||
if (mapping) {
|
||||
keys.push({
|
||||
provider: mapping.provider,
|
||||
keyField: mapping.keyField,
|
||||
key: authEntry.key,
|
||||
modelId: mapping.modelId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (keys.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { source: "opencode", keys }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable source name
|
||||
*/
|
||||
export function getSourceDisplayName(source: ImportSource): string {
|
||||
switch (source) {
|
||||
case "codex":
|
||||
return "OpenAI Codex CLI"
|
||||
case "opencode":
|
||||
return "OpenCode"
|
||||
default:
|
||||
return source
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider display name from providers.json
|
||||
*/
|
||||
export function getProviderDisplayName(provider: string): string {
|
||||
return providerLabels[provider] || provider
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Input filtering utilities for CLI components
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if input contains mouse escape sequences from terminal mouse tracking.
|
||||
* AsciiMotionCli enables mouse tracking which generates sequences like [<35;46;17M
|
||||
* These should be filtered out of text input handlers.
|
||||
*/
|
||||
export function isMouseEscapeSequence(input: string): boolean {
|
||||
// Mouse events look like: [<35;46;17M or contain escape characters
|
||||
return input.includes("\x1b") || input.includes("[<") || /\d+;\d+[Mm]/.test(input)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Utility to fetch and cache OpenRouter models for the CLI
|
||||
*/
|
||||
|
||||
import { openRouterDefaultModelId } from "@/shared/api"
|
||||
import { fetch } from "@/shared/net"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
|
||||
interface OpenRouterModel {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
// In-memory cache
|
||||
let cachedModels: string[] | null = null
|
||||
let fetchPromise: Promise<string[]> | null = null
|
||||
|
||||
/**
|
||||
* Fetch OpenRouter models from the API
|
||||
* Returns cached results if available, or fetches from API
|
||||
*/
|
||||
export async function fetchOpenRouterModels(): Promise<string[]> {
|
||||
// Return cached models if available
|
||||
if (cachedModels) {
|
||||
return cachedModels
|
||||
}
|
||||
|
||||
// If already fetching, wait for that promise
|
||||
if (fetchPromise) {
|
||||
return fetchPromise
|
||||
}
|
||||
|
||||
// Start fetching
|
||||
fetchPromise = (async () => {
|
||||
try {
|
||||
const response = await fetch("https://openrouter.ai/api/v1/models")
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
if (data?.data) {
|
||||
const models = (data.data as OpenRouterModel[]).map((m) => m.id).sort((a, b) => a.localeCompare(b))
|
||||
cachedModels = models
|
||||
return models
|
||||
}
|
||||
return []
|
||||
} catch (error) {
|
||||
Logger.debug("Failed to fetch OpenRouter models:", error)
|
||||
return []
|
||||
} finally {
|
||||
fetchPromise = null
|
||||
}
|
||||
})()
|
||||
|
||||
return fetchPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default OpenRouter model ID
|
||||
*/
|
||||
export function getOpenRouterDefaultModelId(): string {
|
||||
return openRouterDefaultModelId
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if provider uses OpenRouter models (openrouter or cline)
|
||||
*/
|
||||
export function usesOpenRouterModels(provider: string): boolean {
|
||||
return provider === "openrouter" || provider === "cline"
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import fs from "node:fs"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { imageFileToDataUrl, isImagePath, jsonParseSafe, parseImagesFromInput, processImagePaths } from "./parser"
|
||||
|
||||
describe("parser", () => {
|
||||
describe("jsonParseSafe", () => {
|
||||
it("should parse valid JSON", () => {
|
||||
const result = jsonParseSafe('{"key": "value"}', {})
|
||||
expect(result).toEqual({ key: "value" })
|
||||
})
|
||||
|
||||
it("should return default value for invalid JSON", () => {
|
||||
const defaultValue = { fallback: true }
|
||||
const result = jsonParseSafe("not valid json", defaultValue)
|
||||
expect(result).toEqual(defaultValue)
|
||||
})
|
||||
|
||||
it("should parse arrays", () => {
|
||||
const result = jsonParseSafe("[1, 2, 3]", [])
|
||||
expect(result).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it("should handle empty string", () => {
|
||||
const result = jsonParseSafe("", "default")
|
||||
expect(result).toBe("default")
|
||||
})
|
||||
|
||||
it("should parse nested objects", () => {
|
||||
const json = '{"outer": {"inner": "value"}}'
|
||||
const result = jsonParseSafe(json, {})
|
||||
expect(result).toEqual({ outer: { inner: "value" } })
|
||||
})
|
||||
})
|
||||
|
||||
describe("isImagePath", () => {
|
||||
it("should return true for .png files", () => {
|
||||
expect(isImagePath("/path/to/image.png")).toBe(true)
|
||||
})
|
||||
|
||||
it("should return true for .jpg files", () => {
|
||||
expect(isImagePath("/path/to/image.jpg")).toBe(true)
|
||||
})
|
||||
|
||||
it("should return true for .jpeg files", () => {
|
||||
expect(isImagePath("/path/to/image.jpeg")).toBe(true)
|
||||
})
|
||||
|
||||
it("should return true for .gif files", () => {
|
||||
expect(isImagePath("/path/to/image.gif")).toBe(true)
|
||||
})
|
||||
|
||||
it("should return true for .webp files", () => {
|
||||
expect(isImagePath("/path/to/image.webp")).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false for non-image files", () => {
|
||||
expect(isImagePath("/path/to/file.txt")).toBe(false)
|
||||
expect(isImagePath("/path/to/file.pdf")).toBe(false)
|
||||
expect(isImagePath("/path/to/file.js")).toBe(false)
|
||||
})
|
||||
|
||||
it("should handle uppercase extensions", () => {
|
||||
expect(isImagePath("/path/to/image.PNG")).toBe(true)
|
||||
expect(isImagePath("/path/to/image.JPG")).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle mixed case extensions", () => {
|
||||
expect(isImagePath("/path/to/image.Png")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseImagesFromInput", () => {
|
||||
it("should extract image paths with @ prefix", () => {
|
||||
const input = "analyze this image @/path/to/image.png"
|
||||
const result = parseImagesFromInput(input)
|
||||
expect(result.imagePaths).toContain("/path/to/image.png")
|
||||
expect(result.prompt).toBe("analyze this image")
|
||||
})
|
||||
|
||||
it("should extract multiple images", () => {
|
||||
const input = "compare @/img1.png and @/img2.jpg"
|
||||
const result = parseImagesFromInput(input)
|
||||
expect(result.imagePaths).toContain("/img1.png")
|
||||
expect(result.imagePaths).toContain("/img2.jpg")
|
||||
})
|
||||
|
||||
it("should handle standalone image paths", () => {
|
||||
const input = "look at /path/to/image.png please"
|
||||
const result = parseImagesFromInput(input)
|
||||
expect(result.imagePaths).toContain("/path/to/image.png")
|
||||
})
|
||||
|
||||
it("should return empty array when no images", () => {
|
||||
const input = "just some text without images"
|
||||
const result = parseImagesFromInput(input)
|
||||
expect(result.imagePaths).toEqual([])
|
||||
expect(result.prompt).toBe("just some text without images")
|
||||
})
|
||||
|
||||
it("should handle image at start of input", () => {
|
||||
const input = "@/start.png is the image"
|
||||
const result = parseImagesFromInput(input)
|
||||
expect(result.imagePaths).toContain("/start.png")
|
||||
})
|
||||
|
||||
it("should handle all supported image extensions", () => {
|
||||
const input = "@/a.png @/b.jpg @/c.jpeg @/d.gif @/e.webp"
|
||||
const result = parseImagesFromInput(input)
|
||||
expect(result.imagePaths).toHaveLength(5)
|
||||
})
|
||||
|
||||
it("should not duplicate image paths", () => {
|
||||
const input = "@/same.png /same.png"
|
||||
const result = parseImagesFromInput(input)
|
||||
// Both patterns match the same path, should not duplicate
|
||||
expect(result.imagePaths.filter((p) => p === "/same.png").length).toBeLessThanOrEqual(2)
|
||||
})
|
||||
|
||||
it("should clean up extra whitespace in prompt", () => {
|
||||
const input = "text @/image.png more text"
|
||||
const result = parseImagesFromInput(input)
|
||||
expect(result.prompt).toBe("text more text")
|
||||
})
|
||||
})
|
||||
|
||||
describe("imageFileToDataUrl", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(fs.promises, "readFile")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("should convert png to data URL", async () => {
|
||||
const mockBuffer = Buffer.from("fake png data")
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(mockBuffer)
|
||||
|
||||
const result = await imageFileToDataUrl("/path/to/image.png")
|
||||
|
||||
expect(result).toMatch(/^data:image\/png;base64,/)
|
||||
expect(result).toContain(mockBuffer.toString("base64"))
|
||||
})
|
||||
|
||||
it("should use correct MIME type for jpeg", async () => {
|
||||
const mockBuffer = Buffer.from("fake jpeg data")
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(mockBuffer)
|
||||
|
||||
const result = await imageFileToDataUrl("/path/to/image.jpg")
|
||||
|
||||
expect(result).toMatch(/^data:image\/jpeg;base64,/)
|
||||
})
|
||||
|
||||
it("should use correct MIME type for gif", async () => {
|
||||
const mockBuffer = Buffer.from("fake gif data")
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(mockBuffer)
|
||||
|
||||
const result = await imageFileToDataUrl("/path/to/image.gif")
|
||||
|
||||
expect(result).toMatch(/^data:image\/gif;base64,/)
|
||||
})
|
||||
|
||||
it("should use correct MIME type for webp", async () => {
|
||||
const mockBuffer = Buffer.from("fake webp data")
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(mockBuffer)
|
||||
|
||||
const result = await imageFileToDataUrl("/path/to/image.webp")
|
||||
|
||||
expect(result).toMatch(/^data:image\/webp;base64,/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("processImagePaths", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(fs, "existsSync")
|
||||
vi.spyOn(fs.promises, "readFile")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("should process existing image files", async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true)
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(Buffer.from("image data"))
|
||||
|
||||
const result = await processImagePaths(["/path/to/image.png"])
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toMatch(/^data:image\/png;base64,/)
|
||||
})
|
||||
|
||||
it("should skip non-existent files", async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false)
|
||||
|
||||
const result = await processImagePaths(["/nonexistent/image.png"])
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should skip non-image files", async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true)
|
||||
|
||||
const result = await processImagePaths(["/path/to/file.txt"])
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should process multiple images", async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true)
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(Buffer.from("image data"))
|
||||
|
||||
const result = await processImagePaths(["/img1.png", "/img2.jpg", "/img3.gif"])
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
})
|
||||
|
||||
it("should handle read errors gracefully", async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true)
|
||||
vi.mocked(fs.promises.readFile).mockRejectedValue(new Error("Read error"))
|
||||
|
||||
const result = await processImagePaths(["/path/to/image.png"])
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should handle empty input", async () => {
|
||||
const result = await processImagePaths([])
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
|
||||
export function jsonParseSafe<T>(data: string, defaultValue: T): T {
|
||||
try {
|
||||
return JSON.parse(data) as T
|
||||
} catch {
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"])
|
||||
|
||||
/**
|
||||
* Check if a file path is an image based on extension
|
||||
*/
|
||||
export function isImagePath(filePath: string): boolean {
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
return IMAGE_EXTENSIONS.has(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MIME type for an image extension
|
||||
*/
|
||||
function getMimeType(ext: string): string {
|
||||
const mimeTypes: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
}
|
||||
return mimeTypes[ext.toLowerCase()] || "image/png"
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an image file path to a base64 data URL
|
||||
*/
|
||||
export async function imageFileToDataUrl(filePath: string): Promise<string> {
|
||||
const resolvedPath = path.resolve(filePath)
|
||||
const ext = path.extname(resolvedPath).toLowerCase()
|
||||
const mimeType = getMimeType(ext)
|
||||
|
||||
const buffer = await fs.promises.readFile(resolvedPath)
|
||||
const base64 = buffer.toString("base64")
|
||||
|
||||
return `data:${mimeType};base64,${base64}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse input text and extract image file paths.
|
||||
* Supports formats like: "prompt text @/path/to/image.png" or just file paths
|
||||
* Returns the clean prompt text and array of image paths
|
||||
*/
|
||||
export function parseImagesFromInput(input: string): { prompt: string; imagePaths: string[] } {
|
||||
const imagePaths: string[] = []
|
||||
|
||||
// Match @/path/to/image.ext patterns (with space or at start)
|
||||
const atPathPattern = /(?:^|\s)@(\/[^\s]+\.(?:png|jpg|jpeg|gif|webp))/gi
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = atPathPattern.exec(input)) !== null) {
|
||||
imagePaths.push(match[1])
|
||||
}
|
||||
|
||||
// Also match standalone absolute paths that look like images
|
||||
const standalonePathPattern = /(?:^|\s)(\/[^\s]+\.(?:png|jpg|jpeg|gif|webp))(?:\s|$)/gi
|
||||
while ((match = standalonePathPattern.exec(input)) !== null) {
|
||||
const p = match[1]
|
||||
if (!imagePaths.includes(p)) {
|
||||
imagePaths.push(p)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the image references from the prompt
|
||||
const prompt = input.replace(atPathPattern, " ").replace(standalonePathPattern, " ").replace(/\s+/g, " ").trim()
|
||||
|
||||
return { prompt, imagePaths }
|
||||
}
|
||||
|
||||
/**
|
||||
* Process image file paths into base64 data URLs
|
||||
* Returns only successfully converted images
|
||||
*/
|
||||
export async function processImagePaths(imagePaths: string[]): Promise<string[]> {
|
||||
const dataUrls: string[] = []
|
||||
|
||||
for (const imagePath of imagePaths) {
|
||||
try {
|
||||
const resolvedPath = path.resolve(imagePath)
|
||||
if (fs.existsSync(resolvedPath) && isImagePath(resolvedPath)) {
|
||||
const dataUrl = await imageFileToDataUrl(resolvedPath)
|
||||
dataUrls.push(dataUrl)
|
||||
}
|
||||
} catch {
|
||||
// Skip files that can't be read
|
||||
}
|
||||
}
|
||||
|
||||
return dataUrls
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
|
||||
const data = process.env.CLINE_DATA_DIR ?? path.join(os.homedir(), ".cline", "data")
|
||||
|
||||
const log = process.env.CLINE_LOG_DIR ?? path.join(data, "logs")
|
||||
|
||||
export const CLINE_CLI_DIR = {
|
||||
data,
|
||||
log,
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import { Readable } from "node:stream"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { readStdinIfPiped } from "./piped"
|
||||
|
||||
describe("readStdinIfPiped", () => {
|
||||
let mockStdin: Readable & { isTTY?: boolean }
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a mock readable stream
|
||||
mockStdin = new Readable({
|
||||
read() {},
|
||||
}) as Readable & { isTTY?: boolean }
|
||||
|
||||
// Mock process.stdin by stubbing its properties
|
||||
vi.spyOn(process, "stdin", "get").mockReturnValue(mockStdin as any)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe("TTY detection", () => {
|
||||
it("should return null when stdin is a TTY (interactive terminal)", async () => {
|
||||
mockStdin.isTTY = true
|
||||
const result = await readStdinIfPiped()
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should attempt to read when stdin is not a TTY (piped input)", async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
// Simulate immediate end event (no data)
|
||||
setImmediate(() => {
|
||||
mockStdin.emit("end")
|
||||
})
|
||||
|
||||
const result = await readStdinIfPiped()
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("piped input scenarios", () => {
|
||||
interface TestCase {
|
||||
name: string
|
||||
input: string | string[]
|
||||
expected: string | null
|
||||
description?: string
|
||||
}
|
||||
|
||||
const testCases: TestCase[] = [
|
||||
{
|
||||
name: "single line input",
|
||||
input: "echo hello",
|
||||
expected: "echo hello",
|
||||
description: "should read and return single line",
|
||||
},
|
||||
{
|
||||
name: "multi-line input",
|
||||
input: ["line 1", "line 2", "line 3"],
|
||||
expected: "line 1\nline 2\nline 3",
|
||||
description: "should read and join multiple lines",
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
expected: null,
|
||||
description: "should return null for empty input",
|
||||
},
|
||||
{
|
||||
name: "whitespace only",
|
||||
input: " \n \t \n ",
|
||||
expected: null,
|
||||
description: "should return null for whitespace-only input",
|
||||
},
|
||||
{
|
||||
name: "leading and trailing whitespace",
|
||||
input: " hello world \n",
|
||||
expected: "hello world",
|
||||
description: "should trim leading and trailing whitespace",
|
||||
},
|
||||
{
|
||||
name: "large input",
|
||||
input: "a".repeat(10000),
|
||||
expected: "a".repeat(10000),
|
||||
description: "should handle large input",
|
||||
},
|
||||
{
|
||||
name: "special characters",
|
||||
input: "!@#$%^&*(){}[]|\\:;\"'<>?,./",
|
||||
expected: "!@#$%^&*(){}[]|\\:;\"'<>?,./",
|
||||
description: "should preserve special characters",
|
||||
},
|
||||
{
|
||||
name: "unicode characters",
|
||||
input: "Hello 世界 🌍 مرحبا",
|
||||
expected: "Hello 世界 🌍 مرحبا",
|
||||
description: "should handle unicode characters correctly",
|
||||
},
|
||||
{
|
||||
name: "JSON input",
|
||||
input: '{"key": "value", "nested": {"data": 123}}',
|
||||
expected: '{"key": "value", "nested": {"data": 123}}',
|
||||
description: "should preserve JSON structure",
|
||||
},
|
||||
{
|
||||
name: "code snippet",
|
||||
input: ["function test() {", ' console.log("hello")', "}"],
|
||||
expected: 'function test() {\n console.log("hello")\n}',
|
||||
description: "should preserve code structure with indentation",
|
||||
},
|
||||
]
|
||||
|
||||
testCases.forEach(({ name, input, expected, description }) => {
|
||||
it(`${name}${description ? ` - ${description}` : ""}`, async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
// Simulate piped data
|
||||
setImmediate(() => {
|
||||
const data = Array.isArray(input) ? input.join("\n") : input
|
||||
mockStdin.push(data)
|
||||
mockStdin.push(null) // Signal end of stream
|
||||
})
|
||||
|
||||
const result = await readStdinIfPiped()
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("chunked data", () => {
|
||||
it("should accumulate data from multiple chunks", async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
setImmediate(() => {
|
||||
mockStdin.emit("data", "chunk1 ")
|
||||
mockStdin.emit("data", "chunk2 ")
|
||||
mockStdin.emit("data", "chunk3")
|
||||
mockStdin.emit("end")
|
||||
})
|
||||
|
||||
const result = await readStdinIfPiped()
|
||||
expect(result).toBe("chunk1 chunk2 chunk3")
|
||||
})
|
||||
|
||||
it("should handle rapid successive chunks", async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
setImmediate(() => {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
mockStdin.emit("data", `${i} `)
|
||||
}
|
||||
mockStdin.emit("end")
|
||||
})
|
||||
|
||||
const result = await readStdinIfPiped()
|
||||
expect(result).toContain("0 ")
|
||||
expect(result).toContain("99")
|
||||
})
|
||||
})
|
||||
|
||||
describe("timeout behavior", () => {
|
||||
it("should timeout after 100ms if no data received", async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
// Don't emit any events - let it timeout
|
||||
|
||||
const startTime = Date.now()
|
||||
const result = await readStdinIfPiped()
|
||||
const elapsed = Date.now() - startTime
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(elapsed).toBeGreaterThanOrEqual(95) // Allow small margin
|
||||
expect(elapsed).toBeLessThan(150)
|
||||
})
|
||||
|
||||
it("should return data received before timeout", async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
setTimeout(() => {
|
||||
mockStdin.emit("data", "quick data")
|
||||
// Don't emit end - let it timeout
|
||||
}, 50)
|
||||
|
||||
const result = await readStdinIfPiped()
|
||||
expect(result).toBe("quick data")
|
||||
})
|
||||
|
||||
it("should not timeout if end event is received", async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
// Delay end event but emit it before timeout
|
||||
setTimeout(() => {
|
||||
mockStdin.emit("data", "delayed data")
|
||||
mockStdin.emit("end")
|
||||
}, 50)
|
||||
|
||||
const result = await readStdinIfPiped()
|
||||
expect(result).toBe("delayed data")
|
||||
})
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should return null on stdin error", async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
setImmediate(() => {
|
||||
mockStdin.emit("error", new Error("stdin read error"))
|
||||
})
|
||||
|
||||
const result = await readStdinIfPiped()
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should handle error after partial data received", async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
setImmediate(() => {
|
||||
mockStdin.emit("data", "partial data")
|
||||
mockStdin.emit("error", new Error("read error"))
|
||||
})
|
||||
|
||||
const result = await readStdinIfPiped()
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should clean up listeners on error", async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
setImmediate(() => {
|
||||
mockStdin.emit("error", new Error("test error"))
|
||||
})
|
||||
|
||||
await readStdinIfPiped()
|
||||
|
||||
// Note: Implementation uses removeAllListeners() without event names
|
||||
// which should remove all listeners, but in practice there may be one remaining
|
||||
// This is acceptable behavior for error handling
|
||||
expect(mockStdin.listenerCount("data")).toBeLessThanOrEqual(1)
|
||||
expect(mockStdin.listenerCount("end")).toBeLessThanOrEqual(1)
|
||||
expect(mockStdin.listenerCount("error")).toBeLessThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("listener cleanup", () => {
|
||||
it("should remove all listeners on successful completion", async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
setImmediate(() => {
|
||||
mockStdin.emit("data", "test data")
|
||||
mockStdin.emit("end")
|
||||
})
|
||||
|
||||
await readStdinIfPiped()
|
||||
|
||||
// Note: Implementation doesn't explicitly clean up listeners on normal end,
|
||||
// so some listeners may remain attached. This is acceptable for one-time use.
|
||||
expect(mockStdin.listenerCount("data")).toBeLessThanOrEqual(1)
|
||||
expect(mockStdin.listenerCount("end")).toBeLessThanOrEqual(1)
|
||||
expect(mockStdin.listenerCount("error")).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
it("should remove all listeners on timeout", async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
// Let it timeout
|
||||
await readStdinIfPiped()
|
||||
|
||||
// Verify listeners are cleaned up
|
||||
expect(mockStdin.listenerCount("data")).toBe(0)
|
||||
expect(mockStdin.listenerCount("end")).toBe(0)
|
||||
expect(mockStdin.listenerCount("error")).toBe(0)
|
||||
})
|
||||
|
||||
it("should clear timeout when data ends normally", async () => {
|
||||
mockStdin.isTTY = false
|
||||
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout")
|
||||
|
||||
setImmediate(() => {
|
||||
mockStdin.emit("data", "test")
|
||||
mockStdin.emit("end")
|
||||
})
|
||||
|
||||
await readStdinIfPiped()
|
||||
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should clear timeout when error occurs", async () => {
|
||||
mockStdin.isTTY = false
|
||||
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout")
|
||||
|
||||
setImmediate(() => {
|
||||
mockStdin.emit("error", new Error("test"))
|
||||
})
|
||||
|
||||
await readStdinIfPiped()
|
||||
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("encoding", () => {
|
||||
it("should handle UTF-8 encoded data", async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
setImmediate(() => {
|
||||
// The function sets utf8 encoding
|
||||
mockStdin.setEncoding("utf8")
|
||||
mockStdin.emit("data", "UTF-8: café ☕")
|
||||
mockStdin.emit("end")
|
||||
})
|
||||
|
||||
const result = await readStdinIfPiped()
|
||||
expect(result).toBe("UTF-8: café ☕")
|
||||
})
|
||||
})
|
||||
|
||||
describe("stdin resume", () => {
|
||||
it("should call resume on stdin when not TTY", async () => {
|
||||
mockStdin.isTTY = false
|
||||
const resumeSpy = vi.spyOn(mockStdin, "resume")
|
||||
|
||||
setImmediate(() => {
|
||||
mockStdin.emit("end")
|
||||
})
|
||||
|
||||
await readStdinIfPiped()
|
||||
|
||||
expect(resumeSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should not call resume when TTY", async () => {
|
||||
mockStdin.isTTY = true
|
||||
const resumeSpy = vi.spyOn(mockStdin, "resume")
|
||||
|
||||
const result = await readStdinIfPiped()
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(resumeSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("real-world use cases", () => {
|
||||
interface UseCaseTest {
|
||||
name: string
|
||||
input: string
|
||||
expected: string | null
|
||||
}
|
||||
|
||||
const useCases: UseCaseTest[] = [
|
||||
{
|
||||
name: "git diff output",
|
||||
input: "diff --git a/file.ts b/file.ts\nindex 123..456\n--- a/file.ts\n+++ b/file.ts",
|
||||
expected: "diff --git a/file.ts b/file.ts\nindex 123..456\n--- a/file.ts\n+++ b/file.ts",
|
||||
},
|
||||
{
|
||||
name: "curl JSON response",
|
||||
input: '{"status": "ok", "data": [1, 2, 3]}',
|
||||
expected: '{"status": "ok", "data": [1, 2, 3]}',
|
||||
},
|
||||
{
|
||||
name: "cat file contents",
|
||||
input: "export function test() {\n return true\n}",
|
||||
expected: "export function test() {\n return true\n}",
|
||||
},
|
||||
{
|
||||
name: "echo command with newline",
|
||||
input: "Hello World\n",
|
||||
expected: "Hello World",
|
||||
},
|
||||
{
|
||||
name: "command output with ANSI codes",
|
||||
input: "\x1b[32mSUCCESS\x1b[0m",
|
||||
expected: "\x1b[32mSUCCESS\x1b[0m",
|
||||
},
|
||||
]
|
||||
|
||||
useCases.forEach(({ name, input, expected }) => {
|
||||
it(`should handle ${name}`, async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
setImmediate(() => {
|
||||
mockStdin.push(input)
|
||||
mockStdin.push(null)
|
||||
})
|
||||
|
||||
const result = await readStdinIfPiped()
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as fs from "node:fs"
|
||||
|
||||
/**
|
||||
* Read piped input from stdin (non-blocking)
|
||||
*/
|
||||
export async function readStdinIfPiped(): Promise<string | null> {
|
||||
// Check if stdin is a TTY (interactive) or piped
|
||||
if (process.stdin.isTTY) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
// Use synchronous read for reliability with piped input
|
||||
// fd 0 is stdin
|
||||
const data = fs.readFileSync(0, "utf8")
|
||||
return data.trim() || null
|
||||
} catch {
|
||||
// Fallback to async approach if sync read fails
|
||||
return new Promise((resolve) => {
|
||||
let data = ""
|
||||
process.stdin.setEncoding("utf8")
|
||||
|
||||
// Set a timeout in case stdin is not actually providing data
|
||||
const timeout = setTimeout(() => {
|
||||
process.stdin.removeAllListeners()
|
||||
resolve(data.trim() || null)
|
||||
}, 1000)
|
||||
|
||||
process.stdin.on("data", (chunk) => {
|
||||
data += chunk
|
||||
})
|
||||
|
||||
process.stdin.on("end", () => {
|
||||
clearTimeout(timeout)
|
||||
resolve(data.trim() || null)
|
||||
})
|
||||
|
||||
process.stdin.on("error", () => {
|
||||
clearTimeout(timeout)
|
||||
resolve(null)
|
||||
})
|
||||
|
||||
// Resume stdin in case it's paused
|
||||
process.stdin.resume()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Plain-text task runner for non-TTY environments (piped output, file redirection)
|
||||
* Outputs clean text without ANSI codes or Ink rendering
|
||||
*/
|
||||
|
||||
/* eslint-disable no-console */
|
||||
// Console output is intentional here for plain text mode
|
||||
|
||||
import { registerPartialMessageCallback } from "@core/controller/ui/subscribeToPartialMessage"
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import type { Controller } from "@/core/controller"
|
||||
import { setTerminalTitle } from "./display"
|
||||
|
||||
export interface PlainTextTaskOptions {
|
||||
controller: Controller
|
||||
prompt: string
|
||||
imageDataUrls?: string[]
|
||||
verbose?: boolean
|
||||
jsonOutput?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a task with plain text output (no Ink, no ANSI codes)
|
||||
* Returns true if task completed successfully, false if error
|
||||
*/
|
||||
export async function runPlainTextTask(options: PlainTextTaskOptions): Promise<boolean> {
|
||||
const { controller, prompt, imageDataUrls, verbose, jsonOutput } = options
|
||||
|
||||
// Track completion state
|
||||
let isComplete = false
|
||||
let hasError = false
|
||||
const processedMessages = new Map<number, number>() // index -> last output text length
|
||||
let lastStreamingMessageIndex = -1 // track open streaming line that needs closing
|
||||
|
||||
// Subscribe to state updates
|
||||
const originalPostState = controller.postStateToWebview.bind(controller)
|
||||
|
||||
const handleStateUpdate = async () => {
|
||||
try {
|
||||
const state = await controller.getStateToPostToWebview()
|
||||
const messages = state.clineMessages || []
|
||||
|
||||
// Process new messages
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i]
|
||||
const currentTextLength = message.text?.length ?? 0
|
||||
const lastOutputLength = processedMessages.get(i) ?? 0
|
||||
|
||||
// Skip if no new content to output
|
||||
if (currentTextLength <= lastOutputLength) continue
|
||||
|
||||
// Close previous streaming line if we're moving to a different message
|
||||
if (lastStreamingMessageIndex >= 0 && lastStreamingMessageIndex !== i && !jsonOutput) {
|
||||
process.stdout.write("\n")
|
||||
lastStreamingMessageIndex = -1
|
||||
}
|
||||
|
||||
processedMessages.set(i, currentTextLength)
|
||||
|
||||
// Output the message
|
||||
if (jsonOutput) {
|
||||
process.stdout.write(JSON.stringify(message) + "\n")
|
||||
} else {
|
||||
const isStreaming = outputMessageAsText(message, verbose || false, lastOutputLength)
|
||||
// Track streaming state for text messages
|
||||
if (isStreaming) {
|
||||
lastStreamingMessageIndex = i
|
||||
} else {
|
||||
lastStreamingMessageIndex = -1
|
||||
}
|
||||
}
|
||||
|
||||
// Check for completion
|
||||
if (
|
||||
message.say === "completion_result" ||
|
||||
message.ask === "completion_result" ||
|
||||
message.say === "error" ||
|
||||
message.ask === "api_req_failed"
|
||||
) {
|
||||
isComplete = true
|
||||
if (message.say === "error" || message.ask === "api_req_failed") {
|
||||
hasError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close streaming line on completion
|
||||
if (isComplete && lastStreamingMessageIndex >= 0 && !jsonOutput) {
|
||||
process.stdout.write("\n")
|
||||
lastStreamingMessageIndex = -1
|
||||
}
|
||||
} catch (error) {
|
||||
if (jsonOutput) {
|
||||
process.stdout.write(
|
||||
JSON.stringify({ type: "error", message: error instanceof Error ? error.message : String(error) }) + "\n",
|
||||
)
|
||||
} else {
|
||||
process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}` + "\n")
|
||||
}
|
||||
hasError = true
|
||||
isComplete = true
|
||||
}
|
||||
}
|
||||
|
||||
// Override postStateToWebview to capture state updates
|
||||
controller.postStateToWebview = async () => {
|
||||
await originalPostState()
|
||||
await handleStateUpdate()
|
||||
}
|
||||
|
||||
// Subscribe to partial message updates (for streaming)
|
||||
const unsubscribePartial = registerPartialMessageCallback(() => {
|
||||
// Partial updates are handled via postStateToWebview
|
||||
})
|
||||
|
||||
try {
|
||||
// Get initial state
|
||||
await handleStateUpdate()
|
||||
|
||||
// Set terminal title to the task prompt
|
||||
setTerminalTitle(prompt)
|
||||
|
||||
// Start the task
|
||||
await controller.initTask(prompt, imageDataUrls)
|
||||
|
||||
// Wait for completion with timeout
|
||||
const timeout = 10 * 60 * 1000 // 10 minutes
|
||||
const startTime = Date.now()
|
||||
|
||||
while (!isComplete && Date.now() - startTime < timeout) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
|
||||
if (!isComplete) {
|
||||
// Close any open streaming line before error message
|
||||
if (lastStreamingMessageIndex >= 0 && !jsonOutput) {
|
||||
process.stdout.write("\n")
|
||||
lastStreamingMessageIndex = -1
|
||||
}
|
||||
if (jsonOutput) {
|
||||
process.stdout.write(JSON.stringify({ type: "error", message: "Task timeout" }) + "\n")
|
||||
} else {
|
||||
process.stderr.write("Error: Task timeout" + "\n")
|
||||
}
|
||||
hasError = true
|
||||
}
|
||||
} catch (error) {
|
||||
if (jsonOutput) {
|
||||
process.stdout.write(
|
||||
JSON.stringify({ type: "error", message: error instanceof Error ? error.message : String(error) }) + "\n",
|
||||
)
|
||||
} else {
|
||||
process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}` + "\n")
|
||||
}
|
||||
hasError = true
|
||||
} finally {
|
||||
// Close any open streaming line
|
||||
if (lastStreamingMessageIndex >= 0 && !jsonOutput) {
|
||||
process.stdout.write("\n")
|
||||
}
|
||||
// Restore original postStateToWebview
|
||||
controller.postStateToWebview = originalPostState
|
||||
unsubscribePartial()
|
||||
}
|
||||
|
||||
return !hasError
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Cline message as plain text
|
||||
* @param previousLength - Length of text already output for this message (for streaming)
|
||||
* @returns true if this is a streaming message (caller should track for newline), false otherwise
|
||||
*/
|
||||
function outputMessageAsText(message: ClineMessage, verbose: boolean, previousLength: number = 0): boolean {
|
||||
const timestamp = new Date(message.ts || Date.now()).toLocaleTimeString()
|
||||
const fullText = message.text ?? ""
|
||||
|
||||
if (!fullText) {
|
||||
// Skip partial messages without text
|
||||
return false
|
||||
}
|
||||
|
||||
// For streaming text continuations, output only new content
|
||||
if (previousLength > 0 && message.type === "say" && message.say === "text") {
|
||||
process.stdout.write(fullText.slice(previousLength))
|
||||
return true // Still streaming
|
||||
}
|
||||
|
||||
if (message.type === "say") {
|
||||
if (message.say === "task") {
|
||||
process.stdout.write(`[${timestamp}] Task: ${fullText}\n`)
|
||||
} else if (message.say === "text") {
|
||||
// First output of text message - write prefix but no newline (streaming)
|
||||
process.stdout.write(`[${timestamp}] ${fullText}`)
|
||||
return true // Streaming - newline will be added when stream ends
|
||||
} else if (message.say === "completion_result" && fullText) {
|
||||
process.stdout.write(`[${timestamp}] Completed: ${fullText}\n`)
|
||||
} else if (message.say === "error") {
|
||||
process.stderr.write(`[${timestamp}] Error: ${fullText}\n`)
|
||||
} else if (message.say === "api_req_started") {
|
||||
if (verbose) {
|
||||
process.stdout.write(`[${timestamp}] API request started\n`)
|
||||
}
|
||||
} else if (message.say === "api_req_finished") {
|
||||
if (verbose) {
|
||||
process.stdout.write(`[${timestamp}] API request finished\n`)
|
||||
}
|
||||
} else if (verbose) {
|
||||
process.stdout.write(`[${timestamp}] ${message.say}: ${fullText}\n`)
|
||||
}
|
||||
} else if (message.type === "ask") {
|
||||
if (message.ask === "completion_result") {
|
||||
process.stdout.write(`[${timestamp}] Task completed\n`)
|
||||
} else if (message.ask === "api_req_failed") {
|
||||
process.stderr.write(`[${timestamp}] API request failed: ${fullText}\n`)
|
||||
} else if (message.ask === "tool" || message.ask === "command" || message.ask === "browser_action_launch") {
|
||||
// These require approval - in non-interactive mode, warn the user
|
||||
process.stderr.write(`[${timestamp}] Waiting for approval (use --yolo for auto-approve): ${message.ask}\n`)
|
||||
} else if (verbose) {
|
||||
process.stdout.write(`[${timestamp}] Question: ${fullText}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Shared utility for applying provider configuration
|
||||
* Used by both AuthView (onboarding) and SettingsPanelContent (settings)
|
||||
*/
|
||||
|
||||
import { ProviderToApiKeyMap } from "@shared/storage"
|
||||
import { buildApiHandler } from "@/core/api"
|
||||
import type { Controller } from "@/core/controller"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { getDefaultModelId } from "../components/ModelPicker"
|
||||
|
||||
export interface ApplyProviderConfigOptions {
|
||||
providerId: string
|
||||
apiKey?: string
|
||||
modelId?: string // Override default model
|
||||
baseUrl?: string // For OpenAI-compatible providers
|
||||
controller?: Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply provider configuration to state and rebuild API handler if needed
|
||||
*/
|
||||
export async function applyProviderConfig(options: ApplyProviderConfigOptions): Promise<void> {
|
||||
const { providerId, apiKey, modelId, baseUrl, controller } = options
|
||||
const stateManager = StateManager.get()
|
||||
|
||||
const config: Record<string, string> = {
|
||||
actModeApiProvider: providerId,
|
||||
planModeApiProvider: providerId,
|
||||
}
|
||||
|
||||
// Add model ID (use provided or fall back to default)
|
||||
const finalModelId = modelId || getDefaultModelId(providerId)
|
||||
if (finalModelId) {
|
||||
config.actModeApiModelId = finalModelId
|
||||
config.planModeApiModelId = finalModelId
|
||||
}
|
||||
|
||||
// Add API key if provided (maps to provider-specific field like anthropicApiKey, openAiApiKey, etc.)
|
||||
if (apiKey) {
|
||||
const keyField = ProviderToApiKeyMap[providerId as keyof typeof ProviderToApiKeyMap]
|
||||
if (keyField) {
|
||||
const fields = Array.isArray(keyField) ? keyField : [keyField]
|
||||
config[fields[0]] = apiKey
|
||||
}
|
||||
}
|
||||
|
||||
// Add base URL if provided (for OpenAI-compatible providers)
|
||||
if (baseUrl) {
|
||||
config.openAiBaseUrl = baseUrl
|
||||
}
|
||||
|
||||
// Save via StateManager
|
||||
stateManager.setApiConfiguration(config)
|
||||
await stateManager.flushPendingState()
|
||||
|
||||
// Rebuild API handler on active task if one exists
|
||||
if (controller?.task) {
|
||||
const currentMode = stateManager.getGlobalSettingsKey("mode")
|
||||
const apiConfig = stateManager.getApiConfiguration()
|
||||
controller.task.api = buildApiHandler({ ...apiConfig, ulid: controller.task.ulid }, currentMode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
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`
|
||||
}
|
||||
|
||||
// ANSI color codes
|
||||
const GRAY = "\x1b[90m"
|
||||
const GREEN = "\x1b[32m"
|
||||
const RED = "\x1b[31m"
|
||||
const BOLD = "\x1b[1m"
|
||||
const RESET = "\x1b[0m"
|
||||
|
||||
/**
|
||||
* Print session summary to stdout using plain text (not Ink).
|
||||
* Used during shutdown when Ink may not have time to render.
|
||||
*/
|
||||
export function printSessionSummary(): void {
|
||||
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
|
||||
}
|
||||
|
||||
const startTime = session.formatTime(session.getStartTime())
|
||||
const endTime = session.formatTime(session.getEndTime())
|
||||
const sessionTimeStr = `${startTime} → ${endTime}`
|
||||
|
||||
const lines = [
|
||||
"",
|
||||
"┌─────────────────────────────────────────────────────────┐",
|
||||
`│ ${BOLD}Interaction Summary${RESET} │`,
|
||||
"├─────────────────────────────────────────────────────────┤",
|
||||
`│ ${GRAY}Session ID:${RESET} ${stats.sessionId.padEnd(42)}│`,
|
||||
`│ ${GRAY}Session Time:${RESET} ${sessionTimeStr.padEnd(42)}│`,
|
||||
`│ ${GRAY}Tool Calls:${RESET} ${stats.totalToolCalls} ( ${GREEN}✓ ${stats.successfulToolCalls}${RESET} ${RED}✗ ${stats.failedToolCalls}${RESET} )`.padEnd(
|
||||
70,
|
||||
) + "│",
|
||||
`│ ${GRAY}Success Rate:${RESET} ${session.getSuccessRate().toFixed(1)}%`.padEnd(60) + "│",
|
||||
"├─────────────────────────────────────────────────────────┤",
|
||||
`│ ${BOLD}Performance${RESET} │`,
|
||||
`│ ${GRAY}Wall Time:${RESET} ${formatDuration(wallTimeMs).padEnd(42)}│`,
|
||||
`│ ${GRAY}Agent Active:${RESET} ${formatDuration(agentActiveMs).padEnd(42)}│`,
|
||||
`│ ${GRAY} » API Time:${RESET} ${formatDuration(stats.apiTimeMs)} ${GRAY}(${formatPercent(stats.apiTimeMs, agentActiveMs)})${RESET}`.padEnd(
|
||||
60,
|
||||
) + "│",
|
||||
`│ ${GRAY} » Tool Time:${RESET} ${formatDuration(stats.toolTimeMs)} ${GRAY}(${formatPercent(stats.toolTimeMs, agentActiveMs)})${RESET}`.padEnd(
|
||||
60,
|
||||
) + "│",
|
||||
"├─────────────────────────────────────────────────────────┤",
|
||||
`│ ${BOLD}Resources${RESET} │`,
|
||||
`│ ${GRAY}Memory (RSS):${RESET} ${formatBytes(stats.resources.rss).padEnd(42)}│`,
|
||||
`│ ${GRAY}Peak Memory:${RESET} ${formatBytes(stats.peakMemoryBytes).padEnd(42)}│`,
|
||||
`│ ${GRAY}Heap Used:${RESET} ${formatBytes(stats.resources.heapUsed)} ${GRAY}/ ${formatBytes(stats.resources.heapTotal)}${RESET}`.padEnd(
|
||||
60,
|
||||
) + "│",
|
||||
`│ ${GRAY}CPU Time:${RESET} ${formatDuration(stats.resources.userCpuMs + stats.resources.systemCpuMs)} ${GRAY}(user: ${formatDuration(stats.resources.userCpuMs)}, sys: ${formatDuration(stats.resources.systemCpuMs)})${RESET}`.padEnd(
|
||||
60,
|
||||
) + "│",
|
||||
"└─────────────────────────────────────────────────────────┘",
|
||||
"",
|
||||
]
|
||||
|
||||
process.stdout.write(lines.join("\n"))
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Slash command utilities for CLI
|
||||
* Handles detection, filtering, and insertion of slash commands
|
||||
*/
|
||||
|
||||
import type { SlashCommandInfo } from "@shared/proto/cline/slash"
|
||||
import { fuzzyFilter } from "./fuzzy-search"
|
||||
|
||||
export interface SlashQueryInfo {
|
||||
inSlashMode: boolean
|
||||
query: string
|
||||
slashIndex: number
|
||||
}
|
||||
|
||||
export interface VisibleWindow<T> {
|
||||
items: T[]
|
||||
startIndex: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate visible window for a scrollable list menu.
|
||||
* Centers the selected item in the visible window when possible.
|
||||
* Returns the visible items and the start index for selection tracking.
|
||||
*/
|
||||
export function getVisibleWindow<T>(items: T[], selectedIndex: number, maxVisible: number = 5): VisibleWindow<T> {
|
||||
if (items.length <= maxVisible) {
|
||||
return { items, startIndex: 0 }
|
||||
}
|
||||
|
||||
const halfWindow = Math.floor(maxVisible / 2)
|
||||
let startIndex = Math.max(0, selectedIndex - halfWindow)
|
||||
const endIndex = Math.min(items.length, startIndex + maxVisible)
|
||||
|
||||
// Adjust if we're near the end
|
||||
if (endIndex - startIndex < maxVisible) {
|
||||
startIndex = Math.max(0, endIndex - maxVisible)
|
||||
}
|
||||
|
||||
return { items: items.slice(startIndex, endIndex), startIndex }
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort commands with workflows (custom section) first, then default commands.
|
||||
*/
|
||||
export function sortCommandsWorkflowsFirst(commands: SlashCommandInfo[]): SlashCommandInfo[] {
|
||||
return [...commands.filter((cmd) => cmd.section === "custom"), ...commands.filter((cmd) => cmd.section !== "custom")]
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract slash command query from input text.
|
||||
* Returns info about whether we're in slash mode and what the query is.
|
||||
* Takes cursor position to only examine text before cursor (matching webview behavior).
|
||||
*/
|
||||
export function extractSlashQuery(text: string, cursorPosition?: number): SlashQueryInfo {
|
||||
// Use text up to cursor position (or full text if no cursor position provided)
|
||||
const beforeCursor = cursorPosition !== undefined ? text.slice(0, cursorPosition) : text
|
||||
|
||||
// Find the last slash before cursor
|
||||
const slashIndex = beforeCursor.lastIndexOf("/")
|
||||
|
||||
if (slashIndex === -1) {
|
||||
return { inSlashMode: false, query: "", slashIndex: -1 }
|
||||
}
|
||||
|
||||
// Slash must be at start or preceded by whitespace
|
||||
const charBeforeSlash = slashIndex > 0 ? beforeCursor[slashIndex - 1] : null
|
||||
if (charBeforeSlash !== null && !/\s/.test(charBeforeSlash)) {
|
||||
return { inSlashMode: false, query: "", slashIndex: -1 }
|
||||
}
|
||||
|
||||
// Get text after slash (up to cursor)
|
||||
const textAfterSlash = beforeCursor.slice(slashIndex + 1)
|
||||
|
||||
// If there's whitespace after slash, we're not in slash mode anymore
|
||||
if (/\s/.test(textAfterSlash)) {
|
||||
return { inSlashMode: false, query: "", slashIndex: -1 }
|
||||
}
|
||||
|
||||
// Check if there's already a completed slash command earlier in the text
|
||||
// (only first slash command per message is processed)
|
||||
const firstSlashCommandRegex = /(^|\s)\/[a-zA-Z0-9_.-]+\s/
|
||||
const textBeforeCurrentSlash = text.slice(0, slashIndex)
|
||||
if (firstSlashCommandRegex.test(textBeforeCurrentSlash)) {
|
||||
return { inSlashMode: false, query: "", slashIndex: -1 }
|
||||
}
|
||||
|
||||
return {
|
||||
inSlashMode: true,
|
||||
query: textAfterSlash,
|
||||
slashIndex,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter commands using fuzzy matching
|
||||
*/
|
||||
export function filterCommands(commands: SlashCommandInfo[], query: string): SlashCommandInfo[] {
|
||||
if (!query) {
|
||||
return commands
|
||||
}
|
||||
return fuzzyFilter(commands, query, (cmd) => cmd.name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a slash command at the given slash index, replacing any partial query
|
||||
*/
|
||||
export function insertSlashCommand(text: string, slashIndex: number, commandName: string): string {
|
||||
const beforeSlash = text.slice(0, slashIndex)
|
||||
// Insert command with trailing space
|
||||
return `${beforeSlash}/${commandName} `
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Shared tool utilities for CLI components
|
||||
* Centralizes tool name handling and categorization
|
||||
*/
|
||||
|
||||
/**
|
||||
* Tools that perform file edits (create, modify, delete)
|
||||
* Used to determine when to show DiffView and skip dynamic rendering
|
||||
*/
|
||||
export const FILE_EDIT_TOOLS = new Set([
|
||||
"editedExistingFile",
|
||||
"newFileCreated",
|
||||
"replace_in_file",
|
||||
"write_to_file",
|
||||
"fileDeleted",
|
||||
])
|
||||
|
||||
/**
|
||||
* Tools that save/modify files (subset used for "Save" button label)
|
||||
*/
|
||||
export const FILE_SAVE_TOOLS = new Set(["editedExistingFile", "newFileCreated", "fileDeleted"])
|
||||
|
||||
/**
|
||||
* Check if a tool name is a file edit tool
|
||||
*/
|
||||
export function isFileEditTool(toolName: string | undefined): boolean {
|
||||
if (!toolName) return false
|
||||
return FILE_EDIT_TOOLS.has(toolName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool name is a file save tool (for button labeling)
|
||||
*/
|
||||
export function isFileSaveTool(toolName: string | undefined): boolean {
|
||||
if (!toolName) return false
|
||||
return FILE_SAVE_TOOLS.has(toolName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize tool name to snake_case for consistent lookups
|
||||
* Handles both camelCase (readFile) and snake_case (read_file) inputs
|
||||
*/
|
||||
export function normalizeToolName(toolName: string): string {
|
||||
// Convert camelCase to snake_case
|
||||
return toolName.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool descriptions for display
|
||||
* Uses snake_case keys - use normalizeToolName() before lookup
|
||||
*/
|
||||
export const TOOL_DESCRIPTIONS: Record<string, { ask: string; say: string }> = {
|
||||
// File operations
|
||||
read_file: { ask: "wants to read this file", say: "read this file" },
|
||||
write_to_file: { ask: "wants to create a new file", say: "created a new file" },
|
||||
new_file_created: { ask: "wants to create a new file", say: "created a new file" },
|
||||
replace_in_file: { ask: "wants to edit this file", say: "edited this file" },
|
||||
edited_existing_file: { ask: "wants to edit this file", say: "edited this file" },
|
||||
|
||||
// Directory operations
|
||||
list_files: { ask: "wants to view files in this directory", say: "viewed files in this directory" },
|
||||
list_files_top_level: { ask: "wants to view files in this directory", say: "viewed files in this directory" },
|
||||
list_files_recursive: {
|
||||
ask: "wants to recursively view all files in this directory",
|
||||
say: "recursively viewed all files in this directory",
|
||||
},
|
||||
list_code_definition_names: {
|
||||
ask: "wants to view code definitions in this directory",
|
||||
say: "viewed code definitions in this directory",
|
||||
},
|
||||
search_files: { ask: "wants to search files", say: "searched files" },
|
||||
|
||||
// Command execution
|
||||
execute_command: { ask: "wants to execute this command", say: "executed this command" },
|
||||
|
||||
// Browser
|
||||
browser_action: { ask: "wants to use the browser", say: "used the browser" },
|
||||
|
||||
// MCP
|
||||
use_mcp_tool: { ask: "wants to use an MCP tool", say: "used an MCP tool" },
|
||||
access_mcp_resource: { ask: "wants to access an MCP resource", say: "accessed an MCP resource" },
|
||||
|
||||
// Web
|
||||
web_fetch: { ask: "wants to fetch content from this URL", say: "fetched content from this URL" },
|
||||
web_search: { ask: "wants to search the web", say: "searched the web" },
|
||||
|
||||
// Other
|
||||
ask_followup_question: { ask: "wants to ask a question", say: "asked a question" },
|
||||
attempt_completion: { ask: "wants to complete the task", say: "completed the task" },
|
||||
new_task: { ask: "wants to create a new task", say: "created a new task" },
|
||||
focus_chain: { ask: "wants to update the todo list", say: "updated the todo list" },
|
||||
}
|
||||
|
||||
/**
|
||||
* Default description for unknown tools
|
||||
*/
|
||||
export const DEFAULT_TOOL_DESCRIPTION = {
|
||||
ask: "wants to use a tool",
|
||||
say: "used a tool",
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tool description with normalized lookup
|
||||
*/
|
||||
export function getToolDescription(toolName: string): { ask: string; say: string } {
|
||||
const normalized = normalizeToolName(toolName)
|
||||
return TOOL_DESCRIPTIONS[normalized] || DEFAULT_TOOL_DESCRIPTION
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parse JSON from message text
|
||||
* Returns the parsed object or a default value if parsing fails
|
||||
*/
|
||||
export function parseMessageJson<T>(text: string | undefined, defaultValue: T): T {
|
||||
if (!text) return defaultValue
|
||||
try {
|
||||
return JSON.parse(text) as T
|
||||
} catch {
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse tool info from message text
|
||||
*/
|
||||
export function parseToolFromMessage(
|
||||
text: string | undefined,
|
||||
): { toolName: string; args: Record<string, unknown>; result?: string } | null {
|
||||
if (!text) return null
|
||||
try {
|
||||
const parsed = JSON.parse(text)
|
||||
if (parsed.tool) {
|
||||
return {
|
||||
toolName: parsed.tool,
|
||||
args: parsed,
|
||||
result: parsed.content || parsed.output,
|
||||
}
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { spawn } from "node:child_process"
|
||||
import { exit } from "node:process"
|
||||
import { fetch } from "@/shared/net"
|
||||
import { printInfo, printWarning } from "./display"
|
||||
|
||||
/**
|
||||
* Check for updates and install if available
|
||||
*/
|
||||
export async function checkForUpdates(currentVersion: string, options?: { verbose?: boolean }) {
|
||||
printInfo("Checking for updates...")
|
||||
|
||||
try {
|
||||
// Fetch latest version from npm registry
|
||||
const response = await fetch("https://registry.npmjs.org/cline/latest")
|
||||
if (!response.ok && response.statusText !== "OK") {
|
||||
printWarning(`Failed to check for updates: ${response.statusText}`)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { version: string }
|
||||
const latestVersion = data.version
|
||||
|
||||
if (options?.verbose) {
|
||||
printInfo(`Current version: ${currentVersion}`)
|
||||
printInfo(`Latest version: ${latestVersion}`)
|
||||
}
|
||||
|
||||
// Compare versions
|
||||
if (latestVersion === currentVersion) {
|
||||
printInfo(`You are already on the latest version (${currentVersion})`)
|
||||
exit(0)
|
||||
}
|
||||
|
||||
// Check if current is newer (dev version)
|
||||
if (compareVersions(currentVersion, latestVersion) > 0) {
|
||||
printInfo(`You are already on a newer version ${currentVersion} (latest: ${latestVersion})`)
|
||||
exit(0)
|
||||
}
|
||||
|
||||
printInfo(`New version available: ${latestVersion} (current: ${currentVersion})`)
|
||||
// Ask user to confirm update
|
||||
const userConfirmed = new Promise<boolean>((resolve) => {
|
||||
process.stdout.write("Do you want to update now? (y/N): ")
|
||||
process.stdin.setEncoding("utf-8")
|
||||
process.stdin.once("data", (dataBuff) => {
|
||||
const input = dataBuff.toString().trim().toLowerCase()
|
||||
resolve(input === "y" || input === "yes")
|
||||
})
|
||||
})
|
||||
|
||||
if (!(await userConfirmed)) {
|
||||
exit(0)
|
||||
}
|
||||
|
||||
printInfo("Installing update...")
|
||||
|
||||
// Run npm install -g cline@latest
|
||||
const npmProcess = spawn("npm", ["install", "-g", "cline@latest"], {
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
// Ensures the process uses the same environment
|
||||
env: process.env,
|
||||
detached: false,
|
||||
windowsHide: true,
|
||||
})
|
||||
|
||||
npmProcess.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
printInfo(`Successfully updated to version ${latestVersion}`)
|
||||
exit(0)
|
||||
} else {
|
||||
printWarning("Update failed. Please try running: npm install -g cline@latest")
|
||||
exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
npmProcess.on("error", (err) => {
|
||||
printWarning(`Failed to run npm install: ${err.message}`)
|
||||
printInfo("Please try running manually: npm install -g cline@latest")
|
||||
exit(1)
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
printWarning(`Error checking for updates: ${message}`)
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two semantic version strings
|
||||
* Returns: 1 if v1 > v2, -1 if v1 < v2, 0 if equal
|
||||
*/
|
||||
function compareVersions(v1: string, v2: string): number {
|
||||
const parts1 = v1.split(".").map(Number)
|
||||
const parts2 = v2.split(".").map(Number)
|
||||
|
||||
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
|
||||
const part1 = parts1[i] || 0
|
||||
const part2 = parts2[i] || 0
|
||||
|
||||
if (part1 > part2) return 1
|
||||
if (part1 < part2) return -1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* VSCode context stub for CLI mode
|
||||
* Provides mock implementations of VSCode extension context
|
||||
*/
|
||||
|
||||
import { mkdirSync } from "node:fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { ExtensionRegistryInfo } from "@/registry"
|
||||
import { ClineExtensionContext } from "@/shared/cline"
|
||||
import { ClineFileStorage } from "@/shared/storage"
|
||||
import { EnvironmentVariableCollection, ExtensionKind, ExtensionMode, readJson, URI } from "./vscode-shim"
|
||||
|
||||
const SETTINGS_SUBFOLDER = "data"
|
||||
|
||||
/**
|
||||
* CLI-specific state overrides.
|
||||
* These values are always returned regardless of what's stored,
|
||||
* and writes to these keys are silently ignored.
|
||||
*/
|
||||
const CLI_STATE_OVERRIDES: Record<string, any> = {
|
||||
// CLI always uses background execution, not VSCode terminal
|
||||
vscodeTerminalExecutionMode: "backgroundExec",
|
||||
backgroundEditEnabled: true,
|
||||
multiRootEnabled: false,
|
||||
enableCheckpointsSetting: false,
|
||||
browserSettings: {
|
||||
disableToolUse: true,
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* File-based Memento store with optional key overrides.
|
||||
* Implements VSCode's Memento interface using SyncJsonFileStorage.
|
||||
*/
|
||||
class MementoStore extends ClineFileStorage {
|
||||
private overrides: Record<string, any>
|
||||
|
||||
constructor(filePath: string, overrides: Record<string, any> = {}) {
|
||||
super(filePath, "MementoStore")
|
||||
this.overrides = overrides
|
||||
}
|
||||
|
||||
// VSCode Memento interface - override base class get() with overload support
|
||||
override get<T>(key: string): T | undefined
|
||||
override get<T>(key: string, defaultValue: T): T
|
||||
override get<T>(key: string, defaultValue?: T): T | undefined {
|
||||
if (key in this.overrides) {
|
||||
return this.overrides[key] as T
|
||||
}
|
||||
const value = super.get<T>(key)
|
||||
return value !== undefined ? value : defaultValue
|
||||
}
|
||||
|
||||
override async update(key: string, value: any): Promise<void> {
|
||||
if (key in this.overrides) {
|
||||
return
|
||||
}
|
||||
this.set(key, value)
|
||||
}
|
||||
|
||||
setKeysForSync(_keys: readonly string[]): void {
|
||||
// No-op for CLI
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* File-based secret storage implementing VSCode's SecretStorage interface.
|
||||
* Uses sync storage internally but exposes async API for VSCode compatibility.
|
||||
*/
|
||||
class SecretStore {
|
||||
private storage: ClineFileStorage<string>
|
||||
private onDidChangeEmitter = {
|
||||
event: () => ({ dispose: () => {} }),
|
||||
fire: (_e: any) => {},
|
||||
dispose: () => {},
|
||||
}
|
||||
|
||||
onDidChange = this.onDidChangeEmitter.event
|
||||
|
||||
constructor(filePath: string) {
|
||||
this.storage = new ClineFileStorage<string>(filePath, "SecretStore")
|
||||
}
|
||||
|
||||
get(key: string): Promise<string | undefined> {
|
||||
return Promise.resolve(this.storage.get(key))
|
||||
}
|
||||
|
||||
store(key: string, value: string): Promise<void> {
|
||||
this.storage.set(key, value)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
delete(key: string): Promise<void> {
|
||||
this.storage.delete(key)
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
export interface CliContextConfig {
|
||||
clineDir?: string
|
||||
/** The workspace directory being worked in (for hashing into storage path) */
|
||||
workspaceDir?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a short hash of a string for use in directory names
|
||||
*/
|
||||
function hashString(str: string): string {
|
||||
let hash = 0
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i)
|
||||
hash = (hash << 5) - hash + char
|
||||
hash = hash & hash // Convert to 32bit integer
|
||||
}
|
||||
return Math.abs(hash).toString(16).substring(0, 8)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the VSCode-like context for CLI mode
|
||||
*/
|
||||
export function initializeCliContext(config: CliContextConfig = {}) {
|
||||
const CLINE_DIR = config.clineDir || process.env.CLINE_DIR || path.join(os.homedir(), ".cline")
|
||||
const DATA_DIR = path.join(CLINE_DIR, SETTINGS_SUBFOLDER)
|
||||
|
||||
// Workspace storage should always be under ~/.cline/data/workspaces/<hash>/
|
||||
// where hash is derived from the workspace path to keep workspaces isolated
|
||||
const workspacePath = config.workspaceDir || process.cwd()
|
||||
const workspaceHash = hashString(workspacePath)
|
||||
const WORKSPACE_STORAGE_DIR = process.env.WORKSPACE_STORAGE_DIR || path.join(DATA_DIR, "workspaces", workspaceHash)
|
||||
|
||||
// Ensure directories exist
|
||||
mkdirSync(DATA_DIR, { recursive: true })
|
||||
mkdirSync(WORKSPACE_STORAGE_DIR, { recursive: true })
|
||||
|
||||
// For CLI, extension dir is the root of the project (parent of cli-ts)
|
||||
const EXTENSION_DIR = path.resolve(__dirname, "..", "..")
|
||||
const EXTENSION_MODE = process.env.IS_DEV === "true" ? ExtensionMode.Development : ExtensionMode.Production
|
||||
|
||||
const extension: ClineExtensionContext["extension"] = {
|
||||
id: ExtensionRegistryInfo.id,
|
||||
isActive: true,
|
||||
extensionPath: EXTENSION_DIR,
|
||||
extensionUri: URI.file(EXTENSION_DIR),
|
||||
packageJSON: readJson(path.join(EXTENSION_DIR, "package.json")),
|
||||
exports: undefined,
|
||||
activate: async () => {},
|
||||
extensionKind: ExtensionKind.UI,
|
||||
}
|
||||
|
||||
const extensionContext: ClineExtensionContext = {
|
||||
extension: extension,
|
||||
extensionMode: EXTENSION_MODE,
|
||||
|
||||
// Set up KV stores (globalState has CLI-specific overrides)
|
||||
globalState: new MementoStore(path.join(DATA_DIR, "globalState.json"), CLI_STATE_OVERRIDES),
|
||||
secrets: new SecretStore(path.join(DATA_DIR, "secrets.json")),
|
||||
|
||||
// Set up URIs
|
||||
storageUri: URI.file(WORKSPACE_STORAGE_DIR),
|
||||
storagePath: WORKSPACE_STORAGE_DIR,
|
||||
globalStorageUri: URI.file(DATA_DIR),
|
||||
globalStoragePath: DATA_DIR,
|
||||
|
||||
// Logs
|
||||
logUri: URI.file(DATA_DIR),
|
||||
logPath: DATA_DIR,
|
||||
|
||||
extensionUri: URI.file(EXTENSION_DIR),
|
||||
extensionPath: EXTENSION_DIR,
|
||||
asAbsolutePath: (relPath: string) => path.join(EXTENSION_DIR, relPath),
|
||||
|
||||
subscriptions: [],
|
||||
|
||||
environmentVariableCollection: new EnvironmentVariableCollection() as any,
|
||||
|
||||
// Workspace state
|
||||
workspaceState: new MementoStore(path.join(WORKSPACE_STORAGE_DIR, "workspaceState.json")),
|
||||
}
|
||||
|
||||
return {
|
||||
extensionContext,
|
||||
DATA_DIR,
|
||||
EXTENSION_DIR,
|
||||
WORKSPACE_STORAGE_DIR,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* VSCode namespace shim for CLI mode
|
||||
* Provides minimal stubs for VSCode types and enums used by the codebase
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import pino, { type Logger } from "pino"
|
||||
import { printError, printInfo, printWarning } from "./utils/display"
|
||||
import { CLINE_CLI_DIR } from "./utils/path"
|
||||
|
||||
export { URI } from "vscode-uri"
|
||||
export { ClineFileStorage } from "@/shared/storage"
|
||||
|
||||
export const CLI_LOG_FILE = path.join(CLINE_CLI_DIR.log, "cline-cli.1.log")
|
||||
|
||||
/**
|
||||
* Safely read and parse a JSON file, returning a default value on failure
|
||||
*/
|
||||
export function readJson<T = any>(filePath: string, defaultValue: T = {} as T): T {
|
||||
try {
|
||||
if (existsSync(filePath)) {
|
||||
return JSON.parse(readFileSync(filePath, "utf8"))
|
||||
}
|
||||
} catch {
|
||||
// Return default if file doesn't exist or is invalid
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock environment variable collection for non-VSCode environments
|
||||
*/
|
||||
export class EnvironmentVariableCollection {
|
||||
private variables = new Map<string, { value: string; type: string }>()
|
||||
persistent = true
|
||||
description = "CLI Environment Variables"
|
||||
|
||||
entries() {
|
||||
return this.variables.entries()
|
||||
}
|
||||
|
||||
replace(variable: string, value: string) {
|
||||
this.variables.set(variable, { value, type: "replace" })
|
||||
}
|
||||
|
||||
append(variable: string, value: string) {
|
||||
this.variables.set(variable, { value, type: "append" })
|
||||
}
|
||||
|
||||
prepend(variable: string, value: string) {
|
||||
this.variables.set(variable, { value, type: "prepend" })
|
||||
}
|
||||
|
||||
get(variable: string) {
|
||||
return this.variables.get(variable)
|
||||
}
|
||||
|
||||
forEach(callback: (variable: string, mutator: { value: string; type: string }, collection: this) => void) {
|
||||
this.variables.forEach((mutator, variable) => callback(variable, mutator, this))
|
||||
}
|
||||
|
||||
delete(variable: string) {
|
||||
return this.variables.delete(variable)
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.variables.clear()
|
||||
}
|
||||
|
||||
getScoped(_scope: unknown) {
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// VSCode enums
|
||||
// ============================================================================
|
||||
|
||||
export enum ExtensionMode {
|
||||
Production = 1,
|
||||
Development = 2,
|
||||
Test = 3,
|
||||
}
|
||||
|
||||
export enum ExtensionKind {
|
||||
UI = 1,
|
||||
Workspace = 2,
|
||||
}
|
||||
|
||||
export enum DiagnosticSeverity {
|
||||
Error = 0,
|
||||
Warning = 1,
|
||||
Information = 2,
|
||||
Hint = 3,
|
||||
}
|
||||
|
||||
export enum EndOfLine {
|
||||
LF = 1,
|
||||
CRLF = 2,
|
||||
}
|
||||
|
||||
const outputChannelLoggers = new Map<string, Logger>()
|
||||
|
||||
function getOutputChannelLogger(channelName: string): Logger {
|
||||
let logger = outputChannelLoggers.get(channelName)
|
||||
if (!logger) {
|
||||
const transport = pino.transport({
|
||||
target: "pino-roll",
|
||||
options: {
|
||||
name: channelName,
|
||||
file: CLI_LOG_FILE.replace(".1", ""),
|
||||
mkdir: true,
|
||||
frequency: "daily",
|
||||
limit: { count: 5 },
|
||||
},
|
||||
})
|
||||
logger = pino({ timestamp: pino.stdTimeFunctions.isoTime }, transport)
|
||||
outputChannelLoggers.set(channelName, logger)
|
||||
}
|
||||
return logger
|
||||
}
|
||||
|
||||
export class Position {
|
||||
constructor(
|
||||
public readonly line: number,
|
||||
public readonly character: number,
|
||||
) {}
|
||||
|
||||
compareTo(other: Position): number {
|
||||
return this.line - other.line || this.character - other.character
|
||||
}
|
||||
|
||||
isAfter(other: Position): boolean {
|
||||
return this.compareTo(other) > 0
|
||||
}
|
||||
|
||||
isAfterOrEqual(other: Position): boolean {
|
||||
return this.compareTo(other) >= 0
|
||||
}
|
||||
|
||||
isBefore(other: Position): boolean {
|
||||
return this.compareTo(other) < 0
|
||||
}
|
||||
|
||||
isBeforeOrEqual(other: Position): boolean {
|
||||
return this.compareTo(other) <= 0
|
||||
}
|
||||
|
||||
isEqual(other: Position): boolean {
|
||||
return this.compareTo(other) === 0
|
||||
}
|
||||
|
||||
translate(lineDelta = 0, characterDelta = 0): Position {
|
||||
return new Position(this.line + lineDelta, this.character + characterDelta)
|
||||
}
|
||||
|
||||
with(line?: number, character?: number): Position {
|
||||
return new Position(line ?? this.line, character ?? this.character)
|
||||
}
|
||||
}
|
||||
|
||||
export class Range {
|
||||
public readonly start: Position
|
||||
public readonly end: Position
|
||||
|
||||
constructor(start: Position, end: Position)
|
||||
constructor(startLine: number, startCharacter: number, endLine: number, endCharacter: number)
|
||||
constructor(
|
||||
startOrStartLine: Position | number,
|
||||
endOrStartCharacter: Position | number,
|
||||
endLine?: number,
|
||||
endCharacter?: number,
|
||||
) {
|
||||
if (typeof startOrStartLine === "number") {
|
||||
this.start = new Position(startOrStartLine, endOrStartCharacter as number)
|
||||
this.end = new Position(endLine!, endCharacter!)
|
||||
} else {
|
||||
this.start = startOrStartLine
|
||||
this.end = endOrStartCharacter as Position
|
||||
}
|
||||
}
|
||||
|
||||
get isEmpty(): boolean {
|
||||
return this.start.isEqual(this.end)
|
||||
}
|
||||
|
||||
get isSingleLine(): boolean {
|
||||
return this.start.line === this.end.line
|
||||
}
|
||||
|
||||
contains(positionOrRange: Position | Range): boolean {
|
||||
if (positionOrRange instanceof Range) {
|
||||
return this.contains(positionOrRange.start) && this.contains(positionOrRange.end)
|
||||
}
|
||||
return positionOrRange.isAfterOrEqual(this.start) && positionOrRange.isBeforeOrEqual(this.end)
|
||||
}
|
||||
|
||||
isEqual(other: Range): boolean {
|
||||
return this.start.isEqual(other.start) && this.end.isEqual(other.end)
|
||||
}
|
||||
|
||||
intersection(range: Range): Range | undefined {
|
||||
const start = this.start.isAfter(range.start) ? this.start : range.start
|
||||
const end = this.end.isBefore(range.end) ? this.end : range.end
|
||||
return start.isAfter(end) ? undefined : new Range(start, end)
|
||||
}
|
||||
|
||||
union(other: Range): Range {
|
||||
const start = this.start.isBefore(other.start) ? this.start : other.start
|
||||
const end = this.end.isAfter(other.end) ? this.end : other.end
|
||||
return new Range(start, end)
|
||||
}
|
||||
|
||||
with(start?: Position, end?: Position): Range {
|
||||
return new Range(start ?? this.start, end ?? this.end)
|
||||
}
|
||||
}
|
||||
|
||||
export class Selection extends Range {
|
||||
public readonly anchor: Position
|
||||
public readonly active: Position
|
||||
|
||||
constructor(anchor: Position, active: Position)
|
||||
constructor(anchorLine: number, anchorCharacter: number, activeLine: number, activeCharacter: number)
|
||||
constructor(
|
||||
anchorOrAnchorLine: Position | number,
|
||||
activeOrAnchorCharacter: Position | number,
|
||||
activeLine?: number,
|
||||
activeCharacter?: number,
|
||||
) {
|
||||
const anchor =
|
||||
typeof anchorOrAnchorLine === "number"
|
||||
? new Position(anchorOrAnchorLine, activeOrAnchorCharacter as number)
|
||||
: anchorOrAnchorLine
|
||||
const active =
|
||||
typeof anchorOrAnchorLine === "number"
|
||||
? new Position(activeLine!, activeCharacter!)
|
||||
: (activeOrAnchorCharacter as Position)
|
||||
const isForward = anchor.isBefore(active)
|
||||
super(isForward ? anchor : active, isForward ? active : anchor)
|
||||
this.anchor = anchor
|
||||
this.active = active
|
||||
}
|
||||
|
||||
get isReversed(): boolean {
|
||||
return this.anchor.isAfter(this.active)
|
||||
}
|
||||
}
|
||||
|
||||
export interface CancellationToken {
|
||||
isCancellationRequested: boolean
|
||||
onCancellationRequested: any
|
||||
}
|
||||
|
||||
export class EventEmitter<T> {
|
||||
private listeners: Array<(e: T) => void> = []
|
||||
|
||||
event = (listener: (e: T) => void) => {
|
||||
this.listeners.push(listener)
|
||||
return {
|
||||
dispose: () => {
|
||||
const idx = this.listeners.indexOf(listener)
|
||||
if (idx >= 0) this.listeners.splice(idx, 1)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fire(data: T): void {
|
||||
this.listeners.forEach((listener) => listener(data))
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.listeners.length = 0
|
||||
}
|
||||
}
|
||||
|
||||
export class Disposable {
|
||||
constructor(private callOnDispose: () => void) {}
|
||||
|
||||
static from(...disposables: { dispose(): any }[]): Disposable {
|
||||
return new Disposable(() => disposables.forEach((d) => d.dispose()))
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.callOnDispose()
|
||||
}
|
||||
}
|
||||
|
||||
const noop = () => {}
|
||||
const noopAsync = async () => {}
|
||||
const noopDisposable = { dispose: noop }
|
||||
|
||||
export const workspace = {
|
||||
workspaceFolders: undefined as any[] | undefined,
|
||||
getWorkspaceFolder: (_uri: any) => undefined,
|
||||
onDidChangeWorkspaceFolders: () => noopDisposable,
|
||||
fs: {
|
||||
readFile: async (_uri: any): Promise<Uint8Array> => new Uint8Array(),
|
||||
writeFile: noopAsync,
|
||||
delete: noopAsync,
|
||||
stat: async (_uri: any) => ({ type: 1, size: 0 }),
|
||||
readDirectory: async (_uri: any): Promise<any[]> => [],
|
||||
createDirectory: noopAsync,
|
||||
},
|
||||
}
|
||||
|
||||
export const window = {
|
||||
showInformationMessage: async (message: string) => {
|
||||
printInfo(`[INFO] ${message}`)
|
||||
},
|
||||
showWarningMessage: async (message: string) => {
|
||||
printWarning(`[WARN] ${message}`)
|
||||
},
|
||||
showErrorMessage: async (message: string) => {
|
||||
printError(`[ERROR] ${message}`)
|
||||
},
|
||||
createOutputChannel: (name: string) => {
|
||||
const logger = getOutputChannelLogger(name)
|
||||
const log = (text: string) => logger.info({ channel: name }, text)
|
||||
return { appendLine: log, append: log, clear: noop, show: noop, hide: noop, dispose: noop }
|
||||
},
|
||||
terminals: [] as any[],
|
||||
activeTerminal: undefined as any,
|
||||
createTerminal: (_options?: any) => ({
|
||||
name: "CLI Terminal",
|
||||
processId: Promise.resolve(process.pid),
|
||||
sendText: (text: string) => printInfo(`[${new Date().toISOString()}] [Terminal] ${text}`),
|
||||
show: noop,
|
||||
hide: noop,
|
||||
dispose: noop,
|
||||
}),
|
||||
}
|
||||
|
||||
export type ExtensionContext = any
|
||||
export type Memento = any
|
||||
export type SecretStorage = any
|
||||
// biome-ignore lint/correctness/noUnusedVariables: placeholder
|
||||
export type Extension<T> = any
|
||||
|
||||
// ============================================================================
|
||||
// Shutdown event for graceful cleanup
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Event emitter for app shutdown notification.
|
||||
* Components can listen to this to clean up UI before process exit.
|
||||
*/
|
||||
export const shutdownEvent = new EventEmitter<void>()
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"esModuleInterop": true,
|
||||
"experimentalDecorators": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react",
|
||||
"jsxFactory": "React.createElement",
|
||||
"lib": [
|
||||
"es2022"
|
||||
],
|
||||
"module": "esnext",
|
||||
"moduleResolution": "Bundler",
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noUnusedLocals": false,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"target": "es2022",
|
||||
"useDefineForClassFields": true,
|
||||
"useUnknownInCatchVariables": false,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"../src/*"
|
||||
],
|
||||
"@api/*": [
|
||||
"../src/core/api/*"
|
||||
],
|
||||
"@core/*": [
|
||||
"../src/core/*"
|
||||
],
|
||||
"@generated/*": [
|
||||
"../src/generated/*"
|
||||
],
|
||||
"@hosts/*": [
|
||||
"../src/hosts/*"
|
||||
],
|
||||
"@integrations/*": [
|
||||
"../src/integrations/*"
|
||||
],
|
||||
"@packages/*": [
|
||||
"../src/packages/*"
|
||||
],
|
||||
"@services/*": [
|
||||
"../src/services/*"
|
||||
],
|
||||
"@shared/*": [
|
||||
"../src/shared/*"
|
||||
],
|
||||
"@utils/*": [
|
||||
"../src/utils/*"
|
||||
]
|
||||
},
|
||||
"rootDir": "..",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"esbuild.mts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"*.tgz"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import path from "path"
|
||||
import { defineConfig } from "vitest/config"
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.{ts,tsx}", "tests/**/*.test.{ts,tsx}"],
|
||||
coverage: {
|
||||
reporter: ["text", "json", "html"],
|
||||
exclude: ["node_modules/", "dist/"],
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
// Match tsconfig paths - baseUrl is parent directory
|
||||
"@": path.resolve(__dirname, "../src"),
|
||||
"@api": path.resolve(__dirname, "../src/core/api"),
|
||||
"@core": path.resolve(__dirname, "../src/core"),
|
||||
"@generated": path.resolve(__dirname, "../src/generated"),
|
||||
"@hosts": path.resolve(__dirname, "../src/hosts"),
|
||||
"@integrations": path.resolve(__dirname, "../src/integrations"),
|
||||
"@packages": path.resolve(__dirname, "../src/packages"),
|
||||
"@services": path.resolve(__dirname, "../src/services"),
|
||||
"@shared": path.resolve(__dirname, "../src/shared"),
|
||||
"@utils": path.resolve(__dirname, "../src/utils"),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"workflowToggles": {},
|
||||
"localClineRulesToggles": {},
|
||||
"localWindsurfRulesToggles": {},
|
||||
"localCursorRulesToggles": {},
|
||||
"localAgentsRulesToggles": {}
|
||||
}
|
||||
Generated
+2395
-40
File diff suppressed because it is too large
Load Diff
+13
-2
@@ -4,6 +4,9 @@
|
||||
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
|
||||
"version": "3.53.1",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"workspaces": [
|
||||
"cli-ts"
|
||||
],
|
||||
"engines": {
|
||||
"vscode": "^1.84.0"
|
||||
},
|
||||
@@ -381,6 +384,10 @@
|
||||
"compile-standalone": "npm run check-types && npm run lint && node esbuild.mjs --standalone",
|
||||
"compile-standalone-npm": "npm run protos && npm run protos-go && npm run check-types && npm run lint && node esbuild.mjs --standalone",
|
||||
"compile-cli": "scripts/build-cli.sh",
|
||||
"compile-cli-ts": "cd cli-ts && npm run link",
|
||||
"compile-cli-ts:production": "cd cli-ts && npm run build:production",
|
||||
"watch-cli-ts": "cd cli-ts && npm run watch",
|
||||
"dev:cli-ts": "npm run compile-cli-ts && npm run watch-cli-ts",
|
||||
"compile-cli-all-platforms": "scripts/build-cli-all-platforms.sh",
|
||||
"compile-cli-man-page": "pandoc cli/man/cline.1.md -s -t man -o cli/man/cline.1",
|
||||
"test:install": "bash scripts/test-install.sh",
|
||||
@@ -423,7 +430,7 @@
|
||||
"test:e2e": "playwright install && npm run test:e2e:build && node src/test/e2e/utils/build.mjs && playwright test",
|
||||
"test:e2e:optimal": "npm run test:e2e:build && node src/test/e2e/utils/build.mjs && playwright test",
|
||||
"test:e2e:ui": "npx tsx scripts/interactive-playwright.ts",
|
||||
"install:all": "npm install && cd webview-ui && npm install",
|
||||
"install:all": "npm install && cd webview-ui && npm install && cd ../cli-ts && npm install && cd ..",
|
||||
"dev:webview": "cd webview-ui && npm run dev",
|
||||
"build:webview": "cd webview-ui && npm run build",
|
||||
"test:webview": "cd webview-ui && npm run test",
|
||||
@@ -437,7 +444,10 @@
|
||||
"docs:check-links": "cd docs && npm run check",
|
||||
"docs:rename-file": "cd docs && npm run rename",
|
||||
"report-issue": "node scripts/report-issue.js",
|
||||
"storybook": "cd webview-ui && npm run storybook"
|
||||
"storybook": "cd webview-ui && npm run storybook",
|
||||
"cli:dev": "cd cli-ts && npm run dev",
|
||||
"cli:unlink": "cd cli-ts && npm run unlink",
|
||||
"build:cli": "npm run install:all && npm run protos && cd cli-ts && npm run build"
|
||||
},
|
||||
"lint-staged": {
|
||||
"src/shared/storage/state-keys.ts": [
|
||||
@@ -474,6 +484,7 @@
|
||||
"chai": "^4.3.10",
|
||||
"chalk": "5.6.2",
|
||||
"cross-env": "^10.1.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"esbuild": "^0.25.0",
|
||||
"grpc-tools": "^1.13.0",
|
||||
"husky": "^9.1.7",
|
||||
|
||||
+98
-382
@@ -1,18 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* NPM Package Builder for Cline CLI
|
||||
* NPM Package Builder for Cline CLI (TypeScript)
|
||||
*
|
||||
* This script builds the Cline CLI NPM package (dist-standalone/).
|
||||
* It is completely independent from package-standalone.mjs (JetBrains build).
|
||||
* It packages the TypeScript CLI from cli-ts/.
|
||||
*
|
||||
* Usage: node scripts/package-npm.mjs
|
||||
*
|
||||
* Prerequisites:
|
||||
* - npm run protos && npm run protos-go
|
||||
* - npm run compile-cli
|
||||
* - npm run compile-cli-all-platforms
|
||||
* - npm run download-ripgrep
|
||||
* - cd cli-ts && npm run build:production
|
||||
*/
|
||||
|
||||
import { execSync } from "child_process"
|
||||
@@ -21,22 +18,18 @@ import { cp } from "fs/promises"
|
||||
import path from "path"
|
||||
|
||||
const BUILD_DIR = "dist-standalone"
|
||||
const RUNTIME_DEPS_DIR = "standalone/runtime-files"
|
||||
const RIPGREP_BINARIES_DIR = `${BUILD_DIR}/ripgrep-binaries`
|
||||
const CLI_BINARIES_DIR = "cli/bin"
|
||||
const CLI_TS_DIR = "cli-ts"
|
||||
const IS_VERBOSE = process.argv.includes("-v") || process.argv.includes("--verbose")
|
||||
|
||||
async function main() {
|
||||
console.log("🚀 Building Cline NPM Package\n")
|
||||
console.log("🚀 Building Cline CLI NPM Package (TypeScript)\n")
|
||||
|
||||
await installNodeDependencies()
|
||||
await copyCliBinaries()
|
||||
await copyRipgrepBinaries()
|
||||
await copyProtoDescriptors()
|
||||
await createNpmPackageFiles()
|
||||
await createFakeNodeModules()
|
||||
await cleanBuildDir()
|
||||
await buildTypeScriptCli()
|
||||
await copyCliDist()
|
||||
await createNpmPackageJson()
|
||||
await copyReadme()
|
||||
await createNpmIgnoreFile()
|
||||
await createPostinstallScript()
|
||||
|
||||
console.log("\n✅ Build complete!")
|
||||
console.log(`\n📦 NPM package ready in ${BUILD_DIR}/`)
|
||||
@@ -44,251 +37,136 @@ async function main() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Install node dependencies in the build directory
|
||||
* Clean the build directory
|
||||
*/
|
||||
async function installNodeDependencies() {
|
||||
// Clean modules from any previous builds
|
||||
await rmrf(path.join(BUILD_DIR, "node_modules"))
|
||||
|
||||
await cpr(RUNTIME_DEPS_DIR, BUILD_DIR)
|
||||
|
||||
console.log("Running npm install in distribution directory...")
|
||||
execSync("npm install", { stdio: "inherit", cwd: BUILD_DIR })
|
||||
|
||||
// Move the vscode directory into node_modules.
|
||||
// It can't be installed using npm because it will create a symlink which cannot be unzipped correctly on windows.
|
||||
fs.renameSync(`${BUILD_DIR}/vscode`, `${BUILD_DIR}/node_modules/vscode`)
|
||||
async function cleanBuildDir() {
|
||||
console.log("Cleaning build directory...")
|
||||
await rmrf(BUILD_DIR)
|
||||
fs.mkdirSync(BUILD_DIR, { recursive: true })
|
||||
console.log(`✓ ${BUILD_DIR}/ cleaned`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy CLI binaries (cline and cline-host) for all platforms
|
||||
* The Go binaries are cross-compiled for darwin/linux arm64/amd64
|
||||
* Build the TypeScript CLI
|
||||
*/
|
||||
async function copyCliBinaries() {
|
||||
console.log("Copying CLI binaries for all platforms...")
|
||||
async function buildTypeScriptCli() {
|
||||
console.log("Building TypeScript CLI...")
|
||||
|
||||
const platforms = [
|
||||
{ os: "darwin", arch: "arm64" },
|
||||
{ os: "darwin", arch: "amd64" },
|
||||
{ os: "linux", arch: "amd64" },
|
||||
{ os: "linux", arch: "arm64" },
|
||||
]
|
||||
|
||||
const binDir = path.join(BUILD_DIR, "bin")
|
||||
|
||||
// Create bin directory
|
||||
fs.mkdirSync(binDir, { recursive: true })
|
||||
|
||||
// Copy all platform-specific binaries
|
||||
for (const { os, arch } of platforms) {
|
||||
const platformSuffix = `${os}-${arch}`
|
||||
|
||||
// Copy cline binary
|
||||
const clineSource = path.join(CLI_BINARIES_DIR, `cline-${platformSuffix}`)
|
||||
const clineDest = path.join(binDir, `cline-${platformSuffix}`)
|
||||
|
||||
if (!fs.existsSync(clineSource)) {
|
||||
console.error(`Error: CLI binary not found at ${clineSource}`)
|
||||
console.error(`Please run: npm run compile-cli-all-platforms`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await cpr(clineSource, clineDest)
|
||||
fs.chmodSync(clineDest, 0o755)
|
||||
console.log(`✓ cline-${platformSuffix} copied`)
|
||||
|
||||
// Copy cline-host binary
|
||||
const hostSource = path.join(CLI_BINARIES_DIR, `cline-host-${platformSuffix}`)
|
||||
const hostDest = path.join(binDir, `cline-host-${platformSuffix}`)
|
||||
|
||||
if (!fs.existsSync(hostSource)) {
|
||||
console.error(`Error: CLI binary not found at ${hostSource}`)
|
||||
console.error(`Please run: npm run compile-cli-all-platforms`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await cpr(hostSource, hostDest)
|
||||
fs.chmodSync(hostDest, 0o755)
|
||||
console.log(`✓ cline-host-${platformSuffix} copied`)
|
||||
// Install dependencies if needed
|
||||
if (!fs.existsSync(path.join(CLI_TS_DIR, "node_modules"))) {
|
||||
console.log("Installing cli-ts dependencies...")
|
||||
execSync("npm install", { stdio: "inherit", cwd: CLI_TS_DIR })
|
||||
}
|
||||
|
||||
console.log(`✓ All CLI binaries copied to ${binDir}`)
|
||||
// Build production bundle
|
||||
execSync("npm run build:production", { stdio: "inherit", cwd: CLI_TS_DIR })
|
||||
console.log("✓ TypeScript CLI built")
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy ripgrep binaries for ALL platforms
|
||||
* Ripgrep is needed by cline-core for file searching
|
||||
* The postinstall script will select the correct binary for the user's platform
|
||||
* Copy the CLI dist folder to build directory
|
||||
*/
|
||||
async function copyRipgrepBinaries() {
|
||||
console.log("Copying ripgrep binaries for all platforms...")
|
||||
async function copyCliDist() {
|
||||
console.log("Copying CLI distribution files...")
|
||||
|
||||
const platforms = [
|
||||
{ dir: "darwin-arm64", binary: "rg" },
|
||||
{ dir: "darwin-x64", binary: "rg" },
|
||||
{ dir: "linux-x64", binary: "rg" },
|
||||
{ dir: "linux-arm64", binary: "rg" },
|
||||
// { dir: "win-x64", binary: "rg.exe" }, // Windows not supported yet
|
||||
]
|
||||
const distSource = path.join(CLI_TS_DIR, "dist")
|
||||
const distDest = path.join(BUILD_DIR, "dist")
|
||||
|
||||
const ripgrepDir = path.join(BUILD_DIR, "ripgrep")
|
||||
|
||||
// Create ripgrep directory
|
||||
fs.mkdirSync(ripgrepDir, { recursive: true })
|
||||
|
||||
// Check if ripgrep binaries exist, download if missing
|
||||
const firstPlatform = platforms[0]
|
||||
const firstBinaryPath = path.join(RIPGREP_BINARIES_DIR, firstPlatform.dir, firstPlatform.binary)
|
||||
if (!fs.existsSync(firstBinaryPath)) {
|
||||
console.log(`Ripgrep binaries not found, downloading...`)
|
||||
try {
|
||||
execSync("npm run download-ripgrep", { stdio: "inherit" })
|
||||
} catch (error) {
|
||||
console.error(`Error downloading ripgrep: ${error.message}`)
|
||||
console.error(`Please run: npm run download-ripgrep`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Copy all platform-specific binaries
|
||||
for (const { dir, binary } of platforms) {
|
||||
const source = path.join(RIPGREP_BINARIES_DIR, dir, binary)
|
||||
const dest = path.join(ripgrepDir, `rg-${dir}`)
|
||||
|
||||
if (!fs.existsSync(source)) {
|
||||
console.error(`Error: Ripgrep binary not found at ${source}`)
|
||||
console.error(`Please run: npm run download-ripgrep`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await cpr(source, dest)
|
||||
fs.chmodSync(dest, 0o755)
|
||||
console.log(`✓ rg-${dir} copied`)
|
||||
}
|
||||
|
||||
console.log(`✓ All ripgrep binaries copied to ${ripgrepDir}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify proto descriptors exist in the build directory
|
||||
* The proto/descriptor_set.pb file is generated by build-proto.mjs to dist-standalone/proto/
|
||||
* We do NOT copy from proto/ source because that would overwrite the freshly generated descriptor
|
||||
*/
|
||||
async function copyProtoDescriptors() {
|
||||
console.log("Verifying proto descriptors...")
|
||||
|
||||
const protoDest = path.join(BUILD_DIR, "proto")
|
||||
const descriptorPath = path.join(protoDest, "descriptor_set.pb")
|
||||
|
||||
// Check if descriptor_set.pb exists in the build directory
|
||||
// It should have been generated by `npm run protos` which runs build-proto.mjs
|
||||
if (!fs.existsSync(descriptorPath)) {
|
||||
console.error(`Error: proto/descriptor_set.pb not found at ${descriptorPath}`)
|
||||
console.error(`Please run: npm run protos`)
|
||||
console.error(`Note: build-proto.mjs generates the descriptor to dist-standalone/proto/`)
|
||||
if (!fs.existsSync(distSource)) {
|
||||
console.error(`Error: CLI dist not found at ${distSource}`)
|
||||
console.error(`Please run: cd cli-ts && npm run build:production`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Verify the descriptor is recent (not stale)
|
||||
const stats = fs.statSync(descriptorPath)
|
||||
const ageMinutes = (Date.now() - stats.mtimeMs) / 1000 / 60
|
||||
if (ageMinutes > 60) {
|
||||
console.warn(`Warning: descriptor_set.pb is ${Math.round(ageMinutes)} minutes old`)
|
||||
console.warn(`Consider running: npm run protos`)
|
||||
await cpr(distSource, distDest)
|
||||
|
||||
// Make the CLI executable
|
||||
const cliPath = path.join(distDest, "cli.mjs")
|
||||
if (fs.existsSync(cliPath)) {
|
||||
fs.chmodSync(cliPath, 0o755)
|
||||
}
|
||||
|
||||
console.log(`✓ Proto descriptors verified at ${protoDest}`)
|
||||
console.log(`✓ CLI dist copied to ${distDest}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy NPM package files (package.json, README.md, and man page) from cli/ directory
|
||||
* Create package.json for NPM publication
|
||||
* Reads from cli-ts/package.json and modifies for publication
|
||||
*/
|
||||
async function createNpmPackageFiles() {
|
||||
console.log("Copying NPM package files...")
|
||||
async function createNpmPackageJson() {
|
||||
console.log("Creating NPM package.json...")
|
||||
|
||||
// Copy package.json from cli/ directory
|
||||
const packageJsonSource = path.join("cli", "package.json")
|
||||
const packageJsonDest = path.join(BUILD_DIR, "package.json")
|
||||
const sourcePackageJson = path.join(CLI_TS_DIR, "package.json")
|
||||
|
||||
if (!fs.existsSync(packageJsonSource)) {
|
||||
console.error(`Error: NPM package.json not found at ${packageJsonSource}`)
|
||||
if (!fs.existsSync(sourcePackageJson)) {
|
||||
console.error(`Error: package.json not found at ${sourcePackageJson}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await cpr(packageJsonSource, packageJsonDest)
|
||||
console.log(`✓ package.json copied from ${packageJsonSource}`)
|
||||
const pkg = JSON.parse(fs.readFileSync(sourcePackageJson, "utf8"))
|
||||
|
||||
// Copy README.md from cli/ directory
|
||||
const readmeSource = path.join("cli", "README.md")
|
||||
const readmeDest = path.join(BUILD_DIR, "README.md")
|
||||
// Modify for NPM publication
|
||||
const npmPkg = {
|
||||
name: "cline", // Change from @cline/cli to cline for NPM
|
||||
version: pkg.version,
|
||||
description: pkg.description,
|
||||
main: pkg.main,
|
||||
bin: pkg.bin,
|
||||
type: pkg.type,
|
||||
engines: pkg.engines,
|
||||
keywords: pkg.keywords,
|
||||
author: pkg.author,
|
||||
license: pkg.license,
|
||||
repository: pkg.repository,
|
||||
homepage: pkg.homepage,
|
||||
bugs: pkg.bugs,
|
||||
dependencies: pkg.dependencies,
|
||||
os: ["darwin", "linux"],
|
||||
cpu: ["x64", "arm64"],
|
||||
}
|
||||
|
||||
const destPackageJson = path.join(BUILD_DIR, "package.json")
|
||||
fs.writeFileSync(destPackageJson, JSON.stringify(npmPkg, null, "\t"))
|
||||
|
||||
console.log(`✓ package.json created (name: cline, version: ${pkg.version})`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy README.md from cli-ts/ directory
|
||||
*/
|
||||
async function copyReadme() {
|
||||
console.log("Copying README...")
|
||||
|
||||
// Try cli-ts README first, fall back to cli/ README
|
||||
let readmeSource = path.join(CLI_TS_DIR, "README.md")
|
||||
|
||||
if (!fs.existsSync(readmeSource)) {
|
||||
console.error(`Error: NPM README.md not found at ${readmeSource}`)
|
||||
process.exit(1)
|
||||
readmeSource = path.join("cli", "README.md")
|
||||
}
|
||||
|
||||
if (!fs.existsSync(readmeSource)) {
|
||||
console.warn("Warning: No README.md found, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
const readmeDest = path.join(BUILD_DIR, "README.md")
|
||||
await cpr(readmeSource, readmeDest)
|
||||
console.log(`✓ README.md copied from ${readmeSource}`)
|
||||
|
||||
// Copy man page from cli/man/ directory
|
||||
const manPageSource = path.join("cli", "man", "cline.1")
|
||||
const manDir = path.join(BUILD_DIR, "man")
|
||||
const manPageDest = path.join(manDir, "cline.1")
|
||||
|
||||
if (!fs.existsSync(manPageSource)) {
|
||||
console.error(`Error: Man page not found at ${manPageSource}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Create man directory if it doesn't exist
|
||||
fs.mkdirSync(manDir, { recursive: true })
|
||||
|
||||
await cpr(manPageSource, manPageDest)
|
||||
console.log(`✓ Man page copied from ${manPageSource}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create fake_node_modules directory with vscode stub
|
||||
* This directory will be added to NODE_PATH so Node.js can find the vscode module
|
||||
* without npm interfering with the real node_modules directory
|
||||
*/
|
||||
async function createFakeNodeModules() {
|
||||
console.log("Creating fake_node_modules with vscode stub...")
|
||||
|
||||
const vscodeSource = path.join(BUILD_DIR, "node_modules", "vscode")
|
||||
const fakeNodeModulesDir = path.join(BUILD_DIR, "fake_node_modules")
|
||||
const vscodeDest = path.join(fakeNodeModulesDir, "vscode")
|
||||
|
||||
if (!fs.existsSync(vscodeSource)) {
|
||||
console.error(`Error: vscode stub module not found at ${vscodeSource}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Create fake_node_modules directory
|
||||
fs.mkdirSync(fakeNodeModulesDir, { recursive: true })
|
||||
|
||||
// Copy vscode stub into fake_node_modules
|
||||
await cpr(vscodeSource, vscodeDest)
|
||||
|
||||
console.log(`✓ fake_node_modules/vscode created at ${vscodeDest}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create .npmignore file to ensure necessary files are included
|
||||
* Create .npmignore file to exclude unnecessary files
|
||||
*/
|
||||
async function createNpmIgnoreFile() {
|
||||
console.log("Creating .npmignore file...")
|
||||
|
||||
// Create .npmignore that excludes build artifacts
|
||||
// Note: proto/ directory is NOT excluded because proto/descriptor_set.pb is needed at runtime
|
||||
const npmignoreContent = `# Exclude build artifacts and unnecessary files
|
||||
binaries/
|
||||
ripgrep-binaries/
|
||||
standalone.zip
|
||||
cline-core.js.map
|
||||
package-lock.json
|
||||
tree-sitter*.wasm
|
||||
node_modules/vscode
|
||||
*.map
|
||||
*.ts
|
||||
!*.d.ts
|
||||
tsconfig.json
|
||||
.eslintrc*
|
||||
.prettierrc*
|
||||
`
|
||||
|
||||
const npmignorePath = path.join(BUILD_DIR, ".npmignore")
|
||||
@@ -297,175 +175,13 @@ node_modules/vscode
|
||||
console.log(`✓ .npmignore created`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create postinstall script for NPM package
|
||||
* This script selects the correct platform-specific binary and creates symlinks
|
||||
*/
|
||||
async function createPostinstallScript() {
|
||||
console.log("Creating postinstall script...")
|
||||
|
||||
const postinstallScript = `#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
// Detect current platform and architecture
|
||||
function getPlatformInfo() {
|
||||
const platform = os.platform();
|
||||
const arch = os.arch();
|
||||
|
||||
// Map Node.js arch names to Go arch names (for CLI binaries)
|
||||
let goArch = arch;
|
||||
if (arch === 'x64') {
|
||||
goArch = 'amd64';
|
||||
}
|
||||
|
||||
// Map for ripgrep binaries (uses different naming)
|
||||
let rgArch = arch;
|
||||
if (arch === 'arm64') {
|
||||
rgArch = 'arm64';
|
||||
} else if (arch === 'x64') {
|
||||
rgArch = 'x64';
|
||||
}
|
||||
|
||||
return { platform, arch, goArch, rgArch };
|
||||
}
|
||||
|
||||
// Setup platform-specific binaries
|
||||
function setupBinaries() {
|
||||
const { platform, goArch, rgArch } = getPlatformInfo();
|
||||
const cliPlatformSuffix = \`\${platform}-\${goArch}\`;
|
||||
const rgPlatformSuffix = \`\${platform}-\${rgArch}\`;
|
||||
|
||||
console.log(\`Setting up Cline CLI for \${cliPlatformSuffix}...\`);
|
||||
|
||||
// Setup CLI binaries
|
||||
const binDir = path.join(__dirname, 'bin');
|
||||
|
||||
// Check if platform-specific binaries exist
|
||||
const clineSource = path.join(binDir, \`cline-\${cliPlatformSuffix}\`);
|
||||
const clineHostSource = path.join(binDir, \`cline-host-\${cliPlatformSuffix}\`);
|
||||
|
||||
if (!fs.existsSync(clineSource)) {
|
||||
console.error(\`Error: Binary not found for platform \${cliPlatformSuffix}\`);
|
||||
console.error(\`Expected: \${clineSource}\`);
|
||||
console.error(\`Supported platforms: darwin-arm64, darwin-amd64, linux-amd64, linux-arm64\`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(clineHostSource)) {
|
||||
console.error(\`Error: Binary not found for platform \${cliPlatformSuffix}\`);
|
||||
console.error(\`Expected: \${clineHostSource}\`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Create symlinks or copies to the generic names
|
||||
const clineTarget = path.join(binDir, 'cline');
|
||||
const clineHostTarget = path.join(binDir, 'cline-host');
|
||||
|
||||
// Remove existing files if they exist
|
||||
[clineTarget, clineHostTarget].forEach(target => {
|
||||
if (fs.existsSync(target)) {
|
||||
try {
|
||||
fs.unlinkSync(target);
|
||||
} catch (e) {
|
||||
console.warn(\`Warning: Could not remove existing file \${target}: \${e.message}\`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// On Unix, create symlinks; on Windows, copy files
|
||||
if (platform === 'win32') {
|
||||
// Windows: copy files
|
||||
fs.copyFileSync(clineSource, clineTarget);
|
||||
fs.copyFileSync(clineHostSource, clineHostTarget);
|
||||
console.log('✓ Copied platform-specific CLI binaries');
|
||||
} else {
|
||||
// Unix: create symlinks
|
||||
fs.symlinkSync(path.basename(clineSource), clineTarget);
|
||||
fs.symlinkSync(path.basename(clineHostSource), clineHostTarget);
|
||||
console.log('✓ Created symlinks to platform-specific CLI binaries');
|
||||
|
||||
// Make binaries executable
|
||||
try {
|
||||
fs.chmodSync(clineSource, 0o755);
|
||||
fs.chmodSync(clineHostSource, 0o755);
|
||||
fs.chmodSync(clineTarget, 0o755);
|
||||
fs.chmodSync(clineHostTarget, 0o755);
|
||||
} catch (error) {
|
||||
console.warn(\`Warning: Could not set executable permissions: \${error.message}\`);
|
||||
}
|
||||
}
|
||||
|
||||
// Setup ripgrep binary
|
||||
console.log(\`Setting up ripgrep for \${rgPlatformSuffix}...\`);
|
||||
|
||||
const ripgrepDir = path.join(__dirname, 'ripgrep');
|
||||
const rgSource = path.join(ripgrepDir, \`rg-\${rgPlatformSuffix}\`);
|
||||
const rgTarget = path.join(__dirname, 'rg');
|
||||
|
||||
if (!fs.existsSync(rgSource)) {
|
||||
console.error(\`Error: ripgrep binary not found for platform \${rgPlatformSuffix}\`);
|
||||
console.error(\`Expected: \${rgSource}\`);
|
||||
console.error(\`Supported platforms: darwin-arm64, darwin-x64, linux-x64, linux-arm64\`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Remove existing rg if it exists
|
||||
if (fs.existsSync(rgTarget)) {
|
||||
try {
|
||||
fs.unlinkSync(rgTarget);
|
||||
} catch (e) {
|
||||
console.warn(\`Warning: Could not remove existing ripgrep: \${e.message}\`);
|
||||
}
|
||||
}
|
||||
|
||||
// Copy ripgrep binary to root (where cline-core expects it)
|
||||
fs.copyFileSync(rgSource, rgTarget);
|
||||
|
||||
// Make ripgrep executable (Unix only)
|
||||
if (platform !== 'win32') {
|
||||
try {
|
||||
fs.chmodSync(rgTarget, 0o755);
|
||||
} catch (error) {
|
||||
console.warn(\`Warning: Could not set ripgrep executable permissions: \${error.message}\`);
|
||||
}
|
||||
}
|
||||
console.log('✓ Copied platform-specific ripgrep binary');
|
||||
|
||||
console.log('✓ Cline CLI installation complete');
|
||||
console.log('');
|
||||
console.log('Usage:');
|
||||
console.log(' cline - Start Cline CLI');
|
||||
console.log(' cline-host - Start Cline host service');
|
||||
console.log('');
|
||||
console.log('Documentation: https://docs.cline.bot');
|
||||
}
|
||||
|
||||
try {
|
||||
setupBinaries();
|
||||
} catch (error) {
|
||||
console.error(\`Installation failed: \${error.message}\`);
|
||||
console.error('Please report this issue at: https://github.com/cline/cline/issues');
|
||||
process.exit(1);
|
||||
}
|
||||
`
|
||||
|
||||
const postinstallPath = path.join(BUILD_DIR, "postinstall.js")
|
||||
fs.writeFileSync(postinstallPath, postinstallScript)
|
||||
fs.chmodSync(postinstallPath, 0o755)
|
||||
|
||||
console.log(`✓ postinstall.js created`)
|
||||
}
|
||||
|
||||
/* cp -r */
|
||||
async function cpr(source, dest) {
|
||||
log_verbose(`Copying ${source} -> ${dest}`)
|
||||
await cp(source, dest, {
|
||||
recursive: true,
|
||||
preserveTimestamps: true,
|
||||
dereference: false, // preserve symlinks instead of following them
|
||||
dereference: false,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ export interface ToolUse {
|
||||
/**
|
||||
* The call / response ID this tool use is associated with.
|
||||
*/
|
||||
call_id?: string // optional call ID for tracking tool use calls
|
||||
call_id: string // optional call ID for tracking tool use calls
|
||||
/**
|
||||
* Thought signature associated with this tool use, used by Gemini
|
||||
*/
|
||||
|
||||
@@ -39,6 +39,7 @@ import { BannerCardData } from "@/shared/cline/banner"
|
||||
import { getAxiosSettings } from "@/shared/net"
|
||||
import { ShowMessageType } from "@/shared/proto/host/window"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { Session } from "@/shared/services/Session"
|
||||
import { getLatestAnnouncementId } from "@/utils/announcements"
|
||||
import { getCwd, getDesktopDir } from "@/utils/path"
|
||||
import { PromptRegistry } from "../prompts/system-prompt"
|
||||
@@ -118,6 +119,7 @@ export class Controller {
|
||||
}
|
||||
|
||||
constructor(readonly context: vscode.ExtensionContext) {
|
||||
Session.reset() // Reset session on controller initialization
|
||||
PromptRegistry.getInstance() // Ensure prompts and tools are registered
|
||||
this.stateManager = StateManager.get()
|
||||
StateManager.get().registerCallbacks({
|
||||
@@ -860,7 +862,8 @@ export class Controller {
|
||||
const autoCondenseThreshold = this.stateManager.getGlobalSettingsKey("autoCondenseThreshold")
|
||||
|
||||
const currentTaskItem = this.task?.taskId ? (taskHistory || []).find((item) => item.id === this.task?.taskId) : undefined
|
||||
const clineMessages = this.task?.messageStateHandler.getClineMessages() || []
|
||||
// Spread to create new array reference - React needs this to detect changes in useEffect dependencies
|
||||
const clineMessages = [...(this.task?.messageStateHandler.getClineMessages() || [])]
|
||||
const checkpointManagerErrorMessage = this.task?.taskState.checkpointManagerErrorMessage
|
||||
|
||||
const processedTaskHistory = (taskHistory || [])
|
||||
|
||||
@@ -4,9 +4,13 @@ import { Logger } from "@/shared/services/Logger"
|
||||
import { getRequestRegistry, StreamingResponseHandler } from "../grpc-handler"
|
||||
import { Controller } from "../index"
|
||||
|
||||
// Keep track of active partial message subscriptions
|
||||
// Keep track of active partial message subscriptions (gRPC streams)
|
||||
const activePartialMessageSubscriptions = new Set<StreamingResponseHandler<ClineMessage>>()
|
||||
|
||||
// Keep track of callback-based subscriptions (for CLI and other non-gRPC consumers)
|
||||
export type PartialMessageCallback = (message: ClineMessage) => void
|
||||
const callbackSubscriptions = new Set<PartialMessageCallback>()
|
||||
|
||||
/**
|
||||
* Subscribe to partial message events
|
||||
* @param controller The controller instance
|
||||
@@ -34,13 +38,25 @@ export async function subscribeToPartialMessage(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback to receive partial message events (for CLI and non-gRPC consumers)
|
||||
* @param callback The callback function to receive messages
|
||||
* @returns A function to unsubscribe
|
||||
*/
|
||||
export function registerPartialMessageCallback(callback: PartialMessageCallback): () => void {
|
||||
callbackSubscriptions.add(callback)
|
||||
return () => {
|
||||
callbackSubscriptions.delete(callback)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a partial message event to all active subscribers
|
||||
* @param partialMessage The ClineMessage to send
|
||||
*/
|
||||
export async function sendPartialMessageEvent(partialMessage: ClineMessage): Promise<void> {
|
||||
// Send the event to all active subscribers
|
||||
const promises = Array.from(activePartialMessageSubscriptions).map(async (responseStream) => {
|
||||
// Send to gRPC stream subscribers
|
||||
const streamPromises = Array.from(activePartialMessageSubscriptions).map(async (responseStream) => {
|
||||
try {
|
||||
await responseStream(
|
||||
partialMessage,
|
||||
@@ -53,5 +69,14 @@ export async function sendPartialMessageEvent(partialMessage: ClineMessage): Pro
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.all(promises)
|
||||
// Send to callback subscribers (synchronous)
|
||||
for (const callback of callbackSubscriptions) {
|
||||
try {
|
||||
callback(partialMessage)
|
||||
} catch (error) {
|
||||
Logger.error("Error in partial message callback:", error)
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(streamPromises)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,25 @@ import { SystemPromptSection } from "../templates/placeholders"
|
||||
import { TemplateEngine } from "../templates/TemplateEngine"
|
||||
import type { PromptVariant, SystemPromptContext } from "../types"
|
||||
|
||||
const AUTO_FORMATTING_SECTION_IDE = `# Auto-formatting Considerations
|
||||
|
||||
- After using either write_to_file or replace_in_file, the user's editor may automatically format the file
|
||||
- This auto-formatting may modify the file contents, for example:
|
||||
- Breaking single lines into multiple lines
|
||||
- Adjusting indentation to match project style (e.g. 2 spaces vs 4 spaces vs tabs)
|
||||
- Converting single quotes to double quotes (or vice versa based on project preferences)
|
||||
- Organizing imports (e.g. sorting, grouping by type)
|
||||
- Adding/removing trailing commas in objects and arrays
|
||||
- Enforcing consistent brace style (e.g. same-line vs new-line)
|
||||
- Standardizing semicolon usage (adding or removing based on style)
|
||||
- The write_to_file and replace_in_file tool responses will include the final state of the file after any auto-formatting
|
||||
- Use this final state as your reference point for any subsequent edits. This is ESPECIALLY important when crafting SEARCH blocks for replace_in_file which require the content to match what's in the file exactly.`
|
||||
|
||||
const AUTO_FORMATTING_SECTION_CLI = `# Formatting Considerations
|
||||
|
||||
- Files are saved exactly as written. Ensure proper formatting, indentation, and style consistency in your edits.
|
||||
- When crafting SEARCH blocks for replace_in_file, match exactly what you wrote previously.`
|
||||
|
||||
const EDITING_FILES_TEMPLATE_TEXT = `EDITING FILES
|
||||
|
||||
You have access to two tools for working with files: **write_to_file** and **replace_in_file**. Understanding their roles and selecting the right one for the job will help ensure efficient and accurate modifications.
|
||||
@@ -52,19 +71,7 @@ You have access to two tools for working with files: **write_to_file** and **rep
|
||||
- The file is relatively small and the changes affect most of its content
|
||||
- You're generating boilerplate or template files
|
||||
|
||||
# Auto-formatting Considerations
|
||||
|
||||
- After using either write_to_file or replace_in_file, the user's editor may automatically format the file
|
||||
- This auto-formatting may modify the file contents, for example:
|
||||
- Breaking single lines into multiple lines
|
||||
- Adjusting indentation to match project style (e.g. 2 spaces vs 4 spaces vs tabs)
|
||||
- Converting single quotes to double quotes (or vice versa based on project preferences)
|
||||
- Organizing imports (e.g. sorting, grouping by type)
|
||||
- Adding/removing trailing commas in objects and arrays
|
||||
- Enforcing consistent brace style (e.g. same-line vs new-line)
|
||||
- Standardizing semicolon usage (adding or removing based on style)
|
||||
- The write_to_file and replace_in_file tool responses will include the final state of the file after any auto-formatting
|
||||
- Use this final state as your reference point for any subsequent edits. This is ESPECIALLY important when crafting SEARCH blocks for replace_in_file which require the content to match what's in the file exactly.
|
||||
{{AUTO_FORMATTING_SECTION}}
|
||||
|
||||
# Workflow Tips
|
||||
|
||||
@@ -78,5 +85,11 @@ By thoughtfully selecting between write_to_file and replace_in_file, you can mak
|
||||
export async function getEditingFilesSection(variant: PromptVariant, context: SystemPromptContext): Promise<string> {
|
||||
const template = variant.componentOverrides?.[SystemPromptSection.EDITING_FILES]?.template || EDITING_FILES_TEMPLATE_TEXT
|
||||
|
||||
return new TemplateEngine().resolve(template, context, {})
|
||||
// Use CLI-specific auto-formatting section when running in CLI mode
|
||||
// CLI has no IDE to auto-format files
|
||||
const autoFormattingSection = context.isCliEnvironment ? AUTO_FORMATTING_SECTION_CLI : AUTO_FORMATTING_SECTION_IDE
|
||||
|
||||
return new TemplateEngine().resolve(template, context, {
|
||||
AUTO_FORMATTING_SECTION: autoFormattingSection,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ const BROWSER_RULES = `- The user may ask generic non-development tasks, such as
|
||||
|
||||
const BROWSER_WAIT_RULES = ` Then if you want to test your work, you might use browser_action to launch the site, wait for the user's response confirming the site was launched along with a screenshot, then perhaps e.g., click a button to test functionality if needed, wait for the user's response confirming the button was clicked along with a screenshot of the new state, before finally closing the browser.`
|
||||
|
||||
const CLI_RULES = `- After making code changes, consider running any available validation tools for the project (such as type checkers, linters, or build scripts like \`npm run lint\`, \`npx tsc --noEmit\`, \`npm run build\`) to catch errors, since you won't receive automatic diagnostics after edits.\n`
|
||||
|
||||
const getRulesTemplateText = (context: SystemPromptContext) => `RULES
|
||||
|
||||
- Your current working directory is: {{CWD}}
|
||||
@@ -22,7 +24,7 @@ const getRulesTemplateText = (context: SystemPromptContext) => `RULES
|
||||
- When executing commands, if you don't see the expected output, assume the terminal executed the command successfully and proceed with the task. The user's terminal may be unable to stream the output back properly.${context.yoloModeToggled !== true ? " If you absolutely need to see the actual terminal output, use the ask_followup_question tool to request the user to copy and paste it back to you." : ""}
|
||||
- The user may provide a file's contents directly in their message, in which case you shouldn't use the read_file tool to get the file contents again since you already have it.
|
||||
- Your goal is to try to accomplish the user's task, NOT engage in a back and forth conversation.
|
||||
{{BROWSER_RULES}}- NEVER end attempt_completion result with a question or request to engage in further conversation! Formulate the end of your result in a way that is final and does not require further input from the user.
|
||||
{{BROWSER_RULES}}{{CLI_RULES}}- NEVER end attempt_completion result with a question or request to engage in further conversation! Formulate the end of your result in a way that is final and does not require further input from the user.
|
||||
- You are STRICTLY FORBIDDEN from starting your messages with "Great", "Certainly", "Okay", "Sure". You should NOT be conversational in your responses, but rather direct and to the point. For example you should NOT say "Great, I've updated the CSS" but instead something like "I've updated the CSS". It is important you be clear and technical in your messages.
|
||||
- When presented with images, utilize your vision capabilities to thoroughly examine them and extract meaningful information. Incorporate these insights into your thought process as you accomplish the user's task.
|
||||
- At the end of each user message, you will automatically receive environment_details. This information is not written by the user themselves, but is auto-generated to provide potentially relevant context about the project structure and environment. While this information can be valuable for understanding the project context, do not treat it as a direct part of the user's request or response. Use it to inform your actions and decisions, but don't assume the user is explicitly asking about or referring to this information unless they clearly do so in their message. When using environment_details, explain your actions clearly to ensure the user understands, as they may not be aware of these details.
|
||||
@@ -38,10 +40,12 @@ export async function getRulesSection(variant: PromptVariant, context: SystemPro
|
||||
|
||||
const browserRules = context.supportsBrowserUse ? BROWSER_RULES : ""
|
||||
const browserWaitRules = context.supportsBrowserUse ? BROWSER_WAIT_RULES : ""
|
||||
const cliRules = context.isCliEnvironment ? CLI_RULES : ""
|
||||
|
||||
return new TemplateEngine().resolve(template, context, {
|
||||
CWD: context.cwd || process.cwd(),
|
||||
BROWSER_RULES: browserRules,
|
||||
BROWSER_WAIT_RULES: browserWaitRules,
|
||||
CLI_RULES: cliRules,
|
||||
})
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user