Compare commits

...

71 Commits

Author SHA1 Message Date
abeatrix 1fea5aaa63 json mode support and model ID fix 2026-01-23 04:28:12 -08:00
Saoud Rizwan 8de7294de8 fix(cli): adjust thinking indicator colors and use proper ellipsis
- Update blueBright to light purple-blue (#8CAAFF) to match terminal
- Update yellow to pure bright yellow (#FFFF00)
- Use proper ellipsis character (…) instead of three dots (...)
2026-01-23 03:16:52 -08:00
Saoud Rizwan 2e4ded7f14 feat(cli-ts): enhance thinking indicator with shimmer animation and elapsed time
- Add ThinkingIndicator component with shimmer effect that cycles through text
- Display different colors for act mode (blue) vs plan mode (yellow)
- Show elapsed time after 1 second with 'esc to interrupt' hint
- Extend useSpinnerState hook to return start time alongside active status
- Replace basic LoadingSpinner with enhanced ThinkingIndicator in ChatView and TaskView
- Maintain backward compatibility with useIsSpinnerActive hook
2026-01-23 03:10:45 -08:00
Saoud Rizwan ac94eb4cf4 fix(cli): query cursor position before Ink mounts for accurate robot tracking
Query terminal cursor position before render() to determine where the
robot will be displayed on screen. This fixes the issue where the robot's
gaze tracking threshold was incorrect when CLI was started partway down
the terminal.

- Add cursor-position utility to query position before Ink takes over stdin
- Pass robotTopRow through App -> ChatView -> AsciiMotionCli
- Add StaticRobotFrame component for header after messages start
- Remove cursor position query from inside component (was leaking to input)
2026-01-23 02:55:16 -08:00
Saoud Rizwan 73635cf68f Fix frames 2026-01-23 02:53:56 -08:00
Saoud Rizwan 9eb0da6c2b feat(cli): add animated robot that follows cursor
Replace static Cline logo with animated 3D robot head that tracks
mouse cursor position. Uses 192 pre-rendered frames with smooth
interpolation for real-time cursor following.

- Add AsciiMotionCli component from ink-playground
- Show animated robot in dynamic region during welcome state
- Filter mouse escape sequences from text input
- Robot looks straight ahead when cursor is above, follows when below
2026-01-23 02:53:56 -08:00
Saoud Rizwan 59c10df046 feat(cli): improve UI styling and fix flicker for long outputs
- User messages now have gray background bubble with > prefix
- Completion result text is green
- Progress bar uses gray shades instead of white
- Act mode uses blueBright color for border and text
- Thinking/Planning spinner with mode-aware text
- Skip dynamic rendering for completion_result and plan_mode_respond
  (these tend to be very long and cause flicker when exceeding terminal height)
- Progress bar shows at least 1 bar when any tokens used
- Always show default hint text (@ for files, / for commands)
2026-01-23 02:53:56 -08:00
Saoud Rizwan db4d9a267a fix(cli): use refs instead of state for Static tracking to prevent flicker
When messages transition from streaming (dynamic) to complete (Static),
useState was causing extra render cycles. Using useRef instead updates
synchronously without triggering re-renders.
2026-01-23 02:53:56 -08:00
Saoud Rizwan 734dc6e57f chore(cli): upgrade to @jrichman/ink fork with React 19 2026-01-23 02:53:56 -08:00
Saoud Rizwan 27befb16f5 chore: gitignore tsbuildinfo files 2026-01-23 02:53:56 -08:00
Saoud Rizwan 1c32b08644 fix(cli): improve chat message rendering
- Fix extra spacing between bullet and text by combining Text elements
- Change assistant message color from magenta to default foreground
- Show ⎿ only on first line of tool results, use spaces for alignment
- Add inline markdown rendering for **bold**, *italic*, and `code`

The inline markdown parser handles common formatting in model output
without the complexity of full block-level markdown parsing.
2026-01-23 02:53:56 -08:00
Saoud Rizwan 5d81bf0fe2 chore(cli): upgrade to forked Ink package for better rendering
- Switch ink to @jrichman/ink@6.4.7 (same fork Gemini CLI uses)
- Upgrade React to 19.2.3
- Add aws4fetch dependency
- Add aws4fetch to esbuild external list
2026-01-23 02:53:55 -08:00
Saoud Rizwan f44771acd5 feat(cli): add flicker-free ChatView with Static/Dynamic split
Replace the old welcome view with a unified ChatView component that
uses Ink's Static component to prevent terminal flickering.

The Problem:
When Ink output height >= terminal rows, it switches from efficient
line-erasing to clearTerminal() + full redraw on every re-render,
causing severe flickering.

The Solution:
- Split content into Static region (rendered once) and Dynamic region
- Completed messages move to Static, only current streaming + input
  stays dynamic
- This keeps dynamic height small, avoiding clearTerminal path

Also includes:
- ChatMessage component with Claude Code-style tool call rendering
- StatusBar component for model/tokens/cost display
- Manual centering via centerText() since Box centering breaks in Static
- Console suppression for console.info to prevent Ink corruption
- React.memo on AccountInfoView

We explored alternate screen buffer (like Gemini CLI) but disabled it
because scroll wheel doesn't work without custom mouse event handling.

See ChatView.tsx header comment for full documentation.
2026-01-23 02:53:55 -08:00
abeatrix 786728e0d4 fix removed hooks settings 2026-01-22 15:02:58 -08:00
abeatrix 9714cf8861 Merge branch 'main' into bee/cli-ts-ink-poc 2026-01-22 14:00:59 -08:00
abeatrix c5db44964e Supports Piped 2026-01-22 01:13:37 -08:00
abeatrix ed38739d65 store metadata with task history 2026-01-22 00:49:47 -08:00
abeatrix 50bc90835c log 2026-01-21 18:53:49 -08:00
abeatrix 4d41766b9f fix api key storing process 2026-01-21 17:38:36 -08:00
abeatrix e958e29439 Merge branch 'bee/cli-ts-ink-poc' of https://github.com/cline/cline into bee/cli-ts-ink-poc 2026-01-21 16:33:57 -08:00
abeatrix b337bb706e set up secretStorage 2026-01-21 16:33:51 -08:00
abeatrix 314e06731a fix type 2026-01-21 15:04:08 -08:00
abeatrix 3a6f8b98dd Merge branch 'main' into bee/cli-ts-ink-poc 2026-01-21 14:12:54 -08:00
abeatrix 65f9322bd9 exit askprompt on yolo mode completion 2026-01-21 14:05:37 -08:00
abeatrix 33983e80f8 merge saoudrizwan/cli-ts-ink-poc-with-tui with context 2026-01-21 13:44:50 -08:00
Saoud Rizwan 8648154a74 fix(cli): use index key for static logo array to avoid duplicate key warnings 2026-01-21 11:23:20 -08:00
Saoud Rizwan 5d91ac037e feat(cli): redesign welcome view with ASCII logo and mode toggle
- Add centered ASCII Cline logo
- Add "What can I do for you?" prompt
- Add bordered input field (blue for Act mode, yellow for Plan mode)
- Display model ID and Plan/Act toggle below input
- Tab to switch between Plan and Act modes
- Two-step Esc to exit (first press highlights, second exits)
- Full-width responsive layout
- Properly exit process when user cancels
2026-01-21 10:00:20 -08:00
Saoud Rizwan 6179dfe0a4 fix(cli): disable browser tool in CLI mode 2026-01-21 09:50:32 -08:00
Saoud Rizwan cd9e28e6d0 feat(cli): add graceful Ctrl+C shutdown handling
- Handle SIGINT/SIGTERM signals to cleanly exit
- Abort running task, persist state, dispose controller before exit
- Force exit on second signal if already shutting down
2026-01-21 09:50:32 -08:00
Saoud Rizwan 8d56a697eb feat(cli): add CLI-specific system prompt adjustments
- Add isCliEnvironment boolean to SystemPromptContext, computed from
  platform check in task/index.ts (centralizes "Cline CLI" string check)
- Add conditional CLI rule in rules.ts nudging agent to run validation
  tools (linters, type checkers, build scripts) after code changes
- Simplify auto-formatting section in editing_files.ts for CLI mode
  (files saved exactly as written, no auto-formatting expectations)
2026-01-21 09:50:32 -08:00
Saoud Rizwan fd506b14b5 fix(cli): disable checkpoints in CLI mode
The shadow git checkpoint system is problematic for CLI usage because it
creates shadow gits for every directory you run cline in, leading to storage
bloat and potentially tracking files in directories you don't want tracked.

Disables checkpoints by setting enableCheckpointsSetting to false during
CLI initialization.
2026-01-21 09:50:32 -08:00
abeatrix 55d268773d on exit 2026-01-20 23:32:20 -08:00
abeatrix bf978c2c42 Merge branch 'bee/cli-ts-ink-poc' of https://github.com/cline/cline into bee/cli-ts-ink-poc 2026-01-20 23:01:07 -08:00
abeatrix 7e06dbbd33 Fix viewpoints 2026-01-20 23:01:06 -08:00
abeatrix 8d4822d781 add version command 2026-01-20 18:07:30 -08:00
abeatrix dbcaae65d1 refactor(auth): extract provider mapping and add welcome navigation
- Extract provider-to-API-key field mapping into shared utility (ProviderToApiKeyMap)
- Add navigation support from AuthView to WelcomeView via onNavigateToWelcome callback
- Implement internal welcome submission handler with image processing support
- Improve code organization by centralizing provider configuration mapping

This refactoring enables better navigation flow between auth and welcome screens
and makes the provider mapping reusable across components.
2026-01-20 18:07:02 -08:00
abeatrix 66db36ff5b Includes Input field with options fields 2026-01-20 18:05:14 -08:00
abeatrix a53d17a857 update script 2026-01-20 16:01:58 -08:00
abeatrix de56305315 file structure 2026-01-20 15:24:56 -08:00
abeatrix 5cd22e3f86 Clean up and add config view for rules 2026-01-20 14:27:02 -08:00
abeatrix bd9150837d Update DiffView background 2026-01-20 13:55:29 -08:00
abeatrix fbb494b88a Add DiffView & History UX 2026-01-20 13:43:11 -08:00
abeatrix 16b4c48457 Fix Box Order 2026-01-20 13:40:14 -08:00
abeatrix 058c86872b checkpoint restore 2026-01-20 13:02:01 -08:00
abeatrix d3e8f562bc At Mention Files 2026-01-20 12:51:18 -08:00
abeatrix 1594d44190 Show Costs 2026-01-20 12:24:51 -08:00
abeatrix c8419ffa99 Add Unit Tests 2026-01-20 12:14:02 -08:00
abeatrix cf6c80504f Fix spacing 2026-01-20 11:48:19 -08:00
abeatrix 230f615927 fix thinking 2026-01-20 11:37:32 -08:00
abeatrix 08e00dc3bf hide config options 2026-01-20 11:22:36 -08:00
abeatrix c01bf73d3f Interactive ConfigView 2026-01-20 11:17:48 -08:00
abeatrix df28359114 add Focus Chain 2026-01-20 11:17:33 -08:00
abeatrix d3631f0485 add account info 2026-01-20 09:27:18 -08:00
abeatrix eb18c556f4 add image support 2026-01-20 09:27:03 -08:00
abeatrix b18284ea78 clean up 2026-01-20 00:09:22 -08:00
abeatrix e4e99ec711 clean up shim 2026-01-20 00:00:24 -08:00
abeatrix b7b4d0bfe8 add welcome view 2026-01-19 21:53:40 -08:00
abeatrix c12b00e7a9 add thinking options to task command & set model ID 2026-01-19 21:40:52 -08:00
abeatrix 73bb8c2f64 Fix task display in HistoryView 2026-01-19 21:03:39 -08:00
abeatrix 0e1d513462 update prompt handling for plan mode and completion
Add new prompt types "plan_mode_text" and "completion" to improve user experience when interacting with the assistant. Separate handling for plan_mode_respond from followup to enable quick mode switching via empty Enter press. Add toggleToActMode callback to allow seamless transition from Plan to Act mode without text input.

Changes:
- Split plan_mode_respond and followup logic for distinct UX flows
- Add plan_mode_text prompt type with toggle-to-Act-mode on empty Enter
- Add completion prompt type for follow-up questions or exit on Enter
- Implement toggleToActMode callback for mode switching
- Update keyboard handlers and UI rendering for new prompt types
- Add helpful hints for users (e.g., "just Enter to switch to Act mode")
2026-01-19 21:01:36 -08:00
abeatrix d6a1b338bc clean up 2026-01-19 19:18:13 -08:00
abeatrix 0091fb6ffb clean up messages 2026-01-19 16:38:47 -08:00
abeatrix de3e3fe34a clean up logs 2026-01-19 16:25:43 -08:00
abeatrix e136d75621 refactor: switch CLI to ES modules with Ink UI
Convert CLI build output from CommonJS (cli.cjs) to ES modules (cli.mjs) and refactor the UI layer to use React with Ink framework instead of the previous subscription-based state update pattern.

Changes include:
- Updated esbuild configuration to target ESM format
- Added stubOptionalModulesPlugin to handle react-devtools-core
- Updated shebang and module compatibility helpers for ES modules
- Replaced state subscriber pattern with React component-based UI
- Added react and related type dependencies
- Updated external module list to include UI dependencies (ink, ink-spinner, react)
- Enabled top-level-await support for ES modules
2026-01-19 16:18:24 -08:00
abeatrix 91127ffdb9 Update logger 2026-01-16 22:34:20 -08:00
abeatrix 11f76f0218 add cline sign in 2026-01-16 22:08:03 -08:00
abeatrix 74f1b9c75d **feat(cli): allow switching mode and specifying model for tasks**
Added `--switch` (`-s`) and `--model` (`-m`) options to the CLI.
Updated `runTask` signature and logic to set global state for mode (plan/act) and the corresponding API model ID.
This enables users to run tasks in different modes or with a specific model directly from the command line.
2026-01-16 18:46:55 -08:00
abeatrix 942c78119e working prototype
npm install:all
cd cli-ts
npm run link
clinedev auth
2026-01-16 18:36:47 -08:00
abeatrix 3130c3c96e replace console.log with Logger 2026-01-14 13:08:50 -08:00
abeatrix f0225bc20d fileeditprovider 2026-01-14 13:08:28 -08:00
abeatrix 1bf158245c prototype: Cline CLI with Typescript
TODO:
- remove usage of console.log across codebase and replace them with Logger
2026-01-13 16:55:36 -08:00
111 changed files with 348489 additions and 369 deletions
+1
View File
@@ -46,3 +46,4 @@ test-results
/pkg
.secrets
*.tsbuildinfo
+319
View File
@@ -0,0 +1,319 @@
# Cline CLI (TypeScript)
A TypeScript CLI implementation of Cline that reuses the core TypeScript codebase. This allows you to run Cline tasks directly from the terminal while sharing the same underlying functionality as the VS Code extension.
## Features
- **Reuses Core Codebase**: Shares the same Controller, Task, and API handling as the VS Code extension
- **Terminal Output**: Displays Cline messages directly in your terminal with colored output
- **Task History**: Access your task history from the command line
- **Configurable**: Use custom configuration directories and working directories
- **Image Support**: Attach images to your prompts using file paths or inline references
## Prerequisites
- Node.js 20.x or later
- npm or yarn
- The parent Cline project dependencies installed
## Installation
From the repository root:
```bash
# Install all dependencies first
npm run install:all
# Ensure protos are generated
npm run protos
# Build the CLI
npm run build:cli
```
Or install the CLI globally:
```bash
# Install all dependencies first
npm run install:all
# Ensure protos are generated
npm run protos
# Build and link the CLI globally
cd cli-ts
npm install
npm run link
```
## Usage
### Interactive Mode (Default)
When you run `cline` without any command, it launches an interactive welcome prompt:
```bash
# Launch interactive mode
cline
# Or run a task directly
cline "Create a hello world function in Python"
# With options
cline -v --thinking "Analyze this codebase"
```
### Commands
#### `task` (alias: `t`)
Run a new task with a prompt.
```bash
cline task "Create a hello world function in Python"
cline t "Create a hello world function"
```
**Options:**
| Option | Description |
|--------|-------------|
| `-a, --act` | Run in act mode |
| `-p, --plan` | Run in plan mode |
| `-y, --yolo` | Enable yolo mode (auto-approve actions) |
| `-m, --model <model>` | Model to use for the task |
| `-i, --images <paths...>` | Image file paths to include with the task |
| `-v, --verbose` | Show verbose output including reasoning |
| `-c, --cwd <path>` | Working directory for the task |
| `--config <path>` | Path to Cline configuration directory |
| `-t, --thinking` | Enable extended thinking (1024 token budget) |
**Examples:**
```bash
# Run in plan mode with verbose output
cline task -p -v "Design a REST API"
# Use a specific model with yolo mode
cline task -m claude-sonnet-4-5-20250929 -y "Refactor this function"
# Include images with your prompt
cline task -i screenshot.png diagram.jpg "Fix the UI based on these images"
# Or use inline image references in the prompt
cline task "Fix the layout shown in @./screenshot.png"
# Enable extended thinking for complex tasks
cline task -t "Architect a microservices system"
# Specify working directory
cline task -c /path/to/project "Add unit tests"
```
#### `history` (alias: `h`)
List task history with pagination support.
```bash
cline history
cline h
```
**Options:**
| Option | Description |
|--------|-------------|
| `-n, --limit <number>` | Number of tasks to show (default: 10) |
| `-p, --page <number>` | Page number, 1-based (default: 1) |
| `--config <path>` | Path to Cline configuration directory |
**Examples:**
```bash
# Show last 10 tasks (default)
cline history
# Show 20 tasks
cline history -n 20
# Show page 2 with 5 tasks per page
cline history -n 5 -p 2
```
#### `config`
Show current configuration including global and workspace state.
```bash
cline config
```
**Options:**
| Option | Description |
|--------|-------------|
| `--config <path>` | Path to Cline configuration directory |
#### `auth`
Authenticate a provider and configure what model is used.
```bash
cline auth
```
**Options:**
| Option | Description |
|--------|-------------|
| `-p, --provider <id>` | Provider ID for quick setup (e.g., openai-native, anthropic) |
| `-k, --apikey <key>` | API key for the provider |
| `-m, --modelid <id>` | Model ID to configure (e.g., gpt-4o, claude-sonnet-4-5-20250929) |
| `-b, --baseurl <url>` | Base URL (optional, only for openai provider) |
| `-v, --verbose` | Show verbose output |
| `-c, --cwd <path>` | Working directory for the task |
| `--config <path>` | Path to Cline configuration directory |
**Examples:**
```bash
# Interactive authentication
cline auth
# Quick setup with provider and API key
cline auth -p anthropic -k sk-ant-xxxxx
# Full quick setup with model
cline auth -p openai-native -k sk-xxxxx -m gpt-4o
# OpenAI-compatible provider with custom base URL
cline auth -p openai -k your-api-key -b https://api.example.com/v1
```
### Global Options
These options are available for the default command (running a task directly):
| Option | Description |
|--------|-------------|
| `-v, --verbose` | Show verbose output |
| `-c, --cwd <path>` | Working directory |
| `--config <path>` | Configuration directory |
| `--thinking` | Enable extended thinking (1024 token budget) |
## Development
For active development, at the root of this repo:
1. **Initial setup:**
```bash
npm run install:all
npm run protos
```
2. **Make changes to cli-ts:**
```bash
npm run cli:dev
# or
cd cli-ts && npm run dev
```
3. **Test your changes:**
```bash
cline [your-command] # In a new Terminal
```
4. **When done:**
```bash
npm run cli:unlink
```
### Build
```bash
cd cli-ts
# Development build with source maps
npm run build
# Production build (minified)
npm run build:production
```
### Watch Mode
```bash
npm run watch
```
### Type Checking
```bash
npm run typecheck
```
## Architecture
The CLI reuses the core Cline TypeScript codebase:
- **Controller** (`@core/controller`): Manages task lifecycle and state
- **Task** (`@core/task`): Executes Cline tasks using the AI API
- **StateManager** (`@core/storage`): Handles persistent state storage
CLI-specific implementations:
- `cli-host-bridge.ts`: CLI implementations of host bridge services
- `cli-webview-provider.ts`: WebviewProvider that outputs to terminal
- `cli-comment-review.ts`: Comment review controller for terminal
- `vscode-context.ts`: Mock VSCode extension context
- `display.ts`: Terminal output formatting utilities
## Configuration
The CLI stores its data in `~/.cline/data/` by default:
- `globalState.json`: Global settings and state
- `secrets.json`: API keys and secrets
- `workspace/`: Workspace-specific state
- `tasks/`: Task history and conversation data
Override with the `--config` option or `CLINE_DIR` environment variable.
## Comparison with Go CLI
This TypeScript CLI differs from the Go CLI (`cli/` directory):
| Feature | Go CLI | TypeScript CLI |
|---------|--------|----------------|
| Language | Go | TypeScript |
| Core sharing | Uses gRPC to communicate | Direct imports |
| Startup time | Fast | Moderate |
| Dependencies | Standalone binary | Requires Node.js |
| Best for | Production deployment | Development, debugging |
Choose the TypeScript CLI when you need to debug or modify the core Cline logic. Choose the Go CLI for production deployment with faster startup.
## Troubleshooting
### Build Errors
If you encounter build errors, ensure you've:
1. Run `npm install` in the repository root
2. Run `npm run protos` to generate proto files
3. Have all peer dependencies installed
### Missing Dependencies
The CLI imports from the parent project. If you see import errors:
```bash
cd .. # Go to repository root
npm install
npm run protos
```
### Permission Denied
Make the CLI executable:
```bash
chmod +x dist/cli.js
```
+231
View File
@@ -0,0 +1,231 @@
import fs from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
import * as esbuild from "esbuild"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const rootDir = path.resolve(__dirname, "..")
const production = process.argv.includes("--production")
const watch = process.argv.includes("--watch")
/**
* Plugin to resolve path aliases from the parent project
* @type {import('esbuild').Plugin}
*/
const aliasResolverPlugin = {
name: "alias-resolver",
setup(build) {
const aliases = {
"@": path.resolve(rootDir, "src"),
"@core": path.resolve(rootDir, "src/core"),
"@integrations": path.resolve(rootDir, "src/integrations"),
"@services": path.resolve(rootDir, "src/services"),
"@shared": path.resolve(rootDir, "src/shared"),
"@utils": path.resolve(rootDir, "src/utils"),
"@packages": path.resolve(rootDir, "src/packages"),
"@hosts": path.resolve(rootDir, "src/hosts"),
"@generated": path.resolve(rootDir, "src/generated"),
"@api": path.resolve(rootDir, "src/core/api"),
}
// For each alias entry, create a resolver
Object.entries(aliases).forEach(([alias, aliasPath]) => {
const aliasRegex = new RegExp(`^${alias}($|/.*)`)
build.onResolve({ filter: aliasRegex }, (args) => {
const importPath = args.path.replace(alias, aliasPath)
// First, check if the path exists as is
if (fs.existsSync(importPath)) {
const stats = fs.statSync(importPath)
if (stats.isDirectory()) {
// If it's a directory, try to find index files
const extensions = [".ts", ".tsx", ".js", ".jsx"]
for (const ext of extensions) {
const indexFile = path.join(importPath, `index${ext}`)
if (fs.existsSync(indexFile)) {
return { path: indexFile }
}
}
} else {
// It's a file that exists, so return it
return { path: importPath }
}
}
// If the path doesn't exist, try appending extensions
const extensions = [".ts", ".tsx", ".js", ".jsx"]
for (const ext of extensions) {
const pathWithExtension = `${importPath}${ext}`
if (fs.existsSync(pathWithExtension)) {
return { path: pathWithExtension }
}
}
// If nothing worked, return the original path and let esbuild handle the error
return { path: importPath }
})
})
},
}
/**
* Plugin to redirect vscode imports to our shim
* @type {import('esbuild').Plugin}
*/
const vscodeStubPlugin = {
name: "vscode-stub",
setup(build) {
// Redirect 'vscode' imports to our shim
build.onResolve({ filter: /^vscode$/ }, (args) => {
return { path: path.join(__dirname, "src", "vscode-shim.ts") }
})
},
}
const esbuildProblemMatcherPlugin = {
name: "esbuild-problem-matcher",
setup(build) {
build.onStart(() => {
console.log("[cli-ts] Build started...")
})
build.onEnd((result) => {
result.errors.forEach(({ text, location }) => {
console.error(`✘ [ERROR] ${text}`)
if (location) {
console.error(` ${location.file}:${location.line}:${location.column}:`)
}
})
console.log("[cli-ts] Build finished")
})
},
}
// Plugin to stub out optional devtools module
const stubOptionalModulesPlugin = {
name: "stub-optional-modules",
setup(build) {
build.onResolve({ filter: /^react-devtools-core$/ }, () => {
return { path: path.join(__dirname, "src", "stub-devtools.js"), external: false }
})
},
}
const copyWasmFiles = {
name: "copy-wasm-files",
setup(build) {
build.onEnd(() => {
const destDir = path.join(__dirname, "dist")
// Ensure dist directory exists
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true })
}
// tree sitter
const sourceDir = path.join(rootDir, "node_modules", "web-tree-sitter")
// Copy tree-sitter.wasm
const treeSitterWasm = path.join(sourceDir, "tree-sitter.wasm")
if (fs.existsSync(treeSitterWasm)) {
fs.copyFileSync(treeSitterWasm, path.join(destDir, "tree-sitter.wasm"))
}
// Copy language-specific WASM files
const languageWasmDir = path.join(rootDir, "node_modules", "tree-sitter-wasms", "out")
const languages = [
"typescript",
"tsx",
"python",
"rust",
"javascript",
"go",
"cpp",
"c",
"c_sharp",
"ruby",
"java",
"php",
"swift",
"kotlin",
]
if (fs.existsSync(languageWasmDir)) {
languages.forEach((lang) => {
const filename = `tree-sitter-${lang}.wasm`
const sourcePath = path.join(languageWasmDir, filename)
if (fs.existsSync(sourcePath)) {
fs.copyFileSync(sourcePath, path.join(destDir, filename))
}
})
}
})
},
}
const buildEnvVars = {
"process.env.IS_STANDALONE": JSON.stringify("true"),
"process.env.IS_CLI": JSON.stringify("true"),
}
if (production) {
buildEnvVars["process.env.IS_DEV"] = "false"
}
// Set the environment
if (process.env.CLINE_ENVIRONMENT) {
buildEnvVars["process.env.CLINE_ENVIRONMENT"] = JSON.stringify(process.env.CLINE_ENVIRONMENT)
}
const config = {
entryPoints: [path.join(__dirname, "src", "index.ts")],
bundle: true,
minify: production,
sourcemap: !production,
logLevel: "silent",
define: buildEnvVars,
tsconfig: path.join(__dirname, "tsconfig.json"),
plugins: [copyWasmFiles, aliasResolverPlugin, vscodeStubPlugin, stubOptionalModulesPlugin, esbuildProblemMatcherPlugin],
format: "esm",
sourcesContent: false,
platform: "node",
target: "node20",
outfile: path.join(__dirname, "dist", "cli.mjs"),
// These modules need to load files from the module directory at runtime
external: ["@grpc/reflection", "grpc-health-check", "better-sqlite3", "ink", "ink-spinner", "react", "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
@@ -0,0 +1,50 @@
{
"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
@@ -0,0 +1,194 @@
/**
* Account info view component
* Shows current provider, and for Cline provider: credit balance and organization name
*/
import { Box, Text } from "ink"
import React, { useCallback, useEffect, useState } from "react"
import { Controller } from "@/core/controller"
import { StateManager } from "@/core/storage/StateManager"
import { ClineAccountService } from "@/services/account/ClineAccountService"
import { AuthService, ClineAccountOrganization } from "@/services/auth/AuthService"
import { LoadingSpinner } from "./Spinner"
interface AccountInfoViewProps {
controller: Controller
}
/**
* Capitalize provider name for display
*/
function capitalize(str: string): string {
return str
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ")
}
/**
* Format balance as currency (balance is in microcredits, divide by 10000)
*/
function formatBalance(balance: number | null): string {
if (balance === null || balance === undefined) {
return "..."
}
return `$${(balance / 1000000).toFixed(2)}`
}
export const AccountInfoView: React.FC<AccountInfoViewProps> = React.memo(({ controller }) => {
const [provider, setProvider] = useState<string | null>(null)
const [balance, setBalance] = useState<number | null>(null)
const [organization, setOrganization] = useState<ClineAccountOrganization | null>(null)
const [email, setEmail] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const fetchAccountInfo = useCallback(async () => {
try {
setIsLoading(true)
setError(null)
// Get current provider from state
const stateManager = StateManager.get()
const mode = stateManager.getGlobalSettingsKey("mode") as string
const providerKey = mode === "act" ? "actModeApiProvider" : "planModeApiProvider"
const currentProvider = stateManager.getGlobalSettingsKey(providerKey) as string
setProvider(currentProvider || "cline")
// If using Cline provider, fetch additional info
if (currentProvider === "cline") {
const authService = AuthService.getInstance(controller)
// Wait for auth to be restored - poll until we have auth info or timeout
let authInfo = authService.getInfo()
let attempts = 0
const maxAttempts = 20 // 2 seconds max
while (!authInfo?.user?.uid && attempts < maxAttempts) {
await new Promise((resolve) => setTimeout(resolve, 100))
authInfo = authService.getInfo()
attempts++
}
// Get user info
if (authInfo?.user?.email) {
setEmail(authInfo.user.email)
} else {
// User not logged in to Cline
setEmail(null)
setIsLoading(false)
return
}
// Get organization info
const organizations = authService.getUserOrganizations()
if (organizations) {
const activeOrg = organizations.find((org) => org.active)
if (activeOrg) {
setOrganization(activeOrg)
}
}
// Fetch credit balance
try {
const accountService = ClineAccountService.getInstance()
const activeOrgId = authService.getActiveOrganizationId()
if (activeOrgId) {
// Fetch organization balance
const orgBalance = await accountService.fetchOrganizationCreditsRPC(activeOrgId)
if (orgBalance?.balance !== undefined) {
setBalance(orgBalance.balance)
}
} else {
// Fetch personal balance
const balanceData = await accountService.fetchBalanceRPC()
if (balanceData?.balance !== undefined) {
setBalance(balanceData.balance)
}
}
} catch {
// Balance fetch failed, but we can still show other info
// Don't log to console as it pollutes CLI output
}
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load account info")
} finally {
setIsLoading(false)
}
}, [controller])
useEffect(() => {
fetchAccountInfo()
}, [fetchAccountInfo])
if (isLoading) {
return (
<Box>
<LoadingSpinner />
<Text color="gray"> Loading account info...</Text>
</Box>
)
}
if (error) {
return (
<Box>
<Text color="red">Error: {error}</Text>
</Box>
)
}
// If not using Cline provider, just show the provider name
if (provider !== "cline") {
return (
<Box>
<Text color="gray">Provider: </Text>
<Text color="cyan">{capitalize(provider || "Not configured")}</Text>
</Box>
)
}
// Cline provider but not logged in
if (!email) {
return (
<Box>
<Text color="gray">Provider: </Text>
<Text color="cyan">Cline</Text>
<Text color="gray"> </Text>
<Text color="yellow">Not logged in (run 'cline auth' to sign in)</Text>
</Box>
)
}
// Cline provider - show full account info
return (
<Box flexDirection="column">
<Box>
<Text color="gray">Provider: </Text>
<Text color="cyan">Cline</Text>
{email && (
<Box>
<Text color="gray"> </Text>
<Text color="white">{email}</Text>
</Box>
)}
</Box>
<Box>
{organization ? (
<Box>
<Text color="gray">Organization: </Text>
<Text color="magenta">{organization.name}</Text>
</Box>
) : (
<Box>
<Text color="gray">Account: </Text>
<Text color="white">Personal</Text>
</Box>
)}
<Text color="gray"> Credits: </Text>
<Text color="green">{formatBalance(balance)}</Text>
</Box>
</Box>
)
})
+105
View File
@@ -0,0 +1,105 @@
import { Text } from "ink"
import { render } from "ink-testing-library"
import React from "react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { App } from "./App"
// Mock the child components to isolate App routing logic
vi.mock("./TaskView", () => ({
TaskView: ({ taskId, verbose }: any) =>
React.createElement(Text, null, `TaskView: ${taskId || "no-id"} verbose=${String(verbose)}`),
}))
vi.mock("./HistoryView", () => ({
HistoryView: ({ items }: any) => React.createElement(Text, null, `HistoryView: ${items?.length || 0} items`),
}))
vi.mock("./ConfigView", () => ({
ConfigView: ({ dataDir }: any) => React.createElement(Text, null, `ConfigView: ${dataDir}`),
}))
vi.mock("./AuthView", () => ({
AuthView: ({ quickSetup }: any) => React.createElement(Text, null, `AuthView: ${quickSetup?.provider || "no-provider"}`),
}))
vi.mock("./WelcomeView", () => ({
WelcomeView: () => React.createElement(Text, null, "WelcomeView"),
}))
vi.mock("../context/TaskContext", () => ({
TaskContextProvider: ({ children }: any) => children,
}))
describe("App", () => {
const mockController = {
dispose: vi.fn(),
stateManager: { flushPendingState: vi.fn() },
}
beforeEach(() => {
vi.clearAllMocks()
})
describe("view routing", () => {
it("should render TaskView when view is task", () => {
const { lastFrame } = render(<App controller={mockController} taskId="test-task" view="task" />)
expect(lastFrame()).toContain("TaskView")
expect(lastFrame()).toContain("test-task")
})
it("should render HistoryView when view is history", () => {
const historyItems = [
{ id: "1", ts: Date.now(), task: "Task 1" },
{ id: "2", ts: Date.now(), task: "Task 2" },
]
const { lastFrame } = render(<App controller={mockController} historyItems={historyItems} view="history" />)
expect(lastFrame()).toContain("HistoryView")
expect(lastFrame()).toContain("2 items")
})
it("should render ConfigView when view is config", () => {
const { lastFrame } = render(
<App dataDir="/path/to/config" globalState={{ key: "value" }} view="config" workspaceState={{}} />,
)
expect(lastFrame()).toContain("ConfigView")
expect(lastFrame()).toContain("/path/to/config")
})
it("should render AuthView when view is auth", () => {
const { lastFrame } = render(<App authQuickSetup={{ provider: "openai" }} controller={mockController} view="auth" />)
expect(lastFrame()).toContain("AuthView")
expect(lastFrame()).toContain("openai")
})
it("should render WelcomeView when view is welcome", () => {
const { lastFrame } = render(
<App controller={mockController} onWelcomeExit={() => {}} onWelcomeSubmit={() => {}} view="welcome" />,
)
expect(lastFrame()).toContain("WelcomeView")
})
})
describe("default props", () => {
it("should use default verbose=false", () => {
const { lastFrame } = render(<App controller={mockController} view="task" />)
expect(lastFrame()).toContain("verbose=false")
})
it("should use empty array for historyItems by default", () => {
const { lastFrame } = render(<App controller={mockController} view="history" />)
expect(lastFrame()).toContain("0 items")
})
})
describe("props passing", () => {
it("should pass verbose to TaskView", () => {
const { lastFrame } = render(<App controller={mockController} verbose={true} view="task" />)
expect(lastFrame()).toContain("verbose=true")
})
it("should pass taskId to TaskView", () => {
const { lastFrame } = render(<App controller={mockController} taskId="my-task-123" view="task" />)
expect(lastFrame()).toContain("my-task-123")
})
})
})
+278
View File
@@ -0,0 +1,278 @@
/**
* 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
@@ -0,0 +1,378 @@
/**
* 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
@@ -0,0 +1,588 @@
/**
* 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
@@ -0,0 +1,441 @@
/**
* 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
@@ -0,0 +1,752 @@
/**
* 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
@@ -0,0 +1,213 @@
/**
* Checkpoint menu component
* Displays available checkpoints and allows user to select one to restore
*/
import type { ClineMessage } from "@shared/ExtensionMessage"
import { Box, Text, useInput } from "ink"
import React, { useState } from "react"
import { useStdinContext } from "../context/StdinContext"
export type RestoreType = "task" | "workspace" | "taskAndWorkspace"
interface CheckpointOption {
ts: number
hash: string
date: Date
label: string
}
interface CheckpointMenuProps {
messages: ClineMessage[]
onSelect: (messageTs: number, restoreType: RestoreType) => void
onCancel: () => void
}
/**
* Extract checkpoint options from messages
*/
function getCheckpointOptions(messages: ClineMessage[]): CheckpointOption[] {
const options: CheckpointOption[] = []
for (const msg of messages) {
if (msg.lastCheckpointHash) {
options.push({
ts: msg.ts,
hash: msg.lastCheckpointHash,
date: new Date(msg.ts),
label: getCheckpointLabel(msg),
})
}
}
// Sort by timestamp descending (newest first)
return options.sort((a, b) => b.ts - a.ts)
}
/**
* Get a human-readable label for a checkpoint
*/
function getCheckpointLabel(msg: ClineMessage): string {
if (msg.say === "completion_result") {
return "Task completion"
}
if (msg.say === "checkpoint_created") {
return "Checkpoint"
}
if (msg.say === "api_req_started") {
return "API request"
}
return msg.say || msg.ask || "Message"
}
const RESTORE_TYPE_OPTIONS: { type: RestoreType; label: string; description: string }[] = [
{
type: "taskAndWorkspace",
label: "Task + Workspace",
description: "Restore messages and files",
},
{
type: "task",
label: "Task Only",
description: "Delete messages after this point",
},
{
type: "workspace",
label: "Workspace Only",
description: "Restore files only",
},
]
export const CheckpointMenu: React.FC<CheckpointMenuProps> = ({ messages, onSelect, onCancel }) => {
const { isRawModeSupported } = useStdinContext()
const checkpoints = getCheckpointOptions(messages)
const [selectedCheckpoint, setSelectedCheckpoint] = useState(0)
const [selectedRestoreType, setSelectedRestoreType] = useState(0)
const [stage, setStage] = useState<"checkpoint" | "restoreType">("checkpoint")
useInput(
(input, key) => {
if (key.escape) {
if (stage === "restoreType") {
setStage("checkpoint")
} else {
onCancel()
}
return
}
if (stage === "checkpoint") {
if (key.upArrow) {
setSelectedCheckpoint((i) => Math.max(0, i - 1))
} else if (key.downArrow) {
setSelectedCheckpoint((i) => Math.min(checkpoints.length - 1, i + 1))
} else if (key.return && checkpoints.length > 0) {
setStage("restoreType")
}
} else if (stage === "restoreType") {
if (key.upArrow) {
setSelectedRestoreType((i) => Math.max(0, i - 1))
} else if (key.downArrow) {
setSelectedRestoreType((i) => Math.min(RESTORE_TYPE_OPTIONS.length - 1, i + 1))
} else if (key.return) {
const checkpoint = checkpoints[selectedCheckpoint]
const restoreType = RESTORE_TYPE_OPTIONS[selectedRestoreType]
if (checkpoint && restoreType) {
onSelect(checkpoint.ts, restoreType.type)
}
}
}
// Quick number selection for checkpoints
if (stage === "checkpoint") {
const num = parseInt(input, 10)
if (!Number.isNaN(num) && num >= 1 && num <= checkpoints.length) {
setSelectedCheckpoint(num - 1)
setStage("restoreType")
}
}
},
{ isActive: isRawModeSupported },
)
if (checkpoints.length === 0) {
return (
<Box borderColor="yellow" borderStyle="round" flexDirection="column" marginTop={1} paddingLeft={1} paddingRight={1}>
<Text color="yellow">No checkpoints available</Text>
<Text color="gray" dimColor>
Checkpoints are created at task completion points
</Text>
<Text color="gray" dimColor>
Press Escape to close
</Text>
</Box>
)
}
if (stage === "checkpoint") {
return (
<Box borderColor="cyan" borderStyle="round" flexDirection="column" marginTop={1} paddingLeft={1} paddingRight={1}>
<Text bold color="cyan">
Restore Checkpoint
</Text>
<Text color="gray" dimColor>
Select a checkpoint to restore (/ or number, Enter to select, Escape to cancel)
</Text>
<Box flexDirection="column" marginTop={1}>
{checkpoints.map((cp, idx) => {
const isSelected = idx === selectedCheckpoint
const timeStr = cp.date.toLocaleTimeString()
const dateStr = cp.date.toLocaleDateString()
return (
<Box key={cp.ts}>
<Text color={isSelected ? "green" : "gray"}>{isSelected ? "> " : " "}</Text>
<Text color={isSelected ? "white" : "gray"}>{idx + 1}. </Text>
<Text color={isSelected ? "cyan" : undefined}>{cp.label}</Text>
<Text color="gray"> - </Text>
<Text dimColor>
{dateStr} {timeStr}
</Text>
</Box>
)
})}
</Box>
</Box>
)
}
// Stage: restoreType
const selectedCp = checkpoints[selectedCheckpoint]
return (
<Box borderColor="cyan" borderStyle="round" flexDirection="column" marginTop={1} paddingLeft={1} paddingRight={1}>
<Text bold color="cyan">
Restore Type
</Text>
<Text color="gray" dimColor>
Restoring to: {selectedCp?.label} ({selectedCp?.date.toLocaleString()})
</Text>
<Box flexDirection="column" marginTop={1}>
{RESTORE_TYPE_OPTIONS.map((opt, idx) => {
const isSelected = idx === selectedRestoreType
return (
<Box flexDirection="column" key={opt.type} marginBottom={idx < RESTORE_TYPE_OPTIONS.length - 1 ? 1 : 0}>
<Box>
<Text color={isSelected ? "green" : "gray"}>{isSelected ? "> " : " "}</Text>
<Text bold={isSelected} color={isSelected ? "white" : undefined}>
{opt.label}
</Text>
</Box>
<Box marginLeft={4}>
<Text color="gray" dimColor>
{opt.description}
</Text>
</Box>
</Box>
)
})}
</Box>
<Text color="gray" dimColor>
(/ to select, Enter to confirm, Escape to go back)
</Text>
</Box>
)
}
+199
View File
@@ -0,0 +1,199 @@
import { Text } from "ink"
import { render } from "ink-testing-library"
import React from "react"
import { beforeEach, describe, expect, it, vi } from "vitest"
// Create stable mock references using vi.hoisted - must be before any imports that use these modules
const { mockIsSettingsKey } = vi.hoisted(() => ({
mockIsSettingsKey: vi.fn((key: string) => key.startsWith("act") || key.startsWith("plan") || key === "mode"),
}))
vi.mock("./TaskView", () => ({
TaskView: ({ taskId, verbose }: any) =>
React.createElement(Text, null, `TaskView: ${taskId || "no-id"} verbose=${String(verbose)}`),
}))
// Mock the state-keys module - must be hoisted before ConfigView import
vi.mock("@shared/storage/state-keys", () => ({
isSettingsKey: mockIsSettingsKey,
SETTINGS_DEFAULTS: {
mode: "act",
actModeApiProvider: "anthropic",
},
GlobalStateAndSettings: {},
GlobalStateAndSettingsKey: {},
LocalState: {},
LocalStateKey: {},
}))
// Import ConfigView after mocks are set up
import { ConfigView } from "./ConfigView"
describe("ConfigView", () => {
const defaultProps = {
dataDir: "/home/user/.cline",
globalState: {},
workspaceState: {},
}
beforeEach(() => {
vi.clearAllMocks()
})
describe("rendering", () => {
it("should render the config header", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} />)
expect(lastFrame()).toContain("Configuration")
})
it("should display the data directory", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} dataDir="/custom/path" />)
expect(lastFrame()).toContain("/custom/path")
})
it("should display global state entries", () => {
const { lastFrame } = render(
<ConfigView
{...defaultProps}
globalState={{
mode: "act",
actModeApiProvider: "anthropic",
}}
/>,
)
expect(lastFrame()).toContain("mode")
expect(lastFrame()).toContain("act")
})
it("should display workspace state entries", () => {
const { lastFrame } = render(
<ConfigView
{...defaultProps}
workspaceState={{
customSetting: "value",
}}
/>,
)
expect(lastFrame()).toContain("customSetting")
expect(lastFrame()).toContain("value")
})
it("should show section headers", () => {
const { lastFrame } = render(
<ConfigView {...defaultProps} globalState={{ mode: "act" }} workspaceState={{ localKey: "localValue" }} />,
)
expect(lastFrame()).toContain("Global Settings")
})
})
describe("value formatting", () => {
it("should format boolean values", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ actModeSomeBool: true }} />)
expect(lastFrame()).toContain("true")
})
it("should format number values", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ actModeNumber: 42 }} />)
expect(lastFrame()).toContain("42")
})
it("should truncate long string values", () => {
const longString = "x".repeat(100)
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ actModeLongValue: longString }} />)
expect(lastFrame()).toContain("...")
})
it("should format object values as JSON", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ actModeObj: { nested: "value" } }} />)
expect(lastFrame()).toContain("nested")
})
})
describe("filtering", () => {
it("should exclude taskHistory key", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ taskHistory: [1, 2, 3], mode: "act" }} />)
expect(lastFrame()).not.toContain("taskHistory")
})
it("should exclude empty objects", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ emptyObj: {}, mode: "act" }} />)
expect(lastFrame()).not.toContain("emptyObj")
})
it("should exclude empty arrays", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ emptyArr: [], mode: "act" }} />)
expect(lastFrame()).not.toContain("emptyArr")
})
it("should exclude null/undefined values", () => {
const { lastFrame } = render(
<ConfigView {...defaultProps} globalState={{ nullVal: null, undefinedVal: undefined, mode: "act" }} />,
)
expect(lastFrame()).not.toContain("nullVal")
expect(lastFrame()).not.toContain("undefinedVal")
})
it("should exclude keys ending with Toggles", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ someToggles: { a: true }, mode: "act" }} />)
expect(lastFrame()).not.toContain("someToggles")
})
it("should exclude keys starting with apiConfig_", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ apiConfig_test: "value", mode: "act" }} />)
expect(lastFrame()).not.toContain("apiConfig_test")
})
})
describe("keyboard navigation", () => {
it("should show navigation help text", () => {
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ mode: "act" }} />)
expect(lastFrame()).toContain("Navigate")
expect(lastFrame()).toContain("Edit")
})
it("should highlight first item by default", () => {
const { lastFrame } = render(
<ConfigView {...defaultProps} globalState={{ mode: "act", actModeApiProvider: "anthropic" }} />,
)
// The selected indicator
expect(lastFrame()).toContain("")
})
it("should navigate down with arrow key", () => {
const { lastFrame, stdin } = render(
<ConfigView {...defaultProps} globalState={{ actModeFirst: "a", actModeSecond: "b" }} />,
)
// Press down arrow
stdin.write("\x1B[B")
const frame = lastFrame()
expect(frame).toContain("")
})
it("should navigate up with arrow key", () => {
const { lastFrame, stdin } = render(
<ConfigView {...defaultProps} globalState={{ actModeFirst: "a", actModeSecond: "b" }} />,
)
// Press down then up
stdin.write("\x1B[B")
stdin.write("\x1B[A")
expect(lastFrame()).toContain("")
})
})
describe("scrolling", () => {
it("should show scroll indicators when list is long", () => {
const manyEntries: Record<string, string> = {}
for (let i = 0; i < 20; i++) {
manyEntries[`actModeKey${i}`] = `value${i}`
}
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={manyEntries} />)
expect(lastFrame()).toContain("Showing")
})
})
})
+555
View File
@@ -0,0 +1,555 @@
/**
* Interactive config view component for displaying and editing configuration values
* Supports tabs for Settings, Rules, Workflows, Hooks, and Skills
*/
import {
GlobalStateAndSettings,
GlobalStateAndSettingsKey,
LocalState,
LocalStateKey,
SETTINGS_DEFAULTS,
} from "@shared/storage/state-keys"
import { Box, Text, useApp, useInput } from "ink"
import React, { useMemo, useState } from "react"
import { useStdinContext } from "../context/StdinContext"
import {
BooleanSelect,
buildConfigEntries,
buildToggleEntries,
ConfigRow,
HookInfo,
HookRow,
MAX_VISIBLE,
parseValue,
SEPARATOR,
SectionHeader,
SkillInfo,
SkillRow,
TABS,
TabBar,
TabView,
TextInput,
ToggleEntry,
ToggleRow,
WorkspaceHooks,
} from "./ConfigViewComponents"
// ============================================================================
// Types
// ============================================================================
interface ConfigViewProps {
dataDir: string
globalState: Record<string, unknown>
workspaceState: Record<string, unknown>
onUpdateGlobal?: (key: GlobalStateAndSettingsKey, value: GlobalStateAndSettings[GlobalStateAndSettingsKey]) => void
onUpdateWorkspace?: (key: LocalStateKey, value: LocalState[LocalStateKey]) => void
// Rules toggles
globalClineRulesToggles?: Record<string, boolean>
localClineRulesToggles?: Record<string, boolean>
localCursorRulesToggles?: Record<string, boolean>
localWindsurfRulesToggles?: Record<string, boolean>
localAgentsRulesToggles?: Record<string, boolean>
onToggleRule?: (isGlobal: boolean, rulePath: string, enabled: boolean, ruleType: string) => void
// Workflow toggles
globalWorkflowToggles?: Record<string, boolean>
localWorkflowToggles?: Record<string, boolean>
onToggleWorkflow?: (isGlobal: boolean, workflowPath: string, enabled: boolean) => void
// Hooks
hooksEnabled?: boolean
globalHooks?: HookInfo[]
workspaceHooks?: WorkspaceHooks[]
onToggleHook?: (isGlobal: boolean, hookName: string, enabled: boolean, workspaceName?: string) => void
// Skills
skillsEnabled?: boolean
globalSkills?: SkillInfo[]
localSkills?: SkillInfo[]
onToggleSkill?: (isGlobal: boolean, skillPath: string, enabled: boolean) => void
// Open folder callback
onOpenFolder?: (folderType: "rules" | "workflows" | "hooks" | "skills", isGlobal: boolean) => void
}
// ============================================================================
// Main Component
// ============================================================================
export const ConfigView: React.FC<ConfigViewProps> = ({
dataDir,
globalState,
workspaceState,
onUpdateGlobal,
onUpdateWorkspace,
globalClineRulesToggles,
localClineRulesToggles,
localCursorRulesToggles,
localWindsurfRulesToggles,
localAgentsRulesToggles,
onToggleRule,
globalWorkflowToggles,
localWorkflowToggles,
onToggleWorkflow,
hooksEnabled,
globalHooks = [],
workspaceHooks = [],
onToggleHook,
skillsEnabled,
globalSkills = [],
localSkills = [],
onToggleSkill,
onOpenFolder,
}) => {
const { exit } = useApp()
const { isRawModeSupported } = useStdinContext()
const [currentTab, setCurrentTab] = useState<TabView>("settings")
const [isEditing, setIsEditing] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(0)
const [editValue, setEditValue] = useState("")
// Build entries for settings tab
const configEntries = useMemo(
() => [...buildConfigEntries(globalState, "global"), ...buildConfigEntries(workspaceState, "workspace")],
[globalState, workspaceState],
)
// Build entries for rules tab
const ruleEntries = useMemo(() => {
const entries: ToggleEntry[] = []
entries.push(...buildToggleEntries(globalClineRulesToggles, "global", "cline"))
entries.push(...buildToggleEntries(localClineRulesToggles, "workspace", "cline"))
entries.push(...buildToggleEntries(localCursorRulesToggles, "workspace", "cursor"))
entries.push(...buildToggleEntries(localWindsurfRulesToggles, "workspace", "windsurf"))
entries.push(...buildToggleEntries(localAgentsRulesToggles, "workspace", "agents"))
return entries
}, [
globalClineRulesToggles,
localClineRulesToggles,
localCursorRulesToggles,
localWindsurfRulesToggles,
localAgentsRulesToggles,
])
// Build entries for workflows tab
const workflowEntries = useMemo(() => {
const entries: ToggleEntry[] = []
entries.push(...buildToggleEntries(globalWorkflowToggles, "global"))
entries.push(...buildToggleEntries(localWorkflowToggles, "workspace"))
return entries
}, [globalWorkflowToggles, localWorkflowToggles])
// Build flat list of hooks
const hookEntries = useMemo(() => {
const entries: { hook: HookInfo; isGlobal: boolean; workspaceName?: string }[] = []
globalHooks.forEach((hook) => entries.push({ hook, isGlobal: true }))
workspaceHooks.forEach((ws) => {
ws.hooks.forEach((hook) => entries.push({ hook, isGlobal: false, workspaceName: ws.workspaceName }))
})
return entries.sort((a, b) => a.hook.name.localeCompare(b.hook.name))
}, [globalHooks, workspaceHooks])
// Build flat list of skills
const skillEntries = useMemo(() => {
const entries: { skill: SkillInfo; isGlobal: boolean }[] = []
globalSkills.forEach((skill) => entries.push({ skill, isGlobal: true }))
localSkills.forEach((skill) => entries.push({ skill, isGlobal: false }))
return entries.sort((a, b) => a.skill.name.localeCompare(b.skill.name))
}, [globalSkills, localSkills])
// Get current list length based on tab
const currentListLength = useMemo(() => {
switch (currentTab) {
case "settings":
return configEntries.length
case "rules":
return ruleEntries.length
case "workflows":
return workflowEntries.length
case "hooks":
return hookEntries.length
case "skills":
return skillEntries.length
default:
return 0
}
}, [currentTab, configEntries.length, ruleEntries.length, workflowEntries.length, hookEntries.length, skillEntries.length])
// Get available tabs
const availableTabs = useMemo(() => {
return TABS.filter((tab) => {
if (tab.requiresFlag === "hooks") {
return hooksEnabled
}
if (tab.requiresFlag === "skills") {
return skillsEnabled
}
return true
})
}, [hooksEnabled, skillsEnabled])
// Reset selection when changing tabs
const handleTabChange = (newTab: TabView) => {
setCurrentTab(newTab)
setSelectedIndex(0)
setIsEditing(false)
}
// Settings tab handlers
const selectedConfigEntry = configEntries[selectedIndex]
const handleSettingsSave = (value: string | boolean) => {
if (!selectedConfigEntry) {
return
}
const parsed = typeof value === "boolean" ? value : parseValue(value, selectedConfigEntry.type)
if (selectedConfigEntry.source === "global" && onUpdateGlobal) {
onUpdateGlobal(selectedConfigEntry.key as GlobalStateAndSettingsKey, parsed as never)
} else if (selectedConfigEntry.source === "workspace" && onUpdateWorkspace) {
onUpdateWorkspace(selectedConfigEntry.key as LocalStateKey, parsed as never)
}
setIsEditing(false)
}
const handleSettingsReset = () => {
if (!selectedConfigEntry?.isEditable || selectedConfigEntry.source !== "global") {
return
}
const defaultValue = (SETTINGS_DEFAULTS as Record<string, unknown>)[selectedConfigEntry.key]
if (defaultValue !== undefined && onUpdateGlobal) {
onUpdateGlobal(selectedConfigEntry.key as GlobalStateAndSettingsKey, defaultValue as never)
}
}
// Toggle handlers for rules/workflows/hooks/skills
const handleToggle = () => {
if (currentTab === "rules" && ruleEntries[selectedIndex] && onToggleRule) {
const entry = ruleEntries[selectedIndex]
onToggleRule(entry.source === "global", entry.path, !entry.enabled, entry.ruleType || "cline")
} else if (currentTab === "workflows" && workflowEntries[selectedIndex] && onToggleWorkflow) {
const entry = workflowEntries[selectedIndex]
onToggleWorkflow(entry.source === "global", entry.path, !entry.enabled)
} else if (currentTab === "hooks" && hookEntries[selectedIndex] && onToggleHook) {
const entry = hookEntries[selectedIndex]
onToggleHook(entry.isGlobal, entry.hook.name, !entry.hook.enabled, entry.workspaceName)
} else if (currentTab === "skills" && skillEntries[selectedIndex] && onToggleSkill) {
const entry = skillEntries[selectedIndex]
onToggleSkill(entry.isGlobal, entry.skill.path, !entry.skill.enabled)
}
}
// Input handling
useInput(
(input, key) => {
if (input.toLowerCase() === "q" || key.escape) {
exit()
}
// Tab navigation with Tab key or number keys
if (key.tab || (input >= "1" && input <= "5")) {
const targetIdx = key.tab
? (availableTabs.findIndex((t) => t.key === currentTab) + 1) % availableTabs.length
: parseInt(input) - 1
if (targetIdx >= 0 && targetIdx < availableTabs.length) {
handleTabChange(availableTabs[targetIdx].key)
}
return
}
// List navigation (arrow keys and vim-style j/k)
if (key.upArrow || input === "k") {
setSelectedIndex((i) => (i > 0 ? i - 1 : currentListLength - 1))
} else if (key.downArrow || input === "j") {
setSelectedIndex((i) => (i < currentListLength - 1 ? i + 1 : 0))
}
// Tab-specific actions
if (currentTab === "settings") {
if ((key.return || input === "e") && selectedConfigEntry?.isEditable) {
setEditValue(selectedConfigEntry.value !== undefined ? String(selectedConfigEntry.value) : "")
setIsEditing(true)
} else if (input === "r") {
handleSettingsReset()
}
} else if (key.return || input === " ") {
// Toggle for rules/workflows/hooks/skills
handleToggle()
}
// Open folder (for rules/workflows/hooks/skills tabs)
if (input === "o" && onOpenFolder && currentTab !== "settings") {
// Determine if current selection is global or workspace based on the selected entry
let isGlobal = true
if (currentTab === "rules" && ruleEntries[selectedIndex]) {
isGlobal = ruleEntries[selectedIndex].source === "global"
} else if (currentTab === "workflows" && workflowEntries[selectedIndex]) {
isGlobal = workflowEntries[selectedIndex].source === "global"
} else if (currentTab === "hooks" && hookEntries[selectedIndex]) {
isGlobal = hookEntries[selectedIndex].isGlobal
} else if (currentTab === "skills" && skillEntries[selectedIndex]) {
isGlobal = skillEntries[selectedIndex].isGlobal
}
onOpenFolder(currentTab as "rules" | "workflows" | "hooks" | "skills", isGlobal)
}
},
{ isActive: isRawModeSupported && !isEditing },
)
// Scrolling window
const halfVisible = Math.floor(MAX_VISIBLE / 2)
const startIndex = Math.max(0, Math.min(selectedIndex - halfVisible, currentListLength - MAX_VISIBLE))
// Edit mode UI (settings only)
if (isEditing && selectedConfigEntry && currentTab === "settings") {
const header = (
<React.Fragment>
<Text bold color="white">
Edit Configuration
</Text>
<Text color="gray">{SEPARATOR}</Text>
</React.Fragment>
)
if (selectedConfigEntry.type === "boolean") {
return (
<Box flexDirection="column">
{header}
<BooleanSelect
label={selectedConfigEntry.key}
onCancel={() => setIsEditing(false)}
onSelect={handleSettingsSave}
value={Boolean(selectedConfigEntry.value)}
/>
</Box>
)
}
return (
<Box flexDirection="column">
{header}
<TextInput
label={selectedConfigEntry.key}
onCancel={() => setIsEditing(false)}
onChange={setEditValue}
onSubmit={handleSettingsSave}
type={selectedConfigEntry.type}
value={editValue}
/>
</Box>
)
}
// Render tab content
const renderTabContent = () => {
switch (currentTab) {
case "settings": {
const visibleEntries = configEntries.slice(startIndex, startIndex + MAX_VISIBLE)
return (
<React.Fragment>
<Box>
<Text>Data directory: </Text>
<Text color="blue" underline>
{dataDir}
</Text>
</Box>
<Text color="gray">{SEPARATOR}</Text>
<Box flexDirection="column">
{visibleEntries.map((entry, idx) => {
const actualIndex = startIndex + idx
const prevEntry = visibleEntries[idx - 1]
const showHeader = !prevEntry || prevEntry.source !== entry.source
return (
<React.Fragment key={`${entry.source}-${entry.key}`}>
{showHeader && (
<SectionHeader
title={entry.source === "global" ? "Global Settings:" : "Workspace Settings:"}
/>
)}
<ConfigRow entry={entry} isSelected={actualIndex === selectedIndex} />
</React.Fragment>
)
})}
</Box>
</React.Fragment>
)
}
case "rules": {
if (ruleEntries.length === 0) {
return (
<Box>
<Text color="gray">
No rules configured. Add .clinerules files to your workspace or global config.
</Text>
</Box>
)
}
const visibleEntries = ruleEntries.slice(startIndex, startIndex + MAX_VISIBLE)
return (
<Box flexDirection="column">
{visibleEntries.map((entry, idx) => {
const actualIndex = startIndex + idx
const prevEntry = visibleEntries[idx - 1]
const showHeader = !prevEntry || prevEntry.source !== entry.source
return (
<React.Fragment key={`${entry.source}-${entry.path}`}>
{showHeader && (
<SectionHeader title={entry.source === "global" ? "Global Rules:" : "Workspace Rules:"} />
)}
<ToggleRow entry={entry} isSelected={actualIndex === selectedIndex} showType />
</React.Fragment>
)
})}
</Box>
)
}
case "workflows": {
if (workflowEntries.length === 0) {
return (
<Box>
<Text color="gray">No workflows configured. Add workflow files to enable this feature.</Text>
</Box>
)
}
const visibleEntries = workflowEntries.slice(startIndex, startIndex + MAX_VISIBLE)
return (
<Box flexDirection="column">
{visibleEntries.map((entry, idx) => {
const actualIndex = startIndex + idx
const prevEntry = visibleEntries[idx - 1]
const showHeader = !prevEntry || prevEntry.source !== entry.source
return (
<React.Fragment key={`${entry.source}-${entry.path}`}>
{showHeader && (
<SectionHeader
title={entry.source === "global" ? "Global Workflows:" : "Workspace Workflows:"}
/>
)}
<ToggleRow entry={entry} isSelected={actualIndex === selectedIndex} />
</React.Fragment>
)
})}
</Box>
)
}
case "hooks": {
if (hookEntries.length === 0) {
return (
<Box>
<Text color="gray">No hooks configured. Add hook scripts to enable automation.</Text>
</Box>
)
}
const visibleEntries = hookEntries.slice(startIndex, startIndex + MAX_VISIBLE)
return (
<Box flexDirection="column">
{visibleEntries.map((entry, idx) => {
const actualIndex = startIndex + idx
const prevEntry = visibleEntries[idx - 1]
const showHeader =
!prevEntry ||
prevEntry.isGlobal !== entry.isGlobal ||
prevEntry.workspaceName !== entry.workspaceName
let sectionTitle = "Global Hooks:"
if (!entry.isGlobal && entry.workspaceName) {
sectionTitle = `${entry.workspaceName} Hooks:`
}
return (
<React.Fragment key={`${entry.isGlobal}-${entry.workspaceName || ""}-${entry.hook.name}`}>
{showHeader && <SectionHeader title={sectionTitle} />}
<HookRow hook={entry.hook} isSelected={actualIndex === selectedIndex} />
</React.Fragment>
)
})}
</Box>
)
}
case "skills": {
if (skillEntries.length === 0) {
return (
<Box>
<Text color="gray">No skills configured. Add SKILL.md files to enable skills.</Text>
</Box>
)
}
const visibleEntries = skillEntries.slice(startIndex, startIndex + MAX_VISIBLE)
return (
<Box flexDirection="column">
{visibleEntries.map((entry, idx) => {
const actualIndex = startIndex + idx
const prevEntry = visibleEntries[idx - 1]
const showHeader = !prevEntry || prevEntry.isGlobal !== entry.isGlobal
return (
<React.Fragment key={`${entry.isGlobal}-${entry.skill.path}`}>
{showHeader && (
<SectionHeader title={entry.isGlobal ? "Global Skills:" : "Workspace Skills:"} />
)}
<SkillRow isSelected={actualIndex === selectedIndex} skill={entry.skill} />
</React.Fragment>
)
})}
</Box>
)
}
default:
return null
}
}
// Help text based on current tab
const getHelpText = () => {
const base = "↑/↓/j/k Navigate • Tab/1-5 Switch tabs • q/Esc Exit"
if (currentTab === "settings") {
return `${base} • Enter/e Edit • r Reset`
}
const openFolder = onOpenFolder ? " • o Open folder" : ""
return `${base} • Enter/Space Toggle${openFolder}`
}
return (
<Box flexDirection="column">
<Text bold color="white">
Cline Configuration
</Text>
<Text color="gray">{SEPARATOR}</Text>
<TabBar currentTab={currentTab} hooksEnabled={hooksEnabled} skillsEnabled={skillsEnabled} tabs={TABS} />
<Text color="gray">{SEPARATOR}</Text>
{renderTabContent()}
{currentListLength > MAX_VISIBLE && (
<Box marginTop={1}>
<Text color="gray" dimColor>
{startIndex > 0 ? "↑ " : " "}
Showing {startIndex + 1}-{Math.min(startIndex + MAX_VISIBLE, currentListLength)} of {currentListLength}
{startIndex + MAX_VISIBLE < currentListLength ? " ↓" : " "}
</Text>
</Box>
)}
<Text color="gray">{SEPARATOR}</Text>
<Box flexDirection="column">
<Text color="gray" dimColor>
{getHelpText()}
</Text>
{currentTab === "settings" && selectedConfigEntry && !selectedConfigEntry.isEditable && (
<Text color="yellow" dimColor>
This field is read-only ({selectedConfigEntry.type} type or not a setting)
</Text>
)}
</Box>
</Box>
)
}
@@ -0,0 +1,394 @@
/**
* Sub-components and types for ConfigView
*/
import { Box, Text, useInput } from "ink"
import React, { useState } from "react"
import { useStdinContext } from "../context/StdinContext"
// ============================================================================
// Types & Constants
// ============================================================================
export type ValueType = "string" | "number" | "boolean" | "object" | "undefined"
export type TabView = "settings" | "rules" | "workflows" | "hooks" | "skills"
export interface ConfigEntry {
key: string
value: unknown
type: ValueType
isEditable: boolean
source: "global" | "workspace"
}
export interface ToggleEntry {
path: string
enabled: boolean
source: "global" | "workspace" | "remote"
ruleType?: string
}
export interface HookInfo {
name: string
enabled: boolean
absolutePath: string
}
export interface WorkspaceHooks {
workspaceName: string
hooks: HookInfo[]
}
export interface SkillInfo {
name: string
description: string
path: string
enabled: boolean
}
export const EXCLUDED_KEYS = new Set([
"taskHistory",
"primaryRootIndex",
"subagentsEnabled",
"subagentTerminalOutputLineLimit",
"welcomeViewCompleted",
"isNewUser",
])
export const EDITABLE_TYPES: Set<ValueType> = new Set(["string", "number", "boolean"])
export const MAX_VISIBLE = 12
export const SEPARATOR = "─".repeat(80)
export const TABS: { key: TabView; label: string; requiresFlag?: "hooks" | "skills" }[] = [
{ key: "settings", label: "Settings" },
{ key: "rules", label: "Rules" },
{ key: "workflows", label: "Workflows" },
{ key: "hooks", label: "Hooks", requiresFlag: "hooks" },
{ key: "skills", label: "Skills", requiresFlag: "skills" },
]
// ============================================================================
// Helper Functions
// ============================================================================
export function getValueType(value: unknown): ValueType {
if (value === undefined || value === null) {
return "undefined"
}
if (typeof value === "boolean") {
return "boolean"
}
if (typeof value === "number") {
return "number"
}
if (typeof value === "object") {
return "object"
}
return "string"
}
export function isExcluded(key: string, value: unknown): boolean {
if (EXCLUDED_KEYS.has(key)) {
return true
}
if (key.endsWith("Toggles") || key.endsWith("ModelInfo")) {
return true
}
if (key.startsWith("apiConfig_") || key.startsWith("last")) {
return true
}
if (value === undefined || value === null) {
return true
}
if (typeof value === "object" && Object.keys(value as object).length === 0) {
return true
}
if (Array.isArray(value) && value.length === 0) {
return true
}
if (typeof value === "string" && value.trim() === "") {
return true
}
return false
}
export function formatValue(value: unknown, maxLen = 50): string {
if (value === undefined || value === null) {
return "<not set>"
}
if (typeof value === "boolean") {
return value ? "true" : "false"
}
if (typeof value === "number") {
return String(value)
}
if (typeof value === "object") {
const json = JSON.stringify(value)
return json.length > maxLen ? json.slice(0, maxLen - 3) + "..." : json
}
const str = String(value)
return str.length > maxLen ? str.slice(0, maxLen - 3) + "..." : str
}
export function parseValue(input: string, type: ValueType): unknown {
if (type === "boolean") {
return input.toLowerCase() === "true" || input === "1"
}
if (type === "number") {
const num = parseFloat(input)
return Number.isNaN(num) ? 0 : num
}
if (type === "object") {
try {
return JSON.parse(input)
} catch {
return {}
}
}
return input
}
// Import isSettingsKey at module level for proper test mocking
import { isSettingsKey } from "@shared/storage/state-keys"
export function buildConfigEntries(state: Record<string, unknown>, source: "global" | "workspace"): ConfigEntry[] {
return Object.entries(state)
.filter(([key, value]) => !isExcluded(key, value))
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => {
const type = getValueType(value)
const isEditable = EDITABLE_TYPES.has(type) && (source === "workspace" || isSettingsKey(key))
return { key, value, type, isEditable, source }
})
}
export function buildToggleEntries(
toggles: Record<string, boolean> | undefined,
source: "global" | "workspace" | "remote",
ruleType?: string,
): ToggleEntry[] {
if (!toggles) {
return []
}
return Object.entries(toggles)
.sort(([a], [b]) => a.localeCompare(b))
.map(([path, enabled]) => ({ path, enabled, source, ruleType }))
}
export function getFileName(path: string): string {
return path.split("/").pop() || path
}
// ============================================================================
// Sub-components
// ============================================================================
interface TextInputProps {
label: string
onChange: (value: string) => void
onCancel: () => void
onSubmit: (value: string) => void
type: ValueType
value: string
}
export const TextInput: React.FC<TextInputProps> = ({ label, onChange, onCancel, onSubmit, type, value }) => {
const { isRawModeSupported } = useStdinContext()
useInput(
(input, key) => {
if (key.escape) {
onCancel()
} else if (key.return) {
onSubmit(value)
} else if (key.backspace || key.delete) {
onChange(value.slice(0, -1))
} else if (input && !key.ctrl && !key.meta) {
onChange(value + input)
}
},
{ isActive: isRawModeSupported },
)
return (
<Box flexDirection="column" marginTop={1}>
<Text bold color="cyan">
Edit: {label}
</Text>
<Box>
<Text color="white">{value}</Text>
<Text color="gray"></Text>
</Box>
<Text color="gray" dimColor>
Type: {type} Enter to save Esc to cancel
</Text>
</Box>
)
}
interface BooleanSelectProps {
label: string
onCancel: () => void
onSelect: (value: boolean) => void
value: boolean
}
export const BooleanSelect: React.FC<BooleanSelectProps> = ({ label, onCancel, onSelect, value }) => {
const { isRawModeSupported } = useStdinContext()
const [selected, setSelected] = useState(value)
useInput(
(_input, key) => {
if (key.escape) {
onCancel()
} else if (key.return) {
onSelect(selected)
} else if (key.upArrow || key.downArrow) {
setSelected((prev) => !prev)
}
},
{ isActive: isRawModeSupported },
)
return (
<Box flexDirection="column" marginTop={1}>
<Text bold color="cyan">
Edit: {label}
</Text>
<Box flexDirection="column">
<Text color={selected ? "green" : undefined}>{selected ? " " : " "}true</Text>
<Text color={!selected ? "green" : undefined}>{!selected ? " " : " "}false</Text>
</Box>
<Text color="gray" dimColor>
/ to toggle Enter to save Esc to cancel
</Text>
</Box>
)
}
export const ConfigRow: React.FC<{ entry: ConfigEntry; isSelected: boolean }> = ({ entry, isSelected }) => {
const valueColor = entry.type === "boolean" ? (entry.value ? "green" : "red") : "white"
return (
<Box>
<Text color={isSelected ? "cyan" : undefined}>
{isSelected ? " " : " "}
<Text color="cyan">{entry.key}</Text>
<Text color="gray">: </Text>
<Text color={valueColor}>{formatValue(entry.value)}</Text>
{!entry.isEditable && (
<Text color="gray" dimColor>
{" "}
(read-only)
</Text>
)}
</Text>
</Box>
)
}
export const ToggleRow: React.FC<{
entry: ToggleEntry
isSelected: boolean
showType?: boolean
}> = ({ entry, isSelected, showType }) => {
const fileName = getFileName(entry.path)
const typeLabel = entry.ruleType ? ` [${entry.ruleType}]` : ""
return (
<Box>
<Text color={isSelected ? "cyan" : undefined}>
{isSelected ? " " : " "}
<Text color={entry.enabled ? "green" : "red"}>{entry.enabled ? "●" : "○"}</Text>
<Text> </Text>
<Text color="white">{fileName}</Text>
{showType && (
<Text color="gray" dimColor>
{typeLabel}
</Text>
)}
</Text>
</Box>
)
}
export const HookRow: React.FC<{
hook: HookInfo
isSelected: boolean
}> = ({ hook, isSelected }) => {
return (
<Box>
<Text color={isSelected ? "cyan" : undefined}>
{isSelected ? " " : " "}
<Text color={hook.enabled ? "green" : "red"}>{hook.enabled ? "●" : "○"}</Text>
<Text> </Text>
<Text color="white">{hook.name}</Text>
</Text>
</Box>
)
}
export const SkillRow: React.FC<{
skill: SkillInfo
isSelected: boolean
}> = ({ skill, isSelected }) => {
return (
<Box flexDirection="column">
<Box>
<Text color={isSelected ? "cyan" : undefined}>
{isSelected ? " " : " "}
<Text color={skill.enabled ? "green" : "red"}>{skill.enabled ? "●" : "○"}</Text>
<Text> </Text>
<Text bold color="white">
{skill.name}
</Text>
</Text>
</Box>
{skill.description && (
<Box marginLeft={4}>
<Text color="gray" dimColor>
{skill.description.length > 60 ? skill.description.slice(0, 57) + "..." : skill.description}
</Text>
</Box>
)}
</Box>
)
}
export const TabBar: React.FC<{
currentTab: TabView
tabs: typeof TABS
hooksEnabled?: boolean
skillsEnabled?: boolean
}> = ({ currentTab, tabs, hooksEnabled, skillsEnabled }) => {
const visibleTabs = tabs.filter((tab) => {
if (tab.requiresFlag === "hooks") {
return hooksEnabled
}
if (tab.requiresFlag === "skills") {
return skillsEnabled
}
return true
})
return (
<Box marginBottom={1}>
{visibleTabs.map((tab, idx) => (
<React.Fragment key={tab.key}>
{idx > 0 && <Text color="gray"> </Text>}
<Text bold={currentTab === tab.key} color={currentTab === tab.key ? "cyan" : "gray"}>
{currentTab === tab.key ? `[${tab.label}]` : tab.label}
</Text>
</React.Fragment>
))}
</Box>
)
}
export const SectionHeader: React.FC<{ title: string }> = ({ title }) => (
<Box marginTop={1}>
<Text bold color="yellow">
{title}
</Text>
</Box>
)
+301
View File
@@ -0,0 +1,301 @@
/**
* Stateful wrapper for ConfigView that handles toggle operations
*/
import { exec } from "node:child_process"
import os from "node:os"
import path from "node:path"
import { RuleScope } from "@shared/proto/cline/file"
import type { GlobalStateAndSettings, GlobalStateAndSettingsKey, LocalState, LocalStateKey } from "@shared/storage/state-keys"
import React, { useCallback, useEffect, useState } from "react"
import type { Controller } from "@/core/controller"
import { HostProvider } from "@/hosts/host-provider"
import { StdinProvider } from "../context/StdinContext"
import { ConfigView } from "./ConfigView"
interface HookInfo {
name: string
enabled: boolean
absolutePath: string
}
interface WorkspaceHooks {
workspaceName: string
hooks: HookInfo[]
}
interface SkillInfo {
name: string
description: string
path: string
enabled: boolean
}
interface ConfigViewWrapperProps {
controller: Controller
dataDir: string
globalState: Record<string, unknown>
workspaceState: Record<string, unknown>
hooksEnabled: boolean
skillsEnabled: boolean
isRawModeSupported?: boolean
}
export const ConfigViewWrapper: React.FC<ConfigViewWrapperProps> = ({
controller,
dataDir,
globalState: initialGlobalState,
workspaceState: initialWorkspaceState,
hooksEnabled,
skillsEnabled,
isRawModeSupported = true,
}) => {
// Settings state (managed locally for UI updates)
const [globalStateLocal, setGlobalStateLocal] = useState<Record<string, unknown>>(initialGlobalState)
const [workspaceStateLocal, setWorkspaceStateLocal] = useState<Record<string, unknown>>(initialWorkspaceState)
// Rules state
const [globalClineRulesToggles, setGlobalClineRulesToggles] = useState<Record<string, boolean>>({})
const [localClineRulesToggles, setLocalClineRulesToggles] = useState<Record<string, boolean>>({})
const [localCursorRulesToggles, setLocalCursorRulesToggles] = useState<Record<string, boolean>>({})
const [localWindsurfRulesToggles, setLocalWindsurfRulesToggles] = useState<Record<string, boolean>>({})
const [localAgentsRulesToggles, setLocalAgentsRulesToggles] = useState<Record<string, boolean>>({})
// Workflow state
const [globalWorkflowToggles, setGlobalWorkflowToggles] = useState<Record<string, boolean>>({})
const [localWorkflowToggles, setLocalWorkflowToggles] = useState<Record<string, boolean>>({})
// Hooks state
const [globalHooks, setGlobalHooks] = useState<HookInfo[]>([])
const [workspaceHooksState, setWorkspaceHooksState] = useState<WorkspaceHooks[]>([])
// Skills state
const [globalSkills, setGlobalSkills] = useState<SkillInfo[]>([])
const [localSkills, setLocalSkills] = useState<SkillInfo[]>([])
// Load initial data
useEffect(() => {
const loadData = async () => {
const { refreshRules } = await import("@/core/controller/file/refreshRules")
const { refreshHooks } = await import("@/core/controller/file/refreshHooks")
const { refreshSkills } = await import("@/core/controller/file/refreshSkills")
const rulesData = await refreshRules(controller, {})
setGlobalClineRulesToggles(rulesData.globalClineRulesToggles?.toggles || {})
setLocalClineRulesToggles(rulesData.localClineRulesToggles?.toggles || {})
setLocalCursorRulesToggles(rulesData.localCursorRulesToggles?.toggles || {})
setLocalWindsurfRulesToggles(rulesData.localWindsurfRulesToggles?.toggles || {})
setLocalAgentsRulesToggles(rulesData.localAgentsRulesToggles?.toggles || {})
setGlobalWorkflowToggles(rulesData.globalWorkflowToggles?.toggles || {})
setLocalWorkflowToggles(rulesData.localWorkflowToggles?.toggles || {})
if (hooksEnabled) {
const hooksData = await refreshHooks(controller, {})
setGlobalHooks(hooksData.globalHooks || [])
setWorkspaceHooksState(hooksData.workspaceHooks || [])
}
if (skillsEnabled) {
const skillsData = await refreshSkills(controller)
setGlobalSkills(skillsData.globalSkills || [])
setLocalSkills(skillsData.localSkills || [])
}
}
loadData()
}, [controller, hooksEnabled, skillsEnabled])
// Toggle handlers
const handleToggleRule = useCallback(
async (isGlobal: boolean, rulePath: string, enabled: boolean, ruleType: string) => {
const { toggleClineRule } = await import("@/core/controller/file/toggleClineRule")
// Determine scope based on isGlobal and rule type
const scope = isGlobal ? RuleScope.GLOBAL : RuleScope.LOCAL
// For non-cline rules, we need different toggle functions
if (ruleType === "cursor") {
// Update local state optimistically
setLocalCursorRulesToggles((prev) => ({ ...prev, [rulePath]: enabled }))
// Cursor rules use toggleCursorRule but we'll just update the state manager directly
const toggles = controller.stateManager.getWorkspaceStateKey("localCursorRulesToggles") || {}
toggles[rulePath] = enabled
controller.stateManager.setWorkspaceState("localCursorRulesToggles", toggles)
} else if (ruleType === "windsurf") {
setLocalWindsurfRulesToggles((prev) => ({ ...prev, [rulePath]: enabled }))
const toggles = controller.stateManager.getWorkspaceStateKey("localWindsurfRulesToggles") || {}
toggles[rulePath] = enabled
controller.stateManager.setWorkspaceState("localWindsurfRulesToggles", toggles)
} else if (ruleType === "agents") {
setLocalAgentsRulesToggles((prev) => ({ ...prev, [rulePath]: enabled }))
const toggles = controller.stateManager.getWorkspaceStateKey("localAgentsRulesToggles") || {}
toggles[rulePath] = enabled
controller.stateManager.setWorkspaceState("localAgentsRulesToggles", toggles)
} else {
// Cline rules
const result = await toggleClineRule(controller, { metadata: undefined, rulePath, enabled, scope })
if (result.globalClineRulesToggles?.toggles) {
setGlobalClineRulesToggles(result.globalClineRulesToggles.toggles)
}
if (result.localClineRulesToggles?.toggles) {
setLocalClineRulesToggles(result.localClineRulesToggles.toggles)
}
}
},
[controller],
)
const handleToggleWorkflow = useCallback(
async (isGlobal: boolean, workflowPath: string, enabled: boolean) => {
const { toggleWorkflow } = await import("@/core/controller/file/toggleWorkflow")
const scope = isGlobal ? RuleScope.GLOBAL : RuleScope.LOCAL
// Optimistic update
if (isGlobal) {
setGlobalWorkflowToggles((prev) => ({ ...prev, [workflowPath]: enabled }))
} else {
setLocalWorkflowToggles((prev) => ({ ...prev, [workflowPath]: enabled }))
}
await toggleWorkflow(controller, { metadata: undefined, workflowPath, enabled, scope })
},
[controller],
)
const handleToggleHook = useCallback(
async (isGlobal: boolean, hookName: string, enabled: boolean, workspaceName?: string) => {
const { toggleHook } = await import("@/core/controller/file/toggleHook")
// Optimistic update
if (isGlobal) {
setGlobalHooks((prev) => prev.map((h) => (h.name === hookName ? { ...h, enabled } : h)))
} else {
setWorkspaceHooksState((prev) =>
prev.map((ws) =>
ws.workspaceName === workspaceName
? { ...ws, hooks: ws.hooks.map((h) => (h.name === hookName ? { ...h, enabled } : h)) }
: ws,
),
)
}
const result = await toggleHook(controller, { metadata: undefined, hookName, isGlobal, enabled, workspaceName })
if (result.hooksToggles) {
setGlobalHooks(result.hooksToggles.globalHooks || [])
setWorkspaceHooksState(result.hooksToggles.workspaceHooks || [])
}
},
[controller],
)
const handleToggleSkill = useCallback(
async (isGlobal: boolean, skillPath: string, enabled: boolean) => {
const { toggleSkill } = await import("@/core/controller/file/toggleSkill")
// Optimistic update
if (isGlobal) {
setGlobalSkills((prev) => prev.map((s) => (s.path === skillPath ? { ...s, enabled } : s)))
} else {
setLocalSkills((prev) => prev.map((s) => (s.path === skillPath ? { ...s, enabled } : s)))
}
await toggleSkill(controller, { metadata: undefined, skillPath, isGlobal, enabled })
},
[controller],
)
const handleOpenFolder = useCallback(
async (folderType: "rules" | "workflows" | "hooks" | "skills", isGlobal: boolean) => {
let folderPath: string
if (isGlobal) {
// Global folders are in dataDir (e.g., ~/.cline/)
const subFolder = folderType === "rules" ? "rules" : folderType
folderPath = path.join(dataDir, subFolder)
} else {
// Local folders are in the workspace
const workspacePaths = await HostProvider.workspace.getWorkspacePaths({})
const primaryWorkspace = workspacePaths.paths[0]
if (!primaryWorkspace) {
return
}
// Local rules/workflows/hooks/skills are in .clinerules or .cline
const subFolder = folderType === "rules" ? "rules" : folderType
folderPath = path.join(primaryWorkspace, ".clinerules", subFolder)
}
// Open folder using platform-specific command
const platform = os.platform()
let command: string
if (platform === "darwin") {
command = `open "${folderPath}"`
} else if (platform === "win32") {
command = `explorer "${folderPath}"`
} else {
command = `xdg-open "${folderPath}"`
}
exec(command, (error) => {
if (error) {
// Folder might not exist, try to create and open
exec(`mkdir -p "${folderPath}" && ${command}`)
}
})
},
[dataDir],
)
// Settings update handlers
const handleUpdateGlobal = useCallback(
async (key: GlobalStateAndSettingsKey, value: GlobalStateAndSettings[GlobalStateAndSettingsKey]) => {
// Update local state for immediate UI feedback
setGlobalStateLocal((prev) => ({ ...prev, [key]: value }))
// Persist to state manager
controller.stateManager.setGlobalState(key, value)
await controller.stateManager.flushPendingState()
},
[controller],
)
const handleUpdateWorkspace = useCallback(
async (key: LocalStateKey, value: LocalState[LocalStateKey]) => {
// Update local state for immediate UI feedback
setWorkspaceStateLocal((prev) => ({ ...prev, [key]: value }))
// Persist to state manager
controller.stateManager.setWorkspaceState(key, value)
await controller.stateManager.flushPendingState()
},
[controller],
)
return (
<StdinProvider isRawModeSupported={isRawModeSupported}>
<ConfigView
dataDir={dataDir}
globalClineRulesToggles={globalClineRulesToggles}
globalHooks={globalHooks}
globalSkills={globalSkills}
globalState={globalStateLocal}
globalWorkflowToggles={globalWorkflowToggles}
hooksEnabled={hooksEnabled}
localAgentsRulesToggles={localAgentsRulesToggles}
localClineRulesToggles={localClineRulesToggles}
localCursorRulesToggles={localCursorRulesToggles}
localSkills={localSkills}
localWindsurfRulesToggles={localWindsurfRulesToggles}
localWorkflowToggles={localWorkflowToggles}
onOpenFolder={handleOpenFolder}
onToggleHook={handleToggleHook}
onToggleRule={handleToggleRule}
onToggleSkill={handleToggleSkill}
onToggleWorkflow={handleToggleWorkflow}
onUpdateGlobal={handleUpdateGlobal}
onUpdateWorkspace={handleUpdateWorkspace}
skillsEnabled={skillsEnabled}
workspaceHooks={workspaceHooksState}
workspaceState={workspaceStateLocal}
/>
</StdinProvider>
)
}
+175
View File
@@ -0,0 +1,175 @@
/**
* DiffView component for displaying file diffs in Ink
* Shows unified diff output with colored lines for additions/deletions
*/
import { Box, Text } from "ink"
import React from "react"
interface DiffViewProps {
/** File path being displayed */
path: string
/** For newFileCreated: the full content of the new file */
content?: string
/** For editedExistingFile: the unified diff string */
diff?: string
/** Maximum lines to display before truncating */
maxLines?: number
}
interface DiffLine {
type: "add" | "remove" | "context" | "header"
lineNumber?: number
content: string
}
/**
* Parse a unified diff string into structured lines
*/
function parseDiff(diff: string): DiffLine[] {
const lines = diff.split("\n")
const result: DiffLine[] = []
let oldLine = 0
let newLine = 0
for (const line of lines) {
if (line.startsWith("@@")) {
// Parse hunk header like @@ -1,5 +1,7 @@
const match = line.match(/@@ -(\d+),?\d* \+(\d+),?\d* @@/)
if (match) {
oldLine = parseInt(match[1], 10)
newLine = parseInt(match[2], 10)
}
result.push({ type: "header", content: line })
} else if (line.startsWith("+") && !line.startsWith("+++")) {
result.push({ type: "add", lineNumber: newLine, content: line.slice(1) })
newLine++
} else if (line.startsWith("-") && !line.startsWith("---")) {
result.push({ type: "remove", lineNumber: oldLine, content: line.slice(1) })
oldLine++
} else if (line.startsWith(" ")) {
result.push({ type: "context", lineNumber: newLine, content: line.slice(1) })
oldLine++
newLine++
} else if (line.startsWith("---") || line.startsWith("+++")) {
// File headers - skip or show as header
result.push({ type: "header", content: line })
}
}
return result
}
/**
* Format line number with padding
*/
function formatLineNumber(num: number | undefined, width: number): string {
if (num === undefined) {
return " ".repeat(width)
}
return String(num).padStart(width, " ")
}
/**
* Renders a new file with all lines shown as additions
*/
const NewFileView: React.FC<{ path: string; content: string; maxLines: number }> = ({ path, content, maxLines }) => {
const lines = content.split("\n")
const displayLines = lines.slice(0, maxLines)
const lineNumWidth = String(lines.length).length
// Calculate max line length for padding
const maxLineLength = Math.max(...displayLines.map((l) => l.length), 40)
return (
<Box flexDirection="column">
<Text bold color="green">
+ {path} (new file)
</Text>
{displayLines.map((line, idx) => (
<Box key={idx}>
<Text dimColor>{formatLineNumber(idx + 1, lineNumWidth)} </Text>
<Text backgroundColor="rgb(117, 176, 111)" color="white">
+{line.padEnd(maxLineLength)}
</Text>
</Box>
))}
{lines.length > maxLines && <Text dimColor>... and {lines.length - maxLines} more lines</Text>}
</Box>
)
}
/**
* Renders a unified diff with colored additions and deletions
*/
const UnifiedDiffView: React.FC<{ path: string; diff: string; maxLines: number }> = ({ path, diff, maxLines }) => {
const diffLines = parseDiff(diff)
const displayLines = diffLines.slice(0, maxLines)
const maxLineNum = Math.max(...diffLines.filter((l) => l.lineNumber !== undefined).map((l) => l.lineNumber!), 0)
const lineNumWidth = String(maxLineNum).length || 3
// Calculate max line length for padding
const maxLineLength = Math.max(...diffLines.map((l) => l.content.length), 40)
return (
<Box flexDirection="column">
<Text bold color="blue">
~ {path} (modified)
</Text>
{displayLines.map((line, idx) => {
switch (line.type) {
case "header":
return (
<Text color="cyan" key={idx}>
{line.content}
</Text>
)
case "add":
return (
<Box key={idx}>
<Text dimColor>{formatLineNumber(line.lineNumber, lineNumWidth)} </Text>
<Text backgroundColor="rgb(117, 176, 111)" color="white">
+{line.content.padEnd(maxLineLength)}
</Text>
</Box>
)
case "remove":
return (
<Box key={idx}>
<Text dimColor>{formatLineNumber(line.lineNumber, lineNumWidth)} </Text>
<Text backgroundColor="rgb(246, 48, 73)" color="white">
-{line.content.padEnd(maxLineLength)}
</Text>
</Box>
)
case "context":
return (
<Box key={idx}>
<Text dimColor>{formatLineNumber(line.lineNumber, lineNumWidth)} </Text>
<Text> {line.content.padEnd(maxLineLength)}</Text>
</Box>
)
default:
return null
}
})}
{diffLines.length > maxLines && <Text dimColor>... and {diffLines.length - maxLines} more lines</Text>}
</Box>
)
}
/**
* DiffView component that renders either a new file or a unified diff
*/
export const DiffView: React.FC<DiffViewProps> = ({ path, content, diff, maxLines = 20 }) => {
// For new files, show all content as additions
if (content && !diff) {
return <NewFileView content={content} maxLines={maxLines} path={path} />
}
// For edited files, show the unified diff
if (diff) {
return <UnifiedDiffView diff={diff} maxLines={maxLines} path={path} />
}
// Fallback if neither content nor diff is provided
return <Text color="blue">{path} (no diff available)</Text>
}
+99
View File
@@ -0,0 +1,99 @@
/**
* File mention menu component for CLI
* Displays a list of matching files when user types @
*/
import { Box, Text } from "ink"
import React from "react"
import type { FileSearchResult } from "../utils/file-search"
interface FileMentionMenuProps {
results: FileSearchResult[]
selectedIndex: number
isLoading: boolean
query: string
}
/**
* Truncate path from the left if too long, keeping the filename visible
*/
function truncatePath(filePath: string, maxLength: number = 50): string {
if (filePath.length <= maxLength) {
return filePath
}
return "..." + filePath.slice(-(maxLength - 3))
}
export const FileMentionMenu: React.FC<FileMentionMenuProps> = ({ results, selectedIndex, isLoading, query }) => {
if (isLoading) {
return (
<Box flexDirection="column" marginBottom={1} paddingLeft={1} paddingRight={1}>
<Text color="gray">Searching files...</Text>
</Box>
)
}
if (results.length === 0) {
return (
<Box flexDirection="column" marginBottom={1} paddingLeft={1} paddingRight={1}>
<Text color="gray">{query ? `No files matching "${query}"` : "Type to search files..."}</Text>
</Box>
)
}
// Show max 8 items, centered around selected item
const maxVisible = 8
let startIndex = 0
let endIndex = results.length
if (results.length > maxVisible) {
// Center the selected item in the visible window
const halfWindow = Math.floor(maxVisible / 2)
startIndex = Math.max(0, selectedIndex - halfWindow)
endIndex = Math.min(results.length, startIndex + maxVisible)
// Adjust if we're near the end
if (endIndex - startIndex < maxVisible) {
startIndex = Math.max(0, endIndex - maxVisible)
}
}
const visibleResults = results.slice(startIndex, endIndex)
return (
<Box flexDirection="column" marginBottom={1} paddingLeft={1} paddingRight={1}>
{startIndex > 0 && (
<Text color="gray" dimColor>
{startIndex} more...
</Text>
)}
{visibleResults.map((result, idx) => {
const actualIndex = startIndex + idx
const isSelected = actualIndex === selectedIndex
const displayPath = truncatePath(result.path)
return (
<Box key={result.path}>
<Text backgroundColor={isSelected ? "blue" : undefined} color={isSelected ? "white" : undefined}>
{isSelected ? " " : " "}
{displayPath}
</Text>
</Box>
)
})}
{endIndex < results.length && (
<Text color="gray" dimColor>
{results.length - endIndex} more...
</Text>
)}
<Box>
<Text color="cyan" dimColor>
/ to select, Tab/Enter to insert
</Text>
</Box>
</Box>
)
}
+185
View File
@@ -0,0 +1,185 @@
/**
* Focus Chain / To-Do List component for CLI
* Displays a progress-tracked checklist of tasks
*/
import { isCompletedFocusChainItem, isFocusChainItem, parseFocusChainItem } from "@shared/focus-chain-utils"
import { Box, Text } from "ink"
import React, { useMemo } from "react"
interface TodoInfo {
currentTodo: { text: string; completed: boolean; index: number } | null
currentIndex: number
completedCount: number
totalCount: number
progressPercentage: number
}
interface TodoItem {
text: string
checked: boolean
}
interface FocusChainProps {
focusChainChecklist?: string | null
expanded?: boolean
}
/**
* Parse the focus chain checklist text into TodoInfo
*/
function parseCurrentTodoInfo(text: string): TodoInfo | null {
if (!text) {
return null
}
let completedCount = 0
let totalCount = 0
let firstIncompleteIndex = -1
let firstIncompleteText: string | null = null
const lines = text.split("\n")
for (const rawLine of lines) {
const line = rawLine.trim()
if (isFocusChainItem(line)) {
const isCompleted = isCompletedFocusChainItem(line)
if (isCompleted) {
completedCount++
} else if (firstIncompleteIndex === -1) {
firstIncompleteIndex = totalCount
// Extract text after "- [ ] "
firstIncompleteText = line.substring(5).trim()
}
totalCount++
}
}
if (totalCount === 0) {
return null
}
const currentTodo = firstIncompleteText ? { text: firstIncompleteText, completed: false, index: firstIncompleteIndex } : null
return {
currentTodo,
currentIndex: firstIncompleteIndex >= 0 ? firstIncompleteIndex + 1 : totalCount,
completedCount,
totalCount,
progressPercentage: (completedCount / totalCount) * 100,
}
}
/**
* Parse all todo items from the checklist
*/
function parseTodoItems(text: string): TodoItem[] {
const items: TodoItem[] = []
const lines = text.split("\n")
for (const rawLine of lines) {
const line = rawLine.trim()
const parsed = parseFocusChainItem(line)
if (parsed) {
items.push(parsed)
}
}
return items
}
/**
* Render progress bar
*/
const ProgressBar: React.FC<{ percentage: number; width?: number }> = ({ percentage, width = 20 }) => {
const filled = Math.round((percentage / 100) * width)
const empty = width - filled
const bar = "█".repeat(filled) + "░".repeat(empty)
return (
<Text>
<Text color="green">{bar}</Text>
<Text dimColor> {Math.round(percentage)}%</Text>
</Text>
)
}
/**
* Header view showing current task and progress
*/
const Header: React.FC<{
todoInfo: TodoInfo
}> = ({ todoInfo }) => {
const { currentTodo, currentIndex, totalCount, completedCount } = todoInfo
const isCompleted = completedCount === totalCount
const displayText = isCompleted ? "All tasks completed!" : currentTodo?.text || "To-Do list"
const truncatedText = displayText.length > 50 ? displayText.substring(0, 47) + "..." : displayText
return (
<Box flexDirection="row" gap={1}>
<Text color={isCompleted ? "green" : "cyan"}>
[{currentIndex}/{totalCount}]
</Text>
<Text color={isCompleted ? "green" : undefined}>{truncatedText}</Text>
</Box>
)
}
/**
* Expanded view showing all todo items
*/
const ExpandedList: React.FC<{
items: TodoItem[]
isCompleted: boolean
}> = ({ items, isCompleted }) => {
return (
<Box flexDirection="column" marginLeft={2} marginTop={1}>
{items.map((item, index) => (
<Box key={index}>
<Text color={item.checked ? "green" : "gray"}>{item.checked ? "✓" : "○"} </Text>
<Text color={item.checked ? "green" : undefined} dimColor={item.checked}>
{item.text}
</Text>
</Box>
))}
{isCompleted && (
<Box marginTop={1}>
<Text dimColor italic>
New steps will be generated if you continue the task
</Text>
</Box>
)}
</Box>
)
}
/**
* Main FocusChain component for CLI
* Shows a progress summary of the current to-do list
* Use expanded={true} to show all items (e.g., in verbose mode)
*/
export const FocusChain: React.FC<FocusChainProps> = ({ focusChainChecklist, expanded = false }) => {
const todoInfo = useMemo(
() => (focusChainChecklist ? parseCurrentTodoInfo(focusChainChecklist) : null),
[focusChainChecklist],
)
const todoItems = useMemo(() => (focusChainChecklist ? parseTodoItems(focusChainChecklist) : []), [focusChainChecklist])
// No content to display
if (!todoInfo) {
return null
}
const isCompleted = todoInfo.completedCount === todoInfo.totalCount
return (
<Box borderColor={isCompleted ? "green" : "gray"} borderStyle="round" flexDirection="column" paddingX={1}>
<Header todoInfo={todoInfo} />
<ProgressBar percentage={todoInfo.progressPercentage} />
{expanded && <ExpandedList isCompleted={isCompleted} items={todoItems} />}
</Box>
)
}
+218
View File
@@ -0,0 +1,218 @@
import { Text } from "ink"
import { render } from "ink-testing-library"
import React from "react"
import { beforeEach, describe, expect, it, vi } from "vitest"
// Create stable mock reference using vi.hoisted
const { mockShowTaskWithId } = vi.hoisted(() => ({
mockShowTaskWithId: vi.fn().mockResolvedValue(undefined),
}))
vi.mock("./TaskView", () => ({
TaskView: ({ taskId, verbose }: any) =>
React.createElement(Text, null, `TaskView: ${taskId || "no-id"} verbose=${String(verbose)}`),
}))
// Mock the controller dependencies - must be before importing HistoryView
vi.mock("@/core/controller", () => ({
Controller: vi.fn(),
}))
vi.mock("@/core/controller/task/showTaskWithId", () => ({
showTaskWithId: mockShowTaskWithId,
}))
vi.mock("@/shared/proto/cline/common", () => ({
StringRequest: {
create: (data: any) => data,
},
}))
// Import after mocks are set up
import { HistoryView } from "./HistoryView"
describe("HistoryView", () => {
const mockController = {
dispose: vi.fn(),
stateManager: { flushPendingState: vi.fn() },
} as any
const mockItems = [
{ id: "task-1", ts: Date.now() - 3600000, task: "First task" },
{ id: "task-2", ts: Date.now() - 7200000, task: "Second task" },
{ id: "task-3", ts: Date.now() - 10800000, task: "Third task" },
]
beforeEach(() => {
vi.clearAllMocks()
})
describe("rendering", () => {
it("should render the history header", () => {
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} />)
expect(lastFrame()).toContain("Task History")
})
it("should show total count in header", () => {
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} />)
expect(lastFrame()).toContain("3 total")
})
it("should render task items", () => {
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} />)
expect(lastFrame()).toContain("First task")
expect(lastFrame()).toContain("Second task")
expect(lastFrame()).toContain("Third task")
})
it("should show task IDs", () => {
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} />)
expect(lastFrame()).toContain("task-1")
expect(lastFrame()).toContain("task-2")
})
it("should show empty message when no items", () => {
const { lastFrame } = render(<HistoryView controller={mockController} items={[]} />)
expect(lastFrame()).toContain("No task history available")
})
it("should show navigation help", () => {
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} />)
expect(lastFrame()).toContain("↑↓")
expect(lastFrame()).toContain("Enter")
})
})
describe("task details", () => {
it("should display task cost when available", () => {
const itemsWithCost = [{ id: "task-1", ts: Date.now(), task: "Task", totalCost: 0.0025 }]
const { lastFrame } = render(<HistoryView controller={mockController} items={itemsWithCost} />)
expect(lastFrame()).toContain("Cost:")
expect(lastFrame()).toContain("0.0025")
})
it("should display model ID when available", () => {
const itemsWithModel = [{ id: "task-1", ts: Date.now(), task: "Task", modelId: "claude-sonnet-4-20250514" }]
const { lastFrame } = render(<HistoryView controller={mockController} items={itemsWithModel} />)
expect(lastFrame()).toContain("Model:")
expect(lastFrame()).toContain("claude-sonnet-4-20250514")
})
it("should truncate long task descriptions", () => {
const longTask = "x".repeat(100)
const itemsWithLongTask = [{ id: "task-1", ts: Date.now(), task: longTask }]
const { lastFrame } = render(<HistoryView controller={mockController} items={itemsWithLongTask} />)
expect(lastFrame()).toContain("...")
})
it("should handle missing task text", () => {
const itemsWithoutTask = [{ id: "task-1", ts: Date.now() }]
const { lastFrame } = render(<HistoryView controller={mockController} items={itemsWithoutTask} />)
expect(lastFrame()).toContain("Unknown task")
})
})
describe("selection indicator", () => {
it("should show selection indicator on first item by default", () => {
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} />)
expect(lastFrame()).toContain(">")
})
})
describe("keyboard navigation", () => {
it("should navigate down with arrow key", () => {
const { lastFrame, stdin } = render(<HistoryView controller={mockController} items={mockItems} />)
// Press down arrow
stdin.write("\x1B[B")
// Should still render properly
expect(lastFrame()).toContain("Task History")
})
it("should navigate up with arrow key", () => {
const { lastFrame, stdin } = render(<HistoryView controller={mockController} items={mockItems} />)
// Press down then up
stdin.write("\x1B[B")
stdin.write("\x1B[A")
expect(lastFrame()).toContain("Task History")
})
it("should not go below last item", () => {
const { lastFrame, stdin } = render(<HistoryView controller={mockController} items={mockItems} />)
// Press down many times
for (let i = 0; i < 10; i++) {
stdin.write("\x1B[B")
}
expect(lastFrame()).toContain("Task History")
})
it("should not go above first item", () => {
const { lastFrame, stdin } = render(<HistoryView controller={mockController} items={mockItems} />)
// Press up when already at first
stdin.write("\x1B[A")
expect(lastFrame()).toContain("Task History")
})
})
describe("pagination", () => {
it("should show pagination info when provided", () => {
const pagination = {
page: 2,
totalPages: 5,
totalCount: 50,
limit: 10,
}
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} pagination={pagination} />)
expect(lastFrame()).toContain("Page 2 of 5")
})
it("should show correct total count from pagination", () => {
const pagination = {
page: 1,
totalPages: 3,
totalCount: 25,
limit: 10,
}
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} pagination={pagination} />)
expect(lastFrame()).toContain("25 total")
})
it("should not show page info for single page", () => {
const pagination = {
page: 1,
totalPages: 1,
totalCount: 3,
limit: 10,
}
const { lastFrame } = render(<HistoryView controller={mockController} items={mockItems} pagination={pagination} />)
expect(lastFrame()).not.toContain("Page 1 of 1")
})
})
describe("scrolling", () => {
it("should show scroll indicators for long lists", () => {
const manyItems = Array.from({ length: 20 }, (_, i) => ({
id: `task-${i}`,
ts: Date.now() - i * 3600000,
task: `Task ${i}`,
}))
const { lastFrame, stdin } = render(<HistoryView controller={mockController} items={manyItems} visibleCount={5} />)
// Navigate down a bit
for (let i = 0; i < 5; i++) {
stdin.write("\x1B[B")
}
// Should show "more below" indicator
expect(lastFrame()).toContain("more")
})
})
})
+207
View File
@@ -0,0 +1,207 @@
/**
* 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
@@ -0,0 +1,34 @@
/**
* Message list component
* Renders all messages from the task
*/
import { Box } from "ink"
import React from "react"
import { useTaskState } from "../context/TaskContext"
import { MessageRow } from "./MessageRow"
interface MessageListProps {
verbose?: boolean
}
export const MessageList: React.FC<MessageListProps> = ({ verbose = false }) => {
const state = useTaskState()
const messages = state.clineMessages || []
// Filter out some noisy messages when not verbose
const messagesToShow = verbose
? messages
: messages.filter((m) => {
// Show everything in non-verbose mode for now
return true
})
return (
<Box flexDirection="column">
{messagesToShow.map((message, idx) => (
<MessageRow key={`${message.ts}-${idx}`} message={message} verbose={verbose} />
))}
</Box>
)
}
+378
View File
@@ -0,0 +1,378 @@
/**
* Individual message row component
* Renders a single ClineMessage based on its type
*/
import type { ClineAsk, ClineMessage, ClineSay } from "@shared/ExtensionMessage"
import { Box, Text } from "ink"
import React from "react"
import { jsonParseSafe } from "../utils/parser"
import { DiffView } from "./DiffView"
interface MessageRowProps {
message: ClineMessage
verbose?: boolean
}
/**
* Get emoji icon for message type
*/
export function getCliMessagePrefixIcon(message: ClineMessage): string {
if (message.type === "ask") {
switch (message.ask) {
case "followup":
return "❓"
case "command":
case "command_output":
return "⚙️"
case "tool":
return "🔧"
case "completion_result":
return "✅"
case "api_req_failed":
return "❌"
case "resume_task":
case "resume_completed_task":
return "▶️"
case "browser_action_launch":
return "🌐"
case "use_mcp_server":
return "🔌"
case "plan_mode_respond":
return "📋"
default:
return "❔"
}
} else {
switch (message.say) {
case "task":
return "📋"
case "error":
return "❌"
case "text":
return "💬"
case "reasoning":
return "🧠"
case "completion_result":
return "✅"
case "user_feedback":
return "👤"
case "command":
case "command_output":
return "⚙️"
case "tool":
return "🔧"
case "browser_action":
case "browser_action_launch":
case "browser_action_result":
return "🌐"
case "mcp_server_request_started":
case "mcp_server_response":
return "🔌"
case "api_req_started":
case "api_req_finished":
return "🔄"
case "checkpoint_created":
return "💾"
case "info":
return "️"
case "generate_explanation":
return "📝"
default:
return " "
}
}
}
/**
* Format timestamp
*/
function formatTimestamp(ts: number): string {
const date = new Date(ts)
return date.toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})
}
/**
* Render ask message based on type
*/
const AskMessageContent: React.FC<{ message: ClineMessage; verbose?: boolean }> = ({ message, verbose }) => {
const ask = message.ask as ClineAsk
const text = message.text || ""
switch (ask) {
case "followup":
case "plan_mode_respond": {
const parts = jsonParseSafe(text, {
response: undefined as string | undefined,
question: undefined as string | undefined,
})
if (parts.question) {
return (
<Text>
<Text color="cyan">Question:</Text> {parts.question}
</Text>
)
}
if (parts.response) {
return (
<Text>
<Text color="cyan">[{ask}]</Text> {parts.response}
</Text>
)
}
return null
}
case "command":
return (
<Text>
<Text color="magenta">Execute command?</Text> <Text dimColor>{text}</Text>
</Text>
)
case "tool":
return (
<Text>
<Text color="blue">Use tool?</Text> {text}
</Text>
)
case "completion_result":
return (
<Text>
<Text color="green">Task completed</Text> {text ? `- ${text}` : ""}
</Text>
)
case "api_req_failed":
return (
<Text>
<Text color="red">API request failed</Text> {text}
</Text>
)
case "resume_task":
case "resume_completed_task":
return (
<Text>
<Text color="cyan">Resume task?</Text> {text}
</Text>
)
case "browser_action_launch":
return (
<Text>
<Text color="cyan">Launch browser?</Text> {text}
</Text>
)
case "use_mcp_server":
return (
<Text>
<Text color="cyan">Use MCP server?</Text> {text}
</Text>
)
default:
return verbose ? (
<Text>
<Text color="gray">[ASK:{ask}]</Text> {text}
</Text>
) : null
}
}
/**
* Render say message based on type
*/
const SayMessageContent: React.FC<{ message: ClineMessage; verbose?: boolean }> = ({ message, verbose }) => {
const say = message.say as ClineSay
const text = message.text || ""
switch (say) {
case "task":
return (
<Text bold>
<Text color="white">Task:</Text> {text}
</Text>
)
case "text":
return <Text>{text}</Text>
case "reasoning":
return (
<Text color="yellow">
<Text italic>{text}</Text>
</Text>
)
case "error":
return (
<Text color="red">
<Text bold>Error:</Text> {text}
</Text>
)
case "completion_result":
return (
<Text color="green">
<Text bold>Completed:</Text> {text}
</Text>
)
case "user_feedback":
return (
<Text>
<Text color="green">User:</Text> {text}
</Text>
)
case "command":
return (
<Text>
<Text color="magenta">Command:</Text> <Text dimColor>{text}</Text>
</Text>
)
case "command_output": {
const lines = text.split("\n")
const displayLines = lines.slice(0, 10)
return (
<Box flexDirection="column">
<Text dimColor>Output:</Text>
{displayLines.map((line, idx) => (
<Text dimColor key={idx}>
{line}
</Text>
))}
{lines.length > 10 && <Text dimColor> ... and {lines.length - 10} more lines</Text>}
</Box>
)
}
case "tool": {
const { tool, content, path } = jsonParseSafe(text, {
tool: undefined as string | undefined,
content: undefined as string | undefined,
path: undefined as string | undefined,
diff: undefined as string | undefined,
})
if (path) {
if (tool === "newFileCreated") {
return <DiffView content={content} path={path} />
}
// if (tool === "editedExistingFile") {
// return <DiffView diff={diff} path={path} />
// }
}
return (
<Text>
<Text color="blue">{text}</Text>
</Text>
)
}
case "api_req_started": {
const { cost, tokensOut, cacheWrites, cacheReads, tokensIn } = jsonParseSafe(text, {
cost: 0 as number,
tokensIn: 0 as number,
tokensOut: 0 as number,
cacheWrites: 0 as number,
cacheReads: 0 as number,
})
return verbose ? (
<Text dimColor>{text}</Text>
) : (
<Text dimColor>
Cost: {cost} | Tokens In: {tokensIn} | Tokens Out: {tokensOut} | Cache Writes: {cacheWrites} | Cache Reads:{" "}
{cacheReads}
</Text>
)
}
case "api_req_finished":
return null
case "checkpoint_created":
return <Text dimColor>Checkpoint created: {message.lastCheckpointHash}</Text>
case "info":
return <Text color="cyan">{text}</Text>
case "browser_action":
case "browser_action_launch":
return (
<Text>
<Text color="cyan">Browser:</Text> {text}
</Text>
)
case "browser_action_result":
return <Text dimColor>Browser result {text ? `- ${text.substring(0, 100)}...` : ""}</Text>
case "mcp_server_request_started":
return <Text color="cyan">MCP request started {text}</Text>
case "mcp_server_response":
return <Text color="cyan">MCP response {text ? text.substring(0, 200) : ""}</Text>
default:
return verbose ? (
<Text dimColor>
[SAY:{say}] {text}
</Text>
) : null
}
}
export const MessageRow: React.FC<MessageRowProps> = ({ message, verbose = false }) => {
const icon = getCliMessagePrefixIcon(message)
const timestamp = formatTimestamp(message.ts)
// Don't render silent messages
if (message.say === "api_req_finished") {
return null
}
if (message.say === "text" && message.text?.trim() === "") {
return null
}
const content =
message.type === "ask" ? (
<AskMessageContent message={message} verbose={verbose} />
) : (
<SayMessageContent message={message} verbose={verbose} />
)
// command_output and tool return a Box, which can't be nested inside Text
if (message.say === "command_output" || message.say === "tool") {
return (
<Box flexDirection="column">
<Box>
<Text dimColor>{timestamp} </Text>
<Text>{icon} </Text>
</Box>
{content}
</Box>
)
}
return (
<Box flexDirection="column">
<Box>
<Text dimColor>{timestamp} </Text>
<Text>{icon} </Text>
{content}
</Box>
</Box>
)
}
+23
View File
@@ -0,0 +1,23 @@
/**
* Loading spinner component using ink-spinner
*/
import { Box, Text } from "ink"
import Spinner from "ink-spinner"
import React from "react"
interface LoadingSpinnerProps {
mode?: "act" | "plan"
}
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({ mode = "act" }) => {
const message = mode === "plan" ? "Planning" : "Thinking"
return (
<Box>
<Text color="cyan">
<Spinner type="dots" />
</Text>
<Text color="cyan"> {message}...</Text>
</Box>
)
}
+107
View File
@@ -0,0 +1,107 @@
/**
* Status bar component
* Shows git branch, model, context window usage, token count, and cost
*/
import { execSync } from "child_process"
import { Box, Text } from "ink"
import React, { useEffect, useState } from "react"
interface StatusBarProps {
modelId: string
tokensIn?: number
tokensOut?: number
totalCost?: number
contextWindowSize?: number
cwd?: string
}
/**
* Get current git branch name
*/
function getGitBranch(cwd?: string): string | null {
try {
const branch = execSync("git rev-parse --abbrev-ref HEAD", {
cwd: cwd || process.cwd(),
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
}).trim()
return branch
} catch {
return null
}
}
/**
* Get directory basename
*/
function getDirName(cwd?: string): string {
const path = cwd || process.cwd()
return path.split("/").pop() || path
}
/**
* Format number with commas
*/
function formatNumber(num: number): string {
return num.toLocaleString()
}
/**
* Create a progress bar for context window usage
*/
function createContextBar(used: number, total: number, width: number = 8): string {
const ratio = Math.min(used / total, 1)
const filled = Math.round(ratio * width)
const empty = width - filled
return "█".repeat(filled) + "░".repeat(empty)
}
export const StatusBar: React.FC<StatusBarProps> = ({
modelId,
tokensIn = 0,
tokensOut = 0,
totalCost = 0,
contextWindowSize = 200000, // Default Claude context window
cwd,
}) => {
const [branch, setBranch] = useState<string | null>(null)
const dirName = getDirName(cwd)
useEffect(() => {
setBranch(getGitBranch(cwd))
}, [cwd])
const totalTokens = tokensIn + tokensOut
const contextBar = createContextBar(totalTokens, contextWindowSize)
// Format model ID for display (shorten if needed)
const displayModel = modelId.length > 20 ? modelId.substring(0, 17) + "..." : modelId
return (
<Box flexDirection="column">
<Box gap={1}>
{/* Directory and branch */}
<Text color="gray">
{dirName}
{branch && (
<Text color="gray">
{" "}
(<Text color="cyan">{branch}</Text>)
</Text>
)}
</Text>
<Text color="gray">|</Text>
{/* Model and context bar */}
<Text color="white">{displayModel}</Text>
<Text color="blue">{contextBar}</Text>
<Text color="gray">({formatNumber(totalTokens)})</Text>
<Text color="gray">|</Text>
{/* Cost */}
<Text color="green">${totalCost.toFixed(4)}</Text>
</Box>
</Box>
)
}
+125
View File
@@ -0,0 +1,125 @@
/**
* JSON Task view component
* Outputs task messages as JSON instead of rich styled text
*/
import { Box } from "ink"
import React, { useEffect, useRef } from "react"
import { useTaskContext, useTaskState } from "../context/TaskContext"
import { useCompletionSignals } from "../hooks/useStateSubscriber"
import { originalConsoleLog } from "../utils/console"
interface TaskJsonViewProps {
taskId?: string
verbose?: boolean
onComplete?: () => void
onError?: () => void
}
/**
* Output a JSON line to stdout
*/
function outputJson(data: object) {
originalConsoleLog(JSON.stringify(data))
}
export const TaskJsonView: React.FC<TaskJsonViewProps> = ({ taskId: _taskId, verbose = false, onComplete, onError }) => {
const state = useTaskState()
const { isTaskComplete, getCompletionMessage } = useCompletionSignals()
const { setIsComplete } = useTaskContext()
// Track outputted messages by timestamp (don't re-output on updates)
const outputtedMessages = useRef<Set<number>>(new Set())
const hasOutputtedCompletion = useRef(false)
// Determine the role for a message
const getRole = (message: { type: string; ask?: string; say?: string }, index: number): "user" | "assistant" | "system" => {
// User feedback messages
if (message.say === "user_feedback" || message.say === "user_feedback_diff") {
return "user"
}
// First text message is the user's task
if (message.say === "text" && index === 0) {
return "user"
}
// System messages
if (message.say === "api_req_started" || message.say === "api_req_finished") {
return "system"
}
// Default: assistant
return "assistant"
}
// Output messages as JSON when they arrive
useEffect(() => {
const messages = state.clineMessages || []
for (let i = 0; i < messages.length; i++) {
const message = messages[i]
// Skip partial messages - wait for complete message
if (message.partial) {
continue
}
// Skip if we already outputted this timestamp
if (outputtedMessages.current.has(message.ts)) {
continue
}
// Filter out noisy messages in non-verbose mode
if (!verbose) {
if (message.say === "api_req_started" || message.say === "api_req_finished") {
outputtedMessages.current.add(message.ts)
continue
}
}
const role = getRole(message, i)
// Output the message as JSON
outputJson({
type: "message",
timestamp: message.ts,
role,
messageType: message.type,
...(message.ask && { ask: message.ask }),
...(message.say && { say: message.say }),
...(message.text && { text: message.text }),
...(message.reasoning && { reasoning: message.reasoning }),
...(message.images && message.images.length > 0 && { images: message.images }),
...(message.files && message.files.length > 0 && { files: message.files }),
})
outputtedMessages.current.add(message.ts)
}
}, [state.clineMessages, verbose])
// Handle task completion
useEffect(() => {
if (isTaskComplete() && !hasOutputtedCompletion.current) {
hasOutputtedCompletion.current = true
setIsComplete(true)
const completionMsg = getCompletionMessage()
const isError = completionMsg?.say === "error" || completionMsg?.ask === "api_req_failed"
// Output completion status
outputJson({
type: "completion",
status: isError ? "error" : "success",
timestamp: Date.now(),
})
if (isError) {
onError?.()
} else {
onComplete?.()
}
// Don't exit automatically - let the parent handle cleanup
}
}, [isTaskComplete, setIsComplete, onComplete, onError, getCompletionMessage])
// Render nothing visible - all output goes to stdout as JSON
return <Box />
}
+188
View File
@@ -0,0 +1,188 @@
/**
* 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
@@ -0,0 +1,175 @@
/**
* 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
@@ -0,0 +1,328 @@
/**
* Welcome view component
* Shows an interactive prompt when user starts cline without a command
* Supports file mentions with @
*/
import { Box, Text, useInput } from "ink"
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { StateManager } from "@/core/storage/StateManager"
import { getProviderDefaultModelId, Mode } from "@/shared/storage"
import { useStdinContext } from "../context/StdinContext"
import {
checkAndWarnRipgrepMissing,
extractMentionQuery,
type FileSearchResult,
getRipgrepInstallInstructions,
insertMention,
searchWorkspaceFiles,
} from "../utils/file-search"
import { 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
@@ -0,0 +1,36 @@
/**
* Context for tracking stdin raw mode support
* Used to conditionally disable input handling when stdin doesn't support raw mode
* (e.g., when input is piped: echo "..." | clinedev)
*/
import React, { createContext, type ReactNode, useContext } from "react"
interface StdinContextValue {
/**
* Whether stdin supports raw mode (keyboard input handling)
* Will be false when input is piped or stdin is not a TTY
*/
isRawModeSupported: boolean
}
const StdinContext = createContext<StdinContextValue>({ isRawModeSupported: true })
export const useStdinContext = () => useContext(StdinContext)
interface StdinProviderProps {
children: ReactNode
isRawModeSupported: boolean
}
export const StdinProvider: React.FC<StdinProviderProps> = ({ children, isRawModeSupported }) => {
return <StdinContext.Provider value={{ isRawModeSupported }}>{children}</StdinContext.Provider>
}
/**
* Check if stdin supports raw mode
* Returns false when input is piped or stdin is not a TTY
*/
export function checkRawModeSupport(): boolean {
return Boolean(process.stdin.isTTY && typeof process.stdin.setRawMode === "function")
}
+124
View File
@@ -0,0 +1,124 @@
/**
* React Context for task state management in CLI
* Provides access to ExtensionState and task controller
*/
import { registerPartialMessageCallback } from "@core/controller/ui/subscribeToPartialMessage"
import type { ClineMessage, ExtensionState } from "@shared/ExtensionMessage"
import { convertProtoToClineMessage } from "@shared/proto-conversions/cline-message"
import React, { createContext, ReactNode, useContext, useEffect, useRef, useState } from "react"
interface TaskContextType {
state: Partial<ExtensionState>
controller: any
isComplete: boolean
setIsComplete: (complete: boolean) => void
lastError: string | null
setLastError: (error: string | null) => void
}
const TaskContext = createContext<TaskContextType | undefined>(undefined)
interface TaskContextProviderProps {
controller: any
children: ReactNode
}
export const TaskContextProvider: React.FC<TaskContextProviderProps> = ({ controller, children }) => {
const [state, setState] = useState<Partial<ExtensionState>>(
() =>
({
clineMessages: [],
currentTaskItem: null,
}) as unknown as Partial<ExtensionState>,
)
const [isComplete, setIsComplete] = useState(false)
const [lastError, setLastError] = useState<string | null>(null)
// Use ref to track latest state for partial message callback
const stateRef = useRef(state)
stateRef.current = state
// Subscribe to controller state updates
useEffect(() => {
const originalPostState = controller.postStateToWebview.bind(controller)
const handleStateUpdate = async () => {
try {
const newState = await controller.getStateToPostToWebview()
setState(newState)
} catch (error) {
setLastError(error instanceof Error ? error.message : String(error))
}
}
// Override postStateToWebview to update React state
controller.postStateToWebview = async () => {
await originalPostState()
await handleStateUpdate()
}
// Subscribe to partial message events (for streaming updates)
const unsubscribePartial = registerPartialMessageCallback((protoMessage) => {
const updatedMessage = convertProtoToClineMessage(protoMessage) as ClineMessage
setState((prevState) => {
const messages = prevState.clineMessages || []
// Find and update the message by timestamp
const index = messages.findIndex((m) => m.ts === updatedMessage.ts)
if (index >= 0) {
const newMessages = [...messages]
newMessages[index] = updatedMessage
return { ...prevState, clineMessages: newMessages }
}
return prevState
})
})
// Get initial state
handleStateUpdate()
// Cleanup
return () => {
controller.postStateToWebview = originalPostState
unsubscribePartial()
}
}, [controller])
const value: TaskContextType = {
state,
controller,
isComplete,
setIsComplete,
lastError,
setLastError,
}
return <TaskContext.Provider value={value}>{children}</TaskContext.Provider>
}
/**
* Hook to access task context
*/
export const useTaskContext = (): TaskContextType => {
const context = useContext(TaskContext)
if (!context) {
throw new Error("useTaskContext must be used within TaskContextProvider")
}
return context
}
/**
* Hook to access task state only
*/
export const useTaskState = (): Partial<ExtensionState> => {
const { state } = useTaskContext()
return state
}
/**
* Hook to access controller
*/
export const useTaskController = () => {
const { controller } = useTaskContext()
return controller
}
@@ -0,0 +1,91 @@
/**
* CLI-specific CommentReviewController implementation
* Handles code review comments in CLI mode
*/
import { CommentReviewController, type OnReplyCallback, type ReviewComment } from "@/integrations/editor/CommentReviewController"
import { print, style } from "../utils/display"
export class CliCommentReviewController extends CommentReviewController {
private comments: Map<string, string[]> = new Map()
private streamingComment: { filePath: string; startLine: number; endLine: number; content: string } | null = null
setOnReplyCallback(_callback: OnReplyCallback): void {
// No-op - CLI doesn't support interactive replies
}
async ensureCommentsViewDisabled(): Promise<void> {
// No-op - no comments view in CLI
}
addReviewComment(comment: ReviewComment): void {
const key = `${comment.filePath}:${comment.startLine}:${comment.endLine}`
const existing = this.comments.get(key) || []
existing.push(comment.comment)
this.comments.set(key, existing)
print(style.info(`Comment on ${comment.filePath}:${comment.startLine + 1}`))
print(style.dim(` ${comment.comment}`))
}
startStreamingComment(
filePath: string,
startLine: number,
endLine: number,
_relativePath?: string,
_fileContent?: string,
_revealComment?: boolean,
): void {
this.streamingComment = { filePath, startLine, endLine, content: "" }
print(style.info(`Comment on ${filePath}:${startLine + 1}`))
}
appendToStreamingComment(chunk: string): void {
if (this.streamingComment) {
this.streamingComment.content += chunk
process.stdout.write(chunk)
}
}
endStreamingComment(): void {
if (this.streamingComment) {
const key = `${this.streamingComment.filePath}:${this.streamingComment.startLine}:${this.streamingComment.endLine}`
const existing = this.comments.get(key) || []
existing.push(this.streamingComment.content)
this.comments.set(key, existing)
print("") // newline after streaming
this.streamingComment = null
}
}
addReviewComments(comments: ReviewComment[]): void {
for (const comment of comments) {
this.addReviewComment(comment)
}
}
clearAllComments(): void {
this.comments.clear()
}
clearCommentsForFile(filePath: string): void {
for (const key of this.comments.keys()) {
if (key.startsWith(filePath)) {
this.comments.delete(key)
}
}
}
getThreadCount(): number {
return this.comments.size
}
async closeDiffViews(): Promise<void> {
// No-op - no diff views in CLI
}
dispose(): void {
this.comments.clear()
this.streamingComment = null
}
}
@@ -0,0 +1,27 @@
/**
* CLI-specific WebviewProvider implementation
* Instead of rendering to a webview, this outputs to the terminal
*/
import type * as vscode from "vscode"
import { WebviewProvider } from "@/core/webview"
export class CliWebviewProvider extends WebviewProvider {
constructor(context: vscode.ExtensionContext) {
super(context)
}
override getWebviewUrl(path: string): string {
// CLI doesn't have webview URLs
return `file://${path}`
}
override getCspSource(): string {
return "'self'"
}
override isVisible(): boolean {
// CLI is always "visible"
return true
}
}
+283
View File
@@ -0,0 +1,283 @@
/**
* CLI-specific Host Bridge implementations
* These provide stub implementations for the host bridge interfaces that work in CLI mode
*/
import type {
DiffServiceClientInterface,
EnvServiceClientInterface,
WindowServiceClientInterface,
WorkspaceServiceClientInterface,
} from "@generated/hosts/host-bridge-client-types"
import type { HostBridgeClientProvider, StreamingCallbacks } from "@hosts/host-provider-types"
import * as proto from "@shared/proto/index"
import { ClineClient } from "@/shared/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
@@ -0,0 +1,158 @@
/**
* Custom hook to subscribe to controller state updates
* Handles the diff/merge logic for streaming text and message tracking
*/
import type { ClineMessage } from "@shared/ExtensionMessage"
import { useCallback, useRef } from "react"
import { useTaskContext } from "../context/TaskContext"
interface ProcessedState {
processedAskMessages: Set<number>
processedSayMessages: Set<number>
}
/**
* Hook to track which ask/say messages have been processed
* This prevents duplicate prompts for the same ask message
*/
export const useProcessedMessages = () => {
const processedRef = useRef<ProcessedState>({
processedAskMessages: new Set(),
processedSayMessages: new Set(),
})
return processedRef.current
}
/**
* Detect if a message has just been completed (is asking for user input)
*/
export const useCompletedAskMessages = () => {
const { state, controller } = useTaskContext()
const processed = useProcessedMessages()
const getCompletedAskMessages = useCallback(() => {
const completedAsks: ClineMessage[] = []
if (!state.clineMessages) {
return completedAsks
}
for (let i = 0; i < state.clineMessages.length; i++) {
const message = state.clineMessages[i]
if (message.type === "ask" && !message.partial && !processed.processedAskMessages.has(i)) {
completedAsks.push(message)
processed.processedAskMessages.add(i)
}
}
return completedAsks
}, [state.clineMessages, processed])
return getCompletedAskMessages
}
/**
* Get the last completed ask message (for rendering current input prompt)
*/
export const useLastCompletedAskMessage = () => {
const { state } = useTaskContext()
const processed = useProcessedMessages()
const getLastCompletedAskMessage = useCallback((): ClineMessage | null => {
if (!state.clineMessages) {
return null
}
// Find the last ask message that is complete
for (let i = state.clineMessages.length - 1; i >= 0; i--) {
const message = state.clineMessages[i]
if (message.type === "ask" && !message.partial) {
return message
}
}
return null
}, [state.clineMessages])
return getLastCompletedAskMessage()
}
/**
* Get messages that should trigger the completion detection
*/
export const useCompletionSignals = () => {
const { state } = useTaskContext()
const isTaskComplete = useCallback((): boolean => {
if (!state.clineMessages || state.clineMessages.length === 0) {
return false
}
const lastMessage = state.clineMessages[state.clineMessages.length - 1]
if (!lastMessage) {
return false
}
// Check for completion signals
if (lastMessage.say === "completion_result" || lastMessage.ask === "completion_result") {
return true
}
// Check for error signals
if (lastMessage.say === "error" || lastMessage.ask === "api_req_failed") {
return true
}
return false
}, [state.clineMessages])
const getCompletionMessage = useCallback((): ClineMessage | null => {
if (!state.clineMessages || state.clineMessages.length === 0) {
return null
}
return state.clineMessages[state.clineMessages.length - 1] || null
}, [state.clineMessages])
return {
isTaskComplete,
getCompletionMessage,
}
}
/**
* Check if spinner should be shown (when API is thinking)
*/
export const useIsSpinnerActive = (): boolean => {
const { state } = useTaskContext()
if (!state.clineMessages || state.clineMessages.length === 0) {
return false
}
// If the last message is a completed ask message, don't show spinner (waiting for user input)
const lastMessage = state.clineMessages[state.clineMessages.length - 1]
if (lastMessage?.type === "ask" && !lastMessage.partial) {
return false
}
// Look for most recent api_req_started that isn't followed by api_req_finished
for (let i = state.clineMessages.length - 1; i >= 0; i--) {
const msg = state.clineMessages[i]
if (msg.say === "api_req_started") {
// Check if there's an api_req_finished after this
let hasFinished = false
for (let j = i + 1; j < state.clineMessages.length; j++) {
if (state.clineMessages[j].say === "api_req_finished") {
hasFinished = true
break
}
}
return !hasFinished
}
}
return false
}
+372
View File
@@ -0,0 +1,372 @@
import { Command } from "commander"
import { beforeEach, describe, expect, it } from "vitest"
/**
* Tests for CLI command parsing and structure
* These tests verify the commander.js command definitions without
* actually running the commands (which would require full infrastructure)
*/
describe("CLI Commands", () => {
let program: Command
beforeEach(() => {
// Create a fresh program instance for each test
program = new Command()
program.name("cline").description("Cline CLI - AI coding assistant").version("0.0.0")
program.enablePositionalOptions()
// Define commands matching index.ts
program
.command("task")
.alias("t")
.description("Run a new task")
.argument("<prompt>", "The task prompt")
.option("-a, --act", "Run in act mode")
.option("-p, --plan", "Run in plan mode")
.option("-y, --yolo", "Enable yolo mode")
.option("-m, --model <model>", "Model to use")
.option("-i, --images <paths...>", "Image file paths")
.option("-v, --verbose", "Show verbose output")
.option("-c, --cwd <path>", "Working directory")
.option("--config <path>", "Configuration directory")
.option("--thinking", "Enable extended thinking")
.action(() => {})
program
.command("history")
.alias("h")
.description("List task history")
.option("-n, --limit <number>", "Number of tasks to show", "10")
.option("-p, --page <number>", "Page number", "1")
.option("--config <path>", "Configuration directory")
.action(() => {})
program
.command("config")
.description("Show current configuration")
.option("--config <path>", "Configuration directory")
.action(() => {})
program
.command("auth")
.description("Authenticate a provider")
.option("-p, --provider <id>", "Provider ID")
.option("-k, --apikey <key>", "API key")
.option("-m, --modelid <id>", "Model ID")
.option("-b, --baseurl <url>", "Base URL")
.option("-v, --verbose", "Verbose output")
.option("-c, --cwd <path>", "Working directory")
.option("--config <path>", "Configuration directory")
.action(() => {})
// Default command for interactive mode
program
.argument("[prompt]", "Task prompt")
.option("-i, --images <paths...>", "Image file paths")
.option("-v, --verbose", "Verbose output")
.option("-c, --cwd <path>", "Working directory")
.option("--config <path>", "Configuration directory")
.option("--thinking", "Enable extended thinking")
.action(() => {})
})
describe("task command", () => {
it("should parse task command with prompt", () => {
const args = ["node", "cli", "task", "write hello world"]
program.parse(args)
// Command should be parsed without error
})
it("should parse task alias", () => {
const args = ["node", "cli", "t", "write hello world"]
program.parse(args)
})
it("should parse --act flag", () => {
const taskCmd = program.commands.find((c) => c.name() === "task")!
const args = ["test prompt", "--act"]
taskCmd.parse(args, { from: "user" })
expect(taskCmd.opts().act).toBe(true)
})
it("should parse --plan flag", () => {
const taskCmd = program.commands.find((c) => c.name() === "task")!
const args = ["test prompt", "--plan"]
taskCmd.parse(args, { from: "user" })
expect(taskCmd.opts().plan).toBe(true)
})
it("should parse --yolo flag", () => {
const taskCmd = program.commands.find((c) => c.name() === "task")!
const args = ["test prompt", "--yolo"]
taskCmd.parse(args, { from: "user" })
expect(taskCmd.opts().yolo).toBe(true)
})
it("should parse --model option", () => {
const taskCmd = program.commands.find((c) => c.name() === "task")!
const args = ["test prompt", "--model", "claude-sonnet-4-20250514"]
taskCmd.parse(args, { from: "user" })
expect(taskCmd.opts().model).toBe("claude-sonnet-4-20250514")
})
it("should parse --images option with multiple paths", () => {
const taskCmd = program.commands.find((c) => c.name() === "task")!
const args = ["test prompt", "--images", "/path/to/img1.png", "/path/to/img2.jpg"]
taskCmd.parse(args, { from: "user" })
expect(taskCmd.opts().images).toEqual(["/path/to/img1.png", "/path/to/img2.jpg"])
})
it("should parse --verbose flag", () => {
const taskCmd = program.commands.find((c) => c.name() === "task")!
const args = ["test prompt", "--verbose"]
taskCmd.parse(args, { from: "user" })
expect(taskCmd.opts().verbose).toBe(true)
})
it("should parse --cwd option", () => {
const taskCmd = program.commands.find((c) => c.name() === "task")!
const args = ["test prompt", "--cwd", "/some/path"]
taskCmd.parse(args, { from: "user" })
expect(taskCmd.opts().cwd).toBe("/some/path")
})
it("should parse --config option", () => {
const taskCmd = program.commands.find((c) => c.name() === "task")!
const args = ["test prompt", "--config", "/custom/config"]
taskCmd.parse(args, { from: "user" })
expect(taskCmd.opts().config).toBe("/custom/config")
})
it("should parse --thinking flag", () => {
const taskCmd = program.commands.find((c) => c.name() === "task")!
const args = ["test prompt", "--thinking"]
taskCmd.parse(args, { from: "user" })
expect(taskCmd.opts().thinking).toBe(true)
})
it("should parse short flags", () => {
const taskCmd = program.commands.find((c) => c.name() === "task")!
const args = ["test prompt", "-a", "-v", "-m", "gpt-4"]
taskCmd.parse(args, { from: "user" })
expect(taskCmd.opts().act).toBe(true)
expect(taskCmd.opts().verbose).toBe(true)
expect(taskCmd.opts().model).toBe("gpt-4")
})
})
describe("history command", () => {
it("should have default limit of 10", () => {
const historyCmd = program.commands.find((c) => c.name() === "history")!
historyCmd.parse([], { from: "user" })
expect(historyCmd.opts().limit).toBe("10")
})
it("should have default page of 1", () => {
const historyCmd = program.commands.find((c) => c.name() === "history")!
historyCmd.parse([], { from: "user" })
expect(historyCmd.opts().page).toBe("1")
})
it("should parse --limit option", () => {
const historyCmd = program.commands.find((c) => c.name() === "history")!
const args = ["--limit", "20"]
historyCmd.parse(args, { from: "user" })
expect(historyCmd.opts().limit).toBe("20")
})
it("should parse --page option", () => {
const historyCmd = program.commands.find((c) => c.name() === "history")!
const args = ["--page", "3"]
historyCmd.parse(args, { from: "user" })
expect(historyCmd.opts().page).toBe("3")
})
it("should parse history alias", () => {
const args = ["node", "cli", "h"]
program.parse(args)
// Alias should work
})
it("should parse short flags", () => {
const historyCmd = program.commands.find((c) => c.name() === "history")!
const args = ["-n", "5", "-p", "2"]
historyCmd.parse(args, { from: "user" })
expect(historyCmd.opts().limit).toBe("5")
expect(historyCmd.opts().page).toBe("2")
})
})
describe("config command", () => {
it("should parse config command", () => {
const args = ["node", "cli", "config"]
program.parse(args)
})
it("should parse --config option", () => {
const configCmd = program.commands.find((c) => c.name() === "config")!
const args = ["--config", "/custom/path"]
configCmd.parse(args, { from: "user" })
expect(configCmd.opts().config).toBe("/custom/path")
})
})
describe("auth command", () => {
it("should parse auth command", () => {
const args = ["node", "cli", "auth"]
program.parse(args)
})
it("should parse --provider option", () => {
const authCmd = program.commands.find((c) => c.name() === "auth")!
const args = ["--provider", "openai"]
authCmd.parse(args, { from: "user" })
expect(authCmd.opts().provider).toBe("openai")
})
it("should parse --apikey option", () => {
const authCmd = program.commands.find((c) => c.name() === "auth")!
const args = ["--apikey", "sk-test-key"]
authCmd.parse(args, { from: "user" })
expect(authCmd.opts().apikey).toBe("sk-test-key")
})
it("should parse --modelid option", () => {
const authCmd = program.commands.find((c) => c.name() === "auth")!
const args = ["--modelid", "gpt-4"]
authCmd.parse(args, { from: "user" })
expect(authCmd.opts().modelid).toBe("gpt-4")
})
it("should parse --baseurl option", () => {
const authCmd = program.commands.find((c) => c.name() === "auth")!
const args = ["--baseurl", "https://api.example.com"]
authCmd.parse(args, { from: "user" })
expect(authCmd.opts().baseurl).toBe("https://api.example.com")
})
it("should parse short flags", () => {
const authCmd = program.commands.find((c) => c.name() === "auth")!
const args = ["-p", "anthropic", "-k", "key123", "-m", "claude-sonnet-4-20250514"]
authCmd.parse(args, { from: "user" })
expect(authCmd.opts().provider).toBe("anthropic")
expect(authCmd.opts().apikey).toBe("key123")
expect(authCmd.opts().modelid).toBe("claude-sonnet-4-20250514")
})
})
describe("default command (interactive mode)", () => {
it("should parse optional prompt argument", () => {
const args = ["node", "cli", "do something"]
program.parse(args)
})
it("should parse without prompt (interactive mode)", () => {
const args = ["node", "cli"]
program.parse(args)
})
it("should parse --images option", () => {
program.parse(["node", "cli", "--images", "img.png"])
expect(program.opts().images).toEqual(["img.png"])
})
it("should parse --verbose flag", () => {
program.parse(["node", "cli", "--verbose"])
expect(program.opts().verbose).toBe(true)
})
it("should parse --thinking flag", () => {
program.parse(["node", "cli", "--thinking"])
expect(program.opts().thinking).toBe(true)
})
})
describe("command structure", () => {
it("should have all expected commands", () => {
const commandNames = program.commands.map((c) => c.name())
expect(commandNames).toContain("task")
expect(commandNames).toContain("history")
expect(commandNames).toContain("config")
expect(commandNames).toContain("auth")
})
it("should have correct aliases", () => {
const taskCmd = program.commands.find((c) => c.name() === "task")!
const historyCmd = program.commands.find((c) => c.name() === "history")!
expect(taskCmd.aliases()).toContain("t")
expect(historyCmd.aliases()).toContain("h")
})
it("should have descriptions for all commands", () => {
for (const cmd of program.commands) {
expect(cmd.description()).toBeTruthy()
}
})
})
})
describe("getProviderModelIdKey", () => {
// Test the provider model ID key mapping logic
const providerKeyMap: Record<string, string> = {
openrouter: "OpenRouterModelId",
cline: "OpenRouterModelId",
openai: "OpenAiModelId",
ollama: "OllamaModelId",
lmstudio: "LmStudioModelId",
litellm: "LiteLlmModelId",
requesty: "RequestyModelId",
together: "TogetherModelId",
fireworks: "FireworksModelId",
sapaicore: "SapAiCoreModelId",
groq: "GroqModelId",
baseten: "BasetenModelId",
huggingface: "HuggingFaceModelId",
}
function getProviderModelIdKey(provider: string, mode: "act" | "plan"): string | null {
const prefix = mode === "act" ? "actMode" : "planMode"
const keySuffix = providerKeyMap[provider]
if (keySuffix) {
return `${prefix}${keySuffix}`
}
return null
}
it("should return correct key for openrouter in act mode", () => {
expect(getProviderModelIdKey("openrouter", "act")).toBe("actModeOpenRouterModelId")
})
it("should return correct key for openrouter in plan mode", () => {
expect(getProviderModelIdKey("openrouter", "plan")).toBe("planModeOpenRouterModelId")
})
it("should return same key for cline as openrouter", () => {
expect(getProviderModelIdKey("cline", "act")).toBe("actModeOpenRouterModelId")
})
it("should return correct key for openai", () => {
expect(getProviderModelIdKey("openai", "act")).toBe("actModeOpenAiModelId")
})
it("should return correct key for ollama", () => {
expect(getProviderModelIdKey("ollama", "act")).toBe("actModeOllamaModelId")
})
it("should return null for anthropic (uses generic key)", () => {
expect(getProviderModelIdKey("anthropic", "act")).toBeNull()
})
it("should return null for gemini (uses generic key)", () => {
expect(getProviderModelIdKey("gemini", "act")).toBeNull()
})
it("should return null for bedrock (uses generic key)", () => {
expect(getProviderModelIdKey("bedrock", "act")).toBeNull()
})
it("should return null for unknown providers", () => {
expect(getProviderModelIdKey("unknown-provider", "act")).toBeNull()
})
})
+567
View File
@@ -0,0 +1,567 @@
/**
* 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
@@ -0,0 +1,2 @@
// Stub for react-devtools-core - not needed in CLI
module.exports = {}
+36
View File
@@ -0,0 +1,36 @@
/**
* Console management for CLI
*
* Captures original console methods BEFORE any core modules are imported,
* so CLI output works even when console.log is suppressed.
*/
// Capture original console methods immediately
export const originalConsoleLog = console.log.bind(console)
export const originalConsoleError = console.error.bind(console)
export const originalConsoleWarn = console.warn.bind(console)
export const originalConsoleInfo = console.info.bind(console)
export const originalConsoleDebug = console.debug.bind(console)
// Check for verbose flag early (before commander parses)
const isVerbose = process.argv.includes("-v") || process.argv.includes("--verbose")
// Suppress console output unless verbose mode
if (!isVerbose) {
console.log = () => {}
console.warn = () => {}
console.error = () => {}
console.debug = () => {}
console.info = () => {}
}
/**
* Restore original console methods (for cleanup)
*/
export function restoreConsole() {
console.log = originalConsoleLog
console.error = originalConsoleError
console.warn = originalConsoleWarn
console.info = originalConsoleInfo
console.debug = originalConsoleDebug
}
+57
View File
@@ -0,0 +1,57 @@
/**
* Query terminal cursor position before Ink mounts
* Must be called BEFORE render() to avoid escape sequence leaking into Ink's input handling
*/
export async function queryCursorPos(
stdin: NodeJS.ReadStream,
stdout: NodeJS.WriteStream,
{ timeoutMs = 75 } = {},
): Promise<{ row: number; col: number } | null> {
if (!stdin.isTTY || !stdout.isTTY || typeof (stdin as any).setRawMode !== "function") return null
const ttyIn = stdin as any as { isRaw?: boolean; setRawMode: (b: boolean) => void; on: any; off: any }
const wasRaw = !!ttyIn.isRaw
ttyIn.setRawMode(true)
stdin.resume()
return await new Promise((resolve) => {
let buf = ""
const onData = (chunk: Buffer) => {
buf += chunk.toString("utf8")
const m = buf.match(/\x1b\[(\d+);(\d+)R/)
if (!m) return
cleanup()
resolve({ row: Number(m[1]), col: Number(m[2]) })
}
const cleanup = () => {
clearTimeout(timer)
stdin.off("data", onData)
try {
ttyIn.setRawMode(wasRaw)
} catch {}
}
const timer = setTimeout(() => {
cleanup()
resolve(null)
}, timeoutMs)
stdin.on("data", onData)
stdout.write("\x1b[6n") // DSR: cursor position
})
}
/**
* Calculate where the robot will be rendered on screen
*/
export function calculateRobotTopRow(cursorPos: { row: number } | null, terminalRows: number): number {
const robotHeight = 12
const firstFrameHeight = robotHeight + 8 // robot + welcome text + margins + input + footer
const startRow = cursorPos?.row ?? 1
// If content doesn't fit below cursor, terminal scrolls
return Math.max(1, Math.min(startRow, terminalRows - firstFrameHeight + 1))
}
+418
View File
@@ -0,0 +1,418 @@
import type { ClineMessage, ExtensionState } from "@shared/ExtensionMessage"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { colorize, formatMessage, formatState, formatTimestamp, Spinner, separator, style, taskHeader } from "./display"
describe("display", () => {
describe("colorize", () => {
it("should wrap text with color codes", () => {
const result = colorize("test", "\x1b[31m")
expect(result).toBe("\x1b[31mtest\x1b[0m")
})
it("should combine multiple color codes", () => {
const result = colorize("test", "\x1b[1m", "\x1b[31m")
expect(result).toBe("\x1b[1m\x1b[31mtest\x1b[0m")
})
it("should handle empty text", () => {
const result = colorize("", "\x1b[31m")
expect(result).toBe("\x1b[31m\x1b[0m")
})
})
describe("style helpers", () => {
it("should apply bold style", () => {
const result = style.bold("text")
expect(result).toContain("text")
expect(result).toContain("\x1b[1m")
})
it("should apply dim style", () => {
const result = style.dim("text")
expect(result).toContain("text")
expect(result).toContain("\x1b[2m")
})
it("should apply error style", () => {
const result = style.error("error message")
expect(result).toContain("error message")
expect(result).toContain("\x1b[31m") // red
})
it("should apply success style", () => {
const result = style.success("success")
expect(result).toContain("success")
expect(result).toContain("\x1b[32m") // green
})
it("should apply info style", () => {
const result = style.info("info")
expect(result).toContain("info")
expect(result).toContain("\x1b[36m") // cyan
})
it("should apply warning style", () => {
const result = style.warning("warning")
expect(result).toContain("warning")
expect(result).toContain("\x1b[33m") // yellow
})
})
describe("formatTimestamp", () => {
it("should format timestamp as HH:MM:SS", () => {
// Create a known timestamp: Jan 1, 2024 15:30:45 UTC
const ts = new Date("2024-01-01T15:30:45Z").getTime()
const result = formatTimestamp(ts)
// Result depends on local timezone, but should be HH:MM:SS format
expect(result).toMatch(/^\d{2}:\d{2}:\d{2}$/)
})
it("should handle zero timestamp", () => {
const result = formatTimestamp(0)
expect(result).toMatch(/^\d{2}:\d{2}:\d{2}$/)
})
})
describe("formatMessage", () => {
const createMessage = (overrides: Partial<ClineMessage>): ClineMessage =>
({
ts: Date.now(),
type: "say",
say: "text",
text: "test message",
...overrides,
}) as ClineMessage
describe("say messages", () => {
it("should format text message", () => {
const message = createMessage({ say: "text", text: "Hello world" })
const result = formatMessage(message)
expect(result).toContain("Hello world")
})
it("should format task message", () => {
const message = createMessage({ say: "task", text: "New task" })
const result = formatMessage(message)
expect(result).toContain("Task:")
expect(result).toContain("New task")
})
it("should format error message", () => {
const message = createMessage({ say: "error", text: "Something went wrong" })
const result = formatMessage(message)
expect(result).toContain("Error:")
expect(result).toContain("Something went wrong")
})
it("should format completion_result message", () => {
const message = createMessage({ say: "completion_result", text: "Done!" })
const result = formatMessage(message)
expect(result).toContain("Completed:")
})
it("should format reasoning message", () => {
const message = createMessage({ say: "reasoning", text: "Let me think..." })
const result = formatMessage(message)
expect(result).toContain("Thinking:")
expect(result).toContain("Let me think...")
})
it("should format command message", () => {
const message = createMessage({ say: "command", text: "npm install" })
const result = formatMessage(message)
expect(result).toContain("Command:")
expect(result).toContain("npm install")
})
it("should truncate long command output", () => {
const longOutput = "x".repeat(600)
const message = createMessage({ say: "command_output", text: longOutput })
const result = formatMessage(message)
expect(result).toContain("Output:")
expect(result).toContain("...")
expect(result.length).toBeLessThan(longOutput.length + 100)
})
it("should format user_feedback message", () => {
const message = createMessage({ say: "user_feedback", text: "User said something" })
const result = formatMessage(message)
expect(result).toContain("User:")
})
it("should format tool message", () => {
const message = createMessage({ say: "tool", text: "read_file" })
const result = formatMessage(message)
expect(result).toContain("Tool:")
})
it("should format browser_action message", () => {
const message = createMessage({ say: "browser_action", text: "click button" })
const result = formatMessage(message)
expect(result).toContain("Browser:")
})
it("should format api_req_started in verbose mode", () => {
const message = createMessage({ say: "api_req_started", text: "" })
const result = formatMessage(message, true)
expect(result).toContain("API request started")
})
it("should format checkpoint_created message", () => {
const message = createMessage({ say: "checkpoint_created", text: "Saved" })
const result = formatMessage(message)
expect(result).toContain("Checkpoint created")
})
it("should format info message", () => {
const message = createMessage({ say: "info", text: "Information" })
const result = formatMessage(message)
expect(result).toContain("Information")
})
it("should show unknown say types in verbose mode", () => {
const message = createMessage({ say: "unknown_type" as any, text: "test" })
const resultNormal = formatMessage(message, false)
const resultVerbose = formatMessage(message, true)
expect(resultNormal).toBe("")
expect(resultVerbose).toContain("[SAY:unknown_type]")
})
})
describe("ask messages", () => {
it("should format followup question", () => {
const message = createMessage({
type: "ask",
ask: "followup",
text: JSON.stringify({ question: "What do you want?" }),
})
const result = formatMessage(message)
expect(result).toContain("Question:")
expect(result).toContain("What do you want?")
})
it("should handle non-JSON followup text", () => {
const message = createMessage({
type: "ask",
ask: "followup",
text: "Plain text question",
})
const result = formatMessage(message)
expect(result).toContain("Plain text question")
})
it("should format command ask", () => {
const message = createMessage({
type: "ask",
ask: "command",
text: "rm -rf /",
})
const result = formatMessage(message)
expect(result).toContain("Execute command?")
expect(result).toContain("rm -rf /")
})
it("should format tool ask", () => {
const message = createMessage({
type: "ask",
ask: "tool",
text: "write_to_file",
})
const result = formatMessage(message)
expect(result).toContain("Use tool?")
})
it("should format completion_result ask", () => {
const message = createMessage({
type: "ask",
ask: "completion_result",
text: "Task completed successfully",
})
const result = formatMessage(message)
expect(result).toContain("Task completed")
})
it("should format api_req_failed ask", () => {
const message = createMessage({
type: "ask",
ask: "api_req_failed",
text: "Rate limit exceeded",
})
const result = formatMessage(message)
expect(result).toContain("API request failed")
expect(result).toContain("Rate limit exceeded")
})
it("should format resume_task ask", () => {
const message = createMessage({
type: "ask",
ask: "resume_task",
text: "",
})
const result = formatMessage(message)
expect(result).toContain("Resume task?")
})
it("should format browser_action_launch ask", () => {
const message = createMessage({
type: "ask",
ask: "browser_action_launch",
text: "https://example.com",
})
const result = formatMessage(message)
expect(result).toContain("Launch browser?")
})
it("should format use_mcp_server ask", () => {
const message = createMessage({
type: "ask",
ask: "use_mcp_server",
text: "server-name",
})
const result = formatMessage(message)
expect(result).toContain("Use MCP server?")
})
it("should show unknown ask types in verbose mode", () => {
const message = createMessage({
type: "ask",
ask: "unknown_ask" as any,
text: "test",
})
const resultNormal = formatMessage(message, false)
const resultVerbose = formatMessage(message, true)
expect(resultNormal).toBe("")
expect(resultVerbose).toContain("[ASK:unknown_ask]")
})
})
})
describe("separator", () => {
it("should create a separator with default char and width", () => {
const result = separator()
expect(result).toContain("─".repeat(60))
})
it("should use custom character", () => {
const result = separator("=", 10)
expect(result).toContain("=".repeat(10))
})
it("should use custom width", () => {
const result = separator("-", 20)
expect(result).toContain("-".repeat(20))
})
})
describe("taskHeader", () => {
it("should format task header with ID", () => {
const result = taskHeader("task-123")
expect(result).toContain("Task: task-123")
})
it("should include task description", () => {
const result = taskHeader("task-123", "Build a website")
expect(result).toContain("task-123")
expect(result).toContain("Build a website")
})
it("should truncate long task descriptions", () => {
const longTask = "x".repeat(100)
const result = taskHeader("task-123", longTask)
expect(result).toContain("...")
})
})
describe("formatState", () => {
it("should format state with messages", () => {
const state: Partial<ExtensionState> = {
clineMessages: [{ ts: Date.now(), type: "say", say: "text", text: "Hello" } as ClineMessage],
}
const result = formatState(state as ExtensionState)
expect(result).toContain("Hello")
})
it("should include task header when currentTaskItem exists", () => {
const state: Partial<ExtensionState> = {
currentTaskItem: {
id: "task-1",
ts: Date.now(),
task: "Do something",
tokensIn: 10,
tokensOut: 20,
modelId: "gpt-4",
totalCost: 0.0025,
},
clineMessages: [],
}
const result = formatState(state as ExtensionState)
expect(result).toContain("Task: task-1")
})
it("should handle empty messages array", () => {
const state: Partial<ExtensionState> = {
clineMessages: [],
}
const result = formatState(state as ExtensionState)
expect(result).toBe("")
})
it("should handle undefined messages", () => {
const state: Partial<ExtensionState> = {}
const result = formatState(state as ExtensionState)
expect(result).toBe("")
})
})
describe("Spinner", () => {
let spinner: Spinner
let writeSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
spinner = new Spinner()
writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true)
vi.useFakeTimers()
})
afterEach(() => {
spinner.stop()
vi.restoreAllMocks()
vi.useRealTimers()
})
it("should start spinning with message", () => {
spinner.start("Loading...")
vi.advanceTimersByTime(80)
expect(writeSpy).toHaveBeenCalled()
const calls = writeSpy.mock.calls.map((c: any[]) => c[0])
expect(calls.some((c: any) => typeof c === "string" && c.includes("Loading..."))).toBe(true)
})
it("should update message", () => {
spinner.start("Initial")
spinner.update("Updated")
vi.advanceTimersByTime(80)
const calls = writeSpy.mock.calls.map((c: any[]) => c[0])
expect(calls.some((c: any) => typeof c === "string" && c.includes("Updated"))).toBe(true)
})
it("should stop with final message", () => {
spinner.start("Loading...")
spinner.stop("Done!")
const calls = writeSpy.mock.calls.map((c: any[]) => c[0])
expect(calls.some((c: any) => typeof c === "string" && c.includes("Done!"))).toBe(true)
})
it("should clear line when stopped without message", () => {
spinner.start("Loading...")
spinner.stop()
expect(writeSpy).toHaveBeenCalled()
})
it("should show failure message", () => {
spinner.start("Loading...")
spinner.fail("Failed!")
const calls = writeSpy.mock.calls.map((c: any[]) => c[0])
expect(calls.some((c: any) => typeof c === "string" && c.includes("Failed!"))).toBe(true)
})
})
})
+460
View File
@@ -0,0 +1,460 @@
/**
* Terminal display utilities for rendering Cline messages in the CLI
*/
import type { ClineAsk, ClineMessage, ClineSay, ExtensionState } from "@shared/ExtensionMessage"
import { originalConsoleError, originalConsoleLog } from "./console"
// ANSI color codes for terminal output
const colors = {
reset: "\x1b[0m",
bold: "\x1b[1m",
dim: "\x1b[2m",
italic: "\x1b[3m",
underline: "\x1b[4m",
// Foreground colors
black: "\x1b[30m",
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
magenta: "\x1b[35m",
cyan: "\x1b[36m",
white: "\x1b[37m",
// Bright foreground colors
brightBlack: "\x1b[90m",
brightRed: "\x1b[91m",
brightGreen: "\x1b[92m",
brightYellow: "\x1b[93m",
brightBlue: "\x1b[94m",
brightMagenta: "\x1b[95m",
brightCyan: "\x1b[96m",
brightWhite: "\x1b[97m",
// Background colors
bgBlack: "\x1b[40m",
bgRed: "\x1b[41m",
bgGreen: "\x1b[42m",
bgYellow: "\x1b[43m",
bgBlue: "\x1b[44m",
bgMagenta: "\x1b[45m",
bgCyan: "\x1b[46m",
bgWhite: "\x1b[47m",
}
export function colorize(text: string, ...colorCodes: string[]): string {
return colorCodes.join("") + text + colors.reset
}
// Helper functions for common color combinations
export const style = {
bold: (text: string) => colorize(text, colors.bold),
dim: (text: string) => colorize(text, colors.dim),
italic: (text: string) => colorize(text, colors.italic),
error: (text: string) => colorize(text, colors.red, colors.bold),
warning: (text: string) => colorize(text, colors.yellow),
success: (text: string) => colorize(text, colors.green),
info: (text: string) => colorize(text, colors.cyan),
// Message type colors
task: (text: string) => colorize(text, colors.brightWhite, colors.bold),
tool: (text: string) => colorize(text, colors.blue),
command: (text: string) => colorize(text, colors.magenta),
api: (text: string) => colorize(text, colors.brightBlack),
user: (text: string) => colorize(text, colors.green),
assistant: (text: string) => colorize(text, colors.cyan),
// Special formatting
path: (text: string) => colorize(text, colors.underline, colors.blue),
code: (text: string) => colorize(text, colors.bgBlack, colors.brightWhite),
}
/**
* Format a timestamp for display
*/
export function formatTimestamp(ts: number): string {
const date = new Date(ts)
return date.toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})
}
/**
* Get a prefix icon for different message types
*/
function getMessageIcon(message: ClineMessage): string {
if (message.type === "ask") {
switch (message.ask) {
case "followup":
return "❓"
case "command":
case "command_output":
return "⚙️ "
case "tool":
return "🔧"
case "completion_result":
return "✅"
case "api_req_failed":
return "❌"
case "resume_task":
case "resume_completed_task":
return "▶️ "
case "browser_action_launch":
return "🌐"
case "use_mcp_server":
return "🔌"
default:
return "❔"
}
} else {
switch (message.say) {
case "task":
return "📋"
case "error":
return "❌"
case "text":
return "💬"
case "reasoning":
return "🧠"
case "completion_result":
return "✅"
case "user_feedback":
return "👤"
case "command":
case "command_output":
return "⚙️ "
case "tool":
return "🔧"
case "browser_action":
case "browser_action_launch":
case "browser_action_result":
return "🌐"
case "mcp_server_request_started":
case "mcp_server_response":
return "🔌"
case "api_req_started":
case "api_req_finished":
return "🔄"
case "checkpoint_created":
return "💾"
case "info":
return "️ "
default:
return " "
}
}
}
/**
* Format a ClineMessage for terminal display
*/
export function formatMessage(message: ClineMessage, verbose: boolean = false): string {
const icon = getMessageIcon(message)
const timestamp = formatTimestamp(message.ts)
const lines: string[] = []
const prefix = `${style.dim(timestamp)} ${icon}`
if (message.type === "ask") {
lines.push(formatAskMessage(message, prefix, verbose))
} else {
lines.push(formatSayMessage(message, prefix, verbose))
}
return lines.filter(Boolean).join("\n")
}
function formatAskMessage(message: ClineMessage, prefix: string, verbose: boolean): string {
const ask = message.ask as ClineAsk
switch (ask) {
case "followup": {
// Parse JSON question format
let question = message.text || ""
try {
const parsed = JSON.parse(message.text || "{}")
question = parsed.question || question
} catch {
// Fallback to raw text if not JSON
question = message.text || ""
}
return `${prefix} ${style.info("Question:")} ${question}`
}
case "command":
return `${prefix} ${style.command("Execute command?")} ${style.code(message.text || "")}`
case "tool":
return `${prefix} ${style.tool("Use tool?")} ${message.text || ""}`
case "completion_result":
return `${prefix} ${style.success("Task completed")} ${message.text ? `- ${message.text}` : ""}`
case "api_req_failed":
return `${prefix} ${style.error("API request failed")} ${message.text || ""}`
case "resume_task":
case "resume_completed_task":
return `${prefix} ${style.info("Resume task?")} ${message.text || ""}`
case "browser_action_launch":
return `${prefix} ${style.info("Launch browser?")} ${message.text || ""}`
case "use_mcp_server":
return `${prefix} ${style.info("Use MCP server?")} ${message.text || ""}`
case "plan_mode_respond":
return `${prefix} ${style.info("Plan mode response:")} ${message.text || ""}`
default:
return verbose ? `${prefix} [ASK:${ask}] ${message.text || ""}` : ""
}
}
function formatSayMessage(message: ClineMessage, prefix: string, verbose: boolean): string {
const say = message.say as ClineSay
switch (say) {
case "task":
return `${prefix} ${style.task("Task:")} ${message.text || ""}`
case "text":
return `${prefix} ${style.assistant(message.text || "")}`
case "reasoning":
return `${prefix} ${style.dim("Thinking:")} ${style.italic(message.text || "")}`
case "error":
return `${prefix} ${style.error("Error:")} ${message.text || ""}`
case "completion_result":
return `${prefix} ${style.success("✓ Completed:")} ${message.text || ""}`
case "user_feedback":
return `${prefix} ${style.user("User:")} ${message.text || ""}`
case "command":
return `${prefix} ${style.command("Command:")} ${style.code(message.text || "")}`
case "command_output":
const output = message.text || ""
const truncated = output.length > 500 ? output.substring(0, 500) + "..." : output
return `${prefix} ${style.dim("Output:")} ${truncated}`
case "tool":
return `${prefix} ${style.tool("Tool:")} ${message.text || ""}`
case "browser_action":
case "browser_action_launch":
return `${prefix} ${style.info("Browser:")} ${message.text || ""}`
case "browser_action_result":
return `${prefix} ${style.dim("Browser result")} ${message.text ? `- ${message.text.substring(0, 100)}...` : ""}`
case "mcp_server_request_started":
return `${prefix} ${style.info("MCP request started")} ${message.text || ""}`
case "mcp_server_response":
return `${prefix} ${style.info("MCP response")} ${message.text ? message.text.substring(0, 200) : ""}`
case "api_req_started":
return verbose ? `${prefix} ${style.api("API request started")}` : `${message.text || ""}`
case "api_req_finished":
return verbose ? `${prefix} ${style.api("API request finished")}` : ""
case "checkpoint_created":
return `${prefix} ${style.success("Checkpoint created")} ${message.text || ""}`
case "info":
return `${prefix} ${style.info(message.text || "")}`
case "hook_status":
return `${prefix} ${style.dim("Hook:")} ${message.text || ""}`
case "task_progress":
return `${prefix} ${style.info("Progress:")} ${message.text || ""}`
default:
return verbose ? `${prefix} [SAY:${say}] ${message.text || ""}` : ""
}
}
/**
* Display a horizontal separator
*/
export function separator(char: string = "─", width: number = 60): string {
return style.dim(char.repeat(width))
}
/**
* Display the task header
*/
export function taskHeader(taskId: string, task?: string): string {
const lines = [
separator("═"),
style.bold(` Task: ${taskId}`),
task ? ` ${style.dim(task.substring(0, 80))}${task.length > 80 ? "..." : ""}` : "",
separator("═"),
]
return lines.filter(Boolean).join("\n")
}
/**
* Format the current state for display
*/
export function formatState(state: ExtensionState, verbose: boolean = false): string {
const lines: string[] = []
if (state.currentTaskItem) {
lines.push(taskHeader(state.currentTaskItem.id, state.currentTaskItem.task))
}
// Show messages
if (state.clineMessages && state.clineMessages.length > 0) {
const messagesToShow = verbose
? state.clineMessages
: state.clineMessages.filter((m) => {
// Filter out noisy messages in non-verbose mode
// if (m.say === "api_req_started" || m.say === "api_req_finished") return false
return true
})
for (const message of messagesToShow) {
const formatted = formatMessage(message, verbose)
if (formatted) {
lines.push(formatted)
}
}
}
return lines.join("\n")
}
/**
* Display a spinner with message
*/
export class Spinner {
private frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
private frameIndex = 0
private interval: NodeJS.Timeout | null = null
private message: string = ""
start(message: string) {
this.message = message
this.interval = setInterval(() => {
const frame = this.frames[this.frameIndex]
process.stdout.write(`\r${style.info(frame)} ${this.message}`)
this.frameIndex = (this.frameIndex + 1) % this.frames.length
}, 80)
}
update(message: string) {
this.message = message
}
stop(finalMessage?: string) {
if (this.interval) {
clearInterval(this.interval)
this.interval = null
}
if (finalMessage) {
process.stdout.write(`\r${style.success("✓")} ${finalMessage}\n`)
} else {
process.stdout.write("\r" + " ".repeat(this.message.length + 4) + "\r")
}
}
fail(message?: string) {
if (this.interval) {
clearInterval(this.interval)
this.interval = null
}
if (message) {
process.stdout.write(`\r${style.error("✗")} ${message}\n`)
}
}
}
/**
* Clear the current line
*/
export function clearLine() {
process.stdout.write("\r\x1b[K")
}
/**
* Move cursor up n lines
*/
export function cursorUp(n: number = 1) {
process.stdout.write(`\x1b[${n}A`)
}
/**
* Print a message to stdout with newline
* Uses original console.log to work even when console is suppressed
*/
export function print(message: string) {
originalConsoleLog(message)
}
/**
* Print an error message to stderr
* Uses original console.error to work even when console is suppressed
*/
export function printError(message: string) {
originalConsoleError(style.error(message))
}
/**
* Print a success message
*/
export function printSuccess(message: string) {
originalConsoleLog(style.success(message))
}
/**
* Print an info message
*/
export function printInfo(message: string) {
originalConsoleLog(style.info(message))
}
/**
* Print a warning message
*/
export function printWarning(message: string) {
originalConsoleLog(style.warning(message))
}
/**
* Prompt user for input from stdin
*/
export async function promptUser(question: string): Promise<string> {
const readline = await import("readline")
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
})
return new Promise((resolve) => {
rl.question(style.info(question) + " ", (answer: string) => {
rl.close()
resolve(answer.trim())
})
})
}
/**
* Prompt user for yes/no confirmation
*/
export async function promptConfirmation(question: string): Promise<boolean> {
const answer = await promptUser(`${question} ${style.dim("(y/n)")}`)
return answer.toLowerCase() === "y" || answer.toLowerCase() === "yes"
}
+256
View File
@@ -0,0 +1,256 @@
/**
* 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
@@ -0,0 +1,232 @@
import fs from "node:fs"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { imageFileToDataUrl, isImagePath, jsonParseSafe, parseImagesFromInput, processImagePaths } from "./parser"
describe("parser", () => {
describe("jsonParseSafe", () => {
it("should parse valid JSON", () => {
const result = jsonParseSafe('{"key": "value"}', {})
expect(result).toEqual({ key: "value" })
})
it("should return default value for invalid JSON", () => {
const defaultValue = { fallback: true }
const result = jsonParseSafe("not valid json", defaultValue)
expect(result).toEqual(defaultValue)
})
it("should parse arrays", () => {
const result = jsonParseSafe("[1, 2, 3]", [])
expect(result).toEqual([1, 2, 3])
})
it("should handle empty string", () => {
const result = jsonParseSafe("", "default")
expect(result).toBe("default")
})
it("should parse nested objects", () => {
const json = '{"outer": {"inner": "value"}}'
const result = jsonParseSafe(json, {})
expect(result).toEqual({ outer: { inner: "value" } })
})
})
describe("isImagePath", () => {
it("should return true for .png files", () => {
expect(isImagePath("/path/to/image.png")).toBe(true)
})
it("should return true for .jpg files", () => {
expect(isImagePath("/path/to/image.jpg")).toBe(true)
})
it("should return true for .jpeg files", () => {
expect(isImagePath("/path/to/image.jpeg")).toBe(true)
})
it("should return true for .gif files", () => {
expect(isImagePath("/path/to/image.gif")).toBe(true)
})
it("should return true for .webp files", () => {
expect(isImagePath("/path/to/image.webp")).toBe(true)
})
it("should return false for non-image files", () => {
expect(isImagePath("/path/to/file.txt")).toBe(false)
expect(isImagePath("/path/to/file.pdf")).toBe(false)
expect(isImagePath("/path/to/file.js")).toBe(false)
})
it("should handle uppercase extensions", () => {
expect(isImagePath("/path/to/image.PNG")).toBe(true)
expect(isImagePath("/path/to/image.JPG")).toBe(true)
})
it("should handle mixed case extensions", () => {
expect(isImagePath("/path/to/image.Png")).toBe(true)
})
})
describe("parseImagesFromInput", () => {
it("should extract image paths with @ prefix", () => {
const input = "analyze this image @/path/to/image.png"
const result = parseImagesFromInput(input)
expect(result.imagePaths).toContain("/path/to/image.png")
expect(result.prompt).toBe("analyze this image")
})
it("should extract multiple images", () => {
const input = "compare @/img1.png and @/img2.jpg"
const result = parseImagesFromInput(input)
expect(result.imagePaths).toContain("/img1.png")
expect(result.imagePaths).toContain("/img2.jpg")
})
it("should handle standalone image paths", () => {
const input = "look at /path/to/image.png please"
const result = parseImagesFromInput(input)
expect(result.imagePaths).toContain("/path/to/image.png")
})
it("should return empty array when no images", () => {
const input = "just some text without images"
const result = parseImagesFromInput(input)
expect(result.imagePaths).toEqual([])
expect(result.prompt).toBe("just some text without images")
})
it("should handle image at start of input", () => {
const input = "@/start.png is the image"
const result = parseImagesFromInput(input)
expect(result.imagePaths).toContain("/start.png")
})
it("should handle all supported image extensions", () => {
const input = "@/a.png @/b.jpg @/c.jpeg @/d.gif @/e.webp"
const result = parseImagesFromInput(input)
expect(result.imagePaths).toHaveLength(5)
})
it("should not duplicate image paths", () => {
const input = "@/same.png /same.png"
const result = parseImagesFromInput(input)
// Both patterns match the same path, should not duplicate
expect(result.imagePaths.filter((p) => p === "/same.png").length).toBeLessThanOrEqual(2)
})
it("should clean up extra whitespace in prompt", () => {
const input = "text @/image.png more text"
const result = parseImagesFromInput(input)
expect(result.prompt).toBe("text more text")
})
})
describe("imageFileToDataUrl", () => {
beforeEach(() => {
vi.spyOn(fs.promises, "readFile")
})
afterEach(() => {
vi.restoreAllMocks()
})
it("should convert png to data URL", async () => {
const mockBuffer = Buffer.from("fake png data")
vi.mocked(fs.promises.readFile).mockResolvedValue(mockBuffer)
const result = await imageFileToDataUrl("/path/to/image.png")
expect(result).toMatch(/^data:image\/png;base64,/)
expect(result).toContain(mockBuffer.toString("base64"))
})
it("should use correct MIME type for jpeg", async () => {
const mockBuffer = Buffer.from("fake jpeg data")
vi.mocked(fs.promises.readFile).mockResolvedValue(mockBuffer)
const result = await imageFileToDataUrl("/path/to/image.jpg")
expect(result).toMatch(/^data:image\/jpeg;base64,/)
})
it("should use correct MIME type for gif", async () => {
const mockBuffer = Buffer.from("fake gif data")
vi.mocked(fs.promises.readFile).mockResolvedValue(mockBuffer)
const result = await imageFileToDataUrl("/path/to/image.gif")
expect(result).toMatch(/^data:image\/gif;base64,/)
})
it("should use correct MIME type for webp", async () => {
const mockBuffer = Buffer.from("fake webp data")
vi.mocked(fs.promises.readFile).mockResolvedValue(mockBuffer)
const result = await imageFileToDataUrl("/path/to/image.webp")
expect(result).toMatch(/^data:image\/webp;base64,/)
})
})
describe("processImagePaths", () => {
beforeEach(() => {
vi.spyOn(fs, "existsSync")
vi.spyOn(fs.promises, "readFile")
})
afterEach(() => {
vi.restoreAllMocks()
})
it("should process existing image files", async () => {
vi.mocked(fs.existsSync).mockReturnValue(true)
vi.mocked(fs.promises.readFile).mockResolvedValue(Buffer.from("image data"))
const result = await processImagePaths(["/path/to/image.png"])
expect(result).toHaveLength(1)
expect(result[0]).toMatch(/^data:image\/png;base64,/)
})
it("should skip non-existent files", async () => {
vi.mocked(fs.existsSync).mockReturnValue(false)
const result = await processImagePaths(["/nonexistent/image.png"])
expect(result).toHaveLength(0)
})
it("should skip non-image files", async () => {
vi.mocked(fs.existsSync).mockReturnValue(true)
const result = await processImagePaths(["/path/to/file.txt"])
expect(result).toHaveLength(0)
})
it("should process multiple images", async () => {
vi.mocked(fs.existsSync).mockReturnValue(true)
vi.mocked(fs.promises.readFile).mockResolvedValue(Buffer.from("image data"))
const result = await processImagePaths(["/img1.png", "/img2.jpg", "/img3.gif"])
expect(result).toHaveLength(3)
})
it("should handle read errors gracefully", async () => {
vi.mocked(fs.existsSync).mockReturnValue(true)
vi.mocked(fs.promises.readFile).mockRejectedValue(new Error("Read error"))
const result = await processImagePaths(["/path/to/image.png"])
expect(result).toHaveLength(0)
})
it("should handle empty input", async () => {
const result = await processImagePaths([])
expect(result).toEqual([])
})
})
})
+100
View File
@@ -0,0 +1,100 @@
import fs from "node:fs"
import path from "node:path"
export function jsonParseSafe<T>(data: string, defaultValue: T): T {
try {
return JSON.parse(data) as T
} catch {
return defaultValue
}
}
const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"])
/**
* Check if a file path is an image based on extension
*/
export function isImagePath(filePath: string): boolean {
const ext = path.extname(filePath).toLowerCase()
return IMAGE_EXTENSIONS.has(ext)
}
/**
* Get MIME type for an image extension
*/
function getMimeType(ext: string): string {
const mimeTypes: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
}
return mimeTypes[ext.toLowerCase()] || "image/png"
}
/**
* Convert an image file path to a base64 data URL
*/
export async function imageFileToDataUrl(filePath: string): Promise<string> {
const resolvedPath = path.resolve(filePath)
const ext = path.extname(resolvedPath).toLowerCase()
const mimeType = getMimeType(ext)
const buffer = await fs.promises.readFile(resolvedPath)
const base64 = buffer.toString("base64")
return `data:${mimeType};base64,${base64}`
}
/**
* Parse input text and extract image file paths.
* Supports formats like: "prompt text @/path/to/image.png" or just file paths
* Returns the clean prompt text and array of image paths
*/
export function parseImagesFromInput(input: string): { prompt: string; imagePaths: string[] } {
const imagePaths: string[] = []
// Match @/path/to/image.ext patterns (with space or at start)
const atPathPattern = /(?:^|\s)@(\/[^\s]+\.(?:png|jpg|jpeg|gif|webp))/gi
let match: RegExpExecArray | null
while ((match = atPathPattern.exec(input)) !== null) {
imagePaths.push(match[1])
}
// Also match standalone absolute paths that look like images
const standalonePathPattern = /(?:^|\s)(\/[^\s]+\.(?:png|jpg|jpeg|gif|webp))(?:\s|$)/gi
while ((match = standalonePathPattern.exec(input)) !== null) {
const p = match[1]
if (!imagePaths.includes(p)) {
imagePaths.push(p)
}
}
// Remove the image references from the prompt
const prompt = input.replace(atPathPattern, " ").replace(standalonePathPattern, " ").replace(/\s+/g, " ").trim()
return { prompt, imagePaths }
}
/**
* Process image file paths into base64 data URLs
* Returns only successfully converted images
*/
export async function processImagePaths(imagePaths: string[]): Promise<string[]> {
const dataUrls: string[] = []
for (const imagePath of imagePaths) {
try {
const resolvedPath = path.resolve(imagePath)
if (fs.existsSync(resolvedPath) && isImagePath(resolvedPath)) {
const dataUrl = await imageFileToDataUrl(resolvedPath)
dataUrls.push(dataUrl)
}
} catch {
// Skip files that can't be read
}
}
return dataUrls
}
+37
View File
@@ -0,0 +1,37 @@
/**
* Read piped input from stdin (non-blocking)
*/
export async function readStdinIfPiped(): Promise<string | null> {
// Check if stdin is a TTY (interactive) or piped
if (process.stdin.isTTY) {
return null
}
return new Promise((resolve) => {
let data = ""
process.stdin.setEncoding("utf8")
// Set a short timeout in case stdin is not actually providing data
const timeout = setTimeout(() => {
process.stdin.removeAllListeners()
resolve(data.trim() || null)
}, 100)
process.stdin.on("data", (chunk) => {
data += chunk
})
process.stdin.on("end", () => {
clearTimeout(timeout)
resolve(data.trim() || null)
})
process.stdin.on("error", () => {
clearTimeout(timeout)
resolve(null)
})
// Resume stdin in case it's paused
process.stdin.resume()
})
}
+298
View File
@@ -0,0 +1,298 @@
/**
* VSCode context stub for CLI mode
* Provides mock implementations of VSCode extension context
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
import os from "os"
import path from "path"
import type { Memento, SecretStorage } from "vscode"
import { ExtensionRegistryInfo } from "@/registry"
import { 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
@@ -0,0 +1,288 @@
/**
* VSCode namespace shim for CLI mode
* Provides minimal stubs for VSCode types and enums used by the codebase
*/
import { printError, printInfo, printWarning } from "./utils/display"
// Re-export common types from vscode-uri for URI handling
export { URI } from "vscode-uri"
// Extension mode enum
export enum ExtensionMode {
Production = 1,
Development = 2,
Test = 3,
}
// Extension kind enum
export enum ExtensionKind {
UI = 1,
Workspace = 2,
}
// Diagnostic severity enum
export enum DiagnosticSeverity {
Error = 0,
Warning = 1,
Information = 2,
Hint = 3,
}
// End of line enum
export enum EndOfLine {
LF = 1,
CRLF = 2,
}
// Position class
export class Position {
constructor(
public readonly line: number,
public readonly character: number,
) {}
isAfter(other: Position): boolean {
return this.line > other.line || (this.line === other.line && this.character > other.character)
}
isAfterOrEqual(other: Position): boolean {
return this.line > other.line || (this.line === other.line && this.character >= other.character)
}
isBefore(other: Position): boolean {
return this.line < other.line || (this.line === other.line && this.character < other.character)
}
isBeforeOrEqual(other: Position): boolean {
return this.line < other.line || (this.line === other.line && this.character <= other.character)
}
isEqual(other: Position): boolean {
return this.line === other.line && this.character === other.character
}
translate(lineDelta?: number, characterDelta?: number): Position {
return new Position(this.line + (lineDelta || 0), this.character + (characterDelta || 0))
}
with(line?: number, character?: number): Position {
return new Position(line ?? this.line, character ?? this.character)
}
compareTo(other: Position): number {
if (this.line < other.line) {
return -1
}
if (this.line > other.line) {
return 1
}
if (this.character < other.character) {
return -1
}
if (this.character > other.character) {
return 1
}
return 0
}
}
// Range class
export class Range {
public readonly start: Position
public readonly end: Position
constructor(start: Position, end: Position)
constructor(startLine: number, startCharacter: number, endLine: number, endCharacter: number)
constructor(
startOrStartLine: Position | number,
endOrStartCharacter: Position | number,
endLine?: number,
endCharacter?: number,
) {
if (typeof startOrStartLine === "number") {
this.start = new Position(startOrStartLine, endOrStartCharacter as number)
this.end = new Position(endLine!, endCharacter!)
} else {
this.start = startOrStartLine
this.end = endOrStartCharacter as Position
}
}
get isEmpty(): boolean {
return this.start.isEqual(this.end)
}
get isSingleLine(): boolean {
return this.start.line === this.end.line
}
contains(positionOrRange: Position | Range): boolean {
if (positionOrRange instanceof Range) {
return this.contains(positionOrRange.start) && this.contains(positionOrRange.end)
}
return positionOrRange.isAfterOrEqual(this.start) && positionOrRange.isBeforeOrEqual(this.end)
}
isEqual(other: Range): boolean {
return this.start.isEqual(other.start) && this.end.isEqual(other.end)
}
intersection(range: Range): Range | undefined {
const start = Position.prototype.isAfter.call(this.start, range.start) ? this.start : range.start
const end = Position.prototype.isBefore.call(this.end, range.end) ? this.end : range.end
if (start.isAfter(end)) {
return undefined
}
return new Range(start, end)
}
union(other: Range): Range {
const start = this.start.isBefore(other.start) ? this.start : other.start
const end = this.end.isAfter(other.end) ? this.end : other.end
return new Range(start, end)
}
with(start?: Position, end?: Position): Range {
return new Range(start ?? this.start, end ?? this.end)
}
}
// Selection class (extends Range)
export class Selection extends Range {
public readonly anchor: Position
public readonly active: Position
constructor(anchor: Position, active: Position)
constructor(anchorLine: number, anchorCharacter: number, activeLine: number, activeCharacter: number)
constructor(
anchorOrAnchorLine: Position | number,
activeOrAnchorCharacter: Position | number,
activeLine?: number,
activeCharacter?: number,
) {
let anchor: Position
let active: Position
if (typeof anchorOrAnchorLine === "number") {
anchor = new Position(anchorOrAnchorLine, activeOrAnchorCharacter as number)
active = new Position(activeLine!, activeCharacter!)
} else {
anchor = anchorOrAnchorLine
active = activeOrAnchorCharacter as Position
}
super(anchor.isBefore(active) ? anchor : active, anchor.isBefore(active) ? active : anchor)
this.anchor = anchor
this.active = active
}
get isReversed(): boolean {
return this.anchor.isAfter(this.active)
}
}
// Cancellation token
export interface CancellationToken {
isCancellationRequested: boolean
onCancellationRequested: any
}
// Event emitter (simplified)
export class EventEmitter<T> {
private listeners: Array<(e: T) => void> = []
event = (listener: (e: T) => void) => {
this.listeners.push(listener)
return {
dispose: () => {
const index = this.listeners.indexOf(listener)
if (index >= 0) {
this.listeners.splice(index, 1)
}
},
}
}
fire(data: T): void {
for (const listener of this.listeners) {
listener(data)
}
}
dispose(): void {
this.listeners = []
}
}
// Disposable
export class Disposable {
constructor(private callOnDispose: () => void) {}
static from(...disposables: { dispose(): any }[]): Disposable {
return new Disposable(() => {
for (const d of disposables) {
d.dispose()
}
})
}
dispose(): void {
this.callOnDispose()
}
}
// Minimal workspace namespace
export const workspace = {
workspaceFolders: undefined as any[] | undefined,
getWorkspaceFolder: (_uri: any) => undefined,
onDidChangeWorkspaceFolders: () => ({ dispose: () => {} }),
fs: {
readFile: async (_uri: any): Promise<Uint8Array> => new Uint8Array(),
writeFile: async (_uri: any, _content: Uint8Array): Promise<void> => {},
delete: async (_uri: any): Promise<void> => {},
stat: async (_uri: any): Promise<any> => ({ type: 1, size: 0 }),
readDirectory: async (_uri: any): Promise<any[]> => [],
createDirectory: async (_uri: any): Promise<void> => {},
},
}
// Minimal window namespace
export const window = {
showInformationMessage: async (message: string) => {
printInfo(`[INFO] ${message}`)
return undefined
},
showWarningMessage: async (message: string) => {
printWarning(`[WARN] ${message}`)
return undefined
},
showErrorMessage: async (message: string) => {
printError(`[ERROR] ${message}`)
return undefined
},
createOutputChannel: (_name: string) => ({
appendLine: (line: string) => printInfo(`[${new Date().toISOString()}] ${line}`),
append: (text: string) => printInfo(`[${new Date().toISOString()}] ${text}`),
clear: () => {},
show: () => {},
hide: () => {},
dispose: () => {},
}),
terminals: [] as any[],
activeTerminal: undefined as any,
createTerminal: (_options?: any) => ({
name: "CLI Terminal",
processId: Promise.resolve(process.pid),
sendText: (text: string) => printInfo(`[${new Date().toISOString()}] [Terminal] ${text}`),
show: () => {},
hide: () => {},
dispose: () => {},
}),
}
// Export types that are commonly used
export type ExtensionContext = any
export type Memento = any
export type SecretStorage = any
// biome-ignore lint/correctness/noUnusedVariables: placeholder
export type Extension<T> = any
+74
View File
@@ -0,0 +1,74 @@
{
"compilerOptions": {
"esModuleInterop": true,
"experimentalDecorators": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"jsx": "react",
"jsxFactory": "React.createElement",
"lib": [
"es2022"
],
"module": "esnext",
"moduleResolution": "Bundler",
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noUnusedLocals": false,
"resolveJsonModule": true,
"rootDir": ".",
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"target": "es2022",
"useDefineForClassFields": true,
"useUnknownInCatchVariables": false,
"ignoreDeprecations": "6.0",
"baseUrl": "..",
"paths": {
"@/*": [
"src/*"
],
"@api/*": [
"src/core/api/*"
],
"@core/*": [
"src/core/*"
],
"@generated/*": [
"src/generated/*"
],
"@hosts/*": [
"src/hosts/*"
],
"@integrations/*": [
"src/integrations/*"
],
"@packages/*": [
"src/packages/*"
],
"@services/*": [
"src/services/*"
],
"@shared/*": [
"src/shared/*"
],
"@utils/*": [
"src/utils/*"
]
},
"outDir": "dist"
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist"
],
"references": [
{
"path": ".."
}
]
}
+29
View File
@@ -0,0 +1,29 @@
import path from "path"
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["src/**/*.test.{ts,tsx}", "tests/**/*.test.{ts,tsx}"],
coverage: {
reporter: ["text", "json", "html"],
exclude: ["node_modules/", "dist/"],
},
},
resolve: {
alias: {
// Match tsconfig paths - baseUrl is parent directory
"@": path.resolve(__dirname, "../src"),
"@api": path.resolve(__dirname, "../src/core/api"),
"@core": path.resolve(__dirname, "../src/core"),
"@generated": path.resolve(__dirname, "../src/generated"),
"@hosts": path.resolve(__dirname, "../src/hosts"),
"@integrations": path.resolve(__dirname, "../src/integrations"),
"@packages": path.resolve(__dirname, "../src/packages"),
"@services": path.resolve(__dirname, "../src/services"),
"@shared": path.resolve(__dirname, "../src/shared"),
"@utils": path.resolve(__dirname, "../src/utils"),
},
},
})
+7
View File
@@ -0,0 +1,7 @@
{
"workflowToggles": {},
"localClineRulesToggles": {},
"localWindsurfRulesToggles": {},
"localCursorRulesToggles": {},
"localAgentsRulesToggles": {}
}
+12 -1
View File
@@ -4,6 +4,10 @@
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"version": "3.52.0",
"icon": "assets/icons/icon.png",
"workspaces": [
"cli-ts",
"webview-ui"
],
"engines": {
"vscode": "^1.84.0"
},
@@ -381,6 +385,10 @@
"compile-standalone": "npm run check-types && npm run lint && node esbuild.mjs --standalone",
"compile-standalone-npm": "npm run protos && npm run protos-go && npm run check-types && npm run lint && node esbuild.mjs --standalone",
"compile-cli": "scripts/build-cli.sh",
"compile-cli-ts": "cd cli-ts && npm run link",
"compile-cli-ts:production": "cd cli-ts && npm run build:production",
"watch-cli-ts": "cd cli-ts && npm run watch",
"dev:cli-ts": "npm run compile-cli-ts && npm run watch-cli-ts",
"compile-cli-all-platforms": "scripts/build-cli-all-platforms.sh",
"compile-cli-man-page": "pandoc cli/man/cline.1.md -s -t man -o cli/man/cline.1",
"test:install": "bash scripts/test-install.sh",
@@ -437,7 +445,10 @@
"docs:check-links": "cd docs && npm run check",
"docs:rename-file": "cd docs && npm run rename",
"report-issue": "node scripts/report-issue.js",
"storybook": "cd webview-ui && npm run storybook"
"storybook": "cd webview-ui && npm run storybook",
"cli:dev": "cd cli-ts && npm run dev",
"cli:unlink": "cd cli-ts && npm run unlink",
"build:cli": "npm run install:all && npm run protos && cd cli-ts && npm run build"
},
"lint-staged": {
"src/shared/storage/state-keys.ts": [
+1 -1
View File
@@ -110,7 +110,6 @@ message Settings {
optional string lite_llm_base_url = 1;
optional bool lite_llm_use_prompt_cache = 2;
map<string, string> open_ai_headers = 3;
optional string anthropic_base_url = 4;
optional string open_router_provider_sorting = 5;
optional string aws_region = 6;
@@ -279,6 +278,7 @@ message Settings {
optional int32 open_telemetry_log_batch_timeout = 170;
optional int32 open_telemetry_log_max_queue_size = 171;
optional bool worktrees_enabled = 172;
map<string, string> open_ai_headers = 173;
}
message DictationSettings {
+38 -37
View File
@@ -1,3 +1,4 @@
import { Logger } from "@/services/logging/Logger"
import { ClineStorageMessage } from "@/shared/messages/content"
import { fetch } from "@/shared/net"
import { ModelInfo } from "../../../shared/api"
@@ -84,7 +85,7 @@ export class DifyHandler implements ApiHandler {
this.apiKey = options.difyApiKey || ""
this.baseUrl = options.difyBaseUrl || ""
console.log("[DIFY DEBUG] Constructor called with:", {
Logger.log("[DIFY DEBUG] Constructor called with:", {
hasApiKey: !!this.apiKey,
baseUrl: this.baseUrl,
})
@@ -98,7 +99,7 @@ export class DifyHandler implements ApiHandler {
}
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[]): ApiStream {
console.log("[DIFY DEBUG] createMessage called with:", {
Logger.log("[DIFY DEBUG] createMessage called with:", {
systemPromptLength: systemPrompt?.length || 0,
messagesCount: messages?.length || 0,
})
@@ -115,8 +116,8 @@ export class DifyHandler implements ApiHandler {
}
const fullUrl = `${this.baseUrl}/chat-messages`
console.log("[DIFY DEBUG] Making request to:", fullUrl)
console.log("[DIFY DEBUG] Request body:", JSON.stringify(requestBody, null, 2))
Logger.log("[DIFY DEBUG] Making request to:", fullUrl)
Logger.log("[DIFY DEBUG] Request body:", JSON.stringify(requestBody, null, 2))
let response: Response
try {
@@ -129,21 +130,21 @@ export class DifyHandler implements ApiHandler {
body: JSON.stringify(requestBody),
})
} catch (error: any) {
console.error("[DIFY DEBUG] Network error during fetch:", error)
Logger.error("[DIFY DEBUG] Network error during fetch:", error)
const cause = error.cause ? ` | Cause: ${error.cause}` : ""
throw new Error(`Dify API network error: ${error.message}${cause}`)
}
console.log("[DIFY DEBUG] Response status:", response.status)
Logger.log("[DIFY DEBUG] Response status:", response.status)
const headersObj: Record<string, string> = {}
response.headers.forEach((value, key) => {
headersObj[key] = value
})
console.log("[DIFY DEBUG] Response headers:", headersObj)
Logger.log("[DIFY DEBUG] Response headers:", headersObj)
if (!response.ok) {
const errorText = await response.text()
console.error("[DIFY DEBUG] Error response:", errorText)
Logger.debug("[DIFY DEBUG] Error response:", errorText)
throw new Error(`Dify API error: ${response.status} ${response.statusText} - ${errorText}`)
}
@@ -159,14 +160,14 @@ export class DifyHandler implements ApiHandler {
const processedEvents: string[] = []
let lastEventTime = Date.now()
console.log("[DIFY DEBUG] Starting to read streaming response...")
Logger.log("[DIFY DEBUG] Starting to read streaming response...")
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
console.log("[DIFY DEBUG] Stream ended naturally")
console.log(
Logger.log("[DIFY DEBUG] Stream ended naturally")
Logger.log(
"[DIFY DEBUG] Final state - hasYieldedContent:",
hasYieldedContent,
"fullText length:",
@@ -178,7 +179,7 @@ export class DifyHandler implements ApiHandler {
}
const chunk = decoder.decode(value, { stream: true })
console.log("[DIFY DEBUG] Raw chunk received:", JSON.stringify(chunk))
Logger.log("[DIFY DEBUG] Raw chunk received:", JSON.stringify(chunk))
buffer += chunk
const lines = buffer.split("\n")
@@ -187,41 +188,41 @@ export class DifyHandler implements ApiHandler {
buffer = lines.pop() || ""
for (const line of lines) {
console.log("[DIFY DEBUG] Processing line:", JSON.stringify(line))
Logger.log("[DIFY DEBUG] Processing line:", JSON.stringify(line))
if (line.startsWith("data: ")) {
const data = line.slice(6).trim()
console.log("[DIFY DEBUG] Extracted data:", JSON.stringify(data))
Logger.log("[DIFY DEBUG] Extracted data:", JSON.stringify(data))
if (data === "[DONE]") {
console.log("[DIFY DEBUG] Received [DONE] signal")
Logger.log("[DIFY DEBUG] Received [DONE] signal")
break
}
if (data === "") {
console.log("[DIFY DEBUG] Empty data line, skipping")
Logger.log("[DIFY DEBUG] Empty data line, skipping")
continue
}
try {
const parsed = JSON.parse(data)
console.log("[DIFY DEBUG] Parsed JSON:", parsed)
Logger.log("[DIFY DEBUG] Parsed JSON:", parsed)
processedEvents.push(parsed.event || "unknown")
lastEventTime = Date.now()
// Capture conversation_id as soon as it's available
if (parsed.conversation_id && !this.conversationId) {
this.conversationId = parsed.conversation_id
console.log("[DIFY DEBUG] Captured conversation_id:", this.conversationId)
Logger.log("[DIFY DEBUG] Captured conversation_id:", this.conversationId)
}
// Handle different Dify event types based on actual Dify API
if (parsed.event === "message") {
console.log("[DIFY DEBUG] Message event, answer:", parsed.answer)
Logger.log("[DIFY DEBUG] Message event, answer:", parsed.answer)
// Dify sends the full text in each "answer" chunk, so we replace.
if (typeof parsed.answer === "string") {
fullText = parsed.answer
console.log("[DIFY DEBUG] Updated fullText length:", fullText.length)
Logger.log("[DIFY DEBUG] Updated fullText length:", fullText.length)
yield {
type: "text",
text: fullText,
@@ -229,10 +230,10 @@ export class DifyHandler implements ApiHandler {
hasYieldedContent = true
}
} else if (parsed.event === "message_replace") {
console.log("[DIFY DEBUG] Replace message event:", parsed)
Logger.log("[DIFY DEBUG] Replace message event:", parsed)
if (parsed.answer) {
fullText = parsed.answer // Replace instead of append
console.log("[DIFY DEBUG] Replaced fullText length:", fullText.length)
Logger.log("[DIFY DEBUG] Replaced fullText length:", fullText.length)
yield {
type: "text",
text: fullText,
@@ -240,7 +241,7 @@ export class DifyHandler implements ApiHandler {
hasYieldedContent = true
}
} else if (parsed.event === "message_end") {
console.log("[DIFY DEBUG] Message end event", parsed)
Logger.log("[DIFY DEBUG] Message end event", parsed)
// Message completed. Yield final text if we have any.
if (fullText) {
yield {
@@ -260,19 +261,19 @@ export class DifyHandler implements ApiHandler {
}
return // End of stream
} else if (parsed.event === "error") {
console.error("[DIFY DEBUG] Error event:", parsed)
Logger.error("[DIFY DEBUG] Error event:", parsed)
throw new Error(`Dify API error: ${parsed.message || "Unknown error"}`)
} else if (parsed.event === "workflow_started" || parsed.event === "workflow_finished") {
console.log("[DIFY DEBUG] Workflow event:", parsed.event)
Logger.log("[DIFY DEBUG] Workflow event:", parsed.event)
// These are informational events, continue processing
} else if (parsed.event === "node_started" || parsed.event === "node_finished") {
console.log("[DIFY DEBUG] Node event:", parsed.event, parsed.data)
Logger.log("[DIFY DEBUG] Node event:", parsed.event, parsed.data)
// These are informational events, continue processing
} else if (parsed.event === "ping") {
console.log("[DIFY DEBUG] Ping event received, keeping connection alive.")
Logger.log("[DIFY DEBUG] Ping event received, keeping connection alive.")
// Ping event, do nothing
} else {
console.log("[DIFY DEBUG] Unknown event type:", parsed.event, "Full object:", parsed)
Logger.log("[DIFY DEBUG] Unknown event type:", parsed.event, "Full object:", parsed)
// Try to extract text from other possible fields
if (parsed.text) {
fullText += parsed.text
@@ -299,17 +300,17 @@ export class DifyHandler implements ApiHandler {
}
}
} catch (e) {
console.warn("[DIFY DEBUG] Failed to parse JSON:", data, "Error:", e)
Logger.info("[DIFY DEBUG] Failed to parse JSON:", data, "Error:", e)
}
} else if (line.trim() !== "") {
console.log(
Logger.log(
"[DIFY DEBUG] Non-data line (not starting with 'data:'), trying to parse as direct JSON:",
JSON.stringify(line),
)
// Try to parse as direct JSON (fallback for non-SSE responses, though Dify uses SSE)
try {
const parsed = JSON.parse(line.trim())
console.log("[DIFY DEBUG] Parsed direct JSON:", parsed)
Logger.log("[DIFY DEBUG] Parsed direct JSON:", parsed)
processedEvents.push(parsed.event || "direct-json")
// Handle the same event types as above
@@ -330,7 +331,7 @@ export class DifyHandler implements ApiHandler {
}
return
} else if (parsed.event === "error") {
console.error("[DIFY DEBUG] Direct JSON Error event:", parsed)
Logger.error("[DIFY DEBUG] Direct JSON Error event:", parsed)
throw new Error(`Dify API error: ${parsed.message || "Unknown error"}`)
} else if (parsed.answer || parsed.text || parsed.content) {
// Fallback for any content in direct JSON
@@ -344,7 +345,7 @@ export class DifyHandler implements ApiHandler {
}
} catch (e) {
// Not JSON, continue
console.log("[DIFY DEBUG] Line is not direct JSON, continuing")
Logger.log("[DIFY DEBUG] Line is not direct JSON, continuing")
}
}
}
@@ -359,11 +360,11 @@ export class DifyHandler implements ApiHandler {
streamDuration: Date.now() - lastEventTime,
conversationId: this.conversationId,
}
console.error("[DIFY DEBUG] No content was yielded! Diagnostic info:", diagnosticInfo)
Logger.info("[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()) {
console.log("[DIFY DEBUG] Yielding accumulated text as fallback:", fullText)
Logger.log("[DIFY DEBUG] Yielding accumulated text as fallback:", fullText)
yield {
type: "text",
text: fullText,
@@ -380,7 +381,7 @@ export class DifyHandler implements ApiHandler {
}
} finally {
reader.releaseLock()
console.log("[DIFY DEBUG] Stream reader released")
Logger.log("[DIFY DEBUG] Stream reader released")
}
}
@@ -399,7 +400,7 @@ export class DifyHandler implements ApiHandler {
// Only prepend the system prompt if it's the very first message of a new conversation.
if (!this.conversationId && systemPrompt) {
console.log("[DIFY DEBUG] Prepending system prompt for new conversation.")
Logger.log("[DIFY DEBUG] Prepending system prompt for new conversation.")
return `${systemPrompt}\n\n---\n\n${userQuery}`
}
+11 -39
View File
@@ -1,16 +1,11 @@
import * as os from "os"
import { v7 as uuidv7 } from "uuid"
import { ModelInfo, OpenAiCodexModelId, openAiCodexDefaultModelId, openAiCodexModels } from "@shared/api"
import OpenAI from "openai"
import type { ChatCompletionTool } from "openai/resources/chat/completions"
import {
ModelInfo,
OpenAiCodexModelId,
openAiCodexDefaultModelId,
openAiCodexModels,
} from "@shared/api"
import * as os from "os"
import { v7 as uuidv7 } from "uuid"
import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
import { ClineStorageMessage } from "@/shared/messages/content"
import { fetch } from "@/shared/net"
import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
import { ApiHandler, CommonApiHandlerOptions } from "../"
import { convertToOpenAIResponsesInput } from "../transform/openai-response-format"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
@@ -90,11 +85,7 @@ export class OpenAiCodexHandler implements ApiHandler {
return out
}
async *createMessage(
systemPrompt: string,
messages: ClineStorageMessage[],
tools?: ChatCompletionTool[],
): ApiStream {
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[], tools?: ChatCompletionTool[]): ApiStream {
const model = this.getModel()
// Reset state for this request
@@ -104,9 +95,7 @@ export class OpenAiCodexHandler implements ApiHandler {
// Get access token from OAuth manager
let accessToken = await openAiCodexOAuthManager.getAccessToken()
if (!accessToken) {
throw new Error(
"Not authenticated with OpenAI Codex. Please sign in using the OpenAI Codex OAuth flow in settings.",
)
throw new Error("Not authenticated with OpenAI Codex. Please sign in using the OpenAI Codex OAuth flow in settings.")
}
// Format conversation for Responses API
@@ -179,11 +168,7 @@ export class OpenAiCodexHandler implements ApiHandler {
return body
}
private async *executeRequest(
requestBody: any,
model: { id: string; info: ModelInfo },
accessToken: string,
): ApiStream {
private async *executeRequest(requestBody: any, model: { id: string; info: ModelInfo }, accessToken: string): ApiStream {
// Create AbortController for cancellation
this.abortController = new AbortController()
@@ -237,11 +222,7 @@ export class OpenAiCodexHandler implements ApiHandler {
}
}
private async *makeCodexRequest(
requestBody: any,
model: { id: string; info: ModelInfo },
accessToken: string,
): ApiStream {
private async *makeCodexRequest(requestBody: any, model: { id: string; info: ModelInfo }, accessToken: string): ApiStream {
const url = `${CODEX_API_BASE_URL}/responses`
// Get ChatGPT account ID for organization subscriptions
@@ -302,10 +283,7 @@ export class OpenAiCodexHandler implements ApiHandler {
}
}
private async *handleStreamResponse(
body: ReadableStream<Uint8Array>,
model: { id: string; info: ModelInfo },
): ApiStream {
private async *handleStreamResponse(body: ReadableStream<Uint8Array>, model: { id: string; info: ModelInfo }): ApiStream {
const reader = body.getReader()
const decoder = new TextDecoder()
let buffer = ""
@@ -380,10 +358,7 @@ export class OpenAiCodexHandler implements ApiHandler {
}
// Handle tool/function call deltas
if (
event?.type === "response.tool_call_arguments.delta" ||
event?.type === "response.function_call_arguments.delta"
) {
if (event?.type === "response.tool_call_arguments.delta" || event?.type === "response.function_call_arguments.delta") {
const callId = event.call_id || event.tool_call_id || event.id || this.pendingToolCallId
const name = event.name || event.function_name || this.pendingToolCallName
const args = event.delta || event.arguments
@@ -484,10 +459,7 @@ export class OpenAiCodexHandler implements ApiHandler {
getModel(): { id: OpenAiCodexModelId; info: ModelInfo } {
const modelId = this.options.apiModelId
const id =
modelId && modelId in openAiCodexModels
? (modelId as OpenAiCodexModelId)
: openAiCodexDefaultModelId
const id = modelId && modelId in openAiCodexModels ? (modelId as OpenAiCodexModelId) : openAiCodexDefaultModelId
const info: ModelInfo = openAiCodexModels[id]
@@ -1,8 +1,8 @@
import { Empty, EmptyRequest } from "@shared/proto/cline/common"
import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
import { HostProvider } from "@/hosts/host-provider"
import { openExternal } from "@/utils/env"
import { ShowMessageType } from "@shared/proto/host/window"
import { HostProvider } from "@/hosts/host-provider"
import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
import { openExternal } from "@/utils/env"
import { Controller } from ".."
/**
@@ -2,6 +2,7 @@ import { CheckpointRestoreRequest } from "@shared/proto/cline/checkpoints"
import { Empty } from "@shared/proto/cline/common"
import pWaitFor from "p-wait-for"
import { HostProvider } from "@/hosts/host-provider"
import { Logger } from "@/services/logging/Logger"
import { ShowMessageType } from "@/shared/proto/index.host"
import { ClineCheckpointRestore } from "../../../shared/WebviewMessage"
import { Controller } from ".."
@@ -14,7 +15,7 @@ export async function checkpointRestore(controller: Controller, request: Checkpo
await pWaitFor(() => controller.task?.taskState.isInitialized === true, {
timeout: 3_000,
}).catch((error) => {
console.log("Failed to init new Cline instance to restore checkpoint", error)
Logger.log("Failed to init new Cline instance to restore checkpoint", error)
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message: "Failed to restore checkpoint",
+1 -1
View File
@@ -39,7 +39,7 @@ export async function addToCline(controller: Controller, request: CommandContext
await sendAddToInputEvent(input)
}
console.log("addToCline", request.selectedText, filePath, request.language, notebookContext ? "with notebook context" : "")
Logger.log("addToCline", request.selectedText, filePath, request.language, notebookContext ? "with notebook context" : "")
telemetryService.captureButtonClick("codeAction_addToChat", controller.task?.ulid)
return {}
+9 -11
View File
@@ -24,7 +24,6 @@ import open from "open"
import pWaitFor from "p-wait-for"
import * as path from "path"
import type { FolderLockWithRetryResult } from "src/core/locks/types"
import type * as vscode from "vscode"
import { ClineEnv } from "@/config"
import { HostProvider } from "@/hosts/host-provider"
import { ExtensionRegistryInfo } from "@/registry"
@@ -36,6 +35,7 @@ import { featureFlagsService } from "@/services/feature-flags"
import { getDistinctId } from "@/services/logging/distinctId"
import { Logger } from "@/services/logging/Logger"
import { telemetryService } from "@/services/telemetry"
import { ClineExtensionContext } from "@/shared/clients"
import { BannerCardData } from "@/shared/cline/banner"
import { getAxiosSettings } from "@/shared/net"
import { ShowMessageType } from "@/shared/proto/host/window"
@@ -117,7 +117,7 @@ export class Controller {
this.remoteConfigTimer = setInterval(() => fetchRemoteConfig(this), 3600000) // 1 hour
}
constructor(readonly context: vscode.ExtensionContext) {
constructor(readonly context: ClineExtensionContext) {
PromptRegistry.getInstance() // Ensure prompts and tools are registered
HostProvider.get().logToChannel("ClineProvider instantiated")
this.stateManager = StateManager.get()
@@ -170,8 +170,6 @@ export class Controller {
await this.clearTask()
this.mcpHub.dispose()
console.error("Controller disposed")
}
// Auth methods
@@ -410,7 +408,7 @@ export class Controller {
async cancelTask() {
// Prevent duplicate cancellations from spam clicking
if (this.cancelInProgress) {
console.log(`[Controller.cancelTask] Cancellation already in progress, ignoring duplicate request`)
Logger.log(`[Controller.cancelTask] Cancellation already in progress, ignoring duplicate request`)
return
}
@@ -427,7 +425,7 @@ export class Controller {
try {
await this.task.abortTask()
} catch (error) {
console.error("Failed to abort task", error)
Logger.error("Failed to abort task", error)
}
await pWaitFor(
@@ -440,7 +438,7 @@ export class Controller {
timeout: 3_000,
},
).catch(() => {
console.error("Failed to abort task")
Logger.error("Failed to abort task")
})
if (this.task) {
@@ -459,7 +457,7 @@ export class Controller {
} catch (error) {
// Task not in history yet (new task with no messages); catch the
// error to enable the agent to continue making progress.
console.log(`[Controller.cancelTask] Task not found in history: ${error}`)
Logger.log(`[Controller.cancelTask] Task not found in history: ${error}`)
}
// Only re-initialize if we found a history item, otherwise just clear
@@ -537,7 +535,7 @@ export class Controller {
await this.postStateToWebview()
} catch (error) {
console.error("Failed to handle auth callback:", error)
Logger.error("Failed to handle auth callback:", error)
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message: "Failed to log in to Cline",
@@ -782,7 +780,7 @@ export class Controller {
async exportTaskWithId(id: string) {
const { taskDirPath } = await this.getTaskWithId(id)
console.log(`[EXPORT] Opening task directory: ${taskDirPath}`)
Logger.log(`[EXPORT] Opening task directory: ${taskDirPath}`)
await open(taskDirPath)
}
@@ -1015,7 +1013,7 @@ export class Controller {
try {
return BannerService.get().getActiveBanners()
} catch (err) {
console.log(err)
Logger.log(err)
return []
}
}
+2 -1
View File
@@ -3,6 +3,7 @@ import { StringRequest } from "@shared/proto/cline/common"
import { McpDownloadResponse } from "@shared/proto/cline/mcp"
import axios from "axios"
import { ClineEnv } from "@/config"
import { Logger } from "@/services/logging/Logger"
import { getAxiosSettings } from "@/shared/net"
import { Controller } from ".."
import { sendChatButtonClickedEvent } from "../ui/subscribeToChatButtonClicked"
@@ -45,7 +46,7 @@ export async function downloadMcp(controller: Controller, request: StringRequest
throw new Error("Invalid response from MCP marketplace API")
}
console.log("[downloadMcp] Response from download API", { response })
Logger.log("[downloadMcp] Response from download API", { response })
const mcpDetails = response.data
@@ -14,11 +14,8 @@ export async function setWelcomeViewCompleted(controller: Controller, request: B
controller.stateManager.setGlobalState("welcomeViewCompleted", request.value)
await controller.postStateToWebview()
console.log(`Welcome view completed set to: ${request.value}`)
return Empty.create({})
} catch (error) {
console.error("Failed to set welcome view completed:", error)
throw error
}
}
@@ -1,4 +1,5 @@
import type { EmptyRequest, String as ProtoString } from "@shared/proto/cline/common"
import { Logger } from "@/services/logging/Logger"
import { getRequestRegistry, type StreamingResponseHandler } from "../grpc-handler"
import type { Controller } from "../index"
@@ -18,7 +19,7 @@ export async function subscribeToAddToInput(
responseStream: StreamingResponseHandler<ProtoString>,
requestId?: string,
): Promise<void> {
console.log("[DEBUG] set up addToInput subscription")
Logger.log("[DEBUG] set up addToInput subscription")
// Add this subscription to the active subscriptions
activeAddToInputSubscriptions.add(responseStream)
@@ -26,7 +27,7 @@ export async function subscribeToAddToInput(
// Register cleanup when the connection is closed
const cleanup = () => {
activeAddToInputSubscriptions.delete(responseStream)
console.log("[DEBUG] Cleaned up addToInput subscription")
Logger.log("[DEBUG] Cleaned up addToInput subscription")
}
// Register the cleanup function with the request registry if we have a requestId
@@ -50,9 +51,9 @@ export async function sendAddToInputEvent(text: string): Promise<void> {
event,
false, // Not the last message
)
console.log("[DEBUG] sending addToInput event", text.length, "chars")
Logger.log("[DEBUG] sending addToInput event", text.length, "chars")
} catch (error) {
console.error("Error sending addToInput event:", error)
Logger.error("Error sending addToInput event:", error)
// Remove the subscription if there was an error
activeAddToInputSubscriptions.delete(responseStream)
}
@@ -1,4 +1,5 @@
import { Empty, EmptyRequest } from "@shared/proto/cline/common"
import { Logger } from "@/services/logging/Logger"
import { getRequestRegistry, StreamingResponseHandler } from "../grpc-handler"
import { Controller } from "../index"
@@ -18,7 +19,7 @@ export async function subscribeToChatButtonClicked(
responseStream: StreamingResponseHandler<Empty>,
requestId?: string,
): Promise<void> {
console.log(`[DEBUG] set up chatButtonClicked subscription`)
Logger.log(`[DEBUG] set up chatButtonClicked subscription`)
// Add this subscription to the active subscriptions
activeChatButtonClickedSubscriptions.add(responseStream)
@@ -1,4 +1,5 @@
import { Empty, EmptyRequest } from "@shared/proto/cline/common"
import { Logger } from "@/services/logging/Logger"
import { getRequestRegistry, StreamingResponseHandler } from "../grpc-handler"
import { Controller } from "../index"
@@ -18,7 +19,7 @@ export async function subscribeToMcpButtonClicked(
responseStream: StreamingResponseHandler<Empty>,
requestId?: string,
): Promise<void> {
console.log(`[DEBUG] set up mcpButtonClicked subscription`)
Logger.log(`[DEBUG] set up mcpButtonClicked subscription`)
// Add this subscription to the active subscriptions
activeMcpButtonClickedSubscriptions.add(responseStream)
@@ -3,9 +3,13 @@ import { ClineMessage } from "@shared/proto/cline/ui"
import { getRequestRegistry, StreamingResponseHandler } from "../grpc-handler"
import { Controller } from "../index"
// Keep track of active partial message subscriptions
// Keep track of active partial message subscriptions (gRPC streams)
const activePartialMessageSubscriptions = new Set<StreamingResponseHandler<ClineMessage>>()
// Keep track of callback-based subscriptions (for CLI and other non-gRPC consumers)
export type PartialMessageCallback = (message: ClineMessage) => void
const callbackSubscriptions = new Set<PartialMessageCallback>()
/**
* Subscribe to partial message events
* @param controller The controller instance
@@ -33,13 +37,25 @@ export async function subscribeToPartialMessage(
}
}
/**
* Register a callback to receive partial message events (for CLI and non-gRPC consumers)
* @param callback The callback function to receive messages
* @returns A function to unsubscribe
*/
export function registerPartialMessageCallback(callback: PartialMessageCallback): () => void {
callbackSubscriptions.add(callback)
return () => {
callbackSubscriptions.delete(callback)
}
}
/**
* Send a partial message event to all active subscribers
* @param partialMessage The ClineMessage to send
*/
export async function sendPartialMessageEvent(partialMessage: ClineMessage): Promise<void> {
// Send the event to all active subscribers
const promises = Array.from(activePartialMessageSubscriptions).map(async (responseStream) => {
// Send to gRPC stream subscribers
const streamPromises = Array.from(activePartialMessageSubscriptions).map(async (responseStream) => {
try {
await responseStream(
partialMessage,
@@ -52,5 +68,14 @@ export async function sendPartialMessageEvent(partialMessage: ClineMessage): Pro
}
})
await Promise.all(promises)
// Send to callback subscribers (synchronous)
for (const callback of callbackSubscriptions) {
try {
callback(partialMessage)
} catch (error) {
console.error("Error in partial message callback:", error)
}
}
await Promise.all(streamPromises)
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { Logger } from "@/services/logging/Logger"
import { telemetryService } from "../../services/telemetry"
import { getAllHooksDirs } from "../storage/disk"
import { HookFactory, Hooks } from "./hook-factory"
@@ -292,7 +293,7 @@ export class HookDiscoveryCache {
*/
private log(message: string): void {
if (this.debug) {
console.log(`[HookCache] ${message}`)
Logger.log(`[HookCache] ${message}`)
}
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { Logger } from "@/services/logging/Logger"
import { HookProcess } from "./HookProcess"
/**
@@ -39,7 +40,7 @@ export class HookProcessRegistry {
static async terminateAll(): Promise<void> {
const processes = Array.from(HookProcessRegistry.activeProcesses)
if (processes.length > 0) {
console.log(`[HookProcessRegistry] Terminating ${processes.length} active hook process(es)`)
Logger.log(`[HookProcessRegistry] Terminating ${processes.length} active hook process(es)`)
await Promise.all(processes.map((p) => p.terminate()))
HookProcessRegistry.activeProcesses.clear()
}
+2 -1
View File
@@ -1,6 +1,7 @@
import type { HookOutputStreamMeta } from "@shared/ExtensionMessage"
import { ClineMessage } from "@shared/ExtensionMessage"
import type { HookOutput } from "@shared/proto/cline/hooks"
import { Logger } from "@/services/logging/Logger"
import { MessageStateHandler } from "../task/message-state"
import { HookExecutionError } from "./HookError"
import { HookFactory } from "./hook-factory"
@@ -151,7 +152,7 @@ export async function executeHook<Name extends keyof Hooks>(options: HookExecuti
...hookInput,
})
console.log(`[${hookName} Hook]`, result)
Logger.log(`[${hookName} Hook]`, result)
// NoOp hooks return proto defaults; preserve the minimal legacy return shape.
if (result.cancel === false && result.contextModification === "" && result.errorMessage === "") {
+3 -2
View File
@@ -1,6 +1,7 @@
import { findLastIndex } from "@shared/array"
import type { ClineMessage } from "@shared/ExtensionMessage"
import type { ClineStorageMessage } from "@shared/messages/content"
import { Logger } from "@/services/logging/Logger"
import type { ContextManager } from "../context/context-management/ContextManager"
import type { MessageStateHandler } from "../task/message-state"
@@ -246,7 +247,7 @@ export async function executePreCompactHookWithCleanup(params: PreCompactHookPar
if (preCompactResult.cancel === true) {
// Log cancellation for debugging
const cancellationSource = preCompactResult.wasCancelled ? "user" : "PreCompact hook"
console.log(`[PreCompact] Context compaction cancelled by ${cancellationSource} for task ${params.taskId}`)
Logger.log(`[PreCompact] Context compaction cancelled by ${cancellationSource} for task ${params.taskId}`)
// Internalized cancellation state management (replaces handleCancellation callback)
// Always save state before cancelling, regardless of cancellation source
@@ -266,7 +267,7 @@ export async function executePreCompactHookWithCleanup(params: PreCompactHookPar
// Hook completed successfully - log if context modification provided
if (preCompactResult.contextModification) {
console.log(`[PreCompact] Hook provided context modification for task ${params.taskId}`)
Logger.log(`[PreCompact] Hook provided context modification for task ${params.taskId}`)
}
return {
@@ -2,6 +2,25 @@ import { SystemPromptSection } from "../templates/placeholders"
import { TemplateEngine } from "../templates/TemplateEngine"
import type { PromptVariant, SystemPromptContext } from "../types"
const AUTO_FORMATTING_SECTION_IDE = `# Auto-formatting Considerations
- After using either write_to_file or replace_in_file, the user's editor may automatically format the file
- This auto-formatting may modify the file contents, for example:
- Breaking single lines into multiple lines
- Adjusting indentation to match project style (e.g. 2 spaces vs 4 spaces vs tabs)
- Converting single quotes to double quotes (or vice versa based on project preferences)
- Organizing imports (e.g. sorting, grouping by type)
- Adding/removing trailing commas in objects and arrays
- Enforcing consistent brace style (e.g. same-line vs new-line)
- Standardizing semicolon usage (adding or removing based on style)
- The write_to_file and replace_in_file tool responses will include the final state of the file after any auto-formatting
- Use this final state as your reference point for any subsequent edits. This is ESPECIALLY important when crafting SEARCH blocks for replace_in_file which require the content to match what's in the file exactly.`
const AUTO_FORMATTING_SECTION_CLI = `# Formatting Considerations
- Files are saved exactly as written. Ensure proper formatting, indentation, and style consistency in your edits.
- When crafting SEARCH blocks for replace_in_file, match exactly what you wrote previously.`
const EDITING_FILES_TEMPLATE_TEXT = `EDITING FILES
You have access to two tools for working with files: **write_to_file** and **replace_in_file**. Understanding their roles and selecting the right one for the job will help ensure efficient and accurate modifications.
@@ -52,19 +71,7 @@ You have access to two tools for working with files: **write_to_file** and **rep
- The file is relatively small and the changes affect most of its content
- You're generating boilerplate or template files
# Auto-formatting Considerations
- After using either write_to_file or replace_in_file, the user's editor may automatically format the file
- This auto-formatting may modify the file contents, for example:
- Breaking single lines into multiple lines
- Adjusting indentation to match project style (e.g. 2 spaces vs 4 spaces vs tabs)
- Converting single quotes to double quotes (or vice versa based on project preferences)
- Organizing imports (e.g. sorting, grouping by type)
- Adding/removing trailing commas in objects and arrays
- Enforcing consistent brace style (e.g. same-line vs new-line)
- Standardizing semicolon usage (adding or removing based on style)
- The write_to_file and replace_in_file tool responses will include the final state of the file after any auto-formatting
- Use this final state as your reference point for any subsequent edits. This is ESPECIALLY important when crafting SEARCH blocks for replace_in_file which require the content to match what's in the file exactly.
{{AUTO_FORMATTING_SECTION}}
# Workflow Tips
@@ -78,5 +85,11 @@ By thoughtfully selecting between write_to_file and replace_in_file, you can mak
export async function getEditingFilesSection(variant: PromptVariant, context: SystemPromptContext): Promise<string> {
const template = variant.componentOverrides?.[SystemPromptSection.EDITING_FILES]?.template || EDITING_FILES_TEMPLATE_TEXT
return new TemplateEngine().resolve(template, context, {})
// Use CLI-specific auto-formatting section when running in CLI mode
// CLI has no IDE to auto-format files
const autoFormattingSection = context.isCliEnvironment ? AUTO_FORMATTING_SECTION_CLI : AUTO_FORMATTING_SECTION_IDE
return new TemplateEngine().resolve(template, context, {
AUTO_FORMATTING_SECTION: autoFormattingSection,
})
}
@@ -6,6 +6,8 @@ const BROWSER_RULES = `- The user may ask generic non-development tasks, such as
const BROWSER_WAIT_RULES = ` Then if you want to test your work, you might use browser_action to launch the site, wait for the user's response confirming the site was launched along with a screenshot, then perhaps e.g., click a button to test functionality if needed, wait for the user's response confirming the button was clicked along with a screenshot of the new state, before finally closing the browser.`
const CLI_RULES = `- After making code changes, consider running any available validation tools for the project (such as type checkers, linters, or build scripts like \`npm run lint\`, \`npx tsc --noEmit\`, \`npm run build\`) to catch errors, since you won't receive automatic diagnostics after edits.\n`
const getRulesTemplateText = (context: SystemPromptContext) => `RULES
- Your current working directory is: {{CWD}}
@@ -22,7 +24,7 @@ const getRulesTemplateText = (context: SystemPromptContext) => `RULES
- When executing commands, if you don't see the expected output, assume the terminal executed the command successfully and proceed with the task. The user's terminal may be unable to stream the output back properly.${context.yoloModeToggled !== true ? " If you absolutely need to see the actual terminal output, use the ask_followup_question tool to request the user to copy and paste it back to you." : ""}
- The user may provide a file's contents directly in their message, in which case you shouldn't use the read_file tool to get the file contents again since you already have it.
- Your goal is to try to accomplish the user's task, NOT engage in a back and forth conversation.
{{BROWSER_RULES}}- NEVER end attempt_completion result with a question or request to engage in further conversation! Formulate the end of your result in a way that is final and does not require further input from the user.
{{BROWSER_RULES}}{{CLI_RULES}}- NEVER end attempt_completion result with a question or request to engage in further conversation! Formulate the end of your result in a way that is final and does not require further input from the user.
- You are STRICTLY FORBIDDEN from starting your messages with "Great", "Certainly", "Okay", "Sure". You should NOT be conversational in your responses, but rather direct and to the point. For example you should NOT say "Great, I've updated the CSS" but instead something like "I've updated the CSS". It is important you be clear and technical in your messages.
- When presented with images, utilize your vision capabilities to thoroughly examine them and extract meaningful information. Incorporate these insights into your thought process as you accomplish the user's task.
- At the end of each user message, you will automatically receive environment_details. This information is not written by the user themselves, but is auto-generated to provide potentially relevant context about the project structure and environment. While this information can be valuable for understanding the project context, do not treat it as a direct part of the user's request or response. Use it to inform your actions and decisions, but don't assume the user is explicitly asking about or referring to this information unless they clearly do so in their message. When using environment_details, explain your actions clearly to ensure the user understands, as they may not be aware of these details.
@@ -38,10 +40,12 @@ export async function getRulesSection(variant: PromptVariant, context: SystemPro
const browserRules = context.supportsBrowserUse ? BROWSER_RULES : ""
const browserWaitRules = context.supportsBrowserUse ? BROWSER_WAIT_RULES : ""
const cliRules = context.isCliEnvironment ? CLI_RULES : ""
return new TemplateEngine().resolve(template, context, {
CWD: context.cwd || process.cwd(),
BROWSER_RULES: browserRules,
BROWSER_WAIT_RULES: browserWaitRules,
CLI_RULES: cliRules,
})
}
+1
View File
@@ -120,6 +120,7 @@ export interface SystemPromptContext {
readonly workspaceRoots?: Array<{ path: string; name: string; vcs?: string }>
readonly isSubagentsEnabledAndCliInstalled?: boolean
readonly isCliSubagent?: boolean
readonly isCliEnvironment?: boolean
readonly enableNativeToolCalls?: boolean
readonly enableParallelToolCalling?: boolean
readonly terminalExecutionMode?: "vscodeTerminal" | "backgroundExec"
@@ -10,13 +10,13 @@
* 3. Use the builder pattern for type safety
* 4. Run validation to ensure correctness
*/
import { ModelFamily } from "@/shared/prompts"
import { ClineDefaultTool } from "@/shared/tools"
import { PromptVariant } from ".."
import { SystemPromptSection } from "../templates/placeholders"
import { baseTemplate } from "./generic/template"
import { createVariant } from "./variant-builder"
import { validateVariant } from "./variant-validator"
// Type-safe variant configuration using the builder pattern
export const config: Omit<PromptVariant, "id"> = createVariant(ModelFamily.GENERIC) // Change to your target model family
@@ -82,17 +82,6 @@ export const config: Omit<PromptVariant, "id"> = createVariant(ModelFamily.GENER
// })
.build()
// Compile-time validation (optional but recommended)
const validationResult = validateVariant({ ...config, id: "template" }, { strict: true })
if (!validationResult.isValid) {
console.error("Variant configuration validation failed:", validationResult.errors)
throw new Error(`Invalid variant configuration: ${validationResult.errors.join(", ")}`)
}
if (validationResult.warnings.length > 0) {
console.warn("Variant configuration warnings:", validationResult.warnings)
}
// Export type information for better IDE support
export type VariantConfig = typeof config
@@ -3,7 +3,6 @@ import { ClineDefaultTool } from "@/shared/tools"
import { isDevstralModelFamily } from "@/utils/model-utils"
import { SystemPromptSection } from "../../templates/placeholders"
import { createVariant } from "../variant-builder"
import { validateVariant } from "../variant-validator"
import { DEVSTRAL_AGENT_ROLE_TEMPLATE } from "./overrides"
import { baseTemplate } from "./template"
@@ -63,16 +62,5 @@ export const config = createVariant(ModelFamily.DEVSTRAL)
})
.build()
// Compile-time validation
const validationResult = validateVariant({ ...config, id: "devstral" }, { strict: true })
if (!validationResult.isValid) {
console.error("Devstral variant configuration validation failed:", validationResult.errors)
throw new Error(`Invalid Devstral variant configuration: ${validationResult.errors.join(", ")}`)
}
if (validationResult.warnings.length > 0) {
console.warn("Devstral variant configuration warnings:", validationResult.warnings)
}
// Export type information for better IDE support
export type DevstralVariantConfig = typeof config
@@ -3,7 +3,6 @@ import { ClineDefaultTool } from "@/shared/tools"
import { isGemini3ModelFamily, isNextGenModelProvider } from "@/utils/model-utils"
import { SystemPromptSection } from "../../templates/placeholders"
import { createVariant } from "../variant-builder"
import { validateVariant } from "../variant-validator"
import { gemini3ComponentOverrides } from "./overrides"
import { baseTemplate } from "./template"
@@ -81,16 +80,5 @@ export const config = createVariant(ModelFamily.GEMINI_3)
.overrideComponent(SystemPromptSection.TASK_PROGRESS, gemini3ComponentOverrides[SystemPromptSection.TASK_PROGRESS]!)
.build()
// Compile-time validation
const validationResult = validateVariant({ ...config, id: "gemini3" }, { strict: true })
if (!validationResult.isValid) {
console.error("Gemini 3.0 variant configuration validation failed:", validationResult.errors)
throw new Error(`Invalid Gemini 3.0 variant configuration: ${validationResult.errors.join(", ")}`)
}
if (validationResult.warnings.length > 0) {
console.warn("Gemini 3.0 variant configuration warnings:", validationResult.warnings)
}
// Export type information for better IDE support
export type Gemini3VariantConfig = typeof config
@@ -3,7 +3,6 @@ import { ModelFamily } from "@/shared/prompts"
import { ClineDefaultTool } from "@/shared/tools"
import { SystemPromptSection } from "../../templates/placeholders"
import { createVariant } from "../variant-builder"
import { validateVariant } from "../variant-validator"
import { baseTemplate } from "./template"
export const config = createVariant(ModelFamily.GENERIC)
@@ -72,16 +71,5 @@ export const config = createVariant(ModelFamily.GENERIC)
.config({})
.build()
// Compile-time validation
const validationResult = validateVariant({ ...config, id: "generic" }, { strict: true })
if (!validationResult.isValid) {
console.error("Generic variant configuration validation failed:", validationResult.errors)
throw new Error(`Invalid generic variant configuration: ${validationResult.errors.join(", ")}`)
}
if (validationResult.warnings.length > 0) {
console.warn("Generic variant configuration warnings:", validationResult.warnings)
}
// Export type information for better IDE support
export type GenericVariantConfig = typeof config
@@ -3,7 +3,6 @@ import { ClineDefaultTool } from "@/shared/tools"
import { isGLMModelFamily } from "@/utils/model-utils"
import { SystemPromptSection } from "../../templates/placeholders"
import { createVariant } from "../variant-builder"
import { validateVariant } from "../variant-validator"
import { glmComponentOverrides } from "./overrides"
import { baseTemplate } from "./template"
@@ -66,16 +65,5 @@ export const config = createVariant(ModelFamily.GLM)
.overrideComponent(SystemPromptSection.MCP, glmComponentOverrides[SystemPromptSection.MCP])
.build()
// Compile-time validation
const validationResult = validateVariant({ ...config, id: "glm" }, { strict: true })
if (!validationResult.isValid) {
console.error("GLM variant configuration validation failed:", validationResult.errors)
throw new Error(`Invalid GLM variant configuration: ${validationResult.errors.join(", ")}`)
}
if (validationResult.warnings.length > 0) {
console.warn("GLM variant configuration warnings:", validationResult.warnings)
}
// Export type information for better IDE support
export type GLMVariantConfig = typeof config
@@ -3,7 +3,6 @@ import { ModelFamily } from "@/shared/prompts"
import { ClineDefaultTool } from "@/shared/tools"
import { SystemPromptSection } from "../../templates/placeholders"
import { createVariant } from "../variant-builder"
import { validateVariant } from "../variant-validator"
import { GPT_5_TEMPLATE_OVERRIDES } from "./template"
// Type-safe variant configuration using the builder pattern
@@ -75,16 +74,5 @@ export const config = createVariant(ModelFamily.GPT_5)
})
.build()
// Compile-time validation
const validationResult = validateVariant({ ...config, id: ModelFamily.GPT_5 }, { strict: true })
if (!validationResult.isValid) {
console.error("GPT-5 variant configuration validation failed:", validationResult.errors)
throw new Error(`Invalid GPT-5 variant configuration: ${validationResult.errors.join(", ")}`)
}
if (validationResult.warnings.length > 0) {
console.warn("GPT-5 variant configuration warnings:", validationResult.warnings)
}
// Export type information for better IDE support
export type GPT5VariantConfig = typeof config
@@ -3,7 +3,6 @@ import { ClineDefaultTool } from "@/shared/tools"
import { isHermesModelFamily } from "@/utils/model-utils"
import { SystemPromptSection } from "../../templates/placeholders"
import { createVariant } from "../variant-builder"
import { validateVariant } from "../variant-validator"
import { hermesComponentOverrides } from "./overrides"
import { baseTemplate } from "./template"
@@ -69,16 +68,5 @@ export const config = createVariant(ModelFamily.HERMES)
.overrideComponent(SystemPromptSection.MCP, hermesComponentOverrides[SystemPromptSection.MCP])
.build()
// Compile-time validation
const validationResult = validateVariant({ ...config, id: "hermes" }, { strict: true })
if (!validationResult.isValid) {
console.error("Hermes variant configuration validation failed:", validationResult.errors)
throw new Error(`Invalid Hermes variant configuration: ${validationResult.errors.join(", ")}`)
}
if (validationResult.warnings.length > 0) {
console.warn("Hermes variant configuration warnings:", validationResult.warnings)
}
// Export type information for better IDE support
export type HermesVariantConfig = typeof config
@@ -3,7 +3,6 @@ import { ModelFamily } from "@/shared/prompts"
import { ClineDefaultTool } from "@/shared/tools"
import { SystemPromptSection } from "../../templates/placeholders"
import { createVariant } from "../variant-builder"
import { validateVariant } from "../variant-validator"
import { gpt51ComponentOverrides } from "./overrides"
import { GPT_5_1_TEMPLATE_OVERRIDES } from "./template"
@@ -85,16 +84,5 @@ export const config = createVariant(ModelFamily.NATIVE_GPT_5_1)
.overrideComponent(SystemPromptSection.FEEDBACK, gpt51ComponentOverrides[SystemPromptSection.FEEDBACK]!)
.build()
// Compile-time validation
const validationResult = validateVariant({ ...config, id: ModelFamily.NATIVE_GPT_5_1 }, { strict: true })
if (!validationResult.isValid) {
console.error("GPT-5-1 variant configuration validation failed:", validationResult.errors)
throw new Error(`Invalid GPT-5-1 variant configuration: ${validationResult.errors.join(", ")}`)
}
if (validationResult.warnings.length > 0) {
console.warn("GPT-5-1 variant configuration warnings:", validationResult.warnings)
}
// Export type information for better IDE support
export type GPT51VariantConfig = typeof config
@@ -3,7 +3,6 @@ import { ModelFamily } from "@/shared/prompts"
import { ClineDefaultTool } from "@/shared/tools"
import { SystemPromptSection } from "../../templates/placeholders"
import { createVariant } from "../variant-builder"
import { validateVariant } from "../variant-validator"
import { GPT_5_TEMPLATE_OVERRIDES } from "./template"
// Type-safe variant configuration using the builder pattern
@@ -103,16 +102,5 @@ export const config = createVariant(ModelFamily.NATIVE_GPT_5)
})
.build()
// Compile-time validation
const validationResult = validateVariant({ ...config, id: ModelFamily.NATIVE_GPT_5 }, { strict: true })
if (!validationResult.isValid) {
console.error("GPT-5 variant configuration validation failed:", validationResult.errors)
throw new Error(`Invalid GPT-5 variant configuration: ${validationResult.errors.join(", ")}`)
}
if (validationResult.warnings.length > 0) {
console.warn("GPT-5 variant configuration warnings:", validationResult.warnings)
}
// Export type information for better IDE support
export type GPT5VariantConfig = typeof config
@@ -3,7 +3,6 @@ import { ModelFamily } from "@/shared/prompts"
import { ClineDefaultTool } from "@/shared/tools"
import { SystemPromptSection } from "../../templates/placeholders"
import { createVariant } from "../variant-builder"
import { validateVariant } from "../variant-validator"
import { TEMPLATE_OVERRIDES } from "./template"
// Type-safe variant configuration using the builder pattern
@@ -85,16 +84,5 @@ export const config = createVariant(ModelFamily.NATIVE_NEXT_GEN)
})
.build()
// Compile-time validation
const validationResult = validateVariant({ ...config, id: ModelFamily.NATIVE_NEXT_GEN }, { strict: true })
if (!validationResult.isValid) {
console.error("Native Next Gen variant configuration validation failed:", validationResult.errors)
throw new Error(`Invalid Native Next Gen variant configuration: ${validationResult.errors.join(", ")}`)
}
if (validationResult.warnings.length > 0) {
console.warn("Native Next Gen variant configuration warnings:", validationResult.warnings)
}
// Export type information for better IDE support
export type NativeNextGenVariantConfig = typeof config
@@ -3,7 +3,6 @@ import { ModelFamily } from "@/shared/prompts"
import { ClineDefaultTool } from "@/shared/tools"
import { SystemPromptSection } from "../../templates/placeholders"
import { createVariant } from "../variant-builder"
import { validateVariant } from "../variant-validator"
import { baseTemplate, rules_template } from "./template"
// Type-safe variant configuration using the builder pattern
@@ -78,16 +77,5 @@ export const config = createVariant(ModelFamily.NEXT_GEN)
})
.build()
// Compile-time validation
const validationResult = validateVariant({ ...config, id: ModelFamily.NEXT_GEN }, { strict: true })
if (!validationResult.isValid) {
console.error("Next-gen variant configuration validation failed:", validationResult.errors)
throw new Error(`Invalid next-gen variant configuration: ${validationResult.errors.join(", ")}`)
}
if (validationResult.warnings.length > 0) {
console.warn("Next-gen variant configuration warnings:", validationResult.warnings)
}
// Export type information for better IDE support
export type NextGenVariantConfig = typeof config
+29 -7
View File
@@ -16,10 +16,11 @@ import {
SettingsKey,
} from "@shared/storage/state-keys"
import chokidar, { FSWatcher } from "chokidar"
import type { ExtensionContext } from "vscode"
import { HostProvider } from "@/hosts/host-provider"
import { Logger } from "@/services/logging/Logger"
import { ClineExtensionContext } from "@/shared/clients"
import { ShowMessageType } from "@/shared/proto/index.host"
import { secretStorage } from "@/shared/storage/ClineSecretStorage"
import {
getTaskHistoryStateFilePath,
readTaskHistoryFromState,
@@ -60,7 +61,7 @@ export class StateManager {
private remoteConfigCache: Partial<RemoteConfigFields> = {} as RemoteConfigFields
private secretsCache: Secrets = {} as Secrets
private workspaceStateCache: LocalState = {} as LocalState
private context: ExtensionContext
private context: ClineExtensionContext
private isInitialized = false
// In-memory model info cache (not persisted to disk)
@@ -102,14 +103,15 @@ export class StateManager {
// Callback to sync external state changes with the UI client
onSyncExternalChange?: () => void | Promise<void>
private constructor(context: ExtensionContext) {
private constructor(context: ClineExtensionContext) {
this.context = context
secretStorage.init(context.secrets)
}
/**
* Initialize the cache by loading data from disk
*/
public static async initialize(context: ExtensionContext): Promise<StateManager> {
public static async initialize(context: ClineExtensionContext): Promise<StateManager> {
if (!StateManager.instance) {
StateManager.instance = new StateManager(context)
}
@@ -121,7 +123,7 @@ export class StateManager {
try {
// Load all extension state from disk
const globalState = await readGlobalStateFromDisk(context)
const secrets = await readSecretsFromDisk(context)
const secrets = await readSecretsFromDisk()
const workspaceState = await readWorkspaceStateFromDisk(context)
// Populate the cache with all extension state and secrets fields
@@ -779,9 +781,9 @@ export class StateManager {
Array.from(keys).map((key) => {
const value = this.secretsCache[key]
if (value) {
return this.context.secrets.store(key, value)
return secretStorage.store(key, value)
} else {
return this.context.secrets.delete(key)
return secretStorage.delete(key)
}
}),
)
@@ -861,4 +863,24 @@ export class StateManager {
return { ...secrets, ...settings } satisfies ApiConfiguration
}
/**
* Get all global state entries (for debugging/inspection)
*/
getAllGlobalStateEntries(): Record<string, unknown> {
if (!this.isInitialized) {
throw new Error(STATE_MANAGER_NOT_INITIALIZED)
}
return { ...this.globalStateCache }
}
/**
* Get all workspace state entries (for debugging/inspection)
*/
getAllWorkspaceStateEntries(): Record<string, unknown> {
if (!this.isInitialized) {
throw new Error(STATE_MANAGER_NOT_INITIALIZED)
}
return { ...this.workspaceStateCache }
}
}
+6 -5
View File
@@ -11,13 +11,14 @@ import {
SecretKeys,
Secrets,
} from "@shared/storage/state-keys"
import { ExtensionContext } from "vscode"
import { Controller } from "@/core/controller"
import { ClineExtensionContext } from "@/shared/clients"
import { ClineRulesToggles } from "@/shared/cline-rules"
import { secretStorage } from "@/shared/storage/ClineSecretStorage"
import { readTaskHistoryFromState } from "../disk"
export async function readSecretsFromDisk(context: ExtensionContext): Promise<Secrets> {
const secrets = await Promise.all(SecretKeys.map((key) => context.secrets.get(key)))
export async function readSecretsFromDisk(): Promise<Secrets> {
const secrets = await Promise.all(SecretKeys.map((key) => secretStorage.get(key)))
return SecretKeys.reduce((acc, key, index) => {
acc[key] = secrets[index]
@@ -25,7 +26,7 @@ export async function readSecretsFromDisk(context: ExtensionContext): Promise<Se
}, {} as Secrets)
}
export async function readWorkspaceStateFromDisk(context: ExtensionContext): Promise<LocalState> {
export async function readWorkspaceStateFromDisk(context: ClineExtensionContext): Promise<LocalState> {
const states = LocalStateKeys.map((key) => context.workspaceState.get<ClineRulesToggles | undefined>(key))
return LocalStateKeys.reduce((acc, key, index) => {
@@ -34,7 +35,7 @@ export async function readWorkspaceStateFromDisk(context: ExtensionContext): Pro
}, {} as LocalState)
}
export async function readGlobalStateFromDisk(context: ExtensionContext): Promise<GlobalStateAndSettings> {
export async function readGlobalStateFromDisk(context: ClineExtensionContext): Promise<GlobalStateAndSettings> {
try {
// Batch read all state values in a single optimized pass
const stateValues = new Map<string, any>()
+8 -3
View File
@@ -83,6 +83,7 @@ import {
} from "@/integrations/terminal"
import { ClineError, ClineErrorType, ErrorService } from "@/services/error"
import { telemetryService } from "@/services/telemetry"
import { ClineClient } from "@/shared/clients"
import {
ClineAssistantContent,
ClineContent,
@@ -1704,7 +1705,8 @@ export class Task {
})
const providerInfo = this.getCurrentProviderInfo()
const ide = (await HostProvider.env.getHostVersion({})).platform || "Unknown"
const { platform, clineType } = await HostProvider.env.getHostVersion({})
const ide = platform || "Unknown"
const browserSettings = this.stateManager.getGlobalSettingsKey("browserSettings")
const disableBrowserTool = browserSettings.disableToolUse ?? false
// cline browser tool uses image recognition for navigation (requires model image support).
@@ -1824,6 +1826,7 @@ export class Task {
workspaceRoots,
isSubagentsEnabledAndCliInstalled,
isCliSubagent,
isCliEnvironment: clineType === ClineClient.Cli,
enableNativeToolCalls:
providerInfo.model.info.apiFormat === ApiFormat.OPENAI_RESPONSES ||
this.stateManager.getGlobalStateKey("nativeToolCallEnabled"),
@@ -2477,6 +2480,7 @@ export class Task {
await this.postStateToWebview()
try {
const host = await HostProvider.env.getHostVersion({})
const taskMetrics: {
cacheWriteTokens: number
cacheReadTokens: number
@@ -2495,8 +2499,6 @@ export class Task {
if (lastMessage && lastMessage.partial) {
// lastMessage.ts = Date.now() DO NOT update ts since it is used as a key for virtuoso list
lastMessage.partial = false
// instead of streaming partialMessage events, we do a save and post like normal to persist to disk
console.log("updating partial message", lastMessage)
// await this.saveClineMessagesAndUpdateHistory()
}
// update api_req_started to have cancelled and cost, so that we can display the cost of the partial stream
@@ -2538,6 +2540,7 @@ export class Task {
},
cost: taskMetrics.totalCost,
},
metadata: { ...host },
})
telemetryService.captureConversationTurnEvent(
@@ -2869,6 +2872,7 @@ export class Task {
},
cost: taskMetrics.totalCost,
},
metadata: { ...host },
})
}
}
@@ -2961,6 +2965,7 @@ export class Task {
},
cost: taskMetrics.totalCost,
},
metadata: { ...host },
})
let response: ClineAskResponse
+1 -6
View File
@@ -150,8 +150,6 @@ export class AuthHandler {
}
private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
console.log("AuthHandler: Received request", req.url)
if (!req.url) {
this.sendResponse(res, 404, "text/plain", "Not found")
return
@@ -167,10 +165,7 @@ export class AuthHandler {
let redirectUri: string | undefined
try {
redirectUri = (await HostProvider.env.getIdeRedirectUri({})).value
console.log("AuthHandler: Got redirect URI:", redirectUri)
} catch (error) {
// CLI or JetBrains mode - redirect not available
console.log("AuthHandler: No redirect URI available (CLI/JetBrains mode)")
} catch {
redirectUri = undefined
}
+6 -11
View File
@@ -4,6 +4,7 @@ import { URL } from "url"
import type { ExtensionContext } from "vscode"
import { z } from "zod"
import { fetch } from "@/shared/net"
import { secretStorage } from "@/shared/storage/ClineSecretStorage"
/**
* OpenAI Codex OAuth Configuration
@@ -87,11 +88,7 @@ function parseJwtClaims(token: string): IdTokenClaims | undefined {
* 3. First organization ID
*/
function extractAccountIdFromClaims(claims: IdTokenClaims): string | undefined {
return (
claims.chatgpt_account_id ||
claims["https://api.openai.com/auth"]?.chatgpt_account_id ||
claims.organizations?.[0]?.id
)
return claims.chatgpt_account_id || claims["https://api.openai.com/auth"]?.chatgpt_account_id || claims.organizations?.[0]?.id
}
/**
@@ -148,9 +145,7 @@ function parseOAuthErrorDetails(errorText: string): { errorCode?: string; errorM
const errorCode: string | undefined =
typeof errorField === "string"
? errorField
: errorField &&
typeof errorField === "object" &&
typeof (errorField as Record<string, unknown>).type === "string"
: errorField && typeof errorField === "object" && typeof (errorField as Record<string, unknown>).type === "string"
? ((errorField as Record<string, unknown>).type as string)
: undefined
@@ -399,7 +394,7 @@ export class OpenAiCodexOAuthManager {
}
try {
const credentialsJson = await this.context.secrets.get(OPENAI_CODEX_CREDENTIALS_KEY)
const credentialsJson = await secretStorage.get(OPENAI_CODEX_CREDENTIALS_KEY)
if (!credentialsJson) {
return null
}
@@ -421,7 +416,7 @@ export class OpenAiCodexOAuthManager {
throw new Error("OAuth manager not initialized")
}
await this.context.secrets.store(OPENAI_CODEX_CREDENTIALS_KEY, JSON.stringify(credentials))
await secretStorage.store(OPENAI_CODEX_CREDENTIALS_KEY, JSON.stringify(credentials))
this.credentials = credentials
}
@@ -433,7 +428,7 @@ export class OpenAiCodexOAuthManager {
return
}
await this.context.secrets.delete(OPENAI_CODEX_CREDENTIALS_KEY)
await secretStorage.delete(OPENAI_CODEX_CREDENTIALS_KEY)
this.credentials = null
}
+10 -3
View File
@@ -1,4 +1,5 @@
import { isPostHogConfigValid, PostHogClientConfig, posthogConfig } from "@/shared/services/config/posthog-config"
import { Logger } from "../logging/Logger"
import { ClineError } from "./ClineError"
import { IErrorProvider } from "./providers/IErrorProvider"
import { PostHogErrorProvider } from "./providers/PostHogErrorProvider"
@@ -50,6 +51,12 @@ export class ErrorProviderFactory {
* @returns Default configuration using PostHog
*/
public static getDefaultConfig(): ErrorProviderConfig {
if (!posthogConfig.errorTrackingApiKey) {
return {
type: "no-op",
config: posthogConfig,
}
}
return {
type: "posthog",
config: posthogConfig,
@@ -64,7 +71,7 @@ export class ErrorProviderFactory {
class NoOpErrorProvider implements IErrorProvider {
public logException(error: Error | ClineError, _properties?: Record<string, unknown>): void {
// Use console.error directly to avoid potential infinite recursion through Logger
console.error("[NoOpErrorProvider]", error.message || String(error))
Logger.error("[NoOpErrorProvider]" + error.message || String(error))
}
public logMessage(
@@ -72,7 +79,7 @@ class NoOpErrorProvider implements IErrorProvider {
level?: "error" | "warning" | "log" | "debug" | "info",
properties?: Record<string, unknown>,
): void {
console.log("[NoOpErrorProvider]", { message, level, properties })
Logger.log("[NoOpErrorProvider]", { message, level, properties })
}
public isEnabled(): boolean {
@@ -88,6 +95,6 @@ class NoOpErrorProvider implements IErrorProvider {
}
public async dispose(): Promise<void> {
console.info("[NoOpErrorProvider] Disposing")
Logger.info("[NoOpErrorProvider] disposed")
}
}
-1
View File
@@ -41,7 +41,6 @@ export class ErrorService {
public logException(error: Error | ClineError, properties?: Record<string, unknown>): void {
this.provider.logException(error, properties)
console.error("[ErrorService] Logging exception", JSON.stringify(error))
}
public logMessage(
@@ -89,8 +89,6 @@ export class PostHogErrorProvider implements IErrorProvider {
timestamp: new Date().toISOString(),
},
})
console.error("[PostHogErrorProvider] Logging exception", error)
}
public logMessage(
+6 -6
View File
@@ -11,14 +11,14 @@ export class Logger {
static warn(message: string) {
Logger.#output("WARN", message)
}
static log(message: string) {
Logger.#output("LOG", message)
static log(message: string, ...optionalParams: any[]) {
Logger.#output("LOG", message, ...optionalParams)
}
static debug(message: string) {
Logger.#output("DEBUG", message)
static debug(message: string, ...optionalParams: any[]) {
Logger.#output("DEBUG", message, ...optionalParams)
}
static info(message: string) {
Logger.#output("INFO", message)
static info(message: string, ...optionalParams: any[]) {
Logger.#output("INFO", message, ...optionalParams)
}
static trace(message: string) {
Logger.#output("TRACE", message)
+5 -4
View File
@@ -1,6 +1,7 @@
import { machineId } from "node-machine-id"
import { v4 as uuidv4 } from "uuid"
import { ExtensionContext } from "vscode"
import { Logger } from "./Logger"
/*
* Unique identifier for the current installation.
@@ -23,7 +24,7 @@ export async function initializeDistinctId(context: ExtensionContext, uuid: () =
}
if (!distinctId) {
// Fallback to generating a unique ID and keeping in global storage.
console.warn("No machine ID found for telemetry, generating UUID")
Logger.warn("No machine ID found for telemetry, generating UUID")
// Add a prefix to the UUID so we can see in the telemetry how many clients are don't have a machine ID.
distinctId = "cl-" + uuid()
context.globalState.update(_GENERATED_MACHINE_ID_KEY, distinctId)
@@ -45,7 +46,7 @@ async function getMachineId(): Promise<string | undefined> {
const id = await machineId()
return id
} catch (error) {
console.log("Failed to get machine ID from node-machine-id", error)
Logger.log("Failed to get machine ID from node-machine-id", error)
return undefined
}
}
@@ -56,7 +57,7 @@ async function getMachineId(): Promise<string | undefined> {
*/
export function setDistinctId(newId: string) {
if (_distinctId && _distinctId !== newId) {
console.log(`Changing telemetry ID from ${_distinctId} to ${newId}.`)
Logger.log(`Changing telemetry ID from ${_distinctId} to ${newId}.`)
}
_distinctId = newId
}
@@ -68,7 +69,7 @@ export function setDistinctId(newId: string) {
*/
export function getDistinctId() {
if (!_distinctId) {
console.error("Telemetry ID is not initialized. Call initializeDistinctId() first.")
Logger.error("Telemetry ID is not initialized. Call initializeDistinctId() first.")
}
return _distinctId
}
@@ -347,7 +347,6 @@ export class TelemetryService {
private telemetryMetadata: TelemetryMetadata,
) {
this.capture({ event: TelemetryService.EVENTS.USER.TELEMETRY_ENABLED })
console.info(`[TelemetryService] Initialized with ${providers.length} telemetry provider(s)`)
}
public addProvider(provider: ITelemetryProvider) {
+46 -42
View File
@@ -1,48 +1,52 @@
import { ApiFormat } from "./proto/cline/models"
import { ApiHandlerSettings } from "./storage/state-keys"
export type ApiProvider =
| "anthropic"
| "claude-code"
| "openrouter"
| "bedrock"
| "vertex"
| "openai"
| "ollama"
| "lmstudio"
| "gemini"
| "openai-native"
| "openai-codex"
| "requesty"
| "together"
| "deepseek"
| "qwen"
| "qwen-code"
| "doubao"
| "mistral"
| "vscode-lm"
| "cline"
| "litellm"
| "moonshot"
| "nebius"
| "fireworks"
| "asksage"
| "xai"
| "sambanova"
| "cerebras"
| "sapaicore"
| "groq"
| "huggingface"
| "huawei-cloud-maas"
| "dify"
| "baseten"
| "vercel-ai-gateway"
| "zai"
| "oca"
| "aihubmix"
| "minimax"
| "hicap"
| "nousResearch"
const API_PROVIDERS_LIST_BASE = [
"anthropic",
"claude-code",
"openrouter",
"bedrock",
"vertex",
"openai",
"ollama",
"lmstudio",
"gemini",
"openai-native",
"requesty",
"together",
"deepseek",
"qwen",
"qwen-code",
"doubao",
"mistral",
"vscode-lm",
"cline",
"litellm",
"moonshot",
"nebius",
"fireworks",
"asksage",
"xai",
"sambanova",
"cerebras",
"sapaicore",
"groq",
"huggingface",
"huawei-cloud-maas",
"dify",
"baseten",
"vercel-ai-gateway",
"zai",
"oca",
"aihubmix",
"minimax",
"hicap",
"nousResearch",
]
export type ApiProvider = (typeof API_PROVIDERS_LIST_BASE)[number]
export const API_PROVIDERS_LIST = API_PROVIDERS_LIST_BASE as ApiProvider[]
export const DEFAULT_API_PROVIDER = "openrouter" as ApiProvider
+244
View File
@@ -0,0 +1,244 @@
/**
* A Copy of the Type Definition for Visual Studio Code 1.84 Extension API
* See https://code.visualstudio.com/api for more information
*/
import { Memento, SecretStorage } from "vscode"
import { URI } from "vscode-uri"
enum ExtensionMode {
/**
* The extension is installed normally (for example, from the marketplace
* or VSIX) in the editor.
*/
Production = 1,
/**
* The extension is running from an `--extensionDevelopmentPath` provided
* when launching the editor.
*/
Development = 2,
/**
* The extension is running from an `--extensionTestsPath` and
* the extension host is running unit tests.
*/
Test = 3,
}
export interface ClineExtensionContext {
/**
* An array to which disposables can be added. When this
* extension is deactivated the disposables will be disposed.
*
* *Note* that asynchronous dispose-functions aren't awaited.
*/
readonly subscriptions: {
/**
* Function to clean up resources.
*/
dispose(): any
}[]
/**
* A memento object that stores state in the context
* of the currently opened {@link workspace.workspaceFolders workspace}.
*/
readonly workspaceState: Memento
/**
* A memento object that stores state independent
* of the current opened {@link workspace.workspaceFolders workspace}.
*/
readonly globalState: Memento & {
/**
* Set the keys whose values should be synchronized across devices when synchronizing user-data
* like configuration, extensions, and mementos.
*
* Note that this function defines the whole set of keys whose values are synchronized:
* - calling it with an empty array stops synchronization for this memento
* - calling it with a non-empty array replaces all keys whose values are synchronized
*
* For any given set of keys this function needs to be called only once but there is no harm in
* repeatedly calling it.
*
* @param keys The set of keys whose values are synced.
*/
setKeysForSync(keys: readonly string[]): void
}
/**
* A storage utility for secrets. Secrets are persisted across reloads and are independent of the
* current opened {@link workspace.workspaceFolders workspace}.
*/
readonly secrets: SecretStorage
/**
* The uri of the directory containing the extension.
*/
readonly extensionUri: URI
/**
* The absolute file path of the directory containing the extension. Shorthand
* notation for {@link TextDocument.uri ExtensionContext.extensionUri.fsPath} (independent of the uri scheme).
*/
readonly extensionPath: string
/**
* Gets the extension's global environment variable collection for this workspace, enabling changes to be
* applied to terminal environment variables.
*/
readonly environmentVariableCollection: any
/**
* Get the absolute path of a resource contained in the extension.
*
* *Note* that an absolute uri can be constructed via {@linkcode Uri.joinPath} and
* {@linkcode ExtensionContext.extensionUri extensionUri}, e.g. `vscode.Uri.joinPath(context.extensionUri, relativePath);`
*
* @param relativePath A relative path to a resource contained in the extension.
* @returns The absolute path of the resource.
*/
asAbsolutePath(relativePath: string): string
/**
* The uri of a workspace specific directory in which the extension
* can store private state. The directory might not exist and creation is
* up to the extension. However, the parent directory is guaranteed to be existent.
* The value is `undefined` when no workspace nor folder has been opened.
*
* Use {@linkcode ExtensionContext.workspaceState workspaceState} or
* {@linkcode ExtensionContext.globalState globalState} to store key value data.
*
* @see {@linkcode FileSystem workspace.fs} for how to read and write files and folders from
* an uri.
*/
readonly storageUri: URI | undefined
/**
* An absolute file path of a workspace specific directory in which the extension
* can store private state. The directory might not exist on disk and creation is
* up to the extension. However, the parent directory is guaranteed to be existent.
*
* Use {@linkcode ExtensionContext.workspaceState workspaceState} or
* {@linkcode ExtensionContext.globalState globalState} to store key value data.
*
* @deprecated Use {@link ExtensionContext.storageUri storageUri} instead.
*/
readonly storagePath: string | undefined
/**
* The uri of a directory in which the extension can store global state.
* The directory might not exist on disk and creation is
* up to the extension. However, the parent directory is guaranteed to be existent.
*
* Use {@linkcode ExtensionContext.globalState globalState} to store key value data.
*
* @see {@linkcode FileSystem workspace.fs} for how to read and write files and folders from
* an uri.
*/
readonly globalStorageUri: URI
/**
* An absolute file path in which the extension can store global state.
* The directory might not exist on disk and creation is
* up to the extension. However, the parent directory is guaranteed to be existent.
*
* Use {@linkcode ExtensionContext.globalState globalState} to store key value data.
*
* @deprecated Use {@link ExtensionContext.globalStorageUri globalStorageUri} instead.
*/
readonly globalStoragePath: string
/**
* The uri of a directory in which the extension can create log files.
* The directory might not exist on disk and creation is up to the extension. However,
* the parent directory is guaranteed to be existent.
*
* @see {@linkcode FileSystem workspace.fs} for how to read and write files and folders from
* an uri.
*/
readonly logUri: URI
/**
* An absolute file path of a directory in which the extension can create log files.
* The directory might not exist on disk and creation is up to the extension. However,
* the parent directory is guaranteed to be existent.
*
* @deprecated Use {@link ExtensionContext.logUri logUri} instead.
*/
readonly logPath: string
/**
* The mode the extension is running in. See {@link ExtensionMode}
* for possible values and scenarios.
*/
readonly extensionMode: ExtensionMode
/**
* The current `Extension` instance.
*/
readonly extension: Extension<any>
}
export enum ExtensionKind {
/**
* Extension runs where the UI runs.
*/
UI = 1,
/**
* Extension runs where the remote extension host runs.
*/
Workspace = 2,
}
interface Extension<T> {
/**
* The canonical extension identifier in the form of: `publisher.name`.
*/
readonly id: string
/**
* The uri of the directory containing the extension.
*/
readonly extensionUri: URI
/**
* The absolute file path of the directory containing this extension. Shorthand
* notation for {@link Extension.extensionUri Extension.extensionUri.fsPath} (independent of the uri scheme).
*/
readonly extensionPath: string
/**
* `true` if the extension has been activated.
*/
readonly isActive: boolean
/**
* The parsed contents of the extension's package.json.
*/
readonly packageJSON: any
/**
* The extension kind describes if an extension runs where the UI runs
* or if an extension runs where the remote extension host runs. The extension kind
* is defined in the `package.json`-file of extensions but can also be refined
* via the `remote.extensionKind`-setting. When no remote extension host exists,
* the value is {@linkcode ExtensionKind.UI}.
*/
extensionKind: ExtensionKind
/**
* The public API exported by this extension (return value of `activate`).
* It is an invalid action to access this field before this extension has been activated.
*/
readonly exports: T
/**
* Activates this extension and returns its public API.
*
* @returns A promise that will resolve when this extension has been activated.
*/
activate(): Thenable<T>
}
+8
View File
@@ -0,0 +1,8 @@
export enum ClineClient {
VSCode = "VS Code",
JetBrains = "JetBrains",
Cli = "CLI",
}
import type { ClineExtensionContext } from "./context"
export type { ClineExtensionContext }
+4
View File
@@ -98,6 +98,10 @@ export interface ClineStorageMessage extends Anthropic.MessageParam {
* Includes token counts, costs.
*/
metrics?: ClineMessageMetricsInfo
/**
* Extended metadata for this message
*/
metadata?: Record<string, string>
}
/**

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