mirror of
https://github.com/cline/cline.git
synced 2026-09-02 15:52:29 +08:00
Compare commits
89 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3700d77a1d | |||
| bfeb8af23f | |||
| c4b1160389 | |||
| 99f05762cf | |||
| 77ab5a89b4 | |||
| dc8702bcdc | |||
| 486e2ff0d3 | |||
| f003e0fa75 | |||
| 12b2aaa2eb | |||
| 0172870f08 | |||
| 96a2abbb39 | |||
| 163ad28f2c | |||
| 56ce831bf7 | |||
| c4c9f99e97 | |||
| 27d4eec60a | |||
| 2d39095b0d | |||
| 13f89db752 | |||
| af67978d39 | |||
| 03534ed7e5 | |||
| e41aba9354 | |||
| 739790fc9c | |||
| 6993260016 | |||
| 3b6c861ace | |||
| 35a0732cf1 | |||
| 02b44217b1 | |||
| 04fb2da9b5 | |||
| 94f75df12c | |||
| 624433a824 | |||
| 0a267bd078 | |||
| e9c5882179 | |||
| 537ca97cf1 | |||
| 581c2a0282 | |||
| f9d8262e31 | |||
| c8927f1971 | |||
| dde21a0e8c | |||
| 07d630b614 | |||
| cfe25729c0 | |||
| 4818f53ae3 | |||
| f4680b9eac | |||
| c9f928e485 | |||
| 49b8569281 | |||
| c19d76cd80 | |||
| e0e9f8a862 | |||
| 79a5052869 | |||
| a5c048f738 | |||
| f0514758a8 | |||
| 499f7bafbb | |||
| 415653d0c8 | |||
| 5fa12f389c | |||
| b1295471a0 | |||
| 303d99ff81 | |||
| 543a228fd9 | |||
| 5cdacca80c | |||
| 0b5024dc42 | |||
| 23a2c24366 | |||
| 58cfed5613 | |||
| 20bdc3cead | |||
| 525d1b6e21 | |||
| d5169f32f7 | |||
| e23b953a02 | |||
| cd8690b939 | |||
| fd961c2cbc | |||
| 1691177bee | |||
| 488adb42a2 | |||
| b59aa52c9e | |||
| 06f40a75c6 | |||
| 49eb4f95da | |||
| ec0fde8602 | |||
| bee130950e | |||
| 1fb423bc8e | |||
| 7905376e20 | |||
| 01740fbda9 | |||
| d4622a75df | |||
| dd145671af | |||
| 1bb2962b26 | |||
| 7afd207b4d | |||
| 28c7ffd216 | |||
| dbc0e8aa1d | |||
| 714236a233 | |||
| 6c152bc307 | |||
| a9f7e014f9 | |||
| f9e758a29e | |||
| 74591cb254 | |||
| 9c273551e2 | |||
| 9d40ee4529 | |||
| 2140db1aae | |||
| 6714587e6e | |||
| 30dcfe05c3 | |||
| ce1d3bc5b9 |
+26
-1
@@ -5,4 +5,29 @@ The CLI lives in `cli-ts/` and uses React Ink for terminal UI.
|
||||
- If needed, look at `cli-ts/src/constants/colors.ts` for re-used terminal colors, e.g. `COLORS.primaryBlue` highlight color (selections, spinners, success states).
|
||||
- Never use `dimColor` with gray (e.g. `<Text color="gray" dimColor>`) - it's too hard to read. Use `color="gray"` for secondary text and normal foreground (no color) for primary text.
|
||||
- When thinking about how to handle state or messages from core, look at webview for how it communicates with the vs code extension.
|
||||
- When updating the webview, consider and suggest to the user to update the CLI TUI since we want to provide a similar experience to our terminal users as we do our vs code extension users.
|
||||
- When updating the webview, consider and suggest to the user to update the CLI TUI since we want to provide a similar experience to our terminal users as we do our vs code extension users.
|
||||
|
||||
## Adding New API Providers
|
||||
|
||||
When adding a new API provider to the extension, you must also update the CLI:
|
||||
|
||||
1. **Update `cli-ts/src/components/ModelPicker.tsx`**: Add the provider to the `providerModels` map so `getDefaultModelId()` returns the correct default model. Import the models and default ID from `@shared/api`:
|
||||
```typescript
|
||||
import { newProviderDefaultModelId, newProviderModels } from "@/shared/api"
|
||||
|
||||
export const providerModels = {
|
||||
// ...existing providers
|
||||
"new-provider": { models: newProviderModels, defaultId: newProviderDefaultModelId },
|
||||
}
|
||||
```
|
||||
|
||||
2. **Use `applyProviderConfig()` for auth flows**: When implementing OAuth or other auth flows for the provider, use the shared utility at `cli-ts/src/utils/provider-config.ts`:
|
||||
```typescript
|
||||
import { applyProviderConfig } from "../utils/provider-config"
|
||||
|
||||
// After successful auth:
|
||||
await applyProviderConfig({ providerId: "new-provider", controller })
|
||||
```
|
||||
This handles setting provider, default model, API key mapping, state persistence, and rebuilding the API handler.
|
||||
|
||||
3. **Provider-specific auth**: If the provider uses OAuth (like `openai-codex`), add handling in `SettingsPanelContent.tsx`'s `handleProviderSelect` callback. See the existing Codex OAuth flow as a reference.
|
||||
+5
-1
@@ -154,6 +154,8 @@
|
||||
],
|
||||
"includes": [
|
||||
"**",
|
||||
"!**/esbuild.*",
|
||||
"!**/*.mts",
|
||||
"!**/webview-ui/**",
|
||||
"!**/evals/**",
|
||||
"!**/standalone/**",
|
||||
@@ -167,7 +169,9 @@
|
||||
"!**/*.js",
|
||||
"!**/scripts/**",
|
||||
"!**/*.tsx",
|
||||
"!**/testing-platform/**"
|
||||
"!**/testing-platform/**",
|
||||
// ACP mode must redirect console to stderr - this is intentional
|
||||
"!cli-ts/src/acp/index.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+36
-1
@@ -252,6 +252,41 @@ npm run watch
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## Publish
|
||||
|
||||
#### 1. Publish to npm
|
||||
```bash
|
||||
npm publish
|
||||
```
|
||||
|
||||
#### 2. Update the Homebrew formula
|
||||
```bash
|
||||
npm run update-brew-formula
|
||||
```
|
||||
|
||||
#### 3. Test the formula locally
|
||||
```bash
|
||||
# Create a local tap
|
||||
brew tap-new cline/local
|
||||
cp ./cli-ts/cline.rb "$(brew --repository)/Library/Taps/cline/homebrew-local/Formula/cline.rb"
|
||||
|
||||
# Build from Source
|
||||
brew install --build-from-source cline/local/cline
|
||||
|
||||
# Install from your local tap
|
||||
brew install cline/local/cline
|
||||
|
||||
# Clean up when done
|
||||
brew untap cline/local
|
||||
```
|
||||
|
||||
#### 4. If using a tap, commit and push
|
||||
```bash
|
||||
git add cline.rb
|
||||
git commit -m "Update cline to v2.0.0"
|
||||
git push
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
The CLI reuses the core Cline TypeScript codebase:
|
||||
@@ -316,4 +351,4 @@ npm run protos
|
||||
Make the CLI executable:
|
||||
```bash
|
||||
chmod +x dist/cli.js
|
||||
```
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
# IMPORTANT: `npm run postpublish` to update this file after publishing a new version of the package
|
||||
class Cline < Formula
|
||||
desc "Autonomous coding agent CLI - capable of creating/editing files, running commands, and more"
|
||||
homepage "https://cline.bot"
|
||||
url "https://registry.npmjs.org/cline/-/cline-2.0.0.tgz" # GET from https://registry.npmjs.org/cline/latest tarball URL
|
||||
sha256 "65bae90401191aeeabfbbc0b315e816aea96742043ba85b90671bf5e19d0761e"
|
||||
license "Apache-2.0"
|
||||
|
||||
depends_on "node@20"
|
||||
|
||||
def install
|
||||
system "npm", "install", *std_npm_args(prefix: false)
|
||||
bin.install_symlink Dir["#{libexec}/bin/*"]
|
||||
end
|
||||
|
||||
test do
|
||||
# Test that the binary exists and is executable
|
||||
assert_match version.to_s, shell_output("#{bin}/cline --version")
|
||||
end
|
||||
end
|
||||
@@ -1,22 +1,23 @@
|
||||
import "dotenv/config"
|
||||
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import dotenv from "dotenv"
|
||||
import * as esbuild from "esbuild"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const rootDir = path.resolve(__dirname, "..")
|
||||
|
||||
// Load .env from repo root
|
||||
dotenv.config({ path: path.join(rootDir, ".env") })
|
||||
|
||||
const production = process.argv.includes("--production")
|
||||
const watch = process.argv.includes("--watch")
|
||||
|
||||
/**
|
||||
* Plugin to resolve path aliases from the parent project
|
||||
* @type {import('esbuild').Plugin}
|
||||
*/
|
||||
const aliasResolverPlugin = {
|
||||
const aliasResolverPlugin: esbuild.Plugin = {
|
||||
name: "alias-resolver",
|
||||
setup(build) {
|
||||
const aliases = {
|
||||
@@ -65,6 +66,18 @@ const aliasResolverPlugin = {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle .js -> .ts extension mapping (common in ESM TypeScript projects)
|
||||
if (importPath.endsWith(".js")) {
|
||||
const tsPath = importPath.replace(/\.js$/, ".ts")
|
||||
if (fs.existsSync(tsPath)) {
|
||||
return { path: tsPath }
|
||||
}
|
||||
const tsxPath = importPath.replace(/\.js$/, ".tsx")
|
||||
if (fs.existsSync(tsxPath)) {
|
||||
return { path: tsxPath }
|
||||
}
|
||||
}
|
||||
|
||||
// If nothing worked, return the original path and let esbuild handle the error
|
||||
return { path: importPath }
|
||||
})
|
||||
@@ -74,9 +87,8 @@ const aliasResolverPlugin = {
|
||||
|
||||
/**
|
||||
* Plugin to redirect vscode imports to our shim
|
||||
* @type {import('esbuild').Plugin}
|
||||
*/
|
||||
const vscodeStubPlugin = {
|
||||
const vscodeStubPlugin: esbuild.Plugin = {
|
||||
name: "vscode-stub",
|
||||
setup(build) {
|
||||
// Redirect 'vscode' imports to our shim
|
||||
@@ -86,11 +98,11 @@ const vscodeStubPlugin = {
|
||||
},
|
||||
}
|
||||
|
||||
const esbuildProblemMatcherPlugin = {
|
||||
const esbuildProblemMatcherPlugin: esbuild.Plugin = {
|
||||
name: "esbuild-problem-matcher",
|
||||
setup(build) {
|
||||
build.onStart(() => {
|
||||
console.log("[cli-ts] Build started...")
|
||||
console.log("[cli-ts esbuild] Build started...")
|
||||
})
|
||||
build.onEnd((result) => {
|
||||
result.errors.forEach(({ text, location }) => {
|
||||
@@ -99,13 +111,13 @@ const esbuildProblemMatcherPlugin = {
|
||||
console.error(` ${location.file}:${location.line}:${location.column}:`)
|
||||
}
|
||||
})
|
||||
console.log("[cli-ts] Build finished")
|
||||
console.log("[cli-ts esbuild] Build finished")
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// Plugin to stub out optional devtools module
|
||||
const stubOptionalModulesPlugin = {
|
||||
const stubOptionalModulesPlugin: esbuild.Plugin = {
|
||||
name: "stub-optional-modules",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^react-devtools-core$/ }, () => {
|
||||
@@ -114,7 +126,7 @@ const stubOptionalModulesPlugin = {
|
||||
},
|
||||
}
|
||||
|
||||
const copyWasmFiles = {
|
||||
const copyWasmFiles: esbuild.Plugin = {
|
||||
name: "copy-wasm-files",
|
||||
setup(build) {
|
||||
build.onEnd(() => {
|
||||
@@ -166,7 +178,7 @@ const copyWasmFiles = {
|
||||
},
|
||||
}
|
||||
|
||||
const buildEnvVars = {
|
||||
const buildEnvVars: Record<string, string> = {
|
||||
"process.env.IS_STANDALONE": JSON.stringify("true"),
|
||||
"process.env.IS_CLI": JSON.stringify("true"),
|
||||
}
|
||||
@@ -196,7 +208,7 @@ if (production) {
|
||||
buildEnvVars["process.env.IS_DEV"] = "false"
|
||||
}
|
||||
|
||||
const config = {
|
||||
const config: esbuild.BuildOptions = {
|
||||
entryPoints: [path.join(__dirname, "src", "index.ts")],
|
||||
bundle: true,
|
||||
minify: production,
|
||||
@@ -0,0 +1,384 @@
|
||||
.\" Automatically generated by Pandoc 3.8.3
|
||||
.\"
|
||||
.TH "CLINE" "1" "January 2026" "Cline CLI 2.0" "User Commands"
|
||||
.SH NAME
|
||||
cline \- AI coding assistant in your terminal
|
||||
.SH SYNOPSIS
|
||||
\f[B]cline\f[R] [\f[I]prompt\f[R]] [\f[I]options\f[R]]
|
||||
.PP
|
||||
\f[B]cline\f[R] \f[I]command\f[R] [\f[I]options\f[R]]
|
||||
[\f[I]arguments\f[R]]
|
||||
.SH DESCRIPTION
|
||||
\f[B]cline\f[R] is a command\-line interface for the Cline AI coding
|
||||
assistant.
|
||||
It provides the same powerful AI capabilities as the VS Code extension,
|
||||
directly in your terminal.
|
||||
.PP
|
||||
Cline is an autonomous AI agent that can read, write, and execute code
|
||||
across your projects.
|
||||
He can create and edit files, run terminal commands, use a headless
|
||||
browser, and more\(emall while asking for your approval before taking
|
||||
actions.
|
||||
.PP
|
||||
The CLI supports both interactive mode (with a rich terminal UI) and
|
||||
plain text mode (for piped input and scripted workflows).
|
||||
.SH MODES OF OPERATION
|
||||
\f[B]Interactive Mode\f[R] : When you run \f[B]cline\f[R] without
|
||||
arguments, it launches an interactive welcome prompt with a rich
|
||||
terminal UI.
|
||||
You can type your task, view conversation history, and interact with
|
||||
Cline in real\-time.
|
||||
.PP
|
||||
\f[B]Task Mode\f[R] : Run \f[B]cline \(lqprompt\(rq\f[R] or \f[B]cline
|
||||
task \(lqprompt\(rq\f[R] to immediately start a task.
|
||||
If stdin is a TTY, you\(cqll see the interactive UI.
|
||||
If stdin is piped or output is redirected, the CLI automatically
|
||||
switches to plain text mode.
|
||||
.PP
|
||||
\f[B]Plain Text Mode\f[R] : Activated automatically when stdin is piped,
|
||||
output is redirected, or \f[B]\-\-json\f[R]/\f[B]\-\-yolo\f[R] flags are
|
||||
used.
|
||||
Outputs clean text without the Ink UI, suitable for scripting and CI/CD
|
||||
pipelines.
|
||||
.SH AGENT BEHAVIOR
|
||||
Cline operates in two primary modes:
|
||||
.PP
|
||||
\f[B]ACT MODE\f[R] : Cline actively uses tools to accomplish tasks.
|
||||
He can read files, write code, execute commands, use a headless browser,
|
||||
and more.
|
||||
This is the default mode for task execution.
|
||||
.PP
|
||||
\f[B]PLAN MODE\f[R] : Cline gathers information and creates a detailed
|
||||
plan before implementation.
|
||||
He explores the codebase, asks clarifying questions, and presents a
|
||||
strategy for user approval before switching to ACT MODE.
|
||||
.SH COMMANDS
|
||||
.SS task (alias: t)
|
||||
Run a new task with a prompt.
|
||||
.PP
|
||||
\f[B]cline task\f[R] \f[I]prompt\f[R] [\f[I]options\f[R]]
|
||||
.PP
|
||||
\f[B]cline t\f[R] \f[I]prompt\f[R] [\f[I]options\f[R]] : Create and run
|
||||
a new task.
|
||||
Options:
|
||||
.PP
|
||||
\f[B]\-a\f[R], \f[B]\-\-act\f[R] : Run in act mode (default)
|
||||
.PP
|
||||
\f[B]\-p\f[R], \f[B]\-\-plan\f[R] : Run in plan mode
|
||||
.PP
|
||||
\f[B]\-y\f[R], \f[B]\-\-yolo\f[R] : Enable yolo/yes mode (auto\-approve
|
||||
all actions, output in plain mode, exit process automatically when task
|
||||
complete)
|
||||
.PP
|
||||
\f[B]\-m\f[R], \f[B]\-\-model\f[R] \f[I]model\f[R] : Model to use for
|
||||
the task
|
||||
.PP
|
||||
\f[B]\-i\f[R], \f[B]\-\-images\f[R] \f[I]paths\&...\f[R] : Image file
|
||||
paths to include with the task
|
||||
.PP
|
||||
\f[B]\-v\f[R], \f[B]\-\-verbose\f[R] : Show verbose output including
|
||||
reasoning
|
||||
.PP
|
||||
\f[B]\-c\f[R], \f[B]\-\-cwd\f[R] \f[I]path\f[R] : Working directory for
|
||||
the task
|
||||
.PP
|
||||
\f[B]\-\-config\f[R] \f[I]path\f[R] : Path to Cline configuration
|
||||
directory
|
||||
.PP
|
||||
\f[B]\-\-thinking\f[R] : Enable extended thinking (1024 token budget)
|
||||
.PP
|
||||
\f[B]\-\-json\f[R] : Output messages as JSON instead of styled text
|
||||
.SS history (alias: h)
|
||||
List task history with pagination.
|
||||
.PP
|
||||
\f[B]cline history\f[R] [\f[I]options\f[R]]
|
||||
.PP
|
||||
\f[B]cline h\f[R] [\f[I]options\f[R]] : Display previous tasks.
|
||||
Options:
|
||||
.PP
|
||||
\f[B]\-n\f[R], \f[B]\-\-limit\f[R] \f[I]number\f[R] : Number of tasks to
|
||||
show (default: 10)
|
||||
.PP
|
||||
\f[B]\-p\f[R], \f[B]\-\-page\f[R] \f[I]number\f[R] : Page number,
|
||||
1\-based (default: 1)
|
||||
.PP
|
||||
\f[B]\-\-config\f[R] \f[I]path\f[R] : Path to Cline configuration
|
||||
directory
|
||||
.SS config
|
||||
Show current configuration.
|
||||
.PP
|
||||
\f[B]cline config\f[R] [\f[I]options\f[R]] : Display global and
|
||||
workspace state.
|
||||
Options:
|
||||
.PP
|
||||
\f[B]\-\-config\f[R] \f[I]path\f[R] : Path to Cline configuration
|
||||
directory
|
||||
.SS auth
|
||||
Authenticate a provider and configure the model.
|
||||
.PP
|
||||
\f[B]cline auth\f[R] [\f[I]options\f[R]] : Launch interactive
|
||||
authentication wizard, or use quick setup flags.
|
||||
Options:
|
||||
.PP
|
||||
\f[B]\-p\f[R], \f[B]\-\-provider\f[R] \f[I]id\f[R] : Provider ID for
|
||||
quick setup (e.g., openai\-native, anthropic, openrouter)
|
||||
.PP
|
||||
\f[B]\-k\f[R], \f[B]\-\-apikey\f[R] \f[I]key\f[R] : API key for the
|
||||
provider
|
||||
.PP
|
||||
\f[B]\-m\f[R], \f[B]\-\-modelid\f[R] \f[I]id\f[R] : Model ID to
|
||||
configure (e.g., gpt\-4o, claude\-sonnet\-4\-5\-20250929)
|
||||
.PP
|
||||
\f[B]\-b\f[R], \f[B]\-\-baseurl\f[R] \f[I]url\f[R] : Base URL (optional,
|
||||
for OpenAI\-compatible providers)
|
||||
.PP
|
||||
\f[B]\-v\f[R], \f[B]\-\-verbose\f[R] : Show verbose output
|
||||
.PP
|
||||
\f[B]\-c\f[R], \f[B]\-\-cwd\f[R] \f[I]path\f[R] : Working directory
|
||||
.PP
|
||||
\f[B]\-\-config\f[R] \f[I]path\f[R] : Path to Cline configuration
|
||||
directory
|
||||
.SS update
|
||||
Check for updates and install if available.
|
||||
.PP
|
||||
\f[B]cline update\f[R] [\f[I]options\f[R]] : Check npm for newer
|
||||
versions.
|
||||
Options:
|
||||
.PP
|
||||
\f[B]\-v\f[R], \f[B]\-\-verbose\f[R] : Show verbose output
|
||||
.SS version
|
||||
Show the CLI version number.
|
||||
.PP
|
||||
\f[B]cline version\f[R]
|
||||
.SS dev
|
||||
Developer tools and utilities.
|
||||
.PP
|
||||
\f[B]cline dev log\f[R] : Open the log file for debugging.
|
||||
.SH DEFAULT COMMAND OPTIONS
|
||||
When running \f[B]cline\f[R] with just a prompt (no subcommand), these
|
||||
options are available:
|
||||
.PP
|
||||
\f[B]\-a\f[R], \f[B]\-\-act\f[R] : Run in act mode (default)
|
||||
.PP
|
||||
\f[B]\-p\f[R], \f[B]\-\-plan\f[R] : Run in plan mode
|
||||
.PP
|
||||
\f[B]\-y\f[R], \f[B]\-\-yolo\f[R] : Enable yolo mode (auto\-approve all
|
||||
actions).
|
||||
Also forces plain text output mode.
|
||||
.PP
|
||||
\f[B]\-m\f[R], \f[B]\-\-model\f[R] \f[I]model\f[R] : Model to use for
|
||||
the task
|
||||
.PP
|
||||
\f[B]\-v\f[R], \f[B]\-\-verbose\f[R] : Show verbose output
|
||||
.PP
|
||||
\f[B]\-c\f[R], \f[B]\-\-cwd\f[R] \f[I]path\f[R] : Working directory
|
||||
.PP
|
||||
\f[B]\-\-config\f[R] \f[I]path\f[R] : Configuration directory
|
||||
.PP
|
||||
\f[B]\-\-thinking\f[R] : Enable extended thinking (1024 token budget)
|
||||
.PP
|
||||
\f[B]\-\-json\f[R] : Output messages as JSON instead of styled text.
|
||||
Forces plain text mode.
|
||||
.SH JSON OUTPUT FORMAT
|
||||
When using \f[B]\-\-json\f[R], each message is output as a JSON object
|
||||
with these fields:
|
||||
.PP
|
||||
\f[B]Required fields:\f[R]
|
||||
.IP \(bu 2
|
||||
\f[B]type\f[R]: \(lqask\(rq or \(lqsay\(rq
|
||||
.IP \(bu 2
|
||||
\f[B]text\f[R]: message text
|
||||
.IP \(bu 2
|
||||
\f[B]ts\f[R]: Unix epoch timestamp in milliseconds
|
||||
.PP
|
||||
\f[B]Optional fields:\f[R]
|
||||
.IP \(bu 2
|
||||
\f[B]reasoning\f[R]: reasoning text
|
||||
.IP \(bu 2
|
||||
\f[B]say\f[R]: say subtype (when type is \(lqsay\(rq)
|
||||
.IP \(bu 2
|
||||
\f[B]ask\f[R]: ask subtype (when type is \(lqask\(rq)
|
||||
.IP \(bu 2
|
||||
\f[B]partial\f[R]: streaming flag
|
||||
.IP \(bu 2
|
||||
\f[B]images\f[R]: list of image URIs
|
||||
.IP \(bu 2
|
||||
\f[B]files\f[R]: list of file paths
|
||||
.SH EXAMPLES
|
||||
.SS Basic Usage
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# Launch interactive mode\f[R]
|
||||
cline
|
||||
|
||||
\f[I]# Run a task directly\f[R]
|
||||
cline \(dqCreate a hello world function in Python\(dq
|
||||
|
||||
\f[I]# Run with verbose output and extended thinking\f[R]
|
||||
cline \-v \-\-thinking \(dqAnalyze this codebase architecture\(dq
|
||||
.EE
|
||||
.SS Mode Selection
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# Run in plan mode (gather info before acting)\f[R]
|
||||
cline \-p \(dqDesign a REST API for user management\(dq
|
||||
|
||||
\f[I]# Run in act mode with auto\-approval (yolo)\f[R]
|
||||
cline \-y \(dqFix the typo in README.md\(dq
|
||||
.EE
|
||||
.SS Using Specific Models
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# Use a specific model\f[R]
|
||||
cline \-m claude\-sonnet\-4\-5\-20250929 \(dqRefactor this function\(dq
|
||||
|
||||
\f[I]# Quick auth setup with model\f[R]
|
||||
cline auth \-p anthropic \-k sk\-ant\-xxxxx \-m claude\-sonnet\-4\-5\-20250929
|
||||
.EE
|
||||
.SS Including Images
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# Include images with explicit flag\f[R]
|
||||
cline task \-i screenshot.png diagram.jpg \(dqFix the UI based on these images\(dq
|
||||
|
||||
\f[I]# Or use inline image references in the prompt\f[R]
|
||||
cline \(dqFix the layout shown in \(at./screenshot.png\(dq
|
||||
.EE
|
||||
.SS Piped Input
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# Pipe file contents to Cline\f[R]
|
||||
cat README.md \f[B]|\f[R] cline \(dqSummarize this document\(dq
|
||||
|
||||
\f[I]# Pipe with additional prompt\f[R]
|
||||
echo \(dqfunction add(a, b) { return a + b }\(dq \f[B]|\f[R] cline \(dqAdd TypeScript types to this\(dq
|
||||
|
||||
\f[I]# Combine piped input with a prompt\f[R]
|
||||
git diff \f[B]|\f[R] cline \(dqReview these changes and suggest improvements\(dq
|
||||
.EE
|
||||
.SS Scripting and Automation
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# JSON output for parsing\f[R]
|
||||
cline \-\-json \(dqWhat files are in this directory?\(dq \f[B]|\f[R] jq \(aq.text\(aq
|
||||
|
||||
\f[I]# Yolo mode for automated workflows (auto\-approves all actions), forces plain text output\f[R]
|
||||
cline \-y \(dqRun the test suite and fix any failures\(dq
|
||||
.EE
|
||||
.SS Task History
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# List recent tasks\f[R]
|
||||
cline history
|
||||
|
||||
\f[I]# Show more tasks with pagination\f[R]
|
||||
cline history \-n 20 \-p 2
|
||||
.EE
|
||||
.SS Authentication
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# Interactive authentication wizard\f[R]
|
||||
cline auth
|
||||
|
||||
\f[I]# Quick setup for Anthropic\f[R]
|
||||
cline auth \-p anthropic \-k sk\-ant\-api\-xxxxx
|
||||
|
||||
\f[I]# Quick setup for OpenAI\f[R]
|
||||
cline auth \-p openai\-native \-k sk\-xxxxx \-m gpt\-4o
|
||||
|
||||
\f[I]# OpenAI\-compatible provider with custom base URL\f[R]
|
||||
cline auth \-p openai \-k your\-api\-key \-b https://api.example.com/v1
|
||||
.EE
|
||||
.SH ENVIRONMENT
|
||||
\f[B]CLINE_DIR\f[R] : Override the default configuration directory.
|
||||
When set, Cline stores all data in this directory instead of
|
||||
\f[CR]\(ti/.cline/data/\f[R].
|
||||
.PP
|
||||
\f[B]CLINE_COMMAND_PERMISSIONS\f[R] : JSON configuration for restricting
|
||||
which shell commands Cline can execute.
|
||||
When set, commands are validated against allow/deny patternks before
|
||||
execution.
|
||||
When not set, all commands are allowed.
|
||||
.PP
|
||||
Format:
|
||||
\f[CR]{\(dqallow\(dq: [\(dqpattern1\(dq, \(dqpattern2\(dq], \(dqdeny\(dq: [\(dqpattern3\(dq], \(dqallowRedirects\(dq: true}\f[R]
|
||||
.PP
|
||||
\f[B]Fields:\f[R]
|
||||
.IP \(bu 2
|
||||
\f[B]allow\f[R] (array of strings): Glob patterns for allowed commands.
|
||||
If specified, only matching commands are permitted.
|
||||
Uses \f[CR]*\f[R] to match any characters and \f[CR]?\f[R] to match a
|
||||
single character.
|
||||
Setting allow on anything will deny all others.
|
||||
.IP \(bu 2
|
||||
\f[B]deny\f[R] (array of strings): Glob patterns for denied commands.
|
||||
Deny rules take precedence over allow rules.
|
||||
.IP \(bu 2
|
||||
\f[B]allowRedirects\f[R] (boolean): Whether to allow shell redirects
|
||||
(\f[CR]>\f[R], \f[CR]>>\f[R], \f[CR]<\f[R], etc.).
|
||||
Defaults to false.
|
||||
.PP
|
||||
\f[B]Rule evaluation:\f[R]
|
||||
.IP "1." 3
|
||||
Check for dangerous characters (backticks outside single quotes,
|
||||
unquoted newlines)
|
||||
.IP "2." 3
|
||||
Parse command into segments split by operators (\f[CR]&&\f[R],
|
||||
\f[CR]||\f[R], \f[CR]|\f[R], \f[CR];\f[R])
|
||||
.IP "3." 3
|
||||
If redirects detected and \f[CR]allowRedirects\f[R] is not true, command
|
||||
is denied
|
||||
.IP "4." 3
|
||||
Each segment is validated against deny rules first, then allow rules
|
||||
.IP "5." 3
|
||||
Subshell contents (\f[CR]$(...)\f[R] and \f[CR](...)\f[R]) are
|
||||
recursively validated
|
||||
.IP "6." 3
|
||||
All segments must pass for the command to be allowed
|
||||
.PP
|
||||
\f[B]Examples:\f[R]
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# Allow only npm and git commands.\f[R]
|
||||
export CLINE_COMMAND_PERMISSIONS=\(aq{\(dqallow\(dq: [\(dqnpm *\(dq, \(dqgit *\(dq]}\(aq
|
||||
|
||||
\f[I]# Allow development commands but deny dangerous ones. Deny not strictly required here since allow is set.\f[R]
|
||||
export CLINE_COMMAND_PERMISSIONS=\(aq{\(dqallow\(dq: [\(dqnpm *\(dq, \(dqgit *\(dq, \(dqnode *\(dq], \(dqdeny\(dq: [\(dqrm \-rf *\(dq, \(dqsudo *\(dq]}\(aq
|
||||
|
||||
\f[I]# Allow file operations with redirects\f[R]
|
||||
export CLINE_COMMAND_PERMISSIONS=\(aq{\(dqallow\(dq: [\(dqcat *\(dq, \(dqecho *\(dq], \(dqallowRedirects\(dq: true}\(aq
|
||||
.EE
|
||||
.SH FILES
|
||||
\f[B]\(ti/.cline/data/\f[R] : Default configuration directory
|
||||
containing:
|
||||
.PP
|
||||
\f[B]globalState.json\f[R] : Global settings and state
|
||||
.PP
|
||||
\f[B]secrets.json\f[R] : API keys and secrets (stored securely)
|
||||
.PP
|
||||
\f[B]workspace/\f[R] : Workspace\-specific state
|
||||
.PP
|
||||
\f[B]tasks/\f[R] : Task history and conversation data
|
||||
.PP
|
||||
\f[B]\(ti/.cline/log/\f[R] : Log files for debugging.
|
||||
View with \f[CR]cline dev log\f[R].
|
||||
.SH BUGS
|
||||
Report bugs at: \c
|
||||
.UR https://github.com/cline/cline/issues
|
||||
.UE \c
|
||||
.PP
|
||||
For real\-time help, join the Discord community at: \c
|
||||
.UR https://discord.gg/cline
|
||||
.UE \c
|
||||
.SH SEE ALSO
|
||||
Full documentation: \c
|
||||
.UR https://docs.cline.bot
|
||||
.UE \c
|
||||
.PP
|
||||
VS Code extension: \c
|
||||
.UR https://marketplace.visualstudio.com/items?itemName=saoudrizwan.claude-dev
|
||||
.UE \c
|
||||
.SH AUTHORS
|
||||
Cline is developed by Cline Bot Inc.\ and the open source community.
|
||||
.SH COPYRIGHT
|
||||
Copyright © 2025 Cline Bot Inc.\ Licensed under the Apache License 2.0.
|
||||
@@ -0,0 +1,340 @@
|
||||
---
|
||||
title: CLINE
|
||||
section: 1
|
||||
header: User Commands
|
||||
footer: Cline CLI 2.0
|
||||
date: January 2026
|
||||
---
|
||||
|
||||
# NAME
|
||||
|
||||
cline - AI coding assistant in your terminal
|
||||
|
||||
# SYNOPSIS
|
||||
|
||||
**cline** [*prompt*] [*options*]
|
||||
|
||||
**cline** *command* [*options*] [*arguments*]
|
||||
|
||||
# DESCRIPTION
|
||||
|
||||
**cline** is a command-line interface for the Cline AI coding assistant. It provides the same powerful AI capabilities as the VS Code extension, directly in your terminal.
|
||||
|
||||
Cline is an autonomous AI agent that can read, write, and execute code across your projects. He can create and edit files, run terminal commands, use a headless browser, and more—all while asking for your approval before taking actions.
|
||||
|
||||
The CLI supports both interactive mode (with a rich terminal UI) and plain text mode (for piped input and scripted workflows).
|
||||
|
||||
# MODES OF OPERATION
|
||||
|
||||
**Interactive Mode** : When you run **cline** without arguments, it launches an interactive welcome prompt with a rich terminal UI. You can type your task, view conversation history, and interact with Cline in real-time.
|
||||
|
||||
**Task Mode** : Run **cline "prompt"** or **cline task "prompt"** to immediately start a task. If stdin is a TTY, you'll see the interactive UI. If stdin is piped or output is redirected, the CLI automatically switches to plain text mode.
|
||||
|
||||
**Plain Text Mode** : Activated automatically when stdin is piped, output is redirected, or **\--json**/**\--yolo** flags are used. Outputs clean text without the Ink UI, suitable for scripting and CI/CD pipelines.
|
||||
|
||||
# AGENT BEHAVIOR
|
||||
|
||||
Cline operates in two primary modes:
|
||||
|
||||
**ACT MODE** : Cline actively uses tools to accomplish tasks. He can read files, write code, execute commands, use a headless browser, and more. This is the default mode for task execution.
|
||||
|
||||
**PLAN MODE** : Cline gathers information and creates a detailed plan before implementation. He explores the codebase, asks clarifying questions, and presents a strategy for user approval before switching to ACT MODE.
|
||||
|
||||
# COMMANDS
|
||||
|
||||
## task (alias: t)
|
||||
|
||||
Run a new task with a prompt.
|
||||
|
||||
**cline task** *prompt* [*options*]
|
||||
|
||||
**cline t** *prompt* [*options*] : Create and run a new task. Options:
|
||||
|
||||
**-a**, **\--act** : Run in act mode (default)
|
||||
|
||||
**-p**, **\--plan** : Run in plan mode
|
||||
|
||||
**-y**, **\--yolo** : Enable yolo/yes mode (auto-approve all actions, output in plain mode, exit process automatically when task complete)
|
||||
|
||||
**-m**, **\--model** *model* : Model to use for the task
|
||||
|
||||
**-i**, **\--images** *paths...* : Image file paths to include with the task
|
||||
|
||||
**-v**, **\--verbose** : Show verbose output including reasoning
|
||||
|
||||
**-c**, **\--cwd** *path* : Working directory for the task
|
||||
|
||||
**\--config** *path* : Path to Cline configuration directory
|
||||
|
||||
**\--thinking** : Enable extended thinking (1024 token budget)
|
||||
|
||||
**\--json** : Output messages as JSON instead of styled text
|
||||
|
||||
## history (alias: h)
|
||||
|
||||
List task history with pagination.
|
||||
|
||||
**cline history** [*options*]
|
||||
|
||||
**cline h** [*options*] : Display previous tasks. Options:
|
||||
|
||||
**-n**, **\--limit** *number* : Number of tasks to show (default: 10)
|
||||
|
||||
**-p**, **\--page** *number* : Page number, 1-based (default: 1)
|
||||
|
||||
**\--config** *path* : Path to Cline configuration directory
|
||||
|
||||
## config
|
||||
|
||||
Show current configuration.
|
||||
|
||||
**cline config** [*options*] : Display global and workspace state. Options:
|
||||
|
||||
**\--config** *path* : Path to Cline configuration directory
|
||||
|
||||
## auth
|
||||
|
||||
Authenticate a provider and configure the model.
|
||||
|
||||
**cline auth** [*options*] : Launch interactive authentication wizard, or use quick setup flags. Options:
|
||||
|
||||
**-p**, **\--provider** *id* : Provider ID for quick setup (e.g., openai-native, anthropic, openrouter)
|
||||
|
||||
**-k**, **\--apikey** *key* : API key for the provider
|
||||
|
||||
**-m**, **\--modelid** *id* : Model ID to configure (e.g., gpt-4o, claude-sonnet-4-5-20250929)
|
||||
|
||||
**-b**, **\--baseurl** *url* : Base URL (optional, for OpenAI-compatible providers)
|
||||
|
||||
**-v**, **\--verbose** : Show verbose output
|
||||
|
||||
**-c**, **\--cwd** *path* : Working directory
|
||||
|
||||
**\--config** *path* : Path to Cline configuration directory
|
||||
|
||||
## update
|
||||
|
||||
Check for updates and install if available.
|
||||
|
||||
**cline update** [*options*] : Check npm for newer versions. Options:
|
||||
|
||||
**-v**, **\--verbose** : Show verbose output
|
||||
|
||||
## version
|
||||
|
||||
Show the CLI version number.
|
||||
|
||||
**cline version**
|
||||
|
||||
## dev
|
||||
|
||||
Developer tools and utilities.
|
||||
|
||||
**cline dev log** : Open the log file for debugging.
|
||||
|
||||
# DEFAULT COMMAND OPTIONS
|
||||
|
||||
When running **cline** with just a prompt (no subcommand), these options are available:
|
||||
|
||||
**-a**, **\--act** : Run in act mode (default)
|
||||
|
||||
**-p**, **\--plan** : Run in plan mode
|
||||
|
||||
**-y**, **\--yolo** : Enable yolo mode (auto-approve all actions). Also forces plain text output mode.
|
||||
|
||||
**-m**, **\--model** *model* : Model to use for the task
|
||||
|
||||
**-v**, **\--verbose** : Show verbose output
|
||||
|
||||
**-c**, **\--cwd** *path* : Working directory
|
||||
|
||||
**\--config** *path* : Configuration directory
|
||||
|
||||
**\--thinking** : Enable extended thinking (1024 token budget)
|
||||
|
||||
**\--json** : Output messages as JSON instead of styled text. Forces plain text mode.
|
||||
|
||||
# JSON OUTPUT FORMAT
|
||||
|
||||
When using **\--json**, each message is output as a JSON object with these fields:
|
||||
|
||||
**Required fields:**
|
||||
|
||||
- **type**: "ask" or "say"
|
||||
- **text**: message text
|
||||
- **ts**: Unix epoch timestamp in milliseconds
|
||||
|
||||
**Optional fields:**
|
||||
|
||||
- **reasoning**: reasoning text
|
||||
- **say**: say subtype (when type is "say")
|
||||
- **ask**: ask subtype (when type is "ask")
|
||||
- **partial**: streaming flag
|
||||
- **images**: list of image URIs
|
||||
- **files**: list of file paths
|
||||
|
||||
# EXAMPLES
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```bash
|
||||
# Launch interactive mode
|
||||
cline
|
||||
|
||||
# Run a task directly
|
||||
cline "Create a hello world function in Python"
|
||||
|
||||
# Run with verbose output and extended thinking
|
||||
cline -v --thinking "Analyze this codebase architecture"
|
||||
```
|
||||
|
||||
## Mode Selection
|
||||
|
||||
```bash
|
||||
# Run in plan mode (gather info before acting)
|
||||
cline -p "Design a REST API for user management"
|
||||
|
||||
# Run in act mode with auto-approval (yolo)
|
||||
cline -y "Fix the typo in README.md"
|
||||
```
|
||||
|
||||
## Using Specific Models
|
||||
|
||||
```bash
|
||||
# Use a specific model
|
||||
cline -m claude-sonnet-4-5-20250929 "Refactor this function"
|
||||
|
||||
# Quick auth setup with model
|
||||
cline auth -p anthropic -k sk-ant-xxxxx -m claude-sonnet-4-5-20250929
|
||||
```
|
||||
|
||||
## Including Images
|
||||
|
||||
```bash
|
||||
# Include images with explicit flag
|
||||
cline task -i screenshot.png diagram.jpg "Fix the UI based on these images"
|
||||
|
||||
# Or use inline image references in the prompt
|
||||
cline "Fix the layout shown in @./screenshot.png"
|
||||
```
|
||||
|
||||
## Piped Input
|
||||
|
||||
```bash
|
||||
# Pipe file contents to Cline
|
||||
cat README.md | cline "Summarize this document"
|
||||
|
||||
# Pipe with additional prompt
|
||||
echo "function add(a, b) { return a + b }" | cline "Add TypeScript types to this"
|
||||
|
||||
# Combine piped input with a prompt
|
||||
git diff | cline "Review these changes and suggest improvements"
|
||||
```
|
||||
|
||||
## Scripting and Automation
|
||||
|
||||
```bash
|
||||
# JSON output for parsing
|
||||
cline --json "What files are in this directory?" | jq '.text'
|
||||
|
||||
# Yolo mode for automated workflows (auto-approves all actions), forces plain text output
|
||||
cline -y "Run the test suite and fix any failures"
|
||||
```
|
||||
|
||||
## Task History
|
||||
|
||||
```bash
|
||||
# List recent tasks
|
||||
cline history
|
||||
|
||||
# Show more tasks with pagination
|
||||
cline history -n 20 -p 2
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
```bash
|
||||
# Interactive authentication wizard
|
||||
cline auth
|
||||
|
||||
# Quick setup for Anthropic
|
||||
cline auth -p anthropic -k sk-ant-api-xxxxx
|
||||
|
||||
# Quick setup for OpenAI
|
||||
cline auth -p openai-native -k sk-xxxxx -m gpt-4o
|
||||
|
||||
# OpenAI-compatible provider with custom base URL
|
||||
cline auth -p openai -k your-api-key -b https://api.example.com/v1
|
||||
```
|
||||
|
||||
# ENVIRONMENT
|
||||
|
||||
**CLINE_DIR** : Override the default configuration directory. When set, Cline stores all data in this directory instead of `~/.cline/data/`.
|
||||
|
||||
**CLINE_COMMAND_PERMISSIONS** : JSON configuration for restricting which shell commands Cline can execute. When set, commands are validated against allow/deny patternks before execution. When not set, all commands are allowed.
|
||||
|
||||
Format: `{"allow": ["pattern1", "pattern2"], "deny": ["pattern3"], "allowRedirects": true}`
|
||||
|
||||
**Fields:**
|
||||
|
||||
- **allow** (array of strings): Glob patterns for allowed commands. If specified, only matching commands are permitted. Uses `*` to match any characters and `?` to match a single character. Setting allow on anything will deny all others.
|
||||
- **deny** (array of strings): Glob patterns for denied commands. Deny rules take precedence over allow rules.
|
||||
- **allowRedirects** (boolean): Whether to allow shell redirects (`>`, `>>`, `<`, etc.). Defaults to false.
|
||||
|
||||
**Rule evaluation:**
|
||||
|
||||
1. Check for dangerous characters (backticks outside single quotes, unquoted newlines)
|
||||
2. Parse command into segments split by operators (`&&`, `||`, `|`, `;`)
|
||||
3. If redirects detected and `allowRedirects` is not true, command is denied
|
||||
4. Each segment is validated against deny rules first, then allow rules
|
||||
5. Subshell contents (`$(...)` and `(...)`) are recursively validated
|
||||
6. All segments must pass for the command to be allowed
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Allow only npm and git commands.
|
||||
export CLINE_COMMAND_PERMISSIONS='{"allow": ["npm *", "git *"]}'
|
||||
|
||||
# Allow development commands but deny dangerous ones. Deny not strictly required here since allow is set.
|
||||
export CLINE_COMMAND_PERMISSIONS='{"allow": ["npm *", "git *", "node *"], "deny": ["rm -rf *", "sudo *"]}'
|
||||
|
||||
# Allow file operations with redirects
|
||||
export CLINE_COMMAND_PERMISSIONS='{"allow": ["cat *", "echo *"], "allowRedirects": true}'
|
||||
```
|
||||
|
||||
|
||||
# CONFIGURATION FILES
|
||||
|
||||
```
|
||||
~/.cline/
|
||||
├── data/ # Default configuration directory
|
||||
│ ├── globalState.json # Global settings and state
|
||||
│ ├── secrets.json # API keys and secrets (stored securely)
|
||||
│ ├── workspace/ # Workspace-specific state
|
||||
│ └── tasks/ # Task history and conversation data
|
||||
└── log/ # Log files for debugging
|
||||
```
|
||||
|
||||
View logs with `cline dev log`.
|
||||
|
||||
|
||||
# BUGS
|
||||
|
||||
Report bugs at: <https://github.com/cline/cline/issues>
|
||||
|
||||
For real-time help, join the Discord community at: <https://discord.gg/cline>
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
Full documentation: <https://docs.cline.bot>
|
||||
|
||||
VS Code extension: <https://marketplace.visualstudio.com/items?itemName=saoudrizwan.claude-dev>
|
||||
|
||||
# AUTHORS
|
||||
|
||||
Cline is developed by Cline Bot Inc. and the open source community.
|
||||
|
||||
# COPYRIGHT
|
||||
|
||||
Copyright © 2025 Cline Bot Inc. Licensed under the Apache License 2.0.
|
||||
Generated
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"version": "1.0.0",
|
||||
"version": "2.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
@@ -2947,4 +2947,4 @@
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-3
@@ -11,9 +11,12 @@
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node esbuild.mjs",
|
||||
"build:production": "node esbuild.mjs --production",
|
||||
"watch": "node esbuild.mjs --watch",
|
||||
"prepublishOnly": "npm run build:production",
|
||||
"package:brew": "node ./scripts/update-brew-formula.mts",
|
||||
"package": "npm pack --pack-destination ./dist",
|
||||
"build": "node esbuild.mts",
|
||||
"build:production": "node esbuild.mts --production",
|
||||
"watch": "node esbuild.mts --watch",
|
||||
"dev": "IS_DEV=true && npm run link && npm run watch ; npm run unlink",
|
||||
"clean": "rimraf dist",
|
||||
"typecheck": "tsc --noEmit",
|
||||
@@ -61,6 +64,7 @@
|
||||
"vitest": "^4.0.17"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.13.1",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.1.0",
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execSync } from "node:child_process"
|
||||
import { createHash } from "node:crypto"
|
||||
import { readFile, unlink, writeFile } from "node:fs/promises"
|
||||
import { dirname, join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
const CLI_DIR = join(__dirname, "..")
|
||||
const FORMULA_PATH = join(CLI_DIR, "cline.rb")
|
||||
|
||||
interface PackageJson {
|
||||
version: string
|
||||
}
|
||||
|
||||
async function getLocalVersion(): Promise<string> {
|
||||
const packageJson = JSON.parse(await readFile(join(CLI_DIR, "package.json"), "utf-8")) as PackageJson
|
||||
return packageJson.version
|
||||
}
|
||||
|
||||
async function packAndGetSHA256(version: string): Promise<string> {
|
||||
console.log("Packing local package...")
|
||||
execSync("npm run package", { cwd: CLI_DIR, stdio: "inherit" })
|
||||
|
||||
const tarballPath = join(CLI_DIR, "dist", `cline-cli-${version}.tgz`)
|
||||
console.log(`Computing SHA256 for ${tarballPath}...`)
|
||||
|
||||
const buffer = await readFile(tarballPath)
|
||||
const sha256 = createHash("sha256").update(buffer).digest("hex")
|
||||
|
||||
// Clean up the tarball
|
||||
await unlink(tarballPath)
|
||||
|
||||
return sha256
|
||||
}
|
||||
|
||||
async function updateFormula(version: string, sha256: string) {
|
||||
console.log("Updating Homebrew formula...")
|
||||
|
||||
let formula = await readFile(FORMULA_PATH, "utf-8")
|
||||
|
||||
const tarballUrl = `https://registry.npmjs.org/cline/-/cline-${version}.tgz`
|
||||
|
||||
// Update URL - matches pattern like: url "https://registry.npmjs.org/cline/-/cline-1.0.10.tgz"
|
||||
formula = formula.replace(/url "https:\/\/registry\.npmjs\.org\/cline\/-\/cline-[\d.]+\.tgz"/, `url "${tarballUrl}"`)
|
||||
|
||||
// Update SHA256
|
||||
formula = formula.replace(/sha256 "[a-f0-9]+"/, `sha256 "${sha256}"`)
|
||||
|
||||
await writeFile(FORMULA_PATH, formula, "utf-8")
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const version = await getLocalVersion()
|
||||
console.log(`\nLocal version: ${version}`)
|
||||
|
||||
const sha256 = await packAndGetSHA256(version)
|
||||
console.log(`SHA256: ${sha256}`)
|
||||
|
||||
const tarballUrl = `https://registry.npmjs.org/cline/-/cline-${version}.tgz`
|
||||
console.log(`Tarball URL: ${tarballUrl}`)
|
||||
|
||||
await updateFormula(version, sha256)
|
||||
|
||||
console.log("\n✓ Homebrew formula updated successfully!")
|
||||
console.log("\nNext steps:")
|
||||
console.log("1. Review the changes in cline.rb")
|
||||
console.log("2. Test locally: brew install --build-from-source ./cline.rb")
|
||||
console.log("3. Commit and push to your homebrew tap repository")
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
console.error(`\n✗ Error: ${errorMessage}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* ACP-based implementation of DiffViewProvider that uses the ACP client's
|
||||
* filesystem capabilities for reading and writing files.
|
||||
*
|
||||
* This provider attempts to use the ACP client's fs/read_text_file and
|
||||
* fs/write_text_file methods when available, falling back to the
|
||||
* FileEditProvider's local filesystem implementation otherwise.
|
||||
*
|
||||
* @module acp
|
||||
*/
|
||||
|
||||
import type * as acp from "@agentclientprotocol/sdk"
|
||||
import { workspaceResolver } from "@core/workspace"
|
||||
import { createDirectoriesForFile } from "@utils/fs"
|
||||
import { getCwd } from "@utils/path"
|
||||
import * as fs from "fs/promises"
|
||||
import * as iconv from "iconv-lite"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { FileEditProvider } from "@/integrations/editor/FileEditProvider"
|
||||
import { detectEncoding } from "@/integrations/misc/extract-text"
|
||||
import type { FileDiagnostics } from "@/shared/proto/index.cline"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
|
||||
/**
|
||||
* A function that resolves the current session ID.
|
||||
* This is used by ACPDiffViewProvider to get the session ID at runtime,
|
||||
* since the provider may be created before a session exists.
|
||||
*/
|
||||
export type SessionIdResolver = () => string | undefined
|
||||
|
||||
/**
|
||||
* A DiffViewProvider implementation that uses the ACP client's filesystem
|
||||
* capabilities when available, with fallback to local filesystem operations.
|
||||
*
|
||||
* This class extends FileEditProvider and overrides the file I/O methods to
|
||||
* use the ACP protocol's fs/read_text_file and fs/write_text_file requests
|
||||
* when the client supports these capabilities. This allows the editor (client)
|
||||
* to handle file operations, which enables features like:
|
||||
* - Reading unsaved editor state
|
||||
* - Tracking file modifications in the editor
|
||||
* - Proper integration with the client's undo/redo stack
|
||||
*/
|
||||
export class ACPDiffViewProvider extends FileEditProvider {
|
||||
private readonly connection: acp.AgentSideConnection
|
||||
private readonly clientCapabilities: acp.ClientCapabilities | undefined
|
||||
private readonly sessionIdResolver: SessionIdResolver
|
||||
|
||||
/**
|
||||
* Creates a new ACPDiffViewProvider.
|
||||
*
|
||||
* @param connection - The ACP agent-side connection for making requests
|
||||
* @param clientCapabilities - The client's advertised capabilities
|
||||
* @param sessionIdResolver - A function that returns the current session ID
|
||||
*/
|
||||
constructor(
|
||||
connection: acp.AgentSideConnection,
|
||||
clientCapabilities: acp.ClientCapabilities | undefined,
|
||||
sessionIdResolver: SessionIdResolver,
|
||||
) {
|
||||
super()
|
||||
this.connection = connection
|
||||
this.clientCapabilities = clientCapabilities
|
||||
this.sessionIdResolver = sessionIdResolver
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current session ID, or throws if no session is active.
|
||||
*/
|
||||
private getSessionId(): string {
|
||||
const sessionId = this.sessionIdResolver()
|
||||
if (!sessionId) {
|
||||
throw new Error("No active ACP session. Cannot perform file operation.")
|
||||
}
|
||||
return sessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the client supports file read operations.
|
||||
*/
|
||||
private canReadFile(): boolean {
|
||||
return this.clientCapabilities?.fs?.readTextFile === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the client supports file write operations.
|
||||
*/
|
||||
private canWriteFile(): boolean {
|
||||
return this.clientCapabilities?.fs?.writeTextFile === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a file for editing, using ACP fs capabilities when available.
|
||||
*
|
||||
* If the client supports fs/read_text_file, this method will read the file
|
||||
* content via the ACP connection, which may include unsaved editor state.
|
||||
* Otherwise, it falls back to the FileEditProvider's local fs implementation.
|
||||
*/
|
||||
override async open(relPath: string, options?: { displayPath?: string }): Promise<void> {
|
||||
// If we can't read files via ACP, fall back to FileEditProvider
|
||||
if (!this.canReadFile()) {
|
||||
Logger.debug("[ACPDiffViewProvider] Client does not support fs.readTextFile, falling back to local fs")
|
||||
return super.open(relPath, options)
|
||||
}
|
||||
|
||||
// Set up state - this replicates the DiffViewProvider.open() logic
|
||||
// but uses ACP for file reading instead of local fs
|
||||
this.isEditing = true
|
||||
const cwd = await getCwd()
|
||||
const absolutePathResolved = workspaceResolver.resolveWorkspacePath(cwd, relPath, "ACPDiffViewProvider.open.absolutePath")
|
||||
this.absolutePath = typeof absolutePathResolved === "string" ? absolutePathResolved : absolutePathResolved.absolutePath
|
||||
this.relPath = options?.displayPath ?? relPath
|
||||
const fileExists = this.editType === "modify"
|
||||
|
||||
// Read file content
|
||||
if (fileExists) {
|
||||
// Try to save any dirty state in the editor first
|
||||
try {
|
||||
await HostProvider.workspace.saveOpenDocumentIfDirty({
|
||||
filePath: this.absolutePath!,
|
||||
})
|
||||
} catch {
|
||||
// Ignore errors - the host may not support this
|
||||
}
|
||||
|
||||
// Read file content via ACP
|
||||
try {
|
||||
Logger.debug("[ACPDiffViewProvider] Reading file via ACP:", this.absolutePath)
|
||||
|
||||
const response = await this.connection.readTextFile({
|
||||
sessionId: this.getSessionId(),
|
||||
path: this.absolutePath!,
|
||||
})
|
||||
|
||||
this.originalContent = response.content
|
||||
// ACP always returns UTF-8 text content
|
||||
this.fileEncoding = "utf8"
|
||||
|
||||
Logger.debug("[ACPDiffViewProvider] Read file successfully, length:", response.content.length)
|
||||
} catch (error) {
|
||||
// If ACP read fails, fall back to local fs
|
||||
Logger.debug("[ACPDiffViewProvider] ACP read failed, falling back to local fs:", error)
|
||||
|
||||
const fileBuffer = await fs.readFile(this.absolutePath!)
|
||||
this.fileEncoding = await detectEncoding(fileBuffer)
|
||||
this.originalContent = iconv.decode(fileBuffer, this.fileEncoding)
|
||||
}
|
||||
} else {
|
||||
this.originalContent = ""
|
||||
this.fileEncoding = "utf8"
|
||||
}
|
||||
|
||||
// Create directories for new files
|
||||
const createdDirs = await createDirectoriesForFile(this.absolutePath!)
|
||||
// Store for potential cleanup - access via the private field workaround
|
||||
;(this as any).createdDirs = createdDirs
|
||||
|
||||
// Make sure the file exists before we proceed
|
||||
if (!fileExists) {
|
||||
// For new files, write via ACP if possible, otherwise local fs
|
||||
if (this.canWriteFile()) {
|
||||
try {
|
||||
await this.connection.writeTextFile({
|
||||
sessionId: this.getSessionId(),
|
||||
path: this.absolutePath!,
|
||||
content: "",
|
||||
})
|
||||
} catch {
|
||||
// Fall back to local fs
|
||||
await fs.writeFile(this.absolutePath!, "")
|
||||
}
|
||||
} else {
|
||||
await fs.writeFile(this.absolutePath!, "")
|
||||
}
|
||||
}
|
||||
|
||||
// Get diagnostics before editing
|
||||
let preDiagnostics: FileDiagnostics[] = []
|
||||
try {
|
||||
preDiagnostics = (await HostProvider.workspace.getDiagnostics({})).fileDiagnostics
|
||||
} catch {
|
||||
preDiagnostics = []
|
||||
}
|
||||
;(this as any).preDiagnostics = preDiagnostics
|
||||
|
||||
// Call the parent's openDiffEditor to set up in-memory document content
|
||||
await this.openDiffEditor()
|
||||
await this.scrollEditorToLine(0)
|
||||
;(this as any).streamedLines = []
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls the editor to a specific line.
|
||||
* No-op for file-based providers, but needed for protected access.
|
||||
*/
|
||||
protected override async scrollEditorToLine(_line: number): Promise<void> {
|
||||
// No-op: No visual editor to scroll
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the diff editor.
|
||||
*/
|
||||
protected override async openDiffEditor(): Promise<void> {
|
||||
// Set up in-memory document content from the original content
|
||||
// no-op: No visual editor to open
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the document content, using ACP fs capabilities when available.
|
||||
*
|
||||
* If the client supports fs/write_text_file, this method will write the file
|
||||
* content via the ACP connection. Otherwise, it falls back to the
|
||||
* FileEditProvider's local fs implementation.
|
||||
*/
|
||||
protected override async saveDocument(): Promise<Boolean> {
|
||||
// If we can't write files via ACP, fall back to FileEditProvider
|
||||
if (!this.canWriteFile()) {
|
||||
Logger.debug("[ACPDiffViewProvider] Client does not support fs.writeTextFile, falling back to local fs")
|
||||
return super.saveDocument()
|
||||
}
|
||||
|
||||
const content = await this.getContent()
|
||||
if (!this.absolutePath || content === undefined) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
Logger.debug("[ACPDiffViewProvider] Writing file via ACP:", {
|
||||
path: this.absolutePath,
|
||||
contentLength: content.length,
|
||||
})
|
||||
|
||||
await this.connection.writeTextFile({
|
||||
sessionId: this.getSessionId(),
|
||||
path: this.absolutePath,
|
||||
content: content,
|
||||
})
|
||||
|
||||
Logger.debug("[ACPDiffViewProvider] Write file successfully")
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
// If ACP write fails, fall back to local fs
|
||||
Logger.debug("[ACPDiffViewProvider] ACP write failed, falling back to local fs:", error)
|
||||
|
||||
return super.saveDocument()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* ACP Host Bridge Client Provider
|
||||
*
|
||||
* Implements HostBridgeClientProvider for ACP mode, providing stub implementations
|
||||
* of the 4 required service clients. These clients conform to the interfaces in
|
||||
* host-bridge-client-types.ts and will use ACP connection capabilities where applicable.
|
||||
*
|
||||
* @module acp
|
||||
*/
|
||||
|
||||
import type * as acp from "@agentclientprotocol/sdk"
|
||||
import type {
|
||||
DiffServiceClientInterface,
|
||||
EnvServiceClientInterface,
|
||||
WindowServiceClientInterface,
|
||||
WorkspaceServiceClientInterface,
|
||||
} from "@generated/hosts/host-bridge-client-types"
|
||||
import type { HostBridgeClientProvider, StreamingCallbacks } from "@hosts/host-provider-types"
|
||||
import * as proto from "@shared/proto/index"
|
||||
import { ClineClient } from "@/shared/cline"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
|
||||
/**
|
||||
* Function type that resolves the current session ID.
|
||||
* Returns undefined if no session is active.
|
||||
*/
|
||||
export type SessionIdResolver = () => string | undefined
|
||||
|
||||
/**
|
||||
* Function type that resolves the current working directory.
|
||||
* Returns undefined if no cwd is available (will fall back to process.cwd()).
|
||||
*/
|
||||
export type CwdResolver = () => string | undefined
|
||||
|
||||
/**
|
||||
* ACP implementation of DiffService client.
|
||||
*
|
||||
* Handles diff operations for the ACP environment. Most operations are stubs
|
||||
* that will be implemented in the next phase using ACP extension methods or
|
||||
* the fs capabilities (readTextFile/writeTextFile).
|
||||
*/
|
||||
class ACPDiffServiceClient implements DiffServiceClientInterface {
|
||||
async openDiff(_request: proto.host.OpenDiffRequest): Promise<proto.host.OpenDiffResponse> {
|
||||
// Next phase: Could use ACP client capabilities to open a diff view in the editor.
|
||||
// This would involve sending an ACP extension notification/request to the client
|
||||
// to display a side-by-side diff of the original vs modified content.
|
||||
Logger.debug("[ACPDiffServiceClient] openDiff called (stub)")
|
||||
return proto.host.OpenDiffResponse.create({})
|
||||
}
|
||||
|
||||
async getDocumentText(request: proto.host.GetDocumentTextRequest): Promise<proto.host.GetDocumentTextResponse> {
|
||||
// Next phase: Use connection.readTextFile if clientCapabilities.fs.readTextFile is available.
|
||||
// This would read the current document content from the editor, including any unsaved changes.
|
||||
// For now, return empty content.
|
||||
Logger.debug("[ACPDiffServiceClient] getDocumentText called (stub)", { diffId: request.diffId })
|
||||
return proto.host.GetDocumentTextResponse.create({ content: "" })
|
||||
}
|
||||
|
||||
async replaceText(_request: proto.host.ReplaceTextRequest): Promise<proto.host.ReplaceTextResponse> {
|
||||
// Next phase: Use connection.writeTextFile if clientCapabilities.fs.writeTextFile is available.
|
||||
// This would replace text in the document at the specified range.
|
||||
Logger.debug("[ACPDiffServiceClient] replaceText called (stub)")
|
||||
return proto.host.ReplaceTextResponse.create({})
|
||||
}
|
||||
|
||||
async scrollDiff(_request: proto.host.ScrollDiffRequest): Promise<proto.host.ScrollDiffResponse> {
|
||||
// Next phase: Send ACP extension notification to scroll the diff view to a specific line.
|
||||
// No visual editor in ACP mode by default, so this is a no-op.
|
||||
Logger.debug("[ACPDiffServiceClient] scrollDiff called (stub)")
|
||||
return proto.host.ScrollDiffResponse.create({})
|
||||
}
|
||||
|
||||
async truncateDocument(_request: proto.host.TruncateDocumentRequest): Promise<proto.host.TruncateDocumentResponse> {
|
||||
// Next phase: Read file using readTextFile, truncate content, write back using writeTextFile.
|
||||
// This is used to truncate a document to a specific line count.
|
||||
Logger.debug("[ACPDiffServiceClient] truncateDocument called (stub)")
|
||||
return proto.host.TruncateDocumentResponse.create({})
|
||||
}
|
||||
|
||||
async saveDocument(_request: proto.host.SaveDocumentRequest): Promise<proto.host.SaveDocumentResponse> {
|
||||
// Next phase: Use connection.writeTextFile to persist the document to disk.
|
||||
// This saves the current document content to the file system.
|
||||
Logger.debug("[ACPDiffServiceClient] saveDocument called (stub)")
|
||||
return proto.host.SaveDocumentResponse.create({})
|
||||
}
|
||||
|
||||
async closeAllDiffs(_request: proto.host.CloseAllDiffsRequest): Promise<proto.host.CloseAllDiffsResponse> {
|
||||
// Next phase: Send ACP extension notification to close all diff views in the editor.
|
||||
// No visual diff views in ACP mode by default, so this is a no-op.
|
||||
Logger.debug("[ACPDiffServiceClient] closeAllDiffs called (stub)")
|
||||
return proto.host.CloseAllDiffsResponse.create({})
|
||||
}
|
||||
|
||||
async openMultiFileDiff(_request: proto.host.OpenMultiFileDiffRequest): Promise<proto.host.OpenMultiFileDiffResponse> {
|
||||
// Next phase: Send ACP extension notification to open a multi-file diff view.
|
||||
// This would display changes across multiple files in the editor.
|
||||
Logger.debug("[ACPDiffServiceClient] openMultiFileDiff called (stub)")
|
||||
return proto.host.OpenMultiFileDiffResponse.create({})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ACP implementation of EnvService client.
|
||||
*
|
||||
* Handles environment operations like clipboard access, version info, and telemetry.
|
||||
* Most operations are stubs that will be implemented using ACP extension methods.
|
||||
*/
|
||||
class ACPEnvServiceClient implements EnvServiceClientInterface {
|
||||
private readonly version: string
|
||||
|
||||
constructor(
|
||||
_clientCapabilities: acp.ClientCapabilities | undefined,
|
||||
_sessionIdResolver: SessionIdResolver,
|
||||
version: string = "1.0.0",
|
||||
) {
|
||||
this.version = version
|
||||
}
|
||||
|
||||
async debugLog(request: proto.cline.StringRequest): Promise<proto.cline.Empty> {
|
||||
Logger.debug(request.value)
|
||||
return proto.cline.Empty.create()
|
||||
}
|
||||
|
||||
async clipboardWriteText(_request: proto.cline.StringRequest): Promise<proto.cline.Empty> {
|
||||
Logger.debug("[ACPEnvServiceClient] clipboardWriteText called (stub)")
|
||||
return proto.cline.Empty.create()
|
||||
}
|
||||
|
||||
async clipboardReadText(_request: proto.cline.EmptyRequest): Promise<proto.cline.String> {
|
||||
Logger.debug("[ACPEnvServiceClient] clipboardReadText called (stub)")
|
||||
return proto.cline.String.create({ value: "" })
|
||||
}
|
||||
|
||||
async getHostVersion(_request: proto.cline.EmptyRequest): Promise<proto.host.GetHostVersionResponse> {
|
||||
// Return version info for the ACP agent.
|
||||
return proto.host.GetHostVersionResponse.create({
|
||||
version: this.version,
|
||||
platform: "Cline ACP Agent",
|
||||
clineType: ClineClient.Cli,
|
||||
})
|
||||
}
|
||||
|
||||
async getIdeRedirectUri(_request: proto.cline.EmptyRequest): Promise<proto.cline.String> {
|
||||
Logger.debug("[ACPEnvServiceClient] getIdeRedirectUri called (stub)")
|
||||
return proto.cline.String.create({ value: "" })
|
||||
}
|
||||
|
||||
async getTelemetrySettings(_request: proto.cline.EmptyRequest): Promise<proto.host.GetTelemetrySettingsResponse> {
|
||||
// Return telemetry as disabled by default in ACP mode.
|
||||
return proto.host.GetTelemetrySettingsResponse.create({
|
||||
isEnabled: proto.host.Setting.DISABLED,
|
||||
})
|
||||
}
|
||||
|
||||
subscribeToTelemetrySettings(
|
||||
_request: proto.cline.EmptyRequest,
|
||||
callbacks: StreamingCallbacks<proto.host.TelemetrySettingsEvent>,
|
||||
): () => void {
|
||||
// Send initial telemetry settings (disabled) and return unsubscribe function.
|
||||
callbacks.onResponse(
|
||||
proto.host.TelemetrySettingsEvent.create({
|
||||
isEnabled: proto.host.Setting.DISABLED,
|
||||
}),
|
||||
)
|
||||
// Return no-op unsubscribe function
|
||||
return () => {}
|
||||
}
|
||||
|
||||
async shutdown(_request: proto.cline.EmptyRequest): Promise<proto.cline.Empty> {
|
||||
// Next phase: Graceful ACP connection shutdown.
|
||||
// This would cleanly close the ACP connection and release resources.
|
||||
Logger.debug("[ACPEnvServiceClient] shutdown called (stub)")
|
||||
return proto.cline.Empty.create()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ACP implementation of WindowService client.
|
||||
*
|
||||
* Handles window/UI operations like showing documents, dialogs, and messages.
|
||||
* Most operations are stubs that will be implemented using ACP extension methods.
|
||||
*/
|
||||
class ACPWindowServiceClient implements WindowServiceClientInterface {
|
||||
constructor(_clientCapabilities: acp.ClientCapabilities | undefined, _sessionIdResolver: SessionIdResolver) {}
|
||||
|
||||
async showTextDocument(request: proto.host.ShowTextDocumentRequest): Promise<proto.host.TextEditorInfo> {
|
||||
// Next phase: Send ACP extension request to open document in the editor.
|
||||
// This would tell the ACP client to open the specified file.
|
||||
Logger.debug("[ACPWindowServiceClient] showTextDocument called (stub)", { path: request.path })
|
||||
return proto.host.TextEditorInfo.create({
|
||||
documentPath: request.path,
|
||||
})
|
||||
}
|
||||
|
||||
async showOpenDialogue(_request: proto.host.ShowOpenDialogueRequest): Promise<proto.host.SelectedResources> {
|
||||
// Next phase: Send ACP extension request for file picker dialog.
|
||||
// This would display a file open dialog in the ACP client.
|
||||
Logger.debug("[ACPWindowServiceClient] showOpenDialogue called (stub)")
|
||||
return proto.host.SelectedResources.create({ paths: [] })
|
||||
}
|
||||
|
||||
async showMessage(request: proto.host.ShowMessageRequest): Promise<proto.host.SelectedResponse> {
|
||||
// Next phase: Send ACP extension notification to show message in the editor.
|
||||
// This would display an information/warning/error message to the user.
|
||||
Logger.debug("[ACPWindowServiceClient] showMessage called (stub)", {
|
||||
message: request.message,
|
||||
type: request.type,
|
||||
})
|
||||
return proto.host.SelectedResponse.create({})
|
||||
}
|
||||
|
||||
async showInputBox(_request: proto.host.ShowInputBoxRequest): Promise<proto.host.ShowInputBoxResponse> {
|
||||
// Next phase: Send ACP extension request for input dialog.
|
||||
// This would display an input box for user text entry.
|
||||
Logger.debug("[ACPWindowServiceClient] showInputBox called (stub)")
|
||||
return proto.host.ShowInputBoxResponse.create({ response: "" })
|
||||
}
|
||||
|
||||
async showSaveDialog(_request: proto.host.ShowSaveDialogRequest): Promise<proto.host.ShowSaveDialogResponse> {
|
||||
// Next phase: Send ACP extension request for save dialog.
|
||||
// This would display a file save dialog in the ACP client.
|
||||
Logger.debug("[ACPWindowServiceClient] showSaveDialog called (stub)")
|
||||
return proto.host.ShowSaveDialogResponse.create({ selectedPath: "" })
|
||||
}
|
||||
|
||||
async openFile(request: proto.host.OpenFileRequest): Promise<proto.host.OpenFileResponse> {
|
||||
// Next phase: Send ACP extension request to open file in the editor.
|
||||
// This would open the specified file in the ACP client's editor.
|
||||
Logger.debug("[ACPWindowServiceClient] openFile called (stub)", { filePath: request.filePath })
|
||||
return proto.host.OpenFileResponse.create({})
|
||||
}
|
||||
|
||||
async openSettings(_request: proto.host.OpenSettingsRequest): Promise<proto.host.OpenSettingsResponse> {
|
||||
// Next phase: Send ACP extension request to open settings panel.
|
||||
// This would open the settings/preferences in the ACP client.
|
||||
Logger.debug("[ACPWindowServiceClient] openSettings called (stub)")
|
||||
return proto.host.OpenSettingsResponse.create({})
|
||||
}
|
||||
|
||||
async getOpenTabs(_request: proto.host.GetOpenTabsRequest): Promise<proto.host.GetOpenTabsResponse> {
|
||||
// Next phase: Send ACP extension request to list open tabs/documents.
|
||||
// This would return a list of currently open files in the editor.
|
||||
Logger.debug("[ACPWindowServiceClient] getOpenTabs called (stub)")
|
||||
return proto.host.GetOpenTabsResponse.create({ paths: [] })
|
||||
}
|
||||
|
||||
async getVisibleTabs(_request: proto.host.GetVisibleTabsRequest): Promise<proto.host.GetVisibleTabsResponse> {
|
||||
// Next phase: Send ACP extension request to list visible tabs.
|
||||
// This would return a list of visible tabs/panes in the editor.
|
||||
Logger.debug("[ACPWindowServiceClient] getVisibleTabs called (stub)")
|
||||
return proto.host.GetVisibleTabsResponse.create({ paths: [] })
|
||||
}
|
||||
|
||||
async getActiveEditor(_request: proto.host.GetActiveEditorRequest): Promise<proto.host.GetActiveEditorResponse> {
|
||||
// Next phase: Send ACP extension request to get active editor info.
|
||||
// This would return information about the currently focused editor.
|
||||
Logger.debug("[ACPWindowServiceClient] getActiveEditor called (stub)")
|
||||
return proto.host.GetActiveEditorResponse.create({})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ACP implementation of WorkspaceService client.
|
||||
*
|
||||
* Handles workspace operations like getting paths, diagnostics, and terminal commands.
|
||||
* Uses the cwdResolver to get the current working directory, falling back to process.cwd().
|
||||
*/
|
||||
class ACPWorkspaceServiceClient implements WorkspaceServiceClientInterface {
|
||||
private readonly _clientCapabilities: acp.ClientCapabilities | undefined
|
||||
private readonly cwdResolver: CwdResolver
|
||||
|
||||
constructor(
|
||||
clientCapabilities: acp.ClientCapabilities | undefined,
|
||||
_sessionIdResolver: SessionIdResolver,
|
||||
cwdResolver: CwdResolver,
|
||||
) {
|
||||
this._clientCapabilities = clientCapabilities
|
||||
this.cwdResolver = cwdResolver
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current working directory, using the resolver if available,
|
||||
* otherwise falling back to process.cwd().
|
||||
*/
|
||||
private getCwd(): string {
|
||||
return this.cwdResolver() ?? process.cwd()
|
||||
}
|
||||
|
||||
async getWorkspacePaths(_request: proto.host.GetWorkspacePathsRequest): Promise<proto.host.GetWorkspacePathsResponse> {
|
||||
// Return the current working directory from the resolver.
|
||||
const cwd = this.getCwd()
|
||||
Logger.debug("[ACPWorkspaceServiceClient] getWorkspacePaths called", { cwd })
|
||||
return proto.host.GetWorkspacePathsResponse.create({
|
||||
paths: [cwd],
|
||||
})
|
||||
}
|
||||
|
||||
async saveOpenDocumentIfDirty(
|
||||
_request: proto.host.SaveOpenDocumentIfDirtyRequest,
|
||||
): Promise<proto.host.SaveOpenDocumentIfDirtyResponse> {
|
||||
// Next phase: Use ACP extension or fs.writeTextFile to save dirty documents.
|
||||
// This would save any unsaved changes in the specified document.
|
||||
Logger.debug("[ACPWorkspaceServiceClient] saveOpenDocumentIfDirty called (stub)")
|
||||
return proto.host.SaveOpenDocumentIfDirtyResponse.create({})
|
||||
}
|
||||
|
||||
async getDiagnostics(_request: proto.host.GetDiagnosticsRequest): Promise<proto.host.GetDiagnosticsResponse> {
|
||||
// Next phase: Send ACP extension request for diagnostics (errors, warnings).
|
||||
// This would return linting/compilation errors from the ACP client.
|
||||
Logger.debug("[ACPWorkspaceServiceClient] getDiagnostics called (stub)")
|
||||
return proto.host.GetDiagnosticsResponse.create({ fileDiagnostics: [] })
|
||||
}
|
||||
|
||||
async openProblemsPanel(_request: proto.host.OpenProblemsPanelRequest): Promise<proto.host.OpenProblemsPanelResponse> {
|
||||
// Next phase: Send ACP extension notification to open the problems panel.
|
||||
// This would show the diagnostics/problems view in the editor.
|
||||
Logger.debug("[ACPWorkspaceServiceClient] openProblemsPanel called (stub)")
|
||||
return proto.host.OpenProblemsPanelResponse.create({})
|
||||
}
|
||||
|
||||
async openInFileExplorerPanel(
|
||||
request: proto.host.OpenInFileExplorerPanelRequest,
|
||||
): Promise<proto.host.OpenInFileExplorerPanelResponse> {
|
||||
// Next phase: Send ACP extension notification to reveal file in explorer.
|
||||
// This would highlight/reveal the specified path in the file tree.
|
||||
Logger.debug("[ACPWorkspaceServiceClient] openInFileExplorerPanel called (stub)", { path: request.path })
|
||||
return proto.host.OpenInFileExplorerPanelResponse.create({})
|
||||
}
|
||||
|
||||
async openClineSidebarPanel(
|
||||
_request: proto.host.OpenClineSidebarPanelRequest,
|
||||
): Promise<proto.host.OpenClineSidebarPanelResponse> {
|
||||
// Next phase: Send ACP extension notification to open Cline sidebar.
|
||||
// This would show the Cline panel/sidebar in the editor.
|
||||
Logger.debug("[ACPWorkspaceServiceClient] openClineSidebarPanel called (stub)")
|
||||
return proto.host.OpenClineSidebarPanelResponse.create({})
|
||||
}
|
||||
|
||||
async openTerminalPanel(_request: proto.host.OpenTerminalRequest): Promise<proto.host.OpenTerminalResponse> {
|
||||
// Next phase: Send ACP extension notification or use createTerminal capability.
|
||||
// This would open/show the terminal panel in the editor.
|
||||
Logger.debug("[ACPWorkspaceServiceClient] openTerminalPanel called (stub)")
|
||||
return proto.host.OpenTerminalResponse.create({})
|
||||
}
|
||||
|
||||
async executeCommandInTerminal(
|
||||
request: proto.host.ExecuteCommandInTerminalRequest,
|
||||
): Promise<proto.host.ExecuteCommandInTerminalResponse> {
|
||||
// Next phase: Use connection.createTerminal if clientCapabilities.terminal is available.
|
||||
// This would execute the specified command in a terminal via the ACP client.
|
||||
// The ACP SDK provides createTerminal() which returns a TerminalHandle with
|
||||
// methods like currentOutput(), waitForExit(), kill(), and release().
|
||||
Logger.debug("[ACPWorkspaceServiceClient] executeCommandInTerminal called (stub)", {
|
||||
command: request.command,
|
||||
hasTerminalCapability: this._clientCapabilities?.terminal,
|
||||
})
|
||||
return proto.host.ExecuteCommandInTerminalResponse.create({})
|
||||
}
|
||||
|
||||
async openFolder(request: proto.host.OpenFolderRequest): Promise<proto.host.OpenFolderResponse> {
|
||||
// Next phase: Send ACP extension request to change workspace/folder.
|
||||
// This would open a new folder/workspace in the ACP client.
|
||||
Logger.debug("[ACPWorkspaceServiceClient] openFolder called (stub)", { path: request.path })
|
||||
return proto.host.OpenFolderResponse.create({ success: true })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ACP Host Bridge Client Provider
|
||||
*
|
||||
* Provides the 4 service clients required by HostBridgeClientProvider interface,
|
||||
* implemented for the ACP environment. Uses the ACP connection and client capabilities
|
||||
* to delegate operations to the ACP client where possible.
|
||||
*/
|
||||
export class ACPHostBridgeClientProvider implements HostBridgeClientProvider {
|
||||
workspaceClient: WorkspaceServiceClientInterface
|
||||
envClient: EnvServiceClientInterface
|
||||
windowClient: WindowServiceClientInterface
|
||||
diffClient: DiffServiceClientInterface
|
||||
|
||||
/**
|
||||
* Creates a new ACPHostBridgeClientProvider.
|
||||
*
|
||||
* @param connection - The ACP agent-side connection for making requests
|
||||
* @param clientCapabilities - The client's advertised capabilities
|
||||
* @param sessionIdResolver - Function that returns the current session ID
|
||||
* @param cwdResolver - Function that returns the current working directory
|
||||
* @param debug - Whether to enable debug logging
|
||||
* @param version - Version string for getHostVersion (optional)
|
||||
*/
|
||||
constructor(
|
||||
clientCapabilities: acp.ClientCapabilities | undefined,
|
||||
sessionIdResolver: SessionIdResolver,
|
||||
cwdResolver: CwdResolver,
|
||||
version: string = "1.0.0",
|
||||
) {
|
||||
this.workspaceClient = new ACPWorkspaceServiceClient(clientCapabilities, sessionIdResolver, cwdResolver)
|
||||
this.envClient = new ACPEnvServiceClient(clientCapabilities, sessionIdResolver, version)
|
||||
this.windowClient = new ACPWindowServiceClient(clientCapabilities, sessionIdResolver)
|
||||
this.diffClient = new ACPDiffServiceClient()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* AcpAgent - Thin wrapper that bridges stdio connection to ClineAgent.
|
||||
*
|
||||
* This class wraps the ClineAgent and connects it to an ACP AgentSideConnection
|
||||
* for stdio-based communication. It:
|
||||
* - Wires up the permission handler to call connection.requestPermission()
|
||||
* - Subscribes to ClineAgent session events and forwards them to connection.sessionUpdate()
|
||||
* - Delegates all acp.Agent methods to the internal ClineAgent
|
||||
*
|
||||
* For programmatic usage without stdio, use ClineAgent directly.
|
||||
*
|
||||
* @module acp
|
||||
*/
|
||||
|
||||
import type * as acp from "@agentclientprotocol/sdk"
|
||||
import { Logger } from "@/shared/services/Logger.js"
|
||||
import { ClineAgent } from "../agent/ClineAgent.js"
|
||||
import type { AcpAgentOptions, SessionUpdateType } from "../agent/types.js"
|
||||
|
||||
/**
|
||||
* ACP Agent wrapper that bridges stdio connection to ClineAgent.
|
||||
*
|
||||
* This is the class used by runAcpMode() for stdio-based ACP communication.
|
||||
* It creates an internal ClineAgent and wires up the connection for:
|
||||
* - Permission requests (via connection.requestPermission)
|
||||
* - Session updates (via connection.sessionUpdate)
|
||||
*/
|
||||
export class AcpAgent implements acp.Agent {
|
||||
private readonly connection: acp.AgentSideConnection
|
||||
private readonly clineAgent: ClineAgent
|
||||
|
||||
/** Track which sessions we've subscribed to for event forwarding */
|
||||
private readonly subscribedSessions: Set<string> = new Set()
|
||||
|
||||
constructor(connection: acp.AgentSideConnection, options: AcpAgentOptions) {
|
||||
this.connection = connection
|
||||
|
||||
// Create the internal ClineAgent
|
||||
this.clineAgent = new ClineAgent(options)
|
||||
|
||||
// Wire up the permission handler to use the connection
|
||||
this.clineAgent.setPermissionHandler(async (request, resolve) => {
|
||||
try {
|
||||
Logger.debug("[AcpAgent] Forwarding permission request to connection")
|
||||
const response = await this.connection.requestPermission({
|
||||
sessionId: this.getCurrentSessionId() ?? "",
|
||||
toolCall: request.toolCall,
|
||||
options: request.options,
|
||||
})
|
||||
resolve(response)
|
||||
} catch (error) {
|
||||
Logger.debug("[AcpAgent] Error requesting permission:", error)
|
||||
resolve({ outcome: "rejected" as unknown as acp.RequestPermissionOutcome })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current active session ID from the ClineAgent.
|
||||
*/
|
||||
private getCurrentSessionId(): string | undefined {
|
||||
// Find the session that's currently processing
|
||||
for (const [sessionId, session] of this.clineAgent.sessions) {
|
||||
if (session.controller?.task) {
|
||||
return sessionId
|
||||
}
|
||||
}
|
||||
// Fall back to the first session if none is actively processing
|
||||
const firstSession = this.clineAgent.sessions.keys().next()
|
||||
return firstSession.done ? undefined : firstSession.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to session events and forward them to the connection.
|
||||
*/
|
||||
private subscribeToSessionEvents(sessionId: string): void {
|
||||
if (this.subscribedSessions.has(sessionId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const emitter = this.clineAgent.emitterForSession(sessionId)
|
||||
|
||||
// Forward session update by adding the sessionUpdate discriminator
|
||||
const forwardSessionUpdate = <K extends SessionUpdateType>(eventName: K) => {
|
||||
emitter.on(eventName, (payload: Record<string, unknown>) => {
|
||||
const update = {
|
||||
sessionUpdate: eventName,
|
||||
...payload,
|
||||
} as acp.SessionUpdate
|
||||
this.connection.sessionUpdate({ sessionId, update }).catch((error) => {
|
||||
Logger.error(`[AcpAgent] Error forwarding ${eventName}:`, error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Forward all standard session updates
|
||||
forwardSessionUpdate("agent_message_chunk")
|
||||
forwardSessionUpdate("agent_thought_chunk")
|
||||
forwardSessionUpdate("tool_call")
|
||||
forwardSessionUpdate("tool_call_update")
|
||||
forwardSessionUpdate("available_commands_update")
|
||||
forwardSessionUpdate("plan")
|
||||
forwardSessionUpdate("current_mode_update")
|
||||
forwardSessionUpdate("user_message_chunk")
|
||||
forwardSessionUpdate("config_option_update")
|
||||
forwardSessionUpdate("session_info_update")
|
||||
|
||||
// Handle errors specially (not part of ACP SessionUpdate)
|
||||
emitter.on("error", (error) => {
|
||||
Logger.error("[AcpAgent] Session error:", error)
|
||||
})
|
||||
|
||||
this.subscribedSessions.add(sessionId)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// acp.Agent Interface Implementation - Delegate to ClineAgent
|
||||
// ============================================================
|
||||
|
||||
async initialize(params: acp.InitializeRequest): Promise<acp.InitializeResponse> {
|
||||
return await this.clineAgent.initialize(params, this.connection)
|
||||
}
|
||||
|
||||
async newSession(params: acp.NewSessionRequest): Promise<acp.NewSessionResponse> {
|
||||
const response = await this.clineAgent.newSession(params)
|
||||
// Subscribe to events for this new session
|
||||
this.subscribeToSessionEvents(response.sessionId)
|
||||
return response
|
||||
}
|
||||
|
||||
async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
|
||||
// Ensure we're subscribed to this session's events
|
||||
this.subscribeToSessionEvents(params.sessionId)
|
||||
return this.clineAgent.prompt(params)
|
||||
}
|
||||
|
||||
async cancel(params: acp.CancelNotification): Promise<void> {
|
||||
return this.clineAgent.cancel(params)
|
||||
}
|
||||
|
||||
async setSessionMode(params: acp.SetSessionModeRequest): Promise<acp.SetSessionModeResponse> {
|
||||
return this.clineAgent.setSessionMode(params)
|
||||
}
|
||||
|
||||
async unstable_setSessionModel(params: acp.SetSessionModelRequest): Promise<acp.SetSessionModelResponse> {
|
||||
return this.clineAgent.unstable_setSessionModel(params)
|
||||
}
|
||||
|
||||
async authenticate(params: acp.AuthenticateRequest): Promise<acp.AuthenticateResponse> {
|
||||
return this.clineAgent.authenticate(params)
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
this.subscribedSessions.clear()
|
||||
return this.clineAgent.shutdown()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Entry point for ACP (Agent Client Protocol) mode.
|
||||
*
|
||||
* When the CLI is invoked with `--acp`, this module sets up the ACP connection
|
||||
* and runs Cline as an ACP-compliant agent communicating over stdio.
|
||||
*
|
||||
* This module exports:
|
||||
* - `ClineAgent` - Decoupled agent for programmatic use (no stdio dependency)
|
||||
* - `AcpAgent` - Thin wrapper that bridges stdio connection to ClineAgent
|
||||
* - `ClineSessionEmitter` - Typed EventEmitter for per-session events
|
||||
* - `runAcpMode` - Function to run Cline in stdio-based ACP mode
|
||||
*
|
||||
* @module acp
|
||||
*/
|
||||
|
||||
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { version as CLI_VERSION } from "../../../package.json"
|
||||
import { AcpAgent } from "./AcpAgent.js"
|
||||
import { nodeToWebReadable, nodeToWebWritable } from "./streamUtils.js"
|
||||
|
||||
// Re-export classes for programmatic use
|
||||
export { ClineAgent } from "../agent/ClineAgent.js"
|
||||
export { ClineSessionEmitter } from "../agent/ClineSessionEmitter.js"
|
||||
// Re-export types
|
||||
export type {
|
||||
AcpAgentOptions,
|
||||
AcpSessionState,
|
||||
ClineAcpSession,
|
||||
ClineAgentOptions,
|
||||
ClineSessionEvents,
|
||||
PermissionHandler,
|
||||
PermissionResolver,
|
||||
} from "../agent/types.js"
|
||||
export { AcpAgent } from "./AcpAgent.js"
|
||||
|
||||
/** Original console methods for restoration if needed */
|
||||
const originalConsole = {
|
||||
log: console.log,
|
||||
info: console.info,
|
||||
warn: console.warn,
|
||||
debug: console.debug,
|
||||
error: console.error,
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect all console output to stderr.
|
||||
*
|
||||
* In ACP mode, stdout is reserved exclusively for JSON-RPC communication.
|
||||
* All logging must go to stderr to avoid corrupting the protocol stream.
|
||||
*/
|
||||
function redirectConsoleToStderr(): void {
|
||||
console.log = (...args) => console.error(...args)
|
||||
console.info = (...args) => console.error(...args)
|
||||
console.warn = (...args) => console.error(...args)
|
||||
console.debug = (...args) => console.error(...args)
|
||||
// console.error already goes to stderr
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore console methods to their original behavior.
|
||||
*/
|
||||
export function restoreConsole(): void {
|
||||
console.log = originalConsole.log
|
||||
console.info = originalConsole.info
|
||||
console.warn = originalConsole.warn
|
||||
console.debug = originalConsole.debug
|
||||
console.error = originalConsole.error
|
||||
}
|
||||
|
||||
export interface AcpModeOptions {
|
||||
/** Path to Cline configuration directory */
|
||||
config?: string
|
||||
/** Working directory (default: process.cwd()) */
|
||||
cwd?: string
|
||||
/** Enable verbose/debug logging to stderr */
|
||||
verbose?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Cline in ACP mode.
|
||||
*
|
||||
* This function:
|
||||
* 1. Redirects console output to stderr (stdout reserved for JSON-RPC)
|
||||
* 2. Sets up the ndJsonStream for stdio communication
|
||||
* 3. Creates the AgentSideConnection with our AcpAgent factory
|
||||
* 4. Initializes the CLI infrastructure (StateManager, Controller, etc.)
|
||||
* 5. Keeps the process alive until the connection closes
|
||||
*
|
||||
* @param options - Configuration options for ACP mode
|
||||
*/
|
||||
export async function runAcpMode(options: AcpModeOptions = {}): Promise<void> {
|
||||
redirectConsoleToStderr()
|
||||
|
||||
const outputStream = nodeToWebWritable(process.stdout)
|
||||
const inputStream = nodeToWebReadable(process.stdin)
|
||||
const stream = ndJsonStream(outputStream, inputStream)
|
||||
let agent: AcpAgent | null = null
|
||||
|
||||
new AgentSideConnection((conn) => {
|
||||
agent = new AcpAgent(conn, {
|
||||
version: CLI_VERSION,
|
||||
debug: Boolean(options.verbose),
|
||||
})
|
||||
return agent
|
||||
}, stream)
|
||||
|
||||
let isShuttingDown = false
|
||||
const shutdown = async () => {
|
||||
if (isShuttingDown) {
|
||||
// Force exit on second signal
|
||||
process.exit(1)
|
||||
}
|
||||
isShuttingDown = true
|
||||
try {
|
||||
await agent?.shutdown()
|
||||
restoreConsole()
|
||||
} catch (error) {
|
||||
Logger.error("[ACP] Error during shutdown:", error)
|
||||
}
|
||||
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.on("SIGINT", shutdown)
|
||||
process.on("SIGTERM", shutdown)
|
||||
|
||||
// Keep the process alive
|
||||
// The ndJsonStream will handle stdin events automatically.
|
||||
// We need to ensure the process doesn't exit while waiting for input.
|
||||
process.stdin.resume()
|
||||
|
||||
// Handle stdin end (client disconnected)
|
||||
process.stdin.on("end", shutdown)
|
||||
|
||||
// Handle stdin errors
|
||||
process.stdin.on("error", async (error) => {
|
||||
Logger.error("[ACP] stdin error:", error)
|
||||
await shutdown()
|
||||
})
|
||||
|
||||
Logger.info("[ACP] Process is now listening for ACP requests on stdin")
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Stream conversion utilities for ACP mode.
|
||||
*
|
||||
* The ACP SDK's ndJsonStream function expects Web Streams (ReadableStream/WritableStream),
|
||||
* but Node.js provides its own stream types. These utilities convert between them.
|
||||
*
|
||||
* @module acp/streamUtils
|
||||
*/
|
||||
|
||||
import type { Readable, Writable } from "node:stream"
|
||||
|
||||
/**
|
||||
* Convert a Node.js Writable stream to a Web WritableStream.
|
||||
*
|
||||
* Used to convert process.stdout for ACP output.
|
||||
*
|
||||
* @param nodeStream - Node.js Writable stream (e.g., process.stdout)
|
||||
* @returns Web WritableStream compatible with ndJsonStream
|
||||
*/
|
||||
export function nodeToWebWritable(nodeStream: Writable): WritableStream<Uint8Array> {
|
||||
return new WritableStream<Uint8Array>({
|
||||
write(chunk) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
nodeStream.write(Buffer.from(chunk), (err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Node.js Readable stream to a Web ReadableStream.
|
||||
*
|
||||
* Used to convert process.stdin for ACP input.
|
||||
*
|
||||
* @param nodeStream - Node.js Readable stream (e.g., process.stdin)
|
||||
* @returns Web ReadableStream compatible with ndJsonStream
|
||||
*/
|
||||
export function nodeToWebReadable(nodeStream: Readable): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
nodeStream.on("data", (chunk: Buffer) => {
|
||||
controller.enqueue(new Uint8Array(chunk))
|
||||
})
|
||||
nodeStream.on("end", () => controller.close())
|
||||
nodeStream.on("error", (err) => controller.error(err))
|
||||
},
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* Tests for ClineSessionEmitter - Typed EventEmitter for per-session ACP events.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { ClineSessionEmitter } from "./ClineSessionEmitter.js"
|
||||
import type { SessionUpdatePayload } from "./types.js"
|
||||
|
||||
describe("ClineSessionEmitter", () => {
|
||||
let emitter: ClineSessionEmitter
|
||||
|
||||
beforeEach(() => {
|
||||
emitter = new ClineSessionEmitter()
|
||||
})
|
||||
|
||||
describe("on/emit", () => {
|
||||
it("should emit and receive agent_message_chunk events", () => {
|
||||
const listener = vi.fn()
|
||||
const payload: SessionUpdatePayload<"agent_message_chunk"> = {
|
||||
content: { type: "text", text: "Hello, world!" },
|
||||
}
|
||||
|
||||
emitter.on("agent_message_chunk", listener)
|
||||
emitter.emit("agent_message_chunk", payload)
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
expect(listener).toHaveBeenCalledWith(payload)
|
||||
})
|
||||
|
||||
it("should emit and receive agent_thought_chunk events", () => {
|
||||
const listener = vi.fn()
|
||||
const payload: SessionUpdatePayload<"agent_thought_chunk"> = {
|
||||
content: { type: "text", text: "Thinking..." },
|
||||
}
|
||||
|
||||
emitter.on("agent_thought_chunk", listener)
|
||||
emitter.emit("agent_thought_chunk", payload)
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
expect(listener).toHaveBeenCalledWith(payload)
|
||||
})
|
||||
|
||||
it("should emit and receive tool_call events", () => {
|
||||
const listener = vi.fn()
|
||||
const payload: SessionUpdatePayload<"tool_call"> = {
|
||||
toolCallId: "test-tool-call-id",
|
||||
title: "Test Tool Call",
|
||||
status: "in_progress",
|
||||
}
|
||||
|
||||
emitter.on("tool_call", listener)
|
||||
emitter.emit("tool_call", payload)
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
expect(listener).toHaveBeenCalledWith(payload)
|
||||
})
|
||||
|
||||
it("should emit and receive tool_call_update events", () => {
|
||||
const listener = vi.fn()
|
||||
const payload: SessionUpdatePayload<"tool_call_update"> = {
|
||||
toolCallId: "test-tool-call-id",
|
||||
status: "completed",
|
||||
rawOutput: { result: "success" },
|
||||
}
|
||||
|
||||
emitter.on("tool_call_update", listener)
|
||||
emitter.emit("tool_call_update", payload)
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
expect(listener).toHaveBeenCalledWith(payload)
|
||||
})
|
||||
|
||||
it("should emit and receive available_commands_update events", () => {
|
||||
const listener = vi.fn()
|
||||
const payload: SessionUpdatePayload<"available_commands_update"> = {
|
||||
availableCommands: [{ name: "test", description: "Test command" }],
|
||||
}
|
||||
|
||||
emitter.on("available_commands_update", listener)
|
||||
emitter.emit("available_commands_update", payload)
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
expect(listener).toHaveBeenCalledWith(payload)
|
||||
})
|
||||
|
||||
it("should emit and receive current_mode_update events", () => {
|
||||
const listener = vi.fn()
|
||||
const payload: SessionUpdatePayload<"current_mode_update"> = {
|
||||
currentModeId: "act",
|
||||
}
|
||||
|
||||
emitter.on("current_mode_update", listener)
|
||||
emitter.emit("current_mode_update", payload)
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
expect(listener).toHaveBeenCalledWith(payload)
|
||||
})
|
||||
|
||||
it("should emit and receive plan events", () => {
|
||||
const listener = vi.fn()
|
||||
const payload: SessionUpdatePayload<"plan"> = {
|
||||
entries: [{ content: "Step 1", status: "pending", priority: "high" }],
|
||||
}
|
||||
|
||||
emitter.on("plan", listener)
|
||||
emitter.emit("plan", payload)
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
expect(listener).toHaveBeenCalledWith(payload)
|
||||
})
|
||||
|
||||
it("should emit and receive error events", () => {
|
||||
const listener = vi.fn()
|
||||
const error = new Error("Test error")
|
||||
|
||||
emitter.on("error", listener)
|
||||
emitter.emit("error", error)
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
expect(listener).toHaveBeenCalledWith(error)
|
||||
})
|
||||
})
|
||||
|
||||
describe("multiple listeners", () => {
|
||||
it("should support multiple listeners for the same event", () => {
|
||||
const listener1 = vi.fn()
|
||||
const listener2 = vi.fn()
|
||||
const payload: SessionUpdatePayload<"agent_message_chunk"> = {
|
||||
content: { type: "text", text: "Hello" },
|
||||
}
|
||||
|
||||
emitter.on("agent_message_chunk", listener1)
|
||||
emitter.on("agent_message_chunk", listener2)
|
||||
emitter.emit("agent_message_chunk", payload)
|
||||
|
||||
expect(listener1).toHaveBeenCalledTimes(1)
|
||||
expect(listener2).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should call listeners in order of registration", () => {
|
||||
const order: number[] = []
|
||||
const listener1 = vi.fn(() => order.push(1))
|
||||
const listener2 = vi.fn(() => order.push(2))
|
||||
const payload: SessionUpdatePayload<"agent_message_chunk"> = {
|
||||
content: { type: "text", text: "Hello" },
|
||||
}
|
||||
|
||||
emitter.on("agent_message_chunk", listener1)
|
||||
emitter.on("agent_message_chunk", listener2)
|
||||
emitter.emit("agent_message_chunk", payload)
|
||||
|
||||
expect(order).toEqual([1, 2])
|
||||
})
|
||||
})
|
||||
|
||||
describe("off", () => {
|
||||
it("should remove a specific listener", () => {
|
||||
const listener = vi.fn()
|
||||
const payload: SessionUpdatePayload<"agent_message_chunk"> = {
|
||||
content: { type: "text", text: "Hello" },
|
||||
}
|
||||
|
||||
emitter.on("agent_message_chunk", listener)
|
||||
emitter.off("agent_message_chunk", listener)
|
||||
emitter.emit("agent_message_chunk", payload)
|
||||
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should only remove the specified listener", () => {
|
||||
const listener1 = vi.fn()
|
||||
const listener2 = vi.fn()
|
||||
const payload: SessionUpdatePayload<"agent_message_chunk"> = {
|
||||
content: { type: "text", text: "Hello" },
|
||||
}
|
||||
|
||||
emitter.on("agent_message_chunk", listener1)
|
||||
emitter.on("agent_message_chunk", listener2)
|
||||
emitter.off("agent_message_chunk", listener1)
|
||||
emitter.emit("agent_message_chunk", payload)
|
||||
|
||||
expect(listener1).not.toHaveBeenCalled()
|
||||
expect(listener2).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("once", () => {
|
||||
it("should only call the listener once", () => {
|
||||
const listener = vi.fn()
|
||||
const payload: SessionUpdatePayload<"agent_message_chunk"> = {
|
||||
content: { type: "text", text: "Hello" },
|
||||
}
|
||||
|
||||
emitter.once("agent_message_chunk", listener)
|
||||
emitter.emit("agent_message_chunk", payload)
|
||||
emitter.emit("agent_message_chunk", payload)
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("removeAllListeners", () => {
|
||||
it("should remove all listeners for a specific event", () => {
|
||||
const listener1 = vi.fn()
|
||||
const listener2 = vi.fn()
|
||||
const payload: SessionUpdatePayload<"agent_message_chunk"> = {
|
||||
content: { type: "text", text: "Hello" },
|
||||
}
|
||||
|
||||
emitter.on("agent_message_chunk", listener1)
|
||||
emitter.on("agent_message_chunk", listener2)
|
||||
emitter.removeAllListeners("agent_message_chunk")
|
||||
emitter.emit("agent_message_chunk", payload)
|
||||
|
||||
expect(listener1).not.toHaveBeenCalled()
|
||||
expect(listener2).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should remove all listeners when no event is specified", () => {
|
||||
const listener1 = vi.fn()
|
||||
const listener2 = vi.fn()
|
||||
|
||||
emitter.on("agent_message_chunk", listener1)
|
||||
emitter.on("tool_call", listener2)
|
||||
emitter.removeAllListeners()
|
||||
emitter.emit("agent_message_chunk", { content: { type: "text", text: "Hello" } })
|
||||
emitter.emit("tool_call", { toolCallId: "test", title: "Test" })
|
||||
|
||||
expect(listener1).not.toHaveBeenCalled()
|
||||
expect(listener2).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("listenerCount", () => {
|
||||
it("should return the correct number of listeners", () => {
|
||||
const listener1 = vi.fn()
|
||||
const listener2 = vi.fn()
|
||||
|
||||
expect(emitter.listenerCount("agent_message_chunk")).toBe(0)
|
||||
|
||||
emitter.on("agent_message_chunk", listener1)
|
||||
expect(emitter.listenerCount("agent_message_chunk")).toBe(1)
|
||||
|
||||
emitter.on("agent_message_chunk", listener2)
|
||||
expect(emitter.listenerCount("agent_message_chunk")).toBe(2)
|
||||
|
||||
emitter.off("agent_message_chunk", listener1)
|
||||
expect(emitter.listenerCount("agent_message_chunk")).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("chaining", () => {
|
||||
it("should support method chaining", () => {
|
||||
const listener = vi.fn()
|
||||
|
||||
const result = emitter.on("agent_message_chunk", listener).on("error", vi.fn()).off("error", vi.fn())
|
||||
|
||||
expect(result).toBe(emitter)
|
||||
})
|
||||
})
|
||||
|
||||
describe("emit return value", () => {
|
||||
it("should return true when there are listeners", () => {
|
||||
emitter.on("agent_message_chunk", vi.fn())
|
||||
const result = emitter.emit("agent_message_chunk", { content: { type: "text", text: "Hello" } })
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false when there are no listeners", () => {
|
||||
const result = emitter.emit("agent_message_chunk", { content: { type: "text", text: "Hello" } })
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Typed EventEmitter for per-session ACP events.
|
||||
*
|
||||
* This class provides a type-safe wrapper around Node's EventEmitter
|
||||
* for emitting and subscribing to session-specific ACP events.
|
||||
*
|
||||
* @module acp
|
||||
*/
|
||||
|
||||
import { EventEmitter } from "events"
|
||||
import type { ClineSessionEvents } from "./types.js"
|
||||
|
||||
/**
|
||||
* Type-safe EventEmitter for ClineAgent session events.
|
||||
*
|
||||
* Each session has its own emitter instance, allowing consumers to
|
||||
* subscribe to events for specific sessions without filtering.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const agent = new ClineAgent({ version: "1.0.0" })
|
||||
* const session = await agent.newSession({ cwd: "/path/to/project" })
|
||||
*
|
||||
* // Subscribe to session events
|
||||
* agent.session(session.sessionId).on("agent_message_chunk", (content) => {
|
||||
* console.log("Agent says:", content.text)
|
||||
* })
|
||||
*
|
||||
* agent.session(session.sessionId).on("tool_call", (toolCall) => {
|
||||
* console.log("Tool called:", toolCall.toolName)
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export class ClineSessionEmitter {
|
||||
private readonly emitter: EventEmitter
|
||||
|
||||
constructor() {
|
||||
this.emitter = new EventEmitter()
|
||||
// Increase max listeners since we may have many event types
|
||||
this.emitter.setMaxListeners(20)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a session event.
|
||||
*
|
||||
* @param event - The event name to subscribe to
|
||||
* @param listener - The callback function to invoke when the event is emitted
|
||||
* @returns This emitter instance for chaining
|
||||
*/
|
||||
on<K extends keyof ClineSessionEvents>(event: K, listener: ClineSessionEvents[K]): this {
|
||||
this.emitter.on(event, listener as (...args: unknown[]) => void)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a session event for a single invocation.
|
||||
*
|
||||
* @param event - The event name to subscribe to
|
||||
* @param listener - The callback function to invoke when the event is emitted
|
||||
* @returns This emitter instance for chaining
|
||||
*/
|
||||
once<K extends keyof ClineSessionEvents>(event: K, listener: ClineSessionEvents[K]): this {
|
||||
this.emitter.once(event, listener as (...args: unknown[]) => void)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from a session event.
|
||||
*
|
||||
* @param event - The event name to unsubscribe from
|
||||
* @param listener - The callback function to remove
|
||||
* @returns This emitter instance for chaining
|
||||
*/
|
||||
off<K extends keyof ClineSessionEvents>(event: K, listener: ClineSessionEvents[K]): this {
|
||||
this.emitter.off(event, listener as (...args: unknown[]) => void)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a session event.
|
||||
*
|
||||
* @param event - The event name to emit
|
||||
* @param args - The arguments to pass to the event listeners
|
||||
* @returns True if the event had listeners, false otherwise
|
||||
*/
|
||||
emit<K extends keyof ClineSessionEvents>(event: K, ...args: Parameters<ClineSessionEvents[K]>): boolean {
|
||||
return this.emitter.emit(event, ...args)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all listeners for a specific event or all events.
|
||||
*
|
||||
* @param event - Optional event name to remove listeners for
|
||||
* @returns This emitter instance for chaining
|
||||
*/
|
||||
removeAllListeners<K extends keyof ClineSessionEvents>(event?: K): this {
|
||||
if (event) {
|
||||
this.emitter.removeAllListeners(event)
|
||||
} else {
|
||||
this.emitter.removeAllListeners()
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of listeners for a specific event.
|
||||
*
|
||||
* @param event - The event name to count listeners for
|
||||
* @returns The number of listeners
|
||||
*/
|
||||
listenerCount<K extends keyof ClineSessionEvents>(event: K): number {
|
||||
return this.emitter.listenerCount(event)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* Permission handling for ACP integration.
|
||||
*
|
||||
* This module handles the translation between ACP permission requests/responses
|
||||
* and Cline's internal permission system. It maps ClineAsk types to appropriate
|
||||
* ACP permission options and translates user responses back to Cline's format.
|
||||
*
|
||||
* @module acp/permissionHandler
|
||||
*/
|
||||
|
||||
import type * as acp from "@agentclientprotocol/sdk"
|
||||
import type { ClineAsk } from "@shared/ExtensionMessage"
|
||||
import type { ClineAskResponse } from "@shared/WebviewMessage"
|
||||
import { Logger } from "@/shared/services/Logger.js"
|
||||
import type { AcpSessionState, ClinePermissionOption } from "./types.js"
|
||||
|
||||
/**
|
||||
* Standard permission options for operations that support "always allow".
|
||||
* Used for commands, tools, and MCP server operations.
|
||||
*/
|
||||
const STANDARD_PERMISSION_OPTIONS: ClinePermissionOption[] = [
|
||||
{ kind: "allow_once", optionId: "allow_once", name: "Allow Once" },
|
||||
{ kind: "allow_always", optionId: "allow_always", name: "Always Allow" },
|
||||
{ kind: "reject_once", optionId: "reject_once", name: "Reject" },
|
||||
]
|
||||
|
||||
/**
|
||||
* Permission options for operations that don't support "always allow".
|
||||
* Used for browser actions and other one-time operations.
|
||||
*/
|
||||
const RESTRICTED_PERMISSION_OPTIONS: ClinePermissionOption[] = [
|
||||
{ kind: "allow_once", optionId: "allow_once", name: "Allow Once" },
|
||||
{ kind: "reject_once", optionId: "reject_once", name: "Reject" },
|
||||
]
|
||||
|
||||
/**
|
||||
* Mapping of ClineAsk types to their permission option sets.
|
||||
*/
|
||||
const ASK_TYPE_PERMISSION_MAP: Partial<Record<ClineAsk, ClinePermissionOption[]>> = {
|
||||
// Commands support "always allow" for auto-approval
|
||||
command: STANDARD_PERMISSION_OPTIONS,
|
||||
|
||||
// Tool operations support "always allow"
|
||||
tool: STANDARD_PERMISSION_OPTIONS,
|
||||
|
||||
// MCP server operations support "always allow"
|
||||
use_mcp_server: STANDARD_PERMISSION_OPTIONS,
|
||||
|
||||
// Browser actions are one-time, no "always allow"
|
||||
browser_action_launch: RESTRICTED_PERMISSION_OPTIONS,
|
||||
|
||||
// Command output continuation - simple allow/reject
|
||||
command_output: RESTRICTED_PERMISSION_OPTIONS,
|
||||
}
|
||||
|
||||
/**
|
||||
* ClineAsk types that require permission handling.
|
||||
* Other ask types (like followup, plan_mode_respond) don't need permission UI.
|
||||
*/
|
||||
const PERMISSION_REQUIRING_ASK_TYPES: Set<ClineAsk> = new Set([
|
||||
"command",
|
||||
"tool",
|
||||
"browser_action_launch",
|
||||
"use_mcp_server",
|
||||
"command_output",
|
||||
])
|
||||
|
||||
/**
|
||||
* Result of handling a permission response.
|
||||
*/
|
||||
export interface PermissionHandlerResult {
|
||||
/** Cline's internal response type */
|
||||
response: ClineAskResponse
|
||||
/** Optional text to pass with the response */
|
||||
text?: string
|
||||
/** Whether "always allow" was selected (for auto-approval tracking) */
|
||||
alwaysAllow?: boolean
|
||||
/** Whether the request was cancelled */
|
||||
cancelled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a ClineAsk type requires permission handling.
|
||||
*
|
||||
* @param askType - The ClineAsk type to check
|
||||
* @returns True if the ask type requires permission UI
|
||||
*/
|
||||
export function requiresPermission(askType: ClineAsk): boolean {
|
||||
return PERMISSION_REQUIRING_ASK_TYPES.has(askType)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate permission options for a ClineAsk type.
|
||||
*
|
||||
* @param askType - The ClineAsk type
|
||||
* @returns Array of permission options, or undefined if the ask type doesn't require permission
|
||||
*/
|
||||
export function getPermissionOptionsForAskType(askType: ClineAsk): acp.PermissionOption[] | undefined {
|
||||
const options = ASK_TYPE_PERMISSION_MAP[askType]
|
||||
if (!options) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Convert to ACP PermissionOption format
|
||||
return options.map((opt) => ({
|
||||
kind: opt.kind,
|
||||
optionId: opt.optionId,
|
||||
name: opt.name,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an ACP permission response and translate it to Cline's format.
|
||||
*
|
||||
* @param response - The ACP permission response from the client
|
||||
* @param askType - The original ClineAsk type that triggered the permission request
|
||||
* @returns The translated result for Cline's handleWebviewAskResponse
|
||||
*/
|
||||
export function handlePermissionResponse(response: acp.RequestPermissionResponse, askType: ClineAsk): PermissionHandlerResult {
|
||||
// Check if cancelled
|
||||
if (response.outcome.outcome === "cancelled") {
|
||||
return {
|
||||
response: "noButtonClicked",
|
||||
cancelled: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Get the selected option ID
|
||||
const optionId = response.outcome.optionId
|
||||
|
||||
// Translate the option to Cline's response format
|
||||
switch (optionId) {
|
||||
case "allow_once":
|
||||
return {
|
||||
response: "yesButtonClicked",
|
||||
alwaysAllow: false,
|
||||
}
|
||||
|
||||
case "allow_always":
|
||||
return {
|
||||
response: "yesButtonClicked",
|
||||
alwaysAllow: true,
|
||||
}
|
||||
|
||||
case "reject_once":
|
||||
case "reject_always":
|
||||
return {
|
||||
response: "noButtonClicked",
|
||||
alwaysAllow: false,
|
||||
}
|
||||
|
||||
default:
|
||||
// Unknown option ID - treat as rejection for safety
|
||||
Logger.error(`[permissionHandler] Unknown permission option: ${optionId}`)
|
||||
return {
|
||||
response: "noButtonClicked",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a permission request for an ACP tool call.
|
||||
*
|
||||
* @param toolCall - The ACP tool call that needs permission
|
||||
* @param askType - The Cline ask type
|
||||
* @returns The permission request options, or null if no permission needed
|
||||
*/
|
||||
export function createPermissionRequest(
|
||||
toolCall: acp.ToolCall,
|
||||
askType: ClineAsk,
|
||||
): { toolCall: acp.ToolCall; options: acp.PermissionOption[] } | null {
|
||||
const options = getPermissionOptionsForAskType(askType)
|
||||
if (!options) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
toolCall,
|
||||
options,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track "always allow" decisions for auto-approval.
|
||||
* This maintains a set of tool/command patterns that have been auto-approved.
|
||||
*/
|
||||
export class AutoApprovalTracker {
|
||||
/** Set of auto-approved command prefixes */
|
||||
private autoApprovedCommands: Set<string> = new Set()
|
||||
|
||||
/** Set of auto-approved tool names */
|
||||
private autoApprovedTools: Set<string> = new Set()
|
||||
|
||||
/** Set of auto-approved MCP servers */
|
||||
private autoApprovedMcpServers: Set<string> = new Set()
|
||||
|
||||
/**
|
||||
* Record an "always allow" decision for a permission request.
|
||||
*
|
||||
* @param askType - The Cline ask type that was auto-approved
|
||||
* @param identifier - The identifier for the operation (command, tool name, etc.)
|
||||
*/
|
||||
recordAlwaysAllow(askType: ClineAsk, identifier: string): void {
|
||||
switch (askType) {
|
||||
case "command":
|
||||
// Store the first word of the command as the key
|
||||
const commandPrefix = identifier.split(" ")[0]
|
||||
this.autoApprovedCommands.add(commandPrefix)
|
||||
break
|
||||
|
||||
case "tool":
|
||||
this.autoApprovedTools.add(identifier)
|
||||
break
|
||||
|
||||
case "use_mcp_server":
|
||||
this.autoApprovedMcpServers.add(identifier)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an operation has been auto-approved.
|
||||
*
|
||||
* @param askType - The Cline ask type
|
||||
* @param identifier - The identifier for the operation
|
||||
* @returns True if the operation was previously auto-approved
|
||||
*/
|
||||
isAutoApproved(askType: ClineAsk, identifier: string): boolean {
|
||||
switch (askType) {
|
||||
case "command":
|
||||
const commandPrefix = identifier.split(" ")[0]
|
||||
return this.autoApprovedCommands.has(commandPrefix)
|
||||
|
||||
case "tool":
|
||||
return this.autoApprovedTools.has(identifier)
|
||||
|
||||
case "use_mcp_server":
|
||||
return this.autoApprovedMcpServers.has(identifier)
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all auto-approval records.
|
||||
*/
|
||||
clear(): void {
|
||||
this.autoApprovedCommands.clear()
|
||||
this.autoApprovedTools.clear()
|
||||
this.autoApprovedMcpServers.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a pending permission request for a session.
|
||||
*
|
||||
* This function coordinates the permission flow:
|
||||
* 1. Checks if the operation is already auto-approved
|
||||
* 2. If not, requests permission from the ACP client
|
||||
* 3. Tracks "always allow" decisions
|
||||
* 4. Returns the translated result for Cline
|
||||
*
|
||||
* @param requestPermission - Function to request permission from the ACP client
|
||||
* @param sessionId - The session ID
|
||||
* @param toolCall - The tool call requiring permission
|
||||
* @param askType - The Cline ask type
|
||||
* @param identifier - Identifier for auto-approval tracking
|
||||
* @param autoApprovalTracker - The auto-approval tracker
|
||||
* @returns The permission handler result
|
||||
*/
|
||||
export async function processPermissionRequest(
|
||||
requestPermission: (
|
||||
sessionId: string,
|
||||
toolCall: acp.ToolCall,
|
||||
options: acp.PermissionOption[],
|
||||
) => Promise<acp.RequestPermissionResponse>,
|
||||
sessionId: string,
|
||||
toolCall: acp.ToolCall,
|
||||
askType: ClineAsk,
|
||||
identifier: string,
|
||||
autoApprovalTracker?: AutoApprovalTracker,
|
||||
): Promise<PermissionHandlerResult> {
|
||||
// Check if already auto-approved
|
||||
if (autoApprovalTracker?.isAutoApproved(askType, identifier)) {
|
||||
return {
|
||||
response: "yesButtonClicked",
|
||||
alwaysAllow: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Get permission options for this ask type
|
||||
const options = getPermissionOptionsForAskType(askType)
|
||||
if (!options) {
|
||||
// No permission options defined - allow by default
|
||||
return {
|
||||
response: "yesButtonClicked",
|
||||
}
|
||||
}
|
||||
|
||||
// Request permission from the ACP client
|
||||
const response = await requestPermission(sessionId, toolCall, options)
|
||||
|
||||
// Handle the response
|
||||
const result = handlePermissionResponse(response, askType)
|
||||
|
||||
// Track "always allow" decisions
|
||||
if (result.alwaysAllow && autoApprovalTracker) {
|
||||
autoApprovalTracker.recordAlwaysAllow(askType, identifier)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the identifier for auto-approval tracking from a tool call.
|
||||
*
|
||||
* @param toolCall - The ACP tool call
|
||||
* @param askType - The Cline ask type
|
||||
* @returns The identifier string for auto-approval tracking
|
||||
*/
|
||||
export function getAutoApprovalIdentifier(toolCall: acp.ToolCall, askType: ClineAsk): string {
|
||||
const rawInput = toolCall.rawInput as Record<string, unknown> | undefined
|
||||
|
||||
switch (askType) {
|
||||
case "command":
|
||||
return (rawInput?.command as string) || toolCall.title
|
||||
|
||||
case "tool":
|
||||
// Try to get tool name from raw input or title
|
||||
return (rawInput?.tool as string) || toolCall.title
|
||||
|
||||
case "use_mcp_server":
|
||||
return (rawInput?.serverName as string) || toolCall.title
|
||||
|
||||
default:
|
||||
return toolCall.toolCallId
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the session state's pending tool call after permission is handled.
|
||||
*
|
||||
* @param sessionState - The session state to update
|
||||
* @param toolCallId - The tool call ID that was handled
|
||||
* @param approved - Whether the permission was approved
|
||||
*/
|
||||
export function updateSessionStateAfterPermission(sessionState: AcpSessionState, toolCallId: string, approved: boolean): void {
|
||||
// Remove from pending tool calls
|
||||
sessionState.pendingToolCalls.delete(toolCallId)
|
||||
|
||||
// Clear current tool call ID if it matches
|
||||
if (sessionState.currentToolCallId === toolCallId && !approved) {
|
||||
sessionState.currentToolCallId = undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Custom types and extensions for ACP integration with Cline CLI.
|
||||
*
|
||||
* This file extends the base ACP types with Cline-specific functionality.
|
||||
*/
|
||||
|
||||
import type * as acp from "@agentclientprotocol/sdk"
|
||||
import type { Controller } from "@/core/controller"
|
||||
|
||||
// ============================================================
|
||||
// Session Update Type Utilities
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Extract the sessionUpdate discriminator value from a SessionUpdate variant.
|
||||
*/
|
||||
export type SessionUpdateType = acp.SessionUpdate["sessionUpdate"]
|
||||
|
||||
/**
|
||||
* Extract the payload type for a given sessionUpdate discriminator value.
|
||||
* This removes the `sessionUpdate` discriminator field from the type.
|
||||
*/
|
||||
export type SessionUpdatePayload<T extends SessionUpdateType> = Omit<
|
||||
Extract<acp.SessionUpdate, { sessionUpdate: T }>,
|
||||
"sessionUpdate"
|
||||
>
|
||||
|
||||
// ============================================================
|
||||
// Permission Handler Callback Types
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Callback to resolve a permission request with the user's response.
|
||||
*/
|
||||
export type PermissionResolver = (response: acp.RequestPermissionResponse) => void
|
||||
|
||||
/**
|
||||
* Handler function for permission requests.
|
||||
* Called when the agent needs permission for a tool call.
|
||||
* The handler should present the request to the user and call resolve() with their response.
|
||||
*/
|
||||
export type PermissionHandler = (request: Omit<acp.RequestPermissionRequest, "sessionId">, resolve: PermissionResolver) => void
|
||||
|
||||
// ============================================================
|
||||
// Session Event Emitter Types
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Maps ACP SessionUpdate types to their event listener signatures.
|
||||
* Uses the sessionUpdate discriminator to derive event names and payload types.
|
||||
*/
|
||||
export type ClineSessionEvents = {
|
||||
[K in SessionUpdateType]: (payload: SessionUpdatePayload<K>) => void
|
||||
} & {
|
||||
/** Error event for session-level errors (not part of ACP SessionUpdate) */
|
||||
error: (error: Error) => void
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ClineAgent Options (decoupled from connection)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Options for creating a ClineAgent instance (decoupled from connection).
|
||||
*/
|
||||
export interface ClineAgentOptions {
|
||||
/** CLI version string */
|
||||
version: string
|
||||
/** Whether debug logging is enabled */
|
||||
debug?: boolean
|
||||
}
|
||||
|
||||
// Re-export common ACP types for convenience
|
||||
export type {
|
||||
Agent,
|
||||
AgentSideConnection,
|
||||
AudioContent,
|
||||
CancelNotification,
|
||||
ContentBlock,
|
||||
ImageContent,
|
||||
InitializeRequest,
|
||||
InitializeResponse,
|
||||
LoadSessionRequest,
|
||||
LoadSessionResponse,
|
||||
McpServer,
|
||||
ModelInfo,
|
||||
NewSessionRequest,
|
||||
NewSessionResponse,
|
||||
PermissionOption,
|
||||
PermissionOptionKind,
|
||||
PromptRequest,
|
||||
PromptResponse,
|
||||
ReadTextFileRequest,
|
||||
ReadTextFileResponse,
|
||||
RequestPermissionRequest,
|
||||
RequestPermissionResponse,
|
||||
SessionConfigOption,
|
||||
SessionModelState,
|
||||
SessionNotification,
|
||||
SessionUpdate,
|
||||
SetSessionConfigOptionRequest,
|
||||
SetSessionConfigOptionResponse,
|
||||
SetSessionModelRequest,
|
||||
SetSessionModelResponse,
|
||||
SetSessionModeRequest,
|
||||
SetSessionModeResponse,
|
||||
StopReason,
|
||||
TextContent,
|
||||
ToolCall,
|
||||
ToolCallStatus,
|
||||
ToolCallUpdate,
|
||||
ToolKind,
|
||||
WriteTextFileRequest,
|
||||
WriteTextFileResponse,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
|
||||
/**
|
||||
* Cline-specific agent capabilities extending the ACP base capabilities.
|
||||
*/
|
||||
export interface ClineAgentCapabilities {
|
||||
/** Support for loading sessions from disk */
|
||||
loadSession: boolean
|
||||
/** Prompt capabilities for the agent */
|
||||
promptCapabilities: {
|
||||
/** Support for image inputs */
|
||||
image: boolean
|
||||
/** Support for audio inputs */
|
||||
audio: boolean
|
||||
/** Support for embedded context (file resources) */
|
||||
embeddedContext: boolean
|
||||
}
|
||||
/** MCP server passthrough capabilities */
|
||||
mcpCapabilities: {
|
||||
/** Support for HTTP MCP servers */
|
||||
http: boolean
|
||||
/** Support for SSE MCP servers */
|
||||
sse: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cline agent info for ACP initialization response.
|
||||
*/
|
||||
export interface ClineAgentInfo {
|
||||
name: "cline"
|
||||
title: "Cline"
|
||||
version: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended session data stored by Cline for ACP sessions.
|
||||
* Maps to Cline's task history structure.
|
||||
*/
|
||||
export interface ClineAcpSession {
|
||||
/** Unique session/task ID */
|
||||
sessionId: string
|
||||
/** Working directory for the session */
|
||||
cwd: string
|
||||
/** Current mode (plan/act) */
|
||||
mode: "plan" | "act"
|
||||
/** MCP servers passed from the client */
|
||||
mcpServers: acp.McpServer[]
|
||||
/** Timestamp when session was created */
|
||||
createdAt: number
|
||||
/** Timestamp of last activity */
|
||||
lastActivityAt: number
|
||||
/** Whether this session was loaded from history (needs resume on first prompt) */
|
||||
isLoadedFromHistory?: boolean
|
||||
/** Controller instance for this session (manages task execution) */
|
||||
controller?: Controller
|
||||
/** Model ID override for plan mode (format: "provider/modelId") */
|
||||
planModeModelId?: string
|
||||
/** Model ID override for act mode (format: "provider/modelId") */
|
||||
actModeModelId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission option as presented to the ACP client.
|
||||
*/
|
||||
export interface ClinePermissionOption {
|
||||
kind: acp.PermissionOptionKind
|
||||
name: string
|
||||
optionId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping of Cline message types to their ACP session update equivalents.
|
||||
*/
|
||||
export type ClineToAcpUpdateMapping = {
|
||||
/** Text messages from the agent */
|
||||
text: "agent_message_chunk"
|
||||
/** Reasoning/thinking from the agent */
|
||||
reasoning: "agent_thought_chunk"
|
||||
/** Markdown content from the agent */
|
||||
markdown: "agent_message_chunk"
|
||||
/** Tool execution */
|
||||
tool: "tool_call"
|
||||
/** Command execution */
|
||||
command: "tool_call"
|
||||
/** Command output */
|
||||
command_output: "tool_call_update"
|
||||
/** Task completion */
|
||||
completion_result: "end_turn"
|
||||
/** Error messages */
|
||||
error: "tool_call_update" | "error"
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for creating an ACP agent instance.
|
||||
*/
|
||||
export interface AcpAgentOptions {
|
||||
/** CLI version string */
|
||||
version: string
|
||||
/** Whether debug logging is enabled */
|
||||
debug?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of translating a Cline message to ACP session update(s).
|
||||
* A single Cline message may produce multiple ACP updates.
|
||||
*/
|
||||
export interface TranslatedMessage {
|
||||
/** The session updates to send */
|
||||
updates: acp.SessionUpdate[]
|
||||
/** Whether this message requires a permission request */
|
||||
requiresPermission?: boolean
|
||||
/** Permission request details if required */
|
||||
permissionRequest?: Omit<acp.RequestPermissionRequest, "sessionId">
|
||||
/** The toolCallId that was created/used (for tracking across streaming updates) */
|
||||
toolCallId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* State tracking for an active ACP session within Cline.
|
||||
*/
|
||||
export interface AcpSessionState {
|
||||
/** Session ID */
|
||||
sessionId: string
|
||||
/** Whether the session is currently processing a prompt */
|
||||
isProcessing: boolean
|
||||
/** Current tool call ID being executed (if any) */
|
||||
currentToolCallId?: string
|
||||
/** Whether the session has been cancelled */
|
||||
cancelled: boolean
|
||||
/** Accumulated tool calls for permission batching */
|
||||
pendingToolCalls: Map<string, acp.ToolCall>
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { Box, Text } from "ink"
|
||||
import React from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useTerminalSize } from "../hooks/useTerminalSize"
|
||||
import { isFileSaveTool, parseToolFromMessage } from "../utils/tools"
|
||||
|
||||
/**
|
||||
@@ -276,6 +277,17 @@ interface ActionButtonsProps {
|
||||
mode?: "act" | "plan"
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine which buttons are actually visible based on config
|
||||
* Cancel is hidden in the CLI (ThinkingIndicator handles that with esc)
|
||||
*/
|
||||
export function getVisibleButtons(config: ButtonConfig) {
|
||||
const hiddenActions = ["cancel"]
|
||||
const hasPrimary = !!config.primaryText && !hiddenActions.includes(config.primaryAction || "")
|
||||
const hasSecondary = !!config.secondaryText && !hiddenActions.includes(config.secondaryAction || "")
|
||||
return { hasPrimary, hasSecondary }
|
||||
}
|
||||
|
||||
/**
|
||||
* Action buttons component
|
||||
* Shows primary and/or secondary buttons based on config
|
||||
@@ -287,18 +299,14 @@ export const ActionButtons: React.FC<ActionButtonsProps> = ({ config, mode = "ac
|
||||
return null
|
||||
}
|
||||
|
||||
// Don't show cancel buttons (ThinkingIndicator handles esc to interrupt)
|
||||
// Don't show new_task buttons (CLI doesn't handle starting new tasks)
|
||||
const hiddenActions = ["cancel", "new_task"]
|
||||
const hasPrimary = !!config.primaryText && !hiddenActions.includes(config.primaryAction || "")
|
||||
const hasSecondary = !!config.secondaryText && !hiddenActions.includes(config.secondaryAction || "")
|
||||
const { hasPrimary, hasSecondary } = getVisibleButtons(config)
|
||||
|
||||
if (!hasPrimary && !hasSecondary) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Calculate button widths based on terminal width
|
||||
const terminalWidth = process.stdout.columns || 80
|
||||
const { columns: terminalWidth } = useTerminalSize()
|
||||
const buttonCount = (hasPrimary ? 1 : 0) + (hasSecondary ? 1 : 0)
|
||||
const gapWidth = buttonCount > 1 ? 1 : 0 // 1 char gap between buttons
|
||||
const availableWidth = terminalWidth - 2 - gapWidth // 1 space padding on each side
|
||||
@@ -323,7 +331,7 @@ export const ActionButtons: React.FC<ActionButtonsProps> = ({ config, mode = "ac
|
||||
return (
|
||||
<Box flexDirection="row" gap={1} marginLeft={1} width="100%">
|
||||
{hasPrimary && renderButton(config.primaryText!, "1")}
|
||||
{hasSecondary && renderButton(config.secondaryText!, "2")}
|
||||
{hasSecondary && renderButton(config.secondaryText!, hasPrimary ? "2" : "1")}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -35,6 +35,11 @@ vi.mock("../context/StdinContext", () => ({
|
||||
StdinProvider: ({ children }: any) => children,
|
||||
}))
|
||||
|
||||
// Mock useTerminalSize to prevent EventEmitter memory leak warnings from resize listeners
|
||||
vi.mock("../hooks/useTerminalSize", () => ({
|
||||
useTerminalSize: () => ({ columns: 80, rows: 24, resizeKey: 0 }),
|
||||
}))
|
||||
|
||||
describe("App", () => {
|
||||
const mockController = {
|
||||
dispose: vi.fn(),
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Box } from "ink"
|
||||
import React, { ReactNode, useCallback, useState } from "react"
|
||||
import { StdinProvider } from "../context/StdinContext"
|
||||
import { TaskContextProvider } from "../context/TaskContext"
|
||||
import { useTerminalSize } from "../hooks/useTerminalSize"
|
||||
import { AuthView } from "./AuthView"
|
||||
import { ChatView } from "./ChatView"
|
||||
import { ConfigView } from "./ConfigView"
|
||||
@@ -142,6 +143,7 @@ export const App: React.FC<AppProps> = ({
|
||||
isRawModeSupported = true,
|
||||
robotTopRow,
|
||||
}) => {
|
||||
const { resizeKey } = useTerminalSize()
|
||||
const [currentView, setCurrentView] = useState<ViewType>(initialView)
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | undefined>(taskId)
|
||||
|
||||
@@ -271,7 +273,7 @@ export const App: React.FC<AppProps> = ({
|
||||
|
||||
return (
|
||||
<StdinProvider isRawModeSupported={isRawModeSupported}>
|
||||
<Box>{content}</Box>
|
||||
<Box key={resizeKey}>{content}</Box>
|
||||
</StdinProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -333387,6 +333387,17 @@ export const AsciiMotionCli: React.FC<AsciiMotionCliProps> = ({
|
||||
const getColor = useCallback((key: string): string => theme[key] || key, [theme]);
|
||||
const defaultFg = hasDarkBackground ? "white" : "black";
|
||||
|
||||
// Stop animation on terminal resize to prevent visual glitches
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
onScroll?.();
|
||||
};
|
||||
process.stdout.on("resize", handleResize);
|
||||
return () => {
|
||||
process.stdout.off("resize", handleResize);
|
||||
};
|
||||
}, [onScroll]);
|
||||
|
||||
// Mouse tracking - gracefully handle environments without tty support
|
||||
useEffect(() => {
|
||||
if (!stdin || !stdout) return;
|
||||
|
||||
@@ -20,6 +20,7 @@ import { type DetectedSources, detectImportSources, type ImportSource } from "..
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { ApiKeyInput } from "./ApiKeyInput"
|
||||
import { StaticRobotFrame } from "./AsciiMotionCli"
|
||||
import { type BedrockConfig, BedrockSetup } from "./BedrockSetup"
|
||||
import { ImportView } from "./ImportView"
|
||||
import { getDefaultModelId, hasModelPicker, ModelPicker } from "./ModelPicker"
|
||||
import { getProviderLabel, getProviderOrder } from "./ProviderPicker"
|
||||
@@ -36,6 +37,7 @@ type AuthStep =
|
||||
| "cline_auth"
|
||||
| "cline_model"
|
||||
| "openai_codex_auth"
|
||||
| "bedrock"
|
||||
| "import"
|
||||
|
||||
// Featured models loaded from shared constants
|
||||
@@ -162,6 +164,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
const [clineModelIndex, setClineModelIndex] = useState(0)
|
||||
const [importSources, setImportSources] = useState<DetectedSources>({ codex: false, opencode: false })
|
||||
const [importSource, setImportSource] = useState<ImportSource | null>(null)
|
||||
const [bedrockConfig, setBedrockConfig] = useState<BedrockConfig | null>(null)
|
||||
|
||||
// Use providers.json order, filtered to only available providers
|
||||
const sortedProviders = useMemo(() => {
|
||||
@@ -169,35 +172,6 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
return getProviderOrder().filter((p) => availableProviders.has(p))
|
||||
}, [])
|
||||
|
||||
// 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 - conditionally include import options
|
||||
const mainMenuItems: SelectItem[] = useMemo(() => {
|
||||
const items: SelectItem[] = [{ label: "Sign in with Cline account", value: "cline_auth" }]
|
||||
@@ -228,10 +202,10 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
)
|
||||
: sortedProviders
|
||||
return filtered.map((p: string) => ({
|
||||
label: `${getProviderLabel(p)}${configuredProviders.has(p) ? " (configured)" : ""}`,
|
||||
label: getProviderLabel(p),
|
||||
value: p,
|
||||
}))
|
||||
}, [sortedProviders, configuredProviders, providerSearch])
|
||||
}, [sortedProviders, providerSearch])
|
||||
|
||||
// Use shared scrollable list hook for provider windowing
|
||||
const TOTAL_PROVIDER_ROWS = 8
|
||||
@@ -466,6 +440,8 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
} else if (value === "openai-codex") {
|
||||
setStep("openai_codex_auth")
|
||||
startOpenAiCodexAuth()
|
||||
} else if (value === "bedrock") {
|
||||
setStep("bedrock")
|
||||
} else {
|
||||
setStep("apikey")
|
||||
}
|
||||
@@ -499,8 +475,19 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
apiProvider: selectedProvider,
|
||||
}
|
||||
|
||||
// Add API key using provider-specific field
|
||||
if (apiKey) {
|
||||
// Add API key or Bedrock-specific config
|
||||
if (selectedProvider === "bedrock" && bedrockConfig) {
|
||||
const bedrockFields: Record<string, unknown> = {
|
||||
awsAuthentication: bedrockConfig.awsAuthentication,
|
||||
awsRegion: bedrockConfig.awsRegion,
|
||||
awsUseCrossRegionInference: bedrockConfig.awsUseCrossRegionInference,
|
||||
}
|
||||
if (bedrockConfig.awsProfile !== undefined) bedrockFields.awsProfile = bedrockConfig.awsProfile
|
||||
if (bedrockConfig.awsAccessKey) bedrockFields.awsAccessKey = bedrockConfig.awsAccessKey
|
||||
if (bedrockConfig.awsSecretKey) bedrockFields.awsSecretKey = bedrockConfig.awsSecretKey
|
||||
if (bedrockConfig.awsSessionToken) bedrockFields.awsSessionToken = bedrockConfig.awsSessionToken
|
||||
Object.assign(config, bedrockFields)
|
||||
} else if (apiKey) {
|
||||
const keyField = ProviderToApiKeyMap[selectedProvider as keyof typeof ProviderToApiKeyMap]
|
||||
if (keyField) {
|
||||
const fields = Array.isArray(keyField) ? keyField : [keyField]
|
||||
@@ -520,7 +507,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
setStep("error")
|
||||
}
|
||||
},
|
||||
[selectedProvider, apiKey],
|
||||
[selectedProvider, apiKey, bedrockConfig],
|
||||
)
|
||||
|
||||
const handleModelIdSubmit = useCallback(
|
||||
@@ -557,6 +544,11 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
[saveConfiguration],
|
||||
)
|
||||
|
||||
const handleBedrockComplete = useCallback((config: BedrockConfig) => {
|
||||
setBedrockConfig(config)
|
||||
setStep("modelid")
|
||||
}, [])
|
||||
|
||||
const handleImportComplete = useCallback(() => {
|
||||
setStep("success")
|
||||
}, [])
|
||||
@@ -639,6 +631,10 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
setClineModelIndex(0)
|
||||
setStep("menu")
|
||||
break
|
||||
case "bedrock":
|
||||
setBedrockConfig(null)
|
||||
setStep("provider")
|
||||
break
|
||||
case "import":
|
||||
setImportSource(null)
|
||||
setStep("menu")
|
||||
@@ -710,6 +706,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
<Text color="white">Select a model</Text>
|
||||
<Text> </Text>
|
||||
<ModelPicker
|
||||
controller={controller}
|
||||
isActive={step === "modelid"}
|
||||
onChange={setModelId}
|
||||
onSubmit={handleModelIdSubmit}
|
||||
@@ -843,6 +840,18 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
)
|
||||
}
|
||||
|
||||
case "bedrock":
|
||||
return (
|
||||
<BedrockSetup
|
||||
isActive={step === "bedrock"}
|
||||
onCancel={() => {
|
||||
setBedrockConfig(null)
|
||||
setStep("provider")
|
||||
}}
|
||||
onComplete={handleBedrockComplete}
|
||||
/>
|
||||
)
|
||||
|
||||
case "import":
|
||||
if (!importSource) {
|
||||
return null
|
||||
@@ -872,7 +881,16 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
const [menuIndex, setMenuIndex] = useState(0)
|
||||
|
||||
// Steps that allow going back with escape (apikey handled by ApiKeyInput component)
|
||||
const canGoBack = ["provider", "modelid", "baseurl", "cline_auth", "cline_model", "openai_codex_auth", "error"].includes(step)
|
||||
const canGoBack = [
|
||||
"provider",
|
||||
"modelid",
|
||||
"baseurl",
|
||||
"cline_auth",
|
||||
"cline_model",
|
||||
"openai_codex_auth",
|
||||
"bedrock",
|
||||
"error",
|
||||
].includes(step)
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
import BedrockData from "@shared/providers/bedrock.json"
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useCallback, useMemo, useState } from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { useScrollableList } from "../hooks/useScrollableList"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
|
||||
type AuthMethod = "profile" | "credentials" | "default"
|
||||
|
||||
type BedrockStep = "auth_method" | "profile_name" | "access_key" | "secret_key" | "session_token" | "region" | "options"
|
||||
|
||||
export interface BedrockConfig {
|
||||
awsAuthentication: string
|
||||
awsProfile?: string
|
||||
awsAccessKey?: string
|
||||
awsSecretKey?: string
|
||||
awsSessionToken?: string
|
||||
awsRegion: string
|
||||
awsUseCrossRegionInference: boolean
|
||||
}
|
||||
|
||||
interface BedrockSetupProps {
|
||||
isActive: boolean
|
||||
onComplete: (config: BedrockConfig) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const AUTH_METHODS: { label: string; value: AuthMethod; description: string }[] = [
|
||||
{ label: "AWS Profile", value: "profile", description: "Use a named profile from ~/.aws/credentials" },
|
||||
{ label: "AWS Credentials", value: "credentials", description: "Enter access key, secret key, and optional session token" },
|
||||
{
|
||||
label: "Default credential chain",
|
||||
value: "default",
|
||||
description: "Resolve from env vars, IAM role, or ~/.aws/credentials",
|
||||
},
|
||||
]
|
||||
|
||||
const AWS_REGIONS = BedrockData.regions
|
||||
const REGION_ROWS = 8
|
||||
|
||||
/**
|
||||
* Inline text input for credential fields
|
||||
*/
|
||||
const CredentialInput: React.FC<{
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onSubmit: () => void
|
||||
onCancel: () => void
|
||||
isActive: boolean
|
||||
isPassword?: boolean
|
||||
placeholder?: string
|
||||
hint?: string
|
||||
}> = ({ label, value, onChange, onSubmit, onCancel, isActive, isPassword, placeholder, hint }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (isMouseEscapeSequence(input)) return
|
||||
if (key.escape) {
|
||||
onCancel()
|
||||
} else if (key.return) {
|
||||
onSubmit()
|
||||
} else if (key.backspace || key.delete) {
|
||||
onChange(value.slice(0, -1))
|
||||
} else if (input && !key.ctrl && !key.meta) {
|
||||
onChange(value + input)
|
||||
}
|
||||
},
|
||||
{ isActive: isActive && isRawModeSupported },
|
||||
)
|
||||
|
||||
const displayValue = isPassword && value ? "•".repeat(value.length) : value
|
||||
|
||||
// Combine hint and placeholder into description shown above input
|
||||
const description = hint || (placeholder ? `e.g. ${placeholder}` : undefined)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="white">{label}</Text>
|
||||
{description && <Text color="gray">{description}</Text>}
|
||||
<Text> </Text>
|
||||
<Box>
|
||||
<Text color="white">{displayValue}</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
<Text> </Text>
|
||||
<Text color="gray">Enter to continue, Esc to go back</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const BedrockSetup: React.FC<BedrockSetupProps> = ({ isActive, onComplete, onCancel }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
|
||||
const [step, setStep] = useState<BedrockStep>("auth_method")
|
||||
const [authMethodIndex, setAuthMethodIndex] = useState(0)
|
||||
const [authMethod, setAuthMethod] = useState<AuthMethod>("profile")
|
||||
|
||||
// Credential state
|
||||
const [profileName, setProfileName] = useState("")
|
||||
const [accessKey, setAccessKey] = useState("")
|
||||
const [secretKey, setSecretKey] = useState("")
|
||||
const [sessionToken, setSessionToken] = useState("")
|
||||
|
||||
// Region state
|
||||
const [regionSearch, setRegionSearch] = useState("")
|
||||
const [regionIndex, setRegionIndex] = useState(0)
|
||||
|
||||
// Options state
|
||||
const [crossRegion, setCrossRegion] = useState(false)
|
||||
const [optionIndex, setOptionIndex] = useState(0)
|
||||
|
||||
// Filtered regions
|
||||
const filteredRegions = useMemo(() => {
|
||||
const search = regionSearch.toLowerCase()
|
||||
return search ? AWS_REGIONS.filter((r) => r.includes(search)) : AWS_REGIONS
|
||||
}, [regionSearch])
|
||||
|
||||
const {
|
||||
visibleStart: regionVisibleStart,
|
||||
visibleCount: regionVisibleCount,
|
||||
showTopIndicator: showRegionTop,
|
||||
showBottomIndicator: showRegionBottom,
|
||||
} = useScrollableList(filteredRegions.length, regionIndex, REGION_ROWS)
|
||||
|
||||
const visibleRegions = useMemo(
|
||||
() => filteredRegions.slice(regionVisibleStart, regionVisibleStart + regionVisibleCount),
|
||||
[filteredRegions, regionVisibleStart, regionVisibleCount],
|
||||
)
|
||||
|
||||
const nextStepAfterAuth = useCallback((method: AuthMethod) => {
|
||||
setAuthMethod(method)
|
||||
if (method === "profile") {
|
||||
setStep("profile_name")
|
||||
} else if (method === "credentials") {
|
||||
setStep("access_key")
|
||||
} else {
|
||||
// default chain - skip credentials, go to region
|
||||
setStep("region")
|
||||
}
|
||||
}, [])
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
switch (step) {
|
||||
case "auth_method":
|
||||
onCancel()
|
||||
break
|
||||
case "profile_name":
|
||||
setStep("auth_method")
|
||||
break
|
||||
case "access_key":
|
||||
setStep("auth_method")
|
||||
break
|
||||
case "secret_key":
|
||||
setStep("access_key")
|
||||
break
|
||||
case "session_token":
|
||||
setStep("secret_key")
|
||||
break
|
||||
case "region":
|
||||
if (authMethod === "profile") setStep("profile_name")
|
||||
else if (authMethod === "credentials") setStep("session_token")
|
||||
else setStep("auth_method")
|
||||
break
|
||||
case "options":
|
||||
setStep("region")
|
||||
break
|
||||
}
|
||||
}, [step, authMethod, onCancel])
|
||||
|
||||
const finish = useCallback(() => {
|
||||
const config: BedrockConfig = {
|
||||
awsAuthentication: authMethod === "default" ? "credentials" : authMethod,
|
||||
awsRegion: filteredRegions[regionIndex] || "us-east-1",
|
||||
awsUseCrossRegionInference: crossRegion,
|
||||
}
|
||||
if (authMethod === "profile") {
|
||||
config.awsProfile = profileName || ""
|
||||
} else if (authMethod === "credentials") {
|
||||
config.awsAccessKey = accessKey
|
||||
config.awsSecretKey = secretKey
|
||||
if (sessionToken) config.awsSessionToken = sessionToken
|
||||
}
|
||||
onComplete(config)
|
||||
}, [authMethod, profileName, accessKey, secretKey, sessionToken, filteredRegions, regionIndex, crossRegion, onComplete])
|
||||
|
||||
// Handle input for auth_method, region, and options steps
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (isMouseEscapeSequence(input)) return
|
||||
|
||||
if (step === "auth_method") {
|
||||
if (key.escape) {
|
||||
onCancel()
|
||||
} else if (key.upArrow) {
|
||||
setAuthMethodIndex((prev) => (prev > 0 ? prev - 1 : AUTH_METHODS.length - 1))
|
||||
} else if (key.downArrow) {
|
||||
setAuthMethodIndex((prev) => (prev < AUTH_METHODS.length - 1 ? prev + 1 : 0))
|
||||
} else if (key.return) {
|
||||
nextStepAfterAuth(AUTH_METHODS[authMethodIndex].value)
|
||||
}
|
||||
} else if (step === "region") {
|
||||
if (key.escape) {
|
||||
goBack()
|
||||
} else if (key.upArrow) {
|
||||
setRegionIndex((prev) => (prev > 0 ? prev - 1 : filteredRegions.length - 1))
|
||||
} else if (key.downArrow) {
|
||||
setRegionIndex((prev) => (prev < filteredRegions.length - 1 ? prev + 1 : 0))
|
||||
} else if (key.return && filteredRegions.length > 0) {
|
||||
setStep("options")
|
||||
} else if (key.backspace || key.delete) {
|
||||
setRegionSearch((prev) => prev.slice(0, -1))
|
||||
setRegionIndex(0)
|
||||
} else if (input && !key.ctrl && !key.meta) {
|
||||
setRegionSearch((prev) => prev + input)
|
||||
setRegionIndex(0)
|
||||
}
|
||||
} else if (step === "options") {
|
||||
if (key.escape) {
|
||||
goBack()
|
||||
} else if (key.tab || key.return || input === " ") {
|
||||
// Tab/Enter/Space on checkbox toggles it, on Done button finishes
|
||||
if (optionIndex === 0) {
|
||||
setCrossRegion((prev) => !prev)
|
||||
} else {
|
||||
finish()
|
||||
}
|
||||
} else if (key.upArrow) {
|
||||
setOptionIndex((prev) => (prev > 0 ? prev - 1 : 1))
|
||||
} else if (key.downArrow) {
|
||||
setOptionIndex((prev) => (prev < 1 ? prev + 1 : 0))
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: isActive && isRawModeSupported && (step === "auth_method" || step === "region" || step === "options") },
|
||||
)
|
||||
|
||||
if (step === "auth_method") {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="white">Authentication method</Text>
|
||||
<Text> </Text>
|
||||
{AUTH_METHODS.map((method, i) => (
|
||||
<Box flexDirection="column" key={method.value} marginBottom={i < AUTH_METHODS.length - 1 ? 1 : 0}>
|
||||
<Text color={i === authMethodIndex ? COLORS.primaryBlue : undefined}>
|
||||
{i === authMethodIndex ? "❯ " : " "}
|
||||
{method.label}
|
||||
</Text>
|
||||
<Box paddingLeft={2}>
|
||||
<Text color="gray">{method.description}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
<Text> </Text>
|
||||
<Text color="gray">Arrows to navigate, Enter to select, Esc to go back</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (step === "profile_name") {
|
||||
return (
|
||||
<CredentialInput
|
||||
hint="Leave empty to use the default profile"
|
||||
isActive={isActive}
|
||||
label="AWS Profile Name"
|
||||
onCancel={goBack}
|
||||
onChange={setProfileName}
|
||||
onSubmit={() => setStep("region")}
|
||||
placeholder="default"
|
||||
value={profileName}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (step === "access_key") {
|
||||
return (
|
||||
<CredentialInput
|
||||
isActive={isActive}
|
||||
isPassword
|
||||
label="AWS Access Key"
|
||||
onCancel={goBack}
|
||||
onChange={setAccessKey}
|
||||
onSubmit={() => {
|
||||
if (accessKey.trim()) setStep("secret_key")
|
||||
}}
|
||||
placeholder="Enter access key..."
|
||||
value={accessKey}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (step === "secret_key") {
|
||||
return (
|
||||
<CredentialInput
|
||||
isActive={isActive}
|
||||
isPassword
|
||||
label="AWS Secret Key"
|
||||
onCancel={goBack}
|
||||
onChange={setSecretKey}
|
||||
onSubmit={() => {
|
||||
if (secretKey.trim()) setStep("session_token")
|
||||
}}
|
||||
placeholder="Enter secret key..."
|
||||
value={secretKey}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (step === "session_token") {
|
||||
return (
|
||||
<CredentialInput
|
||||
hint="Optional - for temporary credentials"
|
||||
isActive={isActive}
|
||||
isPassword
|
||||
label="AWS Session Token"
|
||||
onCancel={goBack}
|
||||
onChange={setSessionToken}
|
||||
onSubmit={() => setStep("region")}
|
||||
placeholder="Enter session token (optional)..."
|
||||
value={sessionToken}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (step === "region") {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="white">AWS Region</Text>
|
||||
<Text> </Text>
|
||||
<Box>
|
||||
<Text color="gray">Search: </Text>
|
||||
<Text color="white">{regionSearch}</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
<Text> </Text>
|
||||
{showRegionTop && <Text color="gray">... {regionVisibleStart} more above</Text>}
|
||||
{visibleRegions.map((region, i) => {
|
||||
const actualIndex = regionVisibleStart + i
|
||||
return (
|
||||
<Box key={region}>
|
||||
<Text color={actualIndex === regionIndex ? COLORS.primaryBlue : undefined}>
|
||||
{actualIndex === regionIndex ? "❯ " : " "}
|
||||
{region}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
{showRegionBottom && (
|
||||
<Text color="gray">... {filteredRegions.length - regionVisibleStart - regionVisibleCount} more below</Text>
|
||||
)}
|
||||
{filteredRegions.length === 0 && <Text color="gray">No regions match "{regionSearch}"</Text>}
|
||||
<Text> </Text>
|
||||
<Text color="gray">Type to search, arrows to navigate, Enter to select, Esc to go back</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (step === "options") {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="white">Options</Text>
|
||||
<Text> </Text>
|
||||
<Text color={optionIndex === 0 ? COLORS.primaryBlue : undefined}>
|
||||
{optionIndex === 0 ? "❯ " : " "}
|
||||
{crossRegion ? "[x]" : "[ ]"} Use cross-region inference
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text color={optionIndex === 1 ? COLORS.primaryBlue : undefined}>
|
||||
{optionIndex === 1 ? "❯ " : " "}
|
||||
Done
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text color="gray">Arrows to navigate, Enter to select, Esc to go back</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -6,11 +6,13 @@
|
||||
* - ⎿ for tool results (indented)
|
||||
*/
|
||||
|
||||
import { CLINE_ACCOUNT_AUTH_ERROR_MESSAGE } from "@shared/ClineAccount"
|
||||
import { COMMAND_OUTPUT_STRING } from "@shared/combineCommandSequences"
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { Box, Text } from "ink"
|
||||
import React from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useTerminalSize } from "../hooks/useTerminalSize"
|
||||
import { jsonParseSafe } from "../utils/parser"
|
||||
import { getToolDescription, isFileEditTool, parseToolFromMessage } from "../utils/tools"
|
||||
import { DiffView } from "./DiffView"
|
||||
@@ -51,11 +53,7 @@ function renderInlineMarkdown(text: string): React.ReactNode[] {
|
||||
)
|
||||
} else if (fullMatch.startsWith("`") && fullMatch.endsWith("`")) {
|
||||
// Inline code
|
||||
nodes.push(
|
||||
<Text dimColor key={key}>
|
||||
{fullMatch.slice(1, -1)}
|
||||
</Text>,
|
||||
)
|
||||
nodes.push(<Text key={key}>{fullMatch.slice(1, -1)}</Text>)
|
||||
}
|
||||
|
||||
lastIndex = regex.lastIndex
|
||||
@@ -106,7 +104,7 @@ const DotRow: React.FC<{ children: React.ReactNode; color?: string }> = ({ child
|
||||
const ResultRow: React.FC<{ children: React.ReactNode; isFirst?: boolean }> = ({ children, isFirst }) => (
|
||||
<Box flexDirection="row">
|
||||
<Box width={3}>
|
||||
<Text dimColor>{isFirst ? "⎿ " : " "}</Text>
|
||||
<Text color="gray">{isFirst ? "⎿ " : " "}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>{children}</Box>
|
||||
</Box>
|
||||
@@ -122,7 +120,7 @@ function getToolMainArg(_toolName: string, args: Record<string, unknown>): strin
|
||||
|
||||
// Command - truncate long commands
|
||||
if (typeof args.command === "string") {
|
||||
return args.command.length > 60 ? args.command.substring(0, 57) + "..." : args.command
|
||||
return args.command.length > 120 ? args.command.substring(0, 117) + "..." : args.command
|
||||
}
|
||||
|
||||
// Search regex
|
||||
@@ -188,17 +186,28 @@ function formatToolResult(result: string, maxLines: number = 5): string[] {
|
||||
export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode }) => {
|
||||
const { type, ask, say, text } = message
|
||||
const toolColor = mode === "plan" ? "yellow" : COLORS.primaryBlue
|
||||
const { columns: terminalWidth } = useTerminalSize()
|
||||
|
||||
// User messages (task, user_feedback)
|
||||
// If multi-line, extend background to full width for consistent appearance
|
||||
if (say === "task" || say === "user_feedback") {
|
||||
const content = "> " + (text || "")
|
||||
const isMultiLine = content.includes("\n") || content.length > terminalWidth
|
||||
|
||||
if (isMultiLine) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<Box backgroundColor="blackBright" paddingX={1} width="100%">
|
||||
<Text color="white">{content}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box backgroundColor="blackBright" paddingRight={1}>
|
||||
<Text color="white" dimColor>
|
||||
{" "}
|
||||
>{" "}
|
||||
</Text>
|
||||
<Text color="white">{text}</Text>
|
||||
<Box backgroundColor="blackBright" paddingX={1}>
|
||||
<Text color="white">{content}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
@@ -241,19 +250,24 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode }) => {
|
||||
)
|
||||
}
|
||||
|
||||
// Only show result content for completed tools (say), not for pending asks
|
||||
const resultLines = isToolSay && toolInfo.result?.trim() ? formatToolResult(toolInfo.result, 5) : []
|
||||
// Show result content for completed tools, or file path for pending asks
|
||||
const contentLines =
|
||||
isToolSay && toolInfo.result?.trim()
|
||||
? formatToolResult(toolInfo.result, 5)
|
||||
: isToolAsk && filePath
|
||||
? [filePath as string]
|
||||
: []
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor}>
|
||||
<ToolCallText args={toolInfo.args} isAsk={isToolAsk} mode={mode} toolName={toolInfo.toolName} />
|
||||
</DotRow>
|
||||
{resultLines.length > 0 && (
|
||||
{contentLines.length > 0 && (
|
||||
<Box flexDirection="column" marginLeft={2} width="100%">
|
||||
{resultLines.map((line, idx) => (
|
||||
{contentLines.map((line, idx) => (
|
||||
<ResultRow isFirst={idx === 0} key={idx}>
|
||||
<Text dimColor>{line}</Text>
|
||||
<Text color="gray">{line}</Text>
|
||||
</ResultRow>
|
||||
))}
|
||||
</Box>
|
||||
@@ -290,14 +304,14 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode }) => {
|
||||
<DotRow color={toolColor}>
|
||||
<Text>
|
||||
<Text color={toolColor}>{label}</Text>
|
||||
<Text>{truncate(command, 60)}</Text>
|
||||
<Text>{truncate(command, 120)}</Text>
|
||||
</Text>
|
||||
</DotRow>
|
||||
{output && (
|
||||
<Box flexDirection="column" marginLeft={2} width="100%">
|
||||
{formatToolResult(output, 8).map((line, idx) => (
|
||||
<ResultRow isFirst={idx === 0} key={idx}>
|
||||
<Text dimColor>{line}</Text>
|
||||
<Text color="gray">{line}</Text>
|
||||
</ResultRow>
|
||||
))}
|
||||
</Box>
|
||||
@@ -314,7 +328,7 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode }) => {
|
||||
<Box flexDirection="column" marginLeft={2} width="100%">
|
||||
{lines.map((line, idx) => (
|
||||
<ResultRow isFirst={idx === 0} key={idx}>
|
||||
<Text dimColor>{line}</Text>
|
||||
<Text color="gray">{line}</Text>
|
||||
</ResultRow>
|
||||
))}
|
||||
</Box>
|
||||
@@ -332,14 +346,24 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode }) => {
|
||||
errorMessage = parsed.message
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Cline auth error to show sign-in instructions
|
||||
const isClineAuthError = errorMessage.includes(CLINE_ACCOUNT_AUTH_ERROR_MESSAGE)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color="red">
|
||||
<Text bold color="red">
|
||||
Error
|
||||
<Text color="red" wrap="wrap">
|
||||
<Text bold>Error</Text>: {errorMessage}
|
||||
</Text>
|
||||
<Text color="red">: {errorMessage}</Text>
|
||||
</DotRow>
|
||||
{isClineAuthError && (
|
||||
<Box marginLeft={2} marginTop={1}>
|
||||
<Text color="gray">
|
||||
Run <Text color="cyan">/settings</Text> and go to Account to sign in.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -490,7 +514,7 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode }) => {
|
||||
{parsed.options.map((opt, idx) => {
|
||||
const isSelected = parsed.selected === opt
|
||||
return (
|
||||
<Text color={isSelected ? "green" : "gray"} key={opt}>
|
||||
<Text color={isSelected ? "green" : toolColor} key={opt}>
|
||||
{isSelected ? "✓" : `${idx + 1}.`} {opt}
|
||||
</Text>
|
||||
)
|
||||
@@ -516,6 +540,22 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode }) => {
|
||||
}
|
||||
}
|
||||
|
||||
// New task request from assistant
|
||||
if (type === "ask" && ask === "new_task" && text) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={COLORS.primaryBlue}>
|
||||
<Text bold color={COLORS.primaryBlue}>
|
||||
Cline wants to start a new task:
|
||||
</Text>
|
||||
</DotRow>
|
||||
<Box flexDirection="column" paddingLeft={2}>
|
||||
<Text color="gray">{text}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Skip other message types
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* Tests for ChatView component exit and cleanup behavior
|
||||
*
|
||||
* These tests verify that when the user exits (via Ctrl+C or other means),
|
||||
* the input field is properly hidden before the app terminates.
|
||||
*/
|
||||
|
||||
import { Text } from "ink"
|
||||
import { render } from "ink-testing-library"
|
||||
import React from "react"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { ChatView } from "./ChatView"
|
||||
|
||||
// Helper to wait for async state updates
|
||||
// Using 60ms since handleExit has a 50ms setTimeout
|
||||
const delay = (ms: number = 60) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
// Type for our exit mock function
|
||||
type ExitMockFn = ReturnType<typeof vi.fn> & (() => void)
|
||||
|
||||
// Track shutdown event state
|
||||
const shutdownMockState = {
|
||||
listeners: [] as Array<() => void>,
|
||||
fire: () => {
|
||||
shutdownMockState.listeners.forEach((listener) => listener())
|
||||
},
|
||||
reset: () => {
|
||||
shutdownMockState.listeners = []
|
||||
},
|
||||
}
|
||||
|
||||
// Mock vscode-shim shutdownEvent
|
||||
vi.mock("../vscode-shim", () => ({
|
||||
shutdownEvent: {
|
||||
event: (listener: () => void) => {
|
||||
shutdownMockState.listeners.push(listener)
|
||||
return {
|
||||
dispose: () => {
|
||||
const idx = shutdownMockState.listeners.indexOf(listener)
|
||||
if (idx >= 0) shutdownMockState.listeners.splice(idx, 1)
|
||||
},
|
||||
}
|
||||
},
|
||||
fire: () => shutdownMockState.fire(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock TaskContext
|
||||
vi.mock("../context/TaskContext", () => ({
|
||||
useTaskState: vi.fn(() => ({
|
||||
clineMessages: [],
|
||||
mode: "act",
|
||||
})),
|
||||
useTaskContext: vi.fn(() => ({
|
||||
controller: null,
|
||||
})),
|
||||
}))
|
||||
|
||||
// Mock useIsSpinnerActive hook
|
||||
vi.mock("../hooks/useStateSubscriber", () => ({
|
||||
useIsSpinnerActive: vi.fn(() => ({
|
||||
isActive: false,
|
||||
startTime: null,
|
||||
})),
|
||||
}))
|
||||
|
||||
// Mock StateManager
|
||||
vi.mock("@/core/storage/StateManager", () => ({
|
||||
StateManager: {
|
||||
get: vi.fn(() => ({
|
||||
getGlobalSettingsKey: vi.fn((key: string) => {
|
||||
if (key === "mode") return "act"
|
||||
if (key === "yoloModeToggled") return false
|
||||
if (key === "actModeApiModelId") return "claude-sonnet-4-20250514"
|
||||
return null
|
||||
}),
|
||||
setGlobalState: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock child components that aren't under test
|
||||
vi.mock("./ActionButtons", () => ({
|
||||
ActionButtons: () => React.createElement(Text, null, "ActionButtons"),
|
||||
getButtonConfig: vi.fn(() => ({ enableButtons: false })),
|
||||
}))
|
||||
|
||||
vi.mock("./AsciiMotionCli", () => ({
|
||||
AsciiMotionCli: () => React.createElement(Text, null, "AsciiMotion"),
|
||||
StaticRobotFrame: () => React.createElement(Text, null, "StaticRobot"),
|
||||
}))
|
||||
|
||||
vi.mock("./ChatMessage", () => ({
|
||||
ChatMessage: ({ message }: { message?: { ts?: number } }) => React.createElement(Text, null, `Message: ${message?.ts}`),
|
||||
}))
|
||||
|
||||
vi.mock("./FileMentionMenu", () => ({
|
||||
FileMentionMenu: () => React.createElement(Text, null, "FileMentionMenu"),
|
||||
}))
|
||||
|
||||
vi.mock("./HighlightedInput", () => ({
|
||||
HighlightedInput: ({ text }: { text?: string }) => React.createElement(Text, null, `Input: ${text}`),
|
||||
}))
|
||||
|
||||
vi.mock("./HistoryPanelContent", () => ({
|
||||
HistoryPanelContent: () => React.createElement(Text, null, "HistoryPanel"),
|
||||
}))
|
||||
|
||||
vi.mock("./SettingsPanelContent", () => ({
|
||||
SettingsPanelContent: () => React.createElement(Text, null, "SettingsPanel"),
|
||||
}))
|
||||
|
||||
vi.mock("./SlashCommandMenu", () => ({
|
||||
SlashCommandMenu: () => React.createElement(Text, null, "SlashMenu"),
|
||||
}))
|
||||
|
||||
vi.mock("./ThinkingIndicator", () => ({
|
||||
ThinkingIndicator: () => React.createElement(Text, null, "ThinkingIndicator"),
|
||||
}))
|
||||
|
||||
// Mock utility functions
|
||||
vi.mock("../utils/file-search", () => ({
|
||||
checkAndWarnRipgrepMissing: vi.fn(() => false),
|
||||
extractMentionQuery: vi.fn(() => ({ inMentionMode: false, query: "", atIndex: -1 })),
|
||||
getRipgrepInstallInstructions: vi.fn(() => "brew install ripgrep"),
|
||||
insertMention: vi.fn((text: string) => text),
|
||||
searchWorkspaceFiles: vi.fn(async () => []),
|
||||
}))
|
||||
|
||||
vi.mock("../utils/slash-commands", () => ({
|
||||
extractSlashQuery: vi.fn(() => ({ inSlashMode: false, query: "", slashIndex: -1 })),
|
||||
filterCommands: vi.fn(() => []),
|
||||
insertSlashCommand: vi.fn((text: string) => text),
|
||||
sortCommandsWorkflowsFirst: vi.fn((cmds: unknown[]) => cmds),
|
||||
}))
|
||||
|
||||
vi.mock("../utils/input", () => ({
|
||||
isMouseEscapeSequence: vi.fn(() => false),
|
||||
}))
|
||||
|
||||
vi.mock("../utils/parser", () => ({
|
||||
jsonParseSafe: vi.fn((_text: string, defaultValue: unknown) => defaultValue),
|
||||
parseImagesFromInput: vi.fn((text: string) => ({ prompt: text, imagePaths: [] })),
|
||||
}))
|
||||
|
||||
vi.mock("../utils/tools", () => ({
|
||||
isFileEditTool: vi.fn(() => false),
|
||||
parseToolFromMessage: vi.fn(() => null),
|
||||
}))
|
||||
|
||||
vi.mock("../utils/display", () => ({
|
||||
setTerminalTitle: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("../utils/cursor", () => ({
|
||||
moveCursorUp: vi.fn((_text: string, pos: number) => pos),
|
||||
moveCursorDown: vi.fn((_text: string, pos: number) => pos),
|
||||
}))
|
||||
|
||||
vi.mock("@/core/controller/slash/getAvailableSlashCommands", () => ({
|
||||
getAvailableSlashCommands: vi.fn(async () => ({ commands: [] })),
|
||||
}))
|
||||
|
||||
vi.mock("@/core/controller/task/showTaskWithId", () => ({
|
||||
showTaskWithId: vi.fn(async () => {}),
|
||||
}))
|
||||
|
||||
vi.mock("@shared/combineCommandSequences", () => ({
|
||||
combineCommandSequences: vi.fn((messages: unknown[]) => messages),
|
||||
}))
|
||||
|
||||
vi.mock("@shared/getApiMetrics", () => ({
|
||||
getApiMetrics: vi.fn(() => ({
|
||||
totalTokensIn: 0,
|
||||
totalTokensOut: 0,
|
||||
totalCost: 0,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock("child_process", () => ({
|
||||
execSync: vi.fn(() => "main"),
|
||||
}))
|
||||
|
||||
// Helper to create a typed mock for onExit
|
||||
const createExitMock = (): ExitMockFn => vi.fn() as ExitMockFn
|
||||
|
||||
describe("ChatView Exit and Cleanup", () => {
|
||||
let mockOnExit: ExitMockFn
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
shutdownMockState.reset()
|
||||
mockOnExit = createExitMock()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe("Initial render state", () => {
|
||||
it("should render with input field, footer, and mode toggle visible", () => {
|
||||
const { lastFrame } = render(<ChatView onExit={mockOnExit} />)
|
||||
const frame = lastFrame()
|
||||
|
||||
// Input field visible
|
||||
expect(frame).toContain("Input:")
|
||||
// Footer with help text
|
||||
expect(frame).toContain("@ for files")
|
||||
expect(frame).toContain("/ for commands")
|
||||
// Mode toggle
|
||||
expect(frame).toContain("Plan")
|
||||
expect(frame).toContain("Act")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Ctrl+C exit handling", () => {
|
||||
it("should hide input but keep footer, then call onExit", async () => {
|
||||
const { lastFrame, stdin } = render(<ChatView onExit={mockOnExit} />)
|
||||
|
||||
// Verify UI visible before Ctrl+C
|
||||
expect(lastFrame()).toContain("Input:")
|
||||
expect(lastFrame()).toContain("@ for files")
|
||||
|
||||
// Simulate Ctrl+C
|
||||
stdin.write("\x03")
|
||||
|
||||
// onExit should not be called immediately
|
||||
expect(mockOnExit).not.toHaveBeenCalled()
|
||||
|
||||
// Wait for state update and callback
|
||||
await delay()
|
||||
|
||||
// Input should be hidden, but footer should remain
|
||||
const frameAfter = lastFrame()
|
||||
expect(frameAfter).not.toContain("Input:")
|
||||
expect(frameAfter).toContain("@ for files")
|
||||
|
||||
// onExit should have been called
|
||||
expect(mockOnExit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Shutdown event handling", () => {
|
||||
it("should subscribe on mount and unsubscribe on unmount", () => {
|
||||
const { unmount } = render(<ChatView onExit={mockOnExit} />)
|
||||
expect(shutdownMockState.listeners.length).toBe(1)
|
||||
|
||||
unmount()
|
||||
expect(shutdownMockState.listeners.length).toBe(0)
|
||||
})
|
||||
|
||||
it("should hide UI when shutdown event fires", async () => {
|
||||
const { lastFrame } = render(<ChatView onExit={mockOnExit} />)
|
||||
|
||||
expect(lastFrame()).toContain("Input:")
|
||||
|
||||
shutdownMockState.fire()
|
||||
await delay()
|
||||
|
||||
expect(lastFrame()).not.toContain("Input:")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Edge cases", () => {
|
||||
it("should handle exit when onExit prop is undefined", async () => {
|
||||
const { lastFrame, stdin } = render(<ChatView />)
|
||||
|
||||
stdin.write("\x03")
|
||||
await delay()
|
||||
|
||||
// Should not throw, UI should still hide
|
||||
expect(lastFrame()).not.toContain("Input:")
|
||||
})
|
||||
|
||||
it("should handle multiple Ctrl+C presses gracefully", async () => {
|
||||
const { stdin } = render(<ChatView onExit={mockOnExit} />)
|
||||
|
||||
stdin.write("\x03")
|
||||
stdin.write("\x03")
|
||||
stdin.write("\x03")
|
||||
|
||||
await delay()
|
||||
|
||||
expect(mockOnExit).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("ChatView UI State During Exit", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
shutdownMockState.reset()
|
||||
})
|
||||
|
||||
it("should preserve static content and footer, only hide input during exit", async () => {
|
||||
const onExit = createExitMock()
|
||||
const { lastFrame, stdin } = render(<ChatView onExit={onExit} />)
|
||||
|
||||
// Footer contains auto-approve toggle
|
||||
expect(lastFrame()).toContain("Auto-approve")
|
||||
expect(lastFrame()).toContain("What can I do for you?")
|
||||
expect(lastFrame()).toContain("Input:")
|
||||
|
||||
stdin.write("\x03")
|
||||
await delay()
|
||||
|
||||
const frameAfter = lastFrame()
|
||||
|
||||
// Static content should still be present
|
||||
expect(frameAfter).toContain("What can I do for you?")
|
||||
// Footer should still be present (only input is hidden)
|
||||
expect(frameAfter).toContain("Auto-approve")
|
||||
// Input should be hidden
|
||||
expect(frameAfter).not.toContain("Input:")
|
||||
})
|
||||
})
|
||||
@@ -101,28 +101,32 @@
|
||||
* - log-update: node_modules/ink/build/log-update.js (eraseLines logic)
|
||||
*/
|
||||
|
||||
import type { ModelInfo } from "@shared/api"
|
||||
import { combineCommandSequences } from "@shared/combineCommandSequences"
|
||||
import type { ClineAsk, ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { getApiMetrics } from "@shared/getApiMetrics"
|
||||
import { getApiMetrics, getLastApiReqTotalTokens } from "@shared/getApiMetrics"
|
||||
import { EmptyRequest, StringRequest } from "@shared/proto/cline/common"
|
||||
import type { SlashCommandInfo } from "@shared/proto/cline/slash"
|
||||
import { CLI_ONLY_COMMANDS } from "@shared/slashCommands"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import { execSync } from "child_process"
|
||||
import { Box, Static, Text, useInput } from "ink"
|
||||
import { Box, Static, Text, useApp, useInput } from "ink"
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { getAvailableSlashCommands } from "@/core/controller/slash/getAvailableSlashCommands"
|
||||
import { showTaskWithId } from "@/core/controller/task/showTaskWithId"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { Session } from "@/shared/services/Session"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useTaskContext, useTaskState } from "../context/TaskContext"
|
||||
import { useIsSpinnerActive } from "../hooks/useStateSubscriber"
|
||||
import { moveCursorDown, moveCursorUp } from "../utils/cursor"
|
||||
import { setTerminalTitle } from "../utils/display"
|
||||
import {
|
||||
checkAndWarnRipgrepMissing,
|
||||
extractMentionQuery,
|
||||
type FileSearchResult,
|
||||
getRipgrepInstallInstructions,
|
||||
insertMention,
|
||||
searchWorkspaceFiles,
|
||||
} from "../utils/file-search"
|
||||
@@ -130,11 +134,15 @@ import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { jsonParseSafe, parseImagesFromInput } from "../utils/parser"
|
||||
import { extractSlashQuery, filterCommands, insertSlashCommand, sortCommandsWorkflowsFirst } from "../utils/slash-commands"
|
||||
import { isFileEditTool, parseToolFromMessage } from "../utils/tools"
|
||||
import { ActionButtons, type ButtonActionType, getButtonConfig } from "./ActionButtons"
|
||||
import { shutdownEvent } from "../vscode-shim"
|
||||
import { ActionButtons, type ButtonActionType, getButtonConfig, getVisibleButtons } from "./ActionButtons"
|
||||
import { AsciiMotionCli, StaticRobotFrame } from "./AsciiMotionCli"
|
||||
import { ChatMessage } from "./ChatMessage"
|
||||
import { FileMentionMenu } from "./FileMentionMenu"
|
||||
import { HighlightedInput } from "./HighlightedInput"
|
||||
import { HistoryPanelContent } from "./HistoryPanelContent"
|
||||
import { providerModels } from "./ModelPicker"
|
||||
import { SessionSummary } from "./SessionSummary"
|
||||
import { SettingsPanelContent } from "./SettingsPanelContent"
|
||||
import { SlashCommandMenu } from "./SlashCommandMenu"
|
||||
import { ThinkingIndicator } from "./ThinkingIndicator"
|
||||
@@ -154,6 +162,8 @@ const SEARCH_DEBOUNCE_MS = 150
|
||||
const RIPGREP_WARNING_DURATION_MS = 5000
|
||||
const MAX_SEARCH_RESULTS = 15
|
||||
const DEFAULT_CONTEXT_WINDOW = 200000
|
||||
const PASTE_COLLAPSE_THRESHOLD = 100 // Characters before showing placeholder
|
||||
const MAX_HISTORY_ITEMS = 20 // Max history items to navigate with up/down arrows
|
||||
|
||||
/**
|
||||
* Get current git branch name
|
||||
@@ -226,6 +236,37 @@ function centerText(text: string, terminalWidth?: number): string {
|
||||
return " ".repeat(padding) + text
|
||||
}
|
||||
|
||||
/**
|
||||
* Yolo mode auto-approves tool use, commands, browser actions, etc. so the AI can work
|
||||
* uninterrupted. But some ask types genuinely need user input -- you can't auto-approve
|
||||
* "task completed, what next?" or a followup question the AI is asking the user.
|
||||
*
|
||||
* This whitelist defines which ask types should still show buttons and allow text input
|
||||
* even when yolo mode is enabled. Everything NOT in this set gets suppressed (buttons
|
||||
* hidden, input blocked), which is the correct behavior for tool/browser/mcp approvals
|
||||
* since core auto-approves those before they even reach the UI.
|
||||
*
|
||||
* Any new ask types added in the future will be suppressed by default in yolo mode.
|
||||
* If a new ask type needs user interaction, add it here explicitly.
|
||||
*/
|
||||
const YOLO_INTERACTIVE_ASKS = new Set<ClineAsk>([
|
||||
"completion_result",
|
||||
// In yolo mode, ExecuteCommandToolHandler auto-approves commands via say() (not ask()) at line 176,
|
||||
// so command asks never reach the UI for regular tool use. The only command ask that reaches the UI
|
||||
// is from AttemptCompletionHandler (line 135), which uses askApprovalAndPushFeedback("command", ...)
|
||||
// to let the user choose whether to run the suggested verification command after task completion.
|
||||
"command",
|
||||
"followup",
|
||||
"plan_mode_respond",
|
||||
"resume_task",
|
||||
"resume_completed_task",
|
||||
"new_task",
|
||||
])
|
||||
|
||||
function isYoloSuppressed(yolo: boolean, ask: ClineAsk | undefined): boolean {
|
||||
return yolo && (!ask || !YOLO_INTERACTIVE_ASKS.has(ask))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the type of prompt needed for an ask message
|
||||
*/
|
||||
@@ -262,6 +303,17 @@ function parseAskOptions(text: string): string[] {
|
||||
return parts.options || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand pasted text placeholders back to actual content
|
||||
* Replaces [Pasted text #N +X lines] with the stored content
|
||||
*/
|
||||
function expandPastedTexts(text: string, pastedTexts: Map<number, string>): string {
|
||||
return text.replace(/\[Pasted text #(\d+) \+\d+ lines\]/g, (match, num) => {
|
||||
const content = pastedTexts.get(parseInt(num, 10))
|
||||
return content ?? match
|
||||
})
|
||||
}
|
||||
|
||||
export const ChatView: React.FC<ChatViewProps> = ({
|
||||
controller,
|
||||
onExit,
|
||||
@@ -272,9 +324,12 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
initialImages,
|
||||
taskId,
|
||||
}) => {
|
||||
// Get Ink app instance for graceful exit
|
||||
const { exit: inkExit } = useApp()
|
||||
|
||||
// Get task state from context
|
||||
const taskState = useTaskState()
|
||||
const { controller: taskController } = useTaskContext()
|
||||
const { controller: taskController, clearState } = useTaskContext()
|
||||
const { isActive: isSpinnerActive, startTime: spinnerStartTime } = useIsSpinnerActive()
|
||||
|
||||
// Prefer prop controller over context controller (memoized for stable reference in callbacks)
|
||||
@@ -284,12 +339,26 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
const [textInput, setTextInput] = useState("")
|
||||
const [cursorPos, setCursorPos] = useState(0)
|
||||
const [fileResults, setFileResults] = useState<FileSearchResult[]>([])
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0) // For file menu
|
||||
const [historyIndex, setHistoryIndex] = useState(-1) // -1 = not browsing history, 0+ = history item index
|
||||
const [savedInput, setSavedInput] = useState("") // Save user's input when entering history mode
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [showRipgrepWarning, setShowRipgrepWarning] = useState(false)
|
||||
const [respondedToAsk, setRespondedToAsk] = useState<number | null>(null)
|
||||
const [userScrolled, setUserScrolled] = useState(false)
|
||||
|
||||
// Pasted text storage - maps placeholder number to full pasted content
|
||||
const [pastedTexts, setPastedTexts] = useState<Map<number, string>>(new Map())
|
||||
const pasteCounterRef = useRef(0)
|
||||
// Track paste timing to combine chunks that arrive in rapid succession
|
||||
const lastPasteTimeRef = useRef<number>(0)
|
||||
const activePasteNumRef = useRef<number>(0)
|
||||
const activePasteStartPosRef = useRef<number>(0) // Where the placeholder starts in the text
|
||||
const activePasteLinesRef = useRef<number>(0) // Total line count for current paste
|
||||
const pasteUpdateTimeoutRef = useRef<NodeJS.Timeout | null>(null) // Debounce placeholder updates
|
||||
const PASTE_CHUNK_WINDOW_MS = 150 // Chunks within this window are combined into one paste
|
||||
const PASTE_UPDATE_DEBOUNCE_MS = 50 // Debounce visual updates to avoid flicker
|
||||
|
||||
// Slash command state
|
||||
const [availableCommands, setAvailableCommands] = useState<SlashCommandInfo[]>([])
|
||||
const [selectedSlashIndex, setSelectedSlashIndex] = useState(0)
|
||||
@@ -297,12 +366,30 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
const lastSlashIndexRef = useRef<number>(-1)
|
||||
|
||||
// Settings panel state
|
||||
const [activePanel, setActivePanel] = useState<{ type: "settings"; initialMode?: "model-picker" } | null>(null)
|
||||
const [activePanel, setActivePanel] = useState<
|
||||
{ type: "settings"; initialMode?: "model-picker" } | { type: "history" } | null
|
||||
>(null)
|
||||
|
||||
// Track when we're exiting to hide UI elements before exit
|
||||
const [isExiting, setIsExiting] = useState(false)
|
||||
|
||||
// Task switch handling: when switching tasks via /history, we clear the terminal and
|
||||
// increment a counter used as the root Box's key. This forces React to remount the tree,
|
||||
// giving us a fresh Static instance. Mirrors how App.tsx handles resize with resizeKey.
|
||||
const [taskSwitchKey, setTaskSwitchKey] = useState(0)
|
||||
const prevFirstMessageTs = useRef<number | null>(null)
|
||||
|
||||
// Listen for shutdown event (Ctrl+C) to hide UI before exit
|
||||
useEffect(() => {
|
||||
const subscription = shutdownEvent.event(() => {
|
||||
const session = Session.get()
|
||||
const summary = session.getStats()
|
||||
telemetryService.captureHostEvent("exit", JSON.stringify(summary))
|
||||
setIsExiting(true)
|
||||
})
|
||||
return () => subscription.dispose()
|
||||
}, [])
|
||||
|
||||
// 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)
|
||||
const [gitDiffStats, setGitDiffStats] = useState<GitDiffStats | null>(null)
|
||||
|
||||
@@ -314,6 +401,13 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
|
||||
const [yolo, setYolo] = useState<boolean>(() => StateManager.get().getGlobalSettingsKey("yoloModeToggled") ?? false)
|
||||
|
||||
// Sync mode from core state updates (e.g. yolo auto-switching plan to act)
|
||||
useEffect(() => {
|
||||
if (taskState.mode && taskState.mode !== mode) {
|
||||
setMode(taskState.mode as Mode)
|
||||
}
|
||||
}, [taskState.mode])
|
||||
|
||||
const toggleYolo = useCallback(() => {
|
||||
const newValue = !yolo
|
||||
setYolo(newValue)
|
||||
@@ -328,12 +422,38 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
return (stateManager.getGlobalSettingsKey(modelKey) as string) || "claude-sonnet-4-20250514"
|
||||
}, [mode, activePanel])
|
||||
|
||||
const toggleMode = useCallback(() => {
|
||||
// Get provider based on current mode
|
||||
const provider = useMemo(() => {
|
||||
const stateManager = StateManager.get()
|
||||
const providerKey = mode === "act" ? "actModeApiProvider" : "planModeApiProvider"
|
||||
return (stateManager.getGlobalSettingsKey(providerKey) as string) || "anthropic"
|
||||
}, [mode, activePanel])
|
||||
|
||||
const toggleMode = useCallback(async () => {
|
||||
const newMode: Mode = mode === "act" ? "plan" : "act"
|
||||
setMode(newMode)
|
||||
const stateManager = StateManager.get()
|
||||
stateManager.setGlobalState("mode", newMode)
|
||||
}, [mode])
|
||||
|
||||
// When switching from plan to act, include any text in the input box
|
||||
// Text stays visible in the input - don't clear it
|
||||
if (newMode === "act" && textInput.trim()) {
|
||||
const expandedText = expandPastedTexts(textInput, pastedTexts)
|
||||
await ctrl.togglePlanActMode(newMode, { message: expandedText.trim() })
|
||||
} else {
|
||||
await ctrl.togglePlanActMode(newMode)
|
||||
}
|
||||
}, [mode, ctrl, textInput, pastedTexts])
|
||||
|
||||
// Clear the terminal view and reset task state (used by /clear and "Start New Task" button)
|
||||
const clearViewAndResetTask = useCallback(() => {
|
||||
process.stdout.write("\x1b[2J\x1b[3J\x1b[H") // Clear screen + scrollback, cursor home
|
||||
setTaskSwitchKey((k) => k + 1) // Force remount for fresh Static instance
|
||||
clearState() // Force clear React state (bypasses empty messages check)
|
||||
if (ctrl) {
|
||||
ctrl.clearTask().then(() => ctrl.postStateToWebview())
|
||||
}
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
}, [ctrl, clearState])
|
||||
|
||||
const refs = useRef({
|
||||
searchTimeout: null as NodeJS.Timeout | null,
|
||||
@@ -343,7 +463,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
|
||||
const { prompt, imagePaths } = parseImagesFromInput(textInput)
|
||||
const mentionInfo = useMemo(() => extractMentionQuery(textInput), [textInput])
|
||||
const slashInfo = useMemo(() => extractSlashQuery(textInput), [textInput])
|
||||
const slashInfo = useMemo(() => extractSlashQuery(textInput, cursorPos), [textInput, cursorPos])
|
||||
const filteredCommands = useMemo(
|
||||
() => filterCommands(availableCommands, slashInfo.query),
|
||||
[availableCommands, slashInfo.query],
|
||||
@@ -379,8 +499,10 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
// Load existing task when taskId is provided
|
||||
useEffect(() => {
|
||||
if (!taskId) return
|
||||
|
||||
if (!ctrl) return
|
||||
// Prevent duplicate loads after resize. The resize fix remounts components via
|
||||
// resizeKey, but the controller's task persists. Skip if already loaded.
|
||||
if (ctrl.task?.taskId === taskId) return
|
||||
|
||||
// Load the task by ID
|
||||
showTaskWithId(ctrl, StringRequest.create({ value: taskId })).catch((error) => {
|
||||
@@ -411,6 +533,18 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
loadCommands()
|
||||
}, [ctrl])
|
||||
|
||||
// Get history items (limited to MAX_HISTORY_ITEMS, most recent first)
|
||||
const getHistoryItems = useCallback(() => {
|
||||
const history = StateManager.get().getGlobalStateKey("taskHistory")
|
||||
if (!history?.length) return []
|
||||
const filtered = [...history]
|
||||
.reverse()
|
||||
.map((item) => item.task)
|
||||
.slice(0, MAX_HISTORY_ITEMS)
|
||||
.filter(Boolean) as string[]
|
||||
return [...new Set(filtered)]
|
||||
}, [])
|
||||
|
||||
const messages = taskState.clineMessages || []
|
||||
|
||||
// Refresh git diff stats when messages change (after file edits)
|
||||
@@ -433,6 +567,19 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
return combineCommandSequences(filtered)
|
||||
}, [messages])
|
||||
|
||||
// Detect task switches by watching first message timestamp change.
|
||||
// When user selects a different task from /history, the messages array updates with
|
||||
// the new task's messages. We clear the terminal first, then increment the key to
|
||||
// trigger a re-render. The clear must happen before setState so the new render isn't wiped.
|
||||
const firstMessageTs = displayMessages[0]?.ts ?? null
|
||||
useEffect(() => {
|
||||
if (prevFirstMessageTs.current !== null && firstMessageTs !== null && prevFirstMessageTs.current !== firstMessageTs) {
|
||||
process.stdout.write("\x1b[2J\x1b[3J\x1b[H") // Clear screen + scrollback, cursor home
|
||||
setTaskSwitchKey((k) => k + 1) // Trigger remount after clear
|
||||
}
|
||||
prevFirstMessageTs.current = firstMessageTs
|
||||
}, [firstMessageTs])
|
||||
|
||||
// Split messages into completed (for Static) and current (for dynamic region)
|
||||
const { completedMessages, currentMessage } = useMemo(() => {
|
||||
const completed: typeof displayMessages = []
|
||||
@@ -556,17 +703,22 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
async (responseType: string, text?: string) => {
|
||||
if (!ctrl?.task || !pendingAsk) return
|
||||
|
||||
// Expand any pasted text placeholders
|
||||
const expandedText = text ? expandPastedTexts(text, pastedTexts) : text
|
||||
|
||||
setRespondedToAsk(pendingAsk.ts)
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
setPastedTexts(new Map()) // Clear stored pastes
|
||||
pasteCounterRef.current = 0
|
||||
|
||||
try {
|
||||
await ctrl.task.handleWebviewAskResponse(responseType, text)
|
||||
await ctrl.task.handleWebviewAskResponse(responseType, expandedText)
|
||||
} catch {
|
||||
// Controller may be disposed
|
||||
}
|
||||
},
|
||||
[ctrl, pendingAsk],
|
||||
[ctrl, pendingAsk, pastedTexts],
|
||||
)
|
||||
|
||||
// Handle cancel/interrupt
|
||||
@@ -580,6 +732,16 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
}
|
||||
}, [ctrl])
|
||||
|
||||
// Handle exit - hide input first, show summary, then exit Ink app gracefully
|
||||
const handleExit = useCallback(() => {
|
||||
setIsExiting(true)
|
||||
// Delay to allow Ink to re-render with session summary visible
|
||||
setTimeout(() => {
|
||||
inkExit()
|
||||
onExit?.()
|
||||
}, 150)
|
||||
}, [inkExit, onExit])
|
||||
|
||||
// Get button config based on the last message state
|
||||
const buttonConfig = useMemo(() => {
|
||||
const lastMsg = messages[messages.length - 1] as ClineMessage | undefined
|
||||
@@ -601,7 +763,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
case "reject":
|
||||
// Check for resume states that should trigger exit
|
||||
if (pendingAsk?.ask === "resume_task" || pendingAsk?.ask === "resume_completed_task") {
|
||||
onExit?.()
|
||||
handleExit()
|
||||
} else {
|
||||
sendAskResponse("noButtonClicked")
|
||||
}
|
||||
@@ -611,17 +773,23 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
sendAskResponse("yesButtonClicked")
|
||||
break
|
||||
case "new_task":
|
||||
// For now, signal to start a new task (user can type new prompt)
|
||||
setRespondedToAsk(pendingAsk?.ts || null)
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
if (pendingAsk?.ask === "new_task") {
|
||||
// Model called new_task tool - create new task with context
|
||||
setRespondedToAsk(pendingAsk.ts)
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
await ctrl.initTask(pendingAsk.text || "")
|
||||
} else {
|
||||
// From completion_result or resume_completed_task - full clear
|
||||
clearViewAndResetTask()
|
||||
}
|
||||
break
|
||||
case "cancel":
|
||||
handleCancel()
|
||||
break
|
||||
}
|
||||
},
|
||||
[controller, taskController, sendAskResponse, pendingAsk, onExit, handleCancel],
|
||||
[controller, taskController, sendAskResponse, pendingAsk, handleExit, handleCancel],
|
||||
)
|
||||
|
||||
// Handle task submission (new task)
|
||||
@@ -629,8 +797,13 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
async (text: string, images: string[]) => {
|
||||
if (!ctrl || !text.trim()) return
|
||||
|
||||
// Expand any pasted text placeholders
|
||||
const expandedText = expandPastedTexts(text, pastedTexts)
|
||||
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
setPastedTexts(new Map()) // Clear stored pastes
|
||||
pasteCounterRef.current = 0
|
||||
|
||||
try {
|
||||
// Convert image paths to data URLs if needed
|
||||
@@ -652,12 +825,13 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
)
|
||||
: []
|
||||
const validImages = imageDataUrls.filter((img): img is string => img !== null)
|
||||
await ctrl.initTask(text.trim(), validImages.length > 0 ? validImages : undefined)
|
||||
setTerminalTitle(expandedText.trim())
|
||||
await ctrl.initTask(expandedText.trim(), validImages.length > 0 ? validImages : undefined)
|
||||
} catch (_error) {
|
||||
onError?.()
|
||||
}
|
||||
},
|
||||
[ctrl, onError],
|
||||
[ctrl, onError, pastedTexts],
|
||||
)
|
||||
|
||||
// Auto-submit initial prompt if provided
|
||||
@@ -677,6 +851,10 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
try {
|
||||
// Set terminal title to the task prompt
|
||||
if (initialPrompt) {
|
||||
setTerminalTitle(initialPrompt)
|
||||
}
|
||||
// initialImages are already data URLs from index.ts processing
|
||||
await ctrl.initTask(initialPrompt || "", initialImages && initialImages.length > 0 ? initialImages : undefined)
|
||||
} catch (_error) {
|
||||
@@ -743,6 +921,12 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
|
||||
// Handle keyboard input
|
||||
useInput((input, key) => {
|
||||
// Handle Ctrl+C - hide input and exit gracefully
|
||||
if (input === "\x03" || (key.ctrl && input === "c")) {
|
||||
handleExit()
|
||||
return
|
||||
}
|
||||
|
||||
// Filter out mouse escape sequences from AsciiMotionCli's mouse tracking
|
||||
if (isMouseEscapeSequence(input)) {
|
||||
return
|
||||
@@ -788,6 +972,20 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
setSlashMenuDismissed(true)
|
||||
return
|
||||
}
|
||||
if (cmd.name === "history") {
|
||||
setActivePanel({ type: "history" })
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return
|
||||
}
|
||||
if (cmd.name === "clear") {
|
||||
clearViewAndResetTask()
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return
|
||||
}
|
||||
const newText = insertSlashCommand(textInput, slashInfo.slashIndex, cmd.name)
|
||||
setTextInput(newText)
|
||||
setCursorPos(newText.length)
|
||||
@@ -831,21 +1029,85 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// History navigation with up/down arrows
|
||||
// Only works when: input is empty, or input matches the currently selected history item
|
||||
if (key.upArrow && !inSlashMenu && !inFileMenu) {
|
||||
const historyItems = getHistoryItems()
|
||||
if (historyItems.length > 0) {
|
||||
const canNavigate =
|
||||
textInput === "" ||
|
||||
(historyIndex >= 0 && historyIndex < historyItems.length && textInput === historyItems[historyIndex])
|
||||
|
||||
if (canNavigate) {
|
||||
// Save original input when first entering history mode
|
||||
if (historyIndex === -1) {
|
||||
setSavedInput(textInput)
|
||||
}
|
||||
const newIndex = Math.min(historyIndex + 1, historyItems.length - 1)
|
||||
if (newIndex !== historyIndex) {
|
||||
setHistoryIndex(newIndex)
|
||||
const historyText = historyItems[newIndex]
|
||||
setTextInput(historyText)
|
||||
setCursorPos(historyText.length)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (key.downArrow && !inSlashMenu && !inFileMenu) {
|
||||
const historyItems = getHistoryItems()
|
||||
if (historyIndex >= 0) {
|
||||
const canNavigate = historyIndex < historyItems.length && textInput === historyItems[historyIndex]
|
||||
|
||||
if (canNavigate) {
|
||||
const newIndex = historyIndex - 1
|
||||
if (newIndex >= 0) {
|
||||
// Move to older history item
|
||||
setHistoryIndex(newIndex)
|
||||
const historyText = historyItems[newIndex]
|
||||
setTextInput(historyText)
|
||||
setCursorPos(historyText.length)
|
||||
} else {
|
||||
// Exit history mode, restore saved input
|
||||
setHistoryIndex(-1)
|
||||
setTextInput(savedInput)
|
||||
setCursorPos(savedInput.length)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle button actions (1 for primary, 2 for secondary)
|
||||
// Only when buttons are enabled, not streaming, and no text has been typed
|
||||
if (buttonConfig.enableButtons && !isSpinnerActive && textInput === "" && !yolo) {
|
||||
if (input === "1" && buttonConfig.primaryAction) {
|
||||
handleButtonAction(buttonConfig.primaryAction, true)
|
||||
return
|
||||
if (
|
||||
buttonConfig.enableButtons &&
|
||||
!isSpinnerActive &&
|
||||
textInput === "" &&
|
||||
!isYoloSuppressed(yolo, pendingAsk?.ask as ClineAsk | undefined)
|
||||
) {
|
||||
const { hasPrimary, hasSecondary } = getVisibleButtons(buttonConfig)
|
||||
|
||||
if (input === "1") {
|
||||
// "1" triggers primary if shown, otherwise secondary if it's the only button
|
||||
if (hasPrimary && buttonConfig.primaryAction) {
|
||||
handleButtonAction(buttonConfig.primaryAction, true)
|
||||
return
|
||||
} else if (hasSecondary && !hasPrimary && buttonConfig.secondaryAction) {
|
||||
handleButtonAction(buttonConfig.secondaryAction, false)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (input === "2" && buttonConfig.secondaryAction) {
|
||||
if (input === "2" && hasPrimary && hasSecondary && buttonConfig.secondaryAction) {
|
||||
// "2" only works when both buttons are shown
|
||||
handleButtonAction(buttonConfig.secondaryAction, false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Handle ask responses for options and text input
|
||||
if (pendingAsk && !yolo) {
|
||||
if (pendingAsk && !isYoloSuppressed(yolo, pendingAsk.ask as ClineAsk | undefined)) {
|
||||
// Allow sending text message for any ask type where sending is enabled
|
||||
if (key.return && textInput.trim() && !buttonConfig.sendingDisabled) {
|
||||
sendAskResponse("messageResponse", textInput.trim())
|
||||
@@ -862,6 +1124,100 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Ctrl+ shortcuts
|
||||
const keydown = input?.toLowerCase()
|
||||
if (key.ctrl && cursorPos && keydown) {
|
||||
switch (keydown) {
|
||||
case "u": // Ctrl+U, clear line before cursor
|
||||
if (cursorPos > 0) {
|
||||
setTextInput((prev) => prev.slice(cursorPos))
|
||||
setCursorPos(0)
|
||||
}
|
||||
return
|
||||
case "e": // Ctrl+E, move cursor to end
|
||||
setCursorPos(textInput.length)
|
||||
return
|
||||
case "b": // Ctrl+B, move cursor left
|
||||
setCursorPos((pos) => Math.max(0, pos - 1))
|
||||
return
|
||||
case "f": // Ctrl+F, move cursor right
|
||||
setCursorPos((pos) => Math.min(textInput.length, pos + 1))
|
||||
return
|
||||
case "d": // Ctrl+D, delete character after cursor
|
||||
if (cursorPos < textInput.length) {
|
||||
setTextInput((prev) => prev.slice(0, cursorPos) + prev.slice(cursorPos + 1))
|
||||
}
|
||||
return
|
||||
case "h": // Ctrl+H, delete character before cursor (like backspace)
|
||||
if (cursorPos > 0) {
|
||||
setTextInput((prev) => prev.slice(0, cursorPos - 1) + prev.slice(cursorPos))
|
||||
setCursorPos((pos) => pos - 1)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Detect paste by checking if input length exceeds threshold
|
||||
// Large pastes mess up the terminal UI, so we collapse them into a placeholder
|
||||
// Terminal sends large pastes in multiple chunks, so we combine chunks that arrive rapidly
|
||||
if (input && input.length > PASTE_COLLAPSE_THRESHOLD) {
|
||||
const now = Date.now()
|
||||
const timeSinceLastPaste = now - lastPasteTimeRef.current
|
||||
lastPasteTimeRef.current = now
|
||||
|
||||
// Check if this is a continuation of a recent paste (within time window)
|
||||
if (timeSinceLastPaste < PASTE_CHUNK_WINDOW_MS && activePasteNumRef.current > 0) {
|
||||
// Append to existing paste content (store immediately, don't lose data)
|
||||
const pasteNum = activePasteNumRef.current
|
||||
const chunkLines = input.match(/[\r\n]/g)?.length || 0
|
||||
activePasteLinesRef.current += chunkLines
|
||||
|
||||
setPastedTexts((prev) => {
|
||||
const next = new Map(prev)
|
||||
const existing = next.get(pasteNum) || ""
|
||||
next.set(pasteNum, existing + input)
|
||||
return next
|
||||
})
|
||||
|
||||
// Debounce the visual update to avoid flicker while chunks are arriving
|
||||
if (pasteUpdateTimeoutRef.current) {
|
||||
clearTimeout(pasteUpdateTimeoutRef.current)
|
||||
}
|
||||
pasteUpdateTimeoutRef.current = setTimeout(() => {
|
||||
const newPlaceholder = `[Pasted text #${pasteNum} +${activePasteLinesRef.current} lines]`
|
||||
setTextInput((prev) => {
|
||||
const pattern = new RegExp(`\\[Pasted text #${pasteNum} \\+\\d+ lines\\]`)
|
||||
return prev.replace(pattern, newPlaceholder)
|
||||
})
|
||||
// Update cursor to be right after the placeholder
|
||||
setCursorPos(activePasteStartPosRef.current + newPlaceholder.length)
|
||||
Logger.info(`Paste #${pasteNum} complete: ${activePasteLinesRef.current} lines`)
|
||||
}, PASTE_UPDATE_DEBOUNCE_MS)
|
||||
|
||||
return // Don't add another placeholder
|
||||
}
|
||||
|
||||
// New paste operation - create placeholder
|
||||
pasteCounterRef.current += 1
|
||||
const pasteNum = pasteCounterRef.current
|
||||
activePasteNumRef.current = pasteNum
|
||||
activePasteStartPosRef.current = cursorPos // Track where placeholder starts
|
||||
// Count line breaks in the pasted content (handle both \n and \r)
|
||||
const extraLines = input.match(/[\r\n]/g)?.length || 0
|
||||
activePasteLinesRef.current = extraLines // Track total lines
|
||||
const placeholder = `[Pasted text #${pasteNum} +${extraLines} lines]`
|
||||
// Store the full content
|
||||
setPastedTexts((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(pasteNum, input)
|
||||
return next
|
||||
})
|
||||
|
||||
setTextInput((prev) => prev.slice(0, cursorPos) + placeholder + prev.slice(cursorPos))
|
||||
setCursorPos(cursorPos + placeholder.length)
|
||||
return // Exit early - don't also add the raw input via normal handling below
|
||||
}
|
||||
|
||||
// Normal input handling
|
||||
if (key.shift && key.tab) {
|
||||
toggleYolo()
|
||||
@@ -901,6 +1257,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
setCursorPos(moveCursorDown(textInput, cursorPos))
|
||||
return
|
||||
}
|
||||
// Normal input (single char or short paste)
|
||||
if (input && !key.ctrl && !key.meta && !key.upArrow && !key.downArrow && !key.tab) {
|
||||
setTextInput((prev) => prev.slice(0, cursorPos) + input + prev.slice(cursorPos))
|
||||
setCursorPos((pos) => pos + input.length)
|
||||
@@ -909,6 +1266,22 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
|
||||
const borderColor = mode === "act" ? COLORS.primaryBlue : "yellow"
|
||||
const metrics = getApiMetrics(messages)
|
||||
|
||||
// Get last API request total tokens for context window progress
|
||||
const lastApiReqTotalTokens = useMemo(() => getLastApiReqTotalTokens(messages), [messages])
|
||||
|
||||
// Get context window size from model info
|
||||
const contextWindowSize = useMemo(() => {
|
||||
const providerData = providerModels[provider]
|
||||
if (providerData && modelId in providerData.models) {
|
||||
const modelInfo = providerData.models[modelId] as ModelInfo
|
||||
if (modelInfo?.contextWindow) {
|
||||
return modelInfo.contextWindow
|
||||
}
|
||||
}
|
||||
return DEFAULT_CONTEXT_WINDOW
|
||||
}, [provider, modelId])
|
||||
|
||||
const showSlashMenu = slashInfo.inSlashMode && !slashMenuDismissed
|
||||
const showFileMenu = mentionInfo.inMentionMode && !showSlashMenu
|
||||
|
||||
@@ -919,7 +1292,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width="100%">
|
||||
<Box flexDirection="column" key={taskSwitchKey} width="100%">
|
||||
{/* Static content - rendered once, stays above dynamic region */}
|
||||
<Static items={staticItems}>
|
||||
{(item) => {
|
||||
@@ -966,22 +1339,18 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* Action buttons for tool approvals and other asks (not during streaming) */}
|
||||
{buttonConfig.enableButtons && !isSpinnerActive && !yolo && <ActionButtons config={buttonConfig} mode={mode} />}
|
||||
{buttonConfig.enableButtons &&
|
||||
!isSpinnerActive &&
|
||||
!isYoloSuppressed(yolo, pendingAsk?.ask as ClineAsk | undefined) && (
|
||||
<ActionButtons config={buttonConfig} mode={mode} />
|
||||
)}
|
||||
|
||||
{/* Thinking indicator when processing */}
|
||||
{isSpinnerActive && <ThinkingIndicator mode={mode} onCancel={handleCancel} startTime={spinnerStartTime} />}
|
||||
|
||||
{/* Input field with border - hidden when panel is open */}
|
||||
{!activePanel && (
|
||||
{/* Input field with border - hidden when panel is open or exiting */}
|
||||
{!activePanel && !isExiting && (
|
||||
<Box
|
||||
borderColor={borderColor}
|
||||
borderStyle="round"
|
||||
@@ -991,7 +1360,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
paddingRight={1}
|
||||
width="100%">
|
||||
<Box>
|
||||
{inputPrompt && <Text color="yellow">{inputPrompt} </Text>}
|
||||
{inputPrompt && <Text color={borderColor}>{inputPrompt} </Text>}
|
||||
<HighlightedInput
|
||||
availableCommands={availableCommands.map((c) => c.name)}
|
||||
cursorPos={cursorPos}
|
||||
@@ -1010,6 +1379,15 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* History panel */}
|
||||
{activePanel?.type === "history" && ctrl && (
|
||||
<HistoryPanelContent
|
||||
controller={ctrl}
|
||||
onClose={() => setActivePanel(null)}
|
||||
onSelectTask={() => setActivePanel(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Slash command menu - below input (takes priority over file menu) */}
|
||||
{showSlashMenu && !activePanel && (
|
||||
<Box paddingLeft={1} paddingRight={1}>
|
||||
@@ -1029,6 +1407,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
query={mentionInfo.query}
|
||||
results={fileResults}
|
||||
selectedIndex={selectedIndex}
|
||||
showRipgrepWarning={showRipgrepWarning}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
@@ -1044,7 +1423,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
|
||||
{/* Footer - hidden when any menu or panel is shown */}
|
||||
{!showSlashMenu && !showFileMenu && !activePanel && (
|
||||
<>
|
||||
<Box flexDirection="column" width="100%">
|
||||
{/* 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">
|
||||
@@ -1069,18 +1448,16 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
<Box paddingLeft={1} paddingRight={1}>
|
||||
<Text>
|
||||
{modelId} {(() => {
|
||||
const bar = createContextBar(
|
||||
metrics.totalTokensIn + metrics.totalTokensOut,
|
||||
DEFAULT_CONTEXT_WINDOW,
|
||||
)
|
||||
const bar = createContextBar(lastApiReqTotalTokens, contextWindowSize)
|
||||
return (
|
||||
<Text>
|
||||
<Text color="gray">{bar.filled}</Text>
|
||||
<Text>{bar.filled}</Text>
|
||||
<Text color="gray">{bar.empty}</Text>
|
||||
</Text>
|
||||
)
|
||||
})()}({(metrics.totalTokensIn + metrics.totalTokensOut).toLocaleString()}) | $
|
||||
{metrics.totalCost.toFixed(3)}
|
||||
})()} <Text color="gray">
|
||||
({lastApiReqTotalTokens.toLocaleString()}) | ${metrics.totalCost.toFixed(3)}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -1090,7 +1467,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
{workspacePath.split("/").pop() || workspacePath}
|
||||
{gitBranch && ` (${gitBranch})`}
|
||||
{gitDiffStats && gitDiffStats.files > 0 && (
|
||||
<Text>
|
||||
<Text color="gray">
|
||||
{" "}
|
||||
| {gitDiffStats.files} file{gitDiffStats.files !== 1 ? "s" : ""}{" "}
|
||||
<Text color="green">+{gitDiffStats.additions}</Text>{" "}
|
||||
@@ -1108,8 +1485,11 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
<Text color="gray">Auto-approve all disabled (Shift+Tab)</Text>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Session summary - shown when exiting */}
|
||||
{isExiting && <SessionSummary />}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { Box, Text } from "ink"
|
||||
import React from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import type { FileSearchResult } from "../utils/file-search"
|
||||
import { type FileSearchResult, getRipgrepInstallInstructions } from "../utils/file-search"
|
||||
import { getVisibleWindow } from "../utils/slash-commands"
|
||||
|
||||
interface FileMentionMenuProps {
|
||||
@@ -14,6 +14,7 @@ interface FileMentionMenuProps {
|
||||
selectedIndex: number
|
||||
isLoading: boolean
|
||||
query: string
|
||||
showRipgrepWarning?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,11 +27,25 @@ function truncatePath(filePath: string, maxLength: number = 50): string {
|
||||
return "..." + filePath.slice(-(maxLength - 3))
|
||||
}
|
||||
|
||||
export const FileMentionMenu: React.FC<FileMentionMenuProps> = ({ results, selectedIndex, isLoading, query }) => {
|
||||
export const FileMentionMenu: React.FC<FileMentionMenuProps> = ({
|
||||
results,
|
||||
selectedIndex,
|
||||
isLoading,
|
||||
query,
|
||||
showRipgrepWarning,
|
||||
}) => {
|
||||
const ripgrepWarning = showRipgrepWarning && (
|
||||
<Box marginTop={1}>
|
||||
<Text color="yellow">ripgrep not found - file search will be slower. </Text>
|
||||
<Text color="gray">Install: {getRipgrepInstallInstructions()}</Text>
|
||||
</Box>
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} paddingLeft={1} paddingRight={1}>
|
||||
<Text color="gray">Searching files...</Text>
|
||||
{ripgrepWarning}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -39,6 +54,7 @@ export const FileMentionMenu: React.FC<FileMentionMenuProps> = ({ results, selec
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} paddingLeft={1} paddingRight={1}>
|
||||
<Text color="gray">{query ? `No files matching "${query}"` : "Type to search files..."}</Text>
|
||||
{ripgrepWarning}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -61,6 +77,7 @@ export const FileMentionMenu: React.FC<FileMentionMenuProps> = ({ results, selec
|
||||
)
|
||||
})}
|
||||
{hasMoreBelow && <Text color="gray">{" "}▼</Text>}
|
||||
{ripgrepWarning}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* History panel content for inline display in ChatView
|
||||
* Shows task history with search and keyboard navigation
|
||||
*/
|
||||
|
||||
import { StringRequest } from "@shared/proto/cline/common"
|
||||
import { GetTaskHistoryRequest } from "@shared/proto/cline/task"
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import type { Controller } from "@/core/controller"
|
||||
import { getTaskHistory } from "@/core/controller/task/getTaskHistory"
|
||||
import { showTaskWithId } from "@/core/controller/task/showTaskWithId"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { useTerminalSize } from "../hooks/useTerminalSize"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { Panel } from "./Panel"
|
||||
|
||||
interface TaskHistoryItem {
|
||||
id: string
|
||||
ts: number
|
||||
task: string
|
||||
totalCost: number
|
||||
tokensIn: number
|
||||
tokensOut: number
|
||||
isFavorited: boolean
|
||||
}
|
||||
|
||||
interface HistoryPanelContentProps {
|
||||
onClose: () => void
|
||||
onSelectTask: (taskId: string) => void
|
||||
controller: Controller
|
||||
}
|
||||
|
||||
function formatRelativeDate(ts: number): string {
|
||||
const now = Date.now()
|
||||
const diff = now - ts
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
const hours = Math.floor(diff / 3600000)
|
||||
const days = Math.floor(diff / 86400000)
|
||||
|
||||
if (minutes < 1) return "just now"
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
if (days < 7) return `${days}d ago`
|
||||
return new Date(ts).toLocaleDateString()
|
||||
}
|
||||
|
||||
function formatCost(cost: number): string {
|
||||
if (cost === 0) return ""
|
||||
return `$${cost.toFixed(2)}`
|
||||
}
|
||||
|
||||
export const HistoryPanelContent: React.FC<HistoryPanelContentProps> = ({ onClose, onSelectTask, controller }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const { rows: terminalRows } = useTerminalSize()
|
||||
const [items, setItems] = useState<TaskHistoryItem[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// Calculate how many items fit in the panel
|
||||
// Panel has border (2) + header (1) + separator (1) + search bar (1) + hint (1) = 6 lines overhead
|
||||
// Each item takes 2 lines (text + metadata)
|
||||
const panelHeight = Math.min(terminalRows - 6, 20) // Cap panel height
|
||||
const itemHeight = 2
|
||||
const maxVisible = Math.max(1, Math.floor((panelHeight - 4) / itemHeight) - 2) // 4 lines for search + hints + padding
|
||||
|
||||
// Load history
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const request = GetTaskHistoryRequest.create({
|
||||
sortBy: "newest",
|
||||
searchQuery: searchQuery || undefined,
|
||||
})
|
||||
const result = await getTaskHistory(controller, request)
|
||||
setItems(
|
||||
result.tasks.map((t) => ({
|
||||
id: t.id,
|
||||
ts: t.ts,
|
||||
task: t.task,
|
||||
totalCost: t.totalCost,
|
||||
tokensIn: t.tokensIn,
|
||||
tokensOut: t.tokensOut,
|
||||
isFavorited: t.isFavorited,
|
||||
})),
|
||||
)
|
||||
} catch {
|
||||
setItems([])
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
load()
|
||||
}, [controller, searchQuery])
|
||||
|
||||
// Reset selection when search changes
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0)
|
||||
}, [searchQuery])
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (item: TaskHistoryItem) => {
|
||||
try {
|
||||
await showTaskWithId(controller, StringRequest.create({ value: item.id }))
|
||||
onSelectTask(item.id)
|
||||
} catch (error) {
|
||||
console.error("Error opening task:", error)
|
||||
}
|
||||
},
|
||||
[controller, onSelectTask],
|
||||
)
|
||||
|
||||
// Visible window
|
||||
const scrollOffset = useMemo(() => {
|
||||
const half = Math.floor(maxVisible / 2)
|
||||
let start = Math.max(0, selectedIndex - half)
|
||||
const end = Math.min(items.length, start + maxVisible)
|
||||
if (end - start < maxVisible) {
|
||||
start = Math.max(0, end - maxVisible)
|
||||
}
|
||||
return start
|
||||
}, [selectedIndex, maxVisible, items.length])
|
||||
|
||||
const visibleItems = items.slice(scrollOffset, scrollOffset + maxVisible)
|
||||
const showUpIndicator = scrollOffset > 0
|
||||
const showDownIndicator = scrollOffset + maxVisible < items.length
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (isMouseEscapeSequence(input)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (key.escape) {
|
||||
if (searchQuery) {
|
||||
setSearchQuery("")
|
||||
} else {
|
||||
onClose()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (key.return && items[selectedIndex]) {
|
||||
handleSelect(items[selectedIndex])
|
||||
return
|
||||
}
|
||||
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((i) => Math.max(0, i - 1))
|
||||
return
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setSelectedIndex((i) => Math.min(items.length - 1, i + 1))
|
||||
return
|
||||
}
|
||||
|
||||
// Backspace for search
|
||||
if (key.backspace || key.delete) {
|
||||
setSearchQuery((q) => q.slice(0, -1))
|
||||
return
|
||||
}
|
||||
|
||||
// Printable characters for search
|
||||
if (input && !key.ctrl && !key.meta && input.length === 1 && input.charCodeAt(0) >= 32) {
|
||||
setSearchQuery((q) => q + input)
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported },
|
||||
)
|
||||
|
||||
const renderContent = () => {
|
||||
if (loading) {
|
||||
return <Text color="gray">Loading history...</Text>
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return <Text color="gray">{searchQuery ? "No tasks match your search." : "No task history."}</Text>
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="gray">{showUpIndicator ? " ▲" : " "}</Text>
|
||||
{visibleItems.map((item, idx) => {
|
||||
const actualIndex = scrollOffset + idx
|
||||
const isSelected = actualIndex === selectedIndex
|
||||
const taskText = item.task.replace(/\n/g, " ")
|
||||
const meta = [formatRelativeDate(item.ts), formatCost(item.totalCost)].filter(Boolean).join(" · ")
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" key={item.id}>
|
||||
<Box overflow="hidden">
|
||||
<Text color={isSelected ? COLORS.primaryBlue : undefined} wrap="truncate">
|
||||
{isSelected ? "❯ " : " "}
|
||||
{taskText}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color="gray">
|
||||
{" "}
|
||||
{meta}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
<Text color="gray">{showDownIndicator ? " ▼" : " "}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel label="History">
|
||||
<Box>
|
||||
<Text color="gray">Search: </Text>
|
||||
<Text color="white">{searchQuery}</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color="gray">{searchQuery ? "Esc to clear" : "Enter to open · Esc to close"}</Text>
|
||||
</Box>
|
||||
{renderContent()}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
@@ -28,6 +28,11 @@ vi.mock("@/shared/proto/cline/common", () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock useTerminalSize to prevent EventEmitter memory leak warnings from resize listeners
|
||||
vi.mock("../hooks/useTerminalSize", () => ({
|
||||
useTerminalSize: () => ({ columns: 80, rows: 24, resizeKey: 0 }),
|
||||
}))
|
||||
|
||||
// Import after mocks are set up
|
||||
import { HistoryView } from "./HistoryView"
|
||||
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
* Displays task history with keyboard navigation
|
||||
*/
|
||||
|
||||
import { Box, Text, useInput, useStdout } from "ink"
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useCallback, useState } from "react"
|
||||
import { Controller } from "@/core/controller"
|
||||
import { showTaskWithId } from "@/core/controller/task/showTaskWithId"
|
||||
import { StringRequest } from "@/shared/proto/cline/common"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { useTerminalSize } from "../hooks/useTerminalSize"
|
||||
|
||||
interface TaskHistoryItem {
|
||||
id: string
|
||||
@@ -55,12 +56,11 @@ export const HistoryView: React.FC<HistoryViewProps> = ({
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [internalPage, setInternalPage] = useState(pagination?.page ?? 1)
|
||||
const { stdout } = useStdout()
|
||||
const { rows: terminalRows } = useTerminalSize()
|
||||
|
||||
// Calculate visible count based on terminal height to prevent overflow
|
||||
// Each item takes ~5 lines (date, id, task text, cost/model, margin)
|
||||
// Reserve lines for header (title, hint, pagination, separator) and footer (separator)
|
||||
const terminalRows = stdout?.rows ?? 24
|
||||
const headerLines = (pagination?.totalPages ?? 1) > 1 ? 5 : 4
|
||||
const footerLines = 1
|
||||
const availableRows = terminalRows - headerLines - footerLines
|
||||
|
||||
@@ -6,38 +6,98 @@
|
||||
import { Box, Text } from "ink"
|
||||
import Spinner from "ink-spinner"
|
||||
import React, { useEffect, useMemo, useState } from "react"
|
||||
import { refreshOpenRouterModels } from "@/core/controller/models/refreshOpenRouterModels"
|
||||
import {
|
||||
type ApiProvider,
|
||||
anthropicDefaultModelId,
|
||||
anthropicModels,
|
||||
askSageDefaultModelId,
|
||||
askSageModels,
|
||||
basetenDefaultModelId,
|
||||
basetenModels,
|
||||
bedrockDefaultModelId,
|
||||
bedrockModels,
|
||||
cerebrasDefaultModelId,
|
||||
cerebrasModels,
|
||||
claudeCodeDefaultModelId,
|
||||
claudeCodeModels,
|
||||
deepSeekDefaultModelId,
|
||||
deepSeekModels,
|
||||
doubaoDefaultModelId,
|
||||
doubaoModels,
|
||||
fireworksDefaultModelId,
|
||||
fireworksModels,
|
||||
geminiDefaultModelId,
|
||||
geminiModels,
|
||||
groqDefaultModelId,
|
||||
groqModels,
|
||||
huaweiCloudMaasDefaultModelId,
|
||||
huaweiCloudMaasModels,
|
||||
huggingFaceDefaultModelId,
|
||||
huggingFaceModels,
|
||||
internationalQwenDefaultModelId,
|
||||
internationalQwenModels,
|
||||
internationalZAiDefaultModelId,
|
||||
internationalZAiModels,
|
||||
minimaxDefaultModelId,
|
||||
minimaxModels,
|
||||
mistralDefaultModelId,
|
||||
mistralModels,
|
||||
moonshotDefaultModelId,
|
||||
moonshotModels,
|
||||
nebiusDefaultModelId,
|
||||
nebiusModels,
|
||||
nousResearchDefaultModelId,
|
||||
nousResearchModels,
|
||||
openAiCodexDefaultModelId,
|
||||
openAiCodexModels,
|
||||
openAiNativeDefaultModelId,
|
||||
openAiNativeModels,
|
||||
qwenCodeDefaultModelId,
|
||||
qwenCodeModels,
|
||||
sambanovaDefaultModelId,
|
||||
sambanovaModels,
|
||||
sapAiCoreDefaultModelId,
|
||||
sapAiCoreModels,
|
||||
vertexDefaultModelId,
|
||||
vertexModels,
|
||||
xaiDefaultModelId,
|
||||
xaiModels,
|
||||
} from "@/shared/api"
|
||||
import { filterOpenRouterModelIds } from "@/shared/utils/model-filters"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { fetchOpenRouterModels, getOpenRouterDefaultModelId, usesOpenRouterModels } from "../utils/openrouter-models"
|
||||
import { getOpenRouterDefaultModelId, usesOpenRouterModels } from "../utils/openrouter-models"
|
||||
import { SearchableList, SearchableListItem } from "./SearchableList"
|
||||
|
||||
// Map providers to their static model lists and defaults
|
||||
export const providerModels: Record<string, { models: Record<string, unknown>; defaultId: string }> = {
|
||||
anthropic: { models: anthropicModels, defaultId: anthropicDefaultModelId },
|
||||
"openai-native": { models: openAiNativeModels, defaultId: openAiNativeDefaultModelId },
|
||||
gemini: { models: geminiModels, defaultId: geminiDefaultModelId },
|
||||
asksage: { models: askSageModels, defaultId: askSageDefaultModelId },
|
||||
baseten: { models: basetenModels, defaultId: basetenDefaultModelId },
|
||||
bedrock: { models: bedrockModels, defaultId: bedrockDefaultModelId },
|
||||
cerebras: { models: cerebrasModels, defaultId: cerebrasDefaultModelId },
|
||||
"claude-code": { models: claudeCodeModels, defaultId: claudeCodeDefaultModelId },
|
||||
deepseek: { models: deepSeekModels, defaultId: deepSeekDefaultModelId },
|
||||
mistral: { models: mistralModels, defaultId: mistralDefaultModelId },
|
||||
doubao: { models: doubaoModels, defaultId: doubaoDefaultModelId },
|
||||
fireworks: { models: fireworksModels, defaultId: fireworksDefaultModelId },
|
||||
gemini: { models: geminiModels, defaultId: geminiDefaultModelId },
|
||||
groq: { models: groqModels, defaultId: groqDefaultModelId },
|
||||
"huawei-cloud-maas": { models: huaweiCloudMaasModels, defaultId: huaweiCloudMaasDefaultModelId },
|
||||
huggingface: { models: huggingFaceModels, defaultId: huggingFaceDefaultModelId },
|
||||
minimax: { models: minimaxModels, defaultId: minimaxDefaultModelId },
|
||||
mistral: { models: mistralModels, defaultId: mistralDefaultModelId },
|
||||
moonshot: { models: moonshotModels, defaultId: moonshotDefaultModelId },
|
||||
nebius: { models: nebiusModels, defaultId: nebiusDefaultModelId },
|
||||
nousResearch: { models: nousResearchModels, defaultId: nousResearchDefaultModelId },
|
||||
"openai-codex": { models: openAiCodexModels, defaultId: openAiCodexDefaultModelId },
|
||||
"openai-native": { models: openAiNativeModels, defaultId: openAiNativeDefaultModelId },
|
||||
qwen: { models: internationalQwenModels, defaultId: internationalQwenDefaultModelId },
|
||||
"qwen-code": { models: qwenCodeModels, defaultId: qwenCodeDefaultModelId },
|
||||
sambanova: { models: sambanovaModels, defaultId: sambanovaDefaultModelId },
|
||||
sapaicore: { models: sapAiCoreModels, defaultId: sapAiCoreDefaultModelId },
|
||||
vertex: { models: vertexModels, defaultId: vertexDefaultModelId },
|
||||
xai: { models: xaiModels, defaultId: xaiDefaultModelId },
|
||||
zai: { models: internationalZAiModels, defaultId: internationalZAiDefaultModelId },
|
||||
}
|
||||
|
||||
export function hasStaticModels(provider: string): boolean {
|
||||
@@ -62,28 +122,31 @@ export function getModelList(provider: string): string[] {
|
||||
|
||||
interface ModelPickerProps {
|
||||
provider: string
|
||||
controller: any
|
||||
onChange: (modelId: string) => void
|
||||
onSubmit: (modelId: string) => void
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export const ModelPicker: React.FC<ModelPickerProps> = ({ provider, onChange, onSubmit, isActive = true }) => {
|
||||
export const ModelPicker: React.FC<ModelPickerProps> = ({ provider, controller, onChange, onSubmit, isActive = true }) => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [asyncModels, setAsyncModels] = useState<string[]>([])
|
||||
|
||||
// Fetch OpenRouter models when needed
|
||||
// Fetch OpenRouter models when needed using shared core function
|
||||
useEffect(() => {
|
||||
if (usesOpenRouterModels(provider)) {
|
||||
setIsLoading(true)
|
||||
fetchOpenRouterModels()
|
||||
refreshOpenRouterModels(controller)
|
||||
.then((models) => {
|
||||
setAsyncModels(models)
|
||||
const modelIds = Object.keys(models).sort((a, b) => a.localeCompare(b))
|
||||
const filtered = filterOpenRouterModelIds(modelIds, provider as ApiProvider)
|
||||
setAsyncModels(filtered)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
}
|
||||
}, [provider])
|
||||
}, [provider, controller])
|
||||
|
||||
const modelList = useMemo(() => {
|
||||
if (usesOpenRouterModels(provider)) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Organization picker component for switching between personal account and organizations
|
||||
*/
|
||||
|
||||
import React, { useMemo } from "react"
|
||||
import type { ClineAccountOrganization } from "@/services/auth/AuthService"
|
||||
import { SelectList, SelectListItem } from "./SelectList"
|
||||
|
||||
interface OrganizationPickerProps {
|
||||
organizations: ClineAccountOrganization[]
|
||||
onSelect: (orgId: string | null) => void // null = personal account
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the primary role for display (prioritize owner > admin > member)
|
||||
*/
|
||||
function getPrimaryRole(roles: string[]): string {
|
||||
if (roles.includes("owner")) return "Owner"
|
||||
if (roles.includes("admin")) return "Admin"
|
||||
if (roles.includes("member")) return "Member"
|
||||
return roles[0] || ""
|
||||
}
|
||||
|
||||
export const OrganizationPicker: React.FC<OrganizationPickerProps> = ({ organizations, onSelect, isActive = true }) => {
|
||||
const items: SelectListItem[] = useMemo(() => {
|
||||
const result: SelectListItem[] = [
|
||||
{
|
||||
id: "personal",
|
||||
label: "Personal",
|
||||
},
|
||||
]
|
||||
|
||||
for (const org of organizations) {
|
||||
const role = getPrimaryRole(org.roles)
|
||||
result.push({
|
||||
id: org.organizationId,
|
||||
label: org.name,
|
||||
suffix: role ? `(${role})` : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}, [organizations])
|
||||
|
||||
return <SelectList isActive={isActive} items={items} onSelect={(item) => onSelect(item.id === "personal" ? null : item.id)} />
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
import { Box, Text } from "ink"
|
||||
import React, { ReactNode } from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useTerminalSize } from "../hooks/useTerminalSize"
|
||||
|
||||
export interface PanelTab {
|
||||
key: string
|
||||
@@ -24,6 +25,7 @@ interface PanelProps {
|
||||
}
|
||||
|
||||
export const Panel: React.FC<PanelProps> = ({ label, tabs, currentTab, children }) => {
|
||||
const { columns } = useTerminalSize()
|
||||
const currentTabIndex = currentTab && tabs ? tabs.findIndex((t) => t.key === currentTab) : 0
|
||||
|
||||
return (
|
||||
@@ -58,7 +60,7 @@ export const Panel: React.FC<PanelProps> = ({ label, tabs, currentTab, children
|
||||
{/* Separator line */}
|
||||
<Box>
|
||||
<Text bold color={COLORS.primaryBlue}>
|
||||
{"─".repeat(process.stdout.columns - 2)}
|
||||
{"─".repeat(Math.max(columns - 2, 0))}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
*/
|
||||
|
||||
import React, { useMemo } from "react"
|
||||
import { API_PROVIDERS_LIST } from "@/shared/api"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { API_PROVIDERS_LIST, ApiConfiguration } from "@/shared/api"
|
||||
import providersData from "@/shared/providers/providers.json"
|
||||
import { SearchableList, SearchableListItem } from "./SearchableList"
|
||||
|
||||
@@ -23,13 +24,119 @@ export function getProviderOrder(): string[] {
|
||||
return providerOrder
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a provider is configured (has required credentials/settings)
|
||||
* Based on webview's getConfiguredProviders logic
|
||||
*/
|
||||
function isProviderConfigured(providerId: string, config: ApiConfiguration): boolean {
|
||||
switch (providerId) {
|
||||
case "cline":
|
||||
return true // Always available
|
||||
case "anthropic":
|
||||
return !!config.apiKey
|
||||
case "openrouter":
|
||||
return !!config.openRouterApiKey
|
||||
case "bedrock":
|
||||
return !!config.awsRegion
|
||||
case "vertex":
|
||||
return !!(config.vertexProjectId && config.vertexRegion)
|
||||
case "gemini":
|
||||
return !!config.geminiApiKey
|
||||
case "openai-native":
|
||||
return !!config.openAiNativeApiKey
|
||||
case "openai-codex":
|
||||
return !!config.openAiCodexRefreshToken
|
||||
case "deepseek":
|
||||
return !!config.deepSeekApiKey
|
||||
case "xai":
|
||||
return !!config.xaiApiKey
|
||||
case "qwen":
|
||||
case "qwen-code":
|
||||
return !!config.qwenApiKey
|
||||
case "doubao":
|
||||
return !!config.doubaoApiKey
|
||||
case "mistral":
|
||||
return !!config.mistralApiKey
|
||||
case "requesty":
|
||||
return !!config.requestyApiKey
|
||||
case "fireworks":
|
||||
return !!config.fireworksApiKey
|
||||
case "together":
|
||||
return !!config.togetherApiKey
|
||||
case "moonshot":
|
||||
return !!config.moonshotApiKey
|
||||
case "nebius":
|
||||
return !!config.nebiusApiKey
|
||||
case "asksage":
|
||||
return !!config.asksageApiKey
|
||||
case "sambanova":
|
||||
return !!config.sambanovaApiKey
|
||||
case "cerebras":
|
||||
return !!config.cerebrasApiKey
|
||||
case "sapaicore":
|
||||
return !!(
|
||||
config.sapAiCoreBaseUrl &&
|
||||
config.sapAiCoreClientId &&
|
||||
config.sapAiCoreClientSecret &&
|
||||
config.sapAiCoreTokenUrl
|
||||
)
|
||||
case "zai":
|
||||
return !!config.zaiApiKey
|
||||
case "groq":
|
||||
return !!config.groqApiKey
|
||||
case "huggingface":
|
||||
return !!config.huggingFaceApiKey
|
||||
case "baseten":
|
||||
return !!config.basetenApiKey
|
||||
case "dify":
|
||||
return !!(config.difyBaseUrl && config.difyApiKey)
|
||||
case "minimax":
|
||||
return !!config.minimaxApiKey
|
||||
case "hicap":
|
||||
return !!config.hicapApiKey
|
||||
case "huawei-cloud-maas":
|
||||
return !!config.huaweiCloudMaasApiKey
|
||||
case "vercel-ai-gateway":
|
||||
return !!config.vercelAiGatewayApiKey
|
||||
case "aihubmix":
|
||||
return !!config.aihubmixApiKey
|
||||
case "nousResearch":
|
||||
return !!config.nousResearchApiKey
|
||||
case "openai":
|
||||
return !!(
|
||||
(config.openAiBaseUrl && config.openAiApiKey) ||
|
||||
config.planModeOpenAiModelId ||
|
||||
config.actModeOpenAiModelId
|
||||
)
|
||||
case "ollama":
|
||||
return !!(config.ollamaBaseUrl || config.planModeOllamaModelId || config.actModeOllamaModelId)
|
||||
case "lmstudio":
|
||||
return !!(config.lmStudioBaseUrl || config.planModeLmStudioModelId || config.actModeLmStudioModelId)
|
||||
case "litellm":
|
||||
return !!(
|
||||
config.liteLlmBaseUrl ||
|
||||
config.liteLlmApiKey ||
|
||||
config.planModeLiteLlmModelId ||
|
||||
config.actModeLiteLlmModelId
|
||||
)
|
||||
case "claude-code":
|
||||
return !!config.claudeCodePath
|
||||
case "oca":
|
||||
return !!config.ocaBaseUrl
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
interface ProviderPickerProps {
|
||||
onSelect: (providerId: string) => void
|
||||
isActive?: boolean
|
||||
configuredProviders?: Set<string>
|
||||
}
|
||||
|
||||
export const ProviderPicker: React.FC<ProviderPickerProps> = ({ onSelect, isActive = true, configuredProviders = new Set() }) => {
|
||||
export const ProviderPicker: React.FC<ProviderPickerProps> = ({ onSelect, isActive = true }) => {
|
||||
// Get API configuration to check which providers are configured
|
||||
const apiConfig = StateManager.get().getApiConfiguration()
|
||||
|
||||
// Use providers.json order, filtered to only available providers
|
||||
const items: SearchableListItem[] = useMemo(() => {
|
||||
const availableProviders = new Set(API_PROVIDERS_LIST)
|
||||
@@ -38,9 +145,9 @@ export const ProviderPicker: React.FC<ProviderPickerProps> = ({ onSelect, isActi
|
||||
return sorted.map((providerId) => ({
|
||||
id: providerId,
|
||||
label: getProviderLabel(providerId),
|
||||
suffix: configuredProviders.has(providerId) ? "(configured)" : undefined,
|
||||
suffix: isProviderConfigured(providerId, apiConfig) ? "(Configured)" : undefined,
|
||||
}))
|
||||
}, [configuredProviders])
|
||||
}, [apiConfig])
|
||||
|
||||
return <SearchableList isActive={isActive} items={items} onSelect={(item) => onSelect(item.id)} />
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import React, { useEffect, useMemo, useState } from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { useScrollableList } from "../hooks/useScrollableList"
|
||||
import { fuzzyFilter } from "../utils/fuzzy-search"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
|
||||
export interface SearchableListItem {
|
||||
@@ -38,17 +39,14 @@ export function SearchableList<T extends SearchableListItem>({
|
||||
const [search, setSearch] = useState("")
|
||||
const [index, setIndex] = useState(0)
|
||||
|
||||
// Default filter: search in id and label
|
||||
const defaultFilter = (item: T, searchStr: string) => {
|
||||
const searchLower = searchStr.toLowerCase()
|
||||
return item.id.toLowerCase().includes(searchLower) || item.label.toLowerCase().includes(searchLower)
|
||||
}
|
||||
|
||||
// Filter items by search
|
||||
// Filter items by search using fuzzy matching
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!search) return items
|
||||
const filter = filterFn || defaultFilter
|
||||
return items.filter((item) => filter(item, search))
|
||||
// Use custom filter if provided, otherwise use fuzzy search
|
||||
if (filterFn) {
|
||||
return items.filter((item) => filterFn(item, search))
|
||||
}
|
||||
return fuzzyFilter(items, search, (item) => `${item.label} ${item.id}`)
|
||||
}, [items, search, filterFn])
|
||||
|
||||
// Use shared scrollable list hook for windowing
|
||||
@@ -78,7 +76,7 @@ export function SearchableList<T extends SearchableListItem>({
|
||||
setIndex((prev) => Math.max(0, prev - 1))
|
||||
} else if (key.downArrow) {
|
||||
setIndex((prev) => Math.min(filteredItems.length - 1, prev + 1))
|
||||
} else if (key.return) {
|
||||
} else if (key.return || key.tab) {
|
||||
if (filteredItems[index]) {
|
||||
onSelect(filteredItems[index])
|
||||
}
|
||||
@@ -106,7 +104,7 @@ export function SearchableList<T extends SearchableListItem>({
|
||||
return (
|
||||
<Box key={item.id}>
|
||||
<Text color={isSelected ? COLORS.primaryBlue : undefined}>
|
||||
{isSelected ? "> " : " "}
|
||||
{isSelected ? "❯ " : " "}
|
||||
{item.label}
|
||||
{item.suffix && <Text color="gray"> {item.suffix}</Text>}
|
||||
</Text>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Simple select list component - arrow keys to navigate, Enter to select
|
||||
* No search functionality, just a straightforward list picker
|
||||
*/
|
||||
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import { useState } from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
|
||||
export interface SelectListItem {
|
||||
id: string
|
||||
label: string
|
||||
suffix?: string
|
||||
}
|
||||
|
||||
interface SelectListProps<T extends SelectListItem> {
|
||||
items: T[]
|
||||
onSelect: (item: T) => void
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export function SelectList<T extends SelectListItem>({ items, onSelect, isActive = true }: SelectListProps<T>) {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((i) => (i > 0 ? i - 1 : items.length - 1))
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex((i) => (i < items.length - 1 ? i + 1 : 0))
|
||||
} else if (key.return) {
|
||||
const item = items[selectedIndex]
|
||||
if (item) {
|
||||
onSelect(item)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: isActive && isRawModeSupported },
|
||||
)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{items.map((item, idx) => {
|
||||
const isSelected = idx === selectedIndex
|
||||
return (
|
||||
<Box key={item.id}>
|
||||
<Text color={isSelected ? COLORS.primaryBlue : undefined}>
|
||||
{isSelected ? "❯ " : " "}
|
||||
{item.label}
|
||||
{item.suffix && <Text color="gray"> {item.suffix}</Text>}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { Box, Text } from "ink"
|
||||
import React from "react"
|
||||
import { Session } from "@/shared/services/Session"
|
||||
|
||||
/**
|
||||
* Format milliseconds to a human-readable duration string
|
||||
*/
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) {
|
||||
return `${ms}ms`
|
||||
}
|
||||
const seconds = ms / 1000
|
||||
if (seconds < 60) {
|
||||
return `${seconds.toFixed(1)}s`
|
||||
}
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = seconds % 60
|
||||
return `${minutes}m ${remainingSeconds.toFixed(0)}s`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a percentage value
|
||||
*/
|
||||
function formatPercent(value: number, total: number): string {
|
||||
if (total === 0) return "0.0%"
|
||||
return `${((value / total) * 100).toFixed(1)}%`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to a human-readable string (KB, MB, GB)
|
||||
*/
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) {
|
||||
return `${bytes}B`
|
||||
}
|
||||
const kb = bytes / 1024
|
||||
if (kb < 1024) {
|
||||
return `${kb.toFixed(1)}KB`
|
||||
}
|
||||
const mb = kb / 1024
|
||||
if (mb < 1024) {
|
||||
return `${mb.toFixed(1)}MB`
|
||||
}
|
||||
const gb = mb / 1024
|
||||
return `${gb.toFixed(2)}GB`
|
||||
}
|
||||
|
||||
interface SessionSummaryProps {
|
||||
/** Optional width constraint */
|
||||
width?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays session statistics when the CLI exits.
|
||||
* Shows tool call counts, success rate, and timing breakdown.
|
||||
*/
|
||||
export const SessionSummary: React.FC<SessionSummaryProps> = ({ width }) => {
|
||||
const session = Session.get()
|
||||
const stats = session.getStats()
|
||||
const wallTimeMs = session.getWallTimeMs()
|
||||
const agentActiveMs = session.getAgentActiveTimeMs()
|
||||
|
||||
// Don't show if session just started (less than 1 second)
|
||||
if (wallTimeMs < 1000) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Box borderColor="gray" borderStyle="single" flexDirection="column" paddingX={1} width={width}>
|
||||
{/* Header */}
|
||||
<Box marginBottom={1}>
|
||||
<Text bold>Interaction Summary</Text>
|
||||
</Box>
|
||||
|
||||
{/* Session ID */}
|
||||
<Box>
|
||||
<Box width={20}>
|
||||
<Text color="gray">Session ID:</Text>
|
||||
</Box>
|
||||
<Text>{stats.sessionId}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Session Time */}
|
||||
<Box>
|
||||
<Box width={20}>
|
||||
<Text color="gray">Session Time:</Text>
|
||||
</Box>
|
||||
<Text>
|
||||
{session.formatTime(session.getStartTime())} → {session.formatTime(session.getEndTime())}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Tool Calls */}
|
||||
<Box>
|
||||
<Box width={20}>
|
||||
<Text color="gray">Tool Calls:</Text>
|
||||
</Box>
|
||||
<Text>{stats.totalToolCalls}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Performance Header */}
|
||||
<Box marginBottom={0}>
|
||||
<Text bold>Performance</Text>
|
||||
</Box>
|
||||
|
||||
{/* Wall Time */}
|
||||
<Box>
|
||||
<Box width={20}>
|
||||
<Text color="gray">Wall Time:</Text>
|
||||
</Box>
|
||||
<Text>{formatDuration(wallTimeMs)}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Agent Active */}
|
||||
<Box>
|
||||
<Box width={20}>
|
||||
<Text color="gray">Agent Active:</Text>
|
||||
</Box>
|
||||
<Text>{formatDuration(agentActiveMs)}</Text>
|
||||
</Box>
|
||||
|
||||
{/* API Time */}
|
||||
<Box>
|
||||
<Box width={20}>
|
||||
<Text color="gray"> » API Time:</Text>
|
||||
</Box>
|
||||
<Text>
|
||||
{formatDuration(stats.apiTimeMs)} <Text color="gray">({formatPercent(stats.apiTimeMs, agentActiveMs)})</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Tool Time */}
|
||||
<Box marginBottom={1}>
|
||||
<Box width={20}>
|
||||
<Text color="gray"> » Tool Time:</Text>
|
||||
</Box>
|
||||
<Text>
|
||||
{formatDuration(stats.toolTimeMs)}{" "}
|
||||
<Text color="gray">({formatPercent(stats.toolTimeMs, agentActiveMs)})</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Resources Header */}
|
||||
<Box marginBottom={0}>
|
||||
<Text bold>Resources</Text>
|
||||
</Box>
|
||||
|
||||
{/* Memory Usage */}
|
||||
<Box>
|
||||
<Box width={20}>
|
||||
<Text color="gray">Memory (RSS):</Text>
|
||||
</Box>
|
||||
<Text>{formatBytes(stats.resources.rss)}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Peak Memory */}
|
||||
<Box>
|
||||
<Box width={20}>
|
||||
<Text color="gray">Peak Memory:</Text>
|
||||
</Box>
|
||||
<Text>{formatBytes(stats.peakMemoryBytes)}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Heap Usage */}
|
||||
<Box>
|
||||
<Box width={20}>
|
||||
<Text color="gray">Heap Used:</Text>
|
||||
</Box>
|
||||
<Text>
|
||||
{formatBytes(stats.resources.heapUsed)} <Text color="gray">/ {formatBytes(stats.resources.heapTotal)}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* CPU Time */}
|
||||
<Box>
|
||||
<Box width={20}>
|
||||
<Text color="gray">CPU Time:</Text>
|
||||
</Box>
|
||||
<Text>
|
||||
{formatDuration(stats.resources.userCpuMs + stats.resources.systemCpuMs)}{" "}
|
||||
<Text color="gray">
|
||||
(user: {formatDuration(stats.resources.userCpuMs)}, sys: {formatDuration(stats.resources.systemCpuMs)})
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -10,19 +10,22 @@ import type { TelemetrySetting } from "@shared/TelemetrySetting"
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import Spinner from "ink-spinner"
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { buildApiHandler } from "@/core/api"
|
||||
import type { Controller } from "@/core/controller"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
|
||||
import { openAiCodexDefaultModelId } from "@/shared/api"
|
||||
import { ClineAccountService } from "@/services/account/ClineAccountService"
|
||||
import { AuthService, ClineAccountOrganization } from "@/services/auth/AuthService"
|
||||
import { openExternal } from "@/utils/env"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { applyProviderConfig } from "../utils/provider-config"
|
||||
import { ApiKeyInput } from "./ApiKeyInput"
|
||||
import { type BedrockConfig, BedrockSetup } from "./BedrockSetup"
|
||||
import { Checkbox } from "./Checkbox"
|
||||
import { LanguagePicker } from "./LanguagePicker"
|
||||
import { getDefaultModelId, hasModelPicker, ModelPicker } from "./ModelPicker"
|
||||
import { OrganizationPicker } from "./OrganizationPicker"
|
||||
import { Panel, PanelTab } from "./Panel"
|
||||
import { getProviderLabel, ProviderPicker } from "./ProviderPicker"
|
||||
|
||||
@@ -32,12 +35,12 @@ interface SettingsPanelContentProps {
|
||||
initialMode?: "model-picker"
|
||||
}
|
||||
|
||||
type SettingsTab = "api" | "auto-approve" | "features" | "other"
|
||||
type SettingsTab = "api" | "auto-approve" | "features" | "other" | "account"
|
||||
|
||||
interface ListItem {
|
||||
key: string
|
||||
label: string
|
||||
type: "checkbox" | "readonly" | "editable" | "separator" | "header" | "spacer"
|
||||
type: "checkbox" | "readonly" | "editable" | "separator" | "header" | "spacer" | "action"
|
||||
value: string | boolean
|
||||
description?: string
|
||||
isSubItem?: boolean
|
||||
@@ -48,6 +51,7 @@ const TABS: PanelTab[] = [
|
||||
{ key: "api", label: "API" },
|
||||
{ key: "auto-approve", label: "Auto-approve" },
|
||||
{ key: "features", label: "Features" },
|
||||
{ key: "account", label: "Account" },
|
||||
{ key: "other", label: "Other" },
|
||||
]
|
||||
|
||||
@@ -99,6 +103,16 @@ const FEATURE_SETTINGS = {
|
||||
|
||||
type FeatureKey = keyof typeof FEATURE_SETTINGS
|
||||
|
||||
/**
|
||||
* Format balance as currency (balance is in microcredits, divide by 1000000)
|
||||
*/
|
||||
function formatBalance(balance: number | null): string {
|
||||
if (balance === null || balance === undefined) {
|
||||
return "..."
|
||||
}
|
||||
return `$${(balance / 1000000).toFixed(2)}`
|
||||
}
|
||||
|
||||
export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onClose, controller, initialMode }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const stateManager = StateManager.get()
|
||||
@@ -114,6 +128,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
|
||||
const [isPickingProvider, setIsPickingProvider] = useState(false)
|
||||
const [isPickingLanguage, setIsPickingLanguage] = useState(false)
|
||||
const [isEnteringApiKey, setIsEnteringApiKey] = useState(false)
|
||||
const [isConfiguringBedrock, setIsConfiguringBedrock] = useState(false)
|
||||
const [isWaitingForCodexAuth, setIsWaitingForCodexAuth] = useState(false)
|
||||
const [codexAuthError, setCodexAuthError] = useState<string | null>(null)
|
||||
const [pendingProvider, setPendingProvider] = useState<string | null>(null)
|
||||
@@ -154,6 +169,16 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
|
||||
() => stateManager.getGlobalSettingsKey("telemetrySetting") || "unset",
|
||||
)
|
||||
|
||||
// Account tab state
|
||||
const [accountEmail, setAccountEmail] = useState<string | null>(null)
|
||||
const [accountBalance, setAccountBalance] = useState<number | null>(null)
|
||||
const [accountOrganization, setAccountOrganization] = useState<ClineAccountOrganization | null>(null)
|
||||
const [accountOrganizations, setAccountOrganizations] = useState<ClineAccountOrganization[] | null>(null)
|
||||
const [isAccountLoading, setIsAccountLoading] = useState(false)
|
||||
const [isPickingOrganization, setIsPickingOrganization] = useState(false)
|
||||
const [isWaitingForClineAuth, setIsWaitingForClineAuth] = useState(false)
|
||||
const [accountChecked, setAccountChecked] = useState(false) // Tracks if we've already checked auth
|
||||
|
||||
// Get current provider and model info
|
||||
const apiConfig = stateManager.getApiConfiguration()
|
||||
const [provider, setProvider] = useState<string>(
|
||||
@@ -173,12 +198,171 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
|
||||
[features, stateManager],
|
||||
)
|
||||
|
||||
// Fetch account info (reused pattern from AccountInfoView.tsx)
|
||||
const fetchAccountInfo = useCallback(async () => {
|
||||
if (!controller) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
setIsAccountLoading(true)
|
||||
|
||||
const authService = AuthService.getInstance(controller)
|
||||
|
||||
// Wait for auth to be restored
|
||||
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) {
|
||||
setAccountEmail(authInfo.user.email)
|
||||
} else {
|
||||
setAccountEmail(null)
|
||||
setIsAccountLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Get organization info
|
||||
const organizations = authService.getUserOrganizations()
|
||||
if (organizations) {
|
||||
setAccountOrganizations(organizations)
|
||||
const activeOrg = organizations.find((org) => org.active)
|
||||
setAccountOrganization(activeOrg || null)
|
||||
}
|
||||
|
||||
// Fetch credit balance
|
||||
try {
|
||||
const accountService = ClineAccountService.getInstance()
|
||||
const activeOrgId = authService.getActiveOrganizationId()
|
||||
|
||||
if (activeOrgId) {
|
||||
const orgBalance = await accountService.fetchOrganizationCreditsRPC(activeOrgId)
|
||||
if (orgBalance?.balance !== undefined) {
|
||||
setAccountBalance(orgBalance.balance)
|
||||
}
|
||||
} else {
|
||||
const balanceData = await accountService.fetchBalanceRPC()
|
||||
if (balanceData?.balance !== undefined) {
|
||||
setAccountBalance(balanceData.balance)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Balance fetch failed, but we can still show other info
|
||||
}
|
||||
} catch {
|
||||
// Error fetching account info
|
||||
} finally {
|
||||
setIsAccountLoading(false)
|
||||
setAccountChecked(true)
|
||||
}
|
||||
}, [controller])
|
||||
|
||||
// Handle Cline login - starts OAuth flow
|
||||
const handleClineLogin = useCallback(() => {
|
||||
if (!controller) {
|
||||
return
|
||||
}
|
||||
// Set waiting state first (synchronously) to show the waiting UI immediately
|
||||
setIsWaitingForClineAuth(true)
|
||||
// Then start the auth request (async, but we don't need to await)
|
||||
AuthService.getInstance(controller)
|
||||
.createAuthRequest()
|
||||
.catch(() => {
|
||||
setIsWaitingForClineAuth(false)
|
||||
})
|
||||
}, [controller])
|
||||
|
||||
// Handle Cline logout
|
||||
const handleClineLogout = useCallback(async () => {
|
||||
if (!controller) {
|
||||
return
|
||||
}
|
||||
await AuthService.getInstance(controller).handleDeauth()
|
||||
setAccountEmail(null)
|
||||
setAccountBalance(null)
|
||||
setAccountOrganization(null)
|
||||
setAccountOrganizations(null)
|
||||
setAccountChecked(true) // Mark as checked so we don't re-fetch
|
||||
}, [controller])
|
||||
|
||||
// Handle organization selection
|
||||
const handleOrganizationSelect = useCallback(
|
||||
async (orgId: string | null) => {
|
||||
if (!controller) {
|
||||
return
|
||||
}
|
||||
setIsPickingOrganization(false)
|
||||
try {
|
||||
await ClineAccountService.getInstance().switchAccount(orgId || undefined)
|
||||
// Refetch to get updated auth info with new active org
|
||||
await AuthService.getInstance(controller).restoreRefreshTokenAndRetrieveAuthInfo()
|
||||
fetchAccountInfo()
|
||||
} catch {
|
||||
// Error switching organization
|
||||
}
|
||||
},
|
||||
[controller, fetchAccountInfo],
|
||||
)
|
||||
|
||||
// Fetch account info when switching to account tab (only if not already checked)
|
||||
useEffect(() => {
|
||||
if (currentTab === "account" && !accountEmail && !isAccountLoading && !accountChecked && controller) {
|
||||
fetchAccountInfo()
|
||||
}
|
||||
}, [currentTab, accountEmail, isAccountLoading, accountChecked, controller, fetchAccountInfo])
|
||||
|
||||
// Subscribe to auth status updates when waiting for Cline auth
|
||||
useEffect(() => {
|
||||
if (!isWaitingForClineAuth || !controller) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
const authService = AuthService.getInstance(controller)
|
||||
|
||||
const responseHandler = async (authState: { user?: { email?: string } }) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
if (authState.user?.email) {
|
||||
setIsWaitingForClineAuth(false)
|
||||
setAccountChecked(false) // Reset so fetchAccountInfo can run
|
||||
await applyProviderConfig({ providerId: "cline", controller })
|
||||
setProvider("cline")
|
||||
fetchAccountInfo()
|
||||
}
|
||||
}
|
||||
|
||||
authService.subscribeToAuthStatusUpdate(controller, {}, responseHandler, `settings-auth-${Date.now()}`)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [isWaitingForClineAuth, controller, fetchAccountInfo])
|
||||
|
||||
// Build items list based on current tab
|
||||
const items: ListItem[] = useMemo(() => {
|
||||
// OpenAI Native, Codex, and GPT models don't support thinking budget (they use reasoning effort)
|
||||
const isGptModel = actModelId?.toLowerCase().includes("gpt") || planModelId?.toLowerCase().includes("gpt")
|
||||
const showThinkingOption = provider !== "openai-native" && provider !== "openai-codex" && !isGptModel
|
||||
|
||||
switch (currentTab) {
|
||||
case "api":
|
||||
return [
|
||||
{ key: "provider", label: "Provider", type: "editable", value: provider || "not configured" },
|
||||
{
|
||||
key: "provider",
|
||||
label: "Provider",
|
||||
type: "editable",
|
||||
value: provider ? getProviderLabel(provider) : "not configured",
|
||||
},
|
||||
...(provider === "cline"
|
||||
? [{ key: "viewAccount", label: "View account", type: "action" as const, value: "" }]
|
||||
: []),
|
||||
...(separateModels
|
||||
? [
|
||||
{ key: "spacer0", label: "", type: "spacer" as const, value: "" },
|
||||
@@ -189,12 +373,16 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
|
||||
type: "editable" as const,
|
||||
value: actModelId || "not set",
|
||||
},
|
||||
{
|
||||
key: "actThinkingEnabled",
|
||||
label: "Enable thinking",
|
||||
type: "checkbox" as const,
|
||||
value: actThinkingEnabled,
|
||||
},
|
||||
...(showThinkingOption
|
||||
? [
|
||||
{
|
||||
key: "actThinkingEnabled",
|
||||
label: "Enable thinking",
|
||||
type: "checkbox" as const,
|
||||
value: actThinkingEnabled,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ key: "planHeader", label: "Plan Mode", type: "header" as const, value: "" },
|
||||
{
|
||||
key: "planModelId",
|
||||
@@ -202,12 +390,16 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
|
||||
type: "editable" as const,
|
||||
value: planModelId || "not set",
|
||||
},
|
||||
{
|
||||
key: "planThinkingEnabled",
|
||||
label: "Enable thinking",
|
||||
type: "checkbox" as const,
|
||||
value: planThinkingEnabled,
|
||||
},
|
||||
...(showThinkingOption
|
||||
? [
|
||||
{
|
||||
key: "planThinkingEnabled",
|
||||
label: "Enable thinking",
|
||||
type: "checkbox" as const,
|
||||
value: planThinkingEnabled,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ key: "spacer1", label: "", type: "spacer" as const, value: "" },
|
||||
]
|
||||
: [
|
||||
@@ -217,12 +409,16 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
|
||||
type: "editable" as const,
|
||||
value: actModelId || "not set",
|
||||
},
|
||||
{
|
||||
key: "actThinkingEnabled",
|
||||
label: "Enable thinking",
|
||||
type: "checkbox" as const,
|
||||
value: actThinkingEnabled,
|
||||
},
|
||||
...(showThinkingOption
|
||||
? [
|
||||
{
|
||||
key: "actThinkingEnabled",
|
||||
label: "Enable thinking",
|
||||
type: "checkbox" as const,
|
||||
value: actThinkingEnabled,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]),
|
||||
{
|
||||
key: "separateModels",
|
||||
@@ -338,6 +534,40 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
|
||||
},
|
||||
]
|
||||
|
||||
case "account":
|
||||
// If loading, return empty (loading spinner shown in render)
|
||||
if (isAccountLoading) {
|
||||
return []
|
||||
}
|
||||
// If not logged in, show login option
|
||||
if (!accountEmail) {
|
||||
return [{ key: "login", label: "Sign in with Cline", type: "action", value: "" }]
|
||||
}
|
||||
// Logged in - show account info
|
||||
const accountItems: ListItem[] = [
|
||||
{ key: "email", label: "Email", type: "readonly", value: accountEmail },
|
||||
{ key: "balance", label: "Credits", type: "readonly", value: formatBalance(accountBalance) },
|
||||
]
|
||||
// Organization selector - only show if user has organizations
|
||||
if (accountOrganizations && accountOrganizations.length > 0) {
|
||||
accountItems.push({
|
||||
key: "organization",
|
||||
label: "Organization",
|
||||
type: "editable",
|
||||
value: accountOrganization ? accountOrganization.name : "Personal",
|
||||
})
|
||||
} else {
|
||||
accountItems.push({
|
||||
key: "organization",
|
||||
label: "Account",
|
||||
type: "readonly",
|
||||
value: "Personal",
|
||||
})
|
||||
}
|
||||
accountItems.push({ key: "separator", label: "", type: "separator", value: "" })
|
||||
accountItems.push({ key: "logout", label: "Sign out", type: "action", value: "" })
|
||||
return accountItems
|
||||
|
||||
default:
|
||||
return []
|
||||
}
|
||||
@@ -353,6 +583,11 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
|
||||
features,
|
||||
preferredLanguage,
|
||||
telemetry,
|
||||
isAccountLoading,
|
||||
accountEmail,
|
||||
accountBalance,
|
||||
accountOrganization,
|
||||
accountOrganizations,
|
||||
])
|
||||
|
||||
// Reset selection when changing tabs
|
||||
@@ -367,6 +602,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
|
||||
setIsEnteringApiKey(false)
|
||||
setPendingProvider(null)
|
||||
setApiKeyValue("")
|
||||
setIsPickingOrganization(false)
|
||||
}, [])
|
||||
|
||||
// Ensure selected index is valid when items change
|
||||
@@ -382,6 +618,23 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
|
||||
if (!item || item.type === "readonly" || item.type === "separator" || item.type === "header" || item.type === "spacer")
|
||||
return
|
||||
|
||||
if (item.type === "action") {
|
||||
// Action items trigger their handler directly
|
||||
if (item.key === "login") {
|
||||
handleClineLogin()
|
||||
return
|
||||
}
|
||||
if (item.key === "logout") {
|
||||
handleClineLogout()
|
||||
return
|
||||
}
|
||||
if (item.key === "viewAccount") {
|
||||
handleTabChange("account")
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (item.type === "editable") {
|
||||
// For provider field, use the provider picker
|
||||
if (item.key === "provider") {
|
||||
@@ -399,6 +652,11 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
|
||||
setIsPickingLanguage(true)
|
||||
return
|
||||
}
|
||||
// For organization field, use the organization picker
|
||||
if (item.key === "organization" && accountOrganizations && accountOrganizations.length > 0) {
|
||||
setIsPickingOrganization(true)
|
||||
return
|
||||
}
|
||||
setEditValue(typeof item.value === "string" ? item.value : "")
|
||||
setIsEditing(true)
|
||||
return
|
||||
@@ -488,7 +746,16 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
|
||||
const newSettings = { ...autoApproveSettings, version: (autoApproveSettings.version ?? 1) + 1, actions: newActions }
|
||||
setAutoApproveSettings(newSettings)
|
||||
stateManager.setGlobalState("autoApprovalSettings", newSettings)
|
||||
}, [items, selectedIndex, stateManager, autoApproveSettings, toggleFeature])
|
||||
}, [
|
||||
items,
|
||||
selectedIndex,
|
||||
stateManager,
|
||||
autoApproveSettings,
|
||||
toggleFeature,
|
||||
handleClineLogin,
|
||||
handleClineLogout,
|
||||
accountOrganizations,
|
||||
])
|
||||
|
||||
// Handle model selection from picker
|
||||
const handleModelSelect = useCallback(
|
||||
@@ -534,23 +801,8 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
|
||||
// Wait for the callback
|
||||
await openAiCodexOAuthManager.waitForCallback()
|
||||
|
||||
// Success - save configuration
|
||||
const config: Record<string, string> = {
|
||||
actModeApiProvider: "openai-codex",
|
||||
planModeApiProvider: "openai-codex",
|
||||
actModeApiModelId: openAiCodexDefaultModelId,
|
||||
planModeApiModelId: openAiCodexDefaultModelId,
|
||||
}
|
||||
stateManager.setApiConfiguration(config)
|
||||
await stateManager.flushPendingState()
|
||||
|
||||
// Rebuild API handler on active task if one exists
|
||||
if (controller?.task) {
|
||||
const currentMode = stateManager.getGlobalSettingsKey("mode")
|
||||
const apiConfig = stateManager.getApiConfiguration()
|
||||
controller.task.api = buildApiHandler({ ...apiConfig, ulid: controller.task.ulid }, currentMode)
|
||||
}
|
||||
|
||||
// Success - apply provider config
|
||||
await applyProviderConfig({ providerId: "openai-codex", controller })
|
||||
setProvider("openai-codex")
|
||||
setIsWaitingForCodexAuth(false)
|
||||
} catch (error) {
|
||||
@@ -558,11 +810,27 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
|
||||
setCodexAuthError(error instanceof Error ? error.message : String(error))
|
||||
setIsWaitingForCodexAuth(false)
|
||||
}
|
||||
}, [stateManager, controller])
|
||||
}, [controller])
|
||||
|
||||
// Handle provider selection from picker
|
||||
const handleProviderSelect = useCallback(
|
||||
(providerId: string) => {
|
||||
// Special handling for Cline - uses OAuth (but skip if already logged in)
|
||||
if (providerId === "cline") {
|
||||
setIsPickingProvider(false)
|
||||
// Check if already logged in
|
||||
const authInfo = AuthService.getInstance(controller).getInfo()
|
||||
if (authInfo?.user?.email) {
|
||||
// Already logged in - just set the provider
|
||||
applyProviderConfig({ providerId: "cline", controller })
|
||||
setProvider("cline")
|
||||
} else {
|
||||
// Not logged in - trigger OAuth
|
||||
handleClineLogin()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Special handling for OpenAI Codex - uses OAuth instead of API key
|
||||
if (providerId === "openai-codex") {
|
||||
setIsPickingProvider(false)
|
||||
@@ -570,28 +838,34 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
|
||||
return
|
||||
}
|
||||
|
||||
// Special handling for Bedrock - needs multi-field configuration
|
||||
if (providerId === "bedrock") {
|
||||
setPendingProvider(providerId)
|
||||
setIsPickingProvider(false)
|
||||
setIsConfiguringBedrock(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this provider needs an API key
|
||||
const keyField = ProviderToApiKeyMap[providerId as keyof typeof ProviderToApiKeyMap]
|
||||
if (keyField) {
|
||||
// Provider needs an API key - go to API key entry mode
|
||||
// Pre-fill with existing key if configured
|
||||
const apiConfig = stateManager.getApiConfiguration()
|
||||
const fieldName = Array.isArray(keyField) ? keyField[0] : keyField
|
||||
const existingKey = (apiConfig as Record<string, string>)[fieldName] || ""
|
||||
setPendingProvider(providerId)
|
||||
setApiKeyValue("")
|
||||
setApiKeyValue(existingKey)
|
||||
setIsPickingProvider(false)
|
||||
setIsEnteringApiKey(true)
|
||||
} else {
|
||||
// Provider doesn't need an API key (rare) - just set it
|
||||
applyProviderConfig({ providerId, controller })
|
||||
setProvider(providerId)
|
||||
stateManager.setGlobalState("actModeApiProvider", providerId)
|
||||
stateManager.setGlobalState("planModeApiProvider", providerId)
|
||||
const defaultModelId = getDefaultModelId(providerId)
|
||||
if (defaultModelId) {
|
||||
stateManager.setGlobalState("actModeApiModelId", defaultModelId)
|
||||
stateManager.setGlobalState("planModeApiModelId", defaultModelId)
|
||||
}
|
||||
setIsPickingProvider(false)
|
||||
}
|
||||
},
|
||||
[stateManager, startCodexAuth],
|
||||
[stateManager, startCodexAuth, handleClineLogin, controller],
|
||||
)
|
||||
|
||||
// Handle API key submission after provider selection
|
||||
@@ -601,45 +875,55 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
|
||||
return
|
||||
}
|
||||
|
||||
// Build config object with provider, model, and API key
|
||||
const config: Record<string, string> = {
|
||||
actModeApiProvider: pendingProvider,
|
||||
planModeApiProvider: pendingProvider,
|
||||
apiProvider: pendingProvider,
|
||||
}
|
||||
|
||||
// Add default model ID
|
||||
const defaultModelId = getDefaultModelId(pendingProvider)
|
||||
if (defaultModelId) {
|
||||
config.actModeApiModelId = defaultModelId
|
||||
config.planModeApiModelId = defaultModelId
|
||||
}
|
||||
|
||||
// Add API key using provider-specific field
|
||||
const keyField = ProviderToApiKeyMap[pendingProvider as keyof typeof ProviderToApiKeyMap]
|
||||
if (keyField) {
|
||||
const fields = Array.isArray(keyField) ? keyField : [keyField]
|
||||
config[fields[0]] = submittedValue.trim()
|
||||
}
|
||||
|
||||
// Save via StateManager (same pattern as AuthView)
|
||||
stateManager.setApiConfiguration(config)
|
||||
await stateManager.flushPendingState()
|
||||
|
||||
// Rebuild API handler on active task if one exists (matches extension behavior)
|
||||
if (controller?.task) {
|
||||
const currentMode = stateManager.getGlobalSettingsKey("mode")
|
||||
const apiConfig = stateManager.getApiConfiguration()
|
||||
controller.task.api = buildApiHandler({ ...apiConfig, ulid: controller.task.ulid }, currentMode)
|
||||
}
|
||||
|
||||
// Update local state
|
||||
await applyProviderConfig({ providerId: pendingProvider, apiKey: submittedValue.trim(), controller })
|
||||
setProvider(pendingProvider)
|
||||
setIsEnteringApiKey(false)
|
||||
setPendingProvider(null)
|
||||
setApiKeyValue("")
|
||||
},
|
||||
[pendingProvider, stateManager, controller],
|
||||
[pendingProvider, controller],
|
||||
)
|
||||
|
||||
// Handle Bedrock configuration complete
|
||||
const handleBedrockComplete = useCallback(
|
||||
(bedrockConfig: BedrockConfig) => {
|
||||
const config: Record<string, unknown> = {
|
||||
actModeApiProvider: "bedrock",
|
||||
planModeApiProvider: "bedrock",
|
||||
apiProvider: "bedrock",
|
||||
awsAuthentication: bedrockConfig.awsAuthentication,
|
||||
awsRegion: bedrockConfig.awsRegion,
|
||||
awsUseCrossRegionInference: bedrockConfig.awsUseCrossRegionInference,
|
||||
}
|
||||
|
||||
const defaultModelId = getDefaultModelId("bedrock")
|
||||
if (defaultModelId) {
|
||||
config.actModeApiModelId = defaultModelId
|
||||
config.planModeApiModelId = defaultModelId
|
||||
}
|
||||
|
||||
if (bedrockConfig.awsProfile !== undefined) config.awsProfile = bedrockConfig.awsProfile
|
||||
if (bedrockConfig.awsAccessKey) config.awsAccessKey = bedrockConfig.awsAccessKey
|
||||
if (bedrockConfig.awsSecretKey) config.awsSecretKey = bedrockConfig.awsSecretKey
|
||||
if (bedrockConfig.awsSessionToken) config.awsSessionToken = bedrockConfig.awsSessionToken
|
||||
|
||||
stateManager.setApiConfiguration(config as Record<string, string>)
|
||||
|
||||
// Close Bedrock config first, then flush state async
|
||||
setProvider("bedrock")
|
||||
setIsConfiguringBedrock(false)
|
||||
setPendingProvider(null)
|
||||
|
||||
// Flush state and rebuild API handler in background
|
||||
stateManager.flushPendingState().then(() => {
|
||||
if (controller?.task) {
|
||||
const currentMode = stateManager.getGlobalSettingsKey("mode")
|
||||
const apiConfig = stateManager.getApiConfiguration()
|
||||
controller.task.api = buildApiHandler({ ...apiConfig, ulid: controller.task.ulid }, currentMode)
|
||||
}
|
||||
})
|
||||
},
|
||||
[stateManager, controller],
|
||||
)
|
||||
|
||||
// Handle saving edited value
|
||||
@@ -752,6 +1036,22 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
|
||||
return
|
||||
}
|
||||
|
||||
// Organization picker mode - escape to close, input is handled by OrganizationPicker
|
||||
if (isPickingOrganization) {
|
||||
if (key.escape) {
|
||||
setIsPickingOrganization(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Cline OAuth waiting mode - escape to cancel
|
||||
if (isWaitingForClineAuth) {
|
||||
if (key.escape) {
|
||||
setIsWaitingForClineAuth(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
if (key.escape) {
|
||||
setIsEditing(false)
|
||||
@@ -796,7 +1096,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
|
||||
return
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported && !isEnteringApiKey },
|
||||
{ isActive: isRawModeSupported && !isEnteringApiKey && !isConfiguringBedrock },
|
||||
)
|
||||
|
||||
// Render content
|
||||
@@ -834,6 +1134,19 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
|
||||
)
|
||||
}
|
||||
|
||||
if (isConfiguringBedrock) {
|
||||
return (
|
||||
<BedrockSetup
|
||||
isActive={isConfiguringBedrock}
|
||||
onCancel={() => {
|
||||
setIsConfiguringBedrock(false)
|
||||
setPendingProvider(null)
|
||||
}}
|
||||
onComplete={handleBedrockComplete}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (isWaitingForCodexAuth) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
@@ -879,6 +1192,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<ModelPicker
|
||||
controller={controller}
|
||||
isActive={isPickingModel}
|
||||
onChange={() => {}}
|
||||
onSubmit={handleModelSelect}
|
||||
@@ -908,6 +1222,85 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
|
||||
)
|
||||
}
|
||||
|
||||
if (isPickingOrganization && accountOrganizations) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={COLORS.primaryBlue}>
|
||||
Select Organization
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<OrganizationPicker
|
||||
isActive={isPickingOrganization}
|
||||
onSelect={handleOrganizationSelect}
|
||||
organizations={accountOrganizations}
|
||||
/>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">Arrows to navigate, Enter to select, Esc to cancel</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (isWaitingForClineAuth) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text color={COLORS.primaryBlue}>
|
||||
<Spinner type="dots" />
|
||||
</Text>
|
||||
<Text color="white"> Waiting for Cline sign-in...</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">Complete sign-in in your browser.</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">Esc to cancel</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Account tab - loading state
|
||||
if (currentTab === "account" && isAccountLoading) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color={COLORS.primaryBlue}>
|
||||
<Spinner type="dots" />
|
||||
</Text>
|
||||
<Text color="gray"> Loading account info...</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Account tab - logged out state with pitch
|
||||
if (currentTab === "account" && !accountEmail && !isAccountLoading) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="white">Sign in to access Cline features:</Text>
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color="gray"> - Free access to frontier AI models</Text>
|
||||
<Text color="gray"> - Built-in web search capabilities</Text>
|
||||
<Text color="gray"> - Team management and shared billing</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
{items.map((item, idx) => {
|
||||
const isSelected = idx === selectedIndex
|
||||
return (
|
||||
<Text key={item.key}>
|
||||
<Text bold color={isSelected ? COLORS.primaryBlue : undefined}>
|
||||
{isSelected ? "❯" : " "}{" "}
|
||||
</Text>
|
||||
<Text color={isSelected ? COLORS.primaryBlue : "white"}>{item.label}</Text>
|
||||
{isSelected && <Text color="gray"> (Enter)</Text>}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
const item = items[selectedIndex]
|
||||
return (
|
||||
@@ -990,6 +1383,19 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({ onCl
|
||||
)
|
||||
}
|
||||
|
||||
// Action item (button-like, no value display)
|
||||
if (item.type === "action") {
|
||||
return (
|
||||
<Text key={item.key}>
|
||||
<Text bold color={isSelected ? COLORS.primaryBlue : undefined}>
|
||||
{isSelected ? "❯" : " "}{" "}
|
||||
</Text>
|
||||
<Text color={isSelected ? COLORS.primaryBlue : "white"}>{item.label}</Text>
|
||||
{isSelected && <Text color="gray"> (Enter)</Text>}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
// Readonly or editable field
|
||||
return (
|
||||
<Text key={item.key}>
|
||||
|
||||
@@ -319,7 +319,7 @@ export const WelcomeView: React.FC<WelcomeViewProps> = ({ onSubmit, onExit, cont
|
||||
{/* Help text */}
|
||||
<Box>
|
||||
<Text color="gray">Enter to submit · @ to mention files · </Text>
|
||||
<Text bold={escPressedOnce} color={escPressedOnce ? "white" : "gray"} dimColor={!escPressedOnce}>
|
||||
<Text bold={escPressedOnce} color={escPressedOnce ? "white" : "gray"}>
|
||||
{escPressedOnce ? "Press Esc again to exit" : "Esc to exit"}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -15,6 +15,7 @@ interface TaskContextType {
|
||||
setIsComplete: (complete: boolean) => void
|
||||
lastError: string | null
|
||||
setLastError: (error: string | null) => void
|
||||
clearState: () => void
|
||||
}
|
||||
|
||||
const TaskContext = createContext<TaskContextType | undefined>(undefined)
|
||||
@@ -91,6 +92,14 @@ export const TaskContextProvider: React.FC<TaskContextProviderProps> = ({ contro
|
||||
}
|
||||
}, [controller])
|
||||
|
||||
// Force clear state (bypasses the empty messages check for intentional clears like /clear)
|
||||
const clearState = () => {
|
||||
setState({
|
||||
clineMessages: [],
|
||||
currentTaskItem: null,
|
||||
} as unknown as Partial<ExtensionState>)
|
||||
}
|
||||
|
||||
const value: TaskContextType = {
|
||||
state,
|
||||
controller,
|
||||
@@ -98,6 +107,7 @@ export const TaskContextProvider: React.FC<TaskContextProviderProps> = ({ contro
|
||||
setIsComplete,
|
||||
lastError,
|
||||
setLastError,
|
||||
clearState,
|
||||
}
|
||||
|
||||
return <TaskContext.Provider value={value}>{children}</TaskContext.Provider>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useStdout } from "ink"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
|
||||
/**
|
||||
* Reactive terminal size hook with resize recovery.
|
||||
*
|
||||
* WHY THIS EXISTS:
|
||||
* Ink tracks how many lines it rendered last frame (`previousLineCount` in log-update.js,
|
||||
* `lastOutputHeight` in ink.js). On re-render it erases that many lines then writes new
|
||||
* output. When the terminal resizes, text wrapping changes so the actual number of lines
|
||||
* on screen no longer matches what Ink thinks it rendered. This causes cascading visual
|
||||
* artifacts: old content doesn't get fully erased, and new content renders on top of it.
|
||||
*
|
||||
* We tried several approaches that didn't work:
|
||||
* - Writing \x1b[2J\x1b[H before state update: Ink overwrites the clear with its own
|
||||
* stale-count erasure immediately after.
|
||||
* - Calling Ink's clear() via prependListener on resize: clear() itself uses the stale
|
||||
* previousLineCount to erase, so it erases the wrong number of lines too.
|
||||
* - Patching Ink's resized() to reset lastOutputHeight: The dynamic region renders
|
||||
* cleanly but Static content (already printed to scrollback) is gone and Ink won't
|
||||
* re-render it since it tracks which Static items have been rendered by key.
|
||||
*
|
||||
* WHAT WORKS (borrowed from Gemini CLI's approach):
|
||||
* 1. Debounce resize events (300ms) so we wait until the user stops dragging
|
||||
* 2. Clear the entire terminal including scrollback (\x1b[2J\x1b[3J\x1b[H)
|
||||
* 3. Increment a `resizeKey` used as a React key on the content tree, forcing React
|
||||
* to unmount and remount everything from scratch. This resets Ink's internal tracking
|
||||
* AND re-renders Static content since the components are brand new instances.
|
||||
*
|
||||
* Gemini CLI does the same thing in AppContainer.tsx: debounce 300ms, then
|
||||
* stdout.write(ansiEscapes.clearTerminal) + setHistoryRemountKey(prev => prev + 1).
|
||||
*
|
||||
* USAGE:
|
||||
* - `columns`/`rows`: Current terminal dimensions, updated live during resize
|
||||
* - `resizeKey`: Increments after resize settles. Use as a React `key` on the root
|
||||
* content wrapper to force full remount.
|
||||
*/
|
||||
export function useTerminalSize() {
|
||||
const { stdout } = useStdout()
|
||||
const [size, setSize] = useState({
|
||||
columns: process.stdout.columns || 80,
|
||||
rows: process.stdout.rows || 24,
|
||||
})
|
||||
const [resizeKey, setResizeKey] = useState(0)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const refreshAfterResize = useCallback(() => {
|
||||
// Clear terminal + scrollback to wipe stale content from old width
|
||||
// \x1b[2J clears visible screen, \x1b[3J clears scrollback, \x1b[H moves cursor home
|
||||
stdout?.write("\x1b[2J\x1b[3J\x1b[H")
|
||||
// Increment key to force React remount
|
||||
setResizeKey((prev) => prev + 1)
|
||||
}, [stdout])
|
||||
|
||||
useEffect(() => {
|
||||
function updateSize() {
|
||||
setSize({
|
||||
columns: process.stdout.columns || 80,
|
||||
rows: process.stdout.rows || 24,
|
||||
})
|
||||
|
||||
// Debounce: wait 300ms after last resize event to do full recovery
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current)
|
||||
}
|
||||
debounceRef.current = setTimeout(() => {
|
||||
refreshAfterResize()
|
||||
debounceRef.current = null
|
||||
}, 300)
|
||||
}
|
||||
process.stdout.on("resize", updateSize)
|
||||
return () => {
|
||||
process.stdout.off("resize", updateSize)
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current)
|
||||
}
|
||||
}
|
||||
}, [refreshAfterResize])
|
||||
|
||||
return { ...size, resizeKey }
|
||||
}
|
||||
+103
-12
@@ -2,7 +2,6 @@
|
||||
* 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"
|
||||
@@ -21,9 +20,11 @@ import { ErrorService } from "@/services/error/ErrorService"
|
||||
import { initializeDistinctId } from "@/services/logging/distinctId"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { Session } from "@/shared/services/Session"
|
||||
import { getProviderModelIdKey, ProviderToApiKeyMap } from "@/shared/storage"
|
||||
import { secretStorage } from "@/shared/storage/ClineSecretStorage"
|
||||
import { version as CLI_VERSION } from "../package.json"
|
||||
import { runAcpMode } from "./acp/index.js"
|
||||
import { App } from "./components/App"
|
||||
import { checkRawModeSupport } from "./context/StdinContext"
|
||||
import { createCliHostBridgeProvider } from "./controllers"
|
||||
@@ -33,14 +34,35 @@ import { restoreConsole } from "./utils/console"
|
||||
import { calculateRobotTopRow, queryCursorPos } from "./utils/cursor-position"
|
||||
import { printInfo, printWarning } from "./utils/display"
|
||||
import { parseImagesFromInput, processImagePaths } from "./utils/parser"
|
||||
import { CLINE_CLI_DIR, getCliBinaryPath } from "./utils/path"
|
||||
import { readStdinIfPiped } from "./utils/piped"
|
||||
import { runPlainTextTask } from "./utils/plain-text-task"
|
||||
import { printSessionSummary } from "./utils/session-summary"
|
||||
import { checkForUpdates } from "./utils/update"
|
||||
import { initializeCliContext } from "./vscode-context"
|
||||
import { window } from "./vscode-shim"
|
||||
import { CLI_LOG_FILE, shutdownEvent, window } from "./vscode-shim"
|
||||
|
||||
// Track active context for graceful shutdown
|
||||
let activeContext: CliContext | null = null
|
||||
let isShuttingDown = false
|
||||
// Track if we're in plain text mode (no Ink UI) - set by runTask when piped stdin detected
|
||||
let isPlainTextMode = false
|
||||
|
||||
/**
|
||||
* Wait for stdout to fully drain before exiting.
|
||||
* Critical for piping - ensures data is flushed to the next command in the pipe.
|
||||
*/
|
||||
async function drainStdout(): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
// Check if stdout needs draining
|
||||
if (process.stdout.writableNeedDrain) {
|
||||
process.stdout.once("drain", resolve)
|
||||
} else {
|
||||
// Give a small delay to ensure any pending writes complete
|
||||
setImmediate(resolve)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function setupSignalHandlers() {
|
||||
const shutdown = async (signal: string) => {
|
||||
@@ -50,7 +72,20 @@ function setupSignalHandlers() {
|
||||
}
|
||||
isShuttingDown = true
|
||||
|
||||
printWarning(`\n${signal} received, shutting down...`)
|
||||
// Notify components to hide UI before shutdown
|
||||
shutdownEvent.fire()
|
||||
|
||||
// Only clear Ink UI lines if we're not in plain text mode
|
||||
// In plain text mode, there's no Ink UI to clear and the ANSI codes
|
||||
// would corrupt the streaming output
|
||||
if (!isPlainTextMode) {
|
||||
// Clear several lines to remove the input field and footer from display
|
||||
// Move cursor up and clear lines (input box + footer rows)
|
||||
const linesToClear = 8 // Input box (3 lines with border) + footer (4-5 lines)
|
||||
process.stdout.write(`\x1b[${linesToClear}A\x1b[J`)
|
||||
}
|
||||
|
||||
printWarning(`${signal} received, shutting down...`)
|
||||
|
||||
try {
|
||||
if (activeContext) {
|
||||
@@ -65,6 +100,10 @@ function setupSignalHandlers() {
|
||||
} catch {
|
||||
// Best effort cleanup
|
||||
}
|
||||
|
||||
// Print session summary before exit
|
||||
printSessionSummary()
|
||||
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
@@ -77,6 +116,7 @@ function setupSignalHandlers() {
|
||||
const message = reason instanceof Error ? reason.message : String(reason)
|
||||
// Silently ignore abort-related errors - they're expected during task cancellation
|
||||
if (message.includes("aborted") || message.includes("abort")) {
|
||||
Logger.info("Suppressed unhandled rejection due to abort:", message)
|
||||
return
|
||||
}
|
||||
// For other unhandled rejections, log to file via Logger (if available)
|
||||
@@ -115,12 +155,17 @@ async function initializeCli(options: InitOptions): Promise<CliContext> {
|
||||
await ClineEndpoint.initialize()
|
||||
await initializeDistinctId(extensionContext)
|
||||
|
||||
// Initialize/reset session tracking for this CLI run
|
||||
Session.reset()
|
||||
|
||||
if (options.enableAuth) {
|
||||
AuthHandler.getInstance().setEnabled(true)
|
||||
}
|
||||
|
||||
const outputChannel = window.createOutputChannel("Cline CLI")
|
||||
outputChannel.appendLine(`Cline CLI initialized. Data dir: ${DATA_DIR}, Extension dir: ${EXTENSION_DIR}`)
|
||||
outputChannel.appendLine(
|
||||
`Cline CLI initialized. Data dir: ${DATA_DIR}, Extension dir: ${EXTENSION_DIR}, Log dir: ${CLINE_CLI_DIR.log}`,
|
||||
)
|
||||
const logToChannel = (message: string) => outputChannel.appendLine(message)
|
||||
|
||||
HostProvider.initialize(
|
||||
@@ -131,7 +176,7 @@ async function initializeCli(options: InitOptions): Promise<CliContext> {
|
||||
createCliHostBridgeProvider(workspacePath),
|
||||
logToChannel,
|
||||
async () => (options.enableAuth ? AuthHandler.getInstance().getCallbackUrl() : ""),
|
||||
async (name: string) => path.join(process.cwd(), name),
|
||||
getCliBinaryPath,
|
||||
EXTENSION_DIR,
|
||||
DATA_DIR,
|
||||
)
|
||||
@@ -165,7 +210,10 @@ async function runInkApp(element: React.ReactElement, cleanup: () => Promise<voi
|
||||
// Ink's incremental rendering tries to erase N lines based on previous output height,
|
||||
// but when the terminal shrinks, this leaves artifacts. Gemini CLI only enables
|
||||
// incrementalRendering when alternateBuffer is also enabled (which we don't use).
|
||||
const { waitUntilExit, unmount } = render(element)
|
||||
//
|
||||
// exitOnCtrlC: false - We handle Ctrl+C ourselves so we can clean up UI before exiting.
|
||||
// The app components listen for Ctrl+C via useInput and call their exit handlers.
|
||||
const { waitUntilExit, unmount } = render(element, { exitOnCtrlC: false })
|
||||
|
||||
try {
|
||||
await waitUntilExit()
|
||||
@@ -194,6 +242,7 @@ async function runTask(
|
||||
config?: string
|
||||
thinking?: boolean
|
||||
yolo?: boolean
|
||||
timeout?: string
|
||||
images?: string[]
|
||||
json?: boolean
|
||||
stdinWasPiped?: boolean
|
||||
@@ -263,11 +312,14 @@ async function runTask(
|
||||
// Detect if output is a TTY (interactive terminal) or redirected to a file/pipe
|
||||
const isTTY = process.stdout.isTTY === true
|
||||
|
||||
// Use plain text mode when output is redirected, stdin was piped, or JSON mode is enabled
|
||||
// Use plain text mode when output is redirected, stdin was piped, JSON mode is enabled, or --yolo flag is used
|
||||
// Ink requires raw mode on stdin which isn't available when stdin is piped
|
||||
// Note: we use the stdinWasPiped flag passed from the caller because process.stdin.isTTY
|
||||
// may not be reliable after stdin has been consumed by readStdinIfPiped()
|
||||
if (!isTTY || options.stdinWasPiped || options.json) {
|
||||
if (!isTTY || options.stdinWasPiped || options.json || options.yolo) {
|
||||
// Set flag so shutdown handler knows not to clear Ink UI lines
|
||||
isPlainTextMode = true
|
||||
|
||||
// Check if auth is configured before attempting to run the task
|
||||
// In plain text mode we can't show the interactive auth flow
|
||||
const hasAuth = await isAuthConfigured()
|
||||
@@ -279,7 +331,13 @@ async function runTask(
|
||||
exit(1)
|
||||
}
|
||||
|
||||
const reason = options.json ? "json" : options.stdinWasPiped ? "piped_stdin" : "redirected_output"
|
||||
const reason = options.yolo
|
||||
? "yolo_flag"
|
||||
: options.json
|
||||
? "json"
|
||||
: options.stdinWasPiped
|
||||
? "piped_stdin"
|
||||
: "redirected_output"
|
||||
telemetryService.captureHostEvent("plain_text_mode", reason)
|
||||
// Plain text mode: no Ink rendering, just clean text output
|
||||
const success = await runPlainTextTask({
|
||||
@@ -288,12 +346,16 @@ async function runTask(
|
||||
imageDataUrls: imageDataUrls.length > 0 ? imageDataUrls : undefined,
|
||||
verbose: options.verbose,
|
||||
jsonOutput: options.json,
|
||||
timeoutSeconds: options.timeout ? parseInt(options.timeout, 10) : undefined,
|
||||
})
|
||||
|
||||
// Cleanup
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
await ctx.controller.dispose()
|
||||
await ErrorService.get().dispose()
|
||||
|
||||
// Ensure stdout is fully drained before exiting - critical for piping
|
||||
await drainStdout()
|
||||
exit(success ? 0 : 1)
|
||||
}
|
||||
|
||||
@@ -360,7 +422,6 @@ async function listHistory(options: { config?: string; limit?: number; page?: nu
|
||||
await ctx.controller.dispose()
|
||||
await ErrorService.get().dispose()
|
||||
exit(0)
|
||||
return
|
||||
}
|
||||
|
||||
await runInkApp(
|
||||
@@ -481,7 +542,8 @@ program
|
||||
.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("-y, --yolo", "Enable yes/yolo mode (auto-approve actions)")
|
||||
.option("-t, --timeout <seconds>", "Timeout in seconds for yes/yolo mode (default: 600)")
|
||||
.option("-m, --model <model>", "Model to use for the task")
|
||||
.option("-v, --verbose", "Show verbose output")
|
||||
.option("-c, --cwd <path>", "Working directory for the task")
|
||||
@@ -522,6 +584,23 @@ program
|
||||
.description("Show Cline CLI version number")
|
||||
.action(() => printInfo(`Cline CLI version: ${CLI_VERSION}`))
|
||||
|
||||
program
|
||||
.command("update")
|
||||
.description("Check for updates and install if available")
|
||||
.option("-v, --verbose", "Show verbose output")
|
||||
.action(() => checkForUpdates(CLI_VERSION))
|
||||
|
||||
// Dev command with subcommands
|
||||
const devCommand = program.command("dev").description("Developer tools and utilities")
|
||||
|
||||
devCommand
|
||||
.command("log")
|
||||
.description("Open the log file")
|
||||
.action(async () => {
|
||||
const { openExternal } = await import("@/utils/env")
|
||||
await openExternal(CLI_LOG_FILE)
|
||||
})
|
||||
|
||||
/**
|
||||
* Check if the user has authentication configured.
|
||||
* Returns true if they have either:
|
||||
@@ -611,17 +690,29 @@ program
|
||||
.option("-a, --act", "Run in act mode")
|
||||
.option("-p, --plan", "Run in plan mode")
|
||||
.option("-y, --yolo", "Enable yolo mode (auto-approve actions)")
|
||||
.option("-t, --timeout <seconds>", "Timeout in seconds for yolo mode (default: 600)")
|
||||
.option("-m, --model <model>", "Model to use for the task")
|
||||
.option("-v, --verbose", "Show verbose output")
|
||||
.option("-c, --cwd <path>", "Working directory")
|
||||
.option("--config <path>", "Configuration directory")
|
||||
.option("--thinking", "Enable extended thinking (1024 token budget)")
|
||||
.option("--json", "Output messages as JSON instead of styled text")
|
||||
.option("--acp", "Run in ACP (Agent Client Protocol) mode for editor integration")
|
||||
.action(async (prompt, options) => {
|
||||
// Check for ACP mode first - this takes precedence over everything else
|
||||
if (options.acp) {
|
||||
await runAcpMode({
|
||||
config: options.config,
|
||||
cwd: options.cwd,
|
||||
verbose: options.verbose,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Always check for piped stdin content
|
||||
const stdinInput = await readStdinIfPiped()
|
||||
|
||||
// Combine stdin content with prompt argument
|
||||
// If no prompt argument, check if input is piped via stdin
|
||||
let effectivePrompt = prompt
|
||||
if (stdinInput) {
|
||||
if (effectivePrompt) {
|
||||
|
||||
@@ -458,3 +458,15 @@ export async function promptConfirmation(question: string): Promise<boolean> {
|
||||
const answer = await promptUser(`${question} ${style.dim("(y/n)")}`)
|
||||
return answer.toLowerCase() === "y" || answer.toLowerCase() === "yes"
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the terminal session title using OSC escape sequence.
|
||||
* Works in most modern terminal emulators (iTerm2, Terminal.app, GNOME Terminal, etc.)
|
||||
*/
|
||||
export function setTerminalTitle(title: string): void {
|
||||
if (process.stdout.isTTY) {
|
||||
const maxLength = 80
|
||||
const truncated = title.length > maxLength ? title.slice(0, maxLength) + "..." : title
|
||||
process.stdout.write(`\x1b]0;${truncated}\x07`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Fuzzy search utility using fzf
|
||||
*/
|
||||
|
||||
import { Fzf } from "fzf"
|
||||
|
||||
/**
|
||||
* Filter items using fuzzy matching
|
||||
* @param items - Array of items to filter
|
||||
* @param query - Search query string
|
||||
* @param selector - Function to extract searchable string from each item
|
||||
* @returns Filtered and sorted items (best matches first)
|
||||
*/
|
||||
export function fuzzyFilter<T>(items: readonly T[], query: string, selector: (item: T) => string): T[] {
|
||||
if (!query) return [...items]
|
||||
const fzf = new Fzf(items, { selector })
|
||||
return fzf.find(query).map((result) => result.item)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { execFileSync } from "node:child_process"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
|
||||
@@ -9,3 +10,37 @@ export const CLINE_CLI_DIR = {
|
||||
data,
|
||||
log,
|
||||
}
|
||||
|
||||
/**
|
||||
* Find binary location for CLI.
|
||||
* Uses 'which' (Unix) or 'where' (Windows) to locate binaries in the system PATH.
|
||||
* This is needed for tools like ripgrep that the search_files tool uses.
|
||||
*/
|
||||
export async function getCliBinaryPath(name: string): Promise<string> {
|
||||
// The only binary currently supported is ripgrep (rg)
|
||||
if (!name.startsWith("rg")) {
|
||||
throw new Error(`Binary '${name}' is not supported`)
|
||||
}
|
||||
|
||||
const isWindows = process.platform === "win32"
|
||||
const whichCommand = isWindows ? "where" : "which"
|
||||
|
||||
try {
|
||||
const result = execFileSync(whichCommand, [name], {
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
})
|
||||
// 'which' returns the path, 'where' on Windows may return multiple lines
|
||||
const binPath = result.trim().split("\n")[0].trim()
|
||||
if (binPath) {
|
||||
return binPath
|
||||
}
|
||||
} catch {
|
||||
// Binary not found in PATH
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Could not find '${name}' in system PATH. ` +
|
||||
`Please install ripgrep: https://github.com/BurntSushi/ripgrep#installation`,
|
||||
)
|
||||
}
|
||||
|
||||
+41
-235
@@ -1,41 +1,54 @@
|
||||
import { Readable } from "node:stream"
|
||||
import * as fs from "node:fs"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { readStdinIfPiped } from "./piped"
|
||||
|
||||
// Mock the fs module
|
||||
vi.mock("node:fs", () => ({
|
||||
readFileSync: vi.fn(),
|
||||
}))
|
||||
|
||||
describe("readStdinIfPiped", () => {
|
||||
let mockStdin: Readable & { isTTY?: boolean }
|
||||
const mockReadFileSync = fs.readFileSync as ReturnType<typeof vi.fn>
|
||||
let originalIsTTY: boolean | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a mock readable stream
|
||||
mockStdin = new Readable({
|
||||
read() {},
|
||||
}) as Readable & { isTTY?: boolean }
|
||||
|
||||
// Mock process.stdin by stubbing its properties
|
||||
vi.spyOn(process, "stdin", "get").mockReturnValue(mockStdin as any)
|
||||
vi.clearAllMocks()
|
||||
originalIsTTY = process.stdin.isTTY
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
// Restore original isTTY value
|
||||
Object.defineProperty(process.stdin, "isTTY", {
|
||||
value: originalIsTTY,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
function setTTY(value: boolean | undefined) {
|
||||
Object.defineProperty(process.stdin, "isTTY", {
|
||||
value,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
|
||||
describe("TTY detection", () => {
|
||||
it("should return null when stdin is a TTY (interactive terminal)", async () => {
|
||||
mockStdin.isTTY = true
|
||||
setTTY(true)
|
||||
const result = await readStdinIfPiped()
|
||||
expect(result).toBeNull()
|
||||
expect(mockReadFileSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should attempt to read when stdin is not a TTY (piped input)", async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
// Simulate immediate end event (no data)
|
||||
setImmediate(() => {
|
||||
mockStdin.emit("end")
|
||||
})
|
||||
setTTY(false)
|
||||
mockReadFileSync.mockReturnValue("")
|
||||
|
||||
const result = await readStdinIfPiped()
|
||||
expect(result).toBeNull()
|
||||
expect(mockReadFileSync).toHaveBeenCalledWith(0, "utf8")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -112,14 +125,9 @@ describe("readStdinIfPiped", () => {
|
||||
|
||||
testCases.forEach(({ name, input, expected, description }) => {
|
||||
it(`${name}${description ? ` - ${description}` : ""}`, async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
// Simulate piped data
|
||||
setImmediate(() => {
|
||||
const data = Array.isArray(input) ? input.join("\n") : input
|
||||
mockStdin.push(data)
|
||||
mockStdin.push(null) // Signal end of stream
|
||||
})
|
||||
setTTY(false)
|
||||
const data = Array.isArray(input) ? input.join("\n") : input
|
||||
mockReadFileSync.mockReturnValue(data)
|
||||
|
||||
const result = await readStdinIfPiped()
|
||||
expect(result).toBe(expected)
|
||||
@@ -127,217 +135,19 @@ describe("readStdinIfPiped", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("chunked data", () => {
|
||||
it("should accumulate data from multiple chunks", async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
setImmediate(() => {
|
||||
mockStdin.emit("data", "chunk1 ")
|
||||
mockStdin.emit("data", "chunk2 ")
|
||||
mockStdin.emit("data", "chunk3")
|
||||
mockStdin.emit("end")
|
||||
})
|
||||
|
||||
const result = await readStdinIfPiped()
|
||||
expect(result).toBe("chunk1 chunk2 chunk3")
|
||||
})
|
||||
|
||||
it("should handle rapid successive chunks", async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
setImmediate(() => {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
mockStdin.emit("data", `${i} `)
|
||||
}
|
||||
mockStdin.emit("end")
|
||||
})
|
||||
|
||||
const result = await readStdinIfPiped()
|
||||
expect(result).toContain("0 ")
|
||||
expect(result).toContain("99")
|
||||
})
|
||||
})
|
||||
|
||||
describe("timeout behavior", () => {
|
||||
it("should timeout after 100ms if no data received", async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
// Don't emit any events - let it timeout
|
||||
|
||||
const startTime = Date.now()
|
||||
const result = await readStdinIfPiped()
|
||||
const elapsed = Date.now() - startTime
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(elapsed).toBeGreaterThanOrEqual(95) // Allow small margin
|
||||
expect(elapsed).toBeLessThan(150)
|
||||
})
|
||||
|
||||
it("should return data received before timeout", async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
setTimeout(() => {
|
||||
mockStdin.emit("data", "quick data")
|
||||
// Don't emit end - let it timeout
|
||||
}, 50)
|
||||
|
||||
const result = await readStdinIfPiped()
|
||||
expect(result).toBe("quick data")
|
||||
})
|
||||
|
||||
it("should not timeout if end event is received", async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
// Delay end event but emit it before timeout
|
||||
setTimeout(() => {
|
||||
mockStdin.emit("data", "delayed data")
|
||||
mockStdin.emit("end")
|
||||
}, 50)
|
||||
|
||||
const result = await readStdinIfPiped()
|
||||
expect(result).toBe("delayed data")
|
||||
})
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should return null on stdin error", async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
setImmediate(() => {
|
||||
mockStdin.emit("error", new Error("stdin read error"))
|
||||
it("should return null on fs.readFileSync error and fall back to async", async () => {
|
||||
setTTY(false)
|
||||
mockReadFileSync.mockImplementation(() => {
|
||||
throw new Error("EAGAIN: resource temporarily unavailable")
|
||||
})
|
||||
|
||||
// The async fallback will timeout since we can't easily mock process.stdin events
|
||||
// But we can verify it doesn't throw
|
||||
const result = await readStdinIfPiped()
|
||||
// Result will be null because async path times out with no data
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should handle error after partial data received", async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
setImmediate(() => {
|
||||
mockStdin.emit("data", "partial data")
|
||||
mockStdin.emit("error", new Error("read error"))
|
||||
})
|
||||
|
||||
const result = await readStdinIfPiped()
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should clean up listeners on error", async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
setImmediate(() => {
|
||||
mockStdin.emit("error", new Error("test error"))
|
||||
})
|
||||
|
||||
await readStdinIfPiped()
|
||||
|
||||
// Note: Implementation uses removeAllListeners() without event names
|
||||
// which should remove all listeners, but in practice there may be one remaining
|
||||
// This is acceptable behavior for error handling
|
||||
expect(mockStdin.listenerCount("data")).toBeLessThanOrEqual(1)
|
||||
expect(mockStdin.listenerCount("end")).toBeLessThanOrEqual(1)
|
||||
expect(mockStdin.listenerCount("error")).toBeLessThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("listener cleanup", () => {
|
||||
it("should remove all listeners on successful completion", async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
setImmediate(() => {
|
||||
mockStdin.emit("data", "test data")
|
||||
mockStdin.emit("end")
|
||||
})
|
||||
|
||||
await readStdinIfPiped()
|
||||
|
||||
// Note: Implementation doesn't explicitly clean up listeners on normal end,
|
||||
// so some listeners may remain attached. This is acceptable for one-time use.
|
||||
expect(mockStdin.listenerCount("data")).toBeLessThanOrEqual(1)
|
||||
expect(mockStdin.listenerCount("end")).toBeLessThanOrEqual(1)
|
||||
expect(mockStdin.listenerCount("error")).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
it("should remove all listeners on timeout", async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
// Let it timeout
|
||||
await readStdinIfPiped()
|
||||
|
||||
// Verify listeners are cleaned up
|
||||
expect(mockStdin.listenerCount("data")).toBe(0)
|
||||
expect(mockStdin.listenerCount("end")).toBe(0)
|
||||
expect(mockStdin.listenerCount("error")).toBe(0)
|
||||
})
|
||||
|
||||
it("should clear timeout when data ends normally", async () => {
|
||||
mockStdin.isTTY = false
|
||||
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout")
|
||||
|
||||
setImmediate(() => {
|
||||
mockStdin.emit("data", "test")
|
||||
mockStdin.emit("end")
|
||||
})
|
||||
|
||||
await readStdinIfPiped()
|
||||
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should clear timeout when error occurs", async () => {
|
||||
mockStdin.isTTY = false
|
||||
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout")
|
||||
|
||||
setImmediate(() => {
|
||||
mockStdin.emit("error", new Error("test"))
|
||||
})
|
||||
|
||||
await readStdinIfPiped()
|
||||
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("encoding", () => {
|
||||
it("should handle UTF-8 encoded data", async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
setImmediate(() => {
|
||||
// The function sets utf8 encoding
|
||||
mockStdin.setEncoding("utf8")
|
||||
mockStdin.emit("data", "UTF-8: café ☕")
|
||||
mockStdin.emit("end")
|
||||
})
|
||||
|
||||
const result = await readStdinIfPiped()
|
||||
expect(result).toBe("UTF-8: café ☕")
|
||||
})
|
||||
})
|
||||
|
||||
describe("stdin resume", () => {
|
||||
it("should call resume on stdin when not TTY", async () => {
|
||||
mockStdin.isTTY = false
|
||||
const resumeSpy = vi.spyOn(mockStdin, "resume")
|
||||
|
||||
setImmediate(() => {
|
||||
mockStdin.emit("end")
|
||||
})
|
||||
|
||||
await readStdinIfPiped()
|
||||
|
||||
expect(resumeSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should not call resume when TTY", async () => {
|
||||
mockStdin.isTTY = true
|
||||
const resumeSpy = vi.spyOn(mockStdin, "resume")
|
||||
|
||||
const result = await readStdinIfPiped()
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(resumeSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("real-world use cases", () => {
|
||||
@@ -377,12 +187,8 @@ describe("readStdinIfPiped", () => {
|
||||
|
||||
useCases.forEach(({ name, input, expected }) => {
|
||||
it(`should handle ${name}`, async () => {
|
||||
mockStdin.isTTY = false
|
||||
|
||||
setImmediate(() => {
|
||||
mockStdin.push(input)
|
||||
mockStdin.push(null)
|
||||
})
|
||||
setTTY(false)
|
||||
mockReadFileSync.mockReturnValue(input)
|
||||
|
||||
const result = await readStdinIfPiped()
|
||||
expect(result).toBe(expected)
|
||||
|
||||
+39
-32
@@ -1,7 +1,12 @@
|
||||
import * as fs from "node:fs"
|
||||
|
||||
/**
|
||||
* Read piped input from stdin (non-blocking)
|
||||
*
|
||||
* This function is designed to work with piped input, including chained commands:
|
||||
* git diff | cline 'explain' | cline 'summarize'
|
||||
*
|
||||
* The challenge is that when chaining cline commands, the first command may take
|
||||
* several seconds to complete, so we can't use a short timeout. Instead, we wait
|
||||
* for EOF which signals that the previous command has finished writing.
|
||||
*/
|
||||
export async function readStdinIfPiped(): Promise<string | null> {
|
||||
// Check if stdin is a TTY (interactive) or piped
|
||||
@@ -9,39 +14,41 @@ export async function readStdinIfPiped(): Promise<string | null> {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
// Use synchronous read for reliability with piped input
|
||||
// fd 0 is stdin
|
||||
const data = fs.readFileSync(0, "utf8")
|
||||
return data.trim() || null
|
||||
} catch {
|
||||
// Fallback to async approach if sync read fails
|
||||
return new Promise((resolve) => {
|
||||
let data = ""
|
||||
process.stdin.setEncoding("utf8")
|
||||
// Use async approach - more reliable for piped input from other commands
|
||||
// The synchronous readFileSync(0) can fail with EAGAIN when the pipe
|
||||
// isn't ready yet (common when piping from another cline command)
|
||||
return new Promise((resolve) => {
|
||||
let data = ""
|
||||
process.stdin.setEncoding("utf8")
|
||||
|
||||
// Set a timeout in case stdin is not actually providing data
|
||||
const timeout = setTimeout(() => {
|
||||
// For piped input, we wait for EOF (end event) which signals the
|
||||
// previous command in the pipe has finished writing. We use a longer
|
||||
// timeout as a safety net for cases where stdin is opened but never
|
||||
// written to (e.g., some edge cases with file descriptors).
|
||||
// 5 minutes should be more than enough for any reasonable pipeline.
|
||||
const timeout = setTimeout(
|
||||
() => {
|
||||
process.stdin.removeAllListeners()
|
||||
resolve(data.trim() || null)
|
||||
}, 1000)
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
) // 5 minutes
|
||||
|
||||
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()
|
||||
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()
|
||||
})
|
||||
}
|
||||
|
||||
+129
-168
@@ -1,14 +1,20 @@
|
||||
/**
|
||||
* Plain-text task runner for non-TTY environments (piped output, file redirection)
|
||||
* Outputs clean text without ANSI codes or Ink rendering
|
||||
* Optimized for CI/CD and piping - only outputs the final completion result to stdout.
|
||||
*
|
||||
* Design goals:
|
||||
* - stdout: Only the final completion result text (no prefix) - perfect for piping
|
||||
* - stderr: Errors and verbose output (won't break pipes)
|
||||
* - Enables workflows like: git diff | cline 'explain' | cline 'summarize'
|
||||
*/
|
||||
|
||||
/* eslint-disable no-console */
|
||||
// Console output is intentional here for plain text mode
|
||||
|
||||
import { registerPartialMessageCallback } from "@core/controller/ui/subscribeToPartialMessage"
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import type { ClineMessage, ExtensionState } from "@shared/ExtensionMessage"
|
||||
import type { Controller } from "@/core/controller"
|
||||
import { getRequestRegistry } from "@/core/controller/grpc-handler"
|
||||
import { subscribeToState } from "@/core/controller/state/subscribeToState"
|
||||
|
||||
export interface PlainTextTaskOptions {
|
||||
controller: Controller
|
||||
@@ -16,206 +22,161 @@ export interface PlainTextTaskOptions {
|
||||
imageDataUrls?: string[]
|
||||
verbose?: boolean
|
||||
jsonOutput?: boolean
|
||||
/** Timeout in seconds (default: 600 = 10 minutes) */
|
||||
timeoutSeconds?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a task with plain text output (no Ink, no ANSI codes)
|
||||
* Returns true if task completed successfully, false if error
|
||||
*
|
||||
* Output behavior:
|
||||
* - Non-JSON mode: Only writes final completion_result text to stdout
|
||||
* - JSON mode: Streams JSON lines to stdout as messages arrive (unchanged)
|
||||
* - Verbose mode: Progress info goes to stderr
|
||||
* - Errors: Always go to stderr
|
||||
*/
|
||||
export async function runPlainTextTask(options: PlainTextTaskOptions): Promise<boolean> {
|
||||
const { controller, prompt, imageDataUrls, verbose, jsonOutput } = options
|
||||
|
||||
// Track completion state
|
||||
let isComplete = false
|
||||
let hasError = false
|
||||
const processedMessages = new Map<number, number>() // index -> last output text length
|
||||
let lastStreamingMessageIndex = -1 // track open streaming line that needs closing
|
||||
|
||||
// Subscribe to state updates
|
||||
const originalPostState = controller.postStateToWebview.bind(controller)
|
||||
|
||||
const handleStateUpdate = async () => {
|
||||
try {
|
||||
const state = await controller.getStateToPostToWebview()
|
||||
const messages = state.clineMessages || []
|
||||
|
||||
// Process new messages
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i]
|
||||
const currentTextLength = message.text?.length ?? 0
|
||||
const lastOutputLength = processedMessages.get(i) ?? 0
|
||||
|
||||
// Skip if no new content to output
|
||||
if (currentTextLength <= lastOutputLength) continue
|
||||
|
||||
// Close previous streaming line if we're moving to a different message
|
||||
if (lastStreamingMessageIndex >= 0 && lastStreamingMessageIndex !== i && !jsonOutput) {
|
||||
process.stdout.write("\n")
|
||||
lastStreamingMessageIndex = -1
|
||||
}
|
||||
|
||||
processedMessages.set(i, currentTextLength)
|
||||
|
||||
// Output the message
|
||||
if (jsonOutput) {
|
||||
process.stdout.write(JSON.stringify(message) + "\n")
|
||||
} else {
|
||||
const isStreaming = outputMessageAsText(message, verbose || false, lastOutputLength)
|
||||
// Track streaming state for text messages
|
||||
if (isStreaming) {
|
||||
lastStreamingMessageIndex = i
|
||||
} else {
|
||||
lastStreamingMessageIndex = -1
|
||||
}
|
||||
}
|
||||
|
||||
// Check for completion
|
||||
if (
|
||||
message.say === "completion_result" ||
|
||||
message.ask === "completion_result" ||
|
||||
message.say === "error" ||
|
||||
message.ask === "api_req_failed"
|
||||
) {
|
||||
isComplete = true
|
||||
if (message.say === "error" || message.ask === "api_req_failed") {
|
||||
hasError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close streaming line on completion
|
||||
if (isComplete && lastStreamingMessageIndex >= 0 && !jsonOutput) {
|
||||
process.stdout.write("\n")
|
||||
lastStreamingMessageIndex = -1
|
||||
}
|
||||
} catch (error) {
|
||||
if (jsonOutput) {
|
||||
process.stdout.write(
|
||||
JSON.stringify({ type: "error", message: error instanceof Error ? error.message : String(error) }) + "\n",
|
||||
)
|
||||
} else {
|
||||
process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}` + "\n")
|
||||
}
|
||||
hasError = true
|
||||
isComplete = true
|
||||
}
|
||||
}
|
||||
|
||||
// Override postStateToWebview to capture state updates
|
||||
controller.postStateToWebview = async () => {
|
||||
await originalPostState()
|
||||
await handleStateUpdate()
|
||||
}
|
||||
|
||||
// Subscribe to partial message updates (for streaming)
|
||||
const unsubscribePartial = registerPartialMessageCallback(() => {
|
||||
// Partial updates are handled via postStateToWebview
|
||||
let completionResolve: () => void
|
||||
let completionReject: (reason?: any) => void
|
||||
const completionPromise = new Promise<void>((res, rej) => {
|
||||
completionResolve = res
|
||||
completionReject = rej
|
||||
})
|
||||
|
||||
try {
|
||||
// Get initial state
|
||||
await handleStateUpdate()
|
||||
let hasError = false
|
||||
// Track which messages have been processed (by timestamp)
|
||||
const processedMessages = new Map<number, string>()
|
||||
|
||||
// Helper to process a message and track completion state
|
||||
const processMessage = (message: ClineMessage) => {
|
||||
const ts = message.ts || 0
|
||||
if (message.partial || processedMessages.has(ts)) {
|
||||
return
|
||||
}
|
||||
|
||||
// JSON mode: stream all messages to stdout (existing behavior)
|
||||
if (jsonOutput) {
|
||||
process.stdout.write(JSON.stringify(message) + "\n")
|
||||
} else {
|
||||
handleMessageForPipeMode(message, verbose || false)
|
||||
}
|
||||
|
||||
processedMessages.set(ts, message.text ?? "")
|
||||
|
||||
// Check for completion (only on non-partial messages)
|
||||
if (message.say === "completion_result" || message.ask === "completion_result") {
|
||||
completionResolve()
|
||||
} else if (message.say === "error" || message.ask === "api_req_failed") {
|
||||
completionReject(message.text ?? "message.say error || message.ask api_req_failed")
|
||||
}
|
||||
}
|
||||
|
||||
const requestId = "cline-cli-plain-text-task"
|
||||
subscribeToState(
|
||||
controller,
|
||||
{},
|
||||
async ({ stateJson }) => {
|
||||
try {
|
||||
const state = JSON.parse(stateJson) as ExtensionState
|
||||
for (const message of state.clineMessages ?? []) {
|
||||
processMessage(message)
|
||||
}
|
||||
} catch (error) {
|
||||
if (jsonOutput) {
|
||||
process.stdout.write(
|
||||
JSON.stringify({ type: "error", message: error instanceof Error ? error.message : String(error) }) + "\n",
|
||||
)
|
||||
} else {
|
||||
process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}\n`)
|
||||
}
|
||||
completionReject(error)
|
||||
}
|
||||
},
|
||||
requestId,
|
||||
)
|
||||
|
||||
try {
|
||||
// Start the task
|
||||
await controller.initTask(prompt, imageDataUrls)
|
||||
|
||||
// Wait for completion with timeout
|
||||
const timeout = 10 * 60 * 1000 // 10 minutes
|
||||
const startTime = Date.now()
|
||||
|
||||
while (!isComplete && Date.now() - startTime < timeout) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
|
||||
if (!isComplete) {
|
||||
// Close any open streaming line before error message
|
||||
if (lastStreamingMessageIndex >= 0 && !jsonOutput) {
|
||||
process.stdout.write("\n")
|
||||
lastStreamingMessageIndex = -1
|
||||
}
|
||||
if (jsonOutput) {
|
||||
process.stdout.write(JSON.stringify({ type: "error", message: "Task timeout" }) + "\n")
|
||||
} else {
|
||||
process.stderr.write("Error: Task timeout" + "\n")
|
||||
}
|
||||
hasError = true
|
||||
}
|
||||
const timeoutMs = (options.timeoutSeconds ?? 600) * 1000 // default 10 minutes
|
||||
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeoutMs))
|
||||
await Promise.race([completionPromise, timeoutPromise])
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : String(error)
|
||||
if (jsonOutput) {
|
||||
process.stdout.write(
|
||||
JSON.stringify({ type: "error", message: error instanceof Error ? error.message : String(error) }) + "\n",
|
||||
)
|
||||
process.stdout.write(JSON.stringify({ type: "error", message: errMsg }) + "\n")
|
||||
} else {
|
||||
process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}` + "\n")
|
||||
process.stderr.write(`Error: ${errMsg}\n`)
|
||||
}
|
||||
hasError = true
|
||||
} finally {
|
||||
// Close any open streaming line
|
||||
if (lastStreamingMessageIndex >= 0 && !jsonOutput) {
|
||||
process.stdout.write("\n")
|
||||
}
|
||||
// Restore original postStateToWebview
|
||||
controller.postStateToWebview = originalPostState
|
||||
unsubscribePartial()
|
||||
getRequestRegistry().cancelRequest(requestId)
|
||||
}
|
||||
|
||||
// non json mode outputs only the final complete message
|
||||
// (it should be the completion_result message)
|
||||
if (!jsonOutput && !verbose) {
|
||||
const msg = Array.from(processedMessages.entries())
|
||||
.sort(([aTs], [bTs]) => aTs - bTs)
|
||||
.map(([_, msg]) => msg)
|
||||
.at(-1)
|
||||
process.stdout.write(msg + "\n")
|
||||
}
|
||||
|
||||
return !hasError
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Cline message as plain text
|
||||
* @param previousLength - Length of text already output for this message (for streaming)
|
||||
* @returns true if this is a streaming message (caller should track for newline), false otherwise
|
||||
* Handle a message in pipe-optimized mode (non-JSON)
|
||||
* - Assistant response text (say: "text") is passed to the callback for buffering
|
||||
* - Errors go to stderr
|
||||
* - Verbose output goes to stderr
|
||||
* - Nothing else goes to stdout (stdout is reserved for final result only)
|
||||
*/
|
||||
function outputMessageAsText(message: ClineMessage, verbose: boolean, previousLength: number = 0): boolean {
|
||||
const timestamp = new Date(message.ts || Date.now()).toLocaleTimeString()
|
||||
function handleMessageForPipeMode(message: ClineMessage, verbose: boolean): void {
|
||||
const fullText = message.text ?? ""
|
||||
|
||||
if (!fullText) {
|
||||
// Skip partial messages without text
|
||||
return false
|
||||
}
|
||||
|
||||
// For streaming text continuations, output only new content
|
||||
if (previousLength > 0 && message.type === "say" && message.say === "text") {
|
||||
process.stdout.write(fullText.slice(previousLength))
|
||||
return true // Still streaming
|
||||
}
|
||||
|
||||
if (message.type === "say") {
|
||||
if (message.say === "task") {
|
||||
process.stdout.write(`[${timestamp}] Task: ${fullText}\n`)
|
||||
} else if (message.say === "text") {
|
||||
// First output of text message - write prefix but no newline (streaming)
|
||||
process.stdout.write(`[${timestamp}] ${fullText}`)
|
||||
return true // Streaming - newline will be added when stream ends
|
||||
} else if (message.say === "completion_result" && fullText) {
|
||||
process.stdout.write(`[${timestamp}] Completed: ${fullText}\n`)
|
||||
} else if (message.say === "error") {
|
||||
process.stderr.write(`[${timestamp}] Error: ${fullText}\n`)
|
||||
} else if (message.say === "api_req_started") {
|
||||
if (verbose) {
|
||||
process.stdout.write(`[${timestamp}] API request started\n`)
|
||||
}
|
||||
} else if (message.say === "api_req_finished") {
|
||||
if (verbose) {
|
||||
process.stdout.write(`[${timestamp}] API request finished\n`)
|
||||
}
|
||||
if (message.say === "error") {
|
||||
// Errors always go to stderr
|
||||
process.stderr.write(`Error: ${fullText}\n`)
|
||||
} else if (verbose) {
|
||||
process.stdout.write(`[${timestamp}] ${message.say}: ${fullText}\n`)
|
||||
// Verbose output goes to stderr so it doesn't interfere with piped stdout
|
||||
if (message.say === "task") {
|
||||
process.stderr.write(`${fullText}\n`)
|
||||
} else if (message.say === "text" && fullText) {
|
||||
process.stderr.write(`${fullText}\n`)
|
||||
} else if (message.say === "api_req_started") {
|
||||
process.stderr.write(`API request started\n`)
|
||||
} else if (message.say === "api_req_finished") {
|
||||
process.stderr.write(`API request finished\n`)
|
||||
} else if (message.say === "completion_result" && fullText) {
|
||||
process.stderr.write(`${fullText}\n`)
|
||||
} else if (fullText) {
|
||||
process.stderr.write(`${message.say}: ${fullText}\n`)
|
||||
}
|
||||
}
|
||||
} else if (message.type === "ask") {
|
||||
if (message.ask === "completion_result") {
|
||||
process.stdout.write(`[${timestamp}] Task completed\n`)
|
||||
} else if (message.ask === "api_req_failed") {
|
||||
process.stderr.write(`[${timestamp}] API request failed: ${fullText}\n`)
|
||||
if (message.ask === "api_req_failed") {
|
||||
// Errors always go to stderr
|
||||
process.stderr.write(`Error: API request failed: ${fullText}\n`)
|
||||
} else if (message.ask === "tool" || message.ask === "command" || message.ask === "browser_action_launch") {
|
||||
// These require approval - in non-interactive mode, warn the user
|
||||
process.stderr.write(`[${timestamp}] Waiting for approval (use --yolo for auto-approve): ${message.ask}\n`)
|
||||
// These require approval - warn via stderr
|
||||
process.stderr.write(`Waiting for approval (use --yolo for auto-approve): ${message.ask}\n`)
|
||||
} else if (verbose) {
|
||||
process.stdout.write(`[${timestamp}] Question: ${fullText}\n`)
|
||||
// Verbose output goes to stderr
|
||||
if (message.ask === "plan_mode_respond" || message.ask === "act_mode_respond") {
|
||||
if (fullText) {
|
||||
process.stderr.write(`${fullText}\n`)
|
||||
}
|
||||
} else if (message.ask === "completion_result") {
|
||||
process.stderr.write(`Task completed\n`)
|
||||
} else if (fullText) {
|
||||
process.stderr.write(`Question: ${fullText}\n`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Shared utility for applying provider configuration
|
||||
* Used by both AuthView (onboarding) and SettingsPanelContent (settings)
|
||||
*/
|
||||
|
||||
import { ProviderToApiKeyMap } from "@shared/storage"
|
||||
import { buildApiHandler } from "@/core/api"
|
||||
import type { Controller } from "@/core/controller"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { getDefaultModelId } from "../components/ModelPicker"
|
||||
|
||||
export interface ApplyProviderConfigOptions {
|
||||
providerId: string
|
||||
apiKey?: string
|
||||
modelId?: string // Override default model
|
||||
baseUrl?: string // For OpenAI-compatible providers
|
||||
controller?: Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply provider configuration to state and rebuild API handler if needed
|
||||
*/
|
||||
export async function applyProviderConfig(options: ApplyProviderConfigOptions): Promise<void> {
|
||||
const { providerId, apiKey, modelId, baseUrl, controller } = options
|
||||
const stateManager = StateManager.get()
|
||||
|
||||
const config: Record<string, string> = {
|
||||
actModeApiProvider: providerId,
|
||||
planModeApiProvider: providerId,
|
||||
}
|
||||
|
||||
// Add model ID (use provided or fall back to default)
|
||||
const finalModelId = modelId || getDefaultModelId(providerId)
|
||||
if (finalModelId) {
|
||||
config.actModeApiModelId = finalModelId
|
||||
config.planModeApiModelId = finalModelId
|
||||
}
|
||||
|
||||
// Add API key if provided (maps to provider-specific field like anthropicApiKey, openAiApiKey, etc.)
|
||||
if (apiKey) {
|
||||
const keyField = ProviderToApiKeyMap[providerId as keyof typeof ProviderToApiKeyMap]
|
||||
if (keyField) {
|
||||
const fields = Array.isArray(keyField) ? keyField : [keyField]
|
||||
config[fields[0]] = apiKey
|
||||
}
|
||||
}
|
||||
|
||||
// Add base URL if provided (for OpenAI-compatible providers)
|
||||
if (baseUrl) {
|
||||
config.openAiBaseUrl = baseUrl
|
||||
}
|
||||
|
||||
// Save via StateManager
|
||||
stateManager.setApiConfiguration(config)
|
||||
await stateManager.flushPendingState()
|
||||
|
||||
// Rebuild API handler on active task if one exists
|
||||
if (controller?.task) {
|
||||
const currentMode = stateManager.getGlobalSettingsKey("mode")
|
||||
const apiConfig = stateManager.getApiConfiguration()
|
||||
controller.task.api = buildApiHandler({ ...apiConfig, ulid: controller.task.ulid }, currentMode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Session } from "@/shared/services/Session"
|
||||
|
||||
/**
|
||||
* Format milliseconds to a human-readable duration string
|
||||
*/
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) {
|
||||
return `${ms}ms`
|
||||
}
|
||||
const seconds = ms / 1000
|
||||
if (seconds < 60) {
|
||||
return `${seconds.toFixed(1)}s`
|
||||
}
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = seconds % 60
|
||||
return `${minutes}m ${remainingSeconds.toFixed(0)}s`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a percentage value
|
||||
*/
|
||||
function formatPercent(value: number, total: number): string {
|
||||
if (total === 0) return "0.0%"
|
||||
return `${((value / total) * 100).toFixed(1)}%`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to a human-readable string (KB, MB, GB)
|
||||
*/
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) {
|
||||
return `${bytes}B`
|
||||
}
|
||||
const kb = bytes / 1024
|
||||
if (kb < 1024) {
|
||||
return `${kb.toFixed(1)}KB`
|
||||
}
|
||||
const mb = kb / 1024
|
||||
if (mb < 1024) {
|
||||
return `${mb.toFixed(1)}MB`
|
||||
}
|
||||
const gb = mb / 1024
|
||||
return `${gb.toFixed(2)}GB`
|
||||
}
|
||||
|
||||
// ANSI color codes
|
||||
const GRAY = "\x1b[90m"
|
||||
const GREEN = "\x1b[32m"
|
||||
const RED = "\x1b[31m"
|
||||
const BOLD = "\x1b[1m"
|
||||
const RESET = "\x1b[0m"
|
||||
|
||||
/**
|
||||
* Print session summary to stdout using plain text (not Ink).
|
||||
* Used during shutdown when Ink may not have time to render.
|
||||
*/
|
||||
export function printSessionSummary(): void {
|
||||
const session = Session.get()
|
||||
const stats = session.getStats()
|
||||
const wallTimeMs = session.getWallTimeMs()
|
||||
const agentActiveMs = session.getAgentActiveTimeMs()
|
||||
|
||||
// Don't show if session just started (less than 1 second)
|
||||
if (wallTimeMs < 1000) {
|
||||
return
|
||||
}
|
||||
|
||||
const startTime = session.formatTime(session.getStartTime())
|
||||
const endTime = session.formatTime(session.getEndTime())
|
||||
const sessionTimeStr = `${startTime} → ${endTime}`
|
||||
|
||||
const lines = [
|
||||
"",
|
||||
"┌─────────────────────────────────────────────────────────┐",
|
||||
`│ ${BOLD}Interaction Summary${RESET} │`,
|
||||
"├─────────────────────────────────────────────────────────┤",
|
||||
`│ ${GRAY}Session ID:${RESET} ${stats.sessionId.padEnd(42)}│`,
|
||||
`│ ${GRAY}Session Time:${RESET} ${sessionTimeStr.padEnd(42)}│`,
|
||||
`│ ${GRAY}Tool Calls:${RESET} ${stats.totalToolCalls} ( ${GREEN}✓ ${stats.successfulToolCalls}${RESET} ${RED}✗ ${stats.failedToolCalls}${RESET} )`.padEnd(
|
||||
70,
|
||||
) + "│",
|
||||
`│ ${GRAY}Success Rate:${RESET} ${session.getSuccessRate().toFixed(1)}%`.padEnd(60) + "│",
|
||||
"├─────────────────────────────────────────────────────────┤",
|
||||
`│ ${BOLD}Performance${RESET} │`,
|
||||
`│ ${GRAY}Wall Time:${RESET} ${formatDuration(wallTimeMs).padEnd(42)}│`,
|
||||
`│ ${GRAY}Agent Active:${RESET} ${formatDuration(agentActiveMs).padEnd(42)}│`,
|
||||
`│ ${GRAY} » API Time:${RESET} ${formatDuration(stats.apiTimeMs)} ${GRAY}(${formatPercent(stats.apiTimeMs, agentActiveMs)})${RESET}`.padEnd(
|
||||
60,
|
||||
) + "│",
|
||||
`│ ${GRAY} » Tool Time:${RESET} ${formatDuration(stats.toolTimeMs)} ${GRAY}(${formatPercent(stats.toolTimeMs, agentActiveMs)})${RESET}`.padEnd(
|
||||
60,
|
||||
) + "│",
|
||||
"├─────────────────────────────────────────────────────────┤",
|
||||
`│ ${BOLD}Resources${RESET} │`,
|
||||
`│ ${GRAY}Memory (RSS):${RESET} ${formatBytes(stats.resources.rss).padEnd(42)}│`,
|
||||
`│ ${GRAY}Peak Memory:${RESET} ${formatBytes(stats.peakMemoryBytes).padEnd(42)}│`,
|
||||
`│ ${GRAY}Heap Used:${RESET} ${formatBytes(stats.resources.heapUsed)} ${GRAY}/ ${formatBytes(stats.resources.heapTotal)}${RESET}`.padEnd(
|
||||
60,
|
||||
) + "│",
|
||||
`│ ${GRAY}CPU Time:${RESET} ${formatDuration(stats.resources.userCpuMs + stats.resources.systemCpuMs)} ${GRAY}(user: ${formatDuration(stats.resources.userCpuMs)}, sys: ${formatDuration(stats.resources.systemCpuMs)})${RESET}`.padEnd(
|
||||
60,
|
||||
) + "│",
|
||||
"└─────────────────────────────────────────────────────────┘",
|
||||
"",
|
||||
]
|
||||
|
||||
process.stdout.write(lines.join("\n"))
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import type { SlashCommandInfo } from "@shared/proto/cline/slash"
|
||||
import { fuzzyFilter } from "./fuzzy-search"
|
||||
|
||||
export interface SlashQueryInfo {
|
||||
inSlashMode: boolean
|
||||
@@ -48,23 +49,27 @@ export function sortCommandsWorkflowsFirst(commands: SlashCommandInfo[]): SlashC
|
||||
/**
|
||||
* Extract slash command query from input text.
|
||||
* Returns info about whether we're in slash mode and what the query is.
|
||||
* Takes cursor position to only examine text before cursor (matching webview behavior).
|
||||
*/
|
||||
export function extractSlashQuery(text: string): SlashQueryInfo {
|
||||
// Find the last slash in the text
|
||||
const slashIndex = text.lastIndexOf("/")
|
||||
export function extractSlashQuery(text: string, cursorPosition?: number): SlashQueryInfo {
|
||||
// Use text up to cursor position (or full text if no cursor position provided)
|
||||
const beforeCursor = cursorPosition !== undefined ? text.slice(0, cursorPosition) : text
|
||||
|
||||
// Find the last slash before cursor
|
||||
const slashIndex = beforeCursor.lastIndexOf("/")
|
||||
|
||||
if (slashIndex === -1) {
|
||||
return { inSlashMode: false, query: "", slashIndex: -1 }
|
||||
}
|
||||
|
||||
// Slash must be at start or preceded by whitespace
|
||||
const charBeforeSlash = slashIndex > 0 ? text[slashIndex - 1] : null
|
||||
const charBeforeSlash = slashIndex > 0 ? beforeCursor[slashIndex - 1] : null
|
||||
if (charBeforeSlash !== null && !/\s/.test(charBeforeSlash)) {
|
||||
return { inSlashMode: false, query: "", slashIndex: -1 }
|
||||
}
|
||||
|
||||
// Get text after the slash
|
||||
const textAfterSlash = text.slice(slashIndex + 1)
|
||||
// Get text after slash (up to cursor)
|
||||
const textAfterSlash = beforeCursor.slice(slashIndex + 1)
|
||||
|
||||
// If there's whitespace after slash, we're not in slash mode anymore
|
||||
if (/\s/.test(textAfterSlash)) {
|
||||
@@ -87,13 +92,13 @@ export function extractSlashQuery(text: string): SlashQueryInfo {
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter commands that match the query prefix (case-insensitive)
|
||||
* Filter commands using fuzzy matching
|
||||
*/
|
||||
export function filterCommands(commands: SlashCommandInfo[], query: string): SlashCommandInfo[] {
|
||||
if (!query) {
|
||||
return commands
|
||||
}
|
||||
return commands.filter((cmd) => cmd.name.toLowerCase().startsWith(query.toLowerCase()))
|
||||
return fuzzyFilter(commands, query, (cmd) => cmd.name)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { spawn } from "node:child_process"
|
||||
import { exit } from "node:process"
|
||||
import { fetch } from "@/shared/net"
|
||||
import { printInfo, printWarning } from "./display"
|
||||
|
||||
/**
|
||||
* Check for updates and install if available
|
||||
*/
|
||||
export async function checkForUpdates(currentVersion: string, options?: { verbose?: boolean }) {
|
||||
printInfo("Checking for updates...")
|
||||
|
||||
try {
|
||||
// Fetch latest version from npm registry
|
||||
const response = await fetch("https://registry.npmjs.org/cline/latest")
|
||||
if (!response.ok && response.statusText !== "OK") {
|
||||
printWarning(`Failed to check for updates: ${response.statusText}`)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { version: string }
|
||||
const latestVersion = data.version
|
||||
|
||||
if (options?.verbose) {
|
||||
printInfo(`Current version: ${currentVersion}`)
|
||||
printInfo(`Latest version: ${latestVersion}`)
|
||||
}
|
||||
|
||||
// Compare versions
|
||||
if (latestVersion === currentVersion) {
|
||||
printInfo(`You are already on the latest version (${currentVersion})`)
|
||||
exit(0)
|
||||
}
|
||||
|
||||
// Check if current is newer (dev version)
|
||||
if (compareVersions(currentVersion, latestVersion) > 0) {
|
||||
printInfo(`You are already on a newer version ${currentVersion} (latest: ${latestVersion})`)
|
||||
exit(0)
|
||||
}
|
||||
|
||||
printInfo(`New version available: ${latestVersion} (current: ${currentVersion})`)
|
||||
// Ask user to confirm update
|
||||
const userConfirmed = new Promise<boolean>((resolve) => {
|
||||
process.stdout.write("Do you want to update now? (y/N): ")
|
||||
process.stdin.setEncoding("utf-8")
|
||||
process.stdin.once("data", (dataBuff) => {
|
||||
const input = dataBuff.toString().trim().toLowerCase()
|
||||
resolve(input === "y" || input === "yes")
|
||||
})
|
||||
})
|
||||
|
||||
if (!(await userConfirmed)) {
|
||||
exit(0)
|
||||
}
|
||||
|
||||
printInfo("Installing update...")
|
||||
|
||||
// Run npm install -g cline@latest
|
||||
const npmProcess = spawn("npm", ["install", "-g", "cline@latest"], {
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
// Ensures the process uses the same environment
|
||||
env: process.env,
|
||||
detached: false,
|
||||
windowsHide: true,
|
||||
})
|
||||
|
||||
npmProcess.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
printInfo(`Successfully updated to version ${latestVersion}`)
|
||||
exit(0)
|
||||
} else {
|
||||
printWarning("Update failed. Please try running: npm install -g cline@latest")
|
||||
exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
npmProcess.on("error", (err) => {
|
||||
printWarning(`Failed to run npm install: ${err.message}`)
|
||||
printInfo("Please try running manually: npm install -g cline@latest")
|
||||
exit(1)
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
printWarning(`Error checking for updates: ${message}`)
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two semantic version strings
|
||||
* Returns: 1 if v1 > v2, -1 if v1 < v2, 0 if equal
|
||||
*/
|
||||
function compareVersions(v1: string, v2: string): number {
|
||||
const parts1 = v1.split(".").map(Number)
|
||||
const parts2 = v2.split(".").map(Number)
|
||||
|
||||
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
|
||||
const part1 = parts1[i] || 0
|
||||
const part2 = parts2[i] || 0
|
||||
|
||||
if (part1 > part2) return 1
|
||||
if (part1 < part2) return -1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
@@ -116,10 +116,17 @@ function hashString(str: string): string {
|
||||
return Math.abs(hash).toString(16).substring(0, 8)
|
||||
}
|
||||
|
||||
export interface CliContextResult {
|
||||
extensionContext: ClineExtensionContext
|
||||
DATA_DIR: string
|
||||
EXTENSION_DIR: string
|
||||
WORKSPACE_STORAGE_DIR: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the VSCode-like context for CLI mode
|
||||
*/
|
||||
export function initializeCliContext(config: CliContextConfig = {}) {
|
||||
export function initializeCliContext(config: CliContextConfig = {}): CliContextResult {
|
||||
const CLINE_DIR = config.clineDir || process.env.CLINE_DIR || path.join(os.homedir(), ".cline")
|
||||
const DATA_DIR = path.join(CLINE_DIR, SETTINGS_SUBFOLDER)
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import { CLINE_CLI_DIR } from "./utils/path"
|
||||
export { URI } from "vscode-uri"
|
||||
export { ClineFileStorage } from "@/shared/storage"
|
||||
|
||||
export const CLI_LOG_FILE = path.join(CLINE_CLI_DIR.log, "cline-cli.1.log")
|
||||
|
||||
/**
|
||||
* Safely read and parse a JSON file, returning a default value on failure
|
||||
*/
|
||||
@@ -103,14 +105,14 @@ const outputChannelLoggers = new Map<string, Logger>()
|
||||
function getOutputChannelLogger(channelName: string): Logger {
|
||||
let logger = outputChannelLoggers.get(channelName)
|
||||
if (!logger) {
|
||||
const logFileName = (channelName.trim() || "output").replace(/[^a-zA-Z0-9._-]+/g, "-")
|
||||
const transport = pino.transport({
|
||||
target: "pino-roll",
|
||||
options: {
|
||||
file: path.join(CLINE_CLI_DIR.log, logFileName),
|
||||
name: channelName,
|
||||
file: CLI_LOG_FILE.replace(".1", ""),
|
||||
mkdir: true,
|
||||
frequency: "daily",
|
||||
limit: { count: 2 },
|
||||
limit: { count: 5 },
|
||||
},
|
||||
})
|
||||
logger = pino({ timestamp: pino.stdTimeFunctions.isoTime }, transport)
|
||||
@@ -335,3 +337,13 @@ export type Memento = any
|
||||
export type SecretStorage = any
|
||||
// biome-ignore lint/correctness/noUnusedVariables: placeholder
|
||||
export type Extension<T> = any
|
||||
|
||||
// ============================================================================
|
||||
// Shutdown event for graceful cleanup
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Event emitter for app shutdown notification.
|
||||
* Components can listen to this to clean up UI before process exit.
|
||||
*/
|
||||
export const shutdownEvent = new EventEmitter<void>()
|
||||
|
||||
+15
-15
@@ -16,54 +16,54 @@
|
||||
"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/*"
|
||||
"../src/*"
|
||||
],
|
||||
"@api/*": [
|
||||
"src/core/api/*"
|
||||
"../src/core/api/*"
|
||||
],
|
||||
"@core/*": [
|
||||
"src/core/*"
|
||||
"../src/core/*"
|
||||
],
|
||||
"@generated/*": [
|
||||
"src/generated/*"
|
||||
"../src/generated/*"
|
||||
],
|
||||
"@hosts/*": [
|
||||
"src/hosts/*"
|
||||
"../src/hosts/*"
|
||||
],
|
||||
"@integrations/*": [
|
||||
"src/integrations/*"
|
||||
"../src/integrations/*"
|
||||
],
|
||||
"@packages/*": [
|
||||
"src/packages/*"
|
||||
"../src/packages/*"
|
||||
],
|
||||
"@services/*": [
|
||||
"src/services/*"
|
||||
"../src/services/*"
|
||||
],
|
||||
"@shared/*": [
|
||||
"src/shared/*"
|
||||
"../src/shared/*"
|
||||
],
|
||||
"@utils/*": [
|
||||
"src/utils/*"
|
||||
"../src/utils/*"
|
||||
]
|
||||
},
|
||||
"rootDir": "..",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
"src/**/*",
|
||||
"esbuild.mts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
"dist",
|
||||
"*.tgz"
|
||||
]
|
||||
}
|
||||
|
||||
Generated
+10
@@ -163,6 +163,7 @@
|
||||
"version": "2.0.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.13.1",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.1.0",
|
||||
@@ -207,6 +208,15 @@
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/@agentclientprotocol/sdk": {
|
||||
"version": "0.13.1",
|
||||
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.13.1.tgz",
|
||||
"integrity": "sha512-6byvu+F/xc96GBkdAx4hq6/tB3vT63DSBO4i3gYCz8nuyZMerVFna2Gkhm8EHNpZX0J9DjUxzZCW+rnHXUg0FA==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@alcalzone/ansi-tokenize": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.3.tgz",
|
||||
|
||||
@@ -66,7 +66,7 @@ export interface ToolUse {
|
||||
/**
|
||||
* The call / response ID this tool use is associated with.
|
||||
*/
|
||||
call_id?: string // optional call ID for tracking tool use calls
|
||||
call_id: string // optional call ID for tracking tool use calls
|
||||
/**
|
||||
* Thought signature associated with this tool use, used by Gemini
|
||||
*/
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ClineDefaultTool, toolUseNames } from "@shared/tools"
|
||||
import { nanoid } from "nanoid"
|
||||
import { AssistantMessageContent, TextStreamContent, ToolParamName, ToolUse, toolParamNames } from "." // Assuming types are defined in index.ts or a similar file
|
||||
|
||||
// parseAssistantmessageV1 removed in https://github.com/cline/cline/pull/5425
|
||||
@@ -175,6 +176,8 @@ export function parseAssistantMessageV2(assistantMessage: string): AssistantMess
|
||||
name: toolName,
|
||||
params: {},
|
||||
partial: true, // Assume partial until closing tag is found
|
||||
call_id: nanoid(8),
|
||||
isNativeToolCall: false,
|
||||
}
|
||||
currentToolUseStart = currentCharIndex + 1 // Tool content starts after the opening tag
|
||||
startedNewTool = true
|
||||
|
||||
@@ -39,6 +39,7 @@ import { BannerCardData } from "@/shared/cline/banner"
|
||||
import { getAxiosSettings } from "@/shared/net"
|
||||
import { ShowMessageType } from "@/shared/proto/host/window"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { Session } from "@/shared/services/Session"
|
||||
import { getLatestAnnouncementId } from "@/utils/announcements"
|
||||
import { getCwd, getDesktopDir } from "@/utils/path"
|
||||
import { PromptRegistry } from "../prompts/system-prompt"
|
||||
@@ -118,6 +119,7 @@ export class Controller {
|
||||
}
|
||||
|
||||
constructor(readonly context: vscode.ExtensionContext) {
|
||||
Session.reset() // Reset session on controller initialization
|
||||
PromptRegistry.getInstance() // Ensure prompts and tools are registered
|
||||
this.stateManager = StateManager.get()
|
||||
StateManager.get().registerCallbacks({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ToolUse } from "@core/assistant-message"
|
||||
import { JSONParser } from "@streamparser/json"
|
||||
import { nanoid } from "nanoid"
|
||||
import { McpHub } from "@/services/mcp/McpHub"
|
||||
import { CLINE_MCP_TOOL_IDENTIFIER } from "@/shared/mcp"
|
||||
import {
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
ClineAssistantToolUseBlock,
|
||||
ClineReasoningDetailParam,
|
||||
} from "@/shared/messages/content"
|
||||
import { Session } from "@/shared/services/Session"
|
||||
import { ClineDefaultTool } from "@/shared/tools"
|
||||
|
||||
export interface PendingToolUse {
|
||||
@@ -17,7 +19,7 @@ export interface PendingToolUse {
|
||||
parsedInput?: unknown
|
||||
signature?: string
|
||||
jsonParser?: JSONParser
|
||||
call_id?: string
|
||||
call_id: string
|
||||
}
|
||||
|
||||
interface ToolUseDeltaBlock {
|
||||
@@ -225,18 +227,8 @@ class ToolUseHandler {
|
||||
this.pendingToolUses.clear()
|
||||
}
|
||||
|
||||
private createPendingToolUse(id: string, name: string, call_id?: string): PendingToolUse {
|
||||
private createPendingToolUse(id: string, name: string, callId?: string): PendingToolUse {
|
||||
const jsonParser = new JSONParser()
|
||||
const pending: PendingToolUse = {
|
||||
id,
|
||||
name,
|
||||
input: "",
|
||||
parsedInput: undefined,
|
||||
jsonParser,
|
||||
call_id,
|
||||
signature: undefined,
|
||||
}
|
||||
|
||||
jsonParser.onValue = (info: any) => {
|
||||
if (info.stack.length === 0 && info.value && typeof info.value === "object") {
|
||||
pending.parsedInput = info.value
|
||||
@@ -245,7 +237,21 @@ class ToolUseHandler {
|
||||
|
||||
jsonParser.onError = () => {}
|
||||
|
||||
const pending: PendingToolUse = {
|
||||
id,
|
||||
name,
|
||||
input: "",
|
||||
parsedInput: undefined,
|
||||
jsonParser,
|
||||
// Ensure call_id is always set for tracking
|
||||
call_id: callId || id || nanoid(8),
|
||||
signature: undefined,
|
||||
}
|
||||
|
||||
this.pendingToolUses.set(id, pending)
|
||||
// Initialize tool call in session tracking
|
||||
Session.get().updateToolCall(pending.call_id, pending.name)
|
||||
|
||||
return pending
|
||||
}
|
||||
|
||||
|
||||
+25
-11
@@ -96,6 +96,7 @@ import {
|
||||
import { ApiFormat } from "@/shared/proto/cline/models"
|
||||
import { ShowMessageType } from "@/shared/proto/index.host"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { Session } from "@/shared/services/Session"
|
||||
import { isClineCliInstalled, isCliSubagentContext } from "@/utils/cli-detector"
|
||||
import { RuleContextBuilder } from "../context/instructions/user-instructions/RuleContextBuilder"
|
||||
import { ensureLocalClineDirExists } from "../context/instructions/user-instructions/rule-helpers"
|
||||
@@ -739,10 +740,14 @@ export class Task {
|
||||
if (partial) {
|
||||
if (isUpdatingPreviousPartial) {
|
||||
// existing partial message, so update it
|
||||
lastMessage.text = text
|
||||
lastMessage.images = images
|
||||
lastMessage.files = files
|
||||
lastMessage.partial = partial
|
||||
const lastIndex = this.messageStateHandler.getClineMessages().length - 1
|
||||
await this.messageStateHandler.updateClineMessage(lastIndex, {
|
||||
text,
|
||||
images,
|
||||
files,
|
||||
partial,
|
||||
})
|
||||
|
||||
const protoMessage = convertClineMessageToProto(lastMessage)
|
||||
await sendPartialMessageEvent(protoMessage)
|
||||
return undefined
|
||||
@@ -768,14 +773,15 @@ export class Task {
|
||||
if (isUpdatingPreviousPartial) {
|
||||
// this is the complete version of a previously partial message, so replace the partial with the complete version
|
||||
this.taskState.lastMessageTs = lastMessage.ts
|
||||
// lastMessage.ts = sayTs
|
||||
lastMessage.text = text
|
||||
lastMessage.images = images
|
||||
lastMessage.files = files // Ensure files is updated
|
||||
lastMessage.partial = false
|
||||
const lastIndex = this.messageStateHandler.getClineMessages().length - 1
|
||||
// updateClineMessage emits the change event and saves to disk
|
||||
await this.messageStateHandler.updateClineMessage(lastIndex, {
|
||||
text,
|
||||
images,
|
||||
files,
|
||||
partial: false,
|
||||
})
|
||||
|
||||
// instead of streaming partialMessage events, we do a save and post like normal to persist to disk
|
||||
await this.messageStateHandler.saveClineMessagesAndUpdateHistory()
|
||||
// await this.postStateToWebview()
|
||||
const protoMessage = convertClineMessageToProto(lastMessage)
|
||||
await sendPartialMessageEvent(protoMessage) // more performant than an entire postStateToWebview
|
||||
@@ -2145,6 +2151,7 @@ export class Task {
|
||||
}
|
||||
}
|
||||
await this.toolExecutor.executeTool(block)
|
||||
Session.get().updateToolCall(block.call_id, block.name)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -2487,6 +2494,8 @@ export class Task {
|
||||
} = { cacheWriteTokens: 0, cacheReadTokens: 0, inputTokens: 0, outputTokens: 0, totalCost: undefined }
|
||||
|
||||
const abortStream = async (cancelReason: ClineApiReqCancelReason, streamingFailedMessage?: string) => {
|
||||
Session.get().finalizeRequest()
|
||||
|
||||
if (this.diffViewProvider.isEditing) {
|
||||
await this.diffViewProvider.revertChanges() // closes diff view
|
||||
}
|
||||
@@ -2587,6 +2596,9 @@ export class Task {
|
||||
this.taskState.isStreaming = true
|
||||
let didReceiveUsageChunk = false
|
||||
|
||||
// Track API call time for session statistics
|
||||
Session.get().startApiCall()
|
||||
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
switch (chunk.type) {
|
||||
@@ -2756,6 +2768,8 @@ export class Task {
|
||||
}
|
||||
} finally {
|
||||
this.taskState.isStreaming = false
|
||||
// End API call tracking for session statistics
|
||||
Session.get().endApiCall()
|
||||
}
|
||||
|
||||
// Finalize any remaining tool calls at the end of the stream
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import CheckpointTracker from "@integrations/checkpoints/CheckpointTracker"
|
||||
import { EventEmitter } from "events"
|
||||
import getFolderSize from "get-folder-size"
|
||||
import Mutex from "p-mutex"
|
||||
import { findLastIndex } from "@/shared/array"
|
||||
@@ -13,6 +14,28 @@ import { getCwd, getDesktopDir } from "@/utils/path"
|
||||
import { ensureTaskDirectoryExists, saveApiConversationHistory, saveClineMessages } from "../storage/disk"
|
||||
import { TaskState } from "./TaskState"
|
||||
|
||||
// Event types for clineMessages changes
|
||||
export type ClineMessageChangeType = "add" | "update" | "delete" | "set"
|
||||
|
||||
export interface ClineMessageChange {
|
||||
type: ClineMessageChangeType
|
||||
/** The full array after the change */
|
||||
messages: ClineMessage[]
|
||||
/** The affected index (for add/update/delete) */
|
||||
index?: number
|
||||
/** The new/updated message (for add/update) */
|
||||
message?: ClineMessage
|
||||
/** The old message before change (for update/delete) */
|
||||
previousMessage?: ClineMessage
|
||||
/** The entire previous array (for set) */
|
||||
previousMessages?: ClineMessage[]
|
||||
}
|
||||
|
||||
// Strongly-typed event emitter interface
|
||||
export interface MessageStateHandlerEvents {
|
||||
clineMessagesChanged: [change: ClineMessageChange]
|
||||
}
|
||||
|
||||
interface MessageStateHandlerParams {
|
||||
taskId: string
|
||||
ulid: string
|
||||
@@ -22,7 +45,7 @@ interface MessageStateHandlerParams {
|
||||
checkpointManagerErrorMessage?: string
|
||||
}
|
||||
|
||||
export class MessageStateHandler {
|
||||
export class MessageStateHandler extends EventEmitter<MessageStateHandlerEvents> {
|
||||
private apiConversationHistory: ClineStorageMessage[] = []
|
||||
private clineMessages: ClineMessage[] = []
|
||||
private taskIsFavorited: boolean
|
||||
@@ -39,6 +62,7 @@ export class MessageStateHandler {
|
||||
private stateMutex = new Mutex()
|
||||
|
||||
constructor(params: MessageStateHandlerParams) {
|
||||
super()
|
||||
this.taskId = params.taskId
|
||||
this.ulid = params.ulid
|
||||
this.taskState = params.taskState
|
||||
@@ -46,6 +70,13 @@ export class MessageStateHandler {
|
||||
this.updateTaskHistory = params.updateTaskHistory
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a clineMessagesChanged event with the change details
|
||||
*/
|
||||
private emitClineMessagesChanged(change: ClineMessageChange): void {
|
||||
this.emit("clineMessagesChanged", change)
|
||||
}
|
||||
|
||||
setCheckpointTracker(tracker: CheckpointTracker | undefined) {
|
||||
this.checkpointTracker = tracker
|
||||
}
|
||||
@@ -72,7 +103,13 @@ export class MessageStateHandler {
|
||||
}
|
||||
|
||||
setClineMessages(newMessages: ClineMessage[]) {
|
||||
const previousMessages = this.clineMessages
|
||||
this.clineMessages = newMessages
|
||||
this.emitClineMessagesChanged({
|
||||
type: "set",
|
||||
messages: this.clineMessages,
|
||||
previousMessages,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,7 +203,14 @@ export class MessageStateHandler {
|
||||
// it's important that apiConversationHistory is initialized before we add cline messages
|
||||
message.conversationHistoryIndex = this.apiConversationHistory.length - 1 // NOTE: this is the index of the last added message which is the user message, and once the clinemessages have been presented we update the apiconversationhistory with the completed assistant message. This means when resetting to a message, we need to +1 this index to get the correct assistant message that this tool use corresponds to
|
||||
message.conversationHistoryDeletedRange = this.taskState.conversationHistoryDeletedRange
|
||||
const index = this.clineMessages.length
|
||||
this.clineMessages.push(message)
|
||||
this.emitClineMessagesChanged({
|
||||
type: "add",
|
||||
messages: this.clineMessages,
|
||||
index,
|
||||
message,
|
||||
})
|
||||
await this.saveClineMessagesAndUpdateHistoryInternal()
|
||||
})
|
||||
}
|
||||
@@ -177,7 +221,13 @@ export class MessageStateHandler {
|
||||
*/
|
||||
async overwriteClineMessages(newMessages: ClineMessage[]) {
|
||||
return await this.withStateLock(async () => {
|
||||
const previousMessages = this.clineMessages
|
||||
this.clineMessages = newMessages
|
||||
this.emitClineMessagesChanged({
|
||||
type: "set",
|
||||
messages: this.clineMessages,
|
||||
previousMessages,
|
||||
})
|
||||
await this.saveClineMessagesAndUpdateHistoryInternal()
|
||||
})
|
||||
}
|
||||
@@ -192,9 +242,20 @@ export class MessageStateHandler {
|
||||
throw new Error(`Invalid message index: ${index}`)
|
||||
}
|
||||
|
||||
// Capture previous state before mutation
|
||||
const previousMessage = { ...this.clineMessages[index] }
|
||||
|
||||
// Apply updates to the message
|
||||
Object.assign(this.clineMessages[index], updates)
|
||||
|
||||
this.emitClineMessagesChanged({
|
||||
type: "update",
|
||||
messages: this.clineMessages,
|
||||
index,
|
||||
previousMessage,
|
||||
message: this.clineMessages[index],
|
||||
})
|
||||
|
||||
// Save changes and update history
|
||||
await this.saveClineMessagesAndUpdateHistoryInternal()
|
||||
})
|
||||
@@ -210,9 +271,19 @@ export class MessageStateHandler {
|
||||
throw new Error(`Invalid message index: ${index}`)
|
||||
}
|
||||
|
||||
// Capture the message before deletion
|
||||
const previousMessage = this.clineMessages[index]
|
||||
|
||||
// Remove the message at the specified index
|
||||
this.clineMessages.splice(index, 1)
|
||||
|
||||
this.emitClineMessagesChanged({
|
||||
type: "delete",
|
||||
messages: this.clineMessages,
|
||||
index,
|
||||
previousMessage,
|
||||
})
|
||||
|
||||
// Save changes and update history
|
||||
await this.saveClineMessagesAndUpdateHistoryInternal()
|
||||
})
|
||||
|
||||
@@ -495,11 +495,16 @@ export class OpenAiCodexOAuthManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user is authenticated
|
||||
* Check if the user has stored credentials (i.e. has completed auth).
|
||||
* This intentionally does NOT attempt a token refresh so that transient
|
||||
* network failures or expired-but-refreshable tokens don't cause the
|
||||
* CLI to bounce the user back to the onboarding flow.
|
||||
*/
|
||||
async isAuthenticated(): Promise<boolean> {
|
||||
const token = await this.getAccessToken()
|
||||
return token !== null
|
||||
if (!this.credentials) {
|
||||
await this.loadCredentials()
|
||||
}
|
||||
return this.credentials !== null
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -63,3 +63,30 @@ export function getApiMetrics(messages: ClineMessage[]): ApiMetrics {
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the total token count from the last API request.
|
||||
*
|
||||
* This is used for context window progress display - it shows how much of the
|
||||
* context window is used in the current/most recent request, not cumulative totals.
|
||||
*
|
||||
* @param messages - An array of ClineMessage objects to process.
|
||||
* @returns The total tokens (tokensIn + tokensOut + cacheWrites + cacheReads) from the last api_req_started message, or 0 if none found.
|
||||
*/
|
||||
export function getLastApiReqTotalTokens(messages: ClineMessage[]): number {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i]
|
||||
if (msg.type === "say" && msg.say === "api_req_started" && msg.text) {
|
||||
try {
|
||||
const { tokensIn, tokensOut, cacheWrites, cacheReads } = JSON.parse(msg.text)
|
||||
const total = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
|
||||
if (total > 0) {
|
||||
return total
|
||||
}
|
||||
} catch {
|
||||
// Ignore JSON parse errors, continue searching
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { nanoid } from "nanoid"
|
||||
|
||||
export interface ToolCallRecord {
|
||||
name: string
|
||||
success?: boolean
|
||||
startTime: number
|
||||
lastUpdateTime: number
|
||||
}
|
||||
|
||||
export interface ResourceUsage {
|
||||
// Memory (in bytes)
|
||||
heapUsed: number
|
||||
heapTotal: number
|
||||
external: number
|
||||
rss: number // Resident Set Size - total memory allocated for the process
|
||||
// CPU time (in milliseconds)
|
||||
userCpuMs: number
|
||||
systemCpuMs: number
|
||||
}
|
||||
|
||||
export interface SessionStats {
|
||||
sessionId: string
|
||||
// Tool calls
|
||||
totalToolCalls: number
|
||||
successfulToolCalls: number
|
||||
failedToolCalls: number
|
||||
// Timing
|
||||
sessionStartTime: number
|
||||
apiTimeMs: number
|
||||
toolTimeMs: number
|
||||
// Resources
|
||||
resources: ResourceUsage
|
||||
peakMemoryBytes: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Session singleton for tracking current session statistics.
|
||||
* Used by CLI to display interaction summary.
|
||||
*/
|
||||
export class Session {
|
||||
private static instance: Session | null = null
|
||||
|
||||
private sessionId: string
|
||||
private sessionStartTime: number
|
||||
private toolCalls: ToolCallRecord[] = []
|
||||
private apiTimeMs: number = 0
|
||||
private toolTimeMs: number = 0
|
||||
|
||||
// Track in-flight operations
|
||||
private currentApiCallStart: number | null = null
|
||||
private inFlightToolCalls: Map<string, ToolCallRecord> = new Map()
|
||||
|
||||
// Resource tracking
|
||||
private initialCpuUsage: NodeJS.CpuUsage
|
||||
private peakMemoryBytes: number = 0
|
||||
|
||||
private constructor() {
|
||||
this.sessionId = nanoid(10)
|
||||
this.sessionStartTime = Date.now()
|
||||
this.initialCpuUsage = process.cpuUsage()
|
||||
this.updatePeakMemory()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update peak memory if current usage is higher.
|
||||
*/
|
||||
private updatePeakMemory(): void {
|
||||
const memUsage = process.memoryUsage()
|
||||
if (memUsage.rss > this.peakMemoryBytes) {
|
||||
this.peakMemoryBytes = memUsage.rss
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current resource usage for this process.
|
||||
*/
|
||||
getResourceUsage(): ResourceUsage {
|
||||
this.updatePeakMemory()
|
||||
const memUsage = process.memoryUsage()
|
||||
const cpuUsage = process.cpuUsage(this.initialCpuUsage)
|
||||
|
||||
return {
|
||||
heapUsed: memUsage.heapUsed,
|
||||
heapTotal: memUsage.heapTotal,
|
||||
external: memUsage.external,
|
||||
rss: memUsage.rss,
|
||||
// cpuUsage returns microseconds, convert to milliseconds
|
||||
userCpuMs: cpuUsage.user / 1000,
|
||||
systemCpuMs: cpuUsage.system / 1000,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton instance, creating it if necessary.
|
||||
*/
|
||||
static get(): Session {
|
||||
if (!Session.instance) {
|
||||
Session.instance = new Session()
|
||||
}
|
||||
return Session.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the session (creates a new session with fresh ID and stats).
|
||||
*/
|
||||
static reset(): Session {
|
||||
Session.instance = new Session()
|
||||
return Session.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current session ID.
|
||||
*/
|
||||
getSessionId(): string {
|
||||
return this.sessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the start of an API call.
|
||||
*/
|
||||
startApiCall(): void {
|
||||
this.currentApiCallStart = Date.now()
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the end of an API call.
|
||||
*/
|
||||
endApiCall(): void {
|
||||
if (this.currentApiCallStart !== null) {
|
||||
this.apiTimeMs += Date.now() - this.currentApiCallStart
|
||||
this.currentApiCallStart = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a tool call - starts tracking if new, updates lastUpdateTime if existing.
|
||||
* @param callId - Unique identifier for this tool call
|
||||
* @param toolName - The name of the tool (required when starting a new call)
|
||||
* @param success - Optional success status (only set when finalizing)
|
||||
*/
|
||||
updateToolCall(callId: string, toolName: string, success?: boolean): void {
|
||||
const now = Date.now()
|
||||
const existing = this.inFlightToolCalls.get(callId)
|
||||
|
||||
if (existing) {
|
||||
// Update existing tool call
|
||||
existing.lastUpdateTime = now
|
||||
if (success !== undefined) {
|
||||
existing.success = success
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Start tracking new tool call
|
||||
this.inFlightToolCalls.set(callId, {
|
||||
name: toolName,
|
||||
startTime: now,
|
||||
lastUpdateTime: now,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Add API time directly (useful when timing is tracked elsewhere).
|
||||
*/
|
||||
addApiTime(ms: number): void {
|
||||
this.apiTimeMs += ms
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize a request - moves all in-flight tool calls to completed and calculates durations.
|
||||
* Call this when an API request completes to close out all pending tool calls.
|
||||
*/
|
||||
finalizeRequest(): void {
|
||||
for (const [callId, record] of this.inFlightToolCalls) {
|
||||
const duration = record.lastUpdateTime - record.startTime
|
||||
this.toolTimeMs += duration
|
||||
this.toolCalls.push({
|
||||
name: record.name,
|
||||
success: record.success,
|
||||
startTime: record.startTime,
|
||||
lastUpdateTime: record.lastUpdateTime,
|
||||
})
|
||||
this.inFlightToolCalls.delete(callId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all session statistics.
|
||||
* Includes in-flight tool calls in the totals using their lastUpdateTime as end time.
|
||||
*/
|
||||
getStats(): SessionStats {
|
||||
this.finalizeRequest()
|
||||
|
||||
// Combine completed and in-flight for totals
|
||||
const allToolCalls = this.toolCalls
|
||||
const successful = allToolCalls.filter((t) => t.success === true).length
|
||||
const failed = allToolCalls.filter((t) => t.success === false).length
|
||||
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
totalToolCalls: allToolCalls.length,
|
||||
successfulToolCalls: successful,
|
||||
failedToolCalls: failed,
|
||||
sessionStartTime: this.sessionStartTime,
|
||||
apiTimeMs: this.apiTimeMs,
|
||||
toolTimeMs: this.toolTimeMs,
|
||||
resources: this.getResourceUsage(),
|
||||
peakMemoryBytes: this.peakMemoryBytes,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the wall time (time since session started) in milliseconds.
|
||||
*/
|
||||
getWallTimeMs(): number {
|
||||
return Date.now() - this.sessionStartTime
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the session start time as a Date object.
|
||||
*/
|
||||
getStartTime(): Date {
|
||||
return new Date(this.sessionStartTime)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current time (session end time) as a Date object.
|
||||
*/
|
||||
getEndTime(): Date {
|
||||
return new Date()
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a timestamp for display (e.g., "2:34:56 PM").
|
||||
*/
|
||||
formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the agent active time (API time + tool time) in milliseconds.
|
||||
* Includes in-flight tool calls.
|
||||
*/
|
||||
getAgentActiveTimeMs(): number {
|
||||
const stats = this.getStats()
|
||||
return this.apiTimeMs + stats.toolTimeMs
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the success rate as a percentage (0-100).
|
||||
* Includes in-flight tool calls.
|
||||
*/
|
||||
getSuccessRate(): number {
|
||||
const stats = this.getStats()
|
||||
if (stats.totalToolCalls === 0) {
|
||||
return 0
|
||||
}
|
||||
return (stats.successfulToolCalls / stats.totalToolCalls) * 100
|
||||
}
|
||||
}
|
||||
@@ -67,4 +67,16 @@ export const CLI_ONLY_COMMANDS: SlashCommand[] = [
|
||||
section: "default",
|
||||
cliCompatible: true,
|
||||
},
|
||||
{
|
||||
name: "history",
|
||||
description: "Browse and search task history",
|
||||
section: "default",
|
||||
cliCompatible: true,
|
||||
},
|
||||
{
|
||||
name: "clear",
|
||||
description: "Clear the current task and start fresh",
|
||||
section: "default",
|
||||
cliCompatible: true,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ApiProvider } from "@shared/api"
|
||||
|
||||
/**
|
||||
* Filters OpenRouter model IDs based on provider-specific rules.
|
||||
* For Cline provider: excludes :free models (except Minimax and Devstral models)
|
||||
* For OpenRouter/Vercel: excludes cline/ prefixed models
|
||||
* @param modelIds Array of model IDs to filter
|
||||
* @param provider The current API provider
|
||||
* @returns Filtered array of model IDs
|
||||
*/
|
||||
export function filterOpenRouterModelIds(modelIds: string[], provider: ApiProvider): string[] {
|
||||
if (provider === "cline") {
|
||||
// For Cline provider: exclude :free models, but keep Minimax and Devstral models
|
||||
return modelIds.filter((id) => {
|
||||
// Keep all Minimax and devstral models regardless of :free suffix
|
||||
if (id.toLowerCase().includes("minimax-m2") || id.toLowerCase().includes("devstral-2512")) {
|
||||
return true
|
||||
}
|
||||
// Filter out other :free models
|
||||
return !id.includes(":free")
|
||||
})
|
||||
}
|
||||
|
||||
// For OpenRouter and Vercel AI Gateway providers: exclude Cline-specific models
|
||||
return modelIds.filter((id) => !id.startsWith("cline/"))
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import { findLast } from "@shared/array"
|
||||
import { combineApiRequests } from "@shared/combineApiRequests"
|
||||
import { combineCommandSequences } from "@shared/combineCommandSequences"
|
||||
import { combineErrorRetryMessages } from "@shared/combineErrorRetryMessages"
|
||||
import { combineHookSequences } from "@shared/combineHookSequences"
|
||||
import type { ClineApiReqInfo, ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { getApiMetrics } from "@shared/getApiMetrics"
|
||||
import { getApiMetrics, getLastApiReqTotalTokens } from "@shared/getApiMetrics"
|
||||
import { BooleanRequest, StringRequest } from "@shared/proto/cline/common"
|
||||
import { useCallback, useEffect, useMemo } from "react"
|
||||
import { useMount } from "react-use"
|
||||
@@ -70,25 +68,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
// has to be after api_req_finished are all reduced into api_req_started messages
|
||||
const apiMetrics = useMemo(() => getApiMetrics(modifiedMessages), [modifiedMessages])
|
||||
|
||||
const lastApiReqTotalTokens = useMemo(() => {
|
||||
const getTotalTokensFromApiReqMessage = (msg: ClineMessage) => {
|
||||
if (!msg.text) {
|
||||
return 0
|
||||
}
|
||||
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(msg.text)
|
||||
return (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
|
||||
}
|
||||
const lastApiReqMessage = findLast(modifiedMessages, (msg) => {
|
||||
if (msg.say !== "api_req_started") {
|
||||
return false
|
||||
}
|
||||
return getTotalTokensFromApiReqMessage(msg) > 0
|
||||
})
|
||||
if (!lastApiReqMessage) {
|
||||
return undefined
|
||||
}
|
||||
return getTotalTokensFromApiReqMessage(lastApiReqMessage)
|
||||
}, [modifiedMessages])
|
||||
const lastApiReqTotalTokens = useMemo(() => getLastApiReqTotalTokens(modifiedMessages) || undefined, [modifiedMessages])
|
||||
|
||||
// Use custom hooks for state management
|
||||
const chatState = useChatState(messages)
|
||||
|
||||
@@ -811,30 +811,8 @@ export async function syncModeConfigurations(
|
||||
await handleFieldsChange(updates)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters OpenRouter model IDs based on provider-specific rules.
|
||||
* For Cline provider: excludes :free models (except Minimax models)
|
||||
* For OpenRouter/Vercel: excludes cline/ prefixed models
|
||||
* @param modelIds Array of model IDs to filter
|
||||
* @param provider The current API provider
|
||||
* @returns Filtered array of model IDs
|
||||
*/
|
||||
export function filterOpenRouterModelIds(modelIds: string[], provider: ApiProvider): string[] {
|
||||
if (provider === "cline") {
|
||||
// For Cline provider: exclude :free models, but keep Minimax models
|
||||
return modelIds.filter((id) => {
|
||||
// Keep all Minimax and devstral models regardless of :free suffix
|
||||
if (id.toLowerCase().includes("minimax-m2") || id.toLowerCase().includes("devstral-2512")) {
|
||||
return true
|
||||
}
|
||||
// Filter out other :free models
|
||||
return !id.includes(":free")
|
||||
})
|
||||
}
|
||||
|
||||
// For OpenRouter and Vercel AI Gateway providers: exclude Cline-specific models
|
||||
return modelIds.filter((id) => !id.startsWith("cline/"))
|
||||
}
|
||||
// Re-export from shared module for backwards compatibility
|
||||
export { filterOpenRouterModelIds } from "@shared/utils/model-filters"
|
||||
|
||||
// Helper to get provider-specific configuration info and empty state guidance
|
||||
export const getProviderInfo = (
|
||||
|
||||
Reference in New Issue
Block a user