mirror of
https://github.com/cline/cline.git
synced 2026-09-01 23:19:18 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c6a63a0611 | |||
| 243048241a | |||
| ea1652eb44 | |||
| b6828eac0d |
@@ -46,3 +46,4 @@ test-results
|
||||
/pkg
|
||||
.secrets
|
||||
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
The CLI reuses the core Cline TypeScript codebase:
|
||||
|
||||
- **Controller** (`@core/controller`): Manages task lifecycle and state
|
||||
- **Task** (`@core/task`): Executes Cline tasks using the AI API
|
||||
- **StateManager** (`@core/storage`): Handles persistent state storage
|
||||
|
||||
CLI-specific implementations:
|
||||
|
||||
- `cli-host-bridge.ts`: CLI implementations of host bridge services
|
||||
- `cli-webview-provider.ts`: WebviewProvider that outputs to terminal
|
||||
- `cli-comment-review.ts`: Comment review controller for terminal
|
||||
- `vscode-context.ts`: Mock VSCode extension context
|
||||
- `display.ts`: Terminal output formatting utilities
|
||||
|
||||
## Configuration
|
||||
|
||||
The CLI stores its data in `~/.cline/data/` by default:
|
||||
|
||||
- `globalState.json`: Global settings and state
|
||||
- `secrets.json`: API keys and secrets
|
||||
- `workspace/`: Workspace-specific state
|
||||
- `tasks/`: Task history and conversation data
|
||||
|
||||
Override with the `--config` option or `CLINE_DIR` environment variable.
|
||||
|
||||
## Comparison with Go CLI
|
||||
|
||||
This TypeScript CLI differs from the Go CLI (`cli/` directory):
|
||||
|
||||
| Feature | Go CLI | TypeScript CLI |
|
||||
|---------|--------|----------------|
|
||||
| Language | Go | TypeScript |
|
||||
| Core sharing | Uses gRPC to communicate | Direct imports |
|
||||
| Startup time | Fast | Moderate |
|
||||
| Dependencies | Standalone binary | Requires Node.js |
|
||||
| Best for | Production deployment | Development, debugging |
|
||||
|
||||
Choose the TypeScript CLI when you need to debug or modify the core Cline logic. Choose the Go CLI for production deployment with faster startup.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Build Errors
|
||||
|
||||
If you encounter build errors, ensure you've:
|
||||
1. Run `npm install` in the repository root
|
||||
2. Run `npm run protos` to generate proto files
|
||||
3. Have all peer dependencies installed
|
||||
|
||||
### Missing Dependencies
|
||||
|
||||
The CLI imports from the parent project. If you see import errors:
|
||||
```bash
|
||||
cd .. # Go to repository root
|
||||
npm install
|
||||
npm run protos
|
||||
```
|
||||
|
||||
### Permission Denied
|
||||
|
||||
Make the CLI executable:
|
||||
```bash
|
||||
chmod +x dist/cli.js
|
||||
```
|
||||
@@ -0,0 +1,231 @@
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import * as esbuild from "esbuild"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const rootDir = path.resolve(__dirname, "..")
|
||||
|
||||
const production = process.argv.includes("--production")
|
||||
const watch = process.argv.includes("--watch")
|
||||
|
||||
/**
|
||||
* Plugin to resolve path aliases from the parent project
|
||||
* @type {import('esbuild').Plugin}
|
||||
*/
|
||||
const aliasResolverPlugin = {
|
||||
name: "alias-resolver",
|
||||
setup(build) {
|
||||
const aliases = {
|
||||
"@": path.resolve(rootDir, "src"),
|
||||
"@core": path.resolve(rootDir, "src/core"),
|
||||
"@integrations": path.resolve(rootDir, "src/integrations"),
|
||||
"@services": path.resolve(rootDir, "src/services"),
|
||||
"@shared": path.resolve(rootDir, "src/shared"),
|
||||
"@utils": path.resolve(rootDir, "src/utils"),
|
||||
"@packages": path.resolve(rootDir, "src/packages"),
|
||||
"@hosts": path.resolve(rootDir, "src/hosts"),
|
||||
"@generated": path.resolve(rootDir, "src/generated"),
|
||||
"@api": path.resolve(rootDir, "src/core/api"),
|
||||
}
|
||||
|
||||
// For each alias entry, create a resolver
|
||||
Object.entries(aliases).forEach(([alias, aliasPath]) => {
|
||||
const aliasRegex = new RegExp(`^${alias}($|/.*)`)
|
||||
build.onResolve({ filter: aliasRegex }, (args) => {
|
||||
const importPath = args.path.replace(alias, aliasPath)
|
||||
|
||||
// First, check if the path exists as is
|
||||
if (fs.existsSync(importPath)) {
|
||||
const stats = fs.statSync(importPath)
|
||||
if (stats.isDirectory()) {
|
||||
// If it's a directory, try to find index files
|
||||
const extensions = [".ts", ".tsx", ".js", ".jsx"]
|
||||
for (const ext of extensions) {
|
||||
const indexFile = path.join(importPath, `index${ext}`)
|
||||
if (fs.existsSync(indexFile)) {
|
||||
return { path: indexFile }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// It's a file that exists, so return it
|
||||
return { path: importPath }
|
||||
}
|
||||
}
|
||||
|
||||
// If the path doesn't exist, try appending extensions
|
||||
const extensions = [".ts", ".tsx", ".js", ".jsx"]
|
||||
for (const ext of extensions) {
|
||||
const pathWithExtension = `${importPath}${ext}`
|
||||
if (fs.existsSync(pathWithExtension)) {
|
||||
return { path: pathWithExtension }
|
||||
}
|
||||
}
|
||||
|
||||
// If nothing worked, return the original path and let esbuild handle the error
|
||||
return { path: importPath }
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin to redirect vscode imports to our shim
|
||||
* @type {import('esbuild').Plugin}
|
||||
*/
|
||||
const vscodeStubPlugin = {
|
||||
name: "vscode-stub",
|
||||
setup(build) {
|
||||
// Redirect 'vscode' imports to our shim
|
||||
build.onResolve({ filter: /^vscode$/ }, (args) => {
|
||||
return { path: path.join(__dirname, "src", "vscode-shim.ts") }
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const esbuildProblemMatcherPlugin = {
|
||||
name: "esbuild-problem-matcher",
|
||||
setup(build) {
|
||||
build.onStart(() => {
|
||||
console.log("[cli-ts] Build started...")
|
||||
})
|
||||
build.onEnd((result) => {
|
||||
result.errors.forEach(({ text, location }) => {
|
||||
console.error(`✘ [ERROR] ${text}`)
|
||||
if (location) {
|
||||
console.error(` ${location.file}:${location.line}:${location.column}:`)
|
||||
}
|
||||
})
|
||||
console.log("[cli-ts] Build finished")
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// Plugin to stub out optional devtools module
|
||||
const stubOptionalModulesPlugin = {
|
||||
name: "stub-optional-modules",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^react-devtools-core$/ }, () => {
|
||||
return { path: path.join(__dirname, "src", "stub-devtools.js"), external: false }
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const copyWasmFiles = {
|
||||
name: "copy-wasm-files",
|
||||
setup(build) {
|
||||
build.onEnd(() => {
|
||||
const destDir = path.join(__dirname, "dist")
|
||||
|
||||
// Ensure dist directory exists
|
||||
if (!fs.existsSync(destDir)) {
|
||||
fs.mkdirSync(destDir, { recursive: true })
|
||||
}
|
||||
|
||||
// tree sitter
|
||||
const sourceDir = path.join(rootDir, "node_modules", "web-tree-sitter")
|
||||
|
||||
// Copy tree-sitter.wasm
|
||||
const treeSitterWasm = path.join(sourceDir, "tree-sitter.wasm")
|
||||
if (fs.existsSync(treeSitterWasm)) {
|
||||
fs.copyFileSync(treeSitterWasm, path.join(destDir, "tree-sitter.wasm"))
|
||||
}
|
||||
|
||||
// Copy language-specific WASM files
|
||||
const languageWasmDir = path.join(rootDir, "node_modules", "tree-sitter-wasms", "out")
|
||||
const languages = [
|
||||
"typescript",
|
||||
"tsx",
|
||||
"python",
|
||||
"rust",
|
||||
"javascript",
|
||||
"go",
|
||||
"cpp",
|
||||
"c",
|
||||
"c_sharp",
|
||||
"ruby",
|
||||
"java",
|
||||
"php",
|
||||
"swift",
|
||||
"kotlin",
|
||||
]
|
||||
|
||||
if (fs.existsSync(languageWasmDir)) {
|
||||
languages.forEach((lang) => {
|
||||
const filename = `tree-sitter-${lang}.wasm`
|
||||
const sourcePath = path.join(languageWasmDir, filename)
|
||||
if (fs.existsSync(sourcePath)) {
|
||||
fs.copyFileSync(sourcePath, path.join(destDir, filename))
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const buildEnvVars = {
|
||||
"process.env.IS_STANDALONE": JSON.stringify("true"),
|
||||
"process.env.IS_CLI": JSON.stringify("true"),
|
||||
}
|
||||
|
||||
if (production) {
|
||||
buildEnvVars["process.env.IS_DEV"] = "false"
|
||||
}
|
||||
|
||||
// Set the environment
|
||||
if (process.env.CLINE_ENVIRONMENT) {
|
||||
buildEnvVars["process.env.CLINE_ENVIRONMENT"] = JSON.stringify(process.env.CLINE_ENVIRONMENT)
|
||||
}
|
||||
|
||||
const config = {
|
||||
entryPoints: [path.join(__dirname, "src", "index.ts")],
|
||||
bundle: true,
|
||||
minify: production,
|
||||
sourcemap: !production,
|
||||
logLevel: "silent",
|
||||
define: buildEnvVars,
|
||||
tsconfig: path.join(__dirname, "tsconfig.json"),
|
||||
plugins: [copyWasmFiles, aliasResolverPlugin, vscodeStubPlugin, stubOptionalModulesPlugin, esbuildProblemMatcherPlugin],
|
||||
format: "esm",
|
||||
sourcesContent: false,
|
||||
platform: "node",
|
||||
target: "node20",
|
||||
outfile: path.join(__dirname, "dist", "cli.mjs"),
|
||||
// These modules need to load files from the module directory at runtime
|
||||
external: ["@grpc/reflection", "grpc-health-check", "better-sqlite3", "ink", "ink-spinner", "react"],
|
||||
supported: { "top-level-await": true },
|
||||
banner: {
|
||||
js: `#!/usr/bin/env node
|
||||
// Suppress all Node.js warnings (deprecation, experimental, etc.)
|
||||
process.emitWarning = () => {};
|
||||
import { createRequire as _createRequire } from 'module';
|
||||
import { fileURLToPath as _fileURLToPath } from 'url';
|
||||
import { dirname as _dirname } from 'path';
|
||||
const require = _createRequire(import.meta.url);
|
||||
const __filename = _fileURLToPath(import.meta.url);
|
||||
const __dirname = _dirname(__filename);`,
|
||||
},
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const ctx = await esbuild.context(config)
|
||||
if (watch) {
|
||||
await ctx.watch()
|
||||
console.log("[cli-ts] Watching for changes...")
|
||||
} else {
|
||||
await ctx.rebuild()
|
||||
await ctx.dispose()
|
||||
|
||||
// Make the output executable
|
||||
const outfile = path.join(__dirname, "dist", "cli.mjs")
|
||||
if (fs.existsSync(outfile)) {
|
||||
fs.chmodSync(outfile, "755")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
Generated
+2945
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"version": "1.0.0",
|
||||
"description": "Cline CLI - TypeScript implementation that reuses core Cline functionality",
|
||||
"main": "dist/cli.mjs",
|
||||
"bin": {
|
||||
"cline": "./dist/cli.mjs"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node esbuild.mjs",
|
||||
"build:production": "node esbuild.mjs --production",
|
||||
"watch": "node esbuild.mjs --watch",
|
||||
"dev": "IS_DEV=true && npm run link && npm run watch ; npm run unlink",
|
||||
"clean": "rimraf dist",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"link": "npm run build && npm link",
|
||||
"unlink": "npm unlink -g @cline/cli && npm unlink -g cline",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"keywords": [
|
||||
"cline",
|
||||
"cli",
|
||||
"ai",
|
||||
"coding-assistant"
|
||||
],
|
||||
"author": "Cline Bot Inc.",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@types/node": "20.x",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@types/react": "^18.3.27",
|
||||
"esbuild": "^0.25.0",
|
||||
"ink-testing-library": "^4.0.0",
|
||||
"rimraf": "^6.0.1",
|
||||
"typescript": "^5.4.5",
|
||||
"vitest": "^4.0.17"
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.1.0",
|
||||
"ink": "^5.0.1",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"ora": "^8.0.1",
|
||||
"prompts": "^2.4.2",
|
||||
"react": "^18.3.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Account info view component
|
||||
* Shows current provider, and for Cline provider: credit balance and organization name
|
||||
*/
|
||||
|
||||
import { Box, Text } from "ink"
|
||||
import React, { useCallback, useEffect, useState } from "react"
|
||||
import { Controller } from "@/core/controller"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { ClineAccountService } from "@/services/account/ClineAccountService"
|
||||
import { AuthService, ClineAccountOrganization } from "@/services/auth/AuthService"
|
||||
import { LoadingSpinner } from "./Spinner"
|
||||
|
||||
interface AccountInfoViewProps {
|
||||
controller: Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Capitalize provider name for display
|
||||
*/
|
||||
function capitalize(str: string): string {
|
||||
return str
|
||||
.split("-")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/**
|
||||
* Format balance as currency (balance is in microcredits, divide by 10000)
|
||||
*/
|
||||
function formatBalance(balance: number | null): string {
|
||||
if (balance === null || balance === undefined) {
|
||||
return "..."
|
||||
}
|
||||
return `$${(balance / 1000000).toFixed(2)}`
|
||||
}
|
||||
|
||||
export const AccountInfoView: React.FC<AccountInfoViewProps> = ({ controller }) => {
|
||||
const [provider, setProvider] = useState<string | null>(null)
|
||||
const [balance, setBalance] = useState<number | null>(null)
|
||||
const [organization, setOrganization] = useState<ClineAccountOrganization | null>(null)
|
||||
const [email, setEmail] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchAccountInfo = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
// Get current provider from state
|
||||
const stateManager = StateManager.get()
|
||||
const mode = stateManager.getGlobalSettingsKey("mode") as string
|
||||
const providerKey = mode === "act" ? "actModeApiProvider" : "planModeApiProvider"
|
||||
const currentProvider = stateManager.getGlobalSettingsKey(providerKey) as string
|
||||
setProvider(currentProvider || "cline")
|
||||
|
||||
// If using Cline provider, fetch additional info
|
||||
if (currentProvider === "cline") {
|
||||
const authService = AuthService.getInstance(controller)
|
||||
|
||||
// Wait for auth to be restored - poll until we have auth info or timeout
|
||||
let authInfo = authService.getInfo()
|
||||
let attempts = 0
|
||||
const maxAttempts = 20 // 2 seconds max
|
||||
while (!authInfo?.user?.uid && attempts < maxAttempts) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
authInfo = authService.getInfo()
|
||||
attempts++
|
||||
}
|
||||
|
||||
// Get user info
|
||||
if (authInfo?.user?.email) {
|
||||
setEmail(authInfo.user.email)
|
||||
} else {
|
||||
// User not logged in to Cline
|
||||
setEmail(null)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Get organization info
|
||||
const organizations = authService.getUserOrganizations()
|
||||
if (organizations) {
|
||||
const activeOrg = organizations.find((org) => org.active)
|
||||
if (activeOrg) {
|
||||
setOrganization(activeOrg)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch credit balance
|
||||
try {
|
||||
const accountService = ClineAccountService.getInstance()
|
||||
const activeOrgId = authService.getActiveOrganizationId()
|
||||
|
||||
if (activeOrgId) {
|
||||
// Fetch organization balance
|
||||
const orgBalance = await accountService.fetchOrganizationCreditsRPC(activeOrgId)
|
||||
if (orgBalance?.balance !== undefined) {
|
||||
setBalance(orgBalance.balance)
|
||||
}
|
||||
} else {
|
||||
// Fetch personal balance
|
||||
const balanceData = await accountService.fetchBalanceRPC()
|
||||
if (balanceData?.balance !== undefined) {
|
||||
setBalance(balanceData.balance)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Balance fetch failed, but we can still show other info
|
||||
// Don't log to console as it pollutes CLI output
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load account info")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [controller])
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccountInfo()
|
||||
}, [fetchAccountInfo])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box>
|
||||
<LoadingSpinner />
|
||||
<Text color="gray"> Loading account info...</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color="red">Error: {error}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// If not using Cline provider, just show the provider name
|
||||
if (provider !== "cline") {
|
||||
return (
|
||||
<Box>
|
||||
<Text color="gray">Provider: </Text>
|
||||
<Text color="cyan">{capitalize(provider || "Not configured")}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Cline provider but not logged in
|
||||
if (!email) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color="gray">Provider: </Text>
|
||||
<Text color="cyan">Cline</Text>
|
||||
<Text color="gray"> • </Text>
|
||||
<Text color="yellow">Not logged in (run 'cline auth' to sign in)</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Cline provider - show full account info
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text color="gray">Provider: </Text>
|
||||
<Text color="cyan">Cline</Text>
|
||||
{email && (
|
||||
<Box>
|
||||
<Text color="gray"> • </Text>
|
||||
<Text color="white">{email}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
{organization ? (
|
||||
<Box>
|
||||
<Text color="gray">Organization: </Text>
|
||||
<Text color="magenta">{organization.name}</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
<Text color="gray">Account: </Text>
|
||||
<Text color="white">Personal</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Text color="gray"> • Credits: </Text>
|
||||
<Text color="green">{formatBalance(balance)}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Text } from "ink"
|
||||
import { render } from "ink-testing-library"
|
||||
import React from "react"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { App } from "./App"
|
||||
|
||||
// Mock the child components to isolate App routing logic
|
||||
vi.mock("./TaskView", () => ({
|
||||
TaskView: ({ taskId, verbose }: any) =>
|
||||
React.createElement(Text, null, `TaskView: ${taskId || "no-id"} verbose=${String(verbose)}`),
|
||||
}))
|
||||
|
||||
vi.mock("./HistoryView", () => ({
|
||||
HistoryView: ({ items }: any) => React.createElement(Text, null, `HistoryView: ${items?.length || 0} items`),
|
||||
}))
|
||||
|
||||
vi.mock("./ConfigView", () => ({
|
||||
ConfigView: ({ dataDir }: any) => React.createElement(Text, null, `ConfigView: ${dataDir}`),
|
||||
}))
|
||||
|
||||
vi.mock("./AuthView", () => ({
|
||||
AuthView: ({ quickSetup }: any) => React.createElement(Text, null, `AuthView: ${quickSetup?.provider || "no-provider"}`),
|
||||
}))
|
||||
|
||||
vi.mock("./WelcomeView", () => ({
|
||||
WelcomeView: () => React.createElement(Text, null, "WelcomeView"),
|
||||
}))
|
||||
|
||||
vi.mock("../context/TaskContext", () => ({
|
||||
TaskContextProvider: ({ children }: any) => children,
|
||||
}))
|
||||
|
||||
describe("App", () => {
|
||||
const mockController = {
|
||||
dispose: vi.fn(),
|
||||
stateManager: { flushPendingState: vi.fn() },
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("view routing", () => {
|
||||
it("should render TaskView when view is task", () => {
|
||||
const { lastFrame } = render(<App controller={mockController} taskId="test-task" view="task" />)
|
||||
expect(lastFrame()).toContain("TaskView")
|
||||
expect(lastFrame()).toContain("test-task")
|
||||
})
|
||||
|
||||
it("should render HistoryView when view is history", () => {
|
||||
const historyItems = [
|
||||
{ id: "1", ts: Date.now(), task: "Task 1" },
|
||||
{ id: "2", ts: Date.now(), task: "Task 2" },
|
||||
]
|
||||
const { lastFrame } = render(<App controller={mockController} historyItems={historyItems} view="history" />)
|
||||
expect(lastFrame()).toContain("HistoryView")
|
||||
expect(lastFrame()).toContain("2 items")
|
||||
})
|
||||
|
||||
it("should render ConfigView when view is config", () => {
|
||||
const { lastFrame } = render(
|
||||
<App dataDir="/path/to/config" globalState={{ key: "value" }} view="config" workspaceState={{}} />,
|
||||
)
|
||||
expect(lastFrame()).toContain("ConfigView")
|
||||
expect(lastFrame()).toContain("/path/to/config")
|
||||
})
|
||||
|
||||
it("should render AuthView when view is auth", () => {
|
||||
const { lastFrame } = render(<App authQuickSetup={{ provider: "openai" }} controller={mockController} view="auth" />)
|
||||
expect(lastFrame()).toContain("AuthView")
|
||||
expect(lastFrame()).toContain("openai")
|
||||
})
|
||||
|
||||
it("should render WelcomeView when view is welcome", () => {
|
||||
const { lastFrame } = render(
|
||||
<App controller={mockController} onWelcomeExit={() => {}} onWelcomeSubmit={() => {}} view="welcome" />,
|
||||
)
|
||||
expect(lastFrame()).toContain("WelcomeView")
|
||||
})
|
||||
})
|
||||
|
||||
describe("default props", () => {
|
||||
it("should use default verbose=false", () => {
|
||||
const { lastFrame } = render(<App controller={mockController} view="task" />)
|
||||
expect(lastFrame()).toContain("verbose=false")
|
||||
})
|
||||
|
||||
it("should use empty array for historyItems by default", () => {
|
||||
const { lastFrame } = render(<App controller={mockController} view="history" />)
|
||||
expect(lastFrame()).toContain("0 items")
|
||||
})
|
||||
})
|
||||
|
||||
describe("props passing", () => {
|
||||
it("should pass verbose to TaskView", () => {
|
||||
const { lastFrame } = render(<App controller={mockController} verbose={true} view="task" />)
|
||||
expect(lastFrame()).toContain("verbose=true")
|
||||
})
|
||||
|
||||
it("should pass taskId to TaskView", () => {
|
||||
const { lastFrame } = render(<App controller={mockController} taskId="my-task-123" view="task" />)
|
||||
expect(lastFrame()).toContain("my-task-123")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* 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 { AuthView } from "./AuthView"
|
||||
import { ConfigView } from "./ConfigView"
|
||||
import { HistoryView } from "./HistoryView"
|
||||
import { JsonTaskView } from "./JsonTaskView"
|
||||
import { TaskView } from "./TaskView"
|
||||
import { WelcomeView } from "./WelcomeView"
|
||||
|
||||
export type ViewType = "task" | "history" | "config" | "auth" | "welcome"
|
||||
|
||||
interface HistoryPagination {
|
||||
page: number
|
||||
totalPages: number
|
||||
totalCount: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
interface HookInfo {
|
||||
name: string
|
||||
enabled: boolean
|
||||
absolutePath: string
|
||||
}
|
||||
|
||||
interface WorkspaceHooks {
|
||||
workspaceName: string
|
||||
hooks: HookInfo[]
|
||||
}
|
||||
|
||||
interface SkillInfo {
|
||||
name: string
|
||||
description: string
|
||||
path: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface AppProps {
|
||||
view: ViewType
|
||||
taskId?: string
|
||||
verbose?: boolean
|
||||
jsonOutput?: boolean
|
||||
controller?: any
|
||||
onComplete?: () => void
|
||||
onError?: () => void
|
||||
// For history view
|
||||
historyItems?: Array<{ id: string; ts: number; task?: string; totalCost?: number; modelId?: string }>
|
||||
historyAllItems?: Array<{ id: string; ts: number; task?: string; totalCost?: number; modelId?: string }>
|
||||
historyPagination?: HistoryPagination
|
||||
onHistoryPageChange?: (page: number) => void
|
||||
// For config view
|
||||
dataDir?: string
|
||||
globalState?: Record<string, any>
|
||||
workspaceState?: Record<string, any>
|
||||
// Rules toggles
|
||||
globalClineRulesToggles?: Record<string, boolean>
|
||||
localClineRulesToggles?: Record<string, boolean>
|
||||
localCursorRulesToggles?: Record<string, boolean>
|
||||
localWindsurfRulesToggles?: Record<string, boolean>
|
||||
localAgentsRulesToggles?: Record<string, boolean>
|
||||
onToggleRule?: (isGlobal: boolean, rulePath: string, enabled: boolean, ruleType: string) => void
|
||||
// Workflow toggles
|
||||
globalWorkflowToggles?: Record<string, boolean>
|
||||
localWorkflowToggles?: Record<string, boolean>
|
||||
onToggleWorkflow?: (isGlobal: boolean, workflowPath: string, enabled: boolean) => void
|
||||
// Hooks
|
||||
hooksEnabled?: boolean
|
||||
globalHooks?: HookInfo[]
|
||||
workspaceHooks?: WorkspaceHooks[]
|
||||
onToggleHook?: (isGlobal: boolean, hookName: string, enabled: boolean, workspaceName?: string) => void
|
||||
// Skills
|
||||
skillsEnabled?: boolean
|
||||
globalSkills?: SkillInfo[]
|
||||
localSkills?: SkillInfo[]
|
||||
onToggleSkill?: (isGlobal: boolean, skillPath: string, enabled: boolean) => void
|
||||
// For auth view
|
||||
authQuickSetup?: {
|
||||
provider?: string
|
||||
apikey?: string
|
||||
modelid?: string
|
||||
baseurl?: string
|
||||
}
|
||||
// For welcome view
|
||||
onWelcomeSubmit?: (prompt: string, imagePaths: string[]) => void
|
||||
onWelcomeExit?: () => void
|
||||
// Stdin support
|
||||
isRawModeSupported?: boolean
|
||||
}
|
||||
|
||||
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,
|
||||
isRawModeSupported = true,
|
||||
}) => {
|
||||
const [currentView, setCurrentView] = useState<ViewType>(initialView)
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | undefined>(taskId)
|
||||
|
||||
const handleSelectTask = useCallback((taskId: string) => {
|
||||
setSelectedTaskId(taskId)
|
||||
setCurrentView("task")
|
||||
}, [])
|
||||
|
||||
const handleNavigateToWelcome = useCallback(() => {
|
||||
setCurrentView("welcome")
|
||||
}, [])
|
||||
|
||||
// Handle welcome submit when navigating internally (e.g., from auth -> welcome)
|
||||
const handleInternalWelcomeSubmit = useCallback(
|
||||
async (prompt: string, imagePaths: string[]) => {
|
||||
if (onWelcomeSubmit) {
|
||||
// If external handler provided, use it
|
||||
onWelcomeSubmit(prompt, imagePaths)
|
||||
} else if (controller && prompt.trim()) {
|
||||
// Otherwise, start a task directly via controller
|
||||
setCurrentView("task")
|
||||
// Convert image paths to data URLs if needed
|
||||
const imageDataUrls =
|
||||
imagePaths.length > 0
|
||||
? await Promise.all(
|
||||
imagePaths.map(async (p) => {
|
||||
try {
|
||||
const fs = await import("fs/promises")
|
||||
const path = await import("path")
|
||||
const data = await fs.readFile(p)
|
||||
const ext = path.extname(p).toLowerCase().slice(1)
|
||||
const mimeType = ext === "jpg" ? "jpeg" : ext
|
||||
return `data:image/${mimeType};base64,${data.toString("base64")}`
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}),
|
||||
)
|
||||
: []
|
||||
const validImages = imageDataUrls.filter((img): img is string => img !== null)
|
||||
await controller.initTask(prompt.trim(), validImages.length > 0 ? validImages : undefined)
|
||||
}
|
||||
},
|
||||
[onWelcomeSubmit, controller],
|
||||
)
|
||||
|
||||
let content: ReactNode
|
||||
|
||||
switch (currentView) {
|
||||
case "task":
|
||||
content = (
|
||||
<TaskContextProvider controller={controller}>
|
||||
{jsonOutput ? (
|
||||
<JsonTaskView onComplete={onComplete} onError={onError} taskId={selectedTaskId} verbose={verbose} />
|
||||
) : (
|
||||
<TaskView onComplete={onComplete} onError={onError} taskId={selectedTaskId} verbose={verbose} />
|
||||
)}
|
||||
</TaskContextProvider>
|
||||
)
|
||||
break
|
||||
|
||||
case "history":
|
||||
content = (
|
||||
<HistoryView
|
||||
allItems={historyAllItems}
|
||||
controller={controller}
|
||||
items={historyItems}
|
||||
onPageChange={onHistoryPageChange}
|
||||
onSelectTask={handleSelectTask}
|
||||
pagination={historyPagination}
|
||||
/>
|
||||
)
|
||||
break
|
||||
|
||||
case "config":
|
||||
content = (
|
||||
<ConfigView
|
||||
dataDir={dataDir}
|
||||
globalClineRulesToggles={globalClineRulesToggles}
|
||||
globalHooks={globalHooks}
|
||||
globalSkills={globalSkills}
|
||||
globalState={globalState}
|
||||
globalWorkflowToggles={globalWorkflowToggles}
|
||||
hooksEnabled={hooksEnabled}
|
||||
localAgentsRulesToggles={localAgentsRulesToggles}
|
||||
localClineRulesToggles={localClineRulesToggles}
|
||||
localCursorRulesToggles={localCursorRulesToggles}
|
||||
localSkills={localSkills}
|
||||
localWindsurfRulesToggles={localWindsurfRulesToggles}
|
||||
localWorkflowToggles={localWorkflowToggles}
|
||||
onToggleHook={onToggleHook}
|
||||
onToggleRule={onToggleRule}
|
||||
onToggleSkill={onToggleSkill}
|
||||
onToggleWorkflow={onToggleWorkflow}
|
||||
skillsEnabled={skillsEnabled}
|
||||
workspaceHooks={workspaceHooks}
|
||||
workspaceState={workspaceState}
|
||||
/>
|
||||
)
|
||||
break
|
||||
|
||||
case "auth":
|
||||
content = (
|
||||
<AuthView
|
||||
controller={controller}
|
||||
onComplete={onComplete}
|
||||
onError={onError}
|
||||
onNavigateToWelcome={handleNavigateToWelcome}
|
||||
quickSetup={authQuickSetup}
|
||||
/>
|
||||
)
|
||||
break
|
||||
|
||||
case "welcome":
|
||||
content = <WelcomeView controller={controller} onExit={onWelcomeExit} onSubmit={handleInternalWelcomeSubmit} />
|
||||
break
|
||||
|
||||
default:
|
||||
content = null
|
||||
}
|
||||
|
||||
return (
|
||||
<StdinProvider isRawModeSupported={isRawModeSupported}>
|
||||
<Box>{content}</Box>
|
||||
</StdinProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* User input prompt component
|
||||
* Handles different types of user interactions (text input, confirmations, choices)
|
||||
*/
|
||||
|
||||
import type { ClineAsk } from "@shared/ExtensionMessage"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import { Box, Text, useApp, useInput } from "ink"
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { getProviderDefaultModelId } from "@/shared/storage"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { useTaskController } from "../context/TaskContext"
|
||||
import { useLastCompletedAskMessage } from "../hooks/useStateSubscriber"
|
||||
import { jsonParseSafe } from "../utils/parser"
|
||||
import { getCliMessagePrefixIcon } from "./MessageRow"
|
||||
import { PromptInput } from "./PromptInput"
|
||||
|
||||
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)
|
||||
|
||||
const [mode, setMode] = useState<Mode>(() => {
|
||||
const stateManager = StateManager.get()
|
||||
return stateManager.getGlobalSettingsKey("mode") || "act"
|
||||
})
|
||||
|
||||
const toggleMode = useCallback(() => {
|
||||
const newMode: Mode = mode === "act" ? "plan" : "act"
|
||||
setMode(newMode)
|
||||
const stateManager = StateManager.get()
|
||||
stateManager.setGlobalState("mode", newMode)
|
||||
}, [mode])
|
||||
|
||||
const provider = useMemo(() => {
|
||||
const stateManager = StateManager.get()
|
||||
const currentMode = stateManager.getGlobalSettingsKey("mode") as string
|
||||
const providerKey = currentMode === "act" ? "actModeApiProvider" : "planModeApiProvider"
|
||||
const currentProvider = stateManager.getGlobalSettingsKey(providerKey) as string
|
||||
return currentProvider || "cline"
|
||||
}, [])
|
||||
|
||||
const modelId = useMemo(() => {
|
||||
const stateManager = StateManager.get()
|
||||
const modelKey = mode === "act" ? "actModeApiModelId" : "planModeApiModelId"
|
||||
return (stateManager.getGlobalSettingsKey(modelKey) as string) || getProviderDefaultModelId(provider)
|
||||
}, [mode, provider])
|
||||
|
||||
// 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) => {
|
||||
// Tab toggles mode regardless of prompt state
|
||||
if (key.tab) {
|
||||
toggleMode()
|
||||
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("optionSelected", selectedOption)
|
||||
} else {
|
||||
// Regular character input for free text
|
||||
setTextInput((prev) => prev + input)
|
||||
}
|
||||
}
|
||||
} else if (promptType === "text") {
|
||||
// Text input mode
|
||||
if (key.return) {
|
||||
// Submit on Enter
|
||||
if (textInput.trim()) {
|
||||
sendResponse("messageResponse", textInput.trim())
|
||||
}
|
||||
} else if (key.backspace || key.delete) {
|
||||
setTextInput((prev) => prev.slice(0, -1))
|
||||
} else if (input && !key.ctrl && !key.meta) {
|
||||
// Regular character input
|
||||
setTextInput((prev) => prev + input)
|
||||
}
|
||||
} else if (promptType === "plan_mode_text") {
|
||||
// Plan mode text input - allows text response or toggle to Act mode
|
||||
if (key.return) {
|
||||
// Submit on Enter
|
||||
if (textInput.trim()) {
|
||||
sendResponse("messageResponse", textInput.trim())
|
||||
} else {
|
||||
// Empty enter = switch to Act mode
|
||||
toggleToActMode()
|
||||
}
|
||||
} else if (key.backspace || key.delete) {
|
||||
setTextInput((prev) => prev.slice(0, -1))
|
||||
} else if (input && !key.ctrl && !key.meta) {
|
||||
// Regular character input
|
||||
setTextInput((prev) => prev + input)
|
||||
}
|
||||
} else if (promptType === "completion") {
|
||||
// Task completed - allow follow-up question or exit
|
||||
if (input.toLowerCase() === "q" && textInput === "") {
|
||||
// 'q' with no text typed = exit
|
||||
sendResponse("yesButtonClicked")
|
||||
} else if (key.return) {
|
||||
if (textInput.trim()) {
|
||||
// Send follow-up question
|
||||
sendResponse("messageResponse", textInput.trim())
|
||||
}
|
||||
// Empty enter = do nothing (user must type 'q' to exit or type a follow-up)
|
||||
} 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 },
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Helper to render options list above input
|
||||
const renderOptions = (options: string[]) => (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color="cyan">Select an option (enter number):</Text>
|
||||
{options.map((opt, idx) => (
|
||||
<Box key={idx} marginLeft={2}>
|
||||
<Text>{`${idx + 1}. ${opt}`}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
|
||||
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}>
|
||||
{renderOptions(parts.options)}
|
||||
<PromptInput
|
||||
helpText="(Enter number to select, or type response + Enter)"
|
||||
mode={mode}
|
||||
modelId={modelId || ""}
|
||||
question={
|
||||
<React.Fragment>
|
||||
<Text>{icon} </Text>
|
||||
<Text color="cyan">Or type:</Text>
|
||||
</React.Fragment>
|
||||
}
|
||||
textInput={textInput}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Text input prompt
|
||||
return (
|
||||
<PromptInput
|
||||
helpText="(Type your response and press Enter)"
|
||||
mode={mode}
|
||||
modelId={modelId || ""}
|
||||
question={
|
||||
<React.Fragment>
|
||||
<Text>{icon} </Text>
|
||||
<Text color="cyan">Reply:</Text>
|
||||
</React.Fragment>
|
||||
}
|
||||
textInput={textInput}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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}>
|
||||
{renderOptions(parts.options)}
|
||||
<PromptInput
|
||||
helpText="(Enter number to select, or type response + Enter)"
|
||||
mode={mode}
|
||||
modelId={modelId || ""}
|
||||
question={
|
||||
<React.Fragment>
|
||||
<Text>{icon} </Text>
|
||||
<Text color="cyan">Or type:</Text>
|
||||
</React.Fragment>
|
||||
}
|
||||
textInput={textInput}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Plan mode text input - show option to switch to Act mode
|
||||
return (
|
||||
<PromptInput
|
||||
helpText="(Type response + Enter, or just Enter to switch to Act mode)"
|
||||
mode={mode}
|
||||
modelId={modelId || ""}
|
||||
question={
|
||||
<React.Fragment>
|
||||
<Text>{icon} </Text>
|
||||
<Text color="cyan">Reply:</Text>
|
||||
</React.Fragment>
|
||||
}
|
||||
textInput={textInput}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
case "command":
|
||||
return (
|
||||
<PromptInput
|
||||
helpText="(y/n)"
|
||||
mode={mode}
|
||||
modelId={modelId || ""}
|
||||
question={
|
||||
<React.Fragment>
|
||||
<Text>{icon} </Text>
|
||||
<Text color="yellow">Execute this command?</Text>
|
||||
</React.Fragment>
|
||||
}
|
||||
questionColor="yellow"
|
||||
textInput={textInput}
|
||||
/>
|
||||
)
|
||||
|
||||
case "tool":
|
||||
return (
|
||||
<PromptInput
|
||||
helpText="(y/n)"
|
||||
mode={mode}
|
||||
modelId={modelId || ""}
|
||||
question={
|
||||
<React.Fragment>
|
||||
<Text>{icon} </Text>
|
||||
<Text color="blue">Use this tool?</Text>
|
||||
</React.Fragment>
|
||||
}
|
||||
questionColor="blue"
|
||||
textInput={textInput}
|
||||
/>
|
||||
)
|
||||
|
||||
case "completion_result":
|
||||
return (
|
||||
<PromptInput
|
||||
helpText="(Type follow-up question + Enter, or q to exit)"
|
||||
mode={mode}
|
||||
modelId={modelId || ""}
|
||||
question={
|
||||
<React.Fragment>
|
||||
<Text>{icon} </Text>
|
||||
<Text color="cyan">Follow-up:</Text>
|
||||
</React.Fragment>
|
||||
}
|
||||
textInput={textInput}
|
||||
/>
|
||||
)
|
||||
|
||||
case "resume_task":
|
||||
case "resume_completed_task":
|
||||
return (
|
||||
<PromptInput
|
||||
helpText="(y/n)"
|
||||
mode={mode}
|
||||
modelId={modelId || ""}
|
||||
question={
|
||||
<React.Fragment>
|
||||
<Text>{icon} </Text>
|
||||
<Text color="cyan">Resume task?</Text>
|
||||
</React.Fragment>
|
||||
}
|
||||
textInput={textInput}
|
||||
/>
|
||||
)
|
||||
|
||||
case "browser_action_launch":
|
||||
return (
|
||||
<PromptInput
|
||||
helpText="(y/n)"
|
||||
mode={mode}
|
||||
modelId={modelId || ""}
|
||||
question={
|
||||
<React.Fragment>
|
||||
<Text>{icon} </Text>
|
||||
<Text color="cyan">Launch browser?</Text>
|
||||
</React.Fragment>
|
||||
}
|
||||
textInput={textInput}
|
||||
/>
|
||||
)
|
||||
|
||||
case "use_mcp_server":
|
||||
return (
|
||||
<PromptInput
|
||||
helpText="(y/n)"
|
||||
mode={mode}
|
||||
modelId={modelId || ""}
|
||||
question={
|
||||
<React.Fragment>
|
||||
<Text>{icon} </Text>
|
||||
<Text color="cyan">Use MCP server?</Text>
|
||||
</React.Fragment>
|
||||
}
|
||||
textInput={textInput}
|
||||
/>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
/**
|
||||
* Auth view component
|
||||
* Handles interactive authentication and provider configuration
|
||||
*/
|
||||
|
||||
import { Box, Text, useApp, useInput } from "ink"
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { AuthService } from "@/services/auth/AuthService"
|
||||
import { API_PROVIDERS_LIST, openRouterDefaultModelId } from "@/shared/api"
|
||||
import { ProviderToApiKeyMap } from "@/shared/storage"
|
||||
import { secretStorage } from "@/shared/storage/ClineSecretStorage"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { LoadingSpinner } from "./Spinner"
|
||||
|
||||
type AuthStep = "menu" | "provider" | "apikey" | "modelid" | "baseurl" | "saving" | "success" | "error" | "cline_auth"
|
||||
|
||||
interface AuthViewProps {
|
||||
controller: any
|
||||
onComplete?: () => void
|
||||
onError?: () => void
|
||||
onNavigateToWelcome?: () => void
|
||||
// Quick setup options
|
||||
quickSetup?: {
|
||||
provider?: string
|
||||
apikey?: string
|
||||
modelid?: string
|
||||
baseurl?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface SelectItem {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Format separator
|
||||
*/
|
||||
function formatSeparator(char: string = "─", width: number = 60): string {
|
||||
return char.repeat(Math.max(width, 10))
|
||||
}
|
||||
|
||||
/**
|
||||
* Capitalize provider name for display
|
||||
*/
|
||||
function capitalize(str: string): string {
|
||||
return str
|
||||
.split("-")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/**
|
||||
* Select component with keyboard navigation
|
||||
*/
|
||||
const Select: React.FC<{
|
||||
items: SelectItem[]
|
||||
onSelect: (value: string) => void
|
||||
label?: string
|
||||
}> = ({ items, onSelect, label }) => {
|
||||
const { 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 ? "green" : undefined}>
|
||||
{index === selectedIndex ? "❯ " : " "}
|
||||
{item.label}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
<Text color="gray" dimColor>
|
||||
(Use arrow keys to navigate, Enter to select)
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Text input component
|
||||
*/
|
||||
const TextInput: React.FC<{
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onSubmit: (value: string) => void
|
||||
label: string
|
||||
placeholder?: string
|
||||
isPassword?: boolean
|
||||
}> = ({ value, onChange, onSubmit, label, placeholder, isPassword }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (key.return) {
|
||||
onSubmit(value)
|
||||
} else if (key.backspace || key.delete) {
|
||||
onChange(value.slice(0, -1))
|
||||
} else if (input && !key.ctrl && !key.meta) {
|
||||
onChange(value + input)
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported },
|
||||
)
|
||||
|
||||
const displayValue = isPassword ? "•".repeat(value.length) : value
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color="cyan">
|
||||
{label}
|
||||
</Text>
|
||||
<Box>
|
||||
<Text color="white">{displayValue || placeholder || ""}</Text>
|
||||
<Text color="gray">▌</Text>
|
||||
</Box>
|
||||
<Text color="gray" dimColor>
|
||||
(Type your input and press Enter{value ? "" : ", or press Enter to skip"})
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onError, onNavigateToWelcome, quickSetup }) => {
|
||||
const { exit } = useApp()
|
||||
const [step, setStep] = useState<AuthStep>(quickSetup ? "saving" : "menu")
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>(
|
||||
StateManager.get().getApiConfiguration().actModeApiProvider ||
|
||||
StateManager.get().getApiConfiguration().planModeApiProvider ||
|
||||
"",
|
||||
)
|
||||
const [apiKey, setApiKey] = useState("")
|
||||
const [modelId, setModelId] = useState("")
|
||||
const [baseUrl, setBaseUrl] = useState("")
|
||||
const [errorMessage, setErrorMessage] = useState("")
|
||||
const [authStatus, setAuthStatus] = useState<string>("")
|
||||
|
||||
// Sort providers alphabetically
|
||||
const sortedProviders = useMemo(() => API_PROVIDERS_LIST.slice().sort(), [])
|
||||
|
||||
// Get configured providers (those with API keys set)
|
||||
const configuredProviders = useMemo(() => {
|
||||
try {
|
||||
const config = StateManager.get().getApiConfiguration()
|
||||
const configured = new Set<string>()
|
||||
|
||||
for (const provider of sortedProviders) {
|
||||
const keyField = ProviderToApiKeyMap[provider]
|
||||
if (!keyField) {
|
||||
continue
|
||||
}
|
||||
|
||||
const fields = Array.isArray(keyField) ? keyField : [keyField]
|
||||
const hasKey = fields.some((field) => {
|
||||
const value = (config as Record<string, unknown>)[field]
|
||||
return value !== undefined && value !== null && value !== ""
|
||||
})
|
||||
|
||||
if (hasKey) {
|
||||
configured.add(provider)
|
||||
}
|
||||
}
|
||||
|
||||
return configured
|
||||
} catch {
|
||||
return new Set<string>()
|
||||
}
|
||||
}, [sortedProviders, ProviderToApiKeyMap])
|
||||
|
||||
// Main menu items
|
||||
const mainMenuItems: SelectItem[] = [
|
||||
{ label: "Sign in to Cline", value: "cline_auth" },
|
||||
{ label: "Configure BYO API provider", value: "configure_byo" },
|
||||
{ label: "Exit", value: "exit" },
|
||||
]
|
||||
|
||||
// Provider menu items
|
||||
const providerItems: SelectItem[] = useMemo(
|
||||
() =>
|
||||
sortedProviders.map((p: string) => ({
|
||||
label: `${capitalize(p)}${configuredProviders.has(p) ? " (configured)" : ""}`,
|
||||
value: p,
|
||||
})),
|
||||
[sortedProviders, configuredProviders],
|
||||
)
|
||||
|
||||
// Handle quick setup
|
||||
useEffect(() => {
|
||||
if (quickSetup && step === "saving") {
|
||||
handleQuickSetup()
|
||||
}
|
||||
}, [quickSetup, step])
|
||||
|
||||
// Subscribe to auth status updates when in cline_auth step
|
||||
useEffect(() => {
|
||||
if (step !== "cline_auth") {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
// Create a streaming response handler that receives auth state updates
|
||||
const responseHandler = async (authState: { user?: { email?: string } }, _isLast?: boolean) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (authState.user && authState.user.email) {
|
||||
// Auth succeeded - save configuration and transition to success
|
||||
const stateManager = StateManager.get()
|
||||
|
||||
const mode = stateManager.getGlobalSettingsKey("mode") || "act"
|
||||
const providerKey = mode === "act" ? "actModeApiProvider" : "planModeApiProvider"
|
||||
const modelIdKey = mode === "act" ? "actModeApiModelId" : "planModeApiModelId"
|
||||
|
||||
const config: Record<string, string> = {
|
||||
apiProvider: "cline",
|
||||
[providerKey]: "cline",
|
||||
[modelIdKey]: openRouterDefaultModelId,
|
||||
}
|
||||
|
||||
stateManager.setApiConfiguration(config)
|
||||
stateManager.flushPendingState()
|
||||
|
||||
setSelectedProvider("cline")
|
||||
setModelId(config[modelIdKey])
|
||||
setStep("success")
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to auth status updates
|
||||
const authService = AuthService.getInstance(controller)
|
||||
authService.subscribeToAuthStatusUpdate(controller, {}, responseHandler, `cli-auth-${Date.now()}`)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [step, controller])
|
||||
|
||||
const handleQuickSetup = async () => {
|
||||
if (!quickSetup) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { provider, apikey, modelid, baseurl } = quickSetup
|
||||
|
||||
// Validate required parameters
|
||||
if (!provider || !apikey || !modelid) {
|
||||
setErrorMessage("Quick setup requires --provider, --apikey, and --modelid flags")
|
||||
setStep("error")
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedProvider = provider.toLowerCase().trim()
|
||||
|
||||
if (!sortedProviders.includes(normalizedProvider)) {
|
||||
setErrorMessage(`Invalid provider '${provider}'. Supported providers: ${sortedProviders.join(", ")}`)
|
||||
setStep("error")
|
||||
return
|
||||
}
|
||||
|
||||
if (normalizedProvider === "bedrock") {
|
||||
setErrorMessage(
|
||||
"Bedrock provider is not supported for quick setup due to complex authentication requirements. Please use interactive setup.",
|
||||
)
|
||||
setStep("error")
|
||||
return
|
||||
}
|
||||
|
||||
if (baseurl && !["openai", "openai-native"].includes(normalizedProvider)) {
|
||||
setErrorMessage("Base URL is only supported for OpenAI and OpenAI-compatible providers")
|
||||
setStep("error")
|
||||
return
|
||||
}
|
||||
|
||||
// Save configuration
|
||||
const stateManager = StateManager.get()
|
||||
const config: Record<string, string> = {
|
||||
actModeApiProvider: normalizedProvider,
|
||||
planModeApiProvider: normalizedProvider,
|
||||
actModeApiModelId: modelid,
|
||||
planModeApiModelId: modelid,
|
||||
}
|
||||
|
||||
// Use provider-specific API key field
|
||||
const keyField = ProviderToApiKeyMap[normalizedProvider]
|
||||
if (keyField) {
|
||||
const fields = Array.isArray(keyField) ? keyField : [keyField]
|
||||
// Set the first key field for the provider
|
||||
config[fields[0]] = apikey
|
||||
} else {
|
||||
// Fallback to generic apiKey
|
||||
config.apiKey = apikey
|
||||
}
|
||||
|
||||
if (baseurl) {
|
||||
config.openAiBaseUrl = baseurl
|
||||
}
|
||||
|
||||
stateManager.setApiConfiguration(config)
|
||||
|
||||
await stateManager.flushPendingState()
|
||||
setSelectedProvider(normalizedProvider)
|
||||
setModelId(modelid)
|
||||
setBaseUrl(baseurl || "")
|
||||
setStep("success")
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : String(error))
|
||||
setStep("error")
|
||||
}
|
||||
}
|
||||
|
||||
const handleMainMenuSelect = useCallback(
|
||||
(value: string) => {
|
||||
if (value === "exit") {
|
||||
exit()
|
||||
onComplete?.()
|
||||
} else if (value === "cline_auth") {
|
||||
setStep("cline_auth")
|
||||
setAuthStatus("Starting authentication...")
|
||||
AuthService.getInstance(controller).createAuthRequest()
|
||||
} else if (value === "configure_byo") {
|
||||
setStep("provider")
|
||||
}
|
||||
},
|
||||
[exit, onComplete, controller],
|
||||
)
|
||||
|
||||
const handleProviderSelect = useCallback(
|
||||
(value: string) => {
|
||||
setSelectedProvider(value)
|
||||
if (value === "cline") {
|
||||
setStep("cline_auth")
|
||||
setAuthStatus("Starting authentication...")
|
||||
AuthService.getInstance(controller).createAuthRequest()
|
||||
} else {
|
||||
setStep("apikey")
|
||||
}
|
||||
},
|
||||
[controller],
|
||||
)
|
||||
|
||||
const handleApiKeySubmit = useCallback(
|
||||
(value: string) => {
|
||||
if (!value.trim() || !selectedProvider) {
|
||||
// Don't allow empty
|
||||
return
|
||||
}
|
||||
|
||||
// Use provider-specific API key field
|
||||
const foundKey = ProviderToApiKeyMap[selectedProvider] || "apiKey"
|
||||
const providerKey = Array.isArray(foundKey) ? foundKey[0] : foundKey
|
||||
|
||||
secretStorage.store(providerKey, value)
|
||||
|
||||
setApiKey(value)
|
||||
setStep("modelid")
|
||||
},
|
||||
[selectedProvider],
|
||||
)
|
||||
|
||||
const handleModelIdSubmit = useCallback(
|
||||
(value: string) => {
|
||||
if (value.trim()) {
|
||||
setModelId(value)
|
||||
}
|
||||
// Only show baseurl step for OpenAI-like providers
|
||||
if (["openai", "openai-native"].includes(selectedProvider)) {
|
||||
setStep("baseurl")
|
||||
} else {
|
||||
setStep("saving")
|
||||
saveConfiguration(value, "")
|
||||
}
|
||||
},
|
||||
[selectedProvider],
|
||||
)
|
||||
|
||||
const handleBaseUrlSubmit = useCallback(
|
||||
(value: string) => {
|
||||
setBaseUrl(value)
|
||||
setStep("saving")
|
||||
saveConfiguration(modelId, value)
|
||||
},
|
||||
[modelId],
|
||||
)
|
||||
|
||||
const saveConfiguration = useCallback(
|
||||
async (model: string, base: string) => {
|
||||
try {
|
||||
const stateManager = StateManager.get()
|
||||
const config: Record<string, string> = {
|
||||
actModeApiProvider: selectedProvider,
|
||||
planModeApiProvider: selectedProvider,
|
||||
actModeApiModelId: model,
|
||||
planModeApiModelId: model,
|
||||
apiProvider: selectedProvider,
|
||||
}
|
||||
|
||||
if (base) {
|
||||
config.openAiBaseUrl = base
|
||||
}
|
||||
stateManager.setApiConfiguration(config)
|
||||
stateManager.flushPendingState()
|
||||
setStep("success")
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : String(error))
|
||||
setStep("error")
|
||||
}
|
||||
},
|
||||
[selectedProvider],
|
||||
)
|
||||
|
||||
// Success screen menu items
|
||||
const successMenuItems: SelectItem[] = useMemo(() => {
|
||||
const items: SelectItem[] = []
|
||||
if (onNavigateToWelcome) {
|
||||
items.push({ label: "Start a task", value: "welcome" })
|
||||
}
|
||||
items.push({ label: "Exit", value: "exit" })
|
||||
return items
|
||||
}, [onNavigateToWelcome])
|
||||
|
||||
// Error screen menu items
|
||||
const errorMenuItems: SelectItem[] = useMemo(() => {
|
||||
const items: SelectItem[] = [{ label: "Try again", value: "retry" }]
|
||||
if (onNavigateToWelcome) {
|
||||
items.push({ label: "Start a task", value: "welcome" })
|
||||
}
|
||||
items.push({ label: "Exit", value: "exit" })
|
||||
return items
|
||||
}, [onNavigateToWelcome])
|
||||
|
||||
const handleSuccessMenuSelect = useCallback(
|
||||
(value: string) => {
|
||||
if (value === "welcome") {
|
||||
onNavigateToWelcome?.()
|
||||
} else if (value === "exit") {
|
||||
onComplete?.()
|
||||
exit()
|
||||
}
|
||||
},
|
||||
[onNavigateToWelcome, onComplete, exit],
|
||||
)
|
||||
|
||||
const handleErrorMenuSelect = useCallback(
|
||||
(value: string) => {
|
||||
if (value === "retry") {
|
||||
// Reset state and go back to menu
|
||||
setErrorMessage("")
|
||||
setApiKey("")
|
||||
setModelId("")
|
||||
setBaseUrl("")
|
||||
setSelectedProvider("")
|
||||
setStep("menu")
|
||||
} else if (value === "welcome") {
|
||||
onNavigateToWelcome?.()
|
||||
} else if (value === "exit") {
|
||||
onError?.()
|
||||
exit()
|
||||
}
|
||||
},
|
||||
[onNavigateToWelcome, onError, exit],
|
||||
)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color="white">
|
||||
🔐 Cline Authentication
|
||||
</Text>
|
||||
<Text color="gray">{formatSeparator()}</Text>
|
||||
<Text> </Text>
|
||||
|
||||
{step === "menu" && (
|
||||
<Select items={mainMenuItems} label="What would you like to do?" onSelect={handleMainMenuSelect} />
|
||||
)}
|
||||
|
||||
{step === "provider" && <Select items={providerItems} label="Select a provider:" onSelect={handleProviderSelect} />}
|
||||
|
||||
{step === "apikey" && (
|
||||
<TextInput
|
||||
isPassword={true}
|
||||
label="Enter your API key:"
|
||||
onChange={setApiKey}
|
||||
onSubmit={handleApiKeySubmit}
|
||||
value={apiKey}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === "modelid" && (
|
||||
<TextInput
|
||||
label="Enter the model ID (e.g., gpt-4, claude-sonnet-4.5):"
|
||||
onChange={setModelId}
|
||||
onSubmit={handleModelIdSubmit}
|
||||
placeholder="model-id"
|
||||
value={modelId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === "baseurl" && (
|
||||
<TextInput
|
||||
label="Enter base URL (optional, press Enter to skip):"
|
||||
onChange={setBaseUrl}
|
||||
onSubmit={handleBaseUrlSubmit}
|
||||
placeholder="https://api.example.com/v1"
|
||||
value={baseUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === "saving" && (
|
||||
<Box>
|
||||
<LoadingSpinner />
|
||||
<Text color="cyan"> Saving configuration...</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{step === "cline_auth" && (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<LoadingSpinner />
|
||||
<Text color="cyan"> {authStatus || "Authenticating with Cline..."}</Text>
|
||||
</Box>
|
||||
<Text color="gray" dimColor>
|
||||
A browser window should open. Complete the sign-in process there.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{step === "success" && (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color="green">
|
||||
✓ Successfully configured authentication
|
||||
</Text>
|
||||
<Text color="gray">{formatSeparator()}</Text>
|
||||
<Box flexDirection="column" marginLeft={2}>
|
||||
<Text>
|
||||
<Text color="cyan">Provider:</Text> {capitalize(selectedProvider)}
|
||||
</Text>
|
||||
<Text>
|
||||
<Text color="cyan">Model:</Text> {modelId}
|
||||
</Text>
|
||||
{baseUrl && (
|
||||
<Text>
|
||||
<Text color="cyan">Base URL:</Text> {baseUrl}
|
||||
</Text>
|
||||
)}
|
||||
<Text>
|
||||
<Text color="cyan">API Key:</Text> Configured
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color="gray">{formatSeparator()}</Text>
|
||||
<Text color="white">You can now use Cline with this provider.</Text>
|
||||
<Text> </Text>
|
||||
<Select items={successMenuItems} label="What would you like to do?" onSelect={handleSuccessMenuSelect} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{step === "error" && (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color="red">
|
||||
✗ Configuration failed
|
||||
</Text>
|
||||
<Text color="gray">{formatSeparator()}</Text>
|
||||
<Text color="red">{errorMessage}</Text>
|
||||
<Text> </Text>
|
||||
<Select items={errorMenuItems} label="What would you like to do?" onSelect={handleErrorMenuSelect} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* 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" dimColor>
|
||||
Checkpoints are created at task completion points
|
||||
</Text>
|
||||
<Text color="gray" dimColor>
|
||||
Press Escape to close
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (stage === "checkpoint") {
|
||||
return (
|
||||
<Box borderColor="cyan" borderStyle="round" flexDirection="column" marginTop={1} paddingLeft={1} paddingRight={1}>
|
||||
<Text bold color="cyan">
|
||||
Restore Checkpoint
|
||||
</Text>
|
||||
<Text color="gray" dimColor>
|
||||
Select a checkpoint to restore (↑/↓ or number, Enter to select, Escape to cancel)
|
||||
</Text>
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{checkpoints.map((cp, idx) => {
|
||||
const isSelected = idx === selectedCheckpoint
|
||||
const timeStr = cp.date.toLocaleTimeString()
|
||||
const dateStr = cp.date.toLocaleDateString()
|
||||
return (
|
||||
<Box key={cp.ts}>
|
||||
<Text color={isSelected ? "green" : "gray"}>{isSelected ? "> " : " "}</Text>
|
||||
<Text color={isSelected ? "white" : "gray"}>{idx + 1}. </Text>
|
||||
<Text color={isSelected ? "cyan" : undefined}>{cp.label}</Text>
|
||||
<Text color="gray"> - </Text>
|
||||
<Text dimColor>
|
||||
{dateStr} {timeStr}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Stage: restoreType
|
||||
const selectedCp = checkpoints[selectedCheckpoint]
|
||||
return (
|
||||
<Box borderColor="cyan" borderStyle="round" flexDirection="column" marginTop={1} paddingLeft={1} paddingRight={1}>
|
||||
<Text bold color="cyan">
|
||||
Restore Type
|
||||
</Text>
|
||||
<Text color="gray" dimColor>
|
||||
Restoring to: {selectedCp?.label} ({selectedCp?.date.toLocaleString()})
|
||||
</Text>
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{RESTORE_TYPE_OPTIONS.map((opt, idx) => {
|
||||
const isSelected = idx === selectedRestoreType
|
||||
return (
|
||||
<Box flexDirection="column" key={opt.type} marginBottom={idx < RESTORE_TYPE_OPTIONS.length - 1 ? 1 : 0}>
|
||||
<Box>
|
||||
<Text color={isSelected ? "green" : "gray"}>{isSelected ? "> " : " "}</Text>
|
||||
<Text bold={isSelected} color={isSelected ? "white" : undefined}>
|
||||
{opt.label}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginLeft={4}>
|
||||
<Text color="gray" dimColor>
|
||||
{opt.description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
<Text color="gray" dimColor>
|
||||
(↑/↓ 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,555 @@
|
||||
/**
|
||||
* 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" dimColor>
|
||||
{startIndex > 0 ? "↑ " : " "}
|
||||
Showing {startIndex + 1}-{Math.min(startIndex + MAX_VISIBLE, currentListLength)} of {currentListLength}
|
||||
{startIndex + MAX_VISIBLE < currentListLength ? " ↓" : " "}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Text color="gray">{SEPARATOR}</Text>
|
||||
|
||||
<Box flexDirection="column">
|
||||
<Text color="gray" dimColor>
|
||||
{getHelpText()}
|
||||
</Text>
|
||||
{currentTab === "settings" && selectedConfigEntry && !selectedConfigEntry.isEditable && (
|
||||
<Text color="yellow" dimColor>
|
||||
This field is read-only ({selectedConfigEntry.type} type or not a setting)
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* 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 color="gray">▌</Text>
|
||||
</Box>
|
||||
<Text color="gray" dimColor>
|
||||
Type: {type} • Enter to save • Esc to cancel
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
interface BooleanSelectProps {
|
||||
label: string
|
||||
onCancel: () => void
|
||||
onSelect: (value: boolean) => void
|
||||
value: boolean
|
||||
}
|
||||
|
||||
export const BooleanSelect: React.FC<BooleanSelectProps> = ({ label, onCancel, onSelect, value }) => {
|
||||
const { 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" dimColor>
|
||||
↑/↓ to toggle • Enter to save • Esc to cancel
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const ConfigRow: React.FC<{ entry: ConfigEntry; isSelected: boolean }> = ({ entry, isSelected }) => {
|
||||
const valueColor = entry.type === "boolean" ? (entry.value ? "green" : "red") : "white"
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color={isSelected ? "cyan" : undefined}>
|
||||
{isSelected ? "❯ " : " "}
|
||||
<Text color="cyan">{entry.key}</Text>
|
||||
<Text color="gray">: </Text>
|
||||
<Text color={valueColor}>{formatValue(entry.value)}</Text>
|
||||
{!entry.isEditable && (
|
||||
<Text color="gray" dimColor>
|
||||
{" "}
|
||||
(read-only)
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const ToggleRow: React.FC<{
|
||||
entry: ToggleEntry
|
||||
isSelected: boolean
|
||||
showType?: boolean
|
||||
}> = ({ entry, isSelected, showType }) => {
|
||||
const fileName = getFileName(entry.path)
|
||||
const typeLabel = entry.ruleType ? ` [${entry.ruleType}]` : ""
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color={isSelected ? "cyan" : undefined}>
|
||||
{isSelected ? "❯ " : " "}
|
||||
<Text color={entry.enabled ? "green" : "red"}>{entry.enabled ? "●" : "○"}</Text>
|
||||
<Text> </Text>
|
||||
<Text color="white">{fileName}</Text>
|
||||
{showType && (
|
||||
<Text color="gray" dimColor>
|
||||
{typeLabel}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const HookRow: React.FC<{
|
||||
hook: HookInfo
|
||||
isSelected: boolean
|
||||
}> = ({ hook, isSelected }) => {
|
||||
return (
|
||||
<Box>
|
||||
<Text color={isSelected ? "cyan" : undefined}>
|
||||
{isSelected ? "❯ " : " "}
|
||||
<Text color={hook.enabled ? "green" : "red"}>{hook.enabled ? "●" : "○"}</Text>
|
||||
<Text> </Text>
|
||||
<Text color="white">{hook.name}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const SkillRow: React.FC<{
|
||||
skill: SkillInfo
|
||||
isSelected: boolean
|
||||
}> = ({ skill, isSelected }) => {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text color={isSelected ? "cyan" : undefined}>
|
||||
{isSelected ? "❯ " : " "}
|
||||
<Text color={skill.enabled ? "green" : "red"}>{skill.enabled ? "●" : "○"}</Text>
|
||||
<Text> </Text>
|
||||
<Text bold color="white">
|
||||
{skill.name}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
{skill.description && (
|
||||
<Box marginLeft={4}>
|
||||
<Text color="gray" dimColor>
|
||||
{skill.description.length > 60 ? skill.description.slice(0, 57) + "..." : skill.description}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const TabBar: React.FC<{
|
||||
currentTab: TabView
|
||||
tabs: typeof TABS
|
||||
hooksEnabled?: boolean
|
||||
skillsEnabled?: boolean
|
||||
}> = ({ currentTab, tabs, hooksEnabled, skillsEnabled }) => {
|
||||
const visibleTabs = tabs.filter((tab) => {
|
||||
if (tab.requiresFlag === "hooks") {
|
||||
return hooksEnabled
|
||||
}
|
||||
if (tab.requiresFlag === "skills") {
|
||||
return skillsEnabled
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return (
|
||||
<Box marginBottom={1}>
|
||||
{visibleTabs.map((tab, idx) => (
|
||||
<React.Fragment key={tab.key}>
|
||||
{idx > 0 && <Text color="gray"> │ </Text>}
|
||||
<Text bold={currentTab === tab.key} color={currentTab === tab.key ? "cyan" : "gray"}>
|
||||
{currentTab === tab.key ? `[${tab.label}]` : tab.label}
|
||||
</Text>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const SectionHeader: React.FC<{ title: string }> = ({ title }) => (
|
||||
<Box marginTop={1}>
|
||||
<Text bold color="yellow">
|
||||
{title}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
@@ -0,0 +1,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,175 @@
|
||||
/**
|
||||
* DiffView component for displaying file diffs in Ink
|
||||
* Shows unified diff output with colored lines for additions/deletions
|
||||
*/
|
||||
|
||||
import { Box, Text } from "ink"
|
||||
import React from "react"
|
||||
|
||||
interface DiffViewProps {
|
||||
/** File path being displayed */
|
||||
path: string
|
||||
/** For newFileCreated: the full content of the new file */
|
||||
content?: string
|
||||
/** For editedExistingFile: the unified diff string */
|
||||
diff?: string
|
||||
/** Maximum lines to display before truncating */
|
||||
maxLines?: number
|
||||
}
|
||||
|
||||
interface DiffLine {
|
||||
type: "add" | "remove" | "context" | "header"
|
||||
lineNumber?: number
|
||||
content: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a unified diff string into structured lines
|
||||
*/
|
||||
function parseDiff(diff: string): DiffLine[] {
|
||||
const lines = diff.split("\n")
|
||||
const result: DiffLine[] = []
|
||||
let oldLine = 0
|
||||
let newLine = 0
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("@@")) {
|
||||
// Parse hunk header like @@ -1,5 +1,7 @@
|
||||
const match = line.match(/@@ -(\d+),?\d* \+(\d+),?\d* @@/)
|
||||
if (match) {
|
||||
oldLine = parseInt(match[1], 10)
|
||||
newLine = parseInt(match[2], 10)
|
||||
}
|
||||
result.push({ type: "header", content: line })
|
||||
} else if (line.startsWith("+") && !line.startsWith("+++")) {
|
||||
result.push({ type: "add", lineNumber: newLine, content: line.slice(1) })
|
||||
newLine++
|
||||
} else if (line.startsWith("-") && !line.startsWith("---")) {
|
||||
result.push({ type: "remove", lineNumber: oldLine, content: line.slice(1) })
|
||||
oldLine++
|
||||
} else if (line.startsWith(" ")) {
|
||||
result.push({ type: "context", lineNumber: newLine, content: line.slice(1) })
|
||||
oldLine++
|
||||
newLine++
|
||||
} else if (line.startsWith("---") || line.startsWith("+++")) {
|
||||
// File headers - skip or show as header
|
||||
result.push({ type: "header", content: line })
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Format line number with padding
|
||||
*/
|
||||
function formatLineNumber(num: number | undefined, width: number): string {
|
||||
if (num === undefined) {
|
||||
return " ".repeat(width)
|
||||
}
|
||||
return String(num).padStart(width, " ")
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a new file with all lines shown as additions
|
||||
*/
|
||||
const NewFileView: React.FC<{ path: string; content: string; maxLines: number }> = ({ path, content, maxLines }) => {
|
||||
const lines = content.split("\n")
|
||||
const displayLines = lines.slice(0, maxLines)
|
||||
const lineNumWidth = String(lines.length).length
|
||||
// Calculate max line length for padding
|
||||
const maxLineLength = Math.max(...displayLines.map((l) => l.length), 40)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color="green">
|
||||
+ {path} (new file)
|
||||
</Text>
|
||||
{displayLines.map((line, idx) => (
|
||||
<Box key={idx}>
|
||||
<Text dimColor>{formatLineNumber(idx + 1, lineNumWidth)} </Text>
|
||||
<Text backgroundColor="rgb(117, 176, 111)" color="white">
|
||||
+{line.padEnd(maxLineLength)}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
{lines.length > maxLines && <Text dimColor>... and {lines.length - maxLines} more lines</Text>}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a unified diff with colored additions and deletions
|
||||
*/
|
||||
const UnifiedDiffView: React.FC<{ path: string; diff: string; maxLines: number }> = ({ path, diff, maxLines }) => {
|
||||
const diffLines = parseDiff(diff)
|
||||
const displayLines = diffLines.slice(0, maxLines)
|
||||
const maxLineNum = Math.max(...diffLines.filter((l) => l.lineNumber !== undefined).map((l) => l.lineNumber!), 0)
|
||||
const lineNumWidth = String(maxLineNum).length || 3
|
||||
// Calculate max line length for padding
|
||||
const maxLineLength = Math.max(...diffLines.map((l) => l.content.length), 40)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color="blue">
|
||||
~ {path} (modified)
|
||||
</Text>
|
||||
{displayLines.map((line, idx) => {
|
||||
switch (line.type) {
|
||||
case "header":
|
||||
return (
|
||||
<Text color="cyan" key={idx}>
|
||||
{line.content}
|
||||
</Text>
|
||||
)
|
||||
case "add":
|
||||
return (
|
||||
<Box key={idx}>
|
||||
<Text dimColor>{formatLineNumber(line.lineNumber, lineNumWidth)} </Text>
|
||||
<Text backgroundColor="rgb(117, 176, 111)" color="white">
|
||||
+{line.content.padEnd(maxLineLength)}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
case "remove":
|
||||
return (
|
||||
<Box key={idx}>
|
||||
<Text dimColor>{formatLineNumber(line.lineNumber, lineNumWidth)} </Text>
|
||||
<Text backgroundColor="rgb(246, 48, 73)" color="white">
|
||||
-{line.content.padEnd(maxLineLength)}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
case "context":
|
||||
return (
|
||||
<Box key={idx}>
|
||||
<Text dimColor>{formatLineNumber(line.lineNumber, lineNumWidth)} </Text>
|
||||
<Text> {line.content.padEnd(maxLineLength)}</Text>
|
||||
</Box>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})}
|
||||
{diffLines.length > maxLines && <Text dimColor>... and {diffLines.length - maxLines} more lines</Text>}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* DiffView component that renders either a new file or a unified diff
|
||||
*/
|
||||
export const DiffView: React.FC<DiffViewProps> = ({ path, content, diff, maxLines = 20 }) => {
|
||||
// For new files, show all content as additions
|
||||
if (content && !diff) {
|
||||
return <NewFileView content={content} maxLines={maxLines} path={path} />
|
||||
}
|
||||
|
||||
// For edited files, show the unified diff
|
||||
if (diff) {
|
||||
return <UnifiedDiffView diff={diff} maxLines={maxLines} path={path} />
|
||||
}
|
||||
|
||||
// Fallback if neither content nor diff is provided
|
||||
return <Text color="blue">{path} (no diff available)</Text>
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* File mention menu component for CLI
|
||||
* Displays a list of matching files when user types @
|
||||
*/
|
||||
|
||||
import { Box, Text } from "ink"
|
||||
import React from "react"
|
||||
import type { FileSearchResult } from "../utils/file-search"
|
||||
|
||||
interface FileMentionMenuProps {
|
||||
results: FileSearchResult[]
|
||||
selectedIndex: number
|
||||
isLoading: boolean
|
||||
query: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate path from the left if too long, keeping the filename visible
|
||||
*/
|
||||
function truncatePath(filePath: string, maxLength: number = 50): string {
|
||||
if (filePath.length <= maxLength) {
|
||||
return filePath
|
||||
}
|
||||
return "..." + filePath.slice(-(maxLength - 3))
|
||||
}
|
||||
|
||||
export const FileMentionMenu: React.FC<FileMentionMenuProps> = ({ results, selectedIndex, isLoading, query }) => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} paddingLeft={1} paddingRight={1}>
|
||||
<Text color="gray">Searching files...</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} paddingLeft={1} paddingRight={1}>
|
||||
<Text color="gray">{query ? `No files matching "${query}"` : "Type to search files..."}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Show max 8 items, centered around selected item
|
||||
const maxVisible = 8
|
||||
let startIndex = 0
|
||||
let endIndex = results.length
|
||||
|
||||
if (results.length > maxVisible) {
|
||||
// Center the selected item in the visible window
|
||||
const halfWindow = Math.floor(maxVisible / 2)
|
||||
startIndex = Math.max(0, selectedIndex - halfWindow)
|
||||
endIndex = Math.min(results.length, startIndex + maxVisible)
|
||||
|
||||
// Adjust if we're near the end
|
||||
if (endIndex - startIndex < maxVisible) {
|
||||
startIndex = Math.max(0, endIndex - maxVisible)
|
||||
}
|
||||
}
|
||||
|
||||
const visibleResults = results.slice(startIndex, endIndex)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} paddingLeft={1} paddingRight={1}>
|
||||
{startIndex > 0 && (
|
||||
<Text color="gray" dimColor>
|
||||
↑ {startIndex} more...
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{visibleResults.map((result, idx) => {
|
||||
const actualIndex = startIndex + idx
|
||||
const isSelected = actualIndex === selectedIndex
|
||||
const displayPath = truncatePath(result.path)
|
||||
|
||||
return (
|
||||
<Box key={result.path}>
|
||||
<Text backgroundColor={isSelected ? "blue" : undefined} color={isSelected ? "white" : undefined}>
|
||||
{isSelected ? "❯ " : " "}
|
||||
{displayPath}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
|
||||
{endIndex < results.length && (
|
||||
<Text color="gray" dimColor>
|
||||
↓ {results.length - endIndex} more...
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Text color="cyan" dimColor>
|
||||
↑/↓ to select, Tab/Enter to insert
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Focus Chain / To-Do List component for CLI
|
||||
* Displays a progress-tracked checklist of tasks
|
||||
*/
|
||||
|
||||
import { isCompletedFocusChainItem, isFocusChainItem, parseFocusChainItem } from "@shared/focus-chain-utils"
|
||||
import { Box, Text } from "ink"
|
||||
import React, { useMemo } from "react"
|
||||
|
||||
interface TodoInfo {
|
||||
currentTodo: { text: string; completed: boolean; index: number } | null
|
||||
currentIndex: number
|
||||
completedCount: number
|
||||
totalCount: number
|
||||
progressPercentage: number
|
||||
}
|
||||
|
||||
interface TodoItem {
|
||||
text: string
|
||||
checked: boolean
|
||||
}
|
||||
|
||||
interface FocusChainProps {
|
||||
focusChainChecklist?: string | null
|
||||
expanded?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the focus chain checklist text into TodoInfo
|
||||
*/
|
||||
function parseCurrentTodoInfo(text: string): TodoInfo | null {
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
|
||||
let completedCount = 0
|
||||
let totalCount = 0
|
||||
let firstIncompleteIndex = -1
|
||||
let firstIncompleteText: string | null = null
|
||||
|
||||
const lines = text.split("\n")
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim()
|
||||
if (isFocusChainItem(line)) {
|
||||
const isCompleted = isCompletedFocusChainItem(line)
|
||||
|
||||
if (isCompleted) {
|
||||
completedCount++
|
||||
} else if (firstIncompleteIndex === -1) {
|
||||
firstIncompleteIndex = totalCount
|
||||
// Extract text after "- [ ] "
|
||||
firstIncompleteText = line.substring(5).trim()
|
||||
}
|
||||
|
||||
totalCount++
|
||||
}
|
||||
}
|
||||
|
||||
if (totalCount === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const currentTodo = firstIncompleteText ? { text: firstIncompleteText, completed: false, index: firstIncompleteIndex } : null
|
||||
|
||||
return {
|
||||
currentTodo,
|
||||
currentIndex: firstIncompleteIndex >= 0 ? firstIncompleteIndex + 1 : totalCount,
|
||||
completedCount,
|
||||
totalCount,
|
||||
progressPercentage: (completedCount / totalCount) * 100,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse all todo items from the checklist
|
||||
*/
|
||||
function parseTodoItems(text: string): TodoItem[] {
|
||||
const items: TodoItem[] = []
|
||||
const lines = text.split("\n")
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim()
|
||||
const parsed = parseFocusChainItem(line)
|
||||
if (parsed) {
|
||||
items.push(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
/**
|
||||
* Render progress bar
|
||||
*/
|
||||
const ProgressBar: React.FC<{ percentage: number; width?: number }> = ({ percentage, width = 20 }) => {
|
||||
const filled = Math.round((percentage / 100) * width)
|
||||
const empty = width - filled
|
||||
const bar = "█".repeat(filled) + "░".repeat(empty)
|
||||
|
||||
return (
|
||||
<Text>
|
||||
<Text color="green">{bar}</Text>
|
||||
<Text dimColor> {Math.round(percentage)}%</Text>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Header view showing current task and progress
|
||||
*/
|
||||
const Header: React.FC<{
|
||||
todoInfo: TodoInfo
|
||||
}> = ({ todoInfo }) => {
|
||||
const { currentTodo, currentIndex, totalCount, completedCount } = todoInfo
|
||||
const isCompleted = completedCount === totalCount
|
||||
|
||||
const displayText = isCompleted ? "All tasks completed!" : currentTodo?.text || "To-Do list"
|
||||
const truncatedText = displayText.length > 50 ? displayText.substring(0, 47) + "..." : displayText
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={isCompleted ? "green" : "cyan"}>
|
||||
[{currentIndex}/{totalCount}]
|
||||
</Text>
|
||||
<Text color={isCompleted ? "green" : undefined}>{truncatedText}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Expanded view showing all todo items
|
||||
*/
|
||||
const ExpandedList: React.FC<{
|
||||
items: TodoItem[]
|
||||
isCompleted: boolean
|
||||
}> = ({ items, isCompleted }) => {
|
||||
return (
|
||||
<Box flexDirection="column" marginLeft={2} marginTop={1}>
|
||||
{items.map((item, index) => (
|
||||
<Box key={index}>
|
||||
<Text color={item.checked ? "green" : "gray"}>{item.checked ? "✓" : "○"} </Text>
|
||||
<Text color={item.checked ? "green" : undefined} dimColor={item.checked}>
|
||||
{item.text}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
{isCompleted && (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor italic>
|
||||
New steps will be generated if you continue the task
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Main FocusChain component for CLI
|
||||
* Shows a progress summary of the current to-do list
|
||||
* Use expanded={true} to show all items (e.g., in verbose mode)
|
||||
*/
|
||||
export const FocusChain: React.FC<FocusChainProps> = ({ focusChainChecklist, expanded = false }) => {
|
||||
const todoInfo = useMemo(
|
||||
() => (focusChainChecklist ? parseCurrentTodoInfo(focusChainChecklist) : null),
|
||||
[focusChainChecklist],
|
||||
)
|
||||
|
||||
const todoItems = useMemo(() => (focusChainChecklist ? parseTodoItems(focusChainChecklist) : []), [focusChainChecklist])
|
||||
|
||||
// No content to display
|
||||
if (!todoInfo) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isCompleted = todoInfo.completedCount === todoInfo.totalCount
|
||||
|
||||
return (
|
||||
<Box borderColor={isCompleted ? "green" : "gray"} borderStyle="round" flexDirection="column" paddingX={1}>
|
||||
<Header todoInfo={todoInfo} />
|
||||
<ProgressBar percentage={todoInfo.progressPercentage} />
|
||||
{expanded && <ExpandedList isCompleted={isCompleted} items={todoItems} />}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { Text } from "ink"
|
||||
import { render } from "ink-testing-library"
|
||||
import React from "react"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
// Create stable mock reference using vi.hoisted
|
||||
const { mockShowTaskWithId } = vi.hoisted(() => ({
|
||||
mockShowTaskWithId: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock("./TaskView", () => ({
|
||||
TaskView: ({ taskId, verbose }: any) =>
|
||||
React.createElement(Text, null, `TaskView: ${taskId || "no-id"} verbose=${String(verbose)}`),
|
||||
}))
|
||||
|
||||
// Mock the controller dependencies - must be before importing HistoryView
|
||||
vi.mock("@/core/controller", () => ({
|
||||
Controller: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/core/controller/task/showTaskWithId", () => ({
|
||||
showTaskWithId: mockShowTaskWithId,
|
||||
}))
|
||||
|
||||
vi.mock("@/shared/proto/cline/common", () => ({
|
||||
StringRequest: {
|
||||
create: (data: any) => data,
|
||||
},
|
||||
}))
|
||||
|
||||
// Import after mocks are set up
|
||||
import { HistoryView } from "./HistoryView"
|
||||
|
||||
describe("HistoryView", () => {
|
||||
const mockController = {
|
||||
dispose: vi.fn(),
|
||||
stateManager: { flushPendingState: vi.fn() },
|
||||
} as any
|
||||
|
||||
const mockItems = [
|
||||
{ id: "task-1", ts: Date.now() - 3600000, task: "First task" },
|
||||
{ id: "task-2", ts: Date.now() - 7200000, task: "Second task" },
|
||||
{ id: "task-3", ts: Date.now() - 10800000, task: "Third task" },
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("rendering", () => {
|
||||
it("should render the history header", () => {
|
||||
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} />)
|
||||
expect(lastFrame()).toContain("Task History")
|
||||
})
|
||||
|
||||
it("should show total count in header", () => {
|
||||
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} />)
|
||||
expect(lastFrame()).toContain("3 total")
|
||||
})
|
||||
|
||||
it("should render task items", () => {
|
||||
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} />)
|
||||
expect(lastFrame()).toContain("First task")
|
||||
expect(lastFrame()).toContain("Second task")
|
||||
expect(lastFrame()).toContain("Third task")
|
||||
})
|
||||
|
||||
it("should show task IDs", () => {
|
||||
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} />)
|
||||
expect(lastFrame()).toContain("task-1")
|
||||
expect(lastFrame()).toContain("task-2")
|
||||
})
|
||||
|
||||
it("should show empty message when no items", () => {
|
||||
const { lastFrame } = render(<HistoryView controller={mockController} items={[]} />)
|
||||
expect(lastFrame()).toContain("No task history available")
|
||||
})
|
||||
|
||||
it("should show navigation help", () => {
|
||||
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} />)
|
||||
expect(lastFrame()).toContain("↑↓")
|
||||
expect(lastFrame()).toContain("Enter")
|
||||
})
|
||||
})
|
||||
|
||||
describe("task details", () => {
|
||||
it("should display task cost when available", () => {
|
||||
const itemsWithCost = [{ id: "task-1", ts: Date.now(), task: "Task", totalCost: 0.0025 }]
|
||||
const { lastFrame } = render(<HistoryView controller={mockController} items={itemsWithCost} />)
|
||||
expect(lastFrame()).toContain("Cost:")
|
||||
expect(lastFrame()).toContain("0.0025")
|
||||
})
|
||||
|
||||
it("should display model ID when available", () => {
|
||||
const itemsWithModel = [{ id: "task-1", ts: Date.now(), task: "Task", modelId: "claude-sonnet-4-20250514" }]
|
||||
const { lastFrame } = render(<HistoryView controller={mockController} items={itemsWithModel} />)
|
||||
expect(lastFrame()).toContain("Model:")
|
||||
expect(lastFrame()).toContain("claude-sonnet-4-20250514")
|
||||
})
|
||||
|
||||
it("should truncate long task descriptions", () => {
|
||||
const longTask = "x".repeat(100)
|
||||
const itemsWithLongTask = [{ id: "task-1", ts: Date.now(), task: longTask }]
|
||||
const { lastFrame } = render(<HistoryView controller={mockController} items={itemsWithLongTask} />)
|
||||
expect(lastFrame()).toContain("...")
|
||||
})
|
||||
|
||||
it("should handle missing task text", () => {
|
||||
const itemsWithoutTask = [{ id: "task-1", ts: Date.now() }]
|
||||
const { lastFrame } = render(<HistoryView controller={mockController} items={itemsWithoutTask} />)
|
||||
expect(lastFrame()).toContain("Unknown task")
|
||||
})
|
||||
})
|
||||
|
||||
describe("selection indicator", () => {
|
||||
it("should show selection indicator on first item by default", () => {
|
||||
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} />)
|
||||
expect(lastFrame()).toContain(">")
|
||||
})
|
||||
})
|
||||
|
||||
describe("keyboard navigation", () => {
|
||||
it("should navigate down with arrow key", () => {
|
||||
const { lastFrame, stdin } = render(<HistoryView controller={mockController} items={mockItems} />)
|
||||
|
||||
// Press down arrow
|
||||
stdin.write("\x1B[B")
|
||||
|
||||
// Should still render properly
|
||||
expect(lastFrame()).toContain("Task History")
|
||||
})
|
||||
|
||||
it("should navigate up with arrow key", () => {
|
||||
const { lastFrame, stdin } = render(<HistoryView controller={mockController} items={mockItems} />)
|
||||
|
||||
// Press down then up
|
||||
stdin.write("\x1B[B")
|
||||
stdin.write("\x1B[A")
|
||||
|
||||
expect(lastFrame()).toContain("Task History")
|
||||
})
|
||||
|
||||
it("should not go below last item", () => {
|
||||
const { lastFrame, stdin } = render(<HistoryView controller={mockController} items={mockItems} />)
|
||||
|
||||
// Press down many times
|
||||
for (let i = 0; i < 10; i++) {
|
||||
stdin.write("\x1B[B")
|
||||
}
|
||||
|
||||
expect(lastFrame()).toContain("Task History")
|
||||
})
|
||||
|
||||
it("should not go above first item", () => {
|
||||
const { lastFrame, stdin } = render(<HistoryView controller={mockController} items={mockItems} />)
|
||||
|
||||
// Press up when already at first
|
||||
stdin.write("\x1B[A")
|
||||
|
||||
expect(lastFrame()).toContain("Task History")
|
||||
})
|
||||
})
|
||||
|
||||
describe("pagination", () => {
|
||||
it("should show pagination info when provided", () => {
|
||||
const pagination = {
|
||||
page: 2,
|
||||
totalPages: 5,
|
||||
totalCount: 50,
|
||||
limit: 10,
|
||||
}
|
||||
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} pagination={pagination} />)
|
||||
expect(lastFrame()).toContain("Page 2 of 5")
|
||||
})
|
||||
|
||||
it("should show correct total count from pagination", () => {
|
||||
const pagination = {
|
||||
page: 1,
|
||||
totalPages: 3,
|
||||
totalCount: 25,
|
||||
limit: 10,
|
||||
}
|
||||
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} pagination={pagination} />)
|
||||
expect(lastFrame()).toContain("25 total")
|
||||
})
|
||||
|
||||
it("should not show page info for single page", () => {
|
||||
const pagination = {
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
totalCount: 3,
|
||||
limit: 10,
|
||||
}
|
||||
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} pagination={pagination} />)
|
||||
expect(lastFrame()).not.toContain("Page 1 of 1")
|
||||
})
|
||||
})
|
||||
|
||||
describe("scrolling", () => {
|
||||
it("should show scroll indicators for long lists", () => {
|
||||
const manyItems = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: `task-${i}`,
|
||||
ts: Date.now() - i * 3600000,
|
||||
task: `Task ${i}`,
|
||||
}))
|
||||
|
||||
const { lastFrame, stdin } = render(<HistoryView controller={mockController} items={manyItems} visibleCount={5} />)
|
||||
|
||||
// Navigate down a bit
|
||||
for (let i = 0; i < 5; i++) {
|
||||
stdin.write("\x1B[B")
|
||||
}
|
||||
|
||||
// Should show "more below" indicator
|
||||
expect(lastFrame()).toContain("more")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* History view component
|
||||
* Displays task history with keyboard navigation
|
||||
*/
|
||||
|
||||
import { Box, Text, useInput, useStdout } from "ink"
|
||||
import React, { useCallback, useState } from "react"
|
||||
import { Controller } from "@/core/controller"
|
||||
import { showTaskWithId } from "@/core/controller/task/showTaskWithId"
|
||||
import { StringRequest } from "@/shared/proto/cline/common"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
|
||||
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 { stdout } = useStdout()
|
||||
|
||||
// Calculate visible count based on terminal height to prevent overflow
|
||||
// Each item takes ~5 lines (date, id, task text, cost/model, margin)
|
||||
// Reserve lines for header (title, hint, pagination, separator) and footer (separator)
|
||||
const terminalRows = stdout?.rows ?? 24
|
||||
const headerLines = (pagination?.totalPages ?? 1) > 1 ? 5 : 4
|
||||
const footerLines = 1
|
||||
const availableRows = terminalRows - headerLines - footerLines
|
||||
const itemHeight = 5
|
||||
const dynamicVisibleCount = Math.max(1, Math.floor(availableRows / itemHeight))
|
||||
const effectiveVisibleCount = visibleCount ?? dynamicVisibleCount
|
||||
|
||||
const onSelect = useCallback(
|
||||
(item: TaskHistoryItem) => {
|
||||
// Load the task via controller, then notify parent to switch views
|
||||
showTaskWithId(controller, StringRequest.create({ value: item.id }))
|
||||
.then(() => {
|
||||
onSelectTask?.(item.id)
|
||||
})
|
||||
.catch((error) => console.error("Error showing task:", error))
|
||||
},
|
||||
[controller, onSelectTask],
|
||||
)
|
||||
|
||||
// Use internal pagination if allItems is provided, otherwise use external
|
||||
const useInternalPagination = !!allItems
|
||||
const limit = pagination?.limit ?? 10
|
||||
const totalCount = allItems?.length ?? pagination?.totalCount ?? items.length
|
||||
const totalPages = useInternalPagination ? Math.ceil(totalCount / limit) : (pagination?.totalPages ?? 1)
|
||||
const currentPage = useInternalPagination ? internalPage : (pagination?.page ?? 1)
|
||||
const hasPrevPage = currentPage > 1
|
||||
const hasNextPage = currentPage < totalPages
|
||||
|
||||
// Get current page items
|
||||
const pageItems = useInternalPagination ? (allItems ?? []).slice((currentPage - 1) * limit, currentPage * limit) : items
|
||||
|
||||
const handlePageChange = useCallback(
|
||||
(newPage: number) => {
|
||||
if (useInternalPagination) {
|
||||
setInternalPage(newPage)
|
||||
setSelectedIndex(0)
|
||||
} else if (onPageChange) {
|
||||
onPageChange(newPage)
|
||||
setSelectedIndex(0)
|
||||
}
|
||||
},
|
||||
[useInternalPagination, onPageChange],
|
||||
)
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (key.upArrow || 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 dimColor>Use ↑↓/j/k to navigate, Enter to select</Text>
|
||||
{totalPages > 1 && (
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
Page {currentPage} of {totalPages}{" "}
|
||||
</Text>
|
||||
{hasPrevPage ? <Text color="blue">[← prev] </Text> : <Text dimColor>[← prev] </Text>}
|
||||
{hasNextPage ? <Text color="blue">[next →]</Text> : <Text dimColor>[next →]</Text>}
|
||||
</Box>
|
||||
)}
|
||||
<Text>{formatSeparator()}</Text>
|
||||
|
||||
{pageItems.length === 0 ? (
|
||||
<Text>No task history available.</Text>
|
||||
) : (
|
||||
<Box flexDirection="column">
|
||||
{showUpIndicator && <Text dimColor>{" ↑ " + startIndex + " more above"}</Text>}
|
||||
{visibleTasks.map((task, index) => {
|
||||
const actualIndex = startIndex + index
|
||||
const isSelected = actualIndex === selectedIndex
|
||||
const date = new Date(task.ts).toLocaleString()
|
||||
const taskText = task.task?.substring(0, 60) || "Unknown task"
|
||||
const truncated = (task.task?.length || 0) > 60 ? "..." : ""
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" key={`${task.id}-${actualIndex}`} marginBottom={1}>
|
||||
<Box>
|
||||
<Text color={isSelected ? "green" : undefined}>{isSelected ? "> " : " "}</Text>
|
||||
<Text dimColor>{date}</Text>
|
||||
</Box>
|
||||
<Box marginLeft={4}>
|
||||
<Text color="cyan">{task.id}</Text>
|
||||
</Box>
|
||||
<Box marginLeft={4}>
|
||||
<Text bold={isSelected} color={isSelected ? "white" : undefined}>
|
||||
{taskText}
|
||||
{truncated}
|
||||
</Text>
|
||||
</Box>
|
||||
{typeof task.totalCost === "number" && (
|
||||
<Box marginLeft={4}>
|
||||
<Text dimColor>Cost: ${task.totalCost ? task.totalCost.toFixed(4) : "0"}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{task.modelId && (
|
||||
<Box marginLeft={4}>
|
||||
<Text dimColor>Model: {task.modelId}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
{showDownIndicator && <Text dimColor>{" ↓ " + (pageItems.length - endIndex) + " more below"}</Text>}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Text>{formatSeparator()}</Text>
|
||||
</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 JsonTaskViewProps {
|
||||
taskId?: string
|
||||
verbose?: boolean
|
||||
onComplete?: () => void
|
||||
onError?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Output a JSON line to stdout
|
||||
*/
|
||||
function outputJson(data: object) {
|
||||
originalConsoleLog(JSON.stringify(data))
|
||||
}
|
||||
|
||||
export const JsonTaskView: React.FC<JsonTaskViewProps> = ({ 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,34 @@
|
||||
/**
|
||||
* Message list component
|
||||
* Renders all messages from the task
|
||||
*/
|
||||
|
||||
import { Box } from "ink"
|
||||
import React from "react"
|
||||
import { useTaskState } from "../context/TaskContext"
|
||||
import { MessageRow } from "./MessageRow"
|
||||
|
||||
interface MessageListProps {
|
||||
verbose?: boolean
|
||||
}
|
||||
|
||||
export const MessageList: React.FC<MessageListProps> = ({ verbose = false }) => {
|
||||
const state = useTaskState()
|
||||
const messages = state.clineMessages || []
|
||||
|
||||
// Filter out some noisy messages when not verbose
|
||||
const messagesToShow = verbose
|
||||
? messages
|
||||
: messages.filter((m) => {
|
||||
// Show everything in non-verbose mode for now
|
||||
return true
|
||||
})
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{messagesToShow.map((message, idx) => (
|
||||
<MessageRow key={`${message.ts}-${idx}`} message={message} verbose={verbose} />
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* Individual message row component
|
||||
* Renders a single ClineMessage based on its type
|
||||
*/
|
||||
|
||||
import type { ClineAsk, ClineMessage, ClineSay } from "@shared/ExtensionMessage"
|
||||
import { Box, Text } from "ink"
|
||||
import React from "react"
|
||||
import { jsonParseSafe } from "../utils/parser"
|
||||
import { DiffView } from "./DiffView"
|
||||
|
||||
interface MessageRowProps {
|
||||
message: ClineMessage
|
||||
verbose?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Get emoji icon for message type
|
||||
*/
|
||||
export function getCliMessagePrefixIcon(message: ClineMessage): string {
|
||||
if (message.type === "ask") {
|
||||
switch (message.ask) {
|
||||
case "followup":
|
||||
return "❓"
|
||||
case "command":
|
||||
case "command_output":
|
||||
return "⚙️"
|
||||
case "tool":
|
||||
return "🔧"
|
||||
case "completion_result":
|
||||
return "✅"
|
||||
case "api_req_failed":
|
||||
return "❌"
|
||||
case "resume_task":
|
||||
case "resume_completed_task":
|
||||
return "▶️"
|
||||
case "browser_action_launch":
|
||||
return "🌐"
|
||||
case "use_mcp_server":
|
||||
return "🔌"
|
||||
case "plan_mode_respond":
|
||||
return "📋"
|
||||
default:
|
||||
return "❔"
|
||||
}
|
||||
} else {
|
||||
switch (message.say) {
|
||||
case "task":
|
||||
return "📋"
|
||||
case "error":
|
||||
return "❌"
|
||||
case "text":
|
||||
return "💬"
|
||||
case "reasoning":
|
||||
return "🧠"
|
||||
case "completion_result":
|
||||
return "✅"
|
||||
case "user_feedback":
|
||||
return "👤"
|
||||
case "command":
|
||||
case "command_output":
|
||||
return "⚙️"
|
||||
case "tool":
|
||||
return "🔧"
|
||||
case "browser_action":
|
||||
case "browser_action_launch":
|
||||
case "browser_action_result":
|
||||
return "🌐"
|
||||
case "mcp_server_request_started":
|
||||
case "mcp_server_response":
|
||||
return "🔌"
|
||||
case "api_req_started":
|
||||
case "api_req_finished":
|
||||
return "🔄"
|
||||
case "checkpoint_created":
|
||||
return "💾"
|
||||
case "info":
|
||||
return "ℹ️"
|
||||
case "generate_explanation":
|
||||
return "📝"
|
||||
default:
|
||||
return " "
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp
|
||||
*/
|
||||
function formatTimestamp(ts: number): string {
|
||||
const date = new Date(ts)
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Render ask message based on type
|
||||
*/
|
||||
const AskMessageContent: React.FC<{ message: ClineMessage; verbose?: boolean }> = ({ message, verbose }) => {
|
||||
const ask = message.ask as ClineAsk
|
||||
const text = message.text || ""
|
||||
|
||||
switch (ask) {
|
||||
case "followup":
|
||||
case "plan_mode_respond": {
|
||||
const parts = jsonParseSafe(text, {
|
||||
response: undefined as string | undefined,
|
||||
question: undefined as string | undefined,
|
||||
})
|
||||
|
||||
if (parts.question) {
|
||||
return (
|
||||
<Text>
|
||||
<Text color="cyan">Question:</Text> {parts.question}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
if (parts.response) {
|
||||
return (
|
||||
<Text>
|
||||
<Text color="cyan">[{ask}]</Text> {parts.response}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
case "command":
|
||||
return (
|
||||
<Text>
|
||||
<Text color="magenta">Execute command?</Text> <Text dimColor>{text}</Text>
|
||||
</Text>
|
||||
)
|
||||
|
||||
case "tool":
|
||||
return (
|
||||
<Text>
|
||||
<Text color="blue">Use tool?</Text> {text}
|
||||
</Text>
|
||||
)
|
||||
|
||||
case "completion_result":
|
||||
return (
|
||||
<Text>
|
||||
<Text color="green">Task completed</Text> {text ? `- ${text}` : ""}
|
||||
</Text>
|
||||
)
|
||||
|
||||
case "api_req_failed":
|
||||
return (
|
||||
<Text>
|
||||
<Text color="red">API request failed</Text> {text}
|
||||
</Text>
|
||||
)
|
||||
|
||||
case "resume_task":
|
||||
case "resume_completed_task":
|
||||
return (
|
||||
<Text>
|
||||
<Text color="cyan">Resume task?</Text> {text}
|
||||
</Text>
|
||||
)
|
||||
|
||||
case "browser_action_launch":
|
||||
return (
|
||||
<Text>
|
||||
<Text color="cyan">Launch browser?</Text> {text}
|
||||
</Text>
|
||||
)
|
||||
|
||||
case "use_mcp_server":
|
||||
return (
|
||||
<Text>
|
||||
<Text color="cyan">Use MCP server?</Text> {text}
|
||||
</Text>
|
||||
)
|
||||
|
||||
default:
|
||||
return verbose ? (
|
||||
<Text>
|
||||
<Text color="gray">[ASK:{ask}]</Text> {text}
|
||||
</Text>
|
||||
) : null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render say message based on type
|
||||
*/
|
||||
const SayMessageContent: React.FC<{ message: ClineMessage; verbose?: boolean }> = ({ message, verbose }) => {
|
||||
const say = message.say as ClineSay
|
||||
const text = message.text || ""
|
||||
|
||||
switch (say) {
|
||||
case "task":
|
||||
return (
|
||||
<Text bold>
|
||||
<Text color="white">Task:</Text> {text}
|
||||
</Text>
|
||||
)
|
||||
|
||||
case "text":
|
||||
return <Text>{text}</Text>
|
||||
|
||||
case "reasoning":
|
||||
return (
|
||||
<Text color="yellow">
|
||||
<Text italic>{text}</Text>
|
||||
</Text>
|
||||
)
|
||||
|
||||
case "error":
|
||||
return (
|
||||
<Text color="red">
|
||||
<Text bold>Error:</Text> {text}
|
||||
</Text>
|
||||
)
|
||||
|
||||
case "completion_result":
|
||||
return (
|
||||
<Text color="green">
|
||||
<Text bold>Completed:</Text> {text}
|
||||
</Text>
|
||||
)
|
||||
|
||||
case "user_feedback":
|
||||
return (
|
||||
<Text>
|
||||
<Text color="green">User:</Text> {text}
|
||||
</Text>
|
||||
)
|
||||
|
||||
case "command":
|
||||
return (
|
||||
<Text>
|
||||
<Text color="magenta">Command:</Text> <Text dimColor>{text}</Text>
|
||||
</Text>
|
||||
)
|
||||
|
||||
case "command_output": {
|
||||
const lines = text.split("\n")
|
||||
const displayLines = lines.slice(0, 10)
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text dimColor>Output:</Text>
|
||||
{displayLines.map((line, idx) => (
|
||||
<Text dimColor key={idx}>
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
{lines.length > 10 && <Text dimColor> ... and {lines.length - 10} more lines</Text>}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
case "tool": {
|
||||
const { tool, content, path } = jsonParseSafe(text, {
|
||||
tool: undefined as string | undefined,
|
||||
content: undefined as string | undefined,
|
||||
path: undefined as string | undefined,
|
||||
diff: undefined as string | undefined,
|
||||
})
|
||||
if (path) {
|
||||
if (tool === "newFileCreated") {
|
||||
return <DiffView content={content} path={path} />
|
||||
}
|
||||
// if (tool === "editedExistingFile") {
|
||||
// return <DiffView diff={diff} path={path} />
|
||||
// }
|
||||
}
|
||||
|
||||
return (
|
||||
<Text>
|
||||
<Text color="blue">{text}</Text>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
case "api_req_started": {
|
||||
const { cost, tokensOut, cacheWrites, cacheReads, tokensIn } = jsonParseSafe(text, {
|
||||
cost: 0 as number,
|
||||
tokensIn: 0 as number,
|
||||
tokensOut: 0 as number,
|
||||
cacheWrites: 0 as number,
|
||||
cacheReads: 0 as number,
|
||||
})
|
||||
return verbose ? (
|
||||
<Text dimColor>{text}</Text>
|
||||
) : (
|
||||
<Text dimColor>
|
||||
Cost: {cost} | Tokens In: {tokensIn} | Tokens Out: {tokensOut} | Cache Writes: {cacheWrites} | Cache Reads:{" "}
|
||||
{cacheReads}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
case "api_req_finished":
|
||||
return null
|
||||
|
||||
case "checkpoint_created":
|
||||
return <Text dimColor>Checkpoint created: {message.lastCheckpointHash}</Text>
|
||||
|
||||
case "info":
|
||||
return <Text color="cyan">{text}</Text>
|
||||
|
||||
case "browser_action":
|
||||
case "browser_action_launch":
|
||||
return (
|
||||
<Text>
|
||||
<Text color="cyan">Browser:</Text> {text}
|
||||
</Text>
|
||||
)
|
||||
|
||||
case "browser_action_result":
|
||||
return <Text dimColor>Browser result {text ? `- ${text.substring(0, 100)}...` : ""}</Text>
|
||||
|
||||
case "mcp_server_request_started":
|
||||
return <Text color="cyan">MCP request started {text}</Text>
|
||||
|
||||
case "mcp_server_response":
|
||||
return <Text color="cyan">MCP response {text ? text.substring(0, 200) : ""}</Text>
|
||||
|
||||
default:
|
||||
return verbose ? (
|
||||
<Text dimColor>
|
||||
[SAY:{say}] {text}
|
||||
</Text>
|
||||
) : null
|
||||
}
|
||||
}
|
||||
|
||||
export const MessageRow: React.FC<MessageRowProps> = ({ message, verbose = false }) => {
|
||||
const icon = getCliMessagePrefixIcon(message)
|
||||
const timestamp = formatTimestamp(message.ts)
|
||||
|
||||
// Don't render silent messages
|
||||
if (message.say === "api_req_finished") {
|
||||
return null
|
||||
}
|
||||
|
||||
if (message.say === "text" && message.text?.trim() === "") {
|
||||
return null
|
||||
}
|
||||
|
||||
const content =
|
||||
message.type === "ask" ? (
|
||||
<AskMessageContent message={message} verbose={verbose} />
|
||||
) : (
|
||||
<SayMessageContent message={message} verbose={verbose} />
|
||||
)
|
||||
|
||||
// command_output and tool return a Box, which can't be nested inside Text
|
||||
if (message.say === "command_output" || message.say === "tool") {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text dimColor>{timestamp} </Text>
|
||||
<Text>{icon} </Text>
|
||||
</Box>
|
||||
{content}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text dimColor>{timestamp} </Text>
|
||||
<Text>{icon} </Text>
|
||||
{content}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Shared input component with bordered text field and mode toggle row
|
||||
* Used by WelcomeView and AskPrompt for consistent input UI
|
||||
*/
|
||||
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import { Box, Text } from "ink"
|
||||
import React from "react"
|
||||
|
||||
interface PromptInputProps {
|
||||
/** Current text input value */
|
||||
textInput: string
|
||||
/** Current mode (act/plan) */
|
||||
mode: Mode
|
||||
/** Model ID to display */
|
||||
modelId: string
|
||||
/** Optional question/prompt to show above input */
|
||||
question?: React.ReactNode
|
||||
/** Optional icon for the question */
|
||||
questionIcon?: string
|
||||
/** Color for the question text */
|
||||
questionColor?: string
|
||||
/** Help text to show below */
|
||||
helpText?: React.ReactNode
|
||||
/** Whether to show the mode toggle (default: true) */
|
||||
showModeToggle?: boolean
|
||||
/** Children rendered after the input (e.g., file mention menu) */
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export const PromptInput: React.FC<PromptInputProps> = ({
|
||||
textInput,
|
||||
mode,
|
||||
modelId,
|
||||
question,
|
||||
questionIcon,
|
||||
questionColor = "cyan",
|
||||
helpText,
|
||||
showModeToggle = true,
|
||||
children,
|
||||
}) => {
|
||||
const borderColor = mode === "act" ? "blue" : "yellow"
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width="100%">
|
||||
{/* Question/prompt above input */}
|
||||
{question && (
|
||||
<Box marginBottom={0}>
|
||||
{questionIcon && <Text>{questionIcon} </Text>}
|
||||
{typeof question === "string" ? <Text color={questionColor}>{question}</Text> : question}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Input field with border */}
|
||||
<Box
|
||||
borderColor={borderColor}
|
||||
borderStyle="round"
|
||||
flexDirection="row"
|
||||
marginTop={question ? 0 : 1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
width="100%">
|
||||
<Text>{textInput}</Text>
|
||||
<Text color="gray">▌</Text>
|
||||
</Box>
|
||||
|
||||
{/* Model ID and Mode toggle row */}
|
||||
<Box justifyContent="space-between" width="100%">
|
||||
{/* Model ID on left */}
|
||||
<Text color="gray" dimColor>
|
||||
{modelId}
|
||||
</Text>
|
||||
|
||||
{/* Mode toggle on right */}
|
||||
{showModeToggle && (
|
||||
<Box gap={1}>
|
||||
<Box>
|
||||
<Text bold={mode === "plan"} color={mode === "plan" ? "yellow" : "gray"}>
|
||||
{mode === "plan" ? "●" : "○"} Plan
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text bold={mode === "act"} color={mode === "act" ? "blue" : "gray"}>
|
||||
{mode === "act" ? "●" : "○"} Act
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color="gray" dimColor>
|
||||
(Tab)
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Additional content (e.g., file mention menu) */}
|
||||
{children}
|
||||
|
||||
{/* Help text */}
|
||||
{helpText && (
|
||||
<Box>
|
||||
{typeof helpText === "string" ? (
|
||||
<Text color="gray" dimColor>
|
||||
{helpText}
|
||||
</Text>
|
||||
) : (
|
||||
helpText
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Loading spinner component using ink-spinner
|
||||
*/
|
||||
|
||||
import { Box, Text } from "ink"
|
||||
import Spinner from "ink-spinner"
|
||||
import React from "react"
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
message?: string
|
||||
}
|
||||
|
||||
const LOADING_TEXT_IDEAS = ["Thinking", "Loading", "Processing", "Working", "Calculating", "Analyzing", "Exploring"]
|
||||
|
||||
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||
message = LOADING_TEXT_IDEAS[Math.floor(Math.random() * LOADING_TEXT_IDEAS.length)],
|
||||
}) => {
|
||||
return (
|
||||
<Box>
|
||||
<Text color="cyan">
|
||||
<Spinner type="dots" />
|
||||
</Text>
|
||||
<Text color="cyan"> {message}...</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Task view component
|
||||
* Main view for running a task - displays messages and handles user input
|
||||
*/
|
||||
|
||||
import { exit } from "node:process"
|
||||
import { CheckpointRestoreRequest } from "@shared/proto/cline/checkpoints"
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { checkpointRestore } from "@/core/controller/checkpoints/checkpointRestore"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { useTaskContext, useTaskState } from "../context/TaskContext"
|
||||
import { useCompletionSignals, useIsSpinnerActive } from "../hooks/useStateSubscriber"
|
||||
import { AskPrompt } from "./AskPrompt"
|
||||
import { CheckpointMenu, RestoreType } from "./CheckpointMenu"
|
||||
import { FocusChain } from "./FocusChain"
|
||||
import { MessageList } from "./MessageList"
|
||||
import { LoadingSpinner } from "./Spinner"
|
||||
|
||||
interface TaskViewProps {
|
||||
taskId?: string
|
||||
verbose?: boolean
|
||||
onComplete?: () => void
|
||||
onError?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Format separator line
|
||||
*/
|
||||
function formatSeparator(char: string = "═", width: number = 60): string {
|
||||
return char.repeat(Math.max(width, 10))
|
||||
}
|
||||
|
||||
export const TaskView: React.FC<TaskViewProps> = ({ taskId: _taskId, verbose = false, onComplete, onError }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const state = useTaskState()
|
||||
const { isTaskComplete, getCompletionMessage } = useCompletionSignals()
|
||||
const isSpinnerActive = useIsSpinnerActive()
|
||||
const { isComplete, setIsComplete, lastError, controller } = useTaskContext()
|
||||
const [showCheckpointMenu, setShowCheckpointMenu] = useState(false)
|
||||
const [restoreStatus, setRestoreStatus] = useState<"idle" | "restoring" | "success" | "error">("idle")
|
||||
const [restoreMessage, setRestoreMessage] = useState<string | null>(null)
|
||||
|
||||
const yolo = useMemo(() => StateManager.get().getGlobalSettingsKey("yoloModeToggled"), [])
|
||||
|
||||
// Handle task completion
|
||||
useEffect(() => {
|
||||
if (isTaskComplete()) {
|
||||
setIsComplete(true)
|
||||
|
||||
// Check if it's an error
|
||||
const completionMsg = getCompletionMessage()
|
||||
if (completionMsg?.say === "error" || completionMsg?.ask === "api_req_failed") {
|
||||
onError?.()
|
||||
} else {
|
||||
onComplete?.()
|
||||
}
|
||||
|
||||
if (yolo) {
|
||||
exit()
|
||||
}
|
||||
}
|
||||
}, [isTaskComplete, setIsComplete, onComplete, onError, getCompletionMessage])
|
||||
|
||||
// Handle checkpoint restore
|
||||
const handleCheckpointRestore = useCallback(
|
||||
async (messageTs: number, restoreType: RestoreType) => {
|
||||
setShowCheckpointMenu(false)
|
||||
setRestoreStatus("restoring")
|
||||
setRestoreMessage(`Restoring checkpoint (${restoreType})...`)
|
||||
|
||||
try {
|
||||
await checkpointRestore(
|
||||
controller,
|
||||
CheckpointRestoreRequest.create({
|
||||
number: messageTs,
|
||||
restoreType: restoreType,
|
||||
}),
|
||||
)
|
||||
setRestoreStatus("success")
|
||||
setRestoreMessage("Checkpoint restored successfully")
|
||||
// Clear success message after a delay
|
||||
setTimeout(() => {
|
||||
setRestoreStatus("idle")
|
||||
setRestoreMessage(null)
|
||||
}, 3000)
|
||||
} catch (error) {
|
||||
setRestoreStatus("error")
|
||||
setRestoreMessage(`Failed to restore: ${error instanceof Error ? error.message : String(error)}`)
|
||||
// Clear error message after a delay
|
||||
setTimeout(() => {
|
||||
setRestoreStatus("idle")
|
||||
setRestoreMessage(null)
|
||||
}, 5000)
|
||||
}
|
||||
},
|
||||
[controller],
|
||||
)
|
||||
|
||||
// Handle Ctrl+R to open checkpoint menu
|
||||
useInput(
|
||||
(input, key) => {
|
||||
// Ctrl+R to open checkpoint menu
|
||||
if (key.ctrl && input === "r") {
|
||||
setShowCheckpointMenu(true)
|
||||
return
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported && !showCheckpointMenu },
|
||||
)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* Task header */}
|
||||
{state.currentTaskItem && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text>{formatSeparator("═")}</Text>
|
||||
<Text bold color="white">
|
||||
📋 Task: {state.currentTaskItem.id}
|
||||
</Text>
|
||||
{state.currentTaskItem.task && (
|
||||
<Text dimColor>
|
||||
{state.currentTaskItem.task.substring(0, 80)}
|
||||
{state.currentTaskItem.task.length > 80 ? "..." : ""}
|
||||
</Text>
|
||||
)}
|
||||
<Box>
|
||||
<Text>{formatSeparator("═")}</Text>
|
||||
</Box>
|
||||
<Text color="gray" dimColor>
|
||||
(Ctrl+R to restore checkpoint)
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Error message if any */}
|
||||
{lastError && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text bold color="red">
|
||||
Error: {lastError}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Restore status message */}
|
||||
{restoreMessage && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text bold color={restoreStatus === "error" ? "red" : restoreStatus === "success" ? "green" : "yellow"}>
|
||||
{restoreStatus === "restoring" ? "⏳ " : restoreStatus === "success" ? "✓ " : "✗ "}
|
||||
{restoreMessage}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Checkpoint menu */}
|
||||
{showCheckpointMenu && (
|
||||
<CheckpointMenu
|
||||
messages={state.clineMessages || []}
|
||||
onCancel={() => setShowCheckpointMenu(false)}
|
||||
onSelect={handleCheckpointRestore}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Focus Chain / To-Do List */}
|
||||
{state.currentFocusChainChecklist && (
|
||||
<Box marginBottom={1}>
|
||||
<FocusChain focusChainChecklist={state.currentFocusChainChecklist} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Messages list */}
|
||||
<MessageList verbose={verbose} />
|
||||
|
||||
{/* Loading spinner - hide when task is complete/exiting */}
|
||||
{isSpinnerActive && !isComplete && (
|
||||
<Box marginTop={1}>
|
||||
<LoadingSpinner />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* User input prompt */}
|
||||
<AskPrompt />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* Welcome view component
|
||||
* Shows an interactive prompt when user starts cline without a command
|
||||
* Supports file mentions with @
|
||||
*/
|
||||
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { getProviderDefaultModelId } from "@/shared/storage"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import {
|
||||
checkAndWarnRipgrepMissing,
|
||||
extractMentionQuery,
|
||||
type FileSearchResult,
|
||||
getRipgrepInstallInstructions,
|
||||
insertMention,
|
||||
searchWorkspaceFiles,
|
||||
} from "../utils/file-search"
|
||||
import { parseImagesFromInput } from "../utils/parser"
|
||||
import { AccountInfoView } from "./AccountInfoView"
|
||||
import { FileMentionMenu } from "./FileMentionMenu"
|
||||
import { PromptInput } from "./PromptInput"
|
||||
|
||||
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) => {
|
||||
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 },
|
||||
)
|
||||
|
||||
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 content
|
||||
<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 with mode toggle */}
|
||||
<PromptInput
|
||||
helpText={
|
||||
<React.Fragment>
|
||||
<Text color="gray" dimColor>
|
||||
Enter to submit · @ to mention files ·{" "}
|
||||
</Text>
|
||||
<Text bold={escPressedOnce} color={escPressedOnce ? "white" : "gray"} dimColor={!escPressedOnce}>
|
||||
{escPressedOnce ? "Press Esc again to exit" : "Esc to exit"}
|
||||
</Text>
|
||||
</React.Fragment>
|
||||
}
|
||||
mode={mode}
|
||||
modelId={modelId || ""}
|
||||
textInput={textInput}>
|
||||
{/* 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>
|
||||
)}
|
||||
</PromptInput>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -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,124 @@
|
||||
/**
|
||||
* React Context for task state management in CLI
|
||||
* Provides access to ExtensionState and task controller
|
||||
*/
|
||||
|
||||
import { registerPartialMessageCallback } from "@core/controller/ui/subscribeToPartialMessage"
|
||||
import type { ClineMessage, ExtensionState } from "@shared/ExtensionMessage"
|
||||
import { convertProtoToClineMessage } from "@shared/proto-conversions/cline-message"
|
||||
import React, { createContext, ReactNode, useContext, useEffect, useRef, useState } from "react"
|
||||
|
||||
interface TaskContextType {
|
||||
state: Partial<ExtensionState>
|
||||
controller: any
|
||||
isComplete: boolean
|
||||
setIsComplete: (complete: boolean) => void
|
||||
lastError: string | null
|
||||
setLastError: (error: string | null) => void
|
||||
}
|
||||
|
||||
const TaskContext = createContext<TaskContextType | undefined>(undefined)
|
||||
|
||||
interface TaskContextProviderProps {
|
||||
controller: any
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export const TaskContextProvider: React.FC<TaskContextProviderProps> = ({ controller, children }) => {
|
||||
const [state, setState] = useState<Partial<ExtensionState>>(
|
||||
() =>
|
||||
({
|
||||
clineMessages: [],
|
||||
currentTaskItem: null,
|
||||
}) as unknown as Partial<ExtensionState>,
|
||||
)
|
||||
const [isComplete, setIsComplete] = useState(false)
|
||||
const [lastError, setLastError] = useState<string | null>(null)
|
||||
|
||||
// Use ref to track latest state for partial message callback
|
||||
const stateRef = useRef(state)
|
||||
stateRef.current = state
|
||||
|
||||
// Subscribe to controller state updates
|
||||
useEffect(() => {
|
||||
const originalPostState = controller.postStateToWebview.bind(controller)
|
||||
|
||||
const handleStateUpdate = async () => {
|
||||
try {
|
||||
const newState = await controller.getStateToPostToWebview()
|
||||
setState(newState)
|
||||
} catch (error) {
|
||||
setLastError(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
// Override postStateToWebview to update React state
|
||||
controller.postStateToWebview = async () => {
|
||||
await originalPostState()
|
||||
await handleStateUpdate()
|
||||
}
|
||||
|
||||
// Subscribe to partial message events (for streaming updates)
|
||||
const unsubscribePartial = registerPartialMessageCallback((protoMessage) => {
|
||||
const updatedMessage = convertProtoToClineMessage(protoMessage) as ClineMessage
|
||||
setState((prevState) => {
|
||||
const messages = prevState.clineMessages || []
|
||||
// Find and update the message by timestamp
|
||||
const index = messages.findIndex((m) => m.ts === updatedMessage.ts)
|
||||
if (index >= 0) {
|
||||
const newMessages = [...messages]
|
||||
newMessages[index] = updatedMessage
|
||||
return { ...prevState, clineMessages: newMessages }
|
||||
}
|
||||
return prevState
|
||||
})
|
||||
})
|
||||
|
||||
// Get initial state
|
||||
handleStateUpdate()
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
controller.postStateToWebview = originalPostState
|
||||
unsubscribePartial()
|
||||
}
|
||||
}, [controller])
|
||||
|
||||
const value: TaskContextType = {
|
||||
state,
|
||||
controller,
|
||||
isComplete,
|
||||
setIsComplete,
|
||||
lastError,
|
||||
setLastError,
|
||||
}
|
||||
|
||||
return <TaskContext.Provider value={value}>{children}</TaskContext.Provider>
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access task context
|
||||
*/
|
||||
export const useTaskContext = (): TaskContextType => {
|
||||
const context = useContext(TaskContext)
|
||||
if (!context) {
|
||||
throw new Error("useTaskContext must be used within TaskContextProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access task state only
|
||||
*/
|
||||
export const useTaskState = (): Partial<ExtensionState> => {
|
||||
const { state } = useTaskContext()
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access controller
|
||||
*/
|
||||
export const useTaskController = () => {
|
||||
const { controller } = useTaskContext()
|
||||
return controller
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* CLI-specific CommentReviewController implementation
|
||||
* Handles code review comments in CLI mode
|
||||
*/
|
||||
|
||||
import { CommentReviewController, type OnReplyCallback, type ReviewComment } from "@/integrations/editor/CommentReviewController"
|
||||
import { print, style } from "../utils/display"
|
||||
|
||||
export class CliCommentReviewController extends CommentReviewController {
|
||||
private comments: Map<string, string[]> = new Map()
|
||||
private streamingComment: { filePath: string; startLine: number; endLine: number; content: string } | null = null
|
||||
|
||||
setOnReplyCallback(_callback: OnReplyCallback): void {
|
||||
// No-op - CLI doesn't support interactive replies
|
||||
}
|
||||
|
||||
async ensureCommentsViewDisabled(): Promise<void> {
|
||||
// No-op - no comments view in CLI
|
||||
}
|
||||
|
||||
addReviewComment(comment: ReviewComment): void {
|
||||
const key = `${comment.filePath}:${comment.startLine}:${comment.endLine}`
|
||||
const existing = this.comments.get(key) || []
|
||||
existing.push(comment.comment)
|
||||
this.comments.set(key, existing)
|
||||
|
||||
print(style.info(`Comment on ${comment.filePath}:${comment.startLine + 1}`))
|
||||
print(style.dim(` ${comment.comment}`))
|
||||
}
|
||||
|
||||
startStreamingComment(
|
||||
filePath: string,
|
||||
startLine: number,
|
||||
endLine: number,
|
||||
_relativePath?: string,
|
||||
_fileContent?: string,
|
||||
_revealComment?: boolean,
|
||||
): void {
|
||||
this.streamingComment = { filePath, startLine, endLine, content: "" }
|
||||
print(style.info(`Comment on ${filePath}:${startLine + 1}`))
|
||||
}
|
||||
|
||||
appendToStreamingComment(chunk: string): void {
|
||||
if (this.streamingComment) {
|
||||
this.streamingComment.content += chunk
|
||||
process.stdout.write(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
endStreamingComment(): void {
|
||||
if (this.streamingComment) {
|
||||
const key = `${this.streamingComment.filePath}:${this.streamingComment.startLine}:${this.streamingComment.endLine}`
|
||||
const existing = this.comments.get(key) || []
|
||||
existing.push(this.streamingComment.content)
|
||||
this.comments.set(key, existing)
|
||||
print("") // newline after streaming
|
||||
this.streamingComment = null
|
||||
}
|
||||
}
|
||||
|
||||
addReviewComments(comments: ReviewComment[]): void {
|
||||
for (const comment of comments) {
|
||||
this.addReviewComment(comment)
|
||||
}
|
||||
}
|
||||
|
||||
clearAllComments(): void {
|
||||
this.comments.clear()
|
||||
}
|
||||
|
||||
clearCommentsForFile(filePath: string): void {
|
||||
for (const key of this.comments.keys()) {
|
||||
if (key.startsWith(filePath)) {
|
||||
this.comments.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getThreadCount(): number {
|
||||
return this.comments.size
|
||||
}
|
||||
|
||||
async closeDiffViews(): Promise<void> {
|
||||
// No-op - no diff views in CLI
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.comments.clear()
|
||||
this.streamingComment = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* CLI-specific WebviewProvider implementation
|
||||
* Instead of rendering to a webview, this outputs to the terminal
|
||||
*/
|
||||
|
||||
import type * as vscode from "vscode"
|
||||
import { WebviewProvider } from "@/core/webview"
|
||||
|
||||
export class CliWebviewProvider extends WebviewProvider {
|
||||
constructor(context: vscode.ExtensionContext) {
|
||||
super(context)
|
||||
}
|
||||
|
||||
override getWebviewUrl(path: string): string {
|
||||
// CLI doesn't have webview URLs
|
||||
return `file://${path}`
|
||||
}
|
||||
|
||||
override getCspSource(): string {
|
||||
return "'self'"
|
||||
}
|
||||
|
||||
override isVisible(): boolean {
|
||||
// CLI is always "visible"
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* 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 { CLI_VERSION } from ".."
|
||||
import { printError, printInfo, printWarning } from "../utils/display"
|
||||
|
||||
/**
|
||||
* CLI implementation of DiffService - handles diff operations for terminal
|
||||
*
|
||||
* In CLI mode, actual file editing is handled by FileEditProvider (which extends DiffViewProvider).
|
||||
* This service client handles the host bridge interface for UI-related diff operations.
|
||||
* Most operations are no-ops since the CLI doesn't have a visual diff editor.
|
||||
*/
|
||||
export class CliDiffServiceClient implements DiffServiceClientInterface {
|
||||
async openDiff(_request: proto.host.OpenDiffRequest): Promise<proto.host.OpenDiffResponse> {
|
||||
// In CLI mode, diff operations are handled by FileEditProvider directly.
|
||||
// This is a no-op since we don't have a visual diff editor.
|
||||
return proto.host.OpenDiffResponse.create({})
|
||||
}
|
||||
|
||||
async getDocumentText(_request: proto.host.GetDocumentTextRequest): Promise<proto.host.GetDocumentTextResponse> {
|
||||
// In CLI mode, document text is managed by FileEditProvider directly.
|
||||
// Return empty content since we don't track document state here.
|
||||
return proto.host.GetDocumentTextResponse.create({ content: "" })
|
||||
}
|
||||
|
||||
async replaceText(_request: proto.host.ReplaceTextRequest): Promise<proto.host.ReplaceTextResponse> {
|
||||
// No-op in CLI - actual file editing is handled by FileEditProvider
|
||||
return proto.host.ReplaceTextResponse.create({})
|
||||
}
|
||||
|
||||
async scrollDiff(_request: proto.host.ScrollDiffRequest): Promise<proto.host.ScrollDiffResponse> {
|
||||
// No-op in CLI - no visual editor to scroll
|
||||
return proto.host.ScrollDiffResponse.create({})
|
||||
}
|
||||
|
||||
async truncateDocument(_request: proto.host.TruncateDocumentRequest): Promise<proto.host.TruncateDocumentResponse> {
|
||||
// No-op in CLI - actual file editing is handled by FileEditProvider
|
||||
return proto.host.TruncateDocumentResponse.create({})
|
||||
}
|
||||
|
||||
async saveDocument(_request: proto.host.SaveDocumentRequest): Promise<proto.host.SaveDocumentResponse> {
|
||||
// No-op in CLI - actual file saving is handled by FileEditProvider
|
||||
return proto.host.SaveDocumentResponse.create({})
|
||||
}
|
||||
|
||||
async closeAllDiffs(_request: proto.host.CloseAllDiffsRequest): Promise<proto.host.CloseAllDiffsResponse> {
|
||||
// No-op in CLI - no visual diff views to close
|
||||
return proto.host.CloseAllDiffsResponse.create({})
|
||||
}
|
||||
|
||||
async openMultiFileDiff(request: proto.host.OpenMultiFileDiffRequest): Promise<proto.host.OpenMultiFileDiffResponse> {
|
||||
// In CLI mode, we display a summary of the multi-file diff
|
||||
const title = request.title || "Multi-file diff"
|
||||
const diffs = request.diffs || []
|
||||
if (diffs.length > 0) {
|
||||
printInfo(`📝 ${title}: ${diffs.length} file(s) changed`)
|
||||
for (const diff of diffs) {
|
||||
printInfo(` - ${diff.filePath}`)
|
||||
}
|
||||
}
|
||||
return proto.host.OpenMultiFileDiffResponse.create({})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI implementation of EnvService - handles environment operations
|
||||
*/
|
||||
export class CliEnvServiceClient implements EnvServiceClientInterface {
|
||||
private clipboardContent: string = ""
|
||||
|
||||
async clipboardWriteText(request: proto.cline.StringRequest): Promise<proto.cline.Empty> {
|
||||
this.clipboardContent = request.value || ""
|
||||
printInfo(`📋 Copied to clipboard`)
|
||||
return proto.cline.Empty.create()
|
||||
}
|
||||
|
||||
async clipboardReadText(_request: proto.cline.EmptyRequest): Promise<proto.cline.String> {
|
||||
return proto.cline.String.create({ value: this.clipboardContent })
|
||||
}
|
||||
|
||||
async getHostVersion(_request: proto.cline.EmptyRequest): Promise<proto.host.GetHostVersionResponse> {
|
||||
return proto.host.GetHostVersionResponse.create({
|
||||
version: 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: proto.host.Setting.DISABLED,
|
||||
})
|
||||
}
|
||||
|
||||
subscribeToTelemetrySettings(
|
||||
_request: proto.cline.EmptyRequest,
|
||||
callbacks: StreamingCallbacks<proto.host.TelemetrySettingsEvent>,
|
||||
): () => void {
|
||||
// Send initial settings
|
||||
callbacks.onResponse(
|
||||
proto.host.TelemetrySettingsEvent.create({
|
||||
isEnabled: proto.host.Setting.DISABLED,
|
||||
}),
|
||||
)
|
||||
// Return unsubscribe function
|
||||
return () => {}
|
||||
}
|
||||
|
||||
async shutdown(_request: proto.cline.EmptyRequest): Promise<proto.cline.Empty> {
|
||||
printInfo("Shutting down...")
|
||||
return proto.cline.Empty.create()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI implementation of WindowService - handles window/UI operations
|
||||
*/
|
||||
export class CliWindowServiceClient implements WindowServiceClientInterface {
|
||||
async showTextDocument(request: proto.host.ShowTextDocumentRequest): Promise<proto.host.TextEditorInfo> {
|
||||
printInfo(`📄 Opening file: ${request.path}`)
|
||||
return proto.host.TextEditorInfo.create({
|
||||
documentPath: request.path,
|
||||
})
|
||||
}
|
||||
|
||||
async showOpenDialogue(_request: proto.host.ShowOpenDialogueRequest): Promise<proto.host.SelectedResources> {
|
||||
printWarning("Open dialog not available in CLI mode")
|
||||
return proto.host.SelectedResources.create({ paths: [] })
|
||||
}
|
||||
|
||||
async showMessage(request: proto.host.ShowMessageRequest): Promise<proto.host.SelectedResponse> {
|
||||
const message = request.message || ""
|
||||
const type = request.type
|
||||
|
||||
switch (type) {
|
||||
case proto.host.ShowMessageType.ERROR:
|
||||
printError(message)
|
||||
break
|
||||
case proto.host.ShowMessageType.WARNING:
|
||||
printWarning(message)
|
||||
break
|
||||
case proto.host.ShowMessageType.INFORMATION:
|
||||
default:
|
||||
printInfo(message)
|
||||
break
|
||||
}
|
||||
|
||||
return proto.host.SelectedResponse.create({})
|
||||
}
|
||||
|
||||
async showInputBox(_request: proto.host.ShowInputBoxRequest): Promise<proto.host.ShowInputBoxResponse> {
|
||||
// In CLI mode, we could use readline, but for now return empty
|
||||
printWarning("Input box not available in CLI mode")
|
||||
return proto.host.ShowInputBoxResponse.create({ response: "" })
|
||||
}
|
||||
|
||||
async showSaveDialog(_request: proto.host.ShowSaveDialogRequest): Promise<proto.host.ShowSaveDialogResponse> {
|
||||
printWarning("Save dialog not available in CLI mode")
|
||||
return proto.host.ShowSaveDialogResponse.create({ selectedPath: "" })
|
||||
}
|
||||
|
||||
async openFile(request: proto.host.OpenFileRequest): Promise<proto.host.OpenFileResponse> {
|
||||
printInfo(`📂 Opening: ${request.filePath}`)
|
||||
return proto.host.OpenFileResponse.create({})
|
||||
}
|
||||
|
||||
async openSettings(_request: proto.host.OpenSettingsRequest): Promise<proto.host.OpenSettingsResponse> {
|
||||
printInfo("Settings can be configured in ~/.cline/data/globalState.json")
|
||||
return proto.host.OpenSettingsResponse.create({})
|
||||
}
|
||||
|
||||
async getOpenTabs(_request: proto.host.GetOpenTabsRequest): Promise<proto.host.GetOpenTabsResponse> {
|
||||
// CLI doesn't have tabs
|
||||
return proto.host.GetOpenTabsResponse.create({ paths: [] })
|
||||
}
|
||||
|
||||
async getVisibleTabs(_request: proto.host.GetVisibleTabsRequest): Promise<proto.host.GetVisibleTabsResponse> {
|
||||
return proto.host.GetVisibleTabsResponse.create({ paths: [] })
|
||||
}
|
||||
|
||||
async getActiveEditor(_request: proto.host.GetActiveEditorRequest): Promise<proto.host.GetActiveEditorResponse> {
|
||||
return proto.host.GetActiveEditorResponse.create({})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI implementation of WorkspaceService - handles workspace operations
|
||||
*/
|
||||
export class CliWorkspaceServiceClient implements WorkspaceServiceClientInterface {
|
||||
private workspacePath: string
|
||||
|
||||
constructor(workspacePath: string = process.cwd()) {
|
||||
this.workspacePath = workspacePath
|
||||
}
|
||||
|
||||
setWorkspacePath(path: string) {
|
||||
this.workspacePath = path
|
||||
}
|
||||
|
||||
async getWorkspacePaths(_request: proto.host.GetWorkspacePathsRequest): Promise<proto.host.GetWorkspacePathsResponse> {
|
||||
return proto.host.GetWorkspacePathsResponse.create({
|
||||
paths: [this.workspacePath],
|
||||
})
|
||||
}
|
||||
|
||||
async saveOpenDocumentIfDirty(
|
||||
_request: proto.host.SaveOpenDocumentIfDirtyRequest,
|
||||
): Promise<proto.host.SaveOpenDocumentIfDirtyResponse> {
|
||||
return proto.host.SaveOpenDocumentIfDirtyResponse.create({})
|
||||
}
|
||||
|
||||
async getDiagnostics(_request: proto.host.GetDiagnosticsRequest): Promise<proto.host.GetDiagnosticsResponse> {
|
||||
// In CLI mode, we could run linters here
|
||||
return proto.host.GetDiagnosticsResponse.create({ fileDiagnostics: [] })
|
||||
}
|
||||
|
||||
async openProblemsPanel(_request: proto.host.OpenProblemsPanelRequest): Promise<proto.host.OpenProblemsPanelResponse> {
|
||||
printInfo("Run linters to see problems")
|
||||
return proto.host.OpenProblemsPanelResponse.create({})
|
||||
}
|
||||
|
||||
async openInFileExplorerPanel(
|
||||
request: proto.host.OpenInFileExplorerPanelRequest,
|
||||
): Promise<proto.host.OpenInFileExplorerPanelResponse> {
|
||||
printInfo(`📁 ${request.path}`)
|
||||
return proto.host.OpenInFileExplorerPanelResponse.create({})
|
||||
}
|
||||
|
||||
async openClineSidebarPanel(
|
||||
_request: proto.host.OpenClineSidebarPanelRequest,
|
||||
): Promise<proto.host.OpenClineSidebarPanelResponse> {
|
||||
// No sidebar in CLI
|
||||
return proto.host.OpenClineSidebarPanelResponse.create({})
|
||||
}
|
||||
|
||||
async openTerminalPanel(_request: proto.host.OpenTerminalRequest): Promise<proto.host.OpenTerminalResponse> {
|
||||
printInfo("Terminal is already available in CLI mode")
|
||||
return proto.host.OpenTerminalResponse.create({})
|
||||
}
|
||||
|
||||
async executeCommandInTerminal(
|
||||
request: proto.host.ExecuteCommandInTerminalRequest,
|
||||
): Promise<proto.host.ExecuteCommandInTerminalResponse> {
|
||||
printInfo(`⚙️ Executing: ${request.command}`)
|
||||
return proto.host.ExecuteCommandInTerminalResponse.create({})
|
||||
}
|
||||
|
||||
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,158 @@
|
||||
/**
|
||||
* Custom hook to subscribe to controller state updates
|
||||
* Handles the diff/merge logic for streaming text and message tracking
|
||||
*/
|
||||
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { useCallback, useRef } from "react"
|
||||
import { useTaskContext } from "../context/TaskContext"
|
||||
|
||||
interface ProcessedState {
|
||||
processedAskMessages: Set<number>
|
||||
processedSayMessages: Set<number>
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to track which ask/say messages have been processed
|
||||
* This prevents duplicate prompts for the same ask message
|
||||
*/
|
||||
export const useProcessedMessages = () => {
|
||||
const processedRef = useRef<ProcessedState>({
|
||||
processedAskMessages: new Set(),
|
||||
processedSayMessages: new Set(),
|
||||
})
|
||||
|
||||
return processedRef.current
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if a message has just been completed (is asking for user input)
|
||||
*/
|
||||
export const useCompletedAskMessages = () => {
|
||||
const { state, controller } = useTaskContext()
|
||||
const processed = useProcessedMessages()
|
||||
|
||||
const getCompletedAskMessages = useCallback(() => {
|
||||
const completedAsks: ClineMessage[] = []
|
||||
|
||||
if (!state.clineMessages) {
|
||||
return completedAsks
|
||||
}
|
||||
|
||||
for (let i = 0; i < state.clineMessages.length; i++) {
|
||||
const message = state.clineMessages[i]
|
||||
if (message.type === "ask" && !message.partial && !processed.processedAskMessages.has(i)) {
|
||||
completedAsks.push(message)
|
||||
processed.processedAskMessages.add(i)
|
||||
}
|
||||
}
|
||||
|
||||
return completedAsks
|
||||
}, [state.clineMessages, processed])
|
||||
|
||||
return getCompletedAskMessages
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last completed ask message (for rendering current input prompt)
|
||||
*/
|
||||
export const useLastCompletedAskMessage = () => {
|
||||
const { state } = useTaskContext()
|
||||
const processed = useProcessedMessages()
|
||||
|
||||
const getLastCompletedAskMessage = useCallback((): ClineMessage | null => {
|
||||
if (!state.clineMessages) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Find the last ask message that is complete
|
||||
for (let i = state.clineMessages.length - 1; i >= 0; i--) {
|
||||
const message = state.clineMessages[i]
|
||||
if (message.type === "ask" && !message.partial) {
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}, [state.clineMessages])
|
||||
|
||||
return getLastCompletedAskMessage()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get messages that should trigger the completion detection
|
||||
*/
|
||||
export const useCompletionSignals = () => {
|
||||
const { state } = useTaskContext()
|
||||
|
||||
const isTaskComplete = useCallback((): boolean => {
|
||||
if (!state.clineMessages || state.clineMessages.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const lastMessage = state.clineMessages[state.clineMessages.length - 1]
|
||||
if (!lastMessage) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for completion signals
|
||||
if (lastMessage.say === "completion_result" || lastMessage.ask === "completion_result") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for error signals
|
||||
if (lastMessage.say === "error" || lastMessage.ask === "api_req_failed") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}, [state.clineMessages])
|
||||
|
||||
const getCompletionMessage = useCallback((): ClineMessage | null => {
|
||||
if (!state.clineMessages || state.clineMessages.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return state.clineMessages[state.clineMessages.length - 1] || null
|
||||
}, [state.clineMessages])
|
||||
|
||||
return {
|
||||
isTaskComplete,
|
||||
getCompletionMessage,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if spinner should be shown (when API is thinking)
|
||||
*/
|
||||
export const useIsSpinnerActive = (): boolean => {
|
||||
const { state } = useTaskContext()
|
||||
|
||||
if (!state.clineMessages || state.clineMessages.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// If the last message is a completed ask message, don't show spinner (waiting for user input)
|
||||
const lastMessage = state.clineMessages[state.clineMessages.length - 1]
|
||||
if (lastMessage?.type === "ask" && !lastMessage.partial) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Look for most recent api_req_started that isn't followed by api_req_finished
|
||||
for (let i = state.clineMessages.length - 1; i >= 0; i--) {
|
||||
const msg = state.clineMessages[i]
|
||||
if (msg.say === "api_req_started") {
|
||||
// Check if there's an api_req_finished after this
|
||||
let hasFinished = false
|
||||
for (let j = i + 1; j < state.clineMessages.length; j++) {
|
||||
if (state.clineMessages[j].say === "api_req_finished") {
|
||||
hasFinished = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return !hasFinished
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import { Command } from "commander"
|
||||
import { beforeEach, describe, expect, it } from "vitest"
|
||||
|
||||
/**
|
||||
* Tests for CLI command parsing and structure
|
||||
* These tests verify the commander.js command definitions without
|
||||
* actually running the commands (which would require full infrastructure)
|
||||
*/
|
||||
|
||||
describe("CLI Commands", () => {
|
||||
let program: Command
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a fresh program instance for each test
|
||||
program = new Command()
|
||||
program.name("cline").description("Cline CLI - AI coding assistant").version("0.0.0")
|
||||
program.enablePositionalOptions()
|
||||
|
||||
// Define commands matching index.ts
|
||||
program
|
||||
.command("task")
|
||||
.alias("t")
|
||||
.description("Run a new task")
|
||||
.argument("<prompt>", "The task prompt")
|
||||
.option("-a, --act", "Run in act mode")
|
||||
.option("-p, --plan", "Run in plan mode")
|
||||
.option("-y, --yolo", "Enable yolo mode")
|
||||
.option("-m, --model <model>", "Model to use")
|
||||
.option("-i, --images <paths...>", "Image file paths")
|
||||
.option("-v, --verbose", "Show verbose output")
|
||||
.option("-c, --cwd <path>", "Working directory")
|
||||
.option("--config <path>", "Configuration directory")
|
||||
.option("--thinking", "Enable extended thinking")
|
||||
.action(() => {})
|
||||
|
||||
program
|
||||
.command("history")
|
||||
.alias("h")
|
||||
.description("List task history")
|
||||
.option("-n, --limit <number>", "Number of tasks to show", "10")
|
||||
.option("-p, --page <number>", "Page number", "1")
|
||||
.option("--config <path>", "Configuration directory")
|
||||
.action(() => {})
|
||||
|
||||
program
|
||||
.command("config")
|
||||
.description("Show current configuration")
|
||||
.option("--config <path>", "Configuration directory")
|
||||
.action(() => {})
|
||||
|
||||
program
|
||||
.command("auth")
|
||||
.description("Authenticate a provider")
|
||||
.option("-p, --provider <id>", "Provider ID")
|
||||
.option("-k, --apikey <key>", "API key")
|
||||
.option("-m, --modelid <id>", "Model ID")
|
||||
.option("-b, --baseurl <url>", "Base URL")
|
||||
.option("-v, --verbose", "Verbose output")
|
||||
.option("-c, --cwd <path>", "Working directory")
|
||||
.option("--config <path>", "Configuration directory")
|
||||
.action(() => {})
|
||||
|
||||
// Default command for interactive mode
|
||||
program
|
||||
.argument("[prompt]", "Task prompt")
|
||||
.option("-i, --images <paths...>", "Image file paths")
|
||||
.option("-v, --verbose", "Verbose output")
|
||||
.option("-c, --cwd <path>", "Working directory")
|
||||
.option("--config <path>", "Configuration directory")
|
||||
.option("--thinking", "Enable extended thinking")
|
||||
.action(() => {})
|
||||
})
|
||||
|
||||
describe("task command", () => {
|
||||
it("should parse task command with prompt", () => {
|
||||
const args = ["node", "cli", "task", "write hello world"]
|
||||
program.parse(args)
|
||||
// Command should be parsed without error
|
||||
})
|
||||
|
||||
it("should parse task alias", () => {
|
||||
const args = ["node", "cli", "t", "write hello world"]
|
||||
program.parse(args)
|
||||
})
|
||||
|
||||
it("should parse --act flag", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const args = ["test prompt", "--act"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().act).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --plan flag", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const args = ["test prompt", "--plan"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().plan).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --yolo flag", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const args = ["test prompt", "--yolo"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().yolo).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --model option", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const args = ["test prompt", "--model", "claude-sonnet-4-20250514"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().model).toBe("claude-sonnet-4-20250514")
|
||||
})
|
||||
|
||||
it("should parse --images option with multiple paths", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const args = ["test prompt", "--images", "/path/to/img1.png", "/path/to/img2.jpg"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().images).toEqual(["/path/to/img1.png", "/path/to/img2.jpg"])
|
||||
})
|
||||
|
||||
it("should parse --verbose flag", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const args = ["test prompt", "--verbose"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().verbose).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --cwd option", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const args = ["test prompt", "--cwd", "/some/path"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().cwd).toBe("/some/path")
|
||||
})
|
||||
|
||||
it("should parse --config option", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const args = ["test prompt", "--config", "/custom/config"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().config).toBe("/custom/config")
|
||||
})
|
||||
|
||||
it("should parse --thinking flag", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const args = ["test prompt", "--thinking"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().thinking).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse short flags", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const args = ["test prompt", "-a", "-v", "-m", "gpt-4"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().act).toBe(true)
|
||||
expect(taskCmd.opts().verbose).toBe(true)
|
||||
expect(taskCmd.opts().model).toBe("gpt-4")
|
||||
})
|
||||
})
|
||||
|
||||
describe("history command", () => {
|
||||
it("should have default limit of 10", () => {
|
||||
const historyCmd = program.commands.find((c) => c.name() === "history")!
|
||||
historyCmd.parse([], { from: "user" })
|
||||
expect(historyCmd.opts().limit).toBe("10")
|
||||
})
|
||||
|
||||
it("should have default page of 1", () => {
|
||||
const historyCmd = program.commands.find((c) => c.name() === "history")!
|
||||
historyCmd.parse([], { from: "user" })
|
||||
expect(historyCmd.opts().page).toBe("1")
|
||||
})
|
||||
|
||||
it("should parse --limit option", () => {
|
||||
const historyCmd = program.commands.find((c) => c.name() === "history")!
|
||||
const args = ["--limit", "20"]
|
||||
historyCmd.parse(args, { from: "user" })
|
||||
expect(historyCmd.opts().limit).toBe("20")
|
||||
})
|
||||
|
||||
it("should parse --page option", () => {
|
||||
const historyCmd = program.commands.find((c) => c.name() === "history")!
|
||||
const args = ["--page", "3"]
|
||||
historyCmd.parse(args, { from: "user" })
|
||||
expect(historyCmd.opts().page).toBe("3")
|
||||
})
|
||||
|
||||
it("should parse history alias", () => {
|
||||
const args = ["node", "cli", "h"]
|
||||
program.parse(args)
|
||||
// Alias should work
|
||||
})
|
||||
|
||||
it("should parse short flags", () => {
|
||||
const historyCmd = program.commands.find((c) => c.name() === "history")!
|
||||
const args = ["-n", "5", "-p", "2"]
|
||||
historyCmd.parse(args, { from: "user" })
|
||||
expect(historyCmd.opts().limit).toBe("5")
|
||||
expect(historyCmd.opts().page).toBe("2")
|
||||
})
|
||||
})
|
||||
|
||||
describe("config command", () => {
|
||||
it("should parse config command", () => {
|
||||
const args = ["node", "cli", "config"]
|
||||
program.parse(args)
|
||||
})
|
||||
|
||||
it("should parse --config option", () => {
|
||||
const configCmd = program.commands.find((c) => c.name() === "config")!
|
||||
const args = ["--config", "/custom/path"]
|
||||
configCmd.parse(args, { from: "user" })
|
||||
expect(configCmd.opts().config).toBe("/custom/path")
|
||||
})
|
||||
})
|
||||
|
||||
describe("auth command", () => {
|
||||
it("should parse auth command", () => {
|
||||
const args = ["node", "cli", "auth"]
|
||||
program.parse(args)
|
||||
})
|
||||
|
||||
it("should parse --provider option", () => {
|
||||
const authCmd = program.commands.find((c) => c.name() === "auth")!
|
||||
const args = ["--provider", "openai"]
|
||||
authCmd.parse(args, { from: "user" })
|
||||
expect(authCmd.opts().provider).toBe("openai")
|
||||
})
|
||||
|
||||
it("should parse --apikey option", () => {
|
||||
const authCmd = program.commands.find((c) => c.name() === "auth")!
|
||||
const args = ["--apikey", "sk-test-key"]
|
||||
authCmd.parse(args, { from: "user" })
|
||||
expect(authCmd.opts().apikey).toBe("sk-test-key")
|
||||
})
|
||||
|
||||
it("should parse --modelid option", () => {
|
||||
const authCmd = program.commands.find((c) => c.name() === "auth")!
|
||||
const args = ["--modelid", "gpt-4"]
|
||||
authCmd.parse(args, { from: "user" })
|
||||
expect(authCmd.opts().modelid).toBe("gpt-4")
|
||||
})
|
||||
|
||||
it("should parse --baseurl option", () => {
|
||||
const authCmd = program.commands.find((c) => c.name() === "auth")!
|
||||
const args = ["--baseurl", "https://api.example.com"]
|
||||
authCmd.parse(args, { from: "user" })
|
||||
expect(authCmd.opts().baseurl).toBe("https://api.example.com")
|
||||
})
|
||||
|
||||
it("should parse short flags", () => {
|
||||
const authCmd = program.commands.find((c) => c.name() === "auth")!
|
||||
const args = ["-p", "anthropic", "-k", "key123", "-m", "claude-sonnet-4-20250514"]
|
||||
authCmd.parse(args, { from: "user" })
|
||||
expect(authCmd.opts().provider).toBe("anthropic")
|
||||
expect(authCmd.opts().apikey).toBe("key123")
|
||||
expect(authCmd.opts().modelid).toBe("claude-sonnet-4-20250514")
|
||||
})
|
||||
})
|
||||
|
||||
describe("default command (interactive mode)", () => {
|
||||
it("should parse optional prompt argument", () => {
|
||||
const args = ["node", "cli", "do something"]
|
||||
program.parse(args)
|
||||
})
|
||||
|
||||
it("should parse without prompt (interactive mode)", () => {
|
||||
const args = ["node", "cli"]
|
||||
program.parse(args)
|
||||
})
|
||||
|
||||
it("should parse --images option", () => {
|
||||
program.parse(["node", "cli", "--images", "img.png"])
|
||||
expect(program.opts().images).toEqual(["img.png"])
|
||||
})
|
||||
|
||||
it("should parse --verbose flag", () => {
|
||||
program.parse(["node", "cli", "--verbose"])
|
||||
expect(program.opts().verbose).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --thinking flag", () => {
|
||||
program.parse(["node", "cli", "--thinking"])
|
||||
expect(program.opts().thinking).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("command structure", () => {
|
||||
it("should have all expected commands", () => {
|
||||
const commandNames = program.commands.map((c) => c.name())
|
||||
expect(commandNames).toContain("task")
|
||||
expect(commandNames).toContain("history")
|
||||
expect(commandNames).toContain("config")
|
||||
expect(commandNames).toContain("auth")
|
||||
})
|
||||
|
||||
it("should have correct aliases", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const historyCmd = program.commands.find((c) => c.name() === "history")!
|
||||
expect(taskCmd.aliases()).toContain("t")
|
||||
expect(historyCmd.aliases()).toContain("h")
|
||||
})
|
||||
|
||||
it("should have descriptions for all commands", () => {
|
||||
for (const cmd of program.commands) {
|
||||
expect(cmd.description()).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("getProviderModelIdKey", () => {
|
||||
// Test the provider model ID key mapping logic
|
||||
const providerKeyMap: Record<string, string> = {
|
||||
openrouter: "OpenRouterModelId",
|
||||
cline: "OpenRouterModelId",
|
||||
openai: "OpenAiModelId",
|
||||
ollama: "OllamaModelId",
|
||||
lmstudio: "LmStudioModelId",
|
||||
litellm: "LiteLlmModelId",
|
||||
requesty: "RequestyModelId",
|
||||
together: "TogetherModelId",
|
||||
fireworks: "FireworksModelId",
|
||||
sapaicore: "SapAiCoreModelId",
|
||||
groq: "GroqModelId",
|
||||
baseten: "BasetenModelId",
|
||||
huggingface: "HuggingFaceModelId",
|
||||
}
|
||||
|
||||
function getProviderModelIdKey(provider: string, mode: "act" | "plan"): string | null {
|
||||
const prefix = mode === "act" ? "actMode" : "planMode"
|
||||
const keySuffix = providerKeyMap[provider]
|
||||
if (keySuffix) {
|
||||
return `${prefix}${keySuffix}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
it("should return correct key for openrouter in act mode", () => {
|
||||
expect(getProviderModelIdKey("openrouter", "act")).toBe("actModeOpenRouterModelId")
|
||||
})
|
||||
|
||||
it("should return correct key for openrouter in plan mode", () => {
|
||||
expect(getProviderModelIdKey("openrouter", "plan")).toBe("planModeOpenRouterModelId")
|
||||
})
|
||||
|
||||
it("should return same key for cline as openrouter", () => {
|
||||
expect(getProviderModelIdKey("cline", "act")).toBe("actModeOpenRouterModelId")
|
||||
})
|
||||
|
||||
it("should return correct key for openai", () => {
|
||||
expect(getProviderModelIdKey("openai", "act")).toBe("actModeOpenAiModelId")
|
||||
})
|
||||
|
||||
it("should return correct key for ollama", () => {
|
||||
expect(getProviderModelIdKey("ollama", "act")).toBe("actModeOllamaModelId")
|
||||
})
|
||||
|
||||
it("should return null for anthropic (uses generic key)", () => {
|
||||
expect(getProviderModelIdKey("anthropic", "act")).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for gemini (uses generic key)", () => {
|
||||
expect(getProviderModelIdKey("gemini", "act")).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for bedrock (uses generic key)", () => {
|
||||
expect(getProviderModelIdKey("bedrock", "act")).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for unknown providers", () => {
|
||||
expect(getProviderModelIdKey("unknown-provider", "act")).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,605 @@
|
||||
/**
|
||||
* Cline CLI - TypeScript implementation with React Ink
|
||||
*/
|
||||
|
||||
import path from "node:path"
|
||||
import { exit } from "node:process"
|
||||
import type { ApiProvider } from "@shared/api"
|
||||
import { Command } from "commander"
|
||||
import { render } from "ink"
|
||||
import React from "react"
|
||||
import { Controller } from "@/core/controller"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { AuthHandler } from "@/hosts/external/AuthHandler"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { FileEditProvider } from "@/integrations/editor/FileEditProvider"
|
||||
import { StandaloneTerminalManager } from "@/integrations/terminal/standalone/StandaloneTerminalManager"
|
||||
import { ErrorService } from "@/services/error/ErrorService"
|
||||
import { initializeDistinctId } from "@/services/logging/distinctId"
|
||||
import { getProviderModelIdKey } from "@/shared/storage"
|
||||
import { App } from "./components/App"
|
||||
import { checkRawModeSupport } from "./context/StdinContext"
|
||||
import { createCliHostBridgeProvider } from "./controllers"
|
||||
import { CliCommentReviewController } from "./controllers/CliCommentReviewController"
|
||||
import { CliWebviewProvider } from "./controllers/CliWebviewProvider"
|
||||
import { restoreConsole } from "./utils/console"
|
||||
import { print, printError, printInfo, printWarning, separator } from "./utils/display"
|
||||
import { parseImagesFromInput, processImagePaths } from "./utils/parser"
|
||||
import { readStdinIfPiped } from "./utils/piped"
|
||||
import { initializeCliContext } from "./vscode-context"
|
||||
|
||||
export const CLI_VERSION = "0.0.0"
|
||||
|
||||
// Track active context for graceful shutdown
|
||||
let activeContext: CliContext | null = null
|
||||
let isShuttingDown = false
|
||||
|
||||
function setupSignalHandlers() {
|
||||
const shutdown = async (signal: string) => {
|
||||
if (isShuttingDown) {
|
||||
// Force exit on second signal
|
||||
process.exit(1)
|
||||
}
|
||||
isShuttingDown = true
|
||||
printWarning(`\n${signal} received, shutting down...`)
|
||||
|
||||
try {
|
||||
if (activeContext) {
|
||||
const task = activeContext.controller.task
|
||||
if (task) {
|
||||
task.abortTask()
|
||||
}
|
||||
await activeContext.controller.stateManager.flushPendingState()
|
||||
await activeContext.controller.dispose()
|
||||
}
|
||||
await ErrorService.get().dispose()
|
||||
} catch {
|
||||
// Best effort cleanup
|
||||
}
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => shutdown("SIGINT"))
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"))
|
||||
}
|
||||
|
||||
setupSignalHandlers()
|
||||
|
||||
interface CliContext {
|
||||
extensionContext: any
|
||||
dataDir: string
|
||||
extensionDir: string
|
||||
workspacePath: string
|
||||
controller: Controller
|
||||
}
|
||||
|
||||
interface InitOptions {
|
||||
config?: string
|
||||
cwd?: string
|
||||
verbose?: boolean
|
||||
enableAuth?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all CLI infrastructure and return context needed for commands
|
||||
*/
|
||||
async function initializeCli(options: InitOptions): Promise<CliContext> {
|
||||
const workspacePath = options.cwd || process.cwd()
|
||||
const { extensionContext, DATA_DIR, EXTENSION_DIR } = initializeCliContext({
|
||||
clineDir: options.config,
|
||||
workspaceDir: workspacePath,
|
||||
})
|
||||
|
||||
if (options.enableAuth) {
|
||||
AuthHandler.getInstance().setEnabled(true)
|
||||
}
|
||||
|
||||
// const logToChannel = options.verbose ? (message: string) => printInfo(message) : () => {}
|
||||
const logToChannel = () => {}
|
||||
|
||||
HostProvider.initialize(
|
||||
() => new CliWebviewProvider(extensionContext),
|
||||
() => new FileEditProvider(),
|
||||
() => new CliCommentReviewController(),
|
||||
() => new StandaloneTerminalManager(),
|
||||
createCliHostBridgeProvider(workspacePath),
|
||||
logToChannel,
|
||||
async () => (options.enableAuth ? AuthHandler.getInstance().getCallbackUrl() : ""),
|
||||
async (name: string) => path.join(process.cwd(), name),
|
||||
EXTENSION_DIR,
|
||||
DATA_DIR,
|
||||
)
|
||||
|
||||
await ErrorService.initialize()
|
||||
await StateManager.initialize(extensionContext)
|
||||
|
||||
const webview = HostProvider.get().createWebviewProvider() as CliWebviewProvider
|
||||
const controller = webview.controller
|
||||
|
||||
await initializeDistinctId(extensionContext)
|
||||
|
||||
const ctx = { extensionContext, dataDir: DATA_DIR, extensionDir: EXTENSION_DIR, workspacePath, controller }
|
||||
activeContext = ctx
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an Ink app with proper cleanup handling
|
||||
*/
|
||||
async function runInkApp(element: React.ReactElement, cleanup: () => Promise<void>): Promise<void> {
|
||||
const { waitUntilExit, unmount } = render(element)
|
||||
|
||||
try {
|
||||
await waitUntilExit()
|
||||
} finally {
|
||||
try {
|
||||
unmount()
|
||||
} catch {
|
||||
// Already unmounted
|
||||
}
|
||||
restoreConsole()
|
||||
await cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a condition with timeout
|
||||
*/
|
||||
function waitForCondition(check: () => boolean, timeoutMs: number, intervalMs: number = 100): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const startTime = Date.now()
|
||||
const poll = () => {
|
||||
if (check()) {
|
||||
resolve(true)
|
||||
return
|
||||
}
|
||||
if (Date.now() - startTime > timeoutMs) {
|
||||
resolve(false)
|
||||
return
|
||||
}
|
||||
setTimeout(poll, intervalMs)
|
||||
}
|
||||
poll()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a task with the given prompt
|
||||
*/
|
||||
async function runTask(
|
||||
prompt: string,
|
||||
options: {
|
||||
act?: boolean
|
||||
plan?: boolean
|
||||
model?: string
|
||||
verbose?: boolean
|
||||
cwd?: string
|
||||
config?: string
|
||||
thinking?: boolean
|
||||
yolo?: boolean
|
||||
images?: string[]
|
||||
json?: boolean
|
||||
},
|
||||
existingContext?: CliContext,
|
||||
) {
|
||||
const ctx = existingContext || (await initializeCli(options))
|
||||
|
||||
// Parse images from the prompt text (e.g., @/path/to/image.png)
|
||||
const { prompt: cleanPrompt, imagePaths: parsedImagePaths } = parseImagesFromInput(prompt)
|
||||
|
||||
// Combine parsed image paths with explicit --images option
|
||||
const allImagePaths = [...(options.images || []), ...parsedImagePaths]
|
||||
// Convert image file paths to base64 data URLs
|
||||
const imageDataUrls = await processImagePaths(allImagePaths)
|
||||
|
||||
// Use clean prompt (with image refs removed)
|
||||
const taskPrompt = cleanPrompt || prompt
|
||||
|
||||
if (options.plan) {
|
||||
StateManager.get().setGlobalState("mode", "plan")
|
||||
} else if (options.act) {
|
||||
StateManager.get().setGlobalState("mode", "act")
|
||||
}
|
||||
|
||||
if (options.model) {
|
||||
const selectedMode = (StateManager.get().getGlobalSettingsKey("mode") || "act") as "act" | "plan"
|
||||
|
||||
// Get the current provider for the selected mode
|
||||
const providerKey = selectedMode === "act" ? "actModeApiProvider" : "planModeApiProvider"
|
||||
const currentProvider = StateManager.get().getGlobalSettingsKey(providerKey) as ApiProvider
|
||||
|
||||
// Update the generic model ID for the current mode
|
||||
const modelKey = selectedMode === "act" ? "actModeApiModelId" : "planModeApiModelId"
|
||||
StateManager.get().setGlobalState(modelKey, options.model)
|
||||
|
||||
// Also update the provider-specific model ID key if applicable
|
||||
const providerModelKey = getProviderModelIdKey(currentProvider, selectedMode)
|
||||
if (providerModelKey) {
|
||||
StateManager.get().setGlobalState(providerModelKey, options.model)
|
||||
}
|
||||
}
|
||||
|
||||
// Set thinking budget based on --thinking flag
|
||||
const thinkingBudget = options.thinking ? 1024 : 0
|
||||
const currentMode = StateManager.get().getGlobalSettingsKey("mode") || "act"
|
||||
const thinkingKey = currentMode === "act" ? "actModeThinkingBudgetTokens" : "planModeThinkingBudgetTokens"
|
||||
StateManager.get().setGlobalState(thinkingKey, thinkingBudget)
|
||||
|
||||
// Set yolo mode based on --yolo flag
|
||||
StateManager.get().setGlobalState("yoloModeToggled", options.yolo === true)
|
||||
|
||||
await StateManager.get().flushPendingState()
|
||||
|
||||
// Skip info messages in JSON mode
|
||||
if (!options.json) {
|
||||
printInfo(`Starting Cline task...`)
|
||||
printInfo(`Working directory: ${ctx.workspacePath}`)
|
||||
if (imageDataUrls.length > 0) {
|
||||
printInfo(`Images attached: ${imageDataUrls.length}`)
|
||||
}
|
||||
print(separator())
|
||||
}
|
||||
|
||||
let isComplete = false
|
||||
let taskError = false
|
||||
|
||||
const { waitUntilExit, unmount } = render(
|
||||
React.createElement(App, {
|
||||
view: "task",
|
||||
taskId: taskPrompt.substring(0, 30),
|
||||
verbose: options.verbose,
|
||||
jsonOutput: options.json,
|
||||
controller: ctx.controller,
|
||||
isRawModeSupported: checkRawModeSupport(),
|
||||
onComplete: () => {
|
||||
isComplete = true
|
||||
},
|
||||
onError: () => {
|
||||
taskError = true
|
||||
isComplete = true
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
await ctx.controller.initTask(taskPrompt, imageDataUrls.length > 0 ? imageDataUrls : undefined)
|
||||
|
||||
const completed = await waitForCondition(() => isComplete, 10 * 60 * 1000)
|
||||
if (!completed) {
|
||||
printError("Task timeout")
|
||||
}
|
||||
|
||||
// Brief delay for final render
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
try {
|
||||
await waitUntilExit()
|
||||
if (taskError) {
|
||||
process.exit(1)
|
||||
}
|
||||
} catch (error) {
|
||||
printError(`Task failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
process.exit(1)
|
||||
} finally {
|
||||
try {
|
||||
unmount()
|
||||
} catch {
|
||||
// Already unmounted
|
||||
}
|
||||
restoreConsole()
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
await ctx.controller.dispose()
|
||||
await ErrorService.get().dispose()
|
||||
exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List task history
|
||||
*/
|
||||
async function listHistory(options: { config?: string; limit?: number; page?: number }) {
|
||||
const ctx = await initializeCli(options)
|
||||
|
||||
const taskHistory = StateManager.get().getGlobalStateKey("taskHistory") || []
|
||||
// Sort by timestamp (newest first) before pagination
|
||||
const sortedHistory = [...taskHistory].sort((a: any, b: any) => (b.ts || 0) - (a.ts || 0))
|
||||
const limit = typeof options.limit === "string" ? parseInt(options.limit, 10) : options.limit || 10
|
||||
const initialPage = typeof options.page === "string" ? parseInt(options.page, 10) : options.page || 1
|
||||
const totalCount = sortedHistory.length
|
||||
const totalPages = Math.ceil(totalCount / limit)
|
||||
|
||||
if (sortedHistory.length === 0) {
|
||||
printInfo("No task history found.")
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
await ctx.controller.dispose()
|
||||
await ErrorService.get().dispose()
|
||||
exit(0)
|
||||
return
|
||||
}
|
||||
|
||||
await runInkApp(
|
||||
React.createElement(App, {
|
||||
view: "history",
|
||||
historyItems: [],
|
||||
historyAllItems: sortedHistory,
|
||||
controller: ctx.controller,
|
||||
historyPagination: { page: initialPage, totalPages, totalCount, limit },
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
let authError = false
|
||||
|
||||
await runInkApp(
|
||||
React.createElement(App, {
|
||||
view: "auth",
|
||||
controller: ctx.controller,
|
||||
isRawModeSupported: checkRawModeSupport(),
|
||||
onComplete: () => {
|
||||
exit(0)
|
||||
},
|
||||
onError: () => {
|
||||
authError = true
|
||||
},
|
||||
authQuickSetup: quickSetup,
|
||||
}),
|
||||
async () => {
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
await ctx.controller.dispose()
|
||||
await ErrorService.get().dispose()
|
||||
exit(0)
|
||||
},
|
||||
)
|
||||
|
||||
if (authError) {
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Setup CLI commands
|
||||
const program = new Command()
|
||||
|
||||
program.name("cline").description("Cline CLI - AI coding assistant in your terminal").version(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("-i, --images <paths...>", "Image file paths to include with the task")
|
||||
.option("-v, --verbose", "Show verbose output")
|
||||
.option("-c, --cwd <path>", "Working directory for the task")
|
||||
.option("--config <path>", "Path to Cline configuration directory")
|
||||
.option("--thinking", "Enable extended thinking (1024 token budget)")
|
||||
.option("--json", "Output messages as JSON instead of styled text")
|
||||
.action((prompt, options) => runTask(prompt, options))
|
||||
|
||||
program
|
||||
.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}`))
|
||||
/**
|
||||
* Show welcome prompt and run task with user input
|
||||
* Uses internal view switching to avoid visual glitches when transitioning to TaskView
|
||||
*/
|
||||
async function showWelcome(options: {
|
||||
verbose?: boolean
|
||||
cwd?: string
|
||||
config?: string
|
||||
thinking?: boolean
|
||||
yolo?: boolean
|
||||
plan?: boolean
|
||||
act?: boolean
|
||||
model?: string
|
||||
}) {
|
||||
const ctx = await initializeCli({ ...options, enableAuth: true })
|
||||
|
||||
// Apply options that would normally be set in runTask() BEFORE rendering
|
||||
// This ensures they're applied when the internal view switch happens
|
||||
if (options.plan) {
|
||||
StateManager.get().setGlobalState("mode", "plan")
|
||||
} else if (options.act) {
|
||||
StateManager.get().setGlobalState("mode", "act")
|
||||
}
|
||||
|
||||
if (options.model) {
|
||||
const selectedMode = (StateManager.get().getGlobalSettingsKey("mode") || "act") as "act" | "plan"
|
||||
|
||||
// Get the current provider for the selected mode
|
||||
const providerKey = selectedMode === "act" ? "actModeApiProvider" : "planModeApiProvider"
|
||||
const currentProvider = StateManager.get().getGlobalSettingsKey(providerKey) as ApiProvider
|
||||
|
||||
// Update the generic model ID for the current mode
|
||||
const modelKey = selectedMode === "act" ? "actModeApiModelId" : "planModeApiModelId"
|
||||
StateManager.get().setGlobalState(modelKey, options.model)
|
||||
|
||||
// Also update the provider-specific model ID key if applicable
|
||||
const providerModelKey = getProviderModelIdKey(currentProvider, selectedMode)
|
||||
if (providerModelKey) {
|
||||
StateManager.get().setGlobalState(providerModelKey, options.model)
|
||||
}
|
||||
}
|
||||
|
||||
// Set thinking budget based on --thinking flag
|
||||
const thinkingBudget = options.thinking ? 1024 : 0
|
||||
const currentMode = StateManager.get().getGlobalSettingsKey("mode") || "act"
|
||||
const thinkingKey = currentMode === "act" ? "actModeThinkingBudgetTokens" : "planModeThinkingBudgetTokens"
|
||||
StateManager.get().setGlobalState(thinkingKey, thinkingBudget)
|
||||
|
||||
// Set yolo mode based on --yolo flag
|
||||
StateManager.get().setGlobalState("yoloModeToggled", options.yolo === true)
|
||||
|
||||
await StateManager.get().flushPendingState()
|
||||
|
||||
let taskError = false
|
||||
|
||||
// Don't pass onWelcomeSubmit - let App handle view switching internally
|
||||
// This avoids unmounting and remounting which causes the PromptInput flash
|
||||
const { waitUntilExit, unmount } = render(
|
||||
React.createElement(App, {
|
||||
view: "welcome",
|
||||
controller: ctx.controller,
|
||||
verbose: options.verbose,
|
||||
isRawModeSupported: checkRawModeSupport(),
|
||||
// No onWelcomeSubmit - App.handleInternalWelcomeSubmit will switch views
|
||||
onWelcomeExit: () => {
|
||||
unmount()
|
||||
},
|
||||
onComplete: () => {
|
||||
// Task completed successfully
|
||||
},
|
||||
onError: () => {
|
||||
taskError = true
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
try {
|
||||
await waitUntilExit()
|
||||
} catch {
|
||||
// App unmounted
|
||||
}
|
||||
|
||||
restoreConsole()
|
||||
|
||||
// Clean up
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
await ctx.controller.dispose()
|
||||
await ErrorService.get().dispose()
|
||||
|
||||
if (taskError) {
|
||||
exit(1)
|
||||
} else {
|
||||
exit(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("-i, --images <paths...>", "Image file paths to include with the task")
|
||||
.option("-v, --verbose", "Show verbose output")
|
||||
.option("-c, --cwd <path>", "Working directory")
|
||||
.option("--config <path>", "Configuration directory")
|
||||
.option("--thinking", "Enable extended thinking (1024 token budget)")
|
||||
.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
|
||||
}
|
||||
}
|
||||
|
||||
if (effectivePrompt) {
|
||||
await runTask(effectivePrompt, options)
|
||||
} else {
|
||||
// Show welcome prompt if no prompt given
|
||||
await showWelcome(options)
|
||||
}
|
||||
})
|
||||
|
||||
// Parse and run
|
||||
program.parse()
|
||||
@@ -0,0 +1,2 @@
|
||||
// Stub for react-devtools-core - not needed in CLI
|
||||
module.exports = {}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Console management for CLI
|
||||
*
|
||||
* Captures original console methods BEFORE any core modules are imported,
|
||||
* so CLI output works even when console.log is suppressed.
|
||||
*/
|
||||
|
||||
// Capture original console methods immediately
|
||||
export const originalConsoleLog = console.log.bind(console)
|
||||
export const originalConsoleError = console.error.bind(console)
|
||||
export const originalConsoleWarn = console.warn.bind(console)
|
||||
export const originalConsoleInfo = console.info.bind(console)
|
||||
export const originalConsoleDebug = console.debug.bind(console)
|
||||
|
||||
// Check for verbose flag early (before commander parses)
|
||||
const isVerbose = process.argv.includes("-v") || process.argv.includes("--verbose")
|
||||
|
||||
// Suppress console output unless verbose mode
|
||||
if (!isVerbose) {
|
||||
console.log = () => {}
|
||||
console.warn = () => {}
|
||||
console.error = () => {}
|
||||
console.debug = () => {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore original console methods (for cleanup)
|
||||
*/
|
||||
export function restoreConsole() {
|
||||
console.log = originalConsoleLog
|
||||
console.error = originalConsoleError
|
||||
console.warn = originalConsoleWarn
|
||||
console.info = originalConsoleInfo
|
||||
console.debug = originalConsoleDebug
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
import type { ClineMessage, ExtensionState } from "@shared/ExtensionMessage"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { colorize, formatMessage, formatState, formatTimestamp, Spinner, separator, style, taskHeader } from "./display"
|
||||
|
||||
describe("display", () => {
|
||||
describe("colorize", () => {
|
||||
it("should wrap text with color codes", () => {
|
||||
const result = colorize("test", "\x1b[31m")
|
||||
expect(result).toBe("\x1b[31mtest\x1b[0m")
|
||||
})
|
||||
|
||||
it("should combine multiple color codes", () => {
|
||||
const result = colorize("test", "\x1b[1m", "\x1b[31m")
|
||||
expect(result).toBe("\x1b[1m\x1b[31mtest\x1b[0m")
|
||||
})
|
||||
|
||||
it("should handle empty text", () => {
|
||||
const result = colorize("", "\x1b[31m")
|
||||
expect(result).toBe("\x1b[31m\x1b[0m")
|
||||
})
|
||||
})
|
||||
|
||||
describe("style helpers", () => {
|
||||
it("should apply bold style", () => {
|
||||
const result = style.bold("text")
|
||||
expect(result).toContain("text")
|
||||
expect(result).toContain("\x1b[1m")
|
||||
})
|
||||
|
||||
it("should apply dim style", () => {
|
||||
const result = style.dim("text")
|
||||
expect(result).toContain("text")
|
||||
expect(result).toContain("\x1b[2m")
|
||||
})
|
||||
|
||||
it("should apply error style", () => {
|
||||
const result = style.error("error message")
|
||||
expect(result).toContain("error message")
|
||||
expect(result).toContain("\x1b[31m") // red
|
||||
})
|
||||
|
||||
it("should apply success style", () => {
|
||||
const result = style.success("success")
|
||||
expect(result).toContain("success")
|
||||
expect(result).toContain("\x1b[32m") // green
|
||||
})
|
||||
|
||||
it("should apply info style", () => {
|
||||
const result = style.info("info")
|
||||
expect(result).toContain("info")
|
||||
expect(result).toContain("\x1b[36m") // cyan
|
||||
})
|
||||
|
||||
it("should apply warning style", () => {
|
||||
const result = style.warning("warning")
|
||||
expect(result).toContain("warning")
|
||||
expect(result).toContain("\x1b[33m") // yellow
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatTimestamp", () => {
|
||||
it("should format timestamp as HH:MM:SS", () => {
|
||||
// Create a known timestamp: Jan 1, 2024 15:30:45 UTC
|
||||
const ts = new Date("2024-01-01T15:30:45Z").getTime()
|
||||
const result = formatTimestamp(ts)
|
||||
// Result depends on local timezone, but should be HH:MM:SS format
|
||||
expect(result).toMatch(/^\d{2}:\d{2}:\d{2}$/)
|
||||
})
|
||||
|
||||
it("should handle zero timestamp", () => {
|
||||
const result = formatTimestamp(0)
|
||||
expect(result).toMatch(/^\d{2}:\d{2}:\d{2}$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatMessage", () => {
|
||||
const createMessage = (overrides: Partial<ClineMessage>): ClineMessage =>
|
||||
({
|
||||
ts: Date.now(),
|
||||
type: "say",
|
||||
say: "text",
|
||||
text: "test message",
|
||||
...overrides,
|
||||
}) as ClineMessage
|
||||
|
||||
describe("say messages", () => {
|
||||
it("should format text message", () => {
|
||||
const message = createMessage({ say: "text", text: "Hello world" })
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("Hello world")
|
||||
})
|
||||
|
||||
it("should format task message", () => {
|
||||
const message = createMessage({ say: "task", text: "New task" })
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("Task:")
|
||||
expect(result).toContain("New task")
|
||||
})
|
||||
|
||||
it("should format error message", () => {
|
||||
const message = createMessage({ say: "error", text: "Something went wrong" })
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("Error:")
|
||||
expect(result).toContain("Something went wrong")
|
||||
})
|
||||
|
||||
it("should format completion_result message", () => {
|
||||
const message = createMessage({ say: "completion_result", text: "Done!" })
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("Completed:")
|
||||
})
|
||||
|
||||
it("should format reasoning message", () => {
|
||||
const message = createMessage({ say: "reasoning", text: "Let me think..." })
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("Thinking:")
|
||||
expect(result).toContain("Let me think...")
|
||||
})
|
||||
|
||||
it("should format command message", () => {
|
||||
const message = createMessage({ say: "command", text: "npm install" })
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("Command:")
|
||||
expect(result).toContain("npm install")
|
||||
})
|
||||
|
||||
it("should truncate long command output", () => {
|
||||
const longOutput = "x".repeat(600)
|
||||
const message = createMessage({ say: "command_output", text: longOutput })
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("Output:")
|
||||
expect(result).toContain("...")
|
||||
expect(result.length).toBeLessThan(longOutput.length + 100)
|
||||
})
|
||||
|
||||
it("should format user_feedback message", () => {
|
||||
const message = createMessage({ say: "user_feedback", text: "User said something" })
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("User:")
|
||||
})
|
||||
|
||||
it("should format tool message", () => {
|
||||
const message = createMessage({ say: "tool", text: "read_file" })
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("Tool:")
|
||||
})
|
||||
|
||||
it("should format browser_action message", () => {
|
||||
const message = createMessage({ say: "browser_action", text: "click button" })
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("Browser:")
|
||||
})
|
||||
|
||||
it("should format api_req_started in verbose mode", () => {
|
||||
const message = createMessage({ say: "api_req_started", text: "" })
|
||||
const result = formatMessage(message, true)
|
||||
expect(result).toContain("API request started")
|
||||
})
|
||||
|
||||
it("should format checkpoint_created message", () => {
|
||||
const message = createMessage({ say: "checkpoint_created", text: "Saved" })
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("Checkpoint created")
|
||||
})
|
||||
|
||||
it("should format info message", () => {
|
||||
const message = createMessage({ say: "info", text: "Information" })
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("Information")
|
||||
})
|
||||
|
||||
it("should show unknown say types in verbose mode", () => {
|
||||
const message = createMessage({ say: "unknown_type" as any, text: "test" })
|
||||
const resultNormal = formatMessage(message, false)
|
||||
const resultVerbose = formatMessage(message, true)
|
||||
expect(resultNormal).toBe("")
|
||||
expect(resultVerbose).toContain("[SAY:unknown_type]")
|
||||
})
|
||||
})
|
||||
|
||||
describe("ask messages", () => {
|
||||
it("should format followup question", () => {
|
||||
const message = createMessage({
|
||||
type: "ask",
|
||||
ask: "followup",
|
||||
text: JSON.stringify({ question: "What do you want?" }),
|
||||
})
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("Question:")
|
||||
expect(result).toContain("What do you want?")
|
||||
})
|
||||
|
||||
it("should handle non-JSON followup text", () => {
|
||||
const message = createMessage({
|
||||
type: "ask",
|
||||
ask: "followup",
|
||||
text: "Plain text question",
|
||||
})
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("Plain text question")
|
||||
})
|
||||
|
||||
it("should format command ask", () => {
|
||||
const message = createMessage({
|
||||
type: "ask",
|
||||
ask: "command",
|
||||
text: "rm -rf /",
|
||||
})
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("Execute command?")
|
||||
expect(result).toContain("rm -rf /")
|
||||
})
|
||||
|
||||
it("should format tool ask", () => {
|
||||
const message = createMessage({
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
text: "write_to_file",
|
||||
})
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("Use tool?")
|
||||
})
|
||||
|
||||
it("should format completion_result ask", () => {
|
||||
const message = createMessage({
|
||||
type: "ask",
|
||||
ask: "completion_result",
|
||||
text: "Task completed successfully",
|
||||
})
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("Task completed")
|
||||
})
|
||||
|
||||
it("should format api_req_failed ask", () => {
|
||||
const message = createMessage({
|
||||
type: "ask",
|
||||
ask: "api_req_failed",
|
||||
text: "Rate limit exceeded",
|
||||
})
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("API request failed")
|
||||
expect(result).toContain("Rate limit exceeded")
|
||||
})
|
||||
|
||||
it("should format resume_task ask", () => {
|
||||
const message = createMessage({
|
||||
type: "ask",
|
||||
ask: "resume_task",
|
||||
text: "",
|
||||
})
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("Resume task?")
|
||||
})
|
||||
|
||||
it("should format browser_action_launch ask", () => {
|
||||
const message = createMessage({
|
||||
type: "ask",
|
||||
ask: "browser_action_launch",
|
||||
text: "https://example.com",
|
||||
})
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("Launch browser?")
|
||||
})
|
||||
|
||||
it("should format use_mcp_server ask", () => {
|
||||
const message = createMessage({
|
||||
type: "ask",
|
||||
ask: "use_mcp_server",
|
||||
text: "server-name",
|
||||
})
|
||||
const result = formatMessage(message)
|
||||
expect(result).toContain("Use MCP server?")
|
||||
})
|
||||
|
||||
it("should show unknown ask types in verbose mode", () => {
|
||||
const message = createMessage({
|
||||
type: "ask",
|
||||
ask: "unknown_ask" as any,
|
||||
text: "test",
|
||||
})
|
||||
const resultNormal = formatMessage(message, false)
|
||||
const resultVerbose = formatMessage(message, true)
|
||||
expect(resultNormal).toBe("")
|
||||
expect(resultVerbose).toContain("[ASK:unknown_ask]")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("separator", () => {
|
||||
it("should create a separator with default char and width", () => {
|
||||
const result = separator()
|
||||
expect(result).toContain("─".repeat(60))
|
||||
})
|
||||
|
||||
it("should use custom character", () => {
|
||||
const result = separator("=", 10)
|
||||
expect(result).toContain("=".repeat(10))
|
||||
})
|
||||
|
||||
it("should use custom width", () => {
|
||||
const result = separator("-", 20)
|
||||
expect(result).toContain("-".repeat(20))
|
||||
})
|
||||
})
|
||||
|
||||
describe("taskHeader", () => {
|
||||
it("should format task header with ID", () => {
|
||||
const result = taskHeader("task-123")
|
||||
expect(result).toContain("Task: task-123")
|
||||
})
|
||||
|
||||
it("should include task description", () => {
|
||||
const result = taskHeader("task-123", "Build a website")
|
||||
expect(result).toContain("task-123")
|
||||
expect(result).toContain("Build a website")
|
||||
})
|
||||
|
||||
it("should truncate long task descriptions", () => {
|
||||
const longTask = "x".repeat(100)
|
||||
const result = taskHeader("task-123", longTask)
|
||||
expect(result).toContain("...")
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatState", () => {
|
||||
it("should format state with messages", () => {
|
||||
const state: Partial<ExtensionState> = {
|
||||
clineMessages: [{ ts: Date.now(), type: "say", say: "text", text: "Hello" } as ClineMessage],
|
||||
}
|
||||
const result = formatState(state as ExtensionState)
|
||||
expect(result).toContain("Hello")
|
||||
})
|
||||
|
||||
it("should include task header when currentTaskItem exists", () => {
|
||||
const state: Partial<ExtensionState> = {
|
||||
currentTaskItem: {
|
||||
id: "task-1",
|
||||
ts: Date.now(),
|
||||
task: "Do something",
|
||||
tokensIn: 10,
|
||||
tokensOut: 20,
|
||||
modelId: "gpt-4",
|
||||
totalCost: 0.0025,
|
||||
},
|
||||
clineMessages: [],
|
||||
}
|
||||
const result = formatState(state as ExtensionState)
|
||||
expect(result).toContain("Task: task-1")
|
||||
})
|
||||
|
||||
it("should handle empty messages array", () => {
|
||||
const state: Partial<ExtensionState> = {
|
||||
clineMessages: [],
|
||||
}
|
||||
const result = formatState(state as ExtensionState)
|
||||
expect(result).toBe("")
|
||||
})
|
||||
|
||||
it("should handle undefined messages", () => {
|
||||
const state: Partial<ExtensionState> = {}
|
||||
const result = formatState(state as ExtensionState)
|
||||
expect(result).toBe("")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Spinner", () => {
|
||||
let spinner: Spinner
|
||||
let writeSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
spinner = new Spinner()
|
||||
writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true)
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
spinner.stop()
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("should start spinning with message", () => {
|
||||
spinner.start("Loading...")
|
||||
vi.advanceTimersByTime(80)
|
||||
expect(writeSpy).toHaveBeenCalled()
|
||||
const calls = writeSpy.mock.calls.map((c: any[]) => c[0])
|
||||
expect(calls.some((c: any) => typeof c === "string" && c.includes("Loading..."))).toBe(true)
|
||||
})
|
||||
|
||||
it("should update message", () => {
|
||||
spinner.start("Initial")
|
||||
spinner.update("Updated")
|
||||
vi.advanceTimersByTime(80)
|
||||
const calls = writeSpy.mock.calls.map((c: any[]) => c[0])
|
||||
expect(calls.some((c: any) => typeof c === "string" && c.includes("Updated"))).toBe(true)
|
||||
})
|
||||
|
||||
it("should stop with final message", () => {
|
||||
spinner.start("Loading...")
|
||||
spinner.stop("Done!")
|
||||
const calls = writeSpy.mock.calls.map((c: any[]) => c[0])
|
||||
expect(calls.some((c: any) => typeof c === "string" && c.includes("Done!"))).toBe(true)
|
||||
})
|
||||
|
||||
it("should clear line when stopped without message", () => {
|
||||
spinner.start("Loading...")
|
||||
spinner.stop()
|
||||
expect(writeSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should show failure message", () => {
|
||||
spinner.start("Loading...")
|
||||
spinner.fail("Failed!")
|
||||
const calls = writeSpy.mock.calls.map((c: any[]) => c[0])
|
||||
expect(calls.some((c: any) => typeof c === "string" && c.includes("Failed!"))).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,460 @@
|
||||
/**
|
||||
* Terminal display utilities for rendering Cline messages in the CLI
|
||||
*/
|
||||
|
||||
import type { ClineAsk, ClineMessage, ClineSay, ExtensionState } from "@shared/ExtensionMessage"
|
||||
import { originalConsoleError, originalConsoleLog } from "./console"
|
||||
|
||||
// ANSI color codes for terminal output
|
||||
const colors = {
|
||||
reset: "\x1b[0m",
|
||||
bold: "\x1b[1m",
|
||||
dim: "\x1b[2m",
|
||||
italic: "\x1b[3m",
|
||||
underline: "\x1b[4m",
|
||||
|
||||
// Foreground colors
|
||||
black: "\x1b[30m",
|
||||
red: "\x1b[31m",
|
||||
green: "\x1b[32m",
|
||||
yellow: "\x1b[33m",
|
||||
blue: "\x1b[34m",
|
||||
magenta: "\x1b[35m",
|
||||
cyan: "\x1b[36m",
|
||||
white: "\x1b[37m",
|
||||
|
||||
// Bright foreground colors
|
||||
brightBlack: "\x1b[90m",
|
||||
brightRed: "\x1b[91m",
|
||||
brightGreen: "\x1b[92m",
|
||||
brightYellow: "\x1b[93m",
|
||||
brightBlue: "\x1b[94m",
|
||||
brightMagenta: "\x1b[95m",
|
||||
brightCyan: "\x1b[96m",
|
||||
brightWhite: "\x1b[97m",
|
||||
|
||||
// Background colors
|
||||
bgBlack: "\x1b[40m",
|
||||
bgRed: "\x1b[41m",
|
||||
bgGreen: "\x1b[42m",
|
||||
bgYellow: "\x1b[43m",
|
||||
bgBlue: "\x1b[44m",
|
||||
bgMagenta: "\x1b[45m",
|
||||
bgCyan: "\x1b[46m",
|
||||
bgWhite: "\x1b[47m",
|
||||
}
|
||||
|
||||
export function colorize(text: string, ...colorCodes: string[]): string {
|
||||
return colorCodes.join("") + text + colors.reset
|
||||
}
|
||||
|
||||
// Helper functions for common color combinations
|
||||
export const style = {
|
||||
bold: (text: string) => colorize(text, colors.bold),
|
||||
dim: (text: string) => colorize(text, colors.dim),
|
||||
italic: (text: string) => colorize(text, colors.italic),
|
||||
|
||||
error: (text: string) => colorize(text, colors.red, colors.bold),
|
||||
warning: (text: string) => colorize(text, colors.yellow),
|
||||
success: (text: string) => colorize(text, colors.green),
|
||||
info: (text: string) => colorize(text, colors.cyan),
|
||||
|
||||
// Message type colors
|
||||
task: (text: string) => colorize(text, colors.brightWhite, colors.bold),
|
||||
tool: (text: string) => colorize(text, colors.blue),
|
||||
command: (text: string) => colorize(text, colors.magenta),
|
||||
api: (text: string) => colorize(text, colors.brightBlack),
|
||||
user: (text: string) => colorize(text, colors.green),
|
||||
assistant: (text: string) => colorize(text, colors.cyan),
|
||||
|
||||
// Special formatting
|
||||
path: (text: string) => colorize(text, colors.underline, colors.blue),
|
||||
code: (text: string) => colorize(text, colors.bgBlack, colors.brightWhite),
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a timestamp for display
|
||||
*/
|
||||
export function formatTimestamp(ts: number): string {
|
||||
const date = new Date(ts)
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a prefix icon for different message types
|
||||
*/
|
||||
function getMessageIcon(message: ClineMessage): string {
|
||||
if (message.type === "ask") {
|
||||
switch (message.ask) {
|
||||
case "followup":
|
||||
return "❓"
|
||||
case "command":
|
||||
case "command_output":
|
||||
return "⚙️ "
|
||||
case "tool":
|
||||
return "🔧"
|
||||
case "completion_result":
|
||||
return "✅"
|
||||
case "api_req_failed":
|
||||
return "❌"
|
||||
case "resume_task":
|
||||
case "resume_completed_task":
|
||||
return "▶️ "
|
||||
case "browser_action_launch":
|
||||
return "🌐"
|
||||
case "use_mcp_server":
|
||||
return "🔌"
|
||||
default:
|
||||
return "❔"
|
||||
}
|
||||
} else {
|
||||
switch (message.say) {
|
||||
case "task":
|
||||
return "📋"
|
||||
case "error":
|
||||
return "❌"
|
||||
case "text":
|
||||
return "💬"
|
||||
case "reasoning":
|
||||
return "🧠"
|
||||
case "completion_result":
|
||||
return "✅"
|
||||
case "user_feedback":
|
||||
return "👤"
|
||||
case "command":
|
||||
case "command_output":
|
||||
return "⚙️ "
|
||||
case "tool":
|
||||
return "🔧"
|
||||
case "browser_action":
|
||||
case "browser_action_launch":
|
||||
case "browser_action_result":
|
||||
return "🌐"
|
||||
case "mcp_server_request_started":
|
||||
case "mcp_server_response":
|
||||
return "🔌"
|
||||
case "api_req_started":
|
||||
case "api_req_finished":
|
||||
return "🔄"
|
||||
case "checkpoint_created":
|
||||
return "💾"
|
||||
case "info":
|
||||
return "ℹ️ "
|
||||
default:
|
||||
return " "
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a ClineMessage for terminal display
|
||||
*/
|
||||
export function formatMessage(message: ClineMessage, verbose: boolean = false): string {
|
||||
const icon = getMessageIcon(message)
|
||||
const timestamp = formatTimestamp(message.ts)
|
||||
const lines: string[] = []
|
||||
|
||||
const prefix = `${style.dim(timestamp)} ${icon}`
|
||||
|
||||
if (message.type === "ask") {
|
||||
lines.push(formatAskMessage(message, prefix, verbose))
|
||||
} else {
|
||||
lines.push(formatSayMessage(message, prefix, verbose))
|
||||
}
|
||||
|
||||
return lines.filter(Boolean).join("\n")
|
||||
}
|
||||
|
||||
function formatAskMessage(message: ClineMessage, prefix: string, verbose: boolean): string {
|
||||
const ask = message.ask as ClineAsk
|
||||
|
||||
switch (ask) {
|
||||
case "followup": {
|
||||
// Parse JSON question format
|
||||
let question = message.text || ""
|
||||
try {
|
||||
const parsed = JSON.parse(message.text || "{}")
|
||||
question = parsed.question || question
|
||||
} catch {
|
||||
// Fallback to raw text if not JSON
|
||||
question = message.text || ""
|
||||
}
|
||||
return `${prefix} ${style.info("Question:")} ${question}`
|
||||
}
|
||||
|
||||
case "command":
|
||||
return `${prefix} ${style.command("Execute command?")} ${style.code(message.text || "")}`
|
||||
|
||||
case "tool":
|
||||
return `${prefix} ${style.tool("Use tool?")} ${message.text || ""}`
|
||||
|
||||
case "completion_result":
|
||||
return `${prefix} ${style.success("Task completed")} ${message.text ? `- ${message.text}` : ""}`
|
||||
|
||||
case "api_req_failed":
|
||||
return `${prefix} ${style.error("API request failed")} ${message.text || ""}`
|
||||
|
||||
case "resume_task":
|
||||
case "resume_completed_task":
|
||||
return `${prefix} ${style.info("Resume task?")} ${message.text || ""}`
|
||||
|
||||
case "browser_action_launch":
|
||||
return `${prefix} ${style.info("Launch browser?")} ${message.text || ""}`
|
||||
|
||||
case "use_mcp_server":
|
||||
return `${prefix} ${style.info("Use MCP server?")} ${message.text || ""}`
|
||||
|
||||
case "plan_mode_respond":
|
||||
return `${prefix} ${style.info("Plan mode response:")} ${message.text || ""}`
|
||||
|
||||
default:
|
||||
return verbose ? `${prefix} [ASK:${ask}] ${message.text || ""}` : ""
|
||||
}
|
||||
}
|
||||
|
||||
function formatSayMessage(message: ClineMessage, prefix: string, verbose: boolean): string {
|
||||
const say = message.say as ClineSay
|
||||
|
||||
switch (say) {
|
||||
case "task":
|
||||
return `${prefix} ${style.task("Task:")} ${message.text || ""}`
|
||||
|
||||
case "text":
|
||||
return `${prefix} ${style.assistant(message.text || "")}`
|
||||
|
||||
case "reasoning":
|
||||
return `${prefix} ${style.dim("Thinking:")} ${style.italic(message.text || "")}`
|
||||
|
||||
case "error":
|
||||
return `${prefix} ${style.error("Error:")} ${message.text || ""}`
|
||||
|
||||
case "completion_result":
|
||||
return `${prefix} ${style.success("✓ Completed:")} ${message.text || ""}`
|
||||
|
||||
case "user_feedback":
|
||||
return `${prefix} ${style.user("User:")} ${message.text || ""}`
|
||||
|
||||
case "command":
|
||||
return `${prefix} ${style.command("Command:")} ${style.code(message.text || "")}`
|
||||
|
||||
case "command_output":
|
||||
const output = message.text || ""
|
||||
const truncated = output.length > 500 ? output.substring(0, 500) + "..." : output
|
||||
return `${prefix} ${style.dim("Output:")} ${truncated}`
|
||||
|
||||
case "tool":
|
||||
return `${prefix} ${style.tool("Tool:")} ${message.text || ""}`
|
||||
|
||||
case "browser_action":
|
||||
case "browser_action_launch":
|
||||
return `${prefix} ${style.info("Browser:")} ${message.text || ""}`
|
||||
|
||||
case "browser_action_result":
|
||||
return `${prefix} ${style.dim("Browser result")} ${message.text ? `- ${message.text.substring(0, 100)}...` : ""}`
|
||||
|
||||
case "mcp_server_request_started":
|
||||
return `${prefix} ${style.info("MCP request started")} ${message.text || ""}`
|
||||
|
||||
case "mcp_server_response":
|
||||
return `${prefix} ${style.info("MCP response")} ${message.text ? message.text.substring(0, 200) : ""}`
|
||||
|
||||
case "api_req_started":
|
||||
return verbose ? `${prefix} ${style.api("API request started")}` : `${message.text || ""}`
|
||||
|
||||
case "api_req_finished":
|
||||
return verbose ? `${prefix} ${style.api("API request finished")}` : ""
|
||||
|
||||
case "checkpoint_created":
|
||||
return `${prefix} ${style.success("Checkpoint created")} ${message.text || ""}`
|
||||
|
||||
case "info":
|
||||
return `${prefix} ${style.info(message.text || "")}`
|
||||
|
||||
case "hook_status":
|
||||
return `${prefix} ${style.dim("Hook:")} ${message.text || ""}`
|
||||
|
||||
case "task_progress":
|
||||
return `${prefix} ${style.info("Progress:")} ${message.text || ""}`
|
||||
|
||||
default:
|
||||
return verbose ? `${prefix} [SAY:${say}] ${message.text || ""}` : ""
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a horizontal separator
|
||||
*/
|
||||
export function separator(char: string = "─", width: number = 60): string {
|
||||
return style.dim(char.repeat(width))
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the task header
|
||||
*/
|
||||
export function taskHeader(taskId: string, task?: string): string {
|
||||
const lines = [
|
||||
separator("═"),
|
||||
style.bold(` Task: ${taskId}`),
|
||||
task ? ` ${style.dim(task.substring(0, 80))}${task.length > 80 ? "..." : ""}` : "",
|
||||
separator("═"),
|
||||
]
|
||||
return lines.filter(Boolean).join("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the current state for display
|
||||
*/
|
||||
export function formatState(state: ExtensionState, verbose: boolean = false): string {
|
||||
const lines: string[] = []
|
||||
|
||||
if (state.currentTaskItem) {
|
||||
lines.push(taskHeader(state.currentTaskItem.id, state.currentTaskItem.task))
|
||||
}
|
||||
|
||||
// Show messages
|
||||
if (state.clineMessages && state.clineMessages.length > 0) {
|
||||
const messagesToShow = verbose
|
||||
? state.clineMessages
|
||||
: state.clineMessages.filter((m) => {
|
||||
// Filter out noisy messages in non-verbose mode
|
||||
// if (m.say === "api_req_started" || m.say === "api_req_finished") return false
|
||||
return true
|
||||
})
|
||||
|
||||
for (const message of messagesToShow) {
|
||||
const formatted = formatMessage(message, verbose)
|
||||
if (formatted) {
|
||||
lines.push(formatted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a spinner with message
|
||||
*/
|
||||
export class Spinner {
|
||||
private frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
||||
private frameIndex = 0
|
||||
private interval: NodeJS.Timeout | null = null
|
||||
private message: string = ""
|
||||
|
||||
start(message: string) {
|
||||
this.message = message
|
||||
this.interval = setInterval(() => {
|
||||
const frame = this.frames[this.frameIndex]
|
||||
process.stdout.write(`\r${style.info(frame)} ${this.message}`)
|
||||
this.frameIndex = (this.frameIndex + 1) % this.frames.length
|
||||
}, 80)
|
||||
}
|
||||
|
||||
update(message: string) {
|
||||
this.message = message
|
||||
}
|
||||
|
||||
stop(finalMessage?: string) {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval)
|
||||
this.interval = null
|
||||
}
|
||||
if (finalMessage) {
|
||||
process.stdout.write(`\r${style.success("✓")} ${finalMessage}\n`)
|
||||
} else {
|
||||
process.stdout.write("\r" + " ".repeat(this.message.length + 4) + "\r")
|
||||
}
|
||||
}
|
||||
|
||||
fail(message?: string) {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval)
|
||||
this.interval = null
|
||||
}
|
||||
if (message) {
|
||||
process.stdout.write(`\r${style.error("✗")} ${message}\n`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current line
|
||||
*/
|
||||
export function clearLine() {
|
||||
process.stdout.write("\r\x1b[K")
|
||||
}
|
||||
|
||||
/**
|
||||
* Move cursor up n lines
|
||||
*/
|
||||
export function cursorUp(n: number = 1) {
|
||||
process.stdout.write(`\x1b[${n}A`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a message to stdout with newline
|
||||
* Uses original console.log to work even when console is suppressed
|
||||
*/
|
||||
export function print(message: string) {
|
||||
originalConsoleLog(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Print an error message to stderr
|
||||
* Uses original console.error to work even when console is suppressed
|
||||
*/
|
||||
export function printError(message: string) {
|
||||
originalConsoleError(style.error(message))
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a success message
|
||||
*/
|
||||
export function printSuccess(message: string) {
|
||||
originalConsoleLog(style.success(message))
|
||||
}
|
||||
|
||||
/**
|
||||
* Print an info message
|
||||
*/
|
||||
export function printInfo(message: string) {
|
||||
originalConsoleLog(style.info(message))
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a warning message
|
||||
*/
|
||||
export function printWarning(message: string) {
|
||||
originalConsoleLog(style.warning(message))
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt user for input from stdin
|
||||
*/
|
||||
export async function promptUser(question: string): Promise<string> {
|
||||
const readline = await import("readline")
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
})
|
||||
|
||||
return new Promise((resolve) => {
|
||||
rl.question(style.info(question) + " ", (answer: string) => {
|
||||
rl.close()
|
||||
resolve(answer.trim())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt user for yes/no confirmation
|
||||
*/
|
||||
export async function promptConfirmation(question: string): Promise<boolean> {
|
||||
const answer = await promptUser(`${question} ${style.dim("(y/n)")}`)
|
||||
return answer.toLowerCase() === "y" || answer.toLowerCase() === "yes"
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* 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
|
||||
const mention = filePath.includes(" ") ? `@"${filePath}"` : `@${filePath}`
|
||||
return text.slice(0, atIndex) + mention + " " + text.slice(end).trimStart()
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import fs from "node:fs"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { imageFileToDataUrl, isImagePath, jsonParseSafe, parseImagesFromInput, processImagePaths } from "./parser"
|
||||
|
||||
describe("parser", () => {
|
||||
describe("jsonParseSafe", () => {
|
||||
it("should parse valid JSON", () => {
|
||||
const result = jsonParseSafe('{"key": "value"}', {})
|
||||
expect(result).toEqual({ key: "value" })
|
||||
})
|
||||
|
||||
it("should return default value for invalid JSON", () => {
|
||||
const defaultValue = { fallback: true }
|
||||
const result = jsonParseSafe("not valid json", defaultValue)
|
||||
expect(result).toEqual(defaultValue)
|
||||
})
|
||||
|
||||
it("should parse arrays", () => {
|
||||
const result = jsonParseSafe("[1, 2, 3]", [])
|
||||
expect(result).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it("should handle empty string", () => {
|
||||
const result = jsonParseSafe("", "default")
|
||||
expect(result).toBe("default")
|
||||
})
|
||||
|
||||
it("should parse nested objects", () => {
|
||||
const json = '{"outer": {"inner": "value"}}'
|
||||
const result = jsonParseSafe(json, {})
|
||||
expect(result).toEqual({ outer: { inner: "value" } })
|
||||
})
|
||||
})
|
||||
|
||||
describe("isImagePath", () => {
|
||||
it("should return true for .png files", () => {
|
||||
expect(isImagePath("/path/to/image.png")).toBe(true)
|
||||
})
|
||||
|
||||
it("should return true for .jpg files", () => {
|
||||
expect(isImagePath("/path/to/image.jpg")).toBe(true)
|
||||
})
|
||||
|
||||
it("should return true for .jpeg files", () => {
|
||||
expect(isImagePath("/path/to/image.jpeg")).toBe(true)
|
||||
})
|
||||
|
||||
it("should return true for .gif files", () => {
|
||||
expect(isImagePath("/path/to/image.gif")).toBe(true)
|
||||
})
|
||||
|
||||
it("should return true for .webp files", () => {
|
||||
expect(isImagePath("/path/to/image.webp")).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false for non-image files", () => {
|
||||
expect(isImagePath("/path/to/file.txt")).toBe(false)
|
||||
expect(isImagePath("/path/to/file.pdf")).toBe(false)
|
||||
expect(isImagePath("/path/to/file.js")).toBe(false)
|
||||
})
|
||||
|
||||
it("should handle uppercase extensions", () => {
|
||||
expect(isImagePath("/path/to/image.PNG")).toBe(true)
|
||||
expect(isImagePath("/path/to/image.JPG")).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle mixed case extensions", () => {
|
||||
expect(isImagePath("/path/to/image.Png")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseImagesFromInput", () => {
|
||||
it("should extract image paths with @ prefix", () => {
|
||||
const input = "analyze this image @/path/to/image.png"
|
||||
const result = parseImagesFromInput(input)
|
||||
expect(result.imagePaths).toContain("/path/to/image.png")
|
||||
expect(result.prompt).toBe("analyze this image")
|
||||
})
|
||||
|
||||
it("should extract multiple images", () => {
|
||||
const input = "compare @/img1.png and @/img2.jpg"
|
||||
const result = parseImagesFromInput(input)
|
||||
expect(result.imagePaths).toContain("/img1.png")
|
||||
expect(result.imagePaths).toContain("/img2.jpg")
|
||||
})
|
||||
|
||||
it("should handle standalone image paths", () => {
|
||||
const input = "look at /path/to/image.png please"
|
||||
const result = parseImagesFromInput(input)
|
||||
expect(result.imagePaths).toContain("/path/to/image.png")
|
||||
})
|
||||
|
||||
it("should return empty array when no images", () => {
|
||||
const input = "just some text without images"
|
||||
const result = parseImagesFromInput(input)
|
||||
expect(result.imagePaths).toEqual([])
|
||||
expect(result.prompt).toBe("just some text without images")
|
||||
})
|
||||
|
||||
it("should handle image at start of input", () => {
|
||||
const input = "@/start.png is the image"
|
||||
const result = parseImagesFromInput(input)
|
||||
expect(result.imagePaths).toContain("/start.png")
|
||||
})
|
||||
|
||||
it("should handle all supported image extensions", () => {
|
||||
const input = "@/a.png @/b.jpg @/c.jpeg @/d.gif @/e.webp"
|
||||
const result = parseImagesFromInput(input)
|
||||
expect(result.imagePaths).toHaveLength(5)
|
||||
})
|
||||
|
||||
it("should not duplicate image paths", () => {
|
||||
const input = "@/same.png /same.png"
|
||||
const result = parseImagesFromInput(input)
|
||||
// Both patterns match the same path, should not duplicate
|
||||
expect(result.imagePaths.filter((p) => p === "/same.png").length).toBeLessThanOrEqual(2)
|
||||
})
|
||||
|
||||
it("should clean up extra whitespace in prompt", () => {
|
||||
const input = "text @/image.png more text"
|
||||
const result = parseImagesFromInput(input)
|
||||
expect(result.prompt).toBe("text more text")
|
||||
})
|
||||
})
|
||||
|
||||
describe("imageFileToDataUrl", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(fs.promises, "readFile")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("should convert png to data URL", async () => {
|
||||
const mockBuffer = Buffer.from("fake png data")
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(mockBuffer)
|
||||
|
||||
const result = await imageFileToDataUrl("/path/to/image.png")
|
||||
|
||||
expect(result).toMatch(/^data:image\/png;base64,/)
|
||||
expect(result).toContain(mockBuffer.toString("base64"))
|
||||
})
|
||||
|
||||
it("should use correct MIME type for jpeg", async () => {
|
||||
const mockBuffer = Buffer.from("fake jpeg data")
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(mockBuffer)
|
||||
|
||||
const result = await imageFileToDataUrl("/path/to/image.jpg")
|
||||
|
||||
expect(result).toMatch(/^data:image\/jpeg;base64,/)
|
||||
})
|
||||
|
||||
it("should use correct MIME type for gif", async () => {
|
||||
const mockBuffer = Buffer.from("fake gif data")
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(mockBuffer)
|
||||
|
||||
const result = await imageFileToDataUrl("/path/to/image.gif")
|
||||
|
||||
expect(result).toMatch(/^data:image\/gif;base64,/)
|
||||
})
|
||||
|
||||
it("should use correct MIME type for webp", async () => {
|
||||
const mockBuffer = Buffer.from("fake webp data")
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(mockBuffer)
|
||||
|
||||
const result = await imageFileToDataUrl("/path/to/image.webp")
|
||||
|
||||
expect(result).toMatch(/^data:image\/webp;base64,/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("processImagePaths", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(fs, "existsSync")
|
||||
vi.spyOn(fs.promises, "readFile")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("should process existing image files", async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true)
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(Buffer.from("image data"))
|
||||
|
||||
const result = await processImagePaths(["/path/to/image.png"])
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toMatch(/^data:image\/png;base64,/)
|
||||
})
|
||||
|
||||
it("should skip non-existent files", async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false)
|
||||
|
||||
const result = await processImagePaths(["/nonexistent/image.png"])
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should skip non-image files", async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true)
|
||||
|
||||
const result = await processImagePaths(["/path/to/file.txt"])
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should process multiple images", async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true)
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(Buffer.from("image data"))
|
||||
|
||||
const result = await processImagePaths(["/img1.png", "/img2.jpg", "/img3.gif"])
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
})
|
||||
|
||||
it("should handle read errors gracefully", async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true)
|
||||
vi.mocked(fs.promises.readFile).mockRejectedValue(new Error("Read error"))
|
||||
|
||||
const result = await processImagePaths(["/path/to/image.png"])
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should handle empty input", async () => {
|
||||
const result = await processImagePaths([])
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
|
||||
export function jsonParseSafe<T>(data: string, defaultValue: T): T {
|
||||
try {
|
||||
return JSON.parse(data) as T
|
||||
} catch {
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"])
|
||||
|
||||
/**
|
||||
* Check if a file path is an image based on extension
|
||||
*/
|
||||
export function isImagePath(filePath: string): boolean {
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
return IMAGE_EXTENSIONS.has(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MIME type for an image extension
|
||||
*/
|
||||
function getMimeType(ext: string): string {
|
||||
const mimeTypes: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
}
|
||||
return mimeTypes[ext.toLowerCase()] || "image/png"
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an image file path to a base64 data URL
|
||||
*/
|
||||
export async function imageFileToDataUrl(filePath: string): Promise<string> {
|
||||
const resolvedPath = path.resolve(filePath)
|
||||
const ext = path.extname(resolvedPath).toLowerCase()
|
||||
const mimeType = getMimeType(ext)
|
||||
|
||||
const buffer = await fs.promises.readFile(resolvedPath)
|
||||
const base64 = buffer.toString("base64")
|
||||
|
||||
return `data:${mimeType};base64,${base64}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse input text and extract image file paths.
|
||||
* Supports formats like: "prompt text @/path/to/image.png" or just file paths
|
||||
* Returns the clean prompt text and array of image paths
|
||||
*/
|
||||
export function parseImagesFromInput(input: string): { prompt: string; imagePaths: string[] } {
|
||||
const imagePaths: string[] = []
|
||||
|
||||
// Match @/path/to/image.ext patterns (with space or at start)
|
||||
const atPathPattern = /(?:^|\s)@(\/[^\s]+\.(?:png|jpg|jpeg|gif|webp))/gi
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = atPathPattern.exec(input)) !== null) {
|
||||
imagePaths.push(match[1])
|
||||
}
|
||||
|
||||
// Also match standalone absolute paths that look like images
|
||||
const standalonePathPattern = /(?:^|\s)(\/[^\s]+\.(?:png|jpg|jpeg|gif|webp))(?:\s|$)/gi
|
||||
while ((match = standalonePathPattern.exec(input)) !== null) {
|
||||
const p = match[1]
|
||||
if (!imagePaths.includes(p)) {
|
||||
imagePaths.push(p)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the image references from the prompt
|
||||
const prompt = input.replace(atPathPattern, " ").replace(standalonePathPattern, " ").replace(/\s+/g, " ").trim()
|
||||
|
||||
return { prompt, imagePaths }
|
||||
}
|
||||
|
||||
/**
|
||||
* Process image file paths into base64 data URLs
|
||||
* Returns only successfully converted images
|
||||
*/
|
||||
export async function processImagePaths(imagePaths: string[]): Promise<string[]> {
|
||||
const dataUrls: string[] = []
|
||||
|
||||
for (const imagePath of imagePaths) {
|
||||
try {
|
||||
const resolvedPath = path.resolve(imagePath)
|
||||
if (fs.existsSync(resolvedPath) && isImagePath(resolvedPath)) {
|
||||
const dataUrl = await imageFileToDataUrl(resolvedPath)
|
||||
dataUrls.push(dataUrl)
|
||||
}
|
||||
} catch {
|
||||
// Skip files that can't be read
|
||||
}
|
||||
}
|
||||
|
||||
return dataUrls
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let data = ""
|
||||
process.stdin.setEncoding("utf8")
|
||||
|
||||
// Set a short timeout in case stdin is not actually providing data
|
||||
const timeout = setTimeout(() => {
|
||||
process.stdin.removeAllListeners()
|
||||
resolve(data.trim() || null)
|
||||
}, 100)
|
||||
|
||||
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,298 @@
|
||||
/**
|
||||
* VSCode context stub for CLI mode
|
||||
* Provides mock implementations of VSCode extension context
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import type { Memento, SecretStorage } from "vscode"
|
||||
import { ExtensionRegistryInfo } from "@/registry"
|
||||
import { ClineExtensionContext } from "@/shared/cline"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { ExtensionKind, ExtensionMode, URI } from "./vscode-shim"
|
||||
|
||||
const SETTINGS_SUBFOLDER = "data"
|
||||
|
||||
/**
|
||||
* CLI-specific state overrides.
|
||||
* These values are always returned regardless of what's stored,
|
||||
* and writes to these keys are silently ignored.
|
||||
*/
|
||||
const CLI_STATE_OVERRIDES: Record<string, any> = {
|
||||
// CLI always uses background execution, not VSCode terminal
|
||||
vscodeTerminalExecutionMode: "backgroundExec",
|
||||
backgroundEditEnabled: true,
|
||||
multiRootEnabled: false,
|
||||
enableCheckpointsSetting: false,
|
||||
browserSettings: {
|
||||
disableToolUse: true,
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple file-based Memento store for persisting state
|
||||
*/
|
||||
class MementoStore implements Memento {
|
||||
private data: Record<string, any> = {}
|
||||
private filePath: string
|
||||
|
||||
constructor(filePath: string) {
|
||||
this.filePath = filePath
|
||||
this.load()
|
||||
}
|
||||
|
||||
private load() {
|
||||
try {
|
||||
if (existsSync(this.filePath)) {
|
||||
const content = readFileSync(this.filePath, "utf8")
|
||||
this.data = JSON.parse(content)
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`Failed to load state from ${this.filePath}:`, error)
|
||||
this.data = {}
|
||||
}
|
||||
}
|
||||
|
||||
private save() {
|
||||
try {
|
||||
mkdirSync(path.dirname(this.filePath), { recursive: true })
|
||||
writeFileSync(this.filePath, JSON.stringify(this.data, null, 2))
|
||||
} catch (error) {
|
||||
Logger.error(`Failed to save state to ${this.filePath}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
keys(): readonly string[] {
|
||||
return Object.keys(this.data)
|
||||
}
|
||||
|
||||
get<T>(key: string): T | undefined
|
||||
get<T>(key: string, defaultValue: T): T
|
||||
get<T>(key: string, defaultValue?: T): T | undefined {
|
||||
// Return CLI overrides for locked keys
|
||||
if (key in CLI_STATE_OVERRIDES) {
|
||||
return CLI_STATE_OVERRIDES[key] as T
|
||||
}
|
||||
const value = this.data[key]
|
||||
return value !== undefined ? value : defaultValue
|
||||
}
|
||||
|
||||
async update(key: string, value: any): Promise<void> {
|
||||
// Silently ignore writes to CLI-locked keys
|
||||
if (key in CLI_STATE_OVERRIDES) {
|
||||
return
|
||||
}
|
||||
if (value === undefined) {
|
||||
delete this.data[key]
|
||||
} else {
|
||||
this.data[key] = value
|
||||
}
|
||||
this.save()
|
||||
}
|
||||
|
||||
setKeysForSync(_keys: readonly string[]): void {
|
||||
// No-op for CLI
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple file-based secret storage
|
||||
*/
|
||||
class SecretStore implements SecretStorage {
|
||||
private data: Record<string, string> = {}
|
||||
private filePath: string
|
||||
private onDidChangeEmitter = {
|
||||
event: () => ({ dispose: () => {} }),
|
||||
fire: (_e: any) => {},
|
||||
dispose: () => {},
|
||||
}
|
||||
|
||||
onDidChange = this.onDidChangeEmitter.event
|
||||
|
||||
constructor(filePath: string) {
|
||||
this.filePath = filePath
|
||||
this.load()
|
||||
}
|
||||
|
||||
private load() {
|
||||
try {
|
||||
if (existsSync(this.filePath)) {
|
||||
const content = readFileSync(this.filePath, "utf8")
|
||||
this.data = JSON.parse(content)
|
||||
}
|
||||
} catch {
|
||||
this.data = {}
|
||||
}
|
||||
}
|
||||
|
||||
private save() {
|
||||
try {
|
||||
mkdirSync(path.dirname(this.filePath), { recursive: true })
|
||||
writeFileSync(this.filePath, JSON.stringify(this.data, null, 2))
|
||||
} catch (error) {
|
||||
Logger.error(`Failed to save secrets:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
async get(key: string): Promise<string | undefined> {
|
||||
return this.data[key]
|
||||
}
|
||||
|
||||
async store(key: string, value: string): Promise<void> {
|
||||
this.data[key] = value
|
||||
this.save()
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
delete this.data[key]
|
||||
this.save()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock environment variable collection
|
||||
*/
|
||||
class EnvironmentVariableCollection {
|
||||
private variables: Map<string, any> = new Map()
|
||||
persistent = true
|
||||
description = "CLI Environment Variables"
|
||||
|
||||
entries(): IterableIterator<[string, any]> {
|
||||
return this.variables.entries()
|
||||
}
|
||||
|
||||
replace(variable: string, value: string) {
|
||||
this.variables.set(variable, { value, type: "replace" })
|
||||
}
|
||||
|
||||
append(variable: string, value: string) {
|
||||
this.variables.set(variable, { value, type: "append" })
|
||||
}
|
||||
|
||||
prepend(variable: string, value: string) {
|
||||
this.variables.set(variable, { value, type: "prepend" })
|
||||
}
|
||||
|
||||
get(variable: string) {
|
||||
return this.variables.get(variable)
|
||||
}
|
||||
|
||||
forEach(callback: (variable: string, mutator: any, collection: any) => void) {
|
||||
this.variables.forEach((mutator, variable) => {
|
||||
callback(variable, mutator, this)
|
||||
})
|
||||
}
|
||||
|
||||
delete(variable: string) {
|
||||
return this.variables.delete(variable)
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.variables.clear()
|
||||
}
|
||||
|
||||
getScoped(_scope: any) {
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
function readJson(filePath: string): any {
|
||||
try {
|
||||
if (existsSync(filePath)) {
|
||||
return JSON.parse(readFileSync(filePath, "utf8"))
|
||||
}
|
||||
} catch {
|
||||
// Return empty object if file doesn't exist
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
export interface CliContextConfig {
|
||||
clineDir?: string
|
||||
/** The workspace directory being worked in (for hashing into storage path) */
|
||||
workspaceDir?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a short hash of a string for use in directory names
|
||||
*/
|
||||
function hashString(str: string): string {
|
||||
let hash = 0
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i)
|
||||
hash = (hash << 5) - hash + char
|
||||
hash = hash & hash // Convert to 32bit integer
|
||||
}
|
||||
return Math.abs(hash).toString(16).substring(0, 8)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the VSCode-like context for CLI mode
|
||||
*/
|
||||
export function initializeCliContext(config: CliContextConfig = {}) {
|
||||
const CLINE_DIR = config.clineDir || process.env.CLINE_DIR || path.join(os.homedir(), ".cline")
|
||||
const DATA_DIR = path.join(CLINE_DIR, SETTINGS_SUBFOLDER)
|
||||
|
||||
// Workspace storage should always be under ~/.cline/data/workspaces/<hash>/
|
||||
// where hash is derived from the workspace path to keep workspaces isolated
|
||||
const workspacePath = config.workspaceDir || process.cwd()
|
||||
const workspaceHash = hashString(workspacePath)
|
||||
const WORKSPACE_STORAGE_DIR = process.env.WORKSPACE_STORAGE_DIR || path.join(DATA_DIR, "workspaces", workspaceHash)
|
||||
|
||||
// Ensure directories exist
|
||||
mkdirSync(DATA_DIR, { recursive: true })
|
||||
mkdirSync(WORKSPACE_STORAGE_DIR, { recursive: true })
|
||||
|
||||
// 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: new MementoStore(path.join(DATA_DIR, "globalState.json")),
|
||||
secrets: new SecretStore(path.join(DATA_DIR, "secrets.json")),
|
||||
|
||||
// Set up URIs
|
||||
storageUri: URI.file(WORKSPACE_STORAGE_DIR),
|
||||
storagePath: WORKSPACE_STORAGE_DIR,
|
||||
globalStorageUri: URI.file(DATA_DIR),
|
||||
globalStoragePath: DATA_DIR,
|
||||
|
||||
// Logs
|
||||
logUri: URI.file(DATA_DIR),
|
||||
logPath: DATA_DIR,
|
||||
|
||||
extensionUri: URI.file(EXTENSION_DIR),
|
||||
extensionPath: EXTENSION_DIR,
|
||||
asAbsolutePath: (relPath: string) => path.join(EXTENSION_DIR, relPath),
|
||||
|
||||
subscriptions: [],
|
||||
|
||||
environmentVariableCollection: new EnvironmentVariableCollection() as any,
|
||||
|
||||
// Workspace state
|
||||
workspaceState: new MementoStore(path.join(WORKSPACE_STORAGE_DIR, "workspaceState.json")),
|
||||
}
|
||||
|
||||
return {
|
||||
extensionContext,
|
||||
DATA_DIR,
|
||||
EXTENSION_DIR,
|
||||
WORKSPACE_STORAGE_DIR,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* VSCode namespace shim for CLI mode
|
||||
* Provides minimal stubs for VSCode types and enums used by the codebase
|
||||
*/
|
||||
|
||||
import { printError, printInfo, printWarning } from "./utils/display"
|
||||
|
||||
// Re-export common types from vscode-uri for URI handling
|
||||
export { URI } from "vscode-uri"
|
||||
|
||||
// Extension mode enum
|
||||
export enum ExtensionMode {
|
||||
Production = 1,
|
||||
Development = 2,
|
||||
Test = 3,
|
||||
}
|
||||
|
||||
// Extension kind enum
|
||||
export enum ExtensionKind {
|
||||
UI = 1,
|
||||
Workspace = 2,
|
||||
}
|
||||
|
||||
// Diagnostic severity enum
|
||||
export enum DiagnosticSeverity {
|
||||
Error = 0,
|
||||
Warning = 1,
|
||||
Information = 2,
|
||||
Hint = 3,
|
||||
}
|
||||
|
||||
// End of line enum
|
||||
export enum EndOfLine {
|
||||
LF = 1,
|
||||
CRLF = 2,
|
||||
}
|
||||
|
||||
// Position class
|
||||
export class Position {
|
||||
constructor(
|
||||
public readonly line: number,
|
||||
public readonly character: number,
|
||||
) {}
|
||||
|
||||
isAfter(other: Position): boolean {
|
||||
return this.line > other.line || (this.line === other.line && this.character > other.character)
|
||||
}
|
||||
|
||||
isAfterOrEqual(other: Position): boolean {
|
||||
return this.line > other.line || (this.line === other.line && this.character >= other.character)
|
||||
}
|
||||
|
||||
isBefore(other: Position): boolean {
|
||||
return this.line < other.line || (this.line === other.line && this.character < other.character)
|
||||
}
|
||||
|
||||
isBeforeOrEqual(other: Position): boolean {
|
||||
return this.line < other.line || (this.line === other.line && this.character <= other.character)
|
||||
}
|
||||
|
||||
isEqual(other: Position): boolean {
|
||||
return this.line === other.line && this.character === other.character
|
||||
}
|
||||
|
||||
translate(lineDelta?: number, characterDelta?: number): Position {
|
||||
return new Position(this.line + (lineDelta || 0), this.character + (characterDelta || 0))
|
||||
}
|
||||
|
||||
with(line?: number, character?: number): Position {
|
||||
return new Position(line ?? this.line, character ?? this.character)
|
||||
}
|
||||
|
||||
compareTo(other: Position): number {
|
||||
if (this.line < other.line) {
|
||||
return -1
|
||||
}
|
||||
if (this.line > other.line) {
|
||||
return 1
|
||||
}
|
||||
if (this.character < other.character) {
|
||||
return -1
|
||||
}
|
||||
if (this.character > other.character) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// Range class
|
||||
export class Range {
|
||||
public readonly start: Position
|
||||
public readonly end: Position
|
||||
|
||||
constructor(start: Position, end: Position)
|
||||
constructor(startLine: number, startCharacter: number, endLine: number, endCharacter: number)
|
||||
constructor(
|
||||
startOrStartLine: Position | number,
|
||||
endOrStartCharacter: Position | number,
|
||||
endLine?: number,
|
||||
endCharacter?: number,
|
||||
) {
|
||||
if (typeof startOrStartLine === "number") {
|
||||
this.start = new Position(startOrStartLine, endOrStartCharacter as number)
|
||||
this.end = new Position(endLine!, endCharacter!)
|
||||
} else {
|
||||
this.start = startOrStartLine
|
||||
this.end = endOrStartCharacter as Position
|
||||
}
|
||||
}
|
||||
|
||||
get isEmpty(): boolean {
|
||||
return this.start.isEqual(this.end)
|
||||
}
|
||||
|
||||
get isSingleLine(): boolean {
|
||||
return this.start.line === this.end.line
|
||||
}
|
||||
|
||||
contains(positionOrRange: Position | Range): boolean {
|
||||
if (positionOrRange instanceof Range) {
|
||||
return this.contains(positionOrRange.start) && this.contains(positionOrRange.end)
|
||||
}
|
||||
return positionOrRange.isAfterOrEqual(this.start) && positionOrRange.isBeforeOrEqual(this.end)
|
||||
}
|
||||
|
||||
isEqual(other: Range): boolean {
|
||||
return this.start.isEqual(other.start) && this.end.isEqual(other.end)
|
||||
}
|
||||
|
||||
intersection(range: Range): Range | undefined {
|
||||
const start = Position.prototype.isAfter.call(this.start, range.start) ? this.start : range.start
|
||||
const end = Position.prototype.isBefore.call(this.end, range.end) ? this.end : range.end
|
||||
if (start.isAfter(end)) {
|
||||
return undefined
|
||||
}
|
||||
return new Range(start, end)
|
||||
}
|
||||
|
||||
union(other: Range): Range {
|
||||
const start = this.start.isBefore(other.start) ? this.start : other.start
|
||||
const end = this.end.isAfter(other.end) ? this.end : other.end
|
||||
return new Range(start, end)
|
||||
}
|
||||
|
||||
with(start?: Position, end?: Position): Range {
|
||||
return new Range(start ?? this.start, end ?? this.end)
|
||||
}
|
||||
}
|
||||
|
||||
// Selection class (extends Range)
|
||||
export class Selection extends Range {
|
||||
public readonly anchor: Position
|
||||
public readonly active: Position
|
||||
|
||||
constructor(anchor: Position, active: Position)
|
||||
constructor(anchorLine: number, anchorCharacter: number, activeLine: number, activeCharacter: number)
|
||||
constructor(
|
||||
anchorOrAnchorLine: Position | number,
|
||||
activeOrAnchorCharacter: Position | number,
|
||||
activeLine?: number,
|
||||
activeCharacter?: number,
|
||||
) {
|
||||
let anchor: Position
|
||||
let active: Position
|
||||
if (typeof anchorOrAnchorLine === "number") {
|
||||
anchor = new Position(anchorOrAnchorLine, activeOrAnchorCharacter as number)
|
||||
active = new Position(activeLine!, activeCharacter!)
|
||||
} else {
|
||||
anchor = anchorOrAnchorLine
|
||||
active = activeOrAnchorCharacter as Position
|
||||
}
|
||||
super(anchor.isBefore(active) ? anchor : active, anchor.isBefore(active) ? active : anchor)
|
||||
this.anchor = anchor
|
||||
this.active = active
|
||||
}
|
||||
|
||||
get isReversed(): boolean {
|
||||
return this.anchor.isAfter(this.active)
|
||||
}
|
||||
}
|
||||
|
||||
// Cancellation token
|
||||
export interface CancellationToken {
|
||||
isCancellationRequested: boolean
|
||||
onCancellationRequested: any
|
||||
}
|
||||
|
||||
// Event emitter (simplified)
|
||||
export class EventEmitter<T> {
|
||||
private listeners: Array<(e: T) => void> = []
|
||||
|
||||
event = (listener: (e: T) => void) => {
|
||||
this.listeners.push(listener)
|
||||
return {
|
||||
dispose: () => {
|
||||
const index = this.listeners.indexOf(listener)
|
||||
if (index >= 0) {
|
||||
this.listeners.splice(index, 1)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fire(data: T): void {
|
||||
for (const listener of this.listeners) {
|
||||
listener(data)
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.listeners = []
|
||||
}
|
||||
}
|
||||
|
||||
// Disposable
|
||||
export class Disposable {
|
||||
constructor(private callOnDispose: () => void) {}
|
||||
|
||||
static from(...disposables: { dispose(): any }[]): Disposable {
|
||||
return new Disposable(() => {
|
||||
for (const d of disposables) {
|
||||
d.dispose()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.callOnDispose()
|
||||
}
|
||||
}
|
||||
|
||||
// Minimal workspace namespace
|
||||
export const workspace = {
|
||||
workspaceFolders: undefined as any[] | undefined,
|
||||
getWorkspaceFolder: (_uri: any) => undefined,
|
||||
onDidChangeWorkspaceFolders: () => ({ dispose: () => {} }),
|
||||
fs: {
|
||||
readFile: async (_uri: any): Promise<Uint8Array> => new Uint8Array(),
|
||||
writeFile: async (_uri: any, _content: Uint8Array): Promise<void> => {},
|
||||
delete: async (_uri: any): Promise<void> => {},
|
||||
stat: async (_uri: any): Promise<any> => ({ type: 1, size: 0 }),
|
||||
readDirectory: async (_uri: any): Promise<any[]> => [],
|
||||
createDirectory: async (_uri: any): Promise<void> => {},
|
||||
},
|
||||
}
|
||||
|
||||
// Minimal window namespace
|
||||
export const window = {
|
||||
showInformationMessage: async (message: string) => {
|
||||
printInfo(`[INFO] ${message}`)
|
||||
return undefined
|
||||
},
|
||||
showWarningMessage: async (message: string) => {
|
||||
printWarning(`[WARN] ${message}`)
|
||||
return undefined
|
||||
},
|
||||
showErrorMessage: async (message: string) => {
|
||||
printError(`[ERROR] ${message}`)
|
||||
return undefined
|
||||
},
|
||||
createOutputChannel: (_name: string) => ({
|
||||
appendLine: (line: string) => printInfo(`[${new Date().toISOString()}] ${line}`),
|
||||
append: (text: string) => printInfo(`[${new Date().toISOString()}] ${text}`),
|
||||
clear: () => {},
|
||||
show: () => {},
|
||||
hide: () => {},
|
||||
dispose: () => {},
|
||||
}),
|
||||
terminals: [] as any[],
|
||||
activeTerminal: undefined as any,
|
||||
createTerminal: (_options?: any) => ({
|
||||
name: "CLI Terminal",
|
||||
processId: Promise.resolve(process.pid),
|
||||
sendText: (text: string) => printInfo(`[${new Date().toISOString()}] [Terminal] ${text}`),
|
||||
show: () => {},
|
||||
hide: () => {},
|
||||
dispose: () => {},
|
||||
}),
|
||||
}
|
||||
|
||||
// Export types that are commonly used
|
||||
export type ExtensionContext = any
|
||||
export type Memento = any
|
||||
export type SecretStorage = any
|
||||
|
||||
// biome-ignore lint/correctness/noUnusedVariables: placeholder
|
||||
export type Extension<T> = any
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"esModuleInterop": true,
|
||||
"experimentalDecorators": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react",
|
||||
"jsxFactory": "React.createElement",
|
||||
"lib": [
|
||||
"es2022"
|
||||
],
|
||||
"module": "esnext",
|
||||
"moduleResolution": "Bundler",
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noUnusedLocals": false,
|
||||
"resolveJsonModule": true,
|
||||
"rootDir": ".",
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"target": "es2022",
|
||||
"useDefineForClassFields": true,
|
||||
"useUnknownInCatchVariables": false,
|
||||
"ignoreDeprecations": "6.0",
|
||||
"baseUrl": "..",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"src/*"
|
||||
],
|
||||
"@api/*": [
|
||||
"src/core/api/*"
|
||||
],
|
||||
"@core/*": [
|
||||
"src/core/*"
|
||||
],
|
||||
"@generated/*": [
|
||||
"src/generated/*"
|
||||
],
|
||||
"@hosts/*": [
|
||||
"src/hosts/*"
|
||||
],
|
||||
"@integrations/*": [
|
||||
"src/integrations/*"
|
||||
],
|
||||
"@packages/*": [
|
||||
"src/packages/*"
|
||||
],
|
||||
"@services/*": [
|
||||
"src/services/*"
|
||||
],
|
||||
"@shared/*": [
|
||||
"src/shared/*"
|
||||
],
|
||||
"@utils/*": [
|
||||
"src/utils/*"
|
||||
]
|
||||
},
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": ".."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import path from "path"
|
||||
import { defineConfig } from "vitest/config"
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.{ts,tsx}", "tests/**/*.test.{ts,tsx}"],
|
||||
coverage: {
|
||||
reporter: ["text", "json", "html"],
|
||||
exclude: ["node_modules/", "dist/"],
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
// Match tsconfig paths - baseUrl is parent directory
|
||||
"@": path.resolve(__dirname, "../src"),
|
||||
"@api": path.resolve(__dirname, "../src/core/api"),
|
||||
"@core": path.resolve(__dirname, "../src/core"),
|
||||
"@generated": path.resolve(__dirname, "../src/generated"),
|
||||
"@hosts": path.resolve(__dirname, "../src/hosts"),
|
||||
"@integrations": path.resolve(__dirname, "../src/integrations"),
|
||||
"@packages": path.resolve(__dirname, "../src/packages"),
|
||||
"@services": path.resolve(__dirname, "../src/services"),
|
||||
"@shared": path.resolve(__dirname, "../src/shared"),
|
||||
"@utils": path.resolve(__dirname, "../src/utils"),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"workflowToggles": {},
|
||||
"localClineRulesToggles": {},
|
||||
"localWindsurfRulesToggles": {},
|
||||
"localCursorRulesToggles": {},
|
||||
"localAgentsRulesToggles": {}
|
||||
}
|
||||
+8
-1
@@ -4,6 +4,10 @@
|
||||
"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.52.0",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"workspaces": [
|
||||
"cli-ts",
|
||||
"webview-ui"
|
||||
],
|
||||
"engines": {
|
||||
"vscode": "^1.84.0"
|
||||
},
|
||||
@@ -437,7 +441,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": [
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { ExtensionContext } from "vscode"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { ShowMessageType } from "@/shared/proto/index.host"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { secretStorage } from "@/shared/storage/ClineSecretStorage"
|
||||
import {
|
||||
getTaskHistoryStateFilePath,
|
||||
readTaskHistoryFromState,
|
||||
@@ -104,6 +105,7 @@ export class StateManager {
|
||||
|
||||
private constructor(context: ExtensionContext) {
|
||||
this.context = context
|
||||
secretStorage.init(context.secrets)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -121,7 +123,7 @@ export class StateManager {
|
||||
try {
|
||||
// Load all extension state from disk
|
||||
const globalState = await readGlobalStateFromDisk(context)
|
||||
const secrets = await readSecretsFromDisk(context)
|
||||
const secrets = await readSecretsFromDisk()
|
||||
const workspaceState = await readWorkspaceStateFromDisk(context)
|
||||
|
||||
// Populate the cache with all extension state and secrets fields
|
||||
@@ -861,4 +863,24 @@ export class StateManager {
|
||||
|
||||
return { ...secrets, ...settings } satisfies ApiConfiguration
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all global state entries (for debugging/inspection)
|
||||
*/
|
||||
public getAllGlobalStateEntries(): Record<string, unknown> {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error(STATE_MANAGER_NOT_INITIALIZED)
|
||||
}
|
||||
return { ...this.globalStateCache }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all workspace state entries (for debugging/inspection)
|
||||
*/
|
||||
public getAllWorkspaceStateEntries(): Record<string, unknown> {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error(STATE_MANAGER_NOT_INITIALIZED)
|
||||
}
|
||||
return { ...this.workspaceStateCache }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,10 +15,11 @@ import { ExtensionContext } from "vscode"
|
||||
import { Controller } from "@/core/controller"
|
||||
import { ClineRulesToggles } from "@/shared/cline-rules"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { secretStorage } from "@/shared/storage/ClineSecretStorage"
|
||||
import { readTaskHistoryFromState } from "../disk"
|
||||
|
||||
export async function readSecretsFromDisk(context: ExtensionContext): Promise<Secrets> {
|
||||
const secrets = await Promise.all(SecretKeys.map((key) => context.secrets.get(key)))
|
||||
export async function readSecretsFromDisk(): Promise<Secrets> {
|
||||
const secrets = await Promise.all(SecretKeys.map((key) => secretStorage.get(key)))
|
||||
|
||||
return SecretKeys.reduce((acc, key, index) => {
|
||||
acc[key] = secrets[index]
|
||||
@@ -131,7 +132,7 @@ export async function resetGlobalState(controller: Controller) {
|
||||
|
||||
await Promise.all(GlobalStateAndSettingKeys.map((key) => context.globalState.update(key, undefined)))
|
||||
|
||||
await Promise.all(SecretKeys.map((key) => context.secrets.delete(key)))
|
||||
await Promise.all(SecretKeys.map((key) => secretStorage.delete(key)))
|
||||
|
||||
await controller.stateManager.reInitialize()
|
||||
}
|
||||
|
||||
+46
-42
@@ -1,48 +1,52 @@
|
||||
import { ApiFormat } from "./proto/cline/models"
|
||||
import { ApiHandlerSettings } from "./storage/state-keys"
|
||||
|
||||
export type ApiProvider =
|
||||
| "anthropic"
|
||||
| "claude-code"
|
||||
| "openrouter"
|
||||
| "bedrock"
|
||||
| "vertex"
|
||||
| "openai"
|
||||
| "ollama"
|
||||
| "lmstudio"
|
||||
| "gemini"
|
||||
| "openai-native"
|
||||
| "openai-codex"
|
||||
| "requesty"
|
||||
| "together"
|
||||
| "deepseek"
|
||||
| "qwen"
|
||||
| "qwen-code"
|
||||
| "doubao"
|
||||
| "mistral"
|
||||
| "vscode-lm"
|
||||
| "cline"
|
||||
| "litellm"
|
||||
| "moonshot"
|
||||
| "nebius"
|
||||
| "fireworks"
|
||||
| "asksage"
|
||||
| "xai"
|
||||
| "sambanova"
|
||||
| "cerebras"
|
||||
| "sapaicore"
|
||||
| "groq"
|
||||
| "huggingface"
|
||||
| "huawei-cloud-maas"
|
||||
| "dify"
|
||||
| "baseten"
|
||||
| "vercel-ai-gateway"
|
||||
| "zai"
|
||||
| "oca"
|
||||
| "aihubmix"
|
||||
| "minimax"
|
||||
| "hicap"
|
||||
| "nousResearch"
|
||||
const API_PROVIDERS_LIST_BASE = [
|
||||
"anthropic",
|
||||
"claude-code",
|
||||
"openrouter",
|
||||
"bedrock",
|
||||
"vertex",
|
||||
"openai",
|
||||
"ollama",
|
||||
"lmstudio",
|
||||
"gemini",
|
||||
"openai-native",
|
||||
"requesty",
|
||||
"together",
|
||||
"deepseek",
|
||||
"qwen",
|
||||
"qwen-code",
|
||||
"doubao",
|
||||
"mistral",
|
||||
"vscode-lm",
|
||||
"cline",
|
||||
"litellm",
|
||||
"moonshot",
|
||||
"nebius",
|
||||
"fireworks",
|
||||
"asksage",
|
||||
"xai",
|
||||
"sambanova",
|
||||
"cerebras",
|
||||
"sapaicore",
|
||||
"groq",
|
||||
"huggingface",
|
||||
"huawei-cloud-maas",
|
||||
"dify",
|
||||
"baseten",
|
||||
"vercel-ai-gateway",
|
||||
"zai",
|
||||
"oca",
|
||||
"aihubmix",
|
||||
"minimax",
|
||||
"hicap",
|
||||
"nousResearch",
|
||||
]
|
||||
|
||||
export type ApiProvider = (typeof API_PROVIDERS_LIST_BASE)[number]
|
||||
|
||||
export const API_PROVIDERS_LIST = API_PROVIDERS_LIST_BASE as ApiProvider[]
|
||||
|
||||
export const DEFAULT_API_PROVIDER = "openrouter" as ApiProvider
|
||||
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* A Copy of the Type Definition for Visual Studio Code 1.84 Extension API
|
||||
* See https://code.visualstudio.com/api for more information
|
||||
*/
|
||||
|
||||
import { Memento, SecretStorage } from "vscode"
|
||||
import { URI } from "vscode-uri"
|
||||
|
||||
enum ExtensionMode {
|
||||
/**
|
||||
* The extension is installed normally (for example, from the marketplace
|
||||
* or VSIX) in the editor.
|
||||
*/
|
||||
Production = 1,
|
||||
|
||||
/**
|
||||
* The extension is running from an `--extensionDevelopmentPath` provided
|
||||
* when launching the editor.
|
||||
*/
|
||||
Development = 2,
|
||||
|
||||
/**
|
||||
* The extension is running from an `--extensionTestsPath` and
|
||||
* the extension host is running unit tests.
|
||||
*/
|
||||
Test = 3,
|
||||
}
|
||||
|
||||
export interface ClineExtensionContext {
|
||||
/**
|
||||
* An array to which disposables can be added. When this
|
||||
* extension is deactivated the disposables will be disposed.
|
||||
*
|
||||
* *Note* that asynchronous dispose-functions aren't awaited.
|
||||
*/
|
||||
readonly subscriptions: {
|
||||
/**
|
||||
* Function to clean up resources.
|
||||
*/
|
||||
dispose(): any
|
||||
}[]
|
||||
|
||||
/**
|
||||
* A memento object that stores state in the context
|
||||
* of the currently opened {@link workspace.workspaceFolders workspace}.
|
||||
*/
|
||||
readonly workspaceState: Memento
|
||||
|
||||
/**
|
||||
* A memento object that stores state independent
|
||||
* of the current opened {@link workspace.workspaceFolders workspace}.
|
||||
*/
|
||||
readonly globalState: Memento & {
|
||||
/**
|
||||
* Set the keys whose values should be synchronized across devices when synchronizing user-data
|
||||
* like configuration, extensions, and mementos.
|
||||
*
|
||||
* Note that this function defines the whole set of keys whose values are synchronized:
|
||||
* - calling it with an empty array stops synchronization for this memento
|
||||
* - calling it with a non-empty array replaces all keys whose values are synchronized
|
||||
*
|
||||
* For any given set of keys this function needs to be called only once but there is no harm in
|
||||
* repeatedly calling it.
|
||||
*
|
||||
* @param keys The set of keys whose values are synced.
|
||||
*/
|
||||
setKeysForSync(keys: readonly string[]): void
|
||||
}
|
||||
|
||||
/**
|
||||
* A storage utility for secrets. Secrets are persisted across reloads and are independent of the
|
||||
* current opened {@link workspace.workspaceFolders workspace}.
|
||||
*/
|
||||
readonly secrets: SecretStorage
|
||||
|
||||
/**
|
||||
* The uri of the directory containing the extension.
|
||||
*/
|
||||
readonly extensionUri: URI
|
||||
|
||||
/**
|
||||
* The absolute file path of the directory containing the extension. Shorthand
|
||||
* notation for {@link TextDocument.uri ExtensionContext.extensionUri.fsPath} (independent of the uri scheme).
|
||||
*/
|
||||
readonly extensionPath: string
|
||||
|
||||
/**
|
||||
* Gets the extension's global environment variable collection for this workspace, enabling changes to be
|
||||
* applied to terminal environment variables.
|
||||
*/
|
||||
readonly environmentVariableCollection: any
|
||||
|
||||
/**
|
||||
* Get the absolute path of a resource contained in the extension.
|
||||
*
|
||||
* *Note* that an absolute uri can be constructed via {@linkcode Uri.joinPath} and
|
||||
* {@linkcode ExtensionContext.extensionUri extensionUri}, e.g. `vscode.Uri.joinPath(context.extensionUri, relativePath);`
|
||||
*
|
||||
* @param relativePath A relative path to a resource contained in the extension.
|
||||
* @returns The absolute path of the resource.
|
||||
*/
|
||||
asAbsolutePath(relativePath: string): string
|
||||
|
||||
/**
|
||||
* The uri of a workspace specific directory in which the extension
|
||||
* can store private state. The directory might not exist and creation is
|
||||
* up to the extension. However, the parent directory is guaranteed to be existent.
|
||||
* The value is `undefined` when no workspace nor folder has been opened.
|
||||
*
|
||||
* Use {@linkcode ExtensionContext.workspaceState workspaceState} or
|
||||
* {@linkcode ExtensionContext.globalState globalState} to store key value data.
|
||||
*
|
||||
* @see {@linkcode FileSystem workspace.fs} for how to read and write files and folders from
|
||||
* an uri.
|
||||
*/
|
||||
readonly storageUri: URI | undefined
|
||||
|
||||
/**
|
||||
* An absolute file path of a workspace specific directory in which the extension
|
||||
* can store private state. The directory might not exist on disk and creation is
|
||||
* up to the extension. However, the parent directory is guaranteed to be existent.
|
||||
*
|
||||
* Use {@linkcode ExtensionContext.workspaceState workspaceState} or
|
||||
* {@linkcode ExtensionContext.globalState globalState} to store key value data.
|
||||
*
|
||||
* @deprecated Use {@link ExtensionContext.storageUri storageUri} instead.
|
||||
*/
|
||||
readonly storagePath: string | undefined
|
||||
|
||||
/**
|
||||
* The uri of a directory in which the extension can store global state.
|
||||
* The directory might not exist on disk and creation is
|
||||
* up to the extension. However, the parent directory is guaranteed to be existent.
|
||||
*
|
||||
* Use {@linkcode ExtensionContext.globalState globalState} to store key value data.
|
||||
*
|
||||
* @see {@linkcode FileSystem workspace.fs} for how to read and write files and folders from
|
||||
* an uri.
|
||||
*/
|
||||
readonly globalStorageUri: URI
|
||||
|
||||
/**
|
||||
* An absolute file path in which the extension can store global state.
|
||||
* The directory might not exist on disk and creation is
|
||||
* up to the extension. However, the parent directory is guaranteed to be existent.
|
||||
*
|
||||
* Use {@linkcode ExtensionContext.globalState globalState} to store key value data.
|
||||
*
|
||||
* @deprecated Use {@link ExtensionContext.globalStorageUri globalStorageUri} instead.
|
||||
*/
|
||||
readonly globalStoragePath: string
|
||||
|
||||
/**
|
||||
* The uri of a directory in which the extension can create log files.
|
||||
* The directory might not exist on disk and creation is up to the extension. However,
|
||||
* the parent directory is guaranteed to be existent.
|
||||
*
|
||||
* @see {@linkcode FileSystem workspace.fs} for how to read and write files and folders from
|
||||
* an uri.
|
||||
*/
|
||||
readonly logUri: URI
|
||||
|
||||
/**
|
||||
* An absolute file path of a directory in which the extension can create log files.
|
||||
* The directory might not exist on disk and creation is up to the extension. However,
|
||||
* the parent directory is guaranteed to be existent.
|
||||
*
|
||||
* @deprecated Use {@link ExtensionContext.logUri logUri} instead.
|
||||
*/
|
||||
readonly logPath: string
|
||||
|
||||
/**
|
||||
* The mode the extension is running in. See {@link ExtensionMode}
|
||||
* for possible values and scenarios.
|
||||
*/
|
||||
readonly extensionMode: ExtensionMode
|
||||
|
||||
/**
|
||||
* The current `Extension` instance.
|
||||
*/
|
||||
readonly extension: Extension<any>
|
||||
}
|
||||
|
||||
export enum ExtensionKind {
|
||||
/**
|
||||
* Extension runs where the UI runs.
|
||||
*/
|
||||
UI = 1,
|
||||
|
||||
/**
|
||||
* Extension runs where the remote extension host runs.
|
||||
*/
|
||||
Workspace = 2,
|
||||
}
|
||||
|
||||
interface Extension<T> {
|
||||
/**
|
||||
* The canonical extension identifier in the form of: `publisher.name`.
|
||||
*/
|
||||
readonly id: string
|
||||
|
||||
/**
|
||||
* The uri of the directory containing the extension.
|
||||
*/
|
||||
readonly extensionUri: URI
|
||||
|
||||
/**
|
||||
* The absolute file path of the directory containing this extension. Shorthand
|
||||
* notation for {@link Extension.extensionUri Extension.extensionUri.fsPath} (independent of the uri scheme).
|
||||
*/
|
||||
readonly extensionPath: string
|
||||
|
||||
/**
|
||||
* `true` if the extension has been activated.
|
||||
*/
|
||||
readonly isActive: boolean
|
||||
|
||||
/**
|
||||
* The parsed contents of the extension's package.json.
|
||||
*/
|
||||
readonly packageJSON: any
|
||||
|
||||
/**
|
||||
* The extension kind describes if an extension runs where the UI runs
|
||||
* or if an extension runs where the remote extension host runs. The extension kind
|
||||
* is defined in the `package.json`-file of extensions but can also be refined
|
||||
* via the `remote.extensionKind`-setting. When no remote extension host exists,
|
||||
* the value is {@linkcode ExtensionKind.UI}.
|
||||
*/
|
||||
extensionKind: ExtensionKind
|
||||
|
||||
/**
|
||||
* The public API exported by this extension (return value of `activate`).
|
||||
* It is an invalid action to access this field before this extension has been activated.
|
||||
*/
|
||||
readonly exports: T
|
||||
|
||||
/**
|
||||
* Activates this extension and returns its public API.
|
||||
*
|
||||
* @returns A promise that will resolve when this extension has been activated.
|
||||
*/
|
||||
activate(): Thenable<T>
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export * from "./api"
|
||||
export * from "./context"
|
||||
export * from "./onboarding"
|
||||
|
||||
export enum ClineClient {
|
||||
VSCode = "VS Code",
|
||||
JetBrains = "JetBrains",
|
||||
Cli = "CLI",
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
export class Logger {
|
||||
private static isVerbose = process.env.IS_DEV === "true"
|
||||
|
||||
private static output: (msg: string) => void = console.log
|
||||
private static output: (msg: string) => void = Logger.isVerbose ? console.log : () => {}
|
||||
|
||||
/**
|
||||
* Configure the output function for Logger.
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import { Logger } from "../services/Logger"
|
||||
import { ClineStorage } from "./ClineStorage"
|
||||
|
||||
/**
|
||||
* A storage implementation that uses the filesystem to store key-value pairs.
|
||||
*/
|
||||
export class ClineFileStorage extends ClineStorage {
|
||||
override name = "FileBasedStorage"
|
||||
|
||||
private readonly cache = new Map<string, string>()
|
||||
|
||||
constructor(private fsPath: string) {
|
||||
super()
|
||||
this.read()
|
||||
}
|
||||
|
||||
override async _get(key: string): Promise<string | undefined> {
|
||||
try {
|
||||
return this.cache.get(key) || undefined
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
override async _store(key: string, value: string): Promise<void> {
|
||||
try {
|
||||
this.cache.set(key, value)
|
||||
await this.write()
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
override async _delete(key: string): Promise<void> {
|
||||
try {
|
||||
this.cache.delete(key)
|
||||
await this.write()
|
||||
} catch (error) {
|
||||
Logger.error("FileBasedStorage", error)
|
||||
}
|
||||
}
|
||||
|
||||
private async read(): Promise<void> {
|
||||
try {
|
||||
const fileContent = await fs.promises.readFile(this.fsPath, "utf-8")
|
||||
const json = JSON.parse(fileContent) as Record<string, string>
|
||||
this.cache.clear() // Clear existing cache
|
||||
for (const [key, value] of Object.entries(json)) {
|
||||
if (key && value) {
|
||||
this.cache.set(key, value)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async write(): Promise<void> {
|
||||
try {
|
||||
// Ensure directory exists
|
||||
const dir = path.dirname(this.fsPath)
|
||||
await fs.promises.mkdir(dir, { recursive: true })
|
||||
|
||||
// Convert map to object and save
|
||||
const json = Object.fromEntries(this.cache)
|
||||
await fs.promises.writeFile(this.fsPath, JSON.stringify(json, null, 2), "utf-8")
|
||||
} catch (error) {
|
||||
Logger.error("FileBasedStorage", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,16 +28,14 @@ export class ClineSecretStorage extends ClineStorage {
|
||||
public init(store: SecretStores) {
|
||||
if (!this.secretStorage) {
|
||||
this.secretStorage = store
|
||||
Logger.info("[ClineSecretStorage] initialized")
|
||||
}
|
||||
return this.secretStorage
|
||||
}
|
||||
|
||||
protected async _get(key: string): Promise<string | undefined> {
|
||||
try {
|
||||
return key ? await this.storage.get(key) : undefined
|
||||
} catch (error) {
|
||||
Logger.error("[ClineSecretStorage]", error)
|
||||
return await this.storage.get(key)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -49,6 +47,8 @@ export class ClineSecretStorage extends ClineStorage {
|
||||
try {
|
||||
if (value && value.length > 0) {
|
||||
await this.storage.store(key, value)
|
||||
} else {
|
||||
await this.storage.delete(key)
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error("[ClineSecretStorage]", error)
|
||||
@@ -56,7 +56,6 @@ export class ClineSecretStorage extends ClineStorage {
|
||||
}
|
||||
|
||||
protected async _delete(key: string): Promise<void> {
|
||||
Logger.info("[ClineSecretStorage] deleting secret")
|
||||
await this.storage.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./ClineBlobStorage"
|
||||
export * from "./ClineFileStorage"
|
||||
export * from "./ClineSecretStorage"
|
||||
export * from "./ClineStorage"
|
||||
export * from "./provider-keys"
|
||||
export * from "./state-keys"
|
||||
@@ -0,0 +1,138 @@
|
||||
// Map providers to their specific model ID keys
|
||||
|
||||
import {
|
||||
ApiProvider,
|
||||
anthropicDefaultModelId,
|
||||
basetenDefaultModelId,
|
||||
bedrockDefaultModelId,
|
||||
deepSeekDefaultModelId,
|
||||
fireworksDefaultModelId,
|
||||
geminiDefaultModelId,
|
||||
groqDefaultModelId,
|
||||
huaweiCloudMaasDefaultModelId,
|
||||
huggingFaceDefaultModelId,
|
||||
internationalQwenDefaultModelId,
|
||||
liteLlmDefaultModelId,
|
||||
minimaxDefaultModelId,
|
||||
nousResearchDefaultModelId,
|
||||
openAiNativeDefaultModelId,
|
||||
openRouterDefaultModelId,
|
||||
requestyDefaultModelId,
|
||||
sapAiCoreDefaultModelId,
|
||||
xaiDefaultModelId,
|
||||
} from "../api"
|
||||
|
||||
// Note: "cline" provider uses the same model ID key as "openrouter"
|
||||
const ProviderKeyMap: Partial<Record<ApiProvider, string>> = {
|
||||
anthropic: "apiModelId",
|
||||
openrouter: "OpenRouterModelId",
|
||||
cline: "OpenRouterModelId", // Cline provider uses OpenRouter model IDs
|
||||
openai: "OpenAiModelId",
|
||||
ollama: "OllamaModelId",
|
||||
lmstudio: "LmStudioModelId",
|
||||
litellm: "LiteLlmModelId",
|
||||
requesty: "RequestyModelId",
|
||||
together: "TogetherModelId",
|
||||
fireworks: "FireworksModelId",
|
||||
sapaicore: "SapAiCoreModelId",
|
||||
groq: "GroqModelId",
|
||||
baseten: "BasetenModelId",
|
||||
huggingface: "HuggingFaceModelId",
|
||||
"huawei-cloud-maas": "HuaweiCloudMaasModelId",
|
||||
oca: "OcaModelId",
|
||||
aihubmix: "AihubmixModelId",
|
||||
hicap: "HicapModelId",
|
||||
nousResearch: "NousResearchModelId",
|
||||
"vercel-ai-gateway": "VercelAiGatewayModelId",
|
||||
} as const
|
||||
|
||||
export const ProviderToApiKeyMap: Partial<Record<ApiProvider, string | string[]>> = {
|
||||
anthropic: "apiKey",
|
||||
openrouter: "openRouterApiKey",
|
||||
bedrock: ["awsAccessKey", "awsBedrockApiKey"],
|
||||
openai: "openAiApiKey",
|
||||
gemini: "geminiApiKey",
|
||||
"openai-native": "openAiNativeApiKey",
|
||||
ollama: "ollamaApiKey",
|
||||
requesty: "requestyApiKey",
|
||||
together: "togetherApiKey",
|
||||
deepseek: "deepSeekApiKey",
|
||||
qwen: "qwenApiKey",
|
||||
"qwen-code": "qwenApiKey",
|
||||
doubao: "doubaoApiKey",
|
||||
mistral: "mistralApiKey",
|
||||
litellm: "liteLlmApiKey",
|
||||
moonshot: "moonshotApiKey",
|
||||
nebius: "nebiusApiKey",
|
||||
fireworks: "fireworksApiKey",
|
||||
asksage: "asksageApiKey",
|
||||
xai: "xaiApiKey",
|
||||
sambanova: "sambanovaApiKey",
|
||||
cerebras: "cerebrasApiKey",
|
||||
groq: "groqApiKey",
|
||||
huggingface: "huggingFaceApiKey",
|
||||
"huawei-cloud-maas": "huaweiCloudMaasApiKey",
|
||||
dify: "difyApiKey",
|
||||
baseten: "basetenApiKey",
|
||||
"vercel-ai-gateway": "vercelAiGatewayApiKey",
|
||||
zai: "zaiApiKey",
|
||||
oca: "ocaApiKey",
|
||||
aihubmix: "aihubmixApiKey",
|
||||
minimax: "minimaxApiKey",
|
||||
hicap: "hicapApiKey",
|
||||
nousResearch: "nousResearchApiKey",
|
||||
sapaicore: ["sapAiCoreClientId", "sapAiCoreClientSecret"],
|
||||
cline: "clineAccountId",
|
||||
} as const
|
||||
|
||||
const ProviderDefaultModelMap: Partial<Record<ApiProvider, string>> = {
|
||||
anthropic: anthropicDefaultModelId,
|
||||
openrouter: openRouterDefaultModelId,
|
||||
cline: openRouterDefaultModelId,
|
||||
openai: openAiNativeDefaultModelId,
|
||||
ollama: "",
|
||||
lmstudio: "",
|
||||
litellm: liteLlmDefaultModelId,
|
||||
requesty: requestyDefaultModelId,
|
||||
together: openRouterDefaultModelId,
|
||||
fireworks: fireworksDefaultModelId,
|
||||
sapaicore: sapAiCoreDefaultModelId,
|
||||
groq: groqDefaultModelId,
|
||||
baseten: basetenDefaultModelId,
|
||||
huggingface: huggingFaceDefaultModelId,
|
||||
"huawei-cloud-maas": huaweiCloudMaasDefaultModelId,
|
||||
oca: liteLlmDefaultModelId,
|
||||
aihubmix: openRouterDefaultModelId,
|
||||
bedrock: bedrockDefaultModelId,
|
||||
hicap: "",
|
||||
nousResearch: nousResearchDefaultModelId,
|
||||
"vercel-ai-gateway": openRouterDefaultModelId,
|
||||
xai: xaiDefaultModelId,
|
||||
gemini: geminiDefaultModelId,
|
||||
minimax: minimaxDefaultModelId,
|
||||
qwen: internationalQwenDefaultModelId,
|
||||
deepseek: deepSeekDefaultModelId,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Get the provider-specific model ID key for a given provider and mode.
|
||||
* Different providers store their model IDs in different state keys.
|
||||
*/
|
||||
export function getProviderModelIdKey(
|
||||
provider: ApiProvider,
|
||||
mode: "act" | "plan",
|
||||
): keyof import("@shared/storage/state-keys").Settings | null {
|
||||
const keySuffix = ProviderKeyMap[provider]
|
||||
if (keySuffix) {
|
||||
// E.g. actModeOpenAiModelId, planModeOpenAiModelId, etc.
|
||||
return `${mode}Mode${keySuffix}` as keyof import("@shared/storage/state-keys").Settings
|
||||
}
|
||||
|
||||
// For providers without a specific key (anthropic, gemini, bedrock, etc.),
|
||||
// they use the generic actModeApiModelId/planModeApiModelId
|
||||
return `${mode}ModeApiModelId`
|
||||
}
|
||||
|
||||
export function getProviderDefaultModelId(provider: ApiProvider): string | null {
|
||||
return ProviderDefaultModelMap[provider] || ""
|
||||
}
|
||||
Reference in New Issue
Block a user