Compare commits

..

18 Commits

Author SHA1 Message Date
Ara 74f607ff8e chore(release): bump version to 3.53.1 (#8839)
- Fix bug in responses API
- Update changeset package name from "cline" to "claude-dev"
- Update version in package.json and package-lock.json
2026-01-23 15:46:59 -08:00
Robin Newhouse 0edf6d777b fix: prevent infinite retry loops when replace_in_file fails repeatedly (#8787)
* fix: prevent infinite retry loops when replace_in_file fails repeatedly

The consecutiveMistakeCount was being reset to 0 at the START of each
WriteToFileToolHandler execution, before the tooManyMistakes check could
see accumulated failures. This allowed the model to retry failing
replace_in_file operations indefinitely, causing context explosion.

Changes:
- Move counter reset from before operation to after successful saveChanges()
- Add consecutiveMistakeCount++ in the diff error catch block
- Fix typo: "his thought process" → "Cline's thought process"

* chore: add changeset for retry loop prevention

* test: add unit tests for consecutiveMistakeCount behavior

Verify the fix for infinite retry loops by testing that:
- Counter is NOT reset at the start of operations
- Counter IS reset only after successful saveChanges()
- Counter IS incremented on diff errors
- Repeated failures accumulate so tooManyMistakes can trigger
2026-01-23 15:37:01 -08:00
Robin Newhouse de630c64d4 fix: throttle diff view updates during streaming (#8785)
* fix: throttle diff view updates during streaming

Skip redundant rapid updates to reduce performance issues in large
streams (e.g., notebooks) and reset throttle state on cleanup.

* chore: add changeset for diff throttling fix

* test: add unit tests for diff view update throttling

Add comprehensive tests for the throttling behavior introduced in the
streaming diff updates fix. Tests cover empty content, unchanged content,
time-based throttling, final update bypass, and state reset.
2026-01-23 15:36:47 -08:00
Bee 3ff63562c8 chore: migrate host logging to shared Logger service (#8820)
* chore: migrate host logging to shared Logger service

- Replace HostProvider.logToChannel usage with Logger.log/error
  in controller, webview, and checkpoint migration code
- Remove redundant, low-value log statements from Cline API
  methods to reduce noise
- Centralize logging through shared Logger service for more
  consistent, structured logging and easier maintenance
- Remove redundant , low-value log statements from StateManager where
  we logged error that would be throw and get logged again

* Update src/integrations/checkpoints/CheckpointMigration.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix

* update tests

* update tests

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-23 15:27:11 -08:00
Saoud Rizwan 4d6f908fbd fix: add null check when filtering tools by type in Responses API providers (#8837)
Users reported seeing this error with the OpenAI Codex provider:
{"message":"Cannot read properties of undefined (reading 'type')","modelId":"gpt-5.2-codex"}

The issue occurs when filtering tools before sending to the Responses API.
The filter accessed .type without checking if the tool element was defined:

  tools.filter((tool) => tool.type === "function")

If the tools array contains any undefined elements, this throws. Fixed by
adding optional chaining:

  tools.filter((tool) => tool?.type === "function")

Applied the same fix to all three providers using the Responses API:
- openai-codex.ts (ChatGPT Plus/Pro subscriptions)
- openai-native.ts (OpenAI API with Responses format)
- oca.ts (OpenAI-compatible API with Responses format)
2026-01-23 14:48:51 -08:00
Igor Tceglevskii 6521fdcc94 disable telemetry for self-hosted environments (#8790) 2026-01-23 14:16:50 -08:00
Igor Tceglevskii 1393eace27 Endpoint configuration file (#8645) 2026-01-23 13:40:31 -08:00
github-actions[bot] eebb99c1e3 Changeset version bump (#8800)
* changeset version bump

* Updating CHANGELOG.md format

* update changelog and banner for release

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-01-23 13:12:29 -08:00
Ara 9ed44f7a83 feat(cerebras): use model-specific temperature configuration (#8833)
- Extract model retrieval to avoid multiple function calls
- Use temperature from model.info with fallback to 0 instead of hardcoded value
- Allows temperature to be configured per model rather than using a fixed value

This change enables more flexible temperature configuration for different Cerebras models while maintaining backward compatibility with a default value of 0.
2026-01-23 12:50:57 -08:00
Ara 6a95cc5f19 feat: add default temperature to Cerebras model configuration (#8832)
Set default temperature value of 0.9 for Cerebras model in the model
configuration. This establishes a consistent default sampling temperature
for the model's response generation behavior.
2026-01-23 12:42:26 -08:00
er-ri 1d0637f39c fix: add support for haiku 4.5 in JP_SUPPORTED_CRIS_MODELS and enable global endpoint support (#8298) 2026-01-23 11:46:02 -08:00
AJ Juaire f2c16bae7a Make the default bedrock model Sonnet 4.5 (#8830) 2026-01-23 11:45:49 -08:00
Ara 204f15ce1c Remove free period on grok (#8831) 2026-01-23 11:27:42 -08:00
Robin Newhouse 8118e11596 fix(extract-text): strip notebook outputs to reduce context size (#8784)
* fix(extract-text): strip notebook outputs to reduce context size

* chore: add changeset for notebook outputs fix
2026-01-22 17:50:31 -08:00
Bee f7b593df35 chore: remove noisy log when checking file outside workspace (#8814)
* chore: remove noisy log when checking file outside workspace

Removes a `Logger.error` call in `ifFileExistsRelativePath` that triggered whenever a file path was checked without an active workspace. This log was creating excessive noise during long conversations where many files were mentioned but no workspace was open.

* update test
2026-01-22 17:01:22 -08:00
Saoud Rizwan 2e0358a7a1 fix: disable browser tool by default (#8815)
The browser tool conflicts with the new websearch tool. Disabling it by
default provides a better out-of-box experience.
2026-01-22 16:58:29 -08:00
Bee 0fbc10f807 chore: remove unhelpful and noisy log statements - part 1 (#8813)
* chore: remove unhelpful and noisy log statements - part 1

Removes excessive debug and info logs across several services to reduce console noise, specifically:
- Deletes `[DEBUG]` logs for request registration, subscription setup/cleanup, and event dispatching in the gRPC controller and UI handlers.
- Removes verbose file cleanup logs in `ClineTempManager` and process termination logs in `AudioRecordingService`.
- Simplifies the success log in `refreshOpenRouterModels` by removing the large JSON payload dump.
- Upgrades the log level from `debug` to `error` for request cleanup failures in `GrpcRequestRegistry` to ensure exceptions are properly highlighted.

* removes subscription logs
2026-01-22 16:44:05 -08:00
Bee c093ca1760 refactor: replace console with Logger service (#8741)
* chore: add grit rule to enforce Logger service over console calls

Add a new Grit linting rule that detects direct console method usage
(log, debug, error, warn, info) and prompts developers to use the
Logger service instead for consistent logging practices.

The rule is configured in biome.jsonc to apply to most source files
while excluding test files, webview-ui, evals, standalone, e2e tests,
and scripts where direct console usage may be acceptable.

* support variadic args

* wip: migrate console to Logger

* migrate rest of console logger

* Switch to Logger

* Migrations

* shared

* use shared

* revert format change

* Update tests to stub Logger instead of console

* verbose in dev mode
2026-01-22 13:16:37 -08:00
406 changed files with 3349 additions and 349617 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
improve cline command permission validation logic. add cline command permission man page documentation
+7
View File
@@ -0,0 +1,7 @@
---
"claude-dev": patch
---
Add endpoint configuration file support for on-premise deployments
Enterprise customers can now configure custom API endpoints by creating a `~/.cline/endpoints.json` file with custom URLs for `appBaseUrl`, `apiBaseUrl`, and `mcpBaseUrl`. When this file is present, Cline runs in on-premise mode with the custom endpoints.
@@ -0,0 +1,7 @@
---
"claude-dev": patch
---
fix: prevent infinite retry loops when replace_in_file fails repeatedly
Add safeguards to prevent the LLM from getting stuck in infinite retry loops when `replace_in_file` operations fail repeatedly. The system now detects repeated failures and provides better guidance to break out of retry cycles.
+7
View File
@@ -0,0 +1,7 @@
---
"claude-dev": patch
---
fix(extract-text): strip notebook outputs to reduce context size
Strip notebook cell outputs when extracting text content from Jupyter notebooks, significantly reducing the amount of context sent to the LLM while preserving the essential code and markdown content.
+7
View File
@@ -0,0 +1,7 @@
---
"claude-dev": patch
---
fix: throttle diff view updates during streaming
Add throttling to diff view updates during content streaming to reduce UI flickering and improve performance. Updates are now batched at reasonable intervals instead of firing on every token received.
+1 -1
View File
@@ -1,5 +1,5 @@
---
"cline": patch
"claude-dev": patch
---
Remove deprecated zai-glm-4.6 model from Cerebras provider
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Wiring-up "conditionals" for Cline Rules files.
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Make Sonnet 4.5 the default Amazon Bedrock model id
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Fix drag & drop files from SSH remote workspaces into chat
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Updated switch component to adhere to 3:1 contrast accessibility standard for both light and dark vscode themes
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Disable PostHog and build-time OpenTelemetry telemetry in self-hosted/on-premise mode. Enterprise customers running self-hosted deployments will no longer send any telemetry to Cline's collectors. Runtime environment OTEL and remote config OTEL remain available for enterprises to configure their own telemetry collection.
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Remove hooks feature setting and make it always enabled (except on Windows).
-1
View File
@@ -46,4 +46,3 @@ test-results
/pkg
.secrets
*.tsbuildinfo
+12 -2
View File
@@ -1,18 +1,28 @@
# Changelog
## [3.53.1]
### Fixed
- Bug in responses API
## [3.53.0]
### Fixed
- Removed grok model from free tier
## [3.52.0]
### Added
- Users with ChatGPT Plus or Pro subscriptions can now use GPT-5 models directly through Cline without needing an API key. Authentication is handled via OAuth through OpenAI's authentication system.
- Grok models are now moving out of free tier and into paid plans.
- Introduces comprehensive Jupyter Notebook support for Cline, enabling AI-assisted editing of `.ipynb` files with full cell-level context awareness.
- Introduces comprehensive Jupyter Notebook support for Cline, enabling AI-assisted editing of `.ipynb` files with full cell-level context awareness.
### Fixed
- Bugs in DiffViewProvider for file editing
- Ollama's recommended models to use correct identifiers
## [3.51.0]
### Added
+23
View File
@@ -147,6 +147,29 @@
"src/dev/grit/vscode-api.grit"
]
},
{
// Do not use console logging directly, use the Logger service instead.
"plugins": [
"src/dev/grit/console-log.grit"
],
"includes": [
"**",
"!**/webview-ui/**",
"!**/evals/**",
"!**/standalone/**",
"!**/e2e/**",
"!**/test/**",
"!**/__tests__/**",
"!**/*.test.ts",
"!**/*.stories.ts",
"!src/dev/**",
"!**/*.mjs",
"!**/*.js",
"!**/scripts/**",
"!**/*.tsx",
"!**/testing-platform/**"
]
},
{
"includes": [
"**",
-319
View File
@@ -1,319 +0,0 @@
# Cline CLI (TypeScript)
A TypeScript CLI implementation of Cline that reuses the core TypeScript codebase. This allows you to run Cline tasks directly from the terminal while sharing the same underlying functionality as the VS Code extension.
## Features
- **Reuses Core Codebase**: Shares the same Controller, Task, and API handling as the VS Code extension
- **Terminal Output**: Displays Cline messages directly in your terminal with colored output
- **Task History**: Access your task history from the command line
- **Configurable**: Use custom configuration directories and working directories
- **Image Support**: Attach images to your prompts using file paths or inline references
## Prerequisites
- Node.js 20.x or later
- npm or yarn
- The parent Cline project dependencies installed
## Installation
From the repository root:
```bash
# Install all dependencies first
npm run install:all
# Ensure protos are generated
npm run protos
# Build the CLI
npm run build:cli
```
Or install the CLI globally:
```bash
# Install all dependencies first
npm run install:all
# Ensure protos are generated
npm run protos
# Build and link the CLI globally
cd cli-ts
npm install
npm run link
```
## Usage
### Interactive Mode (Default)
When you run `cline` without any command, it launches an interactive welcome prompt:
```bash
# Launch interactive mode
cline
# Or run a task directly
cline "Create a hello world function in Python"
# With options
cline -v --thinking "Analyze this codebase"
```
### Commands
#### `task` (alias: `t`)
Run a new task with a prompt.
```bash
cline task "Create a hello world function in Python"
cline t "Create a hello world function"
```
**Options:**
| Option | Description |
|--------|-------------|
| `-a, --act` | Run in act mode |
| `-p, --plan` | Run in plan mode |
| `-y, --yolo` | Enable yolo mode (auto-approve actions) |
| `-m, --model <model>` | Model to use for the task |
| `-i, --images <paths...>` | Image file paths to include with the task |
| `-v, --verbose` | Show verbose output including reasoning |
| `-c, --cwd <path>` | Working directory for the task |
| `--config <path>` | Path to Cline configuration directory |
| `-t, --thinking` | Enable extended thinking (1024 token budget) |
**Examples:**
```bash
# Run in plan mode with verbose output
cline task -p -v "Design a REST API"
# Use a specific model with yolo mode
cline task -m claude-sonnet-4-5-20250929 -y "Refactor this function"
# Include images with your prompt
cline task -i screenshot.png diagram.jpg "Fix the UI based on these images"
# Or use inline image references in the prompt
cline task "Fix the layout shown in @./screenshot.png"
# Enable extended thinking for complex tasks
cline task -t "Architect a microservices system"
# Specify working directory
cline task -c /path/to/project "Add unit tests"
```
#### `history` (alias: `h`)
List task history with pagination support.
```bash
cline history
cline h
```
**Options:**
| Option | Description |
|--------|-------------|
| `-n, --limit <number>` | Number of tasks to show (default: 10) |
| `-p, --page <number>` | Page number, 1-based (default: 1) |
| `--config <path>` | Path to Cline configuration directory |
**Examples:**
```bash
# Show last 10 tasks (default)
cline history
# Show 20 tasks
cline history -n 20
# Show page 2 with 5 tasks per page
cline history -n 5 -p 2
```
#### `config`
Show current configuration including global and workspace state.
```bash
cline config
```
**Options:**
| Option | Description |
|--------|-------------|
| `--config <path>` | Path to Cline configuration directory |
#### `auth`
Authenticate a provider and configure what model is used.
```bash
cline auth
```
**Options:**
| Option | Description |
|--------|-------------|
| `-p, --provider <id>` | Provider ID for quick setup (e.g., openai-native, anthropic) |
| `-k, --apikey <key>` | API key for the provider |
| `-m, --modelid <id>` | Model ID to configure (e.g., gpt-4o, claude-sonnet-4-5-20250929) |
| `-b, --baseurl <url>` | Base URL (optional, only for openai provider) |
| `-v, --verbose` | Show verbose output |
| `-c, --cwd <path>` | Working directory for the task |
| `--config <path>` | Path to Cline configuration directory |
**Examples:**
```bash
# Interactive authentication
cline auth
# Quick setup with provider and API key
cline auth -p anthropic -k sk-ant-xxxxx
# Full quick setup with model
cline auth -p openai-native -k sk-xxxxx -m gpt-4o
# OpenAI-compatible provider with custom base URL
cline auth -p openai -k your-api-key -b https://api.example.com/v1
```
### Global Options
These options are available for the default command (running a task directly):
| Option | Description |
|--------|-------------|
| `-v, --verbose` | Show verbose output |
| `-c, --cwd <path>` | Working directory |
| `--config <path>` | Configuration directory |
| `--thinking` | Enable extended thinking (1024 token budget) |
## Development
For active development, at the root of this repo:
1. **Initial setup:**
```bash
npm run install:all
npm run protos
```
2. **Make changes to cli-ts:**
```bash
npm run cli:dev
# or
cd cli-ts && npm run dev
```
3. **Test your changes:**
```bash
cline [your-command] # In a new Terminal
```
4. **When done:**
```bash
npm run cli:unlink
```
### Build
```bash
cd cli-ts
# Development build with source maps
npm run build
# Production build (minified)
npm run build:production
```
### Watch Mode
```bash
npm run watch
```
### Type Checking
```bash
npm run typecheck
```
## 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
```
-231
View File
@@ -1,231 +0,0 @@
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", "aws4fetch"],
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)
})
-2950
View File
File diff suppressed because it is too large Load Diff
-50
View File
@@ -1,50 +0,0 @@
{
"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": "^19.2.9",
"esbuild": "^0.25.0",
"ink-testing-library": "^4.0.0",
"rimraf": "^6.0.1",
"typescript": "^5.4.5",
"vitest": "^4.0.17"
},
"dependencies": {
"aws4fetch": "^1.0.20",
"chalk": "^5.3.0",
"commander": "^12.1.0",
"ink": "npm:@jrichman/ink@6.4.7",
"ink-spinner": "^5.0.0",
"ora": "^8.0.1",
"prompts": "^2.4.2",
"react": "^19.2.3"
}
}
-194
View File
@@ -1,194 +0,0 @@
/**
* Account info view component
* Shows current provider, and for Cline provider: credit balance and organization name
*/
import { Box, Text } from "ink"
import React, { useCallback, useEffect, useState } from "react"
import { Controller } from "@/core/controller"
import { StateManager } from "@/core/storage/StateManager"
import { ClineAccountService } from "@/services/account/ClineAccountService"
import { AuthService, ClineAccountOrganization } from "@/services/auth/AuthService"
import { LoadingSpinner } from "./Spinner"
interface AccountInfoViewProps {
controller: Controller
}
/**
* Capitalize provider name for display
*/
function capitalize(str: string): string {
return str
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ")
}
/**
* Format balance as currency (balance is in microcredits, divide by 10000)
*/
function formatBalance(balance: number | null): string {
if (balance === null || balance === undefined) {
return "..."
}
return `$${(balance / 1000000).toFixed(2)}`
}
export const AccountInfoView: React.FC<AccountInfoViewProps> = React.memo(({ controller }) => {
const [provider, setProvider] = useState<string | null>(null)
const [balance, setBalance] = useState<number | null>(null)
const [organization, setOrganization] = useState<ClineAccountOrganization | null>(null)
const [email, setEmail] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const fetchAccountInfo = useCallback(async () => {
try {
setIsLoading(true)
setError(null)
// Get current provider from state
const stateManager = StateManager.get()
const mode = stateManager.getGlobalSettingsKey("mode") as string
const providerKey = mode === "act" ? "actModeApiProvider" : "planModeApiProvider"
const currentProvider = stateManager.getGlobalSettingsKey(providerKey) as string
setProvider(currentProvider || "cline")
// If using Cline provider, fetch additional info
if (currentProvider === "cline") {
const authService = AuthService.getInstance(controller)
// Wait for auth to be restored - poll until we have auth info or timeout
let authInfo = authService.getInfo()
let attempts = 0
const maxAttempts = 20 // 2 seconds max
while (!authInfo?.user?.uid && attempts < maxAttempts) {
await new Promise((resolve) => setTimeout(resolve, 100))
authInfo = authService.getInfo()
attempts++
}
// Get user info
if (authInfo?.user?.email) {
setEmail(authInfo.user.email)
} else {
// User not logged in to Cline
setEmail(null)
setIsLoading(false)
return
}
// Get organization info
const organizations = authService.getUserOrganizations()
if (organizations) {
const activeOrg = organizations.find((org) => org.active)
if (activeOrg) {
setOrganization(activeOrg)
}
}
// Fetch credit balance
try {
const accountService = ClineAccountService.getInstance()
const activeOrgId = authService.getActiveOrganizationId()
if (activeOrgId) {
// Fetch organization balance
const orgBalance = await accountService.fetchOrganizationCreditsRPC(activeOrgId)
if (orgBalance?.balance !== undefined) {
setBalance(orgBalance.balance)
}
} else {
// Fetch personal balance
const balanceData = await accountService.fetchBalanceRPC()
if (balanceData?.balance !== undefined) {
setBalance(balanceData.balance)
}
}
} catch {
// Balance fetch failed, but we can still show other info
// Don't log to console as it pollutes CLI output
}
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load account info")
} finally {
setIsLoading(false)
}
}, [controller])
useEffect(() => {
fetchAccountInfo()
}, [fetchAccountInfo])
if (isLoading) {
return (
<Box>
<LoadingSpinner />
<Text color="gray"> Loading account info...</Text>
</Box>
)
}
if (error) {
return (
<Box>
<Text color="red">Error: {error}</Text>
</Box>
)
}
// If not using Cline provider, just show the provider name
if (provider !== "cline") {
return (
<Box>
<Text color="gray">Provider: </Text>
<Text color="cyan">{capitalize(provider || "Not configured")}</Text>
</Box>
)
}
// Cline provider but not logged in
if (!email) {
return (
<Box>
<Text color="gray">Provider: </Text>
<Text color="cyan">Cline</Text>
<Text color="gray"> </Text>
<Text color="yellow">Not logged in (run 'cline auth' to sign in)</Text>
</Box>
)
}
// Cline provider - show full account info
return (
<Box flexDirection="column">
<Box>
<Text color="gray">Provider: </Text>
<Text color="cyan">Cline</Text>
{email && (
<Box>
<Text color="gray"> </Text>
<Text color="white">{email}</Text>
</Box>
)}
</Box>
<Box>
{organization ? (
<Box>
<Text color="gray">Organization: </Text>
<Text color="magenta">{organization.name}</Text>
</Box>
) : (
<Box>
<Text color="gray">Account: </Text>
<Text color="white">Personal</Text>
</Box>
)}
<Text color="gray"> Credits: </Text>
<Text color="green">{formatBalance(balance)}</Text>
</Box>
</Box>
)
})
-105
View File
@@ -1,105 +0,0 @@
import { Text } from "ink"
import { render } from "ink-testing-library"
import React from "react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { App } from "./App"
// Mock the child components to isolate App routing logic
vi.mock("./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")
})
})
})
-278
View File
@@ -1,278 +0,0 @@
/**
* Main App component for Ink CLI
* Routes between different views (task, history, config)
*/
import { Box } from "ink"
import React, { ReactNode, useCallback, useState } from "react"
import { StdinProvider } from "../context/StdinContext"
import { TaskContextProvider } from "../context/TaskContext"
import { AuthView } from "./AuthView"
import { ChatView } from "./ChatView"
import { ConfigView } from "./ConfigView"
import { HistoryView } from "./HistoryView"
import { TaskJsonView } from "./TaskJsonView"
import { TaskView } from "./TaskView"
export type ViewType = "task" | "history" | "config" | "auth" | "welcome"
interface HistoryPagination {
page: number
totalPages: number
totalCount: number
limit: number
}
interface HookInfo {
name: string
enabled: boolean
absolutePath: string
}
interface WorkspaceHooks {
workspaceName: string
hooks: HookInfo[]
}
interface SkillInfo {
name: string
description: string
path: string
enabled: boolean
}
interface AppProps {
view: ViewType
taskId?: string
controller?: any
// Output Style
verbose?: boolean
jsonOutput?: boolean
// Status Callbacks
onComplete?: () => void
onError?: () => void
// For history view
historyItems?: Array<{ id: string; ts: number; task?: string; totalCost?: number; modelId?: string }>
historyAllItems?: Array<{ id: string; ts: number; task?: string; totalCost?: number; modelId?: string }>
historyPagination?: HistoryPagination
onHistoryPageChange?: (page: number) => void
// For config view
dataDir?: string
globalState?: Record<string, any>
workspaceState?: Record<string, any>
// Rules toggles
globalClineRulesToggles?: Record<string, boolean>
localClineRulesToggles?: Record<string, boolean>
localCursorRulesToggles?: Record<string, boolean>
localWindsurfRulesToggles?: Record<string, boolean>
localAgentsRulesToggles?: Record<string, boolean>
onToggleRule?: (isGlobal: boolean, rulePath: string, enabled: boolean, ruleType: string) => void
// Workflow toggles
globalWorkflowToggles?: Record<string, boolean>
localWorkflowToggles?: Record<string, boolean>
onToggleWorkflow?: (isGlobal: boolean, workflowPath: string, enabled: boolean) => void
// Hooks
hooksEnabled?: boolean
globalHooks?: HookInfo[]
workspaceHooks?: WorkspaceHooks[]
onToggleHook?: (isGlobal: boolean, hookName: string, enabled: boolean, workspaceName?: string) => void
// Skills
skillsEnabled?: boolean
globalSkills?: SkillInfo[]
localSkills?: SkillInfo[]
onToggleSkill?: (isGlobal: boolean, skillPath: string, enabled: boolean) => void
// For auth view
authQuickSetup?: {
provider?: string
apikey?: string
modelid?: string
baseurl?: string
}
// For welcome view
onWelcomeSubmit?: (prompt: string, imagePaths: string[]) => void
onWelcomeExit?: () => void
// Stdin support
isRawModeSupported?: boolean
// Robot position (calculated before Ink mounts)
robotTopRow?: number
}
export const App: React.FC<AppProps> = ({
view: initialView,
taskId,
verbose = false,
jsonOutput = false,
controller,
onComplete,
onError,
historyItems = [],
historyAllItems,
historyPagination,
onHistoryPageChange,
dataDir = "",
globalState = {},
workspaceState = {},
// Rules
globalClineRulesToggles,
localClineRulesToggles,
localCursorRulesToggles,
localWindsurfRulesToggles,
localAgentsRulesToggles,
onToggleRule,
// Workflows
globalWorkflowToggles,
localWorkflowToggles,
onToggleWorkflow,
// Hooks
hooksEnabled,
globalHooks,
workspaceHooks,
onToggleHook,
// Skills
skillsEnabled,
globalSkills,
localSkills,
onToggleSkill,
authQuickSetup,
onWelcomeSubmit,
onWelcomeExit,
isRawModeSupported = true,
robotTopRow,
}) => {
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 ? (
<TaskJsonView 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 = (
<TaskContextProvider controller={controller}>
<ChatView
controller={controller}
onComplete={onComplete}
onError={onError}
onExit={onWelcomeExit}
robotTopRow={robotTopRow}
/>
</TaskContextProvider>
)
break
default:
content = null
}
return (
<StdinProvider isRawModeSupported={isRawModeSupported}>
<Box>{content}</Box>
</StdinProvider>
)
}
File diff suppressed because it is too large Load Diff
-378
View File
@@ -1,378 +0,0 @@
/**
* User input prompt component
* Handles different types of user interactions (text input, confirmations, choices)
*/
import type { ClineAsk } from "@shared/ExtensionMessage"
import { Box, Text, useApp, useInput } from "ink"
import React, { useCallback, useEffect, useRef, useState } from "react"
import { useStdinContext } from "../context/StdinContext"
import { useTaskController } from "../context/TaskContext"
import { useLastCompletedAskMessage } from "../hooks/useStateSubscriber"
import { jsonParseSafe } from "../utils/parser"
import { getCliMessagePrefixIcon } from "./MessageRow"
interface AskPromptProps {
onRespond?: (response: string) => void
}
type PromptType = "confirmation" | "text" | "options" | "plan_mode_text" | "completion" | "exit_confirmation" | "none"
function getPromptType(ask: ClineAsk, text: string): PromptType {
switch (ask) {
case "followup": {
const parts = jsonParseSafe(text, {
question: undefined as string | undefined,
options: undefined as string[] | undefined,
})
if (parts.options && parts.options.length > 0) {
return "options"
}
return "text"
}
case "plan_mode_respond": {
const parts = jsonParseSafe(text, {
question: undefined as string | undefined,
options: undefined as string[] | undefined,
})
if (parts.options && parts.options.length > 0) {
return "options"
}
// Plan mode without options - allow text input or toggle to Act mode
return "plan_mode_text"
}
case "completion_result":
// Task completed - allow follow-up question or exit
return "completion"
case "resume_task":
case "resume_completed_task":
return "exit_confirmation"
case "command":
case "tool":
case "browser_action_launch":
case "use_mcp_server":
return "confirmation"
default:
return "none"
}
}
export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
const { exit } = useApp()
const { isRawModeSupported } = useStdinContext()
const controller = useTaskController()
const lastAskMessage = useLastCompletedAskMessage()
const [textInput, setTextInput] = useState("")
const [responded, setResponded] = useState(false)
const lastAskTs = useRef<number | null>(null)
// Reset state when ask message changes
useEffect(() => {
if (lastAskMessage && lastAskMessage.ts !== lastAskTs.current) {
lastAskTs.current = lastAskMessage.ts
setTextInput("")
setResponded(false)
}
}, [lastAskMessage])
const sendResponse = useCallback(
async (responseType: string, text?: string) => {
if (responded || !controller?.task) {
return
}
setResponded(true)
try {
await controller.task.handleWebviewAskResponse(responseType, text)
onRespond?.(text || responseType)
} catch {
// Controller may be disposed
}
},
[controller, responded, onRespond],
)
const toggleToActMode = useCallback(async () => {
if (responded || !controller) {
return
}
setResponded(true)
try {
await controller.togglePlanActMode("act")
onRespond?.("Switched to Act mode")
} catch {
// Controller may be disposed
}
}, [controller, responded, onRespond])
// Handle keyboard input
useInput(
(input, key) => {
if (!lastAskMessage || responded) {
return
}
const ask = lastAskMessage.ask as ClineAsk
const text = lastAskMessage.text || ""
const promptType = getPromptType(ask, text)
if (promptType === "confirmation" || promptType === "exit_confirmation") {
// y/n confirmation
if (input.toLowerCase() === "y") {
sendResponse("yesButtonClicked")
} else if (input.toLowerCase() === "n") {
if (promptType === "exit_confirmation") {
exit()
return
}
sendResponse("noButtonClicked")
}
} else if (promptType === "options") {
// Number selection for options, or free text input
const parts = jsonParseSafe(text, { options: [] as string[] })
if (key.return) {
// Submit free text on Enter
if (textInput.trim()) {
sendResponse("messageResponse", textInput.trim())
}
} else if (key.backspace || key.delete) {
setTextInput((prev) => prev.slice(0, -1))
} else if (input && !key.ctrl && !key.meta) {
// Check if it's a number for option selection (only when no text typed yet)
const num = parseInt(input, 10)
if (textInput === "" && !Number.isNaN(num) && num >= 1 && num <= parts.options.length) {
const selectedOption = parts.options[num - 1]
sendResponse("optionSelected", selectedOption)
} else {
// Regular character input for free text
setTextInput((prev) => prev + input)
}
}
} else if (promptType === "text") {
// Text input mode
if (key.return) {
// Submit on Enter
if (textInput.trim()) {
sendResponse("messageResponse", textInput.trim())
}
} else if (key.backspace || key.delete) {
setTextInput((prev) => prev.slice(0, -1))
} else if (input && !key.ctrl && !key.meta) {
// Regular character input
setTextInput((prev) => prev + input)
}
} else if (promptType === "plan_mode_text") {
// Plan mode text input - allows text response or toggle to Act mode
if (key.return) {
// Submit on Enter
if (textInput.trim()) {
sendResponse("messageResponse", textInput.trim())
} else {
// Empty enter = switch to Act mode
toggleToActMode()
}
} else if (key.backspace || key.delete) {
setTextInput((prev) => prev.slice(0, -1))
} else if (input && !key.ctrl && !key.meta) {
// Regular character input
setTextInput((prev) => prev + input)
}
} else if (promptType === "completion") {
// Task completed - allow follow-up question or exit
if (key.return) {
if (textInput.trim()) {
// Send follow-up question
sendResponse("messageResponse", textInput.trim())
} else {
// Empty enter = confirm completion (exit)
sendResponse("yesButtonClicked")
}
} else if (key.backspace || key.delete) {
setTextInput((prev) => prev.slice(0, -1))
} else if (input && !key.ctrl && !key.meta) {
// Regular character input
setTextInput((prev) => prev + input)
}
}
},
{ isActive: isRawModeSupported && !!lastAskMessage && !responded },
)
if (!lastAskMessage || responded) {
return null
}
const ask = lastAskMessage.ask as ClineAsk
const text = lastAskMessage.text || ""
const promptType = getPromptType(ask, text)
const icon = getCliMessagePrefixIcon(lastAskMessage)
if (promptType === "none") {
return null
}
switch (ask) {
case "followup": {
const parts = jsonParseSafe(text, {
question: undefined as string | undefined,
options: undefined as string[] | undefined,
})
if (parts.options && parts.options.length > 0) {
return (
<Box flexDirection="column" marginTop={1}>
<Text color="cyan">Select an option (enter number):</Text>
{parts.options.map((opt, idx) => (
<Box key={idx} marginLeft={2}>
<Text>{`${idx + 1}. ${opt}`}</Text>
</Box>
))}
<Box marginTop={1}>
<Text>{icon} </Text>
<Text color="cyan">Or type: </Text>
<Text>{textInput}</Text>
<Text color="gray"></Text>
</Box>
<Text color="gray" dimColor>
(Enter number to select, or type response + Enter)
</Text>
</Box>
)
}
// Text input prompt
return (
<Box flexDirection="column" marginTop={1}>
<Box>
<Text>{icon} </Text>
<Text color="cyan">Reply: </Text>
<Text>{textInput}</Text>
<Text color="gray"></Text>
</Box>
<Text color="gray" dimColor>
(Type your response and press Enter)
</Text>
</Box>
)
}
case "plan_mode_respond": {
const parts = jsonParseSafe(text, {
question: undefined as string | undefined,
options: undefined as string[] | undefined,
})
if (parts.options && parts.options.length > 0) {
return (
<Box flexDirection="column" marginTop={1}>
<Text color="cyan">Select an option (enter number):</Text>
{parts.options.map((opt, idx) => (
<Box key={idx} marginLeft={2}>
<Text>{`${idx + 1}. ${opt}`}</Text>
</Box>
))}
<Box marginTop={1}>
<Text>{icon} </Text>
<Text color="cyan">Or type: </Text>
<Text>{textInput}</Text>
<Text color="gray"></Text>
</Box>
<Text color="gray" dimColor>
(Enter number to select, or type response + Enter)
</Text>
</Box>
)
}
// Plan mode text input - show option to switch to Act mode
return (
<Box flexDirection="column" marginTop={1}>
<Box>
<Text>{icon} </Text>
<Text color="cyan">Reply: </Text>
<Text>{textInput}</Text>
<Text color="gray"></Text>
</Box>
<Text color="gray" dimColor>
(Type response + Enter, or just Enter to switch to Act mode)
</Text>
</Box>
)
}
case "command":
return (
<Box flexDirection="column" marginTop={1}>
<Box>
<Text>{icon} </Text>
<Text color="yellow"> Execute this command? </Text>
<Text color="gray">(y/n)</Text>
</Box>
</Box>
)
case "tool":
return (
<Box flexDirection="column" marginTop={1}>
<Box>
<Text>{icon} </Text>
<Text color="blue"> Use this tool? </Text>
<Text color="gray">(y/n)</Text>
</Box>
</Box>
)
case "completion_result":
return (
<Box flexDirection="column" marginTop={1}>
<Box>
<Text>{icon} </Text>
<Text color="cyan">Follow-up: </Text>
<Text>{textInput}</Text>
<Text color="gray"></Text>
</Box>
<Text color="gray" dimColor>
(Type follow-up question + Enter, or q to exit)
</Text>
</Box>
)
case "resume_task":
case "resume_completed_task":
return (
<Box flexDirection="column" marginTop={1}>
<Box>
<Text>{icon} </Text>
<Text color="cyan"> Resume task? </Text>
<Text color="gray">(y/n)</Text>
</Box>
</Box>
)
case "browser_action_launch":
return (
<Box flexDirection="column" marginTop={1}>
<Box>
<Text>{icon} </Text>
<Text color="cyan"> Launch browser? </Text>
<Text color="gray">(y/n)</Text>
</Box>
</Box>
)
case "use_mcp_server":
return (
<Box flexDirection="column" marginTop={1}>
<Box>
<Text>{icon} </Text>
<Text color="cyan"> Use MCP server? </Text>
<Text color="gray">(y/n)</Text>
</Box>
</Box>
)
default:
return null
}
}
-588
View File
@@ -1,588 +0,0 @@
/**
* 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> = {
actModeApiProvider: "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>
)
}
-441
View File
@@ -1,441 +0,0 @@
/**
* Claude Code style chat message component
* Renders messages with:
* - for user messages
* - ⏺ for assistant messages and tool calls
* - ⎿ for tool results (indented)
*/
import type { ClineMessage } from "@shared/ExtensionMessage"
import { Box, Text } from "ink"
import React from "react"
import { jsonParseSafe } from "../utils/parser"
/**
* Render inline markdown: **bold**, *italic*, `code`
* Returns array of React nodes with appropriate styling
*/
function renderInlineMarkdown(text: string): React.ReactNode[] {
const nodes: React.ReactNode[] = []
// Match **bold**, *italic*, or `code` - order matters (** before *)
const regex = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g
let lastIndex = 0
let match
while ((match = regex.exec(text)) !== null) {
// Add text before match
if (match.index > lastIndex) {
nodes.push(text.slice(lastIndex, match.index))
}
const fullMatch = match[0]
const key = `md-${match.index}`
if (fullMatch.startsWith("**") && fullMatch.endsWith("**")) {
// Bold
nodes.push(
<Text bold key={key}>
{fullMatch.slice(2, -2)}
</Text>,
)
} else if (fullMatch.startsWith("*") && fullMatch.endsWith("*")) {
// Italic
nodes.push(
<Text italic key={key}>
{fullMatch.slice(1, -1)}
</Text>,
)
} else if (fullMatch.startsWith("`") && fullMatch.endsWith("`")) {
// Inline code
nodes.push(
<Text dimColor key={key}>
{fullMatch.slice(1, -1)}
</Text>,
)
}
lastIndex = regex.lastIndex
}
// Add remaining text
if (lastIndex < text.length) {
nodes.push(text.slice(lastIndex))
}
return nodes.length > 0 ? nodes : [text]
}
/**
* Render text with inline markdown support
*/
const MarkdownText: React.FC<{ children: string; color?: string }> = ({ children, color }) => {
const nodes = renderInlineMarkdown(children)
return <Text color={color}>{nodes}</Text>
}
interface ChatMessageProps {
message: ClineMessage
isStreaming?: boolean
}
// Tool name display mapping - convert internal names to user-friendly names
const TOOL_DISPLAY_NAMES: Record<string, string> = {
read_file: "Read",
write_to_file: "Write",
replace_in_file: "Edit",
execute_command: "Bash",
search_files: "Search",
list_files: "List",
list_code_definition_names: "Definitions",
browser_action: "Browser",
use_mcp_tool: "MCP",
access_mcp_resource: "MCP Resource",
ask_followup_question: "Question",
attempt_completion: "Complete",
web_fetch: "Fetch",
web_search: "WebSearch",
new_task: "Task",
focus_chain: "Todo",
}
/**
* Format tool call in Claude Code style
* e.g., Read(src/components/App.tsx)
*/
function formatToolCall(toolName: string, args: Record<string, any>): string {
const displayName = TOOL_DISPLAY_NAMES[toolName] || toolName
// Extract the most relevant argument for display
let mainArg = ""
if (args.path) {
mainArg = args.path
} else if (args.file_path) {
mainArg = args.file_path
} else if (args.command) {
mainArg = args.command.length > 50 ? args.command.substring(0, 47) + "..." : args.command
} else if (args.regex) {
mainArg = `pattern: "${args.regex}"`
if (args.file_pattern) {
mainArg += `, path: "${args.file_pattern}"`
}
} else if (args.url) {
mainArg = args.url
} else if (args.query) {
mainArg = args.query
} else if (args.question) {
mainArg = args.question.length > 40 ? args.question.substring(0, 37) + "..." : args.question
} else if (args.result) {
mainArg = args.result.length > 40 ? args.result.substring(0, 37) + "..." : args.result
}
return mainArg ? `${displayName}(${mainArg})` : displayName
}
/**
* Parse tool message to extract tool info
*/
function parseToolMessage(text: string): { toolName: string; args: Record<string, any>; result?: string } | null {
try {
const parsed = JSON.parse(text)
if (parsed.tool) {
return {
toolName: parsed.tool,
args: parsed,
result: parsed.content || parsed.output,
}
}
return null
} catch {
return null
}
}
/**
* Truncate text with ellipsis
*/
function truncate(text: string, maxLength: number): string {
if (text.length <= maxLength) return text
return text.substring(0, maxLength - 3) + "..."
}
/**
* Format tool result for display
*/
function formatToolResult(result: string, maxLines: number = 5): string[] {
const lines = result.split("\n")
if (lines.length <= maxLines) {
return lines
}
const displayLines = lines.slice(0, maxLines)
displayLines.push(`... ${lines.length - maxLines} more lines`)
return displayLines
}
export const ChatMessage: React.FC<ChatMessageProps> = ({ message, isStreaming }) => {
const { type, ask, say, text } = message
// User messages (task, user_feedback) - bubble style with background and >
if (say === "task" || say === "user_feedback") {
return (
<Box flexDirection="column" marginBottom={1}>
<Box backgroundColor="gray" paddingLeft={1} paddingRight={1} width="100%">
<Text color="white">&gt; {text}</Text>
</Box>
</Box>
)
}
// Assistant text response
if (say === "text") {
if (!text?.trim()) return null
return (
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text> </Text>
<MarkdownText>{text}</MarkdownText>
</Box>
</Box>
)
}
// Reasoning/thinking
if (say === "reasoning") {
if (!text?.trim()) return null
return (
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text color="yellow" italic>
{truncate(text, 200)}
</Text>
</Box>
</Box>
)
}
// Tool calls (ask for permission)
if (type === "ask" && ask === "tool" && text) {
const toolInfo = parseToolMessage(text)
if (toolInfo) {
return (
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text color="blue"> {formatToolCall(toolInfo.toolName, toolInfo.args)}</Text>
</Box>
</Box>
)
}
}
// Tool results (say tool)
if (say === "tool" && text) {
const toolInfo = parseToolMessage(text)
if (toolInfo) {
const hasResult = toolInfo.result && toolInfo.result.trim()
return (
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text color="green"> {formatToolCall(toolInfo.toolName, toolInfo.args)}</Text>
</Box>
{hasResult && (
<Box flexDirection="column" marginLeft={2}>
{formatToolResult(toolInfo.result!, 5).map((line, idx) => (
<Box key={idx}>
<Text dimColor>
{idx === 0 ? "⎿ " : " "}
{line}
</Text>
</Box>
))}
</Box>
)}
</Box>
)
}
// Fallback for unparseable tool messages
return (
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text color="green"> {truncate(text, 100)}</Text>
</Box>
</Box>
)
}
// Command execution ask
if (type === "ask" && ask === "command" && text) {
return (
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text> Bash({truncate(text, 60)})</Text>
</Box>
</Box>
)
}
// Command say (before execution)
if (say === "command" && text) {
return (
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text> Bash({truncate(text, 60)})</Text>
</Box>
</Box>
)
}
// Command output
if (say === "command_output" && text) {
const lines = formatToolResult(text, 8)
return (
<Box flexDirection="column" marginBottom={1}>
<Box flexDirection="column" marginLeft={2}>
{lines.map((line, idx) => (
<Box key={idx}>
<Text dimColor>
{idx === 0 ? "⎿ " : " "}
{line}
</Text>
</Box>
))}
</Box>
</Box>
)
}
// Error messages
if (say === "error") {
return (
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text color="red"> Error: {text}</Text>
</Box>
</Box>
)
}
// Completion result
if (say === "completion_result" || (type === "ask" && ask === "completion_result")) {
return (
<Box flexDirection="column" marginBottom={1}>
<Box flexDirection="column">
<Box>
<Text color="green"> Task completed</Text>
</Box>
{text && (
<Box marginLeft={2}>
<MarkdownText color="green">{text}</MarkdownText>
</Box>
)}
</Box>
</Box>
)
}
// API request info (show cost/tokens inline)
if (say === "api_req_started" && text) {
// Skip showing these - they're summarized in the status bar
return null
}
// Browser actions
if (say === "browser_action" || say === "browser_action_launch") {
return (
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text> Browser({truncate(text || "", 50)})</Text>
</Box>
</Box>
)
}
// MCP server
if (say === "mcp_server_request_started") {
return (
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text> MCP({truncate(text || "", 50)})</Text>
</Box>
</Box>
)
}
// Info messages
if (say === "info") {
return (
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text color="gray"> {text}</Text>
</Box>
</Box>
)
}
// Followup questions from assistant
if (type === "ask" && ask === "followup" && text) {
const parsed = jsonParseSafe(text, { question: undefined as string | undefined })
if (parsed.question) {
return (
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text> </Text>
<MarkdownText>{parsed.question}</MarkdownText>
</Box>
</Box>
)
}
}
// Plan mode response
if (type === "ask" && ask === "plan_mode_respond" && text) {
const parsed = jsonParseSafe(text, { response: undefined as string | undefined })
if (parsed.response) {
return (
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text color="yellow"> </Text>
<MarkdownText color="yellow">{parsed.response}</MarkdownText>
</Box>
</Box>
)
}
}
// Skip other message types
return null
}
/**
* Render a list of messages in Claude Code style
*/
interface ChatMessageListProps {
messages: ClineMessage[]
maxMessages?: number
}
export const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, maxMessages }) => {
// Filter out messages we don't want to display
const displayMessages = messages.filter((m) => {
// Skip api_req_finished, they're just markers
if (m.say === "api_req_finished") return false
// Skip empty text messages
if (m.say === "text" && !m.text?.trim()) return false
// Skip checkpoint messages
if (m.say === "checkpoint_created") return false
return true
})
// Optionally limit number of messages shown
const messagesToShow = maxMessages ? displayMessages.slice(-maxMessages) : displayMessages
// Check if last message is streaming
const lastMessage = messagesToShow[messagesToShow.length - 1]
const isLastStreaming = lastMessage?.partial === true
return (
<Box flexDirection="column">
{messagesToShow.map((msg, idx) => (
<ChatMessage isStreaming={idx === messagesToShow.length - 1 && isLastStreaming} key={msg.ts} message={msg} />
))}
</Box>
)
}
-752
View File
@@ -1,752 +0,0 @@
/**
* Unified Chat View component
* Combines the welcome screen layout with task message display
* Messages appear above the input field, input stays at bottom
*
* IMPORTANT: Ink Rendering and the "Flicker Problem"
* ==================================================
*
* Problem:
* When using Ink (React for CLI), we encountered severe flickering once the
* output grew to fill the terminal height. The top line would repeat endlessly,
* making the UI unusable.
*
* Root Cause:
* Ink uses two different rendering strategies based on output height:
*
* 1. When outputHeight < terminal rows: Ink uses efficient line-erasing
* (ansiEscapes.eraseLines) to update content in place. This is smooth.
*
* 2. When outputHeight >= terminal rows: Ink switches to clearTerminal() +
* full redraw on EVERY re-render. This causes visible flicker because:
* - The entire screen clears
* - Content redraws from scratch
* - This happens on every state change (typing, streaming, spinner, etc.)
*
* This is in Ink's onRender() method in ink.js:
* if (outputHeight >= this.options.stdout.rows) {
* this.options.stdout.write(ansiEscapes.clearTerminal + output);
* }
*
* The underlying issue is Ink's use of ansi-escapes clearTerminal which does
* ^[2J^[3J^[H (erase screen + erase scrollback + move home) instead of more
* efficient alternatives like ^[H^[0J (move home + erase to bottom).
*
* See: https://github.com/vadimdemedes/ink/issues/359
*
* Why Other CLIs (Claude Code, Gemini CLI) Don't Flicker:
* =======================================================
*
* They keep the dynamic region small by either:
* - Printing history as static output (logs that scroll up)
* - Using alternate buffer with internal scrolling
* - Never letting dynamic content reach terminal height
*
* Alternate Buffer Mode (What We Tried):
* Gemini CLI uses alternate screen buffer (\x1b[?1049h) which isolates the app
* in a separate screen (like vim/less). This eliminates flicker but has a major
* drawback: scroll wheel doesn't work without implementing custom mouse event
* handling. Gemini CLI built MouseProvider + ScrollProvider infrastructure to
* capture mouse events and handle scrolling manually.
*
* From Gemini CLI PR #13623:
* "We're setting the default for useAlternateBuffer back to false for now.
* We'll plan to re-enable once we support selection without ctrl-S, speed up
* scrolling on terminals that report fewer scroll events than ideal, and
* optimize performance issues related to very large tool messages."
*
* Our Solution: Static + Dynamic Split
* ====================================
*
* We use Ink's <Static> component to split content into two regions:
*
* 1. Static Region (header + completed messages):
* - Rendered ONCE when items are added
* - Stays above the dynamic region
* - Scrolls up like terminal logs
* - Never re-renders, so no flicker contribution
* - IMPORTANT: Content in Static cannot update after initial render
* (e.g., AccountInfoView showing "Loading..." would stay that way forever)
*
* 2. Dynamic Region (current streaming message + input + status):
* - Only contains actively changing content
* - Stays small (well under terminal height)
* - Uses efficient line-erasing, not clearTerminal
*
* Implementation Details:
* - loggedMessageTs: Set<number> tracks which messages have been rendered to Static
* - headerLogged: boolean tracks if the header has been rendered
* - staticItems: array of items to render in Static (header + new completed messages)
* - completedMessages: messages that are done (not partial/streaming)
* - currentMessage: the single message currently streaming (if any)
*
* When a message completes (partial becomes false), it moves from the dynamic
* region to Static. The dynamic region only ever contains 0-1 messages plus
* the input UI, keeping it well under terminal height.
*
* Other Important Settings:
* - patchConsole: false in render() options - prevents Ink from interfering with console
* - Console suppression in utils/console.ts - prevents core debug output from breaking Ink
*
* Centering in Static:
* Ink's Box centering (justifyContent, alignItems) doesn't work reliably inside
* Static. We use manual centering via centerText() which pads strings based on
* process.stdout.columns.
*
* References:
* - Ink flicker issue: https://github.com/vadimdemedes/ink/issues/359
* - Gemini CLI (uses same @jrichman/ink fork): https://github.com/google-gemini/gemini-cli
* - Gemini CLI alternate buffer PR: https://github.com/google-gemini/gemini-cli/pull/13623
* - Ink source: node_modules/ink/build/ink.js (onRender method)
* - log-update: node_modules/ink/build/log-update.js (eraseLines logic)
*/
import type { ClineAsk } from "@shared/ExtensionMessage"
import { getApiMetrics } from "@shared/getApiMetrics"
import type { Mode } from "@shared/storage/types"
import { execSync } from "child_process"
import { Box, Static, Text, useInput } from "ink"
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { StateManager } from "@/core/storage/StateManager"
import { useTaskContext, useTaskState } from "../context/TaskContext"
import { useIsSpinnerActive } from "../hooks/useStateSubscriber"
import {
checkAndWarnRipgrepMissing,
extractMentionQuery,
type FileSearchResult,
getRipgrepInstallInstructions,
insertMention,
searchWorkspaceFiles,
} from "../utils/file-search"
import { jsonParseSafe, parseImagesFromInput } from "../utils/parser"
import { AsciiMotionCli, StaticRobotFrame } from "./AsciiMotionCli"
import { ChatMessage } from "./ChatMessage"
import { FileMentionMenu } from "./FileMentionMenu"
import { ThinkingIndicator } from "./ThinkingIndicator"
interface ChatViewProps {
controller?: any
onExit?: () => void
onComplete?: () => void
onError?: () => void
robotTopRow?: number
}
const SEARCH_DEBOUNCE_MS = 150
const RIPGREP_WARNING_DURATION_MS = 5000
const MAX_SEARCH_RESULTS = 15
const DEFAULT_CONTEXT_WINDOW = 200000
/**
* Get current git branch name
*/
function getGitBranch(cwd?: string): string | null {
try {
const branch = execSync("git rev-parse --abbrev-ref HEAD", {
cwd: cwd || process.cwd(),
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
}).trim()
return branch
} catch {
return null
}
}
/**
* Create a progress bar for context window usage
* Returns { filled, empty } strings to allow different coloring
*/
function createContextBar(used: number, total: number, width: number = 8): { filled: string; empty: string } {
const ratio = Math.min(used / total, 1)
// Use ceil so any usage > 0 shows at least one bar
const filledCount = used > 0 ? Math.max(1, Math.ceil(ratio * width)) : 0
const emptyCount = width - filledCount
return { filled: "█".repeat(filledCount), empty: "█".repeat(emptyCount) }
}
/**
* Center text by padding with spaces
*/
function centerText(text: string, terminalWidth?: number): string {
const width = terminalWidth || process.stdout.columns || 80
const padding = Math.max(0, Math.floor((width - text.length) / 2))
return " ".repeat(padding) + text
}
/**
* Get the type of prompt needed for an ask message
*/
function getAskPromptType(ask: ClineAsk, text: string): "confirmation" | "text" | "options" | "none" {
switch (ask) {
case "followup":
case "plan_mode_respond": {
const parts = jsonParseSafe(text, { options: undefined as string[] | undefined })
if (parts.options && parts.options.length > 0) {
return "options"
}
return "text"
}
case "completion_result":
return "text"
case "resume_task":
case "resume_completed_task":
case "command":
case "tool":
case "browser_action_launch":
case "use_mcp_server":
return "confirmation"
default:
return "none"
}
}
/**
* Parse options from an ask message
*/
function parseAskOptions(text: string): string[] {
const parts = jsonParseSafe(text, { options: [] as string[] })
return parts.options || []
}
export const ChatView: React.FC<ChatViewProps> = ({ controller, onExit, onComplete: _onComplete, onError, robotTopRow }) => {
// Get task state from context
const taskState = useTaskState()
const { controller: taskController } = useTaskContext()
const { isActive: isSpinnerActive, startTime: spinnerStartTime } = useIsSpinnerActive()
// Input state
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 [respondedToAsk, setRespondedToAsk] = useState<number | null>(null)
// Track which messages have been rendered to Static (by timestamp)
// Using refs instead of state to avoid extra renders during streaming->static transition
const loggedMessageTsRef = useRef<Set<number>>(new Set())
const headerLoggedRef = useRef(false)
const [gitBranch, setGitBranch] = useState<string | null>(null)
// Mode state
const [mode, setMode] = useState<Mode>(() => {
const stateManager = StateManager.get()
return stateManager.getGlobalSettingsKey("mode") || "act"
})
const yolo = useMemo(() => StateManager.get().getGlobalSettingsKey("yoloModeToggled"), [])
// Get model ID based on current mode
const modelId = useMemo(() => {
const stateManager = StateManager.get()
const modelKey = mode === "act" ? "actModeApiModelId" : "planModeApiModelId"
return (stateManager.getGlobalSettingsKey(modelKey) as string) || "claude-sonnet-4-20250514"
}, [mode])
const toggleMode = useCallback(() => {
const newMode: Mode = mode === "act" ? "plan" : "act"
setMode(newMode)
const stateManager = StateManager.get()
stateManager.setGlobalState("mode", newMode)
}, [mode])
const refs = useRef({
searchTimeout: null as NodeJS.Timeout | null,
lastQuery: "",
hasCheckedRipgrep: false,
})
const { prompt, imagePaths } = parseImagesFromInput(textInput)
const mentionInfo = useMemo(() => extractMentionQuery(textInput), [textInput])
const workspacePath = useMemo(() => {
try {
const ctrl = controller || taskController
const root = ctrl?.getWorkspaceManagerSync?.()?.getPrimaryRoot?.()
if (root?.path) {
return root.path
}
} catch {
// Fallback to cwd
}
return process.cwd()
}, [controller, taskController])
// Get git branch on mount
useEffect(() => {
setGitBranch(getGitBranch(workspacePath))
}, [workspacePath])
const messages = taskState.clineMessages || []
// Filter messages we want to display
const displayMessages = useMemo(() => {
return messages.filter((m) => {
if (m.say === "api_req_finished") return false
if (m.say === "text" && !m.text?.trim()) return false
if (m.say === "checkpoint_created") return false
if (m.say === "api_req_started") return false
return true
})
}, [messages])
// Split messages into completed (for Static) and current (for dynamic region)
const { completedMessages, currentMessage } = useMemo(() => {
const completed: typeof displayMessages = []
let current: (typeof displayMessages)[0] | null = null
// Tool types that should skip dynamic rendering entirely.
// Plan and completion text tend to be very long, and because this typically
// exceeds the height of the user's terminal, it causes flashing issues
// (Ink uses clearTerminal when output >= terminal rows).
// These messages wait until complete before showing directly in static.
const skipDynamicTypes = new Set(["completion_result", "plan_mode_respond"])
for (let i = 0; i < displayMessages.length; i++) {
const msg = displayMessages[i]
const isLast = i === displayMessages.length - 1
// Check if this message type should skip dynamic rendering
const shouldSkipDynamic =
skipDynamicTypes.has(msg.say || "") || (msg.type === "ask" && skipDynamicTypes.has(msg.ask || ""))
if (msg.partial) {
// Message is still streaming
if (isLast && !shouldSkipDynamic) {
// Show in dynamic region (normal streaming)
current = msg
}
// If shouldSkipDynamic and partial: don't show anywhere, wait for complete
} else {
// Message is complete, add to static
completed.push(msg)
}
}
return { completedMessages: completed, currentMessage: current }
}, [displayMessages])
// Determine if we're in welcome state (no messages yet)
const isWelcomeState = displayMessages.length === 0
// Build Static items - each item is rendered once and stays above dynamic content
const staticItems = useMemo(() => {
const items: Array<{ type: "header" } | { type: "message"; message: (typeof displayMessages)[0] }> = []
// Add header as first item ONLY after messages start (so animated robot shows first)
// Once messages exist, add header to static so it scrolls up with history
if (!headerLoggedRef.current && displayMessages.length > 0) {
items.push({ type: "header" })
headerLoggedRef.current = true
}
// Add completed messages that haven't been logged yet
for (const msg of completedMessages) {
if (!loggedMessageTsRef.current.has(msg.ts)) {
items.push({ type: "message", message: msg })
loggedMessageTsRef.current.add(msg.ts)
}
}
return items
}, [completedMessages, displayMessages.length])
// Check for pending ask message
const lastMessage = messages[messages.length - 1]
const pendingAsk =
lastMessage?.type === "ask" && !lastMessage.partial && respondedToAsk !== lastMessage.ts ? lastMessage : null
const askType = pendingAsk ? getAskPromptType(pendingAsk.ask as ClineAsk, pendingAsk.text || "") : "none"
const askOptions = pendingAsk && askType === "options" ? parseAskOptions(pendingAsk.text || "") : []
// Send response to ask message
const sendAskResponse = useCallback(
async (responseType: string, text?: string) => {
const ctrl = controller || taskController
if (!ctrl?.task || !pendingAsk) return
setRespondedToAsk(pendingAsk.ts)
setTextInput("")
try {
await ctrl.task.handleWebviewAskResponse(responseType, text)
} catch {
// Controller may be disposed
}
},
[controller, taskController, pendingAsk],
)
// Handle task submission (new task)
const handleSubmit = useCallback(
async (text: string, images: string[]) => {
const ctrl = controller || taskController
if (!ctrl || !text.trim()) return
setTextInput("")
try {
// Convert image paths to data URLs if needed
const imageDataUrls =
images.length > 0
? await Promise.all(
images.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 ctrl.initTask(text.trim(), validImages.length > 0 ? validImages : undefined)
} catch (_error) {
onError?.()
}
},
[controller, taskController, onError],
)
// 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])
// Handle keyboard input
useInput((input, key) => {
// Filter out mouse escape sequences from AsciiMotionCli's mouse tracking
// Mouse events look like: [<35;46;17M or contain escape characters
if (input.includes("\x1b") || input.includes("[<") || /\d+;\d+[Mm]/.test(input)) {
return
}
const inMenu = mentionInfo.inMentionMode && fileResults.length > 0
// Menu navigation
if (inMenu) {
if (key.upArrow) {
setSelectedIndex((i) => (i > 0 ? i - 1 : fileResults.length - 1))
return
}
if (key.downArrow) {
setSelectedIndex((i) => (i < fileResults.length - 1 ? i + 1 : 0))
return
}
if (key.tab || key.return) {
const file = fileResults[selectedIndex]
if (file) {
setTextInput(insertMention(textInput, mentionInfo.atIndex, file.path))
setFileResults([])
setSelectedIndex(0)
}
return
}
if (key.escape) {
setFileResults([])
setSelectedIndex(0)
return
}
}
// Handle ask responses
if (pendingAsk && !yolo) {
if (askType === "confirmation") {
// y/n confirmation
if (input.toLowerCase() === "y") {
sendAskResponse("yesButtonClicked")
return
} else if (input.toLowerCase() === "n") {
if (pendingAsk.ask === "resume_task" || pendingAsk.ask === "resume_completed_task") {
onExit?.()
return
}
sendAskResponse("noButtonClicked")
return
}
} else if (askType === "options") {
// Number selection for options, or free text
if (key.return && textInput.trim()) {
sendAskResponse("messageResponse", textInput.trim())
return
}
// 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 <= askOptions.length) {
const selectedOption = askOptions[num - 1]
sendAskResponse("optionSelected", selectedOption)
return
}
} else if (askType === "text") {
// Text input mode
if (key.return && textInput.trim()) {
sendAskResponse("messageResponse", textInput.trim())
return
}
}
}
// Normal input handling
if (key.tab && !mentionInfo.inMentionMode) {
toggleMode()
return
}
if (key.return && !mentionInfo.inMentionMode && !pendingAsk) {
if (prompt.trim() || imagePaths.length > 0) {
handleSubmit(prompt.trim(), imagePaths)
}
return
}
if (key.escape && !mentionInfo.inMentionMode) {
if (escPressedOnce) {
onExit?.()
} else {
setEscPressedOnce(true)
}
return
}
if (key.backspace || key.delete) {
setTextInput((prev) => prev.slice(0, -1))
setEscPressedOnce(false)
return
}
if (input && !key.ctrl && !key.meta && !key.upArrow && !key.downArrow && !key.tab) {
setTextInput((prev) => prev + input)
setEscPressedOnce(false)
}
})
const borderColor = mode === "act" ? "blueBright" : "yellow"
const metrics = getApiMetrics(messages)
// Determine input placeholder/prompt text
let inputPrompt = ""
if (pendingAsk && !yolo) {
if (askType === "confirmation") {
inputPrompt = "(y/n)"
}
}
return (
<Box>
{/* Static content - rendered once, stays above dynamic region */}
<Static items={staticItems}>
{(item) => {
if (item.type === "header") {
// Show static robot frame in header (first frame, looking straight ahead)
return (
<Box flexDirection="column" key="header">
<StaticRobotFrame />
<Text> </Text>
<Text bold color="white">
{centerText("What can I do for you?")}
</Text>
<Text> </Text>
</Box>
)
}
// Completed message
return <ChatMessage key={item.message.ts} message={item.message} />
}}
</Static>
{/* Dynamic region - only current streaming message + input */}
<Box flexDirection="column" width="100%">
{/* Animated robot and welcome text - only shown before messages start */}
{isWelcomeState && (
<Box flexDirection="column" marginBottom={1}>
<AsciiMotionCli robotTopRow={robotTopRow} />
<Text> </Text>
<Text bold color="white">
{centerText("What can I do for you?")}
</Text>
</Box>
)}
{/* Current streaming message */}
{currentMessage && <ChatMessage isStreaming message={currentMessage} />}
{/* Ripgrep warning if needed */}
{showRipgrepWarning && (
<Box marginTop={1}>
<Text color="yellow">Warning: ripgrep not found - file search will be slower. </Text>
<Text color="gray">Install: {getRipgrepInstallInstructions()}</Text>
</Box>
)}
{/* Options list for ask prompts */}
{pendingAsk && askType === "options" && askOptions.length > 0 && !yolo && (
<Box flexDirection="column" marginBottom={1}>
{askOptions.map((opt, idx) => (
<Text color="gray" key={idx}>
{idx + 1}. {opt}
</Text>
))}
</Box>
)}
{/* Thinking indicator when processing */}
{isSpinnerActive && !pendingAsk && (
<Box marginBottom={1}>
<ThinkingIndicator mode={mode} startTime={spinnerStartTime} />
</Box>
)}
{/* Input field with border - ALWAYS shown */}
<Box
borderColor={borderColor}
borderStyle="round"
flexDirection="row"
justifyContent="space-between"
paddingLeft={1}
paddingRight={1}
width="100%">
<Box>
{inputPrompt && <Text color="yellow">{inputPrompt} </Text>}
<Text>{textInput}</Text>
<Text color="gray"></Text>
</Box>
<Text color="gray" dimColor>
send
</Text>
</Box>
{/* File mention menu - below input */}
{mentionInfo.inMentionMode && (
<Box paddingLeft={1} paddingRight={1}>
<FileMentionMenu
isLoading={isSearching}
query={mentionInfo.query}
results={fileResults}
selectedIndex={selectedIndex}
/>
</Box>
)}
{/* Attached images */}
{imagePaths.length > 0 && (
<Box paddingLeft={1} paddingRight={1}>
<Text color="magenta">
{imagePaths.length} image{imagePaths.length > 1 ? "s" : ""} attached
</Text>
</Box>
)}
{/* Row 1: Instructions (left, can wrap) | Plan/Act toggle (right, no wrap) */}
<Box justifyContent="space-between" paddingLeft={1} paddingRight={1} width="100%">
<Box flexShrink={1} flexWrap="wrap">
<Text color="gray" dimColor>
@ for files · / for commands ·{" "}
</Text>
<Text bold={escPressedOnce} color={escPressedOnce ? "white" : "gray"} dimColor={!escPressedOnce}>
{escPressedOnce ? "Press Esc again to exit" : "Esc to exit"}
</Text>
</Box>
<Box flexShrink={0} 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" ? "blueBright" : "gray"}>
{mode === "act" ? "●" : "○"} Act
</Text>
</Box>
<Text color="gray" dimColor>
(Tab)
</Text>
</Box>
</Box>
{/* Row 2: Model/context/tokens/cost */}
<Box paddingLeft={1} paddingRight={1}>
<Text>
<Text color="gray">{modelId.length > 20 ? modelId.substring(0, 17) + "..." : modelId}</Text> {(() => {
const bar = createContextBar(metrics.totalTokensIn + metrics.totalTokensOut, DEFAULT_CONTEXT_WINDOW)
return (
<>
<Text color="gray">{bar.filled}</Text>
<Text color="gray" dimColor>
{bar.empty}
</Text>
</>
)
})()}
<Text color="gray"> ({(metrics.totalTokensIn + metrics.totalTokensOut).toLocaleString()})</Text>
<Text color="gray"> | </Text>
<Text color="gray">${metrics.totalCost.toFixed(3)}</Text>
</Text>
</Box>
{/* Row 3: Repo/branch */}
<Box paddingLeft={1} paddingRight={1}>
<Text color="gray">
{workspacePath.split("/").pop() || workspacePath}
{gitBranch && (
<Text color="gray">
{" "}
(<Text color="gray">{gitBranch}</Text>)
</Text>
)}
</Text>
</Box>
</Box>
</Box>
)
}
-213
View File
@@ -1,213 +0,0 @@
/**
* Checkpoint menu component
* Displays available checkpoints and allows user to select one to restore
*/
import type { ClineMessage } from "@shared/ExtensionMessage"
import { Box, Text, useInput } from "ink"
import React, { useState } from "react"
import { useStdinContext } from "../context/StdinContext"
export type RestoreType = "task" | "workspace" | "taskAndWorkspace"
interface CheckpointOption {
ts: number
hash: string
date: Date
label: string
}
interface CheckpointMenuProps {
messages: ClineMessage[]
onSelect: (messageTs: number, restoreType: RestoreType) => void
onCancel: () => void
}
/**
* Extract checkpoint options from messages
*/
function getCheckpointOptions(messages: ClineMessage[]): CheckpointOption[] {
const options: CheckpointOption[] = []
for (const msg of messages) {
if (msg.lastCheckpointHash) {
options.push({
ts: msg.ts,
hash: msg.lastCheckpointHash,
date: new Date(msg.ts),
label: getCheckpointLabel(msg),
})
}
}
// Sort by timestamp descending (newest first)
return options.sort((a, b) => b.ts - a.ts)
}
/**
* Get a human-readable label for a checkpoint
*/
function getCheckpointLabel(msg: ClineMessage): string {
if (msg.say === "completion_result") {
return "Task completion"
}
if (msg.say === "checkpoint_created") {
return "Checkpoint"
}
if (msg.say === "api_req_started") {
return "API request"
}
return msg.say || msg.ask || "Message"
}
const RESTORE_TYPE_OPTIONS: { type: RestoreType; label: string; description: string }[] = [
{
type: "taskAndWorkspace",
label: "Task + Workspace",
description: "Restore messages and files",
},
{
type: "task",
label: "Task Only",
description: "Delete messages after this point",
},
{
type: "workspace",
label: "Workspace Only",
description: "Restore files only",
},
]
export const CheckpointMenu: React.FC<CheckpointMenuProps> = ({ messages, onSelect, onCancel }) => {
const { isRawModeSupported } = useStdinContext()
const checkpoints = getCheckpointOptions(messages)
const [selectedCheckpoint, setSelectedCheckpoint] = useState(0)
const [selectedRestoreType, setSelectedRestoreType] = useState(0)
const [stage, setStage] = useState<"checkpoint" | "restoreType">("checkpoint")
useInput(
(input, key) => {
if (key.escape) {
if (stage === "restoreType") {
setStage("checkpoint")
} else {
onCancel()
}
return
}
if (stage === "checkpoint") {
if (key.upArrow) {
setSelectedCheckpoint((i) => Math.max(0, i - 1))
} else if (key.downArrow) {
setSelectedCheckpoint((i) => Math.min(checkpoints.length - 1, i + 1))
} else if (key.return && checkpoints.length > 0) {
setStage("restoreType")
}
} else if (stage === "restoreType") {
if (key.upArrow) {
setSelectedRestoreType((i) => Math.max(0, i - 1))
} else if (key.downArrow) {
setSelectedRestoreType((i) => Math.min(RESTORE_TYPE_OPTIONS.length - 1, i + 1))
} else if (key.return) {
const checkpoint = checkpoints[selectedCheckpoint]
const restoreType = RESTORE_TYPE_OPTIONS[selectedRestoreType]
if (checkpoint && restoreType) {
onSelect(checkpoint.ts, restoreType.type)
}
}
}
// Quick number selection for checkpoints
if (stage === "checkpoint") {
const num = parseInt(input, 10)
if (!Number.isNaN(num) && num >= 1 && num <= checkpoints.length) {
setSelectedCheckpoint(num - 1)
setStage("restoreType")
}
}
},
{ isActive: isRawModeSupported },
)
if (checkpoints.length === 0) {
return (
<Box borderColor="yellow" borderStyle="round" flexDirection="column" marginTop={1} paddingLeft={1} paddingRight={1}>
<Text color="yellow">No checkpoints available</Text>
<Text color="gray" 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>
)
}
-199
View File
@@ -1,199 +0,0 @@
import { Text } from "ink"
import { render } from "ink-testing-library"
import React from "react"
import { beforeEach, describe, expect, it, vi } from "vitest"
// Create stable mock references using vi.hoisted - must be before any imports that use these modules
const { mockIsSettingsKey } = vi.hoisted(() => ({
mockIsSettingsKey: vi.fn((key: string) => key.startsWith("act") || key.startsWith("plan") || key === "mode"),
}))
vi.mock("./TaskView", () => ({
TaskView: ({ taskId, verbose }: any) =>
React.createElement(Text, null, `TaskView: ${taskId || "no-id"} verbose=${String(verbose)}`),
}))
// Mock the state-keys module - must be hoisted before ConfigView import
vi.mock("@shared/storage/state-keys", () => ({
isSettingsKey: mockIsSettingsKey,
SETTINGS_DEFAULTS: {
mode: "act",
actModeApiProvider: "anthropic",
},
GlobalStateAndSettings: {},
GlobalStateAndSettingsKey: {},
LocalState: {},
LocalStateKey: {},
}))
// Import ConfigView after mocks are set up
import { ConfigView } from "./ConfigView"
describe("ConfigView", () => {
const defaultProps = {
dataDir: "/home/user/.cline",
globalState: {},
workspaceState: {},
}
beforeEach(() => {
vi.clearAllMocks()
})
describe("rendering", () => {
it("should render the config header", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} />)
expect(lastFrame()).toContain("Configuration")
})
it("should display the data directory", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} dataDir="/custom/path" />)
expect(lastFrame()).toContain("/custom/path")
})
it("should display global state entries", () => {
const { lastFrame } = render(
<ConfigView
{...defaultProps}
globalState={{
mode: "act",
actModeApiProvider: "anthropic",
}}
/>,
)
expect(lastFrame()).toContain("mode")
expect(lastFrame()).toContain("act")
})
it("should display workspace state entries", () => {
const { lastFrame } = render(
<ConfigView
{...defaultProps}
workspaceState={{
customSetting: "value",
}}
/>,
)
expect(lastFrame()).toContain("customSetting")
expect(lastFrame()).toContain("value")
})
it("should show section headers", () => {
const { lastFrame } = render(
<ConfigView {...defaultProps} globalState={{ mode: "act" }} workspaceState={{ localKey: "localValue" }} />,
)
expect(lastFrame()).toContain("Global Settings")
})
})
describe("value formatting", () => {
it("should format boolean values", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ actModeSomeBool: true }} />)
expect(lastFrame()).toContain("true")
})
it("should format number values", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ actModeNumber: 42 }} />)
expect(lastFrame()).toContain("42")
})
it("should truncate long string values", () => {
const longString = "x".repeat(100)
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ actModeLongValue: longString }} />)
expect(lastFrame()).toContain("...")
})
it("should format object values as JSON", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ actModeObj: { nested: "value" } }} />)
expect(lastFrame()).toContain("nested")
})
})
describe("filtering", () => {
it("should exclude taskHistory key", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ taskHistory: [1, 2, 3], mode: "act" }} />)
expect(lastFrame()).not.toContain("taskHistory")
})
it("should exclude empty objects", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ emptyObj: {}, mode: "act" }} />)
expect(lastFrame()).not.toContain("emptyObj")
})
it("should exclude empty arrays", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ emptyArr: [], mode: "act" }} />)
expect(lastFrame()).not.toContain("emptyArr")
})
it("should exclude null/undefined values", () => {
const { lastFrame } = render(
<ConfigView {...defaultProps} globalState={{ nullVal: null, undefinedVal: undefined, mode: "act" }} />,
)
expect(lastFrame()).not.toContain("nullVal")
expect(lastFrame()).not.toContain("undefinedVal")
})
it("should exclude keys ending with Toggles", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ someToggles: { a: true }, mode: "act" }} />)
expect(lastFrame()).not.toContain("someToggles")
})
it("should exclude keys starting with apiConfig_", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ apiConfig_test: "value", mode: "act" }} />)
expect(lastFrame()).not.toContain("apiConfig_test")
})
})
describe("keyboard navigation", () => {
it("should show navigation help text", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ mode: "act" }} />)
expect(lastFrame()).toContain("Navigate")
expect(lastFrame()).toContain("Edit")
})
it("should highlight first item by default", () => {
const { lastFrame } = render(
<ConfigView {...defaultProps} globalState={{ mode: "act", actModeApiProvider: "anthropic" }} />,
)
// The selected indicator
expect(lastFrame()).toContain("")
})
it("should navigate down with arrow key", () => {
const { lastFrame, stdin } = render(
<ConfigView {...defaultProps} globalState={{ actModeFirst: "a", actModeSecond: "b" }} />,
)
// Press down arrow
stdin.write("\x1B[B")
const frame = lastFrame()
expect(frame).toContain("")
})
it("should navigate up with arrow key", () => {
const { lastFrame, stdin } = render(
<ConfigView {...defaultProps} globalState={{ actModeFirst: "a", actModeSecond: "b" }} />,
)
// Press down then up
stdin.write("\x1B[B")
stdin.write("\x1B[A")
expect(lastFrame()).toContain("")
})
})
describe("scrolling", () => {
it("should show scroll indicators when list is long", () => {
const manyEntries: Record<string, string> = {}
for (let i = 0; i < 20; i++) {
manyEntries[`actModeKey${i}`] = `value${i}`
}
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={manyEntries} />)
expect(lastFrame()).toContain("Showing")
})
})
})
-555
View File
@@ -1,555 +0,0 @@
/**
* Interactive config view component for displaying and editing configuration values
* Supports tabs for Settings, Rules, Workflows, Hooks, and Skills
*/
import {
GlobalStateAndSettings,
GlobalStateAndSettingsKey,
LocalState,
LocalStateKey,
SETTINGS_DEFAULTS,
} from "@shared/storage/state-keys"
import { Box, Text, useApp, useInput } from "ink"
import React, { useMemo, useState } from "react"
import { useStdinContext } from "../context/StdinContext"
import {
BooleanSelect,
buildConfigEntries,
buildToggleEntries,
ConfigRow,
HookInfo,
HookRow,
MAX_VISIBLE,
parseValue,
SEPARATOR,
SectionHeader,
SkillInfo,
SkillRow,
TABS,
TabBar,
TabView,
TextInput,
ToggleEntry,
ToggleRow,
WorkspaceHooks,
} from "./ConfigViewComponents"
// ============================================================================
// Types
// ============================================================================
interface ConfigViewProps {
dataDir: string
globalState: Record<string, unknown>
workspaceState: Record<string, unknown>
onUpdateGlobal?: (key: GlobalStateAndSettingsKey, value: GlobalStateAndSettings[GlobalStateAndSettingsKey]) => void
onUpdateWorkspace?: (key: LocalStateKey, value: LocalState[LocalStateKey]) => void
// Rules toggles
globalClineRulesToggles?: Record<string, boolean>
localClineRulesToggles?: Record<string, boolean>
localCursorRulesToggles?: Record<string, boolean>
localWindsurfRulesToggles?: Record<string, boolean>
localAgentsRulesToggles?: Record<string, boolean>
onToggleRule?: (isGlobal: boolean, rulePath: string, enabled: boolean, ruleType: string) => void
// Workflow toggles
globalWorkflowToggles?: Record<string, boolean>
localWorkflowToggles?: Record<string, boolean>
onToggleWorkflow?: (isGlobal: boolean, workflowPath: string, enabled: boolean) => void
// Hooks
hooksEnabled?: boolean
globalHooks?: HookInfo[]
workspaceHooks?: WorkspaceHooks[]
onToggleHook?: (isGlobal: boolean, hookName: string, enabled: boolean, workspaceName?: string) => void
// Skills
skillsEnabled?: boolean
globalSkills?: SkillInfo[]
localSkills?: SkillInfo[]
onToggleSkill?: (isGlobal: boolean, skillPath: string, enabled: boolean) => void
// Open folder callback
onOpenFolder?: (folderType: "rules" | "workflows" | "hooks" | "skills", isGlobal: boolean) => void
}
// ============================================================================
// Main Component
// ============================================================================
export const ConfigView: React.FC<ConfigViewProps> = ({
dataDir,
globalState,
workspaceState,
onUpdateGlobal,
onUpdateWorkspace,
globalClineRulesToggles,
localClineRulesToggles,
localCursorRulesToggles,
localWindsurfRulesToggles,
localAgentsRulesToggles,
onToggleRule,
globalWorkflowToggles,
localWorkflowToggles,
onToggleWorkflow,
hooksEnabled,
globalHooks = [],
workspaceHooks = [],
onToggleHook,
skillsEnabled,
globalSkills = [],
localSkills = [],
onToggleSkill,
onOpenFolder,
}) => {
const { exit } = useApp()
const { isRawModeSupported } = useStdinContext()
const [currentTab, setCurrentTab] = useState<TabView>("settings")
const [isEditing, setIsEditing] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(0)
const [editValue, setEditValue] = useState("")
// Build entries for settings tab
const configEntries = useMemo(
() => [...buildConfigEntries(globalState, "global"), ...buildConfigEntries(workspaceState, "workspace")],
[globalState, workspaceState],
)
// Build entries for rules tab
const ruleEntries = useMemo(() => {
const entries: ToggleEntry[] = []
entries.push(...buildToggleEntries(globalClineRulesToggles, "global", "cline"))
entries.push(...buildToggleEntries(localClineRulesToggles, "workspace", "cline"))
entries.push(...buildToggleEntries(localCursorRulesToggles, "workspace", "cursor"))
entries.push(...buildToggleEntries(localWindsurfRulesToggles, "workspace", "windsurf"))
entries.push(...buildToggleEntries(localAgentsRulesToggles, "workspace", "agents"))
return entries
}, [
globalClineRulesToggles,
localClineRulesToggles,
localCursorRulesToggles,
localWindsurfRulesToggles,
localAgentsRulesToggles,
])
// Build entries for workflows tab
const workflowEntries = useMemo(() => {
const entries: ToggleEntry[] = []
entries.push(...buildToggleEntries(globalWorkflowToggles, "global"))
entries.push(...buildToggleEntries(localWorkflowToggles, "workspace"))
return entries
}, [globalWorkflowToggles, localWorkflowToggles])
// Build flat list of hooks
const hookEntries = useMemo(() => {
const entries: { hook: HookInfo; isGlobal: boolean; workspaceName?: string }[] = []
globalHooks.forEach((hook) => entries.push({ hook, isGlobal: true }))
workspaceHooks.forEach((ws) => {
ws.hooks.forEach((hook) => entries.push({ hook, isGlobal: false, workspaceName: ws.workspaceName }))
})
return entries.sort((a, b) => a.hook.name.localeCompare(b.hook.name))
}, [globalHooks, workspaceHooks])
// Build flat list of skills
const skillEntries = useMemo(() => {
const entries: { skill: SkillInfo; isGlobal: boolean }[] = []
globalSkills.forEach((skill) => entries.push({ skill, isGlobal: true }))
localSkills.forEach((skill) => entries.push({ skill, isGlobal: false }))
return entries.sort((a, b) => a.skill.name.localeCompare(b.skill.name))
}, [globalSkills, localSkills])
// Get current list length based on tab
const currentListLength = useMemo(() => {
switch (currentTab) {
case "settings":
return configEntries.length
case "rules":
return ruleEntries.length
case "workflows":
return workflowEntries.length
case "hooks":
return hookEntries.length
case "skills":
return skillEntries.length
default:
return 0
}
}, [currentTab, configEntries.length, ruleEntries.length, workflowEntries.length, hookEntries.length, skillEntries.length])
// Get available tabs
const availableTabs = useMemo(() => {
return TABS.filter((tab) => {
if (tab.requiresFlag === "hooks") {
return hooksEnabled
}
if (tab.requiresFlag === "skills") {
return skillsEnabled
}
return true
})
}, [hooksEnabled, skillsEnabled])
// Reset selection when changing tabs
const handleTabChange = (newTab: TabView) => {
setCurrentTab(newTab)
setSelectedIndex(0)
setIsEditing(false)
}
// Settings tab handlers
const selectedConfigEntry = configEntries[selectedIndex]
const handleSettingsSave = (value: string | boolean) => {
if (!selectedConfigEntry) {
return
}
const parsed = typeof value === "boolean" ? value : parseValue(value, selectedConfigEntry.type)
if (selectedConfigEntry.source === "global" && onUpdateGlobal) {
onUpdateGlobal(selectedConfigEntry.key as GlobalStateAndSettingsKey, parsed as never)
} else if (selectedConfigEntry.source === "workspace" && onUpdateWorkspace) {
onUpdateWorkspace(selectedConfigEntry.key as LocalStateKey, parsed as never)
}
setIsEditing(false)
}
const handleSettingsReset = () => {
if (!selectedConfigEntry?.isEditable || selectedConfigEntry.source !== "global") {
return
}
const defaultValue = (SETTINGS_DEFAULTS as Record<string, unknown>)[selectedConfigEntry.key]
if (defaultValue !== undefined && onUpdateGlobal) {
onUpdateGlobal(selectedConfigEntry.key as GlobalStateAndSettingsKey, defaultValue as never)
}
}
// Toggle handlers for rules/workflows/hooks/skills
const handleToggle = () => {
if (currentTab === "rules" && ruleEntries[selectedIndex] && onToggleRule) {
const entry = ruleEntries[selectedIndex]
onToggleRule(entry.source === "global", entry.path, !entry.enabled, entry.ruleType || "cline")
} else if (currentTab === "workflows" && workflowEntries[selectedIndex] && onToggleWorkflow) {
const entry = workflowEntries[selectedIndex]
onToggleWorkflow(entry.source === "global", entry.path, !entry.enabled)
} else if (currentTab === "hooks" && hookEntries[selectedIndex] && onToggleHook) {
const entry = hookEntries[selectedIndex]
onToggleHook(entry.isGlobal, entry.hook.name, !entry.hook.enabled, entry.workspaceName)
} else if (currentTab === "skills" && skillEntries[selectedIndex] && onToggleSkill) {
const entry = skillEntries[selectedIndex]
onToggleSkill(entry.isGlobal, entry.skill.path, !entry.skill.enabled)
}
}
// Input handling
useInput(
(input, key) => {
if (input.toLowerCase() === "q" || key.escape) {
exit()
}
// Tab navigation with Tab key or number keys
if (key.tab || (input >= "1" && input <= "5")) {
const targetIdx = key.tab
? (availableTabs.findIndex((t) => t.key === currentTab) + 1) % availableTabs.length
: parseInt(input) - 1
if (targetIdx >= 0 && targetIdx < availableTabs.length) {
handleTabChange(availableTabs[targetIdx].key)
}
return
}
// List navigation (arrow keys and vim-style j/k)
if (key.upArrow || input === "k") {
setSelectedIndex((i) => (i > 0 ? i - 1 : currentListLength - 1))
} else if (key.downArrow || input === "j") {
setSelectedIndex((i) => (i < currentListLength - 1 ? i + 1 : 0))
}
// Tab-specific actions
if (currentTab === "settings") {
if ((key.return || input === "e") && selectedConfigEntry?.isEditable) {
setEditValue(selectedConfigEntry.value !== undefined ? String(selectedConfigEntry.value) : "")
setIsEditing(true)
} else if (input === "r") {
handleSettingsReset()
}
} else if (key.return || input === " ") {
// Toggle for rules/workflows/hooks/skills
handleToggle()
}
// Open folder (for rules/workflows/hooks/skills tabs)
if (input === "o" && onOpenFolder && currentTab !== "settings") {
// Determine if current selection is global or workspace based on the selected entry
let isGlobal = true
if (currentTab === "rules" && ruleEntries[selectedIndex]) {
isGlobal = ruleEntries[selectedIndex].source === "global"
} else if (currentTab === "workflows" && workflowEntries[selectedIndex]) {
isGlobal = workflowEntries[selectedIndex].source === "global"
} else if (currentTab === "hooks" && hookEntries[selectedIndex]) {
isGlobal = hookEntries[selectedIndex].isGlobal
} else if (currentTab === "skills" && skillEntries[selectedIndex]) {
isGlobal = skillEntries[selectedIndex].isGlobal
}
onOpenFolder(currentTab as "rules" | "workflows" | "hooks" | "skills", isGlobal)
}
},
{ isActive: isRawModeSupported && !isEditing },
)
// Scrolling window
const halfVisible = Math.floor(MAX_VISIBLE / 2)
const startIndex = Math.max(0, Math.min(selectedIndex - halfVisible, currentListLength - MAX_VISIBLE))
// Edit mode UI (settings only)
if (isEditing && selectedConfigEntry && currentTab === "settings") {
const header = (
<React.Fragment>
<Text bold color="white">
Edit Configuration
</Text>
<Text color="gray">{SEPARATOR}</Text>
</React.Fragment>
)
if (selectedConfigEntry.type === "boolean") {
return (
<Box flexDirection="column">
{header}
<BooleanSelect
label={selectedConfigEntry.key}
onCancel={() => setIsEditing(false)}
onSelect={handleSettingsSave}
value={Boolean(selectedConfigEntry.value)}
/>
</Box>
)
}
return (
<Box flexDirection="column">
{header}
<TextInput
label={selectedConfigEntry.key}
onCancel={() => setIsEditing(false)}
onChange={setEditValue}
onSubmit={handleSettingsSave}
type={selectedConfigEntry.type}
value={editValue}
/>
</Box>
)
}
// Render tab content
const renderTabContent = () => {
switch (currentTab) {
case "settings": {
const visibleEntries = configEntries.slice(startIndex, startIndex + MAX_VISIBLE)
return (
<React.Fragment>
<Box>
<Text>Data directory: </Text>
<Text color="blue" underline>
{dataDir}
</Text>
</Box>
<Text color="gray">{SEPARATOR}</Text>
<Box flexDirection="column">
{visibleEntries.map((entry, idx) => {
const actualIndex = startIndex + idx
const prevEntry = visibleEntries[idx - 1]
const showHeader = !prevEntry || prevEntry.source !== entry.source
return (
<React.Fragment key={`${entry.source}-${entry.key}`}>
{showHeader && (
<SectionHeader
title={entry.source === "global" ? "Global Settings:" : "Workspace Settings:"}
/>
)}
<ConfigRow entry={entry} isSelected={actualIndex === selectedIndex} />
</React.Fragment>
)
})}
</Box>
</React.Fragment>
)
}
case "rules": {
if (ruleEntries.length === 0) {
return (
<Box>
<Text color="gray">
No rules configured. Add .clinerules files to your workspace or global config.
</Text>
</Box>
)
}
const visibleEntries = ruleEntries.slice(startIndex, startIndex + MAX_VISIBLE)
return (
<Box flexDirection="column">
{visibleEntries.map((entry, idx) => {
const actualIndex = startIndex + idx
const prevEntry = visibleEntries[idx - 1]
const showHeader = !prevEntry || prevEntry.source !== entry.source
return (
<React.Fragment key={`${entry.source}-${entry.path}`}>
{showHeader && (
<SectionHeader title={entry.source === "global" ? "Global Rules:" : "Workspace Rules:"} />
)}
<ToggleRow entry={entry} isSelected={actualIndex === selectedIndex} showType />
</React.Fragment>
)
})}
</Box>
)
}
case "workflows": {
if (workflowEntries.length === 0) {
return (
<Box>
<Text color="gray">No workflows configured. Add workflow files to enable this feature.</Text>
</Box>
)
}
const visibleEntries = workflowEntries.slice(startIndex, startIndex + MAX_VISIBLE)
return (
<Box flexDirection="column">
{visibleEntries.map((entry, idx) => {
const actualIndex = startIndex + idx
const prevEntry = visibleEntries[idx - 1]
const showHeader = !prevEntry || prevEntry.source !== entry.source
return (
<React.Fragment key={`${entry.source}-${entry.path}`}>
{showHeader && (
<SectionHeader
title={entry.source === "global" ? "Global Workflows:" : "Workspace Workflows:"}
/>
)}
<ToggleRow entry={entry} isSelected={actualIndex === selectedIndex} />
</React.Fragment>
)
})}
</Box>
)
}
case "hooks": {
if (hookEntries.length === 0) {
return (
<Box>
<Text color="gray">No hooks configured. Add hook scripts to enable automation.</Text>
</Box>
)
}
const visibleEntries = hookEntries.slice(startIndex, startIndex + MAX_VISIBLE)
return (
<Box flexDirection="column">
{visibleEntries.map((entry, idx) => {
const actualIndex = startIndex + idx
const prevEntry = visibleEntries[idx - 1]
const showHeader =
!prevEntry ||
prevEntry.isGlobal !== entry.isGlobal ||
prevEntry.workspaceName !== entry.workspaceName
let sectionTitle = "Global Hooks:"
if (!entry.isGlobal && entry.workspaceName) {
sectionTitle = `${entry.workspaceName} Hooks:`
}
return (
<React.Fragment key={`${entry.isGlobal}-${entry.workspaceName || ""}-${entry.hook.name}`}>
{showHeader && <SectionHeader title={sectionTitle} />}
<HookRow hook={entry.hook} isSelected={actualIndex === selectedIndex} />
</React.Fragment>
)
})}
</Box>
)
}
case "skills": {
if (skillEntries.length === 0) {
return (
<Box>
<Text color="gray">No skills configured. Add SKILL.md files to enable skills.</Text>
</Box>
)
}
const visibleEntries = skillEntries.slice(startIndex, startIndex + MAX_VISIBLE)
return (
<Box flexDirection="column">
{visibleEntries.map((entry, idx) => {
const actualIndex = startIndex + idx
const prevEntry = visibleEntries[idx - 1]
const showHeader = !prevEntry || prevEntry.isGlobal !== entry.isGlobal
return (
<React.Fragment key={`${entry.isGlobal}-${entry.skill.path}`}>
{showHeader && (
<SectionHeader title={entry.isGlobal ? "Global Skills:" : "Workspace Skills:"} />
)}
<SkillRow isSelected={actualIndex === selectedIndex} skill={entry.skill} />
</React.Fragment>
)
})}
</Box>
)
}
default:
return null
}
}
// Help text based on current tab
const getHelpText = () => {
const base = "↑/↓/j/k Navigate • Tab/1-5 Switch tabs • q/Esc Exit"
if (currentTab === "settings") {
return `${base} • Enter/e Edit • r Reset`
}
const openFolder = onOpenFolder ? " • o Open folder" : ""
return `${base} • Enter/Space Toggle${openFolder}`
}
return (
<Box flexDirection="column">
<Text bold color="white">
Cline Configuration
</Text>
<Text color="gray">{SEPARATOR}</Text>
<TabBar currentTab={currentTab} hooksEnabled={hooksEnabled} skillsEnabled={skillsEnabled} tabs={TABS} />
<Text color="gray">{SEPARATOR}</Text>
{renderTabContent()}
{currentListLength > MAX_VISIBLE && (
<Box marginTop={1}>
<Text color="gray" 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>
)
}
@@ -1,394 +0,0 @@
/**
* Sub-components and types for ConfigView
*/
import { Box, Text, useInput } from "ink"
import React, { useState } from "react"
import { useStdinContext } from "../context/StdinContext"
// ============================================================================
// Types & Constants
// ============================================================================
export type ValueType = "string" | "number" | "boolean" | "object" | "undefined"
export type TabView = "settings" | "rules" | "workflows" | "hooks" | "skills"
export interface ConfigEntry {
key: string
value: unknown
type: ValueType
isEditable: boolean
source: "global" | "workspace"
}
export interface ToggleEntry {
path: string
enabled: boolean
source: "global" | "workspace" | "remote"
ruleType?: string
}
export interface HookInfo {
name: string
enabled: boolean
absolutePath: string
}
export interface WorkspaceHooks {
workspaceName: string
hooks: HookInfo[]
}
export interface SkillInfo {
name: string
description: string
path: string
enabled: boolean
}
export const EXCLUDED_KEYS = new Set([
"taskHistory",
"primaryRootIndex",
"subagentsEnabled",
"subagentTerminalOutputLineLimit",
"welcomeViewCompleted",
"isNewUser",
])
export const EDITABLE_TYPES: Set<ValueType> = new Set(["string", "number", "boolean"])
export const MAX_VISIBLE = 12
export const SEPARATOR = "─".repeat(80)
export const TABS: { key: TabView; label: string; requiresFlag?: "hooks" | "skills" }[] = [
{ key: "settings", label: "Settings" },
{ key: "rules", label: "Rules" },
{ key: "workflows", label: "Workflows" },
{ key: "hooks", label: "Hooks", requiresFlag: "hooks" },
{ key: "skills", label: "Skills", requiresFlag: "skills" },
]
// ============================================================================
// Helper Functions
// ============================================================================
export function getValueType(value: unknown): ValueType {
if (value === undefined || value === null) {
return "undefined"
}
if (typeof value === "boolean") {
return "boolean"
}
if (typeof value === "number") {
return "number"
}
if (typeof value === "object") {
return "object"
}
return "string"
}
export function isExcluded(key: string, value: unknown): boolean {
if (EXCLUDED_KEYS.has(key)) {
return true
}
if (key.endsWith("Toggles") || key.endsWith("ModelInfo")) {
return true
}
if (key.startsWith("apiConfig_") || key.startsWith("last")) {
return true
}
if (value === undefined || value === null) {
return true
}
if (typeof value === "object" && Object.keys(value as object).length === 0) {
return true
}
if (Array.isArray(value) && value.length === 0) {
return true
}
if (typeof value === "string" && value.trim() === "") {
return true
}
return false
}
export function formatValue(value: unknown, maxLen = 50): string {
if (value === undefined || value === null) {
return "<not set>"
}
if (typeof value === "boolean") {
return value ? "true" : "false"
}
if (typeof value === "number") {
return String(value)
}
if (typeof value === "object") {
const json = JSON.stringify(value)
return json.length > maxLen ? json.slice(0, maxLen - 3) + "..." : json
}
const str = String(value)
return str.length > maxLen ? str.slice(0, maxLen - 3) + "..." : str
}
export function parseValue(input: string, type: ValueType): unknown {
if (type === "boolean") {
return input.toLowerCase() === "true" || input === "1"
}
if (type === "number") {
const num = parseFloat(input)
return Number.isNaN(num) ? 0 : num
}
if (type === "object") {
try {
return JSON.parse(input)
} catch {
return {}
}
}
return input
}
// Import isSettingsKey at module level for proper test mocking
import { isSettingsKey } from "@shared/storage/state-keys"
export function buildConfigEntries(state: Record<string, unknown>, source: "global" | "workspace"): ConfigEntry[] {
return Object.entries(state)
.filter(([key, value]) => !isExcluded(key, value))
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => {
const type = getValueType(value)
const isEditable = EDITABLE_TYPES.has(type) && (source === "workspace" || isSettingsKey(key))
return { key, value, type, isEditable, source }
})
}
export function buildToggleEntries(
toggles: Record<string, boolean> | undefined,
source: "global" | "workspace" | "remote",
ruleType?: string,
): ToggleEntry[] {
if (!toggles) {
return []
}
return Object.entries(toggles)
.sort(([a], [b]) => a.localeCompare(b))
.map(([path, enabled]) => ({ path, enabled, source, ruleType }))
}
export function getFileName(path: string): string {
return path.split("/").pop() || path
}
// ============================================================================
// Sub-components
// ============================================================================
interface TextInputProps {
label: string
onChange: (value: string) => void
onCancel: () => void
onSubmit: (value: string) => void
type: ValueType
value: string
}
export const TextInput: React.FC<TextInputProps> = ({ label, onChange, onCancel, onSubmit, type, value }) => {
const { isRawModeSupported } = useStdinContext()
useInput(
(input, key) => {
if (key.escape) {
onCancel()
} else if (key.return) {
onSubmit(value)
} else if (key.backspace || key.delete) {
onChange(value.slice(0, -1))
} else if (input && !key.ctrl && !key.meta) {
onChange(value + input)
}
},
{ isActive: isRawModeSupported },
)
return (
<Box flexDirection="column" marginTop={1}>
<Text bold color="cyan">
Edit: {label}
</Text>
<Box>
<Text color="white">{value}</Text>
<Text 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>
)
-301
View File
@@ -1,301 +0,0 @@
/**
* Stateful wrapper for ConfigView that handles toggle operations
*/
import { exec } from "node:child_process"
import os from "node:os"
import path from "node:path"
import { RuleScope } from "@shared/proto/cline/file"
import type { GlobalStateAndSettings, GlobalStateAndSettingsKey, LocalState, LocalStateKey } from "@shared/storage/state-keys"
import React, { useCallback, useEffect, useState } from "react"
import type { Controller } from "@/core/controller"
import { HostProvider } from "@/hosts/host-provider"
import { StdinProvider } from "../context/StdinContext"
import { ConfigView } from "./ConfigView"
interface HookInfo {
name: string
enabled: boolean
absolutePath: string
}
interface WorkspaceHooks {
workspaceName: string
hooks: HookInfo[]
}
interface SkillInfo {
name: string
description: string
path: string
enabled: boolean
}
interface ConfigViewWrapperProps {
controller: Controller
dataDir: string
globalState: Record<string, unknown>
workspaceState: Record<string, unknown>
hooksEnabled: boolean
skillsEnabled: boolean
isRawModeSupported?: boolean
}
export const ConfigViewWrapper: React.FC<ConfigViewWrapperProps> = ({
controller,
dataDir,
globalState: initialGlobalState,
workspaceState: initialWorkspaceState,
hooksEnabled,
skillsEnabled,
isRawModeSupported = true,
}) => {
// Settings state (managed locally for UI updates)
const [globalStateLocal, setGlobalStateLocal] = useState<Record<string, unknown>>(initialGlobalState)
const [workspaceStateLocal, setWorkspaceStateLocal] = useState<Record<string, unknown>>(initialWorkspaceState)
// Rules state
const [globalClineRulesToggles, setGlobalClineRulesToggles] = useState<Record<string, boolean>>({})
const [localClineRulesToggles, setLocalClineRulesToggles] = useState<Record<string, boolean>>({})
const [localCursorRulesToggles, setLocalCursorRulesToggles] = useState<Record<string, boolean>>({})
const [localWindsurfRulesToggles, setLocalWindsurfRulesToggles] = useState<Record<string, boolean>>({})
const [localAgentsRulesToggles, setLocalAgentsRulesToggles] = useState<Record<string, boolean>>({})
// Workflow state
const [globalWorkflowToggles, setGlobalWorkflowToggles] = useState<Record<string, boolean>>({})
const [localWorkflowToggles, setLocalWorkflowToggles] = useState<Record<string, boolean>>({})
// Hooks state
const [globalHooks, setGlobalHooks] = useState<HookInfo[]>([])
const [workspaceHooksState, setWorkspaceHooksState] = useState<WorkspaceHooks[]>([])
// Skills state
const [globalSkills, setGlobalSkills] = useState<SkillInfo[]>([])
const [localSkills, setLocalSkills] = useState<SkillInfo[]>([])
// Load initial data
useEffect(() => {
const loadData = async () => {
const { refreshRules } = await import("@/core/controller/file/refreshRules")
const { refreshHooks } = await import("@/core/controller/file/refreshHooks")
const { refreshSkills } = await import("@/core/controller/file/refreshSkills")
const rulesData = await refreshRules(controller, {})
setGlobalClineRulesToggles(rulesData.globalClineRulesToggles?.toggles || {})
setLocalClineRulesToggles(rulesData.localClineRulesToggles?.toggles || {})
setLocalCursorRulesToggles(rulesData.localCursorRulesToggles?.toggles || {})
setLocalWindsurfRulesToggles(rulesData.localWindsurfRulesToggles?.toggles || {})
setLocalAgentsRulesToggles(rulesData.localAgentsRulesToggles?.toggles || {})
setGlobalWorkflowToggles(rulesData.globalWorkflowToggles?.toggles || {})
setLocalWorkflowToggles(rulesData.localWorkflowToggles?.toggles || {})
if (hooksEnabled) {
const hooksData = await refreshHooks(controller, {})
setGlobalHooks(hooksData.globalHooks || [])
setWorkspaceHooksState(hooksData.workspaceHooks || [])
}
if (skillsEnabled) {
const skillsData = await refreshSkills(controller)
setGlobalSkills(skillsData.globalSkills || [])
setLocalSkills(skillsData.localSkills || [])
}
}
loadData()
}, [controller, hooksEnabled, skillsEnabled])
// Toggle handlers
const handleToggleRule = useCallback(
async (isGlobal: boolean, rulePath: string, enabled: boolean, ruleType: string) => {
const { toggleClineRule } = await import("@/core/controller/file/toggleClineRule")
// Determine scope based on isGlobal and rule type
const scope = isGlobal ? RuleScope.GLOBAL : RuleScope.LOCAL
// For non-cline rules, we need different toggle functions
if (ruleType === "cursor") {
// Update local state optimistically
setLocalCursorRulesToggles((prev) => ({ ...prev, [rulePath]: enabled }))
// Cursor rules use toggleCursorRule but we'll just update the state manager directly
const toggles = controller.stateManager.getWorkspaceStateKey("localCursorRulesToggles") || {}
toggles[rulePath] = enabled
controller.stateManager.setWorkspaceState("localCursorRulesToggles", toggles)
} else if (ruleType === "windsurf") {
setLocalWindsurfRulesToggles((prev) => ({ ...prev, [rulePath]: enabled }))
const toggles = controller.stateManager.getWorkspaceStateKey("localWindsurfRulesToggles") || {}
toggles[rulePath] = enabled
controller.stateManager.setWorkspaceState("localWindsurfRulesToggles", toggles)
} else if (ruleType === "agents") {
setLocalAgentsRulesToggles((prev) => ({ ...prev, [rulePath]: enabled }))
const toggles = controller.stateManager.getWorkspaceStateKey("localAgentsRulesToggles") || {}
toggles[rulePath] = enabled
controller.stateManager.setWorkspaceState("localAgentsRulesToggles", toggles)
} else {
// Cline rules
const result = await toggleClineRule(controller, { metadata: undefined, rulePath, enabled, scope })
if (result.globalClineRulesToggles?.toggles) {
setGlobalClineRulesToggles(result.globalClineRulesToggles.toggles)
}
if (result.localClineRulesToggles?.toggles) {
setLocalClineRulesToggles(result.localClineRulesToggles.toggles)
}
}
},
[controller],
)
const handleToggleWorkflow = useCallback(
async (isGlobal: boolean, workflowPath: string, enabled: boolean) => {
const { toggleWorkflow } = await import("@/core/controller/file/toggleWorkflow")
const scope = isGlobal ? RuleScope.GLOBAL : RuleScope.LOCAL
// Optimistic update
if (isGlobal) {
setGlobalWorkflowToggles((prev) => ({ ...prev, [workflowPath]: enabled }))
} else {
setLocalWorkflowToggles((prev) => ({ ...prev, [workflowPath]: enabled }))
}
await toggleWorkflow(controller, { metadata: undefined, workflowPath, enabled, scope })
},
[controller],
)
const handleToggleHook = useCallback(
async (isGlobal: boolean, hookName: string, enabled: boolean, workspaceName?: string) => {
const { toggleHook } = await import("@/core/controller/file/toggleHook")
// Optimistic update
if (isGlobal) {
setGlobalHooks((prev) => prev.map((h) => (h.name === hookName ? { ...h, enabled } : h)))
} else {
setWorkspaceHooksState((prev) =>
prev.map((ws) =>
ws.workspaceName === workspaceName
? { ...ws, hooks: ws.hooks.map((h) => (h.name === hookName ? { ...h, enabled } : h)) }
: ws,
),
)
}
const result = await toggleHook(controller, { metadata: undefined, hookName, isGlobal, enabled, workspaceName })
if (result.hooksToggles) {
setGlobalHooks(result.hooksToggles.globalHooks || [])
setWorkspaceHooksState(result.hooksToggles.workspaceHooks || [])
}
},
[controller],
)
const handleToggleSkill = useCallback(
async (isGlobal: boolean, skillPath: string, enabled: boolean) => {
const { toggleSkill } = await import("@/core/controller/file/toggleSkill")
// Optimistic update
if (isGlobal) {
setGlobalSkills((prev) => prev.map((s) => (s.path === skillPath ? { ...s, enabled } : s)))
} else {
setLocalSkills((prev) => prev.map((s) => (s.path === skillPath ? { ...s, enabled } : s)))
}
await toggleSkill(controller, { metadata: undefined, skillPath, isGlobal, enabled })
},
[controller],
)
const handleOpenFolder = useCallback(
async (folderType: "rules" | "workflows" | "hooks" | "skills", isGlobal: boolean) => {
let folderPath: string
if (isGlobal) {
// Global folders are in dataDir (e.g., ~/.cline/)
const subFolder = folderType === "rules" ? "rules" : folderType
folderPath = path.join(dataDir, subFolder)
} else {
// Local folders are in the workspace
const workspacePaths = await HostProvider.workspace.getWorkspacePaths({})
const primaryWorkspace = workspacePaths.paths[0]
if (!primaryWorkspace) {
return
}
// Local rules/workflows/hooks/skills are in .clinerules or .cline
const subFolder = folderType === "rules" ? "rules" : folderType
folderPath = path.join(primaryWorkspace, ".clinerules", subFolder)
}
// Open folder using platform-specific command
const platform = os.platform()
let command: string
if (platform === "darwin") {
command = `open "${folderPath}"`
} else if (platform === "win32") {
command = `explorer "${folderPath}"`
} else {
command = `xdg-open "${folderPath}"`
}
exec(command, (error) => {
if (error) {
// Folder might not exist, try to create and open
exec(`mkdir -p "${folderPath}" && ${command}`)
}
})
},
[dataDir],
)
// Settings update handlers
const handleUpdateGlobal = useCallback(
async (key: GlobalStateAndSettingsKey, value: GlobalStateAndSettings[GlobalStateAndSettingsKey]) => {
// Update local state for immediate UI feedback
setGlobalStateLocal((prev) => ({ ...prev, [key]: value }))
// Persist to state manager
controller.stateManager.setGlobalState(key, value)
await controller.stateManager.flushPendingState()
},
[controller],
)
const handleUpdateWorkspace = useCallback(
async (key: LocalStateKey, value: LocalState[LocalStateKey]) => {
// Update local state for immediate UI feedback
setWorkspaceStateLocal((prev) => ({ ...prev, [key]: value }))
// Persist to state manager
controller.stateManager.setWorkspaceState(key, value)
await controller.stateManager.flushPendingState()
},
[controller],
)
return (
<StdinProvider isRawModeSupported={isRawModeSupported}>
<ConfigView
dataDir={dataDir}
globalClineRulesToggles={globalClineRulesToggles}
globalHooks={globalHooks}
globalSkills={globalSkills}
globalState={globalStateLocal}
globalWorkflowToggles={globalWorkflowToggles}
hooksEnabled={hooksEnabled}
localAgentsRulesToggles={localAgentsRulesToggles}
localClineRulesToggles={localClineRulesToggles}
localCursorRulesToggles={localCursorRulesToggles}
localSkills={localSkills}
localWindsurfRulesToggles={localWindsurfRulesToggles}
localWorkflowToggles={localWorkflowToggles}
onOpenFolder={handleOpenFolder}
onToggleHook={handleToggleHook}
onToggleRule={handleToggleRule}
onToggleSkill={handleToggleSkill}
onToggleWorkflow={handleToggleWorkflow}
onUpdateGlobal={handleUpdateGlobal}
onUpdateWorkspace={handleUpdateWorkspace}
skillsEnabled={skillsEnabled}
workspaceHooks={workspaceHooksState}
workspaceState={workspaceStateLocal}
/>
</StdinProvider>
)
}
-175
View File
@@ -1,175 +0,0 @@
/**
* 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>
}
-99
View File
@@ -1,99 +0,0 @@
/**
* File mention menu component for CLI
* Displays a list of matching files when user types @
*/
import { Box, Text } from "ink"
import React from "react"
import 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>
)
}
-185
View File
@@ -1,185 +0,0 @@
/**
* Focus Chain / To-Do List component for CLI
* Displays a progress-tracked checklist of tasks
*/
import { isCompletedFocusChainItem, isFocusChainItem, parseFocusChainItem } from "@shared/focus-chain-utils"
import { Box, Text } from "ink"
import React, { useMemo } from "react"
interface TodoInfo {
currentTodo: { text: string; completed: boolean; index: number } | null
currentIndex: number
completedCount: number
totalCount: number
progressPercentage: number
}
interface TodoItem {
text: string
checked: boolean
}
interface FocusChainProps {
focusChainChecklist?: string | null
expanded?: boolean
}
/**
* Parse the focus chain checklist text into TodoInfo
*/
function parseCurrentTodoInfo(text: string): TodoInfo | null {
if (!text) {
return null
}
let completedCount = 0
let totalCount = 0
let firstIncompleteIndex = -1
let firstIncompleteText: string | null = null
const lines = text.split("\n")
for (const rawLine of lines) {
const line = rawLine.trim()
if (isFocusChainItem(line)) {
const isCompleted = isCompletedFocusChainItem(line)
if (isCompleted) {
completedCount++
} else if (firstIncompleteIndex === -1) {
firstIncompleteIndex = totalCount
// Extract text after "- [ ] "
firstIncompleteText = line.substring(5).trim()
}
totalCount++
}
}
if (totalCount === 0) {
return null
}
const currentTodo = firstIncompleteText ? { text: firstIncompleteText, completed: false, index: firstIncompleteIndex } : null
return {
currentTodo,
currentIndex: firstIncompleteIndex >= 0 ? firstIncompleteIndex + 1 : totalCount,
completedCount,
totalCount,
progressPercentage: (completedCount / totalCount) * 100,
}
}
/**
* Parse all todo items from the checklist
*/
function parseTodoItems(text: string): TodoItem[] {
const items: TodoItem[] = []
const lines = text.split("\n")
for (const rawLine of lines) {
const line = rawLine.trim()
const parsed = parseFocusChainItem(line)
if (parsed) {
items.push(parsed)
}
}
return items
}
/**
* Render progress bar
*/
const ProgressBar: React.FC<{ percentage: number; width?: number }> = ({ percentage, width = 20 }) => {
const filled = Math.round((percentage / 100) * width)
const empty = width - filled
const bar = "█".repeat(filled) + "░".repeat(empty)
return (
<Text>
<Text color="green">{bar}</Text>
<Text dimColor> {Math.round(percentage)}%</Text>
</Text>
)
}
/**
* Header view showing current task and progress
*/
const Header: React.FC<{
todoInfo: TodoInfo
}> = ({ todoInfo }) => {
const { currentTodo, currentIndex, totalCount, completedCount } = todoInfo
const isCompleted = completedCount === totalCount
const displayText = isCompleted ? "All tasks completed!" : currentTodo?.text || "To-Do list"
const truncatedText = displayText.length > 50 ? displayText.substring(0, 47) + "..." : displayText
return (
<Box flexDirection="row" gap={1}>
<Text color={isCompleted ? "green" : "cyan"}>
[{currentIndex}/{totalCount}]
</Text>
<Text color={isCompleted ? "green" : undefined}>{truncatedText}</Text>
</Box>
)
}
/**
* Expanded view showing all todo items
*/
const ExpandedList: React.FC<{
items: TodoItem[]
isCompleted: boolean
}> = ({ items, isCompleted }) => {
return (
<Box flexDirection="column" marginLeft={2} marginTop={1}>
{items.map((item, index) => (
<Box key={index}>
<Text color={item.checked ? "green" : "gray"}>{item.checked ? "✓" : "○"} </Text>
<Text color={item.checked ? "green" : undefined} dimColor={item.checked}>
{item.text}
</Text>
</Box>
))}
{isCompleted && (
<Box marginTop={1}>
<Text dimColor italic>
New steps will be generated if you continue the task
</Text>
</Box>
)}
</Box>
)
}
/**
* Main FocusChain component for CLI
* Shows a progress summary of the current to-do list
* Use expanded={true} to show all items (e.g., in verbose mode)
*/
export const FocusChain: React.FC<FocusChainProps> = ({ focusChainChecklist, expanded = false }) => {
const todoInfo = useMemo(
() => (focusChainChecklist ? parseCurrentTodoInfo(focusChainChecklist) : null),
[focusChainChecklist],
)
const todoItems = useMemo(() => (focusChainChecklist ? parseTodoItems(focusChainChecklist) : []), [focusChainChecklist])
// No content to display
if (!todoInfo) {
return null
}
const isCompleted = todoInfo.completedCount === todoInfo.totalCount
return (
<Box borderColor={isCompleted ? "green" : "gray"} borderStyle="round" flexDirection="column" paddingX={1}>
<Header todoInfo={todoInfo} />
<ProgressBar percentage={todoInfo.progressPercentage} />
{expanded && <ExpandedList isCompleted={isCompleted} items={todoItems} />}
</Box>
)
}
-218
View File
@@ -1,218 +0,0 @@
import { Text } from "ink"
import { render } from "ink-testing-library"
import React from "react"
import { beforeEach, describe, expect, it, vi } from "vitest"
// Create stable mock reference using vi.hoisted
const { mockShowTaskWithId } = vi.hoisted(() => ({
mockShowTaskWithId: vi.fn().mockResolvedValue(undefined),
}))
vi.mock("./TaskView", () => ({
TaskView: ({ taskId, verbose }: any) =>
React.createElement(Text, null, `TaskView: ${taskId || "no-id"} verbose=${String(verbose)}`),
}))
// Mock the controller dependencies - must be before importing HistoryView
vi.mock("@/core/controller", () => ({
Controller: vi.fn(),
}))
vi.mock("@/core/controller/task/showTaskWithId", () => ({
showTaskWithId: mockShowTaskWithId,
}))
vi.mock("@/shared/proto/cline/common", () => ({
StringRequest: {
create: (data: any) => data,
},
}))
// 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")
})
})
})
-207
View File
@@ -1,207 +0,0 @@
/**
* 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>
)
}
-34
View File
@@ -1,34 +0,0 @@
/**
* Message list component
* Renders all messages from the task
*/
import { Box } from "ink"
import React from "react"
import { useTaskState } from "../context/TaskContext"
import { MessageRow } from "./MessageRow"
interface MessageListProps {
verbose?: boolean
}
export const MessageList: React.FC<MessageListProps> = ({ verbose = false }) => {
const state = useTaskState()
const messages = state.clineMessages || []
// Filter out some noisy messages when not verbose
const messagesToShow = verbose
? messages
: messages.filter((m) => {
// Show everything in non-verbose mode for now
return true
})
return (
<Box flexDirection="column">
{messagesToShow.map((message, idx) => (
<MessageRow key={`${message.ts}-${idx}`} message={message} verbose={verbose} />
))}
</Box>
)
}
-378
View File
@@ -1,378 +0,0 @@
/**
* Individual message row component
* Renders a single ClineMessage based on its type
*/
import type { ClineAsk, ClineMessage, ClineSay } from "@shared/ExtensionMessage"
import { Box, Text } from "ink"
import React from "react"
import { jsonParseSafe } from "../utils/parser"
import { DiffView } from "./DiffView"
interface MessageRowProps {
message: ClineMessage
verbose?: boolean
}
/**
* Get emoji icon for message type
*/
export function getCliMessagePrefixIcon(message: ClineMessage): string {
if (message.type === "ask") {
switch (message.ask) {
case "followup":
return "❓"
case "command":
case "command_output":
return "⚙️"
case "tool":
return "🔧"
case "completion_result":
return "✅"
case "api_req_failed":
return "❌"
case "resume_task":
case "resume_completed_task":
return "▶️"
case "browser_action_launch":
return "🌐"
case "use_mcp_server":
return "🔌"
case "plan_mode_respond":
return "📋"
default:
return "❔"
}
} else {
switch (message.say) {
case "task":
return "📋"
case "error":
return "❌"
case "text":
return "💬"
case "reasoning":
return "🧠"
case "completion_result":
return "✅"
case "user_feedback":
return "👤"
case "command":
case "command_output":
return "⚙️"
case "tool":
return "🔧"
case "browser_action":
case "browser_action_launch":
case "browser_action_result":
return "🌐"
case "mcp_server_request_started":
case "mcp_server_response":
return "🔌"
case "api_req_started":
case "api_req_finished":
return "🔄"
case "checkpoint_created":
return "💾"
case "info":
return "️"
case "generate_explanation":
return "📝"
default:
return " "
}
}
}
/**
* Format timestamp
*/
function formatTimestamp(ts: number): string {
const date = new Date(ts)
return date.toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})
}
/**
* Render ask message based on type
*/
const AskMessageContent: React.FC<{ message: ClineMessage; verbose?: boolean }> = ({ message, verbose }) => {
const ask = message.ask as ClineAsk
const text = message.text || ""
switch (ask) {
case "followup":
case "plan_mode_respond": {
const parts = jsonParseSafe(text, {
response: undefined as string | undefined,
question: undefined as string | undefined,
})
if (parts.question) {
return (
<Text>
<Text color="cyan">Question:</Text> {parts.question}
</Text>
)
}
if (parts.response) {
return (
<Text>
<Text color="cyan">[{ask}]</Text> {parts.response}
</Text>
)
}
return null
}
case "command":
return (
<Text>
<Text color="magenta">Execute command?</Text> <Text dimColor>{text}</Text>
</Text>
)
case "tool":
return (
<Text>
<Text color="blue">Use tool?</Text> {text}
</Text>
)
case "completion_result":
return (
<Text>
<Text color="green">Task completed</Text> {text ? `- ${text}` : ""}
</Text>
)
case "api_req_failed":
return (
<Text>
<Text color="red">API request failed</Text> {text}
</Text>
)
case "resume_task":
case "resume_completed_task":
return (
<Text>
<Text color="cyan">Resume task?</Text> {text}
</Text>
)
case "browser_action_launch":
return (
<Text>
<Text color="cyan">Launch browser?</Text> {text}
</Text>
)
case "use_mcp_server":
return (
<Text>
<Text color="cyan">Use MCP server?</Text> {text}
</Text>
)
default:
return verbose ? (
<Text>
<Text color="gray">[ASK:{ask}]</Text> {text}
</Text>
) : null
}
}
/**
* Render say message based on type
*/
const SayMessageContent: React.FC<{ message: ClineMessage; verbose?: boolean }> = ({ message, verbose }) => {
const say = message.say as ClineSay
const text = message.text || ""
switch (say) {
case "task":
return (
<Text bold>
<Text color="white">Task:</Text> {text}
</Text>
)
case "text":
return <Text>{text}</Text>
case "reasoning":
return (
<Text color="yellow">
<Text italic>{text}</Text>
</Text>
)
case "error":
return (
<Text color="red">
<Text bold>Error:</Text> {text}
</Text>
)
case "completion_result":
return (
<Text color="green">
<Text bold>Completed:</Text> {text}
</Text>
)
case "user_feedback":
return (
<Text>
<Text color="green">User:</Text> {text}
</Text>
)
case "command":
return (
<Text>
<Text color="magenta">Command:</Text> <Text dimColor>{text}</Text>
</Text>
)
case "command_output": {
const lines = text.split("\n")
const displayLines = lines.slice(0, 10)
return (
<Box flexDirection="column">
<Text dimColor>Output:</Text>
{displayLines.map((line, idx) => (
<Text dimColor key={idx}>
{line}
</Text>
))}
{lines.length > 10 && <Text dimColor> ... and {lines.length - 10} more lines</Text>}
</Box>
)
}
case "tool": {
const { tool, content, path } = jsonParseSafe(text, {
tool: undefined as string | undefined,
content: undefined as string | undefined,
path: undefined as string | undefined,
diff: undefined as string | undefined,
})
if (path) {
if (tool === "newFileCreated") {
return <DiffView content={content} path={path} />
}
// if (tool === "editedExistingFile") {
// return <DiffView diff={diff} path={path} />
// }
}
return (
<Text>
<Text color="blue">{text}</Text>
</Text>
)
}
case "api_req_started": {
const { cost, tokensOut, cacheWrites, cacheReads, tokensIn } = jsonParseSafe(text, {
cost: 0 as number,
tokensIn: 0 as number,
tokensOut: 0 as number,
cacheWrites: 0 as number,
cacheReads: 0 as number,
})
return verbose ? (
<Text dimColor>{text}</Text>
) : (
<Text dimColor>
Cost: {cost} | Tokens In: {tokensIn} | Tokens Out: {tokensOut} | Cache Writes: {cacheWrites} | Cache Reads:{" "}
{cacheReads}
</Text>
)
}
case "api_req_finished":
return null
case "checkpoint_created":
return <Text dimColor>Checkpoint created: {message.lastCheckpointHash}</Text>
case "info":
return <Text color="cyan">{text}</Text>
case "browser_action":
case "browser_action_launch":
return (
<Text>
<Text color="cyan">Browser:</Text> {text}
</Text>
)
case "browser_action_result":
return <Text dimColor>Browser result {text ? `- ${text.substring(0, 100)}...` : ""}</Text>
case "mcp_server_request_started":
return <Text color="cyan">MCP request started {text}</Text>
case "mcp_server_response":
return <Text color="cyan">MCP response {text ? text.substring(0, 200) : ""}</Text>
default:
return verbose ? (
<Text dimColor>
[SAY:{say}] {text}
</Text>
) : null
}
}
export const MessageRow: React.FC<MessageRowProps> = ({ message, verbose = false }) => {
const icon = getCliMessagePrefixIcon(message)
const timestamp = formatTimestamp(message.ts)
// Don't render silent messages
if (message.say === "api_req_finished") {
return null
}
if (message.say === "text" && message.text?.trim() === "") {
return null
}
const content =
message.type === "ask" ? (
<AskMessageContent message={message} verbose={verbose} />
) : (
<SayMessageContent message={message} verbose={verbose} />
)
// command_output and tool return a Box, which can't be nested inside Text
if (message.say === "command_output" || message.say === "tool") {
return (
<Box flexDirection="column">
<Box>
<Text dimColor>{timestamp} </Text>
<Text>{icon} </Text>
</Box>
{content}
</Box>
)
}
return (
<Box flexDirection="column">
<Box>
<Text dimColor>{timestamp} </Text>
<Text>{icon} </Text>
{content}
</Box>
</Box>
)
}
-23
View File
@@ -1,23 +0,0 @@
/**
* Loading spinner component using ink-spinner
*/
import { Box, Text } from "ink"
import Spinner from "ink-spinner"
import React from "react"
interface LoadingSpinnerProps {
mode?: "act" | "plan"
}
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({ mode = "act" }) => {
const message = mode === "plan" ? "Planning" : "Thinking"
return (
<Box>
<Text color="cyan">
<Spinner type="dots" />
</Text>
<Text color="cyan"> {message}...</Text>
</Box>
)
}
-107
View File
@@ -1,107 +0,0 @@
/**
* Status bar component
* Shows git branch, model, context window usage, token count, and cost
*/
import { execSync } from "child_process"
import { Box, Text } from "ink"
import React, { useEffect, useState } from "react"
interface StatusBarProps {
modelId: string
tokensIn?: number
tokensOut?: number
totalCost?: number
contextWindowSize?: number
cwd?: string
}
/**
* Get current git branch name
*/
function getGitBranch(cwd?: string): string | null {
try {
const branch = execSync("git rev-parse --abbrev-ref HEAD", {
cwd: cwd || process.cwd(),
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
}).trim()
return branch
} catch {
return null
}
}
/**
* Get directory basename
*/
function getDirName(cwd?: string): string {
const path = cwd || process.cwd()
return path.split("/").pop() || path
}
/**
* Format number with commas
*/
function formatNumber(num: number): string {
return num.toLocaleString()
}
/**
* Create a progress bar for context window usage
*/
function createContextBar(used: number, total: number, width: number = 8): string {
const ratio = Math.min(used / total, 1)
const filled = Math.round(ratio * width)
const empty = width - filled
return "█".repeat(filled) + "░".repeat(empty)
}
export const StatusBar: React.FC<StatusBarProps> = ({
modelId,
tokensIn = 0,
tokensOut = 0,
totalCost = 0,
contextWindowSize = 200000, // Default Claude context window
cwd,
}) => {
const [branch, setBranch] = useState<string | null>(null)
const dirName = getDirName(cwd)
useEffect(() => {
setBranch(getGitBranch(cwd))
}, [cwd])
const totalTokens = tokensIn + tokensOut
const contextBar = createContextBar(totalTokens, contextWindowSize)
// Format model ID for display (shorten if needed)
const displayModel = modelId.length > 20 ? modelId.substring(0, 17) + "..." : modelId
return (
<Box flexDirection="column">
<Box gap={1}>
{/* Directory and branch */}
<Text color="gray">
{dirName}
{branch && (
<Text color="gray">
{" "}
(<Text color="cyan">{branch}</Text>)
</Text>
)}
</Text>
<Text color="gray">|</Text>
{/* Model and context bar */}
<Text color="white">{displayModel}</Text>
<Text color="blue">{contextBar}</Text>
<Text color="gray">({formatNumber(totalTokens)})</Text>
<Text color="gray">|</Text>
{/* Cost */}
<Text color="green">${totalCost.toFixed(4)}</Text>
</Box>
</Box>
)
}
-125
View File
@@ -1,125 +0,0 @@
/**
* JSON Task view component
* Outputs task messages as JSON instead of rich styled text
*/
import { Box } from "ink"
import React, { useEffect, useRef } from "react"
import { useTaskContext, useTaskState } from "../context/TaskContext"
import { useCompletionSignals } from "../hooks/useStateSubscriber"
import { originalConsoleLog } from "../utils/console"
interface TaskJsonViewProps {
taskId?: string
verbose?: boolean
onComplete?: () => void
onError?: () => void
}
/**
* Output a JSON line to stdout
*/
function outputJson(data: object) {
originalConsoleLog(JSON.stringify(data))
}
export const TaskJsonView: React.FC<TaskJsonViewProps> = ({ taskId: _taskId, verbose = false, onComplete, onError }) => {
const state = useTaskState()
const { isTaskComplete, getCompletionMessage } = useCompletionSignals()
const { setIsComplete } = useTaskContext()
// Track outputted messages by timestamp (don't re-output on updates)
const outputtedMessages = useRef<Set<number>>(new Set())
const hasOutputtedCompletion = useRef(false)
// Determine the role for a message
const getRole = (message: { type: string; ask?: string; say?: string }, index: number): "user" | "assistant" | "system" => {
// User feedback messages
if (message.say === "user_feedback" || message.say === "user_feedback_diff") {
return "user"
}
// First text message is the user's task
if (message.say === "text" && index === 0) {
return "user"
}
// System messages
if (message.say === "api_req_started" || message.say === "api_req_finished") {
return "system"
}
// Default: assistant
return "assistant"
}
// Output messages as JSON when they arrive
useEffect(() => {
const messages = state.clineMessages || []
for (let i = 0; i < messages.length; i++) {
const message = messages[i]
// Skip partial messages - wait for complete message
if (message.partial) {
continue
}
// Skip if we already outputted this timestamp
if (outputtedMessages.current.has(message.ts)) {
continue
}
// Filter out noisy messages in non-verbose mode
if (!verbose) {
if (message.say === "api_req_started" || message.say === "api_req_finished") {
outputtedMessages.current.add(message.ts)
continue
}
}
const role = getRole(message, i)
// Output the message as JSON
outputJson({
type: "message",
timestamp: message.ts,
role,
messageType: message.type,
...(message.ask && { ask: message.ask }),
...(message.say && { say: message.say }),
...(message.text && { text: message.text }),
...(message.reasoning && { reasoning: message.reasoning }),
...(message.images && message.images.length > 0 && { images: message.images }),
...(message.files && message.files.length > 0 && { files: message.files }),
})
outputtedMessages.current.add(message.ts)
}
}, [state.clineMessages, verbose])
// Handle task completion
useEffect(() => {
if (isTaskComplete() && !hasOutputtedCompletion.current) {
hasOutputtedCompletion.current = true
setIsComplete(true)
const completionMsg = getCompletionMessage()
const isError = completionMsg?.say === "error" || completionMsg?.ask === "api_req_failed"
// Output completion status
outputJson({
type: "completion",
status: isError ? "error" : "success",
timestamp: Date.now(),
})
if (isError) {
onError?.()
} else {
onComplete?.()
}
// Don't exit automatically - let the parent handle cleanup
}
}, [isTaskComplete, setIsComplete, onComplete, onError, getCompletionMessage])
// Render nothing visible - all output goes to stdout as JSON
return <Box />
}
-188
View File
@@ -1,188 +0,0 @@
/**
* Task view component
* Main view for running a task - displays messages and handles user input
*/
import { exit } from "node:process"
import { CheckpointRestoreRequest } from "@shared/proto/cline/checkpoints"
import type { Mode } from "@shared/storage/types"
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 mode = useMemo<Mode>(() => StateManager.get().getGlobalSettingsKey("mode") || "act", [])
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 */}
{!yolo && <AskPrompt />}
</Box>
)
}
-175
View File
@@ -1,175 +0,0 @@
/**
* Thinking/Planning indicator with shimmer effect and elapsed time
*
* Creates a shimmer effect by cycling a "bright spot" through the text,
* similar to skeleton loading animations in web UIs.
*/
import { Box, Text } from "ink"
import React, { useEffect, useMemo, useState } from "react"
interface ThinkingIndicatorProps {
mode?: "act" | "plan"
startTime?: number // Unix timestamp when thinking started
}
// Shimmer gradient - how much to blend towards white (0 = base color, 1 = white)
// Index 0 = no brightening (far from shimmer), higher = more white (near shimmer center)
const SHIMMER_GRADIENT = [
0.0, // base color
0.15,
0.3,
0.5,
0.7,
0.9, // almost white (shimmer center)
]
const SHIMMER_RADIUS = SHIMMER_GRADIENT.length - 1
// Spinner frames (dots style from ink-spinner)
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
/**
* Format elapsed time as "1m 5s" or "45s"
*/
function formatElapsedTime(ms: number): string {
const totalSeconds = Math.floor(ms / 1000)
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
if (minutes > 0) {
return `${minutes}m ${seconds}s`
}
return `${seconds}s`
}
/**
* Blend base color towards white based on factor (0 = base color, 1 = white)
*/
function blendTowardsWhite(baseColor: string, factor: number): string {
// Base colors - bright and vivid to match Ink's blueBright/yellow
const colors: Record<string, { r: number; g: number; b: number }> = {
blueBright: { r: 140, g: 170, b: 255 }, // Light purple-blue
yellow: { r: 255, g: 255, b: 0 }, // Bright yellow
}
const base = colors[baseColor] || colors.blueBright
const t = Math.max(0, Math.min(1, factor))
// Lerp from base color towards white (255, 255, 255)
const r = Math.round(base.r + (255 - base.r) * t)
const g = Math.round(base.g + (255 - base.g) * t)
const b = Math.round(base.b + (255 - base.b) * t)
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`
}
/**
* Apply shimmer effect to a string by coloring each character
* based on its position relative to the shimmer center
*/
function ShimmerText({
text,
shimmerPos,
baseColor,
}: {
text: string
shimmerPos: number
baseColor: string
}): React.ReactElement {
const chars = text.split("")
return (
<>
{chars.map((char, i) => {
// Calculate distance from shimmer center position
// shimmerPos moves from -SHIMMER_RADIUS to textLen + SHIMMER_RADIUS
const dist = Math.abs(i - shimmerPos)
// Map distance to whiteness: closer to shimmer = more white
const gradientIdx = Math.min(dist, SHIMMER_RADIUS)
const whiteness = SHIMMER_GRADIENT[SHIMMER_RADIUS - gradientIdx] ?? SHIMMER_GRADIENT[0]
const color = blendTowardsWhite(baseColor, whiteness)
return (
<Text color={color} key={i}>
{char}
</Text>
)
})}
</>
)
}
export const ThinkingIndicator: React.FC<ThinkingIndicatorProps> = ({ mode = "act", startTime }) => {
const message = mode === "plan" ? "Planning" : "Thinking"
const baseColor = mode === "plan" ? "yellow" : "blueBright"
// Spinner frame index
const [spinnerFrame, setSpinnerFrame] = useState(0)
// Shimmer animation position (moves left to right across the text)
const [shimmerPos, setShimmerPos] = useState(-SHIMMER_RADIUS)
// Elapsed time state
const [elapsedMs, setElapsedMs] = useState(0)
// Full text including spinner
const spinnerChar = SPINNER_FRAMES[spinnerFrame]
const fullText = `${spinnerChar} ${message}`
// Animate spinner
useEffect(() => {
const interval = setInterval(() => {
setSpinnerFrame((prev) => (prev + 1) % SPINNER_FRAMES.length)
}, 80)
return () => clearInterval(interval)
}, [])
// Animate shimmer: move from left edge to right edge, then wrap
useEffect(() => {
const interval = setInterval(() => {
setShimmerPos((prev) => {
const next = prev + 1
if (next > fullText.length + SHIMMER_RADIUS) {
return -SHIMMER_RADIUS
}
return next
})
}, 60) // Speed of shimmer movement
return () => clearInterval(interval)
}, [fullText.length])
// Update elapsed time
useEffect(() => {
if (!startTime) return
const updateElapsed = () => {
setElapsedMs(Date.now() - startTime)
}
updateElapsed() // Initial update
const interval = setInterval(updateElapsed, 1000)
return () => clearInterval(interval)
}, [startTime])
const elapsedStr = useMemo(() => {
if (!startTime || elapsedMs < 1000) return null
return formatElapsedTime(elapsedMs)
}, [startTime, elapsedMs])
return (
<Box>
<ShimmerText baseColor={baseColor} shimmerPos={shimmerPos} text={fullText} />
{elapsedStr && (
<Text color="gray">
{" "}
({elapsedStr} · <Text dimColor>esc to interrupt</Text>)
</Text>
)}
</Box>
)
}
-328
View File
@@ -1,328 +0,0 @@
/**
* Welcome view component
* Shows an interactive prompt when user starts cline without a command
* Supports file mentions with @
*/
import { Box, Text, useInput } from "ink"
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { StateManager } from "@/core/storage/StateManager"
import { getProviderDefaultModelId, Mode } from "@/shared/storage"
import { useStdinContext } from "../context/StdinContext"
import {
checkAndWarnRipgrepMissing,
extractMentionQuery,
type FileSearchResult,
getRipgrepInstallInstructions,
insertMention,
searchWorkspaceFiles,
} from "../utils/file-search"
import { parseImagesFromInput } from "../utils/parser"
import { AccountInfoView } from "./AccountInfoView"
import { FileMentionMenu } from "./FileMentionMenu"
interface WelcomeViewProps {
onSubmit: (prompt: string, imagePaths: string[]) => void
onExit?: () => void
controller?: any
}
// ASCII art Cline logo
const CLINE_LOGO = [
" ::::::: ",
" ::::::::: ",
" ::::::::::::::::: ",
" ::::::::::::::::::::::: ",
" ::::::::::::::::::::::::: ",
" ::::::::::::::::::::::::::: ",
" ::::::: ::::::: ::::::: ",
" ::::::: ::::: ::::::: ",
":::::::: ::::: ::::::::",
":::::::: ::::: ::::::::",
" ::::::: ::::: ::::::: ",
" ::::::: ::::::: ::::::: ",
" ::::::::::::::::::::::::::: ",
" ::::::::::::::::::::::::: ",
" ::::::::::::::::::::::: ",
" :::::::::::::::: ",
]
const SEARCH_DEBOUNCE_MS = 150
const RIPGREP_WARNING_DURATION_MS = 5000
const MAX_SEARCH_RESULTS = 15
export const WelcomeView: React.FC<WelcomeViewProps> = ({ onSubmit, onExit, controller }) => {
const { isRawModeSupported } = useStdinContext()
const [textInput, setTextInput] = useState("")
const [fileResults, setFileResults] = useState<FileSearchResult[]>([])
const [selectedIndex, setSelectedIndex] = useState(0)
const [isSearching, setIsSearching] = useState(false)
const [showRipgrepWarning, setShowRipgrepWarning] = useState(false)
const [escPressedOnce, setEscPressedOnce] = useState(false)
const [mode, setMode] = useState<Mode>(() => {
const stateManager = StateManager.get()
return stateManager.getGlobalSettingsKey("mode") || "act"
})
const provider = useMemo(() => {
const stateManager = StateManager.get()
const mode = stateManager.getGlobalSettingsKey("mode") as string
const providerKey = mode === "act" ? "actModeApiProvider" : "planModeApiProvider"
const currentProvider = stateManager.getGlobalSettingsKey(providerKey) as string
return currentProvider || "cline"
}, [controller])
// Get model ID based on current mode
const modelId = useMemo(() => {
const stateManager = StateManager.get()
const modelKey = mode === "act" ? "actModeApiModelId" : "planModeApiModelId"
return (stateManager.getGlobalSettingsKey(modelKey) as string) || getProviderDefaultModelId(provider)
}, [mode, provider])
const toggleMode = useCallback(() => {
const newMode: Mode = mode === "act" ? "plan" : "act"
setMode(newMode)
const stateManager = StateManager.get()
stateManager.setGlobalState("mode", newMode)
}, [mode])
const refs = useRef({
searchTimeout: null as NodeJS.Timeout | null,
lastQuery: "",
hasCheckedRipgrep: false,
})
const { prompt, imagePaths } = parseImagesFromInput(textInput)
const mentionInfo = useMemo(() => extractMentionQuery(textInput), [textInput])
const workspacePath = useMemo(() => {
try {
const root = controller?.getWorkspaceManagerSync?.()?.getPrimaryRoot?.()
if (root?.path) {
return root.path
}
} catch {
// Fallback to cwd
}
return process.cwd()
}, [controller])
// Search for files when in mention mode
useEffect(() => {
const { current: r } = refs
if (!mentionInfo.inMentionMode) {
setFileResults([])
setSelectedIndex(0)
if (r.searchTimeout) {
clearTimeout(r.searchTimeout)
r.searchTimeout = null
}
return
}
// Check for ripgrep on first mention trigger
if (!r.hasCheckedRipgrep) {
r.hasCheckedRipgrep = true
if (checkAndWarnRipgrepMissing()) {
setShowRipgrepWarning(true)
setTimeout(() => setShowRipgrepWarning(false), RIPGREP_WARNING_DURATION_MS)
}
}
const { query } = mentionInfo
if (query === r.lastQuery) {
return
}
r.lastQuery = query
if (r.searchTimeout) {
clearTimeout(r.searchTimeout)
}
setIsSearching(true)
r.searchTimeout = setTimeout(async () => {
try {
const results = await searchWorkspaceFiles(query, workspacePath, MAX_SEARCH_RESULTS)
setFileResults(results)
setSelectedIndex(0)
} catch {
setFileResults([])
} finally {
setIsSearching(false)
}
}, SEARCH_DEBOUNCE_MS)
return () => {
if (r.searchTimeout) {
clearTimeout(r.searchTimeout)
}
}
}, [mentionInfo.inMentionMode, mentionInfo.query, workspacePath])
useInput(
(input, key) => {
const inMenu = mentionInfo.inMentionMode && fileResults.length > 0
// Menu navigation
if (inMenu) {
if (key.upArrow) {
setSelectedIndex((i) => (i > 0 ? i - 1 : fileResults.length - 1))
return
}
if (key.downArrow) {
setSelectedIndex((i) => (i < fileResults.length - 1 ? i + 1 : 0))
return
}
if (key.tab || key.return) {
const file = fileResults[selectedIndex]
if (file) {
setTextInput(insertMention(textInput, mentionInfo.atIndex, file.path))
setFileResults([])
setSelectedIndex(0)
}
return
}
if (key.escape) {
setFileResults([])
setSelectedIndex(0)
return
}
}
// Normal input handling
if (key.tab && !mentionInfo.inMentionMode) {
toggleMode()
return
}
if (key.return && !mentionInfo.inMentionMode) {
if (prompt.trim() || imagePaths.length > 0) {
onSubmit(prompt.trim(), imagePaths)
}
return
}
if (key.escape && !mentionInfo.inMentionMode) {
if (escPressedOnce) {
onExit?.()
} else {
setEscPressedOnce(true)
}
return
}
if (key.backspace || key.delete) {
setTextInput((prev) => prev.slice(0, -1))
setEscPressedOnce(false)
return
}
if (input && !key.ctrl && !key.meta && !key.upArrow && !key.downArrow && !key.tab) {
setTextInput((prev) => prev + input)
setEscPressedOnce(false)
}
},
{ isActive: isRawModeSupported },
)
const borderColor = mode === "act" ? "blue" : "yellow"
return (
<Box flexDirection="column" width="100%">
{/* Account/Provider info at top */}
{controller && (
<Box marginBottom={1}>
<AccountInfoView controller={controller} />
</Box>
)}
{/* Cline logo - centered */}
<Box alignItems="center" flexDirection="column">
{/* biome-ignore lint/suspicious/noArrayIndexKey: static array that never changes */}
{CLINE_LOGO.map((line, idx) => (
<Text color="white" key={idx}>
{line}
</Text>
))}
</Box>
{/* Main prompt - centered, bold */}
<Box justifyContent="center" marginTop={1}>
<Text bold color="white">
What can I do for you?
</Text>
</Box>
{/* Ripgrep warning if needed */}
{showRipgrepWarning && (
<Box marginTop={1}>
<Text color="yellow"> ripgrep not found - file search will be slower. </Text>
<Text color="gray">Install: {getRipgrepInstallInstructions()}</Text>
</Box>
)}
{/* Input field with border */}
<Box
borderColor={borderColor}
borderStyle="round"
flexDirection="row"
marginTop={1}
paddingLeft={1}
paddingRight={1}
width="100%">
<Text>{textInput}</Text>
<Text color="gray"></Text>
</Box>
{/* Model ID and Mode toggle row */}
<Box justifyContent="space-between" width="100%">
{/* Model ID on left */}
<Text color="gray" dimColor>
{modelId}
</Text>
{/* Mode toggle on right */}
<Box gap={1}>
<Box>
<Text bold={mode === "plan"} color={mode === "plan" ? "yellow" : "gray"}>
{mode === "plan" ? "●" : "○"} Plan
</Text>
</Box>
<Box>
<Text bold={mode === "act"} color={mode === "act" ? "blue" : "gray"}>
{mode === "act" ? "●" : "○"} Act
</Text>
</Box>
<Text color="gray" dimColor>
(Tab)
</Text>
</Box>
</Box>
{/* File mention menu - below input */}
{mentionInfo.inMentionMode && (
<FileMentionMenu
isLoading={isSearching}
query={mentionInfo.query}
results={fileResults}
selectedIndex={selectedIndex}
/>
)}
{/* Attached images */}
{imagePaths.length > 0 && (
<Text color="magenta">
📎 {imagePaths.length} image{imagePaths.length > 1 ? "s" : ""} attached
</Text>
)}
{/* Help text */}
<Box>
<Text color="gray" dimColor>
Enter to submit · @ to mention files ·{" "}
</Text>
<Text bold={escPressedOnce} color={escPressedOnce ? "white" : "gray"} dimColor={!escPressedOnce}>
{escPressedOnce ? "Press Esc again to exit" : "Esc to exit"}
</Text>
</Box>
</Box>
)
}
-36
View File
@@ -1,36 +0,0 @@
/**
* 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")
}
-124
View File
@@ -1,124 +0,0 @@
/**
* 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
}
@@ -1,91 +0,0 @@
/**
* 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
}
}
@@ -1,27 +0,0 @@
/**
* 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
}
}
-283
View File
@@ -1,283 +0,0 @@
/**
* 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/clients"
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(),
}
}
-158
View File
@@ -1,158 +0,0 @@
/**
* 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
}
-372
View File
@@ -1,372 +0,0 @@
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()
})
})
-567
View File
@@ -1,567 +0,0 @@
/**
* 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 { calculateRobotTopRow, queryCursorPos } from "./utils/cursor-position"
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)
}
// TODO: Wire up with proper logging system
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, { incrementalRendering: true })
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
if (options.yolo) {
StateManager.get().setGlobalState("yoloModeToggled", 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,
controller: ctx.controller,
jsonOutput: options.json,
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
*/
async function showWelcome(options: { verbose?: boolean; cwd?: string; config?: string; thinking?: boolean }) {
const ctx = await initializeCli({ ...options, enableAuth: true })
// Query cursor position BEFORE Ink mounts to know where robot will render
const cursorPos = await queryCursorPos(process.stdin, process.stdout)
const terminalRows = process.stdout.rows ?? 24
const robotTopRow = calculateRobotTopRow(cursorPos, terminalRows)
let submittedPrompt: string | null = null
let submittedImagePaths: string[] = []
const { waitUntilExit, unmount } = render(
React.createElement(App, {
view: "welcome",
controller: ctx.controller,
isRawModeSupported: checkRawModeSupport(),
robotTopRow,
onWelcomeSubmit: (prompt: string, imagePaths: string[]) => {
submittedPrompt = prompt
submittedImagePaths = imagePaths
unmount()
},
onWelcomeExit: () => {
unmount()
exit(0)
},
}),
{
patchConsole: false,
},
)
try {
await waitUntilExit()
} catch {
// App unmounted after prompt submission
}
if (submittedPrompt || submittedImagePaths.length > 0) {
// Run the task with the submitted prompt and images, reusing the existing context
await runTask(submittedPrompt || "", { ...options, images: submittedImagePaths }, ctx)
} else {
// User exited without submitting - clean up and exit
restoreConsole()
await ctx.controller.stateManager.flushPendingState()
await ctx.controller.dispose()
await ErrorService.get().dispose()
exit(0)
}
}
// Interactive mode (default when no command given)
program
.argument("[prompt]", "Task prompt (starts task immediately)")
.option("-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()
-2
View File
@@ -1,2 +0,0 @@
// Stub for react-devtools-core - not needed in CLI
module.exports = {}
-36
View File
@@ -1,36 +0,0 @@
/**
* Console management for CLI
*
* Captures original console methods BEFORE any core modules are imported,
* so CLI output works even when console.log is suppressed.
*/
// Capture original console methods immediately
export const originalConsoleLog = console.log.bind(console)
export const originalConsoleError = console.error.bind(console)
export const originalConsoleWarn = console.warn.bind(console)
export const originalConsoleInfo = console.info.bind(console)
export const originalConsoleDebug = console.debug.bind(console)
// Check for verbose flag early (before commander parses)
const isVerbose = process.argv.includes("-v") || process.argv.includes("--verbose")
// Suppress console output unless verbose mode
if (!isVerbose) {
console.log = () => {}
console.warn = () => {}
console.error = () => {}
console.debug = () => {}
console.info = () => {}
}
/**
* Restore original console methods (for cleanup)
*/
export function restoreConsole() {
console.log = originalConsoleLog
console.error = originalConsoleError
console.warn = originalConsoleWarn
console.info = originalConsoleInfo
console.debug = originalConsoleDebug
}
-57
View File
@@ -1,57 +0,0 @@
/**
* Query terminal cursor position before Ink mounts
* Must be called BEFORE render() to avoid escape sequence leaking into Ink's input handling
*/
export async function queryCursorPos(
stdin: NodeJS.ReadStream,
stdout: NodeJS.WriteStream,
{ timeoutMs = 75 } = {},
): Promise<{ row: number; col: number } | null> {
if (!stdin.isTTY || !stdout.isTTY || typeof (stdin as any).setRawMode !== "function") return null
const ttyIn = stdin as any as { isRaw?: boolean; setRawMode: (b: boolean) => void; on: any; off: any }
const wasRaw = !!ttyIn.isRaw
ttyIn.setRawMode(true)
stdin.resume()
return await new Promise((resolve) => {
let buf = ""
const onData = (chunk: Buffer) => {
buf += chunk.toString("utf8")
const m = buf.match(/\x1b\[(\d+);(\d+)R/)
if (!m) return
cleanup()
resolve({ row: Number(m[1]), col: Number(m[2]) })
}
const cleanup = () => {
clearTimeout(timer)
stdin.off("data", onData)
try {
ttyIn.setRawMode(wasRaw)
} catch {}
}
const timer = setTimeout(() => {
cleanup()
resolve(null)
}, timeoutMs)
stdin.on("data", onData)
stdout.write("\x1b[6n") // DSR: cursor position
})
}
/**
* Calculate where the robot will be rendered on screen
*/
export function calculateRobotTopRow(cursorPos: { row: number } | null, terminalRows: number): number {
const robotHeight = 12
const firstFrameHeight = robotHeight + 8 // robot + welcome text + margins + input + footer
const startRow = cursorPos?.row ?? 1
// If content doesn't fit below cursor, terminal scrolls
return Math.max(1, Math.min(startRow, terminalRows - firstFrameHeight + 1))
}
-418
View File
@@ -1,418 +0,0 @@
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)
})
})
})
-460
View File
@@ -1,460 +0,0 @@
/**
* 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"
}
-256
View File
@@ -1,256 +0,0 @@
/**
* File search utility for CLI
* Uses ripgrep if available, otherwise falls back to Node.js fs.readdir
* FZF is used for fuzzy matching
*/
import { execFileSync, spawn } from "node:child_process"
import { promises as fs } from "node:fs"
import { basename, dirname, join, relative } from "node:path"
import { createInterface } from "node:readline"
import type { Fzf, FzfResultItem } from "fzf"
export interface FileSearchResult {
path: string
type: "file" | "folder"
label: string
}
const EXCLUDED_DIRS = new Set([
"node_modules",
".git",
".github",
"out",
"dist",
"__pycache__",
".venv",
".env",
"venv",
"env",
".cache",
"tmp",
"temp",
".next",
"coverage",
"build",
])
const RG_EXCLUDE_GLOB = "!**/{node_modules,.git,.github,out,dist,__pycache__,.venv,.env,venv,env,.cache,tmp,temp}/**"
// Cached state
let ripgrepAvailable: boolean | null = null
let ripgrepWarningShown = false
let fzfModule: { Fzf: typeof Fzf; byLengthAsc: any } | null = null
function checkRipgrep(): boolean {
if (ripgrepAvailable !== null) {
return ripgrepAvailable
}
try {
execFileSync("which", ["rg"], { stdio: "ignore" })
ripgrepAvailable = true
} catch {
ripgrepAvailable = false
}
return ripgrepAvailable
}
function addParentDirs(relativePath: string, dirSet: Set<string>): void {
let dir = dirname(relativePath)
while (dir && dir !== "." && dir !== "/") {
dirSet.add(dir)
dir = dirname(dir)
}
}
function dirsToResults(dirSet: Set<string>): FileSearchResult[] {
return Array.from(dirSet, (p) => ({ path: p, type: "folder" as const, label: basename(p) }))
}
async function listFilesWithNodeFs(workspacePath: string, limit: number): Promise<FileSearchResult[]> {
const files: FileSearchResult[] = []
const dirs = new Set<string>()
async function walk(dir: string): Promise<void> {
if (files.length >= limit) {
return
}
try {
const entries = await fs.readdir(dir, { withFileTypes: true })
for (const entry of entries) {
if (files.length >= limit) {
break
}
const name = entry.name
if (entry.isDirectory() && EXCLUDED_DIRS.has(name)) {
continue
}
if (name.startsWith(".") && !name.startsWith(".cline")) {
continue
}
const fullPath = join(dir, name)
const relativePath = relative(workspacePath, fullPath)
if (entry.isDirectory()) {
dirs.add(relativePath)
await walk(fullPath)
} else if (entry.isFile()) {
files.push({ path: relativePath, type: "file", label: name })
addParentDirs(relativePath, dirs)
}
}
} catch {
return
}
}
await walk(workspacePath)
return [...files, ...dirsToResults(dirs)]
}
async function listFilesWithRipgrep(workspacePath: string, limit: number): Promise<FileSearchResult[]> {
return new Promise((resolve, reject) => {
const rg = spawn("rg", ["--files", "--follow", "--hidden", "-g", RG_EXCLUDE_GLOB, workspacePath])
const rl = createInterface({ input: rg.stdout })
const files: FileSearchResult[] = []
const dirs = new Set<string>()
let stderr = ""
rl.on("line", (line) => {
if (files.length >= limit) {
rl.close()
rg.kill()
return
}
const relativePath = relative(workspacePath, line)
files.push({ path: relativePath, type: "file", label: basename(relativePath) })
addParentDirs(relativePath, dirs)
})
rg.stderr.on("data", (data) => {
stderr += data
})
rl.on("close", () => {
if (stderr && files.length === 0) {
reject(new Error(`ripgrep error: ${stderr.trim()}`))
} else {
resolve([...files, ...dirsToResults(dirs)])
}
})
rg.on("error", (err) => reject(new Error(`ripgrep error: ${err.message}`)))
})
}
export function checkAndWarnRipgrepMissing(): boolean {
if (!checkRipgrep() && !ripgrepWarningShown) {
ripgrepWarningShown = true
return true
}
return false
}
export function getRipgrepInstallInstructions(): string {
switch (process.platform) {
case "darwin":
return "brew install ripgrep"
case "linux":
return "apt install ripgrep # or: yum install ripgrep"
case "win32":
return "choco install ripgrep # or: scoop install ripgrep"
default:
return "https://github.com/BurntSushi/ripgrep#installation"
}
}
export async function listWorkspaceFiles(workspacePath: string, limit = 5000): Promise<FileSearchResult[]> {
if (checkRipgrep()) {
try {
return await listFilesWithRipgrep(workspacePath, limit)
} catch {
ripgrepAvailable = false
}
}
return listFilesWithNodeFs(workspacePath, limit)
}
function countGaps(positions: Iterable<number>): number {
let gaps = 0
let prev = -Infinity
for (const pos of positions) {
if (prev !== -Infinity && pos - prev > 1) {
gaps++
}
prev = pos
}
return gaps
}
const orderByMatchScore = (a: FzfResultItem<FileSearchResult>, b: FzfResultItem<FileSearchResult>) =>
countGaps(a.positions) - countGaps(b.positions)
export async function searchWorkspaceFiles(
query: string,
workspacePath: string,
limit = 15,
selectedType?: "file" | "folder",
): Promise<FileSearchResult[]> {
try {
let items = await listWorkspaceFiles(workspacePath, 5000)
if (selectedType) {
items = items.filter((item) => item.type === selectedType)
}
if (!query.trim()) {
return items.slice(0, limit)
}
// Lazy load fzf module
if (!fzfModule) {
fzfModule = await import("fzf")
}
const fzf = new fzfModule.Fzf(items, {
selector: (item: FileSearchResult) => `${item.label} ${item.path}`,
tiebreakers: [orderByMatchScore, fzfModule.byLengthAsc],
limit: limit * 2,
})
return fzf
.find(query)
.slice(0, limit)
.map((r) => r.item)
} catch (error) {
console.error("File search error:", error)
return []
}
}
export function extractMentionQuery(text: string): { inMentionMode: boolean; query: string; atIndex: number } {
const lastAtIndex = text.lastIndexOf("@")
if (lastAtIndex === -1 || (lastAtIndex > 0 && !/\s/.test(text[lastAtIndex - 1]))) {
return { inMentionMode: false, query: "", atIndex: -1 }
}
const afterAt = text.slice(lastAtIndex + 1)
if (afterAt.includes(" ")) {
return { inMentionMode: false, query: "", atIndex: -1 }
}
return { inMentionMode: true, query: afterAt, atIndex: lastAtIndex }
}
export function insertMention(text: string, atIndex: number, filePath: string): string {
const endIndex = text.indexOf(" ", atIndex)
const end = endIndex === -1 ? text.length : endIndex
const mention = filePath.includes(" ") ? `@"${filePath}"` : `@${filePath}`
return text.slice(0, atIndex) + mention + " " + text.slice(end).trimStart()
}
-232
View File
@@ -1,232 +0,0 @@
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([])
})
})
})
-100
View File
@@ -1,100 +0,0 @@
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
}
-37
View File
@@ -1,37 +0,0 @@
/**
* 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()
})
}
-298
View File
@@ -1,298 +0,0 @@
/**
* 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 { Logger } from "@/services/logging/Logger"
import { ClineExtensionContext } from "@/shared/clients"
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,
}
}
-288
View File
@@ -1,288 +0,0 @@
/**
* 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
-74
View File
@@ -1,74 +0,0 @@
{
"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": ".."
}
]
}
-29
View File
@@ -1,29 +0,0 @@
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"),
},
},
})
-7
View File
@@ -1,7 +0,0 @@
{
"workflowToggles": {},
"localClineRulesToggles": {},
"localWindsurfRulesToggles": {},
"localCursorRulesToggles": {},
"localAgentsRulesToggles": {}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "claude-dev",
"version": "3.52.0",
"version": "3.53.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-dev",
"version": "3.52.0",
"version": "3.53.1",
"license": "Apache-2.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.37.0",
+2 -13
View File
@@ -2,12 +2,8 @@
"name": "claude-dev",
"displayName": "Cline",
"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",
"version": "3.53.1",
"icon": "assets/icons/icon.png",
"workspaces": [
"cli-ts",
"webview-ui"
],
"engines": {
"vscode": "^1.84.0"
},
@@ -385,10 +381,6 @@
"compile-standalone": "npm run check-types && npm run lint && node esbuild.mjs --standalone",
"compile-standalone-npm": "npm run protos && npm run protos-go && npm run check-types && npm run lint && node esbuild.mjs --standalone",
"compile-cli": "scripts/build-cli.sh",
"compile-cli-ts": "cd cli-ts && npm run link",
"compile-cli-ts:production": "cd cli-ts && npm run build:production",
"watch-cli-ts": "cd cli-ts && npm run watch",
"dev:cli-ts": "npm run compile-cli-ts && npm run watch-cli-ts",
"compile-cli-all-platforms": "scripts/build-cli-all-platforms.sh",
"compile-cli-man-page": "pandoc cli/man/cline.1.md -s -t man -o cli/man/cline.1",
"test:install": "bash scripts/test-install.sh",
@@ -445,10 +437,7 @@
"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",
"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"
"storybook": "cd webview-ui && npm run storybook"
},
"lint-staged": {
"src/shared/storage/state-keys.ts": [
+1 -3
View File
@@ -106,8 +106,6 @@ message Secrets {
// in src/shared/storage/state-keys.ts and use the scripts/generate-state-proto.mjs
// script to regenerate this list.
message Settings {
reserved 152; // was hooks_enabled (removed - now always enabled on macOS/Linux)
optional string lite_llm_base_url = 1;
optional bool lite_llm_use_prompt_cache = 2;
optional string anthropic_base_url = 4;
@@ -385,7 +383,7 @@ message UpdateTaskSettingsRequest {
// Message for updating settings
message UpdateSettingsRequest {
reserved 26; // was hooks_enabled (removed - now always enabled on macOS/Linux)
reserved 26; // was hooks_enabled (removed - now always enabled on macOS/Linux)
Metadata metadata = 1;
optional ModelsApiConfiguration api_configuration = 2;
+542
View File
@@ -0,0 +1,542 @@
import { afterEach, beforeEach, describe, it } from "mocha"
import "should"
import fs from "fs/promises"
import os from "os"
import path from "path"
import sinon from "sinon"
import { ClineConfigurationError, ClineEndpoint, ClineEnv, Environment } from "../config"
describe("ClineEndpoint configuration", () => {
let sandbox: sinon.SinonSandbox
let tempDir: string
let originalHomedir: typeof os.homedir
beforeEach(async () => {
sandbox = sinon.createSandbox()
tempDir = path.join(os.tmpdir(), `config-test-${Date.now()}-${Math.random().toString(36).slice(2)}`)
await fs.mkdir(tempDir, { recursive: true })
// Create .cline directory
await fs.mkdir(path.join(tempDir, ".cline"), { recursive: true })
// Stub os.homedir to return our temp directory
originalHomedir = os.homedir
sandbox
.stub(os, "homedir")
.returns(tempDir)
// Reset the singleton state using internal method
;(ClineEndpoint as any)._instance = null
;(ClineEndpoint as any)._initialized = false
})
afterEach(async () => {
sandbox.restore()
// Reset singleton state
;(ClineEndpoint as any)._instance = null
;(ClineEndpoint as any)._initialized = false
try {
await fs.rm(tempDir, { recursive: true, force: true })
} catch {
// Ignore cleanup errors
}
})
describe("valid config parsing", () => {
it("should parse valid endpoints.json with all required fields", async () => {
const validConfig = {
appBaseUrl: "https://app.enterprise.com",
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(validConfig), "utf8")
await ClineEndpoint.initialize()
const config = ClineEndpoint.config
config.appBaseUrl.should.equal("https://app.enterprise.com")
config.apiBaseUrl.should.equal("https://api.enterprise.com")
config.mcpBaseUrl.should.equal("https://mcp.enterprise.com")
config.environment.should.equal(Environment.selfHosted)
})
it("should work without endpoints.json (standard mode)", async () => {
// No endpoints.json file exists
await ClineEndpoint.initialize()
const config = ClineEndpoint.config
config.environment.should.not.equal(Environment.selfHosted)
// Should use production defaults
config.appBaseUrl.should.equal("https://app.cline.bot")
config.apiBaseUrl.should.equal("https://api.cline.bot")
})
it("should accept URLs with ports", async () => {
const validConfig = {
appBaseUrl: "http://localhost:3000",
apiBaseUrl: "http://localhost:7777",
mcpBaseUrl: "http://localhost:8080/mcp",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(validConfig), "utf8")
await ClineEndpoint.initialize()
const config = ClineEndpoint.config
config.appBaseUrl.should.equal("http://localhost:3000")
config.apiBaseUrl.should.equal("http://localhost:7777")
config.mcpBaseUrl.should.equal("http://localhost:8080/mcp")
})
it("should accept URLs with paths", async () => {
const validConfig = {
appBaseUrl: "https://proxy.enterprise.com/cline/app",
apiBaseUrl: "https://proxy.enterprise.com/cline/api",
mcpBaseUrl: "https://proxy.enterprise.com/cline/mcp",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(validConfig), "utf8")
await ClineEndpoint.initialize()
const config = ClineEndpoint.config
config.appBaseUrl.should.equal("https://proxy.enterprise.com/cline/app")
})
})
describe("invalid JSON handling", () => {
it("should throw ClineConfigurationError for invalid JSON syntax", async () => {
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), "{ invalid json }", "utf8")
try {
await ClineEndpoint.initialize()
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql("Invalid JSON")
}
})
it("should throw ClineConfigurationError for truncated JSON", async () => {
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), '{"appBaseUrl": "https://test.com"', "utf8")
try {
await ClineEndpoint.initialize()
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql("Invalid JSON")
}
})
it("should throw ClineConfigurationError for empty file", async () => {
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), "", "utf8")
try {
await ClineEndpoint.initialize()
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
}
})
it("should throw ClineConfigurationError for non-object JSON", async () => {
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), '"just a string"', "utf8")
try {
await ClineEndpoint.initialize()
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql("must contain a JSON object")
}
})
it("should throw ClineConfigurationError for array JSON", async () => {
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), "[]", "utf8")
try {
await ClineEndpoint.initialize()
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
// Arrays pass the object check but fail on required fields
error.message.should.containEql("Missing required field")
}
})
it("should throw ClineConfigurationError for null JSON", async () => {
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), "null", "utf8")
try {
await ClineEndpoint.initialize()
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql("must contain a JSON object")
}
})
})
describe("missing required fields", () => {
it("should throw ClineConfigurationError when appBaseUrl is missing", async () => {
const config = {
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
try {
await ClineEndpoint.initialize()
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql('Missing required field "appBaseUrl"')
}
})
it("should throw ClineConfigurationError when apiBaseUrl is missing", async () => {
const config = {
appBaseUrl: "https://app.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
try {
await ClineEndpoint.initialize()
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql('Missing required field "apiBaseUrl"')
}
})
it("should throw ClineConfigurationError when mcpBaseUrl is missing", async () => {
const config = {
appBaseUrl: "https://app.enterprise.com",
apiBaseUrl: "https://api.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
try {
await ClineEndpoint.initialize()
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql('Missing required field "mcpBaseUrl"')
}
})
it("should throw ClineConfigurationError when all fields are missing", async () => {
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), "{}", "utf8")
try {
await ClineEndpoint.initialize()
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql("Missing required field")
}
})
it("should throw ClineConfigurationError when field is null", async () => {
const config = {
appBaseUrl: null,
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
try {
await ClineEndpoint.initialize()
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql('Missing required field "appBaseUrl"')
}
})
it("should throw ClineConfigurationError when field is empty string", async () => {
const config = {
appBaseUrl: "",
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
try {
await ClineEndpoint.initialize()
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql("cannot be empty")
}
})
it("should throw ClineConfigurationError when field is whitespace only", async () => {
const config = {
appBaseUrl: " ",
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
try {
await ClineEndpoint.initialize()
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql("cannot be empty")
}
})
it("should throw ClineConfigurationError when field is non-string", async () => {
const config = {
appBaseUrl: 12345,
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
try {
await ClineEndpoint.initialize()
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql("must be a string")
}
})
})
describe("invalid URL detection", () => {
it("should throw ClineConfigurationError for invalid URL format", async () => {
const config = {
appBaseUrl: "not-a-valid-url",
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
try {
await ClineEndpoint.initialize()
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql("must be a valid URL")
}
})
it("should throw ClineConfigurationError for URL without protocol", async () => {
const config = {
appBaseUrl: "app.enterprise.com",
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
try {
await ClineEndpoint.initialize()
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql("must be a valid URL")
}
})
it("should throw ClineConfigurationError for malformed URL", async () => {
const config = {
appBaseUrl: "https://",
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
try {
await ClineEndpoint.initialize()
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql("must be a valid URL")
}
})
it("should include the invalid URL value in error message", async () => {
const invalidUrl = "definitely-not-a-url"
const config = {
appBaseUrl: invalidUrl,
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
try {
await ClineEndpoint.initialize()
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql(invalidUrl)
}
})
})
describe("environment switching blocked in self-hosted mode", () => {
it("should throw error when trying to change environment in self-hosted mode", async () => {
const config = {
appBaseUrl: "https://app.enterprise.com",
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
await ClineEndpoint.initialize()
// Verify we're in self-hosted mode
ClineEndpoint.config.environment.should.equal(Environment.selfHosted)
// Try to change environment - should throw
try {
ClineEnv.setEnvironment("staging")
throw new Error("Should have thrown")
} catch (error: any) {
error.message.should.containEql("Cannot change environment in on-premise mode")
}
})
it("should throw error for all environment values in self-hosted mode", async () => {
const config = {
appBaseUrl: "https://app.enterprise.com",
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
await ClineEndpoint.initialize()
const environments = ["staging", "local", "production", "anything"]
for (const env of environments) {
try {
ClineEnv.setEnvironment(env)
throw new Error(`Should have thrown for environment: ${env}`)
} catch (error: any) {
error.message.should.containEql("Cannot change environment in on-premise mode")
}
}
})
it("should allow environment switching in standard mode", async () => {
// No endpoints.json file - standard mode
await ClineEndpoint.initialize()
// Verify we're NOT in self-hosted mode
ClineEndpoint.config.environment.should.not.equal(Environment.selfHosted)
// Should be able to change environment
ClineEnv.setEnvironment("staging")
ClineEnv.getEnvironment().environment.should.equal("staging")
ClineEnv.setEnvironment("local")
ClineEnv.getEnvironment().environment.should.equal("local")
ClineEnv.setEnvironment("production")
ClineEnv.getEnvironment().environment.should.equal("production")
})
})
describe("self-hosted mode behavior", () => {
it("should report selfHosted environment in self-hosted mode", async () => {
const config = {
appBaseUrl: "https://app.enterprise.com",
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
await ClineEndpoint.initialize()
const envConfig = ClineEndpoint.config
envConfig.environment.should.equal(Environment.selfHosted)
})
it("should use custom endpoints from file", async () => {
const customConfig = {
appBaseUrl: "https://custom-app.internal",
apiBaseUrl: "https://custom-api.internal",
mcpBaseUrl: "https://custom-mcp.internal/v1",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(customConfig), "utf8")
await ClineEndpoint.initialize()
const config = ClineEndpoint.config
config.appBaseUrl.should.equal("https://custom-app.internal")
config.apiBaseUrl.should.equal("https://custom-api.internal")
config.mcpBaseUrl.should.equal("https://custom-mcp.internal/v1")
})
})
describe("initialization behavior", () => {
it("should only initialize once", async () => {
await ClineEndpoint.initialize()
ClineEndpoint.isInitialized().should.be.true()
// Second initialize should be a no-op
await ClineEndpoint.initialize()
ClineEndpoint.isInitialized().should.be.true()
})
it("should throw error when accessing config before initialization", async () => {
// Already reset in beforeEach, so accessing should throw
try {
const _ = ClineEndpoint.config
throw new Error("Should have thrown")
} catch (error: any) {
error.message.should.containEql("not initialized")
}
})
})
describe("isSelfHosted() method", () => {
it("should return true when not initialized (safety fallback)", async () => {
// Reset singleton state - already done in beforeEach, not initialized
ClineEndpoint.isInitialized().should.be.false()
ClineEndpoint.isSelfHosted().should.be.true()
})
it("should return true when in self-hosted mode", async () => {
const config = {
appBaseUrl: "https://app.enterprise.com",
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
await ClineEndpoint.initialize()
ClineEndpoint.isSelfHosted().should.be.true()
})
it("should return false when in normal mode (no endpoints.json)", async () => {
// No endpoints.json file exists
await ClineEndpoint.initialize()
ClineEndpoint.isSelfHosted().should.be.false()
})
})
})
+14 -3
View File
@@ -7,10 +7,10 @@ import {
migrateWorkspaceToGlobalStorage,
} from "./core/storage/state-migrations"
import { WebviewProvider } from "./core/webview"
import { Logger } from "./services/logging/Logger"
import "./utils/path" // necessary to have access to String.prototype.toPosix
import { HostProvider } from "@/hosts/host-provider"
import { Logger } from "@/shared/services/Logger"
import { FileContextTracker } from "./core/context/context-tracking/FileContextTracker"
import { StateManager } from "./core/storage/StateManager"
import { openAiCodexOAuthManager } from "./integrations/openai-codex/oauth"
@@ -27,17 +27,28 @@ import { syncWorker } from "./shared/services/worker/sync"
import { getBlobStoreSettingsFromEnv } from "./shared/services/worker/worker"
import { getLatestAnnouncementId } from "./utils/announcements"
import { arePathsEqual } from "./utils/path"
/**
* Performs intialization for Cline that is common to all platforms.
*
* @param context
* @returns The webview provider
* @throws ClineConfigurationError if endpoints.json exists but is invalid
*/
export async function initialize(context: vscode.ExtensionContext): Promise<WebviewProvider> {
// Configure the shared Logging class to use HostProvider's output channel
Logger.setOutput((msg: string) => HostProvider.get().logToChannel(msg))
// Initialize ClineEndpoint configuration first (reads ~/.cline/endpoints.json if present)
// This must be done before any other code that calls ClineEnv.config()
// Throws ClineConfigurationError if config file exists but is invalid
const { ClineEndpoint } = await import("./config")
await ClineEndpoint.initialize()
try {
await StateManager.initialize(context)
} catch (error) {
console.error("[Controller] CRITICAL: Failed to initialize StateManager - extension may not function properly:", error)
Logger.error("[Controller] CRITICAL: Failed to initialize StateManager - extension may not function properly:", error)
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message: "Failed to initialize Cline's application state. Please restart the extension.",
@@ -123,7 +134,7 @@ async function showVersionUpdateAnnouncement(context: vscode.ExtensionContext) {
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
console.error(`Error during post-update actions: ${errorMessage}, Stack trace: ${error.stack}`)
Logger.error(`Error during post-update actions: ${errorMessage}, Stack trace: ${error.stack}`)
}
}
+227 -14
View File
@@ -1,22 +1,38 @@
export enum Environment {
production = "production",
staging = "staging",
local = "local",
}
import * as fs from "fs/promises"
import * as os from "os"
import * as path from "path"
import { Environment, type EnvironmentConfig } from "./shared/config-types"
import { Logger } from "./shared/services/Logger"
export interface EnvironmentConfig {
environment: Environment
export { Environment, type EnvironmentConfig }
/**
* Schema for the endpoints.json configuration file used in on-premise deployments.
* All fields are required and must be valid URLs.
*/
interface EndpointsFileSchema {
appBaseUrl: string
apiBaseUrl: string
mcpBaseUrl: string
}
class ClineEndpoint {
public static instance = new ClineEndpoint()
public static get config() {
return ClineEndpoint.instance.config()
/**
* Error thrown when the Cline configuration file exists but is invalid.
* This error prevents Cline from starting to avoid misconfiguration in enterprise environments.
*/
export class ClineConfigurationError extends Error {
constructor(message: string) {
super(message)
this.name = "ClineConfigurationError"
}
}
class ClineEndpoint {
private static _instance: ClineEndpoint | null = null
private static _initialized = false
// On-premise config loaded from file (null if not on-premise)
private onPremiseConfig: EndpointsFileSchema | null = null
private environment: Environment = Environment.production
private constructor() {
@@ -24,15 +40,189 @@ class ClineEndpoint {
const _env = process?.env?.CLINE_ENVIRONMENT_OVERRIDE || process?.env?.CLINE_ENVIRONMENT
if (_env && Object.values(Environment).includes(_env as Environment)) {
this.environment = _env as Environment
return
}
}
/**
* Initializes the ClineEndpoint singleton.
* Must be called before any other methods.
* Reads the endpoints.json file if it exists and validates its schema.
*
* @throws ClineConfigurationError if the endpoints.json file exists but is invalid
*/
public static async initialize(): Promise<void> {
if (ClineEndpoint._initialized) {
return
}
ClineEndpoint._instance = new ClineEndpoint()
// Try to load on-premise config from file
const endpointsConfig = await ClineEndpoint.loadEndpointsFile()
if (endpointsConfig) {
ClineEndpoint._instance.onPremiseConfig = endpointsConfig
Logger.log("Cline running in self-hosted mode with custom endpoints")
}
ClineEndpoint._initialized = true
}
/**
* Returns true if the ClineEndpoint has been initialized.
*/
public static isInitialized(): boolean {
return ClineEndpoint._initialized
}
/**
* Checks if Cline is running in self-hosted/on-premise mode.
* @returns true if in selfHosted mode, or true if not initialized (safety fallback to prevent accidental external calls)
*/
public static isSelfHosted(): boolean {
// Safety fallback: if not initialized, treat as selfHosted
// to prevent accidental external service calls before configuration is loaded
if (!ClineEndpoint._initialized) {
return true
}
return ClineEndpoint.config.environment === Environment.selfHosted
}
/**
* Returns the singleton instance.
* @throws Error if not initialized
*/
public static get instance(): ClineEndpoint {
if (!ClineEndpoint._initialized || !ClineEndpoint._instance) {
throw new Error("ClineEndpoint not initialized. Call ClineEndpoint.initialize() first.")
}
return ClineEndpoint._instance
}
/**
* Static getter for convenient access to the current configuration.
* @throws Error if not initialized
*/
public static get config(): EnvironmentConfig {
return ClineEndpoint.instance.config()
}
/**
* Returns the path to the endpoints.json configuration file.
* Located at ~/.cline/endpoints.json
*/
private static getEndpointsFilePath(): string {
return path.join(os.homedir(), ".cline", "endpoints.json")
}
/**
* Loads and validates the endpoints.json file.
* @returns The validated endpoints config, or null if the file doesn't exist
* @throws ClineConfigurationError if the file exists but is invalid
*/
private static async loadEndpointsFile(): Promise<EndpointsFileSchema | null> {
const filePath = ClineEndpoint.getEndpointsFilePath()
try {
await fs.access(filePath)
} catch {
// File doesn't exist - not on-premise mode
return null
}
// File exists, must be valid or we fail
try {
const fileContent = await fs.readFile(filePath, "utf8")
let data: unknown
try {
data = JSON.parse(fileContent)
} catch (parseError) {
throw new ClineConfigurationError(
`Invalid JSON in endpoints configuration file (${filePath}): ${parseError instanceof Error ? parseError.message : String(parseError)}`,
)
}
return ClineEndpoint.validateEndpointsSchema(data, filePath)
} catch (error) {
if (error instanceof ClineConfigurationError) {
throw error
}
throw new ClineConfigurationError(
`Failed to read endpoints configuration file (${filePath}): ${error instanceof Error ? error.message : String(error)}`,
)
}
}
/**
* Validates that the provided data matches the EndpointsFileSchema.
* All fields must be present and be valid URLs.
*
* @param data The parsed JSON data to validate
* @param filePath The path to the file (for error messages)
* @returns The validated EndpointsFileSchema
* @throws ClineConfigurationError if validation fails
*/
private static validateEndpointsSchema(data: unknown, filePath: string): EndpointsFileSchema {
if (typeof data !== "object" || data === null) {
throw new ClineConfigurationError(`Endpoints configuration file (${filePath}) must contain a JSON object`)
}
const obj = data as Record<string, unknown>
const requiredFields = ["appBaseUrl", "apiBaseUrl", "mcpBaseUrl"] as const
const result: Partial<EndpointsFileSchema> = {}
for (const field of requiredFields) {
const value = obj[field]
if (value === undefined || value === null) {
throw new ClineConfigurationError(
`Missing required field "${field}" in endpoints configuration file (${filePath})`,
)
}
if (typeof value !== "string") {
throw new ClineConfigurationError(
`Field "${field}" in endpoints configuration file (${filePath}) must be a string`,
)
}
if (!value.trim()) {
throw new ClineConfigurationError(
`Field "${field}" in endpoints configuration file (${filePath}) cannot be empty`,
)
}
// Validate URL format
try {
new URL(value)
} catch {
throw new ClineConfigurationError(
`Field "${field}" in endpoints configuration file (${filePath}) must be a valid URL. Got: "${value}"`,
)
}
result[field] = value
}
return result as EndpointsFileSchema
}
/**
* Returns the current environment configuration.
*/
public config(): EnvironmentConfig {
return this.getEnvironment()
}
/**
* Sets the current environment.
* @throws Error if in on-premise mode (environment switching is disabled)
*/
public setEnvironment(env: string) {
if (this.onPremiseConfig) {
throw new Error("Cannot change environment in on-premise mode. Endpoints are configured via ~/.cline/endpoints.json")
}
switch (env.toLowerCase()) {
case "staging":
this.environment = Environment.staging
@@ -44,10 +234,24 @@ class ClineEndpoint {
this.environment = Environment.production
break
}
console.info("Cline environment updated: ", this.environment)
}
/**
* Returns the current environment configuration.
* If running in on-premise mode, returns the custom endpoints.
*/
public getEnvironment(): EnvironmentConfig {
// On-premise mode: use custom endpoints from file
if (this.onPremiseConfig) {
return {
environment: Environment.selfHosted,
appBaseUrl: this.onPremiseConfig.appBaseUrl,
apiBaseUrl: this.onPremiseConfig.apiBaseUrl,
mcpBaseUrl: this.onPremiseConfig.mcpBaseUrl,
}
}
// Standard mode: use built-in environment URLs
switch (this.environment) {
case Environment.staging:
return {
@@ -79,5 +283,14 @@ class ClineEndpoint {
* Usage:
* - ClineEnv.config() to get the current config.
* - ClineEnv.setEnvironment(Environment.local) to change the environment.
*
* IMPORTANT: ClineEndpoint.initialize() must be called before using ClineEnv.
*/
export const ClineEnv = ClineEndpoint.instance
export const ClineEnv = {
config: () => ClineEndpoint.config,
setEnvironment: (env: string) => ClineEndpoint.instance.setEnvironment(env),
getEnvironment: () => ClineEndpoint.instance.getEnvironment(),
}
// Export the class for initialization
export { ClineEndpoint }
+2 -1
View File
@@ -1,6 +1,7 @@
import { ApiConfiguration, ModelInfo, QwenApiRegions } from "@shared/api"
import { Mode } from "@shared/storage/types"
import { ClineStorageMessage } from "@/shared/messages/content"
import { Logger } from "@/shared/services/Logger"
import { ClineTool } from "@/shared/tools"
import { AIhubmixHandler } from "./providers/aihubmix"
import { AnthropicHandler } from "./providers/anthropic"
@@ -480,7 +481,7 @@ export function buildApiHandler(configuration: ApiConfiguration, mode: Mode): Ap
}
}
} catch (error) {
console.error("buildApiHandler error:", error)
Logger.error("buildApiHandler error:", error)
}
return createHandlerForProvider(apiProvider, options, mode)
+4 -3
View File
@@ -1,6 +1,7 @@
import { AskSageModelId, askSageDefaultModelId, askSageDefaultURL, askSageModels, ModelInfo } from "@shared/api"
import { ClineStorageMessage } from "@/shared/messages/content"
import { fetch } from "@/shared/net"
import { Logger } from "@/shared/services/Logger"
import { ApiHandler, CommonApiHandlerOptions } from ".."
import { withRetry } from "../retry"
import { ApiStream } from "../transform/stream"
@@ -58,7 +59,7 @@ export class AskSageHandler implements ApiHandler {
private apiKey: string
constructor(options: AskSageHandlerOptions) {
console.log("init api url", options.asksageApiUrl, askSageDefaultURL)
Logger.log("init api url", options.asksageApiUrl, askSageDefaultURL)
this.options = options
this.apiKey = options.asksageApiKey || ""
this.apiUrl = options.asksageApiUrl || askSageDefaultURL
@@ -164,7 +165,7 @@ export class AskSageHandler implements ApiHandler {
})
if (!response.ok) {
console.error("Failed to fetch AskSage usage", await response.text())
Logger.error("Failed to fetch AskSage usage", await response.text())
return undefined
}
@@ -177,7 +178,7 @@ export class AskSageHandler implements ApiHandler {
outputTokens: 0,
}
} catch (error) {
console.error("Error fetching AskSage usage:", error)
Logger.error("Error fetching AskSage usage:", error)
return undefined
}
}
+13 -8
View File
@@ -12,6 +12,7 @@ import { BedrockModelId, bedrockDefaultModelId, bedrockModels, CLAUDE_SONNET_1M_
import { calculateApiCostOpenAI, calculateApiCostQwen } from "@utils/cost"
import { ExtensionRegistryInfo } from "@/registry"
import { ClineStorageMessage } from "@/shared/messages/content"
import { Logger } from "@/shared/services/Logger"
import { ApiHandler, CommonApiHandlerOptions } from "../"
import { withRetry } from "../retry"
import { convertToR1Format } from "../transform/r1-format"
@@ -109,9 +110,13 @@ interface ProviderChainOptions {
profile?: string
}
// a special jp inference profile was created for sonnet 4.5
// a special jp inference profile was created for sonnet 4.5 & haiku 4.5
// https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html
const JP_SUPPORTED_CRIS_MODELS = ["anthropic.claude-sonnet-4-5-20250929-v1:0", "anthropic.claude-sonnet-4-5-20250929-v1:0:1m"]
const JP_SUPPORTED_CRIS_MODELS = [
"anthropic.claude-sonnet-4-5-20250929-v1:0",
"anthropic.claude-sonnet-4-5-20250929-v1:0:1m",
"anthropic.claude-haiku-4-5-20251001-v1:0",
]
// https://docs.anthropic.com/en/api/claude-on-amazon-bedrock
export class AwsBedrockHandler implements ApiHandler {
@@ -449,7 +454,7 @@ export class AwsBedrockHandler implements ApiHandler {
}
}
} catch (error) {
console.error("Error parsing Deepseek response chunk:", error)
Logger.error("Error parsing Deepseek response chunk:", error)
// Propagate the error by yielding a text response with error information
yield {
type: "text",
@@ -550,7 +555,7 @@ export class AwsBedrockHandler implements ApiHandler {
for await (const chunk of response.stream) {
// Debug logging to see actual response structure
// console.log("Bedrock chunk:", JSON.stringify(chunk, null, 2))
// Logger.log("Bedrock chunk:", JSON.stringify(chunk, null, 2))
// Handle thinking response in additionalModelResponseFields (LangChain format)
const metadata = chunk.metadata as ExtendedMetadata | undefined
@@ -854,7 +859,7 @@ export class AwsBedrockHandler implements ApiHandler {
}
// Log unsupported content types for debugging
console.warn(`Unsupported content type: ${(item as ContentItem).type}`)
Logger.warn(`Unsupported content type: ${(item as ContentItem).type}`)
return null
})
.filter((item): item is ContentBlock => item !== null)
@@ -912,7 +917,7 @@ export class AwsBedrockHandler implements ApiHandler {
},
}
} catch (error) {
console.error("Failed to process image content:", error)
Logger.error("Failed to process image content:", error)
// Return a text content indicating the error instead of null
// This ensures users are aware of the issue
return {
@@ -1111,7 +1116,7 @@ export class AwsBedrockHandler implements ApiHandler {
}
}
} catch (error) {
console.error("Error with OpenAI model via Converse API:", error)
Logger.error("Error with OpenAI model via Converse API:", error)
// Try to extract more detailed error information
let errorMessage = "Failed to process OpenAI model request"
@@ -1246,7 +1251,7 @@ export class AwsBedrockHandler implements ApiHandler {
}
}
} catch (error) {
console.error("Error with Qwen model via Converse API:", error)
Logger.error("Error with Qwen model via Converse API:", error)
// Try to extract more detailed error information
let errorMessage = "Failed to process Qwen model request"
+3 -2
View File
@@ -112,10 +112,11 @@ export class CerebrasHandler implements ApiHandler {
}
try {
const model = this.getModel()
const stream = await client.chat.completions.create({
model: this.getModel().id,
model: model.id,
messages: cerebrasMessages,
temperature: 0,
temperature: model.info.temperature ?? 0,
stream: true,
max_tokens: CEREBRAS_DEFAULT_MAX_TOKENS,
})
+11 -8
View File
@@ -7,10 +7,10 @@ import { ClineEnv } from "@/config"
import { ClineAccountService } from "@/services/account/ClineAccountService"
import { AuthService } from "@/services/auth/AuthService"
import { buildClineExtraHeaders } from "@/services/EnvUtils"
import { Logger } from "@/services/logging/Logger"
import { CLINE_ACCOUNT_AUTH_ERROR_MESSAGE } from "@/shared/ClineAccount"
import { ClineStorageMessage } from "@/shared/messages/content"
import { fetch, getAxiosSettings } from "@/shared/net"
import { Logger } from "@/shared/services/Logger"
import { ApiHandler, CommonApiHandlerOptions } from "../"
import { withRetry } from "../retry"
import { createOpenRouterStream } from "../transform/openrouter-stream"
@@ -35,10 +35,13 @@ export class ClineHandler implements ApiHandler {
private clineAccountService = ClineAccountService.getInstance()
private _authService: AuthService
private client: OpenAI | undefined
private readonly _baseUrl = ClineEnv.config().apiBaseUrl
lastGenerationId?: string
private lastRequestId?: string
private get _baseUrl(): string {
return ClineEnv.config().apiBaseUrl
}
constructor(options: ClineHandlerOptions) {
this.options = options
this._authService = AuthService.getInstance()
@@ -126,7 +129,7 @@ export class ClineHandler implements ApiHandler {
// openrouter returns an error object instead of the openai sdk throwing an error
if ("error" in chunk) {
const error = chunk.error as OpenRouterErrorResponse["error"]
console.error(`Cline API Error: ${error?.code} - ${error?.message}`)
Logger.error(`Cline API Error: ${error?.code} - ${error?.message}`)
// Include metadata in the error message if available
const metadataStr = error.metadata ? `\nMetadata: ${JSON.stringify(error.metadata, null, 2)}` : ""
throw new Error(`Cline API Error ${error.code}: ${error.message}${metadataStr}`)
@@ -143,7 +146,7 @@ export class ClineHandler implements ApiHandler {
const choiceWithError = choice as any
if (choiceWithError.error) {
const error = choiceWithError.error
console.error(`Cline Mid-Stream Error: ${error.code || error.type || "Unknown"} - ${error.message}`)
Logger.error(`Cline Mid-Stream Error: ${error.code || error.type || "Unknown"} - ${error.message}`)
throw new Error(`Cline Mid-Stream Error: ${error.code || error.type || "Unknown"} - ${error.message}`)
} else {
throw new Error(
@@ -199,7 +202,7 @@ export class ClineHandler implements ApiHandler {
// @ts-ignore-next-line
let totalCost = (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0)
if (["x-ai/grok-code-fast-1", "kwaipilot/kat-coder-pro"].includes(this.getModel().id)) {
if (["kwaipilot/kat-coder-pro"].includes(this.getModel().id)) {
totalCost = 0
}
@@ -217,14 +220,14 @@ export class ClineHandler implements ApiHandler {
// Fallback to generation endpoint if usage chunk not returned
if (!didOutputUsage) {
console.warn("Cline API did not return usage chunk, fetching from generation endpoint")
Logger.warn("Cline API did not return usage chunk, fetching from generation endpoint")
const apiStreamUsage = await this.getApiStreamUsage()
if (apiStreamUsage) {
yield apiStreamUsage
}
}
} catch (error) {
console.error("Cline API Error:", error)
Logger.error("Cline API Error:", error)
throw error
}
}
@@ -260,7 +263,7 @@ export class ClineHandler implements ApiHandler {
}
} catch (error) {
// ignore if fails
console.error("Error fetching cline generation details:", error)
Logger.error("Error fetching cline generation details:", error)
}
}
return undefined
+4 -4
View File
@@ -1,6 +1,6 @@
import { Logger } from "@/services/logging/Logger"
import { ClineStorageMessage } from "@/shared/messages/content"
import { fetch } from "@/shared/net"
import { Logger } from "@/shared/services/Logger"
import { ModelInfo } from "../../../shared/api"
import { ApiHandler } from "../index"
import { ApiStream } from "../transform/stream"
@@ -144,7 +144,7 @@ export class DifyHandler implements ApiHandler {
if (!response.ok) {
const errorText = await response.text()
Logger.debug("[DIFY DEBUG] Error response:", errorText)
Logger.error("[DIFY DEBUG] Error response:", errorText)
throw new Error(`Dify API error: ${response.status} ${response.statusText} - ${errorText}`)
}
@@ -300,7 +300,7 @@ export class DifyHandler implements ApiHandler {
}
}
} catch (e) {
Logger.info("[DIFY DEBUG] Failed to parse JSON:", data, "Error:", e)
Logger.warn("[DIFY DEBUG] Failed to parse JSON:", data, "Error:", e)
}
} else if (line.trim() !== "") {
Logger.log(
@@ -360,7 +360,7 @@ export class DifyHandler implements ApiHandler {
streamDuration: Date.now() - lastEventTime,
conversationId: this.conversationId,
}
Logger.info("[DIFY DEBUG] No content was yielded! Diagnostic info:", diagnosticInfo)
Logger.error("[DIFY DEBUG] No content was yielded! Diagnostic info:", diagnosticInfo)
// If we have any accumulated text at all, yield it as a fallback
if (fullText.trim()) {
+6 -5
View File
@@ -11,6 +11,7 @@ import {
import { GeminiModelId, geminiDefaultModelId, geminiModels, ModelInfo } from "@shared/api"
import { telemetryService } from "@/services/telemetry"
import { ClineStorageMessage } from "@/shared/messages/content"
import { Logger } from "@/shared/services/Logger"
import { ApiHandler, CommonApiHandlerOptions } from "../"
import { RetriableError, withRetry } from "../retry"
import { convertAnthropicMessageToGemini } from "../transform/gemini-format"
@@ -332,7 +333,7 @@ export class GeminiHandler implements ApiHandler {
throughputTokensPerSec: throughputTokensPerSecSdk,
})
} else {
console.warn("GeminiHandler: ulid not available for telemetry in createMessage.")
Logger.warn("GeminiHandler: ulid not available for telemetry in createMessage.")
}
}
}
@@ -408,7 +409,7 @@ export class GeminiHandler implements ApiHandler {
trace.cacheRead = { price: cacheReadsPrice, tokens: cacheReadTokens ?? 0, cost: cacheReadCost }
}
// console.log(`[GeminiHandler] calculateCost -> ${totalCost}`, trace)
// Logger.log(`[GeminiHandler] calculateCost -> ${totalCost}`, trace)
return totalCost
}
@@ -450,13 +451,13 @@ export class GeminiHandler implements ApiHandler {
})
if (response.totalTokens === undefined) {
console.warn("Gemini token counting returned undefined, using fallback")
Logger.warn("Gemini token counting returned undefined, using fallback")
return this.estimateTokens(content)
}
return response.totalTokens
} catch (error) {
console.warn("Gemini token counting failed, using fallback", error)
Logger.warn("Gemini token counting failed, using fallback", error)
return this.estimateTokens(content)
}
}
@@ -475,7 +476,7 @@ export class GeminiHandler implements ApiHandler {
const jsonStr = JSON.stringify(block)
return total + jsonStr.length
} catch (e) {
console.warn("Failed to stringify block for token estimation", e)
Logger.warn("Failed to stringify block for token estimation", e)
return total
}
}
+6 -5
View File
@@ -4,6 +4,7 @@ import OpenAI from "openai"
import { StateManager } from "@/core/storage/StateManager"
import { ClineStorageMessage } from "@/shared/messages/content"
import { fetch } from "@/shared/net"
import { Logger } from "@/shared/services/Logger"
import { isAnthropicModelId } from "@/utils/model-utils"
import { ApiHandler, CommonApiHandlerOptions } from ".."
import { withRetry } from "../retry"
@@ -70,7 +71,7 @@ export async function fetchLiteLlmModelsInfo(baseUrl: string, apiKey: string): P
const data: LiteLlmModelInfoResponse = await response.json()
return data
} else {
console.error("Failed to fetch LiteLLM model info:", response.statusText)
Logger.error("Failed to fetch LiteLLM model info:", response.statusText)
// Try with Authorization header instead
const retryResponse = await fetch(url, {
method: "GET",
@@ -84,12 +85,12 @@ export async function fetchLiteLlmModelsInfo(baseUrl: string, apiKey: string): P
const data: LiteLlmModelInfoResponse = await retryResponse.json()
return data
} else {
console.error("Failed to fetch LiteLLM model info with Authorization header:", retryResponse.statusText)
Logger.error("Failed to fetch LiteLLM model info with Authorization header:", retryResponse.statusText)
throw new Error(`Failed to fetch LiteLLM model info: ${retryResponse.statusText}`)
}
}
} catch (error) {
console.error("Error fetching LiteLLM model info:", error)
Logger.error("Error fetching LiteLLM model info:", error)
throw error
}
}
@@ -169,7 +170,7 @@ export class LiteLlmHandler implements ApiHandler {
}
}
} catch (error) {
console.warn("Error getting LiteLLM model cost info:", error)
Logger.warn("Error getting LiteLLM model cost info:", error)
}
// Fallback to zero costs if we can't get the information
@@ -200,7 +201,7 @@ export class LiteLlmHandler implements ApiHandler {
return totalCost
} catch (error) {
console.error("Error calculating spend:", error)
Logger.error("Error calculating spend:", error)
return undefined
}
}
+4 -4
View File
@@ -8,11 +8,11 @@ import {
OCI_HEADER_OPC_REQUEST_ID,
} from "@/services/auth/oca/utils/constants"
import { createOcaHeaders } from "@/services/auth/oca/utils/utils"
import { Logger } from "@/services/logging/Logger"
import { OcaModelInfo } from "@/shared/api"
import { ClineStorageMessage } from "@/shared/messages/content"
import { fetch } from "@/shared/net"
import { ApiFormat } from "@/shared/proto/index.cline"
import { Logger } from "@/shared/services/Logger"
import { ApiHandler, type CommonApiHandlerOptions } from ".."
import { withRetry } from "../retry"
import { convertToOpenAiMessages } from "../transform/openai-format"
@@ -134,11 +134,11 @@ export class OcaHandler implements ApiHandler {
const data: { cost: number } = await response.json()
return data.cost
} else {
console.error("Error calculating spend:", response.statusText)
Logger.error("Error calculating spend:", response.statusText)
return undefined
}
} catch (error) {
console.error("Error calculating spend:", error)
Logger.error("Error calculating spend:", error)
return undefined
}
}
@@ -313,7 +313,7 @@ export class OcaHandler implements ApiHandler {
// Convert ChatCompletion tools to Responses API format if provided
const responseTools = tools
?.filter((tool) => tool.type === "function")
?.filter((tool) => tool?.type === "function")
.map((tool: any) => ({
type: "function" as const,
name: tool.function.name,
+3 -2
View File
@@ -2,6 +2,7 @@ import { type ModelInfo, openAiModelInfoSaneDefaults } from "@shared/api"
import { type Config, type Message, Ollama } from "ollama"
import { ClineStorageMessage } from "@/shared/messages/content"
import { fetch } from "@/shared/net"
import { Logger } from "@/shared/services/Logger"
import type { ApiHandler, CommonApiHandlerOptions } from "../"
import { withRetry } from "../retry"
import { convertToOllamaMessages } from "../transform/ollama-format"
@@ -93,7 +94,7 @@ export class OllamaHandler implements ApiHandler {
}
}
} catch (streamError: any) {
console.error("Error processing Ollama stream:", streamError)
Logger.error("Error processing Ollama stream:", streamError)
throw new Error(`Ollama stream processing error: ${streamError.message || "Unknown error"}`)
}
} catch (error) {
@@ -107,7 +108,7 @@ export class OllamaHandler implements ApiHandler {
const statusCode = error.status || error.statusCode
const errorMessage = error.message || "Unknown error"
console.error(`Ollama API error (${statusCode || "unknown"}): ${errorMessage}`)
Logger.error(`Ollama API error (${statusCode || "unknown"}): ${errorMessage}`)
throw error
}
}
+7 -3
View File
@@ -48,7 +48,9 @@ export class OpenAiCodexHandler implements ApiHandler {
}
private normalizeUsage(usage: any, model: { id: string; info: ModelInfo }): ApiStreamUsageChunk | undefined {
if (!usage) return undefined
if (!usage) {
return undefined
}
const inputDetails = usage.input_tokens_details ?? usage.prompt_tokens_details
@@ -155,7 +157,7 @@ export class OpenAiCodexHandler implements ApiHandler {
// Pass through strict value from tool (MCP/custom tools have strict: false, built-in tools default to true)
if (tools && tools.length > 0) {
body.tools = tools
.filter((tool: any) => tool.type === "function")
.filter((tool: any) => tool?.type === "function")
.map((tool: any) => ({
type: "function",
name: tool.function.name,
@@ -295,7 +297,9 @@ export class OpenAiCodexHandler implements ApiHandler {
}
const { done, value } = await reader.read()
if (done) break
if (done) {
break
}
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split("\n")
+6 -6
View File
@@ -8,10 +8,10 @@ import {
import { calculateApiCostOpenAI } from "@utils/cost"
import OpenAI from "openai"
import type { ChatCompletionReasoningEffort, ChatCompletionTool } from "openai/resources/chat/completions"
import { Logger } from "@/services/logging/Logger"
import { ClineStorageMessage } from "@/shared/messages/content"
import { fetch } from "@/shared/net"
import { ApiFormat } from "@/shared/proto/cline/models"
import { Logger } from "@/shared/services/Logger"
import { isGPT5ModelFamily } from "@/utils/model-utils"
import { ApiHandler, CommonApiHandlerOptions } from "../"
import { withRetry } from "../retry"
@@ -135,7 +135,7 @@ export class OpenAiNativeHandler implements ApiHandler {
try {
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
} catch (error) {
console.error("Error processing tool call delta:", error, delta.tool_calls)
Logger.error("Error processing tool call delta:", error, delta.tool_calls)
}
}
@@ -159,7 +159,7 @@ export class OpenAiNativeHandler implements ApiHandler {
// Convert ChatCompletion tools to Responses API format if provided
const responseTools = tools
?.filter((tool) => tool.type === "function")
?.filter((tool) => tool?.type === "function")
.map((tool: any) => ({
type: "function" as const,
name: tool.function.name,
@@ -316,11 +316,11 @@ export class OpenAiNativeHandler implements ApiHandler {
chunk.response?.status === "incomplete" &&
chunk.response?.incomplete_details?.reason === "max_output_tokens"
) {
console.log("Ran out of tokens")
Logger.log("Ran out of tokens")
if (chunk.response?.output_text?.length > 0) {
console.log("Partial output:", chunk.response.output_text)
Logger.log("Partial output:", chunk.response.output_text)
} else {
console.log("Ran out of tokens during reasoning")
Logger.log("Ran out of tokens during reasoning")
}
}
+7 -6
View File
@@ -7,6 +7,7 @@ import OpenAI from "openai"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import { ClineStorageMessage } from "@/shared/messages/content"
import { fetch, getAxiosSettings } from "@/shared/net"
import { Logger } from "@/shared/services/Logger"
import { ApiHandler, CommonApiHandlerOptions } from "../"
import { withRetry } from "../retry"
import { createOpenRouterStream } from "../transform/openrouter-stream"
@@ -80,7 +81,7 @@ export class OpenRouterHandler implements ApiHandler {
// Check for error field directly on chunk
if ("error" in chunk) {
const error = chunk.error as OpenRouterErrorResponse["error"]
console.error(`OpenRouter API Error: ${error?.code} - ${error?.message}`)
Logger.error(`OpenRouter API Error: ${error?.code} - ${error?.message}`)
// Include metadata in the error message if available
const metadataStr = error.metadata ? `\nMetadata: ${JSON.stringify(error.metadata, null, 2)}` : ""
throw new Error(`OpenRouter API Error ${error.code}: ${error.message}${metadataStr}`)
@@ -95,7 +96,7 @@ export class OpenRouterHandler implements ApiHandler {
const choiceWithError = choice as any
if (choiceWithError.error) {
const error = choiceWithError.error
console.error(
Logger.error(
`OpenRouter Mid-Stream Error: ${error?.code || "Unknown"} - ${error?.message || "Unknown error"}`,
)
// Format error details
@@ -179,7 +180,7 @@ export class OpenRouterHandler implements ApiHandler {
try {
const generationIterator = this.fetchGenerationDetails(this.lastGenerationId)
const generation = (await generationIterator.next()).value
// console.log("OpenRouter generation details:", generation)
// Logger.log("OpenRouter generation details:", generation)
return {
type: "usage",
cacheWriteTokens: 0,
@@ -191,7 +192,7 @@ export class OpenRouterHandler implements ApiHandler {
}
} catch (error) {
// ignore if fails
console.error("Error fetching OpenRouter generation details:", error)
Logger.error("Error fetching OpenRouter generation details:", error)
}
}
return undefined
@@ -199,7 +200,7 @@ export class OpenRouterHandler implements ApiHandler {
@withRetry({ maxRetries: 4, baseDelay: 250, maxDelay: 1000, retryAllErrors: true })
async *fetchGenerationDetails(genId: string) {
// console.log("Fetching generation details for:", genId)
// Logger.log("Fetching generation details for:", genId)
try {
const response = await axios.get(`https://openrouter.ai/api/v1/generation?id=${genId}`, {
headers: {
@@ -211,7 +212,7 @@ export class OpenRouterHandler implements ApiHandler {
yield response.data?.data
} catch (error) {
// ignore if fails
console.error("Error fetching OpenRouter generation details:", error)
Logger.error("Error fetching OpenRouter generation details:", error)
throw error
}
}
+2 -1
View File
@@ -6,6 +6,7 @@ import * as os from "os"
import * as path from "path"
import { ClineStorageMessage } from "@/shared/messages/content"
import { fetch } from "@/shared/net"
import { Logger } from "@/shared/services/Logger"
import { ApiHandler, CommonApiHandlerOptions } from "../"
import { withRetry } from "../retry"
import { convertToOpenAiMessages } from "../transform/openai-format"
@@ -76,7 +77,7 @@ export class QwenCodeHandler implements ApiHandler {
const credsStr = await fs.readFile(keyFile, "utf-8")
return JSON.parse(credsStr)
} catch (error) {
console.error(
Logger.error(
`Error reading or parsing credentials file at ${getQwenCachedCredentialPath(this.options.qwenCodeOauthPath)}`,
)
throw new Error(`Failed to load Qwen OAuth credentials: ${error}`)
+2 -1
View File
@@ -12,6 +12,7 @@ import OpenAI from "openai"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import { ClineStorageMessage } from "@/shared/messages/content"
import { fetch } from "@/shared/net"
import { Logger } from "@/shared/services/Logger"
import { ApiHandler, CommonApiHandlerOptions } from "../"
import { withRetry } from "../retry"
import { convertToOpenAiMessages } from "../transform/openai-format"
@@ -134,7 +135,7 @@ export class QwenHandler implements ApiHandler {
try {
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
} catch (error) {
console.error("Error processing tool call delta:", error, delta.tool_calls)
Logger.error("Error processing tool call delta:", error, delta.tool_calls)
}
}
+22 -21
View File
@@ -12,6 +12,7 @@ import JSON5 from "json5"
import OpenAI from "openai"
import { ClineStorageMessage } from "@/shared/messages/content"
import { getAxiosSettings } from "@/shared/net"
import { Logger } from "@/shared/services/Logger"
import { ApiHandler, CommonApiHandlerOptions } from "../"
import { withRetry } from "../retry"
import { convertToOpenAiMessages } from "../transform/openai-format"
@@ -144,7 +145,7 @@ namespace Bedrock {
}
// Log unsupported content types for debugging
console.warn(`Unsupported content type: ${(item as ContentItem).type}`)
Logger.warn(`Unsupported content type: ${(item as ContentItem).type}`)
return null
})
.filter((item): item is BedrockContentBlock => item !== null)
@@ -213,7 +214,7 @@ namespace Bedrock {
},
}
} catch (error) {
console.error("Failed to process image content:", error)
Logger.error("Failed to process image content:", error)
// Return a text content indicating the error instead of null
// This ensures users are aware of the issue
return {
@@ -412,7 +413,7 @@ export class SapAiCoreHandler implements ApiHandler {
url: destination.url || this.options.sapAiCoreBaseUrl!,
}
} catch (error) {
console.error("Failed to create AI Core destination:", error)
Logger.error("Failed to create AI Core destination:", error)
throw new Error(`Unable to create AI Core destination: ${error instanceof Error ? error.message : String(error)}`)
}
}
@@ -473,7 +474,7 @@ export class SapAiCoreHandler implements ApiHandler {
})
.filter((deployment: any) => deployment !== null)
} catch (error) {
console.error("Error fetching deployments:", error)
Logger.error("Error fetching deployments:", error)
throw new Error("Failed to fetch deployments")
}
}
@@ -577,7 +578,7 @@ export class SapAiCoreHandler implements ApiHandler {
}
}
} catch (error) {
console.error("Error in SAP orchestration mode:", error)
Logger.error("Error in SAP orchestration mode:", error)
throw error
}
}
@@ -596,7 +597,7 @@ export class SapAiCoreHandler implements ApiHandler {
if (!deploymentId) {
// Fallback to runtime deployment id fetching for users who haven't opened the SAP provider UI
console.log(`No pre-configured deployment ID found for model ${model.id}, falling back to runtime fetching`)
Logger.log(`No pre-configured deployment ID found for model ${model.id}, falling back to runtime fetching`)
deploymentId = await this.getDeploymentForModel(model.id)
}
@@ -800,8 +801,8 @@ export class SapAiCoreHandler implements ApiHandler {
if (error.response) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
console.error("Error status:", error.response.status)
console.error("Error headers:", error.response.headers)
Logger.error("Error status:", error.response.status)
Logger.error("Error headers:", error.response.headers)
// Handle error data - need to read stream if responseType was 'stream'
let errorMessage = "Unknown error"
@@ -830,10 +831,10 @@ export class SapAiCoreHandler implements ApiHandler {
} else if (typeof error.response.data === "object") {
errorMessage = JSON.stringify(error.response.data, null, 2)
}
console.error("Error data:", errorMessage)
Logger.error("Error data:", errorMessage)
} catch (e) {
console.error("Failed to read error data:", e)
console.error("Raw error data:", error.response.data)
Logger.error("Failed to read error data:", e)
Logger.error("Raw error data:", error.response.data)
}
}
@@ -846,11 +847,11 @@ export class SapAiCoreHandler implements ApiHandler {
throw new Error(`HTTP ${error.response.status}: ${errorMessage}`)
} else if (error.request) {
// The request was made but no response was received
console.error("Error request:", error.request)
Logger.error("Error request:", error.request)
throw new Error("No response received from server")
} else {
// Something happened in setting up the request that triggered an Error
console.error("Error message:", error.message)
Logger.error("Error message:", error.message)
throw new Error(`Error setting up request: ${error.message}`)
}
}
@@ -898,13 +899,13 @@ export class SapAiCoreHandler implements ApiHandler {
}
}
} catch (error) {
console.error("Failed to parse JSON data:", error)
Logger.error("Failed to parse JSON data:", error)
}
}
}
}
} catch (error) {
console.error("Error streaming completion:", error)
Logger.error("Error streaming completion:", error)
throw error
}
}
@@ -965,7 +966,7 @@ export class SapAiCoreHandler implements ApiHandler {
}
}
} catch (error) {
console.error("Failed to parse JSON data:", error)
Logger.error("Failed to parse JSON data:", error)
yield {
type: "text",
text: `[ERROR] Failed to parse response data: ${error instanceof Error ? error.message : String(error)}`,
@@ -975,7 +976,7 @@ export class SapAiCoreHandler implements ApiHandler {
}
}
} catch (error) {
console.error("Error streaming completion:", error)
Logger.error("Error streaming completion:", error)
yield {
type: "text",
text: `[ERROR] Failed to process stream: ${error instanceof Error ? error.message : String(error)}`,
@@ -1044,13 +1045,13 @@ export class SapAiCoreHandler implements ApiHandler {
}
}
} catch (error) {
console.error("Failed to parse GPT JSON data:", error)
Logger.error("Failed to parse GPT JSON data:", error)
}
}
}
}
} catch (error) {
console.error("Error streaming GPT completion:", error)
Logger.error("Error streaming GPT completion:", error)
throw error
}
}
@@ -1109,13 +1110,13 @@ export class SapAiCoreHandler implements ApiHandler {
}
}
} catch (error) {
console.error("Failed to parse Gemini JSON data:", error)
Logger.error("Failed to parse Gemini JSON data:", error)
}
}
}
}
} catch (error) {
console.error("Error streaming Gemini completion:", error)
Logger.error("Error streaming Gemini completion:", error)
throw error
}
}
+4 -3
View File
@@ -4,6 +4,7 @@ import OpenAI from "openai"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import { ClineStorageMessage } from "@/shared/messages/content"
import { fetch } from "@/shared/net"
import { Logger } from "@/shared/services/Logger"
import { ApiHandler, CommonApiHandlerOptions } from "../index"
import { withRetry } from "../retry"
import { ApiStream } from "../transform/stream"
@@ -125,11 +126,11 @@ export class VercelAIGatewayHandler implements ApiHandler {
}
if (!didOutputUsage) {
console.warn("Vercel AI Gateway did not provide usage information in stream")
Logger.warn("Vercel AI Gateway did not provide usage information in stream")
}
} catch (error: any) {
console.error("Vercel AI Gateway error details:", error)
console.error("Error stack:", error.stack)
Logger.error("Vercel AI Gateway error details:", error)
Logger.error("Error stack:", error.stack)
throw new Error(`Vercel AI Gateway error: ${error.message}`)
}
}
+18 -17
View File
@@ -3,6 +3,7 @@ import { SELECTOR_SEPARATOR, stringifyVsCodeLmModelSelector } from "@shared/vsCo
import { calculateApiCostAnthropic } from "@utils/cost"
import * as vscode from "vscode"
import { ClineStorageMessage } from "@/shared/messages/content"
import { Logger } from "@/shared/services/Logger"
import { ApiHandler, CommonApiHandlerOptions, SingleCompletionHandler } from "../"
import { withRetry } from "../retry"
import { ApiStream } from "../transform/stream"
@@ -123,7 +124,7 @@ declare module "vscode" {
* const systemPrompt = "You are a helpful assistant";
* const messages = [{ role: "user", content: "Hello!" }];
* for await (const chunk of handler.createMessage(systemPrompt, messages)) {
* console.log(chunk);
* Logger.log(chunk);
* }
* ```
*/
@@ -147,7 +148,7 @@ export class VsCodeLmHandler implements ApiHandler, SingleCompletionHandler {
this.client = null
this.ensureCleanState()
} catch (error) {
console.error("Error during configuration change cleanup:", error)
Logger.error("Error during configuration change cleanup:", error)
}
}
})
@@ -279,7 +280,7 @@ export class VsCodeLmHandler implements ApiHandler, SingleCompletionHandler {
private async getClient(): Promise<vscode.LanguageModelChat> {
if (!this.client) {
console.debug("Cline <Language Model API>: Getting client with options:", {
Logger.debug("Cline <Language Model API>: Getting client with options:", {
vsCodeLmModelSelector: this.options.vsCodeLmModelSelector,
hasOptions: !!this.options,
selectorKeys: this.options.vsCodeLmModelSelector ? Object.keys(this.options.vsCodeLmModelSelector) : [],
@@ -288,11 +289,11 @@ export class VsCodeLmHandler implements ApiHandler, SingleCompletionHandler {
try {
// Use default empty selector if none provided to get all available models
const selector = this.options?.vsCodeLmModelSelector || {}
console.debug("Cline <Language Model API>: Creating client with selector:", selector)
Logger.debug("Cline <Language Model API>: Creating client with selector:", selector)
this.client = await this.createClient(selector)
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error"
console.error("Cline <Language Model API>: Client creation failed:", message)
Logger.error("Cline <Language Model API>: Client creation failed:", message)
throw new Error(`Cline <Language Model API>: Failed to create client: ${message}`)
}
}
@@ -413,7 +414,7 @@ export class VsCodeLmHandler implements ApiHandler, SingleCompletionHandler {
if (chunk instanceof vscode.LanguageModelTextPart) {
// Validate text part value
if (typeof chunk.value !== "string") {
console.warn("Cline <Language Model API>: Invalid text part value received:", chunk.value)
Logger.warn("Cline <Language Model API>: Invalid text part value received:", chunk.value)
continue
}
@@ -426,18 +427,18 @@ export class VsCodeLmHandler implements ApiHandler, SingleCompletionHandler {
try {
// Validate tool call parameters
if (!chunk.name || typeof chunk.name !== "string") {
console.warn("Cline <Language Model API>: Invalid tool name received:", chunk.name)
Logger.warn("Cline <Language Model API>: Invalid tool name received:", chunk.name)
continue
}
if (!chunk.callId || typeof chunk.callId !== "string") {
console.warn("Cline <Language Model API>: Invalid tool callId received:", chunk.callId)
Logger.warn("Cline <Language Model API>: Invalid tool callId received:", chunk.callId)
continue
}
// Ensure input is a valid object
if (!chunk.input || typeof chunk.input !== "object") {
console.warn("Cline <Language Model API>: Invalid tool input received:", chunk.input)
Logger.warn("Cline <Language Model API>: Invalid tool input received:", chunk.input)
continue
}
@@ -453,7 +454,7 @@ export class VsCodeLmHandler implements ApiHandler, SingleCompletionHandler {
accumulatedText += toolCallText
// Log tool call for debugging
console.debug("Cline <Language Model API>: Processing tool call:", {
Logger.debug("Cline <Language Model API>: Processing tool call:", {
name: chunk.name,
callId: chunk.callId,
inputSize: JSON.stringify(chunk.input).length,
@@ -464,10 +465,10 @@ export class VsCodeLmHandler implements ApiHandler, SingleCompletionHandler {
text: toolCallText,
}
} catch (error) {
console.error("Cline <Language Model API>: Failed to process tool call:", error)
Logger.error("Cline <Language Model API>: Failed to process tool call:", error)
}
} else {
console.warn("Cline <Language Model API>: Unknown chunk type received:", chunk)
Logger.warn("Cline <Language Model API>: Unknown chunk type received:", chunk)
}
}
@@ -489,7 +490,7 @@ export class VsCodeLmHandler implements ApiHandler, SingleCompletionHandler {
}
if (error instanceof Error) {
console.error("Cline <Language Model API>: Stream error details:", {
Logger.error("Cline <Language Model API>: Stream error details:", {
message: error.message,
stack: error.stack,
name: error.name,
@@ -500,12 +501,12 @@ export class VsCodeLmHandler implements ApiHandler, SingleCompletionHandler {
} else if (typeof error === "object" && error !== null) {
// Handle error-like objects
const errorDetails = JSON.stringify(error, null, 2)
console.error("Cline <Language Model API>: Stream error object:", errorDetails)
Logger.error("Cline <Language Model API>: Stream error object:", errorDetails)
throw new Error(`Cline <Language Model API>: Response stream error: ${errorDetails}`)
} else {
// Fallback for unknown error types
const errorMessage = String(error)
console.error("Cline <Language Model API>: Unknown stream error:", errorMessage)
Logger.error("Cline <Language Model API>: Unknown stream error:", errorMessage)
throw new Error(`Cline <Language Model API>: Response stream error: ${errorMessage}`)
}
}
@@ -526,7 +527,7 @@ export class VsCodeLmHandler implements ApiHandler, SingleCompletionHandler {
// Log any missing properties for debugging
for (const [prop, value] of Object.entries(requiredProps)) {
if (!value && value !== 0) {
console.warn(`Cline <Language Model API>: Client missing ${prop} property`)
Logger.warn(`Cline <Language Model API>: Client missing ${prop} property`)
}
}
@@ -557,7 +558,7 @@ export class VsCodeLmHandler implements ApiHandler, SingleCompletionHandler {
? stringifyVsCodeLmModelSelector(this.options.vsCodeLmModelSelector)
: "vscode-lm"
console.debug("Cline <Language Model API>: No client available, using fallback model info")
Logger.debug("Cline <Language Model API>: No client available, using fallback model info")
return {
id: fallbackId,
+3 -1
View File
@@ -1,3 +1,5 @@
import { Logger } from "@/shared/services/Logger"
interface RetryOptions {
maxRetries?: number
baseDelay?: number
@@ -72,7 +74,7 @@ export function withRetry(options: RetryOptions = {}) {
try {
await handlerInstance.options.onRetryAttempt(attempt + 1, maxRetries, delay, error)
} catch (e) {
console.error("Error in onRetryAttempt callback:", e)
Logger.error("Error in onRetryAttempt callback:", e)
}
}
+5 -4
View File
@@ -1,5 +1,6 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { Logger } from "@/shared/services/Logger"
const o1SystemPrompt = (systemPrompt: string) => `
# System Prompt
@@ -317,7 +318,7 @@ function parseToolCall(toolName: string, content: string): ToolCall | null {
// Validate required parameters
if (!validateToolInput(toolName, tool_input)) {
console.error(`Invalid tool call for ${toolName}:`, content)
Logger.error(`Invalid tool call for ${toolName}:`, content)
return null
}
@@ -358,8 +359,8 @@ function validateToolInput(toolName: string, tool_input: Record<string, string>)
// </write_to_file>`;
//
// const { normalText, toolCalls } = parseAIResponse(aiResponse);
// console.log(normalText);
// console.log(toolCalls);
// Logger.log(normalText);
// Logger.log(toolCalls);
// Convert OpenAI response to Anthropic format
export function convertO1ResponseToAnthropicMessage(
@@ -432,4 +433,4 @@ export function convertO1ResponseToAnthropicMessage(
// usage: { prompt_tokens: 50, completion_tokens: 100 }
// };
// const anthropicMessage = convertO1ResponseToAnthropicMessage(openAICompletion);
// console.log(anthropicMessage);
// Logger.log(anthropicMessage);
+3 -2
View File
@@ -9,6 +9,7 @@ import {
ClineTextContentBlock,
ClineUserToolResultContentBlock,
} from "@/shared/messages/content"
import { Logger } from "@/shared/services/Logger"
// OpenAI API has a maximum tool call ID length of 40 characters
const MAX_TOOL_CALL_ID_LENGTH = 40
@@ -424,7 +425,7 @@ export function convertToAnthropicMessage(completion: OpenAI.Chat.Completions.Ch
try {
parsedInput = JSON.parse(toolCall.function?.arguments || "{}")
} catch (error) {
console.error("Failed to parse tool arguments:", error)
Logger.error("Failed to parse tool arguments:", error)
}
return {
type: "tool_use",
@@ -439,7 +440,7 @@ export function convertToAnthropicMessage(completion: OpenAI.Chat.Completions.Ch
return anthropicMessage
}
} catch (error) {
console.error("Error converting OpenAI message to Anthropic format:", error)
Logger.error("Error converting OpenAI message to Anthropic format:", error)
}
return anthropicMessage
@@ -3,7 +3,7 @@ import type {
ChatCompletionToolChoiceOption,
ChatCompletionTool as OpenAITool,
} from "openai/resources/chat/completions"
import { Logger } from "@/services/logging/Logger"
import { Logger } from "@/shared/services/Logger"
import type { ApiStreamToolCallsChunk } from "./stream"
/**
+2 -1
View File
@@ -1,5 +1,6 @@
import { Anthropic } from "@anthropic-ai/sdk"
import * as vscode from "vscode"
import { Logger } from "@/shared/services/Logger"
/**
* Safely converts a value into a plain object.
@@ -23,7 +24,7 @@ export function asObjectSafe(value: any): object {
return {}
} catch (error) {
console.warn("Cline <Language Model API>: Failed to parse object:", error)
Logger.warn("Cline <Language Model API>: Failed to parse object:", error)
return {}
}
}
+4 -4
View File
@@ -1,6 +1,6 @@
import OpenAI from "openai"
import { Logger } from "@/services/logging/Logger"
import { ModelInfo } from "@/shared/api"
import { Logger } from "@/shared/services/Logger"
// Type that represents the OpenAI ResponseStream with its private properties
// The #private property issue can be resolved by using the AsyncIterable interface
@@ -143,11 +143,11 @@ export async function* handleResponsesApiStreamResponse(
chunk.response?.status === "incomplete" &&
chunk.response?.incomplete_details?.reason === "max_output_tokens"
) {
console.log("Ran out of tokens")
Logger.log("Ran out of tokens")
if (chunk.response?.output_text?.length > 0) {
console.log("Partial output:", chunk.response.output_text)
Logger.log("Partial output:", chunk.response.output_text)
} else {
console.log("Ran out of tokens during reasoning")
Logger.log("Ran out of tokens during reasoning")
}
}
+2 -1
View File
@@ -6,6 +6,7 @@ import { ShowMessageType } from "@shared/proto/host/window"
import { fileExistsAtPath } from "@utils/fs"
import * as path from "path"
import { ulid } from "ulid"
import { Logger } from "@/shared/services/Logger"
interface TaskReconstructionResult {
totalTasks: number
@@ -143,7 +144,7 @@ async function backupExistingTaskHistory(): Promise<void> {
}
} catch (error) {
// Non-fatal error, just log it
console.warn("Failed to backup existing task history:", error)
Logger.warn("Failed to backup existing task history:", error)
}
}
@@ -7,6 +7,7 @@ import { fileExistsAtPath } from "@utils/fs"
import cloneDeep from "clone-deep"
import fs from "fs/promises"
import * as path from "path"
import { Logger } from "@/shared/services/Logger"
import { getContextWindowInfo } from "./context-window-utils"
enum EditType {
@@ -119,7 +120,7 @@ export class ContextManager {
)
}
} catch (error) {
console.error("Failed to load context history:", error)
Logger.error("Failed to load context history:", error)
}
return new Map()
}
@@ -139,7 +140,7 @@ export class ContextManager {
"utf8",
)
} catch (error) {
console.error("Failed to save context history:", error)
Logger.error("Failed to save context history:", error)
}
}
@@ -207,7 +208,7 @@ export class ContextManager {
maxContextWindow: contextWindow,
}
} catch (error) {
console.error("Error parsing API request info for context telemetry:", error)
Logger.error("Error parsing API request info for context telemetry:", error)
}
}
}
@@ -357,7 +358,7 @@ export class ContextManager {
// Validate and fix tool_use/tool_result pairing
this.ensureToolResultsFollowToolUse(updatedMessages)
// OLD NOTE: if you try to console log these, don't forget that logging a reference to an array may not provide the same result as logging a slice() snapshot of that array at that exact moment. The following DOES in fact include the latest assistant message.
// OLD NOTE: if you try to Logger log these, don't forget that logging a reference to an array may not provide the same result as logging a slice() snapshot of that array at that exact moment. The following DOES in fact include the latest assistant message.
return updatedMessages
}
@@ -743,7 +744,7 @@ export class ContextManager {
return true
}
} catch (error) {
console.error("applyFirstUserMessageReplacement:", error)
Logger.error("applyFirstUserMessageReplacement:", error)
}
}
return false
@@ -4,6 +4,7 @@ import chokidar, { FSWatcher } from "chokidar"
import * as path from "path"
import * as vscode from "vscode"
import { Controller } from "@/core/controller"
import { Logger } from "@/shared/services/Logger"
import { getCwd } from "@/utils/path"
import type { FileMetadataEntry } from "./ContextTrackerTypes"
@@ -46,7 +47,7 @@ export class FileContextTracker {
const cwd = await getCwd()
if (!cwd) {
console.info("No workspace folder available - cannot determine current working directory")
Logger.info("No workspace folder available - cannot determine current working directory")
return
}
@@ -85,7 +86,7 @@ export class FileContextTracker {
try {
const cwd = await getCwd()
if (!cwd) {
console.info("No workspace folder available - cannot determine current working directory")
Logger.info("No workspace folder available - cannot determine current working directory")
return
}
@@ -95,7 +96,7 @@ export class FileContextTracker {
// Set up file watcher for this file
await this.setupFileWatcher(filePath)
} catch (error) {
console.error("Failed to track file operation:", error)
Logger.error("Failed to track file operation:", error)
}
}
@@ -157,7 +158,7 @@ export class FileContextTracker {
metadata.files_in_context.push(newEntry)
await saveTaskMetadata(taskId, metadata)
} catch (error) {
console.error("Failed to add file to metadata:", error)
Logger.error("Failed to add file to metadata:", error)
}
}
@@ -208,7 +209,7 @@ export class FileContextTracker {
}
}
} catch (error) {
console.error("Error checking file context metadata:", error)
Logger.error("Error checking file context metadata:", error)
}
// Also check deleted task messages for file operations
@@ -222,7 +223,7 @@ export class FileContextTracker {
}
}
} catch (error) {
console.error("Error checking task messages:", error)
Logger.error("Error checking task messages:", error)
}
}
}
@@ -239,7 +240,7 @@ export class FileContextTracker {
// are legitimate workspace state keys but don't fit the strict LocalStateKey type system
this.controller.stateManager.setWorkspaceState(key as any, files)
} catch (error) {
console.error("Error storing pending file context warning:", error)
Logger.error("Error storing pending file context warning:", error)
}
}
@@ -252,7 +253,7 @@ export class FileContextTracker {
const files = this.controller.stateManager.getWorkspaceStateKey(key as any) as string[]
return files
} catch (error) {
console.error("Error retrieving pending file context warning:", error)
Logger.error("Error retrieving pending file context warning:", error)
}
return undefined
}
@@ -268,7 +269,7 @@ export class FileContextTracker {
return files
}
} catch (error) {
console.error("Error retrieving pending file context warning:", error)
Logger.error("Error retrieving pending file context warning:", error)
}
return undefined
}
@@ -301,11 +302,11 @@ export class FileContextTracker {
}
const duration = Date.now() - startTime
console.log(
Logger.log(
`FileContextTracker: Processed ${existingTaskIds.size} tasks, found ${pendingWarningKeys.length} pending warnings, ${orphanedPendingContextTasks.length} orphaned, deleted ${orphanedPendingContextTasks.length}, took ${duration}ms`,
)
} catch (error) {
console.error("[FileContextTracker] Error cleaning up orphaned file context warnings:", error)
Logger.error("[FileContextTracker] Error cleaning up orphaned file context warnings:", error)
}
}
}

Some files were not shown because too many files have changed in this diff Show More