mirror of
https://github.com/cline/cline.git
synced 2026-09-05 05:02:27 +08:00
Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa2ba4e904 | |||
| 8c00eb9a77 | |||
| 1a4122eb53 | |||
| e6fa037c4c | |||
| 6e0ae8db58 | |||
| 0b1fe6a982 | |||
| c18eef5d4c | |||
| 65443a01ae | |||
| 7d56dd1e69 | |||
| 501f512066 | |||
| 5810a5d1e7 | |||
| 6a262b395a | |||
| 5117b7b0b8 | |||
| 30ce8b1e72 | |||
| 2fcac58e24 | |||
| 112dbaa15b | |||
| 996a2f6a9c | |||
| 47f6d00f61 | |||
| 9ba2c932b4 | |||
| 01aaa5dd04 | |||
| 38bac25721 | |||
| b4d1b83bad | |||
| 8e6780201c | |||
| 087c6449c3 | |||
| 5cd72e5892 | |||
| 2b2fa8a473 | |||
| acca9186f1 | |||
| f3bddc4ec1 | |||
| 5c9cd557c7 | |||
| 0383be5375 | |||
| 4ee1ce507e | |||
| 22b3d654db | |||
| 52493c5ba1 | |||
| e65e5d5900 | |||
| b144baf102 | |||
| f70725dd57 | |||
| 3821f7e52e | |||
| 8bbd1ea0f1 | |||
| 9739e4d11f | |||
| c8d6efd831 | |||
| b17dfa1593 | |||
| d70b0d913f | |||
| 1468a7445d | |||
| 83dcd4c178 | |||
| 18181829f2 | |||
| 8688678ea7 | |||
| da58ddd35e | |||
| 1f5e2086a1 | |||
| 59e5eac3e0 | |||
| fd2c5e1a73 | |||
| 6d93c8af2d | |||
| 1acb2b428c | |||
| be55c3ac5d | |||
| 52872f300c | |||
| 8c7617cead | |||
| d62077d59a | |||
| fece9f232e | |||
| 99b65b421a | |||
| 80783b62cb |
@@ -0,0 +1,208 @@
|
||||
---
|
||||
name: cline-sdk
|
||||
description: Comprehensive Cline SDK skill for building AI agents. Covers the Agent runtime, ClineCore sessions, custom tools, plugins, events, LLM providers, scheduling, multi-agent teams, and production deployment. Use for any task involving @cline/sdk or its sub-packages.
|
||||
metadata:
|
||||
references: agent, clinecore
|
||||
---
|
||||
|
||||
# Cline SDK Skill
|
||||
|
||||
Consolidated skill for building AI agents with the Cline SDK. Use the decision trees below to find the right entry point and API surface, then load detailed references.
|
||||
|
||||
## Critical Rules
|
||||
|
||||
Follow these rules in all Cline SDK code:
|
||||
|
||||
1. Install with `npm install @cline/sdk`. The `@cline/sdk` package re-exports everything from `@cline/core`, `@cline/agents`, `@cline/llms`, and `@cline/shared`.
|
||||
2. Requires Node.js 22 or later.
|
||||
3. Use `createTool()` from `@cline/sdk` (or `@cline/shared`) to define tools. Tool names must be `snake_case`.
|
||||
4. Return errors as structured data from tool `execute` functions. Throwing counts as a "mistake" against the agent's mistake limit.
|
||||
5. Use `lifecycle: { completesRun: true }` on tools that should end the agent loop (e.g. a "submit answer" tool).
|
||||
6. When using `ClineCore`, always call `dispose()` when done to clean up resources.
|
||||
7. The standalone `Agent` and `ClineCore` have different event systems. For `Agent`: use `agent.subscribe()` to get `AgentRuntimeEvent` types (text streaming is `"assistant-text-delta"`, result text is `result.outputText`). For `ClineCore`: use `cline.subscribe()` to get `CoreSessionEvent` types (text streaming is `"chunk"` with `payload.type === "text"`, result text is `result.text`). There is no top-level `onEvent` field on `AgentRuntimeConfig` -- use `agent.subscribe()` or `hooks.onEvent` instead. Do not use event types like `"content_update"` or `"content_start"` with `agent.subscribe()` -- those are internal legacy types from the ClineCore adapter layer.
|
||||
|
||||
## How to Use This Skill
|
||||
|
||||
### Reference File Structure
|
||||
|
||||
The two main API surfaces (`Agent` and `ClineCore`) follow a 4-file pattern. Cross-cutting concepts are single-file guides.
|
||||
|
||||
Each main API surface in `./references/<api>/` contains:
|
||||
|
||||
| File | Purpose | When to Read |
|
||||
|------|---------|--------------|
|
||||
| `REFERENCE.md` | Overview, when to use, quick start | Always read first |
|
||||
| `api.md` | Full API: classes, methods, config, types | Writing code |
|
||||
| `patterns.md` | Common patterns, best practices | Implementation guidance |
|
||||
| `gotchas.md` | Pitfalls, limitations, debugging | Troubleshooting |
|
||||
|
||||
Cross-cutting concepts in `./references/<concept>/` have `REFERENCE.md` as the entry point.
|
||||
|
||||
### Reading Order
|
||||
|
||||
1. Start with `REFERENCE.md` for your chosen API surface
|
||||
2. Then read additional files relevant to your task:
|
||||
- Writing agent code -> `api.md`
|
||||
- Common patterns -> `patterns.md`
|
||||
- Creating tools -> `tools/REFERENCE.md`
|
||||
- Adding plugins/hooks -> `plugins/REFERENCE.md`
|
||||
- Configuring LLM providers -> `providers/REFERENCE.md`
|
||||
- Streaming events -> `events/REFERENCE.md`
|
||||
- Deploying to production -> `production/REFERENCE.md`
|
||||
- Scheduling agents -> `scheduling/REFERENCE.md`
|
||||
- Multi-agent orchestration -> `multi-agent/REFERENCE.md`
|
||||
- Debugging -> `gotchas.md`
|
||||
|
||||
### Example Paths
|
||||
|
||||
```
|
||||
./references/agent/REFERENCE.md # Start here for lightweight agents
|
||||
./references/clinecore/REFERENCE.md # Start here for full runtime
|
||||
./references/agent/api.md # Agent class, config, methods
|
||||
./references/tools/REFERENCE.md # Creating and using tools
|
||||
./references/plugins/REFERENCE.md # Plugin system
|
||||
./references/providers/REFERENCE.md # LLM provider configuration
|
||||
```
|
||||
|
||||
## Quick Decision Trees
|
||||
|
||||
### "Which API surface should I use?"
|
||||
|
||||
```
|
||||
Which API?
|
||||
+-- I want a simple, stateless agent with custom tools
|
||||
| +-- agent/ (Agent class from @cline/agents)
|
||||
+-- I need session persistence, built-in tools, config discovery
|
||||
| +-- clinecore/ (ClineCore from @cline/core)
|
||||
+-- I want built-in file/shell/search/web tools
|
||||
| +-- clinecore/ (has built-in tools; Agent does not)
|
||||
+-- I want scheduled or recurring agents
|
||||
| +-- clinecore/ (automation API)
|
||||
+-- I need multi-process or multi-client session sharing
|
||||
| +-- clinecore/ (hub-backed runtime)
|
||||
+-- I'm building a browser-compatible agent
|
||||
| +-- agent/ (no Node.js dependencies)
|
||||
```
|
||||
|
||||
### "I need to create tools"
|
||||
|
||||
```
|
||||
Tools?
|
||||
+-- Define a custom tool with schema -> tools/REFERENCE.md
|
||||
+-- Use built-in tools (bash, editor, read_files) -> tools/REFERENCE.md (built-in section)
|
||||
+-- Control tool approval/policies -> tools/REFERENCE.md (policies section)
|
||||
+-- Tool that ends the agent loop -> tools/REFERENCE.md (completion tools)
|
||||
+-- Package tools as a reusable plugin -> plugins/REFERENCE.md
|
||||
```
|
||||
|
||||
### "I need to handle events"
|
||||
|
||||
```
|
||||
Events?
|
||||
+-- Stream text/reasoning in real time -> events/REFERENCE.md
|
||||
+-- Track token usage and costs -> events/REFERENCE.md
|
||||
+-- Watch tool calls -> events/REFERENCE.md
|
||||
+-- Detect completion/errors -> events/REFERENCE.md
|
||||
+-- Hook into lifecycle stages -> plugins/REFERENCE.md
|
||||
```
|
||||
|
||||
### "I need to configure a model provider"
|
||||
|
||||
```
|
||||
Providers?
|
||||
+-- Anthropic (Claude) -> providers/REFERENCE.md
|
||||
+-- OpenAI (GPT) -> providers/REFERENCE.md
|
||||
+-- Google (Gemini/Vertex) -> providers/REFERENCE.md
|
||||
+-- AWS Bedrock -> providers/REFERENCE.md
|
||||
+-- Mistral -> providers/REFERENCE.md
|
||||
+-- OpenAI-compatible (vLLM, Together, etc.) -> providers/REFERENCE.md
|
||||
+-- Custom/self-hosted provider -> providers/REFERENCE.md
|
||||
```
|
||||
|
||||
### "I need plugins or hooks"
|
||||
|
||||
```
|
||||
Plugins?
|
||||
+-- Package tools + hooks together -> plugins/REFERENCE.md
|
||||
+-- Observe tool calls (logging, metrics) -> plugins/REFERENCE.md
|
||||
+-- Intercept lifecycle events -> plugins/REFERENCE.md
|
||||
+-- Add system prompt rules -> plugins/REFERENCE.md
|
||||
+-- Distribute via npm/git -> plugins/REFERENCE.md
|
||||
```
|
||||
|
||||
### "I need multi-agent coordination"
|
||||
|
||||
```
|
||||
Multi-agent?
|
||||
+-- Spawn one-off background agents -> multi-agent/REFERENCE.md (sub-agents)
|
||||
+-- Persistent cross-session teams -> multi-agent/REFERENCE.md (teams)
|
||||
+-- Parent-child delegation -> multi-agent/REFERENCE.md (sub-agents)
|
||||
+-- Peer-to-peer task board -> multi-agent/REFERENCE.md (teams)
|
||||
```
|
||||
|
||||
### "I need scheduling or automation"
|
||||
|
||||
```
|
||||
Scheduling?
|
||||
+-- Recurring cron jobs -> scheduling/REFERENCE.md
|
||||
+-- One-off scheduled tasks -> scheduling/REFERENCE.md
|
||||
+-- Event-driven triggers -> scheduling/REFERENCE.md
|
||||
+-- CLI schedule management -> scheduling/REFERENCE.md
|
||||
```
|
||||
|
||||
### "I need to go to production"
|
||||
|
||||
```
|
||||
Production?
|
||||
+-- Error handling and status checks -> production/REFERENCE.md
|
||||
+-- Cost control and token limits -> production/REFERENCE.md
|
||||
+-- Observability (OpenTelemetry) -> production/REFERENCE.md
|
||||
+-- Security and sandboxing -> production/REFERENCE.md
|
||||
+-- Deployment patterns -> production/REFERENCE.md
|
||||
```
|
||||
|
||||
### Troubleshooting Index
|
||||
|
||||
- Agent loop not stopping -> `tools/REFERENCE.md` (completion tools)
|
||||
- Tool errors crashing the agent -> `agent/gotchas.md` or `clinecore/gotchas.md`
|
||||
- Provider auth failures -> `providers/REFERENCE.md`
|
||||
- Session not persisting -> `clinecore/gotchas.md`
|
||||
- Token usage too high -> `production/REFERENCE.md` (cost control)
|
||||
- Hub connection issues -> `clinecore/gotchas.md`
|
||||
- Plugin not loading -> `plugins/REFERENCE.md`
|
||||
- Events not firing -> `events/REFERENCE.md`
|
||||
|
||||
## Product Index
|
||||
|
||||
### API Surfaces
|
||||
| API | Entry File | Description |
|
||||
|-----|------------|-------------|
|
||||
| Agent | `./references/agent/REFERENCE.md` | Lightweight stateless agent loop |
|
||||
| ClineCore | `./references/clinecore/REFERENCE.md` | Full runtime with sessions, persistence, built-in tools |
|
||||
|
||||
### Cross-Cutting Concepts
|
||||
| Concept | Entry File | Description |
|
||||
|---------|------------|-------------|
|
||||
| Tools | `./references/tools/REFERENCE.md` | Built-in and custom tool creation |
|
||||
| Plugins | `./references/plugins/REFERENCE.md` | Extension system with hooks |
|
||||
| Events | `./references/events/REFERENCE.md` | Real-time streaming events |
|
||||
| Providers | `./references/providers/REFERENCE.md` | LLM provider configuration |
|
||||
| Production | `./references/production/REFERENCE.md` | Deployment, security, observability |
|
||||
| Scheduling | `./references/scheduling/REFERENCE.md` | Cron jobs and automation |
|
||||
| Multi-Agent | `./references/multi-agent/REFERENCE.md` | Teams and sub-agents |
|
||||
|
||||
### Package Map
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| `@cline/sdk` | Everything you need, install this one |
|
||||
| `@cline/core` | Sessions, persistence, built-in tools, config, hub |
|
||||
| `@cline/agents` | Stateless agent loop, tool orchestration, streaming |
|
||||
| `@cline/llms` | LLM provider gateway |
|
||||
| `@cline/shared` | Types, tool helpers, hook engine |
|
||||
|
||||
## Resources
|
||||
|
||||
Repository: https://github.com/cline/cline
|
||||
SDK Source: https://github.com/cline/cline/tree/main/sdk
|
||||
Documentation: https://docs.cline.bot/sdk/overview
|
||||
Discord: https://discord.gg/cline
|
||||
@@ -0,0 +1,107 @@
|
||||
# Agent Runtime
|
||||
|
||||
The `Agent` class (also exported as `AgentRuntime`) is the lightweight, stateless agent loop from `@cline/agents`. It handles the core iteration cycle: send messages to an LLM, execute tool calls, collect results, and repeat until the task is done.
|
||||
|
||||
## When to Use Agent
|
||||
|
||||
| Use Agent when... | Use ClineCore instead when... |
|
||||
|---|---|
|
||||
| You want a simple agent with custom tools | You need built-in tools (bash, editor, etc.) |
|
||||
| You want minimal dependencies | You need session persistence |
|
||||
| You need browser compatibility | You need config discovery from `.cline/` |
|
||||
| You're building a stateless worker | You need multi-process session sharing |
|
||||
| You want full control over the runtime | You want batteries-included setup |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { Agent } from "@cline/sdk"
|
||||
|
||||
const agent = new Agent({
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
apiKey: process.env.ANTHROPIC_API_KEY,
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
tools: [],
|
||||
})
|
||||
|
||||
const result = await agent.run("What is the capital of France?")
|
||||
console.log(result.outputText)
|
||||
```
|
||||
|
||||
## Core Concepts
|
||||
|
||||
The Agent operates in a loop:
|
||||
1. Accept user input (string, message, or array of messages)
|
||||
2. Build turn context (system prompt, messages, tools)
|
||||
3. Call the LLM provider
|
||||
4. If the model returns tool calls, execute them and loop back to step 3
|
||||
5. If the model returns text without tool calls, the run completes
|
||||
6. Emit events throughout for streaming
|
||||
|
||||
The agent is stateless in the sense that it does not persist anything to disk. Conversation history is held in memory and can be accessed via `snapshot()`.
|
||||
|
||||
## Key APIs
|
||||
|
||||
- `new Agent(config)` or `createAgent(config)` - Create an agent
|
||||
- `agent.run(input)` - Start a run with user input
|
||||
- `agent.continue(input?)` - Continue an existing conversation
|
||||
- `agent.abort(reason?)` - Cancel an active run
|
||||
- `agent.subscribe(listener)` - Listen to streaming events
|
||||
- `agent.snapshot()` - Get current runtime state
|
||||
- `agent.restore(messages)` - Replace message history
|
||||
|
||||
See `api.md` for full API details.
|
||||
|
||||
## Multi-Turn Conversations
|
||||
|
||||
```typescript
|
||||
const agent = new Agent({
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
tools: [],
|
||||
})
|
||||
|
||||
const first = await agent.run("What is 2 + 2?")
|
||||
console.log(first.outputText)
|
||||
|
||||
const second = await agent.continue("Now multiply that by 3")
|
||||
console.log(second.outputText)
|
||||
```
|
||||
|
||||
Use `agent.hasRun` to check if a run has already been executed, which determines whether to call `run()` or `continue()`.
|
||||
|
||||
## Event Streaming
|
||||
|
||||
Use `agent.subscribe()` to stream events in real time. Register the listener before calling `run()` to avoid missing early events.
|
||||
|
||||
There is no top-level `onEvent` field on the Agent config. For an async alternative, use `hooks.onEvent` (see `api.md` and `gotchas.md`).
|
||||
|
||||
```typescript
|
||||
const agent = new Agent({
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
tools: [],
|
||||
})
|
||||
|
||||
agent.subscribe((event) => {
|
||||
if (event.type === "assistant-text-delta") {
|
||||
process.stdout.write(event.text)
|
||||
}
|
||||
})
|
||||
|
||||
const result = await agent.run("What is the capital of France?")
|
||||
```
|
||||
|
||||
See `events/REFERENCE.md` for the full event type catalog.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- `api.md` - Full Agent API reference
|
||||
- `patterns.md` - Common patterns and best practices
|
||||
- `gotchas.md` - Pitfalls and debugging
|
||||
- `../tools/REFERENCE.md` - Creating custom tools
|
||||
- `../events/REFERENCE.md` - Event system details
|
||||
- `../providers/REFERENCE.md` - Provider configuration
|
||||
@@ -0,0 +1,231 @@
|
||||
# Agent API Reference
|
||||
|
||||
## Constructor
|
||||
|
||||
```typescript
|
||||
import { Agent } from "@cline/sdk"
|
||||
|
||||
const agent = new Agent(config: AgentRuntimeConfig)
|
||||
```
|
||||
|
||||
Also available via factory function:
|
||||
|
||||
```typescript
|
||||
import { createAgent } from "@cline/sdk"
|
||||
|
||||
const agent = createAgent(config)
|
||||
```
|
||||
|
||||
## AgentRuntimeConfig
|
||||
|
||||
Two config forms exist as a discriminated union:
|
||||
|
||||
### With Provider ID (recommended)
|
||||
|
||||
```typescript
|
||||
interface AgentRuntimeConfigWithProvider {
|
||||
providerId: string // e.g. "anthropic", "openai", "gemini"
|
||||
modelId: string // e.g. "claude-sonnet-4-6", "gpt-5.5"
|
||||
apiKey?: string // provider API key
|
||||
baseUrl?: string // custom endpoint
|
||||
headers?: Record<string, string>
|
||||
|
||||
systemPrompt?: string
|
||||
tools?: AgentTool[]
|
||||
initialMessages?: AgentMessage[]
|
||||
toolPolicies?: Record<string, ToolPolicy>
|
||||
hooks?: Partial<AgentRuntimeHooks>
|
||||
plugins?: AgentPlugin[]
|
||||
}
|
||||
```
|
||||
|
||||
### With Pre-built Model
|
||||
|
||||
```typescript
|
||||
interface AgentRuntimeConfigWithModel {
|
||||
model: AgentModel // pre-built model from gateway
|
||||
|
||||
systemPrompt?: string
|
||||
tools?: AgentTool[]
|
||||
initialMessages?: AgentMessage[]
|
||||
toolPolicies?: Record<string, ToolPolicy>
|
||||
hooks?: Partial<AgentRuntimeHooks>
|
||||
plugins?: AgentPlugin[]
|
||||
}
|
||||
```
|
||||
|
||||
Note: there is no top-level `onEvent` field on `AgentRuntimeConfig`. For event streaming, use `agent.subscribe()` or `hooks.onEvent` (see AgentRuntimeHooks below).
|
||||
|
||||
## Methods
|
||||
|
||||
### run(input)
|
||||
|
||||
Start the agent with user input. Returns when the agent loop completes.
|
||||
|
||||
```typescript
|
||||
const result: AgentRunResult = await agent.run("Build a REST API")
|
||||
```
|
||||
|
||||
Input can be a string, an `AgentMessage`, or an array of `AgentMessage[]`.
|
||||
|
||||
### continue(input?)
|
||||
|
||||
Continue an existing conversation with optional new input.
|
||||
|
||||
```typescript
|
||||
const result = await agent.continue("Now add authentication")
|
||||
```
|
||||
|
||||
### abort(reason?)
|
||||
|
||||
Cancel the currently active run.
|
||||
|
||||
```typescript
|
||||
agent.abort("User cancelled")
|
||||
```
|
||||
|
||||
### subscribe(listener)
|
||||
|
||||
Register a listener for streaming events.
|
||||
|
||||
```typescript
|
||||
const unsubscribe = agent.subscribe((event: AgentRuntimeEvent) => {
|
||||
// handle event
|
||||
})
|
||||
|
||||
// Later: stop listening
|
||||
unsubscribe()
|
||||
```
|
||||
|
||||
### snapshot()
|
||||
|
||||
Get the current runtime state including message history.
|
||||
|
||||
```typescript
|
||||
const state: AgentRuntimeStateSnapshot = agent.snapshot()
|
||||
```
|
||||
|
||||
### restore(messages)
|
||||
|
||||
Replace the agent's message history.
|
||||
|
||||
```typescript
|
||||
agent.restore(previousMessages)
|
||||
```
|
||||
|
||||
### hasRun
|
||||
|
||||
Boolean property indicating whether `run()` has been called at least once.
|
||||
|
||||
```typescript
|
||||
if (agent.hasRun) {
|
||||
await agent.continue(input)
|
||||
} else {
|
||||
await agent.run(input)
|
||||
}
|
||||
```
|
||||
|
||||
## AgentRunResult
|
||||
|
||||
Returned by `run()` and `continue()`.
|
||||
|
||||
```typescript
|
||||
interface AgentRunResult {
|
||||
agentId: string
|
||||
agentRole?: string
|
||||
runId: string
|
||||
status: "completed" | "aborted" | "failed"
|
||||
iterations: number
|
||||
outputText: string
|
||||
messages: readonly AgentMessage[]
|
||||
usage: AgentUsage
|
||||
error?: Error
|
||||
}
|
||||
```
|
||||
|
||||
### Status Values
|
||||
|
||||
- `"completed"` - Agent finished normally
|
||||
- `"aborted"` - Cancelled via `abort()`
|
||||
- `"failed"` - Unrecoverable error
|
||||
|
||||
## AgentMessage
|
||||
|
||||
```typescript
|
||||
interface AgentMessage {
|
||||
id: string
|
||||
role: "user" | "assistant" | "tool"
|
||||
content: AgentMessagePart[]
|
||||
createdAt: number
|
||||
metadata?: Record<string, unknown>
|
||||
modelInfo?: { id: string; provider: string; family?: string }
|
||||
metrics?: {
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheReadTokens?: number
|
||||
cacheWriteTokens?: number
|
||||
cost?: number
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## AgentUsage
|
||||
|
||||
```typescript
|
||||
interface AgentUsage {
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheReadTokens: number
|
||||
cacheWriteTokens: number
|
||||
totalInputTokens: number
|
||||
totalOutputTokens: number
|
||||
totalCost?: number
|
||||
}
|
||||
```
|
||||
|
||||
## AgentRuntimeHooks
|
||||
|
||||
```typescript
|
||||
interface AgentRuntimeHooks {
|
||||
beforeRun?(context): AgentStopControl | undefined
|
||||
afterRun?(context): void
|
||||
beforeModel?(context): AgentBeforeModelResult | undefined
|
||||
afterModel?(context): AgentStopControl | undefined
|
||||
beforeTool?(context): AgentBeforeToolResult | undefined
|
||||
afterTool?(context): AgentAfterToolResult | undefined
|
||||
onEvent?(event: AgentRuntimeEvent): void | Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
Hooks can intercept and modify behavior at each stage. Return a stop control from `beforeRun`, `afterModel`, or `beforeTool` to halt the agent loop.
|
||||
|
||||
`hooks.onEvent` receives the same `AgentRuntimeEvent` types as `agent.subscribe()`, but hook callbacks are awaited (can be async), while `subscribe()` listeners are called synchronously. Use `subscribe()` for UI streaming and `hooks.onEvent` for async side effects like logging to an external service.
|
||||
|
||||
## AgentRuntimeStateSnapshot
|
||||
|
||||
```typescript
|
||||
interface AgentRuntimeStateSnapshot {
|
||||
messages: readonly AgentMessage[]
|
||||
usage: AgentUsage
|
||||
iterations: number
|
||||
status: string
|
||||
}
|
||||
```
|
||||
|
||||
## Factory: createAgentRuntime
|
||||
|
||||
Lower-level factory that returns the same `Agent` class:
|
||||
|
||||
```typescript
|
||||
import { createAgentRuntime } from "@cline/sdk"
|
||||
|
||||
const runtime = createAgentRuntime(config)
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
- `REFERENCE.md` - Overview and quick start
|
||||
- `patterns.md` - Common patterns
|
||||
- `../tools/REFERENCE.md` - Tool creation
|
||||
- `../events/REFERENCE.md` - Event types
|
||||
- `../providers/REFERENCE.md` - Provider setup
|
||||
@@ -0,0 +1,134 @@
|
||||
# Agent Gotchas
|
||||
|
||||
## Agent Loop Never Stops
|
||||
|
||||
If the agent keeps iterating without completing:
|
||||
|
||||
- Make sure at least one tool has `lifecycle: { completesRun: true }` if you want the agent to explicitly finish.
|
||||
- Without any tools, the agent will complete after the model returns text without tool calls.
|
||||
- If using tools, ensure the system prompt guides the model toward calling the completion tool when done.
|
||||
- Check that `completesRun` tools return successfully (not throwing errors).
|
||||
|
||||
## Tool Errors Count as Mistakes
|
||||
|
||||
When a tool's `execute` function throws an exception, the SDK counts it as a "mistake." After too many mistakes, the agent stops with a `mistake_limit` finish reason.
|
||||
|
||||
Instead, return errors as structured data:
|
||||
|
||||
```typescript
|
||||
// Bad: throwing
|
||||
execute: async (input) => {
|
||||
throw new Error("File not found")
|
||||
}
|
||||
|
||||
// Good: returning error data
|
||||
execute: async (input) => {
|
||||
return { error: "File not found", path: input.path }
|
||||
}
|
||||
```
|
||||
|
||||
## run() vs continue()
|
||||
|
||||
- Call `run()` for the first interaction. It sets up the conversation.
|
||||
- Call `continue()` for subsequent messages. It appends to the existing conversation.
|
||||
- Calling `run()` a second time resets the conversation history.
|
||||
- Use `agent.hasRun` to check which method to call.
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
`@cline/agents` (and by extension, the `Agent` class) is browser-safe with no Node.js dependencies. However, `@cline/core` and `ClineCore` require Node.js 22+. If you import from `@cline/sdk`, you get everything including the Node-only code. For browser usage, import directly from `@cline/agents`:
|
||||
|
||||
```typescript
|
||||
import { Agent } from "@cline/agents"
|
||||
```
|
||||
|
||||
## No Top-Level onEvent on Agent Config
|
||||
|
||||
`AgentRuntimeConfig` does not have a top-level `onEvent` field. Passing `onEvent` to `new Agent({ onEvent: ... })` has no effect. There are two ways to receive events:
|
||||
|
||||
```typescript
|
||||
// Option 1: subscribe() - synchronous, best for UI streaming
|
||||
const agent = new Agent({ ...config })
|
||||
agent.subscribe((event) => {
|
||||
if (event.type === "assistant-text-delta") {
|
||||
process.stdout.write(event.text)
|
||||
}
|
||||
})
|
||||
|
||||
// Option 2: hooks.onEvent - awaited, best for async side effects
|
||||
const agent = new Agent({
|
||||
...config,
|
||||
hooks: {
|
||||
onEvent: async (event) => {
|
||||
if (event.type === "assistant-text-delta") {
|
||||
await logToService(event.text)
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Both receive the same `AgentRuntimeEvent` types. Prefer `subscribe()` for streaming UI.
|
||||
|
||||
## Event Listener Timing
|
||||
|
||||
Register event listeners via `subscribe()` before calling `run()`:
|
||||
|
||||
```typescript
|
||||
// Good: subscribe before run
|
||||
agent.subscribe(handler)
|
||||
const result = await agent.run(input)
|
||||
|
||||
// Bad: subscribing after run starts loses early events
|
||||
const promise = agent.run(input)
|
||||
agent.subscribe(handler) // may miss events
|
||||
```
|
||||
|
||||
## Tool Input Schema Matters
|
||||
|
||||
The model uses the tool's `inputSchema` to decide what arguments to pass. A vague or missing schema leads to incorrect tool calls.
|
||||
|
||||
- Use `z.enum()` for fixed value sets, not free-form strings
|
||||
- Describe every property with `.describe()` in Zod or `description` in JSON Schema
|
||||
- Include constraints (rate limits, max values) in the tool description
|
||||
|
||||
## Memory and Long Conversations
|
||||
|
||||
The Agent holds all messages in memory. For long-running conversations, memory usage grows with each turn. Consider:
|
||||
|
||||
- Using `ClineCore` with compaction for long sessions
|
||||
- Periodically creating a new agent with a summary of the conversation
|
||||
- Monitoring `result.usage.totalInputTokens` to track context growth
|
||||
|
||||
## Abort Signal Handling in Tools
|
||||
|
||||
Long-running tools should respect the abort signal:
|
||||
|
||||
```typescript
|
||||
execute: async (input, context) => {
|
||||
for (const item of items) {
|
||||
if (context.abortSignal?.aborted) {
|
||||
return { partial: results, aborted: true }
|
||||
}
|
||||
results.push(await process(item))
|
||||
}
|
||||
return { results }
|
||||
}
|
||||
```
|
||||
|
||||
## Provider API Key
|
||||
|
||||
If you get authentication errors, check:
|
||||
|
||||
- `apiKey` is set in the config or via environment variables
|
||||
- The key matches the `providerId` (e.g., Anthropic key for `providerId: "anthropic"`)
|
||||
- For OpenAI-compatible providers, both `apiKey` and `baseUrl` are set
|
||||
|
||||
See `../providers/REFERENCE.md` for provider-specific setup.
|
||||
|
||||
## See Also
|
||||
|
||||
- `api.md` - Full API reference
|
||||
- `patterns.md` - Common patterns
|
||||
- `../tools/REFERENCE.md` - Tool creation
|
||||
- `../clinecore/REFERENCE.md` - Use ClineCore for persistence
|
||||
@@ -0,0 +1,258 @@
|
||||
# Agent Patterns
|
||||
|
||||
## Interactive CLI Agent
|
||||
|
||||
A multi-turn conversational agent in the terminal with streaming output:
|
||||
|
||||
```typescript
|
||||
import { Agent } from "@cline/sdk"
|
||||
import * as readline from "node:readline"
|
||||
|
||||
const agent = new Agent({
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
apiKey: process.env.ANTHROPIC_API_KEY,
|
||||
systemPrompt: "You are a helpful assistant. Keep responses concise.",
|
||||
tools: [],
|
||||
})
|
||||
|
||||
agent.subscribe((event) => {
|
||||
if (event.type === "assistant-text-delta") {
|
||||
process.stdout.write(event.text)
|
||||
}
|
||||
})
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
})
|
||||
|
||||
function prompt(): void {
|
||||
rl.question("\nYou: ", async (input) => {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed || trimmed === "exit") {
|
||||
rl.close()
|
||||
return
|
||||
}
|
||||
|
||||
process.stdout.write("\nAssistant: ")
|
||||
|
||||
if (agent.hasRun) {
|
||||
await agent.continue(trimmed)
|
||||
} else {
|
||||
await agent.run(trimmed)
|
||||
}
|
||||
|
||||
process.stdout.write("\n")
|
||||
prompt()
|
||||
})
|
||||
}
|
||||
|
||||
prompt()
|
||||
```
|
||||
|
||||
## Conversational Agent (Slack Bot, Chat App)
|
||||
|
||||
Maintain per-thread agents with conversation memory:
|
||||
|
||||
```typescript
|
||||
import { Agent } from "@cline/sdk"
|
||||
|
||||
const agents = new Map<string, Agent>()
|
||||
|
||||
async function handleMessage(threadId: string, message: string) {
|
||||
let agent = agents.get(threadId)
|
||||
if (!agent) {
|
||||
agent = new Agent({
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
systemPrompt: "You are a concise assistant.",
|
||||
tools: [],
|
||||
})
|
||||
agents.set(threadId, agent)
|
||||
}
|
||||
|
||||
const result = agent.hasRun
|
||||
? await agent.continue(message)
|
||||
: await agent.run(message)
|
||||
|
||||
return result.outputText
|
||||
}
|
||||
```
|
||||
|
||||
## Streaming UI
|
||||
|
||||
Build a real-time UI by handling events via `subscribe()`:
|
||||
|
||||
```typescript
|
||||
const agent = new Agent({
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
tools: [myTool],
|
||||
})
|
||||
|
||||
agent.subscribe((event) => {
|
||||
switch (event.type) {
|
||||
case "assistant-text-delta":
|
||||
ui.appendText(event.text)
|
||||
break
|
||||
case "assistant-message":
|
||||
ui.endText()
|
||||
break
|
||||
case "turn-started":
|
||||
ui.startTurn(event.iteration)
|
||||
break
|
||||
case "turn-finished":
|
||||
if (event.toolCallCount > 0) ui.showToolCount(event.toolCallCount)
|
||||
break
|
||||
case "usage-updated":
|
||||
ui.updateUsage(event.usage.inputTokens, event.usage.outputTokens)
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
const result = await agent.run("Hello!")
|
||||
```
|
||||
|
||||
## Structured Output via Completion Tool
|
||||
|
||||
Use a tool with `completesRun: true` to extract structured data:
|
||||
|
||||
```typescript
|
||||
import { Agent, createTool } from "@cline/sdk"
|
||||
import { z } from "zod"
|
||||
|
||||
const submitReview = createTool({
|
||||
name: "submit_review",
|
||||
description: "Submit the final code review with structured feedback.",
|
||||
inputSchema: z.object({
|
||||
summary: z.string(),
|
||||
issues: z.array(z.object({
|
||||
file: z.string(),
|
||||
line: z.number(),
|
||||
severity: z.enum(["error", "warning", "info"]),
|
||||
message: z.string(),
|
||||
})),
|
||||
approved: z.boolean(),
|
||||
}),
|
||||
lifecycle: { completesRun: true },
|
||||
execute: async (input) => input,
|
||||
})
|
||||
|
||||
const agent = new Agent({
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
systemPrompt: "Review the code diff and submit structured feedback.",
|
||||
tools: [submitReview],
|
||||
})
|
||||
|
||||
const result = await agent.run(diffContent)
|
||||
const review = result.toolCalls.find(tc => tc.name === "submit_review")
|
||||
console.log(review?.output)
|
||||
```
|
||||
|
||||
## Agent with Abort/Timeout
|
||||
|
||||
```typescript
|
||||
const agent = new Agent({
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
systemPrompt: "Analyze this data.",
|
||||
tools: [],
|
||||
})
|
||||
|
||||
const timeout = setTimeout(() => agent.abort("Timeout"), 30_000)
|
||||
|
||||
try {
|
||||
const result = await agent.run(data)
|
||||
if (result.status === "aborted") {
|
||||
console.log("Agent was aborted")
|
||||
} else {
|
||||
console.log(result.outputText)
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
```
|
||||
|
||||
## Agent with Plugins
|
||||
|
||||
```typescript
|
||||
import { Agent } from "@cline/sdk"
|
||||
import type { AgentPlugin } from "@cline/sdk"
|
||||
|
||||
const loggingPlugin: AgentPlugin = {
|
||||
name: "logging",
|
||||
manifest: { capabilities: ["hooks"] },
|
||||
setup() {},
|
||||
hooks: {
|
||||
beforeTool({ toolCall }) {
|
||||
console.log(`Calling tool: ${toolCall.toolName}`)
|
||||
},
|
||||
afterRun({ result }) {
|
||||
console.log(`Completed in ${result.iterations} iterations`)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const agent = new Agent({
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
tools: [myTool],
|
||||
plugins: [loggingPlugin],
|
||||
})
|
||||
```
|
||||
|
||||
## Restoring State Across Sessions
|
||||
|
||||
Save and restore agent state manually:
|
||||
|
||||
```typescript
|
||||
// Save state
|
||||
const snapshot = agent.snapshot()
|
||||
const serialized = JSON.stringify(snapshot.messages)
|
||||
|
||||
// Later: restore
|
||||
const agent2 = new Agent({ ...config })
|
||||
const messages = JSON.parse(serialized)
|
||||
agent2.restore(messages)
|
||||
const result = await agent2.continue("Continue where we left off")
|
||||
```
|
||||
|
||||
For automatic persistence, use `ClineCore` instead.
|
||||
|
||||
## Pre-Built Model via Gateway
|
||||
|
||||
For advanced provider configuration:
|
||||
|
||||
```typescript
|
||||
import { Agent } from "@cline/sdk"
|
||||
import { createGateway } from "@cline/llms"
|
||||
|
||||
const gateway = createGateway({
|
||||
providerConfigs: [
|
||||
{ providerId: "anthropic", apiKey: process.env.ANTHROPIC_API_KEY },
|
||||
{ providerId: "openai", apiKey: process.env.OPENAI_API_KEY },
|
||||
],
|
||||
})
|
||||
|
||||
const model = gateway.createAgentModel({
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-opus-4-7",
|
||||
})
|
||||
|
||||
const agent = new Agent({
|
||||
model,
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
tools: [],
|
||||
})
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
- `api.md` - Full API reference
|
||||
- `gotchas.md` - Common pitfalls
|
||||
- `../tools/REFERENCE.md` - Creating tools
|
||||
- `../plugins/REFERENCE.md` - Plugin system
|
||||
@@ -0,0 +1,131 @@
|
||||
# ClineCore Runtime
|
||||
|
||||
`ClineCore` is the full-featured runtime from `@cline/core`. It wraps the `Agent` loop with session persistence, built-in tools (bash, editor, file reading, search, web fetch), config discovery, plugin loading, and optional hub-backed multi-process support.
|
||||
|
||||
## When to Use ClineCore
|
||||
|
||||
| Use ClineCore when... | Use Agent instead when... |
|
||||
|---|---|
|
||||
| You need built-in tools (bash, editor, etc.) | You only need custom tools |
|
||||
| You want session persistence to disk | Stateless is fine |
|
||||
| You need config discovery from `.cline/` dirs | You handle config yourself |
|
||||
| You want scheduled/automated agents | You don't need scheduling |
|
||||
| You need multi-client session sharing | Single-process is fine |
|
||||
| You're building a full application | You want minimal dependencies |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { ClineCore } from "@cline/sdk"
|
||||
|
||||
const cline = await ClineCore.create({ clientName: "my-app" })
|
||||
|
||||
const session = await cline.start({
|
||||
prompt: "Set up CI with GitHub Actions",
|
||||
config: {
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
apiKey: process.env.ANTHROPIC_API_KEY,
|
||||
cwd: "/path/to/project",
|
||||
enableTools: true,
|
||||
},
|
||||
})
|
||||
|
||||
console.log(session.result?.text)
|
||||
await cline.dispose()
|
||||
```
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Sessions
|
||||
|
||||
Every `cline.start()` call creates a session with a unique ID. Sessions persist their messages and metadata to SQLite. You can list, read, resume, and delete sessions.
|
||||
|
||||
### Built-in Tools
|
||||
|
||||
ClineCore provides these tools automatically when `enableTools: true`:
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `bash` | Execute shell commands |
|
||||
| `editor` | Edit files |
|
||||
| `read_files` | Read file contents |
|
||||
| `apply_patch` | Apply unified diffs |
|
||||
| `search` | Search file contents and structure |
|
||||
| `fetch_web` | HTTP requests and web content |
|
||||
|
||||
### Config Discovery
|
||||
|
||||
ClineCore watches `.cline/` directories for:
|
||||
- Rules (system prompt additions)
|
||||
- Skills (domain knowledge)
|
||||
- Workflows (multi-step procedures)
|
||||
- Hooks (lifecycle logic)
|
||||
- Plugins (tool + hook bundles)
|
||||
- MCP servers (external tool providers)
|
||||
|
||||
### Backend Modes
|
||||
|
||||
| Mode | Description |
|
||||
|------|-------------|
|
||||
| `"auto"` (default) | Tries to connect to a local hub; falls back to in-process if unavailable |
|
||||
| `"local"` | In-process execution, local SQLite storage, no hub |
|
||||
| `"hub"` | Requires a compatible local WebSocket hub; fails if unavailable |
|
||||
| `"remote"` | Connects to an explicit remote hub endpoint |
|
||||
|
||||
The default mode is `"auto"`. For simple scripts and CLI tools, `"local"` avoids hub discovery overhead. Hub mode enables multi-client session sharing (e.g., a dashboard watching a running session from another process).
|
||||
|
||||
## Key APIs
|
||||
|
||||
- `ClineCore.create(options)` - Create and initialize
|
||||
- `cline.start(input)` - Start a new session
|
||||
- `cline.send({ sessionId, prompt })` - Send follow-up message
|
||||
- `cline.subscribe(listener)` - Listen to session events
|
||||
- `cline.list()` - List sessions
|
||||
- `cline.get(sessionId)` - Get session metadata
|
||||
- `cline.readMessages(sessionId)` - Read persisted messages
|
||||
- `cline.getAccumulatedUsage(sessionId)` - Token/cost totals
|
||||
- `cline.abort(sessionId)` - Abort a session
|
||||
- `cline.delete(sessionId)` - Delete a session
|
||||
- `cline.dispose()` - Clean up resources
|
||||
|
||||
See `api.md` for full API details.
|
||||
|
||||
## Event Streaming
|
||||
|
||||
`cline.subscribe()` emits `CoreSessionEvent` types. These are different from the `AgentRuntimeEvent` types emitted by the standalone `Agent` class -- see `../events/REFERENCE.md` for the full comparison.
|
||||
|
||||
```typescript
|
||||
cline.subscribe((event) => {
|
||||
switch (event.type) {
|
||||
case "chunk":
|
||||
if (event.payload.type === "text") {
|
||||
process.stdout.write(event.payload.text)
|
||||
}
|
||||
break
|
||||
case "ended":
|
||||
console.log(`Session ended: ${event.payload.finishReason}`)
|
||||
break
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
ClineCore results use `AgentResult` with `.text` (not `.outputText` like the standalone Agent's `AgentRunResult`).
|
||||
|
||||
## Session Persistence
|
||||
|
||||
Sessions are stored at:
|
||||
```
|
||||
~/.cline/data/sessions/
|
||||
sessions.db # SQLite database
|
||||
[session-id].json # Message history
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- `api.md` - Full ClineCore API reference
|
||||
- `patterns.md` - Common patterns and best practices
|
||||
- `gotchas.md` - Pitfalls and debugging
|
||||
- `../tools/REFERENCE.md` - Custom tool creation
|
||||
- `../plugins/REFERENCE.md` - Plugin system
|
||||
- `../scheduling/REFERENCE.md` - Scheduled agents
|
||||
@@ -0,0 +1,304 @@
|
||||
# ClineCore API Reference
|
||||
|
||||
## Creating ClineCore
|
||||
|
||||
```typescript
|
||||
import { ClineCore } from "@cline/sdk"
|
||||
|
||||
const cline = await ClineCore.create(options: ClineCoreOptions)
|
||||
```
|
||||
|
||||
### ClineCoreOptions
|
||||
|
||||
```typescript
|
||||
interface ClineCoreOptions {
|
||||
clientName: string // identifies your app
|
||||
distinctId?: string // user/instance identifier
|
||||
backendMode?: "auto" | "local" | "hub" | "remote"
|
||||
hub?: HubOptions
|
||||
remote?: RemoteOptions
|
||||
capabilities?: RuntimeCapabilities
|
||||
toolPolicies?: Record<string, ToolPolicy>
|
||||
automation?: boolean | ClineCoreAutomationOptions
|
||||
fetch?: typeof fetch
|
||||
}
|
||||
```
|
||||
|
||||
### RuntimeCapabilities
|
||||
|
||||
```typescript
|
||||
interface RuntimeCapabilities {
|
||||
requestToolApproval?: (request: ToolApprovalRequest) => Promise<ToolApprovalResult>
|
||||
// ... other capability callbacks
|
||||
}
|
||||
```
|
||||
|
||||
## Starting Sessions
|
||||
|
||||
### start(input)
|
||||
|
||||
```typescript
|
||||
const session = await cline.start(input: ClineCoreStartInput)
|
||||
```
|
||||
|
||||
Returns a `StartSessionResult`:
|
||||
|
||||
```typescript
|
||||
interface StartSessionResult {
|
||||
sessionId: string
|
||||
manifest: SessionManifest
|
||||
manifestPath: string
|
||||
messagesPath: string
|
||||
result?: AgentResult
|
||||
}
|
||||
```
|
||||
|
||||
### ClineCoreStartInput
|
||||
|
||||
```typescript
|
||||
interface ClineCoreStartInput {
|
||||
prompt: string
|
||||
config: CoreSessionConfig
|
||||
source?: string
|
||||
interactive?: boolean
|
||||
sessionMetadata?: Record<string, unknown>
|
||||
initialMessages?: AgentMessage[]
|
||||
toolPolicies?: Record<string, ToolPolicy>
|
||||
capabilities?: RuntimeCapabilities
|
||||
}
|
||||
```
|
||||
|
||||
### CoreSessionConfig
|
||||
|
||||
```typescript
|
||||
interface CoreSessionConfig {
|
||||
cwd?: string // working directory
|
||||
providerId: string // LLM provider
|
||||
modelId: string // model identifier
|
||||
apiKey?: string // provider API key
|
||||
systemPrompt?: string // custom system prompt
|
||||
tools?: readonly AgentTool[] // additional custom tools
|
||||
enableTools?: boolean // enable built-in tools
|
||||
hooks?: Partial<AgentRuntimeHooks> // runtime hooks
|
||||
extensions?: AgentPlugin[] // plugins loaded inline
|
||||
pluginPaths?: string[] // paths to plugin packages
|
||||
extensionLoading?: "isolated" | "direct"
|
||||
extensionContext?: { // context passed to plugin setup()
|
||||
workspace?: { rootPath: string; cwd: string }
|
||||
}
|
||||
checkpointConfig?: CoreCheckpointConfig
|
||||
compactionConfig?: CoreCompactionConfig
|
||||
telemetry?: ITelemetryService
|
||||
logger?: BasicLogger
|
||||
enableSpawnAgent?: boolean // enable sub-agent spawning
|
||||
enableAgentTeams?: boolean // enable team coordination
|
||||
teamName?: string // team identifier
|
||||
}
|
||||
```
|
||||
|
||||
`extensions` passes plugin objects directly. `pluginPaths` points to directories with `package.json` containing a `cline.plugins` field. Set `extensionContext.workspace` so plugins receive `ctx.workspaceInfo` in their `setup()` call -- without it, `ctx.workspaceInfo` is undefined.
|
||||
|
||||
## Follow-Up Messages
|
||||
|
||||
### send({ sessionId, prompt })
|
||||
|
||||
Send a follow-up message to an existing session:
|
||||
|
||||
```typescript
|
||||
const result = await cline.send({
|
||||
sessionId: session.sessionId,
|
||||
prompt: "Now add authentication",
|
||||
})
|
||||
```
|
||||
|
||||
Returns `AgentResult | undefined`.
|
||||
|
||||
## Event Subscription
|
||||
|
||||
### subscribe(listener, options?)
|
||||
|
||||
```typescript
|
||||
const unsubscribe = cline.subscribe(
|
||||
(event: CoreSessionEvent) => {
|
||||
// handle events
|
||||
},
|
||||
{ sessionId: "optional-filter" }
|
||||
)
|
||||
```
|
||||
|
||||
### CoreSessionEvent
|
||||
|
||||
```typescript
|
||||
type CoreSessionEvent =
|
||||
| { type: "chunk"; payload: SessionChunkEvent }
|
||||
| { type: "agent_event"; payload: { sessionId: string, event: AgentEvent } }
|
||||
| { type: "ended"; payload: SessionEndedEvent }
|
||||
| { type: "team_progress"; payload: SessionTeamProgressEvent }
|
||||
| { type: "status"; payload: { sessionId: string, status: string } }
|
||||
| { type: "hook"; payload: SessionToolEvent }
|
||||
```
|
||||
|
||||
## Session Management
|
||||
|
||||
### list(limit?, options?)
|
||||
|
||||
```typescript
|
||||
const sessions: SessionRecord[] = await cline.list(50)
|
||||
```
|
||||
|
||||
### get(sessionId)
|
||||
|
||||
```typescript
|
||||
const session: SessionRecord = await cline.get(sessionId)
|
||||
```
|
||||
|
||||
### readMessages(sessionId)
|
||||
|
||||
```typescript
|
||||
const messages: AgentMessage[] = await cline.readMessages(sessionId)
|
||||
```
|
||||
|
||||
### getAccumulatedUsage(sessionId)
|
||||
|
||||
```typescript
|
||||
const usage = await cline.getAccumulatedUsage(sessionId)
|
||||
// usage.usage - root agent only
|
||||
// usage.aggregateUsage - root + subagents/teammates
|
||||
```
|
||||
|
||||
### update(sessionId, updates)
|
||||
|
||||
```typescript
|
||||
await cline.update(sessionId, { title: "New title" })
|
||||
```
|
||||
|
||||
### abort(sessionId, reason?)
|
||||
|
||||
```typescript
|
||||
await cline.abort(sessionId, "User cancelled")
|
||||
```
|
||||
|
||||
### stop(sessionId)
|
||||
|
||||
```typescript
|
||||
await cline.stop(sessionId)
|
||||
```
|
||||
|
||||
### delete(sessionId)
|
||||
|
||||
```typescript
|
||||
await cline.delete(sessionId)
|
||||
```
|
||||
|
||||
### restore(input)
|
||||
|
||||
Restore a session from a checkpoint:
|
||||
|
||||
```typescript
|
||||
await cline.restore({ sessionId, checkpointId })
|
||||
```
|
||||
|
||||
### dispose(reason?)
|
||||
|
||||
Clean up all resources. Always call this when done:
|
||||
|
||||
```typescript
|
||||
await cline.dispose("Shutting down")
|
||||
```
|
||||
|
||||
## AgentResult
|
||||
|
||||
Returned by session operations:
|
||||
|
||||
```typescript
|
||||
interface AgentResult {
|
||||
text: string
|
||||
usage: LegacyAgentUsage
|
||||
messages: MessageWithMetadata[]
|
||||
toolCalls: ToolCallRecord[]
|
||||
iterations: number
|
||||
finishReason: "completed" | "max_iterations" | "aborted" | "mistake_limit" | "error"
|
||||
model: { id: string; provider: string; info?: ModelInfo }
|
||||
startedAt: Date
|
||||
endedAt: Date
|
||||
durationMs: number
|
||||
}
|
||||
```
|
||||
|
||||
## Tool Policies
|
||||
|
||||
Control tool access at the session level:
|
||||
|
||||
```typescript
|
||||
const session = await cline.start({
|
||||
prompt: "Review the code",
|
||||
config: { ... },
|
||||
toolPolicies: {
|
||||
read_files: { autoApprove: true },
|
||||
bash: { autoApprove: false },
|
||||
editor: { enabled: false },
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### ToolPolicy
|
||||
|
||||
```typescript
|
||||
interface ToolPolicy {
|
||||
enabled?: boolean // false = tool is hidden from the model
|
||||
autoApprove?: boolean // false = requires approval callback
|
||||
}
|
||||
```
|
||||
|
||||
## Interactive Approval
|
||||
|
||||
```typescript
|
||||
const cline = await ClineCore.create({
|
||||
clientName: "my-app",
|
||||
capabilities: {
|
||||
requestToolApproval: async (request) => {
|
||||
console.log(`Tool: ${request.toolName}, Input: ${JSON.stringify(request.input)}`)
|
||||
const approved = await askUser(`Allow ${request.toolName}?`)
|
||||
return { approved }
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Automation API
|
||||
|
||||
When `automation` is enabled in `ClineCore.create()`:
|
||||
|
||||
```typescript
|
||||
const cline = await ClineCore.create({
|
||||
clientName: "my-app",
|
||||
automation: true,
|
||||
})
|
||||
|
||||
// Access automation methods
|
||||
cline.automation.start()
|
||||
cline.automation.stop()
|
||||
cline.automation.reconcile(specs)
|
||||
cline.automation.ingestEvent(event)
|
||||
cline.automation.listEvents()
|
||||
cline.automation.listSpecs()
|
||||
cline.automation.listRuns()
|
||||
```
|
||||
|
||||
## Settings API
|
||||
|
||||
```typescript
|
||||
// Read settings
|
||||
const settings = await cline.settings.list()
|
||||
|
||||
// Toggle tools, plugins, MCP servers
|
||||
await cline.settings.toggle({ type: "tool", name: "bash", enabled: true })
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
- `REFERENCE.md` - Overview and quick start
|
||||
- `patterns.md` - Common patterns
|
||||
- `gotchas.md` - Pitfalls
|
||||
- `../tools/REFERENCE.md` - Tool creation
|
||||
- `../plugins/REFERENCE.md` - Plugin system
|
||||
@@ -0,0 +1,148 @@
|
||||
# ClineCore Gotchas
|
||||
|
||||
## Always Call dispose()
|
||||
|
||||
`ClineCore` holds resources (file watchers, database connections, hub connections). Failing to call `dispose()` can leave orphan processes and file locks.
|
||||
|
||||
```typescript
|
||||
const cline = await ClineCore.create({ clientName: "my-app" })
|
||||
try {
|
||||
// ... use cline
|
||||
} finally {
|
||||
await cline.dispose()
|
||||
}
|
||||
```
|
||||
|
||||
## Node.js 22 Required
|
||||
|
||||
ClineCore and `@cline/core` require Node.js 22 or later. If you're on an older version, you'll get runtime errors. Check with `node --version`.
|
||||
|
||||
## Session Config vs Global Config
|
||||
|
||||
Tool policies can be set at two levels:
|
||||
- Global: in `ClineCore.create({ toolPolicies })` -- applies to all sessions
|
||||
- Per-session: in `cline.start({ toolPolicies })` -- overrides global for that session
|
||||
|
||||
Per-session policies take precedence.
|
||||
|
||||
## enableTools Must Be Explicit
|
||||
|
||||
Built-in tools (bash, editor, read_files, etc.) are not available unless you set `enableTools: true` in the session config:
|
||||
|
||||
```typescript
|
||||
await cline.start({
|
||||
prompt: "Read package.json",
|
||||
config: {
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
enableTools: true, // required for built-in tools
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Without this, the agent only has access to custom tools you provide via `config.tools`.
|
||||
|
||||
## cwd Matters for Built-in Tools
|
||||
|
||||
Built-in tools like `bash`, `editor`, and `read_files` operate relative to `config.cwd`. If not set, they use the process working directory. Always set it explicitly for predictable behavior:
|
||||
|
||||
```typescript
|
||||
config: {
|
||||
cwd: "/absolute/path/to/project",
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Hub Startup Latency
|
||||
|
||||
With `backendMode: "auto"`, the first session may be slow if a hub daemon needs to be spawned. For immediate responsiveness:
|
||||
- Use `backendMode: "local"` for in-process execution (fastest startup)
|
||||
- Pre-warm the hub with `cline hub ensure` CLI command
|
||||
- Accept the one-time startup cost and let subsequent sessions reuse the hub
|
||||
|
||||
## Session Storage Location
|
||||
|
||||
Sessions are stored at `~/.cline/data/sessions/`. This includes:
|
||||
- `sessions.db` - SQLite database with session metadata
|
||||
- `[session-id].json` - Individual message history files
|
||||
|
||||
If you're running in a container or ephemeral environment, these paths may not persist across restarts.
|
||||
|
||||
## requestToolApproval Blocks Execution
|
||||
|
||||
When a tool policy has `autoApprove: false` and you provide a `requestToolApproval` callback, the agent loop blocks until your callback resolves. If your callback never resolves (e.g., waiting for user input that never comes), the session hangs.
|
||||
|
||||
For automated pipelines, either:
|
||||
- Set all tools to `autoApprove: true`
|
||||
- Implement a timeout in your approval callback
|
||||
|
||||
## Plugin Discovery Paths
|
||||
|
||||
ClineCore discovers plugins from:
|
||||
- Global: `~/.cline/plugins/`
|
||||
- Workspace: `.cline/plugins/`
|
||||
|
||||
For SDK consumers, pass plugins via `extensions: [plugin]` or `pluginPaths: ["./path"]` in the session config.
|
||||
|
||||
If a plugin isn't loading, verify:
|
||||
- The file is in one of the discovery directories, or passed via `extensions`/`pluginPaths`
|
||||
- The file exports a default plugin object with a non-empty `manifest.capabilities` array
|
||||
- Every `api.register*` call in `setup()` has a matching capability declared
|
||||
- If `hooks` is present on the plugin, `"hooks"` is in `capabilities`
|
||||
|
||||
## extensionContext.workspace Is Required for Plugins
|
||||
|
||||
If your plugins use `ctx.workspaceInfo` (e.g., to resolve workspace paths), you must set `extensionContext.workspace` in the session config. Without it, `ctx.workspaceInfo` is undefined:
|
||||
|
||||
```typescript
|
||||
await cline.start({
|
||||
config: {
|
||||
extensions: [myPlugin],
|
||||
extensionContext: {
|
||||
workspace: { rootPath: process.cwd(), cwd: process.cwd() },
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
The CLI sets this automatically, but SDK consumers must set it explicitly.
|
||||
|
||||
## send() Requires an Active Session
|
||||
|
||||
`cline.send()` only works on sessions that are still active. If a session has already completed, `send()` may return `undefined` or fail. Check session status with `cline.get(sessionId)` first.
|
||||
|
||||
## Result May Be Undefined
|
||||
|
||||
`session.result` can be `undefined` if the session was started but hasn't completed yet (e.g., in a non-blocking hub mode). Check for this:
|
||||
|
||||
```typescript
|
||||
const session = await cline.start({ ... })
|
||||
if (session.result) {
|
||||
console.log(session.result.text)
|
||||
} else {
|
||||
console.log("Session started but not yet complete")
|
||||
}
|
||||
```
|
||||
|
||||
## Compaction and Long Sessions
|
||||
|
||||
For long-running sessions, message history grows and eventually exceeds the model's context window. ClineCore handles this via compaction, which summarizes older messages. Configure it via `compactionConfig`:
|
||||
|
||||
```typescript
|
||||
config: {
|
||||
compactionConfig: {
|
||||
strategy: "summarize",
|
||||
// ...
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
The default strategy works for most cases, but extremely long sessions may benefit from tuning.
|
||||
|
||||
## See Also
|
||||
|
||||
- `api.md` - Full API reference
|
||||
- `patterns.md` - Common patterns
|
||||
- `../agent/gotchas.md` - Agent-level gotchas
|
||||
- `../tools/REFERENCE.md` - Tool troubleshooting
|
||||
- `../providers/REFERENCE.md` - Provider troubleshooting
|
||||
@@ -0,0 +1,279 @@
|
||||
# ClineCore Patterns
|
||||
|
||||
## Basic Session with Built-in Tools
|
||||
|
||||
```typescript
|
||||
import { ClineCore } from "@cline/sdk"
|
||||
|
||||
const cline = await ClineCore.create({ clientName: "my-app" })
|
||||
|
||||
const session = await cline.start({
|
||||
prompt: "Read package.json and summarize the dependencies",
|
||||
config: {
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
apiKey: process.env.ANTHROPIC_API_KEY,
|
||||
cwd: process.cwd(),
|
||||
enableTools: true,
|
||||
},
|
||||
})
|
||||
|
||||
console.log(session.result?.text)
|
||||
await cline.dispose()
|
||||
```
|
||||
|
||||
## Streaming Session with UI Updates
|
||||
|
||||
```typescript
|
||||
const cline = await ClineCore.create({ clientName: "my-app" })
|
||||
|
||||
cline.subscribe((event) => {
|
||||
switch (event.type) {
|
||||
case "chunk":
|
||||
if (event.payload.type === "text") {
|
||||
ui.appendText(event.payload.text)
|
||||
}
|
||||
break
|
||||
case "ended":
|
||||
ui.showComplete(event.payload.finishReason)
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
await cline.start({
|
||||
prompt: "Refactor the auth module",
|
||||
config: {
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
cwd: "/path/to/project",
|
||||
enableTools: true,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Multi-Turn Session
|
||||
|
||||
```typescript
|
||||
const cline = await ClineCore.create({ clientName: "my-app" })
|
||||
|
||||
const session = await cline.start({
|
||||
prompt: "Create a new Express server",
|
||||
config: {
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
cwd: "/path/to/project",
|
||||
enableTools: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Follow-up
|
||||
const result = await cline.send({
|
||||
sessionId: session.sessionId,
|
||||
prompt: "Now add a health check endpoint",
|
||||
})
|
||||
|
||||
console.log(result?.text)
|
||||
await cline.dispose()
|
||||
```
|
||||
|
||||
## Tiered Permission Model
|
||||
|
||||
Auto-approve reads, require approval for writes:
|
||||
|
||||
```typescript
|
||||
const cline = await ClineCore.create({
|
||||
clientName: "my-app",
|
||||
toolPolicies: {
|
||||
read_files: { autoApprove: true },
|
||||
search: { autoApprove: true },
|
||||
fetch_web: { autoApprove: true },
|
||||
bash: { autoApprove: false },
|
||||
editor: { autoApprove: false },
|
||||
apply_patch: { autoApprove: false },
|
||||
},
|
||||
capabilities: {
|
||||
requestToolApproval: async (request) => {
|
||||
const approved = await promptUser(
|
||||
`Allow ${request.toolName}?\n${JSON.stringify(request.input, null, 2)}`
|
||||
)
|
||||
return { approved }
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Custom Tools Alongside Built-ins
|
||||
|
||||
```typescript
|
||||
import { ClineCore, createTool } from "@cline/sdk"
|
||||
import { z } from "zod"
|
||||
|
||||
const deployTool = createTool({
|
||||
name: "deploy",
|
||||
description: "Deploy the application to the specified environment.",
|
||||
inputSchema: z.object({
|
||||
environment: z.enum(["staging", "production"]),
|
||||
}),
|
||||
execute: async (input) => {
|
||||
const result = await runDeployment(input.environment)
|
||||
return { url: result.url, status: "deployed" }
|
||||
},
|
||||
})
|
||||
|
||||
const cline = await ClineCore.create({ clientName: "my-app" })
|
||||
|
||||
await cline.start({
|
||||
prompt: "Deploy the app to staging",
|
||||
config: {
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
cwd: process.cwd(),
|
||||
enableTools: true,
|
||||
tools: [deployTool],
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Session with Plugins
|
||||
|
||||
Load plugins inline with `extensions` and provide workspace context so plugins can access `ctx.workspaceInfo`:
|
||||
|
||||
```typescript
|
||||
import { ClineCore } from "@cline/sdk"
|
||||
import myPlugin from "./my-plugin"
|
||||
|
||||
const cline = await ClineCore.create({
|
||||
clientName: "my-app",
|
||||
backendMode: "local",
|
||||
})
|
||||
|
||||
await cline.start({
|
||||
prompt: "Do the thing my plugin enables",
|
||||
config: {
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
cwd: process.cwd(),
|
||||
enableTools: true,
|
||||
extensions: [myPlugin],
|
||||
extensionContext: {
|
||||
workspace: { rootPath: process.cwd(), cwd: process.cwd() },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await cline.dispose()
|
||||
```
|
||||
|
||||
For directory-based plugin packages, use `pluginPaths` instead:
|
||||
|
||||
```typescript
|
||||
config: {
|
||||
pluginPaths: ["./my-cline-plugin"],
|
||||
extensionContext: {
|
||||
workspace: { rootPath: process.cwd(), cwd: process.cwd() },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
See `../plugins/REFERENCE.md` for the full plugin authoring guide.
|
||||
|
||||
## Session Listing and Replay
|
||||
|
||||
```typescript
|
||||
const cline = await ClineCore.create({ clientName: "my-app" })
|
||||
|
||||
// List recent sessions
|
||||
const sessions = await cline.list(10)
|
||||
for (const session of sessions) {
|
||||
console.log(`${session.id}: ${session.title}`)
|
||||
}
|
||||
|
||||
// Read messages from a past session
|
||||
const messages = await cline.readMessages(sessions[0].id)
|
||||
for (const msg of messages) {
|
||||
console.log(`[${msg.role}] ${msg.content}`)
|
||||
}
|
||||
|
||||
// Check usage
|
||||
const usage = await cline.getAccumulatedUsage(sessions[0].id)
|
||||
console.log(`Total tokens: ${usage.aggregateUsage.totalInputTokens + usage.aggregateUsage.totalOutputTokens}`)
|
||||
```
|
||||
|
||||
## Graceful Shutdown
|
||||
|
||||
```typescript
|
||||
const cline = await ClineCore.create({ clientName: "my-app" })
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
await cline.dispose("SIGTERM received")
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
// Run sessions...
|
||||
```
|
||||
|
||||
## Stateless Worker Pattern
|
||||
|
||||
For request/response workloads (API endpoints, queue consumers):
|
||||
|
||||
```typescript
|
||||
import { ClineCore } from "@cline/sdk"
|
||||
|
||||
const cline = await ClineCore.create({
|
||||
clientName: "worker",
|
||||
backendMode: "local",
|
||||
})
|
||||
|
||||
async function handleRequest(prompt: string, workspace: string) {
|
||||
const session = await cline.start({
|
||||
prompt,
|
||||
config: {
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
cwd: workspace,
|
||||
enableTools: true,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
text: session.result?.text,
|
||||
usage: session.result?.usage,
|
||||
sessionId: session.sessionId,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Hub-Backed Multi-Client
|
||||
|
||||
Multiple clients can attach to the same session:
|
||||
|
||||
```typescript
|
||||
// Process 1: start session
|
||||
const cline = await ClineCore.create({
|
||||
clientName: "backend",
|
||||
backendMode: "hub",
|
||||
})
|
||||
|
||||
const session = await cline.start({
|
||||
prompt: "Long running refactor task",
|
||||
config: { ... },
|
||||
})
|
||||
|
||||
// Process 2: attach and stream events
|
||||
const viewer = await ClineCore.create({
|
||||
clientName: "dashboard",
|
||||
backendMode: "hub",
|
||||
})
|
||||
|
||||
viewer.subscribe((event) => {
|
||||
dashboard.render(event)
|
||||
}, { sessionId: session.sessionId })
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
- `api.md` - Full API reference
|
||||
- `gotchas.md` - Common pitfalls
|
||||
- `../tools/REFERENCE.md` - Tool creation
|
||||
- `../plugins/REFERENCE.md` - Plugin system
|
||||
- `../scheduling/REFERENCE.md` - Scheduled agents
|
||||
@@ -0,0 +1,269 @@
|
||||
# Events
|
||||
|
||||
The Cline SDK has three event layers. Which one you use depends on whether you're working with the standalone `Agent` class or `ClineCore`.
|
||||
|
||||
## Which Events Do I Get?
|
||||
|
||||
| If you use... | You subscribe with... | You receive... | Text streaming event |
|
||||
|---|---|---|---|
|
||||
| Standalone `Agent` | `agent.subscribe()` | `AgentRuntimeEvent` | `assistant-text-delta` |
|
||||
| `ClineCore` | `cline.subscribe()` | `CoreSessionEvent` | `chunk` (with `payload.type === "text"`) |
|
||||
|
||||
These are different event types with different shapes. Do not mix them up.
|
||||
|
||||
## Layer 1: AgentRuntimeEvent (Standalone Agent)
|
||||
|
||||
Emitted by the `Agent` class via `agent.subscribe()`. This is what you get when using `new Agent(...)` directly. Every event includes a `snapshot` field with the current `AgentRuntimeStateSnapshot`.
|
||||
|
||||
### Run Lifecycle
|
||||
|
||||
```typescript
|
||||
{ type: "run-started", snapshot }
|
||||
{ type: "run-finished", snapshot, result: AgentRunResult }
|
||||
{ type: "run-failed", snapshot, error: Error }
|
||||
```
|
||||
|
||||
### Turns
|
||||
|
||||
```typescript
|
||||
{ type: "turn-started", snapshot, iteration: number }
|
||||
{ type: "turn-finished", snapshot, iteration: number, toolCallCount: number }
|
||||
```
|
||||
|
||||
### Text Streaming
|
||||
|
||||
```typescript
|
||||
// Streaming text delta (arrives as chunks during generation)
|
||||
{ type: "assistant-text-delta", snapshot, iteration: number, text: string, accumulatedText: string }
|
||||
|
||||
// Streaming reasoning delta (when model uses extended thinking)
|
||||
{ type: "assistant-reasoning-delta", snapshot, iteration: number, text: string }
|
||||
|
||||
// Complete assistant message after model finishes
|
||||
{ type: "assistant-message", snapshot, iteration: number, message: AgentMessage, finishReason: string }
|
||||
```
|
||||
|
||||
### Messages
|
||||
|
||||
```typescript
|
||||
// Fired when any message (user or assistant) is added to conversation history
|
||||
{ type: "message-added", snapshot, message: AgentMessage }
|
||||
```
|
||||
|
||||
### Tool Events
|
||||
|
||||
```typescript
|
||||
{ type: "tool-started", snapshot, toolCall: { toolName: string, toolCallId: string, input: unknown } }
|
||||
{ type: "tool-updated", snapshot, toolCall: { toolName: string, toolCallId: string }, update: string }
|
||||
{ type: "tool-finished", snapshot, toolCall: { toolName: string, toolCallId: string }, message: AgentMessage }
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: "usage-updated",
|
||||
snapshot,
|
||||
usage: {
|
||||
inputTokens: number,
|
||||
outputTokens: number,
|
||||
cacheReadTokens?: number,
|
||||
cacheWriteTokens?: number,
|
||||
totalCost?: number,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Notices
|
||||
|
||||
```typescript
|
||||
{ type: "status-notice", snapshot, message: string, metadata?: Record<string, unknown> }
|
||||
```
|
||||
|
||||
### Subscribing
|
||||
|
||||
Use `agent.subscribe()`. Register the listener before calling `run()` to avoid missing early events.
|
||||
|
||||
```typescript
|
||||
const agent = new Agent({
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
apiKey: process.env.ANTHROPIC_API_KEY,
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
tools: [],
|
||||
})
|
||||
|
||||
agent.subscribe((event) => {
|
||||
switch (event.type) {
|
||||
case "assistant-text-delta":
|
||||
process.stdout.write(event.text)
|
||||
break
|
||||
case "tool-started":
|
||||
console.log(`\nUsing tool: ${event.toolCall.toolName}`)
|
||||
break
|
||||
case "usage-updated":
|
||||
console.log(`Cost: $${event.usage.totalCost?.toFixed(4)}`)
|
||||
break
|
||||
case "run-finished":
|
||||
console.log(`\nDone: ${event.result.status}`)
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
const result = await agent.run("Hello!")
|
||||
```
|
||||
|
||||
You can also receive events through hooks (these are awaited, so they can be async):
|
||||
|
||||
```typescript
|
||||
const agent = new Agent({
|
||||
...config,
|
||||
hooks: {
|
||||
onEvent: async (event) => {
|
||||
// Same AgentRuntimeEvent types as subscribe()
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Layer 2: AgentEvent (ClineCore Internal)
|
||||
|
||||
When using `ClineCore`, a `RuntimeEventAdapter` translates Layer 1 events into a legacy format called `AgentEvent`. You do not interact with this layer directly -- it is projected into `CoreSessionEvent` for subscribers. The key mappings:
|
||||
|
||||
| AgentRuntimeEvent (Layer 1) | AgentEvent (Layer 2) |
|
||||
|---|---|
|
||||
| `turn-started` | `iteration_start` |
|
||||
| `turn-finished` | `iteration_end` |
|
||||
| `assistant-text-delta` | `content_start` (text) |
|
||||
| `assistant-message` | `content_end` (text) |
|
||||
| `tool-started` | `content_start` (tool) |
|
||||
| `tool-updated` | `content_update` (tool) |
|
||||
| `tool-finished` | `content_end` (tool) |
|
||||
| `usage-updated` | `usage` (with computed deltas) |
|
||||
| `run-finished` | `done` |
|
||||
| `run-failed` | `error` |
|
||||
| `run-started`, `message-added` | (suppressed, not emitted) |
|
||||
|
||||
This layer exists for backwards compatibility. If you see event types like `content_update` or `iteration_start` in other documentation, they refer to this layer, not to what `agent.subscribe()` emits.
|
||||
|
||||
## Layer 3: CoreSessionEvent (ClineCore Subscriber)
|
||||
|
||||
Emitted by `ClineCore` via `cline.subscribe()`. These are higher-level session events.
|
||||
|
||||
```typescript
|
||||
type CoreSessionEvent =
|
||||
| { type: "chunk"; payload: SessionChunkEvent }
|
||||
| { type: "agent_event"; payload: { sessionId: string, event: AgentEvent } }
|
||||
| { type: "ended"; payload: SessionEndedEvent }
|
||||
| { type: "team_progress"; payload: SessionTeamProgressEvent }
|
||||
| { type: "status"; payload: { sessionId: string, status: string } }
|
||||
| { type: "hook"; payload: SessionToolEvent }
|
||||
```
|
||||
|
||||
### SessionChunkEvent
|
||||
|
||||
```typescript
|
||||
interface SessionChunkEvent {
|
||||
type: "text" | "reasoning"
|
||||
text: string
|
||||
sessionId: string
|
||||
}
|
||||
```
|
||||
|
||||
### SessionEndedEvent
|
||||
|
||||
```typescript
|
||||
interface SessionEndedEvent {
|
||||
sessionId: string
|
||||
finishReason: "completed" | "max_iterations" | "aborted" | "mistake_limit" | "error"
|
||||
result?: AgentResult
|
||||
}
|
||||
```
|
||||
|
||||
### Subscribing
|
||||
|
||||
```typescript
|
||||
cline.subscribe((event) => {
|
||||
switch (event.type) {
|
||||
case "chunk":
|
||||
if (event.payload.type === "text") {
|
||||
process.stdout.write(event.payload.text)
|
||||
}
|
||||
break
|
||||
case "ended":
|
||||
console.log(`Finished: ${event.payload.finishReason}`)
|
||||
break
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
Filter by session:
|
||||
|
||||
```typescript
|
||||
cline.subscribe(handler, { sessionId: "specific-session-id" })
|
||||
```
|
||||
|
||||
## Hub Events (Layer 3b)
|
||||
|
||||
When ClineCore runs in hub mode (via `backendMode: "hub"` or `"auto"` when a hub is available), events are projected over WebSocket using `HubEventName` types like `assistant.delta`, `iteration.started`, `tool.started`, etc. You do not interact with these directly -- `cline.subscribe()` still gives you `CoreSessionEvent` regardless of backend mode.
|
||||
|
||||
## Result Type Differences
|
||||
|
||||
The standalone Agent and ClineCore return different result types:
|
||||
|
||||
| API | Result type | Text property |
|
||||
|---|---|---|
|
||||
| `agent.run()` | `AgentRunResult` | `result.outputText` |
|
||||
| `cline.start()` / `cline.send()` | `AgentResult` | `result.text` |
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Streaming Text (Standalone Agent)
|
||||
|
||||
```typescript
|
||||
agent.subscribe((event) => {
|
||||
if (event.type === "assistant-text-delta") {
|
||||
process.stdout.write(event.text)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Streaming Text (ClineCore)
|
||||
|
||||
```typescript
|
||||
cline.subscribe((event) => {
|
||||
if (event.type === "chunk" && event.payload.type === "text") {
|
||||
process.stdout.write(event.payload.text)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Usage Tracking (Standalone Agent)
|
||||
|
||||
```typescript
|
||||
agent.subscribe((event) => {
|
||||
if (event.type === "usage-updated" && event.usage.totalCost) {
|
||||
console.log(`Running cost: $${event.usage.totalCost.toFixed(4)}`)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Tool Call Logging (Standalone Agent)
|
||||
|
||||
```typescript
|
||||
agent.subscribe((event) => {
|
||||
if (event.type === "tool-started") {
|
||||
console.log(`Tool started: ${event.toolCall.toolName}`)
|
||||
}
|
||||
if (event.type === "tool-finished") {
|
||||
console.log(`Tool finished: ${event.toolCall.toolName}`)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
- `../agent/REFERENCE.md` - Agent runtime overview
|
||||
- `../clinecore/REFERENCE.md` - ClineCore session management
|
||||
- `../plugins/REFERENCE.md` - Plugin hooks for lifecycle events
|
||||
- `../production/REFERENCE.md` - Observability in production
|
||||
@@ -0,0 +1,157 @@
|
||||
# Multi-Agent Coordination
|
||||
|
||||
The Cline SDK supports two models for multi-agent work: sub-agents (parent-child) and teams (peer-to-peer).
|
||||
|
||||
## Sub-Agents vs Teams
|
||||
|
||||
| Feature | Sub-Agents | Teams |
|
||||
|---------|-----------|-------|
|
||||
| Enable with | `enableSpawnAgent: true` | `enableAgentTeams: true` |
|
||||
| Persistence | Session-scoped only | Across sessions |
|
||||
| Coordination | Parent-child hierarchy | Peer-to-peer |
|
||||
| Shared state | None | Task board, mailbox, mission log |
|
||||
| Best for | One-off delegation | Complex multi-session projects |
|
||||
|
||||
## Sub-Agents
|
||||
|
||||
Sub-agents are spawned by a parent agent during a run. They execute independently and report results back.
|
||||
|
||||
### Enabling Sub-Agents
|
||||
|
||||
```typescript
|
||||
const cline = await ClineCore.create({ clientName: "my-app" })
|
||||
|
||||
await cline.start({
|
||||
prompt: "Refactor the auth module and update tests",
|
||||
config: {
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
enableSpawnAgent: true,
|
||||
enableTools: true,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
When `enableSpawnAgent` is true, the agent gets access to sub-agent tools:
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `start_subagent` | Spawn a background agent with a task |
|
||||
| `message_subagent` | Send a message to a running sub-agent |
|
||||
| `handoff_to_agent` | Delegate the current task entirely |
|
||||
| `submit_and_exit` | Signal completion |
|
||||
|
||||
### How Sub-Agents Work
|
||||
|
||||
1. The parent agent decides a subtask can be delegated
|
||||
2. It calls `start_subagent` with a role, task description, and optionally a preset
|
||||
3. The sub-agent runs independently in the background
|
||||
4. The parent can check status or send follow-up messages
|
||||
5. Sub-agent results are available to the parent when complete
|
||||
|
||||
## Teams
|
||||
|
||||
Teams provide persistent, cross-session coordination between agents.
|
||||
|
||||
### Enabling Teams
|
||||
|
||||
```typescript
|
||||
await cline.start({
|
||||
config: {
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
enableAgentTeams: true,
|
||||
teamName: "auth-sprint",
|
||||
enableTools: true,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Team Tools
|
||||
|
||||
When `enableAgentTeams` is true, the coordinator agent gets:
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `team_spawn_teammate` | Create a new agent with a role and task |
|
||||
| `team_delegate_task` | Assign a task to an existing teammate |
|
||||
| `team_check_status` | Check on a delegated task's progress |
|
||||
| `team_get_result` | Get the completed result from a teammate |
|
||||
|
||||
### Team Persistence
|
||||
|
||||
Teams store shared state in:
|
||||
|
||||
```
|
||||
~/.cline/data/teams/[team-name]/
|
||||
task-board.json # task assignments and status
|
||||
mailbox.json # inter-agent messages
|
||||
mission-log.json # coordination log
|
||||
```
|
||||
|
||||
This state persists across sessions, so team members can pick up where they left off.
|
||||
|
||||
### CLI Team Access
|
||||
|
||||
```bash
|
||||
cline --team-name auth-sprint "Continue the auth refactor"
|
||||
```
|
||||
|
||||
## Choosing Between Sub-Agents and Teams
|
||||
|
||||
Use sub-agents when:
|
||||
- You need one-off parallel execution within a single session
|
||||
- Tasks are independent and don't need to communicate with each other
|
||||
- Results only matter to the parent agent
|
||||
|
||||
Use teams when:
|
||||
- Work spans multiple sessions over time
|
||||
- Agents need to coordinate and share progress
|
||||
- Tasks have dependencies between them
|
||||
- You want a persistent record of multi-agent collaboration
|
||||
|
||||
## Patterns
|
||||
|
||||
### Parallel Research with Sub-Agents
|
||||
|
||||
A parent agent spawns multiple sub-agents to research different topics simultaneously:
|
||||
|
||||
```typescript
|
||||
await cline.start({
|
||||
prompt: `Research these three topics in parallel:
|
||||
1. Current best practices for JWT auth
|
||||
2. OAuth 2.0 provider comparison
|
||||
3. Session management patterns
|
||||
Spawn a sub-agent for each topic, then synthesize the results.`,
|
||||
config: {
|
||||
enableSpawnAgent: true,
|
||||
enableTools: true,
|
||||
// ...
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Team Sprint
|
||||
|
||||
A coordinator manages a multi-session project:
|
||||
|
||||
```typescript
|
||||
await cline.start({
|
||||
prompt: `You are the coordinator for the auth-sprint team.
|
||||
Review the task board and delegate the next highest-priority task
|
||||
to a teammate. Check status on any in-progress tasks.`,
|
||||
config: {
|
||||
enableAgentTeams: true,
|
||||
teamName: "auth-sprint",
|
||||
enableTools: true,
|
||||
// ...
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
- `../clinecore/REFERENCE.md` - ClineCore runtime
|
||||
- `../clinecore/api.md` - Session config for teams
|
||||
- `../tools/REFERENCE.md` - Tool system
|
||||
- `../plugins/REFERENCE.md` - Plugin system
|
||||
@@ -0,0 +1,649 @@
|
||||
# Plugins
|
||||
|
||||
A Cline plugin is a TypeScript module that extends any agent built on the Cline SDK. The same plugin runs in the Cline CLI, VS Code and JetBrains extensions, and any custom app built on `@cline/core`.
|
||||
|
||||
A plugin can:
|
||||
|
||||
- Register tools the model can call.
|
||||
- Hook into the agent loop before/after runs, model calls, and tool calls.
|
||||
- Rewrite provider messages before they hit the model (custom compaction, redaction, context shaping).
|
||||
- Register slash commands, prompt rules, providers, and automation event types.
|
||||
|
||||
A plugin ships in one of two shapes:
|
||||
|
||||
1. Single-file plugin -- one `.ts` file that exports a default plugin object. Drop it in a discovery folder and it loads.
|
||||
2. Plugin package -- a directory with `package.json`, npm dependencies, and optionally bundled assets. Installable via `cline plugin install`.
|
||||
|
||||
Both shapes use the same plugin API.
|
||||
|
||||
## The Mental Model
|
||||
|
||||
When the host starts a session, it builds a registry of plugins and runs four phases:
|
||||
|
||||
1. resolve -- collect the plugin objects.
|
||||
2. validate -- check each plugin's `manifest`. Capabilities must be non-empty; declared hook stages must have matching handlers; if `hooks` is present, `"hooks"` must be in `capabilities`.
|
||||
3. setup -- call each plugin's `setup(api, ctx)` once. This is where you `registerTool`, `registerCommand`, etc.
|
||||
4. activate -- registry is frozen, the agent loop starts, and your hooks/tools are live.
|
||||
|
||||
Two invariants the registry enforces:
|
||||
|
||||
- Every contribution requires a matching capability. Calling `api.registerRule(...)` without `"rules"` in `manifest.capabilities` throws.
|
||||
- Capabilities and handlers must agree. Declaring `"hooks"` without a `hooks` object, or vice versa, fails validation.
|
||||
|
||||
After validation, registration is one-shot -- no dynamic register/unregister during the session.
|
||||
|
||||
## The Smallest Working Plugin
|
||||
|
||||
```typescript
|
||||
import type { AgentPlugin } from "@cline/core"
|
||||
import { createTool } from "@cline/core"
|
||||
|
||||
const plugin: AgentPlugin = {
|
||||
name: "hello-plugin",
|
||||
manifest: {
|
||||
capabilities: ["tools"],
|
||||
},
|
||||
setup(api, ctx) {
|
||||
api.registerTool(
|
||||
createTool({
|
||||
name: "say_hello",
|
||||
description: "Greet a person by name.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { name: { type: "string" } },
|
||||
required: ["name"],
|
||||
},
|
||||
async execute({ name }: { name: string }) {
|
||||
return { greeting: `Hello, ${name}!` }
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export default plugin
|
||||
```
|
||||
|
||||
The agent will see `say_hello` as a callable tool.
|
||||
|
||||
## The Manifest
|
||||
|
||||
```typescript
|
||||
manifest: {
|
||||
capabilities: ["tools", "hooks"], // required, non-empty array
|
||||
paths?: string[], // optional, multi-entry packages
|
||||
providerIds?: string[], // optional, provider plugins
|
||||
modelIds?: string[], // optional, model plugins
|
||||
}
|
||||
```
|
||||
|
||||
### The Complete Capability List
|
||||
|
||||
| Capability | What It Unlocks in `api` |
|
||||
|-----------|--------------------------|
|
||||
| `"tools"` | `api.registerTool()` |
|
||||
| `"commands"` | `api.registerCommand()` (slash commands in chat surfaces) |
|
||||
| `"rules"` | `api.registerRule()` (string injected into the system prompt) |
|
||||
| `"messageBuilders"` | `api.registerMessageBuilder()` (rewrites provider-bound messages) |
|
||||
| `"providers"` | `api.registerProvider()` (custom model provider) |
|
||||
| `"automationEvents"` | `api.registerAutomationEventType()` and `ctx.automation?.ingestEvent()` |
|
||||
| `"hooks"` | The runtime `hooks` object on the plugin (lifecycle callbacks) |
|
||||
|
||||
You declare any combination -- most real plugins need 1-3 capabilities.
|
||||
|
||||
## setup(api, ctx) -- The Registration Phase
|
||||
|
||||
`setup()` runs once per session before the agent loop starts. Everything you register here is frozen for the lifetime of the session.
|
||||
|
||||
### The api Object
|
||||
|
||||
Each `register*` method requires the matching capability in your manifest:
|
||||
|
||||
```typescript
|
||||
api.registerTool(tool) // requires "tools"
|
||||
api.registerCommand({ name, description, handler }) // requires "commands"
|
||||
api.registerRule({ id, content, source }) // requires "rules"
|
||||
api.registerMessageBuilder({ name, build }) // requires "messageBuilders"
|
||||
api.registerProvider({ name, description }) // requires "providers"
|
||||
api.registerAutomationEventType({ eventType, source }) // requires "automationEvents"
|
||||
```
|
||||
|
||||
### The ctx Object -- Host-Provided Session Context
|
||||
|
||||
The second argument carries everything the host knows about the current session. All fields are optional, so feature-detect before using them -- the same plugin must work in hosts that supply less context (unit tests, sandboxed plugin processes).
|
||||
|
||||
```typescript
|
||||
ctx.session?.sessionId // string, stable core session id
|
||||
ctx.client?.name // host: "cline-cli", "cline-vscode", etc.
|
||||
ctx.user // authenticated user/org info, when available
|
||||
ctx.workspaceInfo // { rootPath, hint, latestGitBranchName,
|
||||
// latestGitCommitHash, associatedRemoteUrls }
|
||||
ctx.automation?.ingestEvent // emit normalized automation events
|
||||
ctx.logger?.log // structured logger scoped to this plugin
|
||||
ctx.telemetry // ITelemetryService, only present in-process
|
||||
```
|
||||
|
||||
Two rules about `ctx.workspaceInfo`:
|
||||
|
||||
1. Always prefer `ctx.workspaceInfo?.rootPath` over `process.cwd()`. The CLI may have been launched with `--cwd` without calling `chdir`, and VS Code workspaces don't share a single CWD. `workspaceInfo` is sourced from the session config and is always correct.
|
||||
2. Don't use `import.meta.url` tricks to find "the workspace". That gives you the plugin's own location, not the user's project.
|
||||
|
||||
### Persisting State Across Hooks
|
||||
|
||||
`setup()` runs first; hooks fire later. The simplest way to share state is module-level variables:
|
||||
|
||||
```typescript
|
||||
let sessionWorkspaceRoot: string | undefined
|
||||
let sessionBranch: string | undefined
|
||||
|
||||
const plugin: AgentPlugin = {
|
||||
name: "metrics",
|
||||
manifest: { capabilities: ["hooks"] },
|
||||
setup(api, ctx) {
|
||||
sessionWorkspaceRoot = ctx.workspaceInfo?.rootPath
|
||||
sessionBranch = ctx.workspaceInfo?.latestGitBranchName
|
||||
},
|
||||
hooks: {
|
||||
beforeTool({ toolCall, input }) {
|
||||
if (sessionBranch === "main" && toolCall.toolName === "run_commands") {
|
||||
// inspect input, optionally block
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
A single Node process may host multiple sessions concurrently. If your plugin will run in a multi-session host, key your state by `ctx.session?.sessionId`:
|
||||
|
||||
```typescript
|
||||
const stateBySession = new Map<string, MyState>()
|
||||
setup(api, ctx) {
|
||||
const id = ctx.session?.sessionId
|
||||
if (id) stateBySession.set(id, /* ... */)
|
||||
}
|
||||
```
|
||||
|
||||
## Runtime Hooks
|
||||
|
||||
Runtime hooks are typed in-process callbacks on the same hook layer the runtime uses internally. They run inside the agent loop with full type information -- no IPC, no JSON marshaling.
|
||||
|
||||
Declare `"hooks"` in `manifest.capabilities`, then add a `hooks` property:
|
||||
|
||||
```typescript
|
||||
const plugin: AgentPlugin = {
|
||||
name: "metrics",
|
||||
manifest: { capabilities: ["hooks"] },
|
||||
hooks: {
|
||||
beforeRun(ctx) { /* ... */ },
|
||||
beforeTool({ toolCall, input }) { /* ... */ },
|
||||
afterTool({ toolCall, result }) { /* ... */ },
|
||||
afterRun({ result }) { /* ... */ },
|
||||
onEvent(event) { /* ... */ },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### The Seven Hooks
|
||||
|
||||
| Hook | Fires | Can Stop the Loop? | Common Uses |
|
||||
|------|-------|--------------------|-------------|
|
||||
| `beforeRun` | Before the runtime loop starts | Yes | Greet, log, attach session metadata |
|
||||
| `afterRun` | After the runtime loop finishes (success, abort, or fail) | No | Notifications, metrics, persistent logs |
|
||||
| `beforeModel` | Before each model request | Yes (mutate req) | Inject context, last-mile prompt edits |
|
||||
| `afterModel` | After each model response, before tool execution | Yes | Block based on model output |
|
||||
| `beforeTool` | Before each tool execution | Yes (`{ stop }`) | Audit, redact, block dangerous tools |
|
||||
| `afterTool` | After each tool execution | Can replace result | Post-process, redact secrets in tool output |
|
||||
| `onEvent` | On every `AgentRuntimeEvent` emitted by the runtime | No | Streaming UIs, telemetry pipes |
|
||||
|
||||
### Stopping the Loop from a Hook
|
||||
|
||||
Several hooks return an optional control object. The most common pattern is `beforeTool` blocking a destructive tool call:
|
||||
|
||||
```typescript
|
||||
beforeTool({ toolCall, input }) {
|
||||
if (toolCall.toolName === "run_commands") {
|
||||
const { commands } = input as { commands?: string[] }
|
||||
if (sessionBranch === "main" && commands?.some(c => c.startsWith("git push"))) {
|
||||
return { stop: true, reason: "Blocked git push on protected branch" }
|
||||
}
|
||||
}
|
||||
return undefined // explicit "continue"
|
||||
}
|
||||
```
|
||||
|
||||
Returning `undefined` (or omitting `return`) lets execution continue normally.
|
||||
|
||||
### afterRun Semantics
|
||||
|
||||
`afterRun` fires for every terminal status -- `completed`, `aborted`, `failed`. If you only want to act on success:
|
||||
|
||||
```typescript
|
||||
afterRun({ result }) {
|
||||
if (result.status !== "completed") return
|
||||
// notify, log success metrics, etc.
|
||||
}
|
||||
```
|
||||
|
||||
### Plugin Hooks vs File Hooks
|
||||
|
||||
The runtime supports two hook systems:
|
||||
|
||||
- File hooks -- external scripts in `.cline/hooks/` invoked with serialized JSON. Right for user/workspace-specific scripts that don't ship with code.
|
||||
- Plugin runtime hooks -- typed in-process callbacks. Right when the behavior belongs to a reusable extension and needs typed access to the runtime.
|
||||
|
||||
Core adapts file hooks onto the runtime hook layer, so you don't need both. If you're shipping a plugin, write it as runtime hooks.
|
||||
|
||||
## Message Builders
|
||||
|
||||
Message builders rewrite the provider-bound message list before the model call. They run after runtime messages are converted into SDK message blocks but before core's built-in safety builder.
|
||||
|
||||
Use them for:
|
||||
|
||||
- Custom compaction policies (replace middle history with a summary).
|
||||
- Redacting PII or secrets before they reach the provider.
|
||||
- Reshaping context for a specific model's strengths.
|
||||
|
||||
```typescript
|
||||
api.registerMessageBuilder({
|
||||
name: "summarize-middle-history",
|
||||
build(messages) {
|
||||
if (estimateTokens(messages) < THRESHOLD) return messages
|
||||
return [...prefix, summary, ...recent]
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Multiple builders run in registration order; the output of one is the input of the next.
|
||||
|
||||
When to use `beforeModel` instead: reach for the `beforeModel` hook only if you need the runtime snapshot or want to mutate the request object itself. Pure message rewrites belong in a builder.
|
||||
|
||||
## Automation Events
|
||||
|
||||
Plugins can declare normalized event types and emit them into Cline automation. Hosts that don't have automation enabled simply ignore both -- feature-detect `ctx.automation`.
|
||||
|
||||
```typescript
|
||||
manifest: { capabilities: ["automationEvents"] },
|
||||
|
||||
setup(api, ctx) {
|
||||
api.registerAutomationEventType({
|
||||
eventType: "github.pull_request.opened",
|
||||
source: "github",
|
||||
description: "A new GitHub PR was opened",
|
||||
attributesSchema: { /* JSON Schema for envelope.attributes */ },
|
||||
})
|
||||
|
||||
if (!ctx.automation) return // host has no automation
|
||||
ctx.automation.ingestEvent({
|
||||
eventId: "pr-1234",
|
||||
eventType: "github.pull_request.opened",
|
||||
source: "github",
|
||||
subject: "owner/repo#1234",
|
||||
occurredAt: new Date().toISOString(),
|
||||
attributes: { /* ... */ },
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Loading a Plugin
|
||||
|
||||
There are three ways a plugin gets into a session:
|
||||
|
||||
### Auto-Discovery (CLI)
|
||||
|
||||
The CLI scans these directories on startup:
|
||||
|
||||
- `<workspace>/.cline/plugins/` -- project-scoped plugins.
|
||||
- `~/.cline/plugins/` -- user-scoped plugins.
|
||||
|
||||
Drop a `.ts` or `.js` file in, run `cline`, done:
|
||||
|
||||
```bash
|
||||
mkdir -p .cline/plugins
|
||||
cp my-plugin.ts .cline/plugins/
|
||||
cline -i "do the thing my plugin enables"
|
||||
```
|
||||
|
||||
### Explicit extensions in SDK Config
|
||||
|
||||
When you build your own host with `ClineCore`, pass the plugin object directly:
|
||||
|
||||
```typescript
|
||||
import plugin from "./my-plugin"
|
||||
import { ClineCore } from "@cline/core"
|
||||
|
||||
const host = await ClineCore.create({ backendMode: "local" })
|
||||
await host.start({
|
||||
config: {
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
apiKey: process.env.ANTHROPIC_API_KEY ?? "",
|
||||
cwd: process.cwd(),
|
||||
enableTools: true,
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
extensions: [plugin],
|
||||
extensionContext: {
|
||||
workspace: { rootPath: process.cwd(), cwd: process.cwd() },
|
||||
},
|
||||
},
|
||||
prompt: "...",
|
||||
interactive: false,
|
||||
})
|
||||
```
|
||||
|
||||
### pluginPaths for Directory-Based Plugins
|
||||
|
||||
When the plugin is a directory with `package.json`, point `pluginPaths` at the directory:
|
||||
|
||||
```typescript
|
||||
config: {
|
||||
pluginPaths: ["./path/to/my-plugin-package"],
|
||||
}
|
||||
```
|
||||
|
||||
Or install with the CLI:
|
||||
|
||||
```bash
|
||||
cline plugin install ./path/to/my-plugin-package
|
||||
cline plugin install @scope/my-cline-plugin # from npm
|
||||
cline plugin install --git github.com/owner/repo # from git
|
||||
```
|
||||
|
||||
## Single-File Plugin Template
|
||||
|
||||
Save as `my-plugin.ts`, drop in `.cline/plugins/`:
|
||||
|
||||
```typescript
|
||||
import { type AgentPlugin, ClineCore, createTool } from "@cline/core"
|
||||
|
||||
let sessionRoot: string | undefined
|
||||
|
||||
const plugin: AgentPlugin = {
|
||||
name: "my-plugin",
|
||||
manifest: {
|
||||
capabilities: ["tools", "hooks"],
|
||||
},
|
||||
|
||||
setup(api, ctx) {
|
||||
sessionRoot = ctx.workspaceInfo?.rootPath
|
||||
|
||||
api.registerTool(
|
||||
createTool({
|
||||
name: "do_thing",
|
||||
description: "Do the thing this plugin exists for.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { target: { type: "string" } },
|
||||
required: ["target"],
|
||||
},
|
||||
async execute(input) {
|
||||
const { target } = input as { target: string }
|
||||
return { ok: true, target, root: sessionRoot }
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
hooks: {
|
||||
beforeRun() {
|
||||
console.log("[my-plugin] run started")
|
||||
},
|
||||
afterRun({ result }) {
|
||||
if (result.status !== "completed") return
|
||||
console.log(`[my-plugin] done in ${result.iterations} iteration(s)`)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
async function runDemo(): Promise<void> {
|
||||
const host = await ClineCore.create({ backendMode: "local" })
|
||||
try {
|
||||
const result = await host.start({
|
||||
config: {
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
apiKey: process.env.ANTHROPIC_API_KEY ?? "",
|
||||
cwd: process.cwd(),
|
||||
enableTools: true,
|
||||
systemPrompt: "You are a helpful assistant. Use tools when needed.",
|
||||
extensions: [plugin],
|
||||
extensionContext: {
|
||||
workspace: { rootPath: process.cwd(), cwd: process.cwd() },
|
||||
},
|
||||
},
|
||||
prompt: "Use do_thing on the target 'world'.",
|
||||
interactive: false,
|
||||
})
|
||||
console.log(result.result?.text ?? "")
|
||||
} finally {
|
||||
await host.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
await runDemo()
|
||||
}
|
||||
|
||||
export { plugin, runDemo }
|
||||
export default plugin
|
||||
```
|
||||
|
||||
Copy it, rename the tool, swap in your logic. The `runDemo()` function lets you test with `ANTHROPIC_API_KEY=sk-... bun run my-plugin.ts`.
|
||||
|
||||
## Plugin Package
|
||||
|
||||
Use a plugin package when you need npm dependencies, multiple entry points, bundled assets, or npm/git distribution.
|
||||
|
||||
### Layout
|
||||
|
||||
```
|
||||
my-cline-plugin/
|
||||
+-- package.json
|
||||
+-- tsconfig.json (optional, for local typechecking)
|
||||
+-- index.ts (the plugin entry point)
|
||||
+-- README.md
|
||||
+-- assets/ (optional, bundled content)
|
||||
+-- templates/
|
||||
+-- schemas/
|
||||
```
|
||||
|
||||
### package.json -- The Discovery Contract
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-cline-plugin",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "What this plugin does, in one sentence.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./index.ts"
|
||||
},
|
||||
"cline": {
|
||||
"plugins": [
|
||||
{
|
||||
"paths": ["./index.ts"],
|
||||
"capabilities": ["tools", "hooks"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@cline/core": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@cline/core": { "optional": true }
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.1.5"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Key fields:
|
||||
|
||||
- `type: "module"` -- required. Cline plugins are ES modules.
|
||||
- `cline.plugins` -- the discovery contract. Array of entries, each with `paths` (entry files) and `capabilities` (pre-declared, validated before importing).
|
||||
- `peerDependencies` for `@cline/core` -- the host already provides it. Marking it optional lets you typecheck in isolation.
|
||||
|
||||
### Bundling Assets
|
||||
|
||||
Resolve asset paths with `import.meta.url`, not `process.cwd()`:
|
||||
|
||||
```typescript
|
||||
import { dirname, join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { readFileSync, existsSync } from "node:fs"
|
||||
|
||||
const MODULE_DIR = dirname(fileURLToPath(import.meta.url))
|
||||
const TEMPLATES_DIR = join(MODULE_DIR, "assets", "templates")
|
||||
|
||||
function loadTemplate(name: string): string | undefined {
|
||||
const path = join(TEMPLATES_DIR, `${name}.md`)
|
||||
return existsSync(path) ? readFileSync(path, "utf8") : undefined
|
||||
}
|
||||
```
|
||||
|
||||
This is the only place `import.meta.url` is appropriate in a plugin -- locating files inside the plugin package. For workspace paths, always use `ctx.workspaceInfo?.rootPath`.
|
||||
|
||||
### The Override Pattern (Bundled / Global / Project)
|
||||
|
||||
A package can ship default assets and let users override them. The convention is a three-tier lookup, last write wins by `name`:
|
||||
|
||||
1. bundled -- files inside the plugin package (defaults shipped with the plugin).
|
||||
2. global -- files under `~/.cline/data/settings/<kind>/` (user overrides).
|
||||
3. project -- files under `<workspace>/.cline/<kind>/` (project overrides).
|
||||
|
||||
### Multiple Plugin Entries
|
||||
|
||||
If your package exposes more than one plugin, list each in `cline.plugins`:
|
||||
|
||||
```json
|
||||
"cline": {
|
||||
"plugins": [
|
||||
{ "paths": ["./tools-plugin.ts"], "capabilities": ["tools"] },
|
||||
{ "paths": ["./hooks-plugin.ts"], "capabilities": ["hooks"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Each entry file should `export default` its own plugin object.
|
||||
|
||||
## Testing Your Plugin
|
||||
|
||||
### Unit Tests
|
||||
|
||||
The plugin object is plain data. Drive `setup()` against a minimal context and exercise tools directly:
|
||||
|
||||
```typescript
|
||||
import plugin from "../my-plugin"
|
||||
|
||||
const tools: unknown[] = []
|
||||
const api = {
|
||||
registerTool: (t: unknown) => tools.push(t),
|
||||
registerCommand: () => {},
|
||||
registerRule: () => {},
|
||||
registerMessageBuilder: () => {},
|
||||
registerProvider: () => {},
|
||||
registerAutomationEventType: () => {},
|
||||
}
|
||||
await plugin.setup?.(api as never, {
|
||||
workspaceInfo: { rootPath: "/tmp/fake-workspace" },
|
||||
})
|
||||
|
||||
// Now `tools` contains the registered tools -- call tool.execute(input, ctx)
|
||||
```
|
||||
|
||||
### End-to-End with runDemo()
|
||||
|
||||
Add a `runDemo()` in your plugin file (see the single-file template above) that boots a real `ClineCore` session:
|
||||
|
||||
```bash
|
||||
ANTHROPIC_API_KEY=sk-... bun run my-plugin.ts
|
||||
```
|
||||
|
||||
### CLI Smoke Test
|
||||
|
||||
```bash
|
||||
mkdir -p .cline/plugins
|
||||
cp my-plugin.ts .cline/plugins/
|
||||
cline -i "trigger something that exercises the plugin"
|
||||
```
|
||||
|
||||
For packages:
|
||||
|
||||
```bash
|
||||
cline plugin install ./my-cline-plugin
|
||||
cline -i "..."
|
||||
```
|
||||
|
||||
If the plugin fails validation or setup, the CLI prints a clear error and continues without it.
|
||||
|
||||
## Common Gotchas
|
||||
|
||||
- "capabilities must be a non-empty array" -- you forgot `manifest.capabilities`, or it's `[]`.
|
||||
- "registerRule requires the 'rules' capability" -- capability/handler drift. Add `"rules"` to capabilities, or stop calling `registerRule`.
|
||||
- Tool not visible to the model -- check `enableTools: true` on the session config, and that you're declaring `"tools"` in capabilities.
|
||||
- `ctx.workspaceInfo` is undefined in SDK tests -- the host didn't pass `extensionContext.workspace`. In SDK code, set it explicitly (see the ClineCore loading example above).
|
||||
- State leaking across sessions -- module-level variables are shared across sessions in the same process. Key by `ctx.session?.sessionId` if your host runs multiple sessions concurrently.
|
||||
- `afterRun` firing on aborts -- guard with `if (result.status !== "completed") return`.
|
||||
- Heavy work in `setup()` -- `setup()` blocks session start. Defer expensive work into the first tool call or `beforeRun`.
|
||||
- Importing host internals -- only import from `@cline/core`. Reaching into host-specific packages (e.g. CLI internals) will break in non-CLI hosts.
|
||||
- Sandboxed plugins and `telemetry` -- telemetry is process-local. Feature-detect `ctx.telemetry` and expect it to be undefined in sandboxed plugin processes.
|
||||
- Resolving bundled assets -- use `import.meta.url` + `fileURLToPath` to find files inside your package; never `process.cwd()`. For workspace paths, do the opposite: use `ctx.workspaceInfo?.rootPath`, never `import.meta.url`.
|
||||
- Plugin name collisions -- `name` must be unique within a session. If two plugins share a name, validation fails. Namespace by package (`my-org-redactor`, not `redactor`).
|
||||
|
||||
## Decision Guide -- Which Extension Point?
|
||||
|
||||
| You want to... | Use |
|
||||
|----------------|-----|
|
||||
| Give the model a new capability | `registerTool` |
|
||||
| Add a slash command in chat surfaces | `registerCommand` |
|
||||
| Inject text into the system prompt | `registerRule` |
|
||||
| Rewrite messages before they hit the provider | `registerMessageBuilder` |
|
||||
| Add a custom model provider | `registerProvider` |
|
||||
| Emit normalized cron/webhook events | `registerAutomationEventType` + `ctx.automation` |
|
||||
| Observe or steer the agent loop | `hooks.*` |
|
||||
| Block a dangerous tool call | `hooks.beforeTool` returning `{ stop: true }` |
|
||||
| Notify on completion | `hooks.afterRun` (gate on `status === "completed"`) |
|
||||
| Tweak each model request | `hooks.beforeModel` |
|
||||
| Stream events to a UI | `hooks.onEvent` |
|
||||
| Ship reusable templates with the plugin | Bundle assets next to `index.ts`, resolve via `import.meta.url` |
|
||||
| Let users override defaults globally or per-project | Three-tier lookup: bundled / global / project |
|
||||
|
||||
## Pre-Ship Checklist
|
||||
|
||||
- `manifest.capabilities` is a non-empty array.
|
||||
- Every `api.register*` call has a matching capability declared.
|
||||
- If `hooks` is present, `"hooks"` is in `capabilities`.
|
||||
- `ctx.workspaceInfo?.rootPath` is used for workspace paths (not `process.cwd()`).
|
||||
- Optional `ctx` fields are feature-detected.
|
||||
- Tool names are snake_case verbs; descriptions are written for the model.
|
||||
- Tool inputs have JSON Schema with `required` set.
|
||||
- `afterRun` handlers gate on `result.status === "completed"` if they only want successes.
|
||||
- State that must not leak between concurrent sessions is keyed by `ctx.session?.sessionId`.
|
||||
- (Package) `package.json` has `type: "module"`, `cline.plugins`, and `@cline/core` as an optional peer dep.
|
||||
- (Package) Bundled assets resolved via `import.meta.url`, not `process.cwd()`.
|
||||
- Smoke test: drop the plugin into `.cline/plugins/` (or `cline plugin install`), run `cline -i "..."`, watch it work.
|
||||
|
||||
## Plugin Examples from SDK
|
||||
|
||||
The SDK repo includes these example plugins:
|
||||
|
||||
| Plugin | Description |
|
||||
|--------|-------------|
|
||||
| `weather-metrics.ts` | Tool registration + lifecycle metrics |
|
||||
| `mac-notify.ts` | macOS Notification Center alerts |
|
||||
| `custom-compaction.ts` | Custom message compaction via message builders |
|
||||
| `background-terminal.ts` | Detached shell job management |
|
||||
| `automation-events.ts` | Plugin-emitted automation events |
|
||||
| `gitignore-read-files-guard.ts` | File access policy enforcement via beforeTool |
|
||||
| `web-search.ts` | Web search via Exa API |
|
||||
| `typescript-lsp/` | TypeScript Language Service tools (plugin package) |
|
||||
| `agents-squad/` | Multi-agent team orchestration (plugin package) |
|
||||
|
||||
## See Also
|
||||
|
||||
- `../tools/REFERENCE.md` - Tool creation
|
||||
- `../events/REFERENCE.md` - Event system
|
||||
- `../agent/REFERENCE.md` - Using plugins with Agent
|
||||
- `../clinecore/REFERENCE.md` - Using plugins with ClineCore
|
||||
@@ -0,0 +1,253 @@
|
||||
# Going to Production
|
||||
|
||||
Guidelines for deploying Cline SDK agents in production environments.
|
||||
|
||||
## Error Handling
|
||||
|
||||
Always check the result status:
|
||||
|
||||
```typescript
|
||||
const result = await agent.run(input)
|
||||
|
||||
switch (result.status) {
|
||||
case "completed":
|
||||
console.log("Success:", result.outputText)
|
||||
break
|
||||
case "aborted":
|
||||
console.log("Cancelled:", result.error?.message)
|
||||
break
|
||||
case "failed":
|
||||
console.error("Failed:", result.error)
|
||||
break
|
||||
}
|
||||
```
|
||||
|
||||
For ClineCore, check `finishReason`:
|
||||
|
||||
```typescript
|
||||
const session = await cline.start({ ... })
|
||||
|
||||
switch (session.result?.finishReason) {
|
||||
case "completed":
|
||||
// normal completion
|
||||
break
|
||||
case "max_iterations":
|
||||
// agent hit iteration limit
|
||||
break
|
||||
case "aborted":
|
||||
// manually cancelled
|
||||
break
|
||||
case "mistake_limit":
|
||||
// too many tool errors
|
||||
break
|
||||
case "error":
|
||||
// unrecoverable error
|
||||
break
|
||||
}
|
||||
```
|
||||
|
||||
## Cost Control
|
||||
|
||||
### Token Limits
|
||||
|
||||
Set maximum tokens per turn and iteration limits:
|
||||
|
||||
```typescript
|
||||
const agent = new Agent({
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
maxTokensPerTurn: 4096,
|
||||
maxIterations: 10,
|
||||
tools: [...],
|
||||
})
|
||||
```
|
||||
|
||||
### Model Selection
|
||||
|
||||
Use cheaper models for simple tasks:
|
||||
|
||||
```typescript
|
||||
// Simple classification or formatting
|
||||
{ providerId: "anthropic", modelId: "claude-haiku-4-5" }
|
||||
|
||||
// Complex reasoning and code generation
|
||||
{ providerId: "anthropic", modelId: "claude-sonnet-4-6" }
|
||||
|
||||
// Hardest tasks requiring deep reasoning
|
||||
{ providerId: "anthropic", modelId: "claude-opus-4-7" }
|
||||
```
|
||||
|
||||
### Usage Tracking
|
||||
|
||||
Monitor spending in real time:
|
||||
|
||||
```typescript
|
||||
agent.subscribe((event) => {
|
||||
if (event.type === "usage-updated" && event.usage.totalCost) {
|
||||
if (event.usage.totalCost > MAX_BUDGET) {
|
||||
agent.abort("Budget exceeded")
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Observability
|
||||
|
||||
### OpenTelemetry Integration
|
||||
|
||||
The SDK supports OpenTelemetry for traces, metrics, and logs:
|
||||
|
||||
```typescript
|
||||
import { ClineCore } from "@cline/sdk"
|
||||
|
||||
const cline = await ClineCore.create({
|
||||
clientName: "my-app",
|
||||
// OpenTelemetry config is picked up from environment
|
||||
// OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_SERVICE_NAME, etc.
|
||||
})
|
||||
```
|
||||
|
||||
### Structured Logging
|
||||
|
||||
Use the `BasicLogger` interface for injectable logging:
|
||||
|
||||
```typescript
|
||||
import type { BasicLogger } from "@cline/sdk"
|
||||
|
||||
const logger: BasicLogger = {
|
||||
debug: (msg, meta) => console.debug(msg, meta),
|
||||
log: (msg, meta) => console.log(msg, meta),
|
||||
error: (msg, meta) => console.error(msg, meta),
|
||||
}
|
||||
|
||||
await cline.start({
|
||||
config: {
|
||||
logger,
|
||||
// ...
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Custom Metrics via Plugins
|
||||
|
||||
```typescript
|
||||
const metricsPlugin: AgentPlugin = {
|
||||
name: "metrics",
|
||||
manifest: { capabilities: ["hooks"] },
|
||||
setup() {},
|
||||
hooks: {
|
||||
beforeRun() {
|
||||
metrics.increment("agent.runs.started")
|
||||
},
|
||||
afterRun({ result }) {
|
||||
metrics.increment("agent.runs.completed")
|
||||
metrics.histogram("agent.iterations", result.iterations)
|
||||
metrics.histogram("agent.tokens.output", result.usage.outputTokens)
|
||||
},
|
||||
beforeTool({ toolCall }) {
|
||||
metrics.increment(`agent.tools.${toolCall.toolName}`)
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
### Sandbox Tool Execution
|
||||
|
||||
Validate tool inputs to prevent path traversal and injection:
|
||||
|
||||
```typescript
|
||||
execute: async (input) => {
|
||||
const safePath = path.resolve(WORKSPACE_ROOT, input.path)
|
||||
if (!safePath.startsWith(WORKSPACE_ROOT)) {
|
||||
return { error: "Path traversal attempt blocked" }
|
||||
}
|
||||
return await readFile(safePath, "utf-8")
|
||||
}
|
||||
```
|
||||
|
||||
### API Key Management
|
||||
|
||||
- Use environment variables, never hardcode keys
|
||||
- Rotate keys regularly
|
||||
- Use different keys for development and production
|
||||
|
||||
```typescript
|
||||
{
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
apiKey: process.env.ANTHROPIC_API_KEY, // never a literal string
|
||||
}
|
||||
```
|
||||
|
||||
### Tool Policy Hardening
|
||||
|
||||
Disable tools you don't need and require approval for dangerous ones:
|
||||
|
||||
```typescript
|
||||
toolPolicies: {
|
||||
read_files: { autoApprove: true },
|
||||
search: { autoApprove: true },
|
||||
bash: { autoApprove: false }, // require approval
|
||||
editor: { autoApprove: false },
|
||||
apply_patch: { autoApprove: false },
|
||||
fetch_web: { enabled: false }, // disable entirely
|
||||
}
|
||||
```
|
||||
|
||||
## Deployment Patterns
|
||||
|
||||
### Stateless Worker
|
||||
|
||||
For request/response workloads (API endpoints, queue consumers):
|
||||
|
||||
```typescript
|
||||
const cline = await ClineCore.create({
|
||||
clientName: "worker",
|
||||
backendMode: "local",
|
||||
})
|
||||
|
||||
app.post("/agent", async (req, res) => {
|
||||
const session = await cline.start({
|
||||
prompt: req.body.prompt,
|
||||
config: { ... },
|
||||
})
|
||||
res.json({ text: session.result?.text, usage: session.result?.usage })
|
||||
})
|
||||
```
|
||||
|
||||
### Persistent Service
|
||||
|
||||
For long-running services with session management:
|
||||
|
||||
```typescript
|
||||
const cline = await ClineCore.create({
|
||||
clientName: "service",
|
||||
backendMode: "hub",
|
||||
})
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
await cline.dispose("SIGTERM")
|
||||
process.exit(0)
|
||||
})
|
||||
```
|
||||
|
||||
### Scheduled Automation
|
||||
|
||||
See `../scheduling/REFERENCE.md` for recurring agent tasks.
|
||||
|
||||
## Retry and Resilience
|
||||
|
||||
- Tool `execute` functions support `retryable: true` (default) and `maxRetries: 3` (default)
|
||||
- Provider API calls are retried automatically on transient failures
|
||||
- Use `timeoutMs` on tools to prevent hanging
|
||||
- Monitor `mistake_limit` finish reason to detect systematic tool failures
|
||||
|
||||
## See Also
|
||||
|
||||
- `../agent/REFERENCE.md` - Agent overview
|
||||
- `../clinecore/REFERENCE.md` - ClineCore overview
|
||||
- `../tools/REFERENCE.md` - Tool configuration
|
||||
- `../plugins/REFERENCE.md` - Metrics plugins
|
||||
- `../scheduling/REFERENCE.md` - Scheduled agents
|
||||
@@ -0,0 +1,257 @@
|
||||
# Model Providers
|
||||
|
||||
The Cline SDK supports every major LLM provider out of the box via `@cline/llms`.
|
||||
|
||||
## Supported Providers
|
||||
|
||||
| Provider ID | Models |
|
||||
|-------------|--------|
|
||||
| `"anthropic"` | Claude Opus 4.7, Sonnet 4.6, Haiku 4.5 |
|
||||
| `"openai"` | GPT-5.5, GPT-5.3 Codex |
|
||||
| `"gemini"` | Gemini 3.1 Pro Preview, Gemini 3 Flash Preview |
|
||||
| `"vertex"` | Google models via Vertex AI |
|
||||
| `"bedrock"` | Claude, Llama via AWS Bedrock |
|
||||
| `"mistral"` | Mistral Large, Codestral |
|
||||
| `"openai-compatible"` | vLLM, Together, Fireworks, Groq, etc. |
|
||||
|
||||
## Basic Configuration
|
||||
|
||||
### With Agent
|
||||
|
||||
```typescript
|
||||
import { Agent } from "@cline/sdk"
|
||||
|
||||
const agent = new Agent({
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
apiKey: process.env.ANTHROPIC_API_KEY,
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
tools: [],
|
||||
})
|
||||
```
|
||||
|
||||
### With ClineCore
|
||||
|
||||
```typescript
|
||||
import { ClineCore } from "@cline/sdk"
|
||||
|
||||
const cline = await ClineCore.create({ clientName: "my-app" })
|
||||
|
||||
await cline.start({
|
||||
prompt: "Hello",
|
||||
config: {
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
apiKey: process.env.ANTHROPIC_API_KEY,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Provider-Specific Configuration
|
||||
|
||||
### Anthropic
|
||||
|
||||
```typescript
|
||||
{
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-opus-4-7", // or "claude-sonnet-4-6", "claude-haiku-4-5"
|
||||
apiKey: process.env.ANTHROPIC_API_KEY,
|
||||
}
|
||||
```
|
||||
|
||||
### OpenAI
|
||||
|
||||
```typescript
|
||||
{
|
||||
providerId: "openai",
|
||||
modelId: "gpt-5.5",
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
}
|
||||
```
|
||||
|
||||
### Google (Gemini)
|
||||
|
||||
```typescript
|
||||
{
|
||||
providerId: "gemini",
|
||||
modelId: "gemini-3.1-pro-preview",
|
||||
apiKey: process.env.GOOGLE_API_KEY,
|
||||
}
|
||||
```
|
||||
|
||||
### Google (Vertex AI)
|
||||
|
||||
```typescript
|
||||
{
|
||||
providerId: "vertex",
|
||||
modelId: "gemini-3.1-pro-preview",
|
||||
// Uses application default credentials or service account
|
||||
}
|
||||
```
|
||||
|
||||
### AWS Bedrock
|
||||
|
||||
```typescript
|
||||
{
|
||||
providerId: "bedrock",
|
||||
modelId: "anthropic.claude-sonnet-4-6",
|
||||
// Uses AWS credential chain (env vars, config file, IAM role)
|
||||
// Set AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
|
||||
}
|
||||
```
|
||||
|
||||
### Mistral
|
||||
|
||||
```typescript
|
||||
{
|
||||
providerId: "mistral",
|
||||
modelId: "mistral-large-latest",
|
||||
apiKey: process.env.MISTRAL_API_KEY,
|
||||
}
|
||||
```
|
||||
|
||||
### OpenAI-Compatible
|
||||
|
||||
For any provider with an OpenAI-compatible API:
|
||||
|
||||
```typescript
|
||||
{
|
||||
providerId: "openai-compatible",
|
||||
modelId: "my-model",
|
||||
apiKey: process.env.API_KEY,
|
||||
baseUrl: "https://api.together.xyz/v1",
|
||||
}
|
||||
```
|
||||
|
||||
Works with: vLLM, Together AI, Fireworks, Groq, Ollama, LiteLLM, etc.
|
||||
|
||||
## Custom Base URL
|
||||
|
||||
Override the API endpoint for any provider:
|
||||
|
||||
```typescript
|
||||
{
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
apiKey: process.env.API_KEY,
|
||||
baseUrl: "https://my-proxy.example.com/v1",
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Headers
|
||||
|
||||
Pass additional headers to API requests:
|
||||
|
||||
```typescript
|
||||
{
|
||||
providerId: "openai",
|
||||
modelId: "gpt-5.5",
|
||||
apiKey: process.env.API_KEY,
|
||||
headers: {
|
||||
"X-Custom-Header": "value",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Gateway API
|
||||
|
||||
For advanced multi-provider setups, use the Gateway directly:
|
||||
|
||||
```typescript
|
||||
import { createGateway, DefaultGateway } from "@cline/llms"
|
||||
|
||||
const gateway = createGateway({
|
||||
providerConfigs: [
|
||||
{ providerId: "anthropic", apiKey: process.env.ANTHROPIC_API_KEY },
|
||||
{ providerId: "openai", apiKey: process.env.OPENAI_API_KEY },
|
||||
],
|
||||
})
|
||||
|
||||
// Create a model for a specific provider
|
||||
const model = gateway.createAgentModel({
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-opus-4-7",
|
||||
})
|
||||
|
||||
// Use with Agent
|
||||
const agent = new Agent({ model, systemPrompt: "...", tools: [] })
|
||||
```
|
||||
|
||||
### Gateway Methods
|
||||
|
||||
```typescript
|
||||
gateway.registerProvider(registration) // add a custom provider
|
||||
gateway.configureProvider(config) // update provider settings
|
||||
gateway.listProviders() // list available providers
|
||||
gateway.listModels(providerId?) // list available models
|
||||
gateway.createAgentModel(selection) // create model for agent
|
||||
gateway.stream(request) // raw streaming (AsyncIterable)
|
||||
```
|
||||
|
||||
## Provider Registry
|
||||
|
||||
Query and register providers programmatically:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
getAllProviders,
|
||||
getProviderIds,
|
||||
getProvider,
|
||||
getModelsForProvider,
|
||||
registerProvider,
|
||||
registerModel,
|
||||
createHandler,
|
||||
} from "@cline/llms"
|
||||
|
||||
// List all registered providers
|
||||
const providers = getAllProviders()
|
||||
|
||||
// Get models for a provider
|
||||
const models = getModelsForProvider("anthropic")
|
||||
|
||||
// Register a custom provider
|
||||
registerProvider({
|
||||
id: "my-provider",
|
||||
name: "My Custom Provider",
|
||||
handler: createHandler({ ... }),
|
||||
})
|
||||
```
|
||||
|
||||
## Model Metadata
|
||||
|
||||
Access model info (context window, pricing, capabilities):
|
||||
|
||||
```typescript
|
||||
import { getModelsForProvider } from "@cline/llms"
|
||||
|
||||
const models = getModelsForProvider("anthropic")
|
||||
for (const model of models) {
|
||||
console.log(`${model.id}: context=${model.contextWindow}, input=$${model.inputPrice}/MTok`)
|
||||
}
|
||||
```
|
||||
|
||||
## Cost Tracking
|
||||
|
||||
Track per-request and cumulative costs:
|
||||
|
||||
```typescript
|
||||
// Via events
|
||||
agent.subscribe((event) => {
|
||||
if (event.type === "usage-updated") {
|
||||
console.log(`Cost: $${event.usage.totalCost?.toFixed(4)}`)
|
||||
}
|
||||
})
|
||||
|
||||
// Via result
|
||||
const result = await agent.run("...")
|
||||
console.log(`Total cost: $${result.usage.totalCost?.toFixed(4)}`)
|
||||
|
||||
// Via ClineCore accumulated usage
|
||||
const usage = await cline.getAccumulatedUsage(sessionId)
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
- `../agent/REFERENCE.md` - Using providers with Agent
|
||||
- `../clinecore/REFERENCE.md` - Using providers with ClineCore
|
||||
- `../production/REFERENCE.md` - Cost control in production
|
||||
@@ -0,0 +1,227 @@
|
||||
# Scheduling and Automation
|
||||
|
||||
The Cline SDK supports scheduled, one-off, and event-driven agent execution through the automation subsystem in `@cline/core`.
|
||||
|
||||
## Overview
|
||||
|
||||
Three trigger types:
|
||||
|
||||
| Trigger | Description |
|
||||
|---------|-------------|
|
||||
| `schedule` | Recurring jobs via cron expressions |
|
||||
| `one_off` | Single execution tasks |
|
||||
| `event` | Triggered by external events (GitHub, Linear, custom) |
|
||||
|
||||
## CLI Schedule Management
|
||||
|
||||
```bash
|
||||
# Create a recurring schedule
|
||||
cline schedule create "Daily standup" \
|
||||
--cron "0 9 * * MON-FRI" \
|
||||
--prompt "Summarize open PRs and blockers" \
|
||||
--workspace /path/to/project \
|
||||
--model anthropic/claude-sonnet-4-6
|
||||
|
||||
# List schedules
|
||||
cline schedule list
|
||||
|
||||
# Trigger a schedule immediately
|
||||
cline schedule trigger <schedule-id>
|
||||
|
||||
# Pause/resume
|
||||
cline schedule pause <schedule-id>
|
||||
cline schedule resume <schedule-id>
|
||||
|
||||
# Delete
|
||||
cline schedule delete <schedule-id>
|
||||
|
||||
# View past executions
|
||||
cline schedule executions <schedule-id>
|
||||
```
|
||||
|
||||
## Cron Expressions
|
||||
|
||||
| Expression | Meaning |
|
||||
|-----------|---------|
|
||||
| `0 9 * * MON-FRI` | 9 AM weekdays |
|
||||
| `0 */6 * * *` | Every 6 hours |
|
||||
| `0 8 * * MON` | Mondays at 8 AM |
|
||||
| `*/30 * * * *` | Every 30 minutes |
|
||||
| `0 0 1 * *` | First of every month |
|
||||
|
||||
## File-Based Specs
|
||||
|
||||
Create Markdown files in `~/.cline/cron/` (global) or `.cline/cron/` (workspace):
|
||||
|
||||
### Recurring Schedule
|
||||
|
||||
```markdown
|
||||
---
|
||||
trigger: schedule
|
||||
schedule: "0 9 * * MON-FRI"
|
||||
timezone: America/New_York
|
||||
mode: exclusive
|
||||
prompt: "Check for dependency updates and create PRs for any outdated packages."
|
||||
modelSelection:
|
||||
providerId: anthropic
|
||||
modelId: claude-sonnet-4-6
|
||||
tools:
|
||||
enabled: true
|
||||
---
|
||||
|
||||
Additional context or instructions for the agent go in the body.
|
||||
```
|
||||
|
||||
### One-Off Task
|
||||
|
||||
```markdown
|
||||
---
|
||||
trigger: one_off
|
||||
prompt: "Generate a comprehensive test coverage report."
|
||||
modelSelection:
|
||||
providerId: anthropic
|
||||
modelId: claude-sonnet-4-6
|
||||
---
|
||||
```
|
||||
|
||||
### Event-Driven
|
||||
|
||||
```markdown
|
||||
---
|
||||
trigger: event
|
||||
eventType: github.pull_request.opened
|
||||
filters:
|
||||
repository: myorg/myrepo
|
||||
debounceMs: 5000
|
||||
cooldownMs: 60000
|
||||
prompt: "Review the PR for security issues and code quality."
|
||||
modelSelection:
|
||||
providerId: anthropic
|
||||
modelId: claude-sonnet-4-6
|
||||
---
|
||||
```
|
||||
|
||||
## CronSpec Types
|
||||
|
||||
```typescript
|
||||
interface CronScheduleSpec {
|
||||
trigger: "schedule"
|
||||
schedule: string // cron expression
|
||||
timezone?: string
|
||||
mode?: "exclusive" | "concurrent"
|
||||
prompt: string
|
||||
modelSelection?: { providerId: string; modelId?: string }
|
||||
extensionLoading?: "isolated" | "direct"
|
||||
configExtensions?: RuntimeConfigExtensionKind[]
|
||||
tools?: { enabled?: boolean; names?: string[] }
|
||||
}
|
||||
|
||||
interface CronOneOffSpec {
|
||||
trigger: "one_off"
|
||||
prompt: string
|
||||
modelSelection?: { providerId: string; modelId?: string }
|
||||
}
|
||||
|
||||
interface CronEventSpec {
|
||||
trigger: "event"
|
||||
eventType: string // e.g., "github.pull_request.opened"
|
||||
filters?: Record<string, unknown>
|
||||
debounceMs?: number
|
||||
cooldownMs?: number
|
||||
prompt: string
|
||||
modelSelection?: { providerId: string; modelId?: string }
|
||||
}
|
||||
```
|
||||
|
||||
## Programmatic Automation API
|
||||
|
||||
```typescript
|
||||
const cline = await ClineCore.create({
|
||||
clientName: "my-app",
|
||||
automation: true,
|
||||
})
|
||||
|
||||
// Start automation service
|
||||
cline.automation.start()
|
||||
|
||||
// Ingest an external event
|
||||
cline.automation.ingestEvent({
|
||||
eventId: "evt-123",
|
||||
eventType: "github.pull_request.opened",
|
||||
source: "github",
|
||||
timestamp: Date.now(),
|
||||
payload: { pr: { number: 42, title: "..." } },
|
||||
})
|
||||
|
||||
// List specs, runs, events
|
||||
const specs = await cline.automation.listSpecs()
|
||||
const runs = await cline.automation.listRuns()
|
||||
const events = await cline.automation.listEvents()
|
||||
|
||||
// Reconcile specs from directory
|
||||
await cline.automation.reconcile(specDirectory)
|
||||
|
||||
// Stop automation
|
||||
cline.automation.stop()
|
||||
```
|
||||
|
||||
## Event Ingestion from Plugins
|
||||
|
||||
Plugins can declare and emit automation events:
|
||||
|
||||
```typescript
|
||||
const webhookPlugin: AgentPlugin = {
|
||||
name: "webhook-events",
|
||||
manifest: { capabilities: ["automationEvents"] },
|
||||
setup(api) {
|
||||
api.registerAutomationEventType({
|
||||
type: "webhook.received",
|
||||
description: "External webhook received",
|
||||
})
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Submit events via the plugin context:
|
||||
|
||||
```typescript
|
||||
ctx.automation.ingestEvent({
|
||||
eventId: "evt-456",
|
||||
eventType: "webhook.received",
|
||||
source: "custom",
|
||||
timestamp: Date.now(),
|
||||
payload: { ... },
|
||||
})
|
||||
```
|
||||
|
||||
## Concurrency Control
|
||||
|
||||
| Mode | Behavior |
|
||||
|------|----------|
|
||||
| `"exclusive"` | Skip if previous run still active |
|
||||
| `"concurrent"` | Allow overlapping runs |
|
||||
|
||||
## Run Reports
|
||||
|
||||
Each completed run writes a Markdown report to `.cline/cron/reports/<run-id>.md` with:
|
||||
- Run metadata (spec, trigger, timing)
|
||||
- Summary of agent output
|
||||
- Usage (tokens, cost)
|
||||
- Tool calls made
|
||||
- Trigger event context (for event-driven runs)
|
||||
|
||||
## Use Cases
|
||||
|
||||
- Daily standup summaries
|
||||
- Automated dependency update checks
|
||||
- PR review on open
|
||||
- Codebase health reports
|
||||
- Scheduled security scans
|
||||
- Event-driven CI/CD workflows
|
||||
|
||||
## See Also
|
||||
|
||||
- `../clinecore/REFERENCE.md` - ClineCore runtime
|
||||
- `../clinecore/api.md` - Automation API details
|
||||
- `../plugins/REFERENCE.md` - Plugin events
|
||||
- `../production/REFERENCE.md` - Production deployment
|
||||
@@ -0,0 +1,259 @@
|
||||
# Tools
|
||||
|
||||
Tools are how agents interact with the world. The Cline SDK supports both built-in tools (via ClineCore) and custom tools you define yourself.
|
||||
|
||||
## Creating Custom Tools
|
||||
|
||||
Use `createTool()` from `@cline/sdk` (or `@cline/shared`):
|
||||
|
||||
```typescript
|
||||
import { createTool } from "@cline/sdk"
|
||||
|
||||
const myTool = createTool({
|
||||
name: "search_issues",
|
||||
description: "Search GitHub issues by query. Returns up to 10 results.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Search query" },
|
||||
state: { type: "string", enum: ["open", "closed", "all"] },
|
||||
},
|
||||
required: ["query"],
|
||||
},
|
||||
execute: async (input) => {
|
||||
const issues = await github.searchIssues(input.query, input.state)
|
||||
return { issues, count: issues.length }
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### With Zod Schema
|
||||
|
||||
```typescript
|
||||
import { createTool } from "@cline/sdk"
|
||||
import { z } from "zod"
|
||||
|
||||
const deployTool = createTool({
|
||||
name: "deploy",
|
||||
description: "Deploy the app to the specified environment.",
|
||||
inputSchema: z.object({
|
||||
environment: z.enum(["staging", "production"]).describe("Target environment"),
|
||||
version: z.string().optional().describe("Version tag, defaults to latest"),
|
||||
}),
|
||||
execute: async (input) => {
|
||||
const result = await deploy(input.environment, input.version)
|
||||
return { url: result.url, status: "deployed" }
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Tool Config Options
|
||||
|
||||
```typescript
|
||||
createTool({
|
||||
name: string, // snake_case, unique per agent
|
||||
description: string, // what the tool does (model reads this)
|
||||
inputSchema: JSONSchema | ZodSchema, // input validation
|
||||
execute: async (input, context, onChange?) => output,
|
||||
timeoutMs?: number, // default: 30000
|
||||
retryable?: boolean, // default: true
|
||||
maxRetries?: number, // default: 3
|
||||
lifecycle?: {
|
||||
completesRun?: boolean // true = ends agent loop on success
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### AgentToolContext
|
||||
|
||||
The second argument to `execute` provides runtime context:
|
||||
|
||||
```typescript
|
||||
interface AgentToolContext {
|
||||
agentId: string
|
||||
conversationId: string
|
||||
iteration: number
|
||||
abortSignal?: AbortSignal
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
```
|
||||
|
||||
## Tool Naming Rules
|
||||
|
||||
- Names must be `snake_case` (e.g., `search_issues`, `deploy_app`)
|
||||
- Names must be unique within a single agent's tool set
|
||||
- Choose descriptive names since the model uses them to decide which tool to call
|
||||
|
||||
## Tool Descriptions Matter
|
||||
|
||||
The model reads the tool description to decide when and how to use it. Write clear, specific descriptions:
|
||||
|
||||
```typescript
|
||||
// Bad: vague
|
||||
description: "Does deployment stuff"
|
||||
|
||||
// Good: specific with constraints
|
||||
description: "Deploy the application to staging or production. " +
|
||||
"Staging deployments are immediate. Production requires a passing CI build. " +
|
||||
"Returns the deployment URL and status."
|
||||
```
|
||||
|
||||
Include constraints, rate limits, and expected behavior in the description.
|
||||
|
||||
## Error Handling in Tools
|
||||
|
||||
Return errors as structured data instead of throwing:
|
||||
|
||||
```typescript
|
||||
// Good: return error data
|
||||
execute: async (input) => {
|
||||
const file = await readFile(input.path).catch(() => null)
|
||||
if (!file) {
|
||||
return { error: "File not found", path: input.path }
|
||||
}
|
||||
return { content: file }
|
||||
}
|
||||
```
|
||||
|
||||
Thrown exceptions count as "mistakes" against the agent's mistake limit. Returned error data lets the agent adjust its approach.
|
||||
|
||||
## Completion Tools
|
||||
|
||||
Tools with `lifecycle: { completesRun: true }` end the agent loop when they execute successfully:
|
||||
|
||||
```typescript
|
||||
const submitAnswer = createTool({
|
||||
name: "submit_answer",
|
||||
description: "Submit the final answer and end the task.",
|
||||
inputSchema: z.object({
|
||||
answer: z.string(),
|
||||
confidence: z.number().min(0).max(1),
|
||||
}),
|
||||
lifecycle: { completesRun: true },
|
||||
execute: async (input) => input,
|
||||
})
|
||||
```
|
||||
|
||||
The model sees the tool result and the run ends. Access the output via `result.toolCalls`.
|
||||
|
||||
## Built-in Tools (ClineCore Only)
|
||||
|
||||
When using `ClineCore` with `enableTools: true`, these tools are available automatically:
|
||||
|
||||
| Tool | Name | What It Does |
|
||||
|------|------|-------------|
|
||||
| Shell | `bash` | Execute shell commands in the session workspace |
|
||||
| Editor | `editor` | Create and edit files |
|
||||
| Read | `read_files` | Read file contents |
|
||||
| Patch | `apply_patch` | Apply unified diffs to files |
|
||||
| Search | `search` | Search file contents and directory structure |
|
||||
| Web | `fetch_web` | Fetch web content via HTTP |
|
||||
|
||||
Built-in tools respect the `cwd` setting in `CoreSessionConfig`.
|
||||
|
||||
## Tool Policies
|
||||
|
||||
Control which tools are available and whether they require approval:
|
||||
|
||||
```typescript
|
||||
// In Agent config
|
||||
const agent = new Agent({
|
||||
tools: [toolA, toolB, toolC],
|
||||
toolPolicies: {
|
||||
tool_a: { autoApprove: true }, // runs without asking
|
||||
tool_b: { autoApprove: false }, // requires approval
|
||||
tool_c: { enabled: false }, // hidden from model
|
||||
},
|
||||
})
|
||||
|
||||
// In ClineCore session
|
||||
await cline.start({
|
||||
prompt: "...",
|
||||
config: { ... },
|
||||
toolPolicies: {
|
||||
bash: { autoApprove: true },
|
||||
editor: { autoApprove: false },
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Policy Options
|
||||
|
||||
| Policy | Effect |
|
||||
|--------|--------|
|
||||
| `{ autoApprove: true }` | Tool runs without approval |
|
||||
| `{ autoApprove: false }` | Triggers approval callback before running |
|
||||
| `{ enabled: false }` | Tool is hidden from the model entirely |
|
||||
| No policy set | Defaults to enabled and auto-approved |
|
||||
|
||||
## Abort Signal in Long-Running Tools
|
||||
|
||||
Respect the abort signal for tools that take a long time:
|
||||
|
||||
```typescript
|
||||
execute: async (input, context) => {
|
||||
const results = []
|
||||
for (const item of input.items) {
|
||||
if (context.abortSignal?.aborted) {
|
||||
return { results, aborted: true, processed: results.length }
|
||||
}
|
||||
results.push(await processItem(item))
|
||||
}
|
||||
return { results, processed: results.length }
|
||||
}
|
||||
```
|
||||
|
||||
## Streaming Tool Output
|
||||
|
||||
Use the `onChange` callback (third argument) to stream partial results:
|
||||
|
||||
```typescript
|
||||
execute: async (input, context, onChange) => {
|
||||
let progress = 0
|
||||
for (const step of steps) {
|
||||
progress++
|
||||
onChange?.(`Processing step ${progress}/${steps.length}...`)
|
||||
await processStep(step)
|
||||
}
|
||||
return { completed: true }
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Tools
|
||||
|
||||
Tools are plain async functions, so they're straightforward to test:
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
describe("deploy tool", () => {
|
||||
it("deploys to staging", async () => {
|
||||
const context = { agentId: "test", conversationId: "test", iteration: 1 }
|
||||
const result = await deployTool.execute({ environment: "staging" }, context)
|
||||
expect(result.status).toBe("deployed")
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## MCP Tool Integration
|
||||
|
||||
ClineCore can connect to MCP (Model Context Protocol) servers for additional tools. Configure in `.cline/mcp-servers.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"servers": {
|
||||
"my-server": {
|
||||
"command": "node",
|
||||
"args": ["./mcp-server.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
MCP tools appear alongside built-in and custom tools automatically.
|
||||
|
||||
## See Also
|
||||
|
||||
- `../agent/REFERENCE.md` - Using tools with Agent
|
||||
- `../clinecore/REFERENCE.md` - Using tools with ClineCore
|
||||
- `../plugins/REFERENCE.md` - Packaging tools as plugins
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/cline-sdk
|
||||
@@ -1,33 +0,0 @@
|
||||
# CLI Development
|
||||
|
||||
The CLI lives in `cli/` and uses React Ink for terminal UI.
|
||||
|
||||
- If needed, look at `cli/src/constants/colors.ts` for re-used terminal colors, e.g. `COLORS.primaryBlue` highlight color (selections, spinners, success states).
|
||||
- Never use `dimColor` with gray (e.g. `<Text color="gray" dimColor>`) - it's too hard to read. Use `color="gray"` for secondary text and normal foreground (no color) for primary text.
|
||||
- When thinking about how to handle state or messages from core, look at webview for how it communicates with the vs code extension.
|
||||
- When updating the webview, consider and suggest to the user to update the CLI TUI since we want to provide a similar experience to our terminal users as we do our vs code extension users.
|
||||
|
||||
## Adding New API Providers
|
||||
|
||||
When adding a new API provider to the extension, you must also update the CLI:
|
||||
|
||||
1. **Update `cli/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/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.
|
||||
@@ -176,7 +176,7 @@ Present a final summary:
|
||||
- Slack message copied to clipboard: yes
|
||||
|
||||
Remind the user to:
|
||||
1. Manually trigger the publish release GitHub Action at: https://github.com/cline/cline/actions/workflows/publish.yml (paste `v{VERSION}` as the tag)
|
||||
1. Manually trigger the publish release GitHub Action at: https://github.com/cline/cline/actions/workflows/ext-vscode-publish-stable.yml (paste `v{VERSION}` as the tag)
|
||||
2. Post the Slack message to announce the hotfix
|
||||
|
||||
## Important Notes
|
||||
|
||||
@@ -43,7 +43,7 @@ git push origin v<version>
|
||||
### 4) Trigger publish workflow
|
||||
|
||||
Tell the maintainer to run:
|
||||
https://github.com/cline/cline/actions/workflows/publish.yml
|
||||
https://github.com/cline/cline/actions/workflows/ext-vscode-publish-stable.yml
|
||||
|
||||
Use `v<version>` as the release tag.
|
||||
|
||||
|
||||
@@ -20,8 +20,9 @@ command = "chmod +x ./scripts/run-extension-host.sh && ./scripts/run-extension-h
|
||||
name = "CLI"
|
||||
icon = "run"
|
||||
command = '''
|
||||
npm run cli:build
|
||||
npm run cli:run
|
||||
cd sdk
|
||||
bun install
|
||||
bun run cli
|
||||
'''
|
||||
|
||||
[[actions]]
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
demo.gif filter=lfs diff=lfs merge=lfs -text
|
||||
assets/docs/demo.gif filter=lfs diff=lfs merge=lfs -text
|
||||
webview-ui/src/assets/cline_kanban_demo.mp4 filter=lfs diff=lfs merge=lfs -text
|
||||
webview-ui/src/assets/cline_kanban_demo.webm filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
* text=auto eol=lf
|
||||
|
||||
@@ -5,7 +5,6 @@ This is a VS Code extension. Read `.clinerules/general.md` for tribal knowledge
|
||||
## Architecture
|
||||
- **Core** (`src/`): `extension.ts` → `WebviewProvider` → `Controller` (single source of truth) → `Task` (agent loop).
|
||||
- **Webview** (`webview-ui/`): React/Vite app. State via `ExtensionStateContext.tsx`, synced through message passing.
|
||||
- **CLI** (`cli/`): React Ink terminal UI sharing core logic. Update CLI when changing webview features.
|
||||
- **Communication**: Protobuf-defined gRPC-like protocol over VS Code message passing. Schemas in `proto/`.
|
||||
- **MCP**: `src/services/mcp/McpHub.ts`.
|
||||
|
||||
@@ -28,7 +27,7 @@ Three proto conversion updates are **required** or the provider silently resets
|
||||
2. `convertApiProviderToProto()` in `src/shared/proto-conversions/models/api-configuration-conversion.ts`.
|
||||
3. `convertProtoToApiProvider()` in the same file.
|
||||
|
||||
Also update: `src/shared/api.ts`, `src/shared/providers/providers.json`, `src/core/api/index.ts`, `webview-ui/.../providerUtils.ts`, `webview-ui/.../validate.ts`, `webview-ui/.../ApiOptions.tsx`, and `cli/src/components/ModelPicker.tsx`.
|
||||
Also update: `src/shared/api.ts`, `src/shared/providers/providers.json`, `src/core/api/index.ts`, `webview-ui/.../providerUtils.ts`, `webview-ui/.../validate.ts`, `webview-ui/.../ApiOptions.tsx`.
|
||||
|
||||
For Responses API providers: add to `isNextGenModelProvider()` in `src/utils/model-utils.ts` and set `apiFormat: ApiFormat.OPENAI_RESPONSES` on models.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Publish CLI to NPM
|
||||
name: cli-publish
|
||||
|
||||
on:
|
||||
schedule:
|
||||
@@ -88,9 +88,9 @@ jobs:
|
||||
|
||||
- name: Validate release tag
|
||||
id: version
|
||||
env:
|
||||
TAG: ${{ github.event.inputs.git_tag }}
|
||||
run: |
|
||||
TAG="${{ github.event.inputs.git_tag }}"
|
||||
|
||||
if [ -z "$TAG" ]; then
|
||||
echo "git_tag is required when publish_target=main"
|
||||
exit 1
|
||||
@@ -141,8 +141,9 @@ jobs:
|
||||
working-directory: sdk/apps/cli
|
||||
|
||||
- name: Verify build output
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
EXPECTED=(
|
||||
"@cline/cli-darwin-arm64"
|
||||
"@cline/cli-darwin-x64"
|
||||
@@ -179,8 +180,9 @@ jobs:
|
||||
|
||||
- name: Get Previous CLI Tag
|
||||
id: prev_tag
|
||||
env:
|
||||
CURRENT_TAG: ${{ steps.version.outputs.tag }}
|
||||
run: |
|
||||
CURRENT_TAG="${{ steps.version.outputs.tag }}"
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 --match 'cli-v*' "$CURRENT_TAG^" 2>/dev/null || echo "")
|
||||
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -200,13 +202,15 @@ jobs:
|
||||
name: "CLI v${{ steps.version.outputs.version }}"
|
||||
body: |
|
||||
${{ steps.changelog.outputs.content }}
|
||||
${{ steps.prev_tag.outputs.prev_tag != '' && format('Full Changelog: https://github.com/{0}/compare/{1}...{2}', github.repository, steps.prev_tag.outputs.prev_tag, steps.version.outputs.tag) || '' }}
|
||||
|
||||
${{ steps.prev_tag.outputs.prev_tag != '' && format('**Full Changelog**: https://github.com/{0}/compare/{1}...{2}', github.repository, steps.prev_tag.outputs.prev_tag, steps.version.outputs.tag) || '' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Summary
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
echo "Published cline@${VERSION} to npm with dist-tag 'latest'"
|
||||
echo "Install with: npm install -g cline"
|
||||
|
||||
@@ -329,8 +333,9 @@ jobs:
|
||||
|
||||
- name: Update nightly package version
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
node -e '
|
||||
const fs = require("node:fs");
|
||||
const path = "apps/cli/package.json";
|
||||
@@ -339,8 +344,6 @@ jobs:
|
||||
fs.writeFileSync(path, `${JSON.stringify(pkg, null, "\t")}\n`);
|
||||
'
|
||||
cat apps/cli/package.json | grep '"version"'
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
|
||||
- name: Build platform binaries
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
@@ -349,8 +352,9 @@ jobs:
|
||||
|
||||
- name: Verify build output
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
EXPECTED=(
|
||||
"@cline/cli-darwin-arm64"
|
||||
"@cline/cli-darwin-x64"
|
||||
@@ -388,7 +392,8 @@ jobs:
|
||||
|
||||
- name: Summary
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
echo "Published cline@${VERSION} to npm with dist-tag 'nightly'"
|
||||
echo "Install with: npm install -g cline@nightly"
|
||||
@@ -1,78 +0,0 @@
|
||||
name: Smoke Tests
|
||||
|
||||
# Temporarily disabled: this workflow built and linked the legacy CLI
|
||||
# (`cd cli && npm install && npm run build && npm link`) before running the
|
||||
# smoke-test scenarios. The legacy CLI publish chain has been retired in
|
||||
# favor of the SDK CLI at `sdk/apps/cli/`. The scenarios under
|
||||
# `evals/smoke-tests/scenarios/` are CLI-agnostic and should be re-enabled
|
||||
# once the build step is repointed at the new SDK CLI. Until then, only
|
||||
# manual `workflow_dispatch` runs are accepted (and will fail in their
|
||||
# current form).
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: smoke-tests-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
smoke-tests:
|
||||
name: Smoke Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build and install CLI
|
||||
run: |
|
||||
npm run protos
|
||||
cd cli && npm install && npm run build && npm link
|
||||
echo "$(npm config get prefix)/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Verify CLI
|
||||
run: cline --version
|
||||
|
||||
- name: Run smoke tests
|
||||
env:
|
||||
CLINE_API_KEY: ${{ secrets.CLINE_API_KEY }}
|
||||
run: |
|
||||
cline auth -p cline -k "$CLINE_API_KEY" -m "anthropic/claude-sonnet-4.5"
|
||||
max_attempts=3
|
||||
for attempt in $(seq 1 $max_attempts); do
|
||||
echo "::group::Attempt $attempt of $max_attempts"
|
||||
if npx tsx evals/smoke-tests/run-smoke-tests.ts --trials 1 --parallel; then
|
||||
echo "::endgroup::"
|
||||
echo "Smoke tests passed on attempt $attempt"
|
||||
exit 0
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
if [ $attempt -lt $max_attempts ]; then
|
||||
echo "::warning::Smoke tests failed on attempt $attempt, retrying..."
|
||||
sleep 10
|
||||
fi
|
||||
done
|
||||
echo "::error::Smoke tests failed after $max_attempts attempts"
|
||||
exit 1
|
||||
|
||||
- name: Generate summary
|
||||
if: always()
|
||||
run: cat evals/smoke-tests/results/latest/summary.md >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Upload results
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: smoke-test-results-${{ github.run_id }}
|
||||
path: evals/smoke-tests/results/latest/
|
||||
retention-days: 30
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
name: Trigger Jetbrains Plugin <-> Cline Tests
|
||||
name: ext-jb-test-integration
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
+4
-10
@@ -1,4 +1,7 @@
|
||||
name: "Publish New SDK Extension Nightly"
|
||||
# TODO: Fold this workflow's SDK login changes into ext-vscode-publish-nightly.yml
|
||||
# and delete this file. Pinned to dpc/sdk-migration-simpler-login while Max is iterating.
|
||||
# Owner: Max Paulus
|
||||
name: ext-vscode-publish-nightly-sdk
|
||||
|
||||
on:
|
||||
schedule:
|
||||
@@ -46,15 +49,6 @@ jobs:
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
- name: Verify LFS media assets are resolved
|
||||
run: |
|
||||
for FILE in webview-ui/src/assets/cline_kanban_demo.mp4 webview-ui/src/assets/cline_kanban_demo.webm; do
|
||||
if grep -q "git-lfs.github.com/spec/v1" "$FILE"; then
|
||||
echo "Error: $FILE is still a Git LFS pointer in CI checkout"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Publish SDK nightly extension
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
+3
-12
@@ -1,4 +1,4 @@
|
||||
name: "Publish Nightly Release"
|
||||
name: ext-vscode-publish-nightly
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -10,7 +10,7 @@ run-name: "Publish Nightly from ${{ github.ref_name }} @ ${{ github.sha }}"
|
||||
# runs on the same ref can collide on the same version and cause publish failures
|
||||
# or inconsistent tagging. Runs on different branches proceed independently.
|
||||
concurrency:
|
||||
group: publish-nightly-${{ github.ref }}
|
||||
group: ext-vscode-publish-nightly-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions: {}
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/test.yml
|
||||
uses: ./.github/workflows/ext-vscode-test.yml
|
||||
|
||||
publish:
|
||||
needs: test
|
||||
@@ -61,15 +61,6 @@ jobs:
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
- name: Verify LFS media assets are resolved
|
||||
run: |
|
||||
for FILE in webview-ui/src/assets/cline_kanban_demo.mp4 webview-ui/src/assets/cline_kanban_demo.webm; do
|
||||
if grep -q "git-lfs.github.com/spec/v1" "$FILE"; then
|
||||
echo "Error: $FILE is still a Git LFS pointer in CI checkout"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Publish Nightly Extension
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
@@ -1,4 +1,4 @@
|
||||
name: "Publish Release"
|
||||
name: ext-vscode-publish-stable
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -29,7 +29,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
uses: ./.github/workflows/test.yml
|
||||
uses: ./.github/workflows/ext-vscode-test.yml
|
||||
|
||||
publish:
|
||||
needs: test
|
||||
@@ -135,15 +135,6 @@ jobs:
|
||||
fi
|
||||
echo "Tag and package version match: $TAG"
|
||||
|
||||
- name: Verify LFS media assets are resolved
|
||||
run: |
|
||||
for FILE in webview-ui/src/assets/cline_kanban_demo.mp4 webview-ui/src/assets/cline_kanban_demo.webm; do
|
||||
if grep -q "git-lfs.github.com/spec/v1" "$FILE"; then
|
||||
echo "Error: $FILE is still a Git LFS pointer in CI checkout"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Package and Publish Extension
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
@@ -1,4 +1,4 @@
|
||||
name: E2E Tests
|
||||
name: ext-vscode-test-e2e
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -12,8 +12,53 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
detect-changes:
|
||||
runs-on: ubuntu-latest
|
||||
name: Detect Changes
|
||||
outputs:
|
||||
e2e: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.e2e == 'true' }}
|
||||
steps:
|
||||
- id: force
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
run: echo "run_all=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
if: steps.force.outputs.run_all != 'true'
|
||||
|
||||
- uses: dorny/paths-filter@v3
|
||||
if: steps.force.outputs.run_all != 'true'
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
e2e:
|
||||
- 'src/**'
|
||||
- 'webview-ui/**'
|
||||
- 'proto/**'
|
||||
- 'tests/**'
|
||||
- 'scripts/**'
|
||||
- 'standalone/**'
|
||||
- 'assets/**'
|
||||
- 'walkthrough/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'buf.yaml'
|
||||
- 'tsconfig*.json'
|
||||
- 'biome.jsonc'
|
||||
- 'esbuild.mjs'
|
||||
- '.mocharc.json'
|
||||
- '.vscode-test.mjs'
|
||||
- '.vscodeignore'
|
||||
- 'playwright*.ts'
|
||||
- '.github/workflows/ext-vscode-test-e2e.yml'
|
||||
|
||||
matrix_prep:
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.e2e == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
@@ -23,7 +68,8 @@ jobs:
|
||||
echo 'matrix=[{"runner":"ubuntu"},{"runner":"windows"},{"runner":"macos"}]' >> $GITHUB_OUTPUT
|
||||
|
||||
e2e:
|
||||
needs: matrix_prep
|
||||
needs: [detect-changes, matrix_prep]
|
||||
if: needs.detect-changes.outputs.e2e == 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Tests
|
||||
name: ext-vscode-test
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -13,9 +13,66 @@ on:
|
||||
# Set default permissions for all jobs
|
||||
permissions:
|
||||
contents: read # Needed to check out code
|
||||
pull-requests: read # Needed for changed-file detection on pull requests
|
||||
|
||||
jobs:
|
||||
detect-changes:
|
||||
runs-on: ubuntu-latest
|
||||
name: Detect Changes
|
||||
outputs:
|
||||
vscode: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.vscode == 'true' }}
|
||||
testing_platform: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.testing_platform == 'true' }}
|
||||
steps:
|
||||
- id: force
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call'
|
||||
run: echo "run_all=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
if: steps.force.outputs.run_all != 'true'
|
||||
|
||||
- uses: dorny/paths-filter@v3
|
||||
if: steps.force.outputs.run_all != 'true'
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
vscode:
|
||||
- 'src/**'
|
||||
- 'webview-ui/**'
|
||||
- 'proto/**'
|
||||
- 'tests/**'
|
||||
- 'scripts/**'
|
||||
- 'standalone/**'
|
||||
- 'assets/**'
|
||||
- 'walkthrough/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'buf.yaml'
|
||||
- 'tsconfig*.json'
|
||||
- 'biome.jsonc'
|
||||
- 'esbuild.mjs'
|
||||
- '.mocharc.json'
|
||||
- '.nycrc*.json'
|
||||
- '.vscode-test.mjs'
|
||||
- 'test-setup.js'
|
||||
- '.github/workflows/ext-vscode-test.yml'
|
||||
testing_platform:
|
||||
- 'src/**'
|
||||
- 'proto/**'
|
||||
- 'standalone/**'
|
||||
- 'testing-platform/**'
|
||||
- 'tests/specs/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'buf.yaml'
|
||||
- 'tsconfig*.json'
|
||||
- 'esbuild.mjs'
|
||||
- '.vscodeignore'
|
||||
- 'scripts/**'
|
||||
- '.github/workflows/ext-vscode-test.yml'
|
||||
|
||||
quality-checks:
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.vscode == 'true' || needs.detect-changes.outputs.testing_platform == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
name: Quality Checks
|
||||
steps:
|
||||
@@ -42,8 +99,9 @@ jobs:
|
||||
- name: Run Quality Checks (Parallel)
|
||||
run: npm run ci:check-all
|
||||
|
||||
test:
|
||||
needs: quality-checks
|
||||
vscode-test:
|
||||
needs: [detect-changes, quality-checks]
|
||||
if: needs.detect-changes.outputs.vscode == 'true'
|
||||
env:
|
||||
VSCODE_TEST_VERSION: 1.103.0
|
||||
strategy:
|
||||
@@ -51,7 +109,7 @@ jobs:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: ${{ matrix.os == 'ubuntu-latest' && 'test' || format('test ({0})', matrix.os) }}
|
||||
name: ${{ matrix.os == 'ubuntu-latest' && 'vscode test' || format('vscode test ({0})', matrix.os) }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -136,11 +194,6 @@ jobs:
|
||||
cd webview-ui
|
||||
npm run test:coverage
|
||||
|
||||
- name: CLI Tests
|
||||
id: cli_tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: cd cli && npm run test:run
|
||||
|
||||
- name: Save Coverage Reports
|
||||
uses: actions/upload-artifact@v4
|
||||
# Only upload artifacts on Linux - We only need coverage from one OS
|
||||
@@ -152,7 +205,8 @@ jobs:
|
||||
webview-ui/coverage/lcov.info
|
||||
|
||||
test-platform-integration:
|
||||
needs: quality-checks
|
||||
needs: [detect-changes, quality-checks]
|
||||
if: needs.detect-changes.outputs.testing_platform == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -195,8 +249,55 @@ jobs:
|
||||
name: test-platform-integration-core-coverage
|
||||
path: coverage/**/lcov.info
|
||||
|
||||
# Keep the required "test" check as a tiny aggregate gate instead of the conditional
|
||||
# VS Code matrix. GitHub treats conditionally skipped jobs as successful required
|
||||
# checks, so the gate below preserves the old required check name while making sure
|
||||
# whichever filtered test jobs were selected actually passed.
|
||||
test:
|
||||
needs: [detect-changes, quality-checks, vscode-test, test-platform-integration]
|
||||
if: ${{ !cancelled() }}
|
||||
runs-on: ubuntu-latest
|
||||
name: test
|
||||
steps:
|
||||
- name: Verify selected test jobs
|
||||
env:
|
||||
DETECT_CHANGES_RESULT: ${{ needs.detect-changes.result }}
|
||||
QUALITY_CHECKS_RESULT: ${{ needs.quality-checks.result }}
|
||||
VSCODE_CHANGED: ${{ needs.detect-changes.outputs.vscode }}
|
||||
TESTING_PLATFORM_CHANGED: ${{ needs.detect-changes.outputs.testing_platform }}
|
||||
VSCODE_TEST_RESULT: ${{ needs.vscode-test.result }}
|
||||
TEST_PLATFORM_RESULT: ${{ needs.test-platform-integration.result }}
|
||||
run: |
|
||||
if [ "$DETECT_CHANGES_RESULT" != "success" ]; then
|
||||
echo "detect-changes did not succeed: $DETECT_CHANGES_RESULT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$VSCODE_CHANGED" != "true" ] && [ "$TESTING_PLATFORM_CHANGED" != "true" ]; then
|
||||
echo "No root test paths changed; skipping root test requirements."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$QUALITY_CHECKS_RESULT" != "success" ]; then
|
||||
echo "quality-checks did not succeed: $QUALITY_CHECKS_RESULT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$VSCODE_CHANGED" = "true" ] && [ "$VSCODE_TEST_RESULT" != "success" ]; then
|
||||
echo "vscode-test did not succeed: $VSCODE_TEST_RESULT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$TESTING_PLATFORM_CHANGED" = "true" ] && [ "$TEST_PLATFORM_RESULT" != "success" ]; then
|
||||
echo "test-platform-integration did not succeed: $TEST_PLATFORM_RESULT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Selected root test jobs passed."
|
||||
|
||||
qlty:
|
||||
needs: [test, test-platform-integration]
|
||||
needs: [detect-changes, quality-checks, vscode-test, test-platform-integration]
|
||||
if: ${{ !cancelled() && needs.quality-checks.result == 'success' && (needs.vscode-test.result == 'success' || needs.vscode-test.result == 'skipped') && (needs.test-platform-integration.result == 'success' || needs.test-platform-integration.result == 'skipped') && (needs.detect-changes.outputs.vscode == 'true' || needs.detect-changes.outputs.testing_platform == 'true') }}
|
||||
runs-on: ubuntu-latest
|
||||
# Run on PRs to main, pushes to main, and manual dispatches
|
||||
steps:
|
||||
@@ -204,12 +305,14 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download unit tests coverage reports
|
||||
if: needs.detect-changes.outputs.vscode == 'true'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: pr-coverage-reports
|
||||
path: .
|
||||
|
||||
- name: Upload core unit tests coverage to Qlty
|
||||
if: needs.detect-changes.outputs.vscode == 'true'
|
||||
uses: qltysh/qlty-action/coverage@v2
|
||||
with:
|
||||
token: ${{ secrets.QLTY_COVERAGE_TOKEN }}
|
||||
@@ -219,6 +322,7 @@ jobs:
|
||||
tag: unit:core
|
||||
|
||||
- name: Upload webview-ui unit tests coverage to Qlty
|
||||
if: needs.detect-changes.outputs.vscode == 'true'
|
||||
uses: qltysh/qlty-action/coverage@v2
|
||||
with:
|
||||
token: ${{ secrets.QLTY_COVERAGE_TOKEN }}
|
||||
@@ -229,6 +333,7 @@ jobs:
|
||||
add-prefix: webview-ui/
|
||||
|
||||
- name: Download test platform integration core coverage artifact
|
||||
if: needs.detect-changes.outputs.testing_platform == 'true'
|
||||
uses: actions/download-artifact@v4
|
||||
continue-on-error: true
|
||||
id: download-integration-coverage
|
||||
@@ -237,7 +342,7 @@ jobs:
|
||||
path: integration-core-coverage-reports
|
||||
|
||||
- name: Upload core integration tests coverage to Qlty
|
||||
if: steps.download-integration-coverage.outcome == 'success'
|
||||
if: needs.detect-changes.outputs.testing_platform == 'true' && steps.download-integration-coverage.outcome == 'success'
|
||||
uses: qltysh/qlty-action/coverage@v2
|
||||
with:
|
||||
token: ${{ secrets.QLTY_COVERAGE_TOKEN }}
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
name: Auto-label Issues
|
||||
name: repo-label-issues
|
||||
|
||||
on:
|
||||
issues:
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
script: |
|
||||
const body = context.payload.issue.body || '';
|
||||
const labels = context.payload.issue.labels.map(l => l.name);
|
||||
|
||||
|
||||
// Check if JetBrains Plugin is selected
|
||||
if (body.match(/###\s*Plugin Type\s*\n+JetBrains Plugin/i)) {
|
||||
if (!labels.includes('JetBrains')) {
|
||||
@@ -1,6 +1,6 @@
|
||||
# This workflow will only label and/or close 30 issues at a time in order to avoid exceeding a rate limit.
|
||||
# More info: https://docs.github.com/en/actions/use-cases-and-examples/project-management/closing-inactive-issues
|
||||
name: Close inactive issues
|
||||
name: repo-stale-issues
|
||||
on:
|
||||
schedule:
|
||||
- cron: "30 1 * * *"
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Publish Main SDK Packages
|
||||
name: sdk-publish
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -59,19 +59,23 @@ jobs:
|
||||
|
||||
- name: Determine publish channel
|
||||
id: channel
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
INPUT_CHANNEL: ${{ inputs.channel }}
|
||||
run: |
|
||||
# Default to nightly for scheduled runs
|
||||
if [ "${{ github.event_name }}" = "schedule" ]; then
|
||||
if [ "$EVENT_NAME" = "schedule" ]; then
|
||||
echo "channel=nightly" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "channel=${{ inputs.channel }}" >> $GITHUB_OUTPUT
|
||||
echo "channel=$INPUT_CHANNEL" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Check for recent commits
|
||||
id: check_commits
|
||||
env:
|
||||
CHANNEL: ${{ steps.channel.outputs.channel }}
|
||||
FORCE_PUBLISH: ${{ inputs.force_publish }}
|
||||
run: |
|
||||
CHANNEL="${{ steps.channel.outputs.channel }}"
|
||||
|
||||
# Always publish for latest (production) releases
|
||||
if [ "$CHANNEL" = "latest" ]; then
|
||||
echo "Production release requested, proceeding with publish"
|
||||
@@ -79,7 +83,7 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "${{ inputs.force_publish }}" = "true" ]; then
|
||||
if [ "$FORCE_PUBLISH" = "true" ]; then
|
||||
echo "force_publish enabled, proceeding with publish"
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
@@ -141,8 +145,9 @@ jobs:
|
||||
- name: Generate shared version
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
id: version
|
||||
env:
|
||||
CHANNEL: ${{ steps.channel.outputs.channel }}
|
||||
run: |
|
||||
CHANNEL="${{ steps.channel.outputs.channel }}"
|
||||
BASE_VERSION=$(node -p "require('./packages/llms/package.json').version")
|
||||
|
||||
if [ "$CHANNEL" = "nightly" ]; then
|
||||
@@ -159,7 +164,9 @@ jobs:
|
||||
|
||||
- name: Update all package versions and lockfile
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: bun scripts/version.ts "${{ steps.version.outputs.version }}"
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: bun scripts/version.ts "$VERSION"
|
||||
|
||||
- name: Verify publishability
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
@@ -176,9 +183,10 @@ jobs:
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
NPM_CONFIG_PROVENANCE: "true"
|
||||
CHANNEL: ${{ steps.channel.outputs.channel }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
CHANNEL="${{ steps.channel.outputs.channel }}"
|
||||
echo "Publishing @cline/shared@${{ steps.version.outputs.version }} with tag '${CHANNEL}'..."
|
||||
echo "Publishing @cline/shared@${VERSION} with tag '${CHANNEL}'..."
|
||||
cd packages/shared
|
||||
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
|
||||
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
|
||||
@@ -187,9 +195,10 @@ jobs:
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
NPM_CONFIG_PROVENANCE: "true"
|
||||
CHANNEL: ${{ steps.channel.outputs.channel }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
CHANNEL="${{ steps.channel.outputs.channel }}"
|
||||
echo "Publishing @cline/llms@${{ steps.version.outputs.version }} with tag '${CHANNEL}'..."
|
||||
echo "Publishing @cline/llms@${VERSION} with tag '${CHANNEL}'..."
|
||||
cd packages/llms
|
||||
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
|
||||
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
|
||||
@@ -198,9 +207,10 @@ jobs:
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
NPM_CONFIG_PROVENANCE: "true"
|
||||
CHANNEL: ${{ steps.channel.outputs.channel }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
CHANNEL="${{ steps.channel.outputs.channel }}"
|
||||
echo "Publishing @cline/agents@${{ steps.version.outputs.version }} with tag '${CHANNEL}'..."
|
||||
echo "Publishing @cline/agents@${VERSION} with tag '${CHANNEL}'..."
|
||||
cd packages/agents
|
||||
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
|
||||
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
|
||||
@@ -209,9 +219,10 @@ jobs:
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
NPM_CONFIG_PROVENANCE: "true"
|
||||
CHANNEL: ${{ steps.channel.outputs.channel }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
CHANNEL="${{ steps.channel.outputs.channel }}"
|
||||
echo "Publishing @cline/core@${{ steps.version.outputs.version }} with tag '${CHANNEL}'..."
|
||||
echo "Publishing @cline/core@${VERSION} with tag '${CHANNEL}'..."
|
||||
cd packages/core
|
||||
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
|
||||
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
|
||||
@@ -220,18 +231,19 @@ jobs:
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
NPM_CONFIG_PROVENANCE: "true"
|
||||
CHANNEL: ${{ steps.channel.outputs.channel }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
CHANNEL="${{ steps.channel.outputs.channel }}"
|
||||
echo "Publishing @cline/sdk@${{ steps.version.outputs.version }} with tag '${CHANNEL}'..."
|
||||
echo "Publishing @cline/sdk@${VERSION} with tag '${CHANNEL}'..."
|
||||
cd packages/sdk
|
||||
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
|
||||
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
|
||||
|
||||
- name: Create package tags for production publish
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
@@ -250,9 +262,10 @@ jobs:
|
||||
|
||||
- name: Summary
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
CHANNEL: ${{ steps.channel.outputs.channel }}
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
CHANNEL="${{ steps.channel.outputs.channel }}"
|
||||
echo "Published SDK packages with tag '${CHANNEL}':"
|
||||
echo " - @cline/shared@${VERSION}"
|
||||
echo " - @cline/llms@${VERSION}"
|
||||
@@ -1,4 +1,4 @@
|
||||
name: SDK Tests
|
||||
name: sdk-test
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
name: Test Stale Issues Workflow
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
days-before-stale:
|
||||
description: "Days before an issue becomes stale"
|
||||
required: true
|
||||
default: "1"
|
||||
days-before-close:
|
||||
description: "Days before a stale issue is closed"
|
||||
required: true
|
||||
default: "1"
|
||||
|
||||
jobs:
|
||||
test-stale:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@28ca103
|
||||
with:
|
||||
days-before-issue-stale: ${{ github.event.inputs.days-before-stale }}
|
||||
days-before-issue-close: ${{ github.event.inputs.days-before-close }}
|
||||
stale-issue-label: "stale"
|
||||
stale-issue-message: "This issue is stale because it has been open for ${{ github.event.inputs.days-before-stale }} days with no activity."
|
||||
close-issue-message: "This issue was closed because it has been inactive for ${{ github.event.inputs.days-before-close }} days since being marked as stale."
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
exempt-issue-labels: "pinned,security"
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
debug-only: true
|
||||
@@ -1,2 +1,3 @@
|
||||
node 22
|
||||
|
||||
bun 1.3.13
|
||||
node 22
|
||||
Vendored
+2
-1
@@ -5,6 +5,7 @@
|
||||
"connor4312.esbuild-problem-matchers",
|
||||
"ms-vscode.extension-test-runner",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"biomejs.biome"
|
||||
"biomejs.biome",
|
||||
"oven.bun-vscode"
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+113
@@ -199,6 +199,119 @@
|
||||
"env": {
|
||||
"IS_DEV": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Launch Bun CLI (Prompt)",
|
||||
"type": "bun",
|
||||
"request": "launch",
|
||||
"cwd": "${workspaceFolder}/sdk/apps/cli",
|
||||
"runtime": "bun",
|
||||
"runtimeArgs": [
|
||||
"--conditions=development"
|
||||
],
|
||||
"program": "${workspaceFolder}/sdk/apps/cli/src/index.ts",
|
||||
"args": [
|
||||
"${input:cliPrompt}"
|
||||
],
|
||||
"env": {
|
||||
"CLINE_BUILD_ENV": "development"
|
||||
},
|
||||
"presentation": {
|
||||
"hidden": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Launch RPC Server",
|
||||
"type": "bun",
|
||||
"request": "launch",
|
||||
"cwd": "${workspaceFolder}/sdk/apps/cli",
|
||||
"runtime": "bun",
|
||||
"runtimeArgs": [
|
||||
"--conditions=development"
|
||||
],
|
||||
"program": "${workspaceFolder}/sdk/apps/cli/src/index.ts",
|
||||
"args": [
|
||||
"rpc",
|
||||
"start"
|
||||
],
|
||||
"env": {
|
||||
"CLINE_BUILD_ENV": "development",
|
||||
"CLINE_DEBUG_PORT_BASE": "9230"
|
||||
},
|
||||
"presentation": {
|
||||
"hidden": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Attach RPC Runtime (9230)",
|
||||
"type": "bun",
|
||||
"request": "attach",
|
||||
"url": "ws://127.0.0.1:9230",
|
||||
"localRoot": "${workspaceFolder}/sdk",
|
||||
"remoteRoot": "${workspaceFolder}/sdk",
|
||||
"presentation": {
|
||||
"hidden": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Attach Hook Worker (9231)",
|
||||
"type": "bun",
|
||||
"request": "attach",
|
||||
"url": "ws://127.0.0.1:9231",
|
||||
"localRoot": "${workspaceFolder}/sdk",
|
||||
"remoteRoot": "${workspaceFolder}/sdk",
|
||||
"presentation": {
|
||||
"hidden": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Attach Plugin Sandbox (9232)",
|
||||
"type": "bun",
|
||||
"request": "attach",
|
||||
"url": "ws://127.0.0.1:9232",
|
||||
"localRoot": "${workspaceFolder}/sdk",
|
||||
"remoteRoot": "${workspaceFolder}/sdk",
|
||||
"presentation": {
|
||||
"hidden": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Attach Connector Child (9233)",
|
||||
"type": "bun",
|
||||
"request": "attach",
|
||||
"url": "ws://127.0.0.1:9233",
|
||||
"localRoot": "${workspaceFolder}/sdk",
|
||||
"remoteRoot": "${workspaceFolder}/sdk",
|
||||
"presentation": {
|
||||
"hidden": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
{
|
||||
"name": "Launch RPC Server Debugger",
|
||||
"configurations": [
|
||||
"Launch RPC Server",
|
||||
"Attach RPC Runtime (9230)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Launch CLI Debugger",
|
||||
"configurations": [
|
||||
"Launch Bun CLI (Prompt)",
|
||||
"Attach RPC Runtime (9230)",
|
||||
"Attach Hook Worker (9231)",
|
||||
"Attach Plugin Sandbox (9232)",
|
||||
"Attach Connector Child (9233)"
|
||||
]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"id": "cliPrompt",
|
||||
"type": "promptString",
|
||||
"description": "Prompt to send to the CLI",
|
||||
"default": "hey"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -1,5 +1,6 @@
|
||||
// Place your settings in this file to overwrite default and user settings.
|
||||
{
|
||||
"files.insertFinalNewline": true,
|
||||
"files.exclude": {
|
||||
"out": false, // set this to true to hide the "out" folder with the compiled JS files
|
||||
"dist": false // set this to true to hide the "dist" folder with the compiled JS files
|
||||
|
||||
Vendored
+11
@@ -283,6 +283,17 @@
|
||||
"IS_DEV": "true"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "build-sdk",
|
||||
"type": "shell",
|
||||
"command": "bun run build:sdk",
|
||||
"problemMatcher": [
|
||||
"$tsc"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/sdk"
|
||||
}
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
|
||||
+4
-3
@@ -2,6 +2,10 @@
|
||||
.vscode/**
|
||||
.vscode-test/**
|
||||
.worktrees/**
|
||||
# Agent tooling, never shipped in the VSIX
|
||||
.agents/**
|
||||
.claude/**
|
||||
.codex/**
|
||||
CLAUDE.local.md
|
||||
out/
|
||||
dist-standalone/
|
||||
@@ -22,9 +26,6 @@ eslint-rules/**
|
||||
.husky/**
|
||||
.env
|
||||
|
||||
# cli
|
||||
cli/**
|
||||
|
||||
# sdk (separate monorepo with its own build/release pipeline)
|
||||
sdk/**
|
||||
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
## [3.83.0]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Show a clear "Searching..." state in the @-mention file picker
|
||||
- Improve @-mention file search performance
|
||||
- Allow `write_to_file` to create or overwrite files with empty content.
|
||||
- Fix validation failures for MCP servers that require an object.
|
||||
- Enable OpenRouter prompt cache control for Qwen models.
|
||||
- Update Axios and SAP Connectivity dependencies
|
||||
|
||||
### Changed
|
||||
|
||||
- Use the VS Code-specific `README.marketplace.md` when packaging and publishing the VS Code extension
|
||||
- Add telemetry to @-mention search to help diagnose local, remote, and multi-root workspace search behavior.
|
||||
|
||||
## [3.82.0]
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
@.clinerules/general.md
|
||||
@.clinerules/network.md
|
||||
@.clinerules/cli.md
|
||||
|
||||
@@ -126,14 +126,14 @@ npm install @cline/sdk
|
||||
|
||||
## Index
|
||||
|
||||
| Product | Description | Location |
|
||||
|---------|------------|--------------|
|
||||
| **SDK** | Node.js programmatic agent API and extension exports. | [`sdk/`](https://github.com/cline/cline/tree/main/sdk) |
|
||||
| **CLI** | Terminal UI, headless mode, shell commands, and CLI-specific flows. | [`sdk/apps/cli/`](https://github.com/cline/cline/tree/main/sdk/apps/cli) |
|
||||
| **VS Code Extension** | The Marketplace extension and extension host integration. | [`/`](https://github.com/cline/cline/tree/main) (WIP migrating) |
|
||||
| **JetBrains Plugin** | JetBrains-hosted client that talks to the shared agent core. | Currently we are not open-sourcing JetBrains plugins |
|
||||
| **Kanban** | Web-based multi-agent task board. | [`cline/kanban`](https://github.com/cline/kanban). |
|
||||
| **Docs site** | Public documentation pages. | [`docs/`](https://docs.cline.bot/) |
|
||||
| Product | Description | Location | CHANGELOG |
|
||||
|---------|------------|--------------|--------------|
|
||||
| **SDK** | Node.js programmatic agent API and extension exports. | [`sdk/`](https://github.com/cline/cline/tree/main/sdk) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/sdk/CHANGELOG.md) |
|
||||
| **CLI** | Terminal UI, headless mode, shell commands, and CLI-specific flows. | [`sdk/apps/cli/`](https://github.com/cline/cline/tree/main/sdk/apps/cli) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/sdk/apps/cli/CHANGELOG.md) |
|
||||
| **VS Code Extension** | The Marketplace extension and extension host integration. | [`/`](https://github.com/cline/cline/tree/main) (WIP migrating) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/CHANGELOG.md) |
|
||||
| **JetBrains Plugin** | JetBrains-hosted client that talks to the shared agent core. | Currently we are not open-sourcing JetBrains plugins | - |
|
||||
| **Kanban** | Web-based multi-agent task board. | [`cline/kanban`](https://github.com/cline/kanban) | [CHANGELOG.md](https://github.com/cline/kanban/blob/main/CHANGELOG.md) |
|
||||
| **Docs site** | Public documentation pages. | [`docs/`](https://docs.cline.bot/) | - |
|
||||
|
||||
## Edits Code Across Your Project
|
||||
|
||||
|
||||
+1
-3
@@ -179,9 +179,7 @@
|
||||
"!!**/*.js",
|
||||
"!!**/scripts/**",
|
||||
"!!**/*.tsx",
|
||||
"!!**/testing-platform/**",
|
||||
// ACP mode must redirect console to stderr - this is intentional
|
||||
"!!cli/src/acp/index.ts"
|
||||
"!!**/testing-platform/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,325 +0,0 @@
|
||||
# cline
|
||||
|
||||
## [2.18.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Restore foreground terminal support and settings.
|
||||
- Add latest OpenAI, SAP AI Core, and Z AI models.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix hook template JSON escaping.
|
||||
- Improve ripgrep file search error handling.
|
||||
|
||||
### Changed
|
||||
|
||||
- Remove hardcoded model lists from docs.
|
||||
|
||||
## [2.17.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Add GPT-5.5 model support for OpenAI Codex subscription users.
|
||||
|
||||
### Changed
|
||||
|
||||
- Improve `cline-core` runtime memory diagnostics used by CLI:
|
||||
- enable near-heap-limit heap snapshots
|
||||
- add periodic memory usage logging
|
||||
- log discovered heap snapshots on abnormal exits for easier OOM debugging
|
||||
|
||||
## [2.16.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Wire up remote `globalSkills` from enterprise remote config with full toggle support and system prompt integration — enterprise-managed skills now support `alwaysEnabled` enforcement
|
||||
- Add dedicated "Quota Exceeded" error message when Cline account spend caps are hit
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix OOM crashes during long conversations by setting `--max-old-space-size=8192` for the cline-core Node.js process (was defaulting to ~2 GB)
|
||||
- Show detailed error information instead of a generic caught error message
|
||||
- Update `axios` to 1.15.0 across all packages
|
||||
|
||||
### Changed
|
||||
|
||||
- Remove dead ACP terminal setter stubs as part of foreground terminal mode removal
|
||||
|
||||
## [2.15.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Add Claude Opus 4.7 model support
|
||||
- Inline value reuse in user-level remote-config discovery
|
||||
- Add `globalSkills` to remote config
|
||||
|
||||
### Fixed
|
||||
|
||||
- Stabilize Windows CI test path handling
|
||||
|
||||
## [2.14.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Simplify unified `cline update` flow for `cline` and `kanban`
|
||||
- Docs updates
|
||||
|
||||
### Fixed
|
||||
|
||||
- Update Kanban migration view copy
|
||||
|
||||
## [2.12.0]
|
||||
|
||||
### Added
|
||||
|
||||
- `read_file` tool now supports chunked reading for targeted file access
|
||||
|
||||
### Fixed
|
||||
|
||||
- Exclude `new_task` tool from system prompt in yolo/headless mode
|
||||
|
||||
### Changed
|
||||
|
||||
- Polish `Notification` hook functionality
|
||||
|
||||
## [2.9.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Latency improvements for remote workspaces
|
||||
|
||||
## [2.8.2]
|
||||
|
||||
### Fixed
|
||||
- Use `kanban@latest` in `cline kanban` to always fetch the newest version
|
||||
|
||||
## [2.8.1]
|
||||
|
||||
### Added
|
||||
- Implement dynamic free model detection for Cline API
|
||||
- Add file read deduplication cache to prevent repeated reads
|
||||
- Add feature tips tooltip during thinking state
|
||||
|
||||
### Fixed
|
||||
- Fix flaky CLI Enter-key handling across Windows/test environments
|
||||
- Replace error message when not logged in to Cline
|
||||
- Align ClineRulesToggleModal padding with ServersToggleModal
|
||||
- Skip WebP for GLM and Devstral models running through llama.cpp
|
||||
- Respect user-configured context window in LiteLLM getModel()
|
||||
- Honor explicit model IDs outside static catalog in W&B provider
|
||||
- Add missing Fireworks serverless models and pricing
|
||||
|
||||
## [2.8.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Added W&B Inference by CoreWeave as a new API provider with 17 models including DeepSeek-V3.1, Llama 4, and Qwen3-Coder
|
||||
- Added CLI TUI end-to-end test suite
|
||||
|
||||
### Fixed
|
||||
|
||||
- Claude Code: handle rate limit events, empty content arrays, error results, and unknown content types without crashing
|
||||
- CLI: `/q` and `/exit` slash commands now execute immediately on Enter without requiring the slash menu to be visible
|
||||
- CLI: slash command filtering now prioritizes exact and prefix matches over fuzzy matches
|
||||
|
||||
## [2.7.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Added MCP add shortcuts for stdio and HTTP servers
|
||||
- Added `--continue` for the current directory
|
||||
- Added `--auto-condense` flag for AI-powered context compaction
|
||||
- Added `--hooks-dir` flag for runtime hook injection
|
||||
- Enabled error autocapture
|
||||
- Prompt rules now include test verification guidance and make `CLI_RULES` language-agnostic
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed remount behavior so TUI remounts only on width resize
|
||||
- Fixed startup prompt replay on resize remount
|
||||
- Fixed task flags so they are applied before the welcome TUI mounts
|
||||
|
||||
### Changed
|
||||
|
||||
- Hooks: reintroduced feature toggle
|
||||
|
||||
## [2.6.1]
|
||||
|
||||
### Added
|
||||
|
||||
- Added GPT-5.4 models for ChatGPT subscription users
|
||||
- Hooks: Added a `Notification` hook for attention and completion boundaries
|
||||
- Added `--hooks-dir` CLI flag for runtime hook injection
|
||||
- Added `--auto-approve-all` CLI flag for interactive mode
|
||||
|
||||
### Fixed
|
||||
|
||||
- Handle streamable HTTP MCP reconnects more reliably
|
||||
|
||||
## [2.6.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Hook payloads now include `model.provider` and `model.slug`
|
||||
- Token/cost updates now happen immediately as usage chunks arrive, not after tool execution
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improve subagent context compaction logic
|
||||
- Subagent stream retry delay increased to reduce noise from transient failures
|
||||
- State serialization errors are now caught and logged instead of crashing
|
||||
- Removed incorrect `max_tokens` from OpenRouter requests
|
||||
|
||||
## [2.5.2]
|
||||
|
||||
### Added
|
||||
|
||||
- Added Windows PowerShell support for hooks (execution, resolution, and management), improving hook behavior on Windows for CLI and shared core workflows.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Restored GPT-OSS native file editing for OpenAI-compatible models used through shared core tooling.
|
||||
- Improved OpenRouter context overflow error handling so auto-compaction triggers correctly for wrapped 400 errors.
|
||||
- Hardened checkpoint recovery by retrying nested git restore and preventing silent `.git_disabled` leftovers.
|
||||
- Added a User-Agent header for requests to the Cline back-end to improve request handling consistency.
|
||||
|
||||
## [2.5.1]
|
||||
|
||||
### Added
|
||||
|
||||
- Expanded CLI markdown rendering support (headings, lists, blockquotes, fenced code blocks, links, and nested lists).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed CLI headless auth provider model metadata loading for Cline and Vercel AI Gateway by fetching model info from API with cache fallback.
|
||||
- Increased flaky CLI import test timeout on Windows CI to reduce intermittent test failures.
|
||||
|
||||
## [2.5.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Added Cline SDK API interface for programmatic access to Cline features and tools, enabling integration into custom applications.
|
||||
- Added Codex 5.3 model support
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix OpenAI Codex by setting `store` to `false`
|
||||
- Use `isLocatedInPath()` instead of string matching for path containment checks
|
||||
|
||||
## [2.4.3]
|
||||
|
||||
### Added
|
||||
|
||||
- Add /q command to quit CLI
|
||||
- Fetch featured models from backend with local fallback
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix auth check for ACP mode
|
||||
- Fix Cline auth with ACP flag
|
||||
- Fix yolo mode to not persist yolo setting to disk
|
||||
|
||||
## [2.4.2]
|
||||
|
||||
### Added
|
||||
|
||||
- Gemini-3.1 Pro Preview
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- VSCode uses shared files for global, workspace and secret state.
|
||||
|
||||
## [2.4.1]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix infinite retry loop when write_to_file fails with missing content parameter. Provides progressive guidance to the model, escalating from suggestions to hard stops, with context window awareness to break the loop.
|
||||
|
||||
## [2.4.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Adding Anthropic Sonnet 4.6
|
||||
- Allows users to enter custom aws region when selecting bedrock as a provider in CLI
|
||||
- Keep reasoning rows visible when low-stakes tool groups start immediately after reasoning.
|
||||
- Restore reasoning trace visibility in chat and improve the thinking row UX so streamed reasoning is visible, then collapsible after completion.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Banners now display immediately when opening the extension instead of requiring user interaction first
|
||||
- Resolved 17 security vulnerabilities including high-severity DoS issues in dependencies (body-parser, axios, qs, tar, and others)
|
||||
|
||||
## [2.2.2]
|
||||
|
||||
- Allows users to enter custom aws region when selecting bedrock as a provider
|
||||
- Prevent Parent Container Scrolling In Dropdowns
|
||||
|
||||
## [2.2.1]
|
||||
|
||||
- Added Minimax 2.5 Free Promo
|
||||
- Fixed Response chaining for OpenAI's Responses API
|
||||
|
||||
## [2.2.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Subagent: replace legacy subagents with the native `use_subagents` tool
|
||||
- Bundle `endpoints.json` support so packaged distributions can ship required endpoints out-of-the-box
|
||||
- Amazon Bedrock: support parallel tool calling
|
||||
- New "double-check completion" experimental feature to verify work before marking tasks complete
|
||||
- CLI: new task controls/flags including custom `--thinking` token budget and `--max-consecutive-mistakes` for yolo runs
|
||||
- Remote config: new UI/options (including connection/test buttons) and support for syncing deletion of remotely configured MCP servers
|
||||
- Vertex / Claude Code: add 1M context model options for Claude Opus 4.6
|
||||
- ZAI/GLM: add GLM-5
|
||||
|
||||
### Fixed
|
||||
|
||||
- CLI: handle stdin redirection correctly in CI/headless environments
|
||||
- CLI: preserve OAuth callback paths during auth redirects
|
||||
- VS Code Web: generate auth callback URLs via `vscode.env.asExternalUri` (OAuth callback reliability)
|
||||
- Terminal: surface command exit codes in results and improve long-running `execute_command` timeout behavior
|
||||
- UI: add loading indicator and fix `api_req_started` rendering
|
||||
- Task streaming: prevent duplicate streamed text rows after completion
|
||||
- API: preserve selected Vercel model when model metadata is missing
|
||||
- Telemetry: route PostHog networking through proxy-aware shared fetch and ensure telemetry flushes on shutdown
|
||||
- CI: increase Windows E2E test timeout to reduce flakiness
|
||||
|
||||
### Changed
|
||||
|
||||
- Settings/model UX: move "reasoning effort" into model configuration and expose it in settings
|
||||
- CLI provider selection: limit provider list to those remotely configured
|
||||
- UI: consolidate ViewHeader component/styling across views
|
||||
- Tools: add auto-approval support for `attempt_completion` commands
|
||||
- Remotely configured MCP server schema now supports custom headers
|
||||
|
||||
## [2.1.0]
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 42ce100: Add Generate API Key on Hicap Provider selection
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 195294f: Add support for bundled endpoints.json in enterprise distributions. Extensions can now include a pre-configured endpoints.json file that automatically switches Cline to self-hosted mode. Includes packaging scripts for VSIX, NPM, and JetBrains plugins.
|
||||
- a1f2601: Replace the LiteLLM model list with a selector
|
||||
- 739d75a: Add Claude Code provider support for Claude Opus 4.6 and Sonnet 4.5 1M variants via both full model names and aliases (`opus[1m]`, `sonnet[1m]`), and align the `opus` alias with Opus 4.6.
|
||||
- 8440380: Add GitHub Actions workflow to build CLI from any commit for testing
|
||||
- b1a8db2: fix(cli): prevent hang when spawned without TTY
|
||||
- 7c87017: Add Claude Opus 4.6 model support
|
||||
- d116ac5: Supports rendering markdown table in chat view.
|
||||
- 6d8fb85: Fix CLI crashing in CI environments and with stdin redirection (e.g., `cline "prompt" < /dev/null`). Now checks both stdin and stdout TTY status before using Ink, and only errors on empty stdin when no prompt is provided.
|
||||
- 70a9904: Fix JetBrains sign-in regression by adding fallback for openExternal RPC
|
||||
- f440f3a: fix: use vscode.env.openExternal for auth in remote environments
|
||||
|
||||
Fixes OAuth authentication in VS Code Server and remote environments by routing browser URL opening through VS Code's native openExternal API instead of the npm 'open' package.
|
||||
|
||||
- 70a9904: fix: use vscode.env.asExternalUri for auth callback URLs only in VS Code Web
|
||||
|
||||
Fixes OAuth callback redirect in VS Code Web (`code serve-web`, Codespaces) by using `vscode.env.asExternalUri()` to resolve the callback URI. This is gated behind a `vscode.env.uiKind === UIKind.Web` check so regular desktop VS Code continues to use the `vscode://` URI directly. The `getCallbackUrl` API now accepts a `path` parameter so the full callback URI (including route) is resolved correctly, and callers pass their path directly instead of appending after.
|
||||
|
||||
- 5308ded: Updating script documentation and removing unnecessary continue on error
|
||||
- b514f18: Prevent duplicate streamed text rows when a partial text update arrives after the same text was already finalized.
|
||||
- 26391c9: Fix Bedrock model id
|
||||
- d19a877: Unify ViewHeader Styles Across All Views
|
||||
- 5dcaa8c: Add Vertex Claude Opus 4.6 1M model option and global endpoint support, and pass the 1M beta header for Vertex Claude requests.
|
||||
@@ -1,365 +0,0 @@
|
||||
# Cline CLI
|
||||
|
||||
The official CLI for Cline. Run Cline tasks directly from the terminal with the same underlying functionality as the VS Code extension.
|
||||
|
||||
## Features
|
||||
|
||||
- **Reuses Core Codebase**: Shares the same Controller, Task, and API handling as the VS Code extension
|
||||
- **Terminal Output**: Displays Cline messages directly in your terminal with colored output
|
||||
- **Task History**: Access your task history from the command line
|
||||
- **Configurable**: Use custom configuration directories and working directories
|
||||
- **Image Support**: Attach images to your prompts using file paths or inline references
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 20.x or later
|
||||
- npm or yarn
|
||||
- The parent Cline project dependencies installed
|
||||
|
||||
## Installation
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
# Install all dependencies first
|
||||
npm run install:all
|
||||
|
||||
# Ensure protos are generated
|
||||
npm run protos
|
||||
|
||||
# Build and link the CLI globally
|
||||
npm run cli:link
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Interactive Mode (Default)
|
||||
|
||||
When you run `cline` without any command, it launches an interactive welcome prompt:
|
||||
|
||||
```bash
|
||||
# Launch interactive mode
|
||||
cline
|
||||
|
||||
# Or run a task directly
|
||||
cline "Create a hello world function in Python"
|
||||
|
||||
# With options
|
||||
cline -v --thinking "Analyze this codebase"
|
||||
```
|
||||
|
||||
### Commands
|
||||
|
||||
#### `task` (alias: `t`)
|
||||
|
||||
Run a new task with a prompt.
|
||||
|
||||
```bash
|
||||
cline task "Create a hello world function in Python"
|
||||
cline t "Create a hello world function"
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-a, --act` | Run in act mode |
|
||||
| `-p, --plan` | Run in plan mode |
|
||||
| `-y, --yolo` | Enable yolo mode (auto-approve actions) |
|
||||
| `-m, --model <model>` | Model to use for the task |
|
||||
| `-i, --images <paths...>` | Image file paths to include with the task |
|
||||
| `-v, --verbose` | Show verbose output including reasoning |
|
||||
| `-c, --cwd <path>` | Working directory for the task |
|
||||
| `--config <path>` | Path to Cline configuration directory |
|
||||
| `-t, --thinking` | Enable extended thinking (1024 token budget) |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Run in plan mode with verbose output
|
||||
cline task -p -v "Design a REST API"
|
||||
|
||||
# Use a specific model with yolo mode
|
||||
cline task -m claude-sonnet-4-5-20250929 -y "Refactor this function"
|
||||
|
||||
# Include images with your prompt
|
||||
cline task -i screenshot.png diagram.jpg "Fix the UI based on these images"
|
||||
|
||||
# Or use inline image references in the prompt
|
||||
cline task "Fix the layout shown in @./screenshot.png"
|
||||
|
||||
# Enable extended thinking for complex tasks
|
||||
cline task -t "Architect a microservices system"
|
||||
|
||||
# Specify working directory
|
||||
cline task -c /path/to/project "Add unit tests"
|
||||
```
|
||||
|
||||
#### `history` (alias: `h`)
|
||||
|
||||
List task history with pagination support.
|
||||
|
||||
```bash
|
||||
cline history
|
||||
cline h
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-n, --limit <number>` | Number of tasks to show (default: 10) |
|
||||
| `-p, --page <number>` | Page number, 1-based (default: 1) |
|
||||
| `--config <path>` | Path to Cline configuration directory |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Show last 10 tasks (default)
|
||||
cline history
|
||||
|
||||
# Show 20 tasks
|
||||
cline history -n 20
|
||||
|
||||
# Show page 2 with 5 tasks per page
|
||||
cline history -n 5 -p 2
|
||||
```
|
||||
|
||||
#### `config`
|
||||
|
||||
Show current configuration including global and workspace state.
|
||||
|
||||
```bash
|
||||
cline config
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--config <path>` | Path to Cline configuration directory |
|
||||
|
||||
#### `auth`
|
||||
|
||||
Authenticate a provider and configure what model is used.
|
||||
|
||||
```bash
|
||||
cline auth
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-p, --provider <id>` | Provider ID for quick setup (e.g., openai-native, anthropic) |
|
||||
| `-k, --apikey <key>` | API key for the provider |
|
||||
| `-m, --modelid <id>` | Model ID to configure (e.g., gpt-4o, claude-sonnet-4-5-20250929) |
|
||||
| `-b, --baseurl <url>` | Base URL (optional, only for openai provider) |
|
||||
| `-v, --verbose` | Show verbose output |
|
||||
| `-c, --cwd <path>` | Working directory for the task |
|
||||
| `--config <path>` | Path to Cline configuration directory |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Interactive authentication
|
||||
cline auth
|
||||
|
||||
# Quick setup with provider and API key
|
||||
cline auth -p anthropic -k sk-ant-xxxxx
|
||||
|
||||
# Full quick setup with model
|
||||
cline auth -p openai-native -k sk-xxxxx -m gpt-4o
|
||||
|
||||
# OpenAI-compatible provider with custom base URL
|
||||
cline auth -p openai -k your-api-key -b https://api.example.com/v1
|
||||
```
|
||||
|
||||
### Global Options
|
||||
|
||||
These options are available for the default command (running a task directly):
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-v, --verbose` | Show verbose output |
|
||||
| `-c, --cwd <path>` | Working directory |
|
||||
| `--config <path>` | Configuration directory |
|
||||
| `--thinking` | Enable extended thinking (1024 token budget) |
|
||||
|
||||
## Development
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Install all dependencies (root, webview-ui, cli)
|
||||
npm run install:all
|
||||
|
||||
# 2. Build and link globally so you can run `cline` from anywhere
|
||||
npm run cli:link
|
||||
|
||||
# 3. Test it
|
||||
cline --help
|
||||
```
|
||||
|
||||
### Scripts
|
||||
|
||||
Run these from the repository root:
|
||||
|
||||
| Script | Description |
|
||||
|--------|-------------|
|
||||
| `npm run install:all` | Install deps for root, webview-ui, and cli |
|
||||
| `npm run cli:build` | Generate protos and build CLI |
|
||||
| `npm run cli:build:production` | Production build (minified) |
|
||||
| `npm run cli:link` | Build and `npm link` so you can run `cline` from anywhere |
|
||||
| `npm run cli:unlink` | Remove the global `cline` symlink |
|
||||
| `npm run cli:dev` | Link + watch mode for development |
|
||||
| `npm run cli:watch` | Watch mode only (no initial build) |
|
||||
| `npm run cli:test` | Run CLI tests |
|
||||
|
||||
### Development Workflow
|
||||
|
||||
1. Run `npm run cli:dev` - this links the CLI globally and starts watch mode
|
||||
2. Make changes to files in `cli/src/`
|
||||
3. The build automatically rebuilds on save
|
||||
4. Test your changes by running `cline` in another terminal
|
||||
5. When done, run `npm run cli:unlink` to clean up
|
||||
|
||||
### Proto Generation
|
||||
|
||||
The CLI uses proto-generated types for message passing (same as the VS Code extension). If you modify any `.proto` files, run:
|
||||
|
||||
```bash
|
||||
npm run protos
|
||||
```
|
||||
|
||||
This generates TypeScript types in `src/generated/` that both the CLI and extension use.
|
||||
|
||||
## 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/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
|
||||
|
||||
### How It Works
|
||||
|
||||
The CLI directly imports and reuses the core Cline TypeScript codebase (the same code that powers the VS Code extension). This means feature parity is easy to maintain - when core gets updated, the CLI automatically benefits.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ CLI (cli/) │
|
||||
│ - React Ink terminal UI │
|
||||
│ - Command parsing (commander) │
|
||||
│ - Terminal-specific adapters │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ direct imports
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Core (src/core/) │
|
||||
│ - Controller: task lifecycle, state management │
|
||||
│ - Task: AI API calls, tool execution │
|
||||
│ - StateManager: persistent storage │
|
||||
│ - Proto types: message definitions │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Unlike a client-server architecture, the CLI runs everything in a single Node.js process. The "host bridge" pattern provides terminal-appropriate implementations for things the VS Code extension would handle differently (clipboard, file dialogs, etc.).
|
||||
|
||||
### Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/index.ts` | Entry point, command definitions |
|
||||
| `src/components/App.tsx` | Main React Ink app |
|
||||
| `src/components/ChatView.tsx` | Task conversation UI |
|
||||
| `src/controllers/CliWebviewProvider.ts` | Bridges core messages to terminal output |
|
||||
| `src/vscode-context.ts` | Mock VS Code extension context for core compatibility |
|
||||
| `src/vscode-shim.ts` | Shims for VS Code APIs that core depends on |
|
||||
| `src/constants/colors.ts` | Terminal color definitions |
|
||||
|
||||
### React Ink
|
||||
|
||||
The CLI uses [React Ink](https://github.com/vadimdemedes/ink) for its terminal UI. This lets us build the interface with React components that render to the terminal. Key patterns:
|
||||
|
||||
- Components in `src/components/` render terminal UI
|
||||
- Hooks in `src/hooks/` manage terminal-specific state (size, scrolling)
|
||||
- The `useStateSubscriber` hook subscribes to core state changes
|
||||
|
||||
## Configuration
|
||||
|
||||
The CLI stores its data in `~/.cline/data/` by default:
|
||||
|
||||
- `globalState.json`: Global settings and state
|
||||
- `secrets.json`: API keys and secrets
|
||||
- `workspace/`: Workspace-specific state
|
||||
- `tasks/`: Task history and conversation data
|
||||
|
||||
Override with the `--config` option or `CLINE_DIR` environment variable.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Build Errors
|
||||
|
||||
If you encounter build errors:
|
||||
|
||||
```bash
|
||||
# Make sure all deps are installed
|
||||
npm run install:all
|
||||
|
||||
# Regenerate proto types
|
||||
npm run protos
|
||||
|
||||
# Then rebuild
|
||||
npm run cli:build
|
||||
```
|
||||
|
||||
### "command not found: cline"
|
||||
|
||||
The CLI isn't linked globally. Run:
|
||||
|
||||
```bash
|
||||
npm run cli:link
|
||||
```
|
||||
|
||||
### Changes Not Reflected
|
||||
|
||||
If your code changes aren't showing up:
|
||||
|
||||
1. Make sure watch mode is running (`npm run cli:dev`)
|
||||
2. Check for TypeScript errors in the watch output
|
||||
3. Try unlinking and relinking: `npm run cli:unlink && npm run cli:link`
|
||||
|
||||
### Import Errors from Core
|
||||
|
||||
The CLI imports from `@core/`, `@shared/`, etc. These paths are defined in the root `tsconfig.json`. If you see import errors, make sure you're building from the repo root, not from inside `cli/`.
|
||||
@@ -1,81 +0,0 @@
|
||||
# Cline
|
||||
|
||||
<p align="center">
|
||||
<img src="https://github.com/user-attachments/assets/7123f9d1-afeb-48d5-93fa-e750dec0ebba" width="70%" />
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tbody>
|
||||
<td align="center">
|
||||
<a href="https://www.npmjs.com/package/cline" target="_blank"><strong>NPM</strong></a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://discord.gg/cline" target="_blank"><strong>Discord</strong></a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://www.reddit.com/r/cline/" target="_blank"><strong>r/cline</strong></a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop" target="_blank"><strong>Feature Requests</strong></a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://docs.cline.bot/getting-started/for-new-coders" target="_blank"><strong>Getting Started</strong></a>
|
||||
</td>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
Meet Cline, an AI assistant that lives in your terminal.
|
||||
|
||||
Cline can handle complex software development tasks step-by-step. With tools that let him create & edit files, explore large projects, use the browser, and execute terminal commands (after you grant permission), he can assist you in ways that go beyond code completion or tech support.
|
||||
|
||||
```bash
|
||||
npm i -g cline
|
||||
|
||||
# cd into your project and run:
|
||||
cline
|
||||
```
|
||||
|
||||
> Move your mouse around under the Cline icon for a surprise!
|
||||
|
||||
---
|
||||
|
||||
<img align="right" width="340" src="https://github.com/user-attachments/assets/ceb74224-08aa-4b8b-a3e7-b438ac3d160a">
|
||||
|
||||
### Use any API and Model
|
||||
|
||||
Cline supports API providers like ChatGPT, Anthropic, OpenAI, Google Gemini, AWS Bedrock, Azure, GCP Vertex, Cerebras, Groq, and Moonshot. You can also configure any OpenAI compatible API, or use a local model through LM Studio/Ollama. If you're using a Cline Account, you'll always have access to the newest models as soon as they're available.
|
||||
|
||||
<!-- Transparent pixel to create line break after floating image -->
|
||||
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
<img align="left" width="370" src="https://github.com/user-attachments/assets/cad091f6-6c0f-4e4b-97ea-a1ff67e39b9b">
|
||||
|
||||
### Stay in Control with Human-in-the-Loop
|
||||
|
||||
Cline asks for your approval before running commands, editing files, or taking any action. Review each step and approve or reject as you go—or enable auto-approve to let Cline work autonomously to completion.
|
||||
|
||||
<!-- Transparent pixel to create line break after floating image -->
|
||||
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/cad091f6-6c0f-4e4b-97ea-a1ff67e39b9b"><br>
|
||||
|
||||
<img align="right" width="400" src="https://github.com/user-attachments/assets/4f264a0c-3802-49a7-8e5e-13d97beb659e">
|
||||
|
||||
### Plan & Act Modes
|
||||
|
||||
Toggle to Plan Mode to discuss implementation and architecture with Cline. He'll ask clarifying questions, explore your codebase, and present a plan for you to align on. Once you're satisfied, switch to Act Mode and let Cline execute the plan.
|
||||
|
||||
<!-- Transparent pixel to create line break after floating image -->
|
||||
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/4f264a0c-3802-49a7-8e5e-13d97beb659e"><br>
|
||||
|
||||
|
||||
## Enterprise
|
||||
|
||||
Get the same Cline experience with enterprise-grade controls: SSO (SAML/OIDC), global policies and configuration, observability with audit trails, private networking (VPC/private link), and self-hosted or on-prem deployments, and enterprise support. Learn more at our [enterprise page](https://cline.bot/enterprise) or [talk to us](https://cline.bot/contact-sales).
|
||||
|
||||
## License
|
||||
|
||||
[Apache 2.0 © 2026 Cline Bot Inc.](./LICENSE)
|
||||
@@ -1,21 +0,0 @@
|
||||
# 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"
|
||||
depends_on "ripgrep"
|
||||
|
||||
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
|
||||
-305
@@ -1,305 +0,0 @@
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import dotenv from "dotenv"
|
||||
import * as esbuild from "esbuild"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const rootDir = path.resolve(__dirname, "..")
|
||||
|
||||
// Load .env from repo root
|
||||
dotenv.config({ path: path.join(rootDir, ".env") })
|
||||
|
||||
const production = process.argv.includes("--production")
|
||||
const watch = process.argv.includes("--watch")
|
||||
|
||||
/**
|
||||
* Plugin to resolve path aliases from the parent project
|
||||
*/
|
||||
const aliasResolverPlugin: esbuild.Plugin = {
|
||||
name: "alias-resolver",
|
||||
setup(build) {
|
||||
const aliases = {
|
||||
"@": path.resolve(rootDir, "src"),
|
||||
"@core": path.resolve(rootDir, "src/core"),
|
||||
"@integrations": path.resolve(rootDir, "src/integrations"),
|
||||
"@services": path.resolve(rootDir, "src/services"),
|
||||
"@shared": path.resolve(rootDir, "src/shared"),
|
||||
"@utils": path.resolve(rootDir, "src/utils"),
|
||||
"@packages": path.resolve(rootDir, "src/packages"),
|
||||
"@hosts": path.resolve(rootDir, "src/hosts"),
|
||||
"@generated": path.resolve(rootDir, "src/generated"),
|
||||
"@api": path.resolve(rootDir, "src/core/api"),
|
||||
}
|
||||
|
||||
// For each alias entry, create a resolver
|
||||
Object.entries(aliases).forEach(([alias, aliasPath]) => {
|
||||
const aliasRegex = new RegExp(`^${alias}($|/.*)`)
|
||||
build.onResolve({ filter: aliasRegex }, (args) => {
|
||||
const importPath = args.path.replace(alias, aliasPath)
|
||||
|
||||
// First, check if the path exists as is
|
||||
if (fs.existsSync(importPath)) {
|
||||
const stats = fs.statSync(importPath)
|
||||
if (stats.isDirectory()) {
|
||||
// If it's a directory, try to find index files
|
||||
const extensions = [".ts", ".tsx", ".js", ".jsx"]
|
||||
for (const ext of extensions) {
|
||||
const indexFile = path.join(importPath, `index${ext}`)
|
||||
if (fs.existsSync(indexFile)) {
|
||||
return { path: indexFile }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// It's a file that exists, so return it
|
||||
return { path: importPath }
|
||||
}
|
||||
}
|
||||
|
||||
// If the path doesn't exist, try appending extensions
|
||||
const extensions = [".ts", ".tsx", ".js", ".jsx"]
|
||||
for (const ext of extensions) {
|
||||
const pathWithExtension = `${importPath}${ext}`
|
||||
if (fs.existsSync(pathWithExtension)) {
|
||||
return { path: pathWithExtension }
|
||||
}
|
||||
}
|
||||
|
||||
// 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 }
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin to redirect vscode imports to our shim
|
||||
*/
|
||||
const vscodeStubPlugin: esbuild.Plugin = {
|
||||
name: "vscode-stub",
|
||||
setup(build) {
|
||||
// Redirect 'vscode' imports to our shim
|
||||
build.onResolve({ filter: /^vscode$/ }, () => {
|
||||
return { path: path.join(__dirname, "src", "vscode-shim.ts") }
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const esbuildProblemMatcherPlugin: esbuild.Plugin = {
|
||||
name: "esbuild-problem-matcher",
|
||||
setup(build) {
|
||||
build.onStart(() => {
|
||||
console.log("[cli esbuild] Build started...")
|
||||
})
|
||||
build.onEnd((result) => {
|
||||
result.errors.forEach(({ text, location }) => {
|
||||
console.error(`✘ [ERROR] ${text}`)
|
||||
if (location) {
|
||||
console.error(` ${location.file}:${location.line}:${location.column}:`)
|
||||
}
|
||||
})
|
||||
console.log("[cli esbuild] Build finished")
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// Plugin to stub out optional devtools module
|
||||
const stubOptionalModulesPlugin: esbuild.Plugin = {
|
||||
name: "stub-optional-modules",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^react-devtools-core$/ }, () => {
|
||||
return { path: path.join(__dirname, "src", "stub-devtools.js"), external: false }
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const copyWasmFiles: esbuild.Plugin = {
|
||||
name: "copy-wasm-files",
|
||||
setup(build) {
|
||||
build.onEnd(() => {
|
||||
const destDir = path.join(__dirname, "dist")
|
||||
|
||||
// Ensure dist directory exists
|
||||
if (!fs.existsSync(destDir)) {
|
||||
fs.mkdirSync(destDir, { recursive: true })
|
||||
}
|
||||
|
||||
// tree sitter
|
||||
const sourceDir = path.join(rootDir, "node_modules", "web-tree-sitter")
|
||||
|
||||
// Copy tree-sitter.wasm
|
||||
const treeSitterWasm = path.join(sourceDir, "tree-sitter.wasm")
|
||||
if (fs.existsSync(treeSitterWasm)) {
|
||||
fs.copyFileSync(treeSitterWasm, path.join(destDir, "tree-sitter.wasm"))
|
||||
}
|
||||
|
||||
// Copy language-specific WASM files
|
||||
const languageWasmDir = path.join(rootDir, "node_modules", "tree-sitter-wasms", "out")
|
||||
const languages = [
|
||||
"typescript",
|
||||
"tsx",
|
||||
"python",
|
||||
"rust",
|
||||
"javascript",
|
||||
"go",
|
||||
"cpp",
|
||||
"c",
|
||||
"c_sharp",
|
||||
"ruby",
|
||||
"java",
|
||||
"php",
|
||||
"swift",
|
||||
"kotlin",
|
||||
]
|
||||
|
||||
if (fs.existsSync(languageWasmDir)) {
|
||||
languages.forEach((lang) => {
|
||||
const filename = `tree-sitter-${lang}.wasm`
|
||||
const sourcePath = path.join(languageWasmDir, filename)
|
||||
if (fs.existsSync(sourcePath)) {
|
||||
fs.copyFileSync(sourcePath, path.join(destDir, filename))
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const buildEnvVars: Record<string, string> = {
|
||||
"process.env.IS_STANDALONE": JSON.stringify("true"),
|
||||
"process.env.IS_CLI": JSON.stringify("true"),
|
||||
}
|
||||
|
||||
const buildTimeEnvs = [
|
||||
"TELEMETRY_SERVICE_API_KEY",
|
||||
"ERROR_SERVICE_API_KEY",
|
||||
"ENABLE_ERROR_AUTOCAPTURE",
|
||||
"POSTHOG_TELEMETRY_ENABLED",
|
||||
"OTEL_TELEMETRY_ENABLED",
|
||||
"OTEL_LOGS_EXPORTER",
|
||||
"OTEL_METRICS_EXPORTER",
|
||||
"OTEL_EXPORTER_OTLP_PROTOCOL",
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_HEADERS",
|
||||
"OTEL_METRIC_EXPORT_INTERVAL",
|
||||
"CLINE_ENVIRONMENT",
|
||||
]
|
||||
|
||||
buildTimeEnvs.forEach((envVar) => {
|
||||
if (process.env[envVar]) {
|
||||
console.log(`[cli esbuild] ${envVar} env var is set`)
|
||||
buildEnvVars[`process.env.${envVar}`] = JSON.stringify(process.env[envVar])
|
||||
}
|
||||
})
|
||||
|
||||
if (production) {
|
||||
buildEnvVars["process.env.IS_DEV"] = "false"
|
||||
}
|
||||
|
||||
// Shared build options
|
||||
const sharedOptions: Partial<esbuild.BuildOptions> = {
|
||||
bundle: true,
|
||||
minify: production,
|
||||
sourcemap: !production,
|
||||
logLevel: "silent",
|
||||
define: buildEnvVars,
|
||||
tsconfig: path.join(__dirname, "tsconfig.json"),
|
||||
plugins: [copyWasmFiles, aliasResolverPlugin, vscodeStubPlugin, stubOptionalModulesPlugin, esbuildProblemMatcherPlugin],
|
||||
format: "esm",
|
||||
sourcesContent: false,
|
||||
platform: "node",
|
||||
target: "node20",
|
||||
// These modules need to load files from the module directory at runtime
|
||||
external: [
|
||||
"@grpc/reflection",
|
||||
"grpc-health-check",
|
||||
"better-sqlite3",
|
||||
"ink",
|
||||
"ink-spinner",
|
||||
"ink-picture",
|
||||
"react",
|
||||
"aws4fetch",
|
||||
"pino",
|
||||
"pino-roll",
|
||||
"@vscode/ripgrep", // Uses __dirname to locate the binary
|
||||
],
|
||||
supported: { "top-level-await": true },
|
||||
}
|
||||
|
||||
// CLI executable configuration
|
||||
const cliConfig: esbuild.BuildOptions = {
|
||||
...sharedOptions,
|
||||
entryPoints: [path.join(__dirname, "src", "index.ts")],
|
||||
outfile: path.join(__dirname, "dist", "cli.mjs"),
|
||||
banner: {
|
||||
js: `#!/usr/bin/env node
|
||||
// Suppress all Node.js warnings (deprecation, experimental, etc.)
|
||||
process.emitWarning = () => {};
|
||||
import { createRequire as _createRequire } from 'module';
|
||||
import { fileURLToPath as _fileURLToPath } from 'url';
|
||||
import { dirname as _dirname } from 'path';
|
||||
const require = _createRequire(import.meta.url);
|
||||
const __filename = _fileURLToPath(import.meta.url);
|
||||
const __dirname = _dirname(__filename);`,
|
||||
},
|
||||
}
|
||||
|
||||
// Library configuration for programmatic use
|
||||
const libConfig: esbuild.BuildOptions = {
|
||||
...sharedOptions,
|
||||
entryPoints: [path.join(__dirname, "src", "exports.ts")],
|
||||
outfile: path.join(__dirname, "dist", "lib.mjs"),
|
||||
banner: {
|
||||
js: `// Cline Library - Programmatic API
|
||||
import { createRequire as _createRequire } from 'module';
|
||||
import { fileURLToPath as _fileURLToPath } from 'url';
|
||||
import { dirname as _dirname } from 'path';
|
||||
const require = _createRequire(import.meta.url);
|
||||
const __filename = _fileURLToPath(import.meta.url);
|
||||
const __dirname = _dirname(__filename);`,
|
||||
},
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (watch) {
|
||||
// In watch mode, only watch the CLI (primary use case for development)
|
||||
const ctx = await esbuild.context(cliConfig)
|
||||
await ctx.watch()
|
||||
console.log("[cli] Watching for changes...")
|
||||
} else {
|
||||
// Build both CLI and library
|
||||
console.log("[cli esbuild] Building CLI executable...")
|
||||
const cliCtx = await esbuild.context(cliConfig)
|
||||
await cliCtx.rebuild()
|
||||
await cliCtx.dispose()
|
||||
|
||||
console.log("[cli esbuild] Building library bundle...")
|
||||
const libCtx = await esbuild.context(libConfig)
|
||||
await libCtx.rebuild()
|
||||
await libCtx.dispose()
|
||||
|
||||
// Make the CLI output executable
|
||||
const cliOutfile = path.join(__dirname, "dist", "cli.mjs")
|
||||
if (fs.existsSync(cliOutfile)) {
|
||||
fs.chmodSync(cliOutfile, "755")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
-412
@@ -1,412 +0,0 @@
|
||||
.\" 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
|
||||
.PP
|
||||
\f[B]\-T\f[R], \f[B]\-\-taskId\f[R] \f[I]id\f[R] : Resume an existing
|
||||
task by ID.
|
||||
The prompt argument becomes an optional follow\-up message.
|
||||
.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, moonshot)
|
||||
.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, kimi\-k2.5)
|
||||
.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.
|
||||
.PP
|
||||
\f[B]\-T\f[R], \f[B]\-\-taskId\f[R] \f[I]id\f[R] : Resume an existing
|
||||
task by ID instead of starting a new one.
|
||||
The prompt becomes an optional follow\-up message.
|
||||
.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
|
||||
|
||||
\f[I]# Quick auth setup for Moonshot\f[R]
|
||||
cline auth \-p moonshot \-k sk\-xxxxx \-m kimi\-k2.5
|
||||
.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 Resuming Tasks
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# Resume a task by ID (get IDs from cline history)\f[R]
|
||||
cline \-T abc123def
|
||||
|
||||
\f[I]# Resume a task with a follow\-up message\f[R]
|
||||
cline \-T abc123def \(dqNow add unit tests for the changes\(dq
|
||||
|
||||
\f[I]# Resume in plan mode to review before continuing\f[R]
|
||||
cline \-T abc123def \-p \(dqWhat\(aqs left to do?\(dq
|
||||
|
||||
\f[I]# Resume with yolo mode for automated continuation\f[R]
|
||||
cline \-T abc123def \-y \(dqContinue with the implementation\(dq
|
||||
.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]# Quick setup for Moonshot\f[R]
|
||||
cline auth \-p moonshot \-k sk\-xxxxx \-m kimi\-k2.5
|
||||
|
||||
\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 CONFIGURATION FILES
|
||||
.IP
|
||||
.EX
|
||||
\(ti/.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
|
||||
.EE
|
||||
.PP
|
||||
View logs 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.
|
||||
@@ -1,369 +0,0 @@
|
||||
---
|
||||
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)
|
||||
|
||||
**-t**, **\--timeout** *seconds* : Optional timeout in seconds. Only applied when explicitly provided.
|
||||
|
||||
**-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
|
||||
|
||||
**-T**, **\--taskId** *id* : Resume an existing task by ID. The prompt argument becomes an optional follow-up message.
|
||||
|
||||
## 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.
|
||||
|
||||
**-t**, **\--timeout** *seconds* : Optional timeout in seconds. Only applied when explicitly provided.
|
||||
|
||||
**-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.
|
||||
|
||||
**-T**, **\--taskId** *id* : Resume an existing task by ID instead of starting a new one. The prompt becomes an optional follow-up message.
|
||||
|
||||
**\--continue** : Resume the most recent task from the current working directory instead of starting a new one.
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
## Resuming Tasks
|
||||
|
||||
```bash
|
||||
# Resume a task by ID (get IDs from cline history)
|
||||
cline -T abc123def
|
||||
|
||||
# Resume a task with a follow-up message
|
||||
cline -T abc123def "Now add unit tests for the changes"
|
||||
|
||||
# Resume the most recent task from the current directory
|
||||
cline --continue
|
||||
|
||||
# Resume in plan mode to review before continuing
|
||||
cline -T abc123def -p "What's left to do?"
|
||||
|
||||
# Resume with yolo mode for automated continuation
|
||||
cline -T abc123def -y "Continue with the implementation"
|
||||
```
|
||||
|
||||
## 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.
|
||||
@@ -1,101 +0,0 @@
|
||||
{
|
||||
"name": "cline",
|
||||
"version": "2.18.0",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"main": "dist/lib.mjs",
|
||||
"types": "dist/lib.d.ts",
|
||||
"bin": {
|
||||
"cline": "./dist/cli.mjs"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/lib.mjs",
|
||||
"types": "./dist/lib.d.ts"
|
||||
}
|
||||
},
|
||||
"os": [
|
||||
"darwin",
|
||||
"linux",
|
||||
"win32"
|
||||
],
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
],
|
||||
"man": "./man/cline.1",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"package:brew": "npx tsx ./scripts/update-brew-formula.mts",
|
||||
"package": "npm pack --pack-destination ./dist",
|
||||
"build": "npm run typecheck && npx tsx esbuild.mts && npm run build:types",
|
||||
"build:production": "npm run typecheck && npx tsx esbuild.mts --production && npm run build:types",
|
||||
"build:types": "(npx tsc -p tsconfig.lib.json || true) && cp dist/types/cli/src/exports.d.ts dist/lib.d.ts && mkdir -p dist/agent && cp dist/types/cli/src/agent/ClineAgent.d.ts dist/types/cli/src/agent/ClineSessionEmitter.d.ts dist/types/cli/src/agent/public-types.d.ts dist/agent/ && rm -rf dist/types",
|
||||
"watch": "npx tsx esbuild.mts --watch",
|
||||
"dev": "IS_DEV=true && npm run link && npm run watch ; npm run unlink",
|
||||
"clean": "rimraf dist",
|
||||
"typecheck": "npx tsc --noEmit",
|
||||
"link": "npm run build && npm link",
|
||||
"unlink": "npm unlink -g cline",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"keywords": [
|
||||
"cline",
|
||||
"claude",
|
||||
"dev",
|
||||
"mcp",
|
||||
"openrouter",
|
||||
"coding",
|
||||
"agent",
|
||||
"autonomous",
|
||||
"chatgpt",
|
||||
"sonnet",
|
||||
"ai",
|
||||
"llama",
|
||||
"cli"
|
||||
],
|
||||
"author": {
|
||||
"name": "Cline Bot Inc."
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cline/cline"
|
||||
},
|
||||
"homepage": "https://cline.bot",
|
||||
"bugs": {
|
||||
"url": "https://github.com/cline/cline/issues"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/marked": "^5.0.2",
|
||||
"@types/node": "20.x",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@types/react": "^19.2.9",
|
||||
"dotenv": "^16.4.5",
|
||||
"esbuild": "^0.25.0",
|
||||
"ink-testing-library": "^4.0.0",
|
||||
"rimraf": "^6.0.1",
|
||||
"typescript": "^5.4.5",
|
||||
"vitest": "^4.0.17"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.13.1",
|
||||
"@vscode/ripgrep": "^1.15.9",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.1.0",
|
||||
"ink": "npm:@jrichman/ink@6.4.7",
|
||||
"ink-picture": "^1.3.3",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"marked": "^17.0.3",
|
||||
"nanoid": "^5.1.6",
|
||||
"ora": "^8.0.1",
|
||||
"pino": "^10.0.0",
|
||||
"pino-roll": "^4.0.0",
|
||||
"prompts": "^2.4.2",
|
||||
"react": "^19.2.3"
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
#!/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()
|
||||
@@ -1,248 +0,0 @@
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,414 +0,0 @@
|
||||
/**
|
||||
* 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) {
|
||||
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()
|
||||
}
|
||||
|
||||
async openExternal(request: proto.cline.StringRequest): Promise<proto.cline.Empty> {
|
||||
const url = request.value || ""
|
||||
if (url) {
|
||||
Logger.debug(`[ACPEnvServiceClient] openExternal: ${url}`)
|
||||
const { openUrlInBrowser } = await import("../utils/browser")
|
||||
await openUrlInBrowser(url)
|
||||
}
|
||||
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 })
|
||||
}
|
||||
|
||||
async searchWorkspaceItems(
|
||||
_request: proto.host.SearchWorkspaceItemsRequest,
|
||||
): Promise<proto.host.SearchWorkspaceItemsResponse> {
|
||||
throw new Error("searchWorkspaceItems is not implemented on the ACP host")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
) {
|
||||
this.workspaceClient = new ACPWorkspaceServiceClient(clientCapabilities, sessionIdResolver, cwdResolver)
|
||||
this.envClient = new ACPEnvServiceClient(clientCapabilities, sessionIdResolver, version)
|
||||
this.windowClient = new ACPWindowServiceClient(clientCapabilities, sessionIdResolver)
|
||||
this.diffClient = new ACPDiffServiceClient()
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
/**
|
||||
* 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, type 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) => {
|
||||
try {
|
||||
Logger.debug("[AcpAgent] Forwarding permission request to connection")
|
||||
return await this.connection.requestPermission({
|
||||
sessionId: request.sessionId,
|
||||
toolCall: request.toolCall,
|
||||
options: request.options,
|
||||
})
|
||||
} catch (error) {
|
||||
Logger.debug("[AcpAgent] Error requesting permission:", error)
|
||||
return { outcome: { outcome: "cancelled" } }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
@@ -1,141 +0,0 @@
|
||||
/**
|
||||
* 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 { 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"
|
||||
export type {
|
||||
AcpAgentOptions,
|
||||
AcpSessionState,
|
||||
ClineAgentOptions,
|
||||
ClineSessionEvents,
|
||||
PermissionHandler,
|
||||
} 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
|
||||
/** Additional runtime hooks directory */
|
||||
hooksDir?: 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, {
|
||||
debug: Boolean(options.verbose),
|
||||
hooksDir: options.hooksDir,
|
||||
})
|
||||
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")
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* 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
@@ -1,274 +0,0 @@
|
||||
/**
|
||||
* 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,114 +0,0 @@
|
||||
/**
|
||||
* 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 "./public-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
@@ -1,356 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
/**
|
||||
* Public types for the Cline library API.
|
||||
*
|
||||
* This file contains types that are safe to export to library consumers.
|
||||
* It must NOT import any internal types (Controller, StateManager, etc.)
|
||||
* to keep the generated declaration files clean.
|
||||
*
|
||||
* Internal-only extensions of these types live in ./types.ts.
|
||||
*/
|
||||
|
||||
import type * as acp from "@agentclientprotocol/sdk"
|
||||
|
||||
// ============================================================
|
||||
// Session Update Type Utilities
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Different types of updates that can be sent during session processing.
|
||||
*
|
||||
* These updates provide real-time feedback about the agent's progress.
|
||||
*
|
||||
* See protocol docs: [Agent Reports Output](https://agentclientprotocol.com/protocol/prompt-turn#3-agent-reports-output)
|
||||
*/
|
||||
export type SessionUpdateType = acp.SessionUpdate["sessionUpdate"]
|
||||
|
||||
/**
|
||||
* Different types of update payloads that can be sent during session processing.
|
||||
*
|
||||
* Each update type has a corresponding payload structure defined in the ACP SessionUpdate union.
|
||||
*/
|
||||
export type SessionUpdatePayload<T extends SessionUpdateType> = Omit<
|
||||
Extract<acp.SessionUpdate, { sessionUpdate: T }>,
|
||||
"sessionUpdate"
|
||||
>
|
||||
|
||||
// ============================================================
|
||||
// Permission Handler Callback Types
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 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: acp.RequestPermissionRequest) => Promise<acp.RequestPermissionResponse>
|
||||
|
||||
// ============================================================
|
||||
// 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
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Options for creating a ClineAgent instance.
|
||||
*/
|
||||
export interface ClineAgentOptions {
|
||||
/** Whether debug logging is enabled */
|
||||
debug?: boolean
|
||||
/** Cline Config Directory (defaults to ~/.cline) */
|
||||
clineDir?: string
|
||||
/** Additional runtime hooks directory */
|
||||
hooksDir?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for creating an ACP agent instance.
|
||||
*/
|
||||
export interface AcpAgentOptions {
|
||||
/** Whether debug logging is enabled */
|
||||
debug?: boolean
|
||||
/** Additional runtime hooks directory */
|
||||
hooksDir?: string
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Session Types
|
||||
// ============================================================
|
||||
export type SessionID = string
|
||||
|
||||
/**
|
||||
* Extended session data stored by Cline for ACP sessions.
|
||||
*/
|
||||
export interface ClineAcpSession {
|
||||
/** Unique session ID */
|
||||
sessionId: SessionID
|
||||
/** 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
|
||||
/** Model ID override for plan mode (format: "provider/modelId") */
|
||||
planModeModelId?: string
|
||||
/** Model ID override for act mode (format: "provider/modelId") */
|
||||
actModeModelId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle status of an ACP session.
|
||||
*
|
||||
* Represents the state machine:
|
||||
* Idle → Processing → Idle (normal completion)
|
||||
* Idle → Processing → Cancelled (cancellation, then back to Idle on next prompt)
|
||||
*/
|
||||
export enum AcpSessionStatus {
|
||||
/** Session is idle, waiting for a prompt */
|
||||
Idle = "idle",
|
||||
/** Session is actively processing a prompt */
|
||||
Processing = "processing",
|
||||
/** Session processing was cancelled */
|
||||
Cancelled = "cancelled",
|
||||
}
|
||||
|
||||
/**
|
||||
* State tracking for an active ACP session within Cline.
|
||||
*/
|
||||
export interface AcpSessionState {
|
||||
/** Session ID */
|
||||
sessionId: SessionID
|
||||
/** Current lifecycle status of the session */
|
||||
status: AcpSessionStatus
|
||||
/** Current tool call ID being executed (if any) */
|
||||
currentToolCallId?: string
|
||||
/** Accumulated tool calls for permission batching */
|
||||
pendingToolCalls: Map<string, acp.ToolCall>
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Agent Capabilities
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Permission Options
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Permission option as presented to the ACP client.
|
||||
*/
|
||||
export interface ClinePermissionOption {
|
||||
kind: acp.PermissionOptionKind
|
||||
name: string
|
||||
optionId: string
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Message Translation
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Re-exported ACP Types
|
||||
// ============================================================
|
||||
|
||||
export type {
|
||||
Agent,
|
||||
AgentSideConnection,
|
||||
AudioContent,
|
||||
CancelNotification,
|
||||
ClientCapabilities,
|
||||
ContentBlock,
|
||||
ImageContent,
|
||||
InitializeRequest,
|
||||
InitializeResponse,
|
||||
LoadSessionRequest,
|
||||
LoadSessionResponse,
|
||||
McpServer,
|
||||
ModelInfo,
|
||||
NewSessionRequest,
|
||||
NewSessionResponse,
|
||||
PermissionOption,
|
||||
PermissionOptionKind,
|
||||
PromptRequest,
|
||||
PromptResponse,
|
||||
RequestPermissionRequest,
|
||||
RequestPermissionResponse,
|
||||
SessionConfigOption,
|
||||
SessionModelState,
|
||||
SessionNotification,
|
||||
SessionUpdate,
|
||||
SetSessionConfigOptionRequest,
|
||||
SetSessionConfigOptionResponse,
|
||||
SetSessionModelRequest,
|
||||
SetSessionModelResponse,
|
||||
SetSessionModeRequest,
|
||||
SetSessionModeResponse,
|
||||
StopReason,
|
||||
TextContent,
|
||||
ToolCall,
|
||||
ToolCallStatus,
|
||||
ToolCallUpdate,
|
||||
ToolKind,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
@@ -1,68 +0,0 @@
|
||||
/**
|
||||
* Internal types for ACP integration with Cline CLI.
|
||||
*
|
||||
* This file re-exports all public types from ./public-types.ts and adds
|
||||
* internal-only Types that reference core modules (Controller, etc.).
|
||||
*
|
||||
* Library consumers should never import from this file directly — they
|
||||
* get the public types via the library entrypoint (exports.ts).
|
||||
*/
|
||||
|
||||
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"
|
||||
|
||||
export type {
|
||||
AcpAgentOptions,
|
||||
AcpSessionState,
|
||||
ClineAgentCapabilities,
|
||||
ClineAgentInfo,
|
||||
ClineAgentOptions,
|
||||
ClinePermissionOption,
|
||||
ClineSessionEvents,
|
||||
PermissionHandler,
|
||||
SessionUpdatePayload,
|
||||
SessionUpdateType,
|
||||
TranslatedMessage,
|
||||
} from "./public-types.js"
|
||||
|
||||
export { AcpSessionStatus } from "./public-types.js"
|
||||
@@ -1,194 +0,0 @@
|
||||
/**
|
||||
* Account info view component
|
||||
* Shows current provider, and for Cline provider: credit balance and organization name
|
||||
*/
|
||||
|
||||
import { Box, Text } from "ink"
|
||||
import React, { useCallback, useEffect, useState } from "react"
|
||||
import { Controller } from "@/core/controller"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { ClineAccountService } from "@/services/account/ClineAccountService"
|
||||
import { AuthService, ClineAccountOrganization } from "@/services/auth/AuthService"
|
||||
import { LoadingSpinner } from "./Spinner"
|
||||
|
||||
interface AccountInfoViewProps {
|
||||
controller: Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Capitalize provider name for display
|
||||
*/
|
||||
function capitalize(str: string): string {
|
||||
return str
|
||||
.split("-")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/**
|
||||
* Format balance as currency (balance is in microcredits, divide by 10000)
|
||||
*/
|
||||
function formatBalance(balance: number | null): string {
|
||||
if (balance === null || balance === undefined) {
|
||||
return "..."
|
||||
}
|
||||
return `$${(balance / 1000000).toFixed(2)}`
|
||||
}
|
||||
|
||||
export const AccountInfoView: React.FC<AccountInfoViewProps> = React.memo(({ controller }) => {
|
||||
const [provider, setProvider] = useState<string | null>(null)
|
||||
const [balance, setBalance] = useState<number | null>(null)
|
||||
const [organization, setOrganization] = useState<ClineAccountOrganization | null>(null)
|
||||
const [email, setEmail] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchAccountInfo = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
// Get current provider from state
|
||||
const stateManager = StateManager.get()
|
||||
const mode = stateManager.getGlobalSettingsKey("mode") as string
|
||||
const providerKey = mode === "act" ? "actModeApiProvider" : "planModeApiProvider"
|
||||
const currentProvider = stateManager.getGlobalSettingsKey(providerKey) as string
|
||||
setProvider(currentProvider || "cline")
|
||||
|
||||
// If using Cline provider, fetch additional info
|
||||
if (currentProvider === "cline") {
|
||||
const authService = AuthService.getInstance(controller)
|
||||
|
||||
// Wait for auth to be restored - poll until we have auth info or timeout
|
||||
let authInfo = authService.getInfo()
|
||||
let attempts = 0
|
||||
const maxAttempts = 20 // 2 seconds max
|
||||
while (!authInfo?.user?.uid && attempts < maxAttempts) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
authInfo = authService.getInfo()
|
||||
attempts++
|
||||
}
|
||||
|
||||
// Get user info
|
||||
if (authInfo?.user?.email) {
|
||||
setEmail(authInfo.user.email)
|
||||
} else {
|
||||
// User not logged in to Cline
|
||||
setEmail(null)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Get organization info
|
||||
const organizations = authService.getUserOrganizations()
|
||||
if (organizations) {
|
||||
const activeOrg = organizations.find((org) => org.active)
|
||||
if (activeOrg) {
|
||||
setOrganization(activeOrg)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch credit balance
|
||||
try {
|
||||
const accountService = ClineAccountService.getInstance()
|
||||
const activeOrgId = authService.getActiveOrganizationId()
|
||||
|
||||
if (activeOrgId) {
|
||||
// Fetch organization balance
|
||||
const orgBalance = await accountService.fetchOrganizationCreditsRPC(activeOrgId)
|
||||
if (orgBalance?.balance !== undefined) {
|
||||
setBalance(orgBalance.balance)
|
||||
}
|
||||
} else {
|
||||
// Fetch personal balance
|
||||
const balanceData = await accountService.fetchBalanceRPC()
|
||||
if (balanceData?.balance !== undefined) {
|
||||
setBalance(balanceData.balance)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Balance fetch failed, but we can still show other info
|
||||
// Don't log to console as it pollutes CLI output
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load account info")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [controller])
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccountInfo()
|
||||
}, [fetchAccountInfo])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box>
|
||||
<LoadingSpinner />
|
||||
<Text color="gray"> Loading account info...</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color="red">Error: {error}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// If not using Cline provider, just show the provider name
|
||||
if (provider !== "cline") {
|
||||
return (
|
||||
<Box>
|
||||
<Text color="gray">Provider: </Text>
|
||||
<Text color="cyan">{capitalize(provider || "Not configured")}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Cline provider but not logged in
|
||||
if (!email) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color="gray">Provider: </Text>
|
||||
<Text color="cyan">Cline</Text>
|
||||
<Text color="gray"> • </Text>
|
||||
<Text color="yellow">Not logged in (run 'cline auth' to sign in)</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Cline provider - show full account info
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text color="gray">Provider: </Text>
|
||||
<Text color="cyan">Cline</Text>
|
||||
{email && (
|
||||
<Box>
|
||||
<Text color="gray"> • </Text>
|
||||
<Text color="white">{email}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
{organization ? (
|
||||
<Box>
|
||||
<Text color="gray">Organization: </Text>
|
||||
<Text color="magenta">{organization.name}</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
<Text color="gray">Account: </Text>
|
||||
<Text color="white">Personal</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Text color="gray"> • Credits: </Text>
|
||||
<Text color="green">{formatBalance(balance)}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
@@ -1,337 +0,0 @@
|
||||
/**
|
||||
* Action buttons component for CLI
|
||||
* Shows primary/secondary buttons above the input field
|
||||
* Supports keyboard navigation (1/2 for buttons, arrows to navigate, esc to cancel)
|
||||
*/
|
||||
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { Box, Text } from "ink"
|
||||
import React from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useTerminalSize } from "../hooks/useTerminalSize"
|
||||
import { isFileSaveTool, parseToolFromMessage } from "../utils/tools"
|
||||
|
||||
/**
|
||||
* Button action types that determine the behavior
|
||||
*/
|
||||
export type ButtonActionType =
|
||||
| "approve" // Send yesButtonClicked
|
||||
| "reject" // Send noButtonClicked
|
||||
| "proceed" // Send messageResponse or yesButtonClicked
|
||||
| "new_task" // Start a new task
|
||||
| "cancel" // Cancel streaming
|
||||
| "retry" // Retry the last action
|
||||
|
||||
/**
|
||||
* Button configuration for different message states
|
||||
*/
|
||||
export interface ButtonConfig {
|
||||
sendingDisabled: boolean
|
||||
enableButtons: boolean
|
||||
primaryText?: string
|
||||
secondaryText?: string
|
||||
primaryAction?: ButtonActionType
|
||||
secondaryAction?: ButtonActionType
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized button state configurations based on task lifecycle
|
||||
*/
|
||||
const BUTTON_CONFIGS: Record<string, ButtonConfig> = {
|
||||
// Error recovery states
|
||||
api_req_failed: {
|
||||
sendingDisabled: true,
|
||||
enableButtons: true,
|
||||
primaryText: "Retry",
|
||||
secondaryText: "Start New Task",
|
||||
primaryAction: "retry",
|
||||
secondaryAction: "new_task",
|
||||
},
|
||||
mistake_limit_reached: {
|
||||
sendingDisabled: false,
|
||||
enableButtons: true,
|
||||
primaryText: "Proceed Anyways",
|
||||
secondaryText: "Start New Task",
|
||||
primaryAction: "proceed",
|
||||
secondaryAction: "new_task",
|
||||
},
|
||||
|
||||
// Tool approval states
|
||||
tool_approve: {
|
||||
sendingDisabled: false,
|
||||
enableButtons: true,
|
||||
primaryText: "Approve",
|
||||
secondaryText: "Reject",
|
||||
primaryAction: "approve",
|
||||
secondaryAction: "reject",
|
||||
},
|
||||
tool_save: {
|
||||
sendingDisabled: false,
|
||||
enableButtons: true,
|
||||
primaryText: "Save",
|
||||
secondaryText: "Reject",
|
||||
primaryAction: "approve",
|
||||
secondaryAction: "reject",
|
||||
},
|
||||
|
||||
// Command execution states
|
||||
command: {
|
||||
sendingDisabled: false,
|
||||
enableButtons: true,
|
||||
primaryText: "Run Command",
|
||||
secondaryText: "Reject",
|
||||
primaryAction: "approve",
|
||||
secondaryAction: "reject",
|
||||
},
|
||||
command_output: {
|
||||
sendingDisabled: false,
|
||||
enableButtons: true,
|
||||
primaryText: "Proceed While Running",
|
||||
secondaryText: undefined,
|
||||
primaryAction: "proceed",
|
||||
secondaryAction: undefined,
|
||||
},
|
||||
|
||||
// Browser and external tool states
|
||||
browser_action_launch: {
|
||||
sendingDisabled: false,
|
||||
enableButtons: true,
|
||||
primaryText: "Approve",
|
||||
secondaryText: "Reject",
|
||||
primaryAction: "approve",
|
||||
secondaryAction: "reject",
|
||||
},
|
||||
use_mcp_server: {
|
||||
sendingDisabled: false,
|
||||
enableButtons: true,
|
||||
primaryText: "Approve",
|
||||
secondaryText: "Reject",
|
||||
primaryAction: "approve",
|
||||
secondaryAction: "reject",
|
||||
},
|
||||
followup: {
|
||||
sendingDisabled: false,
|
||||
enableButtons: false,
|
||||
primaryText: undefined,
|
||||
secondaryText: undefined,
|
||||
primaryAction: undefined,
|
||||
secondaryAction: undefined,
|
||||
},
|
||||
plan_mode_respond: {
|
||||
sendingDisabled: false,
|
||||
enableButtons: false,
|
||||
primaryText: undefined,
|
||||
secondaryText: undefined,
|
||||
primaryAction: undefined,
|
||||
secondaryAction: undefined,
|
||||
},
|
||||
|
||||
// Task lifecycle states
|
||||
completion_result: {
|
||||
sendingDisabled: false,
|
||||
enableButtons: true,
|
||||
primaryText: "Start New Task",
|
||||
secondaryText: "Exit",
|
||||
primaryAction: "new_task",
|
||||
secondaryAction: "reject",
|
||||
},
|
||||
resume_task: {
|
||||
sendingDisabled: false,
|
||||
enableButtons: true,
|
||||
primaryText: "Resume Task",
|
||||
secondaryText: "Exit",
|
||||
primaryAction: "proceed",
|
||||
secondaryAction: "reject",
|
||||
},
|
||||
resume_completed_task: {
|
||||
sendingDisabled: false,
|
||||
enableButtons: true,
|
||||
primaryText: "Start New Task",
|
||||
secondaryText: "Exit",
|
||||
primaryAction: "new_task",
|
||||
secondaryAction: "reject",
|
||||
},
|
||||
new_task: {
|
||||
sendingDisabled: false,
|
||||
enableButtons: true,
|
||||
primaryText: "Start New Task with Context",
|
||||
secondaryText: "Exit",
|
||||
primaryAction: "new_task",
|
||||
secondaryAction: "reject",
|
||||
},
|
||||
|
||||
// Streaming/partial states
|
||||
partial: {
|
||||
sendingDisabled: true,
|
||||
enableButtons: true,
|
||||
primaryText: undefined,
|
||||
secondaryText: "Cancel",
|
||||
primaryAction: undefined,
|
||||
secondaryAction: "cancel",
|
||||
},
|
||||
|
||||
// Default states
|
||||
default: {
|
||||
sendingDisabled: false,
|
||||
enableButtons: false,
|
||||
primaryText: undefined,
|
||||
secondaryText: undefined,
|
||||
primaryAction: undefined,
|
||||
secondaryAction: undefined,
|
||||
},
|
||||
api_req_active: {
|
||||
sendingDisabled: true,
|
||||
enableButtons: true,
|
||||
primaryText: undefined,
|
||||
secondaryText: "Cancel",
|
||||
primaryAction: undefined,
|
||||
secondaryAction: "cancel",
|
||||
},
|
||||
}
|
||||
|
||||
const errorTypes = ["api_req_failed", "mistake_limit_reached"]
|
||||
|
||||
/**
|
||||
* Get button configuration based on message type and state
|
||||
*/
|
||||
export function getButtonConfig(message: ClineMessage | undefined, isStreaming: boolean = false): ButtonConfig {
|
||||
if (!message) {
|
||||
return BUTTON_CONFIGS.default
|
||||
}
|
||||
|
||||
const isError = message?.ask ? errorTypes.includes(message.ask) : false
|
||||
|
||||
// Special case: command_output should show "Proceed While Running" button even while streaming
|
||||
if (message.type === "ask" && message.ask === "command_output") {
|
||||
return BUTTON_CONFIGS.command_output
|
||||
}
|
||||
|
||||
// Handle partial/streaming messages first
|
||||
if (isStreaming && !isError) {
|
||||
return BUTTON_CONFIGS.partial
|
||||
}
|
||||
|
||||
// Handle ask messages (user interaction required)
|
||||
if (message.type === "ask") {
|
||||
switch (message.ask) {
|
||||
// Error recovery states
|
||||
case "api_req_failed":
|
||||
return BUTTON_CONFIGS.api_req_failed
|
||||
case "mistake_limit_reached":
|
||||
return BUTTON_CONFIGS.mistake_limit_reached
|
||||
|
||||
// Tool approval (most common)
|
||||
case "tool": {
|
||||
const toolInfo = parseToolFromMessage(message.text)
|
||||
if (toolInfo && isFileSaveTool(toolInfo.toolName)) {
|
||||
return BUTTON_CONFIGS.tool_save
|
||||
}
|
||||
return BUTTON_CONFIGS.tool_approve
|
||||
}
|
||||
|
||||
// Command execution
|
||||
case "command":
|
||||
return BUTTON_CONFIGS.command
|
||||
case "command_output":
|
||||
return BUTTON_CONFIGS.command_output
|
||||
|
||||
// Standard approvals
|
||||
case "followup":
|
||||
return BUTTON_CONFIGS.followup
|
||||
case "browser_action_launch":
|
||||
return BUTTON_CONFIGS.browser_action_launch
|
||||
case "use_mcp_server":
|
||||
return BUTTON_CONFIGS.use_mcp_server
|
||||
case "plan_mode_respond":
|
||||
return BUTTON_CONFIGS.plan_mode_respond
|
||||
|
||||
// Task lifecycle
|
||||
case "completion_result":
|
||||
return BUTTON_CONFIGS.completion_result
|
||||
case "resume_task":
|
||||
return BUTTON_CONFIGS.resume_task
|
||||
case "resume_completed_task":
|
||||
return BUTTON_CONFIGS.resume_completed_task
|
||||
case "new_task":
|
||||
return BUTTON_CONFIGS.new_task
|
||||
|
||||
default:
|
||||
return BUTTON_CONFIGS.tool_approve
|
||||
}
|
||||
}
|
||||
|
||||
// Handle say messages
|
||||
if (message.type === "say" && message.say === "api_req_started") {
|
||||
return BUTTON_CONFIGS.api_req_active
|
||||
}
|
||||
|
||||
if (message.type === "say" && message.say === "command_output") {
|
||||
return BUTTON_CONFIGS.command_output
|
||||
}
|
||||
|
||||
return BUTTON_CONFIGS.partial
|
||||
}
|
||||
|
||||
interface ActionButtonsProps {
|
||||
config: ButtonConfig
|
||||
mode?: "act" | "plan"
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine which buttons are actually visible based on config
|
||||
* Cancel is hidden in the CLI (ThinkingIndicator handles that with esc)
|
||||
*/
|
||||
export function getVisibleButtons(config: ButtonConfig) {
|
||||
const hiddenActions = ["cancel"]
|
||||
const hasPrimary = !!config.primaryText && !hiddenActions.includes(config.primaryAction || "")
|
||||
const hasSecondary = !!config.secondaryText && !hiddenActions.includes(config.secondaryAction || "")
|
||||
return { hasPrimary, hasSecondary }
|
||||
}
|
||||
|
||||
/**
|
||||
* Action buttons component
|
||||
* Shows primary and/or secondary buttons based on config
|
||||
* Buttons take full width (one button = full, two buttons = half each)
|
||||
* Does not show cancel-only buttons (ThinkingIndicator handles that with esc)
|
||||
*/
|
||||
export const ActionButtons: React.FC<ActionButtonsProps> = ({ config, mode = "act" }) => {
|
||||
if (!config.enableButtons) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { hasPrimary, hasSecondary } = getVisibleButtons(config)
|
||||
|
||||
if (!hasPrimary && !hasSecondary) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Calculate button widths based on terminal width
|
||||
const { columns: terminalWidth } = useTerminalSize()
|
||||
const buttonCount = (hasPrimary ? 1 : 0) + (hasSecondary ? 1 : 0)
|
||||
const gapWidth = buttonCount > 1 ? 1 : 0 // 1 char gap between buttons
|
||||
const availableWidth = terminalWidth - 2 - gapWidth // 1 space padding on each side
|
||||
const buttonWidth = Math.floor(availableWidth / buttonCount)
|
||||
|
||||
const modeColor = mode === "plan" ? "yellow" : COLORS.primaryBlue
|
||||
|
||||
const renderButton = (text: string, shortcut: string) => {
|
||||
const label = ` ${text} (${shortcut}) `
|
||||
const padding = Math.max(0, buttonWidth - label.length)
|
||||
const leftPad = Math.floor(padding / 2)
|
||||
const rightPad = padding - leftPad
|
||||
const paddedLabel = " ".repeat(leftPad) + label + " ".repeat(rightPad)
|
||||
|
||||
return (
|
||||
<Text backgroundColor={modeColor} color="black">
|
||||
{paddedLabel}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" gap={1} marginLeft={1} width="100%">
|
||||
{hasPrimary && renderButton(config.primaryText!, "1")}
|
||||
{hasSecondary && renderButton(config.secondaryText!, hasPrimary ? "2" : "1")}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
/**
|
||||
* Reusable API key input component
|
||||
* Shows a password-masked input field for entering API keys
|
||||
*/
|
||||
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { isEnterKey, isMouseEscapeSequence } from "../utils/input"
|
||||
|
||||
interface ApiKeyInputProps {
|
||||
providerName: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onSubmit: (value: string) => void
|
||||
onCancel: () => void
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
|
||||
providerName,
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
isActive = true,
|
||||
}) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
// Filter out mouse escape sequences
|
||||
if (isMouseEscapeSequence(input)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (key.escape) {
|
||||
onCancel()
|
||||
return
|
||||
}
|
||||
if (isEnterKey(input, key)) {
|
||||
onSubmit(value)
|
||||
return
|
||||
}
|
||||
if (key.backspace || key.delete) {
|
||||
onChange(value.slice(0, -1))
|
||||
return
|
||||
}
|
||||
if (input && !key.ctrl && !key.meta) {
|
||||
onChange(value + input)
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported && isActive },
|
||||
)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={COLORS.primaryBlue}>
|
||||
{providerName} API Key
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">Paste your API key below</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color="white">{"•".repeat(value.length)}</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">Enter to save, Esc to cancel</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
import { Text } from "ink"
|
||||
import { render } from "ink-testing-library"
|
||||
import React from "react"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { App } from "./App"
|
||||
|
||||
const CLEAR_SEQUENCE = "\x1b[2J\x1b[3J\x1b[H"
|
||||
|
||||
function setTerminalSize(columns: number, rows: number) {
|
||||
Object.defineProperty(process.stdout, "columns", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: columns,
|
||||
})
|
||||
|
||||
Object.defineProperty(process.stdout, "rows", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: rows,
|
||||
})
|
||||
}
|
||||
|
||||
function hasClearSequenceCall(calls: unknown[][]): boolean {
|
||||
return calls.some((call) => call[0] === CLEAR_SEQUENCE)
|
||||
}
|
||||
|
||||
vi.mock("./ChatView", () => ({
|
||||
ChatView: ({ controller, initialPrompt, initialImages }: any) => {
|
||||
React.useEffect(() => {
|
||||
if (initialPrompt || (initialImages && initialImages.length > 0)) {
|
||||
controller?.initTask(initialPrompt || "", initialImages)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return React.createElement(Text, null, "ChatView")
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("./TaskJsonView", () => ({
|
||||
TaskJsonView: () => React.createElement(Text, null, "TaskJsonView"),
|
||||
}))
|
||||
|
||||
vi.mock("./HistoryView", () => ({
|
||||
HistoryView: () => React.createElement(Text, null, "HistoryView"),
|
||||
}))
|
||||
|
||||
vi.mock("./ConfigView", () => ({
|
||||
ConfigView: () => React.createElement(Text, null, "ConfigView"),
|
||||
}))
|
||||
|
||||
vi.mock("./AuthView", () => ({
|
||||
AuthView: () => React.createElement(Text, null, "AuthView"),
|
||||
}))
|
||||
|
||||
vi.mock("../context/TaskContext", () => ({
|
||||
TaskContextProvider: ({ children }: any) => children,
|
||||
}))
|
||||
|
||||
vi.mock("../context/StdinContext", () => ({
|
||||
StdinProvider: ({ children }: any) => children,
|
||||
}))
|
||||
|
||||
describe("App startup prompt resize behavior", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
delete (process.stdout as any).columns
|
||||
delete (process.stdout as any).rows
|
||||
})
|
||||
|
||||
it("does not replay initialPrompt after a width resize", async () => {
|
||||
const initTask = vi.fn()
|
||||
setTerminalSize(120, 40)
|
||||
|
||||
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((...args: any[]) => {
|
||||
const callback = args.find((arg) => typeof arg === "function")
|
||||
if (callback) {
|
||||
callback()
|
||||
}
|
||||
return true
|
||||
}) as any)
|
||||
|
||||
const { unmount } = render(
|
||||
<App controller={{ initTask }} initialPrompt="hello" isRawModeSupported={true} view="welcome" />,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(initTask).toHaveBeenCalledTimes(1)
|
||||
writeSpy.mockClear()
|
||||
|
||||
setTerminalSize(121, 40)
|
||||
process.stdout.emit("resize")
|
||||
await vi.advanceTimersByTimeAsync(350)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(initTask).toHaveBeenCalledTimes(1)
|
||||
expect(hasClearSequenceCall(writeSpy.mock.calls as unknown[][])).toBe(true)
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it("does not remount on height-only resize", async () => {
|
||||
const initTask = vi.fn()
|
||||
setTerminalSize(120, 40)
|
||||
|
||||
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((...args: any[]) => {
|
||||
const callback = args.find((arg) => typeof arg === "function")
|
||||
if (callback) {
|
||||
callback()
|
||||
}
|
||||
return true
|
||||
}) as any)
|
||||
|
||||
const { unmount } = render(
|
||||
<App controller={{ initTask }} initialPrompt="hello" isRawModeSupported={true} view="welcome" />,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(initTask).toHaveBeenCalledTimes(1)
|
||||
writeSpy.mockClear()
|
||||
|
||||
setTerminalSize(120, 45)
|
||||
process.stdout.emit("resize")
|
||||
await vi.advanceTimersByTimeAsync(350)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(initTask).toHaveBeenCalledTimes(1)
|
||||
expect(hasClearSequenceCall(writeSpy.mock.calls as unknown[][])).toBe(false)
|
||||
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
@@ -1,125 +0,0 @@
|
||||
import { Text } from "ink"
|
||||
import { render } from "ink-testing-library"
|
||||
import React from "react"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { App } from "./App"
|
||||
|
||||
// Mock the child components to isolate App routing logic
|
||||
vi.mock("./ChatView", () => ({
|
||||
ChatView: ({ taskId, controller }: any) =>
|
||||
React.createElement(Text, null, `ChatView: ${taskId || "no-id"} controller=${controller ? "present" : "none"}`),
|
||||
}))
|
||||
|
||||
vi.mock("./TaskJsonView", () => ({
|
||||
TaskJsonView: ({ taskId, verbose }: any) =>
|
||||
React.createElement(Text, null, `TaskJsonView: ${taskId || "no-id"} verbose=${String(verbose)}`),
|
||||
}))
|
||||
|
||||
vi.mock("./HistoryView", () => ({
|
||||
HistoryView: ({ items }: any) => React.createElement(Text, null, `HistoryView: ${items?.length || 0} items`),
|
||||
}))
|
||||
|
||||
vi.mock("./ConfigView", () => ({
|
||||
ConfigView: ({ dataDir }: any) => React.createElement(Text, null, `ConfigView: ${dataDir}`),
|
||||
}))
|
||||
|
||||
vi.mock("./AuthView", () => ({
|
||||
AuthView: ({ quickSetup }: any) => React.createElement(Text, null, `AuthView: ${quickSetup?.provider || "no-provider"}`),
|
||||
}))
|
||||
|
||||
vi.mock("../context/TaskContext", () => ({
|
||||
TaskContextProvider: ({ children }: any) => children,
|
||||
}))
|
||||
|
||||
vi.mock("../context/StdinContext", () => ({
|
||||
StdinProvider: ({ children }: any) => children,
|
||||
}))
|
||||
|
||||
// Mock useTerminalSize to prevent EventEmitter memory leak warnings from resize listeners
|
||||
vi.mock("../hooks/useTerminalSize", () => ({
|
||||
useTerminalSize: () => ({ columns: 80, rows: 24, resizeKey: 0 }),
|
||||
}))
|
||||
|
||||
describe("App", () => {
|
||||
const mockController = {
|
||||
dispose: vi.fn(),
|
||||
stateManager: { flushPendingState: vi.fn() },
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("view routing", () => {
|
||||
it("should render ChatView when view is task", () => {
|
||||
const { lastFrame } = render(<App controller={mockController} taskId="test-task" view="task" />)
|
||||
expect(lastFrame()).toContain("ChatView")
|
||||
expect(lastFrame()).toContain("test-task")
|
||||
})
|
||||
|
||||
it("should render TaskJsonView when view is task with jsonOutput", () => {
|
||||
const { lastFrame } = render(<App controller={mockController} jsonOutput={true} taskId="test-task" view="task" />)
|
||||
expect(lastFrame()).toContain("TaskJsonView")
|
||||
expect(lastFrame()).toContain("test-task")
|
||||
})
|
||||
|
||||
it("should render HistoryView when view is history", () => {
|
||||
const historyItems = [
|
||||
{ id: "1", ts: Date.now(), task: "Task 1" },
|
||||
{ id: "2", ts: Date.now(), task: "Task 2" },
|
||||
]
|
||||
const { lastFrame } = render(<App controller={mockController} historyItems={historyItems} view="history" />)
|
||||
expect(lastFrame()).toContain("HistoryView")
|
||||
expect(lastFrame()).toContain("2 items")
|
||||
})
|
||||
|
||||
it("should render ConfigView when view is config", () => {
|
||||
const { lastFrame } = render(
|
||||
<App dataDir="/path/to/config" globalState={{ key: "value" }} view="config" workspaceState={{}} />,
|
||||
)
|
||||
expect(lastFrame()).toContain("ConfigView")
|
||||
expect(lastFrame()).toContain("/path/to/config")
|
||||
})
|
||||
|
||||
it("should render AuthView when view is auth", () => {
|
||||
const { lastFrame } = render(<App controller={mockController} view="auth" />)
|
||||
expect(lastFrame()).toContain("AuthView")
|
||||
})
|
||||
|
||||
it("should render ChatView when view is welcome", () => {
|
||||
const { lastFrame } = render(
|
||||
<App controller={mockController} onWelcomeExit={() => {}} onWelcomeSubmit={() => {}} view="welcome" />,
|
||||
)
|
||||
expect(lastFrame()).toContain("ChatView")
|
||||
})
|
||||
})
|
||||
|
||||
describe("default props", () => {
|
||||
it("should use default verbose=false with jsonOutput", () => {
|
||||
const { lastFrame } = render(<App controller={mockController} jsonOutput={true} view="task" />)
|
||||
expect(lastFrame()).toContain("verbose=false")
|
||||
})
|
||||
|
||||
it("should use empty array for historyItems by default", () => {
|
||||
const { lastFrame } = render(<App controller={mockController} view="history" />)
|
||||
expect(lastFrame()).toContain("0 items")
|
||||
})
|
||||
})
|
||||
|
||||
describe("props passing", () => {
|
||||
it("should pass verbose to TaskJsonView", () => {
|
||||
const { lastFrame } = render(<App controller={mockController} jsonOutput={true} verbose={true} view="task" />)
|
||||
expect(lastFrame()).toContain("verbose=true")
|
||||
})
|
||||
|
||||
it("should pass taskId to ChatView", () => {
|
||||
const { lastFrame } = render(<App controller={mockController} taskId="my-task-123" view="task" />)
|
||||
expect(lastFrame()).toContain("my-task-123")
|
||||
})
|
||||
|
||||
it("should pass controller to ChatView", () => {
|
||||
const { lastFrame } = render(<App controller={mockController} view="task" />)
|
||||
expect(lastFrame()).toContain("controller=present")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,288 +0,0 @@
|
||||
/**
|
||||
* Main App component for Ink CLI
|
||||
* Routes between different views (task, history, config)
|
||||
*/
|
||||
|
||||
import { Box, useApp } from "ink"
|
||||
import React, { ReactNode, useCallback, useEffect, useState } from "react"
|
||||
import { StdinProvider } from "../context/StdinContext"
|
||||
import { TaskContextProvider } from "../context/TaskContext"
|
||||
import { useTerminalSize } from "../hooks/useTerminalSize"
|
||||
import { AuthView } from "./AuthView"
|
||||
import { ChatView } from "./ChatView"
|
||||
import { ConfigView } from "./ConfigView"
|
||||
import { ErrorBoundary } from "./ErrorBoundary"
|
||||
import { HistoryView } from "./HistoryView"
|
||||
import { TaskJsonView } from "./TaskJsonView"
|
||||
|
||||
export type ViewType = "task" | "history" | "config" | "auth" | "welcome"
|
||||
|
||||
interface HistoryPagination {
|
||||
page: number
|
||||
totalPages: number
|
||||
totalCount: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
interface HookInfo {
|
||||
name: string
|
||||
enabled: boolean
|
||||
absolutePath: string
|
||||
}
|
||||
|
||||
interface WorkspaceHooks {
|
||||
workspaceName: string
|
||||
hooks: HookInfo[]
|
||||
}
|
||||
|
||||
interface SkillInfo {
|
||||
name: string
|
||||
description: string
|
||||
path: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface AppProps {
|
||||
view: ViewType
|
||||
taskId?: string
|
||||
controller?: any
|
||||
// Output Style
|
||||
verbose?: boolean
|
||||
jsonOutput?: boolean
|
||||
// Status Callbacks
|
||||
onComplete?: () => void
|
||||
onError?: () => void
|
||||
// For history view
|
||||
historyItems?: Array<{ id: string; ts: number; task?: string; totalCost?: number; modelId?: string }>
|
||||
historyAllItems?: Array<{ id: string; ts: number; task?: string; totalCost?: number; modelId?: string }>
|
||||
historyPagination?: HistoryPagination
|
||||
onHistoryPageChange?: (page: number) => void
|
||||
// For config view
|
||||
dataDir?: string
|
||||
globalState?: Record<string, any>
|
||||
workspaceState?: Record<string, any>
|
||||
// Rules toggles
|
||||
globalClineRulesToggles?: Record<string, boolean>
|
||||
localClineRulesToggles?: Record<string, boolean>
|
||||
localCursorRulesToggles?: Record<string, boolean>
|
||||
localWindsurfRulesToggles?: Record<string, boolean>
|
||||
localAgentsRulesToggles?: Record<string, boolean>
|
||||
onToggleRule?: (isGlobal: boolean, rulePath: string, enabled: boolean, ruleType: string) => void
|
||||
// Workflow toggles
|
||||
globalWorkflowToggles?: Record<string, boolean>
|
||||
localWorkflowToggles?: Record<string, boolean>
|
||||
onToggleWorkflow?: (isGlobal: boolean, workflowPath: string, enabled: boolean) => void
|
||||
// Hooks
|
||||
hooksEnabled?: boolean
|
||||
globalHooks?: HookInfo[]
|
||||
workspaceHooks?: WorkspaceHooks[]
|
||||
onToggleHook?: (isGlobal: boolean, hookName: string, enabled: boolean, workspaceName?: string) => void
|
||||
// Skills
|
||||
skillsEnabled?: boolean
|
||||
globalSkills?: SkillInfo[]
|
||||
localSkills?: SkillInfo[]
|
||||
onToggleSkill?: (isGlobal: boolean, skillPath: string, enabled: boolean) => void
|
||||
// For welcome view
|
||||
onWelcomeSubmit?: (prompt: string, imagePaths: string[]) => void
|
||||
onWelcomeExit?: () => void
|
||||
initialPrompt?: string
|
||||
initialImages?: string[]
|
||||
// Stdin support
|
||||
isRawModeSupported?: boolean
|
||||
}
|
||||
|
||||
export const App: React.FC<AppProps> = (props) => {
|
||||
const { exit } = useApp()
|
||||
|
||||
return (
|
||||
<ErrorBoundary exit={exit}>
|
||||
<InternalApp {...props} />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
const InternalApp: React.FC<AppProps> = ({
|
||||
view: initialView,
|
||||
taskId,
|
||||
verbose = false,
|
||||
jsonOutput = false,
|
||||
controller,
|
||||
onComplete,
|
||||
onError,
|
||||
historyItems = [],
|
||||
historyAllItems,
|
||||
historyPagination,
|
||||
onHistoryPageChange,
|
||||
dataDir = "",
|
||||
globalState = {},
|
||||
workspaceState = {},
|
||||
// Rules
|
||||
globalClineRulesToggles,
|
||||
localClineRulesToggles,
|
||||
localCursorRulesToggles,
|
||||
localWindsurfRulesToggles,
|
||||
localAgentsRulesToggles,
|
||||
onToggleRule,
|
||||
// Workflows
|
||||
globalWorkflowToggles,
|
||||
localWorkflowToggles,
|
||||
onToggleWorkflow,
|
||||
// Hooks
|
||||
hooksEnabled,
|
||||
globalHooks,
|
||||
workspaceHooks,
|
||||
onToggleHook,
|
||||
// Skills
|
||||
skillsEnabled,
|
||||
globalSkills,
|
||||
localSkills,
|
||||
onToggleSkill,
|
||||
onWelcomeSubmit,
|
||||
onWelcomeExit,
|
||||
initialPrompt,
|
||||
initialImages,
|
||||
isRawModeSupported = true,
|
||||
}) => {
|
||||
const { resizeKey } = useTerminalSize()
|
||||
const [currentView, setCurrentView] = useState<ViewType>(initialView)
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | undefined>(taskId)
|
||||
const [pendingInitialPrompt, setPendingInitialPrompt] = useState<string | undefined>(initialPrompt)
|
||||
const [pendingInitialImages, setPendingInitialImages] = useState<string[] | undefined>(initialImages)
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingInitialPrompt && (!pendingInitialImages || pendingInitialImages.length === 0)) {
|
||||
return
|
||||
}
|
||||
|
||||
setPendingInitialPrompt(undefined)
|
||||
setPendingInitialImages(undefined)
|
||||
}, [pendingInitialPrompt, pendingInitialImages])
|
||||
|
||||
const handleSelectTask = useCallback((taskId: string) => {
|
||||
setSelectedTaskId(taskId)
|
||||
setCurrentView("task")
|
||||
}, [])
|
||||
|
||||
const handleNavigateToWelcome = useCallback(() => {
|
||||
setCurrentView("welcome")
|
||||
}, [])
|
||||
|
||||
// Handle welcome submit when navigating internally (e.g., from auth -> welcome)
|
||||
const _handleInternalWelcomeSubmit = useCallback(
|
||||
async (prompt: string, imagePaths: string[]) => {
|
||||
if (onWelcomeSubmit) {
|
||||
// If external handler provided, use it
|
||||
onWelcomeSubmit(prompt, imagePaths)
|
||||
} else if (controller && prompt.trim()) {
|
||||
// Otherwise, start a task directly via controller
|
||||
setCurrentView("task")
|
||||
// Convert image paths to data URLs if needed
|
||||
const imageDataUrls =
|
||||
imagePaths.length > 0
|
||||
? await Promise.all(
|
||||
imagePaths.map(async (p) => {
|
||||
try {
|
||||
const fs = await import("fs/promises")
|
||||
const path = await import("path")
|
||||
const data = await fs.readFile(p)
|
||||
const ext = path.extname(p).toLowerCase().slice(1)
|
||||
const mimeType = ext === "jpg" ? "jpeg" : ext
|
||||
return `data:image/${mimeType};base64,${data.toString("base64")}`
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}),
|
||||
)
|
||||
: []
|
||||
const validImages = imageDataUrls.filter((img): img is string => img !== null)
|
||||
await controller.initTask(prompt.trim(), validImages.length > 0 ? validImages : undefined)
|
||||
}
|
||||
},
|
||||
[onWelcomeSubmit, controller],
|
||||
)
|
||||
|
||||
let content: ReactNode
|
||||
|
||||
switch (currentView) {
|
||||
case "history":
|
||||
content = (
|
||||
<HistoryView
|
||||
allItems={historyAllItems}
|
||||
controller={controller}
|
||||
items={historyItems}
|
||||
onPageChange={onHistoryPageChange}
|
||||
onSelectTask={handleSelectTask}
|
||||
pagination={historyPagination}
|
||||
/>
|
||||
)
|
||||
break
|
||||
|
||||
case "config":
|
||||
content = (
|
||||
<ConfigView
|
||||
dataDir={dataDir}
|
||||
globalClineRulesToggles={globalClineRulesToggles}
|
||||
globalHooks={globalHooks}
|
||||
globalSkills={globalSkills}
|
||||
globalState={globalState}
|
||||
globalWorkflowToggles={globalWorkflowToggles}
|
||||
hooksEnabled={hooksEnabled}
|
||||
localAgentsRulesToggles={localAgentsRulesToggles}
|
||||
localClineRulesToggles={localClineRulesToggles}
|
||||
localCursorRulesToggles={localCursorRulesToggles}
|
||||
localSkills={localSkills}
|
||||
localWindsurfRulesToggles={localWindsurfRulesToggles}
|
||||
localWorkflowToggles={localWorkflowToggles}
|
||||
onToggleHook={onToggleHook}
|
||||
onToggleRule={onToggleRule}
|
||||
onToggleSkill={onToggleSkill}
|
||||
onToggleWorkflow={onToggleWorkflow}
|
||||
skillsEnabled={skillsEnabled}
|
||||
workspaceHooks={workspaceHooks}
|
||||
workspaceState={workspaceState}
|
||||
/>
|
||||
)
|
||||
break
|
||||
|
||||
case "auth":
|
||||
content = (
|
||||
<AuthView
|
||||
controller={controller}
|
||||
onComplete={onComplete}
|
||||
onError={onError}
|
||||
onNavigateToWelcome={handleNavigateToWelcome}
|
||||
/>
|
||||
)
|
||||
break
|
||||
|
||||
case "task":
|
||||
case "welcome":
|
||||
content = (
|
||||
<TaskContextProvider controller={controller}>
|
||||
{jsonOutput ? (
|
||||
<TaskJsonView onComplete={onComplete} onError={onError} taskId={selectedTaskId} verbose={verbose} />
|
||||
) : (
|
||||
<ChatView
|
||||
controller={controller}
|
||||
initialImages={pendingInitialImages}
|
||||
initialPrompt={pendingInitialPrompt}
|
||||
onComplete={onComplete}
|
||||
onError={onError}
|
||||
onExit={onWelcomeExit}
|
||||
taskId={selectedTaskId}
|
||||
/>
|
||||
)}
|
||||
</TaskContextProvider>
|
||||
)
|
||||
break
|
||||
|
||||
default:
|
||||
content = null
|
||||
}
|
||||
|
||||
return (
|
||||
<StdinProvider isRawModeSupported={isRawModeSupported}>
|
||||
<Box key={resizeKey}>{content}</Box>
|
||||
</StdinProvider>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,442 +0,0 @@
|
||||
/**
|
||||
* User input prompt component
|
||||
* Handles different types of user interactions (text input, confirmations, choices)
|
||||
*/
|
||||
|
||||
import type { ClineAsk, ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { Box, Text, useApp, useInput } from "ink"
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { useTaskController } from "../context/TaskContext"
|
||||
import { useLastCompletedAskMessage } from "../hooks/useStateSubscriber"
|
||||
import { isEnterKey, isMouseEscapeSequence } from "../utils/input"
|
||||
import { jsonParseSafe } from "../utils/parser"
|
||||
|
||||
interface AskPromptProps {
|
||||
onRespond?: (response: string) => void
|
||||
}
|
||||
|
||||
type PromptType = "confirmation" | "text" | "options" | "plan_mode_text" | "completion" | "exit_confirmation" | "none"
|
||||
|
||||
function getPromptType(ask: ClineAsk, text: string): PromptType {
|
||||
switch (ask) {
|
||||
case "followup": {
|
||||
const parts = jsonParseSafe(text, {
|
||||
question: undefined as string | undefined,
|
||||
options: undefined as string[] | undefined,
|
||||
})
|
||||
if (parts.options && parts.options.length > 0) {
|
||||
return "options"
|
||||
}
|
||||
return "text"
|
||||
}
|
||||
case "plan_mode_respond": {
|
||||
const parts = jsonParseSafe(text, {
|
||||
question: undefined as string | undefined,
|
||||
options: undefined as string[] | undefined,
|
||||
})
|
||||
if (parts.options && parts.options.length > 0) {
|
||||
return "options"
|
||||
}
|
||||
// Plan mode without options - allow text input or toggle to Act mode
|
||||
return "plan_mode_text"
|
||||
}
|
||||
case "completion_result":
|
||||
// Task completed - allow follow-up question or exit
|
||||
return "completion"
|
||||
|
||||
case "resume_task":
|
||||
case "resume_completed_task":
|
||||
return "exit_confirmation"
|
||||
|
||||
case "command":
|
||||
case "tool":
|
||||
case "browser_action_launch":
|
||||
case "use_mcp_server":
|
||||
return "confirmation"
|
||||
default:
|
||||
return "none"
|
||||
}
|
||||
}
|
||||
|
||||
export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
|
||||
const { exit } = useApp()
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const controller = useTaskController()
|
||||
const lastAskMessage = useLastCompletedAskMessage()
|
||||
const [textInput, setTextInput] = useState("")
|
||||
const [responded, setResponded] = useState(false)
|
||||
const lastAskTs = useRef<number | null>(null)
|
||||
|
||||
// Reset state when ask message changes
|
||||
useEffect(() => {
|
||||
if (lastAskMessage && lastAskMessage.ts !== lastAskTs.current) {
|
||||
lastAskTs.current = lastAskMessage.ts
|
||||
setTextInput("")
|
||||
setResponded(false)
|
||||
}
|
||||
}, [lastAskMessage])
|
||||
|
||||
const sendResponse = useCallback(
|
||||
async (responseType: string, text?: string) => {
|
||||
if (responded || !controller?.task) {
|
||||
return
|
||||
}
|
||||
setResponded(true)
|
||||
try {
|
||||
await controller.task.handleWebviewAskResponse(responseType, text)
|
||||
onRespond?.(text || responseType)
|
||||
} catch {
|
||||
// Controller may be disposed
|
||||
}
|
||||
},
|
||||
[controller, responded, onRespond],
|
||||
)
|
||||
|
||||
const toggleToActMode = useCallback(async () => {
|
||||
if (responded || !controller) {
|
||||
return
|
||||
}
|
||||
setResponded(true)
|
||||
try {
|
||||
await controller.togglePlanActMode("act")
|
||||
onRespond?.("Switched to Act mode")
|
||||
} catch {
|
||||
// Controller may be disposed
|
||||
}
|
||||
}, [controller, responded, onRespond])
|
||||
|
||||
// Handle keyboard input
|
||||
useInput(
|
||||
(input, key) => {
|
||||
// Filter out mouse escape sequences
|
||||
if (isMouseEscapeSequence(input)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!lastAskMessage || responded) {
|
||||
return
|
||||
}
|
||||
|
||||
const ask = lastAskMessage.ask as ClineAsk
|
||||
const text = lastAskMessage.text || ""
|
||||
const promptType = getPromptType(ask, text)
|
||||
|
||||
if (promptType === "confirmation" || promptType === "exit_confirmation") {
|
||||
// y/n confirmation
|
||||
if (input.toLowerCase() === "y") {
|
||||
sendResponse("yesButtonClicked")
|
||||
} else if (input.toLowerCase() === "n") {
|
||||
if (promptType === "exit_confirmation") {
|
||||
exit()
|
||||
return
|
||||
}
|
||||
sendResponse("noButtonClicked")
|
||||
}
|
||||
} else if (promptType === "options") {
|
||||
// Number selection for options, or free text input
|
||||
const parts = jsonParseSafe(text, { options: [] as string[] })
|
||||
if (isEnterKey(input, key)) {
|
||||
// Submit free text on Enter
|
||||
if (textInput.trim()) {
|
||||
sendResponse("messageResponse", textInput.trim())
|
||||
}
|
||||
} else if (key.backspace || key.delete) {
|
||||
setTextInput((prev) => prev.slice(0, -1))
|
||||
} else if (input && !key.ctrl && !key.meta) {
|
||||
// Check if it's a number for option selection (only when no text typed yet)
|
||||
const num = Number.parseInt(input, 10)
|
||||
if (textInput === "" && !Number.isNaN(num) && num >= 1 && num <= parts.options.length) {
|
||||
const selectedOption = parts.options[num - 1]
|
||||
sendResponse("messageResponse", selectedOption)
|
||||
} else {
|
||||
// Regular character input for free text
|
||||
setTextInput((prev) => prev + input)
|
||||
}
|
||||
}
|
||||
} else if (promptType === "text") {
|
||||
// Text input mode
|
||||
if (isEnterKey(input, key)) {
|
||||
// Submit on Enter
|
||||
if (textInput.trim()) {
|
||||
sendResponse("messageResponse", textInput.trim())
|
||||
}
|
||||
} else if (key.backspace || key.delete) {
|
||||
setTextInput((prev) => prev.slice(0, -1))
|
||||
} else if (input && !key.ctrl && !key.meta) {
|
||||
// Regular character input
|
||||
setTextInput((prev) => prev + input)
|
||||
}
|
||||
} else if (promptType === "plan_mode_text") {
|
||||
// Plan mode text input - allows text response or toggle to Act mode
|
||||
if (isEnterKey(input, key)) {
|
||||
// Submit on Enter
|
||||
if (textInput.trim()) {
|
||||
sendResponse("messageResponse", textInput.trim())
|
||||
} else {
|
||||
// Empty enter = switch to Act mode
|
||||
toggleToActMode()
|
||||
}
|
||||
} else if (key.backspace || key.delete) {
|
||||
setTextInput((prev) => prev.slice(0, -1))
|
||||
} else if (input && !key.ctrl && !key.meta) {
|
||||
// Regular character input
|
||||
setTextInput((prev) => prev + input)
|
||||
}
|
||||
} else if (promptType === "completion") {
|
||||
// Task completed - allow follow-up question or exit
|
||||
if (isEnterKey(input, key)) {
|
||||
if (textInput.trim()) {
|
||||
// Send follow-up question
|
||||
sendResponse("messageResponse", textInput.trim())
|
||||
} else {
|
||||
// Empty enter = confirm completion (exit)
|
||||
sendResponse("yesButtonClicked")
|
||||
}
|
||||
} else if (key.backspace || key.delete) {
|
||||
setTextInput((prev) => prev.slice(0, -1))
|
||||
} else if (input && !key.ctrl && !key.meta) {
|
||||
// Regular character input
|
||||
setTextInput((prev) => prev + input)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported && !!lastAskMessage && !responded },
|
||||
)
|
||||
|
||||
if (!lastAskMessage || responded) {
|
||||
return null
|
||||
}
|
||||
|
||||
const ask = lastAskMessage.ask as ClineAsk
|
||||
const text = lastAskMessage.text || ""
|
||||
const promptType = getPromptType(ask, text)
|
||||
const icon = getCliMessagePrefixIcon(lastAskMessage)
|
||||
|
||||
if (promptType === "none") {
|
||||
return null
|
||||
}
|
||||
|
||||
switch (ask) {
|
||||
case "followup": {
|
||||
const parts = jsonParseSafe(text, {
|
||||
question: undefined as string | undefined,
|
||||
options: undefined as string[] | undefined,
|
||||
})
|
||||
|
||||
if (parts.options && parts.options.length > 0) {
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color="cyan">Select an option (enter number):</Text>
|
||||
{parts.options.map((opt, idx) => (
|
||||
<Box key={idx} marginLeft={2}>
|
||||
<Text>{`${idx + 1}. ${opt}`}</Text>
|
||||
</Box>
|
||||
))}
|
||||
<Box marginTop={1}>
|
||||
<Text>{icon} </Text>
|
||||
<Text color="cyan">Or type: </Text>
|
||||
<Text>{textInput}</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
<Text color="gray">(Enter number to select, or type response + Enter)</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Text input prompt
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text>{icon} </Text>
|
||||
<Text color="cyan">Reply: </Text>
|
||||
<Text>{textInput}</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
<Text color="gray">(Type your response and press Enter)</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
case "plan_mode_respond": {
|
||||
const parts = jsonParseSafe(text, {
|
||||
question: undefined as string | undefined,
|
||||
options: undefined as string[] | undefined,
|
||||
})
|
||||
|
||||
if (parts.options && parts.options.length > 0) {
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color="cyan">Select an option (enter number):</Text>
|
||||
{parts.options.map((opt, idx) => (
|
||||
<Box key={idx} marginLeft={2}>
|
||||
<Text>{`${idx + 1}. ${opt}`}</Text>
|
||||
</Box>
|
||||
))}
|
||||
<Box marginTop={1}>
|
||||
<Text>{icon} </Text>
|
||||
<Text color="cyan">Or type: </Text>
|
||||
<Text>{textInput}</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
<Text color="gray">(Enter number to select, or type response + Enter)</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Plan mode text input - show option to switch to Act mode
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text>{icon} </Text>
|
||||
<Text color="cyan">Reply: </Text>
|
||||
<Text>{textInput}</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
<Text color="gray">(Type response + Enter, or just Enter to switch to Act mode)</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
case "command":
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text>{icon} </Text>
|
||||
<Text color="yellow"> Execute this command? </Text>
|
||||
<Text color="gray">(y/n)</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
case "tool":
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text>{icon} </Text>
|
||||
<Text color="blue"> Use this tool? </Text>
|
||||
<Text color="gray">(y/n)</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
case "completion_result":
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text>{icon} </Text>
|
||||
<Text color="cyan">Follow-up: </Text>
|
||||
<Text>{textInput}</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
<Text color="gray">(Type follow-up question + Enter, or q to exit)</Text>
|
||||
</Box>
|
||||
)
|
||||
|
||||
case "resume_task":
|
||||
case "resume_completed_task":
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text>{icon} </Text>
|
||||
<Text color="cyan"> Resume task? </Text>
|
||||
<Text color="gray">(y/n)</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
case "browser_action_launch":
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text>{icon} </Text>
|
||||
<Text color="cyan"> Launch browser? </Text>
|
||||
<Text color="gray">(y/n)</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
case "use_mcp_server":
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text>{icon} </Text>
|
||||
<Text color="cyan"> Use MCP server? </Text>
|
||||
<Text color="gray">(y/n)</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get emoji icon for message type
|
||||
*/
|
||||
function getCliMessagePrefixIcon(message: ClineMessage): string {
|
||||
if (message.type === "ask") {
|
||||
switch (message.ask) {
|
||||
case "followup":
|
||||
return "❓"
|
||||
case "command":
|
||||
case "command_output":
|
||||
return "⚙️"
|
||||
case "tool":
|
||||
return "🔧"
|
||||
case "completion_result":
|
||||
return "✅"
|
||||
case "api_req_failed":
|
||||
return "❌"
|
||||
case "resume_task":
|
||||
case "resume_completed_task":
|
||||
return "▶️"
|
||||
case "browser_action_launch":
|
||||
return "🌐"
|
||||
case "use_mcp_server":
|
||||
return "🔌"
|
||||
case "plan_mode_respond":
|
||||
return "📋"
|
||||
default:
|
||||
return "❔"
|
||||
}
|
||||
}
|
||||
switch (message.say) {
|
||||
case "task":
|
||||
return "📋"
|
||||
case "error":
|
||||
return "❌"
|
||||
case "text":
|
||||
return "💬"
|
||||
case "reasoning":
|
||||
return "🧠"
|
||||
case "completion_result":
|
||||
return "✅"
|
||||
case "user_feedback":
|
||||
return "👤"
|
||||
case "command":
|
||||
case "command_output":
|
||||
return "⚙️"
|
||||
case "tool":
|
||||
return "🔧"
|
||||
case "browser_action":
|
||||
case "browser_action_launch":
|
||||
case "browser_action_result":
|
||||
return "🌐"
|
||||
case "mcp_server_request_started":
|
||||
case "mcp_server_response":
|
||||
return "🔌"
|
||||
case "api_req_started":
|
||||
case "api_req_finished":
|
||||
return "🔄"
|
||||
case "checkpoint_created":
|
||||
return "💾"
|
||||
case "info":
|
||||
return "ℹ️"
|
||||
case "generate_explanation":
|
||||
return "📝"
|
||||
default:
|
||||
return " "
|
||||
}
|
||||
}
|
||||
@@ -1,942 +0,0 @@
|
||||
/**
|
||||
* Auth view component
|
||||
* Handles interactive authentication and provider configuration
|
||||
*/
|
||||
|
||||
import { Box, Text, useApp, useInput } from "ink"
|
||||
import Spinner from "ink-spinner"
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { refreshOcaModels } from "@/core/controller/models/refreshOcaModels"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
|
||||
import { AuthService } from "@/services/auth/AuthService"
|
||||
import { openAiCodexDefaultModelId, openRouterDefaultModelId } from "@/shared/api"
|
||||
import { StringRequest } from "@/shared/proto/cline/common"
|
||||
import { openExternal } from "@/utils/env"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { useClineFeaturedModels } from "../hooks/useClineFeaturedModels"
|
||||
import { useOcaAuth } from "../hooks/useOcaAuth"
|
||||
import { useScrollableList } from "../hooks/useScrollableList"
|
||||
import { type DetectedSources, detectImportSources, type ImportSource } from "../utils/import-configs"
|
||||
import { isEnterKey, isMouseEscapeSequence } from "../utils/input"
|
||||
import { applyBedrockConfig, applyProviderConfig } from "../utils/provider-config"
|
||||
import { useValidProviders } from "../utils/providers"
|
||||
import { ApiKeyInput } from "./ApiKeyInput"
|
||||
import { StaticRobotFrame } from "./AsciiMotionCli"
|
||||
import { BedrockCustomModelFlow } from "./BedrockCustomModelFlow"
|
||||
import { type BedrockConfig, BedrockSetup } from "./BedrockSetup"
|
||||
import {
|
||||
FeaturedModelPicker,
|
||||
getFeaturedModelAtIndex,
|
||||
getFeaturedModelMaxIndex,
|
||||
isBrowseAllSelected,
|
||||
} from "./FeaturedModelPicker"
|
||||
import { ImportView } from "./ImportView"
|
||||
import { CUSTOM_MODEL_ID, getDefaultModelId, hasModelPicker, ModelPicker } from "./ModelPicker"
|
||||
import { OcaEmployeeCheck } from "./OcaEmployeeCheck"
|
||||
import { getProviderLabel } from "./ProviderPicker"
|
||||
|
||||
type AuthStep =
|
||||
| "menu"
|
||||
| "provider"
|
||||
| "apikey"
|
||||
| "modelid"
|
||||
| "baseurl"
|
||||
| "saving"
|
||||
| "success"
|
||||
| "error"
|
||||
| "cline_auth"
|
||||
| "oca_employee_check"
|
||||
| "oca_auth"
|
||||
| "cline_model"
|
||||
| "openai_codex_auth"
|
||||
| "bedrock"
|
||||
| "import"
|
||||
| "bedrock_custom"
|
||||
|
||||
interface AuthViewProps {
|
||||
controller: any
|
||||
onComplete?: () => void
|
||||
onError?: () => void
|
||||
onNavigateToWelcome?: () => void
|
||||
}
|
||||
|
||||
interface SelectItem {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Select component with keyboard navigation
|
||||
*/
|
||||
const Select: React.FC<{
|
||||
items: SelectItem[]
|
||||
onSelect: (value: string) => void
|
||||
label?: string
|
||||
}> = ({ items, onSelect, label }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1))
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0))
|
||||
} else if (isEnterKey(input, key)) {
|
||||
onSelect(items[selectedIndex].value)
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported },
|
||||
)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{label && (
|
||||
<Text bold color="cyan">
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
{items.map((item, index) => (
|
||||
<Box key={item.value}>
|
||||
<Text color={index === selectedIndex ? COLORS.primaryBlue : undefined}>
|
||||
{index === selectedIndex ? "❯ " : " "}
|
||||
{item.label}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
<Text color="gray">(Use arrow keys to navigate, Enter to select)</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Text input component - minimal, just the input field
|
||||
*/
|
||||
const TextInput: React.FC<{
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onSubmit: (value: string) => void
|
||||
placeholder?: string
|
||||
isPassword?: boolean
|
||||
}> = ({ value, onChange, onSubmit, placeholder, isPassword }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
// Filter out mouse escape sequences
|
||||
if (isMouseEscapeSequence(input)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isEnterKey(input, key)) {
|
||||
onSubmit(value)
|
||||
} else if (key.backspace || key.delete) {
|
||||
onChange(value.slice(0, -1))
|
||||
} else if (input && !key.ctrl && !key.meta) {
|
||||
onChange(value + input)
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported },
|
||||
)
|
||||
|
||||
const displayValue = isPassword ? "•".repeat(value.length) : value
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{!displayValue && placeholder ? (
|
||||
<Text color="gray">e.g. {placeholder}</Text>
|
||||
) : (
|
||||
<Text color="white">{displayValue || ""}</Text>
|
||||
)}
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onError, onNavigateToWelcome }) => {
|
||||
const { exit } = useApp()
|
||||
|
||||
const providers = useValidProviders()
|
||||
|
||||
const [step, setStep] = useState<AuthStep>("menu")
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>(
|
||||
StateManager.get().getApiConfiguration().actModeApiProvider ||
|
||||
StateManager.get().getApiConfiguration().planModeApiProvider ||
|
||||
"",
|
||||
)
|
||||
const [apiKey, setApiKey] = useState("")
|
||||
const [modelId, setModelId] = useState("")
|
||||
const [baseUrl, setBaseUrl] = useState("")
|
||||
const [errorMessage, setErrorMessage] = useState("")
|
||||
const [providerSearch, setProviderSearch] = useState("")
|
||||
const [providerIndex, setProviderIndex] = useState(0)
|
||||
const [clineModelIndex, setClineModelIndex] = useState(0)
|
||||
const featuredModels = useClineFeaturedModels()
|
||||
const [importSources, setImportSources] = useState<DetectedSources>({ codex: false, opencode: false })
|
||||
const [importSource, setImportSource] = useState<ImportSource | null>(null)
|
||||
const [bedrockConfig, setBedrockConfig] = useState<BedrockConfig | null>(null)
|
||||
|
||||
// OCA auth hook - enabled when step is oca_auth
|
||||
const handleOcaAuthSuccess = useCallback(async () => {
|
||||
await applyProviderConfig({ providerId: "oca", controller })
|
||||
// Fetch OCA models from the API - this sets actModeOcaModelId/planModeOcaModelId in state
|
||||
await refreshOcaModels(controller, StringRequest.create({ value: "" }))
|
||||
const stateManager = StateManager.get()
|
||||
stateManager.setGlobalState("welcomeViewCompleted", true)
|
||||
await stateManager.flushPendingState()
|
||||
setSelectedProvider("oca")
|
||||
const actModelId = stateManager.getGlobalSettingsKey("actModeOcaModelId") || ""
|
||||
setModelId(actModelId)
|
||||
setStep("success")
|
||||
}, [controller])
|
||||
|
||||
const handleOcaAuthError = useCallback((error: Error) => {
|
||||
setErrorMessage(error.message)
|
||||
setStep("error")
|
||||
}, [])
|
||||
|
||||
const { startAuth: initiateOcaAuth } = useOcaAuth({
|
||||
controller,
|
||||
enabled: step === "oca_auth",
|
||||
onSuccess: handleOcaAuthSuccess,
|
||||
onError: handleOcaAuthError,
|
||||
})
|
||||
|
||||
// Main menu items - conditionally include import options
|
||||
const mainMenuItems: SelectItem[] = useMemo(() => {
|
||||
const items: SelectItem[] = [{ label: "Sign in with Cline", value: "cline_auth" }]
|
||||
|
||||
// Add OpenAI Codex option for ChatGPT subscribers
|
||||
items.push({ label: "Sign in with ChatGPT Subscription", value: "openai_codex_auth" })
|
||||
|
||||
// Add import options if detected
|
||||
if (importSources.codex) {
|
||||
items.push({ label: "Import from Codex CLI", value: "import_codex" })
|
||||
}
|
||||
if (importSources.opencode) {
|
||||
items.push({ label: "Import from OpenCode", value: "import_opencode" })
|
||||
}
|
||||
|
||||
items.push({ label: "Use your own API key", value: "configure_byo" })
|
||||
items.push({ label: "Exit", value: "exit" })
|
||||
|
||||
return items
|
||||
}, [importSources])
|
||||
|
||||
// Provider menu items - filtered by search (searches both ID and display name)
|
||||
const providerItems: SelectItem[] = useMemo(() => {
|
||||
const search = providerSearch.toLowerCase()
|
||||
const filtered = providerSearch
|
||||
? providers.filter((p) => p.toLowerCase().includes(search) || getProviderLabel(p).toLowerCase().includes(search))
|
||||
: providers
|
||||
return filtered.map((p: string) => ({
|
||||
label: getProviderLabel(p),
|
||||
value: p,
|
||||
}))
|
||||
}, [providers, providerSearch])
|
||||
|
||||
// Use shared scrollable list hook for provider windowing
|
||||
const TOTAL_PROVIDER_ROWS = 8
|
||||
const {
|
||||
visibleStart: providerVisibleStart,
|
||||
visibleCount: providerVisibleCount,
|
||||
showTopIndicator: showProviderTopIndicator,
|
||||
showBottomIndicator: showProviderBottomIndicator,
|
||||
} = useScrollableList(providerItems.length, providerIndex, TOTAL_PROVIDER_ROWS)
|
||||
|
||||
const visibleProviderItems = useMemo(() => {
|
||||
return providerItems.slice(providerVisibleStart, providerVisibleStart + providerVisibleCount)
|
||||
}, [providerItems, providerVisibleStart, providerVisibleCount])
|
||||
|
||||
// Detect import sources on mount
|
||||
useEffect(() => {
|
||||
setImportSources(detectImportSources())
|
||||
}, [])
|
||||
|
||||
// Reset provider index when search changes
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to reset here
|
||||
useEffect(() => {
|
||||
setProviderIndex(0)
|
||||
}, [providerSearch])
|
||||
|
||||
// Set default model when entering model step
|
||||
useEffect(() => {
|
||||
if (step === "modelid" && hasModelPicker(selectedProvider)) {
|
||||
setModelId(getDefaultModelId(selectedProvider))
|
||||
}
|
||||
}, [step, selectedProvider])
|
||||
|
||||
// Subscribe to auth status updates when in cline_auth step
|
||||
useEffect(() => {
|
||||
if (step !== "cline_auth") {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
// Create a streaming response handler that receives auth state updates
|
||||
const responseHandler = async (authState: { user?: { email?: string } }, _isLast?: boolean) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (authState.user?.email) {
|
||||
// Auth succeeded - save configuration and transition to model selection
|
||||
await applyProviderConfig({ providerId: "cline", controller })
|
||||
setSelectedProvider("cline")
|
||||
setModelId(openRouterDefaultModelId)
|
||||
setStep("cline_model")
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to auth status updates
|
||||
const authService = AuthService.getInstance(controller)
|
||||
authService.subscribeToAuthStatusUpdate(controller, {}, responseHandler, `cli-auth-${Date.now()}`)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [step, controller])
|
||||
|
||||
// Start OpenAI Codex OAuth flow
|
||||
const startOpenAiCodexAuth = useCallback(async () => {
|
||||
try {
|
||||
// Get the authorization URL and start the callback server
|
||||
const authUrl = openAiCodexOAuthManager.startAuthorizationFlow()
|
||||
|
||||
// Open browser to authorization URL (uses cross-platform 'open' package)
|
||||
await openExternal(authUrl)
|
||||
|
||||
// Wait for the callback
|
||||
await openAiCodexOAuthManager.waitForCallback()
|
||||
|
||||
// Success - save configuration
|
||||
await applyProviderConfig({ providerId: "openai-codex", controller })
|
||||
const stateManager = StateManager.get()
|
||||
stateManager.setGlobalState("welcomeViewCompleted", true)
|
||||
await stateManager.flushPendingState()
|
||||
setSelectedProvider("openai-codex")
|
||||
setModelId(openAiCodexDefaultModelId)
|
||||
setStep("success")
|
||||
} catch (error) {
|
||||
openAiCodexOAuthManager.cancelAuthorizationFlow()
|
||||
setErrorMessage(error instanceof Error ? error.message : String(error))
|
||||
setStep("error")
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Start Cline auth flow
|
||||
const startClineAuth = useCallback(async () => {
|
||||
try {
|
||||
setStep("cline_auth")
|
||||
await AuthService.getInstance(controller).createAuthRequest()
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : String(error))
|
||||
setStep("error")
|
||||
}
|
||||
}, [controller])
|
||||
|
||||
const startOcaAuth = useCallback(() => {
|
||||
setStep("oca_auth")
|
||||
initiateOcaAuth()
|
||||
}, [initiateOcaAuth])
|
||||
|
||||
const handleMainMenuSelect = useCallback(
|
||||
(value: string) => {
|
||||
if (value === "exit") {
|
||||
exit()
|
||||
onComplete?.()
|
||||
} else if (value === "cline_auth") {
|
||||
startClineAuth()
|
||||
} else if (value === "openai_codex_auth") {
|
||||
setStep("openai_codex_auth")
|
||||
startOpenAiCodexAuth()
|
||||
} else if (value === "configure_byo") {
|
||||
setStep("provider")
|
||||
} else if (value === "import_codex") {
|
||||
setImportSource("codex")
|
||||
setStep("import")
|
||||
} else if (value === "import_opencode") {
|
||||
setImportSource("opencode")
|
||||
setStep("import")
|
||||
}
|
||||
},
|
||||
[exit, onComplete, startClineAuth, startOpenAiCodexAuth],
|
||||
)
|
||||
|
||||
const handleProviderSelect = useCallback(
|
||||
(value: string) => {
|
||||
setSelectedProvider(value)
|
||||
if (value === "oca") {
|
||||
// Show employee check screen before starting auth
|
||||
setStep("oca_employee_check")
|
||||
} else if (value === "openai-codex") {
|
||||
setStep("openai_codex_auth")
|
||||
startOpenAiCodexAuth()
|
||||
} else if (value === "bedrock") {
|
||||
setStep("bedrock")
|
||||
} else {
|
||||
setStep("apikey")
|
||||
}
|
||||
},
|
||||
[startOcaAuth, startOpenAiCodexAuth],
|
||||
)
|
||||
|
||||
const handleApiKeySubmit = useCallback(
|
||||
(value: string) => {
|
||||
if (!value.trim() || !selectedProvider) {
|
||||
// Don't allow empty
|
||||
return
|
||||
}
|
||||
|
||||
// Store in local state - will be saved via StateManager in saveConfiguration
|
||||
setApiKey(value)
|
||||
setStep("modelid")
|
||||
},
|
||||
[selectedProvider],
|
||||
)
|
||||
|
||||
// Save custom Bedrock ARN configuration with base model for capability detection
|
||||
const saveCustomBedrockConfiguration = useCallback(
|
||||
async (arn: string, baseModelId: string) => {
|
||||
try {
|
||||
if (!bedrockConfig) {
|
||||
throw new Error("Bedrock configuration is missing")
|
||||
}
|
||||
await applyBedrockConfig({
|
||||
bedrockConfig,
|
||||
modelId: arn,
|
||||
customModelBaseId: baseModelId,
|
||||
controller,
|
||||
})
|
||||
|
||||
const stateManager = StateManager.get()
|
||||
stateManager.setGlobalState("welcomeViewCompleted", true)
|
||||
await stateManager.flushPendingState()
|
||||
|
||||
setStep("success")
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : String(error))
|
||||
setStep("error")
|
||||
}
|
||||
},
|
||||
[bedrockConfig, controller],
|
||||
)
|
||||
|
||||
const saveConfiguration = useCallback(
|
||||
async (model: string, base: string) => {
|
||||
try {
|
||||
if (selectedProvider === "bedrock" && bedrockConfig) {
|
||||
await applyBedrockConfig({
|
||||
bedrockConfig,
|
||||
modelId: model,
|
||||
controller,
|
||||
})
|
||||
} else {
|
||||
await applyProviderConfig({
|
||||
providerId: selectedProvider,
|
||||
apiKey,
|
||||
modelId: model,
|
||||
baseUrl: base,
|
||||
controller,
|
||||
})
|
||||
}
|
||||
|
||||
const stateManager = StateManager.get()
|
||||
stateManager.setGlobalState("welcomeViewCompleted", true)
|
||||
await stateManager.flushPendingState()
|
||||
|
||||
setStep("success")
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : String(error))
|
||||
setStep("error")
|
||||
}
|
||||
},
|
||||
[selectedProvider, apiKey, bedrockConfig, controller],
|
||||
)
|
||||
|
||||
const handleModelIdSubmit = useCallback(
|
||||
(value: string) => {
|
||||
// Intercept "Custom" selection for Bedrock — redirect to custom ARN input flow
|
||||
if (value === CUSTOM_MODEL_ID && selectedProvider === "bedrock") {
|
||||
setStep("bedrock_custom")
|
||||
return
|
||||
}
|
||||
|
||||
if (value.trim()) {
|
||||
setModelId(value)
|
||||
}
|
||||
// Only show baseurl step for OpenAI-like providers
|
||||
if (["openai", "openai-native"].includes(selectedProvider)) {
|
||||
setStep("baseurl")
|
||||
} else {
|
||||
setStep("saving")
|
||||
saveConfiguration(value, "")
|
||||
}
|
||||
},
|
||||
[selectedProvider, saveConfiguration],
|
||||
)
|
||||
|
||||
const handleBaseUrlSubmit = useCallback(
|
||||
(value: string) => {
|
||||
setBaseUrl(value)
|
||||
setStep("saving")
|
||||
saveConfiguration(modelId, value)
|
||||
},
|
||||
[modelId, saveConfiguration],
|
||||
)
|
||||
|
||||
const handleClineModelSelect = useCallback(
|
||||
(modelId: string) => {
|
||||
setModelId(modelId)
|
||||
setStep("saving")
|
||||
saveConfiguration(modelId, "")
|
||||
},
|
||||
[saveConfiguration],
|
||||
)
|
||||
|
||||
const handleBedrockComplete = useCallback((config: BedrockConfig) => {
|
||||
setBedrockConfig(config)
|
||||
setStep("modelid")
|
||||
}, [])
|
||||
|
||||
const handleImportComplete = useCallback(() => {
|
||||
setStep("success")
|
||||
}, [])
|
||||
|
||||
const handleImportCancel = useCallback(() => {
|
||||
setImportSource(null)
|
||||
setStep("menu")
|
||||
}, [])
|
||||
|
||||
// Auto-navigate to welcome after success (immediate)
|
||||
// For quick setup mode (no onNavigateToWelcome), exit the Ink app
|
||||
useEffect(() => {
|
||||
if (step === "success") {
|
||||
if (onNavigateToWelcome) {
|
||||
onNavigateToWelcome()
|
||||
} else {
|
||||
// Quick setup mode - exit Ink app after successful configuration
|
||||
// The cleanup handler in runInkApp will handle process exit
|
||||
exit()
|
||||
}
|
||||
}
|
||||
}, [step, onNavigateToWelcome, exit])
|
||||
|
||||
// Error screen menu items
|
||||
const errorMenuItems: SelectItem[] = useMemo(() => {
|
||||
const items: SelectItem[] = [{ label: "Try again", value: "retry" }]
|
||||
if (onNavigateToWelcome) {
|
||||
items.push({ label: "Start a task", value: "welcome" })
|
||||
}
|
||||
items.push({ label: "Exit", value: "exit" })
|
||||
return items
|
||||
}, [onNavigateToWelcome])
|
||||
|
||||
const handleErrorMenuSelect = useCallback(
|
||||
(value: string) => {
|
||||
if (value === "retry") {
|
||||
// Reset state and go back to menu
|
||||
setErrorMessage("")
|
||||
setApiKey("")
|
||||
setModelId("")
|
||||
setBaseUrl("")
|
||||
setSelectedProvider("")
|
||||
setStep("menu")
|
||||
} else if (value === "welcome") {
|
||||
onNavigateToWelcome?.()
|
||||
} else if (value === "exit") {
|
||||
onError?.()
|
||||
exit()
|
||||
}
|
||||
},
|
||||
[onNavigateToWelcome, onError, exit],
|
||||
)
|
||||
|
||||
// Handle going back to previous step
|
||||
const goBack = useCallback(() => {
|
||||
switch (step) {
|
||||
case "provider":
|
||||
setProviderSearch("")
|
||||
setProviderIndex(0)
|
||||
setStep("menu")
|
||||
break
|
||||
case "apikey":
|
||||
setApiKey("")
|
||||
setStep("provider")
|
||||
break
|
||||
case "modelid":
|
||||
setModelId("")
|
||||
// Go back to cline_model if we came from there (Cline provider)
|
||||
if (selectedProvider === "cline") {
|
||||
setStep("cline_model")
|
||||
} else if (selectedProvider === "bedrock") {
|
||||
// Bedrock skips the API key step — go back to Bedrock setup
|
||||
setStep("bedrock")
|
||||
} else {
|
||||
setStep("apikey")
|
||||
}
|
||||
break
|
||||
case "baseurl":
|
||||
setBaseUrl("")
|
||||
setStep("modelid")
|
||||
break
|
||||
case "oca_employee_check":
|
||||
setStep("provider")
|
||||
break
|
||||
case "oca_auth":
|
||||
setStep("oca_employee_check")
|
||||
break
|
||||
case "cline_auth":
|
||||
setStep("menu")
|
||||
break
|
||||
case "openai_codex_auth":
|
||||
openAiCodexOAuthManager.cancelAuthorizationFlow()
|
||||
setStep("menu")
|
||||
break
|
||||
case "cline_model":
|
||||
setClineModelIndex(0)
|
||||
setStep("menu")
|
||||
break
|
||||
case "bedrock":
|
||||
setBedrockConfig(null)
|
||||
setStep("provider")
|
||||
break
|
||||
case "import":
|
||||
setImportSource(null)
|
||||
setStep("menu")
|
||||
break
|
||||
case "error":
|
||||
setErrorMessage("")
|
||||
setStep("menu")
|
||||
break
|
||||
// menu, saving, success - no back action
|
||||
}
|
||||
}, [step, selectedProvider])
|
||||
|
||||
// Render the auth box content based on current step
|
||||
// Note: "menu" step is rendered separately in the main return for proper menuIndex tracking
|
||||
const renderAuthContent = () => {
|
||||
switch (step) {
|
||||
case "provider": {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="white">Select a provider</Text>
|
||||
<Text> </Text>
|
||||
<Box>
|
||||
<Text color="gray">Search: </Text>
|
||||
<Text color="white">{providerSearch}</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
<Text> </Text>
|
||||
{showProviderTopIndicator && <Text color="gray">... {providerVisibleStart} more above</Text>}
|
||||
{visibleProviderItems.map((item, i) => {
|
||||
const actualIndex = providerVisibleStart + i
|
||||
return (
|
||||
<Box key={item.value}>
|
||||
<Text color={actualIndex === providerIndex ? COLORS.primaryBlue : undefined}>
|
||||
{actualIndex === providerIndex ? "❯ " : " "}
|
||||
{item.label}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
{showProviderBottomIndicator && (
|
||||
<Text color="gray">
|
||||
... {providerItems.length - providerVisibleStart - providerVisibleCount} more below
|
||||
</Text>
|
||||
)}
|
||||
{providerItems.length === 0 && <Text color="gray">No providers match "{providerSearch}"</Text>}
|
||||
<Text> </Text>
|
||||
<Text color="gray">Type to search, arrows to navigate, Enter to select, Esc to go back</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
case "apikey":
|
||||
return (
|
||||
<ApiKeyInput
|
||||
isActive={step === "apikey"}
|
||||
onCancel={goBack}
|
||||
onChange={setApiKey}
|
||||
onSubmit={handleApiKeySubmit}
|
||||
providerName={getProviderLabel(selectedProvider)}
|
||||
value={apiKey}
|
||||
/>
|
||||
)
|
||||
|
||||
case "modelid":
|
||||
// Show model picker for providers with static model lists
|
||||
if (hasModelPicker(selectedProvider)) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="white">Select a model</Text>
|
||||
<Text> </Text>
|
||||
<ModelPicker
|
||||
controller={controller}
|
||||
isActive={step === "modelid"}
|
||||
onChange={setModelId}
|
||||
onSubmit={handleModelIdSubmit}
|
||||
provider={selectedProvider}
|
||||
/>
|
||||
<Text> </Text>
|
||||
<Text color="gray">Type to search, arrows to navigate, Enter to select, Esc to go back</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
// Fall back to text input for providers without static model lists
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="white">Model ID</Text>
|
||||
<Text> </Text>
|
||||
<Text color="gray">e.g., claude-sonnet-4-6, gpt-4o</Text>
|
||||
<Text> </Text>
|
||||
<TextInput onChange={setModelId} onSubmit={handleModelIdSubmit} placeholder="model-id" value={modelId} />
|
||||
<Text> </Text>
|
||||
<Text color="gray">Enter to continue, Esc to go back</Text>
|
||||
</Box>
|
||||
)
|
||||
|
||||
case "baseurl":
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="white">Base URL (optional)</Text>
|
||||
<Text> </Text>
|
||||
<Text color="gray">For self-hosted or proxy endpoints</Text>
|
||||
<Text> </Text>
|
||||
<TextInput
|
||||
onChange={setBaseUrl}
|
||||
onSubmit={handleBaseUrlSubmit}
|
||||
placeholder="https://api.example.com/v1"
|
||||
value={baseUrl}
|
||||
/>
|
||||
<Text> </Text>
|
||||
<Text color="gray">Enter to skip or continue, Esc to go back</Text>
|
||||
</Box>
|
||||
)
|
||||
|
||||
case "saving":
|
||||
return (
|
||||
<Box>
|
||||
<Text color={COLORS.primaryBlue}>
|
||||
<Spinner type="dots" />
|
||||
</Text>
|
||||
<Text color="white"> Saving configuration...</Text>
|
||||
</Box>
|
||||
)
|
||||
|
||||
case "oca_employee_check":
|
||||
return <OcaEmployeeCheck isActive={step === "oca_employee_check"} onCancel={goBack} onSignIn={startOcaAuth} />
|
||||
|
||||
case "oca_auth":
|
||||
case "cline_auth":
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text color={COLORS.primaryBlue}>
|
||||
<Spinner type="dots" />
|
||||
</Text>
|
||||
<Text color="white"> Waiting for browser sign-in...</Text>
|
||||
</Box>
|
||||
<Text> </Text>
|
||||
<Text color="gray">Complete sign-in in your browser, then return here.</Text>
|
||||
<Text> </Text>
|
||||
<Text color="gray">Esc to cancel</Text>
|
||||
</Box>
|
||||
)
|
||||
|
||||
case "openai_codex_auth":
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text color={COLORS.primaryBlue}>
|
||||
<Spinner type="dots" />
|
||||
</Text>
|
||||
<Text color="white"> Waiting for ChatGPT sign-in...</Text>
|
||||
</Box>
|
||||
<Text> </Text>
|
||||
<Text color="gray">Sign in with your ChatGPT account in the browser.</Text>
|
||||
<Text color="gray">Requires ChatGPT Plus, Pro, or Team subscription.</Text>
|
||||
<Text> </Text>
|
||||
<Text color="gray">Esc to cancel</Text>
|
||||
</Box>
|
||||
)
|
||||
|
||||
case "cline_model": {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="white">Choose a model</Text>
|
||||
<Text> </Text>
|
||||
<FeaturedModelPicker featuredModels={featuredModels} selectedIndex={clineModelIndex} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
case "bedrock":
|
||||
return (
|
||||
<BedrockSetup
|
||||
isActive={step === "bedrock"}
|
||||
onCancel={() => {
|
||||
setBedrockConfig(null)
|
||||
setStep("provider")
|
||||
}}
|
||||
onComplete={handleBedrockComplete}
|
||||
/>
|
||||
)
|
||||
|
||||
case "bedrock_custom":
|
||||
return (
|
||||
<BedrockCustomModelFlow
|
||||
isActive={step === "bedrock_custom"}
|
||||
onCancel={() => setStep("modelid")}
|
||||
onComplete={(arn, baseModelId) => {
|
||||
setStep("saving")
|
||||
saveCustomBedrockConfiguration(arn, baseModelId)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
case "import":
|
||||
if (!importSource) {
|
||||
return null
|
||||
}
|
||||
return <ImportView onCancel={handleImportCancel} onComplete={handleImportComplete} source={importSource} />
|
||||
|
||||
case "error":
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color="red">
|
||||
Something went wrong
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text color="yellow">{errorMessage}</Text>
|
||||
<Text> </Text>
|
||||
<Select items={errorMenuItems} onSelect={handleErrorMenuSelect} />
|
||||
</Box>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// For menu step, we need to handle input at the top level
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const [menuIndex, setMenuIndex] = useState(0)
|
||||
|
||||
// Steps that allow going back with escape (apikey handled by ApiKeyInput component)
|
||||
// OcaEmployeeCheck handles its own escape key, so oca_employee_check is not in this list
|
||||
const canGoBack = [
|
||||
"provider",
|
||||
"modelid",
|
||||
"baseurl",
|
||||
"cline_auth",
|
||||
"oca_auth",
|
||||
"cline_model",
|
||||
"openai_codex_auth",
|
||||
"bedrock",
|
||||
"error",
|
||||
].includes(step)
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
// Handle escape to go back (except on menu)
|
||||
if (key.escape && canGoBack) {
|
||||
goBack()
|
||||
return
|
||||
}
|
||||
|
||||
if (step === "menu") {
|
||||
if (key.upArrow) {
|
||||
setMenuIndex((prev) => (prev > 0 ? prev - 1 : mainMenuItems.length - 1))
|
||||
} else if (key.downArrow) {
|
||||
setMenuIndex((prev) => (prev < mainMenuItems.length - 1 ? prev + 1 : 0))
|
||||
} else if (isEnterKey(input, key)) {
|
||||
handleMainMenuSelect(mainMenuItems[menuIndex].value)
|
||||
}
|
||||
} else if (step === "provider") {
|
||||
if (key.upArrow) {
|
||||
setProviderIndex((prev) => (prev > 0 ? prev - 1 : providerItems.length - 1))
|
||||
} else if (key.downArrow) {
|
||||
setProviderIndex((prev) => (prev < providerItems.length - 1 ? prev + 1 : 0))
|
||||
} else if (isEnterKey(input, key)) {
|
||||
if (providerItems[providerIndex]) {
|
||||
handleProviderSelect(providerItems[providerIndex].value)
|
||||
}
|
||||
} else if (key.backspace || key.delete) {
|
||||
setProviderSearch((prev) => prev.slice(0, -1))
|
||||
} else if (input && !key.ctrl && !key.meta) {
|
||||
setProviderSearch((prev) => prev + input)
|
||||
}
|
||||
} else if (step === "cline_model") {
|
||||
const maxIndex = getFeaturedModelMaxIndex(featuredModels)
|
||||
|
||||
if (key.upArrow) {
|
||||
setClineModelIndex((prev) => (prev > 0 ? prev - 1 : maxIndex))
|
||||
} else if (key.downArrow) {
|
||||
setClineModelIndex((prev) => (prev < maxIndex ? prev + 1 : 0))
|
||||
} else if (isEnterKey(input, key)) {
|
||||
if (isBrowseAllSelected(clineModelIndex, featuredModels)) {
|
||||
setStep("modelid")
|
||||
} else {
|
||||
const selectedModel = getFeaturedModelAtIndex(clineModelIndex, featuredModels)
|
||||
if (selectedModel) {
|
||||
handleClineModelSelect(selectedModel.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Note: modelid step input is handled by ModelPicker component
|
||||
},
|
||||
{ isActive: isRawModeSupported && (step === "menu" || step === "provider" || step === "cline_model" || canGoBack) },
|
||||
)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingLeft={1} paddingRight={1} width="100%">
|
||||
{/* Cline robot - centered */}
|
||||
<StaticRobotFrame />
|
||||
|
||||
{/* Welcome text - centered */}
|
||||
<Box justifyContent="center" marginTop={1}>
|
||||
<Text bold color="white">
|
||||
Welcome to Cline
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Auth box with border */}
|
||||
<Box
|
||||
borderColor="gray"
|
||||
borderStyle="round"
|
||||
flexDirection="column"
|
||||
marginTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
paddingTop={1}>
|
||||
{step === "menu" ? (
|
||||
<Box flexDirection="column">
|
||||
<Text color="gray">How would you like to get started?</Text>
|
||||
<Text> </Text>
|
||||
{mainMenuItems.map((item, index) => (
|
||||
<Box key={item.value}>
|
||||
<Text>
|
||||
<Text color={index === menuIndex ? COLORS.primaryBlue : undefined}>
|
||||
{index === menuIndex ? "❯ " : " "}
|
||||
{item.label}
|
||||
</Text>
|
||||
{item.value === "cline_auth" && <Text color="yellow"> (try Opus 4.6!)</Text>}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
<Text> </Text>
|
||||
<Text color="gray">Use arrow keys, Enter to select</Text>
|
||||
</Box>
|
||||
) : (
|
||||
renderAuthContent()
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
/**
|
||||
* Bedrock Custom Model Flow component
|
||||
* Two-step flow: ARN/custom model ID input → base model selection for capability detection.
|
||||
* Used by both AuthView (onboarding) and SettingsPanelContent (/settings).
|
||||
*/
|
||||
|
||||
import { Box, Text, useInput } from "ink"
|
||||
// biome-ignore lint/correctness/noUnusedImports: React is needed for JSX at runtime
|
||||
import React, { useCallback, useState } from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { isEnterKey } from "../utils/input"
|
||||
import { getModelList } from "./ModelPicker"
|
||||
import { SearchableList } from "./SearchableList"
|
||||
|
||||
type FlowStep = "arn_input" | "base_model"
|
||||
|
||||
interface BedrockCustomModelFlowProps {
|
||||
/** Whether this component should capture keyboard input */
|
||||
isActive: boolean
|
||||
/** Called when the user completes both steps (ARN + base model selection) */
|
||||
onComplete: (arn: string, baseModelId: string) => void
|
||||
/** Called when the user presses Escape on the first step (ARN input) */
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export const BedrockCustomModelFlow: React.FC<BedrockCustomModelFlowProps> = ({ isActive, onComplete, onCancel }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const [step, setStep] = useState<FlowStep>("arn_input")
|
||||
const [customArn, setCustomArn] = useState("")
|
||||
|
||||
const handleArnSubmit = useCallback(() => {
|
||||
if (customArn.trim()) {
|
||||
setStep("base_model")
|
||||
}
|
||||
}, [customArn])
|
||||
|
||||
const handleBaseModelCancel = useCallback(() => {
|
||||
setStep("arn_input")
|
||||
}, [])
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (step === "arn_input") {
|
||||
if (key.escape) {
|
||||
onCancel()
|
||||
} else if (isEnterKey(input, key)) {
|
||||
handleArnSubmit()
|
||||
} else if (key.backspace || key.delete) {
|
||||
setCustomArn((prev) => prev.slice(0, -1))
|
||||
} else if (input && !key.ctrl && !key.meta) {
|
||||
setCustomArn((prev) => prev + input)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (step === "base_model") {
|
||||
if (key.escape) {
|
||||
handleBaseModelCancel()
|
||||
}
|
||||
// Other input is handled by SearchableList
|
||||
}
|
||||
},
|
||||
{ isActive: isActive && isRawModeSupported },
|
||||
)
|
||||
|
||||
if (step === "arn_input") {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={COLORS.primaryBlue}>
|
||||
Custom Model ID
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">Enter your Application Inference Profile ARN or custom model ID</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
{customArn ? (
|
||||
<Text color="white">{customArn}</Text>
|
||||
) : (
|
||||
<Text color="gray">e.g. arn:aws:bedrock:region:account:application-inference-profile/...</Text>
|
||||
)}
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">Enter to continue, Esc to go back</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// step === "base_model"
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={COLORS.primaryBlue}>
|
||||
Base Inference Model
|
||||
</Text>
|
||||
<Text color="gray">Select the base model your inference profile uses (for capability detection)</Text>
|
||||
<Box marginTop={1}>
|
||||
<SearchableList
|
||||
isActive={isActive && step === "base_model"}
|
||||
items={getModelList("bedrock").map((id) => ({ id, label: id }))}
|
||||
onSelect={(item) => {
|
||||
onComplete(customArn, item.id)
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">Type to search, arrows to navigate, Enter to select, Esc to go back</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,391 +0,0 @@
|
||||
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().trim()
|
||||
if (!search) {
|
||||
return AWS_REGIONS
|
||||
}
|
||||
return AWS_REGIONS.filter((r) => r.toLowerCase().includes(search))
|
||||
}, [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 getSelectedRegion = useCallback(() => {
|
||||
if (filteredRegions.length > 0 && regionIndex >= 0 && regionIndex < filteredRegions.length) {
|
||||
return filteredRegions[regionIndex]
|
||||
}
|
||||
// If no matches, use the search term as custom region
|
||||
return regionSearch.trim() || "us-east-1"
|
||||
}, [filteredRegions, regionIndex, regionSearch])
|
||||
|
||||
const finish = useCallback(() => {
|
||||
const config: BedrockConfig = {
|
||||
awsAuthentication: authMethod === "default" ? "credentials" : authMethod,
|
||||
awsRegion: getSelectedRegion(),
|
||||
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, getSelectedRegion, 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 && filteredRegions.length > 0) {
|
||||
setRegionIndex((prev) => (prev > 0 ? prev - 1 : filteredRegions.length - 1))
|
||||
} else if (key.downArrow && filteredRegions.length > 0) {
|
||||
setRegionIndex((prev) => (prev < filteredRegions.length - 1 ? prev + 1 : 0))
|
||||
} else if (key.return && (filteredRegions.length > 0 || regionSearch.trim())) {
|
||||
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 or enter custom region: </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>
|
||||
)}
|
||||
<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
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { render } from "ink-testing-library"
|
||||
import React from "react"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { ChatMessage } from "./ChatMessage"
|
||||
|
||||
vi.mock("../hooks/useTerminalSize", () => ({
|
||||
useTerminalSize: () => ({
|
||||
columns: 120,
|
||||
rows: 40,
|
||||
resizeKey: 0,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe("ChatMessage markdown rendering", () => {
|
||||
it("renders basic markdown elements correctly with appropriate styling", () => {
|
||||
const message: ClineMessage = {
|
||||
ts: Date.now(),
|
||||
type: "say",
|
||||
say: "text",
|
||||
text: "# Heading 1\n\nThis is a **bold** and *italic* text with `inline code`.\n\n- List item 1\n- List item 2\n\n> Blockquote\n\n```javascript\nconst x = 1;\n```",
|
||||
}
|
||||
|
||||
const { lastFrame } = render(React.createElement(ChatMessage, { message, mode: "act" }))
|
||||
const frame = lastFrame() || ""
|
||||
|
||||
// Check for heading (bold)
|
||||
// \x1B[1m is the ANSI escape code for bold
|
||||
expect(frame).toMatch(/\x1B\[1mHeading 1\x1B\[22m/)
|
||||
|
||||
// Check for bold text
|
||||
expect(frame).toMatch(/\x1B\[1mbold\x1B\[22m/)
|
||||
|
||||
// Check for italic text
|
||||
// \x1B[3m is the ANSI escape code for italic
|
||||
expect(frame).toMatch(/\x1B\[3mitalic\x1B\[23m/)
|
||||
|
||||
// Check for inline code (no special styling in the current implementation, just text)
|
||||
expect(frame).toContain("inline code")
|
||||
|
||||
// Check for list items (gray bullet)
|
||||
// \x1B[90m is the ANSI escape code for gray
|
||||
expect(frame).toMatch(/\x1B\[90m• \x1B\[39mList item 1/)
|
||||
expect(frame).toMatch(/\x1B\[90m• \x1B\[39mList item 2/)
|
||||
|
||||
// Check for blockquote (gray pipe)
|
||||
expect(frame).toMatch(/\x1B\[90m│ \x1B\[39mBlockquote/)
|
||||
|
||||
// Check for code block (cyan text)
|
||||
// \x1B[36m is the ANSI escape code for cyan
|
||||
expect(frame).toMatch(/\x1B\[36mconst x = 1;\x1B\[39m/)
|
||||
})
|
||||
})
|
||||
@@ -1,106 +0,0 @@
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { render } from "ink-testing-library"
|
||||
import React from "react"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { ChatMessage } from "./ChatMessage"
|
||||
|
||||
vi.mock("../hooks/useTerminalSize", () => ({
|
||||
useTerminalSize: () => ({
|
||||
columns: 120,
|
||||
rows: 40,
|
||||
resizeKey: 0,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe("ChatMessage subagent rendering", () => {
|
||||
it("renders subagent approval prompts as a tree", () => {
|
||||
const message: ClineMessage = {
|
||||
ts: Date.now(),
|
||||
type: "ask",
|
||||
ask: "use_subagents",
|
||||
text: JSON.stringify({
|
||||
prompts: [
|
||||
"Find codebase stats and size",
|
||||
"Find funny comments and easter eggs",
|
||||
"Find unusual patterns and history",
|
||||
],
|
||||
}),
|
||||
}
|
||||
|
||||
const { lastFrame } = render(React.createElement(ChatMessage, { message, mode: "act" }))
|
||||
const frame = lastFrame() || ""
|
||||
|
||||
expect(frame).toContain("Cline wants to run subagents")
|
||||
expect(frame).toContain("├─ Find codebase stats and size")
|
||||
expect(frame).toContain("├─ Find funny comments and easter eggs")
|
||||
expect(frame).toContain("└─ Find unusual patterns and history")
|
||||
})
|
||||
|
||||
it("renders subagent progress rows with compact token stats and completion checks", () => {
|
||||
const message: ClineMessage = {
|
||||
ts: Date.now(),
|
||||
type: "say",
|
||||
say: "subagent",
|
||||
text: JSON.stringify({
|
||||
status: "running",
|
||||
total: 3,
|
||||
completed: 1,
|
||||
successes: 1,
|
||||
failures: 0,
|
||||
toolCalls: 21,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
contextWindow: 0,
|
||||
maxContextTokens: 0,
|
||||
maxContextUsagePercentage: 0,
|
||||
items: [
|
||||
{
|
||||
index: 1,
|
||||
prompt: "Find codebase stats and size",
|
||||
status: "completed",
|
||||
toolCalls: 5,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalCost: 0.034,
|
||||
contextTokens: 24400,
|
||||
contextWindow: 200000,
|
||||
contextUsagePercentage: 12.2,
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
prompt: "Find funny comments and easter eggs",
|
||||
status: "running",
|
||||
toolCalls: 11,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalCost: 0.056,
|
||||
contextTokens: 31600,
|
||||
contextWindow: 200000,
|
||||
contextUsagePercentage: 15.8,
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
prompt: "Find unusual patterns and history",
|
||||
status: "pending",
|
||||
toolCalls: 5,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalCost: 0,
|
||||
contextTokens: 28900,
|
||||
contextWindow: 200000,
|
||||
contextUsagePercentage: 14.4,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}
|
||||
|
||||
const { lastFrame } = render(React.createElement(ChatMessage, { isStreaming: true, message, mode: "act" }))
|
||||
const frame = lastFrame() || ""
|
||||
|
||||
expect(frame).toContain("Cline is running subagents")
|
||||
expect(frame).toContain("✓ Find codebase stats and size")
|
||||
expect(frame).toContain("5 tool uses · 24.4k tokens · $0.03")
|
||||
expect(frame).toContain("11 tool uses · 31.6k tokens · $0.06")
|
||||
expect(frame).toContain("5 tool uses · 28.9k tokens · $0.00")
|
||||
})
|
||||
})
|
||||
@@ -1,903 +0,0 @@
|
||||
/**
|
||||
* Claude Code style chat message component
|
||||
* Renders messages with:
|
||||
* - ❯ for user messages
|
||||
* - ⏺ for assistant messages and tool calls
|
||||
* - ⎿ for tool results (indented)
|
||||
*/
|
||||
|
||||
import { CLINE_ACCOUNT_AUTH_ERROR_MESSAGE } from "@shared/ClineAccount"
|
||||
import { COMMAND_OUTPUT_STRING } from "@shared/combineCommandSequences"
|
||||
import type { ClineAskUseMcpServer, ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { Box, Text } from "ink"
|
||||
import Spinner from "ink-spinner"
|
||||
import { lexer, type Token, type Tokens } from "marked"
|
||||
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"
|
||||
import { SubagentMessage } from "./SubagentMessage"
|
||||
|
||||
/**
|
||||
* Add "(Tab)" hint after "Act mode" mentions in plain text.
|
||||
* Case-insensitive, avoids double-adding if already present.
|
||||
*/
|
||||
function addActModeHint(text: string, keyPrefix: string): React.ReactNode[] {
|
||||
const actModeRegex = /\bact\s+mode\b(?!\s*\(tab\))/gi
|
||||
const parts = text.split(actModeRegex)
|
||||
const matches = text.match(actModeRegex)
|
||||
|
||||
if (!matches || parts.length <= 1) {
|
||||
return [text]
|
||||
}
|
||||
|
||||
const nodes: React.ReactNode[] = []
|
||||
parts.forEach((part, i) => {
|
||||
if (part) nodes.push(part)
|
||||
if (matches[i]) {
|
||||
nodes.push(
|
||||
<React.Fragment key={`${keyPrefix}-act-mode-${i}`}>
|
||||
{matches[i]}
|
||||
<Text color="gray"> (Tab)</Text>
|
||||
</React.Fragment>,
|
||||
)
|
||||
}
|
||||
})
|
||||
return nodes
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an array of marked tokens as Ink React nodes.
|
||||
* This is the entry point for recursive rendering — each token may
|
||||
* contain child tokens (e.g. a paragraph contains inline tokens,
|
||||
* a list contains items, etc.).
|
||||
*/
|
||||
function renderTokens(tokens: Token[], color?: string): React.ReactNode[] {
|
||||
return tokens.map((token, i) => renderToken(token, i, color))
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single marked token (block or inline) as an Ink React node.
|
||||
* Handles both block-level tokens (heading, paragraph, list, code, etc.)
|
||||
* and inline tokens (strong, em, codespan, link, text).
|
||||
*/
|
||||
function renderToken(token: Token, key: number, color?: string): React.ReactNode {
|
||||
switch (token.type) {
|
||||
// --- Block tokens ---
|
||||
|
||||
case "heading": {
|
||||
const { depth, tokens } = token as Tokens.Heading
|
||||
return (
|
||||
<Box key={key} marginY={depth === 1 ? 1 : 0}>
|
||||
<Text bold color={color}>
|
||||
{renderTokens(tokens, color)}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
case "paragraph":
|
||||
return (
|
||||
<Text color={color} key={key}>
|
||||
{renderTokens((token as Tokens.Paragraph).tokens, color)}
|
||||
</Text>
|
||||
)
|
||||
|
||||
case "code":
|
||||
return (
|
||||
<Box flexDirection="column" key={key} marginY={1}>
|
||||
{(token as Tokens.Code).text.split("\n").map((line, i) => (
|
||||
<Text color="cyan" key={i}>
|
||||
{line || " "}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
|
||||
case "list": {
|
||||
const { ordered, start, items } = token as Tokens.List
|
||||
return (
|
||||
<Box flexDirection="column" key={key}>
|
||||
{items.map((item, i) => (
|
||||
<Box flexDirection="row" key={i}>
|
||||
<Text color="gray">{ordered ? `${Number(start ?? 1) + i}. ` : "• "}</Text>
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
{renderTokens(item.tokens, color)}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
case "blockquote":
|
||||
return (
|
||||
<Box flexDirection="row" key={key}>
|
||||
<Text color="gray">│ </Text>
|
||||
<Box flexDirection="column">{renderTokens((token as Tokens.Blockquote).tokens, color)}</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
case "space":
|
||||
return <Text key={key}> </Text>
|
||||
|
||||
// --- Inline tokens ---
|
||||
|
||||
case "strong":
|
||||
return (
|
||||
<Text bold color={color} key={key}>
|
||||
{renderTokens((token as Tokens.Strong).tokens, color)}
|
||||
</Text>
|
||||
)
|
||||
|
||||
case "em":
|
||||
return (
|
||||
<Text color={color} italic key={key}>
|
||||
{renderTokens((token as Tokens.Em).tokens, color)}
|
||||
</Text>
|
||||
)
|
||||
|
||||
case "codespan":
|
||||
return <Text key={key}>{(token as Tokens.Codespan).text}</Text>
|
||||
|
||||
case "link": {
|
||||
const { text, href } = token as Tokens.Link
|
||||
return (
|
||||
<Text color={color} key={key}>
|
||||
{text && text !== href ? `${text} (${href})` : href}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
case "text": {
|
||||
const { text, tokens } = token as Tokens.Text
|
||||
if (tokens?.length) {
|
||||
return (
|
||||
<Text color={color} key={key}>
|
||||
{renderTokens(tokens, color)}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Text color={color} key={key}>
|
||||
{addActModeHint(text, `${key}`)}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback for any unhandled token type
|
||||
default:
|
||||
return "raw" in token ? (
|
||||
<Text color={color} key={key}>
|
||||
{(token as { raw: string }).raw}
|
||||
</Text>
|
||||
) : null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a markdown string as Ink components.
|
||||
* Uses marked's lexer to parse markdown into tokens, then renders
|
||||
* each token to the appropriate Ink component.
|
||||
*/
|
||||
const MarkdownText: React.FC<{ children: string; color?: string }> = ({ children, color }) => {
|
||||
const tokens = lexer(children)
|
||||
return <Box flexDirection="column">{renderTokens(tokens, color)}</Box>
|
||||
}
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: ClineMessage
|
||||
isStreaming?: boolean
|
||||
mode?: "act" | "plan"
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-column layout for messages with a dot prefix.
|
||||
* Keeps content from wrapping under the dot.
|
||||
*
|
||||
* For this to work properly, parent containers must have width="100%"
|
||||
* so flexGrow={1} on the content box has a reference width to fill.
|
||||
*/
|
||||
const DotRow: React.FC<{ children: React.ReactNode; color?: string; flashing?: boolean }> = ({
|
||||
children,
|
||||
color,
|
||||
flashing = false,
|
||||
}) => (
|
||||
<Box flexDirection="row">
|
||||
<Box width={2}>
|
||||
{flashing ? (
|
||||
<Text color={color}>
|
||||
<Spinner type="toggle8" />
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={color}>⏺</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box flexGrow={1}>{children}</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
/**
|
||||
* Two-column layout for tool results with ⎿ prefix.
|
||||
* Keeps content from wrapping under the prefix.
|
||||
*/
|
||||
const ResultRow: React.FC<{ children: React.ReactNode; isFirst?: boolean }> = ({ children, isFirst }) => (
|
||||
<Box flexDirection="row">
|
||||
<Box width={3}>
|
||||
<Text color="gray">{isFirst ? "⎿ " : " "}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>{children}</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
/**
|
||||
* Get the primary argument to display for a tool (file path, command, url, etc.)
|
||||
*/
|
||||
function getToolMainArg(_toolName: string, args: Record<string, unknown>): string {
|
||||
// Search files: show 'regex' in path
|
||||
if (typeof args.regex === "string" && typeof args.path === "string") {
|
||||
return `'${args.regex}' in ${args.path}`
|
||||
}
|
||||
|
||||
// File path
|
||||
if (typeof args.path === "string") return args.path
|
||||
if (typeof args.file_path === "string") return args.file_path
|
||||
|
||||
// Command - truncate long commands
|
||||
if (typeof args.command === "string") {
|
||||
return args.command.length > 120 ? args.command.substring(0, 117) + "..." : args.command
|
||||
}
|
||||
|
||||
// URL
|
||||
if (typeof args.url === "string") return args.url
|
||||
|
||||
// Search query
|
||||
if (typeof args.query === "string") return args.query
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a tool call in webview style: "Cline wants to read this file:" / "Cline read this file:"
|
||||
*/
|
||||
const ToolCallText: React.FC<{
|
||||
toolName: string
|
||||
args: Record<string, unknown>
|
||||
mode?: "act" | "plan"
|
||||
isAsk?: boolean
|
||||
}> = ({ toolName, args, mode, isAsk = false }) => {
|
||||
const desc = getToolDescription(toolName)
|
||||
const actionText = isAsk ? desc.ask : desc.say
|
||||
const mainArg = getToolMainArg(toolName, args)
|
||||
const toolColor = mode === "plan" ? "yellow" : COLORS.primaryBlue
|
||||
|
||||
return (
|
||||
<Text>
|
||||
<Text color={toolColor}>Cline {actionText}</Text>
|
||||
{mainArg && (
|
||||
<Text>
|
||||
<Text color={toolColor}>: </Text>
|
||||
<Text>{mainArg}</Text>
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text with ellipsis
|
||||
*/
|
||||
function truncate(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text
|
||||
return text.substring(0, maxLength - 3) + "..."
|
||||
}
|
||||
|
||||
/**
|
||||
* Format tool result for display
|
||||
*/
|
||||
function formatToolResult(result: string, maxLines = 5): string[] {
|
||||
const lines = result.split("\n")
|
||||
if (lines.length <= maxLines) {
|
||||
return lines
|
||||
}
|
||||
const displayLines = lines.slice(0, maxLines)
|
||||
displayLines.push(`... ${lines.length - maxLines} more lines`)
|
||||
return displayLines
|
||||
}
|
||||
|
||||
export const ChatMessage: React.FC<ChatMessageProps> = ({ message, mode, isStreaming }) => {
|
||||
const { type, ask, say, text, partial } = message
|
||||
const toolColor = mode === "plan" ? "yellow" : COLORS.primaryBlue
|
||||
const { columns: terminalWidth } = useTerminalSize()
|
||||
|
||||
// User messages (task, user_feedback)
|
||||
// If multi-line, extend background to full width for consistent appearance
|
||||
if (say === "task" || say === "user_feedback") {
|
||||
const content = "> " + (text || "")
|
||||
const isMultiLine = content.includes("\n") || content.length > terminalWidth
|
||||
|
||||
if (isMultiLine) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<Box backgroundColor="blackBright" paddingX={1} width="100%">
|
||||
<Text color="white">{content}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box backgroundColor="blackBright" paddingX={1}>
|
||||
<Text color="white">{content}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Assistant text response (hide reasoning traces - they're verbose and clutter the UI)
|
||||
if (say === "reasoning") {
|
||||
return null
|
||||
}
|
||||
if (say === "text") {
|
||||
if (!text?.trim()) return null
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow>
|
||||
<MarkdownText>{text}</MarkdownText>
|
||||
</DotRow>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Tool calls (ask) and tool results (say)
|
||||
const isToolAsk = type === "ask" && ask === "tool"
|
||||
const isToolSay = say === "tool"
|
||||
if ((isToolAsk || isToolSay) && text) {
|
||||
const toolInfo = parseToolFromMessage(text)
|
||||
if (toolInfo) {
|
||||
const filePath = toolInfo.args.path || toolInfo.args.file_path
|
||||
|
||||
// File edit tools - show diff
|
||||
if (isFileEditTool(toolInfo.toolName) && filePath && toolInfo.args.content) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor} flashing={partial === true && isStreaming}>
|
||||
<ToolCallText args={toolInfo.args} isAsk={isToolAsk} mode={mode} toolName={toolInfo.toolName} />
|
||||
</DotRow>
|
||||
<Box marginLeft={2}>
|
||||
<DiffView content={toolInfo.args.content as string} filePath={filePath as string | undefined} />
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Show result content for completed tools (both say and ask), or file path for pending asks
|
||||
const contentLines = toolInfo.result?.trim()
|
||||
? formatToolResult(toolInfo.result, 5)
|
||||
: (isToolAsk || isToolSay) && filePath
|
||||
? [filePath as string]
|
||||
: []
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor} flashing={partial === true && isStreaming}>
|
||||
<ToolCallText args={toolInfo.args} isAsk={isToolAsk} mode={mode} toolName={toolInfo.toolName} />
|
||||
</DotRow>
|
||||
{contentLines.length > 0 && (
|
||||
<Box flexDirection="column" marginLeft={2} width="100%">
|
||||
{contentLines.map((line, idx) => (
|
||||
<ResultRow isFirst={idx === 0} key={idx}>
|
||||
<Text color="gray">{line}</Text>
|
||||
</ResultRow>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
// Fallback for unparseable tool messages
|
||||
if (isToolSay) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor} flashing={partial === true && isStreaming}>
|
||||
<Text color={toolColor}>{truncate(text, 100)}</Text>
|
||||
</DotRow>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Command execution (ask or say) - now includes combined output
|
||||
if ((type === "ask" && ask === "command") || say === "command") {
|
||||
if (!text) return null
|
||||
|
||||
// Parse command and output from combined text
|
||||
const outputIndex = text.indexOf(COMMAND_OUTPUT_STRING)
|
||||
const command = outputIndex === -1 ? text : text.slice(0, outputIndex).trim()
|
||||
const output = outputIndex === -1 ? "" : text.slice(outputIndex + COMMAND_OUTPUT_STRING.length).trim()
|
||||
|
||||
const isAsk = type === "ask"
|
||||
const label = isAsk ? "Cline wants to execute this command: " : "Cline executed this command: "
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor} flashing={partial === true && isStreaming}>
|
||||
<Text>
|
||||
<Text color={toolColor}>{label}</Text>
|
||||
<Text>{truncate(command, 120)}</Text>
|
||||
</Text>
|
||||
</DotRow>
|
||||
{output && (
|
||||
<Box flexDirection="column" marginLeft={2} width="100%">
|
||||
{formatToolResult(output, 8).map((line, idx) => (
|
||||
<ResultRow isFirst={idx === 0} key={idx}>
|
||||
<Text color="gray">{line}</Text>
|
||||
</ResultRow>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Command output - should not appear after combineCommandSequences, but handle as fallback
|
||||
if (say === "command_output" && text) {
|
||||
const lines = formatToolResult(text, 8)
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<Box flexDirection="column" marginLeft={2} width="100%">
|
||||
{lines.map((line, idx) => (
|
||||
<ResultRow isFirst={idx === 0} key={idx}>
|
||||
<Text color="gray">{line}</Text>
|
||||
</ResultRow>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// MCP approval (ask) or acknowledgment (say)
|
||||
if ((type === "ask" && ask === "use_mcp_server") || say === "use_mcp_server") {
|
||||
const isAsk = type === "ask"
|
||||
const parsed = text
|
||||
? jsonParseSafe<Partial<ClineAskUseMcpServer> & { serverName: string }>(text, {
|
||||
type: undefined,
|
||||
serverName: "unknown server",
|
||||
toolName: undefined,
|
||||
arguments: undefined,
|
||||
uri: undefined,
|
||||
})
|
||||
: undefined
|
||||
|
||||
const serverName = parsed?.serverName || "unknown server"
|
||||
const actionLabel = isAsk ? "Cline wants to use MCP" : "Cline used MCP"
|
||||
const targetLine =
|
||||
parsed?.type === "access_mcp_resource"
|
||||
? `resource: ${parsed?.uri || "unknown"}`
|
||||
: parsed?.type === "use_mcp_tool"
|
||||
? `tool: ${parsed?.toolName || "unknown"}`
|
||||
: "tool: unknown"
|
||||
|
||||
let argsLines: string[] = []
|
||||
if (parsed?.arguments && parsed.arguments.trim() && parsed.arguments !== "{}") {
|
||||
let formattedArgs = parsed.arguments
|
||||
try {
|
||||
formattedArgs = JSON.stringify(JSON.parse(parsed.arguments), null, 2)
|
||||
} catch {
|
||||
// Keep raw string if not valid JSON
|
||||
}
|
||||
argsLines = formatToolResult(formattedArgs, 10)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor} flashing={partial === true && isStreaming}>
|
||||
<Text>
|
||||
<Text color={toolColor}>{actionLabel}</Text>
|
||||
<Text>{`: ${serverName}`}</Text>
|
||||
</Text>
|
||||
</DotRow>
|
||||
<Box flexDirection="column" marginLeft={2} width="100%">
|
||||
<ResultRow isFirst>
|
||||
<Text color="gray">{targetLine}</Text>
|
||||
</ResultRow>
|
||||
{argsLines.length > 0 && (
|
||||
<Box flexDirection="column" paddingLeft={3} width="100%">
|
||||
<Text color="gray">args:</Text>
|
||||
{argsLines.map((line, idx) => (
|
||||
<Text color="gray" key={`mcp-args-${idx}`}>
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if ((type === "ask" && ask === "use_subagents") || say === "use_subagents" || say === "subagent") {
|
||||
return <SubagentMessage isStreaming={isStreaming} message={message} mode={mode} />
|
||||
}
|
||||
|
||||
// MCP response
|
||||
if (say === "mcp_server_response" && text) {
|
||||
const lines = formatToolResult(text, 8)
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor} flashing={partial === true && isStreaming}>
|
||||
<Text color={toolColor}>MCP response</Text>
|
||||
</DotRow>
|
||||
<Box flexDirection="column" marginLeft={2} width="100%">
|
||||
{lines.map((line, idx) => (
|
||||
<ResultRow isFirst={idx === 0} key={idx}>
|
||||
<Text color="gray">{line}</Text>
|
||||
</ResultRow>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Error messages
|
||||
if (say === "clineignore_error") {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color="red">
|
||||
<Text color="red" wrap="wrap">
|
||||
Cline tried to access <Text bold>{text}</Text> which is blocked by the .clineignore file.
|
||||
</Text>
|
||||
</DotRow>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (say === "error" || (type === "ask" && ask === "api_req_failed")) {
|
||||
// Try to parse error message if it's JSON
|
||||
let errorMessage = text || "Unknown error"
|
||||
if (text) {
|
||||
const parsed = jsonParseSafe(text, { message: undefined as string | undefined })
|
||||
if (parsed.message) {
|
||||
errorMessage = parsed.message
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Cline auth error to show sign-in instructions
|
||||
const isClineAuthError = errorMessage.includes(CLINE_ACCOUNT_AUTH_ERROR_MESSAGE)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color="red">
|
||||
<Text color="red" wrap="wrap">
|
||||
<Text bold>Error</Text>: {errorMessage}
|
||||
</Text>
|
||||
</DotRow>
|
||||
{isClineAuthError && (
|
||||
<Box marginLeft={2} marginTop={1}>
|
||||
<Text color="gray">
|
||||
Run <Text color="cyan">/settings</Text> and go to Account to sign in.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Error retry messages
|
||||
if (say === "error_retry" && text) {
|
||||
const retryInfo = jsonParseSafe(text, {
|
||||
failed: false,
|
||||
attempt: 0,
|
||||
maxAttempts: 3,
|
||||
errorMessage: undefined as string | undefined,
|
||||
})
|
||||
|
||||
// Parse nested errorMessage if it's a JSON string
|
||||
let errorMsg = "Request failed"
|
||||
if (retryInfo.errorMessage) {
|
||||
try {
|
||||
const errorObj = jsonParseSafe(retryInfo.errorMessage, { message: undefined as string | undefined })
|
||||
errorMsg = errorObj.message || retryInfo.errorMessage
|
||||
} catch {
|
||||
errorMsg = retryInfo.errorMessage
|
||||
}
|
||||
}
|
||||
|
||||
if (retryInfo.failed) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color="red">
|
||||
<Text bold color="red">
|
||||
Failed
|
||||
</Text>
|
||||
<Text color="red"> after {retryInfo.maxAttempts} retries</Text>
|
||||
</DotRow>
|
||||
<Box marginLeft={2}>
|
||||
<Text color="red" dimColor>
|
||||
{errorMsg}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color="yellow">
|
||||
<Text bold color="yellow">
|
||||
Retrying
|
||||
</Text>
|
||||
<Text color="yellow">
|
||||
... (attempt {retryInfo.attempt}/{retryInfo.maxAttempts})
|
||||
</Text>
|
||||
</DotRow>
|
||||
<Box marginLeft={2}>
|
||||
<Text color="yellow" dimColor>
|
||||
{errorMsg}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Completion result
|
||||
// Only render ask: "completion_result" if it has text - the empty ask is just for UI confirmation
|
||||
if (say === "completion_result" || (type === "ask" && ask === "completion_result" && text)) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color="green">
|
||||
<Text color="green">Task completed</Text>
|
||||
</DotRow>
|
||||
{text && (
|
||||
<Box marginLeft={2}>
|
||||
<MarkdownText color="greenBright">{text}</MarkdownText>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// API request info (show cost/tokens inline)
|
||||
if (say === "api_req_started" && text) {
|
||||
// Skip showing these - they're summarized in the status bar
|
||||
return null
|
||||
}
|
||||
|
||||
// Browser actions
|
||||
if (say === "browser_action" || say === "browser_action_launch") {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor} flashing={partial === true && isStreaming}>
|
||||
<Text>
|
||||
<Text color={toolColor}>Cline used the browser</Text>
|
||||
{text && (
|
||||
<Text>
|
||||
<Text color={toolColor}>: </Text>
|
||||
<Text>{truncate(text, 50)}</Text>
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</DotRow>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// MCP server
|
||||
if (say === "mcp_server_request_started") {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor} flashing={partial === true && isStreaming}>
|
||||
<Text>
|
||||
<Text color={toolColor}>Cline is using an MCP tool</Text>
|
||||
{text && (
|
||||
<Text>
|
||||
<Text color={toolColor}>: </Text>
|
||||
<Text>{truncate(text, 50)}</Text>
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</DotRow>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// MCP notifications
|
||||
if (say === "mcp_notification" && text) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor}>
|
||||
<Text>
|
||||
<Text color={toolColor}>MCP Notification</Text>
|
||||
<Text>: {truncate(text, 120)}</Text>
|
||||
</Text>
|
||||
</DotRow>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Info messages
|
||||
if (say === "info") {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color="gray">
|
||||
<Text color="gray">{text}</Text>
|
||||
</DotRow>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Followup questions from assistant
|
||||
if (type === "ask" && ask === "followup" && text) {
|
||||
const parsed = jsonParseSafe(text, {
|
||||
question: undefined as string | undefined,
|
||||
options: undefined as string[] | undefined,
|
||||
selected: undefined as string | undefined,
|
||||
})
|
||||
if (parsed.question) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow>
|
||||
<MarkdownText>{parsed.question}</MarkdownText>
|
||||
</DotRow>
|
||||
{parsed.options && parsed.options.length > 0 && (
|
||||
<Box flexDirection="column" paddingLeft={2}>
|
||||
{parsed.options.map((opt, idx) => {
|
||||
const isSelected = parsed.selected === opt
|
||||
return (
|
||||
<Text color={isSelected ? "green" : toolColor} key={opt}>
|
||||
{isSelected ? "✓" : `${idx + 1}.`} {opt}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Act mode response (non-blocking progress update)
|
||||
if (type === "ask" && ask === "act_mode_respond" && text) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={toolColor}>
|
||||
<MarkdownText color={toolColor}>{text}</MarkdownText>
|
||||
</DotRow>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Plan mode response
|
||||
if (type === "ask" && ask === "plan_mode_respond" && text) {
|
||||
const parsed = jsonParseSafe(text, { response: undefined as string | undefined })
|
||||
if (parsed.response) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color="yellow">
|
||||
<MarkdownText color="yellow">{parsed.response}</MarkdownText>
|
||||
</DotRow>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Mistake limit reached (ask)
|
||||
if (type === "ask" && ask === "mistake_limit_reached") {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color="red">
|
||||
<Text color="red" wrap="wrap">
|
||||
<Text bold>Error</Text>: {text || "Mistake limit reached."}
|
||||
</Text>
|
||||
</DotRow>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// New task request from assistant
|
||||
if (type === "ask" && ask === "new_task" && text) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={COLORS.primaryBlue}>
|
||||
<Text bold color={COLORS.primaryBlue}>
|
||||
Cline wants to start a new task:
|
||||
</Text>
|
||||
</DotRow>
|
||||
<Box flexDirection="column" paddingLeft={2}>
|
||||
<Text color="gray">{text}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Condense conversation request
|
||||
if (type === "ask" && ask === "condense" && text) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={COLORS.primaryBlue} flashing={partial === true && isStreaming}>
|
||||
<Text bold color={COLORS.primaryBlue}>
|
||||
Cline wants to condense your conversation:
|
||||
</Text>
|
||||
</DotRow>
|
||||
<Box flexDirection="column" paddingLeft={2}>
|
||||
<Text color="gray">{text}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Summarize task request
|
||||
if (type === "ask" && ask === "summarize_task" && text) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={COLORS.primaryBlue} flashing={partial === true && isStreaming}>
|
||||
<Text bold color={COLORS.primaryBlue}>
|
||||
Cline wants to summarize the task:
|
||||
</Text>
|
||||
</DotRow>
|
||||
<Box flexDirection="column" paddingLeft={2}>
|
||||
<Text color="gray">{text}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Report bug request
|
||||
if (type === "ask" && ask === "report_bug" && text) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
||||
<DotRow color={COLORS.primaryBlue} flashing={partial === true && isStreaming}>
|
||||
<Text bold color={COLORS.primaryBlue}>
|
||||
Cline wants to create a Github issue:
|
||||
</Text>
|
||||
</DotRow>
|
||||
<Box flexDirection="column" paddingLeft={2}>
|
||||
<Text color="gray">{text}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Skip other message types
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a list of messages in Claude Code style
|
||||
*/
|
||||
interface ChatMessageListProps {
|
||||
messages: ClineMessage[]
|
||||
maxMessages?: number
|
||||
}
|
||||
|
||||
export const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, maxMessages }) => {
|
||||
// Filter out messages we don't want to display
|
||||
const displayMessages = messages.filter((m) => {
|
||||
// Skip api_req_finished, they're just markers
|
||||
if (m.say === "api_req_finished") return false
|
||||
// Skip hidden aggregated usage messages
|
||||
if (m.say === "subagent_usage") return false
|
||||
// Skip empty text messages
|
||||
if (m.say === "text" && !m.text?.trim()) return false
|
||||
// Skip checkpoint messages
|
||||
if (m.say === "checkpoint_created") return false
|
||||
return true
|
||||
})
|
||||
|
||||
// Optionally limit number of messages shown
|
||||
const messagesToShow = maxMessages ? displayMessages.slice(-maxMessages) : displayMessages
|
||||
|
||||
// Check if last message is streaming
|
||||
const lastMessage = messagesToShow[messagesToShow.length - 1]
|
||||
const isLastStreaming = lastMessage?.partial === true
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{messagesToShow.map((msg, idx) => (
|
||||
<ChatMessage isStreaming={idx === messagesToShow.length - 1 && isLastStreaming} key={msg.ts} message={msg} />
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
/**
|
||||
* Tests for ChatView component exit and cleanup behavior
|
||||
*
|
||||
* These tests verify that when the user exits (via shutdown event 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
|
||||
const delay = (ms = 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", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../utils/slash-commands")>()
|
||||
return {
|
||||
...actual,
|
||||
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,
|
||||
})),
|
||||
getLastApiReqTotalTokens: vi.fn(() => 0),
|
||||
}))
|
||||
|
||||
vi.mock("child_process", () => ({
|
||||
exec: vi.fn(),
|
||||
execFile: vi.fn(),
|
||||
execSync: vi.fn(() => "main"),
|
||||
}))
|
||||
|
||||
// Mock telemetry service to prevent HostProvider errors in shutdown handler
|
||||
vi.mock("@/services/telemetry", () => ({
|
||||
telemetryService: {
|
||||
captureHostEvent: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// 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("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 input when shutdown event fires", async () => {
|
||||
const { lastFrame } = render(<ChatView onExit={mockOnExit} />)
|
||||
|
||||
// Input should be visible initially
|
||||
expect(lastFrame()).toContain("Input:")
|
||||
|
||||
// Fire shutdown event (simulates Ctrl+C)
|
||||
shutdownMockState.fire()
|
||||
await delay()
|
||||
|
||||
// Input should be hidden after shutdown
|
||||
expect(lastFrame()).not.toContain("Input:")
|
||||
})
|
||||
|
||||
it("should preserve footer when shutdown event fires", async () => {
|
||||
const { lastFrame } = render(<ChatView onExit={mockOnExit} />)
|
||||
|
||||
// Footer should be visible initially
|
||||
expect(lastFrame()).toContain("@ for files")
|
||||
|
||||
// Fire shutdown event
|
||||
shutdownMockState.fire()
|
||||
await delay()
|
||||
|
||||
// Footer should still be present (only input is hidden)
|
||||
expect(lastFrame()).toContain("@ for files")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Edge cases", () => {
|
||||
it("should handle shutdown event when onExit prop is undefined", async () => {
|
||||
const { lastFrame } = render(<ChatView />)
|
||||
|
||||
// Fire shutdown event
|
||||
shutdownMockState.fire()
|
||||
await delay()
|
||||
|
||||
// Should not throw, UI should still hide
|
||||
expect(lastFrame()).not.toContain("Input:")
|
||||
})
|
||||
|
||||
it("should handle multiple shutdown events gracefully", async () => {
|
||||
const { lastFrame } = render(<ChatView onExit={mockOnExit} />)
|
||||
|
||||
// Fire multiple shutdown events
|
||||
shutdownMockState.fire()
|
||||
shutdownMockState.fire()
|
||||
shutdownMockState.fire()
|
||||
|
||||
await delay()
|
||||
|
||||
// UI should still hide properly
|
||||
expect(lastFrame()).not.toContain("Input:")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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 } = 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:")
|
||||
|
||||
// Fire shutdown event
|
||||
shutdownMockState.fire()
|
||||
await delay()
|
||||
|
||||
const frameAfter = lastFrame()
|
||||
|
||||
// Static content should still be present
|
||||
expect(frameAfter).toContain("What can I do for you?")
|
||||
// Footer should still be present (only input is hidden)
|
||||
expect(frameAfter).toContain("Auto-approve")
|
||||
// Input should be hidden
|
||||
expect(frameAfter).not.toContain("Input:")
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* Reusable Checkbox component for settings panels
|
||||
*/
|
||||
|
||||
import { Box, Text } from "ink"
|
||||
import React from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
|
||||
interface CheckboxProps {
|
||||
/** Label displayed next to the checkbox */
|
||||
label: string
|
||||
/** Current checked state */
|
||||
checked: boolean
|
||||
/** Whether this checkbox is currently selected/focused */
|
||||
isSelected?: boolean
|
||||
/** Optional description shown below the label */
|
||||
description?: string
|
||||
}
|
||||
|
||||
export const Checkbox: React.FC<CheckboxProps> = ({ label, checked, isSelected = false, description }) => {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text>
|
||||
<Text bold color={isSelected ? COLORS.primaryBlue : undefined}>
|
||||
{isSelected ? "❯" : " "}{" "}
|
||||
</Text>
|
||||
<Text color={isSelected || checked ? COLORS.primaryBlue : "gray"}>{checked ? "[✓]" : "[ ]"}</Text>
|
||||
<Text color={isSelected ? COLORS.primaryBlue : "white"}> {label}</Text>
|
||||
{isSelected && <Text color="gray"> (Tab to toggle)</Text>}
|
||||
</Text>
|
||||
{description && (
|
||||
<Box marginLeft={6}>
|
||||
<Text color="gray">{description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
/**
|
||||
* Checkpoint menu component
|
||||
* Displays available checkpoints and allows user to select one to restore
|
||||
*/
|
||||
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useState } from "react"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { isEnterKey } from "../utils/input"
|
||||
|
||||
export type RestoreType = "task" | "workspace" | "taskAndWorkspace"
|
||||
|
||||
interface CheckpointOption {
|
||||
ts: number
|
||||
hash: string
|
||||
date: Date
|
||||
label: string
|
||||
}
|
||||
|
||||
interface CheckpointMenuProps {
|
||||
messages: ClineMessage[]
|
||||
onSelect: (messageTs: number, restoreType: RestoreType) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract checkpoint options from messages
|
||||
*/
|
||||
function getCheckpointOptions(messages: ClineMessage[]): CheckpointOption[] {
|
||||
const options: CheckpointOption[] = []
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.lastCheckpointHash) {
|
||||
options.push({
|
||||
ts: msg.ts,
|
||||
hash: msg.lastCheckpointHash,
|
||||
date: new Date(msg.ts),
|
||||
label: getCheckpointLabel(msg),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by timestamp descending (newest first)
|
||||
return options.sort((a, b) => b.ts - a.ts)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable label for a checkpoint
|
||||
*/
|
||||
function getCheckpointLabel(msg: ClineMessage): string {
|
||||
if (msg.say === "completion_result") {
|
||||
return "Task completion"
|
||||
}
|
||||
if (msg.say === "checkpoint_created") {
|
||||
return "Checkpoint"
|
||||
}
|
||||
if (msg.say === "api_req_started") {
|
||||
return "API request"
|
||||
}
|
||||
return msg.say || msg.ask || "Message"
|
||||
}
|
||||
|
||||
const RESTORE_TYPE_OPTIONS: { type: RestoreType; label: string; description: string }[] = [
|
||||
{
|
||||
type: "taskAndWorkspace",
|
||||
label: "Task + Workspace",
|
||||
description: "Restore messages and files",
|
||||
},
|
||||
{
|
||||
type: "task",
|
||||
label: "Task Only",
|
||||
description: "Delete messages after this point",
|
||||
},
|
||||
{
|
||||
type: "workspace",
|
||||
label: "Workspace Only",
|
||||
description: "Restore files only",
|
||||
},
|
||||
]
|
||||
|
||||
export const CheckpointMenu: React.FC<CheckpointMenuProps> = ({ messages, onSelect, onCancel }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const checkpoints = getCheckpointOptions(messages)
|
||||
const [selectedCheckpoint, setSelectedCheckpoint] = useState(0)
|
||||
const [selectedRestoreType, setSelectedRestoreType] = useState(0)
|
||||
const [stage, setStage] = useState<"checkpoint" | "restoreType">("checkpoint")
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (key.escape) {
|
||||
if (stage === "restoreType") {
|
||||
setStage("checkpoint")
|
||||
} else {
|
||||
onCancel()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (stage === "checkpoint") {
|
||||
if (key.upArrow) {
|
||||
setSelectedCheckpoint((i) => Math.max(0, i - 1))
|
||||
} else if (key.downArrow) {
|
||||
setSelectedCheckpoint((i) => Math.min(checkpoints.length - 1, i + 1))
|
||||
} else if (isEnterKey(input, key) && checkpoints.length > 0) {
|
||||
setStage("restoreType")
|
||||
}
|
||||
} else if (stage === "restoreType") {
|
||||
if (key.upArrow) {
|
||||
setSelectedRestoreType((i) => Math.max(0, i - 1))
|
||||
} else if (key.downArrow) {
|
||||
setSelectedRestoreType((i) => Math.min(RESTORE_TYPE_OPTIONS.length - 1, i + 1))
|
||||
} else if (isEnterKey(input, key)) {
|
||||
const checkpoint = checkpoints[selectedCheckpoint]
|
||||
const restoreType = RESTORE_TYPE_OPTIONS[selectedRestoreType]
|
||||
if (checkpoint && restoreType) {
|
||||
onSelect(checkpoint.ts, restoreType.type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Quick number selection for checkpoints
|
||||
if (stage === "checkpoint") {
|
||||
const num = Number.parseInt(input, 10)
|
||||
if (!Number.isNaN(num) && num >= 1 && num <= checkpoints.length) {
|
||||
setSelectedCheckpoint(num - 1)
|
||||
setStage("restoreType")
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported },
|
||||
)
|
||||
|
||||
if (checkpoints.length === 0) {
|
||||
return (
|
||||
<Box borderColor="yellow" borderStyle="round" flexDirection="column" marginTop={1} paddingLeft={1} paddingRight={1}>
|
||||
<Text color="yellow">No checkpoints available</Text>
|
||||
<Text color="gray">Checkpoints are created at task completion points</Text>
|
||||
<Text color="gray">Press Escape to close</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (stage === "checkpoint") {
|
||||
return (
|
||||
<Box borderColor="cyan" borderStyle="round" flexDirection="column" marginTop={1} paddingLeft={1} paddingRight={1}>
|
||||
<Text bold color="cyan">
|
||||
Restore Checkpoint
|
||||
</Text>
|
||||
<Text color="gray">Select a checkpoint to restore (↑/↓ or number, Enter to select, Escape to cancel)</Text>
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{checkpoints.map((cp, idx) => {
|
||||
const isSelected = idx === selectedCheckpoint
|
||||
const timeStr = cp.date.toLocaleTimeString()
|
||||
const dateStr = cp.date.toLocaleDateString()
|
||||
return (
|
||||
<Box key={cp.ts}>
|
||||
<Text color={isSelected ? "green" : "gray"}>{isSelected ? "> " : " "}</Text>
|
||||
<Text color={isSelected ? "white" : "gray"}>{idx + 1}. </Text>
|
||||
<Text color={isSelected ? "cyan" : undefined}>{cp.label}</Text>
|
||||
<Text color="gray">
|
||||
{" "}
|
||||
- {dateStr} {timeStr}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Stage: restoreType
|
||||
const selectedCp = checkpoints[selectedCheckpoint]
|
||||
return (
|
||||
<Box borderColor="cyan" borderStyle="round" flexDirection="column" marginTop={1} paddingLeft={1} paddingRight={1}>
|
||||
<Text bold color="cyan">
|
||||
Restore Type
|
||||
</Text>
|
||||
<Text color="gray">
|
||||
Restoring to: {selectedCp?.label} ({selectedCp?.date.toLocaleString()})
|
||||
</Text>
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{RESTORE_TYPE_OPTIONS.map((opt, idx) => {
|
||||
const isSelected = idx === selectedRestoreType
|
||||
return (
|
||||
<Box flexDirection="column" key={opt.type} marginBottom={idx < RESTORE_TYPE_OPTIONS.length - 1 ? 1 : 0}>
|
||||
<Box>
|
||||
<Text color={isSelected ? "green" : "gray"}>{isSelected ? "> " : " "}</Text>
|
||||
<Text bold={isSelected} color={isSelected ? "white" : undefined}>
|
||||
{opt.label}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginLeft={4}>
|
||||
<Text color="gray">{opt.description}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
<Text color="gray">(↑/↓ to select, Enter to confirm, Escape to go back)</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
import { Text } from "ink"
|
||||
import { render } from "ink-testing-library"
|
||||
import React from "react"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
// Create stable mock references using vi.hoisted - must be before any imports that use these modules
|
||||
const { mockIsSettingsKey } = vi.hoisted(() => ({
|
||||
mockIsSettingsKey: vi.fn((key: string) => key.startsWith("act") || key.startsWith("plan") || key === "mode"),
|
||||
}))
|
||||
|
||||
vi.mock("./TaskView", () => ({
|
||||
TaskView: ({ taskId, verbose }: any) =>
|
||||
React.createElement(Text, null, `TaskView: ${taskId || "no-id"} verbose=${String(verbose)}`),
|
||||
}))
|
||||
|
||||
// Mock the state-keys module - must be hoisted before ConfigView import
|
||||
vi.mock("@shared/storage/state-keys", () => ({
|
||||
isSettingsKey: mockIsSettingsKey,
|
||||
SETTINGS_DEFAULTS: {
|
||||
mode: "act",
|
||||
actModeApiProvider: "anthropic",
|
||||
},
|
||||
GlobalStateAndSettings: {},
|
||||
GlobalStateAndSettingsKey: {},
|
||||
LocalState: {},
|
||||
LocalStateKey: {},
|
||||
}))
|
||||
|
||||
// Import ConfigView after mocks are set up
|
||||
import { ConfigView } from "./ConfigView"
|
||||
|
||||
describe("ConfigView", () => {
|
||||
const defaultProps = {
|
||||
dataDir: "/home/user/.cline",
|
||||
globalState: {},
|
||||
workspaceState: {},
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("rendering", () => {
|
||||
it("should render the config header", () => {
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} />)
|
||||
expect(lastFrame()).toContain("Configuration")
|
||||
})
|
||||
|
||||
it("should display the data directory", () => {
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} dataDir="/custom/path" />)
|
||||
expect(lastFrame()).toContain("/custom/path")
|
||||
})
|
||||
|
||||
it("should display global state entries", () => {
|
||||
const { lastFrame } = render(
|
||||
<ConfigView
|
||||
{...defaultProps}
|
||||
globalState={{
|
||||
mode: "act",
|
||||
actModeApiProvider: "anthropic",
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
expect(lastFrame()).toContain("mode")
|
||||
expect(lastFrame()).toContain("act")
|
||||
})
|
||||
|
||||
it("should display workspace state entries", () => {
|
||||
const { lastFrame } = render(
|
||||
<ConfigView
|
||||
{...defaultProps}
|
||||
workspaceState={{
|
||||
customSetting: "value",
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
expect(lastFrame()).toContain("customSetting")
|
||||
expect(lastFrame()).toContain("value")
|
||||
})
|
||||
|
||||
it("should show section headers", () => {
|
||||
const { lastFrame } = render(
|
||||
<ConfigView {...defaultProps} globalState={{ mode: "act" }} workspaceState={{ localKey: "localValue" }} />,
|
||||
)
|
||||
expect(lastFrame()).toContain("Global Settings")
|
||||
})
|
||||
|
||||
it("hides Hooks tab when hooks are disabled", () => {
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} hooksEnabled={false} skillsEnabled={true} />)
|
||||
expect(lastFrame()).not.toContain("Hooks")
|
||||
})
|
||||
|
||||
it("shows Hooks tab when hooks are enabled", () => {
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} hooksEnabled={true} skillsEnabled={true} />)
|
||||
expect(lastFrame()).toContain("Hooks")
|
||||
})
|
||||
})
|
||||
|
||||
describe("value formatting", () => {
|
||||
it("should format boolean values", () => {
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ actModeSomeBool: true }} />)
|
||||
expect(lastFrame()).toContain("true")
|
||||
})
|
||||
|
||||
it("should format number values", () => {
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ actModeNumber: 42 }} />)
|
||||
expect(lastFrame()).toContain("42")
|
||||
})
|
||||
|
||||
it("should truncate long string values", () => {
|
||||
const longString = "x".repeat(100)
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ actModeLongValue: longString }} />)
|
||||
expect(lastFrame()).toContain("...")
|
||||
})
|
||||
|
||||
it("should format object values as JSON", () => {
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ actModeObj: { nested: "value" } }} />)
|
||||
expect(lastFrame()).toContain("nested")
|
||||
})
|
||||
})
|
||||
|
||||
describe("filtering", () => {
|
||||
it("should exclude taskHistory key", () => {
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ taskHistory: [1, 2, 3], mode: "act" }} />)
|
||||
expect(lastFrame()).not.toContain("taskHistory")
|
||||
})
|
||||
|
||||
it("should exclude empty objects", () => {
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ emptyObj: {}, mode: "act" }} />)
|
||||
expect(lastFrame()).not.toContain("emptyObj")
|
||||
})
|
||||
|
||||
it("should exclude empty arrays", () => {
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ emptyArr: [], mode: "act" }} />)
|
||||
expect(lastFrame()).not.toContain("emptyArr")
|
||||
})
|
||||
|
||||
it("should exclude null/undefined values", () => {
|
||||
const { lastFrame } = render(
|
||||
<ConfigView {...defaultProps} globalState={{ nullVal: null, undefinedVal: undefined, mode: "act" }} />,
|
||||
)
|
||||
expect(lastFrame()).not.toContain("nullVal")
|
||||
expect(lastFrame()).not.toContain("undefinedVal")
|
||||
})
|
||||
|
||||
it("should exclude keys ending with Toggles", () => {
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ someToggles: { a: true }, mode: "act" }} />)
|
||||
expect(lastFrame()).not.toContain("someToggles")
|
||||
})
|
||||
|
||||
it("should exclude keys starting with apiConfig_", () => {
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ apiConfig_test: "value", mode: "act" }} />)
|
||||
expect(lastFrame()).not.toContain("apiConfig_test")
|
||||
})
|
||||
})
|
||||
|
||||
describe("keyboard navigation", () => {
|
||||
it("should show navigation help text", () => {
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={{ mode: "act" }} />)
|
||||
expect(lastFrame()).toContain("Navigate")
|
||||
expect(lastFrame()).toContain("Edit")
|
||||
})
|
||||
|
||||
it("should highlight first item by default", () => {
|
||||
const { lastFrame } = render(
|
||||
<ConfigView {...defaultProps} globalState={{ mode: "act", actModeApiProvider: "anthropic" }} />,
|
||||
)
|
||||
// The selected indicator
|
||||
expect(lastFrame()).toContain("❯")
|
||||
})
|
||||
|
||||
it("should navigate down with arrow key", () => {
|
||||
const { lastFrame, stdin } = render(
|
||||
<ConfigView {...defaultProps} globalState={{ actModeFirst: "a", actModeSecond: "b" }} />,
|
||||
)
|
||||
|
||||
// Press down arrow
|
||||
stdin.write("\x1B[B")
|
||||
|
||||
const frame = lastFrame()
|
||||
expect(frame).toContain("❯")
|
||||
})
|
||||
|
||||
it("should navigate up with arrow key", () => {
|
||||
const { lastFrame, stdin } = render(
|
||||
<ConfigView {...defaultProps} globalState={{ actModeFirst: "a", actModeSecond: "b" }} />,
|
||||
)
|
||||
|
||||
// Press down then up
|
||||
stdin.write("\x1B[B")
|
||||
stdin.write("\x1B[A")
|
||||
|
||||
expect(lastFrame()).toContain("❯")
|
||||
})
|
||||
})
|
||||
|
||||
describe("scrolling", () => {
|
||||
it("should show scroll indicators when list is long", () => {
|
||||
const manyEntries: Record<string, string> = {}
|
||||
for (let i = 0; i < 20; i++) {
|
||||
manyEntries[`actModeKey${i}`] = `value${i}`
|
||||
}
|
||||
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} globalState={manyEntries} />)
|
||||
|
||||
expect(lastFrame()).toContain("Showing")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,657 +0,0 @@
|
||||
/**
|
||||
* Interactive config view component for displaying and editing configuration values
|
||||
* Supports tabs for Settings, Rules, Workflows, Hooks, and Skills
|
||||
*/
|
||||
|
||||
import {
|
||||
GlobalStateAndSettings,
|
||||
GlobalStateAndSettingsKey,
|
||||
LocalState,
|
||||
LocalStateKey,
|
||||
SETTINGS_DEFAULTS,
|
||||
} from "@shared/storage/state-keys"
|
||||
import { Box, Text, useApp, useInput } from "ink"
|
||||
import React, { useMemo, useState } from "react"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { fuzzyFilter } from "../utils/fuzzy-search"
|
||||
import {
|
||||
BooleanSelect,
|
||||
buildConfigEntries,
|
||||
buildToggleEntries,
|
||||
ConfigRow,
|
||||
HookInfo,
|
||||
HookRow,
|
||||
MAX_VISIBLE,
|
||||
ObjectEditorPanel,
|
||||
ObjectEditorState,
|
||||
parseValue,
|
||||
SEPARATOR,
|
||||
SectionHeader,
|
||||
SkillInfo,
|
||||
SkillRow,
|
||||
TABS,
|
||||
TabBar,
|
||||
TabView,
|
||||
TextInput,
|
||||
ToggleEntry,
|
||||
ToggleRow,
|
||||
WorkspaceHooks,
|
||||
} from "./ConfigViewComponents"
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
interface ConfigViewProps {
|
||||
dataDir: string
|
||||
globalState: Record<string, unknown>
|
||||
workspaceState: Record<string, unknown>
|
||||
onUpdateGlobal?: (key: GlobalStateAndSettingsKey, value: GlobalStateAndSettings[GlobalStateAndSettingsKey]) => void
|
||||
onUpdateWorkspace?: (key: LocalStateKey, value: LocalState[LocalStateKey]) => void
|
||||
// Rules toggles
|
||||
globalClineRulesToggles?: Record<string, boolean>
|
||||
localClineRulesToggles?: Record<string, boolean>
|
||||
localCursorRulesToggles?: Record<string, boolean>
|
||||
localWindsurfRulesToggles?: Record<string, boolean>
|
||||
localAgentsRulesToggles?: Record<string, boolean>
|
||||
onToggleRule?: (isGlobal: boolean, rulePath: string, enabled: boolean, ruleType: string) => void
|
||||
// Workflow toggles
|
||||
globalWorkflowToggles?: Record<string, boolean>
|
||||
localWorkflowToggles?: Record<string, boolean>
|
||||
onToggleWorkflow?: (isGlobal: boolean, workflowPath: string, enabled: boolean) => void
|
||||
// Hooks
|
||||
hooksEnabled?: boolean
|
||||
globalHooks?: HookInfo[]
|
||||
workspaceHooks?: WorkspaceHooks[]
|
||||
onToggleHook?: (isGlobal: boolean, hookName: string, enabled: boolean, workspaceName?: string) => void
|
||||
// Skills
|
||||
skillsEnabled?: boolean
|
||||
globalSkills?: SkillInfo[]
|
||||
localSkills?: SkillInfo[]
|
||||
onToggleSkill?: (isGlobal: boolean, skillPath: string, enabled: boolean) => void
|
||||
// Open folder callback
|
||||
onOpenFolder?: (folderType: "rules" | "workflows" | "hooks" | "skills", isGlobal: boolean) => void
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Component
|
||||
// ============================================================================
|
||||
|
||||
export const ConfigView: React.FC<ConfigViewProps> = ({
|
||||
dataDir,
|
||||
globalState,
|
||||
workspaceState,
|
||||
onUpdateGlobal,
|
||||
onUpdateWorkspace,
|
||||
globalClineRulesToggles,
|
||||
localClineRulesToggles,
|
||||
localCursorRulesToggles,
|
||||
localWindsurfRulesToggles,
|
||||
localAgentsRulesToggles,
|
||||
onToggleRule,
|
||||
globalWorkflowToggles,
|
||||
localWorkflowToggles,
|
||||
onToggleWorkflow,
|
||||
hooksEnabled,
|
||||
globalHooks = [],
|
||||
workspaceHooks = [],
|
||||
onToggleHook,
|
||||
skillsEnabled,
|
||||
globalSkills = [],
|
||||
localSkills = [],
|
||||
onToggleSkill,
|
||||
onOpenFolder,
|
||||
}) => {
|
||||
const { exit } = useApp()
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const [currentTab, setCurrentTab] = useState<TabView>("settings")
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [editValue, setEditValue] = useState("")
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [objectEditor, setObjectEditor] = useState<ObjectEditorState | null>(null)
|
||||
|
||||
// Build entries for settings tab
|
||||
const configEntries = useMemo(
|
||||
() => [...buildConfigEntries(globalState, "global"), ...buildConfigEntries(workspaceState, "workspace")],
|
||||
[globalState, workspaceState],
|
||||
)
|
||||
|
||||
const filteredConfigEntries = useMemo(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
return configEntries
|
||||
}
|
||||
return fuzzyFilter(configEntries, searchQuery, (entry) => `${entry.key} ${String(entry.value ?? "")}`)
|
||||
}, [configEntries, searchQuery])
|
||||
|
||||
// Build entries for rules tab
|
||||
const ruleEntries = useMemo(() => {
|
||||
const entries: ToggleEntry[] = []
|
||||
entries.push(...buildToggleEntries(globalClineRulesToggles, "global", "cline"))
|
||||
entries.push(...buildToggleEntries(localClineRulesToggles, "workspace", "cline"))
|
||||
entries.push(...buildToggleEntries(localCursorRulesToggles, "workspace", "cursor"))
|
||||
entries.push(...buildToggleEntries(localWindsurfRulesToggles, "workspace", "windsurf"))
|
||||
entries.push(...buildToggleEntries(localAgentsRulesToggles, "workspace", "agents"))
|
||||
return entries
|
||||
}, [
|
||||
globalClineRulesToggles,
|
||||
localClineRulesToggles,
|
||||
localCursorRulesToggles,
|
||||
localWindsurfRulesToggles,
|
||||
localAgentsRulesToggles,
|
||||
])
|
||||
|
||||
// Build entries for workflows tab
|
||||
const workflowEntries = useMemo(() => {
|
||||
const entries: ToggleEntry[] = []
|
||||
entries.push(...buildToggleEntries(globalWorkflowToggles, "global"))
|
||||
entries.push(...buildToggleEntries(localWorkflowToggles, "workspace"))
|
||||
return entries
|
||||
}, [globalWorkflowToggles, localWorkflowToggles])
|
||||
|
||||
// Build flat list of hooks
|
||||
const hookEntries = useMemo(() => {
|
||||
const entries: { hook: HookInfo; isGlobal: boolean; workspaceName?: string }[] = []
|
||||
globalHooks.forEach((hook) => entries.push({ hook, isGlobal: true }))
|
||||
workspaceHooks.forEach((ws) => {
|
||||
ws.hooks.forEach((hook) => entries.push({ hook, isGlobal: false, workspaceName: ws.workspaceName }))
|
||||
})
|
||||
return entries.sort((a, b) => a.hook.name.localeCompare(b.hook.name))
|
||||
}, [globalHooks, workspaceHooks])
|
||||
|
||||
// Build flat list of skills
|
||||
const skillEntries = useMemo(() => {
|
||||
const entries: { skill: SkillInfo; isGlobal: boolean }[] = []
|
||||
globalSkills.forEach((skill) => entries.push({ skill, isGlobal: true }))
|
||||
localSkills.forEach((skill) => entries.push({ skill, isGlobal: false }))
|
||||
return entries.sort((a, b) => a.skill.name.localeCompare(b.skill.name))
|
||||
}, [globalSkills, localSkills])
|
||||
|
||||
// Get current list length based on tab
|
||||
const currentListLength = useMemo(() => {
|
||||
switch (currentTab) {
|
||||
case "settings":
|
||||
return filteredConfigEntries.length
|
||||
case "rules":
|
||||
return ruleEntries.length
|
||||
case "workflows":
|
||||
return workflowEntries.length
|
||||
case "hooks":
|
||||
return hookEntries.length
|
||||
case "skills":
|
||||
return skillEntries.length
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}, [
|
||||
currentTab,
|
||||
filteredConfigEntries.length,
|
||||
ruleEntries.length,
|
||||
workflowEntries.length,
|
||||
hookEntries.length,
|
||||
skillEntries.length,
|
||||
])
|
||||
|
||||
// Get available tabs
|
||||
const availableTabs = useMemo(() => {
|
||||
return TABS.filter((tab) => {
|
||||
if (tab.requiresFlag === "hooks") {
|
||||
return hooksEnabled
|
||||
}
|
||||
if (tab.requiresFlag === "skills") {
|
||||
return skillsEnabled
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [hooksEnabled, skillsEnabled])
|
||||
|
||||
// Reset selection when changing tabs
|
||||
const handleTabChange = (newTab: TabView) => {
|
||||
setCurrentTab(newTab)
|
||||
setSelectedIndex(0)
|
||||
setIsEditing(false)
|
||||
setObjectEditor(null)
|
||||
}
|
||||
|
||||
// Settings tab handlers
|
||||
const selectedConfigEntry = filteredConfigEntries[selectedIndex]
|
||||
|
||||
const handleSettingsSave = (value: string | boolean) => {
|
||||
if (!selectedConfigEntry) {
|
||||
return
|
||||
}
|
||||
const parsed = typeof value === "boolean" ? value : parseValue(value, selectedConfigEntry.type)
|
||||
|
||||
if (selectedConfigEntry.source === "global" && onUpdateGlobal) {
|
||||
onUpdateGlobal(selectedConfigEntry.key as GlobalStateAndSettingsKey, parsed as never)
|
||||
} else if (selectedConfigEntry.source === "workspace" && onUpdateWorkspace) {
|
||||
onUpdateWorkspace(selectedConfigEntry.key as LocalStateKey, parsed as never)
|
||||
}
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
const getObjectAtPath = (root: Record<string, unknown>, path: string[]): Record<string, unknown> => {
|
||||
let current: unknown = root
|
||||
for (const segment of path) {
|
||||
if (!current || typeof current !== "object") {
|
||||
return {}
|
||||
}
|
||||
current = (current as Record<string, unknown>)[segment]
|
||||
}
|
||||
return current && typeof current === "object" ? (current as Record<string, unknown>) : {}
|
||||
}
|
||||
|
||||
const setObjectValueAtPath = (
|
||||
root: Record<string, unknown>,
|
||||
path: string[],
|
||||
key: string,
|
||||
value: unknown,
|
||||
): Record<string, unknown> => {
|
||||
if (path.length === 0) {
|
||||
return { ...root, [key]: value }
|
||||
}
|
||||
const [head, ...rest] = path
|
||||
const child = root[head]
|
||||
const childObj = child && typeof child === "object" ? (child as Record<string, unknown>) : {}
|
||||
return {
|
||||
...root,
|
||||
[head]: setObjectValueAtPath(childObj, rest, key, value),
|
||||
}
|
||||
}
|
||||
|
||||
const persistObjectEditor = (nextObject: Record<string, unknown>, source: "global" | "workspace", key: string) => {
|
||||
if (source === "global" && onUpdateGlobal) {
|
||||
onUpdateGlobal(key as GlobalStateAndSettingsKey, nextObject as never)
|
||||
} else if (source === "workspace" && onUpdateWorkspace) {
|
||||
onUpdateWorkspace(key as LocalStateKey, nextObject as never)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSettingsReset = () => {
|
||||
if (!selectedConfigEntry?.isEditable || selectedConfigEntry.source !== "global") {
|
||||
return
|
||||
}
|
||||
const defaultValue = (SETTINGS_DEFAULTS as Record<string, unknown>)[selectedConfigEntry.key]
|
||||
if (defaultValue !== undefined && onUpdateGlobal) {
|
||||
onUpdateGlobal(selectedConfigEntry.key as GlobalStateAndSettingsKey, defaultValue as never)
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle handlers for rules/workflows/hooks/skills
|
||||
const handleToggle = () => {
|
||||
if (currentTab === "rules" && ruleEntries[selectedIndex] && onToggleRule) {
|
||||
const entry = ruleEntries[selectedIndex]
|
||||
onToggleRule(entry.source === "global", entry.path, !entry.enabled, entry.ruleType || "cline")
|
||||
} else if (currentTab === "workflows" && workflowEntries[selectedIndex] && onToggleWorkflow) {
|
||||
const entry = workflowEntries[selectedIndex]
|
||||
onToggleWorkflow(entry.source === "global", entry.path, !entry.enabled)
|
||||
} else if (currentTab === "hooks" && hookEntries[selectedIndex] && onToggleHook) {
|
||||
const entry = hookEntries[selectedIndex]
|
||||
onToggleHook(entry.isGlobal, entry.hook.name, !entry.hook.enabled, entry.workspaceName)
|
||||
} else if (currentTab === "skills" && skillEntries[selectedIndex] && onToggleSkill) {
|
||||
const entry = skillEntries[selectedIndex]
|
||||
onToggleSkill(entry.isGlobal, entry.skill.path, !entry.skill.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
// Input handling
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (objectEditor) {
|
||||
return
|
||||
}
|
||||
|
||||
if (key.escape) {
|
||||
exit()
|
||||
}
|
||||
|
||||
if (key.leftArrow || key.rightArrow || (input >= "1" && input <= "5")) {
|
||||
const currentTabIndex = availableTabs.findIndex((t) => t.key === currentTab)
|
||||
const targetIdx =
|
||||
input >= "1" && input <= "5"
|
||||
? Number.parseInt(input) - 1
|
||||
: key.leftArrow
|
||||
? (currentTabIndex - 1 + availableTabs.length) % availableTabs.length
|
||||
: (currentTabIndex + 1) % availableTabs.length
|
||||
if (targetIdx >= 0 && targetIdx < availableTabs.length) {
|
||||
handleTabChange(availableTabs[targetIdx].key)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// List navigation (arrow keys and vim-style j/k)
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((i) => (i > 0 ? i - 1 : currentListLength - 1))
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex((i) => (i < currentListLength - 1 ? i + 1 : 0))
|
||||
}
|
||||
|
||||
// Tab-specific actions
|
||||
if (currentTab === "settings") {
|
||||
if ((key.return || key.tab) && selectedConfigEntry?.isEditable) {
|
||||
if (selectedConfigEntry.type === "boolean") {
|
||||
handleSettingsSave(!selectedConfigEntry.value)
|
||||
return
|
||||
}
|
||||
if (selectedConfigEntry.type === "object") {
|
||||
const value =
|
||||
selectedConfigEntry.value && typeof selectedConfigEntry.value === "object"
|
||||
? (selectedConfigEntry.value as Record<string, unknown>)
|
||||
: {}
|
||||
setObjectEditor({
|
||||
source: selectedConfigEntry.source,
|
||||
key: selectedConfigEntry.key,
|
||||
path: [],
|
||||
value,
|
||||
selectedIndex: 0,
|
||||
isEditingValue: false,
|
||||
editValue: "",
|
||||
})
|
||||
return
|
||||
}
|
||||
setEditValue(selectedConfigEntry.value !== undefined ? String(selectedConfigEntry.value) : "")
|
||||
setIsEditing(true)
|
||||
} else if (key.ctrl && input.toLowerCase() === "r") {
|
||||
handleSettingsReset()
|
||||
} else if (key.backspace || key.delete) {
|
||||
setSearchQuery((prev) => prev.slice(0, -1))
|
||||
} else if (input && !key.ctrl && !key.meta && !key.escape && !key.upArrow && !key.downArrow) {
|
||||
setSearchQuery((prev) => prev + input)
|
||||
}
|
||||
} else if (key.return || key.tab || input === " ") {
|
||||
// Toggle for rules/workflows/hooks/skills
|
||||
handleToggle()
|
||||
}
|
||||
|
||||
// Open folder (for rules/workflows/hooks/skills tabs)
|
||||
if (input === "o" && onOpenFolder && currentTab !== "settings") {
|
||||
// Determine if current selection is global or workspace based on the selected entry
|
||||
let isGlobal = true
|
||||
if (currentTab === "rules" && ruleEntries[selectedIndex]) {
|
||||
isGlobal = ruleEntries[selectedIndex].source === "global"
|
||||
} else if (currentTab === "workflows" && workflowEntries[selectedIndex]) {
|
||||
isGlobal = workflowEntries[selectedIndex].source === "global"
|
||||
} else if (currentTab === "hooks" && hookEntries[selectedIndex]) {
|
||||
isGlobal = hookEntries[selectedIndex].isGlobal
|
||||
} else if (currentTab === "skills" && skillEntries[selectedIndex]) {
|
||||
isGlobal = skillEntries[selectedIndex].isGlobal
|
||||
}
|
||||
onOpenFolder(currentTab as "rules" | "workflows" | "hooks" | "skills", isGlobal)
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported && !isEditing },
|
||||
)
|
||||
|
||||
// Scrolling window
|
||||
const halfVisible = Math.floor(MAX_VISIBLE / 2)
|
||||
const startIndex = Math.max(0, Math.min(selectedIndex - halfVisible, currentListLength - MAX_VISIBLE))
|
||||
|
||||
// Edit mode UI (settings only)
|
||||
if (isEditing && selectedConfigEntry && currentTab === "settings") {
|
||||
const header = (
|
||||
<React.Fragment>
|
||||
<Text bold color="white">
|
||||
⚙️ Edit Configuration
|
||||
</Text>
|
||||
<Text color="gray">{SEPARATOR}</Text>
|
||||
</React.Fragment>
|
||||
)
|
||||
|
||||
if (selectedConfigEntry.type === "boolean") {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{header}
|
||||
<BooleanSelect
|
||||
label={selectedConfigEntry.key}
|
||||
onCancel={() => setIsEditing(false)}
|
||||
onSelect={handleSettingsSave}
|
||||
value={Boolean(selectedConfigEntry.value)}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{header}
|
||||
<TextInput
|
||||
label={selectedConfigEntry.key}
|
||||
onCancel={() => setIsEditing(false)}
|
||||
onChange={setEditValue}
|
||||
onSubmit={handleSettingsSave}
|
||||
type={selectedConfigEntry.type}
|
||||
value={editValue}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (objectEditor && currentTab === "settings") {
|
||||
return (
|
||||
<ObjectEditorPanel
|
||||
getObjectAtPath={getObjectAtPath}
|
||||
onClose={() => setObjectEditor(null)}
|
||||
onPersist={(nextObject) => persistObjectEditor(nextObject, objectEditor.source, objectEditor.key)}
|
||||
setObjectValueAtPath={setObjectValueAtPath}
|
||||
setState={setObjectEditor}
|
||||
state={objectEditor}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Render tab content
|
||||
const renderTabContent = () => {
|
||||
switch (currentTab) {
|
||||
case "settings": {
|
||||
const visibleEntries = filteredConfigEntries.slice(startIndex, startIndex + MAX_VISIBLE)
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Box>
|
||||
<Text>Search: </Text>
|
||||
<Text color="white">{searchQuery}</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>Data directory: </Text>
|
||||
<Text color="blue" underline>
|
||||
{dataDir}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color="gray">{SEPARATOR}</Text>
|
||||
<Box flexDirection="column">
|
||||
{visibleEntries.map((entry, idx) => {
|
||||
const actualIndex = startIndex + idx
|
||||
const prevEntry = visibleEntries[idx - 1]
|
||||
const showHeader = !prevEntry || prevEntry.source !== entry.source
|
||||
|
||||
return (
|
||||
<React.Fragment key={`${entry.source}-${entry.key}`}>
|
||||
{showHeader && (
|
||||
<SectionHeader
|
||||
title={entry.source === "global" ? "Global Settings:" : "Workspace Settings:"}
|
||||
/>
|
||||
)}
|
||||
<ConfigRow entry={entry} isSelected={actualIndex === selectedIndex} />
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
case "rules": {
|
||||
if (ruleEntries.length === 0) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color="gray">
|
||||
No rules configured. Add .clinerules files to your workspace or global config.
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
const visibleEntries = ruleEntries.slice(startIndex, startIndex + MAX_VISIBLE)
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{visibleEntries.map((entry, idx) => {
|
||||
const actualIndex = startIndex + idx
|
||||
const prevEntry = visibleEntries[idx - 1]
|
||||
const showHeader = !prevEntry || prevEntry.source !== entry.source
|
||||
|
||||
return (
|
||||
<React.Fragment key={`${entry.source}-${entry.path}`}>
|
||||
{showHeader && (
|
||||
<SectionHeader title={entry.source === "global" ? "Global Rules:" : "Workspace Rules:"} />
|
||||
)}
|
||||
<ToggleRow entry={entry} isSelected={actualIndex === selectedIndex} showType />
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
case "workflows": {
|
||||
if (workflowEntries.length === 0) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color="gray">No workflows configured. Add workflow files to enable this feature.</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
const visibleEntries = workflowEntries.slice(startIndex, startIndex + MAX_VISIBLE)
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{visibleEntries.map((entry, idx) => {
|
||||
const actualIndex = startIndex + idx
|
||||
const prevEntry = visibleEntries[idx - 1]
|
||||
const showHeader = !prevEntry || prevEntry.source !== entry.source
|
||||
|
||||
return (
|
||||
<React.Fragment key={`${entry.source}-${entry.path}`}>
|
||||
{showHeader && (
|
||||
<SectionHeader
|
||||
title={entry.source === "global" ? "Global Workflows:" : "Workspace Workflows:"}
|
||||
/>
|
||||
)}
|
||||
<ToggleRow entry={entry} isSelected={actualIndex === selectedIndex} />
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
case "hooks": {
|
||||
if (hookEntries.length === 0) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color="gray">No hooks configured. Add hook scripts to enable automation.</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
const visibleEntries = hookEntries.slice(startIndex, startIndex + MAX_VISIBLE)
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{visibleEntries.map((entry, idx) => {
|
||||
const actualIndex = startIndex + idx
|
||||
const prevEntry = visibleEntries[idx - 1]
|
||||
const showHeader =
|
||||
!prevEntry ||
|
||||
prevEntry.isGlobal !== entry.isGlobal ||
|
||||
prevEntry.workspaceName !== entry.workspaceName
|
||||
|
||||
let sectionTitle = "Global Hooks:"
|
||||
if (!entry.isGlobal && entry.workspaceName) {
|
||||
sectionTitle = `${entry.workspaceName} Hooks:`
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment key={`${entry.isGlobal}-${entry.workspaceName || ""}-${entry.hook.name}`}>
|
||||
{showHeader && <SectionHeader title={sectionTitle} />}
|
||||
<HookRow hook={entry.hook} isSelected={actualIndex === selectedIndex} />
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
case "skills": {
|
||||
if (skillEntries.length === 0) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color="gray">No skills configured. Add SKILL.md files to enable skills.</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
const visibleEntries = skillEntries.slice(startIndex, startIndex + MAX_VISIBLE)
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{visibleEntries.map((entry, idx) => {
|
||||
const actualIndex = startIndex + idx
|
||||
const prevEntry = visibleEntries[idx - 1]
|
||||
const showHeader = !prevEntry || prevEntry.isGlobal !== entry.isGlobal
|
||||
|
||||
return (
|
||||
<React.Fragment key={`${entry.isGlobal}-${entry.skill.path}`}>
|
||||
{showHeader && (
|
||||
<SectionHeader title={entry.isGlobal ? "Global Skills:" : "Workspace Skills:"} />
|
||||
)}
|
||||
<SkillRow isSelected={actualIndex === selectedIndex} skill={entry.skill} />
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Help text based on current tab
|
||||
const getHelpText = () => {
|
||||
const base = "↑/↓ Navigate • ←/→ tabs • 1-5 tabs • Esc Exit"
|
||||
if (currentTab === "settings") {
|
||||
return `${base} • Type to search • Enter/Tab Edit (booleans toggle) • Backspace clear search • Ctrl+R Reset`
|
||||
}
|
||||
const openFolder = onOpenFolder ? " • o Open folder" : ""
|
||||
return `${base} • Enter/Tab/Space Toggle${openFolder}`
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color="white">
|
||||
⚙️ Cline Configuration
|
||||
</Text>
|
||||
<Text color="gray">{SEPARATOR}</Text>
|
||||
|
||||
<TabBar currentTab={currentTab} hooksEnabled={hooksEnabled} skillsEnabled={skillsEnabled} tabs={TABS} />
|
||||
|
||||
<Text color="gray">{SEPARATOR}</Text>
|
||||
|
||||
{renderTabContent()}
|
||||
|
||||
{currentListLength > MAX_VISIBLE && (
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">
|
||||
{startIndex > 0 ? "↑ " : " "}
|
||||
Showing {startIndex + 1}-{Math.min(startIndex + MAX_VISIBLE, currentListLength)} of {currentListLength}
|
||||
{startIndex + MAX_VISIBLE < currentListLength ? " ↓" : " "}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Text color="gray">{SEPARATOR}</Text>
|
||||
|
||||
<Box flexDirection="column">
|
||||
<Text color="gray">{getHelpText()}</Text>
|
||||
{currentTab === "settings" && selectedConfigEntry && !selectedConfigEntry.isEditable && (
|
||||
<Text color="yellow">This field is read-only ({selectedConfigEntry.type} type or not a setting)</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,556 +0,0 @@
|
||||
/**
|
||||
* Sub-components and types for ConfigView
|
||||
*/
|
||||
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useState } from "react"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
|
||||
// ============================================================================
|
||||
// Types & Constants
|
||||
// ============================================================================
|
||||
|
||||
export type ValueType = "string" | "number" | "boolean" | "object" | "undefined"
|
||||
export type TabView = "settings" | "rules" | "workflows" | "hooks" | "skills"
|
||||
|
||||
export interface ConfigEntry {
|
||||
key: string
|
||||
value: unknown
|
||||
type: ValueType
|
||||
isEditable: boolean
|
||||
source: "global" | "workspace"
|
||||
}
|
||||
|
||||
export interface ToggleEntry {
|
||||
path: string
|
||||
enabled: boolean
|
||||
source: "global" | "workspace" | "remote"
|
||||
ruleType?: string
|
||||
}
|
||||
|
||||
export interface HookInfo {
|
||||
name: string
|
||||
enabled: boolean
|
||||
absolutePath: string
|
||||
}
|
||||
|
||||
export interface WorkspaceHooks {
|
||||
workspaceName: string
|
||||
hooks: HookInfo[]
|
||||
}
|
||||
|
||||
export interface SkillInfo {
|
||||
name: string
|
||||
description: string
|
||||
path: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface ObjectEditorState {
|
||||
source: "global" | "workspace"
|
||||
key: string
|
||||
path: string[]
|
||||
value: Record<string, unknown>
|
||||
selectedIndex: number
|
||||
isEditingValue: boolean
|
||||
editValue: string
|
||||
}
|
||||
|
||||
export const EXCLUDED_KEYS = new Set([
|
||||
"taskHistory",
|
||||
"primaryRootIndex",
|
||||
"welcomeViewCompleted",
|
||||
"isNewUser",
|
||||
"cliKanbanMigrationAnnouncementShown",
|
||||
])
|
||||
|
||||
export const EDITABLE_TYPES: Set<ValueType> = new Set(["string", "number", "boolean", "object"])
|
||||
export const MAX_VISIBLE = 12
|
||||
export const SEPARATOR = "─".repeat(80)
|
||||
|
||||
export const TABS: { key: TabView; label: string; requiresFlag?: "hooks" | "skills" }[] = [
|
||||
{ key: "settings", label: "Settings" },
|
||||
{ key: "rules", label: "Rules" },
|
||||
{ key: "workflows", label: "Workflows" },
|
||||
{ key: "hooks", label: "Hooks", requiresFlag: "hooks" },
|
||||
{ key: "skills", label: "Skills", requiresFlag: "skills" },
|
||||
]
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
export function getValueType(value: unknown): ValueType {
|
||||
if (value === undefined || value === null) {
|
||||
return "undefined"
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return "boolean"
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return "number"
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return "object"
|
||||
}
|
||||
return "string"
|
||||
}
|
||||
|
||||
export function isExcluded(key: string, value: unknown): boolean {
|
||||
if (EXCLUDED_KEYS.has(key)) {
|
||||
return true
|
||||
}
|
||||
if (key.endsWith("Toggles") || key.endsWith("ModelInfo")) {
|
||||
return true
|
||||
}
|
||||
if (key.startsWith("apiConfig_") || key.startsWith("last")) {
|
||||
return true
|
||||
}
|
||||
if (value === undefined || value === null) {
|
||||
return true
|
||||
}
|
||||
if (typeof value === "object" && Object.keys(value as object).length === 0) {
|
||||
return true
|
||||
}
|
||||
if (Array.isArray(value) && value.length === 0) {
|
||||
return true
|
||||
}
|
||||
if (typeof value === "string" && value.trim() === "") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function formatValue(value: unknown, maxLen = 50): string {
|
||||
if (value === undefined || value === null) {
|
||||
return "<not set>"
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return value ? "true" : "false"
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return String(value)
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
const json = JSON.stringify(value)
|
||||
return json.length > maxLen ? json.slice(0, maxLen - 3) + "..." : json
|
||||
}
|
||||
const str = String(value)
|
||||
return str.length > maxLen ? str.slice(0, maxLen - 3) + "..." : str
|
||||
}
|
||||
|
||||
export function parseValue(input: string, type: ValueType): unknown {
|
||||
if (type === "boolean") {
|
||||
return input.toLowerCase() === "true" || input === "1"
|
||||
}
|
||||
if (type === "number") {
|
||||
const num = Number.parseFloat(input)
|
||||
return Number.isNaN(num) ? 0 : num
|
||||
}
|
||||
if (type === "object") {
|
||||
try {
|
||||
return JSON.parse(input)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
// Import isSettingsKey at module level for proper test mocking
|
||||
import { isSettingsKey } from "@shared/storage/state-keys"
|
||||
|
||||
export function buildConfigEntries(state: Record<string, unknown>, source: "global" | "workspace"): ConfigEntry[] {
|
||||
return Object.entries(state)
|
||||
.filter(([key, value]) => !isExcluded(key, value))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, value]) => {
|
||||
const type = getValueType(value)
|
||||
const isEditable = EDITABLE_TYPES.has(type) && (source === "workspace" || isSettingsKey(key))
|
||||
return { key, value, type, isEditable, source }
|
||||
})
|
||||
}
|
||||
|
||||
export function buildToggleEntries(
|
||||
toggles: Record<string, boolean> | undefined,
|
||||
source: "global" | "workspace" | "remote",
|
||||
ruleType?: string,
|
||||
): ToggleEntry[] {
|
||||
if (!toggles) {
|
||||
return []
|
||||
}
|
||||
return Object.entries(toggles)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([path, enabled]) => ({ path, enabled, source, ruleType }))
|
||||
}
|
||||
|
||||
export function getFileName(path: string): string {
|
||||
return path.split("/").pop() || path
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Sub-components
|
||||
// ============================================================================
|
||||
|
||||
interface TextInputProps {
|
||||
label: string
|
||||
onChange: (value: string) => void
|
||||
onCancel: () => void
|
||||
onSubmit: (value: string) => void
|
||||
type: ValueType
|
||||
value: string
|
||||
}
|
||||
|
||||
export const TextInput: React.FC<TextInputProps> = ({ label, onChange, onCancel, onSubmit, type, value }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (key.escape) {
|
||||
onCancel()
|
||||
} else if (key.return) {
|
||||
onSubmit(value)
|
||||
} else if (key.backspace || key.delete) {
|
||||
onChange(value.slice(0, -1))
|
||||
} else if (input && !key.ctrl && !key.meta) {
|
||||
onChange(value + input)
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported },
|
||||
)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold color="cyan">
|
||||
Edit: {label}
|
||||
</Text>
|
||||
<Box>
|
||||
<Text color="white">{value}</Text>
|
||||
<Text color="cyan">|</Text>
|
||||
</Box>
|
||||
<Text color="gray">Type: {type} • Enter to save • Esc to cancel</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
interface BooleanSelectProps {
|
||||
label: string
|
||||
onCancel: () => void
|
||||
onSelect: (value: boolean) => void
|
||||
value: boolean
|
||||
}
|
||||
|
||||
export const BooleanSelect: React.FC<BooleanSelectProps> = ({ label, onCancel, onSelect, value }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const [selected, setSelected] = useState(value)
|
||||
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
if (key.escape) {
|
||||
onCancel()
|
||||
} else if (key.return) {
|
||||
onSelect(selected)
|
||||
} else if (key.upArrow || key.downArrow) {
|
||||
setSelected((prev) => !prev)
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported },
|
||||
)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold color="cyan">
|
||||
Edit: {label}
|
||||
</Text>
|
||||
<Box flexDirection="column">
|
||||
<Text color={selected ? "green" : undefined}>{selected ? "❯ " : " "}true</Text>
|
||||
<Text color={!selected ? "green" : undefined}>{!selected ? "❯ " : " "}false</Text>
|
||||
</Box>
|
||||
<Text color="gray">↑/↓ to toggle • Enter to save • Esc to cancel</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const ConfigRow: React.FC<{ entry: ConfigEntry; isSelected: boolean }> = ({ entry, isSelected }) => {
|
||||
const valueColor = entry.type === "boolean" ? (entry.value ? "green" : "red") : "white"
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color={isSelected ? "cyan" : undefined}>
|
||||
{isSelected ? "❯ " : " "}
|
||||
<Text color="cyan">{entry.key}</Text>
|
||||
<Text color="gray">: </Text>
|
||||
<Text color={valueColor}>{formatValue(entry.value)}</Text>
|
||||
{!entry.isEditable && <Text color="gray"> (read-only)</Text>}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const ToggleRow: React.FC<{
|
||||
entry: ToggleEntry
|
||||
isSelected: boolean
|
||||
showType?: boolean
|
||||
}> = ({ entry, isSelected, showType }) => {
|
||||
const fileName = getFileName(entry.path)
|
||||
const typeLabel = entry.ruleType ? ` [${entry.ruleType}]` : ""
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color={isSelected ? "cyan" : undefined}>
|
||||
{isSelected ? "❯ " : " "}
|
||||
<Text color={entry.enabled ? "green" : "red"}>{entry.enabled ? "●" : "○"}</Text>
|
||||
<Text> </Text>
|
||||
<Text color="white">{fileName}</Text>
|
||||
{showType && <Text color="gray">{typeLabel}</Text>}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const HookRow: React.FC<{
|
||||
hook: HookInfo
|
||||
isSelected: boolean
|
||||
}> = ({ hook, isSelected }) => {
|
||||
return (
|
||||
<Box>
|
||||
<Text color={isSelected ? "cyan" : undefined}>
|
||||
{isSelected ? "❯ " : " "}
|
||||
<Text color={hook.enabled ? "green" : "red"}>{hook.enabled ? "●" : "○"}</Text>
|
||||
<Text> </Text>
|
||||
<Text color="white">{hook.name}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const SkillRow: React.FC<{
|
||||
skill: SkillInfo
|
||||
isSelected: boolean
|
||||
}> = ({ skill, isSelected }) => {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text color={isSelected ? "cyan" : undefined}>
|
||||
{isSelected ? "❯ " : " "}
|
||||
<Text color={skill.enabled ? "green" : "red"}>{skill.enabled ? "●" : "○"}</Text>
|
||||
<Text> </Text>
|
||||
<Text bold color="white">
|
||||
{skill.name}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
{skill.description && (
|
||||
<Box marginLeft={4}>
|
||||
<Text color="gray">
|
||||
{skill.description.length > 60 ? skill.description.slice(0, 57) + "..." : skill.description}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const TabBar: React.FC<{
|
||||
currentTab: TabView
|
||||
tabs: typeof TABS
|
||||
hooksEnabled?: boolean
|
||||
skillsEnabled?: boolean
|
||||
}> = ({ currentTab, tabs, hooksEnabled, skillsEnabled }) => {
|
||||
const visibleTabs = tabs.filter((tab) => {
|
||||
if (tab.requiresFlag === "hooks") {
|
||||
return hooksEnabled
|
||||
}
|
||||
if (tab.requiresFlag === "skills") {
|
||||
return skillsEnabled
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return (
|
||||
<Box marginBottom={1}>
|
||||
{visibleTabs.map((tab, idx) => (
|
||||
<React.Fragment key={tab.key}>
|
||||
{idx > 0 && <Text color="gray"> │ </Text>}
|
||||
<Text bold={currentTab === tab.key} color={currentTab === tab.key ? "cyan" : "gray"}>
|
||||
{currentTab === tab.key ? `[${tab.label}]` : tab.label}
|
||||
</Text>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const SectionHeader: React.FC<{ title: string }> = ({ title }) => (
|
||||
<Box marginTop={1}>
|
||||
<Text bold color="yellow">
|
||||
{title}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
|
||||
interface ObjectEditorPanelProps {
|
||||
state: ObjectEditorState
|
||||
setState: React.Dispatch<React.SetStateAction<ObjectEditorState | null>>
|
||||
onClose: () => void
|
||||
onPersist: (nextObject: Record<string, unknown>) => void
|
||||
getObjectAtPath: (root: Record<string, unknown>, path: string[]) => Record<string, unknown>
|
||||
setObjectValueAtPath: (root: Record<string, unknown>, path: string[], key: string, value: unknown) => Record<string, unknown>
|
||||
}
|
||||
|
||||
export const ObjectEditorPanel: React.FC<ObjectEditorPanelProps> = ({
|
||||
state,
|
||||
setState,
|
||||
onClose,
|
||||
onPersist,
|
||||
getObjectAtPath,
|
||||
setObjectValueAtPath,
|
||||
}) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const currentNode = getObjectAtPath(state.value, state.path)
|
||||
const objectEntries = Object.entries(currentNode).sort(([a], [b]) => a.localeCompare(b))
|
||||
const selectedEntry = objectEntries[state.selectedIndex]
|
||||
const breadcrumb = [state.key, ...state.path].join(" › ")
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (state.isEditingValue) {
|
||||
if (key.escape) {
|
||||
setState((prev) => (prev ? { ...prev, isEditingValue: false, editValue: "" } : prev))
|
||||
return
|
||||
}
|
||||
if (key.return) {
|
||||
if (!selectedEntry) {
|
||||
setState((prev) => (prev ? { ...prev, isEditingValue: false, editValue: "" } : prev))
|
||||
return
|
||||
}
|
||||
const [entryKey, entryValue] = selectedEntry
|
||||
let parsed: unknown = state.editValue
|
||||
if (typeof entryValue === "boolean") {
|
||||
parsed = state.editValue.toLowerCase() === "true" || state.editValue === "1"
|
||||
} else if (typeof entryValue === "number") {
|
||||
const maybeNum = Number(state.editValue)
|
||||
parsed = Number.isNaN(maybeNum) ? 0 : maybeNum
|
||||
}
|
||||
const nextObject = setObjectValueAtPath(state.value, state.path, entryKey, parsed)
|
||||
onPersist(nextObject)
|
||||
setState((prev) => (prev ? { ...prev, value: nextObject, isEditingValue: false, editValue: "" } : prev))
|
||||
return
|
||||
}
|
||||
if (key.backspace || key.delete) {
|
||||
setState((prev) => (prev ? { ...prev, editValue: prev.editValue.slice(0, -1) } : prev))
|
||||
return
|
||||
}
|
||||
if (input && !key.ctrl && !key.meta) {
|
||||
setState((prev) => (prev ? { ...prev, editValue: prev.editValue + input } : prev))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (key.escape) {
|
||||
if (state.path.length > 0) {
|
||||
setState((prev) => (prev ? { ...prev, path: prev.path.slice(0, -1), selectedIndex: 0 } : prev))
|
||||
} else {
|
||||
onClose()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (key.upArrow || input === "k") {
|
||||
setState((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
selectedIndex:
|
||||
objectEntries.length > 0
|
||||
? prev.selectedIndex > 0
|
||||
? prev.selectedIndex - 1
|
||||
: objectEntries.length - 1
|
||||
: 0,
|
||||
}
|
||||
: prev,
|
||||
)
|
||||
return
|
||||
}
|
||||
if (key.downArrow || input === "j") {
|
||||
setState((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
selectedIndex:
|
||||
objectEntries.length > 0
|
||||
? prev.selectedIndex < objectEntries.length - 1
|
||||
? prev.selectedIndex + 1
|
||||
: 0
|
||||
: 0,
|
||||
}
|
||||
: prev,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (key.return || key.tab) {
|
||||
if (!selectedEntry) {
|
||||
return
|
||||
}
|
||||
const [entryKey, entryValue] = selectedEntry
|
||||
if (typeof entryValue === "boolean") {
|
||||
const nextObject = setObjectValueAtPath(state.value, state.path, entryKey, !entryValue)
|
||||
onPersist(nextObject)
|
||||
setState((prev) => (prev ? { ...prev, value: nextObject } : prev))
|
||||
return
|
||||
}
|
||||
if (entryValue && typeof entryValue === "object" && !Array.isArray(entryValue)) {
|
||||
setState((prev) => (prev ? { ...prev, path: [...prev.path, entryKey], selectedIndex: 0 } : prev))
|
||||
return
|
||||
}
|
||||
setState((prev) =>
|
||||
prev
|
||||
? { ...prev, isEditingValue: true, editValue: entryValue !== undefined ? String(entryValue) : "" }
|
||||
: prev,
|
||||
)
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported },
|
||||
)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color="white">
|
||||
⚙️ Edit Nested Object
|
||||
</Text>
|
||||
<Text color="gray">{SEPARATOR}</Text>
|
||||
<Text color="cyan">{breadcrumb}</Text>
|
||||
{state.isEditingValue ? (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text color="white">{state.editValue}</Text>
|
||||
<Text color="cyan">|</Text>
|
||||
</Box>
|
||||
<Text color="gray">Enter to save • Esc to cancel</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{objectEntries.length === 0 ? (
|
||||
<Text color="gray">No nested keys at this level.</Text>
|
||||
) : (
|
||||
objectEntries.map(([key, value], idx) => {
|
||||
const isSelected = idx === state.selectedIndex
|
||||
const valueText =
|
||||
value && typeof value === "object" && !Array.isArray(value) ? "{...}" : String(value)
|
||||
return (
|
||||
<Text color={isSelected ? "cyan" : undefined} key={key}>
|
||||
{isSelected ? "❯ " : " "}
|
||||
<Text color="cyan">{key}</Text>
|
||||
<Text color="gray">: </Text>
|
||||
<Text color="white">{valueText}</Text>
|
||||
</Text>
|
||||
)
|
||||
})
|
||||
)}
|
||||
<Text color="gray">↑/↓ Navigate • Enter/Tab Edit or drill in • Esc Back/Close</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
/**
|
||||
* Stateful wrapper for ConfigView that handles toggle operations
|
||||
*/
|
||||
|
||||
import { exec } from "node:child_process"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
|
||||
import { RuleScope } from "@shared/proto/cline/file"
|
||||
import type { GlobalStateAndSettings, GlobalStateAndSettingsKey, LocalState, LocalStateKey } from "@shared/storage/state-keys"
|
||||
import React, { useCallback, useEffect, useState } from "react"
|
||||
|
||||
import type { Controller } from "@/core/controller"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { StdinProvider } from "../context/StdinContext"
|
||||
import { ConfigView } from "./ConfigView"
|
||||
|
||||
interface HookInfo {
|
||||
name: string
|
||||
enabled: boolean
|
||||
absolutePath: string
|
||||
}
|
||||
|
||||
interface WorkspaceHooks {
|
||||
workspaceName: string
|
||||
hooks: HookInfo[]
|
||||
}
|
||||
|
||||
interface SkillInfo {
|
||||
name: string
|
||||
description: string
|
||||
path: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface ConfigViewWrapperProps {
|
||||
controller: Controller
|
||||
dataDir: string
|
||||
globalState: Record<string, unknown>
|
||||
workspaceState: Record<string, unknown>
|
||||
hooksEnabled: boolean
|
||||
skillsEnabled: boolean
|
||||
isRawModeSupported?: boolean
|
||||
}
|
||||
|
||||
export const ConfigViewWrapper: React.FC<ConfigViewWrapperProps> = ({
|
||||
controller,
|
||||
dataDir,
|
||||
globalState: initialGlobalState,
|
||||
workspaceState: initialWorkspaceState,
|
||||
hooksEnabled,
|
||||
skillsEnabled,
|
||||
isRawModeSupported = true,
|
||||
}) => {
|
||||
// Settings state (managed locally for UI updates)
|
||||
const [globalStateLocal, setGlobalStateLocal] = useState<Record<string, unknown>>(initialGlobalState)
|
||||
const [workspaceStateLocal, setWorkspaceStateLocal] = useState<Record<string, unknown>>(initialWorkspaceState)
|
||||
|
||||
// Rules state
|
||||
const [globalClineRulesToggles, setGlobalClineRulesToggles] = useState<Record<string, boolean>>({})
|
||||
const [localClineRulesToggles, setLocalClineRulesToggles] = useState<Record<string, boolean>>({})
|
||||
const [localCursorRulesToggles, setLocalCursorRulesToggles] = useState<Record<string, boolean>>({})
|
||||
const [localWindsurfRulesToggles, setLocalWindsurfRulesToggles] = useState<Record<string, boolean>>({})
|
||||
const [localAgentsRulesToggles, setLocalAgentsRulesToggles] = useState<Record<string, boolean>>({})
|
||||
|
||||
// Workflow state
|
||||
const [globalWorkflowToggles, setGlobalWorkflowToggles] = useState<Record<string, boolean>>({})
|
||||
const [localWorkflowToggles, setLocalWorkflowToggles] = useState<Record<string, boolean>>({})
|
||||
|
||||
// Hooks state
|
||||
const [globalHooks, setGlobalHooks] = useState<HookInfo[]>([])
|
||||
const [workspaceHooksState, setWorkspaceHooksState] = useState<WorkspaceHooks[]>([])
|
||||
|
||||
// Skills state
|
||||
const [globalSkills, setGlobalSkills] = useState<SkillInfo[]>([])
|
||||
const [localSkills, setLocalSkills] = useState<SkillInfo[]>([])
|
||||
|
||||
// Load initial data
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
const { refreshRules } = await import("@/core/controller/file/refreshRules")
|
||||
const { refreshHooks } = await import("@/core/controller/file/refreshHooks")
|
||||
const { refreshSkills } = await import("@/core/controller/file/refreshSkills")
|
||||
|
||||
const rulesData = await refreshRules(controller, {})
|
||||
setGlobalClineRulesToggles(rulesData.globalClineRulesToggles?.toggles || {})
|
||||
setLocalClineRulesToggles(rulesData.localClineRulesToggles?.toggles || {})
|
||||
setLocalCursorRulesToggles(rulesData.localCursorRulesToggles?.toggles || {})
|
||||
setLocalWindsurfRulesToggles(rulesData.localWindsurfRulesToggles?.toggles || {})
|
||||
setLocalAgentsRulesToggles(rulesData.localAgentsRulesToggles?.toggles || {})
|
||||
setGlobalWorkflowToggles(rulesData.globalWorkflowToggles?.toggles || {})
|
||||
setLocalWorkflowToggles(rulesData.localWorkflowToggles?.toggles || {})
|
||||
|
||||
if (hooksEnabled) {
|
||||
const hooksData = await refreshHooks(controller, {})
|
||||
setGlobalHooks(hooksData.globalHooks || [])
|
||||
setWorkspaceHooksState(hooksData.workspaceHooks || [])
|
||||
}
|
||||
|
||||
if (skillsEnabled) {
|
||||
const skillsData = await refreshSkills(controller)
|
||||
setGlobalSkills(skillsData.globalSkills || [])
|
||||
setLocalSkills(skillsData.localSkills || [])
|
||||
}
|
||||
}
|
||||
loadData()
|
||||
}, [controller, hooksEnabled, skillsEnabled])
|
||||
|
||||
// Toggle handlers
|
||||
const handleToggleRule = useCallback(
|
||||
async (isGlobal: boolean, rulePath: string, enabled: boolean, ruleType: string) => {
|
||||
const { toggleClineRule } = await import("@/core/controller/file/toggleClineRule")
|
||||
|
||||
// Determine scope based on isGlobal and rule type
|
||||
const scope = isGlobal ? RuleScope.GLOBAL : RuleScope.LOCAL
|
||||
|
||||
// For non-cline rules, we need different toggle functions
|
||||
if (ruleType === "cursor") {
|
||||
// Update local state optimistically
|
||||
setLocalCursorRulesToggles((prev) => ({ ...prev, [rulePath]: enabled }))
|
||||
// Cursor rules use toggleCursorRule but we'll just update the state manager directly
|
||||
const toggles = controller.stateManager.getWorkspaceStateKey("localCursorRulesToggles") || {}
|
||||
toggles[rulePath] = enabled
|
||||
controller.stateManager.setWorkspaceState("localCursorRulesToggles", toggles)
|
||||
} else if (ruleType === "windsurf") {
|
||||
setLocalWindsurfRulesToggles((prev) => ({ ...prev, [rulePath]: enabled }))
|
||||
const toggles = controller.stateManager.getWorkspaceStateKey("localWindsurfRulesToggles") || {}
|
||||
toggles[rulePath] = enabled
|
||||
controller.stateManager.setWorkspaceState("localWindsurfRulesToggles", toggles)
|
||||
} else if (ruleType === "agents") {
|
||||
setLocalAgentsRulesToggles((prev) => ({ ...prev, [rulePath]: enabled }))
|
||||
const toggles = controller.stateManager.getWorkspaceStateKey("localAgentsRulesToggles") || {}
|
||||
toggles[rulePath] = enabled
|
||||
controller.stateManager.setWorkspaceState("localAgentsRulesToggles", toggles)
|
||||
} else {
|
||||
// Cline rules
|
||||
const result = await toggleClineRule(controller, { metadata: undefined, rulePath, enabled, scope })
|
||||
if (result.globalClineRulesToggles?.toggles) {
|
||||
setGlobalClineRulesToggles(result.globalClineRulesToggles.toggles)
|
||||
}
|
||||
if (result.localClineRulesToggles?.toggles) {
|
||||
setLocalClineRulesToggles(result.localClineRulesToggles.toggles)
|
||||
}
|
||||
}
|
||||
},
|
||||
[controller],
|
||||
)
|
||||
|
||||
const handleToggleWorkflow = useCallback(
|
||||
async (isGlobal: boolean, workflowPath: string, enabled: boolean) => {
|
||||
const { toggleWorkflow } = await import("@/core/controller/file/toggleWorkflow")
|
||||
const scope = isGlobal ? RuleScope.GLOBAL : RuleScope.LOCAL
|
||||
|
||||
// Optimistic update
|
||||
if (isGlobal) {
|
||||
setGlobalWorkflowToggles((prev) => ({ ...prev, [workflowPath]: enabled }))
|
||||
} else {
|
||||
setLocalWorkflowToggles((prev) => ({ ...prev, [workflowPath]: enabled }))
|
||||
}
|
||||
|
||||
await toggleWorkflow(controller, { metadata: undefined, workflowPath, enabled, scope })
|
||||
},
|
||||
[controller],
|
||||
)
|
||||
|
||||
const handleToggleHook = useCallback(
|
||||
async (isGlobal: boolean, hookName: string, enabled: boolean, workspaceName?: string) => {
|
||||
const { toggleHook } = await import("@/core/controller/file/toggleHook")
|
||||
|
||||
// Optimistic update
|
||||
if (isGlobal) {
|
||||
setGlobalHooks((prev) => prev.map((h) => (h.name === hookName ? { ...h, enabled } : h)))
|
||||
} else {
|
||||
setWorkspaceHooksState((prev) =>
|
||||
prev.map((ws) =>
|
||||
ws.workspaceName === workspaceName
|
||||
? { ...ws, hooks: ws.hooks.map((h) => (h.name === hookName ? { ...h, enabled } : h)) }
|
||||
: ws,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const result = await toggleHook(controller, { metadata: undefined, hookName, isGlobal, enabled, workspaceName })
|
||||
if (result.hooksToggles) {
|
||||
setGlobalHooks(result.hooksToggles.globalHooks || [])
|
||||
setWorkspaceHooksState(result.hooksToggles.workspaceHooks || [])
|
||||
}
|
||||
},
|
||||
[controller],
|
||||
)
|
||||
|
||||
const handleToggleSkill = useCallback(
|
||||
async (isGlobal: boolean, skillPath: string, enabled: boolean) => {
|
||||
const { toggleSkill } = await import("@/core/controller/file/toggleSkill")
|
||||
|
||||
// Optimistic update
|
||||
if (isGlobal) {
|
||||
setGlobalSkills((prev) => prev.map((s) => (s.path === skillPath ? { ...s, enabled } : s)))
|
||||
} else {
|
||||
setLocalSkills((prev) => prev.map((s) => (s.path === skillPath ? { ...s, enabled } : s)))
|
||||
}
|
||||
|
||||
await toggleSkill(controller, { metadata: undefined, skillPath, isGlobal, enabled })
|
||||
},
|
||||
[controller],
|
||||
)
|
||||
|
||||
const handleOpenFolder = useCallback(
|
||||
async (folderType: "rules" | "workflows" | "hooks" | "skills", isGlobal: boolean) => {
|
||||
let folderPath: string
|
||||
|
||||
if (isGlobal) {
|
||||
// Global folders are in dataDir (e.g., ~/.cline/)
|
||||
const subFolder = folderType === "rules" ? "rules" : folderType
|
||||
folderPath = path.join(dataDir, subFolder)
|
||||
} else {
|
||||
// Local folders are in the workspace
|
||||
const workspacePaths = await HostProvider.workspace.getWorkspacePaths({})
|
||||
const primaryWorkspace = workspacePaths.paths[0]
|
||||
if (!primaryWorkspace) {
|
||||
return
|
||||
}
|
||||
// Local rules/workflows/hooks/skills are in .clinerules or .cline
|
||||
const subFolder = folderType === "rules" ? "rules" : folderType
|
||||
folderPath = path.join(primaryWorkspace, ".clinerules", subFolder)
|
||||
}
|
||||
|
||||
// Open folder using platform-specific command
|
||||
const platform = os.platform()
|
||||
let command: string
|
||||
if (platform === "darwin") {
|
||||
command = `open "${folderPath}"`
|
||||
} else if (platform === "win32") {
|
||||
command = `explorer "${folderPath}"`
|
||||
} else {
|
||||
command = `xdg-open "${folderPath}"`
|
||||
}
|
||||
|
||||
exec(command, (error) => {
|
||||
if (error) {
|
||||
// Folder might not exist, try to create and open
|
||||
exec(`mkdir -p "${folderPath}" && ${command}`)
|
||||
}
|
||||
})
|
||||
},
|
||||
[dataDir],
|
||||
)
|
||||
|
||||
// Settings update handlers
|
||||
const handleUpdateGlobal = useCallback(
|
||||
async (key: GlobalStateAndSettingsKey, value: GlobalStateAndSettings[GlobalStateAndSettingsKey]) => {
|
||||
// Update local state for immediate UI feedback
|
||||
setGlobalStateLocal((prev) => ({ ...prev, [key]: value }))
|
||||
// Persist to state manager
|
||||
controller.stateManager.setGlobalState(key, value)
|
||||
await controller.stateManager.flushPendingState()
|
||||
},
|
||||
[controller],
|
||||
)
|
||||
|
||||
const handleUpdateWorkspace = useCallback(
|
||||
async (key: LocalStateKey, value: LocalState[LocalStateKey]) => {
|
||||
// Update local state for immediate UI feedback
|
||||
setWorkspaceStateLocal((prev) => ({ ...prev, [key]: value }))
|
||||
// Persist to state manager
|
||||
controller.stateManager.setWorkspaceState(key, value)
|
||||
await controller.stateManager.flushPendingState()
|
||||
},
|
||||
[controller],
|
||||
)
|
||||
|
||||
return (
|
||||
<StdinProvider isRawModeSupported={isRawModeSupported}>
|
||||
<ConfigView
|
||||
dataDir={dataDir}
|
||||
globalClineRulesToggles={globalClineRulesToggles}
|
||||
globalHooks={globalHooks}
|
||||
globalSkills={globalSkills}
|
||||
globalState={globalStateLocal}
|
||||
globalWorkflowToggles={globalWorkflowToggles}
|
||||
hooksEnabled={hooksEnabled}
|
||||
localAgentsRulesToggles={localAgentsRulesToggles}
|
||||
localClineRulesToggles={localClineRulesToggles}
|
||||
localCursorRulesToggles={localCursorRulesToggles}
|
||||
localSkills={localSkills}
|
||||
localWindsurfRulesToggles={localWindsurfRulesToggles}
|
||||
localWorkflowToggles={localWorkflowToggles}
|
||||
onOpenFolder={handleOpenFolder}
|
||||
onToggleHook={handleToggleHook}
|
||||
onToggleRule={handleToggleRule}
|
||||
onToggleSkill={handleToggleSkill}
|
||||
onToggleWorkflow={handleToggleWorkflow}
|
||||
onUpdateGlobal={handleUpdateGlobal}
|
||||
onUpdateWorkspace={handleUpdateWorkspace}
|
||||
skillsEnabled={skillsEnabled}
|
||||
workspaceHooks={workspaceHooksState}
|
||||
workspaceState={workspaceStateLocal}
|
||||
/>
|
||||
</StdinProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
/**
|
||||
* DiffView component for displaying file diffs in Ink
|
||||
* Shows unified diff output with:
|
||||
* - Line numbers in a gutter
|
||||
* - Colored additions (green) and deletions (red)
|
||||
* - Context lines (unchanged) in dim
|
||||
* - Proper diff algorithm using Myers diff
|
||||
* - Collapsed context (hides long runs of unchanged lines)
|
||||
*/
|
||||
|
||||
import { Box, Text } from "ink"
|
||||
import React, { useMemo } from "react"
|
||||
import { type ComputedDiff, computeDiff, type DiffBlock, type DiffLine, getGutterWidth } from "../utils/DiffComputer"
|
||||
|
||||
interface DiffViewProps {
|
||||
/** Diff content (SEARCH/REPLACE format, ApplyPatch format, or raw content for new files) */
|
||||
content?: string
|
||||
/** File path (used for potential syntax highlighting in the future) */
|
||||
filePath?: string
|
||||
/** Number of context lines to show before/after changes (default: 3) */
|
||||
contextLines?: number
|
||||
}
|
||||
|
||||
// Diff colors - muted backgrounds with readable foreground text
|
||||
const DIFF_COLORS = {
|
||||
addBg: "rgb(35, 61, 41)", // dark muted green
|
||||
addFg: "rgb(156, 204, 122)", // light green text
|
||||
removeBg: "rgb(62, 36, 36)", // dark muted red
|
||||
removeFg: "rgb(224, 139, 139)", // light red/pink text
|
||||
gutterFg: "gray", // line number color
|
||||
} as const
|
||||
|
||||
// Default number of context lines to show
|
||||
const DEFAULT_CONTEXT_LINES = 3
|
||||
|
||||
/**
|
||||
* Render a single diff line with gutter and colored content
|
||||
*/
|
||||
const DiffLineRow: React.FC<{
|
||||
line: DiffLine
|
||||
gutterWidth: number
|
||||
}> = ({ line, gutterWidth }) => {
|
||||
if (line.type === "separator") {
|
||||
return <Text> </Text>
|
||||
}
|
||||
|
||||
// Determine which line number to show
|
||||
// For additions: show new line number
|
||||
// For deletions: show old line number
|
||||
// For context: show new line number (both are available)
|
||||
const lineNum = line.type === "add" ? line.newLineNumber : line.type === "remove" ? line.oldLineNumber : line.newLineNumber
|
||||
|
||||
const lineNumStr = lineNum !== undefined ? lineNum.toString().padStart(gutterWidth, " ") : " ".repeat(gutterWidth)
|
||||
|
||||
const prefix = line.type === "add" ? "+" : line.type === "remove" ? "-" : " "
|
||||
|
||||
switch (line.type) {
|
||||
case "add":
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Box backgroundColor={DIFF_COLORS.addBg} flexShrink={0}>
|
||||
<Text color={DIFF_COLORS.gutterFg}>{lineNumStr} </Text>
|
||||
</Box>
|
||||
<Box backgroundColor={DIFF_COLORS.addBg} flexGrow={1}>
|
||||
<Text color={DIFF_COLORS.addFg}>
|
||||
{prefix}
|
||||
{line.content}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
case "remove":
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Box backgroundColor={DIFF_COLORS.removeBg} flexShrink={0}>
|
||||
<Text color={DIFF_COLORS.gutterFg}>{lineNumStr} </Text>
|
||||
</Box>
|
||||
<Box backgroundColor={DIFF_COLORS.removeBg} flexGrow={1}>
|
||||
<Text color={DIFF_COLORS.removeFg}>
|
||||
{prefix}
|
||||
{line.content}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
case "context":
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Box flexShrink={0}>
|
||||
<Text color={DIFF_COLORS.gutterFg}>{lineNumStr} </Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text dimColor>
|
||||
{prefix}
|
||||
{line.content}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a separator between diff blocks
|
||||
*/
|
||||
const BlockSeparator: React.FC = () => (
|
||||
<Box marginY={0}>
|
||||
<Text color="gray">{"─".repeat(40)}</Text>
|
||||
</Box>
|
||||
)
|
||||
|
||||
/**
|
||||
* Render an ellipsis row for collapsed context lines
|
||||
*/
|
||||
const CollapsedRow: React.FC<{ count: number; gutterWidth: number }> = ({ count, gutterWidth }) => (
|
||||
<Box flexDirection="row">
|
||||
<Box flexShrink={0}>
|
||||
<Text color="gray">{" ".repeat(gutterWidth)} </Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text color="gray" dimColor>
|
||||
{" "}... {count} unchanged line{count === 1 ? "" : "s"} ...
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
/**
|
||||
* Represents either a diff line or a collapsed section marker
|
||||
*/
|
||||
type DisplayLine =
|
||||
| { type: "line"; line: DiffLine }
|
||||
| { type: "collapsed"; count: number; startLineNumber: number; endLineNumber: number }
|
||||
|
||||
/**
|
||||
* Collapse long runs of context lines, keeping only contextLines before/after changes
|
||||
*/
|
||||
function collapseContext(block: DiffBlock, contextLines: number): DisplayLine[] {
|
||||
const lines = block.lines
|
||||
const result: DisplayLine[] = []
|
||||
|
||||
// Find indices of all change lines (add/remove)
|
||||
const changeIndices: number[] = []
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].type === "add" || lines[i].type === "remove") {
|
||||
changeIndices.push(i)
|
||||
}
|
||||
}
|
||||
|
||||
// If no changes, show all (shouldn't happen but handle it)
|
||||
if (changeIndices.length === 0) {
|
||||
return lines.map((line) => ({ type: "line", line }))
|
||||
}
|
||||
|
||||
// Build a set of indices to keep (within contextLines of any change)
|
||||
const keepIndices = new Set<number>()
|
||||
for (const changeIdx of changeIndices) {
|
||||
for (let i = Math.max(0, changeIdx - contextLines); i <= Math.min(lines.length - 1, changeIdx + contextLines); i++) {
|
||||
keepIndices.add(i)
|
||||
}
|
||||
}
|
||||
|
||||
// Process lines, grouping consecutive hidden context lines
|
||||
let hiddenStart: number | null = null
|
||||
let hiddenCount = 0
|
||||
let hiddenStartLineNum = 0
|
||||
let hiddenEndLineNum = 0
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
|
||||
if (keepIndices.has(i)) {
|
||||
// Emit any pending collapsed section
|
||||
if (hiddenCount > 0) {
|
||||
result.push({
|
||||
type: "collapsed",
|
||||
count: hiddenCount,
|
||||
startLineNumber: hiddenStartLineNum,
|
||||
endLineNumber: hiddenEndLineNum,
|
||||
})
|
||||
hiddenCount = 0
|
||||
hiddenStart = null
|
||||
}
|
||||
result.push({ type: "line", line })
|
||||
} else {
|
||||
// Context line that should be hidden
|
||||
if (hiddenStart === null) {
|
||||
hiddenStart = i
|
||||
hiddenStartLineNum = line.newLineNumber ?? line.oldLineNumber ?? 0
|
||||
}
|
||||
hiddenEndLineNum = line.newLineNumber ?? line.oldLineNumber ?? 0
|
||||
hiddenCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Emit any remaining collapsed section
|
||||
if (hiddenCount > 0) {
|
||||
result.push({
|
||||
type: "collapsed",
|
||||
count: hiddenCount,
|
||||
startLineNumber: hiddenStartLineNum,
|
||||
endLineNumber: hiddenEndLineNum,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* DiffView component that renders file edits as a proper diff
|
||||
* Supports SEARCH/REPLACE format and ApplyPatch format
|
||||
*/
|
||||
export const DiffView: React.FC<DiffViewProps> = ({ content, contextLines = DEFAULT_CONTEXT_LINES }) => {
|
||||
const diff = useMemo((): ComputedDiff | null => {
|
||||
if (!content) return null
|
||||
return computeDiff(content)
|
||||
}, [content])
|
||||
|
||||
const gutterWidth = useMemo(() => {
|
||||
if (!diff) return 1
|
||||
return getGutterWidth(diff)
|
||||
}, [diff])
|
||||
|
||||
// Collapse context lines for each block
|
||||
const collapsedBlocks = useMemo(() => {
|
||||
if (!diff) return []
|
||||
return diff.blocks.map((block) => collapseContext(block, contextLines))
|
||||
}, [diff, contextLines])
|
||||
|
||||
if (!diff || diff.blocks.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width="100%">
|
||||
{collapsedBlocks.map((displayLines, blockIdx) => (
|
||||
<React.Fragment key={blockIdx}>
|
||||
{blockIdx > 0 && <BlockSeparator />}
|
||||
{displayLines.map((item, lineIdx) =>
|
||||
item.type === "collapsed" ? (
|
||||
<CollapsedRow count={item.count} gutterWidth={gutterWidth} key={`${blockIdx}-${lineIdx}`} />
|
||||
) : (
|
||||
<DiffLineRow gutterWidth={gutterWidth} key={`${blockIdx}-${lineIdx}`} line={item.line} />
|
||||
),
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { Box, Text } from "ink"
|
||||
import React from "react"
|
||||
import { ErrorService } from "@/services/error"
|
||||
import { StaticRobotFrame } from "./AsciiMotionCli"
|
||||
|
||||
type Props = React.PropsWithChildren<{ exit: (error?: Error) => void }>
|
||||
|
||||
async function onReactError(props: Props, error: Error, errorInfo: React.ErrorInfo) {
|
||||
try {
|
||||
await ErrorService.get().captureException(error, { context: "ErrorBoundary", errorInfo })
|
||||
await ErrorService.get().dispose()
|
||||
} catch {
|
||||
// Ignore errors
|
||||
} finally {
|
||||
props.exit(error)
|
||||
}
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends React.Component<Props, { hasError: boolean }> {
|
||||
override state = { hasError: false }
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
}
|
||||
|
||||
override componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
||||
onReactError(this.props, error, errorInfo)
|
||||
}
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true }
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Box flexDirection="column" height="100%" key="header" width="100%">
|
||||
<StaticRobotFrame />
|
||||
<Text> </Text>
|
||||
<Text bold color="white">
|
||||
Something went wrong. We're sorry.
|
||||
</Text>
|
||||
<Text color="white">Please check the logs for more details.</Text>
|
||||
<Text> </Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
/**
|
||||
* Rotating feature tips shown during thinking/acting phases.
|
||||
* Appears after a brief delay and cycles through tips to educate users
|
||||
* about Cline features while they wait.
|
||||
*/
|
||||
|
||||
import { Box, Text } from "ink"
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react"
|
||||
|
||||
interface FeatureTipItem {
|
||||
text: string
|
||||
}
|
||||
|
||||
const FEATURE_TIPS: FeatureTipItem[] = [
|
||||
{
|
||||
text: 'Enable "Double-Check Completion" in settings to have Cline verify its work before finishing a task.',
|
||||
},
|
||||
{
|
||||
text: "Add a .clinerules file to your project root to give Cline project-specific instructions.",
|
||||
},
|
||||
{
|
||||
text: "Press Tab to switch between Plan and Act mode — plan an approach before Cline takes action.",
|
||||
},
|
||||
{
|
||||
text: "Use @ in the chat input to add files, folders, or URLs as context for your task.",
|
||||
},
|
||||
{
|
||||
text: "Set up MCP Servers to give Cline access to external tools and APIs.",
|
||||
},
|
||||
{
|
||||
text: "Cline creates checkpoints after changes — you can always restore to a previous state.",
|
||||
},
|
||||
{
|
||||
text: "Use /compact to condense long conversations and free up context window space.",
|
||||
},
|
||||
{
|
||||
text: "Enable auto-approve for read-only tools like file reads to speed up exploration.",
|
||||
},
|
||||
{
|
||||
text: "Use /settings to configure your API provider and model without leaving the terminal.",
|
||||
},
|
||||
{
|
||||
text: "You can pass images with --images flag or paste image file paths in the chat.",
|
||||
},
|
||||
{
|
||||
text: "Cline can browse websites — ask it to test your local dev server in the browser.",
|
||||
},
|
||||
{
|
||||
text: "Use /reportbug to quickly file a GitHub issue with diagnostic context included.",
|
||||
},
|
||||
{
|
||||
text: "Try 'npm i -g cline' to manage tasks on a Kankan board — orchestrate coding agents across worktrees.",
|
||||
},
|
||||
{
|
||||
text: "Use Shift+Tab to toggle auto-approve all — let Cline work uninterrupted on trusted tasks.",
|
||||
},
|
||||
{
|
||||
text: "Press Up/Down arrows in an empty input to browse your previous task prompts.",
|
||||
},
|
||||
{
|
||||
text: "Type / to see all available commands — /history, /compact, /settings, and more.",
|
||||
},
|
||||
{
|
||||
text: "Use /skills to browse and attach reusable skill files that guide Cline's behavior.",
|
||||
},
|
||||
{
|
||||
text: 'You can disable these tips in /settings → Features → "Feature tips".',
|
||||
},
|
||||
]
|
||||
|
||||
const SHOW_DELAY_MS = 2000
|
||||
const CYCLE_INTERVAL_MS = 8000
|
||||
|
||||
/**
|
||||
* Shows rotating feature tips below the thinking indicator.
|
||||
* Appears after a brief delay and cycles through tips while Cline is thinking/acting.
|
||||
*/
|
||||
export const FeatureTip: React.FC = React.memo(() => {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [tipIndex, setTipIndex] = useState(Math.floor(Math.random() * FEATURE_TIPS.length))
|
||||
const cycleTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const showTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const currentTip = FEATURE_TIPS[tipIndex]
|
||||
|
||||
const advanceTip = useCallback(() => {
|
||||
setTipIndex((prev) => (prev + 1) % FEATURE_TIPS.length)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
showTimerRef.current = setTimeout(() => {
|
||||
setIsVisible(true)
|
||||
cycleTimerRef.current = setInterval(advanceTip, CYCLE_INTERVAL_MS)
|
||||
}, SHOW_DELAY_MS)
|
||||
|
||||
return () => {
|
||||
if (showTimerRef.current) {
|
||||
clearTimeout(showTimerRef.current)
|
||||
}
|
||||
if (cycleTimerRef.current) {
|
||||
clearInterval(cycleTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [advanceTip])
|
||||
|
||||
if (!isVisible) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Box paddingLeft={1}>
|
||||
<Text color="gray">
|
||||
💡 <Text bold>Tip:</Text> {currentTip.text}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
@@ -1,105 +0,0 @@
|
||||
/**
|
||||
* Featured model picker component
|
||||
* Shows curated models with labels (Best, New, Trending, FREE) and optional "Browse all" option
|
||||
* Used in both onboarding (AuthView) and settings (SettingsPanelContent)
|
||||
*/
|
||||
|
||||
import { Box, Text } from "ink"
|
||||
import React from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import type { FeaturedModel } from "../constants/featured-models"
|
||||
|
||||
interface FeaturedModelPickerProps {
|
||||
selectedIndex: number
|
||||
title?: string
|
||||
showBrowseAll?: boolean
|
||||
helpText?: string
|
||||
featuredModels: FeaturedModel[]
|
||||
}
|
||||
|
||||
export const FeaturedModelPicker: React.FC<FeaturedModelPickerProps> = ({
|
||||
selectedIndex,
|
||||
title,
|
||||
showBrowseAll = true,
|
||||
helpText = "Arrows to navigate, Enter to select",
|
||||
featuredModels,
|
||||
}) => {
|
||||
const models = featuredModels
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{title && (
|
||||
<Text>
|
||||
<Text bold color={COLORS.primaryBlue}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{models.map((model, i) => {
|
||||
const isSelected = i === selectedIndex
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" key={`${model.id}-${model.labels[0] || "default"}`} marginBottom={1}>
|
||||
<Box>
|
||||
<Text color={isSelected ? COLORS.primaryBlue : undefined}>{isSelected ? "❯ " : " "}</Text>
|
||||
<Text bold color={isSelected ? COLORS.primaryBlue : "white"}>
|
||||
{model.name}
|
||||
</Text>
|
||||
{model.labels.map((label) => (
|
||||
<Text key={label}>
|
||||
<Text> </Text>
|
||||
<Text backgroundColor={label === "FREE" ? "gray" : COLORS.primaryBlue} color="black">
|
||||
{" "}
|
||||
{label}{" "}
|
||||
</Text>
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
<Box paddingLeft={2}>
|
||||
<Text color="gray">{model.description}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
|
||||
{showBrowseAll && (
|
||||
<Box>
|
||||
<Text color={selectedIndex === models.length ? COLORS.primaryBlue : "white"}>
|
||||
{selectedIndex === models.length ? "❯ " : " "}
|
||||
Browse all models...
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Text> </Text>
|
||||
<Text color="gray">{helpText}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the maximum valid index for the featured model picker
|
||||
* (includes "Browse all" option if showBrowseAll is true)
|
||||
*/
|
||||
export function getFeaturedModelMaxIndex(featuredModels: FeaturedModel[], showBrowseAll = true): number {
|
||||
return showBrowseAll ? featuredModels.length : featuredModels.length - 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the selected index is the "Browse all" option
|
||||
*/
|
||||
export function isBrowseAllSelected(selectedIndex: number, featuredModels: FeaturedModel[]): boolean {
|
||||
return selectedIndex === featuredModels.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the featured model at the given index, or null if "Browse all" is selected
|
||||
*/
|
||||
export function getFeaturedModelAtIndex(index: number, featuredModels: FeaturedModel[]): FeaturedModel | null {
|
||||
if (index >= 0 && index < featuredModels.length) {
|
||||
return featuredModels[index]
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* File mention menu component for CLI
|
||||
* Displays a list of matching files when user types @
|
||||
*/
|
||||
|
||||
import { Box, Text } from "ink"
|
||||
import React from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { type FileSearchResult, getRipgrepInstallInstructions } from "../utils/file-search"
|
||||
import { getVisibleWindow } from "../utils/slash-commands"
|
||||
|
||||
interface FileMentionMenuProps {
|
||||
results: FileSearchResult[]
|
||||
selectedIndex: number
|
||||
isLoading: boolean
|
||||
query: string
|
||||
showRipgrepWarning?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate path from the left if too long, keeping the filename visible
|
||||
*/
|
||||
function truncatePath(filePath: string, maxLength: number = 50): string {
|
||||
if (filePath.length <= maxLength) {
|
||||
return filePath
|
||||
}
|
||||
return "..." + filePath.slice(-(maxLength - 3))
|
||||
}
|
||||
|
||||
export const FileMentionMenu: React.FC<FileMentionMenuProps> = ({
|
||||
results,
|
||||
selectedIndex,
|
||||
isLoading,
|
||||
query,
|
||||
showRipgrepWarning,
|
||||
}) => {
|
||||
const ripgrepWarning = showRipgrepWarning && (
|
||||
<Box marginTop={1}>
|
||||
<Text color="yellow">ripgrep not found - file search will be slower. </Text>
|
||||
<Text color="gray">Install: {getRipgrepInstallInstructions()}</Text>
|
||||
</Box>
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} paddingLeft={1} paddingRight={1}>
|
||||
<Text color="gray">Searching files...</Text>
|
||||
{ripgrepWarning}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} paddingLeft={1} paddingRight={1}>
|
||||
<Text color="gray">{query ? `No files matching "${query}"` : "Type to search files..."}</Text>
|
||||
{ripgrepWarning}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const { items: visibleResults, startIndex } = getVisibleWindow(results, selectedIndex)
|
||||
const hasMoreBelow = startIndex + visibleResults.length < results.length
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} paddingLeft={1} paddingRight={1}>
|
||||
{visibleResults.map((result, idx) => {
|
||||
const isSelected = startIndex + idx === selectedIndex
|
||||
const displayPath = truncatePath(result.path)
|
||||
|
||||
return (
|
||||
<Box key={result.path}>
|
||||
<Text color={isSelected ? COLORS.primaryBlue : undefined}>
|
||||
{isSelected ? "❯" : " "} {displayPath}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
{hasMoreBelow && <Text color="gray">{" "}▼</Text>}
|
||||
{ripgrepWarning}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
/**
|
||||
* Focus Chain / To-Do List component for CLI
|
||||
* Displays a progress-tracked checklist of tasks
|
||||
*/
|
||||
|
||||
import { isCompletedFocusChainItem, isFocusChainItem, parseFocusChainItem } from "@shared/focus-chain-utils"
|
||||
import { Box, Text } from "ink"
|
||||
import React, { useMemo } from "react"
|
||||
|
||||
interface TodoInfo {
|
||||
currentTodo: { text: string; completed: boolean; index: number } | null
|
||||
currentIndex: number
|
||||
completedCount: number
|
||||
totalCount: number
|
||||
progressPercentage: number
|
||||
}
|
||||
|
||||
interface TodoItem {
|
||||
text: string
|
||||
checked: boolean
|
||||
}
|
||||
|
||||
interface FocusChainProps {
|
||||
focusChainChecklist?: string | null
|
||||
expanded?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the focus chain checklist text into TodoInfo
|
||||
*/
|
||||
function parseCurrentTodoInfo(text: string): TodoInfo | null {
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
|
||||
let completedCount = 0
|
||||
let totalCount = 0
|
||||
let firstIncompleteIndex = -1
|
||||
let firstIncompleteText: string | null = null
|
||||
|
||||
const lines = text.split("\n")
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim()
|
||||
if (isFocusChainItem(line)) {
|
||||
const isCompleted = isCompletedFocusChainItem(line)
|
||||
|
||||
if (isCompleted) {
|
||||
completedCount++
|
||||
} else if (firstIncompleteIndex === -1) {
|
||||
firstIncompleteIndex = totalCount
|
||||
// Extract text after "- [ ] "
|
||||
firstIncompleteText = line.substring(5).trim()
|
||||
}
|
||||
|
||||
totalCount++
|
||||
}
|
||||
}
|
||||
|
||||
if (totalCount === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const currentTodo = firstIncompleteText ? { text: firstIncompleteText, completed: false, index: firstIncompleteIndex } : null
|
||||
|
||||
return {
|
||||
currentTodo,
|
||||
currentIndex: firstIncompleteIndex >= 0 ? firstIncompleteIndex + 1 : totalCount,
|
||||
completedCount,
|
||||
totalCount,
|
||||
progressPercentage: (completedCount / totalCount) * 100,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse all todo items from the checklist
|
||||
*/
|
||||
function parseTodoItems(text: string): TodoItem[] {
|
||||
const items: TodoItem[] = []
|
||||
const lines = text.split("\n")
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim()
|
||||
const parsed = parseFocusChainItem(line)
|
||||
if (parsed) {
|
||||
items.push(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
/**
|
||||
* Render progress bar
|
||||
*/
|
||||
const ProgressBar: React.FC<{ percentage: number; width?: number }> = ({ percentage, width = 20 }) => {
|
||||
const filled = Math.round((percentage / 100) * width)
|
||||
const empty = width - filled
|
||||
const bar = "█".repeat(filled) + "░".repeat(empty)
|
||||
|
||||
return (
|
||||
<Text>
|
||||
<Text color="green">{bar}</Text>
|
||||
<Text dimColor> {Math.round(percentage)}%</Text>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Header view showing current task and progress
|
||||
*/
|
||||
const Header: React.FC<{
|
||||
todoInfo: TodoInfo
|
||||
}> = ({ todoInfo }) => {
|
||||
const { currentTodo, currentIndex, totalCount, completedCount } = todoInfo
|
||||
const isCompleted = completedCount === totalCount
|
||||
|
||||
const displayText = isCompleted ? "All tasks completed!" : currentTodo?.text || "To-Do list"
|
||||
const truncatedText = displayText.length > 50 ? displayText.substring(0, 47) + "..." : displayText
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={isCompleted ? "green" : "cyan"}>
|
||||
[{currentIndex}/{totalCount}]
|
||||
</Text>
|
||||
<Text color={isCompleted ? "green" : undefined}>{truncatedText}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Expanded view showing all todo items
|
||||
*/
|
||||
const ExpandedList: React.FC<{
|
||||
items: TodoItem[]
|
||||
isCompleted: boolean
|
||||
}> = ({ items, isCompleted }) => {
|
||||
return (
|
||||
<Box flexDirection="column" marginLeft={2} marginTop={1}>
|
||||
{items.map((item, index) => (
|
||||
<Box key={index}>
|
||||
<Text color={item.checked ? "green" : "gray"}>{item.checked ? "✓" : "○"} </Text>
|
||||
<Text color={item.checked ? "green" : undefined} dimColor={item.checked}>
|
||||
{item.text}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
{isCompleted && (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor italic>
|
||||
New steps will be generated if you continue the task
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Main FocusChain component for CLI
|
||||
* Shows a progress summary of the current to-do list
|
||||
* Use expanded={true} to show all items (e.g., in verbose mode)
|
||||
*/
|
||||
export const FocusChain: React.FC<FocusChainProps> = ({ focusChainChecklist, expanded = false }) => {
|
||||
const todoInfo = useMemo(
|
||||
() => (focusChainChecklist ? parseCurrentTodoInfo(focusChainChecklist) : null),
|
||||
[focusChainChecklist],
|
||||
)
|
||||
|
||||
const todoItems = useMemo(() => (focusChainChecklist ? parseTodoItems(focusChainChecklist) : []), [focusChainChecklist])
|
||||
|
||||
// No content to display
|
||||
if (!todoInfo) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isCompleted = todoInfo.completedCount === todoInfo.totalCount
|
||||
|
||||
return (
|
||||
<Box borderColor={isCompleted ? "green" : "gray"} borderStyle="round" flexDirection="column" paddingX={1}>
|
||||
<Header todoInfo={todoInfo} />
|
||||
<ProgressBar percentage={todoInfo.progressPercentage} />
|
||||
{expanded && <ExpandedList isCompleted={isCompleted} items={todoItems} />}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* Help panel content for inline display in ChatView
|
||||
* Explains Cline CLI features and links to documentation
|
||||
*/
|
||||
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { Panel } from "./Panel"
|
||||
|
||||
interface HelpPanelContentProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const HelpPanelContent: React.FC<HelpPanelContentProps> = ({ onClose }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (isMouseEscapeSequence(input)) {
|
||||
return
|
||||
}
|
||||
if (key.escape) {
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported },
|
||||
)
|
||||
|
||||
return (
|
||||
<Panel label="Help">
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text>Cline can edit files, run terminal commands, use the browser, and more with your permission.</Text>
|
||||
|
||||
<Box flexDirection="column">
|
||||
<Text bold>Plan vs Act Mode</Text>
|
||||
<Text>
|
||||
Use <Text color="yellow">Plan</Text> mode to discuss and strategize before making changes. Use{" "}
|
||||
<Text color={COLORS.primaryBlue}>Act</Text> mode when you're ready for Cline to edit files and run
|
||||
commands. Toggle between them with <Text color="white">Tab</Text>.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column">
|
||||
<Text bold>Keyboard Shortcuts</Text>
|
||||
<Text>
|
||||
{" "}
|
||||
<Text color="white">Ctrl+U</Text> - Clear entire input (delete to start)
|
||||
</Text>
|
||||
<Text>
|
||||
{" "}
|
||||
<Text color="white">Ctrl+K</Text> - Delete from cursor to end
|
||||
</Text>
|
||||
<Text>
|
||||
{" "}
|
||||
<Text color="white">Ctrl+W</Text> - Delete word backwards
|
||||
</Text>
|
||||
<Text>
|
||||
{" "}
|
||||
<Text color="white">Ctrl+A / Ctrl+E</Text> - Jump to start / end of input
|
||||
</Text>
|
||||
<Text>
|
||||
{" "}
|
||||
<Text color="white">Alt/Option+←/→</Text> - Move by word
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column">
|
||||
<Text bold>Slash Commands</Text>
|
||||
<Text>
|
||||
Type <Text color="white">/</Text> to see available commands. Key ones include:
|
||||
</Text>
|
||||
<Text>
|
||||
{" "}
|
||||
<Text color="white">/settings</Text> - Configure your API provider and preferences
|
||||
</Text>
|
||||
<Text>
|
||||
{" "}
|
||||
<Text color="white">/models</Text> - Switch AI models
|
||||
</Text>
|
||||
<Text>
|
||||
{" "}
|
||||
<Text color="white">/history</Text> - Browse previous tasks
|
||||
</Text>
|
||||
<Text>
|
||||
{" "}
|
||||
<Text color="white">/clear</Text> - Start a fresh task
|
||||
</Text>
|
||||
<Text>
|
||||
{" "}
|
||||
<Text color="white">/q</Text> - Quit Cline
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Text>
|
||||
For more help: <Text color={COLORS.primaryBlue}>https://docs.cline.bot/cline-cli</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
/**
|
||||
* Highlighted input component for CLI
|
||||
* Renders text with @ mentions and / commands highlighted, plus a movable cursor
|
||||
*/
|
||||
|
||||
import { mentionRegexGlobal } from "@shared/context-mentions"
|
||||
import { Text } from "ink"
|
||||
import React from "react"
|
||||
|
||||
interface HighlightedInputProps {
|
||||
text: string
|
||||
cursorPos?: number
|
||||
availableCommands?: string[]
|
||||
}
|
||||
|
||||
// Regex for / commands (at start or after whitespace)
|
||||
const slashCommandRegex = /(^|\s)(\/[a-zA-Z0-9_.-]+)/g
|
||||
|
||||
interface Segment {
|
||||
text: string
|
||||
type: "normal" | "mention" | "command"
|
||||
startIndex: number
|
||||
}
|
||||
|
||||
function parseInput(text: string, availableCommands?: string[]): Segment[] {
|
||||
const highlights: { start: number; end: number; type: "mention" | "command" }[] = []
|
||||
|
||||
// Find all mentions
|
||||
mentionRegexGlobal.lastIndex = 0
|
||||
let match
|
||||
while ((match = mentionRegexGlobal.exec(text)) !== null) {
|
||||
highlights.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
type: "mention",
|
||||
})
|
||||
}
|
||||
|
||||
// Find first slash command only (must be complete and valid)
|
||||
slashCommandRegex.lastIndex = 0
|
||||
const slashMatch = slashCommandRegex.exec(text)
|
||||
if (slashMatch) {
|
||||
const prefix = slashMatch[1] || ""
|
||||
const commandText = slashMatch[2] // e.g., "/help"
|
||||
const commandName = commandText.slice(1) // e.g., "help"
|
||||
const commandStart = slashMatch.index + prefix.length
|
||||
const commandEnd = commandStart + commandText.length
|
||||
|
||||
// Only highlight if command exists in available commands (or if no list provided)
|
||||
if (!availableCommands || availableCommands.includes(commandName)) {
|
||||
highlights.push({
|
||||
start: commandStart,
|
||||
end: commandEnd,
|
||||
type: "command",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort highlights by start position
|
||||
highlights.sort((a, b) => a.start - b.start)
|
||||
|
||||
// Build segments
|
||||
const segments: Segment[] = []
|
||||
let lastIndex = 0
|
||||
|
||||
for (const highlight of highlights) {
|
||||
// Skip overlapping highlights
|
||||
if (highlight.start < lastIndex) continue
|
||||
|
||||
// Add normal text before this highlight
|
||||
if (highlight.start > lastIndex) {
|
||||
segments.push({
|
||||
text: text.slice(lastIndex, highlight.start),
|
||||
type: "normal",
|
||||
startIndex: lastIndex,
|
||||
})
|
||||
}
|
||||
|
||||
// Add highlighted segment
|
||||
segments.push({
|
||||
text: text.slice(highlight.start, highlight.end),
|
||||
type: highlight.type,
|
||||
startIndex: highlight.start,
|
||||
})
|
||||
|
||||
lastIndex = highlight.end
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (lastIndex < text.length) {
|
||||
segments.push({
|
||||
text: text.slice(lastIndex),
|
||||
type: "normal",
|
||||
startIndex: lastIndex,
|
||||
})
|
||||
}
|
||||
|
||||
// Always ensure at least one segment exists for stable cursor rendering
|
||||
if (segments.length === 0) {
|
||||
segments.push({
|
||||
text: text,
|
||||
type: "normal",
|
||||
startIndex: 0,
|
||||
})
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
export const HighlightedInput: React.FC<HighlightedInputProps> = ({ text, cursorPos, availableCommands }) => {
|
||||
// If no cursor position provided, just render text with highlights (backward compatible)
|
||||
if (cursorPos === undefined) {
|
||||
if (!text) return null
|
||||
const segments = parseInput(text, availableCommands)
|
||||
return (
|
||||
<Text>
|
||||
{segments.map((segment, idx) => {
|
||||
if (segment.type === "mention" || segment.type === "command") {
|
||||
return (
|
||||
<Text backgroundColor="gray" key={idx}>
|
||||
{segment.text}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
return <Text key={idx}>{segment.text}</Text>
|
||||
})}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
// With cursor position - render cursor within the text
|
||||
const safeCursorPos = Math.min(Math.max(0, cursorPos), text.length)
|
||||
const segments = parseInput(text, availableCommands)
|
||||
|
||||
// Render segments with cursor
|
||||
const renderSegmentWithCursor = (segment: Segment, segmentIdx: number) => {
|
||||
const segmentStart = segment.startIndex
|
||||
const segmentEnd = segmentStart + segment.text.length
|
||||
const isHighlighted = segment.type === "mention" || segment.type === "command"
|
||||
|
||||
// Check if cursor is within this segment
|
||||
if (safeCursorPos >= segmentStart && safeCursorPos < segmentEnd) {
|
||||
// Cursor is in this segment - split it
|
||||
const localCursorPos = safeCursorPos - segmentStart
|
||||
const beforeCursor = segment.text.slice(0, localCursorPos)
|
||||
const cursorChar = segment.text[localCursorPos]
|
||||
const afterCursor = segment.text.slice(localCursorPos + 1)
|
||||
|
||||
if (isHighlighted) {
|
||||
return (
|
||||
<Text key={segmentIdx}>
|
||||
{beforeCursor && <Text backgroundColor="gray">{beforeCursor}</Text>}
|
||||
<Text backgroundColor="gray" inverse>
|
||||
{cursorChar}
|
||||
</Text>
|
||||
{afterCursor && <Text backgroundColor="gray">{afterCursor}</Text>}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Text key={segmentIdx}>
|
||||
{beforeCursor}
|
||||
<Text inverse>{cursorChar}</Text>
|
||||
{afterCursor}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
// Cursor not in this segment - render normally
|
||||
if (isHighlighted) {
|
||||
return (
|
||||
<Text backgroundColor="gray" key={segmentIdx}>
|
||||
{segment.text}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
return <Text key={segmentIdx}>{segment.text}</Text>
|
||||
}
|
||||
|
||||
// Check if cursor is at the end (past all text)
|
||||
const cursorAtEnd = safeCursorPos >= text.length
|
||||
|
||||
return (
|
||||
<Text>
|
||||
{segments.map((segment, idx) => renderSegmentWithCursor(segment, idx))}
|
||||
{cursorAtEnd && <Text inverse> </Text>}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user