mirror of
https://github.com/cline/cline.git
synced 2026-09-01 23:19:18 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eab96e6a8a | |||
| 00526721d7 | |||
| 9852d459ba | |||
| 9153f36989 | |||
| 08047d6efd | |||
| b970af74ca |
@@ -1,208 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,107 +0,0 @@
|
||||
# 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
|
||||
@@ -1,231 +0,0 @@
|
||||
# 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
|
||||
@@ -1,134 +0,0 @@
|
||||
# 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
|
||||
@@ -1,258 +0,0 @@
|
||||
# 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
|
||||
@@ -1,131 +0,0 @@
|
||||
# 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
|
||||
@@ -1,304 +0,0 @@
|
||||
# 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
|
||||
@@ -1,148 +0,0 @@
|
||||
# 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
|
||||
@@ -1,279 +0,0 @@
|
||||
# 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
|
||||
@@ -1,269 +0,0 @@
|
||||
# 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
|
||||
@@ -1,157 +0,0 @@
|
||||
# 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
|
||||
@@ -1,649 +0,0 @@
|
||||
# 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
|
||||
@@ -1,253 +0,0 @@
|
||||
# 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
|
||||
@@ -1,257 +0,0 @@
|
||||
# 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
|
||||
@@ -1,227 +0,0 @@
|
||||
# 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
|
||||
@@ -1,259 +0,0 @@
|
||||
# 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
|
||||
@@ -1,200 +0,0 @@
|
||||
---
|
||||
name: opentui
|
||||
description: Comprehensive OpenTUI skill for building terminal user interfaces. Covers the core imperative API, React reconciler, and Solid reconciler. Use for any TUI development task including components, layout, keyboard handling, animations, and testing.
|
||||
metadata:
|
||||
references: core, react, solid
|
||||
---
|
||||
|
||||
# OpenTUI Platform Skill
|
||||
|
||||
Consolidated skill for building terminal user interfaces with OpenTUI. Use decision trees below to find the right framework and components, then load detailed references.
|
||||
|
||||
## Critical Rules
|
||||
|
||||
**Follow these rules in all OpenTUI code:**
|
||||
|
||||
1. **Use `create-tui` for new projects.** See framework `REFERENCE.md` quick starts.
|
||||
2. **`create-tui` options must come before arguments.** `bunx create-tui -t react my-app` works, `bunx create-tui my-app -t react` does NOT.
|
||||
3. **Never call `process.exit()` directly.** Use `renderer.destroy()` (see `core/gotchas.md`).
|
||||
4. **Text styling requires nested tags in React/Solid.** Use modifier elements, not props (see `components/text-display.md`).
|
||||
|
||||
## How to Use This Skill
|
||||
|
||||
### Reference File Structure
|
||||
|
||||
Framework references follow a 5-file pattern. Cross-cutting concepts are single-file guides.
|
||||
|
||||
Each framework in `./references/<framework>/` contains:
|
||||
|
||||
| File | Purpose | When to Read |
|
||||
|------|---------|--------------|
|
||||
| `REFERENCE.md` | Overview, when to use, quick start | **Always read first** |
|
||||
| `api.md` | Runtime API, components, hooks | Writing code |
|
||||
| `configuration.md` | Setup, tsconfig, bundling | Configuring a project |
|
||||
| `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 framework
|
||||
2. Then read additional files relevant to your task:
|
||||
- Building components -> `api.md` + `components/<category>.md`
|
||||
- Setting up project -> `configuration.md`
|
||||
- Layout/positioning -> `layout/REFERENCE.md`
|
||||
- Keyboard/input handling -> `keyboard/REFERENCE.md`
|
||||
- Animations -> `animation/REFERENCE.md`
|
||||
- Troubleshooting -> `gotchas.md` + `testing/REFERENCE.md`
|
||||
|
||||
### Example Paths
|
||||
|
||||
```
|
||||
./references/react/REFERENCE.md # Start here for React
|
||||
./references/react/api.md # React components and hooks
|
||||
./references/solid/configuration.md # Solid project setup
|
||||
./references/components/inputs.md # Input, Textarea, Select docs
|
||||
./references/core/gotchas.md # Core debugging tips
|
||||
```
|
||||
|
||||
### Runtime Notes
|
||||
|
||||
OpenTUI runs on Bun and uses Zig for native builds. Read `./references/core/gotchas.md` for runtime requirements and build guidance.
|
||||
|
||||
## Quick Decision Trees
|
||||
|
||||
### "Which framework should I use?"
|
||||
|
||||
```
|
||||
Which framework?
|
||||
├─ I want full control, maximum performance, no framework overhead
|
||||
│ └─ core/ (imperative API)
|
||||
├─ I know React, want familiar component patterns
|
||||
│ └─ react/ (React reconciler)
|
||||
├─ I want fine-grained reactivity, optimal re-renders
|
||||
│ └─ solid/ (Solid reconciler)
|
||||
└─ I'm building a library/framework on top of OpenTUI
|
||||
└─ core/ (imperative API)
|
||||
```
|
||||
|
||||
### "I need to display content"
|
||||
|
||||
```
|
||||
Display content?
|
||||
├─ Plain or styled text -> components/text-display.md
|
||||
├─ Container with borders/background -> components/containers.md
|
||||
├─ Scrollable content area -> components/containers.md (scrollbox)
|
||||
├─ ASCII art banner/title -> components/text-display.md (ascii-font)
|
||||
├─ Data table with borders/wrapping -> components/code-diff.md (TextTable)
|
||||
├─ Code with syntax highlighting -> components/code-diff.md
|
||||
├─ Diff viewer (unified/split) -> components/code-diff.md
|
||||
├─ Line numbers with diagnostics -> components/code-diff.md
|
||||
└─ Markdown content (streaming) -> components/code-diff.md (markdown)
|
||||
```
|
||||
|
||||
### "I need user input"
|
||||
|
||||
```
|
||||
User input?
|
||||
├─ Single-line text field -> components/inputs.md (input)
|
||||
├─ Multi-line text editor -> components/inputs.md (textarea)
|
||||
├─ Select from a list (vertical) -> components/inputs.md (select)
|
||||
├─ Tab-based selection (horizontal) -> components/inputs.md (tab-select)
|
||||
└─ Custom keyboard shortcuts -> keyboard/REFERENCE.md
|
||||
```
|
||||
|
||||
### "I need layout/positioning"
|
||||
|
||||
```
|
||||
Layout?
|
||||
├─ Flexbox-style layouts (row, column, wrap) -> layout/REFERENCE.md
|
||||
├─ Absolute positioning -> layout/patterns.md
|
||||
├─ Responsive to terminal size -> layout/patterns.md
|
||||
├─ Centering content -> layout/patterns.md
|
||||
└─ Complex nested layouts -> layout/patterns.md
|
||||
```
|
||||
|
||||
### "I need animations"
|
||||
|
||||
```
|
||||
Animations?
|
||||
├─ Timeline-based animations -> animation/REFERENCE.md
|
||||
├─ Easing functions -> animation/REFERENCE.md
|
||||
├─ Property transitions -> animation/REFERENCE.md
|
||||
└─ Looping animations -> animation/REFERENCE.md
|
||||
```
|
||||
|
||||
### "I need to handle input"
|
||||
|
||||
```
|
||||
Input handling?
|
||||
├─ Keyboard events (keypress, release) -> keyboard/REFERENCE.md
|
||||
├─ Focus management -> keyboard/REFERENCE.md
|
||||
├─ Paste events -> keyboard/REFERENCE.md
|
||||
├─ Mouse events -> components/containers.md
|
||||
├─ Text selection & copy-on-select -> keyboard/REFERENCE.md (selection)
|
||||
└─ Clipboard (OSC 52) -> keyboard/REFERENCE.md (clipboard)
|
||||
```
|
||||
|
||||
### "I need to test my TUI"
|
||||
|
||||
```
|
||||
Testing?
|
||||
├─ Snapshot testing -> testing/REFERENCE.md
|
||||
├─ Interaction testing -> testing/REFERENCE.md
|
||||
├─ Test renderer setup -> testing/REFERENCE.md
|
||||
└─ Debugging tests -> testing/REFERENCE.md
|
||||
```
|
||||
|
||||
### "I need to debug/troubleshoot"
|
||||
|
||||
```
|
||||
Troubleshooting?
|
||||
├─ Runtime errors, crashes -> <framework>/gotchas.md
|
||||
├─ Layout issues -> layout/REFERENCE.md + layout/patterns.md
|
||||
├─ Input/focus issues -> keyboard/REFERENCE.md
|
||||
└─ Repro + regression tests -> testing/REFERENCE.md
|
||||
```
|
||||
|
||||
### Troubleshooting Index
|
||||
|
||||
- Terminal cleanup, crashes -> `core/gotchas.md`
|
||||
- Text styling not applying -> `components/text-display.md`
|
||||
- Input focus/shortcuts -> `keyboard/REFERENCE.md`
|
||||
- Layout misalignment -> `layout/REFERENCE.md`
|
||||
- Flaky snapshots -> `testing/REFERENCE.md`
|
||||
|
||||
For component naming differences and text modifiers, see `components/REFERENCE.md`.
|
||||
|
||||
## Product Index
|
||||
|
||||
### Frameworks
|
||||
| Framework | Entry File | Description |
|
||||
|-----------|------------|-------------|
|
||||
| Core | `./references/core/REFERENCE.md` | Imperative API, all primitives |
|
||||
| React | `./references/react/REFERENCE.md` | React reconciler for declarative TUI |
|
||||
| Solid | `./references/solid/REFERENCE.md` | SolidJS reconciler for declarative TUI |
|
||||
|
||||
### Cross-Cutting Concepts
|
||||
| Concept | Entry File | Description |
|
||||
|---------|------------|-------------|
|
||||
| Layout | `./references/layout/REFERENCE.md` | Yoga/Flexbox layout system |
|
||||
| Components | `./references/components/REFERENCE.md` | Component reference by category |
|
||||
| Keyboard | `./references/keyboard/REFERENCE.md` | Keyboard input handling |
|
||||
| Animation | `./references/animation/REFERENCE.md` | Timeline-based animations |
|
||||
| Testing | `./references/testing/REFERENCE.md` | Test renderer and snapshots |
|
||||
|
||||
### Component Categories
|
||||
| Category | Entry File | Components |
|
||||
|----------|------------|------------|
|
||||
| Text & Display | `./references/components/text-display.md` | text, ascii-font, styled text |
|
||||
| Containers | `./references/components/containers.md` | box, scrollbox, borders |
|
||||
| Inputs | `./references/components/inputs.md` | input, textarea, select, tab-select |
|
||||
| Code & Diff | `./references/components/code-diff.md` | code, line-number, diff, markdown, text-table |
|
||||
|
||||
## Resources
|
||||
|
||||
**Repository**: https://github.com/anomalyco/opentui
|
||||
**Core Docs**: https://github.com/anomalyco/opentui/tree/main/packages/core/docs
|
||||
**Examples**: https://github.com/anomalyco/opentui/tree/main/packages/core/src/examples
|
||||
**Awesome List**: https://github.com/msmps/awesome-opentui
|
||||
@@ -1,431 +0,0 @@
|
||||
# Animation System
|
||||
|
||||
OpenTUI provides a timeline-based animation system for smooth property transitions.
|
||||
|
||||
## Overview
|
||||
|
||||
Animations in OpenTUI use:
|
||||
- **Timeline**: Orchestrates multiple animations
|
||||
- **Animation Engine**: Manages timelines and rendering
|
||||
- **Easing Functions**: Control animation curves
|
||||
|
||||
## When to Use
|
||||
|
||||
Use this reference when you need timeline-driven animations, easing curves, or progressive transitions.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### React
|
||||
|
||||
```tsx
|
||||
import { useTimeline } from "@opentui/react"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
function AnimatedBox() {
|
||||
const [width, setWidth] = useState(0)
|
||||
|
||||
const timeline = useTimeline({
|
||||
duration: 2000,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
timeline.add(
|
||||
{ width: 0 },
|
||||
{
|
||||
width: 50,
|
||||
duration: 2000,
|
||||
ease: "easeOutQuad",
|
||||
onUpdate: (anim) => {
|
||||
setWidth(Math.round(anim.targets[0].width))
|
||||
},
|
||||
}
|
||||
)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<box
|
||||
width={width}
|
||||
height={3}
|
||||
backgroundColor="#6a5acd"
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Solid
|
||||
|
||||
```tsx
|
||||
import { useTimeline } from "@opentui/solid"
|
||||
import { createSignal, onMount } from "solid-js"
|
||||
|
||||
function AnimatedBox() {
|
||||
const [width, setWidth] = createSignal(0)
|
||||
|
||||
const timeline = useTimeline({
|
||||
duration: 2000,
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
timeline.add(
|
||||
{ width: 0 },
|
||||
{
|
||||
width: 50,
|
||||
duration: 2000,
|
||||
ease: "easeOutQuad",
|
||||
onUpdate: (anim) => {
|
||||
setWidth(Math.round(anim.targets[0].width))
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
width={width()}
|
||||
height={3}
|
||||
backgroundColor="#6a5acd"
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Core
|
||||
|
||||
```typescript
|
||||
import { createCliRenderer, Timeline, engine } from "@opentui/core"
|
||||
|
||||
const renderer = await createCliRenderer()
|
||||
engine.attach(renderer)
|
||||
|
||||
const timeline = new Timeline({
|
||||
duration: 2000,
|
||||
autoplay: true,
|
||||
})
|
||||
|
||||
timeline.add(
|
||||
{ x: 0 },
|
||||
{
|
||||
x: 50,
|
||||
duration: 2000,
|
||||
ease: "easeOutQuad",
|
||||
onUpdate: (anim) => {
|
||||
box.setLeft(Math.round(anim.targets[0].x))
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
engine.addTimeline(timeline)
|
||||
```
|
||||
|
||||
## Timeline Options
|
||||
|
||||
```typescript
|
||||
const timeline = useTimeline({
|
||||
duration: 2000, // Total duration in ms
|
||||
loop: false, // Loop the timeline
|
||||
autoplay: true, // Start automatically
|
||||
onComplete: () => {}, // Called when timeline completes
|
||||
onPause: () => {}, // Called when timeline pauses
|
||||
})
|
||||
```
|
||||
|
||||
## Timeline Methods
|
||||
|
||||
```typescript
|
||||
// Add animation
|
||||
timeline.add(target, properties, startTime?)
|
||||
|
||||
// Control playback
|
||||
timeline.play() // Start/resume
|
||||
timeline.pause() // Pause
|
||||
timeline.restart() // Restart from beginning
|
||||
|
||||
// State
|
||||
timeline.progress // Current progress (0-1)
|
||||
timeline.duration // Total duration
|
||||
```
|
||||
|
||||
## Animation Properties
|
||||
|
||||
```typescript
|
||||
timeline.add(
|
||||
{ value: 0 }, // Target object with initial values
|
||||
{
|
||||
value: 100, // Final value
|
||||
duration: 1000, // Animation duration in ms
|
||||
ease: "linear", // Easing function
|
||||
delay: 0, // Delay before starting
|
||||
onUpdate: (anim) => {
|
||||
// Called each frame
|
||||
const current = anim.targets[0].value
|
||||
},
|
||||
onComplete: () => {
|
||||
// Called when this animation completes
|
||||
},
|
||||
},
|
||||
0 // Start time in timeline (optional)
|
||||
)
|
||||
```
|
||||
|
||||
## Easing Functions
|
||||
|
||||
Available easing functions:
|
||||
|
||||
### Linear
|
||||
|
||||
| Name | Description |
|
||||
|------|-------------|
|
||||
| `linear` | Constant speed |
|
||||
|
||||
### Quad (Power of 2)
|
||||
|
||||
| Name | Description |
|
||||
|------|-------------|
|
||||
| `easeInQuad` | Slow start |
|
||||
| `easeOutQuad` | Slow end |
|
||||
| `easeInOutQuad` | Slow start and end |
|
||||
|
||||
### Cubic (Power of 3)
|
||||
|
||||
| Name | Description |
|
||||
|------|-------------|
|
||||
| `easeInCubic` | Slower start |
|
||||
| `easeOutCubic` | Slower end |
|
||||
| `easeInOutCubic` | Slower start and end |
|
||||
|
||||
### Quart (Power of 4)
|
||||
|
||||
| Name | Description |
|
||||
|------|-------------|
|
||||
| `easeInQuart` | Even slower start |
|
||||
| `easeOutQuart` | Even slower end |
|
||||
| `easeInOutQuart` | Even slower start and end |
|
||||
|
||||
### Expo (Exponential)
|
||||
|
||||
| Name | Description |
|
||||
|------|-------------|
|
||||
| `easeInExpo` | Exponential start |
|
||||
| `easeOutExpo` | Exponential end |
|
||||
| `easeInOutExpo` | Exponential start and end |
|
||||
|
||||
### Back (Overshoot)
|
||||
|
||||
| Name | Description |
|
||||
|------|-------------|
|
||||
| `easeInBack` | Pull back, then forward |
|
||||
| `easeOutBack` | Overshoot, then settle |
|
||||
| `easeInOutBack` | Both |
|
||||
|
||||
### Elastic
|
||||
|
||||
| Name | Description |
|
||||
|------|-------------|
|
||||
| `easeInElastic` | Elastic start |
|
||||
| `easeOutElastic` | Elastic end (bouncy) |
|
||||
| `easeInOutElastic` | Both |
|
||||
|
||||
### Bounce
|
||||
|
||||
| Name | Description |
|
||||
|------|-------------|
|
||||
| `easeInBounce` | Bounce at start |
|
||||
| `easeOutBounce` | Bounce at end |
|
||||
| `easeInOutBounce` | Both |
|
||||
|
||||
## Patterns
|
||||
|
||||
### Progress Bar
|
||||
|
||||
```tsx
|
||||
function ProgressBar({ progress }: { progress: number }) {
|
||||
const [width, setWidth] = useState(0)
|
||||
const maxWidth = 50
|
||||
|
||||
const timeline = useTimeline()
|
||||
|
||||
useEffect(() => {
|
||||
timeline.add(
|
||||
{ value: width },
|
||||
{
|
||||
value: (progress / 100) * maxWidth,
|
||||
duration: 300,
|
||||
ease: "easeOutQuad",
|
||||
onUpdate: (anim) => {
|
||||
setWidth(Math.round(anim.targets[0].value))
|
||||
},
|
||||
}
|
||||
)
|
||||
}, [progress])
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text>Progress: {progress}%</text>
|
||||
<box width={maxWidth} height={1} backgroundColor="#333">
|
||||
<box width={width} height={1} backgroundColor="#00FF00" />
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Fade In
|
||||
|
||||
```tsx
|
||||
function FadeIn({ children }) {
|
||||
const [opacity, setOpacity] = useState(0)
|
||||
|
||||
const timeline = useTimeline()
|
||||
|
||||
useEffect(() => {
|
||||
timeline.add(
|
||||
{ opacity: 0 },
|
||||
{
|
||||
opacity: 1,
|
||||
duration: 500,
|
||||
ease: "easeOutQuad",
|
||||
onUpdate: (anim) => {
|
||||
setOpacity(anim.targets[0].opacity)
|
||||
},
|
||||
}
|
||||
)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<box style={{ opacity }}>
|
||||
{children}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Looping Animation
|
||||
|
||||
```tsx
|
||||
function Spinner() {
|
||||
const [frame, setFrame] = useState(0)
|
||||
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setFrame(f => (f + 1) % frames.length)
|
||||
}, 80)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
return <text>{frames[frame]} Loading...</text>
|
||||
}
|
||||
```
|
||||
|
||||
### Staggered Animation
|
||||
|
||||
```tsx
|
||||
function StaggeredList({ items }) {
|
||||
const [visibleCount, setVisibleCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
let count = 0
|
||||
const interval = setInterval(() => {
|
||||
count++
|
||||
setVisibleCount(count)
|
||||
if (count >= items.length) {
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, 100)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [items.length])
|
||||
|
||||
return (
|
||||
<box flexDirection="column">
|
||||
{items.slice(0, visibleCount).map((item, i) => (
|
||||
<text key={i}>{item}</text>
|
||||
))}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Slide In
|
||||
|
||||
```tsx
|
||||
function SlideIn({ children, from = "left" }) {
|
||||
const [offset, setOffset] = useState(from === "left" ? -20 : 20)
|
||||
|
||||
const timeline = useTimeline()
|
||||
|
||||
useEffect(() => {
|
||||
timeline.add(
|
||||
{ offset: from === "left" ? -20 : 20 },
|
||||
{
|
||||
offset: 0,
|
||||
duration: 300,
|
||||
ease: "easeOutCubic",
|
||||
onUpdate: (anim) => {
|
||||
setOffset(Math.round(anim.targets[0].offset))
|
||||
},
|
||||
}
|
||||
)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<box position="relative" left={offset}>
|
||||
{children}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Tips
|
||||
|
||||
### Batch Updates
|
||||
|
||||
Timeline automatically batches updates within the render loop.
|
||||
|
||||
### Use Integer Values
|
||||
|
||||
Round animated values for character-based positioning:
|
||||
|
||||
```typescript
|
||||
onUpdate: (anim) => {
|
||||
setX(Math.round(anim.targets[0].x))
|
||||
}
|
||||
```
|
||||
|
||||
### Clean Up Timelines
|
||||
|
||||
Hooks automatically clean up, but for core:
|
||||
|
||||
```typescript
|
||||
// When done with timeline
|
||||
engine.removeTimeline(timeline)
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
### Terminal Refresh Rate
|
||||
|
||||
Terminal UIs typically refresh at 60 FPS max. Very fast animations may appear choppy.
|
||||
|
||||
### Character Grid
|
||||
|
||||
Animations are constrained to character cells. Sub-pixel positioning isn't possible.
|
||||
|
||||
### Cleanup in Effects
|
||||
|
||||
Always clean up intervals and timelines:
|
||||
|
||||
```tsx
|
||||
useEffect(() => {
|
||||
const interval = setInterval(...)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
- [React API](../react/api.md) - `useTimeline` hook reference
|
||||
- [Solid API](../solid/api.md) - `useTimeline` hook reference
|
||||
- [Core API](../core/api.md) - `AnimationEngine` and `Timeline` classes
|
||||
- [Layout Patterns](../layout/patterns.md) - Animated positioning and transitions
|
||||
@@ -1,144 +0,0 @@
|
||||
# OpenTUI Components
|
||||
|
||||
Reference for all OpenTUI components, organized by category. Components are available in all three frameworks (Core, React, Solid) with slight API differences.
|
||||
|
||||
## When to Use
|
||||
|
||||
Use this reference when you need to find the right component category or compare naming across Core, React, and Solid.
|
||||
|
||||
## Component Categories
|
||||
|
||||
| Category | Components | File |
|
||||
|----------|------------|------|
|
||||
| Text & Display | text, ascii-font, styled text | [text-display.md](./text-display.md) |
|
||||
| Containers | box, scrollbox, borders | [containers.md](./containers.md) |
|
||||
| Inputs | input, textarea, select, tab-select | [inputs.md](./inputs.md) |
|
||||
| Code & Diff | code, line-number, diff, markdown, text-table | [code-diff.md](./code-diff.md) |
|
||||
|
||||
## Component Chooser
|
||||
|
||||
```
|
||||
Need a component?
|
||||
├─ Styled text or ASCII art -> text-display.md
|
||||
├─ Containers, borders, scrolling -> containers.md
|
||||
├─ Forms or input controls -> inputs.md
|
||||
└─ Code blocks, diffs, line numbers, markdown -> code-diff.md
|
||||
```
|
||||
|
||||
## Component Naming
|
||||
|
||||
Components have different names across frameworks:
|
||||
|
||||
| Concept | Core (Class) | React (JSX) | Solid (JSX) |
|
||||
|---------|--------------|-------------|-------------|
|
||||
| Text | `TextRenderable` | `<text>` | `<text>` |
|
||||
| Box | `BoxRenderable` | `<box>` | `<box>` |
|
||||
| ScrollBox | `ScrollBoxRenderable` | `<scrollbox>` | `<scrollbox>` |
|
||||
| Input | `InputRenderable` | `<input>` | `<input>` |
|
||||
| Textarea | `TextareaRenderable` | `<textarea>` | `<textarea>` |
|
||||
| Select | `SelectRenderable` | `<select>` | `<select>` |
|
||||
| Tab Select | `TabSelectRenderable` | `<tab-select>` | `<tab_select>` |
|
||||
| ASCII Font | `ASCIIFontRenderable` | `<ascii-font>` | `<ascii_font>` |
|
||||
| Code | `CodeRenderable` | `<code>` | `<code>` |
|
||||
| Line Number | `LineNumberRenderable` | `<line-number>` | `<line_number>` |
|
||||
| Diff | `DiffRenderable` | `<diff>` | `<diff>` |
|
||||
| Markdown | `MarkdownRenderable` | `<markdown>` | `<markdown>` |
|
||||
| TextTable | `TextTableRenderable` | N/A (Core only) | N/A (Core only) |
|
||||
|
||||
**Note**: Solid uses underscores (`tab_select`) while React uses hyphens (`tab-select`). `TextTableRenderable` is used internally by `MarkdownRenderable` for table rendering and is also available as a standalone Core component.
|
||||
|
||||
## Common Properties
|
||||
|
||||
All components share these layout properties (see [Layout](../layout/REFERENCE.md)):
|
||||
|
||||
```tsx
|
||||
// Positioning
|
||||
position="relative" | "absolute"
|
||||
left, top, right, bottom
|
||||
|
||||
// Dimensions
|
||||
width, height
|
||||
minWidth, maxWidth, minHeight, maxHeight
|
||||
|
||||
// Flexbox
|
||||
flexDirection, flexGrow, flexShrink, flexBasis
|
||||
justifyContent, alignItems, alignSelf
|
||||
flexWrap, gap
|
||||
|
||||
// Spacing
|
||||
padding, paddingTop, paddingRight, paddingBottom, paddingLeft
|
||||
paddingX, paddingY // Axis shorthand (horizontal/vertical)
|
||||
margin, marginTop, marginRight, marginBottom, marginLeft
|
||||
marginX, marginY // Axis shorthand (horizontal/vertical)
|
||||
|
||||
// Display
|
||||
display="flex" | "none"
|
||||
overflow="visible" | "hidden" | "scroll"
|
||||
zIndex
|
||||
```
|
||||
|
||||
## Quick Examples
|
||||
|
||||
### Core (Imperative)
|
||||
|
||||
```typescript
|
||||
import { createCliRenderer, TextRenderable, BoxRenderable } from "@opentui/core"
|
||||
|
||||
const renderer = await createCliRenderer()
|
||||
|
||||
const box = new BoxRenderable(renderer, {
|
||||
id: "container",
|
||||
border: true,
|
||||
padding: 2,
|
||||
})
|
||||
|
||||
const text = new TextRenderable(renderer, {
|
||||
id: "greeting",
|
||||
content: "Hello!",
|
||||
fg: "#00FF00",
|
||||
})
|
||||
|
||||
box.add(text)
|
||||
renderer.root.add(box)
|
||||
```
|
||||
|
||||
### React
|
||||
|
||||
```tsx
|
||||
import { createCliRenderer } from "@opentui/core"
|
||||
import { createRoot } from "@opentui/react"
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<box border padding={2}>
|
||||
<text fg="#00FF00">Hello!</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
const renderer = await createCliRenderer()
|
||||
createRoot(renderer).render(<App />)
|
||||
```
|
||||
|
||||
### Solid
|
||||
|
||||
```tsx
|
||||
import { render } from "@opentui/solid"
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<box border padding={2}>
|
||||
<text fg="#00FF00">Hello!</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
render(() => <App />)
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
- [Core API](../core/api.md) - Imperative component classes
|
||||
- [React API](../react/api.md) - React component props
|
||||
- [Solid API](../solid/api.md) - Solid component props
|
||||
- [Layout](../layout/REFERENCE.md) - Layout system details
|
||||
@@ -1,672 +0,0 @@
|
||||
# Code & Diff Components
|
||||
|
||||
Components for displaying code with syntax highlighting and diffs in OpenTUI.
|
||||
|
||||
## Code Component
|
||||
|
||||
Display syntax-highlighted code blocks.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```tsx
|
||||
// React
|
||||
<code
|
||||
code={`function hello() {
|
||||
console.log("Hello, World!");
|
||||
}`}
|
||||
language="typescript"
|
||||
/>
|
||||
|
||||
// Solid
|
||||
<code
|
||||
code={sourceCode}
|
||||
language="javascript"
|
||||
/>
|
||||
|
||||
// Core
|
||||
const codeBlock = new CodeRenderable(renderer, {
|
||||
id: "code",
|
||||
code: sourceCode,
|
||||
language: "typescript",
|
||||
})
|
||||
```
|
||||
|
||||
### Supported Languages
|
||||
|
||||
OpenTUI uses Tree-sitter for syntax highlighting. Common languages:
|
||||
- `typescript`, `javascript`
|
||||
- `python`
|
||||
- `rust`
|
||||
- `go`
|
||||
- `json`
|
||||
- `html`, `css`
|
||||
- `markdown`
|
||||
- `bash`, `shell`
|
||||
|
||||
### Styling
|
||||
|
||||
```tsx
|
||||
<code
|
||||
code={sourceCode}
|
||||
language="typescript"
|
||||
backgroundColor="#1a1a2e"
|
||||
showLineNumbers
|
||||
/>
|
||||
```
|
||||
|
||||
### onHighlight Callback
|
||||
|
||||
Intercept and modify syntax highlights before rendering:
|
||||
|
||||
```tsx
|
||||
// Core
|
||||
const codeBlock = new CodeRenderable(renderer, {
|
||||
id: "code",
|
||||
code: sourceCode,
|
||||
language: "typescript",
|
||||
onHighlight: (highlights, context) => {
|
||||
// Add custom highlights
|
||||
highlights.push([10, 20, "custom.error", {}])
|
||||
return highlights
|
||||
},
|
||||
})
|
||||
|
||||
// React/Solid
|
||||
<code
|
||||
code={sourceCode}
|
||||
language="typescript"
|
||||
onHighlight={(highlights, context) => {
|
||||
// context: { content, filetype, syntaxStyle }
|
||||
// Modify and return highlights array
|
||||
return highlights.filter(h => h[2] !== "comment")
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
**Callback signature:**
|
||||
- `highlights: SimpleHighlight[]` - Array of `[start, end, scope, metadata]`
|
||||
- `context: { content, filetype, syntaxStyle }` - Highlighting context
|
||||
- Return modified highlights array or `undefined` to use original
|
||||
|
||||
Supports async callbacks for fetching additional highlight data.
|
||||
|
||||
### onChunks Callback
|
||||
|
||||
Post-process rendered text chunks after syntax highlighting. Runs after `onHighlight` and receives fully resolved chunks:
|
||||
|
||||
```tsx
|
||||
// Core
|
||||
const codeBlock = new CodeRenderable(renderer, {
|
||||
id: "code",
|
||||
code: sourceCode,
|
||||
language: "typescript",
|
||||
onChunks: (chunks, context) => {
|
||||
// Transform chunks (e.g., add link detection)
|
||||
return chunks
|
||||
},
|
||||
})
|
||||
|
||||
// React/Solid
|
||||
<code
|
||||
code={sourceCode}
|
||||
language="typescript"
|
||||
onChunks={(chunks, context) => {
|
||||
// context: { content, filetype, syntaxStyle, highlights }
|
||||
return chunks
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Link Detection Utility
|
||||
|
||||
Auto-detect URLs in code and add clickable hyperlinks:
|
||||
|
||||
```typescript
|
||||
import { detectLinks } from "@opentui/core"
|
||||
|
||||
<code
|
||||
code={sourceCode}
|
||||
language="typescript"
|
||||
onChunks={(chunks, context) => detectLinks(chunks, context)}
|
||||
/>
|
||||
```
|
||||
|
||||
`detectLinks` examines Tree-sitter highlights to find URL tokens and sets `chunk.link` on matching chunks. Supports async usage.
|
||||
|
||||
## TextTable Component
|
||||
|
||||
Render data tables with borders, word wrapping, and selection support.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
// Core
|
||||
import { TextTableRenderable, type TextTableContent } from "@opentui/core"
|
||||
|
||||
const content: TextTableContent = [
|
||||
[[ { text: "Name" } ], [ { text: "Age" } ], [ { text: "Role" } ]],
|
||||
[[ { text: "Alice" } ], [ { text: "30" } ], [ { text: "Engineer" } ]],
|
||||
[[ { text: "Bob" } ], [ { text: "25" } ], [ { text: "Designer" } ]],
|
||||
]
|
||||
|
||||
const table = new TextTableRenderable(renderer, {
|
||||
id: "table",
|
||||
content,
|
||||
wrapMode: "word", // "none" | "char" | "word"
|
||||
columnWidthMode: "content", // "content" | "fill"
|
||||
cellPadding: 0,
|
||||
border: true,
|
||||
outerBorder: true,
|
||||
borderStyle: "single", // single | double | rounded | bold
|
||||
selectable: true, // Allow text selection
|
||||
columnFitter: "balanced", // "proportional" | "balanced"
|
||||
})
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `content` | `TextTableContent` | - | 2D array of cell content |
|
||||
| `wrapMode` | `"none" \| "char" \| "word"` | `"none"` | Text wrapping in cells |
|
||||
| `columnWidthMode` | `"content" \| "fill"` | `"content"` | Column sizing strategy |
|
||||
| `cellPadding` | `number` | `0` | Padding inside cells |
|
||||
| `border` | `boolean` | `true` | Show inner borders |
|
||||
| `outerBorder` | `boolean` | `true` | Show outer borders |
|
||||
| `borderStyle` | `string` | `"single"` | Border style |
|
||||
| `borderColor` | `string \| RGBA` | - | Border color |
|
||||
| `selectable` | `boolean` | `false` | Allow text selection |
|
||||
| `columnFitter` | `"proportional" \| "balanced"` | `"proportional"` | Column width distribution |
|
||||
|
||||
### Cell Content Format
|
||||
|
||||
Each cell is an array of styled text chunks:
|
||||
|
||||
```typescript
|
||||
type TextTableCellContent = { text: string; fg?: RGBA; bg?: RGBA }[]
|
||||
type TextTableContent = TextTableCellContent[][] // rows -> cells -> chunks
|
||||
```
|
||||
|
||||
### Selection
|
||||
|
||||
```typescript
|
||||
table.getSelectedText() // Get selected text
|
||||
table.hasSelection() // Check if text is selected
|
||||
```
|
||||
|
||||
Columnar selection is supported: dragging vertically within a single column selects only that column's content.
|
||||
|
||||
## Line Number Component
|
||||
|
||||
Code display with line numbers, highlighting, and diagnostics.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```tsx
|
||||
// React
|
||||
<line-number
|
||||
code={sourceCode}
|
||||
language="typescript"
|
||||
/>
|
||||
|
||||
// Solid (note underscore)
|
||||
<line_number
|
||||
code={sourceCode}
|
||||
language="typescript"
|
||||
/>
|
||||
|
||||
// Core
|
||||
const codeView = new LineNumberRenderable(renderer, {
|
||||
id: "code-view",
|
||||
code: sourceCode,
|
||||
language: "typescript",
|
||||
})
|
||||
```
|
||||
|
||||
### Line Number Options
|
||||
|
||||
```tsx
|
||||
// React
|
||||
<line-number
|
||||
code={sourceCode}
|
||||
language="typescript"
|
||||
startLine={1} // Starting line number
|
||||
showLineNumbers={true} // Display line numbers
|
||||
/>
|
||||
|
||||
// Solid
|
||||
<line_number
|
||||
code={sourceCode}
|
||||
language="typescript"
|
||||
startLine={1}
|
||||
showLineNumbers={true}
|
||||
/>
|
||||
```
|
||||
|
||||
### Line Highlighting
|
||||
|
||||
Highlight specific lines:
|
||||
|
||||
```tsx
|
||||
// React
|
||||
<line-number
|
||||
code={sourceCode}
|
||||
language="typescript"
|
||||
highlightedLines={[5, 10, 15]} // Highlight these lines
|
||||
/>
|
||||
|
||||
// Solid
|
||||
<line_number
|
||||
code={sourceCode}
|
||||
language="typescript"
|
||||
highlightedLines={[5, 10, 15]}
|
||||
/>
|
||||
```
|
||||
|
||||
### Diagnostics
|
||||
|
||||
Show errors, warnings, and info on specific lines:
|
||||
|
||||
```tsx
|
||||
// React
|
||||
<line-number
|
||||
code={sourceCode}
|
||||
language="typescript"
|
||||
diagnostics={[
|
||||
{ line: 3, severity: "error", message: "Unexpected token" },
|
||||
{ line: 7, severity: "warning", message: "Unused variable" },
|
||||
{ line: 12, severity: "info", message: "Consider using const" },
|
||||
]}
|
||||
/>
|
||||
|
||||
// Solid
|
||||
<line_number
|
||||
code={sourceCode}
|
||||
language="typescript"
|
||||
diagnostics={[
|
||||
{ line: 3, severity: "error", message: "Unexpected token" },
|
||||
]}
|
||||
/>
|
||||
```
|
||||
|
||||
**Diagnostic severity levels:**
|
||||
- `error` - Red indicator
|
||||
- `warning` - Yellow indicator
|
||||
- `info` - Blue indicator
|
||||
- `hint` - Gray indicator
|
||||
|
||||
### Diff Highlighting
|
||||
|
||||
Show added/removed lines:
|
||||
|
||||
```tsx
|
||||
<line-number
|
||||
code={sourceCode}
|
||||
language="typescript"
|
||||
addedLines={[5, 6, 7]} // Green background
|
||||
removedLines={[10, 11]} // Red background
|
||||
/>
|
||||
```
|
||||
|
||||
## Diff Component
|
||||
|
||||
Unified or split diff viewer with syntax highlighting.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```tsx
|
||||
// React
|
||||
<diff
|
||||
oldCode={originalCode}
|
||||
newCode={modifiedCode}
|
||||
language="typescript"
|
||||
/>
|
||||
|
||||
// Solid
|
||||
<diff
|
||||
oldCode={originalCode}
|
||||
newCode={modifiedCode}
|
||||
language="typescript"
|
||||
/>
|
||||
|
||||
// Core
|
||||
const diffView = new DiffRenderable(renderer, {
|
||||
id: "diff",
|
||||
oldCode: originalCode,
|
||||
newCode: modifiedCode,
|
||||
language: "typescript",
|
||||
})
|
||||
```
|
||||
|
||||
### Display Modes
|
||||
|
||||
```tsx
|
||||
// Unified diff (default)
|
||||
<diff
|
||||
oldCode={old}
|
||||
newCode={new}
|
||||
mode="unified"
|
||||
/>
|
||||
|
||||
// Split/side-by-side diff
|
||||
<diff
|
||||
oldCode={old}
|
||||
newCode={new}
|
||||
mode="split"
|
||||
/>
|
||||
```
|
||||
|
||||
### Synchronized Scrolling (Split View)
|
||||
|
||||
In split view, enable synchronized scrolling between left and right panes:
|
||||
|
||||
```tsx
|
||||
// React/Solid
|
||||
<diff
|
||||
oldCode={old}
|
||||
newCode={new}
|
||||
mode="split"
|
||||
syncScroll // Scrolling one pane syncs the other
|
||||
/>
|
||||
|
||||
// Core
|
||||
const diffView = new DiffRenderable(renderer, {
|
||||
id: "diff",
|
||||
diff: unifiedDiff,
|
||||
view: "split",
|
||||
syncScroll: true,
|
||||
})
|
||||
|
||||
// Toggle at runtime
|
||||
diffView.syncScroll = true
|
||||
diffView.syncScroll = false
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
```tsx
|
||||
<diff
|
||||
oldCode={originalCode}
|
||||
newCode={modifiedCode}
|
||||
language="typescript"
|
||||
mode="unified"
|
||||
showLineNumbers
|
||||
context={3} // Lines of context around changes
|
||||
/>
|
||||
```
|
||||
|
||||
### Styling
|
||||
|
||||
```tsx
|
||||
<diff
|
||||
oldCode={old}
|
||||
newCode={new}
|
||||
addedLineColor="#2d4f2d" // Background for added lines
|
||||
removedLineColor="#4f2d2d" // Background for removed lines
|
||||
unchangedLineColor="transparent"
|
||||
/>
|
||||
```
|
||||
|
||||
### Line Highlighting API (Core)
|
||||
|
||||
Programmatically highlight specific lines in a diff:
|
||||
|
||||
```typescript
|
||||
// Set a single line's color
|
||||
diffView.setLineColor(5, "#2d4f2d")
|
||||
diffView.setLineColor(5, { gutter: "#333", content: "#2d4f2d" })
|
||||
|
||||
// Clear a single line's color
|
||||
diffView.clearLineColor(5)
|
||||
|
||||
// Set multiple lines at once
|
||||
diffView.setLineColors(new Map([
|
||||
[1, "#2d4f2d"],
|
||||
[2, "#4f2d2d"],
|
||||
]))
|
||||
|
||||
// Highlight a range
|
||||
diffView.highlightLines(10, 20, "#2d4f2d")
|
||||
diffView.clearHighlightLines(10, 20)
|
||||
|
||||
// Clear all line colors
|
||||
diffView.clearAllLineColors()
|
||||
```
|
||||
|
||||
The `LineNumberRenderable` also supports programmatic highlighting:
|
||||
|
||||
```typescript
|
||||
lineNumberView.highlightLines(5, 10, "#2d4f2d")
|
||||
lineNumberView.clearHighlightLines(5, 10)
|
||||
```
|
||||
```
|
||||
|
||||
## Markdown Component
|
||||
|
||||
Render markdown content with syntax highlighting for code blocks.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```tsx
|
||||
// React
|
||||
<markdown
|
||||
content={markdownText}
|
||||
syntaxStyle={mySyntaxStyle}
|
||||
/>
|
||||
|
||||
// Solid
|
||||
<markdown
|
||||
content={markdownText}
|
||||
syntaxStyle={mySyntaxStyle}
|
||||
/>
|
||||
|
||||
// Core
|
||||
import { MarkdownRenderable } from "@opentui/core"
|
||||
|
||||
const md = new MarkdownRenderable(renderer, {
|
||||
id: "markdown",
|
||||
content: "# Hello\n\nThis is **markdown**.",
|
||||
syntaxStyle: mySyntaxStyle,
|
||||
})
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
```tsx
|
||||
<markdown
|
||||
content={markdownText}
|
||||
syntaxStyle={syntaxStyle}
|
||||
treeSitterClient={client} // Optional: custom tree-sitter client
|
||||
conceal={true} // Hide markdown syntax characters
|
||||
streaming={true} // Enable streaming mode for incremental updates
|
||||
tableOptions={{ // Customize markdown table rendering
|
||||
widthMode: "full", // "content" | "full"
|
||||
wrapMode: "word", // "none" | "char" | "word"
|
||||
cellPadding: 0,
|
||||
borders: true,
|
||||
outerBorder: true,
|
||||
borderStyle: "single",
|
||||
borderColor: "#555",
|
||||
selectable: true, // Tables are selectable by default
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Custom Node Rendering
|
||||
|
||||
```tsx
|
||||
// Core
|
||||
const md = new MarkdownRenderable(renderer, {
|
||||
id: "markdown",
|
||||
content: "# Custom Heading",
|
||||
syntaxStyle,
|
||||
renderNode: (node, ctx, defaultRender) => {
|
||||
if (node.type === "heading") {
|
||||
// Return custom renderable for headings
|
||||
return new TextRenderable(ctx, {
|
||||
content: `>> ${node.content} <<`,
|
||||
})
|
||||
}
|
||||
return null // Use default rendering
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Streaming Mode
|
||||
|
||||
For real-time content like LLM output:
|
||||
|
||||
```tsx
|
||||
const [content, setContent] = useState("")
|
||||
|
||||
// Append text as it arrives
|
||||
useEffect(() => {
|
||||
llmStream.on("token", (token) => {
|
||||
setContent(c => c + token)
|
||||
})
|
||||
}, [])
|
||||
|
||||
<markdown
|
||||
content={content}
|
||||
syntaxStyle={syntaxStyle}
|
||||
streaming={true} // Optimizes for incremental updates
|
||||
/>
|
||||
```
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Code Editor
|
||||
|
||||
```tsx
|
||||
function CodeEditor() {
|
||||
const [code, setCode] = useState(`function hello() {
|
||||
console.log("Hello!");
|
||||
}`)
|
||||
|
||||
return (
|
||||
<box flexDirection="column" height="100%">
|
||||
<box height={1}>
|
||||
<text>editor.ts</text>
|
||||
</box>
|
||||
<textarea
|
||||
value={code}
|
||||
onChange={setCode}
|
||||
language="typescript"
|
||||
showLineNumbers
|
||||
flexGrow={1}
|
||||
focused
|
||||
/>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Code Review
|
||||
|
||||
```tsx
|
||||
function CodeReview({ oldCode, newCode }) {
|
||||
return (
|
||||
<box flexDirection="column" height="100%">
|
||||
<box height={1} backgroundColor="#333">
|
||||
<text>Changes in src/utils.ts</text>
|
||||
</box>
|
||||
<diff
|
||||
oldCode={oldCode}
|
||||
newCode={newCode}
|
||||
language="typescript"
|
||||
mode="split"
|
||||
showLineNumbers
|
||||
/>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Syntax-Highlighted Preview
|
||||
|
||||
```tsx
|
||||
function MarkdownPreview({ content }) {
|
||||
// Extract code blocks from markdown
|
||||
const codeBlocks = extractCodeBlocks(content)
|
||||
|
||||
return (
|
||||
<scrollbox height={20}>
|
||||
{codeBlocks.map((block, i) => (
|
||||
<box key={i} marginBottom={1}>
|
||||
<code
|
||||
code={block.code}
|
||||
language={block.language}
|
||||
/>
|
||||
</box>
|
||||
))}
|
||||
</scrollbox>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Error Display
|
||||
|
||||
```tsx
|
||||
function ErrorView({ errors, code }) {
|
||||
const diagnostics = errors.map(err => ({
|
||||
line: err.line,
|
||||
severity: "error",
|
||||
message: err.message,
|
||||
}))
|
||||
|
||||
return (
|
||||
<line-number
|
||||
code={code}
|
||||
language="typescript"
|
||||
diagnostics={diagnostics}
|
||||
highlightedLines={errors.map(e => e.line)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
### Solid Uses Underscores
|
||||
|
||||
```tsx
|
||||
// React
|
||||
<line-number />
|
||||
|
||||
// Solid
|
||||
<line_number />
|
||||
```
|
||||
|
||||
### Language Required for Highlighting
|
||||
|
||||
```tsx
|
||||
// No highlighting (plain text)
|
||||
<code code={text} />
|
||||
|
||||
// With highlighting
|
||||
<code code={text} language="typescript" />
|
||||
```
|
||||
|
||||
### Large Files
|
||||
|
||||
For very large files, consider:
|
||||
- Pagination or virtual scrolling
|
||||
- Loading only visible portion
|
||||
- Using `scrollbox` wrapper
|
||||
|
||||
```tsx
|
||||
<scrollbox height={30}>
|
||||
<line-number
|
||||
code={largeFile}
|
||||
language="typescript"
|
||||
/>
|
||||
</scrollbox>
|
||||
```
|
||||
|
||||
### Tree-sitter Loading
|
||||
|
||||
Syntax highlighting requires Tree-sitter grammars. If highlighting isn't working:
|
||||
|
||||
1. Check the language is supported
|
||||
2. Verify grammars are installed
|
||||
3. Check `OTUI_TREE_SITTER_WORKER_PATH` if using custom path
|
||||
@@ -1,417 +0,0 @@
|
||||
# Container Components
|
||||
|
||||
Components for grouping and organizing content in OpenTUI.
|
||||
|
||||
## Box Component
|
||||
|
||||
The primary container component with borders, backgrounds, and layout capabilities.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```tsx
|
||||
// React/Solid
|
||||
<box>
|
||||
<text>Content inside box</text>
|
||||
</box>
|
||||
|
||||
// Core
|
||||
const box = new BoxRenderable(renderer, {
|
||||
id: "container",
|
||||
})
|
||||
box.add(child)
|
||||
```
|
||||
|
||||
### Borders
|
||||
|
||||
```tsx
|
||||
<box border>
|
||||
Simple border
|
||||
</box>
|
||||
|
||||
<box
|
||||
border
|
||||
borderStyle="single" // single | double | rounded | bold | none
|
||||
borderColor="#FFFFFF"
|
||||
>
|
||||
Styled border
|
||||
</box>
|
||||
|
||||
// Individual borders
|
||||
<box
|
||||
borderTop
|
||||
borderBottom
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
>
|
||||
Top and bottom only
|
||||
</box>
|
||||
```
|
||||
|
||||
**Border Styles:**
|
||||
|
||||
| Style | Appearance |
|
||||
|-------|------------|
|
||||
| `single` | `┌─┐│ │└─┘` |
|
||||
| `double` | `╔═╗║ ║╚═╝` |
|
||||
| `rounded` | `╭─╮│ │╰─╯` |
|
||||
| `bold` | `┏━┓┃ ┃┗━┛` |
|
||||
|
||||
### Title
|
||||
|
||||
```tsx
|
||||
<box
|
||||
border
|
||||
title="Settings"
|
||||
titleAlignment="center" // left | center | right
|
||||
>
|
||||
Panel content
|
||||
</box>
|
||||
```
|
||||
|
||||
### Background
|
||||
|
||||
```tsx
|
||||
<box backgroundColor="#1a1a2e">
|
||||
Dark background
|
||||
</box>
|
||||
|
||||
<box backgroundColor="transparent">
|
||||
No background
|
||||
</box>
|
||||
```
|
||||
|
||||
### Layout
|
||||
|
||||
Boxes are flex containers by default:
|
||||
|
||||
```tsx
|
||||
<box
|
||||
flexDirection="row" // row | column | row-reverse | column-reverse
|
||||
justifyContent="center" // flex-start | flex-end | center | space-between | space-around
|
||||
alignItems="center" // flex-start | flex-end | center | stretch | baseline
|
||||
gap={2} // Space between children
|
||||
>
|
||||
<text>Item 1</text>
|
||||
<text>Item 2</text>
|
||||
</box>
|
||||
```
|
||||
|
||||
### Spacing
|
||||
|
||||
```tsx
|
||||
<box
|
||||
padding={2} // All sides
|
||||
paddingTop={1}
|
||||
paddingRight={2}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingX={2} // Horizontal (left + right)
|
||||
paddingY={1} // Vertical (top + bottom)
|
||||
margin={1}
|
||||
marginTop={1}
|
||||
marginX={2} // Horizontal (left + right)
|
||||
marginY={1} // Vertical (top + bottom)
|
||||
>
|
||||
Spaced content
|
||||
</box>
|
||||
```
|
||||
|
||||
### Dimensions
|
||||
|
||||
```tsx
|
||||
<box
|
||||
width={40} // Fixed width
|
||||
height={10} // Fixed height
|
||||
width="50%" // Percentage of parent
|
||||
minWidth={20} // Minimum width
|
||||
maxWidth={80} // Maximum width
|
||||
flexGrow={1} // Grow to fill space
|
||||
>
|
||||
Sized box
|
||||
</box>
|
||||
```
|
||||
|
||||
### Mouse Events
|
||||
|
||||
```tsx
|
||||
<box
|
||||
onMouseDown={(event) => {
|
||||
console.log("Clicked at:", event.x, event.y)
|
||||
}}
|
||||
onMouseUp={(event) => {}}
|
||||
onMouseMove={(event) => {}}
|
||||
>
|
||||
Clickable box
|
||||
</box>
|
||||
```
|
||||
|
||||
### Focusable Boxes
|
||||
|
||||
By default, Box elements are not focusable. Set the `focusable` prop to enable focus behavior:
|
||||
|
||||
```tsx
|
||||
// Make a box focusable - it can receive focus via mouse click
|
||||
<box focusable border>
|
||||
<text>Click to focus</text>
|
||||
</box>
|
||||
|
||||
// Controlled focus state
|
||||
const [focused, setFocused] = useState(false)
|
||||
|
||||
<box
|
||||
focusable
|
||||
focused={focused}
|
||||
border
|
||||
borderColor={focused ? "#00ff00" : "#888"}
|
||||
>
|
||||
<text>{focused ? "Focused!" : "Not focused"}</text>
|
||||
</box>
|
||||
```
|
||||
|
||||
When a focusable Box is clicked, focus bubbles up from the click target to the nearest focusable parent. Use `event.preventDefault()` in `onMouseDown` to prevent auto-focus.
|
||||
|
||||
## ScrollBox Component
|
||||
|
||||
A scrollable container for content that exceeds the viewport.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```tsx
|
||||
// React
|
||||
<scrollbox height={10}>
|
||||
{items.map((item, i) => (
|
||||
<text key={i}>{item}</text>
|
||||
))}
|
||||
</scrollbox>
|
||||
|
||||
// Solid
|
||||
<scrollbox height={10}>
|
||||
<For each={items()}>
|
||||
{(item) => <text>{item}</text>}
|
||||
</For>
|
||||
</scrollbox>
|
||||
|
||||
// Core
|
||||
const scrollbox = new ScrollBoxRenderable(renderer, {
|
||||
id: "list",
|
||||
height: 10,
|
||||
})
|
||||
items.forEach(item => {
|
||||
scrollbox.add(new TextRenderable(renderer, { content: item }))
|
||||
})
|
||||
```
|
||||
|
||||
### Focus for Keyboard Scrolling
|
||||
|
||||
```tsx
|
||||
<scrollbox focused height={20}>
|
||||
{/* Use arrow keys to scroll */}
|
||||
</scrollbox>
|
||||
```
|
||||
|
||||
### Scrollbar Styling
|
||||
|
||||
```tsx
|
||||
// React
|
||||
<scrollbox
|
||||
style={{
|
||||
rootOptions: {
|
||||
backgroundColor: "#24283b",
|
||||
},
|
||||
wrapperOptions: {
|
||||
backgroundColor: "#1f2335",
|
||||
},
|
||||
viewportOptions: {
|
||||
backgroundColor: "#1a1b26",
|
||||
},
|
||||
contentOptions: {
|
||||
backgroundColor: "#16161e",
|
||||
},
|
||||
scrollbarOptions: {
|
||||
showArrows: true,
|
||||
trackOptions: {
|
||||
foregroundColor: "#7aa2f7",
|
||||
backgroundColor: "#414868",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</scrollbox>
|
||||
```
|
||||
|
||||
### Scroll Position (Core)
|
||||
|
||||
```typescript
|
||||
const scrollbox = new ScrollBoxRenderable(renderer, {
|
||||
id: "list",
|
||||
height: 20,
|
||||
})
|
||||
|
||||
// Scroll programmatically
|
||||
scrollbox.scrollTo(0) // Scroll to top
|
||||
scrollbox.scrollTo(100) // Scroll to position
|
||||
scrollbox.scrollBy(10) // Scroll relative
|
||||
scrollbox.scrollToBottom() // Scroll to end
|
||||
|
||||
// Scroll a child into view (nearest alignment)
|
||||
scrollbox.scrollChildIntoView("child-id") // Searches descendants by ID
|
||||
```
|
||||
|
||||
`scrollChildIntoView(childId)` scrolls the minimum amount needed to make the identified descendant visible. It mirrors `Element.scrollIntoView({ block: "nearest" })` from the CSSOM View spec. Works with nested descendants and handles both horizontal and vertical scrolling.
|
||||
|
||||
## Composition Patterns
|
||||
|
||||
### Card Component
|
||||
|
||||
```tsx
|
||||
function Card({ title, children }) {
|
||||
return (
|
||||
<box
|
||||
border
|
||||
borderStyle="rounded"
|
||||
padding={2}
|
||||
marginBottom={1}
|
||||
>
|
||||
{title && (
|
||||
<text fg="#00FFFF" bold>
|
||||
{title}
|
||||
</text>
|
||||
)}
|
||||
<box marginTop={title ? 1 : 0}>
|
||||
{children}
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Panel Component
|
||||
|
||||
```tsx
|
||||
function Panel({ title, children, width = 40 }) {
|
||||
return (
|
||||
<box
|
||||
border
|
||||
borderStyle="double"
|
||||
width={width}
|
||||
backgroundColor="#1a1a2e"
|
||||
>
|
||||
{title && (
|
||||
<box
|
||||
borderBottom
|
||||
padding={1}
|
||||
backgroundColor="#2a2a4e"
|
||||
>
|
||||
<text bold>{title}</text>
|
||||
</box>
|
||||
)}
|
||||
<box padding={2}>
|
||||
{children}
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### List Container
|
||||
|
||||
```tsx
|
||||
function List({ items, renderItem }) {
|
||||
return (
|
||||
<scrollbox height={15} focused>
|
||||
{items.map((item, i) => (
|
||||
<box
|
||||
key={i}
|
||||
padding={1}
|
||||
backgroundColor={i % 2 === 0 ? "#222" : "#333"}
|
||||
>
|
||||
{renderItem(item, i)}
|
||||
</box>
|
||||
))}
|
||||
</scrollbox>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Nesting Containers
|
||||
|
||||
```tsx
|
||||
<box flexDirection="column" height="100%">
|
||||
{/* Header */}
|
||||
<box height={3} border>
|
||||
<text>Header</text>
|
||||
</box>
|
||||
|
||||
{/* Main area with sidebar */}
|
||||
<box flexDirection="row" flexGrow={1}>
|
||||
<box width={20} border>
|
||||
<text>Sidebar</text>
|
||||
</box>
|
||||
<box flexGrow={1}>
|
||||
<scrollbox height="100%">
|
||||
{/* Scrollable content */}
|
||||
</scrollbox>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* Footer */}
|
||||
<box height={1}>
|
||||
<text>Footer</text>
|
||||
</box>
|
||||
</box>
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
### Percentage Dimensions Need Parent Size
|
||||
|
||||
```tsx
|
||||
// WRONG - parent has no explicit size
|
||||
<box>
|
||||
<box width="50%">Won't work</box>
|
||||
</box>
|
||||
|
||||
// CORRECT
|
||||
<box width="100%">
|
||||
<box width="50%">Works</box>
|
||||
</box>
|
||||
```
|
||||
|
||||
### FlexGrow Needs Sized Parent
|
||||
|
||||
```tsx
|
||||
// WRONG
|
||||
<box>
|
||||
<box flexGrow={1}>Won't grow</box>
|
||||
</box>
|
||||
|
||||
// CORRECT
|
||||
<box height="100%">
|
||||
<box flexGrow={1}>Will grow</box>
|
||||
</box>
|
||||
```
|
||||
|
||||
### ScrollBox Needs Height
|
||||
|
||||
```tsx
|
||||
// WRONG - no height constraint
|
||||
<scrollbox>
|
||||
{items}
|
||||
</scrollbox>
|
||||
|
||||
// CORRECT
|
||||
<scrollbox height={20}>
|
||||
{items}
|
||||
</scrollbox>
|
||||
```
|
||||
|
||||
### Borders Add to Size
|
||||
|
||||
Borders take up space inside the box:
|
||||
|
||||
```tsx
|
||||
<box width={10} border>
|
||||
{/* Inner content area is 8 chars (10 - 2 for borders) */}
|
||||
</box>
|
||||
```
|
||||
@@ -1,531 +0,0 @@
|
||||
# Input Components
|
||||
|
||||
Components for user input in OpenTUI.
|
||||
|
||||
## Input Component
|
||||
|
||||
Single-line text input field.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```tsx
|
||||
// React
|
||||
<input
|
||||
value={value}
|
||||
onChange={(newValue) => setValue(newValue)}
|
||||
placeholder="Enter text..."
|
||||
focused
|
||||
/>
|
||||
|
||||
// Solid
|
||||
<input
|
||||
value={value()}
|
||||
onInput={(newValue) => setValue(newValue)}
|
||||
placeholder="Enter text..."
|
||||
focused
|
||||
/>
|
||||
|
||||
// Core
|
||||
const input = new InputRenderable(renderer, {
|
||||
id: "name",
|
||||
placeholder: "Enter text...",
|
||||
})
|
||||
input.on(InputRenderableEvents.CHANGE, (value) => {
|
||||
console.log("Value:", value)
|
||||
})
|
||||
input.focus()
|
||||
```
|
||||
|
||||
### Styling
|
||||
|
||||
```tsx
|
||||
<input
|
||||
width={30}
|
||||
backgroundColor="#1a1a1a"
|
||||
textColor="#FFFFFF"
|
||||
cursorColor="#00FF00"
|
||||
focusedBackgroundColor="#2a2a2a"
|
||||
placeholderColor="#666666"
|
||||
/>
|
||||
```
|
||||
|
||||
### Events
|
||||
|
||||
```tsx
|
||||
// React
|
||||
<input
|
||||
onChange={(value) => console.log("Changed:", value)}
|
||||
onFocus={() => console.log("Focused")}
|
||||
onBlur={() => console.log("Blurred")}
|
||||
/>
|
||||
|
||||
// Core
|
||||
input.on(InputRenderableEvents.CHANGE, (value) => {})
|
||||
input.on(InputRenderableEvents.FOCUS, () => {})
|
||||
input.on(InputRenderableEvents.BLUR, () => {})
|
||||
```
|
||||
|
||||
### Controlled Input
|
||||
|
||||
```tsx
|
||||
// React
|
||||
function ControlledInput() {
|
||||
const [value, setValue] = useState("")
|
||||
|
||||
return (
|
||||
<input
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
focused
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Solid
|
||||
function ControlledInput() {
|
||||
const [value, setValue] = createSignal("")
|
||||
|
||||
return (
|
||||
<input
|
||||
value={value()}
|
||||
onInput={setValue}
|
||||
focused
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Textarea Component
|
||||
|
||||
Multi-line text input field.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```tsx
|
||||
// React
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(newText) => setText(newText)}
|
||||
placeholder="Enter multiple lines..."
|
||||
width={40}
|
||||
height={10}
|
||||
focused
|
||||
/>
|
||||
|
||||
// Solid
|
||||
<textarea
|
||||
value={text()}
|
||||
onInput={(newText) => setText(newText)}
|
||||
placeholder="Enter multiple lines..."
|
||||
width={40}
|
||||
height={10}
|
||||
focused
|
||||
/>
|
||||
|
||||
// Core
|
||||
const textarea = new TextareaRenderable(renderer, {
|
||||
id: "editor",
|
||||
width: 40,
|
||||
height: 10,
|
||||
placeholder: "Enter text...",
|
||||
})
|
||||
```
|
||||
|
||||
### Features
|
||||
|
||||
```tsx
|
||||
<textarea
|
||||
showLineNumbers // Display line numbers
|
||||
wrapText // Wrap long lines
|
||||
readOnly // Disable editing
|
||||
tabSize={2} // Tab character width
|
||||
/>
|
||||
```
|
||||
|
||||
### Syntax Highlighting
|
||||
|
||||
```tsx
|
||||
<textarea
|
||||
language="typescript"
|
||||
value={code}
|
||||
onChange={setCode}
|
||||
/>
|
||||
```
|
||||
|
||||
## Select Component
|
||||
|
||||
List selection for choosing from options.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```tsx
|
||||
// React
|
||||
<select
|
||||
options={[
|
||||
{ name: "Option 1", description: "First option", value: "1" },
|
||||
{ name: "Option 2", description: "Second option", value: "2" },
|
||||
{ name: "Option 3", description: "Third option", value: "3" },
|
||||
]}
|
||||
onSelect={(index, option) => {
|
||||
console.log("Selected:", option.name) // Called when Enter is pressed
|
||||
}}
|
||||
focused
|
||||
/>
|
||||
|
||||
// Solid
|
||||
<select
|
||||
options={[
|
||||
{ name: "Option 1", description: "First option", value: "1" },
|
||||
{ name: "Option 2", description: "Second option", value: "2" },
|
||||
]}
|
||||
onSelect={(index, option) => {
|
||||
console.log("Selected:", option.name) // Called when Enter is pressed
|
||||
}}
|
||||
focused
|
||||
/>
|
||||
|
||||
// Core
|
||||
const select = new SelectRenderable(renderer, {
|
||||
id: "menu",
|
||||
options: [
|
||||
{ name: "Option 1", description: "First option", value: "1" },
|
||||
{ name: "Option 2", description: "Second option", value: "2" },
|
||||
],
|
||||
})
|
||||
select.on(SelectRenderableEvents.ITEM_SELECTED, (index, option) => {
|
||||
console.log("Selected:", option.name) // Called when Enter is pressed
|
||||
})
|
||||
select.focus()
|
||||
```
|
||||
|
||||
### Option Format
|
||||
|
||||
```typescript
|
||||
interface SelectOption {
|
||||
name: string // Display text
|
||||
description?: string // Optional description shown below
|
||||
value?: any // Associated value
|
||||
}
|
||||
```
|
||||
|
||||
### Styling
|
||||
|
||||
```tsx
|
||||
<select
|
||||
height={8} // Visible height
|
||||
selectedIndex={0} // Initially selected
|
||||
showScrollIndicator // Show scroll arrows
|
||||
selectedBackgroundColor="#333"
|
||||
selectedTextColor="#fff"
|
||||
highlightBackgroundColor="#444"
|
||||
/>
|
||||
```
|
||||
|
||||
### Navigation
|
||||
|
||||
Default keybindings:
|
||||
- `Up` / `k` - Move up
|
||||
- `Down` / `j` - Move down
|
||||
- `Enter` - Select item
|
||||
|
||||
### Events
|
||||
|
||||
**Important**: `onSelect` and `onChange` serve different purposes:
|
||||
|
||||
| Event | Trigger | Use Case |
|
||||
|-------|---------|----------|
|
||||
| `onSelect` | **Enter key pressed** - user confirms selection | Perform action with selected item |
|
||||
| `onChange` | **Arrow keys** - user navigates list | Preview, update UI as user browses |
|
||||
|
||||
```tsx
|
||||
// React/Solid
|
||||
<select
|
||||
onSelect={(index, option) => {
|
||||
// Called when Enter is pressed - selection confirmed
|
||||
console.log("User selected:", option.name)
|
||||
performAction(option)
|
||||
}}
|
||||
onChange={(index, option) => {
|
||||
// Called when navigating with arrow keys
|
||||
console.log("Browsing:", option.name)
|
||||
showPreview(option)
|
||||
}}
|
||||
/>
|
||||
|
||||
// Core
|
||||
select.on(SelectRenderableEvents.ITEM_SELECTED, (index, option) => {
|
||||
// Called when Enter is pressed
|
||||
})
|
||||
select.on(SelectRenderableEvents.SELECTION_CHANGED, (index, option) => {
|
||||
// Called when navigating with arrow keys
|
||||
})
|
||||
```
|
||||
|
||||
## Tab Select Component
|
||||
|
||||
Horizontal tab-based selection.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```tsx
|
||||
// React
|
||||
<tab-select
|
||||
options={[
|
||||
{ name: "Home", description: "Dashboard view" },
|
||||
{ name: "Settings", description: "Configuration" },
|
||||
{ name: "Help", description: "Documentation" },
|
||||
]}
|
||||
onSelect={(index, option) => {
|
||||
console.log("Tab selected:", option.name) // Called when Enter is pressed
|
||||
}}
|
||||
focused
|
||||
/>
|
||||
|
||||
// Solid (note underscore)
|
||||
<tab_select
|
||||
options={[
|
||||
{ name: "Home", description: "Dashboard view" },
|
||||
{ name: "Settings", description: "Configuration" },
|
||||
]}
|
||||
onSelect={(index, option) => {
|
||||
console.log("Tab selected:", option.name) // Called when Enter is pressed
|
||||
}}
|
||||
focused
|
||||
/>
|
||||
|
||||
// Core
|
||||
const tabs = new TabSelectRenderable(renderer, {
|
||||
id: "tabs",
|
||||
options: [...],
|
||||
tabWidth: 20,
|
||||
})
|
||||
tabs.on(TabSelectRenderableEvents.ITEM_SELECTED, (index, option) => {
|
||||
console.log("Tab selected:", option.name) // Called when Enter is pressed
|
||||
})
|
||||
tabs.focus()
|
||||
```
|
||||
|
||||
### Events
|
||||
|
||||
Same pattern as Select - `onSelect` for Enter key, `onChange` for navigation:
|
||||
|
||||
```tsx
|
||||
<tab-select
|
||||
onSelect={(index, option) => {
|
||||
// Called when Enter is pressed - switch to tab
|
||||
setActiveTab(index)
|
||||
}}
|
||||
onChange={(index, option) => {
|
||||
// Called when navigating with arrow keys
|
||||
showTabPreview(option)
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Styling
|
||||
|
||||
```tsx
|
||||
// React
|
||||
<tab-select
|
||||
tabWidth={20} // Width of each tab
|
||||
selectedIndex={0} // Initially selected tab
|
||||
/>
|
||||
|
||||
// Solid
|
||||
<tab_select
|
||||
tabWidth={20}
|
||||
selectedIndex={0}
|
||||
/>
|
||||
```
|
||||
|
||||
### Navigation
|
||||
|
||||
Default keybindings:
|
||||
- `Left` / `[` - Previous tab
|
||||
- `Right` / `]` - Next tab
|
||||
- `Enter` - Select tab
|
||||
|
||||
## Focus Management
|
||||
|
||||
### Single Focused Input
|
||||
|
||||
```tsx
|
||||
function SingleInput() {
|
||||
return <input placeholder="I'm focused" focused />
|
||||
}
|
||||
```
|
||||
|
||||
### Multiple Inputs with Focus State
|
||||
|
||||
```tsx
|
||||
// React
|
||||
function Form() {
|
||||
const [focusIndex, setFocusIndex] = useState(0)
|
||||
const fields = ["name", "email", "message"]
|
||||
|
||||
useKeyboard((key) => {
|
||||
if (key.name === "tab") {
|
||||
setFocusIndex(i => (i + 1) % fields.length)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
{fields.map((field, i) => (
|
||||
<input
|
||||
key={field}
|
||||
placeholder={`Enter ${field}`}
|
||||
focused={i === focusIndex}
|
||||
/>
|
||||
))}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Focus Methods (Core)
|
||||
|
||||
```typescript
|
||||
input.focus() // Give focus
|
||||
input.blur() // Remove focus
|
||||
input.isFocused() // Check focus state
|
||||
```
|
||||
|
||||
## Form Patterns
|
||||
|
||||
### Login Form
|
||||
|
||||
```tsx
|
||||
function LoginForm() {
|
||||
const [username, setUsername] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [focusField, setFocusField] = useState<"username" | "password">("username")
|
||||
|
||||
useKeyboard((key) => {
|
||||
if (key.name === "tab") {
|
||||
setFocusField(f => f === "username" ? "password" : "username")
|
||||
}
|
||||
if (key.name === "enter") {
|
||||
handleLogin()
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1} border padding={2}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text>Username:</text>
|
||||
<input
|
||||
value={username}
|
||||
onChange={setUsername}
|
||||
focused={focusField === "username"}
|
||||
width={20}
|
||||
/>
|
||||
</box>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text>Password:</text>
|
||||
<input
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
focused={focusField === "password"}
|
||||
width={20}
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Search with Results
|
||||
|
||||
```tsx
|
||||
function SearchableList({ items, onItemSelected }) {
|
||||
const [query, setQuery] = useState("")
|
||||
const [focusSearch, setFocusSearch] = useState(true)
|
||||
const [preview, setPreview] = useState(null)
|
||||
|
||||
const filtered = items.filter(item =>
|
||||
item.toLowerCase().includes(query.toLowerCase())
|
||||
)
|
||||
|
||||
useKeyboard((key) => {
|
||||
if (key.name === "tab") {
|
||||
setFocusSearch(f => !f)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box flexDirection="column">
|
||||
<input
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder="Search..."
|
||||
focused={focusSearch}
|
||||
/>
|
||||
<select
|
||||
options={filtered.map(item => ({ name: item }))}
|
||||
focused={!focusSearch}
|
||||
height={10}
|
||||
onSelect={(index, option) => {
|
||||
// Enter pressed - confirm selection
|
||||
onItemSelected(option)
|
||||
}}
|
||||
onChange={(index, option) => {
|
||||
// Navigating - show preview
|
||||
setPreview(option)
|
||||
}}
|
||||
/>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
### Focus Required
|
||||
|
||||
Inputs must be focused to receive keyboard input:
|
||||
|
||||
```tsx
|
||||
// WRONG - won't receive input
|
||||
<input placeholder="Type here" />
|
||||
|
||||
// CORRECT
|
||||
<input placeholder="Type here" focused />
|
||||
```
|
||||
|
||||
### Select Options Format
|
||||
|
||||
Options must be objects with `name` property:
|
||||
|
||||
```tsx
|
||||
// WRONG
|
||||
<select options={["a", "b", "c"]} />
|
||||
|
||||
// CORRECT
|
||||
<select options={[
|
||||
{ name: "A", description: "Option A" },
|
||||
{ name: "B", description: "Option B" },
|
||||
]} />
|
||||
```
|
||||
|
||||
### Solid Uses Underscores
|
||||
|
||||
```tsx
|
||||
// React
|
||||
<tab-select />
|
||||
|
||||
// Solid
|
||||
<tab_select />
|
||||
```
|
||||
|
||||
### Value vs onInput (Solid)
|
||||
|
||||
Solid uses `onInput` instead of `onChange`:
|
||||
|
||||
```tsx
|
||||
// React
|
||||
<input value={value} onChange={setValue} />
|
||||
|
||||
// Solid
|
||||
<input value={value()} onInput={setValue} />
|
||||
```
|
||||
@@ -1,386 +0,0 @@
|
||||
# Text & Display Components
|
||||
|
||||
Components for displaying text content in OpenTUI.
|
||||
|
||||
## Text Component
|
||||
|
||||
The primary component for displaying styled text.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```tsx
|
||||
// React/Solid
|
||||
<text>Hello, World!</text>
|
||||
|
||||
// With content prop
|
||||
<text content="Hello, World!" />
|
||||
|
||||
// Core
|
||||
const text = new TextRenderable(renderer, {
|
||||
id: "greeting",
|
||||
content: "Hello, World!",
|
||||
})
|
||||
```
|
||||
|
||||
### Styling (React/Solid)
|
||||
|
||||
For React and Solid, use **nested modifier tags** for text styling:
|
||||
|
||||
```tsx
|
||||
<text fg="#FFFFFF" bg="#000000">
|
||||
<strong>Bold</strong>, <em>italic</em>, and <u>underlined</u>
|
||||
</text>
|
||||
```
|
||||
|
||||
> **Important**: Do NOT use `bold`, `italic`, `underline`, `dim`, `strikethrough` as props on `<text>` — they don't work. Always use nested tags like `<strong>`, `<em>`, `<u>`, or `<span>` with styling.
|
||||
|
||||
### Styling (Core) - Text Attributes
|
||||
|
||||
```typescript
|
||||
import { TextRenderable, TextAttributes } from "@opentui/core"
|
||||
|
||||
const text = new TextRenderable(renderer, {
|
||||
content: "Styled",
|
||||
attributes: TextAttributes.BOLD | TextAttributes.UNDERLINE,
|
||||
})
|
||||
```
|
||||
|
||||
**Available attributes:**
|
||||
- `TextAttributes.BOLD`
|
||||
- `TextAttributes.DIM`
|
||||
- `TextAttributes.ITALIC`
|
||||
- `TextAttributes.UNDERLINE`
|
||||
- `TextAttributes.BLINK`
|
||||
- `TextAttributes.INVERSE`
|
||||
- `TextAttributes.HIDDEN`
|
||||
- `TextAttributes.STRIKETHROUGH`
|
||||
|
||||
### Text Selection
|
||||
|
||||
```tsx
|
||||
<text selectable>
|
||||
This text can be selected by the user
|
||||
</text>
|
||||
|
||||
<text selectable={false}>
|
||||
This text cannot be selected
|
||||
</text>
|
||||
```
|
||||
|
||||
For copy-on-selection and the full selection API, see `keyboard/REFERENCE.md` (selection).
|
||||
|
||||
## Text Modifiers
|
||||
|
||||
Inline styling elements that must be used inside `<text>`:
|
||||
|
||||
### Span
|
||||
|
||||
Inline styled text:
|
||||
|
||||
```tsx
|
||||
<text>
|
||||
Normal text with <span fg="red">red text</span> inline
|
||||
</text>
|
||||
```
|
||||
|
||||
### Bold/Strong
|
||||
|
||||
```tsx
|
||||
<text>
|
||||
<strong>Bold text</strong>
|
||||
<b>Also bold</b>
|
||||
</text>
|
||||
```
|
||||
|
||||
### Italic/Emphasis
|
||||
|
||||
```tsx
|
||||
<text>
|
||||
<em>Italic text</em>
|
||||
<i>Also italic</i>
|
||||
</text>
|
||||
```
|
||||
|
||||
### Underline
|
||||
|
||||
```tsx
|
||||
<text>
|
||||
<u>Underlined text</u>
|
||||
</text>
|
||||
```
|
||||
|
||||
### Line Break
|
||||
|
||||
```tsx
|
||||
<text>
|
||||
Line one
|
||||
<br />
|
||||
Line two
|
||||
</text>
|
||||
```
|
||||
|
||||
### Link
|
||||
|
||||
```tsx
|
||||
<text>
|
||||
Visit <a href="https://example.com">our website</a>
|
||||
</text>
|
||||
```
|
||||
|
||||
### Combined Modifiers
|
||||
|
||||
```tsx
|
||||
<text>
|
||||
<span fg="#00FF00">
|
||||
<strong>Bold green</strong>
|
||||
</span>
|
||||
and
|
||||
<span fg="#FF0000">
|
||||
<em><u>italic underlined red</u></em>
|
||||
</span>
|
||||
</text>
|
||||
```
|
||||
|
||||
## Styled Text Template (Core)
|
||||
|
||||
The `t` template literal for complex styling:
|
||||
|
||||
```typescript
|
||||
import { t, bold, italic, underline, fg, bg, dim } from "@opentui/core"
|
||||
|
||||
const styled = t`
|
||||
${bold("Bold")} and ${italic("italic")} text.
|
||||
${fg("#FF0000")("Red text")} with ${bg("#0000FF")("blue background")}.
|
||||
${dim("Dimmed")} and ${underline("underlined")}.
|
||||
`
|
||||
|
||||
const text = new TextRenderable(renderer, {
|
||||
content: styled,
|
||||
})
|
||||
```
|
||||
|
||||
### Style Functions
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `bold(text)` | Bold text |
|
||||
| `italic(text)` | Italic text |
|
||||
| `underline(text)` | Underlined text |
|
||||
| `dim(text)` | Dimmed text |
|
||||
| `strikethrough(text)` | Strikethrough text |
|
||||
| `fg(color)(text)` | Set foreground color |
|
||||
| `bg(color)(text)` | Set background color |
|
||||
|
||||
## ASCII Font Component
|
||||
|
||||
Display large ASCII art text banners.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```tsx
|
||||
// React
|
||||
<ascii-font text="TITLE" font="tiny" />
|
||||
|
||||
// Solid
|
||||
<ascii_font text="TITLE" font="tiny" />
|
||||
|
||||
// Core
|
||||
const title = new ASCIIFontRenderable(renderer, {
|
||||
id: "title",
|
||||
text: "TITLE",
|
||||
font: "tiny",
|
||||
})
|
||||
```
|
||||
|
||||
### Available Fonts
|
||||
|
||||
| Font | Description |
|
||||
|------|-------------|
|
||||
| `tiny` | Compact ASCII font |
|
||||
| `block` | Block-style letters |
|
||||
| `slick` | Sleek modern style |
|
||||
| `shade` | Shaded 3D effect |
|
||||
|
||||
### Styling
|
||||
|
||||
```tsx
|
||||
// React
|
||||
<ascii-font
|
||||
text="HELLO"
|
||||
font="block"
|
||||
color="#00FF00"
|
||||
/>
|
||||
|
||||
// Core
|
||||
import { RGBA } from "@opentui/core"
|
||||
|
||||
const title = new ASCIIFontRenderable(renderer, {
|
||||
text: "HELLO",
|
||||
font: "block",
|
||||
color: RGBA.fromHex("#00FF00"),
|
||||
})
|
||||
```
|
||||
|
||||
### Example Output
|
||||
|
||||
```
|
||||
Font: tiny
|
||||
╭─╮╭─╮╭─╮╭╮╭╮╭─╮╶╮╶ ╶╮
|
||||
│ ││─┘├┤ │╰╯││ │ │
|
||||
╰─╯╵ ╰─╯╵ ╵╰─╯╶╯╶╰─╯
|
||||
|
||||
Font: block
|
||||
█▀▀█ █▀▀█ █▀▀ █▀▀▄
|
||||
█ █ █▀▀▀ █▀▀ █ █
|
||||
▀▀▀▀ ▀ ▀▀▀ ▀ ▀
|
||||
```
|
||||
|
||||
## Colors
|
||||
|
||||
### Color Formats
|
||||
|
||||
```tsx
|
||||
// Hex colors
|
||||
<text fg="#FF0000">Red</text>
|
||||
<text fg="#F00">Short hex</text>
|
||||
|
||||
// Named colors
|
||||
<text fg="red">Red</text>
|
||||
<text fg="blue">Blue</text>
|
||||
|
||||
// Transparent
|
||||
<text bg="transparent">No background</text>
|
||||
```
|
||||
|
||||
### RGBA Class
|
||||
|
||||
The `RGBA` class from `@opentui/core` can be used in **all frameworks** (Core, React, Solid) for programmatic color manipulation:
|
||||
|
||||
```typescript
|
||||
import { RGBA } from "@opentui/core"
|
||||
|
||||
// From hex string (most common)
|
||||
const red = RGBA.fromHex("#FF0000")
|
||||
const shortHex = RGBA.fromHex("#F00") // Short form supported
|
||||
|
||||
// From integers (0-255 range for each channel)
|
||||
const green = RGBA.fromInts(0, 255, 0, 255) // r, g, b, a
|
||||
const semiGreen = RGBA.fromInts(0, 255, 0, 128) // 50% transparent
|
||||
|
||||
// From normalized floats (0.0-1.0 range)
|
||||
const blue = RGBA.fromValues(0.0, 0.0, 1.0, 1.0) // r, g, b, a
|
||||
const overlay = RGBA.fromValues(0.1, 0.1, 0.1, 0.7) // Dark semi-transparent
|
||||
|
||||
// Common use cases
|
||||
const backgroundColor = RGBA.fromHex("#1a1a2e")
|
||||
const textColor = RGBA.fromHex("#FFFFFF")
|
||||
const borderColor = RGBA.fromInts(122, 162, 247, 255) // Tokyo Night blue
|
||||
const shadowColor = RGBA.fromValues(0.0, 0.0, 0.0, 0.5) // 50% black
|
||||
```
|
||||
|
||||
**When to use each method:**
|
||||
- `fromHex()` - When working with design specs or CSS colors
|
||||
- `fromInts()` - When you have 8-bit color values (0-255)
|
||||
- `fromValues()` - When doing color math or interpolation (normalized 0.0-1.0)
|
||||
|
||||
### Using RGBA in React/Solid
|
||||
|
||||
```tsx
|
||||
// React or Solid - RGBA works with color props
|
||||
import { RGBA } from "@opentui/core"
|
||||
|
||||
const primaryColor = RGBA.fromHex("#7aa2f7")
|
||||
|
||||
function MyComponent() {
|
||||
return (
|
||||
<box backgroundColor={primaryColor} borderColor={primaryColor}>
|
||||
<text fg={RGBA.fromHex("#c0caf5")}>Styled with RGBA</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Most props that accept color strings (`"#FF0000"`, `"red"`) also accept `RGBA` objects directly.
|
||||
|
||||
## Text Wrapping
|
||||
|
||||
Text wraps based on parent container:
|
||||
|
||||
```tsx
|
||||
<box width={40}>
|
||||
<text>
|
||||
This long text will wrap when it reaches the edge of the
|
||||
40-character wide parent container.
|
||||
</text>
|
||||
</box>
|
||||
```
|
||||
|
||||
## Dynamic Content
|
||||
|
||||
### React
|
||||
|
||||
```tsx
|
||||
function Counter() {
|
||||
const [count, setCount] = useState(0)
|
||||
return <text>Count: {count}</text>
|
||||
}
|
||||
```
|
||||
|
||||
### Solid
|
||||
|
||||
```tsx
|
||||
function Counter() {
|
||||
const [count, setCount] = createSignal(0)
|
||||
return <text>Count: {count()}</text>
|
||||
}
|
||||
```
|
||||
|
||||
### Core
|
||||
|
||||
```typescript
|
||||
const text = new TextRenderable(renderer, {
|
||||
id: "counter",
|
||||
content: "Count: 0",
|
||||
})
|
||||
|
||||
// Update later
|
||||
text.setContent("Count: 1")
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
### Text Modifiers Outside Text
|
||||
|
||||
```tsx
|
||||
// WRONG - modifiers only work inside <text>
|
||||
<box>
|
||||
<strong>Won't work</strong>
|
||||
</box>
|
||||
|
||||
// CORRECT
|
||||
<box>
|
||||
<text>
|
||||
<strong>This works</strong>
|
||||
</text>
|
||||
</box>
|
||||
```
|
||||
|
||||
### Empty Text
|
||||
|
||||
```tsx
|
||||
// May cause layout issues
|
||||
<text></text>
|
||||
|
||||
// Better - use space or conditional
|
||||
<text>{content || " "}</text>
|
||||
```
|
||||
|
||||
### Color Format
|
||||
|
||||
```tsx
|
||||
// WRONG
|
||||
<text fg="FF0000">Missing #</text>
|
||||
|
||||
// CORRECT
|
||||
<text fg="#FF0000">With #</text>
|
||||
```
|
||||
@@ -1,145 +0,0 @@
|
||||
# OpenTUI Core (@opentui/core)
|
||||
|
||||
The foundational library for building terminal user interfaces. Provides an imperative API with all primitives, giving you maximum control over rendering, state, and behavior.
|
||||
|
||||
## Overview
|
||||
|
||||
OpenTUI Core runs on Bun with native Zig bindings for performance-critical operations:
|
||||
- **Renderer**: Manages terminal output, input events, and the rendering loop
|
||||
- **Renderables**: Hierarchical UI building blocks with Yoga layout
|
||||
- **Constructs**: Declarative wrappers for composing Renderables
|
||||
- **FrameBuffer**: Low-level 2D rendering surface for custom graphics
|
||||
|
||||
## When to Use Core
|
||||
|
||||
Use the core imperative API when:
|
||||
- Building a library or framework on top of OpenTUI
|
||||
- Need maximum control over rendering and state
|
||||
- Want smallest possible bundle size (no React/Solid runtime)
|
||||
- Building performance-critical applications
|
||||
- Integrating with existing imperative codebases
|
||||
|
||||
## When NOT to Use Core
|
||||
|
||||
| Scenario | Use Instead |
|
||||
|----------|-------------|
|
||||
| Familiar with React patterns | `@opentui/react` |
|
||||
| Want fine-grained reactivity | `@opentui/solid` |
|
||||
| Building typical applications | React or Solid reconciler |
|
||||
| Rapid prototyping | React or Solid reconciler |
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Using create-tui (Recommended)
|
||||
|
||||
```bash
|
||||
bunx create-tui@latest -t core my-app
|
||||
cd my-app
|
||||
bun run src/index.ts
|
||||
```
|
||||
|
||||
The CLI creates the `my-app` directory for you - it must **not already exist**.
|
||||
|
||||
**Agent guidance**: Always use autonomous mode with `-t <template>` flag. Never use interactive mode (`bunx create-tui@latest my-app` without `-t`) as it requires user prompts that agents cannot respond to.
|
||||
|
||||
### Manual Setup
|
||||
|
||||
```bash
|
||||
mkdir my-tui && cd my-tui
|
||||
bun init
|
||||
bun install @opentui/core
|
||||
```
|
||||
|
||||
```typescript
|
||||
import { createCliRenderer, TextRenderable, BoxRenderable } from "@opentui/core"
|
||||
|
||||
const renderer = await createCliRenderer()
|
||||
|
||||
// Create a box container
|
||||
const container = new BoxRenderable(renderer, {
|
||||
id: "container",
|
||||
width: 40,
|
||||
height: 10,
|
||||
border: true,
|
||||
borderStyle: "rounded",
|
||||
padding: 1,
|
||||
})
|
||||
|
||||
// Create text inside the box
|
||||
const greeting = new TextRenderable(renderer, {
|
||||
id: "greeting",
|
||||
content: "Hello, OpenTUI!",
|
||||
fg: "#00FF00",
|
||||
})
|
||||
|
||||
// Compose the tree
|
||||
container.add(greeting)
|
||||
renderer.root.add(container)
|
||||
```
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Renderer
|
||||
|
||||
The `CliRenderer` orchestrates everything:
|
||||
- Manages the terminal viewport and alternate screen
|
||||
- Handles input events (keyboard, mouse, paste)
|
||||
- Runs the rendering loop (configurable FPS)
|
||||
- Provides the root node for the renderable tree
|
||||
|
||||
### Renderables vs Constructs
|
||||
|
||||
| Renderables (Imperative) | Constructs (Declarative) |
|
||||
|--------------------------|--------------------------|
|
||||
| `new TextRenderable(renderer, {...})` | `Text({...})` |
|
||||
| Requires renderer at creation | Creates VNode, instantiated later |
|
||||
| Direct mutation via methods | Chained calls recorded, replayed on instantiation |
|
||||
| Full control | Cleaner composition |
|
||||
|
||||
### Storage Options
|
||||
|
||||
Renderables can be composed in two ways:
|
||||
1. **Imperative**: Create instances, call `.add()` to compose
|
||||
2. **Declarative (Constructs)**: Create VNodes, pass children as arguments
|
||||
|
||||
## Essential Commands
|
||||
|
||||
```bash
|
||||
bun install @opentui/core # Install
|
||||
bun run src/index.ts # Run directly (no build needed)
|
||||
bun test # Run tests
|
||||
```
|
||||
|
||||
## Runtime Requirements
|
||||
|
||||
OpenTUI runs on Bun and uses Zig for native builds.
|
||||
|
||||
```bash
|
||||
# Package management
|
||||
bun install @opentui/core
|
||||
|
||||
# Running
|
||||
bun run src/index.ts
|
||||
bun test
|
||||
|
||||
# Building (only needed for native code changes)
|
||||
bun run build
|
||||
```
|
||||
|
||||
**Zig** is required for building native components.
|
||||
|
||||
## In This Reference
|
||||
|
||||
- [Configuration](./configuration.md) - Renderer options, environment variables
|
||||
- [API](./api.md) - Renderer, Renderables, types, utilities
|
||||
- [Patterns](./patterns.md) - Composition, events, state management
|
||||
- [Gotchas](./gotchas.md) - Common issues, debugging, limitations
|
||||
|
||||
## See Also
|
||||
|
||||
- [React](../react/REFERENCE.md) - React reconciler for declarative TUI
|
||||
- [Solid](../solid/REFERENCE.md) - Solid reconciler for declarative TUI
|
||||
- [Layout](../layout/REFERENCE.md) - Yoga/Flexbox layout system
|
||||
- [Components](../components/REFERENCE.md) - Component reference by category
|
||||
- [Keyboard](../keyboard/REFERENCE.md) - Input handling and shortcuts
|
||||
- [Testing](../testing/REFERENCE.md) - Test renderer and snapshots
|
||||
@@ -1,543 +0,0 @@
|
||||
# Core API Reference
|
||||
|
||||
## Renderer
|
||||
|
||||
### createCliRenderer(config?)
|
||||
|
||||
Creates and initializes the CLI renderer.
|
||||
|
||||
```typescript
|
||||
import { createCliRenderer, type CliRendererConfig } from "@opentui/core"
|
||||
|
||||
const renderer = await createCliRenderer({
|
||||
targetFPS: 60, // Target frames per second
|
||||
exitOnCtrlC: true, // Exit process on Ctrl+C
|
||||
consoleOptions: { // Debug console overlay
|
||||
position: ConsolePosition.BOTTOM,
|
||||
sizePercent: 30,
|
||||
startInDebugMode: false,
|
||||
},
|
||||
onDestroy: () => {}, // Cleanup callback
|
||||
})
|
||||
```
|
||||
|
||||
### CliRenderer Instance
|
||||
|
||||
```typescript
|
||||
renderer.root // Root renderable node
|
||||
renderer.width // Terminal width in columns
|
||||
renderer.height // Terminal height in rows
|
||||
renderer.keyInput // Keyboard event emitter
|
||||
renderer.console // Console overlay controller
|
||||
|
||||
renderer.start() // Start render loop
|
||||
renderer.stop() // Stop render loop
|
||||
renderer.destroy() // Cleanup and exit alternate screen
|
||||
renderer.requestRender() // Request a re-render
|
||||
|
||||
renderer.setCursorStyle(options) // Set cursor style
|
||||
renderer.setCursorColor(color) // Set cursor color
|
||||
renderer.setMousePointer(style) // Set mouse pointer shape
|
||||
```
|
||||
|
||||
### Cursor & Mouse Pointer
|
||||
|
||||
```typescript
|
||||
import { type CursorStyleOptions, type MousePointerStyle } from "@opentui/core"
|
||||
|
||||
// Set cursor style (options object)
|
||||
renderer.setCursorStyle({
|
||||
style: "block", // "block" | "line" | "underline" | "default"
|
||||
blinking: true, // Cursor blink
|
||||
color: RGBA.fromHex("#FF0000"), // Cursor color
|
||||
cursor: "pointer", // Mouse pointer shape
|
||||
})
|
||||
|
||||
// Set mouse pointer shape (OSC 22)
|
||||
renderer.setMousePointer("pointer")
|
||||
// Available: "default" | "pointer" | "text" | "crosshair" | "move" | "not-allowed"
|
||||
```
|
||||
|
||||
### Renderer Events
|
||||
|
||||
```typescript
|
||||
renderer.on("resize", (width, height) => {}) // Terminal resized
|
||||
renderer.on("focus", () => {}) // Terminal window gained focus
|
||||
renderer.on("blur", () => {}) // Terminal window lost focus
|
||||
renderer.on("theme_mode", (mode) => {}) // "dark" | "light"
|
||||
renderer.on("capabilities", (caps) => {}) // Terminal capabilities detected
|
||||
renderer.on("selection", (selection) => {}) // Text selection finished (mouse-up)
|
||||
renderer.on("destroy", () => {}) // Renderer destroyed
|
||||
renderer.on("memory:snapshot", (snapshot) => {}) // Memory snapshot
|
||||
renderer.on("debugOverlay:toggle", () => {}) // Debug overlay toggled
|
||||
```
|
||||
|
||||
### Console Overlay
|
||||
|
||||
```typescript
|
||||
renderer.console.show() // Show console overlay
|
||||
renderer.console.hide() // Hide console overlay
|
||||
renderer.console.toggle() // Toggle visibility/focus
|
||||
renderer.console.clear() // Clear console contents
|
||||
```
|
||||
|
||||
## Renderables
|
||||
|
||||
All renderables extend the base `Renderable` class and share common properties.
|
||||
|
||||
### Common Properties
|
||||
|
||||
```typescript
|
||||
interface CommonProps {
|
||||
id?: string // Unique identifier
|
||||
|
||||
// Positioning
|
||||
position?: "relative" | "absolute"
|
||||
left?: number | string
|
||||
top?: number | string
|
||||
right?: number | string
|
||||
bottom?: number | string
|
||||
|
||||
// Dimensions
|
||||
width?: number | string | "auto"
|
||||
height?: number | string | "auto"
|
||||
minWidth?: number
|
||||
minHeight?: number
|
||||
maxWidth?: number
|
||||
maxHeight?: number
|
||||
|
||||
// Flexbox
|
||||
flexDirection?: "row" | "column" | "row-reverse" | "column-reverse"
|
||||
flexGrow?: number
|
||||
flexShrink?: number
|
||||
flexBasis?: number | string
|
||||
flexWrap?: "nowrap" | "wrap" | "wrap-reverse"
|
||||
justifyContent?: "flex-start" | "flex-end" | "center" | "space-between" | "space-around" | "space-evenly"
|
||||
alignItems?: "flex-start" | "flex-end" | "center" | "stretch" | "baseline"
|
||||
alignSelf?: "auto" | "flex-start" | "flex-end" | "center" | "stretch" | "baseline"
|
||||
alignContent?: "flex-start" | "flex-end" | "center" | "stretch" | "space-between" | "space-around"
|
||||
|
||||
// Spacing
|
||||
padding?: number
|
||||
paddingTop?: number
|
||||
paddingRight?: number
|
||||
paddingBottom?: number
|
||||
paddingLeft?: number
|
||||
margin?: number
|
||||
marginTop?: number
|
||||
marginRight?: number
|
||||
marginBottom?: number
|
||||
marginLeft?: number
|
||||
gap?: number
|
||||
|
||||
// Display
|
||||
display?: "flex" | "none"
|
||||
overflow?: "visible" | "hidden" | "scroll"
|
||||
zIndex?: number
|
||||
}
|
||||
```
|
||||
|
||||
### Renderable Methods
|
||||
|
||||
```typescript
|
||||
renderable.add(child) // Add child renderable
|
||||
renderable.remove(child) // Remove child renderable
|
||||
renderable.getRenderable(id) // Find child by ID
|
||||
renderable.focus() // Focus this renderable
|
||||
renderable.blur() // Remove focus
|
||||
renderable.destroy() // Destroy and cleanup
|
||||
|
||||
renderable.on(event, handler) // Add event listener
|
||||
renderable.off(event, handler) // Remove event listener
|
||||
renderable.emit(event, ...args) // Emit event
|
||||
```
|
||||
|
||||
### TextRenderable
|
||||
|
||||
Display styled text content.
|
||||
|
||||
```typescript
|
||||
import { TextRenderable, TextAttributes, t, bold, fg, underline } from "@opentui/core"
|
||||
|
||||
const text = new TextRenderable(renderer, {
|
||||
id: "text",
|
||||
content: "Hello World",
|
||||
fg: "#FFFFFF", // Foreground color
|
||||
bg: "#000000", // Background color
|
||||
attributes: TextAttributes.BOLD | TextAttributes.UNDERLINE,
|
||||
selectable: true, // Allow text selection
|
||||
})
|
||||
|
||||
// Styled text with template literals
|
||||
const styled = new TextRenderable(renderer, {
|
||||
content: t`${bold("Bold")} and ${fg("#FF0000")(underline("red underlined"))}`,
|
||||
})
|
||||
```
|
||||
|
||||
**TextAttributes flags:**
|
||||
- `TextAttributes.BOLD`
|
||||
- `TextAttributes.DIM`
|
||||
- `TextAttributes.ITALIC`
|
||||
- `TextAttributes.UNDERLINE`
|
||||
- `TextAttributes.BLINK`
|
||||
- `TextAttributes.INVERSE`
|
||||
- `TextAttributes.HIDDEN`
|
||||
- `TextAttributes.STRIKETHROUGH`
|
||||
|
||||
### BoxRenderable
|
||||
|
||||
Container with borders and layout.
|
||||
|
||||
```typescript
|
||||
import { BoxRenderable } from "@opentui/core"
|
||||
|
||||
const box = new BoxRenderable(renderer, {
|
||||
id: "box",
|
||||
width: 40,
|
||||
height: 10,
|
||||
backgroundColor: "#1a1a2e",
|
||||
border: true,
|
||||
borderStyle: "single" | "double" | "rounded" | "bold" | "none",
|
||||
borderColor: "#FFFFFF",
|
||||
title: "Panel Title",
|
||||
titleAlignment: "left" | "center" | "right",
|
||||
onMouseDown: (event) => {},
|
||||
onMouseUp: (event) => {},
|
||||
onMouseMove: (event) => {},
|
||||
})
|
||||
```
|
||||
|
||||
### InputRenderable
|
||||
|
||||
Single-line text input.
|
||||
|
||||
```typescript
|
||||
import { InputRenderable, InputRenderableEvents } from "@opentui/core"
|
||||
|
||||
const input = new InputRenderable(renderer, {
|
||||
id: "input",
|
||||
width: 30,
|
||||
placeholder: "Enter text...",
|
||||
value: "", // Initial value
|
||||
backgroundColor: "#1a1a1a",
|
||||
textColor: "#FFFFFF",
|
||||
cursorColor: "#00FF00",
|
||||
focusedBackgroundColor: "#2a2a2a",
|
||||
})
|
||||
|
||||
input.on(InputRenderableEvents.CHANGE, (value: string) => {
|
||||
console.log("Value:", value)
|
||||
})
|
||||
|
||||
input.focus() // Must be focused to receive input
|
||||
```
|
||||
|
||||
### SelectRenderable
|
||||
|
||||
List selection component.
|
||||
|
||||
```typescript
|
||||
import { SelectRenderable, SelectRenderableEvents } from "@opentui/core"
|
||||
|
||||
const select = new SelectRenderable(renderer, {
|
||||
id: "select",
|
||||
width: 30,
|
||||
height: 10,
|
||||
options: [
|
||||
{ name: "Option 1", description: "First option", value: "1" },
|
||||
{ name: "Option 2", description: "Second option", value: "2" },
|
||||
],
|
||||
selectedIndex: 0,
|
||||
})
|
||||
|
||||
// Called when Enter is pressed - selection confirmed
|
||||
select.on(SelectRenderableEvents.ITEM_SELECTED, (index, option) => {
|
||||
console.log("Selected:", option.name)
|
||||
performAction(option)
|
||||
})
|
||||
|
||||
// Called when navigating with arrow keys
|
||||
select.on(SelectRenderableEvents.SELECTION_CHANGED, (index, option) => {
|
||||
console.log("Browsing:", option.name)
|
||||
showPreview(option)
|
||||
})
|
||||
|
||||
select.focus() // Navigate with up/down/j/k, select with enter
|
||||
```
|
||||
|
||||
**Event distinction:**
|
||||
- `ITEM_SELECTED` - Enter key pressed, user confirms selection
|
||||
- `SELECTION_CHANGED` - Arrow keys, user navigating/browsing options
|
||||
|
||||
### TabSelectRenderable
|
||||
|
||||
Horizontal tab selection.
|
||||
|
||||
```typescript
|
||||
import { TabSelectRenderable, TabSelectRenderableEvents } from "@opentui/core"
|
||||
|
||||
const tabs = new TabSelectRenderable(renderer, {
|
||||
id: "tabs",
|
||||
width: 60,
|
||||
options: [
|
||||
{ name: "Home", description: "Dashboard" },
|
||||
{ name: "Settings", description: "Configuration" },
|
||||
],
|
||||
tabWidth: 20,
|
||||
})
|
||||
|
||||
// Called when Enter is pressed - tab selected
|
||||
tabs.on(TabSelectRenderableEvents.ITEM_SELECTED, (index, option) => {
|
||||
console.log("Tab selected:", option.name)
|
||||
switchToTab(index)
|
||||
})
|
||||
|
||||
// Called when navigating with arrow keys
|
||||
tabs.on(TabSelectRenderableEvents.SELECTION_CHANGED, (index, option) => {
|
||||
console.log("Browsing tab:", option.name)
|
||||
})
|
||||
|
||||
tabs.focus() // Navigate with left/right/[/], select with enter
|
||||
```
|
||||
|
||||
**Event distinction** (same as SelectRenderable):
|
||||
- `ITEM_SELECTED` - Enter key pressed, user confirms tab
|
||||
- `SELECTION_CHANGED` - Arrow keys, user navigating tabs
|
||||
|
||||
### ScrollBoxRenderable
|
||||
|
||||
Scrollable container.
|
||||
|
||||
```typescript
|
||||
import { ScrollBoxRenderable } from "@opentui/core"
|
||||
|
||||
const scrollbox = new ScrollBoxRenderable(renderer, {
|
||||
id: "scrollbox",
|
||||
width: 40,
|
||||
height: 20,
|
||||
showScrollbar: true,
|
||||
scrollbarOptions: {
|
||||
showArrows: true,
|
||||
trackOptions: {
|
||||
foregroundColor: "#7aa2f7",
|
||||
backgroundColor: "#414868",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Add content that exceeds viewport
|
||||
for (let i = 0; i < 100; i++) {
|
||||
scrollbox.add(new TextRenderable(renderer, {
|
||||
id: `line-${i}`,
|
||||
content: `Line ${i}`,
|
||||
}))
|
||||
}
|
||||
|
||||
scrollbox.focus() // Scroll with arrow keys
|
||||
```
|
||||
|
||||
### ASCIIFontRenderable
|
||||
|
||||
ASCII art text.
|
||||
|
||||
```typescript
|
||||
import { ASCIIFontRenderable, RGBA } from "@opentui/core"
|
||||
|
||||
const title = new ASCIIFontRenderable(renderer, {
|
||||
id: "title",
|
||||
text: "OPENTUI",
|
||||
font: "tiny" | "block" | "slick" | "shade",
|
||||
color: RGBA.fromHex("#FFFFFF"),
|
||||
})
|
||||
```
|
||||
|
||||
### FrameBufferRenderable
|
||||
|
||||
Low-level 2D rendering surface.
|
||||
|
||||
```typescript
|
||||
import { FrameBufferRenderable, RGBA } from "@opentui/core"
|
||||
|
||||
const canvas = new FrameBufferRenderable(renderer, {
|
||||
id: "canvas",
|
||||
width: 50,
|
||||
height: 20,
|
||||
})
|
||||
|
||||
// Direct pixel manipulation
|
||||
canvas.frameBuffer.fillRect(10, 5, 20, 8, RGBA.fromHex("#FF0000"))
|
||||
canvas.frameBuffer.drawText("Custom", 12, 7, RGBA.fromHex("#FFFFFF"))
|
||||
canvas.frameBuffer.setCell(x, y, char, fg, bg)
|
||||
```
|
||||
|
||||
## Constructs (VNode API)
|
||||
|
||||
Declarative wrappers that create VNodes instead of direct instances.
|
||||
|
||||
```typescript
|
||||
import { Text, Box, Input, Select, instantiate, delegate } from "@opentui/core"
|
||||
|
||||
// Create VNode tree
|
||||
const ui = Box(
|
||||
{ border: true, padding: 1 },
|
||||
Text({ content: "Hello" }),
|
||||
Input({ placeholder: "Type here..." }),
|
||||
)
|
||||
|
||||
// Instantiate onto renderer
|
||||
renderer.root.add(ui)
|
||||
|
||||
// Delegate focus to nested element
|
||||
const form = delegate(
|
||||
{ focus: "email-input" },
|
||||
Box(
|
||||
{},
|
||||
Text({ content: "Email:" }),
|
||||
Input({ id: "email-input", placeholder: "you@example.com" }),
|
||||
),
|
||||
)
|
||||
form.focus() // Focuses the input, not the box
|
||||
```
|
||||
|
||||
## Colors (RGBA)
|
||||
|
||||
The `RGBA` class is exported from `@opentui/core` but works across **all frameworks** (Core, React, Solid). Use it for programmatic color manipulation.
|
||||
|
||||
### Creating Colors
|
||||
|
||||
```typescript
|
||||
import { RGBA, parseColor } from "@opentui/core"
|
||||
|
||||
// From hex string (most common)
|
||||
RGBA.fromHex("#FF0000") // Full hex
|
||||
RGBA.fromHex("#F00") // Short hex
|
||||
|
||||
// From integers (0-255 range)
|
||||
RGBA.fromInts(255, 0, 0, 255) // r, g, b, a - fully opaque red
|
||||
RGBA.fromInts(255, 0, 0, 128) // 50% transparent red
|
||||
RGBA.fromInts(0, 0, 0, 0) // Fully transparent
|
||||
|
||||
// From normalized floats (0.0-1.0 range)
|
||||
RGBA.fromValues(1.0, 0.0, 0.0, 1.0) // Fully opaque red
|
||||
RGBA.fromValues(0.1, 0.1, 0.1, 0.7) // Dark gray, 70% opaque
|
||||
RGBA.fromValues(0.0, 0.5, 1.0, 1.0) // Light blue
|
||||
```
|
||||
|
||||
### Common Color Patterns
|
||||
|
||||
```typescript
|
||||
// Theme colors
|
||||
const primary = RGBA.fromHex("#7aa2f7") // Tokyo Night blue
|
||||
const background = RGBA.fromHex("#1a1a2e")
|
||||
const foreground = RGBA.fromHex("#c0caf5")
|
||||
const error = RGBA.fromHex("#f7768e")
|
||||
|
||||
// Overlays and shadows
|
||||
const modalOverlay = RGBA.fromValues(0.0, 0.0, 0.0, 0.5) // 50% black
|
||||
const shadow = RGBA.fromInts(0, 0, 0, 77) // 30% black
|
||||
|
||||
// Borders
|
||||
const activeBorder = RGBA.fromHex("#7aa2f7")
|
||||
const inactiveBorder = RGBA.fromInts(65, 72, 104, 255)
|
||||
```
|
||||
|
||||
### parseColor Utility
|
||||
|
||||
```typescript
|
||||
// Accepts multiple formats
|
||||
parseColor("#FF0000") // Hex string
|
||||
parseColor("red") // CSS color name
|
||||
parseColor("transparent") // Special values
|
||||
parseColor(RGBA.fromHex("#F00")) // Pass-through RGBA objects
|
||||
```
|
||||
|
||||
### When to Use Each Method
|
||||
|
||||
| Method | Use When |
|
||||
|--------|----------|
|
||||
| `fromHex()` | Working with design specs, CSS colors, config files |
|
||||
| `fromInts()` | You have 8-bit values (0-255), common in graphics |
|
||||
| `fromValues()` | Doing color interpolation, animations, math |
|
||||
| `parseColor()` | Accepting user input or config that could be any format |
|
||||
|
||||
### Using RGBA in React/Solid
|
||||
|
||||
```tsx
|
||||
// Import from @opentui/core, use in any framework
|
||||
import { RGBA } from "@opentui/core"
|
||||
|
||||
// React or Solid component
|
||||
function ThemedBox() {
|
||||
const bg = RGBA.fromHex("#1a1a2e")
|
||||
const border = RGBA.fromInts(122, 162, 247, 255)
|
||||
|
||||
return (
|
||||
<box backgroundColor={bg} borderColor={border} border>
|
||||
<text fg={RGBA.fromHex("#c0caf5")}>Works everywhere!</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Color props in React/Solid accept both string formats (`"#FF0000"`, `"red"`) and `RGBA` objects.
|
||||
|
||||
## Keyboard Input
|
||||
|
||||
```typescript
|
||||
import { type KeyEvent } from "@opentui/core"
|
||||
|
||||
renderer.keyInput.on("keypress", (key: KeyEvent) => {
|
||||
console.log(key.name) // "a", "escape", "f1", etc.
|
||||
console.log(key.sequence) // Raw escape sequence
|
||||
console.log(key.ctrl) // Ctrl held
|
||||
console.log(key.shift) // Shift held
|
||||
console.log(key.meta) // Alt held
|
||||
console.log(key.option) // Option held (macOS)
|
||||
console.log(key.eventType) // "press" | "release" | "repeat"
|
||||
})
|
||||
|
||||
renderer.keyInput.on("paste", (event: PasteEvent) => {
|
||||
const text = decodePasteBytes(event.bytes)
|
||||
console.log("Pasted:", text)
|
||||
})
|
||||
```
|
||||
|
||||
## Animation Timeline
|
||||
|
||||
```typescript
|
||||
import { Timeline, engine } from "@opentui/core"
|
||||
|
||||
const timeline = new Timeline({
|
||||
duration: 2000,
|
||||
loop: false,
|
||||
autoplay: true,
|
||||
})
|
||||
|
||||
timeline.add(
|
||||
{ width: 0 },
|
||||
{
|
||||
width: 50,
|
||||
duration: 1000,
|
||||
ease: "easeOutQuad",
|
||||
onUpdate: (anim) => {
|
||||
box.setWidth(anim.targets[0].width)
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
engine.attach(renderer)
|
||||
engine.addTimeline(timeline)
|
||||
```
|
||||
|
||||
## Type Exports
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
CliRenderer,
|
||||
CliRendererConfig,
|
||||
RenderContext,
|
||||
KeyEvent,
|
||||
Renderable,
|
||||
// ... and more
|
||||
} from "@opentui/core"
|
||||
```
|
||||
@@ -1,168 +0,0 @@
|
||||
# Core Configuration
|
||||
|
||||
## Renderer Configuration
|
||||
|
||||
### createCliRenderer Options
|
||||
|
||||
```typescript
|
||||
import { createCliRenderer, ConsolePosition } from "@opentui/core"
|
||||
|
||||
const renderer = await createCliRenderer({
|
||||
// Rendering
|
||||
targetFPS: 60, // Target frames per second (default: 60)
|
||||
|
||||
// Behavior
|
||||
exitOnCtrlC: true, // Exit on Ctrl+C (default: true)
|
||||
|
||||
// Console overlay
|
||||
consoleOptions: {
|
||||
position: ConsolePosition.BOTTOM, // BOTTOM | TOP | LEFT | RIGHT
|
||||
sizePercent: 30, // Percentage of screen
|
||||
colorInfo: "#00FFFF",
|
||||
colorWarn: "#FFFF00",
|
||||
colorError: "#FF0000",
|
||||
colorDebug: "#888888",
|
||||
startInDebugMode: false,
|
||||
},
|
||||
|
||||
// Lifecycle
|
||||
onDestroy: () => {
|
||||
// Cleanup callback
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
OpenTUI respects several environment variables for configuration and debugging.
|
||||
|
||||
### Debug & Development
|
||||
|
||||
| Variable | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `OTUI_DEBUG` | boolean | false | Enable debug mode, capture raw input |
|
||||
| `OTUI_DEBUG_FFI` | boolean | false | Debug logging for FFI bindings |
|
||||
| `OTUI_TRACE_FFI` | boolean | false | Tracing for FFI bindings |
|
||||
| `OTUI_SHOW_STATS` | boolean | false | Show debug overlay at startup |
|
||||
| `OTUI_DUMP_CAPTURES` | boolean | false | Dump captured output on exit |
|
||||
|
||||
### Console
|
||||
|
||||
| Variable | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `OTUI_USE_CONSOLE` | boolean | true | Enable console capture |
|
||||
| `SHOW_CONSOLE` | boolean | false | Show console at startup |
|
||||
|
||||
### Rendering
|
||||
|
||||
| Variable | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `OTUI_NO_NATIVE_RENDER` | boolean | false | Disable ANSI output (for debugging) |
|
||||
| `OTUI_USE_ALTERNATE_SCREEN` | boolean | true | Use alternate screen buffer |
|
||||
| `OTUI_OVERRIDE_STDOUT` | boolean | true | Override stdout stream |
|
||||
|
||||
### Terminal Capabilities
|
||||
|
||||
| Variable | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `OPENTUI_NO_GRAPHICS` | boolean | false | Disable Kitty graphics protocol |
|
||||
| `OPENTUI_FORCE_UNICODE` | boolean | false | Force Mode 2026 Unicode support |
|
||||
| `OPENTUI_FORCE_WCWIDTH` | boolean | false | Use wcwidth for character width |
|
||||
| `OPENTUI_FORCE_NOZWJ` | boolean | false | Disable ZWJ emoji joining |
|
||||
| `OPENTUI_FORCE_EXPLICIT_WIDTH` | string | - | Force explicit width ("true"/"false") |
|
||||
|
||||
### Tree-sitter (Syntax Highlighting)
|
||||
|
||||
| Variable | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `OTUI_TS_STYLE_WARN` | boolean | false | Warn on missing syntax styles |
|
||||
| `OTUI_TREE_SITTER_WORKER_PATH` | string | "" | Custom tree-sitter worker path |
|
||||
|
||||
### XDG Paths
|
||||
|
||||
| Variable | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `XDG_CONFIG_HOME` | string | "" | User config directory |
|
||||
| `XDG_DATA_HOME` | string | "" | User data directory |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Development Mode
|
||||
|
||||
```bash
|
||||
# Show debug overlay and console
|
||||
OTUI_SHOW_STATS=true SHOW_CONSOLE=true bun run src/index.ts
|
||||
|
||||
# Debug FFI issues
|
||||
OTUI_DEBUG_FFI=true OTUI_TRACE_FFI=true bun run src/index.ts
|
||||
|
||||
# Disable native rendering for testing
|
||||
OTUI_NO_NATIVE_RENDER=true bun run src/index.ts
|
||||
```
|
||||
|
||||
### Terminal Compatibility
|
||||
|
||||
```bash
|
||||
# Force wcwidth for problematic terminals
|
||||
OPENTUI_FORCE_WCWIDTH=true bun run src/index.ts
|
||||
|
||||
# Disable graphics for SSH sessions
|
||||
OPENTUI_NO_GRAPHICS=true bun run src/index.ts
|
||||
```
|
||||
|
||||
## Project Setup
|
||||
|
||||
### package.json
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-tui-app",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "bun run src/index.ts",
|
||||
"dev": "bun --watch run src/index.ts",
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentui/core": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"typescript": "latest"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### tsconfig.json
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
```
|
||||
|
||||
> **Note**: OpenTUI uses `NodeNext` module resolution. All internal imports use `.js` extensions. If you use `bundler` resolution, imports still work but `NodeNext` is recommended for compatibility.
|
||||
|
||||
## Building Native Code
|
||||
|
||||
Native code changes require rebuilding:
|
||||
|
||||
```bash
|
||||
# From repo root (if developing OpenTUI itself)
|
||||
bun run build
|
||||
|
||||
# Zig is required for native compilation
|
||||
# Install: https://ziglang.org/learn/getting-started/
|
||||
```
|
||||
|
||||
**Note**: TypeScript changes do NOT require building. Bun runs TypeScript directly.
|
||||
@@ -1,393 +0,0 @@
|
||||
# Core Gotchas
|
||||
|
||||
## Runtime Environment
|
||||
|
||||
### Use Bun, Not Node.js
|
||||
|
||||
OpenTUI is built for Bun. Always use Bun commands:
|
||||
|
||||
```bash
|
||||
# CORRECT
|
||||
bun install @opentui/core
|
||||
bun run src/index.ts
|
||||
bun test
|
||||
|
||||
# WRONG
|
||||
npm install @opentui/core
|
||||
node src/index.ts
|
||||
npx jest
|
||||
```
|
||||
|
||||
### Bun APIs to Use
|
||||
|
||||
Prefer Bun's built-in APIs for your application code:
|
||||
|
||||
```typescript
|
||||
// CORRECT - Bun APIs
|
||||
Bun.serve({ ... }) // Instead of express
|
||||
Bun.$`ls -la` // Instead of execa
|
||||
import { Database } from "bun:sqlite" // Instead of better-sqlite3
|
||||
|
||||
// WRONG - Node.js patterns
|
||||
import express from "express"
|
||||
```
|
||||
|
||||
> **Note**: OpenTUI itself uses `node:fs` internally for file I/O (for broader compatibility), but your application code should still prefer Bun APIs where available.
|
||||
|
||||
### Avoid process.exit()
|
||||
|
||||
**Never use `process.exit()` directly** - it prevents proper terminal cleanup and can leave the terminal in a broken state (alternate screen mode, raw input mode, etc.).
|
||||
|
||||
```typescript
|
||||
// WRONG - Terminal may be left in broken state
|
||||
if (error) {
|
||||
console.error("Fatal error")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// CORRECT - Use renderer.destroy() for cleanup
|
||||
if (error) {
|
||||
console.error("Fatal error")
|
||||
await renderer.destroy()
|
||||
process.exit(1) // Only after destroy
|
||||
}
|
||||
|
||||
// BETTER - Let destroy handle exit
|
||||
const renderer = await createCliRenderer({
|
||||
exitOnCtrlC: true, // Handles Ctrl+C properly
|
||||
})
|
||||
|
||||
// For programmatic exit
|
||||
renderer.destroy() // Cleans up and exits
|
||||
```
|
||||
|
||||
`renderer.destroy()` restores the terminal to its original state before exiting.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Bun auto-loads `.env` files. Don't use dotenv:
|
||||
|
||||
```typescript
|
||||
// CORRECT
|
||||
const apiKey = process.env.API_KEY
|
||||
|
||||
// WRONG
|
||||
import dotenv from "dotenv"
|
||||
dotenv.config()
|
||||
```
|
||||
|
||||
## Debugging TUIs
|
||||
|
||||
### Cannot See console.log Output
|
||||
|
||||
OpenTUI captures console output for the debug overlay. You can't see logs in the terminal while the TUI is running.
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Use the console overlay:**
|
||||
```typescript
|
||||
const renderer = await createCliRenderer()
|
||||
renderer.console.show()
|
||||
console.log("This appears in the overlay")
|
||||
```
|
||||
|
||||
2. **Toggle with keyboard:**
|
||||
```typescript
|
||||
renderer.keyInput.on("keypress", (key) => {
|
||||
if (key.name === "f12") {
|
||||
renderer.console.toggle()
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
3. **Write to a file:**
|
||||
```typescript
|
||||
import { appendFileSync } from "node:fs"
|
||||
function debugLog(msg: string) {
|
||||
appendFileSync("debug.log", `${new Date().toISOString()} ${msg}\n`)
|
||||
}
|
||||
```
|
||||
|
||||
4. **Disable console capture:**
|
||||
```bash
|
||||
OTUI_USE_CONSOLE=false bun run src/index.ts
|
||||
```
|
||||
|
||||
### Reproduce Issues in Tests
|
||||
|
||||
Don't guess at bugs. Create a reproducible test:
|
||||
|
||||
```typescript
|
||||
import { test, expect } from "bun:test"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
|
||||
test("reproduces the issue", async () => {
|
||||
const { renderer, snapshot } = await createTestRenderer({
|
||||
width: 40,
|
||||
height: 10,
|
||||
})
|
||||
|
||||
// Setup that reproduces the bug
|
||||
const box = new BoxRenderable(renderer, { ... })
|
||||
renderer.root.add(box)
|
||||
|
||||
// Verify with snapshot
|
||||
expect(snapshot()).toMatchSnapshot()
|
||||
})
|
||||
```
|
||||
|
||||
## Focus Management
|
||||
|
||||
### Components Must Be Focused
|
||||
|
||||
Input components only receive keyboard input when focused:
|
||||
|
||||
```typescript
|
||||
const input = new InputRenderable(renderer, {
|
||||
id: "input",
|
||||
placeholder: "Type here...",
|
||||
})
|
||||
|
||||
renderer.root.add(input)
|
||||
|
||||
// WRONG - input won't receive keystrokes
|
||||
// (no focus call)
|
||||
|
||||
// CORRECT
|
||||
input.focus()
|
||||
```
|
||||
|
||||
### Focus in Nested Components
|
||||
|
||||
When a component is inside a container, focus the component directly:
|
||||
|
||||
```typescript
|
||||
const container = new BoxRenderable(renderer, { id: "container" })
|
||||
const input = new InputRenderable(renderer, { id: "input" })
|
||||
container.add(input)
|
||||
renderer.root.add(container)
|
||||
|
||||
// WRONG
|
||||
container.focus()
|
||||
|
||||
// CORRECT
|
||||
input.focus()
|
||||
|
||||
// Or use getRenderable
|
||||
container.getRenderable("input")?.focus()
|
||||
|
||||
// Or use delegate (constructs)
|
||||
const form = delegate(
|
||||
{ focus: "input" },
|
||||
Box({}, Input({ id: "input" })),
|
||||
)
|
||||
form.focus() // Routes to the input
|
||||
```
|
||||
|
||||
## Build Requirements
|
||||
|
||||
### Zig is Required
|
||||
|
||||
Native code compilation requires Zig:
|
||||
|
||||
```bash
|
||||
# Install Zig first
|
||||
# macOS
|
||||
brew install zig
|
||||
|
||||
# Linux
|
||||
# Download from https://ziglang.org/download/
|
||||
|
||||
# Then build
|
||||
bun run build
|
||||
```
|
||||
|
||||
### When to Build
|
||||
|
||||
- **TypeScript changes**: NO build needed (Bun runs TS directly)
|
||||
- **Native code changes**: Build required
|
||||
|
||||
```bash
|
||||
# Only needed when changing native (Zig) code
|
||||
cd packages/core
|
||||
bun run build
|
||||
```
|
||||
|
||||
## Common Errors
|
||||
|
||||
### "Cannot read properties of undefined"
|
||||
|
||||
Usually means a renderable wasn't added to the tree:
|
||||
|
||||
```typescript
|
||||
// WRONG - not added to tree
|
||||
const text = new TextRenderable(renderer, { content: "Hello" })
|
||||
// text.someMethod() // May fail
|
||||
|
||||
// CORRECT
|
||||
const text = new TextRenderable(renderer, { content: "Hello" })
|
||||
renderer.root.add(text)
|
||||
text.someMethod()
|
||||
```
|
||||
|
||||
### Layout Not Updating
|
||||
|
||||
Yoga layout is calculated lazily. Force a recalculation:
|
||||
|
||||
```typescript
|
||||
// After changing layout properties
|
||||
box.setWidth(newWidth)
|
||||
renderer.requestRender()
|
||||
```
|
||||
|
||||
### Text Overflow/Clipping
|
||||
|
||||
Text doesn't wrap by default. Set explicit width:
|
||||
|
||||
```typescript
|
||||
// May overflow
|
||||
const text = new TextRenderable(renderer, {
|
||||
content: "Very long text that might overflow the terminal...",
|
||||
})
|
||||
|
||||
// Contained within width
|
||||
const text = new TextRenderable(renderer, {
|
||||
content: "Very long text that might overflow the terminal...",
|
||||
width: 40, // Will clip or wrap based on parent
|
||||
})
|
||||
```
|
||||
|
||||
### Colors Not Showing
|
||||
|
||||
Check terminal capability and color format:
|
||||
|
||||
```typescript
|
||||
// CORRECT formats
|
||||
fg: "#FF0000" // Hex
|
||||
fg: "red" // CSS color name
|
||||
fg: RGBA.fromHex("#FF0000")
|
||||
|
||||
// WRONG
|
||||
fg: "FF0000" // Missing #
|
||||
fg: 0xFF0000 // Number (not supported)
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
### Avoid Frequent Re-renders
|
||||
|
||||
Batch updates when possible:
|
||||
|
||||
```typescript
|
||||
// WRONG - multiple render calls
|
||||
item1.setContent("...")
|
||||
item2.setContent("...")
|
||||
item3.setContent("...")
|
||||
|
||||
// BETTER - single render after all updates
|
||||
// (OpenTUI batches automatically, but be mindful)
|
||||
items.forEach((item, i) => {
|
||||
item.setContent(data[i])
|
||||
})
|
||||
```
|
||||
|
||||
### Minimize Tree Depth
|
||||
|
||||
Deep nesting impacts layout calculation:
|
||||
|
||||
```typescript
|
||||
// Avoid unnecessary wrappers
|
||||
// WRONG
|
||||
Box({}, Box({}, Box({}, Text({ content: "Hello" }))))
|
||||
|
||||
// CORRECT
|
||||
Box({}, Text({ content: "Hello" }))
|
||||
```
|
||||
|
||||
### Use display: none
|
||||
|
||||
Hide elements instead of removing/re-adding:
|
||||
|
||||
```typescript
|
||||
// For toggling visibility
|
||||
element.setDisplay("none") // Hidden
|
||||
element.setDisplay("flex") // Visible
|
||||
|
||||
// Instead of
|
||||
parent.remove(element)
|
||||
parent.add(element)
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Runner
|
||||
|
||||
Use Bun's test runner:
|
||||
|
||||
```typescript
|
||||
import { test, expect, beforeEach, afterEach } from "bun:test"
|
||||
|
||||
test("my test", () => {
|
||||
expect(1 + 1).toBe(2)
|
||||
})
|
||||
```
|
||||
|
||||
### Test from Package Directories
|
||||
|
||||
Run tests from the specific package directory:
|
||||
|
||||
```bash
|
||||
# CORRECT
|
||||
cd packages/core
|
||||
bun test
|
||||
|
||||
# For native tests
|
||||
cd packages/core
|
||||
bun run test:native
|
||||
```
|
||||
|
||||
### Filter Tests
|
||||
|
||||
```bash
|
||||
# Bun test filter
|
||||
bun test --filter "component name"
|
||||
|
||||
# Native test filter
|
||||
bun run test:native -Dtest-filter="test name"
|
||||
```
|
||||
|
||||
## Keyboard Handling
|
||||
|
||||
### Key Names
|
||||
|
||||
Common key names for `KeyEvent.name`:
|
||||
|
||||
```typescript
|
||||
// Letters/numbers
|
||||
"a", "b", ..., "z"
|
||||
"1", "2", ..., "0"
|
||||
|
||||
// Special keys
|
||||
"escape", "enter", "return", "tab", "backspace", "delete"
|
||||
"up", "down", "left", "right"
|
||||
"home", "end", "pageup", "pagedown"
|
||||
"f1", "f2", ..., "f12"
|
||||
"space"
|
||||
|
||||
// Modifiers (check boolean properties)
|
||||
key.ctrl // Ctrl held
|
||||
key.shift // Shift held
|
||||
key.meta // Alt held
|
||||
key.option // Option held (macOS)
|
||||
```
|
||||
|
||||
### Key Event Types
|
||||
|
||||
```typescript
|
||||
renderer.keyInput.on("keypress", (key) => {
|
||||
// eventType: "press" | "release" | "repeat"
|
||||
if (key.eventType === "repeat") {
|
||||
// Key being held down
|
||||
}
|
||||
})
|
||||
```
|
||||
@@ -1,449 +0,0 @@
|
||||
# Core Patterns
|
||||
|
||||
## Composition Patterns
|
||||
|
||||
### Imperative Composition
|
||||
|
||||
Create renderables and compose with `.add()`:
|
||||
|
||||
```typescript
|
||||
import { createCliRenderer, BoxRenderable, TextRenderable } from "@opentui/core"
|
||||
|
||||
const renderer = await createCliRenderer()
|
||||
|
||||
// Create parent
|
||||
const container = new BoxRenderable(renderer, {
|
||||
id: "container",
|
||||
flexDirection: "column",
|
||||
padding: 1,
|
||||
})
|
||||
|
||||
// Create children
|
||||
const header = new TextRenderable(renderer, {
|
||||
id: "header",
|
||||
content: "Header",
|
||||
fg: "#00FF00",
|
||||
})
|
||||
|
||||
const body = new TextRenderable(renderer, {
|
||||
id: "body",
|
||||
content: "Body content",
|
||||
})
|
||||
|
||||
// Compose tree
|
||||
container.add(header)
|
||||
container.add(body)
|
||||
renderer.root.add(container)
|
||||
```
|
||||
|
||||
### Declarative Composition (Constructs)
|
||||
|
||||
Use VNode functions for cleaner composition:
|
||||
|
||||
```typescript
|
||||
import { createCliRenderer, Box, Text, Input, delegate } from "@opentui/core"
|
||||
|
||||
const renderer = await createCliRenderer()
|
||||
|
||||
// Compose as function calls
|
||||
const ui = Box(
|
||||
{ flexDirection: "column", padding: 1 },
|
||||
Text({ content: "Header", fg: "#00FF00" }),
|
||||
Box(
|
||||
{ flexDirection: "row", gap: 2 },
|
||||
Text({ content: "Name:" }),
|
||||
Input({ id: "name", placeholder: "Enter name..." }),
|
||||
),
|
||||
)
|
||||
|
||||
renderer.root.add(ui)
|
||||
```
|
||||
|
||||
### Reusable Components
|
||||
|
||||
Create factory functions for reusable UI pieces:
|
||||
|
||||
```typescript
|
||||
// Imperative factory
|
||||
function createLabeledInput(
|
||||
renderer: RenderContext,
|
||||
props: { id: string; label: string; placeholder: string }
|
||||
) {
|
||||
const container = new BoxRenderable(renderer, {
|
||||
id: `${props.id}-container`,
|
||||
flexDirection: "row",
|
||||
gap: 1,
|
||||
})
|
||||
|
||||
container.add(new TextRenderable(renderer, {
|
||||
id: `${props.id}-label`,
|
||||
content: props.label,
|
||||
}))
|
||||
|
||||
container.add(new InputRenderable(renderer, {
|
||||
id: `${props.id}-input`,
|
||||
placeholder: props.placeholder,
|
||||
width: 20,
|
||||
}))
|
||||
|
||||
return container
|
||||
}
|
||||
|
||||
// Declarative factory
|
||||
function LabeledInput(props: { id: string; label: string; placeholder: string }) {
|
||||
return delegate(
|
||||
{ focus: `${props.id}-input` },
|
||||
Box(
|
||||
{ flexDirection: "row", gap: 1 },
|
||||
Text({ content: props.label }),
|
||||
Input({
|
||||
id: `${props.id}-input`,
|
||||
placeholder: props.placeholder,
|
||||
width: 20,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Focus Delegation
|
||||
|
||||
Route focus calls to nested elements:
|
||||
|
||||
```typescript
|
||||
import { delegate, Box, Input, Text } from "@opentui/core"
|
||||
|
||||
const form = delegate(
|
||||
{
|
||||
focus: "email-input", // Route .focus() to this child
|
||||
blur: "email-input", // Route .blur() to this child
|
||||
},
|
||||
Box(
|
||||
{ border: true, padding: 1 },
|
||||
Text({ content: "Email:" }),
|
||||
Input({ id: "email-input", placeholder: "you@example.com" }),
|
||||
),
|
||||
)
|
||||
|
||||
// This focuses the input inside, not the box
|
||||
form.focus()
|
||||
```
|
||||
|
||||
## Event Handling
|
||||
|
||||
### Keyboard Events
|
||||
|
||||
```typescript
|
||||
const renderer = await createCliRenderer()
|
||||
|
||||
// Global keyboard handler
|
||||
renderer.keyInput.on("keypress", (key) => {
|
||||
if (key.name === "escape") {
|
||||
renderer.destroy()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (key.ctrl && key.name === "c") {
|
||||
// Ctrl+C handling (if exitOnCtrlC is false)
|
||||
}
|
||||
|
||||
if (key.name === "tab") {
|
||||
// Tab navigation
|
||||
focusNext()
|
||||
}
|
||||
})
|
||||
|
||||
// Paste events
|
||||
renderer.keyInput.on("paste", (event) => {
|
||||
const text = decodePasteBytes(event.bytes)
|
||||
currentInput?.setValue(currentInput.value + text)
|
||||
})
|
||||
```
|
||||
|
||||
### Component Events
|
||||
|
||||
```typescript
|
||||
import { InputRenderable, InputRenderableEvents } from "@opentui/core"
|
||||
|
||||
const input = new InputRenderable(renderer, {
|
||||
id: "search",
|
||||
placeholder: "Search...",
|
||||
})
|
||||
|
||||
input.on(InputRenderableEvents.CHANGE, (value) => {
|
||||
performSearch(value)
|
||||
})
|
||||
|
||||
// Select events
|
||||
const select = new SelectRenderable(renderer, {
|
||||
id: "menu",
|
||||
options: [...],
|
||||
})
|
||||
|
||||
select.on(SelectRenderableEvents.ITEM_SELECTED, (index, option) => {
|
||||
handleSelection(option)
|
||||
})
|
||||
|
||||
select.on(SelectRenderableEvents.SELECTION_CHANGED, (index, option) => {
|
||||
showPreview(option)
|
||||
})
|
||||
```
|
||||
|
||||
### Mouse Events
|
||||
|
||||
```typescript
|
||||
const button = new BoxRenderable(renderer, {
|
||||
id: "button",
|
||||
border: true,
|
||||
onMouseDown: (event) => {
|
||||
button.setBackgroundColor("#444444")
|
||||
},
|
||||
onMouseUp: (event) => {
|
||||
button.setBackgroundColor("#222222")
|
||||
handleClick()
|
||||
},
|
||||
onMouseMove: (event) => {
|
||||
// Hover effect
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
### Local State
|
||||
|
||||
Manage state in closures or objects:
|
||||
|
||||
```typescript
|
||||
// Closure-based state
|
||||
function createCounter(renderer: RenderContext) {
|
||||
let count = 0
|
||||
|
||||
const display = new TextRenderable(renderer, {
|
||||
id: "count",
|
||||
content: `Count: ${count}`,
|
||||
})
|
||||
|
||||
const increment = () => {
|
||||
count++
|
||||
display.setContent(`Count: ${count}`)
|
||||
}
|
||||
|
||||
return { display, increment }
|
||||
}
|
||||
|
||||
// Class-based state
|
||||
class CounterWidget {
|
||||
private count = 0
|
||||
private display: TextRenderable
|
||||
|
||||
constructor(renderer: RenderContext) {
|
||||
this.display = new TextRenderable(renderer, {
|
||||
id: "count",
|
||||
content: this.formatCount(),
|
||||
})
|
||||
}
|
||||
|
||||
private formatCount() {
|
||||
return `Count: ${this.count}`
|
||||
}
|
||||
|
||||
increment() {
|
||||
this.count++
|
||||
this.display.setContent(this.formatCount())
|
||||
}
|
||||
|
||||
getRenderable() {
|
||||
return this.display
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Focus Management
|
||||
|
||||
Track and manage focus across components:
|
||||
|
||||
```typescript
|
||||
class FocusManager {
|
||||
private focusables: Renderable[] = []
|
||||
private currentIndex = 0
|
||||
|
||||
register(renderable: Renderable) {
|
||||
this.focusables.push(renderable)
|
||||
}
|
||||
|
||||
focusNext() {
|
||||
this.focusables[this.currentIndex]?.blur()
|
||||
this.currentIndex = (this.currentIndex + 1) % this.focusables.length
|
||||
this.focusables[this.currentIndex]?.focus()
|
||||
}
|
||||
|
||||
focusPrevious() {
|
||||
this.focusables[this.currentIndex]?.blur()
|
||||
this.currentIndex = (this.currentIndex - 1 + this.focusables.length) % this.focusables.length
|
||||
this.focusables[this.currentIndex]?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const focusManager = new FocusManager()
|
||||
focusManager.register(input1)
|
||||
focusManager.register(input2)
|
||||
focusManager.register(select1)
|
||||
|
||||
renderer.keyInput.on("keypress", (key) => {
|
||||
if (key.name === "tab") {
|
||||
key.shift ? focusManager.focusPrevious() : focusManager.focusNext()
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Lifecycle Patterns
|
||||
|
||||
### Cleanup
|
||||
|
||||
Always clean up resources:
|
||||
|
||||
```typescript
|
||||
const renderer = await createCliRenderer()
|
||||
|
||||
// Track intervals/timeouts
|
||||
const intervals: Timer[] = []
|
||||
|
||||
intervals.push(setInterval(() => {
|
||||
updateClock()
|
||||
}, 1000))
|
||||
|
||||
// Cleanup on exit
|
||||
process.on("SIGINT", () => {
|
||||
intervals.forEach(clearInterval)
|
||||
renderer.destroy()
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
// Or use onDestroy callback
|
||||
const renderer = await createCliRenderer({
|
||||
onDestroy: () => {
|
||||
intervals.forEach(clearInterval)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Dynamic Updates
|
||||
|
||||
Update UI based on external data:
|
||||
|
||||
```typescript
|
||||
async function createDashboard(renderer: RenderContext) {
|
||||
const statsText = new TextRenderable(renderer, {
|
||||
id: "stats",
|
||||
content: "Loading...",
|
||||
})
|
||||
|
||||
// Poll for updates
|
||||
const updateStats = async () => {
|
||||
const data = await fetchStats()
|
||||
statsText.setContent(`CPU: ${data.cpu}% | Memory: ${data.memory}%`)
|
||||
}
|
||||
|
||||
// Initial load
|
||||
await updateStats()
|
||||
|
||||
// Periodic updates
|
||||
setInterval(updateStats, 5000)
|
||||
|
||||
return statsText
|
||||
}
|
||||
```
|
||||
|
||||
## Layout Patterns
|
||||
|
||||
### Responsive Layout
|
||||
|
||||
Adapt to terminal size:
|
||||
|
||||
```typescript
|
||||
const renderer = await createCliRenderer()
|
||||
|
||||
const mainPanel = new BoxRenderable(renderer, {
|
||||
id: "main",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
flexDirection: renderer.width > 80 ? "row" : "column",
|
||||
})
|
||||
|
||||
// Listen for resize
|
||||
process.stdout.on("resize", () => {
|
||||
mainPanel.setFlexDirection(renderer.width > 80 ? "row" : "column")
|
||||
})
|
||||
```
|
||||
|
||||
### Split Panels
|
||||
|
||||
```typescript
|
||||
function createSplitView(renderer: RenderContext, ratio = 0.3) {
|
||||
const container = new BoxRenderable(renderer, {
|
||||
id: "split",
|
||||
flexDirection: "row",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
})
|
||||
|
||||
const left = new BoxRenderable(renderer, {
|
||||
id: "left",
|
||||
width: `${ratio * 100}%`,
|
||||
border: true,
|
||||
})
|
||||
|
||||
const right = new BoxRenderable(renderer, {
|
||||
id: "right",
|
||||
flexGrow: 1,
|
||||
border: true,
|
||||
})
|
||||
|
||||
container.add(left)
|
||||
container.add(right)
|
||||
|
||||
return { container, left, right }
|
||||
}
|
||||
```
|
||||
|
||||
## Debugging Patterns
|
||||
|
||||
### Console Overlay
|
||||
|
||||
Use the built-in console for debugging:
|
||||
|
||||
```typescript
|
||||
const renderer = await createCliRenderer({
|
||||
consoleOptions: {
|
||||
startInDebugMode: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Show console
|
||||
renderer.console.show()
|
||||
|
||||
// All console methods work
|
||||
console.log("Debug info")
|
||||
console.warn("Warning")
|
||||
console.error("Error")
|
||||
|
||||
// Toggle with keyboard
|
||||
renderer.keyInput.on("keypress", (key) => {
|
||||
if (key.name === "f12") {
|
||||
renderer.console.toggle()
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### State Inspection
|
||||
|
||||
```typescript
|
||||
function debugState(label: string, state: unknown) {
|
||||
console.log(`[${label}]`, JSON.stringify(state, null, 2))
|
||||
}
|
||||
|
||||
// In your update logic
|
||||
debugState("form", { name: nameInput.value, email: emailInput.value })
|
||||
```
|
||||
@@ -1,617 +0,0 @@
|
||||
# Keyboard Input Handling
|
||||
|
||||
How to handle keyboard input in OpenTUI applications.
|
||||
|
||||
## Overview
|
||||
|
||||
OpenTUI provides keyboard input handling through:
|
||||
- **Core**: `renderer.keyInput` EventEmitter
|
||||
- **React**: `useKeyboard()` hook
|
||||
- **Solid**: `useKeyboard()` hook
|
||||
|
||||
## When to Use
|
||||
|
||||
Use this reference when you need keyboard shortcuts, focus-aware input handling, or custom keybindings.
|
||||
|
||||
## KeyEvent Object
|
||||
|
||||
All keyboard handlers receive a `KeyEvent` object:
|
||||
|
||||
```typescript
|
||||
interface KeyEvent {
|
||||
name: string // Key name: "a", "escape", "f1", etc.
|
||||
sequence: string // Raw escape sequence
|
||||
ctrl: boolean // Ctrl modifier held
|
||||
shift: boolean // Shift modifier held
|
||||
meta: boolean // Alt modifier held
|
||||
option: boolean // Option modifier held (macOS)
|
||||
eventType: "press" | "release" | "repeat"
|
||||
repeated: boolean // Key is being held (repeat event)
|
||||
}
|
||||
```
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Core
|
||||
|
||||
```typescript
|
||||
import { createCliRenderer, type KeyEvent } from "@opentui/core"
|
||||
|
||||
const renderer = await createCliRenderer()
|
||||
|
||||
renderer.keyInput.on("keypress", (key: KeyEvent) => {
|
||||
if (key.name === "escape") {
|
||||
renderer.destroy()
|
||||
return
|
||||
}
|
||||
|
||||
if (key.ctrl && key.name === "s") {
|
||||
saveDocument()
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### React
|
||||
|
||||
```tsx
|
||||
import { useKeyboard, useRenderer } from "@opentui/react"
|
||||
|
||||
function App() {
|
||||
const renderer = useRenderer()
|
||||
useKeyboard((key) => {
|
||||
if (key.name === "escape") {
|
||||
renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
return <text>Press ESC to exit</text>
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### Solid
|
||||
|
||||
```tsx
|
||||
import { useKeyboard, useRenderer } from "@opentui/solid"
|
||||
|
||||
function App() {
|
||||
const renderer = useRenderer()
|
||||
useKeyboard((key) => {
|
||||
if (key.name === "escape") {
|
||||
renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
return <text>Press ESC to exit</text>
|
||||
}
|
||||
```
|
||||
|
||||
## Key Names
|
||||
|
||||
### Alphabetic Keys
|
||||
|
||||
Lowercase: `a`, `b`, `c`, ... `z`
|
||||
|
||||
With Shift: Check `key.shift && key.name === "a"` for uppercase
|
||||
|
||||
### Numeric Keys
|
||||
|
||||
`0`, `1`, `2`, ... `9`
|
||||
|
||||
### Function Keys
|
||||
|
||||
`f1`, `f2`, `f3`, ... `f12`
|
||||
|
||||
### Special Keys
|
||||
|
||||
| Key Name | Description |
|
||||
|----------|-------------|
|
||||
| `escape` | Escape key |
|
||||
| `enter` | Enter/Return |
|
||||
| `return` | Enter/Return (alias) |
|
||||
| `tab` | Tab key |
|
||||
| `backspace` | Backspace |
|
||||
| `delete` | Delete key |
|
||||
| `space` | Spacebar |
|
||||
|
||||
### Arrow Keys
|
||||
|
||||
| Key Name | Description |
|
||||
|----------|-------------|
|
||||
| `up` | Up arrow |
|
||||
| `down` | Down arrow |
|
||||
| `left` | Left arrow |
|
||||
| `right` | Right arrow |
|
||||
|
||||
### Navigation Keys
|
||||
|
||||
| Key Name | Description |
|
||||
|----------|-------------|
|
||||
| `home` | Home key |
|
||||
| `end` | End key |
|
||||
| `pageup` | Page Up |
|
||||
| `pagedown` | Page Down |
|
||||
| `insert` | Insert key |
|
||||
|
||||
## Modifier Keys
|
||||
|
||||
Check modifier properties on `KeyEvent`:
|
||||
|
||||
```typescript
|
||||
renderer.keyInput.on("keypress", (key) => {
|
||||
if (key.ctrl && key.name === "c") {
|
||||
// Ctrl+C
|
||||
}
|
||||
|
||||
if (key.shift && key.name === "tab") {
|
||||
// Shift+Tab
|
||||
}
|
||||
|
||||
if (key.meta && key.name === "s") {
|
||||
// Alt+S (meta = Alt on most systems)
|
||||
}
|
||||
|
||||
if (key.option && key.name === "a") {
|
||||
// Option+A (macOS)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Modifier Combinations
|
||||
|
||||
```typescript
|
||||
// Ctrl+Shift+S
|
||||
if (key.ctrl && key.shift && key.name === "s") {
|
||||
saveAs()
|
||||
}
|
||||
|
||||
// Ctrl+Alt+Delete (careful with system shortcuts!)
|
||||
if (key.ctrl && key.meta && key.name === "delete") {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Event Types
|
||||
|
||||
### Press Events (Default)
|
||||
|
||||
Normal key press:
|
||||
|
||||
```typescript
|
||||
renderer.keyInput.on("keypress", (key) => {
|
||||
if (key.eventType === "press") {
|
||||
// Initial key press
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Repeat Events
|
||||
|
||||
Key held down:
|
||||
|
||||
```typescript
|
||||
renderer.keyInput.on("keypress", (key) => {
|
||||
if (key.eventType === "repeat" || key.repeated) {
|
||||
// Key is being held
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Release Events
|
||||
|
||||
Key released (opt-in):
|
||||
|
||||
```tsx
|
||||
// React
|
||||
useKeyboard(
|
||||
(key) => {
|
||||
if (key.eventType === "release") {
|
||||
// Key released
|
||||
}
|
||||
},
|
||||
{ release: true } // Enable release events
|
||||
)
|
||||
|
||||
// Solid
|
||||
useKeyboard(
|
||||
(key) => {
|
||||
if (key.eventType === "release") {
|
||||
// Key released
|
||||
}
|
||||
},
|
||||
{ release: true }
|
||||
)
|
||||
```
|
||||
|
||||
## Patterns
|
||||
|
||||
### Navigation Menu
|
||||
|
||||
```tsx
|
||||
function Menu() {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const items = ["Home", "Settings", "Help", "Quit"]
|
||||
|
||||
useKeyboard((key) => {
|
||||
switch (key.name) {
|
||||
case "up":
|
||||
case "k":
|
||||
setSelectedIndex(i => Math.max(0, i - 1))
|
||||
break
|
||||
case "down":
|
||||
case "j":
|
||||
setSelectedIndex(i => Math.min(items.length - 1, i + 1))
|
||||
break
|
||||
case "enter":
|
||||
handleSelect(items[selectedIndex])
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box flexDirection="column">
|
||||
{items.map((item, i) => (
|
||||
<text
|
||||
key={item}
|
||||
fg={i === selectedIndex ? "#00FF00" : "#FFFFFF"}
|
||||
>
|
||||
{i === selectedIndex ? "> " : " "}{item}
|
||||
</text>
|
||||
))}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Modal Escape
|
||||
|
||||
```tsx
|
||||
function Modal({ onClose, children }) {
|
||||
useKeyboard((key) => {
|
||||
if (key.name === "escape") {
|
||||
onClose()
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box border padding={2}>
|
||||
{children}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Vim-style Modes
|
||||
|
||||
```tsx
|
||||
function Editor() {
|
||||
const [mode, setMode] = useState<"normal" | "insert">("normal")
|
||||
const [content, setContent] = useState("")
|
||||
|
||||
useKeyboard((key) => {
|
||||
if (mode === "normal") {
|
||||
switch (key.name) {
|
||||
case "i":
|
||||
setMode("insert")
|
||||
break
|
||||
case "escape":
|
||||
// Already in normal mode
|
||||
break
|
||||
case "j":
|
||||
moveCursorDown()
|
||||
break
|
||||
case "k":
|
||||
moveCursorUp()
|
||||
break
|
||||
}
|
||||
} else if (mode === "insert") {
|
||||
if (key.name === "escape") {
|
||||
setMode("normal")
|
||||
}
|
||||
// Input component handles text in insert mode
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box flexDirection="column">
|
||||
<text>Mode: {mode}</text>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
focused={mode === "insert"}
|
||||
/>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Game Controls
|
||||
|
||||
```tsx
|
||||
function Game() {
|
||||
const [pressed, setPressed] = useState(new Set<string>())
|
||||
|
||||
useKeyboard(
|
||||
(key) => {
|
||||
setPressed(keys => {
|
||||
const newKeys = new Set(keys)
|
||||
if (key.eventType === "release") {
|
||||
newKeys.delete(key.name)
|
||||
} else {
|
||||
newKeys.add(key.name)
|
||||
}
|
||||
return newKeys
|
||||
})
|
||||
},
|
||||
{ release: true }
|
||||
)
|
||||
|
||||
// Game logic uses pressed set
|
||||
useEffect(() => {
|
||||
if (pressed.has("up") || pressed.has("w")) {
|
||||
moveUp()
|
||||
}
|
||||
if (pressed.has("down") || pressed.has("s")) {
|
||||
moveDown()
|
||||
}
|
||||
}, [pressed])
|
||||
|
||||
return <text>WASD or arrows to move</text>
|
||||
}
|
||||
```
|
||||
|
||||
### Keyboard Shortcuts Help
|
||||
|
||||
```tsx
|
||||
function ShortcutsHelp() {
|
||||
const shortcuts = [
|
||||
{ keys: "Ctrl+S", action: "Save" },
|
||||
{ keys: "Ctrl+Q", action: "Quit" },
|
||||
{ keys: "Ctrl+F", action: "Find" },
|
||||
{ keys: "Tab", action: "Next field" },
|
||||
{ keys: "Shift+Tab", action: "Previous field" },
|
||||
]
|
||||
|
||||
return (
|
||||
<box border title="Keyboard Shortcuts" padding={1}>
|
||||
{shortcuts.map(({ keys, action }) => (
|
||||
<box key={keys} flexDirection="row">
|
||||
<text width={15} fg="#00FFFF">{keys}</text>
|
||||
<text>{action}</text>
|
||||
</box>
|
||||
))}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Paste Events
|
||||
|
||||
Handle pasted content. Paste events deliver raw bytes, not decoded text.
|
||||
|
||||
### PasteEvent Object
|
||||
|
||||
```typescript
|
||||
import { type PasteEvent } from "@opentui/core"
|
||||
|
||||
interface PasteEvent {
|
||||
type: "paste" // Always "paste"
|
||||
bytes: Uint8Array // Raw pasted bytes
|
||||
metadata?: PasteMetadata // Optional metadata
|
||||
preventDefault(): void // Prevent default paste handling
|
||||
defaultPrevented: boolean // Whether preventDefault was called
|
||||
}
|
||||
|
||||
interface PasteMetadata {
|
||||
mimeType?: string // MIME type if available
|
||||
kind?: PasteKind // Paste kind
|
||||
}
|
||||
```
|
||||
|
||||
### Decoding Paste Bytes
|
||||
|
||||
Use `decodePasteBytes` to convert raw bytes to a string, and `stripAnsiSequences` to remove ANSI escape codes:
|
||||
|
||||
```typescript
|
||||
import { decodePasteBytes, stripAnsiSequences } from "@opentui/core"
|
||||
|
||||
const text = decodePasteBytes(event.bytes) // Decode UTF-8
|
||||
const clean = stripAnsiSequences(decodePasteBytes(event.bytes)) // Decode + strip ANSI
|
||||
```
|
||||
|
||||
### Core
|
||||
|
||||
```typescript
|
||||
import { type PasteEvent, decodePasteBytes } from "@opentui/core"
|
||||
|
||||
renderer.keyInput.on("paste", (event: PasteEvent) => {
|
||||
const text = decodePasteBytes(event.bytes)
|
||||
console.log("Pasted:", text)
|
||||
})
|
||||
```
|
||||
|
||||
### Solid
|
||||
|
||||
Solid provides a dedicated `usePaste` hook:
|
||||
|
||||
```tsx
|
||||
import { usePaste } from "@opentui/solid"
|
||||
import { decodePasteBytes } from "@opentui/core"
|
||||
|
||||
function App() {
|
||||
usePaste((event) => {
|
||||
const text = decodePasteBytes(event.bytes)
|
||||
console.log("Pasted:", text)
|
||||
})
|
||||
|
||||
return <text>Paste something</text>
|
||||
}
|
||||
```
|
||||
|
||||
> **Note**: `usePaste` is **Solid-only**. React does not have this hook - handle paste via the Core event emitter or input component's `onChange`.
|
||||
|
||||
## Text Selection
|
||||
|
||||
Text selection is renderer-managed. The renderer owns a single `Selection` object, walks the renderable tree to find selectable children, and emits a `"selection"` event when the user finishes selecting (mouse-up). The `Selection` object aggregates text from all selected renderables automatically.
|
||||
|
||||
### Making Renderables Selectable
|
||||
|
||||
A renderable must have `selectable` set to `true` to participate in selection. Text-based renderables (`TextRenderable`, `TextareaRenderable`, `ASCIIFontRenderable`, `TextTableRenderable`) support this:
|
||||
|
||||
```tsx
|
||||
// React / Solid
|
||||
<text selectable>This text can be selected</text>
|
||||
|
||||
// Core
|
||||
const text = new TextRenderable(renderer, {
|
||||
id: "label",
|
||||
content: "This text can be selected",
|
||||
selectable: true,
|
||||
})
|
||||
```
|
||||
|
||||
### Copy-on-Selection (Core)
|
||||
|
||||
Listen to the renderer's `"selection"` event. The `Selection` object's `getSelectedText()` returns text aggregated from all selected renderables in reading order:
|
||||
|
||||
```typescript
|
||||
import type { Selection } from "@opentui/core"
|
||||
|
||||
renderer.on("selection", (selection: Selection) => {
|
||||
const text = selection.getSelectedText()
|
||||
if (text) {
|
||||
renderer.copyToClipboardOSC52(text)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
> **Important**: Call `selection.getSelectedText()` on the `Selection` object from the event -- not `renderer.root.getSelectedText()`. Individual renderables only return their own selected text. The `Selection` object aggregates across the tree.
|
||||
|
||||
### Copy-on-Selection (Solid)
|
||||
|
||||
```tsx
|
||||
import { useSelectionHandler } from "@opentui/solid"
|
||||
|
||||
function App() {
|
||||
useSelectionHandler((selection) => {
|
||||
const text = selection.getSelectedText()
|
||||
if (text) {
|
||||
renderer.copyToClipboardOSC52(text)
|
||||
}
|
||||
})
|
||||
|
||||
return <text selectable>Select this text</text>
|
||||
}
|
||||
```
|
||||
|
||||
> **Note**: `useSelectionHandler` is **Solid-only**. React does not have this hook -- use the Core `renderer.on("selection", ...)` event.
|
||||
|
||||
### Selection Object
|
||||
|
||||
The `Selection` object passed to the event callback:
|
||||
|
||||
```typescript
|
||||
selection.getSelectedText() // Aggregated text from all selected renderables
|
||||
selection.bounds // { startX, startY, endX, endY } bounding rect
|
||||
selection.selectedRenderables // Renderable[] with active selections
|
||||
selection.isActive // Whether selection is still active
|
||||
```
|
||||
|
||||
Individual renderables also expose:
|
||||
|
||||
```typescript
|
||||
renderable.hasSelection() // Does this renderable have selected text?
|
||||
renderable.getSelectedText() // Selected text in this renderable only
|
||||
```
|
||||
|
||||
### How Selection Traversal Works
|
||||
|
||||
When the user drags to select, the renderer:
|
||||
1. Identifies the selection container (common ancestor of start and end points)
|
||||
2. Walks all `selectable` descendants within the selection bounds
|
||||
3. Calls `onSelectionChanged(selection)` on each, which computes local selection
|
||||
4. Tracks which renderables have active selections in `selection.selectedRenderables`
|
||||
|
||||
This means selection works across multiple renderables. Dragging across two `<text selectable>` elements selects text in both, and `selection.getSelectedText()` joins them with newlines.
|
||||
|
||||
## Clipboard API (OSC 52)
|
||||
|
||||
Copy text to the system clipboard using OSC 52 escape sequences. Works over SSH and in most modern terminal emulators.
|
||||
|
||||
```typescript
|
||||
// Copy to clipboard
|
||||
const success = renderer.copyToClipboardOSC52("text to copy")
|
||||
|
||||
// Check if OSC 52 is supported
|
||||
if (renderer.isOsc52Supported()) {
|
||||
renderer.copyToClipboardOSC52("Hello!")
|
||||
}
|
||||
|
||||
// Clear clipboard
|
||||
renderer.clearClipboardOSC52()
|
||||
|
||||
// Target specific clipboard (X11)
|
||||
import { ClipboardTarget } from "@opentui/core"
|
||||
renderer.copyToClipboardOSC52("text", ClipboardTarget.Primary) // X11 primary
|
||||
renderer.copyToClipboardOSC52("text", ClipboardTarget.Clipboard) // System clipboard (default)
|
||||
```
|
||||
|
||||
## Focus and Input Components
|
||||
|
||||
Input components (`<input>`, `<textarea>`, `<select>`) capture keyboard events when focused:
|
||||
|
||||
```tsx
|
||||
<input focused /> // Receives keyboard input
|
||||
|
||||
// Global useKeyboard still fires, but input consumes characters
|
||||
```
|
||||
|
||||
To prevent conflicts, check if an input is focused before handling global shortcuts:
|
||||
|
||||
```tsx
|
||||
function App() {
|
||||
const renderer = useRenderer()
|
||||
const [inputFocused, setInputFocused] = useState(false)
|
||||
|
||||
useKeyboard((key) => {
|
||||
if (inputFocused) return // Let input handle it
|
||||
|
||||
// Global shortcuts
|
||||
if (key.name === "escape") {
|
||||
renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<input
|
||||
focused={inputFocused}
|
||||
onFocus={() => setInputFocused(true)}
|
||||
onBlur={() => setInputFocused(false)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
### Terminal Limitations
|
||||
|
||||
Some key combinations are captured by the terminal or OS:
|
||||
- `Ctrl+C` often sends SIGINT (use `exitOnCtrlC: false` to handle)
|
||||
- `Ctrl+Z` suspends the process
|
||||
- Some function keys may be intercepted
|
||||
|
||||
### SSH and Remote Sessions
|
||||
|
||||
Key detection may vary over SSH. Test on target environments.
|
||||
|
||||
### Multiple Handlers
|
||||
|
||||
Multiple `useKeyboard` calls all receive events. Coordinate handlers to prevent conflicts.
|
||||
|
||||
## See Also
|
||||
|
||||
- [React API](../react/api.md) - `useKeyboard` hook reference
|
||||
- [Solid API](../solid/api.md) - `useKeyboard` hook reference
|
||||
- [Input Components](../components/inputs.md) - Focus management with input, textarea, select
|
||||
- [Testing](../testing/REFERENCE.md) - Simulating key presses in tests
|
||||
@@ -1,337 +0,0 @@
|
||||
# OpenTUI Layout System
|
||||
|
||||
OpenTUI uses the Yoga layout engine, providing CSS Flexbox-like capabilities for positioning and sizing components in the terminal.
|
||||
|
||||
## Overview
|
||||
|
||||
Key concepts:
|
||||
- **Flexbox model**: Familiar CSS Flexbox properties
|
||||
- **Yoga engine**: Facebook's cross-platform layout engine
|
||||
- **Terminal units**: Dimensions are in character cells (columns x rows)
|
||||
- **Percentage support**: Relative sizing based on parent
|
||||
|
||||
## Flex Container Properties
|
||||
|
||||
### flexDirection
|
||||
|
||||
Controls the main axis direction:
|
||||
|
||||
```tsx
|
||||
// Row (default) - children flow horizontally
|
||||
<box flexDirection="row">
|
||||
<text>1</text>
|
||||
<text>2</text>
|
||||
<text>3</text>
|
||||
</box>
|
||||
// Output: 1 2 3
|
||||
|
||||
// Column - children flow vertically
|
||||
<box flexDirection="column">
|
||||
<text>1</text>
|
||||
<text>2</text>
|
||||
<text>3</text>
|
||||
</box>
|
||||
// Output:
|
||||
// 1
|
||||
// 2
|
||||
// 3
|
||||
|
||||
// Reverse variants
|
||||
<box flexDirection="row-reverse">...</box> // 3 2 1
|
||||
<box flexDirection="column-reverse">...</box> // Bottom to top
|
||||
```
|
||||
|
||||
### justifyContent
|
||||
|
||||
Aligns children along the main axis:
|
||||
|
||||
```tsx
|
||||
<box flexDirection="row" width={40} justifyContent="flex-start">
|
||||
{/* Children at start (left for row) */}
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" width={40} justifyContent="flex-end">
|
||||
{/* Children at end (right for row) */}
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" width={40} justifyContent="center">
|
||||
{/* Children centered */}
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" width={40} justifyContent="space-between">
|
||||
{/* First at start, last at end, rest evenly distributed */}
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" width={40} justifyContent="space-around">
|
||||
{/* Equal space around each child */}
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" width={40} justifyContent="space-evenly">
|
||||
{/* Equal space between all children and edges */}
|
||||
</box>
|
||||
```
|
||||
|
||||
### alignItems
|
||||
|
||||
Aligns children along the cross axis:
|
||||
|
||||
```tsx
|
||||
<box flexDirection="row" height={10} alignItems="flex-start">
|
||||
{/* Children at top */}
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" height={10} alignItems="flex-end">
|
||||
{/* Children at bottom */}
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" height={10} alignItems="center">
|
||||
{/* Children vertically centered */}
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" height={10} alignItems="stretch">
|
||||
{/* Children stretch to fill height */}
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" height={10} alignItems="baseline">
|
||||
{/* Children aligned by text baseline */}
|
||||
</box>
|
||||
```
|
||||
|
||||
### flexWrap
|
||||
|
||||
Controls whether children wrap to new lines:
|
||||
|
||||
```tsx
|
||||
<box flexDirection="row" flexWrap="nowrap" width={20}>
|
||||
{/* Children overflow (default) */}
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" flexWrap="wrap" width={20}>
|
||||
{/* Children wrap to next row */}
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" flexWrap="wrap-reverse" width={20}>
|
||||
{/* Children wrap upward */}
|
||||
</box>
|
||||
```
|
||||
|
||||
### gap
|
||||
|
||||
Space between children:
|
||||
|
||||
```tsx
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text>A</text>
|
||||
<text>B</text>
|
||||
<text>C</text>
|
||||
</box>
|
||||
// Output: A B C (2 spaces between)
|
||||
```
|
||||
|
||||
## Flex Item Properties
|
||||
|
||||
### flexGrow
|
||||
|
||||
How much a child should grow relative to siblings:
|
||||
|
||||
```tsx
|
||||
<box flexDirection="row" width={30}>
|
||||
<box flexGrow={1}><text>1</text></box>
|
||||
<box flexGrow={2}><text>2</text></box>
|
||||
<box flexGrow={1}><text>1</text></box>
|
||||
</box>
|
||||
// Widths: 7.5 | 15 | 7.5 (1:2:1 ratio)
|
||||
```
|
||||
|
||||
### flexShrink
|
||||
|
||||
How much a child should shrink when space is limited:
|
||||
|
||||
```tsx
|
||||
<box flexDirection="row" width={20}>
|
||||
<box width={15} flexShrink={1}><text>Shrinks</text></box>
|
||||
<box width={15} flexShrink={0}><text>Fixed</text></box>
|
||||
</box>
|
||||
```
|
||||
|
||||
### flexBasis
|
||||
|
||||
Initial size before growing/shrinking:
|
||||
|
||||
```tsx
|
||||
<box flexDirection="row">
|
||||
<box flexBasis={20} flexGrow={1}>Starts at 20, can grow</box>
|
||||
<box flexBasis="50%">Half of parent</box>
|
||||
</box>
|
||||
```
|
||||
|
||||
### alignSelf
|
||||
|
||||
Override parent's alignItems for this child:
|
||||
|
||||
```tsx
|
||||
<box flexDirection="row" height={10} alignItems="center">
|
||||
<text>Centered</text>
|
||||
<text alignSelf="flex-start">Top</text>
|
||||
<text alignSelf="flex-end">Bottom</text>
|
||||
</box>
|
||||
```
|
||||
|
||||
## Dimensions
|
||||
|
||||
### Fixed Dimensions
|
||||
|
||||
```tsx
|
||||
<box width={40} height={10}>
|
||||
{/* Exactly 40 columns by 10 rows */}
|
||||
</box>
|
||||
```
|
||||
|
||||
### Percentage Dimensions
|
||||
|
||||
Parent must have explicit size:
|
||||
|
||||
```tsx
|
||||
<box width="100%" height="100%">
|
||||
<box width="50%" height="50%">
|
||||
{/* Half of parent */}
|
||||
</box>
|
||||
</box>
|
||||
```
|
||||
|
||||
### Min/Max Constraints
|
||||
|
||||
```tsx
|
||||
<box
|
||||
minWidth={20}
|
||||
maxWidth={60}
|
||||
minHeight={5}
|
||||
maxHeight={20}
|
||||
>
|
||||
{/* Constrained sizing */}
|
||||
</box>
|
||||
```
|
||||
|
||||
## Spacing
|
||||
|
||||
### Padding (inside)
|
||||
|
||||
```tsx
|
||||
// All sides
|
||||
<box padding={2}>Content</box>
|
||||
|
||||
// Individual sides
|
||||
<box
|
||||
paddingTop={1}
|
||||
paddingRight={2}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
>
|
||||
Content
|
||||
</box>
|
||||
```
|
||||
|
||||
### Margin (outside)
|
||||
|
||||
```tsx
|
||||
// All sides
|
||||
<box margin={1}>Content</box>
|
||||
|
||||
// Individual sides
|
||||
<box
|
||||
marginTop={1}
|
||||
marginRight={2}
|
||||
marginBottom={1}
|
||||
marginLeft={2}
|
||||
>
|
||||
Content
|
||||
</box>
|
||||
```
|
||||
|
||||
## Positioning
|
||||
|
||||
### Relative (default)
|
||||
|
||||
Element flows in normal document order:
|
||||
|
||||
```tsx
|
||||
<box position="relative">
|
||||
{/* Normal flow */}
|
||||
</box>
|
||||
```
|
||||
|
||||
### Absolute
|
||||
|
||||
Element positioned relative to nearest positioned ancestor:
|
||||
|
||||
```tsx
|
||||
<box position="relative" width="100%" height="100%">
|
||||
<box
|
||||
position="absolute"
|
||||
left={10}
|
||||
top={5}
|
||||
width={20}
|
||||
height={5}
|
||||
>
|
||||
Positioned at (10, 5)
|
||||
</box>
|
||||
</box>
|
||||
```
|
||||
|
||||
### Position Properties
|
||||
|
||||
```tsx
|
||||
<box
|
||||
position="absolute"
|
||||
left={10} // From left edge
|
||||
top={5} // From top edge
|
||||
right={10} // From right edge
|
||||
bottom={5} // From bottom edge
|
||||
>
|
||||
Content
|
||||
</box>
|
||||
```
|
||||
|
||||
## Display
|
||||
|
||||
### Visibility Control
|
||||
|
||||
```tsx
|
||||
// Visible (default)
|
||||
<box display="flex">Visible</box>
|
||||
|
||||
// Hidden (removed from layout)
|
||||
<box display="none">Hidden</box>
|
||||
```
|
||||
|
||||
## Overflow
|
||||
|
||||
```tsx
|
||||
<box overflow="visible">
|
||||
{/* Content can extend beyond bounds (default) */}
|
||||
</box>
|
||||
|
||||
<box overflow="hidden">
|
||||
{/* Content clipped at bounds */}
|
||||
</box>
|
||||
|
||||
<box overflow="scroll">
|
||||
{/* Scrollable when content exceeds bounds */}
|
||||
</box>
|
||||
```
|
||||
|
||||
## Z-Index
|
||||
|
||||
Control stacking order for overlapping elements:
|
||||
|
||||
```tsx
|
||||
<box position="relative">
|
||||
<box position="absolute" zIndex={1}>Behind</box>
|
||||
<box position="absolute" zIndex={2}>In front</box>
|
||||
</box>
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
- [Layout Patterns](./patterns.md) - Common layout recipes
|
||||
- [Components/Containers](../components/containers.md) - Box and ScrollBox details
|
||||
@@ -1,444 +0,0 @@
|
||||
# Layout Patterns
|
||||
|
||||
Common layout recipes for terminal user interfaces.
|
||||
|
||||
## Full-Screen App
|
||||
|
||||
Fill the entire terminal:
|
||||
|
||||
```tsx
|
||||
function App() {
|
||||
return (
|
||||
<box width="100%" height="100%">
|
||||
{/* Content fills terminal */}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Header/Content/Footer
|
||||
|
||||
Classic app layout:
|
||||
|
||||
```tsx
|
||||
function AppLayout() {
|
||||
return (
|
||||
<box flexDirection="column" width="100%" height="100%">
|
||||
{/* Header - fixed height */}
|
||||
<box height={3} borderStyle="single" borderBottom>
|
||||
<text>Header</text>
|
||||
</box>
|
||||
|
||||
{/* Content - fills remaining space */}
|
||||
<box flexGrow={1}>
|
||||
<text>Main Content</text>
|
||||
</box>
|
||||
|
||||
{/* Footer - fixed height */}
|
||||
<box height={1}>
|
||||
<text>Status: Ready</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Sidebar Layout
|
||||
|
||||
```tsx
|
||||
function SidebarLayout() {
|
||||
return (
|
||||
<box flexDirection="row" width="100%" height="100%">
|
||||
{/* Sidebar - fixed width */}
|
||||
<box width={25} borderStyle="single" borderRight>
|
||||
<text>Sidebar</text>
|
||||
</box>
|
||||
|
||||
{/* Main - fills remaining space */}
|
||||
<box flexGrow={1}>
|
||||
<text>Main Content</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Resizable Sidebar
|
||||
|
||||
Responsive based on terminal width:
|
||||
|
||||
```tsx
|
||||
function ResponsiveSidebar() {
|
||||
const dims = useTerminalDimensions() // React: useTerminalDimensions()
|
||||
const showSidebar = dims.width > 60
|
||||
const sidebarWidth = Math.min(30, Math.floor(dims.width * 0.3))
|
||||
|
||||
return (
|
||||
<box flexDirection="row" width="100%" height="100%">
|
||||
{showSidebar && (
|
||||
<box width={sidebarWidth} border>
|
||||
<text>Sidebar</text>
|
||||
</box>
|
||||
)}
|
||||
<box flexGrow={1}>
|
||||
<text>Main</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Centered Content
|
||||
|
||||
### Horizontally Centered
|
||||
|
||||
```tsx
|
||||
<box width="100%" justifyContent="center">
|
||||
<box width={40}>
|
||||
<text>Centered horizontally</text>
|
||||
</box>
|
||||
</box>
|
||||
```
|
||||
|
||||
### Vertically Centered
|
||||
|
||||
```tsx
|
||||
<box height="100%" alignItems="center">
|
||||
<text>Centered vertically</text>
|
||||
</box>
|
||||
```
|
||||
|
||||
### Both Axes
|
||||
|
||||
```tsx
|
||||
<box
|
||||
width="100%"
|
||||
height="100%"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
<box width={40} height={10} border>
|
||||
<text>Centered both ways</text>
|
||||
</box>
|
||||
</box>
|
||||
```
|
||||
|
||||
## Modal/Dialog
|
||||
|
||||
Centered overlay:
|
||||
|
||||
```tsx
|
||||
function Modal({ children, visible }) {
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<box
|
||||
position="absolute"
|
||||
left={0}
|
||||
top={0}
|
||||
width="100%"
|
||||
height="100%"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
backgroundColor="rgba(0,0,0,0.5)"
|
||||
>
|
||||
<box
|
||||
width={50}
|
||||
height={15}
|
||||
border
|
||||
borderStyle="double"
|
||||
backgroundColor="#1a1a2e"
|
||||
padding={2}
|
||||
>
|
||||
{children}
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Grid Layout
|
||||
|
||||
Using flexWrap:
|
||||
|
||||
```tsx
|
||||
function Grid({ items, columns = 3 }) {
|
||||
const itemWidth = `${Math.floor(100 / columns)}%`
|
||||
|
||||
return (
|
||||
<box flexDirection="row" flexWrap="wrap" width="100%">
|
||||
{items.map((item, i) => (
|
||||
<box key={i} width={itemWidth} padding={1}>
|
||||
<text>{item}</text>
|
||||
</box>
|
||||
))}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Split Panels
|
||||
|
||||
### Horizontal Split
|
||||
|
||||
```tsx
|
||||
function HorizontalSplit({ ratio = 0.5 }) {
|
||||
return (
|
||||
<box flexDirection="row" width="100%" height="100%">
|
||||
<box width={`${ratio * 100}%`} border>
|
||||
<text>Left Panel</text>
|
||||
</box>
|
||||
<box flexGrow={1} border>
|
||||
<text>Right Panel</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Vertical Split
|
||||
|
||||
```tsx
|
||||
function VerticalSplit({ ratio = 0.5 }) {
|
||||
return (
|
||||
<box flexDirection="column" width="100%" height="100%">
|
||||
<box height={`${ratio * 100}%`} border>
|
||||
<text>Top Panel</text>
|
||||
</box>
|
||||
<box flexGrow={1} border>
|
||||
<text>Bottom Panel</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Form Layout
|
||||
|
||||
Label + Input pairs:
|
||||
|
||||
```tsx
|
||||
function FormField({ label, children }) {
|
||||
return (
|
||||
<box flexDirection="row" marginBottom={1}>
|
||||
<box width={15}>
|
||||
<text>{label}:</text>
|
||||
</box>
|
||||
<box flexGrow={1}>
|
||||
{children}
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function LoginForm() {
|
||||
return (
|
||||
<box flexDirection="column" padding={2} border width={50}>
|
||||
<FormField label="Username">
|
||||
<input placeholder="Enter username" />
|
||||
</FormField>
|
||||
<FormField label="Password">
|
||||
<input placeholder="Enter password" />
|
||||
</FormField>
|
||||
<box marginTop={2} justifyContent="flex-end">
|
||||
<box border padding={1}>
|
||||
<text>Login</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Navigation Tabs
|
||||
|
||||
```tsx
|
||||
function TabBar({ tabs, activeIndex, onSelect }) {
|
||||
return (
|
||||
<box flexDirection="row" borderBottom>
|
||||
{tabs.map((tab, i) => (
|
||||
<box
|
||||
key={i}
|
||||
padding={1}
|
||||
backgroundColor={i === activeIndex ? "#333" : "transparent"}
|
||||
onMouseDown={() => onSelect(i)}
|
||||
>
|
||||
<text fg={i === activeIndex ? "#fff" : "#888"}>
|
||||
{tab}
|
||||
</text>
|
||||
</box>
|
||||
))}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Sticky Footer
|
||||
|
||||
Footer always at bottom:
|
||||
|
||||
```tsx
|
||||
function StickyFooterLayout() {
|
||||
return (
|
||||
<box flexDirection="column" width="100%" height="100%">
|
||||
{/* Content area */}
|
||||
<box flexGrow={1} flexDirection="column">
|
||||
{/* Your content here */}
|
||||
<text>Content that might be short</text>
|
||||
</box>
|
||||
|
||||
{/* Footer pushed to bottom */}
|
||||
<box height={1}>
|
||||
<text fg="#888">Press ? for help | q to quit</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Absolute Positioning Overlay
|
||||
|
||||
Tooltip or popup:
|
||||
|
||||
```tsx
|
||||
function Tooltip({ x, y, children }) {
|
||||
return (
|
||||
<box
|
||||
position="absolute"
|
||||
left={x}
|
||||
top={y}
|
||||
border
|
||||
backgroundColor="#333"
|
||||
padding={1}
|
||||
zIndex={100}
|
||||
>
|
||||
{children}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Responsive Breakpoints
|
||||
|
||||
Different layouts based on terminal size:
|
||||
|
||||
```tsx
|
||||
function ResponsiveApp() {
|
||||
const { width, height } = useTerminalDimensions()
|
||||
|
||||
// Define breakpoints
|
||||
const isSmall = width < 60
|
||||
const isMedium = width >= 60 && width < 100
|
||||
const isLarge = width >= 100
|
||||
|
||||
if (isSmall) {
|
||||
// Mobile-like: stacked layout
|
||||
return (
|
||||
<box flexDirection="column">
|
||||
<Navigation />
|
||||
<Content />
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMedium) {
|
||||
// Tablet-like: sidebar + content
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<box width={20}><Navigation /></box>
|
||||
<box flexGrow={1}><Content /></box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
// Large: full layout
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<box width={25}><Navigation /></box>
|
||||
<box flexGrow={1}><Content /></box>
|
||||
<box width={30}><Sidebar /></box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Equal Height Columns
|
||||
|
||||
```tsx
|
||||
function EqualColumns() {
|
||||
return (
|
||||
<box flexDirection="row" alignItems="stretch" height={20}>
|
||||
<box flexGrow={1} border>
|
||||
<text>Short content</text>
|
||||
</box>
|
||||
<box flexGrow={1} border>
|
||||
<text>
|
||||
Longer content that
|
||||
spans multiple lines
|
||||
and takes up space
|
||||
</text>
|
||||
</box>
|
||||
<box flexGrow={1} border>
|
||||
<text>Medium content</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Spacing Utilities
|
||||
|
||||
Consistent spacing patterns:
|
||||
|
||||
```tsx
|
||||
// Spacer component
|
||||
function Spacer({ size = 1 }) {
|
||||
return <box height={size} width={size} />
|
||||
}
|
||||
|
||||
// Divider component
|
||||
function Divider() {
|
||||
return <box height={1} width="100%" backgroundColor="#333" />
|
||||
}
|
||||
|
||||
// Usage
|
||||
<box flexDirection="column">
|
||||
<text>Section 1</text>
|
||||
<Spacer size={2} />
|
||||
<Divider />
|
||||
<Spacer size={2} />
|
||||
<text>Section 2</text>
|
||||
</box>
|
||||
```
|
||||
|
||||
### Axis Shorthand Props
|
||||
|
||||
Use `paddingX`/`paddingY` and `marginX`/`marginY` for horizontal/vertical spacing:
|
||||
|
||||
```tsx
|
||||
// Horizontal padding (left + right)
|
||||
<box paddingX={4}>
|
||||
<text>4 chars padding left and right</text>
|
||||
</box>
|
||||
|
||||
// Vertical padding (top + bottom)
|
||||
<box paddingY={2}>
|
||||
<text>2 lines padding top and bottom</text>
|
||||
</box>
|
||||
|
||||
// Horizontal margin for centering-like effect
|
||||
<box marginX={10}>
|
||||
<text>Indented content</text>
|
||||
</box>
|
||||
|
||||
// Combined for card-like spacing
|
||||
<box paddingX={3} paddingY={1} marginY={1} border>
|
||||
<text>Nicely spaced card</text>
|
||||
</box>
|
||||
```
|
||||
|
||||
These are shorthand for:
|
||||
- `paddingX={n}` = `paddingLeft={n}` + `paddingRight={n}`
|
||||
- `paddingY={n}` = `paddingTop={n}` + `paddingBottom={n}`
|
||||
- `marginX={n}` = `marginLeft={n}` + `marginRight={n}`
|
||||
- `marginY={n}` = `marginTop={n}` + `marginBottom={n}`
|
||||
@@ -1,174 +0,0 @@
|
||||
# OpenTUI React (@opentui/react)
|
||||
|
||||
A React reconciler for building terminal user interfaces with familiar React patterns. Write TUIs using JSX, hooks, and component composition.
|
||||
|
||||
## Overview
|
||||
|
||||
OpenTUI React provides:
|
||||
- **Custom reconciler**: React components render to OpenTUI renderables
|
||||
- **JSX intrinsics**: `<text>`, `<box>`, `<input>`, etc.
|
||||
- **Hooks**: `useKeyboard`, `useRenderer`, `useTimeline`, etc.
|
||||
- **Full React compatibility**: useState, useEffect, context, and more
|
||||
|
||||
## When to Use React
|
||||
|
||||
Use the React reconciler when:
|
||||
- You're familiar with React patterns
|
||||
- You want declarative UI composition
|
||||
- You need React's ecosystem (context, state management libraries)
|
||||
- Building applications with complex state
|
||||
- Team knows React already
|
||||
|
||||
## When NOT to Use React
|
||||
|
||||
| Scenario | Use Instead |
|
||||
|----------|-------------|
|
||||
| Maximum performance critical | `@opentui/core` (imperative) |
|
||||
| Fine-grained reactivity | `@opentui/solid` |
|
||||
| Smallest bundle size | `@opentui/core` |
|
||||
| Building a framework/library | `@opentui/core` |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
bunx create-tui@latest -t react my-app
|
||||
cd my-app
|
||||
bun run src/index.tsx
|
||||
```
|
||||
|
||||
The CLI creates the `my-app` directory for you - it must **not already exist**.
|
||||
|
||||
**Agent guidance**: Always use autonomous mode with `-t <template>` flag. Never use interactive mode (`bunx create-tui@latest my-app` without `-t`) as it requires user prompts that agents cannot respond to.
|
||||
|
||||
Or manual setup:
|
||||
|
||||
```bash
|
||||
mkdir my-tui && cd my-tui
|
||||
bun init
|
||||
bun install @opentui/react @opentui/core react
|
||||
```
|
||||
|
||||
```tsx
|
||||
import { createCliRenderer } from "@opentui/core"
|
||||
import { createRoot } from "@opentui/react"
|
||||
import { useState } from "react"
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
return (
|
||||
<box border padding={2}>
|
||||
<text>Count: {count}</text>
|
||||
<box
|
||||
border
|
||||
onMouseDown={() => setCount(c => c + 1)}
|
||||
>
|
||||
<text>Click me!</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
const renderer = await createCliRenderer()
|
||||
createRoot(renderer).render(<App />)
|
||||
```
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### JSX Elements
|
||||
|
||||
React maps JSX intrinsic elements to OpenTUI renderables:
|
||||
|
||||
```tsx
|
||||
// These are not HTML elements!
|
||||
<text>Hello</text> // TextRenderable
|
||||
<box border>Content</box> // BoxRenderable
|
||||
<input placeholder="..." /> // InputRenderable
|
||||
<select options={[...]} /> // SelectRenderable
|
||||
```
|
||||
|
||||
### Text Modifiers
|
||||
|
||||
Inside `<text>`, use modifier elements:
|
||||
|
||||
```tsx
|
||||
<text>
|
||||
<strong>Bold</strong>, <em>italic</em>, and <u>underlined</u>
|
||||
<span fg="red">Colored text</span>
|
||||
<br />
|
||||
New line with <a href="https://example.com">link</a>
|
||||
</text>
|
||||
```
|
||||
|
||||
### Styling
|
||||
|
||||
Two approaches to styling:
|
||||
|
||||
```tsx
|
||||
// Direct props
|
||||
<box backgroundColor="blue" padding={2} border>
|
||||
<text fg="#00FF00">Green text</text>
|
||||
</box>
|
||||
|
||||
// Style prop
|
||||
<box style={{ backgroundColor: "blue", padding: 2, border: true }}>
|
||||
<text style={{ fg: "#00FF00" }}>Green text</text>
|
||||
</box>
|
||||
```
|
||||
|
||||
## Available Components
|
||||
|
||||
### Layout & Display
|
||||
- `<text>` - Styled text content
|
||||
- `<box>` - Container with borders and layout
|
||||
- `<scrollbox>` - Scrollable container
|
||||
- `<ascii-font>` - ASCII art text
|
||||
|
||||
### Input
|
||||
- `<input>` - Single-line text input
|
||||
- `<textarea>` - Multi-line text input
|
||||
- `<select>` - List selection
|
||||
- `<tab-select>` - Tab-based selection
|
||||
|
||||
### Code & Diff
|
||||
- `<code>` - Syntax-highlighted code
|
||||
- `<line-number>` - Code with line numbers
|
||||
- `<diff>` - Unified or split diff viewer
|
||||
|
||||
### Text Modifiers (inside `<text>`)
|
||||
- `<span>` - Inline styled text
|
||||
- `<strong>`, `<b>` - Bold
|
||||
- `<em>`, `<i>` - Italic
|
||||
- `<u>` - Underline
|
||||
- `<br>` - Line break
|
||||
- `<a>` - Link
|
||||
|
||||
## Essential Hooks
|
||||
|
||||
```tsx
|
||||
import {
|
||||
useRenderer,
|
||||
useKeyboard,
|
||||
useOnResize,
|
||||
useTerminalDimensions,
|
||||
useTimeline,
|
||||
} from "@opentui/react"
|
||||
```
|
||||
|
||||
See [API Reference](./api.md) for detailed hook documentation.
|
||||
|
||||
## In This Reference
|
||||
|
||||
- [Configuration](./configuration.md) - Project setup, tsconfig, bundling
|
||||
- [API](./api.md) - Components, hooks, createRoot
|
||||
- [Patterns](./patterns.md) - State management, keyboard handling, forms
|
||||
- [Gotchas](./gotchas.md) - Common issues, debugging, limitations
|
||||
|
||||
## See Also
|
||||
|
||||
- [Core](../core/REFERENCE.md) - Underlying imperative API
|
||||
- [Solid](../solid/REFERENCE.md) - Alternative declarative approach
|
||||
- [Components](../components/REFERENCE.md) - Component reference by category
|
||||
- [Layout](../layout/REFERENCE.md) - Flexbox layout system
|
||||
- [Keyboard](../keyboard/REFERENCE.md) - Input handling and shortcuts
|
||||
- [Testing](../testing/REFERENCE.md) - Test renderer and snapshots
|
||||
@@ -1,436 +0,0 @@
|
||||
# React API Reference
|
||||
|
||||
## Rendering
|
||||
|
||||
### createRoot(renderer)
|
||||
|
||||
Creates a React root for rendering.
|
||||
|
||||
```tsx
|
||||
import { createCliRenderer } from "@opentui/core"
|
||||
import { createRoot } from "@opentui/react"
|
||||
|
||||
const renderer = await createCliRenderer({
|
||||
exitOnCtrlC: false, // Handle Ctrl+C yourself
|
||||
})
|
||||
|
||||
const root = createRoot(renderer)
|
||||
root.render(<App />)
|
||||
```
|
||||
|
||||
## Hooks
|
||||
|
||||
### useRenderer()
|
||||
|
||||
Access the OpenTUI renderer instance.
|
||||
|
||||
```tsx
|
||||
import { useRenderer } from "@opentui/react"
|
||||
import { useEffect } from "react"
|
||||
|
||||
function App() {
|
||||
const renderer = useRenderer()
|
||||
|
||||
useEffect(() => {
|
||||
// Access renderer properties
|
||||
console.log(`Terminal: ${renderer.width}x${renderer.height}`)
|
||||
|
||||
// Show debug console
|
||||
renderer.console.show()
|
||||
|
||||
// Access theme mode (dark/light based on terminal settings)
|
||||
console.log(`Theme: ${renderer.themeMode}`) // "dark" | "light" | null
|
||||
}, [renderer])
|
||||
|
||||
return <text>Hello</text>
|
||||
}
|
||||
|
||||
// Listen for theme mode changes
|
||||
function ThemedApp() {
|
||||
const renderer = useRenderer()
|
||||
const [theme, setTheme] = useState(renderer.themeMode ?? "dark")
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (mode: "dark" | "light") => setTheme(mode)
|
||||
renderer.on("theme_mode", handler)
|
||||
return () => renderer.off("theme_mode", handler)
|
||||
}, [renderer])
|
||||
|
||||
return (
|
||||
<box backgroundColor={theme === "dark" ? "#1a1a2e" : "#ffffff"}>
|
||||
<text fg={theme === "dark" ? "#fff" : "#000"}>
|
||||
Current theme: {theme}
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### useKeyboard(handler, options?)
|
||||
|
||||
Handle keyboard events.
|
||||
|
||||
```tsx
|
||||
import { useKeyboard, useRenderer } from "@opentui/react"
|
||||
|
||||
function App() {
|
||||
const renderer = useRenderer()
|
||||
|
||||
useKeyboard((key) => {
|
||||
if (key.name === "escape") {
|
||||
renderer.destroy() // Never use process.exit() directly!
|
||||
}
|
||||
if (key.ctrl && key.name === "s") {
|
||||
saveDocument()
|
||||
}
|
||||
})
|
||||
|
||||
return <text>Press ESC to exit</text>
|
||||
}
|
||||
|
||||
// With release events
|
||||
function GameControls() {
|
||||
const [pressed, setPressed] = useState(new Set<string>())
|
||||
|
||||
useKeyboard(
|
||||
(event) => {
|
||||
setPressed(keys => {
|
||||
const newKeys = new Set(keys)
|
||||
if (event.eventType === "release") {
|
||||
newKeys.delete(event.name)
|
||||
} else {
|
||||
newKeys.add(event.name)
|
||||
}
|
||||
return newKeys
|
||||
})
|
||||
},
|
||||
{ release: true } // Include release events
|
||||
)
|
||||
|
||||
return <text>Pressed: {Array.from(pressed).join(", ")}</text>
|
||||
}
|
||||
```
|
||||
|
||||
**Options:**
|
||||
- `release?: boolean` - Include key release events (default: false)
|
||||
|
||||
**KeyEvent properties:**
|
||||
- `name: string` - Key name ("a", "escape", "f1", etc.)
|
||||
- `sequence: string` - Raw escape sequence
|
||||
- `ctrl: boolean` - Ctrl modifier
|
||||
- `shift: boolean` - Shift modifier
|
||||
- `meta: boolean` - Alt modifier
|
||||
- `option: boolean` - Option modifier (macOS)
|
||||
- `eventType: "press" | "release" | "repeat"`
|
||||
- `repeated: boolean` - Key is being held
|
||||
|
||||
### useOnResize(callback)
|
||||
|
||||
Handle terminal resize events.
|
||||
|
||||
```tsx
|
||||
import { useOnResize } from "@opentui/react"
|
||||
|
||||
function App() {
|
||||
useOnResize((width, height) => {
|
||||
console.log(`Resized to ${width}x${height}`)
|
||||
})
|
||||
|
||||
return <text>Resize the terminal</text>
|
||||
}
|
||||
```
|
||||
|
||||
### useTerminalDimensions()
|
||||
|
||||
Get reactive terminal dimensions.
|
||||
|
||||
```tsx
|
||||
import { useTerminalDimensions } from "@opentui/react"
|
||||
|
||||
function ResponsiveLayout() {
|
||||
const { width, height } = useTerminalDimensions()
|
||||
|
||||
return (
|
||||
<box flexDirection={width > 80 ? "row" : "column"}>
|
||||
<box flexGrow={1}>
|
||||
<text>Width: {width}</text>
|
||||
</box>
|
||||
<box flexGrow={1}>
|
||||
<text>Height: {height}</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### useTimeline(options?)
|
||||
|
||||
Create animations with the timeline system.
|
||||
|
||||
```tsx
|
||||
import { useTimeline } from "@opentui/react"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
function AnimatedBox() {
|
||||
const [width, setWidth] = useState(0)
|
||||
|
||||
const timeline = useTimeline({
|
||||
duration: 2000,
|
||||
loop: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
timeline.add(
|
||||
{ width: 0 },
|
||||
{
|
||||
width: 50,
|
||||
duration: 2000,
|
||||
ease: "easeOutQuad",
|
||||
onUpdate: (anim) => {
|
||||
setWidth(Math.round(anim.targets[0].width))
|
||||
},
|
||||
}
|
||||
)
|
||||
}, [timeline])
|
||||
|
||||
return <box style={{ width, height: 3, backgroundColor: "#6a5acd" }} />
|
||||
}
|
||||
```
|
||||
|
||||
**Options:**
|
||||
- `duration?: number` - Default duration (ms)
|
||||
- `loop?: boolean` - Loop the timeline
|
||||
- `autoplay?: boolean` - Auto-start (default: true)
|
||||
- `onComplete?: () => void` - Completion callback
|
||||
- `onPause?: () => void` - Pause callback
|
||||
|
||||
**Timeline methods:**
|
||||
- `add(target, properties, startTime?)` - Add animation
|
||||
- `play()` - Start playback
|
||||
- `pause()` - Pause playback
|
||||
- `restart()` - Restart from beginning
|
||||
|
||||
## Components
|
||||
|
||||
### Text Component
|
||||
|
||||
```tsx
|
||||
<text
|
||||
content="Hello" // Or use children
|
||||
fg="#FFFFFF" // Foreground color
|
||||
bg="#000000" // Background color
|
||||
selectable={true} // Allow text selection
|
||||
>
|
||||
{/* Use nested modifier tags for styling */}
|
||||
<span fg="red">Red</span>
|
||||
<strong>Bold</strong>
|
||||
<em>Italic</em>
|
||||
<u>Underline</u>
|
||||
<br />
|
||||
<a href="https://...">Link</a>
|
||||
</text>
|
||||
```
|
||||
|
||||
> **Note**: Do NOT use `bold`, `italic`, `underline` as props on `<text>`. Use nested modifier tags like `<strong>`, `<em>`, `<u>` instead.
|
||||
|
||||
### Box Component
|
||||
|
||||
```tsx
|
||||
<box
|
||||
// Borders
|
||||
border // Enable border
|
||||
borderStyle="single" // single | double | rounded | bold
|
||||
borderColor="#FFFFFF"
|
||||
title="Title"
|
||||
titleAlignment="center" // left | center | right
|
||||
|
||||
// Colors
|
||||
backgroundColor="#1a1a2e"
|
||||
|
||||
// Layout (see layout/REFERENCE.md)
|
||||
flexDirection="row"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
gap={2}
|
||||
|
||||
// Spacing
|
||||
padding={2}
|
||||
paddingTop={1}
|
||||
paddingX={2} // Horizontal (left + right)
|
||||
paddingY={1} // Vertical (top + bottom)
|
||||
margin={1}
|
||||
marginX={2} // Horizontal (left + right)
|
||||
marginY={1} // Vertical (top + bottom)
|
||||
|
||||
// Dimensions
|
||||
width={40}
|
||||
height={10}
|
||||
flexGrow={1}
|
||||
|
||||
// Focus
|
||||
focusable // Allow box to receive focus
|
||||
focused={isFocused} // Controlled focus state
|
||||
|
||||
// Events
|
||||
onMouseDown={(e) => {}}
|
||||
onMouseUp={(e) => {}}
|
||||
onMouseMove={(e) => {}}
|
||||
>
|
||||
{children}
|
||||
</box>
|
||||
```
|
||||
|
||||
### Scrollbox Component
|
||||
|
||||
```tsx
|
||||
<scrollbox
|
||||
focused // Enable keyboard scrolling
|
||||
style={{
|
||||
rootOptions: { backgroundColor: "#24283b" },
|
||||
wrapperOptions: { backgroundColor: "#1f2335" },
|
||||
viewportOptions: { backgroundColor: "#1a1b26" },
|
||||
contentOptions: { backgroundColor: "#16161e" },
|
||||
scrollbarOptions: {
|
||||
showArrows: true,
|
||||
trackOptions: {
|
||||
foregroundColor: "#7aa2f7",
|
||||
backgroundColor: "#414868",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Scrollable content */}
|
||||
{items.map((item, i) => (
|
||||
<box key={i}>
|
||||
<text>{item}</text>
|
||||
</box>
|
||||
))}
|
||||
</scrollbox>
|
||||
```
|
||||
|
||||
### Input Component
|
||||
|
||||
```tsx
|
||||
<input
|
||||
value={value}
|
||||
onChange={(newValue) => setValue(newValue)}
|
||||
placeholder="Enter text..."
|
||||
focused // Start focused
|
||||
width={30}
|
||||
backgroundColor="#1a1a1a"
|
||||
textColor="#FFFFFF"
|
||||
cursorColor="#00FF00"
|
||||
focusedBackgroundColor="#2a2a2a"
|
||||
/>
|
||||
```
|
||||
|
||||
### Textarea Component
|
||||
|
||||
```tsx
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(newValue) => setText(newValue)}
|
||||
placeholder="Enter multiple lines..."
|
||||
focused
|
||||
width={40}
|
||||
height={10}
|
||||
showLineNumbers
|
||||
wrapText
|
||||
/>
|
||||
```
|
||||
|
||||
### Select Component
|
||||
|
||||
```tsx
|
||||
<select
|
||||
options={[
|
||||
{ name: "Option 1", description: "First option", value: "1" },
|
||||
{ name: "Option 2", description: "Second option", value: "2" },
|
||||
]}
|
||||
onChange={(index, option) => setSelected(option)}
|
||||
selectedIndex={0}
|
||||
focused
|
||||
showScrollIndicator
|
||||
height={8}
|
||||
/>
|
||||
```
|
||||
|
||||
### Tab Select Component
|
||||
|
||||
```tsx
|
||||
<tab-select
|
||||
options={[
|
||||
{ name: "Home", description: "Dashboard" },
|
||||
{ name: "Settings", description: "Configuration" },
|
||||
]}
|
||||
onChange={(index, option) => setTab(option)}
|
||||
tabWidth={20}
|
||||
focused
|
||||
/>
|
||||
```
|
||||
|
||||
### ASCII Font Component
|
||||
|
||||
```tsx
|
||||
<ascii-font
|
||||
text="TITLE"
|
||||
font="tiny" // tiny | block | slick | shade
|
||||
color="#FFFFFF"
|
||||
/>
|
||||
```
|
||||
|
||||
### Code Component
|
||||
|
||||
```tsx
|
||||
<code
|
||||
code={sourceCode}
|
||||
language="typescript"
|
||||
showLineNumbers
|
||||
highlightLines={[1, 5, 10]}
|
||||
/>
|
||||
```
|
||||
|
||||
### Line Number Component
|
||||
|
||||
```tsx
|
||||
<line-number
|
||||
code={sourceCode}
|
||||
language="typescript"
|
||||
startLine={1}
|
||||
highlightedLines={[5]}
|
||||
diagnostics={[
|
||||
{ line: 3, severity: "error", message: "Syntax error" }
|
||||
]}
|
||||
/>
|
||||
```
|
||||
|
||||
### Diff Component
|
||||
|
||||
```tsx
|
||||
<diff
|
||||
oldCode={originalCode}
|
||||
newCode={modifiedCode}
|
||||
language="typescript"
|
||||
mode="unified" // unified | split
|
||||
syncScroll // Sync scroll between split view panes
|
||||
showLineNumbers
|
||||
/>
|
||||
```
|
||||
|
||||
## Type Exports
|
||||
|
||||
```tsx
|
||||
import type {
|
||||
// Component props
|
||||
TextProps,
|
||||
BoxProps,
|
||||
InputProps,
|
||||
SelectProps,
|
||||
|
||||
// Hook types
|
||||
KeyEvent,
|
||||
|
||||
// From core
|
||||
CliRenderer,
|
||||
} from "@opentui/react"
|
||||
```
|
||||
@@ -1,302 +0,0 @@
|
||||
# React Configuration
|
||||
|
||||
## Project Setup
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
bunx create-tui@latest -t react my-app
|
||||
cd my-app && bun install
|
||||
```
|
||||
|
||||
The CLI creates the `my-app` directory for you - it must **not already exist**.
|
||||
|
||||
Options: `--no-git` (skip git init), `--no-install` (skip bun install)
|
||||
|
||||
### Manual Setup
|
||||
|
||||
```bash
|
||||
mkdir my-tui && cd my-tui
|
||||
bun init
|
||||
bun install @opentui/react @opentui/core react
|
||||
```
|
||||
|
||||
## TypeScript Configuration
|
||||
|
||||
### tsconfig.json
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"target": "ESNext",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "@opentui/react",
|
||||
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
```
|
||||
|
||||
**Critical settings:**
|
||||
- `jsx: "react-jsx"` - Use the new JSX transform
|
||||
- `jsxImportSource: "@opentui/react"` - Import JSX runtime from OpenTUI
|
||||
- `module` / `moduleResolution: "NodeNext"` - Recommended for OpenTUI compatibility
|
||||
|
||||
### Why DOM lib?
|
||||
|
||||
The `DOM` lib is needed for React types. OpenTUI's JSX types extend React's.
|
||||
|
||||
## Package Configuration
|
||||
|
||||
### package.json
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-tui-app",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "bun run src/index.tsx",
|
||||
"dev": "bun --watch run src/index.tsx",
|
||||
"test": "bun test",
|
||||
"build": "bun build src/index.tsx --outdir=dist --target=bun"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentui/core": "latest",
|
||||
"@opentui/react": "latest",
|
||||
"react": ">=19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/react": ">=19.0.0",
|
||||
"typescript": "latest"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
Recommended structure:
|
||||
|
||||
```
|
||||
my-tui-app/
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ │ ├── Header.tsx
|
||||
│ │ ├── Sidebar.tsx
|
||||
│ │ └── MainContent.tsx
|
||||
│ ├── hooks/
|
||||
│ │ └── useAppState.ts
|
||||
│ ├── App.tsx
|
||||
│ └── index.tsx
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
### Entry Point (src/index.tsx)
|
||||
|
||||
```tsx
|
||||
import { createCliRenderer } from "@opentui/core"
|
||||
import { createRoot } from "@opentui/react"
|
||||
import { App } from "./App"
|
||||
|
||||
const renderer = await createCliRenderer({
|
||||
exitOnCtrlC: true,
|
||||
})
|
||||
|
||||
createRoot(renderer).render(<App />)
|
||||
```
|
||||
|
||||
### App Component (src/App.tsx)
|
||||
|
||||
```tsx
|
||||
import { Header } from "./components/Header"
|
||||
import { Sidebar } from "./components/Sidebar"
|
||||
import { MainContent } from "./components/MainContent"
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<box flexDirection="column" width="100%" height="100%">
|
||||
<Header />
|
||||
<box flexDirection="row" flexGrow={1}>
|
||||
<Sidebar />
|
||||
<MainContent />
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Renderer Configuration
|
||||
|
||||
### createCliRenderer Options
|
||||
|
||||
```tsx
|
||||
import { createCliRenderer, ConsolePosition } from "@opentui/core"
|
||||
|
||||
const renderer = await createCliRenderer({
|
||||
// Rendering
|
||||
targetFPS: 60,
|
||||
|
||||
// Behavior
|
||||
exitOnCtrlC: true, // Set false to handle Ctrl+C yourself
|
||||
autoFocus: true, // Auto-focus elements on click (default: true)
|
||||
useMouse: true, // Enable mouse support (default: true)
|
||||
|
||||
// Debug console
|
||||
consoleOptions: {
|
||||
position: ConsolePosition.BOTTOM,
|
||||
sizePercent: 30,
|
||||
startInDebugMode: false,
|
||||
},
|
||||
|
||||
// Cleanup
|
||||
onDestroy: () => {
|
||||
// Cleanup code
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Building for Distribution
|
||||
|
||||
### Bundling with Bun
|
||||
|
||||
```typescript
|
||||
// build.ts
|
||||
await Bun.build({
|
||||
entrypoints: ["./src/index.tsx"],
|
||||
outdir: "./dist",
|
||||
target: "bun",
|
||||
minify: true,
|
||||
})
|
||||
```
|
||||
|
||||
Run: `bun run build.ts`
|
||||
|
||||
### Creating Executables
|
||||
|
||||
```typescript
|
||||
// build.ts
|
||||
await Bun.build({
|
||||
entrypoints: ["./src/index.tsx"],
|
||||
outdir: "./dist",
|
||||
target: "bun",
|
||||
compile: {
|
||||
target: "bun-darwin-arm64", // or bun-linux-x64, etc.
|
||||
outfile: "my-app",
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Create `.env` for development:
|
||||
|
||||
```env
|
||||
# Debug settings
|
||||
OTUI_SHOW_STATS=false
|
||||
SHOW_CONSOLE=false
|
||||
|
||||
# App settings
|
||||
API_URL=https://api.example.com
|
||||
```
|
||||
|
||||
Bun auto-loads `.env` files. Access via `process.env`:
|
||||
|
||||
```tsx
|
||||
const apiUrl = process.env.API_URL
|
||||
```
|
||||
|
||||
## React DevTools
|
||||
|
||||
OpenTUI React supports React DevTools for debugging.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Install DevTools as a dev dependency (must use version 7):
|
||||
```bash
|
||||
bun add react-devtools-core@7 -d
|
||||
```
|
||||
|
||||
2. Run DevTools standalone app:
|
||||
```bash
|
||||
npx react-devtools@7
|
||||
```
|
||||
|
||||
3. Start your app with `DEV=true` environment variable:
|
||||
```bash
|
||||
DEV=true bun run src/index.tsx
|
||||
```
|
||||
|
||||
**Important**: Auto-connect to DevTools ONLY happens when `DEV=true` is set. Without this environment variable, the DevTools connection code is not loaded.
|
||||
|
||||
### How It Works
|
||||
|
||||
OpenTUI checks for `process.env["DEV"] === "true"` at startup. When true, it dynamically imports `react-devtools-core` and connects to the standalone DevTools app.
|
||||
|
||||
## Testing Configuration
|
||||
|
||||
### Test Setup
|
||||
|
||||
```typescript
|
||||
// src/test-utils.tsx
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { createRoot } from "@opentui/react"
|
||||
|
||||
export async function renderForTest(
|
||||
element: React.ReactElement,
|
||||
options = { width: 80, height: 24 }
|
||||
) {
|
||||
const testSetup = await createTestRenderer(options)
|
||||
createRoot(testSetup.renderer).render(element)
|
||||
return testSetup
|
||||
}
|
||||
```
|
||||
|
||||
### Test Example
|
||||
|
||||
```typescript
|
||||
// src/components/Counter.test.tsx
|
||||
import { test, expect } from "bun:test"
|
||||
import { renderForTest } from "../test-utils"
|
||||
import { Counter } from "./Counter"
|
||||
|
||||
test("Counter renders initial value", async () => {
|
||||
const { snapshot } = await renderForTest(<Counter initialValue={5} />)
|
||||
expect(snapshot()).toContain("Count: 5")
|
||||
})
|
||||
```
|
||||
|
||||
## Common Issues
|
||||
|
||||
### JSX Types Not Working
|
||||
|
||||
Ensure `jsxImportSource` is set:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "@opentui/react"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### React Version Mismatch
|
||||
|
||||
Ensure React 19+:
|
||||
|
||||
```bash
|
||||
bun install react@19 @types/react@19
|
||||
```
|
||||
|
||||
### Module Resolution Errors
|
||||
|
||||
Use `moduleResolution: "bundler"` for Bun compatibility.
|
||||
@@ -1,443 +0,0 @@
|
||||
# React Gotchas
|
||||
|
||||
## Critical
|
||||
|
||||
### Never use `process.exit()` directly
|
||||
|
||||
**This is the most common mistake.** Using `process.exit()` leaves the terminal in a broken state (cursor hidden, raw mode, alternate screen).
|
||||
|
||||
```tsx
|
||||
// WRONG - Terminal left in broken state
|
||||
process.exit(0)
|
||||
|
||||
// CORRECT - Use renderer.destroy()
|
||||
import { useRenderer } from "@opentui/react"
|
||||
|
||||
function App() {
|
||||
const renderer = useRenderer()
|
||||
|
||||
const handleExit = () => {
|
||||
renderer.destroy() // Cleans up and exits properly
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`renderer.destroy()` restores the terminal (exits alternate screen, restores cursor, etc.) before exiting.
|
||||
|
||||
### Signal Handling
|
||||
|
||||
OpenTUI automatically handles cleanup for these signals:
|
||||
- `SIGINT` (Ctrl+C), `SIGTERM`, `SIGQUIT` - Standard termination
|
||||
- `SIGHUP` - Terminal closed/hangup
|
||||
- `SIGBREAK` - Ctrl+Break (Windows)
|
||||
- `SIGPIPE` - Broken pipe (output closed)
|
||||
- `SIGBUS`, `SIGFPE` - Hardware errors
|
||||
|
||||
This ensures terminal state is restored even on unexpected termination. If you need custom signal handling, use `exitOnCtrlC: false` and handle signals yourself while still calling `renderer.destroy()`.
|
||||
|
||||
## JSX Configuration
|
||||
|
||||
### Missing jsxImportSource
|
||||
|
||||
**Symptom**: JSX elements have wrong types, components don't render
|
||||
|
||||
```
|
||||
// Error: Property 'text' does not exist on type 'JSX.IntrinsicElements'
|
||||
```
|
||||
|
||||
**Fix**: Configure tsconfig.json:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "@opentui/react"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### HTML Elements vs TUI Elements
|
||||
|
||||
OpenTUI's JSX elements are **not** HTML elements:
|
||||
|
||||
```tsx
|
||||
// WRONG - These are HTML concepts
|
||||
<div>Not supported</div>
|
||||
<button>Not supported</button>
|
||||
<span>Only works inside <text></span>
|
||||
|
||||
// CORRECT - OpenTUI elements
|
||||
<box>Container</box>
|
||||
<text>Display text</text>
|
||||
<text><span>Inline styled</span></text>
|
||||
```
|
||||
|
||||
## Component Issues
|
||||
|
||||
### Text Modifiers Outside Text
|
||||
|
||||
Text modifiers only work inside `<text>`:
|
||||
|
||||
```tsx
|
||||
// WRONG
|
||||
<box>
|
||||
<strong>This won't work</strong>
|
||||
</box>
|
||||
|
||||
// CORRECT
|
||||
<box>
|
||||
<text>
|
||||
<strong>This works</strong>
|
||||
</text>
|
||||
</box>
|
||||
```
|
||||
|
||||
### Focus Not Working
|
||||
|
||||
Components must be explicitly focused:
|
||||
|
||||
```tsx
|
||||
// WRONG - Won't receive keyboard input
|
||||
<input placeholder="Type here..." />
|
||||
|
||||
// CORRECT
|
||||
<input placeholder="Type here..." focused />
|
||||
|
||||
// Or manage focus state
|
||||
const [isFocused, setIsFocused] = useState(true)
|
||||
<input placeholder="Type here..." focused={isFocused} />
|
||||
```
|
||||
|
||||
### Select Not Responding
|
||||
|
||||
Select requires focus and proper options format:
|
||||
|
||||
```tsx
|
||||
// WRONG - Missing required properties
|
||||
<select options={["a", "b", "c"]} />
|
||||
|
||||
// CORRECT
|
||||
<select
|
||||
options={[
|
||||
{ name: "Option A", description: "First option", value: "a" },
|
||||
{ name: "Option B", description: "Second option", value: "b" },
|
||||
]}
|
||||
onSelect={(index, option) => {
|
||||
// Called when Enter is pressed
|
||||
console.log("Selected:", option.name)
|
||||
}}
|
||||
focused
|
||||
/>
|
||||
```
|
||||
|
||||
### Select Events Confusion
|
||||
|
||||
Remember: `onSelect` fires on Enter (selection confirmed), `onChange` fires on navigation:
|
||||
|
||||
```tsx
|
||||
// WRONG - expecting onChange to fire on Enter
|
||||
<select
|
||||
options={options}
|
||||
onChange={(i, opt) => submitForm(opt)} // This fires on arrow keys!
|
||||
/>
|
||||
|
||||
// CORRECT
|
||||
<select
|
||||
options={options}
|
||||
onSelect={(i, opt) => submitForm(opt)} // Enter pressed - submit
|
||||
onChange={(i, opt) => showPreview(opt)} // Arrow keys - preview
|
||||
/>
|
||||
```
|
||||
|
||||
## Hook Issues
|
||||
|
||||
### useKeyboard Not Firing
|
||||
|
||||
Multiple `useKeyboard` hooks can conflict:
|
||||
|
||||
```tsx
|
||||
// Both handlers fire - may cause issues
|
||||
function App() {
|
||||
useKeyboard((key) => { /* parent handler */ })
|
||||
return <ChildWithKeyboard />
|
||||
}
|
||||
|
||||
function ChildWithKeyboard() {
|
||||
useKeyboard((key) => { /* child handler */ })
|
||||
return <text>Child</text>
|
||||
}
|
||||
```
|
||||
|
||||
**Solution**: Use a single keyboard handler or implement event stopping:
|
||||
|
||||
```tsx
|
||||
function App() {
|
||||
const [handled, setHandled] = useState(false)
|
||||
|
||||
useKeyboard((key) => {
|
||||
if (handled) {
|
||||
setHandled(false)
|
||||
return
|
||||
}
|
||||
// Handle at app level
|
||||
})
|
||||
|
||||
return <Child onKeyHandled={() => setHandled(true)} />
|
||||
}
|
||||
```
|
||||
|
||||
### useEffect Cleanup
|
||||
|
||||
Always clean up intervals and listeners:
|
||||
|
||||
```tsx
|
||||
// WRONG - Memory leak
|
||||
useEffect(() => {
|
||||
setInterval(() => updateData(), 1000)
|
||||
}, [])
|
||||
|
||||
// CORRECT
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => updateData(), 1000)
|
||||
return () => clearInterval(interval) // Cleanup!
|
||||
}, [])
|
||||
```
|
||||
|
||||
## Styling Issues
|
||||
|
||||
### Colors Not Applying
|
||||
|
||||
Check color format:
|
||||
|
||||
```tsx
|
||||
// CORRECT formats
|
||||
<text fg="#FF0000">Red</text>
|
||||
<text fg="red">Red</text>
|
||||
<box backgroundColor="#1a1a2e">Box</box>
|
||||
|
||||
// WRONG
|
||||
<text fg="FF0000">Missing #</text>
|
||||
<text color="#FF0000">Wrong prop name (use fg)</text>
|
||||
```
|
||||
|
||||
### Layout Not Working
|
||||
|
||||
Ensure parent has dimensions:
|
||||
|
||||
```tsx
|
||||
// WRONG - Parent has no height
|
||||
<box flexDirection="column">
|
||||
<box flexGrow={1}>Won't grow</box>
|
||||
</box>
|
||||
|
||||
// CORRECT
|
||||
<box flexDirection="column" height="100%">
|
||||
<box flexGrow={1}>Will grow</box>
|
||||
</box>
|
||||
```
|
||||
|
||||
### Percentage Widths Not Working
|
||||
|
||||
Parent must have explicit dimensions:
|
||||
|
||||
```tsx
|
||||
// WRONG
|
||||
<box>
|
||||
<box width="50%">Won't work</box>
|
||||
</box>
|
||||
|
||||
// CORRECT
|
||||
<box width="100%">
|
||||
<box width="50%">Works</box>
|
||||
</box>
|
||||
```
|
||||
|
||||
## Performance Issues
|
||||
|
||||
### Too Many Re-renders
|
||||
|
||||
Avoid inline objects/functions in props:
|
||||
|
||||
```tsx
|
||||
// WRONG - New object every render
|
||||
<box style={{ padding: 2 }}>Content</box>
|
||||
|
||||
// BETTER - Use direct props
|
||||
<box padding={2}>Content</box>
|
||||
|
||||
// OR memoize style objects
|
||||
const style = useMemo(() => ({ padding: 2 }), [])
|
||||
<box style={style}>Content</box>
|
||||
```
|
||||
|
||||
### Heavy Components
|
||||
|
||||
Use React.memo for expensive components:
|
||||
|
||||
```tsx
|
||||
const ExpensiveList = React.memo(function ExpensiveList({
|
||||
items
|
||||
}: {
|
||||
items: Item[]
|
||||
}) {
|
||||
return (
|
||||
<box flexDirection="column">
|
||||
{items.map(item => (
|
||||
<text key={item.id}>{item.name}</text>
|
||||
))}
|
||||
</box>
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
### State Updates During Render
|
||||
|
||||
Don't update state during render:
|
||||
|
||||
```tsx
|
||||
// WRONG
|
||||
function Component({ value }: { value: number }) {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
// This causes infinite loop!
|
||||
if (value > 10) {
|
||||
setCount(value)
|
||||
}
|
||||
|
||||
return <text>{count}</text>
|
||||
}
|
||||
|
||||
// CORRECT
|
||||
function Component({ value }: { value: number }) {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (value > 10) {
|
||||
setCount(value)
|
||||
}
|
||||
}, [value])
|
||||
|
||||
return <text>{count}</text>
|
||||
}
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
### Console Not Visible
|
||||
|
||||
OpenTUI captures console output. Show the overlay:
|
||||
|
||||
```tsx
|
||||
import { useRenderer } from "@opentui/react"
|
||||
import { useEffect } from "react"
|
||||
|
||||
function App() {
|
||||
const renderer = useRenderer()
|
||||
|
||||
useEffect(() => {
|
||||
renderer.console.show()
|
||||
console.log("Now you can see this!")
|
||||
}, [renderer])
|
||||
|
||||
return <box>{/* ... */}</box>
|
||||
}
|
||||
```
|
||||
|
||||
### Component Not Rendering
|
||||
|
||||
Check if component is in the tree:
|
||||
|
||||
```tsx
|
||||
// WRONG - Conditional returns nothing
|
||||
function MaybeComponent({ show }: { show: boolean }) {
|
||||
if (!show) return // Returns undefined!
|
||||
return <text>Visible</text>
|
||||
}
|
||||
|
||||
// CORRECT
|
||||
function MaybeComponent({ show }: { show: boolean }) {
|
||||
if (!show) return null // Explicit null
|
||||
return <text>Visible</text>
|
||||
}
|
||||
```
|
||||
|
||||
### Events Not Firing
|
||||
|
||||
Check event handler names:
|
||||
|
||||
```tsx
|
||||
// WRONG
|
||||
<box onClick={() => {}}>Click</box> // No onClick in TUI
|
||||
|
||||
// CORRECT
|
||||
<box onMouseDown={() => {}}>Click</box>
|
||||
<box onMouseUp={() => {}}>Click</box>
|
||||
```
|
||||
|
||||
## Runtime Issues
|
||||
|
||||
### Use Bun, Not Node
|
||||
|
||||
```bash
|
||||
# WRONG
|
||||
node src/index.tsx
|
||||
npm run start
|
||||
|
||||
# CORRECT
|
||||
bun run src/index.tsx
|
||||
bun run start
|
||||
```
|
||||
|
||||
### Async Top-level
|
||||
|
||||
Bun supports top-level await, but be careful:
|
||||
|
||||
```tsx
|
||||
// index.tsx - This works in Bun
|
||||
const renderer = await createCliRenderer()
|
||||
createRoot(renderer).render(<App />)
|
||||
|
||||
// If you need to handle errors
|
||||
try {
|
||||
const renderer = await createCliRenderer()
|
||||
createRoot(renderer).render(<App />)
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize:", error)
|
||||
process.exit(1)
|
||||
}
|
||||
```
|
||||
|
||||
## Common Error Messages
|
||||
|
||||
### "Cannot read properties of undefined (reading 'root')"
|
||||
|
||||
Renderer not initialized:
|
||||
|
||||
```tsx
|
||||
// WRONG
|
||||
const renderer = createCliRenderer() // Missing await!
|
||||
createRoot(renderer).render(<App />)
|
||||
|
||||
// CORRECT
|
||||
const renderer = await createCliRenderer()
|
||||
createRoot(renderer).render(<App />)
|
||||
```
|
||||
|
||||
### "Invalid hook call"
|
||||
|
||||
Hooks called outside component:
|
||||
|
||||
```tsx
|
||||
// WRONG
|
||||
const dimensions = useTerminalDimensions() // Outside component!
|
||||
|
||||
function App() {
|
||||
return <text>{dimensions.width}</text>
|
||||
}
|
||||
|
||||
// CORRECT
|
||||
function App() {
|
||||
const dimensions = useTerminalDimensions()
|
||||
return <text>{dimensions.width}</text>
|
||||
}
|
||||
```
|
||||
@@ -1,501 +0,0 @@
|
||||
# React Patterns
|
||||
|
||||
## State Management
|
||||
|
||||
### Local State with useState
|
||||
|
||||
```tsx
|
||||
import { useState } from "react"
|
||||
|
||||
function Counter() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
return (
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text>Count: {count}</text>
|
||||
<box border onMouseDown={() => setCount(c => c - 1)}>
|
||||
<text>-</text>
|
||||
</box>
|
||||
<box border onMouseDown={() => setCount(c => c + 1)}>
|
||||
<text>+</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Complex State with useReducer
|
||||
|
||||
```tsx
|
||||
import { useReducer } from "react"
|
||||
|
||||
type State = {
|
||||
items: string[]
|
||||
selectedIndex: number
|
||||
}
|
||||
|
||||
type Action =
|
||||
| { type: "ADD_ITEM"; item: string }
|
||||
| { type: "REMOVE_ITEM"; index: number }
|
||||
| { type: "SELECT"; index: number }
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "ADD_ITEM":
|
||||
return { ...state, items: [...state.items, action.item] }
|
||||
case "REMOVE_ITEM":
|
||||
return {
|
||||
...state,
|
||||
items: state.items.filter((_, i) => i !== action.index),
|
||||
}
|
||||
case "SELECT":
|
||||
return { ...state, selectedIndex: action.index }
|
||||
}
|
||||
}
|
||||
|
||||
function ItemList() {
|
||||
const [state, dispatch] = useReducer(reducer, {
|
||||
items: [],
|
||||
selectedIndex: 0,
|
||||
})
|
||||
|
||||
// Use state and dispatch...
|
||||
}
|
||||
```
|
||||
|
||||
### Context for Global State
|
||||
|
||||
```tsx
|
||||
import { createContext, useContext, useState, ReactNode } from "react"
|
||||
|
||||
type Theme = "dark" | "light"
|
||||
|
||||
const ThemeContext = createContext<{
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
} | null>(null)
|
||||
|
||||
function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>("dark")
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function useTheme() {
|
||||
const context = useContext(ThemeContext)
|
||||
if (!context) throw new Error("useTheme must be used within ThemeProvider")
|
||||
return context
|
||||
}
|
||||
|
||||
// Usage
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<ThemedBox />
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function ThemedBox() {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<box backgroundColor={theme === "dark" ? "#1a1a2e" : "#f0f0f0"}>
|
||||
<text fg={theme === "dark" ? "#fff" : "#000"}>
|
||||
Current theme: {theme}
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Focus Management
|
||||
|
||||
### Focus State
|
||||
|
||||
```tsx
|
||||
import { useState } from "react"
|
||||
import { useKeyboard } from "@opentui/react"
|
||||
|
||||
function FocusableForm() {
|
||||
const [focusIndex, setFocusIndex] = useState(0)
|
||||
const fields = ["name", "email", "message"]
|
||||
|
||||
useKeyboard((key) => {
|
||||
if (key.name === "tab") {
|
||||
setFocusIndex(i => (i + 1) % fields.length)
|
||||
}
|
||||
if (key.shift && key.name === "tab") {
|
||||
setFocusIndex(i => (i - 1 + fields.length) % fields.length)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
{fields.map((field, i) => (
|
||||
<input
|
||||
key={field}
|
||||
placeholder={`Enter ${field}...`}
|
||||
focused={i === focusIndex}
|
||||
/>
|
||||
))}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Ref-based Focus
|
||||
|
||||
```tsx
|
||||
import { useRef, useEffect } from "react"
|
||||
|
||||
function AutoFocusInput() {
|
||||
const inputRef = useRef<any>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Focus on mount
|
||||
inputRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
return <input ref={inputRef} placeholder="Auto-focused" />
|
||||
}
|
||||
```
|
||||
|
||||
## Keyboard Navigation
|
||||
|
||||
### Global Shortcuts
|
||||
|
||||
```tsx
|
||||
import { useKeyboard, useRenderer } from "@opentui/react"
|
||||
|
||||
function App() {
|
||||
const renderer = useRenderer()
|
||||
|
||||
useKeyboard((key) => {
|
||||
// Quit on Escape or Ctrl+C - use renderer.destroy(), never process.exit()
|
||||
if (key.name === "escape" || (key.ctrl && key.name === "c")) {
|
||||
renderer.destroy()
|
||||
return
|
||||
}
|
||||
|
||||
// Toggle help on ?
|
||||
if (key.name === "?" || (key.shift && key.name === "/")) {
|
||||
setShowHelp(h => !h)
|
||||
}
|
||||
|
||||
// Vim-style navigation
|
||||
if (key.name === "j") moveDown()
|
||||
if (key.name === "k") moveUp()
|
||||
})
|
||||
|
||||
return <box>{/* ... */}</box>
|
||||
}
|
||||
```
|
||||
|
||||
### Component-level Shortcuts
|
||||
|
||||
```tsx
|
||||
function Editor() {
|
||||
const [mode, setMode] = useState<"normal" | "insert">("normal")
|
||||
|
||||
useKeyboard((key) => {
|
||||
if (mode === "normal") {
|
||||
if (key.name === "i") setMode("insert")
|
||||
if (key.name === "escape") setMode("normal")
|
||||
} else {
|
||||
if (key.name === "escape") setMode("normal")
|
||||
// Handle text input in insert mode
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box>
|
||||
<text>Mode: {mode}</text>
|
||||
<textarea focused={mode === "insert"} />
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Form Handling
|
||||
|
||||
### Controlled Inputs
|
||||
|
||||
```tsx
|
||||
import { useState } from "react"
|
||||
|
||||
function LoginForm() {
|
||||
const [username, setUsername] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
|
||||
const handleSubmit = () => {
|
||||
console.log("Login:", { username, password })
|
||||
}
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1} padding={2} border>
|
||||
<text>Login</text>
|
||||
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text>Username:</text>
|
||||
<input
|
||||
value={username}
|
||||
onChange={setUsername}
|
||||
width={20}
|
||||
/>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text>Password:</text>
|
||||
<input
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
width={20}
|
||||
/>
|
||||
</box>
|
||||
|
||||
<box border onMouseDown={handleSubmit}>
|
||||
<text>Submit</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Form Validation
|
||||
|
||||
```tsx
|
||||
function ValidatedForm() {
|
||||
const [email, setEmail] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
|
||||
const validateEmail = (value: string) => {
|
||||
if (!value.includes("@")) {
|
||||
setError("Invalid email address")
|
||||
} else {
|
||||
setError("")
|
||||
}
|
||||
setEmail(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<input
|
||||
value={email}
|
||||
onChange={validateEmail}
|
||||
placeholder="Email"
|
||||
/>
|
||||
{error && <text fg="red">{error}</text>}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Responsive Design
|
||||
|
||||
### Terminal-size Responsive
|
||||
|
||||
```tsx
|
||||
import { useTerminalDimensions } from "@opentui/react"
|
||||
|
||||
function ResponsiveLayout() {
|
||||
const { width } = useTerminalDimensions()
|
||||
|
||||
// Stack vertically on narrow terminals
|
||||
const isNarrow = width < 80
|
||||
|
||||
return (
|
||||
<box flexDirection={isNarrow ? "column" : "row"}>
|
||||
<box flexGrow={isNarrow ? 0 : 1} height={isNarrow ? 10 : "100%"}>
|
||||
<text>Sidebar</text>
|
||||
</box>
|
||||
<box flexGrow={1}>
|
||||
<text>Main Content</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Dynamic Layouts
|
||||
|
||||
```tsx
|
||||
function DynamicGrid({ items }: { items: string[] }) {
|
||||
const { width } = useTerminalDimensions()
|
||||
const columns = Math.max(1, Math.floor(width / 20))
|
||||
|
||||
return (
|
||||
<box flexDirection="row" flexWrap="wrap">
|
||||
{items.map((item, i) => (
|
||||
<box key={i} width={`${100 / columns}%`} padding={1}>
|
||||
<text>{item}</text>
|
||||
</box>
|
||||
))}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Async Data Loading
|
||||
|
||||
### Loading States
|
||||
|
||||
```tsx
|
||||
import { useState, useEffect } from "react"
|
||||
|
||||
function DataDisplay() {
|
||||
const [data, setData] = useState<string[] | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const response = await fetch("https://api.example.com/data")
|
||||
const json = await response.json()
|
||||
setData(json.items)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Unknown error")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return <text>Loading...</text>
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <text fg="red">Error: {error}</text>
|
||||
}
|
||||
|
||||
return (
|
||||
<box flexDirection="column">
|
||||
{data?.map((item, i) => (
|
||||
<text key={i}>{item}</text>
|
||||
))}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Animation Patterns
|
||||
|
||||
### Simple Animations
|
||||
|
||||
```tsx
|
||||
import { useState, useEffect } from "react"
|
||||
import { useTimeline } from "@opentui/react"
|
||||
|
||||
function ProgressBar() {
|
||||
const [progress, setProgress] = useState(0)
|
||||
|
||||
const timeline = useTimeline({ duration: 3000 })
|
||||
|
||||
useEffect(() => {
|
||||
timeline.add(
|
||||
{ value: 0 },
|
||||
{
|
||||
value: 100,
|
||||
duration: 3000,
|
||||
ease: "linear",
|
||||
onUpdate: (anim) => {
|
||||
setProgress(Math.round(anim.targets[0].value))
|
||||
},
|
||||
}
|
||||
)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text>Progress: {progress}%</text>
|
||||
<box width={50} height={1} backgroundColor="#333">
|
||||
<box
|
||||
width={`${progress}%`}
|
||||
height={1}
|
||||
backgroundColor="#00ff00"
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Interval-based Updates
|
||||
|
||||
```tsx
|
||||
function Clock() {
|
||||
const [time, setTime] = useState(new Date())
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setTime(new Date())
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
return <text>{time.toLocaleTimeString()}</text>
|
||||
}
|
||||
```
|
||||
|
||||
## Component Composition
|
||||
|
||||
### Render Props
|
||||
|
||||
```tsx
|
||||
function Focusable({
|
||||
children
|
||||
}: {
|
||||
children: (focused: boolean) => React.ReactNode
|
||||
}) {
|
||||
const [focused, setFocused] = useState(false)
|
||||
|
||||
return (
|
||||
<box
|
||||
onMouseDown={() => setFocused(true)}
|
||||
onMouseUp={() => setFocused(false)}
|
||||
>
|
||||
{children(focused)}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
// Usage
|
||||
<Focusable>
|
||||
{(focused) => (
|
||||
<text fg={focused ? "#00ff00" : "#ffffff"}>
|
||||
{focused ? "Focused!" : "Click me"}
|
||||
</text>
|
||||
)}
|
||||
</Focusable>
|
||||
```
|
||||
|
||||
### Higher-Order Components
|
||||
|
||||
```tsx
|
||||
function withBorder<P extends object>(
|
||||
Component: React.ComponentType<P>,
|
||||
borderStyle: string = "single"
|
||||
) {
|
||||
return function BorderedComponent(props: P) {
|
||||
return (
|
||||
<box border borderStyle={borderStyle} padding={1}>
|
||||
<Component {...props} />
|
||||
</box>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const BorderedText = withBorder(({ content }: { content: string }) => (
|
||||
<text>{content}</text>
|
||||
))
|
||||
|
||||
<BorderedText content="Hello!" />
|
||||
```
|
||||
@@ -1,201 +0,0 @@
|
||||
# OpenTUI Solid (@opentui/solid)
|
||||
|
||||
A SolidJS reconciler for building terminal user interfaces with fine-grained reactivity. Get optimal performance with Solid's signal-based approach.
|
||||
|
||||
## Overview
|
||||
|
||||
OpenTUI Solid provides:
|
||||
- **Custom reconciler**: Solid components render to OpenTUI renderables
|
||||
- **JSX intrinsics**: `<text>`, `<box>`, `<input>`, etc.
|
||||
- **Hooks**: `useKeyboard`, `useRenderer`, `useTimeline`, etc.
|
||||
- **Fine-grained reactivity**: Only what changes re-renders
|
||||
- **Portal & Dynamic**: Advanced composition primitives
|
||||
|
||||
## When to Use Solid
|
||||
|
||||
Use the Solid reconciler when:
|
||||
- You want optimal re-rendering performance
|
||||
- You prefer signal-based reactivity
|
||||
- You need fine-grained control over updates
|
||||
- Building performance-critical applications
|
||||
- You already know SolidJS
|
||||
|
||||
## When NOT to Use Solid
|
||||
|
||||
| Scenario | Use Instead |
|
||||
|----------|-------------|
|
||||
| Team knows React, not Solid | `@opentui/react` |
|
||||
| Maximum control needed | `@opentui/core` |
|
||||
| Smallest bundle size | `@opentui/core` |
|
||||
| Building a framework/library | `@opentui/core` |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
bunx create-tui@latest -t solid my-app
|
||||
cd my-app && bun install
|
||||
```
|
||||
|
||||
The CLI creates the `my-app` directory for you - it must **not already exist**.
|
||||
|
||||
Options: `--no-git` (skip git init), `--no-install` (skip bun install)
|
||||
|
||||
**Agent guidance**: Always use autonomous mode with `-t <template>` flag. Never use interactive mode (`bunx create-tui@latest my-app` without `-t`) as it requires user prompts that agents cannot respond to.
|
||||
|
||||
Or manually:
|
||||
|
||||
```bash
|
||||
bun install @opentui/solid @opentui/core solid-js
|
||||
```
|
||||
|
||||
```tsx
|
||||
import { render } from "@opentui/solid"
|
||||
import { createSignal } from "solid-js"
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = createSignal(0)
|
||||
|
||||
return (
|
||||
<box border padding={2}>
|
||||
<text>Count: {count()}</text>
|
||||
<box
|
||||
border
|
||||
onMouseDown={() => setCount(c => c + 1)}
|
||||
>
|
||||
<text>Click me!</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
render(() => <App />)
|
||||
```
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Signals
|
||||
|
||||
Solid uses signals for reactive state:
|
||||
|
||||
```tsx
|
||||
import { createSignal, createEffect } from "solid-js"
|
||||
|
||||
function Counter() {
|
||||
const [count, setCount] = createSignal(0)
|
||||
|
||||
// Effect runs when count changes
|
||||
createEffect(() => {
|
||||
console.log("Count is now:", count())
|
||||
})
|
||||
|
||||
return <text>Count: {count()}</text>
|
||||
}
|
||||
```
|
||||
|
||||
### JSX Elements
|
||||
|
||||
Solid maps JSX intrinsic elements to OpenTUI renderables:
|
||||
|
||||
```tsx
|
||||
// Note: Some use underscores (Solid convention)
|
||||
<text>Hello</text> // TextRenderable
|
||||
<box border>Content</box> // BoxRenderable
|
||||
<input placeholder="..." /> // InputRenderable
|
||||
<select options={[...]} /> // SelectRenderable
|
||||
<tab_select /> // TabSelectRenderable (underscore!)
|
||||
<ascii_font /> // ASCIIFontRenderable (underscore!)
|
||||
<line_number /> // LineNumberRenderable (underscore!)
|
||||
```
|
||||
|
||||
### Text Modifiers
|
||||
|
||||
Inside `<text>`, use modifier elements:
|
||||
|
||||
```tsx
|
||||
<text>
|
||||
<strong>Bold</strong>, <em>italic</em>, and <u>underlined</u>
|
||||
<span fg="red">Colored text</span>
|
||||
<br />
|
||||
New line with <a href="https://example.com">link</a>
|
||||
</text>
|
||||
```
|
||||
|
||||
## Available Components
|
||||
|
||||
### Layout & Display
|
||||
- `<text>` - Styled text content
|
||||
- `<box>` - Container with borders and layout
|
||||
- `<scrollbox>` - Scrollable container
|
||||
- `<ascii_font>` - ASCII art text (note underscore)
|
||||
|
||||
### Input
|
||||
- `<input>` - Single-line text input
|
||||
- `<textarea>` - Multi-line text input
|
||||
- `<select>` - List selection
|
||||
- `<tab_select>` - Tab-based selection (note underscore)
|
||||
|
||||
### Code & Diff
|
||||
- `<code>` - Syntax-highlighted code
|
||||
- `<line_number>` - Code with line numbers (note underscore)
|
||||
- `<diff>` - Unified or split diff viewer
|
||||
|
||||
### Text Modifiers (inside `<text>`)
|
||||
- `<span>` - Inline styled text
|
||||
- `<strong>`, `<b>` - Bold
|
||||
- `<em>`, `<i>` - Italic
|
||||
- `<u>` - Underline
|
||||
- `<br>` - Line break
|
||||
- `<a>` - Link
|
||||
|
||||
## Special Components
|
||||
|
||||
### Portal
|
||||
|
||||
Render children to a different mount node:
|
||||
|
||||
```tsx
|
||||
import { Portal } from "@opentui/solid"
|
||||
|
||||
function Overlay() {
|
||||
return (
|
||||
<Portal mount={renderer.root}>
|
||||
<box position="absolute" left={10} top={5} border>
|
||||
<text>Overlay content</text>
|
||||
</box>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Dynamic
|
||||
|
||||
Render components dynamically:
|
||||
|
||||
```tsx
|
||||
import { Dynamic } from "@opentui/solid"
|
||||
|
||||
function DynamicInput(props: { multiline: boolean }) {
|
||||
return (
|
||||
<Dynamic
|
||||
component={props.multiline ? "textarea" : "input"}
|
||||
placeholder="Enter text..."
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## In This Reference
|
||||
|
||||
- [Configuration](./configuration.md) - Project setup, tsconfig, bunfig, building
|
||||
- [API](./api.md) - Components, hooks, render function
|
||||
- [Patterns](./patterns.md) - Signals, stores, control flow, composition
|
||||
- [Gotchas](./gotchas.md) - Common issues, debugging, limitations
|
||||
|
||||
## See Also
|
||||
|
||||
- [Core](../core/REFERENCE.md) - Underlying imperative API
|
||||
- [React](../react/REFERENCE.md) - Alternative declarative approach
|
||||
- [Components](../components/REFERENCE.md) - Component reference by category
|
||||
- [Layout](../layout/REFERENCE.md) - Flexbox layout system
|
||||
- [Keyboard](../keyboard/REFERENCE.md) - Input handling and shortcuts
|
||||
- [Testing](../testing/REFERENCE.md) - Test renderer and snapshots
|
||||
@@ -1,564 +0,0 @@
|
||||
# Solid API Reference
|
||||
|
||||
## Rendering
|
||||
|
||||
### render(node, rendererOrConfig?)
|
||||
|
||||
Renders a Solid component tree into a CLI renderer.
|
||||
|
||||
```tsx
|
||||
import { render } from "@opentui/solid"
|
||||
|
||||
// Simple usage - creates renderer automatically
|
||||
render(() => <App />)
|
||||
|
||||
// With config
|
||||
render(() => <App />, {
|
||||
exitOnCtrlC: false,
|
||||
targetFPS: 60,
|
||||
})
|
||||
|
||||
// With existing renderer
|
||||
import { createCliRenderer } from "@opentui/core"
|
||||
|
||||
const renderer = await createCliRenderer()
|
||||
render(() => <App />, renderer)
|
||||
```
|
||||
|
||||
### testRender(node, options?)
|
||||
|
||||
Create a test renderer for snapshots and tests.
|
||||
|
||||
```tsx
|
||||
import { testRender } from "@opentui/solid"
|
||||
|
||||
const testSetup = await testRender(() => <App />, {
|
||||
width: 40,
|
||||
height: 10,
|
||||
})
|
||||
|
||||
// Access test utilities
|
||||
testSetup.snapshot() // Get current render
|
||||
testSetup.renderer // Access renderer
|
||||
```
|
||||
|
||||
### extend(components)
|
||||
|
||||
Register custom renderables as JSX intrinsic elements.
|
||||
|
||||
```tsx
|
||||
import { extend } from "@opentui/solid"
|
||||
import { CustomRenderable } from "./custom"
|
||||
|
||||
extend({
|
||||
custom: CustomRenderable,
|
||||
})
|
||||
|
||||
// Now usable in JSX
|
||||
<custom prop="value" />
|
||||
```
|
||||
|
||||
### getComponentCatalogue()
|
||||
|
||||
Returns the current component catalogue.
|
||||
|
||||
```tsx
|
||||
import { getComponentCatalogue } from "@opentui/solid"
|
||||
|
||||
const catalogue = getComponentCatalogue()
|
||||
console.log(Object.keys(catalogue))
|
||||
```
|
||||
|
||||
## Hooks
|
||||
|
||||
### useRenderer()
|
||||
|
||||
Access the OpenTUI renderer instance.
|
||||
|
||||
```tsx
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { onMount } from "solid-js"
|
||||
|
||||
function App() {
|
||||
const renderer = useRenderer()
|
||||
|
||||
onMount(() => {
|
||||
console.log(`Terminal: ${renderer.width}x${renderer.height}`)
|
||||
renderer.console.show()
|
||||
|
||||
// Access theme mode (dark/light based on terminal settings)
|
||||
console.log(`Theme: ${renderer.themeMode}`) // "dark" | "light" | null
|
||||
})
|
||||
|
||||
return <text>Hello</text>
|
||||
}
|
||||
|
||||
// Listen for theme mode changes
|
||||
function ThemedApp() {
|
||||
const renderer = useRenderer()
|
||||
const [theme, setTheme] = createSignal(renderer.themeMode ?? "dark")
|
||||
|
||||
onMount(() => {
|
||||
renderer.on("theme_mode", (mode: "dark" | "light") => setTheme(mode))
|
||||
})
|
||||
|
||||
return (
|
||||
<box backgroundColor={theme() === "dark" ? "#1a1a2e" : "#ffffff"}>
|
||||
<text fg={theme() === "dark" ? "#fff" : "#000"}>
|
||||
Current theme: {theme()}
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### useKeyboard(handler, options?)
|
||||
|
||||
Handle keyboard events.
|
||||
|
||||
```tsx
|
||||
import { useKeyboard, useRenderer } from "@opentui/solid"
|
||||
|
||||
function App() {
|
||||
const renderer = useRenderer()
|
||||
|
||||
useKeyboard((key) => {
|
||||
if (key.name === "escape") {
|
||||
renderer.destroy() // Never use process.exit() directly!
|
||||
}
|
||||
if (key.ctrl && key.name === "s") {
|
||||
saveDocument()
|
||||
}
|
||||
})
|
||||
|
||||
return <text>Press ESC to exit</text>
|
||||
}
|
||||
|
||||
// With release events
|
||||
function GameControls() {
|
||||
const [pressed, setPressed] = createSignal(new Set<string>())
|
||||
|
||||
useKeyboard(
|
||||
(event) => {
|
||||
setPressed(keys => {
|
||||
const newKeys = new Set(keys)
|
||||
if (event.eventType === "release") {
|
||||
newKeys.delete(event.name)
|
||||
} else {
|
||||
newKeys.add(event.name)
|
||||
}
|
||||
return newKeys
|
||||
})
|
||||
},
|
||||
{ release: true }
|
||||
)
|
||||
|
||||
return <text>Pressed: {Array.from(pressed()).join(", ")}</text>
|
||||
}
|
||||
```
|
||||
|
||||
### usePaste(handler)
|
||||
|
||||
Handle paste events. Receives a `PasteEvent` with raw bytes.
|
||||
|
||||
```tsx
|
||||
import { usePaste } from "@opentui/solid"
|
||||
import { decodePasteBytes } from "@opentui/core"
|
||||
|
||||
function PasteHandler() {
|
||||
usePaste((event) => {
|
||||
const text = decodePasteBytes(event.bytes)
|
||||
console.log("Pasted:", text)
|
||||
})
|
||||
|
||||
return <text>Paste something</text>
|
||||
}
|
||||
```
|
||||
|
||||
### onResize(callback)
|
||||
|
||||
Handle terminal resize events.
|
||||
|
||||
```tsx
|
||||
import { onResize } from "@opentui/solid"
|
||||
|
||||
function App() {
|
||||
onResize((width, height) => {
|
||||
console.log(`Resized to ${width}x${height}`)
|
||||
})
|
||||
|
||||
return <text>Resize the terminal</text>
|
||||
}
|
||||
```
|
||||
|
||||
### useTerminalDimensions()
|
||||
|
||||
Get reactive terminal dimensions.
|
||||
|
||||
```tsx
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
|
||||
function ResponsiveLayout() {
|
||||
const dimensions = useTerminalDimensions()
|
||||
|
||||
return (
|
||||
<box flexDirection={dimensions().width > 80 ? "row" : "column"}>
|
||||
<text>Width: {dimensions().width}</text>
|
||||
<text>Height: {dimensions().height}</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### onFocus(callback) / onBlur(callback)
|
||||
|
||||
Handle terminal window focus and blur events. Solid-only hooks.
|
||||
|
||||
```tsx
|
||||
import { onFocus, onBlur } from "@opentui/solid"
|
||||
|
||||
function App() {
|
||||
onFocus(() => {
|
||||
console.log("Terminal window gained focus")
|
||||
})
|
||||
|
||||
onBlur(() => {
|
||||
console.log("Terminal window lost focus")
|
||||
})
|
||||
|
||||
return <text>Focus/blur tracking</text>
|
||||
}
|
||||
```
|
||||
|
||||
These hooks fire when the terminal emulator window gains or loses operating system focus. The renderer deduplicates events (won't re-emit the same focus state).
|
||||
|
||||
### useSelectionHandler(handler)
|
||||
|
||||
Handle text selection events. Fires when the user finishes a mouse selection (mouse-up). Solid-only hook - React does not have this.
|
||||
|
||||
```tsx
|
||||
import { useSelectionHandler } from "@opentui/solid"
|
||||
import type { Selection } from "@opentui/core"
|
||||
|
||||
function SelectableText() {
|
||||
const [selected, setSelected] = createSignal("")
|
||||
const renderer = useRenderer()
|
||||
|
||||
useSelectionHandler((selection: Selection) => {
|
||||
const text = selection.getSelectedText()
|
||||
if (text) {
|
||||
setSelected(text)
|
||||
renderer.copyToClipboardOSC52(text)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box flexDirection="column">
|
||||
<text selectable>Select this text with your mouse</text>
|
||||
<text fg="#888">Selected: {selected()}</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
The `Selection` object aggregates selected text from all selectable renderables in the tree. See `keyboard/REFERENCE.md` (selection) for full details on the selection API and traversal model.
|
||||
|
||||
### useTimeline(options?)
|
||||
|
||||
Create animations with the timeline system.
|
||||
|
||||
```tsx
|
||||
import { useTimeline } from "@opentui/solid"
|
||||
import { createSignal, onMount } from "solid-js"
|
||||
|
||||
function AnimatedBox() {
|
||||
const [width, setWidth] = createSignal(0)
|
||||
|
||||
const timeline = useTimeline({
|
||||
duration: 2000,
|
||||
loop: false,
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
timeline.add(
|
||||
{ width: 0 },
|
||||
{
|
||||
width: 50,
|
||||
duration: 2000,
|
||||
ease: "easeOutQuad",
|
||||
onUpdate: (anim) => {
|
||||
setWidth(Math.round(anim.targets[0].width))
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
return <box style={{ width: width(), height: 3, backgroundColor: "#6a5acd" }} />
|
||||
}
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### Text Component
|
||||
|
||||
```tsx
|
||||
<text
|
||||
content="Hello" // Or use children
|
||||
fg="#FFFFFF" // Foreground color
|
||||
bg="#000000" // Background color
|
||||
selectable={true} // Allow text selection
|
||||
>
|
||||
{/* Use nested modifier tags for styling */}
|
||||
<span fg="red">Red</span>
|
||||
<strong>Bold</strong>
|
||||
<em>Italic</em>
|
||||
<u>Underline</u>
|
||||
<br />
|
||||
<a href="https://...">Link</a>
|
||||
</text>
|
||||
```
|
||||
|
||||
> **Note**: Do NOT use `bold`, `italic`, `underline` as props on `<text>`. Use nested modifier tags like `<strong>`, `<em>`, `<u>` instead.
|
||||
|
||||
### Box Component
|
||||
|
||||
```tsx
|
||||
<box
|
||||
// Borders
|
||||
border // Enable border
|
||||
borderStyle="single" // single | double | rounded | bold
|
||||
borderColor="#FFFFFF"
|
||||
title="Title"
|
||||
titleAlignment="center" // left | center | right
|
||||
|
||||
// Colors
|
||||
backgroundColor="#1a1a2e"
|
||||
|
||||
// Layout
|
||||
flexDirection="row"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
gap={2}
|
||||
|
||||
// Spacing
|
||||
padding={2}
|
||||
paddingX={2} // Horizontal (left + right)
|
||||
paddingY={1} // Vertical (top + bottom)
|
||||
margin={1}
|
||||
marginX={2} // Horizontal (left + right)
|
||||
marginY={1} // Vertical (top + bottom)
|
||||
|
||||
// Dimensions
|
||||
width={40}
|
||||
height={10}
|
||||
flexGrow={1}
|
||||
|
||||
// Focus
|
||||
focusable // Allow box to receive focus
|
||||
focused={isFocused()} // Controlled focus state
|
||||
|
||||
// Events
|
||||
onMouseDown={(e) => {}}
|
||||
onMouseUp={(e) => {}}
|
||||
>
|
||||
{children}
|
||||
</box>
|
||||
```
|
||||
|
||||
### Scrollbox Component
|
||||
|
||||
```tsx
|
||||
<scrollbox
|
||||
focused // Enable keyboard scrolling
|
||||
style={{
|
||||
scrollbarOptions: {
|
||||
showArrows: true,
|
||||
trackOptions: {
|
||||
foregroundColor: "#7aa2f7",
|
||||
backgroundColor: "#414868",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<For each={items()}>
|
||||
{(item) => <text>{item}</text>}
|
||||
</For>
|
||||
</scrollbox>
|
||||
```
|
||||
|
||||
### Input Component
|
||||
|
||||
```tsx
|
||||
<input
|
||||
value={value()}
|
||||
onInput={(newValue) => setValue(newValue)}
|
||||
placeholder="Enter text..."
|
||||
focused
|
||||
width={30}
|
||||
/>
|
||||
```
|
||||
|
||||
### Textarea Component
|
||||
|
||||
```tsx
|
||||
<textarea
|
||||
value={text()}
|
||||
onInput={(newValue) => setText(newValue)}
|
||||
placeholder="Enter multiple lines..."
|
||||
focused
|
||||
width={40}
|
||||
height={10}
|
||||
/>
|
||||
```
|
||||
|
||||
### Select Component
|
||||
|
||||
```tsx
|
||||
<select
|
||||
options={[
|
||||
{ name: "Option 1", description: "First", value: "1" },
|
||||
{ name: "Option 2", description: "Second", value: "2" },
|
||||
]}
|
||||
onChange={(index, option) => setSelected(option)}
|
||||
selectedIndex={0}
|
||||
focused
|
||||
/>
|
||||
```
|
||||
|
||||
### Tab Select Component (Note: underscore)
|
||||
|
||||
```tsx
|
||||
<tab_select
|
||||
options={[
|
||||
{ name: "Home", description: "Dashboard" },
|
||||
{ name: "Settings", description: "Configuration" },
|
||||
]}
|
||||
onChange={(index, option) => setTab(option)}
|
||||
tabWidth={20}
|
||||
focused
|
||||
/>
|
||||
```
|
||||
|
||||
### ASCII Font Component (Note: underscore)
|
||||
|
||||
```tsx
|
||||
<ascii_font
|
||||
text="TITLE"
|
||||
font="tiny" // tiny | block | slick | shade
|
||||
color="#FFFFFF"
|
||||
/>
|
||||
```
|
||||
|
||||
### Code Component
|
||||
|
||||
```tsx
|
||||
<code
|
||||
code={sourceCode}
|
||||
language="typescript"
|
||||
/>
|
||||
```
|
||||
|
||||
### Line Number Component (Note: underscore)
|
||||
|
||||
```tsx
|
||||
<line_number
|
||||
code={sourceCode}
|
||||
language="typescript"
|
||||
startLine={1}
|
||||
highlightedLines={[5]}
|
||||
/>
|
||||
```
|
||||
|
||||
### Diff Component
|
||||
|
||||
```tsx
|
||||
<diff
|
||||
oldCode={originalCode}
|
||||
newCode={modifiedCode}
|
||||
language="typescript"
|
||||
mode="unified" // unified | split
|
||||
syncScroll // Sync scroll between split view panes
|
||||
/>
|
||||
```
|
||||
|
||||
## Control Flow
|
||||
|
||||
Solid's control flow components work with OpenTUI:
|
||||
|
||||
### For
|
||||
|
||||
```tsx
|
||||
import { For } from "solid-js"
|
||||
|
||||
<For each={items()}>
|
||||
{(item, index) => (
|
||||
<box key={index()}>
|
||||
<text>{item.name}</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
```
|
||||
|
||||
### Show
|
||||
|
||||
```tsx
|
||||
import { Show } from "solid-js"
|
||||
|
||||
<Show when={isVisible()} fallback={<text>Hidden</text>}>
|
||||
<text>Visible content</text>
|
||||
</Show>
|
||||
```
|
||||
|
||||
### Switch/Match
|
||||
|
||||
```tsx
|
||||
import { Switch, Match } from "solid-js"
|
||||
|
||||
<Switch>
|
||||
<Match when={status() === "loading"}>
|
||||
<text>Loading...</text>
|
||||
</Match>
|
||||
<Match when={status() === "error"}>
|
||||
<text fg="red">Error!</text>
|
||||
</Match>
|
||||
<Match when={status() === "success"}>
|
||||
<text fg="green">Success!</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
```
|
||||
|
||||
### Index
|
||||
|
||||
```tsx
|
||||
import { Index } from "solid-js"
|
||||
|
||||
<Index each={items()}>
|
||||
{(item, index) => (
|
||||
<text>{index}: {item().name}</text>
|
||||
)}
|
||||
</Index>
|
||||
```
|
||||
|
||||
## Special Components
|
||||
|
||||
### Portal
|
||||
|
||||
```tsx
|
||||
import { Portal } from "@opentui/solid"
|
||||
|
||||
<Portal mount={targetNode}>
|
||||
<box>Portal content</box>
|
||||
</Portal>
|
||||
```
|
||||
|
||||
### Dynamic
|
||||
|
||||
```tsx
|
||||
import { Dynamic } from "@opentui/solid"
|
||||
|
||||
<Dynamic
|
||||
component={isMultiline() ? "textarea" : "input"}
|
||||
placeholder="Enter text..."
|
||||
focused
|
||||
/>
|
||||
```
|
||||
@@ -1,316 +0,0 @@
|
||||
# Solid Configuration
|
||||
|
||||
## Project Setup
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
bunx create-tui@latest -t solid my-app
|
||||
cd my-app && bun install
|
||||
```
|
||||
|
||||
The CLI creates the `my-app` directory for you - it must **not already exist**.
|
||||
|
||||
Options: `--no-git` (skip git init), `--no-install` (skip bun install)
|
||||
|
||||
### Manual Setup
|
||||
|
||||
```bash
|
||||
mkdir my-tui && cd my-tui
|
||||
bun init
|
||||
bun install @opentui/solid @opentui/core solid-js
|
||||
```
|
||||
|
||||
## TypeScript Configuration
|
||||
|
||||
### tsconfig.json
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "@opentui/solid",
|
||||
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
```
|
||||
|
||||
**Critical settings:**
|
||||
- `jsx: "preserve"` - Let Solid's compiler handle JSX
|
||||
- `jsxImportSource: "@opentui/solid"` - Import JSX runtime from OpenTUI Solid
|
||||
- `module` / `moduleResolution: "NodeNext"` - Recommended for OpenTUI compatibility
|
||||
|
||||
## Bun Configuration
|
||||
|
||||
### bunfig.toml
|
||||
|
||||
**Required** for the Solid compiler:
|
||||
|
||||
```toml
|
||||
preload = ["@opentui/solid/preload"]
|
||||
```
|
||||
|
||||
This loads the Solid JSX transform before your code runs.
|
||||
|
||||
## Package Configuration
|
||||
|
||||
### package.json
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-tui-app",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "bun run src/index.tsx",
|
||||
"dev": "bun --watch run src/index.tsx",
|
||||
"test": "bun test",
|
||||
"build": "bun run build.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentui/core": "latest",
|
||||
"@opentui/solid": "latest",
|
||||
"solid-js": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"typescript": "latest"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
Recommended structure:
|
||||
|
||||
```
|
||||
my-tui-app/
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ │ ├── Header.tsx
|
||||
│ │ ├── Sidebar.tsx
|
||||
│ │ └── MainContent.tsx
|
||||
│ ├── stores/
|
||||
│ │ └── appStore.ts
|
||||
│ ├── App.tsx
|
||||
│ └── index.tsx
|
||||
├── bunfig.toml # Required!
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
### Entry Point (src/index.tsx)
|
||||
|
||||
```tsx
|
||||
import { render } from "@opentui/solid"
|
||||
import { App } from "./App"
|
||||
|
||||
render(() => <App />)
|
||||
```
|
||||
|
||||
### App Component (src/App.tsx)
|
||||
|
||||
```tsx
|
||||
import { Header } from "./components/Header"
|
||||
import { Sidebar } from "./components/Sidebar"
|
||||
import { MainContent } from "./components/MainContent"
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<box flexDirection="column" width="100%" height="100%">
|
||||
<Header />
|
||||
<box flexDirection="row" flexGrow={1}>
|
||||
<Sidebar />
|
||||
<MainContent />
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Renderer Configuration
|
||||
|
||||
### render() Options
|
||||
|
||||
```tsx
|
||||
import { render } from "@opentui/solid"
|
||||
import { ConsolePosition } from "@opentui/core"
|
||||
|
||||
render(() => <App />, {
|
||||
// Rendering
|
||||
targetFPS: 60,
|
||||
|
||||
// Behavior
|
||||
exitOnCtrlC: true,
|
||||
autoFocus: true, // Auto-focus elements on click (default: true)
|
||||
useMouse: true, // Enable mouse support (default: true)
|
||||
|
||||
// Debug console
|
||||
consoleOptions: {
|
||||
position: ConsolePosition.BOTTOM,
|
||||
sizePercent: 30,
|
||||
startInDebugMode: false,
|
||||
},
|
||||
|
||||
// Cleanup
|
||||
onDestroy: () => {
|
||||
// Cleanup code
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Using Existing Renderer
|
||||
|
||||
```tsx
|
||||
import { render } from "@opentui/solid"
|
||||
import { createCliRenderer } from "@opentui/core"
|
||||
|
||||
const renderer = await createCliRenderer({
|
||||
exitOnCtrlC: false,
|
||||
})
|
||||
|
||||
render(() => <App />, renderer)
|
||||
```
|
||||
|
||||
## Building for Distribution
|
||||
|
||||
### Build Script (build.ts)
|
||||
|
||||
```typescript
|
||||
import solidPlugin from "@opentui/solid/bun-plugin"
|
||||
|
||||
await Bun.build({
|
||||
entrypoints: ["./src/index.tsx"],
|
||||
outdir: "./dist",
|
||||
target: "bun",
|
||||
minify: true,
|
||||
plugins: [solidPlugin],
|
||||
})
|
||||
|
||||
console.log("Build complete!")
|
||||
```
|
||||
|
||||
Run: `bun run build.ts`
|
||||
|
||||
### Creating Executables
|
||||
|
||||
```typescript
|
||||
import solidPlugin from "@opentui/solid/bun-plugin"
|
||||
|
||||
await Bun.build({
|
||||
entrypoints: ["./src/index.tsx"],
|
||||
target: "bun",
|
||||
plugins: [solidPlugin],
|
||||
compile: {
|
||||
target: "bun-darwin-arm64", // or bun-linux-x64, etc.
|
||||
outfile: "my-app",
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
**Available targets:**
|
||||
- `bun-darwin-arm64` - macOS Apple Silicon
|
||||
- `bun-darwin-x64` - macOS Intel
|
||||
- `bun-linux-x64` - Linux x64
|
||||
- `bun-linux-arm64` - Linux ARM64
|
||||
- `bun-windows-x64` - Windows x64
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Create `.env` for development:
|
||||
|
||||
```env
|
||||
# Debug settings
|
||||
OTUI_SHOW_STATS=false
|
||||
SHOW_CONSOLE=false
|
||||
|
||||
# App settings
|
||||
API_URL=https://api.example.com
|
||||
```
|
||||
|
||||
Bun auto-loads `.env` files:
|
||||
|
||||
```tsx
|
||||
const apiUrl = process.env.API_URL
|
||||
```
|
||||
|
||||
## Testing Configuration
|
||||
|
||||
### Test Setup
|
||||
|
||||
```typescript
|
||||
// src/test-utils.tsx
|
||||
import { testRender } from "@opentui/solid"
|
||||
|
||||
export async function renderForTest(
|
||||
Component: () => JSX.Element,
|
||||
options = { width: 80, height: 24 }
|
||||
) {
|
||||
return await testRender(Component, options)
|
||||
}
|
||||
```
|
||||
|
||||
### Test Example
|
||||
|
||||
```typescript
|
||||
// src/components/Counter.test.tsx
|
||||
import { test, expect } from "bun:test"
|
||||
import { renderForTest } from "../test-utils"
|
||||
import { Counter } from "./Counter"
|
||||
|
||||
test("Counter renders initial value", async () => {
|
||||
const { snapshot } = await renderForTest(() => <Counter initialValue={5} />)
|
||||
expect(snapshot()).toContain("Count: 5")
|
||||
})
|
||||
```
|
||||
|
||||
## Common Configuration Issues
|
||||
|
||||
### Missing bunfig.toml
|
||||
|
||||
**Symptom**: JSX not transformed, syntax errors
|
||||
|
||||
**Fix**: Create `bunfig.toml` with preload:
|
||||
|
||||
```toml
|
||||
preload = ["@opentui/solid/preload"]
|
||||
```
|
||||
|
||||
### Wrong JSX Settings
|
||||
|
||||
**Symptom**: JSX compiles to React calls
|
||||
|
||||
**Fix**: Ensure tsconfig has:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "@opentui/solid"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Build Missing Plugin
|
||||
|
||||
**Symptom**: Built output has untransformed JSX
|
||||
|
||||
**Fix**: Add Solid plugin to build:
|
||||
|
||||
```typescript
|
||||
import solidPlugin from "@opentui/solid/bun-plugin"
|
||||
|
||||
await Bun.build({
|
||||
// ...
|
||||
plugins: [solidPlugin],
|
||||
})
|
||||
```
|
||||
@@ -1,427 +0,0 @@
|
||||
# Solid Gotchas
|
||||
|
||||
## Critical
|
||||
|
||||
### Never use `process.exit()` directly
|
||||
|
||||
**This is the most common mistake.** Using `process.exit()` leaves the terminal in a broken state (cursor hidden, raw mode, alternate screen).
|
||||
|
||||
```tsx
|
||||
// WRONG - Terminal left in broken state
|
||||
process.exit(0)
|
||||
|
||||
// CORRECT - Use renderer.destroy()
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
|
||||
function App() {
|
||||
const renderer = useRenderer()
|
||||
|
||||
const handleExit = () => {
|
||||
renderer.destroy() // Cleans up and exits properly
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`renderer.destroy()` restores the terminal (exits alternate screen, restores cursor, etc.) before exiting.
|
||||
|
||||
## Configuration Issues
|
||||
|
||||
### Missing bunfig.toml
|
||||
|
||||
**Symptom**: JSX syntax errors, components not rendering
|
||||
|
||||
```
|
||||
SyntaxError: Unexpected token '<'
|
||||
```
|
||||
|
||||
**Fix**: Create `bunfig.toml` in project root:
|
||||
|
||||
```toml
|
||||
preload = ["@opentui/solid/preload"]
|
||||
```
|
||||
|
||||
### Wrong JSX Settings
|
||||
|
||||
**Symptom**: JSX compiles to React, errors about React not found
|
||||
|
||||
**Fix**: Ensure tsconfig.json has:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "@opentui/solid"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Build Without Plugin
|
||||
|
||||
**Symptom**: Built bundle has raw JSX
|
||||
|
||||
**Fix**: Add Solid plugin to build:
|
||||
|
||||
```typescript
|
||||
import solidPlugin from "@opentui/solid/bun-plugin"
|
||||
|
||||
await Bun.build({
|
||||
// ...
|
||||
plugins: [solidPlugin],
|
||||
})
|
||||
```
|
||||
|
||||
## Reactivity Issues
|
||||
|
||||
### Accessing Signals Without Calling
|
||||
|
||||
**Symptom**: Value never updates, shows `[Function]`
|
||||
|
||||
```tsx
|
||||
// WRONG - Missing ()
|
||||
const [count, setCount] = createSignal(0)
|
||||
<text>Count: {count}</text> // Shows [Function]
|
||||
|
||||
// CORRECT
|
||||
<text>Count: {count()}</text>
|
||||
```
|
||||
|
||||
### Breaking Reactivity with Destructuring
|
||||
|
||||
**Symptom**: Props stop being reactive
|
||||
|
||||
```tsx
|
||||
// WRONG - Breaks reactivity
|
||||
function Component(props: { value: number }) {
|
||||
const { value } = props // Destructured once, never updates!
|
||||
return <text>{value}</text>
|
||||
}
|
||||
|
||||
// CORRECT - Keep props reactive
|
||||
function Component(props: { value: number }) {
|
||||
return <text>{props.value}</text>
|
||||
}
|
||||
|
||||
// OR use splitProps
|
||||
function Component(props: { value: number; other: string }) {
|
||||
const [local, rest] = splitProps(props, ["value"])
|
||||
return <text>{local.value}</text>
|
||||
}
|
||||
```
|
||||
|
||||
### Effects Not Running
|
||||
|
||||
**Symptom**: createEffect doesn't trigger
|
||||
|
||||
```tsx
|
||||
// WRONG - Signal not accessed in effect
|
||||
const [count, setCount] = createSignal(0)
|
||||
|
||||
createEffect(() => {
|
||||
console.log("Count changed") // Never runs after initial!
|
||||
})
|
||||
|
||||
// CORRECT - Access the signal
|
||||
createEffect(() => {
|
||||
console.log("Count:", count()) // Runs when count changes
|
||||
})
|
||||
```
|
||||
|
||||
## HTML Entity Decoding
|
||||
|
||||
Solid's reconciler automatically decodes HTML entities in JSX text content. This means `<`, `>`, `&`, etc. render as their literal characters:
|
||||
|
||||
```tsx
|
||||
// These render correctly in Solid
|
||||
<text>Use <box> for containers</text> // Displays: Use <box> for containers
|
||||
<text>A & B</text> // Displays: A & B
|
||||
```
|
||||
|
||||
This applies to text nodes, the `content` prop, and the `text` prop.
|
||||
|
||||
## Component Naming
|
||||
|
||||
### Underscore vs Hyphen
|
||||
|
||||
Solid uses underscores for multi-word component names:
|
||||
|
||||
```tsx
|
||||
// WRONG - React-style naming
|
||||
<tab-select /> // Error!
|
||||
<ascii-font /> // Error!
|
||||
<line-number /> // Error!
|
||||
|
||||
// CORRECT - Solid naming
|
||||
<tab_select />
|
||||
<ascii_font />
|
||||
<line_number />
|
||||
```
|
||||
|
||||
**Component mapping:**
|
||||
| Concept | React | Solid |
|
||||
|---------|-------|-------|
|
||||
| Tab Select | `<tab-select>` | `<tab_select>` |
|
||||
| ASCII Font | `<ascii-font>` | `<ascii_font>` |
|
||||
| Line Number | `<line-number>` | `<line_number>` |
|
||||
|
||||
## Focus Issues
|
||||
|
||||
### Focus Not Working
|
||||
|
||||
Components need explicit focus:
|
||||
|
||||
```tsx
|
||||
// WRONG
|
||||
<input placeholder="Type here..." />
|
||||
|
||||
// CORRECT
|
||||
<input placeholder="Type here..." focused />
|
||||
```
|
||||
|
||||
### Select Not Responding
|
||||
|
||||
```tsx
|
||||
// WRONG
|
||||
<select options={["a", "b"]} />
|
||||
|
||||
// CORRECT
|
||||
<select
|
||||
options={[
|
||||
{ name: "A", description: "Option A", value: "a" },
|
||||
{ name: "B", description: "Option B", value: "b" },
|
||||
]}
|
||||
onSelect={(index, option) => {
|
||||
// Called when Enter is pressed
|
||||
console.log("Selected:", option.name)
|
||||
}}
|
||||
focused
|
||||
/>
|
||||
```
|
||||
|
||||
### Select Events Confusion
|
||||
|
||||
Remember: `onSelect` fires on Enter (selection confirmed), `onChange` fires on navigation:
|
||||
|
||||
```tsx
|
||||
// WRONG - expecting onChange to fire on Enter
|
||||
<select
|
||||
options={options()}
|
||||
onChange={(i, opt) => submitForm(opt)} // This fires on arrow keys!
|
||||
/>
|
||||
|
||||
// CORRECT
|
||||
<select
|
||||
options={options()}
|
||||
onSelect={(i, opt) => submitForm(opt)} // Enter pressed - submit
|
||||
onChange={(i, opt) => showPreview(opt)} // Arrow keys - preview
|
||||
/>
|
||||
```
|
||||
|
||||
## Control Flow Issues
|
||||
|
||||
### For vs Index
|
||||
|
||||
Use `For` for arrays of objects, `Index` for primitives:
|
||||
|
||||
```tsx
|
||||
// For objects - item is reactive
|
||||
<For each={objects()}>
|
||||
{(obj) => <text>{obj.name}</text>}
|
||||
</For>
|
||||
|
||||
// For primitives - use Index, item() is reactive
|
||||
<Index each={strings()}>
|
||||
{(str, index) => <text>{index}: {str()}</text>}
|
||||
</Index>
|
||||
```
|
||||
|
||||
### Missing Fallback
|
||||
|
||||
Show requires fallback for proper rendering:
|
||||
|
||||
```tsx
|
||||
// May cause issues
|
||||
<Show when={data()}>
|
||||
<Component />
|
||||
</Show>
|
||||
|
||||
// Better - explicit fallback
|
||||
<Show when={data()} fallback={<text>Loading...</text>}>
|
||||
<Component />
|
||||
</Show>
|
||||
```
|
||||
|
||||
## Cleanup Issues
|
||||
|
||||
### Forgetting onCleanup
|
||||
|
||||
**Symptom**: Memory leaks, multiple intervals running
|
||||
|
||||
```tsx
|
||||
// WRONG - Interval never cleared
|
||||
function Timer() {
|
||||
const [time, setTime] = createSignal(0)
|
||||
|
||||
setInterval(() => setTime(t => t + 1), 1000)
|
||||
|
||||
return <text>{time()}</text>
|
||||
}
|
||||
|
||||
// CORRECT
|
||||
function Timer() {
|
||||
const [time, setTime] = createSignal(0)
|
||||
|
||||
const interval = setInterval(() => setTime(t => t + 1), 1000)
|
||||
onCleanup(() => clearInterval(interval))
|
||||
|
||||
return <text>{time()}</text>
|
||||
}
|
||||
```
|
||||
|
||||
### Effect Cleanup
|
||||
|
||||
```tsx
|
||||
createEffect(() => {
|
||||
const subscription = subscribe(data())
|
||||
|
||||
// WRONG - No cleanup
|
||||
// subscription stays active
|
||||
|
||||
// CORRECT
|
||||
onCleanup(() => subscription.unsubscribe())
|
||||
})
|
||||
```
|
||||
|
||||
## Store Issues
|
||||
|
||||
### Mutating Store Directly
|
||||
|
||||
**Symptom**: Changes don't trigger updates
|
||||
|
||||
```tsx
|
||||
const [state, setState] = createStore({ items: [] })
|
||||
|
||||
// WRONG - Direct mutation
|
||||
state.items.push(newItem) // Won't trigger updates!
|
||||
|
||||
// CORRECT - Use setState
|
||||
setState("items", items => [...items, newItem])
|
||||
```
|
||||
|
||||
### Nested Updates
|
||||
|
||||
```tsx
|
||||
const [state, setState] = createStore({
|
||||
user: { profile: { name: "John" } }
|
||||
})
|
||||
|
||||
// WRONG
|
||||
state.user.profile.name = "Jane"
|
||||
|
||||
// CORRECT
|
||||
setState("user", "profile", "name", "Jane")
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
### Console Not Visible
|
||||
|
||||
OpenTUI captures console output:
|
||||
|
||||
```tsx
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { onMount } from "solid-js"
|
||||
|
||||
function App() {
|
||||
const renderer = useRenderer()
|
||||
|
||||
onMount(() => {
|
||||
renderer.console.show()
|
||||
console.log("Now visible!")
|
||||
})
|
||||
|
||||
return <box>{/* ... */}</box>
|
||||
}
|
||||
```
|
||||
|
||||
### Tracking Reactivity
|
||||
|
||||
Use `createEffect` to debug:
|
||||
|
||||
```tsx
|
||||
createEffect(() => {
|
||||
console.log("State:", {
|
||||
count: count(),
|
||||
items: items(),
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Runtime Issues
|
||||
|
||||
### Use Bun
|
||||
|
||||
```bash
|
||||
# WRONG
|
||||
node src/index.tsx
|
||||
npm run start
|
||||
|
||||
# CORRECT
|
||||
bun run src/index.tsx
|
||||
bun run start
|
||||
```
|
||||
|
||||
### Async render()
|
||||
|
||||
The render function is async when creating a renderer:
|
||||
|
||||
```tsx
|
||||
// This is fine - Bun supports top-level await
|
||||
render(() => <App />)
|
||||
|
||||
// If you need the renderer
|
||||
import { createCliRenderer } from "@opentui/core"
|
||||
import { render } from "@opentui/solid"
|
||||
|
||||
const renderer = await createCliRenderer()
|
||||
render(() => <App />, renderer)
|
||||
```
|
||||
|
||||
## Common Error Messages
|
||||
|
||||
### "Cannot read properties of undefined"
|
||||
|
||||
Usually a missing reactive access:
|
||||
|
||||
```tsx
|
||||
// Check if signal is being called
|
||||
<text>{count()}</text> // Note the ()
|
||||
|
||||
// Check if props are being accessed correctly
|
||||
<text>{props.value}</text> // Not destructured
|
||||
```
|
||||
|
||||
### "JSX element has no corresponding closing tag"
|
||||
|
||||
Check component naming:
|
||||
|
||||
```tsx
|
||||
// Wrong
|
||||
<tab-select></tab-select>
|
||||
|
||||
// Correct
|
||||
<tab_select></tab_select>
|
||||
```
|
||||
|
||||
### "store is not a function"
|
||||
|
||||
Stores aren't called like signals:
|
||||
|
||||
```tsx
|
||||
const [store, setStore] = createStore({ count: 0 })
|
||||
|
||||
// WRONG
|
||||
<text>{store().count}</text>
|
||||
|
||||
// CORRECT
|
||||
<text>{store.count}</text>
|
||||
```
|
||||
@@ -1,560 +0,0 @@
|
||||
# Solid Patterns
|
||||
|
||||
## Reactive State
|
||||
|
||||
### Signals
|
||||
|
||||
Basic reactive state with signals:
|
||||
|
||||
```tsx
|
||||
import { createSignal } from "solid-js"
|
||||
|
||||
function Counter() {
|
||||
const [count, setCount] = createSignal(0)
|
||||
|
||||
return (
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text>Count: {count()}</text>
|
||||
<box border onMouseDown={() => setCount(c => c - 1)}>
|
||||
<text>-</text>
|
||||
</box>
|
||||
<box border onMouseDown={() => setCount(c => c + 1)}>
|
||||
<text>+</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Derived State
|
||||
|
||||
Compute values from signals:
|
||||
|
||||
```tsx
|
||||
import { createSignal, createMemo } from "solid-js"
|
||||
|
||||
function PriceCalculator() {
|
||||
const [quantity, setQuantity] = createSignal(1)
|
||||
const [price, setPrice] = createSignal(9.99)
|
||||
|
||||
// Derived value - only recalculates when dependencies change
|
||||
const total = createMemo(() => quantity() * price())
|
||||
const formatted = createMemo(() => `$${total().toFixed(2)}`)
|
||||
|
||||
return (
|
||||
<box flexDirection="column">
|
||||
<text>Quantity: {quantity()}</text>
|
||||
<text>Price: ${price()}</text>
|
||||
<text>Total: {formatted()}</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Effects
|
||||
|
||||
React to state changes:
|
||||
|
||||
```tsx
|
||||
import { createSignal, createEffect, onCleanup } from "solid-js"
|
||||
|
||||
function AutoSave() {
|
||||
const [content, setContent] = createSignal("")
|
||||
|
||||
createEffect(() => {
|
||||
const text = content()
|
||||
|
||||
// Debounced save
|
||||
const timeout = setTimeout(() => {
|
||||
saveToFile(text)
|
||||
}, 1000)
|
||||
|
||||
// Cleanup on next run or disposal
|
||||
onCleanup(() => clearTimeout(timeout))
|
||||
})
|
||||
|
||||
return (
|
||||
<textarea
|
||||
value={content()}
|
||||
onInput={setContent}
|
||||
placeholder="Auto-saves after 1 second..."
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Stores
|
||||
|
||||
### createStore for Complex State
|
||||
|
||||
```tsx
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
interface AppState {
|
||||
user: { name: string; email: string } | null
|
||||
items: Array<{ id: number; name: string; done: boolean }>
|
||||
settings: { theme: "dark" | "light" }
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [state, setState] = createStore<AppState>({
|
||||
user: null,
|
||||
items: [],
|
||||
settings: { theme: "dark" },
|
||||
})
|
||||
|
||||
const addItem = (name: string) => {
|
||||
setState("items", items => [
|
||||
...items,
|
||||
{ id: Date.now(), name, done: false }
|
||||
])
|
||||
}
|
||||
|
||||
const toggleItem = (id: number) => {
|
||||
setState("items", item => item.id === id, "done", done => !done)
|
||||
}
|
||||
|
||||
const setTheme = (theme: "dark" | "light") => {
|
||||
setState("settings", "theme", theme)
|
||||
}
|
||||
|
||||
return (
|
||||
<box backgroundColor={state.settings.theme === "dark" ? "#1a1a2e" : "#f0f0f0"}>
|
||||
<For each={state.items}>
|
||||
{(item) => (
|
||||
<text
|
||||
fg={item.done ? "#888" : "#fff"}
|
||||
onMouseDown={() => toggleItem(item.id)}
|
||||
>
|
||||
{item.done ? "[x]" : "[ ]"} {item.name}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Store with Context
|
||||
|
||||
Share state across components:
|
||||
|
||||
```tsx
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createContext, useContext, ParentComponent } from "solid-js"
|
||||
|
||||
interface Store {
|
||||
count: number
|
||||
items: string[]
|
||||
}
|
||||
|
||||
type StoreContextValue = [
|
||||
Store,
|
||||
{
|
||||
increment: () => void
|
||||
addItem: (item: string) => void
|
||||
}
|
||||
]
|
||||
|
||||
const StoreContext = createContext<StoreContextValue>()
|
||||
|
||||
const StoreProvider: ParentComponent = (props) => {
|
||||
const [state, setState] = createStore<Store>({
|
||||
count: 0,
|
||||
items: [],
|
||||
})
|
||||
|
||||
const actions = {
|
||||
increment: () => setState("count", c => c + 1),
|
||||
addItem: (item: string) => setState("items", i => [...i, item]),
|
||||
}
|
||||
|
||||
return (
|
||||
<StoreContext.Provider value={[state, actions]}>
|
||||
{props.children}
|
||||
</StoreContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function useStore() {
|
||||
const context = useContext(StoreContext)
|
||||
if (!context) throw new Error("useStore must be used within StoreProvider")
|
||||
return context
|
||||
}
|
||||
|
||||
// Usage
|
||||
function Counter() {
|
||||
const [state, { increment }] = useStore()
|
||||
return (
|
||||
<box onMouseDown={increment}>
|
||||
<text>Count: {state.count}</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Control Flow
|
||||
|
||||
### Conditional Rendering with Show
|
||||
|
||||
```tsx
|
||||
import { Show, createSignal } from "solid-js"
|
||||
|
||||
function ToggleableContent() {
|
||||
const [visible, setVisible] = createSignal(false)
|
||||
|
||||
return (
|
||||
<box flexDirection="column">
|
||||
<box border onMouseDown={() => setVisible(v => !v)}>
|
||||
<text>Toggle</text>
|
||||
</box>
|
||||
|
||||
<Show
|
||||
when={visible()}
|
||||
fallback={<text fg="#888">Content is hidden</text>}
|
||||
>
|
||||
<text fg="#0f0">Content is visible!</text>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Lists with For
|
||||
|
||||
```tsx
|
||||
import { For, createSignal } from "solid-js"
|
||||
|
||||
function TodoList() {
|
||||
const [todos, setTodos] = createSignal([
|
||||
{ id: 1, text: "Learn Solid", done: false },
|
||||
{ id: 2, text: "Build TUI", done: false },
|
||||
])
|
||||
|
||||
const toggle = (id: number) => {
|
||||
setTodos(todos =>
|
||||
todos.map(t =>
|
||||
t.id === id ? { ...t, done: !t.done } : t
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<box flexDirection="column">
|
||||
<For each={todos()}>
|
||||
{(todo) => (
|
||||
<box onMouseDown={() => toggle(todo.id)}>
|
||||
<text fg={todo.done ? "#888" : "#fff"}>
|
||||
{todo.done ? "[x]" : "[ ]"} {todo.text}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Index for Primitive Arrays
|
||||
|
||||
Use `Index` when array items are primitives:
|
||||
|
||||
```tsx
|
||||
import { Index, createSignal } from "solid-js"
|
||||
|
||||
function StringList() {
|
||||
const [items, setItems] = createSignal(["apple", "banana", "cherry"])
|
||||
|
||||
return (
|
||||
<box flexDirection="column">
|
||||
<Index each={items()}>
|
||||
{(item, index) => (
|
||||
<text>{index}: {item()}</text>
|
||||
)}
|
||||
</Index>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Switch/Match for Multiple Conditions
|
||||
|
||||
```tsx
|
||||
import { Switch, Match, createSignal } from "solid-js"
|
||||
|
||||
type Status = "idle" | "loading" | "success" | "error"
|
||||
|
||||
function StatusDisplay() {
|
||||
const [status, setStatus] = createSignal<Status>("idle")
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={status() === "idle"}>
|
||||
<text>Ready</text>
|
||||
</Match>
|
||||
<Match when={status() === "loading"}>
|
||||
<text fg="#ff0">Loading...</text>
|
||||
</Match>
|
||||
<Match when={status() === "success"}>
|
||||
<text fg="#0f0">Success!</text>
|
||||
</Match>
|
||||
<Match when={status() === "error"}>
|
||||
<text fg="#f00">Error occurred</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Focus Management
|
||||
|
||||
### Focus State
|
||||
|
||||
```tsx
|
||||
import { createSignal } from "solid-js"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
|
||||
function FocusableForm() {
|
||||
const [focusIndex, setFocusIndex] = createSignal(0)
|
||||
const fields = ["name", "email", "message"]
|
||||
|
||||
useKeyboard((key) => {
|
||||
if (key.name === "tab") {
|
||||
setFocusIndex(i => (i + 1) % fields.length)
|
||||
}
|
||||
if (key.shift && key.name === "tab") {
|
||||
setFocusIndex(i => (i - 1 + fields.length) % fields.length)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<Index each={fields}>
|
||||
{(field, i) => (
|
||||
<input
|
||||
placeholder={`Enter ${field()}...`}
|
||||
focused={i === focusIndex()}
|
||||
/>
|
||||
)}
|
||||
</Index>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Keyboard Navigation
|
||||
|
||||
### Global Shortcuts
|
||||
|
||||
```tsx
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
|
||||
function App() {
|
||||
const renderer = useRenderer()
|
||||
|
||||
useKeyboard((key) => {
|
||||
if (key.name === "escape") {
|
||||
renderer.destroy() // Never use process.exit() directly!
|
||||
}
|
||||
|
||||
if (key.ctrl && key.name === "s") {
|
||||
save()
|
||||
}
|
||||
|
||||
// Vim-style
|
||||
if (key.name === "j") moveDown()
|
||||
if (key.name === "k") moveUp()
|
||||
})
|
||||
|
||||
return <box>{/* ... */}</box>
|
||||
}
|
||||
```
|
||||
|
||||
## Responsive Design
|
||||
|
||||
### Terminal-size Responsive
|
||||
|
||||
```tsx
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
|
||||
function ResponsiveLayout() {
|
||||
const dims = useTerminalDimensions()
|
||||
|
||||
return (
|
||||
<box flexDirection={dims().width > 80 ? "row" : "column"}>
|
||||
<box flexGrow={1}>
|
||||
<text>Panel 1</text>
|
||||
</box>
|
||||
<box flexGrow={1}>
|
||||
<text>Panel 2</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Async Data
|
||||
|
||||
### Resources
|
||||
|
||||
```tsx
|
||||
import { createResource, Suspense } from "solid-js"
|
||||
|
||||
async function fetchData() {
|
||||
const response = await fetch("https://api.example.com/data")
|
||||
return response.json()
|
||||
}
|
||||
|
||||
function DataDisplay() {
|
||||
const [data] = createResource(fetchData)
|
||||
|
||||
return (
|
||||
<Suspense fallback={<text>Loading...</text>}>
|
||||
<Show when={data()}>
|
||||
{(items) => (
|
||||
<For each={items()}>
|
||||
{(item) => <text>{item.name}</text>}
|
||||
</For>
|
||||
)}
|
||||
</Show>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```tsx
|
||||
import { createResource, Show, ErrorBoundary } from "solid-js"
|
||||
|
||||
function SafeDataDisplay() {
|
||||
const [data] = createResource(fetchData)
|
||||
|
||||
return (
|
||||
<ErrorBoundary fallback={(err) => <text fg="red">Error: {err.message}</text>}>
|
||||
<Show
|
||||
when={!data.loading}
|
||||
fallback={<text>Loading...</text>}
|
||||
>
|
||||
<Show
|
||||
when={!data.error}
|
||||
fallback={<text fg="red">Failed to load</text>}
|
||||
>
|
||||
<For each={data()}>
|
||||
{(item) => <text>{item.name}</text>}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Component Composition
|
||||
|
||||
### Props and Children
|
||||
|
||||
```tsx
|
||||
import { ParentComponent, JSX } from "solid-js"
|
||||
|
||||
interface PanelProps {
|
||||
title: string
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
const Panel: ParentComponent<{ title: string }> = (props) => {
|
||||
return (
|
||||
<box border padding={1} flexDirection="column">
|
||||
<text fg="#0ff">{props.title}</text>
|
||||
<box marginTop={1}>
|
||||
{props.children}
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
// Usage
|
||||
<Panel title="Settings">
|
||||
<text>Panel content here</text>
|
||||
</Panel>
|
||||
```
|
||||
|
||||
### Spread Props
|
||||
|
||||
```tsx
|
||||
import { splitProps } from "solid-js"
|
||||
|
||||
interface ButtonProps {
|
||||
label: string
|
||||
onClick: () => void
|
||||
// ...rest goes to box
|
||||
}
|
||||
|
||||
function Button(props: ButtonProps) {
|
||||
const [local, rest] = splitProps(props, ["label", "onClick"])
|
||||
|
||||
return (
|
||||
<box border onMouseDown={local.onClick} {...rest}>
|
||||
<text>{local.label}</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Animation
|
||||
|
||||
### With Timeline
|
||||
|
||||
```tsx
|
||||
import { createSignal, onMount } from "solid-js"
|
||||
import { useTimeline } from "@opentui/solid"
|
||||
|
||||
function AnimatedProgress() {
|
||||
const [width, setWidth] = createSignal(0)
|
||||
|
||||
const timeline = useTimeline({
|
||||
duration: 2000,
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
timeline.add(
|
||||
{ value: 0 },
|
||||
{
|
||||
value: 50,
|
||||
duration: 2000,
|
||||
ease: "easeOutQuad",
|
||||
onUpdate: (anim) => {
|
||||
setWidth(Math.round(anim.targets[0].value))
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text>Progress: {width()}%</text>
|
||||
<box width={50} height={1} backgroundColor="#333">
|
||||
<box width={width()} height={1} backgroundColor="#0f0" />
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Interval-based
|
||||
|
||||
```tsx
|
||||
import { createSignal, onCleanup } from "solid-js"
|
||||
|
||||
function Clock() {
|
||||
const [time, setTime] = createSignal(new Date())
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setTime(new Date())
|
||||
}, 1000)
|
||||
|
||||
onCleanup(() => clearInterval(interval))
|
||||
|
||||
return <text>{time().toLocaleTimeString()}</text>
|
||||
}
|
||||
```
|
||||
@@ -1,614 +0,0 @@
|
||||
# Testing OpenTUI Applications
|
||||
|
||||
How to test terminal user interfaces built with OpenTUI.
|
||||
|
||||
## Overview
|
||||
|
||||
OpenTUI provides:
|
||||
- **Test Renderer**: Headless renderer for testing
|
||||
- **Snapshot Testing**: Verify visual output
|
||||
- **Interaction Testing**: Simulate user input
|
||||
|
||||
## When to Use
|
||||
|
||||
Use this reference when you need snapshot tests, interaction testing, or renderer-based regression checks.
|
||||
|
||||
## Test Setup
|
||||
|
||||
### Bun Test Runner
|
||||
|
||||
OpenTUI uses Bun's built-in test runner:
|
||||
|
||||
```typescript
|
||||
import { test, expect, beforeEach, afterEach } from "bun:test"
|
||||
```
|
||||
|
||||
### Test Renderer
|
||||
|
||||
Create a test renderer for headless testing:
|
||||
|
||||
```typescript
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
|
||||
const testSetup = await createTestRenderer({
|
||||
width: 80, // Terminal width
|
||||
height: 24, // Terminal height
|
||||
})
|
||||
```
|
||||
|
||||
## Core Testing
|
||||
|
||||
### Basic Test
|
||||
|
||||
```typescript
|
||||
import { test, expect } from "bun:test"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { TextRenderable } from "@opentui/core"
|
||||
|
||||
test("renders text", async () => {
|
||||
const testSetup = await createTestRenderer({
|
||||
width: 40,
|
||||
height: 10,
|
||||
})
|
||||
|
||||
const text = new TextRenderable(testSetup.renderer, {
|
||||
id: "greeting",
|
||||
content: "Hello, World!",
|
||||
})
|
||||
|
||||
testSetup.renderer.root.add(text)
|
||||
await testSetup.renderOnce()
|
||||
|
||||
expect(testSetup.captureCharFrame()).toContain("Hello, World!")
|
||||
})
|
||||
```
|
||||
|
||||
### Snapshot Testing
|
||||
|
||||
```typescript
|
||||
import { test, expect, afterEach } from "bun:test"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { BoxRenderable, TextRenderable } from "@opentui/core"
|
||||
|
||||
let testSetup: Awaited<ReturnType<typeof createTestRenderer>>
|
||||
|
||||
afterEach(() => {
|
||||
if (testSetup) {
|
||||
testSetup.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("component matches snapshot", async () => {
|
||||
testSetup = await createTestRenderer({
|
||||
width: 40,
|
||||
height: 10,
|
||||
})
|
||||
|
||||
const box = new BoxRenderable(testSetup.renderer, {
|
||||
id: "box",
|
||||
border: true,
|
||||
width: 20,
|
||||
height: 5,
|
||||
})
|
||||
box.add(new TextRenderable(testSetup.renderer, {
|
||||
content: "Content",
|
||||
}))
|
||||
|
||||
testSetup.renderer.root.add(box)
|
||||
await testSetup.renderOnce()
|
||||
|
||||
expect(testSetup.captureCharFrame()).toMatchSnapshot()
|
||||
})
|
||||
```
|
||||
|
||||
## React Testing
|
||||
|
||||
### Test Utilities
|
||||
|
||||
React provides a built-in `testRender` utility via the `@opentui/react/test-utils` subpath export:
|
||||
|
||||
```tsx
|
||||
import { testRender } from "@opentui/react/test-utils"
|
||||
```
|
||||
|
||||
This utility:
|
||||
- Creates a headless test renderer
|
||||
- Sets up the React Act environment automatically
|
||||
- Handles proper unmounting on destroy
|
||||
- Returns the standard test setup object
|
||||
|
||||
### Basic Component Test
|
||||
|
||||
```tsx
|
||||
import { test, expect } from "bun:test"
|
||||
import { testRender } from "@opentui/react/test-utils"
|
||||
|
||||
function Greeting({ name }: { name: string }) {
|
||||
return <text>Hello, {name}!</text>
|
||||
}
|
||||
|
||||
test("Greeting renders name", async () => {
|
||||
const testSetup = await testRender(
|
||||
<Greeting name="World" />,
|
||||
{ width: 80, height: 24 }
|
||||
)
|
||||
|
||||
await testSetup.renderOnce()
|
||||
const frame = testSetup.captureCharFrame()
|
||||
|
||||
expect(frame).toContain("Hello, World!")
|
||||
})
|
||||
```
|
||||
|
||||
### Snapshot Testing
|
||||
|
||||
```tsx
|
||||
import { test, expect, afterEach } from "bun:test"
|
||||
import { testRender } from "@opentui/react/test-utils"
|
||||
|
||||
let testSetup: Awaited<ReturnType<typeof testRender>>
|
||||
|
||||
afterEach(() => {
|
||||
if (testSetup) {
|
||||
testSetup.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("component matches snapshot", async () => {
|
||||
testSetup = await testRender(
|
||||
<box style={{ width: 20, height: 5, border: true }}>
|
||||
<text>Content</text>
|
||||
</box>,
|
||||
{ width: 25, height: 8 }
|
||||
)
|
||||
|
||||
await testSetup.renderOnce()
|
||||
const frame = testSetup.captureCharFrame()
|
||||
|
||||
expect(frame).toMatchSnapshot()
|
||||
})
|
||||
```
|
||||
|
||||
### State Testing
|
||||
|
||||
```tsx
|
||||
import { test, expect, afterEach } from "bun:test"
|
||||
import { useState } from "react"
|
||||
import { testRender } from "@opentui/react/test-utils"
|
||||
|
||||
let testSetup: Awaited<ReturnType<typeof testRender>>
|
||||
|
||||
afterEach(() => {
|
||||
if (testSetup) {
|
||||
testSetup.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
function Counter() {
|
||||
const [count, setCount] = useState(0)
|
||||
return (
|
||||
<box>
|
||||
<text>Count: {count}</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
test("Counter shows initial value", async () => {
|
||||
testSetup = await testRender(
|
||||
<Counter />,
|
||||
{ width: 20, height: 5 }
|
||||
)
|
||||
|
||||
await testSetup.renderOnce()
|
||||
const frame = testSetup.captureCharFrame()
|
||||
|
||||
expect(frame).toContain("Count: 0")
|
||||
})
|
||||
```
|
||||
|
||||
### Test Setup/Teardown Pattern
|
||||
|
||||
For multiple tests, use beforeEach/afterEach to manage the renderer lifecycle:
|
||||
|
||||
```tsx
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { testRender } from "@opentui/react/test-utils"
|
||||
|
||||
let testSetup: Awaited<ReturnType<typeof testRender>>
|
||||
|
||||
describe("MyComponent", () => {
|
||||
beforeEach(async () => {
|
||||
if (testSetup) {
|
||||
testSetup.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (testSetup) {
|
||||
testSetup.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("renders correctly", async () => {
|
||||
testSetup = await testRender(<MyComponent />, {
|
||||
width: 40,
|
||||
height: 10,
|
||||
})
|
||||
|
||||
await testSetup.renderOnce()
|
||||
const frame = testSetup.captureCharFrame()
|
||||
expect(frame).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Test Setup Return Object
|
||||
|
||||
The `testRender` function returns a test setup object with these properties:
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `renderer` | `Renderer` | The headless renderer instance |
|
||||
| `renderOnce` | `() => Promise<void>` | Triggers a single render cycle |
|
||||
| `captureCharFrame` | `() => string` | Captures current output as text |
|
||||
| `resize` | `(width, height) => void` | Resize the virtual terminal |
|
||||
|
||||
## Solid Testing
|
||||
|
||||
### Test Utilities
|
||||
|
||||
Solid exports `testRender` directly from the main package:
|
||||
|
||||
```tsx
|
||||
import { testRender } from "@opentui/solid"
|
||||
```
|
||||
|
||||
Note: Unlike React, Solid's `testRender` takes a **function component** (not a JSX element).
|
||||
|
||||
### Basic Component Test
|
||||
|
||||
```tsx
|
||||
import { test, expect } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
|
||||
function Greeting(props: { name: string }) {
|
||||
return <text>Hello, {props.name}!</text>
|
||||
}
|
||||
|
||||
test("Greeting renders name", async () => {
|
||||
const testSetup = await testRender(
|
||||
() => <Greeting name="World" />,
|
||||
{ width: 80, height: 24 }
|
||||
)
|
||||
|
||||
await testSetup.renderOnce()
|
||||
const frame = testSetup.captureCharFrame()
|
||||
|
||||
expect(frame).toContain("Hello, World!")
|
||||
})
|
||||
```
|
||||
|
||||
### Snapshot Testing
|
||||
|
||||
```tsx
|
||||
import { test, expect, afterEach } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
|
||||
let testSetup: Awaited<ReturnType<typeof testRender>>
|
||||
|
||||
afterEach(() => {
|
||||
if (testSetup) {
|
||||
testSetup.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("component matches snapshot", async () => {
|
||||
testSetup = await testRender(
|
||||
() => (
|
||||
<box style={{ width: 20, height: 5, border: true }}>
|
||||
<text>Content</text>
|
||||
</box>
|
||||
),
|
||||
{ width: 25, height: 8 }
|
||||
)
|
||||
|
||||
await testSetup.renderOnce()
|
||||
const frame = testSetup.captureCharFrame()
|
||||
|
||||
expect(frame).toMatchSnapshot()
|
||||
})
|
||||
```
|
||||
|
||||
## Snapshot Format
|
||||
|
||||
Snapshots capture the rendered terminal output as text:
|
||||
|
||||
```
|
||||
┌──────────────────┐
|
||||
│ Hello, World! │
|
||||
│ │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
### Updating Snapshots
|
||||
|
||||
```bash
|
||||
bun test --update-snapshots
|
||||
```
|
||||
|
||||
## Interaction Testing
|
||||
|
||||
### Simulating Key Presses
|
||||
|
||||
```typescript
|
||||
import { test, expect, afterEach } from "bun:test"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
|
||||
let testSetup: Awaited<ReturnType<typeof createTestRenderer>>
|
||||
|
||||
afterEach(() => {
|
||||
if (testSetup) {
|
||||
testSetup.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("responds to keyboard", async () => {
|
||||
testSetup = await createTestRenderer({
|
||||
width: 40,
|
||||
height: 10,
|
||||
})
|
||||
|
||||
// Create component that responds to keys
|
||||
// ...
|
||||
|
||||
// Simulate keypress
|
||||
testSetup.renderer.keyInput.emit("keypress", {
|
||||
name: "enter",
|
||||
sequence: "\r",
|
||||
ctrl: false,
|
||||
shift: false,
|
||||
meta: false,
|
||||
option: false,
|
||||
eventType: "press",
|
||||
repeated: false,
|
||||
})
|
||||
|
||||
// Render after the keypress
|
||||
await testSetup.renderOnce()
|
||||
|
||||
expect(testSetup.captureCharFrame()).toContain("Selected")
|
||||
})
|
||||
```
|
||||
|
||||
### Testing Focus
|
||||
|
||||
```typescript
|
||||
import { test, expect, afterEach } from "bun:test"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { InputRenderable } from "@opentui/core"
|
||||
|
||||
let testSetup: Awaited<ReturnType<typeof createTestRenderer>>
|
||||
|
||||
afterEach(() => {
|
||||
if (testSetup) {
|
||||
testSetup.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("input receives focus", async () => {
|
||||
testSetup = await createTestRenderer({
|
||||
width: 40,
|
||||
height: 10,
|
||||
})
|
||||
|
||||
const input = new InputRenderable(testSetup.renderer, {
|
||||
id: "test-input",
|
||||
placeholder: "Type here",
|
||||
})
|
||||
testSetup.renderer.root.add(input)
|
||||
|
||||
input.focus()
|
||||
|
||||
expect(input.isFocused()).toBe(true)
|
||||
})
|
||||
```
|
||||
|
||||
## Test Organization
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/
|
||||
│ ├── Button.tsx
|
||||
│ └── Button.test.tsx
|
||||
├── hooks/
|
||||
│ ├── useCounter.ts
|
||||
│ └── useCounter.test.ts
|
||||
└── test-utils.tsx
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
bun test
|
||||
|
||||
# Run specific test file
|
||||
bun test src/components/Button.test.tsx
|
||||
|
||||
# Run with filter
|
||||
bun test --filter "Button"
|
||||
|
||||
# Watch mode
|
||||
bun test --watch
|
||||
```
|
||||
|
||||
## Patterns
|
||||
|
||||
### Testing Conditional Rendering (React)
|
||||
|
||||
```tsx
|
||||
import { test, expect, afterEach } from "bun:test"
|
||||
import { testRender } from "@opentui/react/test-utils"
|
||||
|
||||
let testSetup: Awaited<ReturnType<typeof testRender>>
|
||||
|
||||
afterEach(() => {
|
||||
if (testSetup) {
|
||||
testSetup.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("shows loading state", async () => {
|
||||
testSetup = await testRender(
|
||||
<DataLoader loading={true} />,
|
||||
{ width: 40, height: 10 }
|
||||
)
|
||||
|
||||
await testSetup.renderOnce()
|
||||
expect(testSetup.captureCharFrame()).toContain("Loading...")
|
||||
})
|
||||
|
||||
test("shows data when loaded", async () => {
|
||||
testSetup = await testRender(
|
||||
<DataLoader loading={false} data={["Item 1", "Item 2"]} />,
|
||||
{ width: 40, height: 10 }
|
||||
)
|
||||
|
||||
await testSetup.renderOnce()
|
||||
const frame = testSetup.captureCharFrame()
|
||||
expect(frame).toContain("Item 1")
|
||||
expect(frame).toContain("Item 2")
|
||||
})
|
||||
```
|
||||
|
||||
### Testing Lists
|
||||
|
||||
```tsx
|
||||
test("renders all items", async () => {
|
||||
const items = ["Apple", "Banana", "Cherry"]
|
||||
|
||||
testSetup = await testRender(
|
||||
<ItemList items={items} />,
|
||||
{ width: 40, height: 10 }
|
||||
)
|
||||
|
||||
await testSetup.renderOnce()
|
||||
const frame = testSetup.captureCharFrame()
|
||||
|
||||
items.forEach(item => {
|
||||
expect(frame).toContain(item)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Testing Layouts
|
||||
|
||||
```tsx
|
||||
test("matches layout snapshot", async () => {
|
||||
testSetup = await testRender(
|
||||
<AppLayout />,
|
||||
{ width: 120, height: 40 } // Larger viewport
|
||||
)
|
||||
|
||||
await testSetup.renderOnce()
|
||||
expect(testSetup.captureCharFrame()).toMatchSnapshot()
|
||||
})
|
||||
```
|
||||
|
||||
## Debugging Tests
|
||||
|
||||
### Print Frame Output
|
||||
|
||||
```tsx
|
||||
import { testRender } from "@opentui/react/test-utils"
|
||||
|
||||
test("debug output", async () => {
|
||||
const testSetup = await testRender(
|
||||
<MyComponent />,
|
||||
{ width: 40, height: 10 }
|
||||
)
|
||||
|
||||
await testSetup.renderOnce()
|
||||
const frame = testSetup.captureCharFrame()
|
||||
|
||||
// Print to see what's rendered
|
||||
console.log(frame)
|
||||
|
||||
expect(frame).toContain("expected")
|
||||
})
|
||||
```
|
||||
|
||||
### Verbose Mode
|
||||
|
||||
```bash
|
||||
bun test --verbose
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
### Async Rendering
|
||||
|
||||
Always call `renderOnce()` after setting up your component to ensure rendering is complete:
|
||||
|
||||
```typescript
|
||||
const testSetup = await testRender(<MyComponent />, { width: 40, height: 10 })
|
||||
await testSetup.renderOnce() // Required before capturing frame
|
||||
const frame = testSetup.captureCharFrame()
|
||||
```
|
||||
|
||||
### Test Isolation and Cleanup
|
||||
|
||||
Always destroy the renderer after each test to avoid resource leaks:
|
||||
|
||||
```typescript
|
||||
import { afterEach } from "bun:test"
|
||||
|
||||
let testSetup: Awaited<ReturnType<typeof testRender>>
|
||||
|
||||
afterEach(() => {
|
||||
if (testSetup) {
|
||||
testSetup.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("test 1", async () => {
|
||||
testSetup = await testRender(<Component1 />, { width: 40, height: 10 })
|
||||
// ...
|
||||
})
|
||||
|
||||
test("test 2", async () => {
|
||||
testSetup = await testRender(<Component2 />, { width: 40, height: 10 })
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
### Snapshot Dimensions
|
||||
|
||||
Be consistent with test dimensions for stable snapshots:
|
||||
|
||||
```typescript
|
||||
const testSetup = await createTestRenderer({
|
||||
width: 80, // Standard width
|
||||
height: 24, // Standard height
|
||||
})
|
||||
```
|
||||
|
||||
### Running from Package Directory
|
||||
|
||||
Run tests from the package directory:
|
||||
|
||||
```bash
|
||||
cd packages/core
|
||||
bun test
|
||||
|
||||
# Not from repo root for package-specific tests
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
- [Core API](../core/api.md) - `createTestRenderer` and renderable classes
|
||||
- [React Configuration](../react/configuration.md) - React test setup
|
||||
- [Solid Configuration](../solid/configuration.md) - Solid test setup
|
||||
- [Keyboard](../keyboard/REFERENCE.md) - Simulating key events in tests
|
||||
@@ -1 +0,0 @@
|
||||
../../.cline/skills/publish-cli
|
||||
@@ -1 +0,0 @@
|
||||
../../.cline/skills/publish-desktop
|
||||
@@ -1 +0,0 @@
|
||||
../../.cline/skills/publish-extension
|
||||
@@ -1 +0,0 @@
|
||||
../../.cline/skills/tuistory
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Remove the non-functional "Use compact prompt" toggle from LM Studio provider settings
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix auto-approve checkboxes freezing after "New Task": clear the task-scoped settings overlay when the task view is cleared or switched, so stale task settings no longer shadow global settings
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: restore workflow support regressions — expand `/workflow.md` slash commands (the legacy filename spelling the autocomplete inserts) and mid-message commands, honor workflow enable/disable toggles during expansion, refresh the slash menu's workflow list on webview launch, and bring back the Workflows management tab in the rules modal (now last in the tab list, with a deprecation notice pointing to Skills)
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix hidden plan/act mode-switch and task-resumption prompts reappearing as user messages when a task is reopened from history
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: strip trailing slashes from the OpenAI Compatible base URL when fetching the model list, so `/models` is queried correctly and the model dropdown populates
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: center-align the sign-in verification code box shown after clicking "Sign in to Cline"
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: use correct base URL for Vertex AI global endpoint with Claude models
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Enable Auto Compact by default so long chats automatically compress conversation history instead of failing at the model context limit. It can be disabled in Settings → Features → "Auto Compact".
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Bring back a copy button on turn-final response rows, under a new subtle "Completed" / "Plan" header
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix /compact UX: clear the chat input as soon as the command is submitted, wrap the compaction divider row at narrow sidebar widths, and update the context-window header even when compacting a small conversation grows the estimated context
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Disable feature tips by default; they can be enabled in Settings → Features → "Feature Tips"
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Show the edited file in a regular editor tab after the diff preview closes, restoring the legacy post-edit behavior
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Hide the "View Changes" button on completion rows until there are actually changes to show, instead of rendering it faded and disabled. Turns that changed nothing, non-git workspaces, and repos without commits no longer show a dead button with a misleading tooltip.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Show the user's message in chat immediately when sending to a task opened from history, instead of only a thinking indicator until the session resume finishes
|
||||
@@ -41,11 +41,11 @@ fi
|
||||
|
||||
# Install project dependencies
|
||||
echo "Installing dependencies..."
|
||||
bun run install:all
|
||||
npm run install:all
|
||||
|
||||
# Generate gRPC/protobuf types (required for TypeScript)
|
||||
echo "Generating proto types..."
|
||||
bun run protos
|
||||
npm run protos
|
||||
|
||||
echo ""
|
||||
echo "Session setup complete!"
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/cline-sdk
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/opentui
|
||||
@@ -1 +0,0 @@
|
||||
../../.cline/skills/publish-cli
|
||||
@@ -1 +0,0 @@
|
||||
../../.cline/skills/publish-desktop
|
||||
@@ -1 +0,0 @@
|
||||
../../.cline/skills/publish-extension
|
||||
@@ -1 +0,0 @@
|
||||
../../.cline/skills/tuistory
|
||||
@@ -1,266 +0,0 @@
|
||||
---
|
||||
name: publish-cli
|
||||
description: Use when preparing, tagging, and publishing an apps/cli npm release. Guides changelog drafting, apps/cli/package.json version bumps, cli-vX.Y.Z tags, local npm publishing, and the publish-cli GitHub workflow.
|
||||
---
|
||||
|
||||
# CLI Release
|
||||
|
||||
Use this skill when the user asks to release the CLI, publish `cline`, bump the CLI version, draft release notes, create a `cli-vX.Y.Z` tag, or trigger the CLI publish workflow.
|
||||
|
||||
The CLI is npm-only. Do not add alternate distribution channels. Windows binaries are Authenticode-signed automatically by the publish workflow via Azure Trusted Signing (see the `.github/actions/sign-windows-cli` composite action and "Windows code signing" in `apps/cli/DISTRIBUTION.md`); if the signing secrets are not configured the workflow warns and publishes unsigned binaries. Local publishes (`bun release cli`) do not sign — prefer the GitHub Actions publish path for releases users run on Windows.
|
||||
|
||||
> Working directory: run every command below from the repository root. Paths and scripts (e.g. `apps/cli/package.json`, `sdk/packages/`, `bun release cli`, `bun run version`) are written relative to the repo root.
|
||||
|
||||
The skill should guide the user through one release preparation flow, then offer the publish path options. The two normal publish paths are GitHub Actions and local publishing from an authenticated machine.
|
||||
|
||||
## Release contract
|
||||
|
||||
- SDK prerequisite: the CLI depends on the SDK via `workspace:*` (`@cline/core`, `@cline/shared`, and friends). If the SDK changed since its last release, release the SDK first and wait for it to finish publishing before releasing the CLI. See "Step 0: Release the SDK first if it changed" below.
|
||||
- Version source: `apps/cli/package.json`.
|
||||
- Main release tag: `cli-vX.Y.Z`, where `X.Y.Z` matches `apps/cli/package.json`.
|
||||
- Nightly release version: `X.Y.Z-nightly.TIMESTAMP`.
|
||||
- Release prep includes approved release notes, a version bump, and an `apps/cli/CHANGELOG.md` update.
|
||||
- Publish paths:
|
||||
- GitHub workflow: `.github/workflows/cli-publish.yml`.
|
||||
- Local publish helper: `bun release cli`.
|
||||
- npm dist-tags and git tags are separate. `--tag latest` and `--tag nightly` are npm registry channels. `cli-vX.Y.Z` is a git tag for source history and GitHub releases.
|
||||
- The GitHub main release workflow runs from `main`, requires an existing `cli-vX.Y.Z` tag, checks out that tag, and publishes from it.
|
||||
- The GitHub nightly workflow publishes to npm with the `nightly` dist-tag and does not create a tag.
|
||||
- The local release helper requires a clean checkout and `cli-vX.Y.Z` to point at `HEAD` locally and on `origin` before publishing.
|
||||
- Local GitHub release creation requires `gh` to be authenticated with release permissions for the repo.
|
||||
- Always ask before pushing commits or tags.
|
||||
- Do not amend commits unless explicitly requested.
|
||||
|
||||
## Step 0: Release the SDK first if it changed
|
||||
|
||||
Do this before anything else in the Workflow below.
|
||||
|
||||
The CLI builds and ships against the SDK source in the monorepo (`workspace:*` for `@cline/core`, `@cline/shared`, and the rest), so a CLI release always contains the latest SDK code whether or not the SDK was released. The build and tests use that source too, not anything from npm. Releasing the SDK alongside the CLI is still worth doing for two reasons:
|
||||
|
||||
- Hub freshness. The hub daemon lives in `@cline/core` and stamps a `buildId` that defaults to the `@cline/core` package version (`resolveHubBuildId` in `sdk/packages/core/src/hub/discovery/index.ts`). A running hub is only retired and respawned when that `buildId` changes (`isCompatibleHubRecord` / `retireIncompatibleHub` in `sdk/packages/core/src/hub/daemon/index.ts`). So if the SDK code changed but the version did not, a user who upgrades the CLI keeps talking to their already-running hub, which is still executing the old SDK code. Bumping the SDK version makes the new CLI's `buildId` differ, so the stale hub is detected as incompatible and respawned with the fresh code.
|
||||
- Release hygiene. We want regular SDK releases; cutting one whenever we cut a CLI release keeps the published SDK in step with what the CLI ships.
|
||||
|
||||
So when the SDK has changed, release it first (which bumps the `@cline/core` version), then cut the CLI release on top of that bump. Leave the CLI's SDK dependency as `workspace:*` — the fix is to release the SDK, not to pin the CLI.
|
||||
|
||||
1. Check for unreleased SDK changes.
|
||||
|
||||
```sh
|
||||
git fetch origin --tags
|
||||
git tag --list 'sdk/sdk/v*' 'sdk-v*' --sort=-v:refname | head -1
|
||||
git log <last-sdk-tag>..origin/main --oneline --no-merges -- sdk/packages
|
||||
```
|
||||
|
||||
`sdk/<pkg>/v*` tags are created by the `sdk-publish.yml` workflow; `sdk-v*` tags are created by the local `bun release sdk` helper. Use whichever is newest as the baseline.
|
||||
|
||||
If `git log` prints no commits, the SDK is already up to date. Skip the rest of Step 0 and continue with the Workflow below.
|
||||
|
||||
If it prints commits, sanity-check the diff (ignore entries that are only the previous version-bump commit's lockfile or generated files), then release the SDK.
|
||||
|
||||
2. Decide the SDK version bump.
|
||||
|
||||
All SDK packages share one version, read from `sdk/packages/llms/package.json`. Ask whether this is patch, minor, major, or an explicit version. Patch is the default. Do not guess if the user has not made it clear.
|
||||
|
||||
3. Draft the SDK release notes and update the changelog.
|
||||
|
||||
Draft user-facing notes from the SDK commits found in step 1, translating commit messages into user-facing language (same approach as the CLI release notes below). Prepend a new `## <version>` section with those notes to the top of `sdk/CHANGELOG.md`, using the header format `## <version>` with no date — the same flat, newest-on-top format as `apps/cli/CHANGELOG.md`. This is the SDK changelog (all SDK packages share one version) and it is maintained by hand; the `sdk-publish.yml` workflow does not read it.
|
||||
|
||||
4. Bump versions and regenerate.
|
||||
|
||||
```sh
|
||||
bun run version <version>
|
||||
```
|
||||
|
||||
This bumps every SDK `package.json` to the new version, regenerates the lockfile and the generated model catalog, formats, and builds. Review the result.
|
||||
|
||||
5. Commit and push the bump to `main`.
|
||||
|
||||
The `sdk-publish.yml` workflow publishes the version that is committed on `main` and tags that commit, so the bump must land on `main` before the workflow runs.
|
||||
|
||||
```sh
|
||||
git add -A
|
||||
git commit -m "chore(sdk): release v<version>"
|
||||
```
|
||||
|
||||
Ask before pushing:
|
||||
|
||||
```sh
|
||||
git push origin HEAD
|
||||
```
|
||||
|
||||
6. Trigger the SDK publish workflow on the `latest` channel.
|
||||
|
||||
```sh
|
||||
gh workflow run sdk-publish.yml -f channel=latest -f confirm_publish=publish
|
||||
gh run list --workflow=sdk-publish.yml --limit=1 --json databaseId,url,status,createdAt --jq '.[0]'
|
||||
```
|
||||
|
||||
The workflow runs the SDK tests, publishes `@cline/shared`, `@cline/llms`, `@cline/agents`, `@cline/core`, and `@cline/sdk` to npm with the `latest` dist-tag in dependency order, and pushes `sdk/<pkg>/v<version>` git tags.
|
||||
|
||||
7. Wait for the SDK workflow to succeed before starting the CLI release.
|
||||
|
||||
```sh
|
||||
gh run watch <run-id> --exit-status
|
||||
```
|
||||
|
||||
Do not start the CLI release until this run has finished successfully. The CLI does not install the SDK from npm, but cutting the CLI release on top of a clean, completed SDK release keeps the two in step: the CLI release commit then sits on top of the `@cline/core` version bump, so the shipped CLI carries the new version that forces a running hub to respawn with the new code, and you are not building a CLI release on top of an SDK release that failed midway.
|
||||
|
||||
After the SDK release succeeds, pull `main` so the CLI release is prepared on top of the SDK version bump:
|
||||
|
||||
```sh
|
||||
git checkout main && git pull --ff-only
|
||||
```
|
||||
|
||||
Then continue with the Workflow below.
|
||||
|
||||
For a local SDK publish from an authenticated machine instead of the workflow, `bun release sdk <version>` exists, but prefer the `sdk-publish.yml` workflow for normal releases so the CLI release can gate on a single GitHub Actions run.
|
||||
|
||||
## Workflow
|
||||
|
||||
Complete Step 0 first. Only proceed once the SDK is released (or you confirmed no SDK release was needed).
|
||||
|
||||
1. Gather context.
|
||||
|
||||
```sh
|
||||
git status --short --branch
|
||||
git fetch origin --tags
|
||||
git tag --list 'cli-v*' --sort=-v:refname | head -10
|
||||
node -p "require('./apps/cli/package.json').version"
|
||||
```
|
||||
|
||||
Find the latest CLI tag. If there is no `cli-v*` tag, use the first relevant CLI release commit as the baseline and say that the baseline is inferred.
|
||||
|
||||
2. Collect release commits.
|
||||
|
||||
```sh
|
||||
git log <last-cli-tag>..HEAD --oneline --no-merges -- apps/cli sdk/packages sdk/scripts .github/workflows/cli-publish.yml
|
||||
```
|
||||
|
||||
The `sdk/packages` commits matter here even though the SDK was released separately in Step 0: the CLI bundles the SDK, so SDK changes ship in this CLI release too. Read those commits and fold anything user-relevant to the CLI into the release notes (provider/model updates, behavior changes, fixes the CLI inherits). Skip SDK changes that are purely internal or have no CLI-visible effect.
|
||||
|
||||
3. Draft user-facing release notes.
|
||||
|
||||
Include user-facing features, fixes, behavior changes, compatibility changes, and notable install or release changes. Exclude pure refactors, tests, style, chores, and internal file moves unless they matter to users.
|
||||
|
||||
Write a flat bullet list. Translate commit messages into user-facing language. If a commit is unclear, read the full commit before summarizing it.
|
||||
|
||||
Present the draft and wait for approval before editing files.
|
||||
|
||||
4. Decide the version bump.
|
||||
|
||||
Ask whether this should be patch, minor, major, or an explicit version. Do not guess if the user has not made it clear.
|
||||
|
||||
5. Update release files.
|
||||
|
||||
Update `apps/cli/package.json` to the approved version.
|
||||
|
||||
Prepend a section to `apps/cli/CHANGELOG.md` for the approved version using the approved release notes. Use the header format `## X.Y.Z` with no date. The publish workflow extracts the top section of the changelog by matching `^## [0-9]` and pastes it verbatim into the GitHub release body and the Slack release announcement, so the section content is the release notes that get shipped.
|
||||
|
||||
6. Verify before committing.
|
||||
|
||||
Run focused checks first:
|
||||
|
||||
```sh
|
||||
bun -F @cline/cli typecheck
|
||||
bun -F @cline/cli test:unit
|
||||
```
|
||||
|
||||
For higher confidence, run:
|
||||
|
||||
```sh
|
||||
bun run types
|
||||
bun --cwd apps/cli run build:platforms:single
|
||||
```
|
||||
|
||||
If the user wants full release confidence before tagging, run:
|
||||
|
||||
```sh
|
||||
bun run test
|
||||
bun --cwd apps/cli run build:platforms
|
||||
```
|
||||
|
||||
Known local-only test failure: `src/commands/distribution-package.test.ts > rejects direct source package packing by default` will fail on machines that have `ignore-scripts=true` in `~/.npmrc` (set by the npm supply-chain hardening guide). Bun reads npm's `ignore-scripts` from `~/.npmrc`, so `bun pm pack --dry-run` skips the source-publish `prepack` guard and exits 0, which the test reads as a failure. CI does not set `ignore-scripts`, so the test passes there. Confirm by running `bun pm pack --dry-run` directly: with `~/.npmrc` in place it exits 0 with no guard output; with `~/.npmrc` moved aside it exits 1 and prints the guard message. This is not a release blocker by itself, but it does mean the local-publish path (`bun release cli`) will also bypass the source-publish guard on this machine; prefer the GitHub Actions publish path on machines with `ignore-scripts=true` set globally, or temporarily unset it (`npm config delete ignore-scripts` or `mv ~/.npmrc ~/.npmrc.bak`) for the duration of a local publish.
|
||||
|
||||
7. Commit release changes.
|
||||
|
||||
Only after the user approves the notes and version:
|
||||
|
||||
```sh
|
||||
git add apps/cli/package.json apps/cli/CHANGELOG.md
|
||||
git commit -m "chore(cli): release vX.Y.Z"
|
||||
```
|
||||
|
||||
Ask before pushing the release commit:
|
||||
|
||||
```sh
|
||||
git push origin HEAD
|
||||
```
|
||||
|
||||
For the GitHub main release path, ask before creating and pushing the release tag:
|
||||
|
||||
```sh
|
||||
git tag -a cli-vX.Y.Z -m "CLI vX.Y.Z"
|
||||
git push origin refs/tags/cli-vX.Y.Z
|
||||
```
|
||||
|
||||
8. Publish.
|
||||
|
||||
Ask the user which path to use:
|
||||
|
||||
- GitHub main release. Use this after the release commit is on `main` and the matching `cli-vX.Y.Z` tag has been pushed. The workflow publishes to npm from that tag, creates the GitHub release, and posts to Slack.
|
||||
- Local release. Use this when the user wants to publish from this machine. The local machine must be authenticated to npm and GitHub.
|
||||
- GitHub nightly release.
|
||||
- Stop after the version commit.
|
||||
|
||||
For GitHub main release:
|
||||
|
||||
```sh
|
||||
gh workflow run cli-publish.yml -f publish_target=main -f git_tag=cli-vX.Y.Z -f confirm_publish=publish
|
||||
gh run list --workflow=cli-publish.yml --limit=1 --json url,status,conclusion,createdAt --jq '.[0]'
|
||||
```
|
||||
|
||||
For GitHub nightly release:
|
||||
|
||||
```sh
|
||||
gh workflow run cli-publish.yml -f publish_target=nightly
|
||||
```
|
||||
|
||||
For forced GitHub nightly release:
|
||||
|
||||
```sh
|
||||
gh workflow run cli-publish.yml -f publish_target=nightly -f force_nightly_publish=true
|
||||
```
|
||||
|
||||
For local publish:
|
||||
|
||||
```sh
|
||||
gh auth status
|
||||
npm whoami
|
||||
git tag -a cli-vX.Y.Z -m "CLI vX.Y.Z"
|
||||
git push origin refs/tags/cli-vX.Y.Z
|
||||
bun release cli
|
||||
```
|
||||
|
||||
After a successful local publish, ask before running:
|
||||
|
||||
```sh
|
||||
gh release create cli-vX.Y.Z --verify-tag --title "CLI vX.Y.Z" --notes "Paste the approved release notes here."
|
||||
```
|
||||
|
||||
If publishing with another npm dist-tag:
|
||||
|
||||
```sh
|
||||
bun release cli --tag next
|
||||
```
|
||||
|
||||
9. Final response.
|
||||
|
||||
Report:
|
||||
|
||||
- version
|
||||
- tag
|
||||
- changelog file updated
|
||||
- commit hash
|
||||
- whether anything was pushed
|
||||
- publish path selected
|
||||
- workflow URL or local publish result
|
||||
- tests and builds run
|
||||
@@ -1,177 +0,0 @@
|
||||
---
|
||||
name: publish-desktop
|
||||
description: Use when preparing, tagging, and publishing a Cline desktop app (apps/examples/desktop-app) release — stable (desktop-vX.Y.Z from main) or beta (desktop-vX.Y.Z-beta.N from desktop-experimental, shipped as the side-by-side "Cline Beta" app). Guides changelog drafting, version bumps in package.json + tauri.conf.json, tagging, and the desktop-publish GitHub workflow that builds, signs, notarizes, and updates the per-channel auto-update feed.
|
||||
---
|
||||
|
||||
# Desktop App Release
|
||||
|
||||
Use this skill when the user asks to release the desktop app, publish the Cline desktop app, cut a desktop beta, bump the desktop version, create a `desktop-vX.Y.Z` (or `desktop-vX.Y.Z-beta.N`) tag, or trigger the desktop publish workflow.
|
||||
|
||||
> Working directory: run every command below from the repository root.
|
||||
|
||||
Desktop releases ship two platforms, built entirely in GitHub Actions — there is no local publish path. macOS: a single signed + notarized universal DMG that runs natively on both Apple Silicon and Intel. Windows: an Authenticode-signed NSIS installer (`<Product>_<version>_x64-setup.exe`), signed via Azure Trusted Signing in the `build-windows` job (jsign through Tauri's `signCommand`, see `apps/examples/desktop-app/scripts/tauri-sign-windows.ps1`; requires the repo-level `AZURE_*` secrets including `AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP`, plus a `PublishDesktop`-environment federated credential on the `cline-cli-signing` Entra app). Installed apps discover new releases automatically through the Tauri updater, so publishing a release is what ships the update to every existing user **on that channel**.
|
||||
|
||||
## Release contract
|
||||
|
||||
- Two channels, one workflow (`channel` input on `desktop-publish.yml`):
|
||||
- **stable** — tag `desktop-vX.Y.Z` (no suffix; the workflow rejects prerelease suffixes on this channel), cut from `main`, feeds the rolling `desktop-latest` release, ships as "Cline".
|
||||
- **beta** — tag `desktop-vX.Y.Z-beta.N`, cut from `desktop-experimental`, feeds the rolling `desktop-beta` release, ships as "Cline Beta" (separate bundle identifier `bot.cline.app.beta`; installs side by side with stable). Built with the extra `src-tauri/tauri.beta.conf.json` overlay. Process background: `apps/examples/desktop-app/EXPERIMENTAL.md`.
|
||||
- Version sources (must match each other and the tag): `apps/examples/desktop-app/package.json` and `apps/examples/desktop-app/src-tauri/tauri.conf.json`. (`src-tauri/Cargo.toml` has its own version but `tauri.conf.json` overrides it; no need to touch it.)
|
||||
- Beta versions are prereleases of the **next** stable: stable `0.0.13` → betas `0.0.14-beta.1`, `-beta.2`, … Once a stable ≥ the beta base ships, the next beta bumps its base (`0.0.15-beta.1`).
|
||||
- Release prep includes approved release notes, the version bumps, and an `apps/examples/desktop-app/CHANGELOG.md` update — committed on `main` for stable, on `desktop-experimental` for beta.
|
||||
- Publish path: `.github/workflows/desktop-publish.yml` (workflow_dispatch, requires the tag to exist, point at the checked-out commit, and be reachable from the channel's branch — `origin/main` for stable, `origin/desktop-experimental` for beta).
|
||||
- **Both channels dispatch from `main`.** This is a security invariant, not a convenience: the run executes `main`'s workflow copy and only the checkout points at the tag, so the signing-secret gates (the `github.ref == main` check and the PublishDesktop environment's main-only deployment-branch policy) hold for beta too. Never add `desktop-experimental` to the PublishDesktop deployment-branch policy.
|
||||
- The workflow creates the tag's GitHub release (universal DMG + macOS updater artifact + Windows NSIS installer with its updater signature + `latest.json`; marked prerelease for beta) and refreshes the channel's rolling feed release, which is the static auto-update feed every installed app on that channel polls. Never delete the `desktop-latest` or `desktop-beta` release or tag.
|
||||
- The changelog's `## <version>` section (exact-match, not "topmost") is extracted verbatim into the GitHub release body, the Slack announcement, and the updater manifest notes.
|
||||
- Always ask before pushing commits or tags.
|
||||
|
||||
## Workflow
|
||||
|
||||
0. Ask which channel this release is for — **stable or beta** — if the user has not said. Everything below branches on it; never guess.
|
||||
|
||||
1. Gather context.
|
||||
|
||||
```sh
|
||||
git status --short --branch
|
||||
git fetch origin --tags
|
||||
git tag --list 'desktop-v*' --sort=-v:refname | head -10
|
||||
node -p "require('./apps/examples/desktop-app/package.json').version"
|
||||
node -p "require('./apps/examples/desktop-app/src-tauri/tauri.conf.json').version"
|
||||
```
|
||||
|
||||
If there is no `desktop-v*` tag yet, this is the first release; use the desktop app's first commit as the baseline and say the baseline is inferred.
|
||||
|
||||
For a **beta** release, work on `desktop-experimental` (check out `origin/desktop-experimental`; merge `origin/main` into it first if it is behind — see EXPERIMENTAL.md for the conflict policy) and read the version files from that branch. The last-tag baseline is the newest `desktop-v*` tag of either channel that is an ancestor of the branch.
|
||||
|
||||
2. Collect release commits.
|
||||
|
||||
```sh
|
||||
# stable (on main):
|
||||
git log <last-desktop-tag>..HEAD --oneline --no-merges -- apps/examples/desktop-app sdk/packages .github/workflows/desktop-publish.yml
|
||||
# beta (on desktop-experimental):
|
||||
git log <last-desktop-tag>..origin/desktop-experimental --oneline --no-merges -- apps/examples/desktop-app sdk/packages .github/workflows/desktop-publish.yml
|
||||
```
|
||||
|
||||
The sidecar bundles `@cline/core` and friends from the monorepo, so SDK changes ship inside the desktop app too. Fold user-visible SDK changes (providers, models, behavior fixes) into the notes; skip purely internal ones.
|
||||
|
||||
3. Draft user-facing release notes.
|
||||
|
||||
Flat bullet list, user-facing language. Present the draft and wait for approval before editing files.
|
||||
|
||||
4. Decide the version bump.
|
||||
|
||||
Stable: ask whether this is patch, minor, major, or an explicit version. Do not guess if the user has not made it clear.
|
||||
|
||||
Beta: apply the versioning rule — base = next stable version, increment `N` (`0.0.14-beta.1` → `0.0.14-beta.2`; after stable `0.0.14` ships, next is `0.0.15-beta.1`). Confirm the computed version with the user.
|
||||
|
||||
5. Update release files (on `main` for stable, on `desktop-experimental` for beta).
|
||||
|
||||
- `apps/examples/desktop-app/package.json` → new version
|
||||
- `apps/examples/desktop-app/src-tauri/tauri.conf.json` → same version
|
||||
- Prepend `## X.Y.Z` (no date; `## X.Y.Z-beta.N` for beta) to `apps/examples/desktop-app/CHANGELOG.md` with the approved notes.
|
||||
|
||||
6. Verify before committing.
|
||||
|
||||
```sh
|
||||
bun -F @cline/code typecheck
|
||||
bun test apps/examples/desktop-app/scripts/generate-update-manifest.test.ts
|
||||
```
|
||||
|
||||
The full desktop bundle can only be built on macOS; the workflow's build job is the real verification. For extra local confidence on a Mac checkout, `bun run package:desktop:mac --allow-unsigned-mac` from the app directory.
|
||||
|
||||
7. Commit release changes.
|
||||
|
||||
```sh
|
||||
git add apps/examples/desktop-app/package.json apps/examples/desktop-app/src-tauri/tauri.conf.json apps/examples/desktop-app/CHANGELOG.md
|
||||
git commit -m "chore(desktop): release vX.Y.Z"
|
||||
```
|
||||
|
||||
Ask before pushing the release commit, then before creating and pushing the tag:
|
||||
|
||||
```sh
|
||||
git push origin HEAD
|
||||
git tag -a desktop-vX.Y.Z -m "Desktop vX.Y.Z" # beta: desktop-vX.Y.Z-beta.N / "Desktop vX.Y.Z-beta.N"
|
||||
git push origin refs/tags/desktop-vX.Y.Z
|
||||
```
|
||||
|
||||
8. Publish.
|
||||
|
||||
The release commit must be on the channel's branch (`main` for stable, `desktop-experimental` for beta) and the tag pushed first. Dispatch from `main` for **both** channels (see the release contract for why).
|
||||
|
||||
```sh
|
||||
# stable:
|
||||
gh workflow run desktop-publish.yml --ref main -f git_tag=desktop-vX.Y.Z -f channel=stable -f confirm_publish=publish
|
||||
# beta:
|
||||
gh workflow run desktop-publish.yml --ref main -f git_tag=desktop-vX.Y.Z-beta.N -f channel=beta -f confirm_publish=publish
|
||||
|
||||
gh run list --workflow=desktop-publish.yml --limit=1 --json url,status,conclusion,createdAt --jq '.[0]'
|
||||
```
|
||||
|
||||
**The run pauses for approval.** `validate` runs immediately, then the `build`
|
||||
job waits on the `PublishDesktop` environment until a required reviewer approves
|
||||
it — the run sits in `waiting`, which is expected, not a hang. Approve it in the
|
||||
run's web UI ("Review deployments"), or:
|
||||
|
||||
```sh
|
||||
gh api repos/cline/cline/actions/runs/<run-id>/pending_deployments \
|
||||
--method POST -f state=approved -f comment="desktop vX.Y.Z" \
|
||||
-F 'environment_ids[]=19152605990' # PublishDesktop
|
||||
```
|
||||
|
||||
Nothing after `validate` runs — and no signing key is readable — until then.
|
||||
|
||||
The workflow builds one universal macOS bundle (`tauri build --target universal-apple-darwin` lipos the aarch64 + x86_64 Rust binaries; the Bun sidecar is lipo'd by `build-sidecar-bin.ts`; beta adds the `tauri.beta.conf.json` overlay), verifies every Mach-O in the bundle carries both slices and that the compiled binary embeds exactly its own channel's feed URL, signs with the Developer ID certificate, notarizes with the App Store Connect API key, and signs the updater artifact with the Tauri updater key. In parallel, `build-windows` builds the x64 NSIS installer on a Windows runner, Authenticode-signs every binary via Azure Trusted Signing (Tauri `signCommand` -> `scripts/tauri-sign-windows.ps1`), runs the same feed-endpoint and telemetry guardrails, and verifies the shipped installer with `Get-AuthenticodeSignature`. The release job then creates the GitHub release (prerelease for beta), refreshes the channel's feed (`desktop-latest/latest.json` or `desktop-beta/latest.json`), and posts to Slack. Notarization typically adds 2–10 minutes.
|
||||
|
||||
If the workflow fails on missing credentials, see "Publish secrets (one-time setup)" below.
|
||||
|
||||
9. Verify the update feed after the run succeeds.
|
||||
|
||||
```sh
|
||||
curl -sL https://github.com/cline/cline/releases/download/desktop-latest/latest.json | head -30 # stable
|
||||
curl -sL https://github.com/cline/cline/releases/download/desktop-beta/latest.json | head -30 # beta
|
||||
```
|
||||
|
||||
The `version` field must be the new release; both `darwin-aarch64` and `darwin-x86_64` entries must point at the same new universal `.app.tar.gz` asset under the release tag (each slice of the fat binary requests its own arch key at runtime, so both keys serve the one artifact), and the `windows-x86_64` entry must point at the new `*_x64-setup.exe` asset. Installed apps on that channel — including older per-arch installs — pick the update up on next launch or within 2 hours.
|
||||
|
||||
After a **beta** publish, also confirm the stable feed was not touched: `desktop-latest/latest.json` must still serve the previous stable version. (The workflow guards this fail-closed, but it is cheap to verify and catastrophic to miss — the updater comparator is a plain semver "newer than", so a beta manifest on `desktop-latest` would auto-update every stable install onto the beta.)
|
||||
|
||||
10. Final response.
|
||||
|
||||
Report: channel, version, tag, changelog updated, commit hash, what was pushed, workflow URL, and the feed verification result.
|
||||
|
||||
## Publish secrets (one-time setup)
|
||||
|
||||
These live on the **`PublishDesktop` environment**, not at repository level, so
|
||||
only the `build` job can read them and only after an approval. Set them under
|
||||
Settings → Environments → PublishDesktop → Environment secrets. The environment
|
||||
also restricts deployments to `main` and requires a reviewer.
|
||||
|
||||
Adding one of these as a *repository* secret is the common mistake. The build
|
||||
would still succeed — an environment-gated job resolves repository secrets too,
|
||||
with environment values simply taking precedence — so the credential would sit
|
||||
repo-wide while everything looked fine. `validate` therefore fails the run if any
|
||||
of them resolves in a job with no environment. If you hit that, delete the
|
||||
repository-level copy rather than duplicating it.
|
||||
|
||||
If a secret is missing everywhere, the preflight in `build` fails the run naming
|
||||
the missing entries. The Apple values come from the same Apple Developer account
|
||||
used for manual signing (see the app README's "macOS signing & notarization"
|
||||
section for how to obtain them):
|
||||
|
||||
| Secret | Value |
|
||||
| --- | --- |
|
||||
| `APPLE_CERTIFICATE` | Base64 of the **Developer ID Application** identity exported from Keychain Access as `.p12` (must include the private key): `base64 -i certificate.p12 \| pbcopy` |
|
||||
| `APPLE_CERTIFICATE_PASSWORD` | The password chosen when exporting the `.p12` |
|
||||
| `APPLE_SIGNING_IDENTITY` | `Developer ID Application: <Team Name> (<TEAMID>)` — from `security find-identity -v -p codesigning` |
|
||||
| `APPLE_API_KEY` | App Store Connect API **Key ID** (notarization) |
|
||||
| `APPLE_API_KEY_CONTENT` | Contents of the `AuthKey_<KEYID>.p8` file |
|
||||
| `APPLE_API_ISSUER` | App Store Connect **Issuer ID** (UUID from Users and Access → Integrations) |
|
||||
| `TAURI_SIGNING_PRIVATE_KEY` | Contents of the Tauri updater private key (`tauri signer generate`). If this key is ever lost, shipped apps can no longer verify updates — guard it. |
|
||||
| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Password for that key |
|
||||
|
||||
The Slack + telemetry secrets (`SLACK_RELEASE_BOT_TOKEN`, `TELEMETRY_SERVICE_API_KEY`,
|
||||
`ERROR_SERVICE_API_KEY`, OTEL settings) are shared with the CLI, SDK, and extension
|
||||
publish workflows and already configured. **Do not move these into
|
||||
`PublishDesktop`** — scoping them to this environment empties them in every other
|
||||
publish workflow, silently, with no error beyond missing telemetry and a failed
|
||||
Slack post.
|
||||
@@ -1,186 +0,0 @@
|
||||
---
|
||||
name: publish-extension
|
||||
description: Use when releasing the Cline VS Code extension — stable (currently the combined legacy+next A/B VSIX via ext-vscode-ab-package), nightly (ext-vscode-publish-nightly), or a legacy-branch hotfix (ext-vscode-publish-legacy). Guides version selection, changelog, PostHog rollout-flag coordination, workflow dispatch, environment approvals, tagging, and post-publish verification, plus the eventual cutover to publishing the SDK extension standalone.
|
||||
---
|
||||
|
||||
# VS Code Extension Release
|
||||
|
||||
Use this skill when the user asks to release, publish, or ship the VS Code extension — stable, nightly, or a legacy hotfix — or to dial the rollout, or to cut over to the SDK extension permanently.
|
||||
|
||||
> Working directory: repo root. All workflows are dispatched from `main` (GitHub requires the workflow file on the default branch; each workflow checks out the refs it actually builds).
|
||||
|
||||
## The current era: combined A/B rollout
|
||||
|
||||
We are mid-migration from the legacy (npm, pre-SDK) extension to the next (SDK-based, bun) extension. Until the cutover is complete, **the stable and nightly listings ship a combined VSIX**: a small loader + two complete extensions (`next/` built from `main`, `legacy/` built from the `legacy-extension` branch). The loader picks one per window based on the PostHog flag `ext-sdk-bundle-rollout`. Deep-dive docs: `apps/vscode-rollout/README.md` (authoritative) and PR #12253 (design + runbook comments).
|
||||
|
||||
Endgame (see "Cutover" at the bottom): once the next bundle is trusted at 100%, stable goes back to a plain build of `main` via `ext-vscode-publish-stable.yml` and all the legacy/rollout machinery is retired.
|
||||
|
||||
### The listings and the workflows
|
||||
|
||||
| Channel | Marketplace ID | Workflow | Trigger | Version |
|
||||
|---|---|---|---|---|
|
||||
| Stable (combined) | `saoudrizwan.claude-dev` | `ext-vscode-ab-package.yml` | dispatch only; `publish` input defaults false | manual input (semver, e.g. `4.1.0`) |
|
||||
| Nightly (combined) | `saoudrizwan.cline-nightly` | `ext-vscode-publish-nightly.yml` | cron 12:00 UTC + dispatch | auto `<major>.<minor>.<unix-ts>` from main's `apps/vscode/package.json` |
|
||||
| Legacy hotfix (standalone) | `saoudrizwan.claude-dev` | `ext-vscode-publish-legacy.yml` | dispatch | from `apps/vscode/package.json` on `legacy-extension` |
|
||||
| Stable standalone (post-cutover) | `saoudrizwan.claude-dev` | `ext-vscode-publish-stable.yml` | dispatch | from `apps/vscode/package.json` on `main` |
|
||||
|
||||
All three publish paths gate on tests before publishing: nightly and ab-package run the reusable bun suite (`ext-vscode-test.yml`, tests `main`) — ab-package additionally runs the legacy branch's npm suite — and the legacy workflow inlines the npm suite. Environment gates: stable paths use `publish` → `Publish` environment (required reviewers approve in the Actions UI); nightly uses `PublishNightly` (branch policy only, no reviewers — a reviewer requirement would block the cron).
|
||||
|
||||
## Golden rules (read before any release)
|
||||
|
||||
1. **One listing, one version line.** `claude-dev` is published from multiple workflows/branches. Every stable publish must use a version **strictly above the highest version ever published to the listing from any branch** — marketplace versions are monotonic and cannot be unpublished (supersede, never delete). Check what's live first:
|
||||
|
||||
```bash
|
||||
curl -s -X POST "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery" \
|
||||
-H "Content-Type: application/json" -H "Accept: application/json;api-version=3.0-preview.1" \
|
||||
-d '{"filters":[{"criteria":[{"filterType":7,"value":"saoudrizwan.claude-dev"}]}],"flags":16}' \
|
||||
| python3 -c "import json,sys; v=json.load(sys.stdin)['results'][0]['extensions'][0]['versions'][0]; print(v['version'], v['lastUpdated'])"
|
||||
```
|
||||
|
||||
`ext-vscode-ab-package` also enforces this automatically for `publish=true` runs: a preflight job validates the version format (plain `X.Y.Z`) and hard-fails unless it exceeds the live Marketplace version, and the publish job re-checks right before publishing (the approval wait can last days — a legacy hotfix landing in between is caught). Still run the query yourself when *choosing* the version.
|
||||
|
||||
2. **Check the flag BEFORE any stable combined publish.** `ext-sdk-bundle-rollout` is **shared between nightly and stable** — the loader sends only a machine id to `/decide`, no channel property, so there is no per-channel targeting. If the flag is high (nightly dogfooding) and you publish stable, stable users get the next bundle at that same percentage. Verify the effective percentage empirically (no PostHog admin needed — sample `/decide` with random ids using the key inlined in any shipped loader):
|
||||
|
||||
```bash
|
||||
node -e '
|
||||
const KEY = process.argv[1]; // phc_... extracted from a shipped VSIX loader
|
||||
(async () => {
|
||||
let t = 0, n = 200;
|
||||
for (let i = 0; i < n; i += 20) {
|
||||
const rs = await Promise.all(Array.from({length: 20}, (_, j) =>
|
||||
fetch("https://data.cline.bot/decide?v=3", { method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({api_key: KEY, distinct_id: `probe-${i+j}-${Math.random()}`})
|
||||
}).then(r => r.json())));
|
||||
for (const r of rs) if ((r.featureFlags||{})["ext-sdk-bundle-rollout"] === true) t++;
|
||||
}
|
||||
console.log(`~${(100*t/n).toFixed(1)}% (${t}/${n})`);
|
||||
})()' "$KEY"
|
||||
```
|
||||
|
||||
Flag changes are made in the PostHog UI (Cline project). **0% is the kill switch** — the flag is two-way; there is no separate killswitch flag. Dialing down demotes machines back to legacy on their next window reload.
|
||||
|
||||
3. **Ask before pushing** commits or tags. Environment approvals are the maintainer's to give.
|
||||
|
||||
4. **Changelog lives at the repo ROOT** (`CHANGELOG.md`), on the branch being released — not `apps/vscode/CHANGELOG.md` (doesn't exist). The legacy and stable workflows hard-fail unless the first heading is exactly `## [<version>]`.
|
||||
|
||||
5. **Stuck concurrency groups**: `ext-vscode-ab-package` groups on the version with `cancel-in-progress: false`. Only `publish=true` runs wait on environment approval (build-only rehearsals run ungated to completion), but a publish run left `waiting` still blocks every later dispatch of the same version — cancel it (`gh run cancel <id>`) before re-dispatching.
|
||||
|
||||
## Stable release (combined A/B VSIX) — the current stable path
|
||||
|
||||
### Pre-flight
|
||||
|
||||
```bash
|
||||
# 1. What's live, and what version comes next (must exceed it — rule 1)
|
||||
# 2. Flag percentage (rule 2) — decide where it should be for this release
|
||||
# 3. Legacy tip = what the non-promoted cohort will run; confirm it's the shipped hotfix line
|
||||
git fetch origin main legacy-extension
|
||||
git log --oneline -3 origin/legacy-extension
|
||||
|
||||
# 4. Cheap local rehearsal of the most likely build failure: the union manifest
|
||||
# hard-fails if views/viewsContainers/configuration diverged between branches.
|
||||
git show origin/main:apps/vscode/package.json > /tmp/next.json
|
||||
git show origin/legacy-extension:apps/vscode/package.json > /tmp/legacy.json
|
||||
node apps/vscode-rollout/scripts/gen-manifest.mjs --next /tmp/next.json --legacy /tmp/legacy.json --version <VERSION>
|
||||
# Expected warnings only: engines union (takes newer) + walkthrough copy drift.
|
||||
```
|
||||
|
||||
Release prep on `main` (PR, not direct push):
|
||||
- Add `## [<VERSION>]` entry at the top of root `CHANGELOG.md`.
|
||||
- Bump `apps/vscode/package.json` to `<VERSION>` so the repo reflects the published line. Side effect: nightly versions become `<major>.<minor>.<unix-ts>` of the new base — harmless (separate listing, still monotonic).
|
||||
|
||||
### Dispatch
|
||||
|
||||
```bash
|
||||
gh workflow run ext-vscode-ab-package.yml --ref main \
|
||||
-f version=<VERSION> -f next-ref=main -f publish=true
|
||||
# (the legacy bundle always builds from the protected legacy-extension branch;
|
||||
# it is deliberately not an input)
|
||||
# publish=false builds an installable .vsix artifact without publishing and
|
||||
# needs NO environment approval — the ungated build job uploads the artifact
|
||||
# and the run completes.
|
||||
gh run list --workflow=ext-vscode-ab-package.yml --limit 1
|
||||
```
|
||||
|
||||
Preflight (version format + monotonicity) and both test suites run first, then the ungated `build` job packages and uploads the VSIX; for `publish=true` the `publish` job then **waits for `Publish` environment approval** (Actions → run → "Review deployments"). Both bundles build the exact revisions their test gates ran against (branch names are resolved once — commits landing on either branch mid-run or during the approval wait are not picked up); `publish=true` is additionally refused for any `next-ref` other than `main` (the bun gate only tests main — non-main next-refs are for build-only artifact rehearsals). Check what a run is waiting on:
|
||||
|
||||
```bash
|
||||
gh api repos/cline/cline/actions/runs/<run-id>/pending_deployments
|
||||
```
|
||||
|
||||
### Post-publish
|
||||
|
||||
1. Verify the marketplace serves the new version (query from rule 1) — expect minutes-to-an-hour of validation lag after "Published" appears in the logs. Also verify Open VSX:
|
||||
|
||||
```bash
|
||||
curl -s "https://open-vsx.org/api/saoudrizwan/claude-dev" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['version'], d['timestamp'])"
|
||||
```
|
||||
2. Tag, GitHub Release (with the .vsix attached), and the Slack release-bot post happen **automatically** after a real publish (all `continue-on-error` — the publish itself already succeeded, so bookkeeping failures leave the run green). Verify they landed; the known failure is the tag push when the built commit touches `.github/workflows/**` (default token cannot create such refs — no grantable permission fixes it). Manual fallback:
|
||||
|
||||
```bash
|
||||
git tag v<VERSION> <main-sha-built> # ask before pushing
|
||||
git push origin v<VERSION>
|
||||
gh release create v<VERSION> --title "v<VERSION>" --notes "<changelog section>" <path-to.vsix>
|
||||
```
|
||||
|
||||
A real publish also **hard-fails early** if root `CHANGELOG.md` on the built main revision doesn't start with `## [<VERSION>]` — the release prep PR must be merged before dispatching.
|
||||
|
||||
3. Thorough artifact check (`gh run download <run-id>`): union `package.json` is `saoudrizwan.claude-dev@<VERSION>`, `next/package.json` and `legacy/package.json` carry the SAME version, `grep -c 'phc_' extension/extension.js` ≥ 1 (loader key inlined), no leftover `process.env.TELEMETRY_SERVICE_API_KEY` / `process.env.CLINE_ROLLOUT_VARIANT` literals in either bundle's dist (leftovers = a build ran without its env and telemetry is silently dead).
|
||||
4. Monitor: `extension.rollout.bundle_activated` in `otel.otel_logs` filtered to `extension_version = '<VERSION>'` (stable cohort is cleanly separable — nightly versions are timestamps). Watch the next/legacy ratio and the crash-fallback rate; Metabase dashboards 17 (rollout + task error rate) and 19 (error deep dive). `extension.rollout.loader_decision` (incl. `double_failure`) is PostHog-only, not in ClickHouse.
|
||||
5. Dial the flag per the rollout plan (e.g. 0% at publish → 1% → up), verifying each change with the probe from rule 2. Announce demotions ahead of time — dialing down also demotes nightly dogfooders unless they set `"cline-nightly.rollout.bundleOverride": "next"`.
|
||||
|
||||
### Known caveats of this path
|
||||
|
||||
- **`engines.vscode` unions upward** (main's floor wins, e.g. `^1.101.0` vs legacy's `^1.84.0`): users on older VS Code are never offered the combined VSIX. Fail-safe during rollout; must be resolved before 100%.
|
||||
- A red run can still mean a successful publish on paths that tag (see Gotchas).
|
||||
|
||||
## Nightly release
|
||||
|
||||
Happens automatically (cron 12:00 UTC). Manual cut:
|
||||
|
||||
```bash
|
||||
gh workflow run ext-vscode-publish-nightly.yml --ref main # real publish
|
||||
gh workflow run ext-vscode-publish-nightly.yml --ref main -f dry-run=true # artifact only
|
||||
gh run watch <run-id> --exit-status --interval 60
|
||||
```
|
||||
|
||||
No changelog/version prep — the version is computed. Verify with the marketplace query against `saoudrizwan.cline-nightly`.
|
||||
|
||||
**Red run ≠ failed publish**: the final tag-push step fails whenever main's HEAD touches `.github/workflows/**` (default token cannot create such refs). If "Published" appears in the logs, the release went out; push the `nightly-main-<UTC ts>-<sha12>` tag manually with user credentials.
|
||||
|
||||
## Legacy hotfix release (and emergency full rollback)
|
||||
|
||||
For shipping a fix on the `legacy-extension` branch — or as the **structural rollback** from a bad combined stable VSIX: a standalone legacy publish at a higher version supersedes the combined VSIX entirely (loader and all) for every user. (For "next bundle misbehaving" you don't need this — dial the flag to 0% instead.)
|
||||
|
||||
```bash
|
||||
# On legacy-extension: commit the fix, bump apps/vscode/package.json ABOVE the
|
||||
# highest version ever published to the listing (rule 1 — including combined
|
||||
# versions, e.g. combined 4.1.0 live -> hotfix is 4.1.1, not 4.0.13),
|
||||
# add the matching `## [x.y.z]` entry to root CHANGELOG.md, push.
|
||||
gh workflow run ext-vscode-publish-legacy.yml --ref main \
|
||||
-f release-type=release
|
||||
# (the branch is hardcoded to legacy-extension in the workflow; it is
|
||||
# deliberately not an input)
|
||||
```
|
||||
|
||||
npm test suite runs ungated; the publish job waits on the `Publish` environment. This workflow derives + pushes the `v<version>` tag itself and creates the GitHub release — no manual tagging. Publishes to Marketplace **and** Open VSX. The branch is the npm codebase: use `npm`, never `bun`, and expect the old monolith layout (`apps/vscode/src/core/...`).
|
||||
|
||||
## Cutover: retiring the A/B machinery (the endgame)
|
||||
|
||||
When the next bundle has held at 100% long enough to trust:
|
||||
|
||||
1. **Resolve the engines floor**: decide whether stranding VS Code < main's `engines.vscode` on the last combined version is acceptable, or lower main's floor first.
|
||||
2. Bump `apps/vscode/package.json` on `main` above everything ever published; root `CHANGELOG.md` entry to match (both are enforced by the workflow).
|
||||
3. Ship standalone from main: `gh workflow run ext-vscode-publish-stable.yml --ref main` — tests main, tags `v<version>` itself, creates the GitHub release, publishes Marketplace + Open VSX.
|
||||
4. Watch the same rollout telemetry through the transition — `extension_variant` disappears from events as users leave combined builds, which is itself the adoption signal.
|
||||
5. Only after the standalone version dominates: retire `legacy-extension` (keep for history), delete `ext-vscode-publish-legacy.yml` and `ext-vscode-ab-package.yml`, convert the nightly workflow back to a plain build of main, remove `apps/vscode-rollout/`, and archive the `ext-sdk-bundle-rollout` flag in PostHog (harmless to machines still on a combined VSIX: absent flag fails safe to... nothing changing until they update, but their loader treats a deleted flag as legacy — leave the flag at 100% until combined-VSIX activations flatline, then archive).
|
||||
6. Update this skill: delete the combined-era sections and keep the standalone flow.
|
||||
|
||||
## Gotchas index
|
||||
|
||||
- `inputs.*` are empty strings on `schedule` events — preserve `|| 'default'` fallbacks when editing the nightly workflow.
|
||||
- `bun run package` in `apps/vscode` does not build `@cline/*` workspace deps — fresh checkouts need `bun run build:sdk` first (workflows handle this).
|
||||
- Job-level `if:` ref checks in workflow YAML are advisory (a dispatched branch runs its own copy of the file); the enforced boundary is each environment's deployment-branch policy in repo settings.
|
||||
- Marketplace PATs (`VSCE_PAT`/`OVSX_PAT`) are only mounted into publish steps; neither publish workflow has an untrusted trigger surface.
|
||||
- Environment-approval runs left waiting don't time out quickly — they sit for days and (for ab-package publish runs) block their version's concurrency group.
|
||||
- Local forcing for manual testing: `CLINE_BUNDLE_OVERRIDE=next|legacy` env (launch VS Code fresh from a terminal) or the `<prefix>.rollout.bundleOverride` setting + reload; both report as `override` in telemetry so they don't pollute cohort data.
|
||||
@@ -1,158 +0,0 @@
|
||||
---
|
||||
name: publish-ui
|
||||
description: Prepare, validate, and publish standalone @cline/ui npm releases. Use when bumping the UI package version, publishing latest or next through ui-publish.yml, checking UI release readiness, or completing the one-time npm trusted-publishing bootstrap.
|
||||
---
|
||||
|
||||
# Publish UI
|
||||
|
||||
Release `@cline/ui` independently from the Cline SDK runtime packages.
|
||||
|
||||
## Release contract
|
||||
|
||||
- Version source: `sdk/packages/ui/package.json`.
|
||||
- Workflow: `.github/workflows/ui-publish.yml`.
|
||||
- The package keeps `internal: true` only to stay out of the SDK's shared
|
||||
version/publish scripts. It is still a public npm package because
|
||||
`private: false` and `publishConfig.access: public` control npm publication.
|
||||
- `latest` is the production channel. `next` is an opt-in preview channel.
|
||||
- Use prerelease versions such as `0.2.0-next.0` for `next`; do not publish a
|
||||
version intended for `latest` under the preview tag because npm versions
|
||||
cannot be republished.
|
||||
- There is no UI Git tag, GitHub release, schedule, or Slack announcement.
|
||||
- The workflow runs only by manual dispatch. Every release attempt runs the UI
|
||||
quality checks before publishing and requires `confirm_publish=publish` from
|
||||
`main`.
|
||||
- The publish job and npm trust relationship use the protected `Publish`
|
||||
environment.
|
||||
- Every npm publication needs a new semver version; npm versions are immutable.
|
||||
- Always ask before pushing commits, triggering the publish workflow, changing
|
||||
npm trust settings, or running a local publish command.
|
||||
|
||||
## Normal release
|
||||
|
||||
1. Inspect the branch, current version, npm state, and UI changes.
|
||||
|
||||
```sh
|
||||
git status --short --branch
|
||||
node -p "require('./sdk/packages/ui/package.json').version"
|
||||
npm view @cline/ui dist-tags versions --json
|
||||
git log --oneline --no-merges -- \
|
||||
sdk/packages/ui apps/examples/desktop-app/webview/components/views/chat \
|
||||
.github/workflows/ui-publish.yml
|
||||
```
|
||||
|
||||
2. Ask for the npm channel and version together. For `latest`, ask for patch,
|
||||
minor, major, or an explicit version. For `next`, require an explicit
|
||||
prerelease version such as `0.2.0-next.0`. Do not guess. Update only
|
||||
`sdk/packages/ui/package.json` and its workspace version in `bun.lock`. Do
|
||||
not run the SDK version command.
|
||||
|
||||
3. Validate the release candidate.
|
||||
|
||||
```sh
|
||||
bun install --filter @cline/ui --filter @cline/code --frozen-lockfile
|
||||
bun -F @cline/ui typecheck
|
||||
bun -F @cline/ui test
|
||||
bun -F @cline/ui test:package
|
||||
bun -F @cline/ui build-storybook
|
||||
bun -F @cline/code test:chat-ui
|
||||
```
|
||||
|
||||
The packed-package test installs the tarball with Bun/React 19 and with
|
||||
npm/Node/React 18.
|
||||
Inspect `bun pm pack --dry-run` when the exported file set changed.
|
||||
|
||||
4. Commit the version bump separately from feature work. Ask before pushing.
|
||||
|
||||
```sh
|
||||
git add sdk/packages/ui/package.json bun.lock
|
||||
git commit -m "chore(ui): release vX.Y.Z"
|
||||
git push origin HEAD
|
||||
```
|
||||
|
||||
5. After the release commit reaches `main`, restate the selected npm tag and ask
|
||||
for explicit publish approval. Then trigger and watch the standalone
|
||||
workflow:
|
||||
|
||||
```sh
|
||||
run_url=$(gh workflow run ui-publish.yml --ref main \
|
||||
-f npm_tag=latest \
|
||||
-f confirm_publish=publish)
|
||||
test -n "$run_url"
|
||||
run_id=${run_url##*/}
|
||||
gh run watch "$run_id" --exit-status
|
||||
```
|
||||
|
||||
Use `npm_tag=next` only for a deliberate preview. Do not report success until
|
||||
the workflow succeeds and npm shows the exact version under the selected tag.
|
||||
|
||||
```sh
|
||||
npm view @cline/ui dist-tags versions --json
|
||||
```
|
||||
|
||||
## One-time npm bootstrap
|
||||
|
||||
Use this only while `npm view @cline/ui` returns `E404`. npm requires the
|
||||
package to exist before its GitHub trusted publisher can be configured.
|
||||
|
||||
1. Merge the package and `ui-publish.yml` to `main`. Start from a clean,
|
||||
reviewed `main` checkout. Verify authentication, account 2FA, and write
|
||||
access to the `@cline` npm organization. The `npm trust` command in step 4
|
||||
requires npm CLI 11.15 or newer; the automated trusted-publishing workflow
|
||||
itself enforces npm 11.5.1 or newer.
|
||||
|
||||
```sh
|
||||
npm --version
|
||||
npm whoami
|
||||
npm view @cline/ui version
|
||||
```
|
||||
|
||||
If npm is older than 11.15, ask before upgrading with
|
||||
`npm install -g npm@^11.15.0`.
|
||||
|
||||
2. Run the normal release validation in step 3 above. Then build, pack, test,
|
||||
and inspect the exact initial tarball. Record the absolute archive path
|
||||
printed by the final command.
|
||||
|
||||
```sh
|
||||
bun -F @cline/ui build
|
||||
pack_dir=$(mktemp -d)
|
||||
(cd sdk/packages/ui && bun pm pack --ignore-scripts --destination "$pack_dir" --quiet)
|
||||
tarball=$(find "$pack_dir" -maxdepth 1 -name '*.tgz' -print -quit)
|
||||
test -n "$tarball"
|
||||
bun sdk/packages/ui/scripts/smoke-package.ts "$tarball"
|
||||
tar -tzf "$tarball"
|
||||
printf 'Bootstrap archive: %s\n' "$tarball"
|
||||
```
|
||||
|
||||
3. Ask for explicit approval, then publish the initial version publicly under
|
||||
`latest`:
|
||||
|
||||
```sh
|
||||
npm publish /absolute/path/from-step-2.tgz --access public --tag latest
|
||||
```
|
||||
|
||||
4. Ask separately before configuring the standalone workflow as the trusted
|
||||
publisher:
|
||||
|
||||
```sh
|
||||
npm trust github @cline/ui \
|
||||
--repo cline/cline \
|
||||
--file ui-publish.yml \
|
||||
--env Publish \
|
||||
--allow-publish
|
||||
```
|
||||
|
||||
5. Verify both package state and trust. Every later release uses the workflow;
|
||||
do not add a long-lived npm token.
|
||||
|
||||
```sh
|
||||
npm view @cline/ui dist-tags versions --json
|
||||
npm trust list @cline/ui
|
||||
```
|
||||
|
||||
## Final report
|
||||
|
||||
Report the version and npm tag, release commit, whether anything was pushed,
|
||||
workflow URL or bootstrap result, npm verification, and tests/builds run. If
|
||||
the package still returns `E404`, state that bootstrap remains required.
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "Publish UI"
|
||||
short_description: "Prepare and publish the Cline UI package"
|
||||
default_prompt: "Use $publish-ui to prepare and publish a new @cline/ui npm release."
|
||||
@@ -1,107 +0,0 @@
|
||||
---
|
||||
name: tuistory
|
||||
description: |
|
||||
Drive and test terminal apps (especially the Cline CLI TUI in apps/cli) through tuistory — named background PTY sessions that agents can read, wait on, snapshot, screenshot, and type into. Like Playwright/tmux for terminals, with reactive waiting instead of blind `sleep`.
|
||||
|
||||
Use this skill when you need to:
|
||||
- Manually test or reproduce bugs in the interactive Cline TUI (`bun run cli -i`) from a headless environment
|
||||
- Run a dev server or any long-lived/interactive process in the background without hanging your tool call
|
||||
- Write or extend Playwright-style e2e tests for the TUI (`bun run test:e2e:tuistory` in apps/cli)
|
||||
- Capture text snapshots or styled PNG screenshots of a TUI screen as evidence
|
||||
---
|
||||
|
||||
# tuistory
|
||||
|
||||
[tuistory](https://github.com/remorses/tuistory) wraps any terminal command in a named background PTY session backed by a Ghostty terminal emulator. Agents interact with the session via short CLI calls that return instantly; humans can `tuistory attach` to the same session to watch or intervene. No real terminal or display (`DISPLAY`) is needed — it works fully headless, which makes it the preferred way for cloud agents to exercise the Cline TUI.
|
||||
|
||||
It is installed as a devDependency of `@cline/cli`, so the pinned binary resolves when you run from `apps/cli`:
|
||||
|
||||
```bash
|
||||
cd apps/cli
|
||||
bunx tuistory --help # source of truth for commands, options, and syntax
|
||||
```
|
||||
|
||||
For full upstream docs: `curl -s https://raw.githubusercontent.com/remorses/tuistory/refs/heads/main/README.md`
|
||||
|
||||
## Driving the Cline TUI headlessly
|
||||
|
||||
Launch the TUI in an isolated environment so you don't touch real user config (`~/.cline`):
|
||||
|
||||
```bash
|
||||
cd apps/cli
|
||||
DATA_DIR=$(mktemp -d) && HOME_DIR=$(mktemp -d)
|
||||
bunx tuistory -s cline --cols 120 --rows 36 \
|
||||
--env HOME=$HOME_DIR --env CLINE_DATA_DIR=$DATA_DIR \
|
||||
--env CLINE_DISABLE_CLINE_PASS_NOTICE=1 --env CLINE_TELEMETRY_DISABLED=1 \
|
||||
-- bun src/index.ts --provider anthropic -m claude-sonnet-4-6 -k test-key
|
||||
```
|
||||
|
||||
The dummy `-k test-key` renders the full chat UI; only an actual agent turn would fail. For recorded LLM turns, use the VCR cassettes described in `apps/cli/src/tests/helpers/env.ts` (`CLINE_VCR=playback` + `CLINE_VCR_CASSETTE`). Real turns need a provider credential (e.g. `ANTHROPIC_API_KEY`, `CLINE_API_KEY`).
|
||||
|
||||
Then use an **observe → act → observe** loop:
|
||||
|
||||
```bash
|
||||
# Wait reactively for the chat view — never use sleep
|
||||
bunx tuistory -s cline wait "What can I do for you?" --timeout 30000
|
||||
|
||||
# Act, then always observe the resulting screen state
|
||||
bunx tuistory -s cline type "/settings"
|
||||
bunx tuistory -s cline snapshot --trim
|
||||
bunx tuistory -s cline press enter
|
||||
bunx tuistory -s cline snapshot --trim
|
||||
|
||||
# Styled PNG of the current screen (prints the file path) — good for artifacts
|
||||
bunx tuistory -s cline screenshot
|
||||
|
||||
# Full raw output stream (snapshot shows only the visible screen)
|
||||
bunx tuistory read -s cline --all
|
||||
|
||||
# Tear down a session YOU started (double Ctrl+C exits the TUI cleanly)
|
||||
bunx tuistory -s cline press ctrl c
|
||||
bunx tuistory -s cline press ctrl c
|
||||
bunx tuistory -s cline close
|
||||
```
|
||||
|
||||
## Background processes (instead of tmux)
|
||||
|
||||
```bash
|
||||
bunx tuistory -s my-server -- bun run dev:sidecar # returns immediately
|
||||
bunx tuistory -s my-server wait "/listening|ready/i" --timeout 30000
|
||||
bunx tuistory read -s my-server # new output since last read
|
||||
bunx tuistory -s my-server restart # after code changes
|
||||
```
|
||||
|
||||
## Key rules
|
||||
|
||||
- **Options before `--`, command after.** Everything after the first `--` is passed verbatim to the child: `tuistory -s name --cols 150 -- bun src/index.ts` is correct.
|
||||
- **Snapshot after every action.** TUIs are stateful; dialogs and errors can render over the view you expect. `snapshot` reflects what the user actually sees (occluded text does not count), unlike grepping the raw stream.
|
||||
- **Wait, never sleep.** `wait "text"` / `wait "/regex/i"` (case-sensitive by default) reacts as fast as the terminal updates; `wait-idle` when you don't know what to expect. Always pass `--timeout`.
|
||||
- **Keys land instantly.** Unlike sleep-based scripts, a queued second keypress can leak into the next view (e.g. one Enter both accepts a slash completion and submits it).
|
||||
- **Never close a session you didn't start.** Sessions are shared with humans (`tuistory attach -s name`) and other agents. Default to leaving sessions running; use `read`/`wait`/`snapshot` to inspect without disrupting.
|
||||
- `--cols`/`--rows` affect TUI layout (assertions are width-sensitive); `--pixel-ratio 2` gives sharper screenshots.
|
||||
|
||||
## Writing e2e tests with the library API
|
||||
|
||||
`apps/cli/src/cli.tuistory.e2e.test.ts` (run: `bun run test:e2e:tuistory`) is the reference. The programmatic API runs in-process — no daemon:
|
||||
|
||||
```ts
|
||||
import { launchTerminal } from "tuistory";
|
||||
|
||||
const session = await launchTerminal({
|
||||
command: "bun",
|
||||
args: ["src/index.ts", "--provider", "anthropic", "-k", "test-key"],
|
||||
cwd: cliRoot,
|
||||
env: isolatedEnv, // see createCliEnv() in the reference test
|
||||
cols: 120,
|
||||
rows: 36,
|
||||
waitForDataTimeout: 30_000, // CLI cold start compiles a large TS graph
|
||||
});
|
||||
|
||||
await session.waitForText("What can I do for you?", { timeout: 30_000 });
|
||||
const screen = await session.text({ trimEnd: true }); // emulated screen state
|
||||
await session.type("/settings");
|
||||
await session.press("enter");
|
||||
session.close(); // always close in test teardown
|
||||
```
|
||||
|
||||
Screen-state assertions can check that stale UI is *gone* (`expect(screen).not.toContain(...)`), which stream-grepping harnesses cannot. `session.text({ only: { bold: true } })` filters by style; `session.read()` returns the raw stream since the last read.
|
||||
@@ -1,55 +0,0 @@
|
||||
# Bun (tooling) and Node (runtime)
|
||||
|
||||
This repo uses **bun** for package management and task running, and **Node** as
|
||||
the execution runtime. Both are correct at the same time; the distinction is the
|
||||
source of most confusion, so keep it straight before editing scripts, configs,
|
||||
docs, or comments.
|
||||
|
||||
## Use bun for tooling
|
||||
|
||||
- `bun install` (never `npm install` / `npm ci`)
|
||||
- `bun run <script>` (never `npm run <script>`)
|
||||
- `bunx <bin>` (never `npx <bin>`)
|
||||
- `bun <file>.ts` to run a TS entrypoint directly (no `ts-node` / `tsx`)
|
||||
- `bun esbuild.mjs` to drive the build (esbuild/vite are still the bundlers)
|
||||
- `bun run --parallel ...` for parallel tasks
|
||||
|
||||
The root `bun.lock` is the single lockfile for the whole workspace, including
|
||||
`apps/vscode`, `webview-ui`, and `testing-platform`. There are no per-package npm
|
||||
lockfiles.
|
||||
|
||||
## Node is the runtime — do NOT rewrite these to bun
|
||||
|
||||
The build product runs on Node: the VS Code extension host loads
|
||||
`dist/extension.js` as CommonJS under Node, and the standalone `cline-core` is a
|
||||
Node process. The following are Node runtime/ABI references and are correct as-is:
|
||||
|
||||
| Reference | Why it is Node |
|
||||
|-----------|----------------|
|
||||
| esbuild `platform: "node"` / `target: "node..."` | The bundle targets the Node runtime (extension host, standalone core). |
|
||||
| `TARGET_NODE_VERSION` (`scripts/package-standalone.mjs`) | Pins the Node ABI of the bundled standalone runtime (matches the JetBrains-packaged Node). |
|
||||
| `prebuild-install --target=<node version>` | Downloads native `.node` binaries for that Node ABI. |
|
||||
| `NODE_PATH=... node cline-core.js` | The standalone core is launched by Node, not bun. |
|
||||
| `node:` import specifiers (e.g. `node:fs`) | Node builtin module scheme; unrelated to tooling. |
|
||||
| `process.versions.node`, `engines.node`, `@types/node` | Runtime version probe / declared runtime / its types. |
|
||||
| `ELECTRON_RUN_AS_NODE` | VS Code/Electron runs the extension host as Node. |
|
||||
|
||||
When a file legitimately uses both bun and node (e.g. `package-standalone.mjs`
|
||||
does `bun install` but `prebuild-install --target=<node>`), the `node` token is
|
||||
the runtime/ABI target, not tooling. If unsure, leave it.
|
||||
|
||||
## Tests: bun vs the VS Code host
|
||||
|
||||
A test file's runner is decided by its import:
|
||||
|
||||
- **`import ... from "bun:test"`** → runs under `bun test` (the node-side unit
|
||||
suites + the SDK/model-catalog suites). `scripts/run-bun-unit-tests.ts`
|
||||
discovers these by the `bun:test` import and runs one isolated bun process per
|
||||
file. `build-tests.js` excludes them from the integration compile so the
|
||||
`bun:test` builtin never reaches Node.
|
||||
- **`import ... from "mocha"`** → runs under `@vscode/test-cli` in a real VS Code
|
||||
extension host (Node). These exercise the live `vscode` API and cannot run
|
||||
under bun.
|
||||
|
||||
So a file imports `bun:test` XOR `mocha`. Don't add `bun:test` to a test that
|
||||
needs the real extension host.
|
||||
@@ -0,0 +1,33 @@
|
||||
# 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.
|
||||
@@ -1,129 +0,0 @@
|
||||
# Debug Harness
|
||||
|
||||
HTTP-controlled debugger for the VSCode extension at `src/dev/debug-harness/server.ts`.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Build extension first if needed (protos + esbuild):
|
||||
bun run protos && IS_DEV=true bun esbuild.mjs
|
||||
|
||||
# Launch (skip-build if already built). Run with node, NOT bun — Playwright's
|
||||
# Electron launch times out under bun:
|
||||
node src/dev/debug-harness/server.ts --skip-build --auto-launch
|
||||
|
||||
# In another terminal:
|
||||
curl localhost:19229/api -d '{"method":"status"}'
|
||||
```
|
||||
|
||||
## Data Isolation
|
||||
|
||||
The debugee runs with `CLINE_DIR=~/.cline2` by default, separate from your real `~/.cline`.
|
||||
This prevents the debugee's logout from logging out the debugger, and vice versa.
|
||||
Override with `--cline-dir /tmp/test-dir`. Check with `status()` → `clineDir`.
|
||||
|
||||
## Browser Capture & OAuth
|
||||
|
||||
The debugee runs with `CLINE_CAPTURE_BROWSER=1`, which intercepts `openExternal()` in
|
||||
`src/utils/env.ts`. URLs are captured instead of opening a real browser:
|
||||
|
||||
- Logged to `$CLINE_DIR/data/debug-captured-urls.jsonl`
|
||||
- POSTed in real-time to `/captured-url` on the harness server
|
||||
- Queryable via `oauth.captured_urls`
|
||||
|
||||
### OAuth API
|
||||
|
||||
- **`oauth.captured_urls`** `{clear?}` — URLs the debugee tried to open
|
||||
- **`oauth.read_stored_token`** — Check auth token presence in secrets.json
|
||||
- **`oauth.simulate_callback`** `{path, code?, state?, provider?, token?}` — Build vscode:// callback URI
|
||||
- **`oauth.read_captured_urls_file`** — Read on-disk JSONL of captured URLs
|
||||
|
||||
### OAuth testing flow
|
||||
|
||||
For **Cline OAuth** (SDK local callback): The SDK starts a local HTTP server, the auth URL
|
||||
is captured. To complete: open the captured URL in a real browser (it redirects back to the
|
||||
SDK's callback server), OR extract the callback port and `curl http://127.0.0.1:PORT/callback?code=...`.
|
||||
|
||||
For **MCP/Provider OAuth** (vscode:// URI): The redirect goes to a vscode:// URI.
|
||||
`oauth.simulate_callback` only *builds* the URI — it does not deliver it, and the ESM
|
||||
extension host can't `require()` the handler. To actually deliver the callback, call the
|
||||
debug-only hook via `ext.evaluate` (with `awaitPromise: true`):
|
||||
`globalThis.__clineHandleUri("vscode://saoudrizwan.claude-dev/...?code=...&state=...")`.
|
||||
It runs the same `SharedUriHandler.handleUri` as VSCode's real URI handler and exists only
|
||||
when `CLINE_CAPTURE_BROWSER` is set (the harness always sets it; never ships in prod).
|
||||
For end-to-end MCP OAuth, get a real `code` from the local MCP OAuth test server
|
||||
(`bun run dev:mcp-oauth-test-server`).
|
||||
|
||||
## Navigating Views — Use Commands, Not Clicks
|
||||
|
||||
Don't try to find/click small sidebar icons. Use VSCode commands via command palette.
|
||||
Registered in `src/registry.ts`:
|
||||
|
||||
| Command | View |
|
||||
|---------|------|
|
||||
| `cline.accountButtonClicked` | Account / sign-in |
|
||||
| `cline.historyButtonClicked` | Task history |
|
||||
| `cline.settingsButtonClicked` | Settings |
|
||||
| `cline.mcpButtonClicked` | MCP servers |
|
||||
| `cline.plusButtonClicked` | New task (chat) |
|
||||
| `cline.worktreesButtonClicked` | Worktrees |
|
||||
|
||||
```bash
|
||||
curl localhost:19229/api -d '{"method":"ui.command_palette","params":{"command":"cline.accountButtonClicked"}}'
|
||||
```
|
||||
|
||||
## Key commands
|
||||
|
||||
All via `POST localhost:19229/api` with `{"method":"...", "params":{...}}`:
|
||||
|
||||
- **`launch`** / **`shutdown`** — lifecycle
|
||||
- **`ui.screenshot`** — screenshot to `/tmp/cline-debug/`; returns `{path}` — **use `read_file` on the path to examine, do NOT `open` the file** (Preview.app covers the VSCode window)
|
||||
- **`ui.open_sidebar`** — open the Cline sidebar
|
||||
- **`ext.set_breakpoint`** `{file, line, condition?}` — breakpoint by source file (sourcemap-resolved)
|
||||
- **`ext.evaluate`** `{expression, callFrameId?}` — eval in extension host
|
||||
- **`ext.resume`** / **`ext.step_over`** / **`ext.step_into`** — stepping
|
||||
- **`ext.call_stack`** — inspect when paused
|
||||
- **`web.evaluate`** `{expression}` — eval in webview
|
||||
- **`web.post_message`** `{message}` — send postMessage to extension host via exposed vsCodeApi
|
||||
- **`wait_for_pause`** `{timeout?}` — block until breakpoint hit
|
||||
- **`ui.locator`** `{role?, testId?, text?, frame?}` — Playwright locator (auto-retries on stale sidebar frame)
|
||||
- **`ui.react_input`** `{text, selector?, clear?, submit?}` — set React textarea value via `execCommand('insertText')`; works reliably across multiple tasks
|
||||
- **`ui.send_message`** `{text, images?, files?, responseType?}` — send chat message bypassing the textarea entirely (via gRPC postMessage)
|
||||
- **`ui.command_palette`** `{command}` — run VSCode command
|
||||
|
||||
## Typical Session
|
||||
|
||||
```bash
|
||||
# 1. Launch
|
||||
curl localhost:19229/api -d '{"method":"launch","params":{"skipBuild":true}}'
|
||||
|
||||
# 2. Open sidebar + dismiss overlays (ALWAYS do this first)
|
||||
curl localhost:19229/api -d '{"method":"ui.open_sidebar"}'
|
||||
curl localhost:19229/api -d '{"method":"web.evaluate","params":{"expression":"document.querySelectorAll(\".sr-only\").forEach(el => el.parentElement?.click())"}}'
|
||||
|
||||
# 3. Navigate to view
|
||||
curl localhost:19229/api -d '{"method":"ui.command_palette","params":{"command":"cline.accountButtonClicked"}}'
|
||||
|
||||
# 4. Check captured OAuth URLs if testing auth
|
||||
curl localhost:19229/api -d '{"method":"oauth.captured_urls"}'
|
||||
|
||||
# 5. Verify
|
||||
curl localhost:19229/api -d '{"method":"ui.screenshot"}'
|
||||
```
|
||||
|
||||
## Caveats
|
||||
|
||||
- **⚠️ Dismiss promotional overlays FIRST**: On fresh launches, full-screen promo overlays block the sidebar. **Dismiss immediately after `ui.open_sidebar`**, before any other interaction or screenshot. May need to run twice:
|
||||
```bash
|
||||
curl localhost:19229/api -d '{"method": "ui.open_sidebar"}'
|
||||
curl localhost:19229/api -d '{"method": "web.evaluate", "params": {"expression": "document.querySelectorAll(\".sr-only\").forEach(el => el.parentElement?.click())"}}'
|
||||
```
|
||||
- **Screenshots — don't open the file**: `ui.screenshot` and `ui.sidebar_screenshot` save PNGs to `/tmp/cline-debug/` and return the `{path}`. Use `read_file` on that path to examine screenshots. Running `open <path>` launches Preview.app on macOS which covers the VSCode window.
|
||||
- **Scripts count = 0 after launch**: CDP connects after extension host starts, so scripts parsed during startup aren't tracked. Breakpoints still work via sourcemap resolution.
|
||||
- **Port 9230**: Extension host inspector. If another VSCode instance uses this port, the harness will fail to connect. Kill other debug instances first.
|
||||
- **macOS only** for now (Playwright Electron launch behavior).
|
||||
- **Webview CDP**: `connect_webview` may fail depending on Electron version. `web.evaluate` still works via Playwright's `frame.evaluate()` fallback.
|
||||
- **Sourcemap paths**: esbuild outputs relative paths like `../src/extension.ts` in the sourcemap. The resolver handles this, but if a file isn't found, use `ext.source_files` to see exact paths.
|
||||
- **OAuth with fake codes**: Browser capture intercepts the URL but doesn't provide a valid auth code. For real OAuth testing, open the captured URL in a browser. For unit testing, mock the token exchange.
|
||||
|
||||
See `src/dev/debug-harness/README.md` for full API reference.
|
||||
+103
-103
@@ -13,57 +13,11 @@ This file is the secret sauce for working effectively in this codebase. It captu
|
||||
**What NOT to add:** Stuff you can figure out from reading a few files, obvious patterns, or standard practices. This file should be high-signal, not comprehensive.
|
||||
|
||||
## Miscellaneous
|
||||
- The whole repo (including `apps/vscode`) uses **bun** for package management and task running. Emit `bun run X` / `bun install` / `bunx <bin>` / `bun file.ts`, never npm/npx. Node remains the *runtime* (VS Code's extension host and the standalone cline-core are Node), so Node-runtime tokens are legitimate and must not be "fixed" to bun — see @.clinerules/bun-and-node.md for the keep-list vs rewrite-list.
|
||||
- Avoid provider-specific string matching / hardcoded provider branches when fixing provider/config plumbing. Prefer provider metadata, shared catalog/defaults, explicit protocol/client capabilities, or centralized normalization utilities that apply by data shape rather than `providerId === "..."`. If a provider exception seems necessary, stop and explain why instead of adding ad-hoc string matching.
|
||||
- This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `bun run compile`, not `bun run build`).
|
||||
- When reading a configuration files that users may edit, use `readFileStrippingUtf8Bom`, `readFileSyncStrippingUtf8Bom`, or `stripUtf8Bom` from `@cline/shared/node`. DON'T strip byte order marks of user files handled by tools/passed to models.
|
||||
- This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `npm run compile`, not `npm run build`).
|
||||
- When creating PRs, contributors should not create changelog-entry files. Maintainers handle release versioning and changelog curation during the release process.
|
||||
- When adding new feature flags, see this PR as a reference https://github.com/cline/cline/pull/7566
|
||||
- Additional instructions about making requests: @.clinerules/network.md
|
||||
|
||||
## Searching the Codebase — Avoiding Build Output
|
||||
|
||||
Several directories contain build output or generated code that produces
|
||||
noisy or unusable results with `search_files` / `grep`:
|
||||
|
||||
| Directory | What it is | Why it's a problem |
|
||||
|-----------|-----------|-------------------|
|
||||
| `out/` | esbuild bundle output | Mirrors `src/` structure as minified JS — every search gets duplicate hits on single-line files |
|
||||
| `dist/` | Packaged extension | Entire extension bundled into one minified `extension.js` (~1 long line) |
|
||||
| `dist-standalone/` | Standalone build output | Same minification issue |
|
||||
| `src/generated/` | Generated protobuf code | Auto-generated from `proto/`; not the source of truth |
|
||||
| `src/shared/proto/` | Generated proto type defs | Auto-generated from `proto/`; not the source of truth |
|
||||
| `node_modules/` | Dependencies | Huge, not project source |
|
||||
|
||||
### How to skip build output
|
||||
|
||||
**`search_files`** — Point at `src/` (not the project root) and use `file_pattern`:
|
||||
```
|
||||
search_files(path="src/core", regex="myFunction", file_pattern="*.ts")
|
||||
```
|
||||
The `file_pattern` parameter is the most effective filter — e.g. `"*.ts"`,
|
||||
`"*.tsx"`, `"*.proto"`.
|
||||
|
||||
**`grep` directly** — Exclude build dirs and restrict to source extensions:
|
||||
```bash
|
||||
grep -rn "myFunction" src/ --include="*.ts" --exclude-dir={out,dist,node_modules,generated}
|
||||
```
|
||||
|
||||
### When you must search minified files
|
||||
|
||||
Sometimes you need to verify what got bundled (e.g., checking if a change
|
||||
made it into the build). Minified files are typically one long line, so
|
||||
normal `grep` shows the entire file as context. Use these approaches:
|
||||
|
||||
- **`grep -oP`** to extract just the match with limited surrounding context:
|
||||
```bash
|
||||
grep -oP '.{0,40}myFunction.{0,40}' dist/extension.js
|
||||
```
|
||||
- **`read_file`** on files in `out/src/` — these have source maps and are
|
||||
more readable than `dist/extension.js` (which is the fully bundled output).
|
||||
- **Source maps** — `out/src/*.js.map` and `dist/extension.js.map` can be
|
||||
used to trace minified output back to original source locations.
|
||||
|
||||
## gRPC/Protobuf Communication
|
||||
The extension and webview communicate via gRPC-like protocol over VS Code message passing.
|
||||
|
||||
@@ -74,7 +28,7 @@ The extension and webview communicate via gRPC-like protocol over VS Code messag
|
||||
- Naming: Services `PascalCaseService`, RPCs `camelCase`, Messages `PascalCase`
|
||||
- For streaming responses, use `stream` keyword (see `subscribeToAuthCallback` in `account.proto`)
|
||||
|
||||
**Run `bun run protos`** after any proto changes—generates types in:
|
||||
**Run `npm run protos`** after any proto changes—generates types in:
|
||||
- `src/shared/proto/` - Shared type definitions
|
||||
- `src/generated/grpc-js/` - Service implementations
|
||||
- `src/generated/nice-grpc/` - Promise-based clients
|
||||
@@ -94,15 +48,104 @@ The extension and webview communicate via gRPC-like protocol over VS Code messag
|
||||
- `src/core/controller/task/explainChanges.ts` - Handler implementation
|
||||
- `webview-ui/src/components/chat/ChatRow.tsx` - UI rendering
|
||||
|
||||
## Adding a New API Provider
|
||||
When adding a new provider (e.g., "openai-codex"), you must update the proto conversion layer in THREE places or the provider will silently reset to Anthropic:
|
||||
|
||||
1. `proto/cline/models.proto` - Add to the `ApiProvider` enum (e.g., `OPENAI_CODEX = 40;`)
|
||||
2. `convertApiProviderToProto()` in `src/shared/proto-conversions/models/api-configuration-conversion.ts` - Add case mapping string to proto enum
|
||||
3. `convertProtoToApiProvider()` in the same file - Add case mapping proto enum back to string
|
||||
|
||||
**Why this matters:** Without these, the provider string hits the `default` case and returns `ANTHROPIC`. The webview, provider list, and handler all work fine, but the state silently resets when it round-trips through proto serialization. No error is thrown.
|
||||
|
||||
**Other files to update when adding a provider:**
|
||||
- `src/shared/api.ts` - Add to `ApiProvider` union type, define models
|
||||
- `src/shared/providers/providers.json` - Add to provider list for dropdown
|
||||
- `src/core/api/index.ts` - Register handler in `createHandlerForProvider()`
|
||||
- `webview-ui/src/components/settings/utils/providerUtils.ts` - Add cases in `getModelsForProvider()` and `normalizeApiConfiguration()`
|
||||
- `webview-ui/src/utils/validate.ts` - Add validation case
|
||||
- `webview-ui/src/components/settings/ApiOptions.tsx` - Render provider component
|
||||
|
||||
## Responses API Providers (OpenAI Codex, OpenAI Native)
|
||||
Providers using OpenAI's Responses API require native tool calling. XML tools don't work with the Responses API.
|
||||
|
||||
**Symptoms of broken native tool calling:**
|
||||
- Tools get called multiple times (e.g., `ask_followup_question` asks the same question twice)
|
||||
- Tool arguments get duplicated or malformed
|
||||
- The model responds but tools aren't recognized
|
||||
|
||||
**Root causes to check:**
|
||||
1. **Provider missing from `isNextGenModelProvider()`** in `src/utils/model-utils.ts`. The native variant matchers (e.g., `native-gpt-5/config.ts`) call this function. If your provider isn't in the list, the matcher returns false and falls back to XML tools.
|
||||
|
||||
2. **Model missing `apiFormat: ApiFormat.OPENAI_RESPONSES`** in its model info (`src/shared/api.ts`). This property signals that the model requires native tool calling. The task runner in `src/core/task/index.ts` checks this and forces `enableNativeToolCalls: true` regardless of user settings.
|
||||
|
||||
**When adding a new Responses API provider:**
|
||||
1. Add provider to `isNextGenModelProvider()` list in `src/utils/model-utils.ts`
|
||||
2. Set `apiFormat: ApiFormat.OPENAI_RESPONSES` on all models that use the Responses API
|
||||
3. The variant matcher and task runner will handle the rest automatically
|
||||
|
||||
## Adding Tools to System Prompt
|
||||
This is tricky—multiple prompt variants and configs. **Always search for existing similar tools first and follow their pattern.** Look at the full chain from prompt definition → variant configs → handler → UI before implementing.
|
||||
|
||||
1. **Add to `ClineDefaultTool` enum** in `src/shared/tools.ts`
|
||||
2. **Tool definition** in `src/core/prompts/system-prompt/tools/` (create file like `generate_explanation.ts`)
|
||||
- Define variants for each `ModelFamily` (generic, next-gen, xs, etc.)
|
||||
- Export variants array (e.g., `export const my_tool_variants = [GENERIC, NATIVE_NEXT_GEN, XS]`)
|
||||
- **Fallback behavior**: If a variant isn't defined for a model family, `ClineToolSet.getToolByNameWithFallback()` automatically falls back to GENERIC. So you only need to export `[GENERIC]` unless the tool needs model-specific behavior.
|
||||
3. **Register in `src/core/prompts/system-prompt/tools/init.ts`** - Import and spread into `allToolVariants`
|
||||
4. **Add to variant configs** - Each model family has its own config in `src/core/prompts/system-prompt/variants/*/config.ts`. Add your tool's enum to the `.tools()` list:
|
||||
- `generic/config.ts`, `next-gen/config.ts`, `gpt-5/config.ts`, `native-gpt-5/config.ts`, `native-gpt-5-1/config.ts`, `native-next-gen/config.ts`, `gemini-3/config.ts`, `glm/config.ts`, `hermes/config.ts`, `xs/config.ts`
|
||||
- **Important**: If you add to a variant's config, make sure the tool spec exports a variant for that ModelFamily (or relies on GENERIC fallback)
|
||||
5. **Create handler** in `src/core/task/tools/handlers/`
|
||||
6. **Wire up in `ToolExecutor.ts`** if needed for execution flow
|
||||
7. **Add to tool parsing** in `src/core/assistant-message/index.ts` if needed
|
||||
8. **If tool has UI feedback**: add `ClineSay` enum in proto, update `src/shared/ExtensionMessage.ts`, update `src/shared/proto-conversions/cline-message.ts`, update `webview-ui/src/components/chat/ChatRow.tsx`
|
||||
|
||||
## Modifying System Prompt
|
||||
**Read these first:** `src/core/prompts/system-prompt/README.md`, `tools/README.md`, `__tests__/README.md`
|
||||
|
||||
System prompt is modular: **components** (reusable sections) + **variants** (model-specific configs) + **templates** (with `{{PLACEHOLDER}}` resolution).
|
||||
|
||||
**Key directories:**
|
||||
- `components/` - Shared sections: `rules.ts`, `capabilities.ts`, `editing_files.ts`, etc.
|
||||
- `variants/` - Model-specific: `generic/`, `next-gen/`, `xs/`, `gpt-5/`, `gemini-3/`, `hermes/`, `glm/`, etc.
|
||||
- `templates/` - Template engine and placeholder definitions
|
||||
|
||||
**Variant tiers (ask user which to modify):**
|
||||
- **Next-gen** (Claude 4, GPT-5, Gemini 2.5): `next-gen/`, `native-next-gen/`, `native-gpt-5/`, `native-gpt-5-1/`, `gemini-3/`, `gpt-5/`
|
||||
- **Standard** (default fallback): `generic/`
|
||||
- **Local/small models**: `xs/`, `hermes/`, `glm/`
|
||||
|
||||
**How overrides work:** Variants can override components via `componentOverrides` in their `config.ts`, or provide a custom template in `template.ts` (e.g., `next-gen/template.ts` exports `rules_template`). If no override, the shared component from `components/` is used.
|
||||
|
||||
**Example: Adding a rule to RULES section**
|
||||
1. Check if variant overrides rules: look for `rules_template` in `variants/*/template.ts` or `componentOverrides.RULES` in `config.ts`
|
||||
2. If shared: modify `components/rules.ts`
|
||||
3. If overridden: modify that variant's template
|
||||
4. XS variant is special—has heavily condensed inline content in `template.ts`
|
||||
|
||||
**After any changes, regenerate snapshots:**
|
||||
```bash
|
||||
UPDATE_SNAPSHOTS=true npm run test:unit
|
||||
```
|
||||
Snapshots live in `__tests__/__snapshots__/`. Tests validate across model families and context variations (browser, MCP, focus chain).
|
||||
|
||||
## Modifying Default Slash Commands
|
||||
Three places need updates:
|
||||
- `src/core/slash-commands/index.ts` - Command definitions
|
||||
- `src/core/prompts/commands.ts` - System prompt integration
|
||||
- `webview-ui/src/utils/slash-commands.ts` - Webview autocomplete
|
||||
|
||||
## Adding New Global State Keys
|
||||
Adding a new key to global state requires updates in multiple places. Missing any step causes silent failures.
|
||||
|
||||
Required steps:
|
||||
1. Type definition in `src/shared/storage/state-keys.ts` - Add to `GlobalState` or `Settings` interface
|
||||
2. Add any default value or transform in `src/shared/storage/state-keys.ts` if the key needs one
|
||||
3. Read and write the value through `StateManager` (`setGlobalState()` / `getGlobalStateKey()`) after initialization
|
||||
2. Read from globalState in `src/core/storage/utils/state-helpers.ts`:
|
||||
- Add `const myKey = context.globalState.get<GlobalStateAndSettings["myKey"]>("myKey")` in `readGlobalStateFromDisk()`
|
||||
- Add to the return object: `myKey: myKey ?? defaultValue,`
|
||||
3. StateManager handles read/write via `setGlobalState()`/`getGlobalStateKey()` after initialization
|
||||
|
||||
Persistent state is file-backed through `StateManager`; do not add new runtime reads or writes against VS Code `ExtensionContext` storage. That storage is only a legacy migration source.
|
||||
Common mistake: Adding only the return value without the `context.globalState.get()` call. This compiles but the value is always `undefined` on load.
|
||||
|
||||
Settings plumbing gotcha: if a key is user-toggleable from settings, wire both controller update paths:
|
||||
- `src/core/controller/state/updateSettings.ts` for webview `updateSetting(...)`
|
||||
@@ -110,26 +153,28 @@ Settings plumbing gotcha: if a key is user-toggleable from settings, wire both c
|
||||
Missing one path causes a toggle to appear to change in one surface while the backend state stays unchanged.
|
||||
|
||||
Webview toggle gotcha: settings changes must also round-trip back in state payloads.
|
||||
- Add the field to `UpdateSettingsRequest` in `proto/cline/state.proto` (for webview update requests), then run `bun run protos`
|
||||
- Add the field to `UpdateSettingsRequest` in `proto/cline/state.proto` (for webview update requests), then run `npm run protos`
|
||||
- Include the key in `Controller.getStateToPostToWebview()` (`src/core/controller/index.ts`)
|
||||
- Ensure `ExtensionState` and webview defaults include the key (`src/shared/ExtensionMessage.ts`, `webview-ui/src/context/ExtensionStateContext.tsx`)
|
||||
If this round-trip wiring is missing, the backend value can update but the toggle in webview appears stuck or reverts.
|
||||
|
||||
## StateManager Cache vs Direct globalState Access
|
||||
StateManager uses an in-memory cache populated during `StateManager.initialize()` from file-backed storage. For most state, use `controller.stateManager.setGlobalState()`/`getGlobalStateKey()`.
|
||||
StateManager uses an in-memory cache populated during `StateManager.initialize(context)` in `common.ts`. For most state, use `controller.stateManager.setGlobalState()`/`getGlobalStateKey()`.
|
||||
|
||||
Exception: host migration code may read legacy VS Code storage before file-backed storage is initialized.
|
||||
Exception: State needed immediately at extension startup (before cache is ready)
|
||||
|
||||
Example pattern:
|
||||
When Window A sets state and immediately opens Window B, the new window's StateManager cache is populated from `context.globalState` during initialization. If you need to read state in Window B right at startup (e.g., in `common.ts` during `initialize()`), read directly from `context.globalState.get()` instead of StateManager's cache.
|
||||
|
||||
Example pattern (see `lastShownAnnouncementId` and `worktreeAutoOpenPath`):
|
||||
```typescript
|
||||
// Writing (normal pattern)
|
||||
controller.stateManager.setGlobalState("myKey", value)
|
||||
|
||||
// Reading after initialization
|
||||
const value = controller.stateManager.getGlobalStateKey("myKey")
|
||||
// Reading at startup in common.ts (bypass cache)
|
||||
const value = context.globalState.get<string>("myKey")
|
||||
```
|
||||
|
||||
Use `context.globalState` only in VS Code migration code that copies legacy ExtensionContext values into the shared file-backed stores.
|
||||
This is only needed for cross-window state read during the brief startup window before StateManager cache is fully usable. Normal state access after initialization should use StateManager.
|
||||
|
||||
## ChatRow Cancelled/Interrupted States
|
||||
When a ChatRow displays a loading/in-progress state (spinner), you must handle what happens when the task is cancelled. This is non-obvious because cancellation doesn't update the message content—you have to infer it from context.
|
||||
@@ -158,48 +203,3 @@ const isGenerating = explanationInfo.status === "generating" && !wasCancelled
|
||||
**See also:** `BrowserSessionRow.tsx` uses similar pattern with `isLastApiReqInterrupted` and `isLastMessageResume`.
|
||||
|
||||
**Backend side:** When streaming is cancelled, clean up properly (close tabs, clear comments, etc.) by checking `taskState.abort` after the streaming function returns.
|
||||
|
||||
## Debug Harness: clear inherited VSCode/Electron env vars before launching
|
||||
|
||||
The debug harness (`apps/vscode/src/dev/debug-harness/server.ts`) launches a child
|
||||
VSCode via Playwright's `_electron.launch({ env: { ...process.env, ... } })`. If you
|
||||
run the harness from a process that was itself spawned by VSCode (e.g. the Cline
|
||||
extension host, an integrated terminal, or an agent running inside VSCode), the
|
||||
parent's VSCode/Electron env vars leak into the child and break the launch.
|
||||
|
||||
The fatal one is **`ELECTRON_RUN_AS_NODE=1`**: it makes the child VSCode binary run
|
||||
as plain Node, so it rejects every VSCode CLI flag. Symptom:
|
||||
|
||||
```
|
||||
.../Visual Studio Code.app/Contents/MacOS/Code: bad option: --extensionDevelopmentPath=...
|
||||
Error: Process failed to launch! (Playwright _electron.launch)
|
||||
```
|
||||
|
||||
This is NOT the macOS Playwright flakiness mentioned in the harness README — it's
|
||||
env inheritance. Fix: strip the inherited vars before starting the harness:
|
||||
|
||||
```bash
|
||||
env -u ELECTRON_RUN_AS_NODE -u ELECTRON_NO_ATTACH_CONSOLE \
|
||||
-u VSCODE_CLI -u VSCODE_CODE_CACHE_PATH -u VSCODE_CRASH_REPORTER_PROCESS_TYPE \
|
||||
-u VSCODE_CWD -u VSCODE_ESM_ENTRYPOINT -u VSCODE_HANDLES_UNCAUGHT_ERRORS \
|
||||
-u VSCODE_IPC_HOOK -u VSCODE_NLS_CONFIG -u VSCODE_PID -u VSCODE_L10N_BUNDLE_LOCATION \
|
||||
bun src/dev/debug-harness/server.ts --auto-launch --skip-build
|
||||
```
|
||||
|
||||
Check your own env with `env | grep -iE 'electron|vscode_'` first; `ELECTRON_RUN_AS_NODE=1`
|
||||
present means you must scrub before launching.
|
||||
|
||||
Other harness notes confirmed in practice:
|
||||
- The extension host is **ESM** (`VSCODE_ESM_ENTRYPOINT`), so `ext.evaluate` has no
|
||||
`require` and module-internal functions aren't reachable as globals. To inspect
|
||||
internal builders (e.g. `buildBedrockProviderConfig`), set a breakpoint with
|
||||
`ext.set_breakpoint` and read locals via `ext.evaluate` with the paused `callFrameId`
|
||||
— don't try to `require()` the bundle.
|
||||
- `web.evaluate` wraps the expression as a single returned expression; multi-statement
|
||||
snippets must be an IIFE `(() => { ...; return x; })()`, otherwise you get
|
||||
`SyntaxError: Unexpected token ';'`.
|
||||
- Webview settings inputs are `vscode-text-field` web components with debounced React
|
||||
onChange. Setting `.value` + dispatching events via `web.evaluate` is unreliable for
|
||||
some fields; focus the inner shadow `input` then use real keystrokes (`ui.type` +
|
||||
`ui.press Tab`, or click the dropdown option) to make the value persist.
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ Here, we use the common `StringRequest` and `KeyValuePair` types.
|
||||
|
||||
After editing a `.proto` file, regenerate the TypeScript code. From the project root, run:
|
||||
```bash
|
||||
bun run protos
|
||||
npm run protos
|
||||
```
|
||||
This command compiles all `.proto` files and outputs the generated code to `src/generated/` and `src/shared/`. Do not edit these generated files manually.
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
# SDK Adapter
|
||||
|
||||
The VSCode extension runs on the Cline SDK (`@cline/core`, `@cline/llms`,
|
||||
`@cline/shared`) through an adapter layer in `apps/vscode/src/sdk/`. The
|
||||
webview still talks gRPC; the adapter translates between gRPC handlers and SDK
|
||||
calls. See `apps/vscode/src/dev/debug-harness/README.md` for the debug harness.
|
||||
|
||||
## Conventions
|
||||
|
||||
1. **Look up SDK APIs, don't guess.** Use `kb_search(name="sdk", query="...")`
|
||||
before implementing against an SDK surface.
|
||||
2. **Reference the pre-SDK implementation when replacing a module.** Add a
|
||||
`// Replaces classic src/core/... (see origin/main)` header and use
|
||||
`kb_search(name="cline", commit="origin/main")` or
|
||||
`git show origin/main:path` to consult the prior implementation.
|
||||
3. **Single entry point.** There is one codepath — the SDK adapter. No
|
||||
`CLINE_SDK` env flag.
|
||||
4. **Use `{appBaseUrl}`**, never hardcode `app.cline.bot`.
|
||||
5. **Avoid `as` casts.** Use explicit conversion functions with tests. The
|
||||
branded types in `apps/vscode/src/sdk/model-catalog/contracts.ts` exist so
|
||||
casts are unnecessary outside parse/compute boundaries.
|
||||
|
||||
## Debug harness
|
||||
|
||||
- **Dismiss the Kanban/promo overlay** before any debug harness interaction.
|
||||
- **Use the command palette** to navigate tabs in the debug harness.
|
||||
@@ -91,7 +91,7 @@ On the main branch, create a commit that updates:
|
||||
|
||||
3. No changelog-entry file cleanup is needed. Contributors do not create changelog-entry files in this repo.
|
||||
|
||||
**No dependency install is needed.** A CHANGELOG + `version` bump does not change any dependency, and `bun.lock` does not pin workspace-package versions, so the lockfile stays consistent. The publish workflow runs `bun install --frozen-lockfile`, which would *fail* on an out-of-sync lock — so only run `bun install` here if you actually change dependencies (then commit the updated `bun.lock`).
|
||||
**Skip running `npm run install:all`** - release automation handles lockfile consistency as needed.
|
||||
|
||||
Commit with message format: `v{VERSION} Release Notes (hotfix)`
|
||||
|
||||
@@ -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/ext-vscode-publish-stable.yml (paste `v{VERSION}` as the tag)
|
||||
1. Manually trigger the publish release GitHub Action at: https://github.com/cline/cline/actions/workflows/publish.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/ext-vscode-publish-stable.yml
|
||||
https://github.com/cline/cline/actions/workflows/publish.yml
|
||||
|
||||
Use `v<version>` as the release tag.
|
||||
|
||||
|
||||
@@ -20,9 +20,8 @@ command = "chmod +x ./scripts/run-extension-host.sh && ./scripts/run-extension-h
|
||||
name = "CLI"
|
||||
icon = "run"
|
||||
command = '''
|
||||
cd sdk
|
||||
bun install
|
||||
bun run cli
|
||||
npm run cli:build
|
||||
npm run cli:run
|
||||
'''
|
||||
|
||||
[[actions]]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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.webm filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
* text=auto eol=lf
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
/.github/ @saoudrizwan @arafatkatze @maxpaulus43 @dominiccooney
|
||||
/.github/ @saoudrizwan @arafatkatze @maxpaulus43 @candieduniverse
|
||||
/README.md @saoudrizwan @juanpflores
|
||||
|
||||
@@ -7,16 +7,14 @@ body:
|
||||
value: |
|
||||
**Important:** All bug reports must be reproducible using Claude Sonnet 4.5. Cline uses complex prompts so less capable models may not work as expected.
|
||||
- type: dropdown
|
||||
id: cline-surface
|
||||
id: plugin-type
|
||||
attributes:
|
||||
label: Cline Surface
|
||||
description: Which Cline surface are you reporting a bug for?
|
||||
label: Plugin Type
|
||||
description: Which plugin are you reporting a bug for?
|
||||
options:
|
||||
- VSCode Extension
|
||||
- JetBrains Plugin
|
||||
- CLI
|
||||
- Desktop App
|
||||
- Cloud Platform
|
||||
default: 0
|
||||
validations:
|
||||
required: true
|
||||
@@ -28,12 +26,6 @@ body:
|
||||
placeholder: 'e.g., 1.2.3'
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: beta
|
||||
attributes:
|
||||
label: Beta version
|
||||
options:
|
||||
- label: I am using a beta version of Cline
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
@@ -61,20 +53,6 @@ body:
|
||||
placeholder: 'e.g., cline:anthropic/claude-sonnet-4.5, gemini:gemini-2.5-pro-exp-03-25'
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: ide-diagnostics
|
||||
attributes:
|
||||
label: Diagnostics
|
||||
description: |
|
||||
Paste the diagnostics for your Cline surface. This captures the build, runtime, and host details we need.
|
||||
- VSCode Extension: open `Help → About` (Windows/Linux) or `Code → About Visual Studio Code` (macOS), then copy the info.
|
||||
- JetBrains Plugin: open `Help → About` (Windows/Linux) or `<IDE name> → About` (macOS), then click `Copy` to grab build, runtime, OS, memory, and cores.
|
||||
- CLI: there is no About dialog. Run `cline --version` and paste the output.
|
||||
- Desktop App: paste the app version from the Settings view.
|
||||
- Cloud Platform: paste your browser name and version, plus the page URL where the issue occurred.
|
||||
placeholder: Paste the copied About info, `cline --version` output, or browser/app details here.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: system-info
|
||||
attributes:
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
name: Sign Windows CLI binaries
|
||||
description: >
|
||||
Authenticode-signs the compiled Windows CLI executables with Azure Trusted
|
||||
Signing (via jsign, so it runs on Linux runners) and verifies the resulting
|
||||
signatures. If the Azure Trusted Signing secrets are not configured, the
|
||||
action logs a warning and exits successfully so releases keep working while
|
||||
signing infrastructure is being provisioned.
|
||||
|
||||
inputs:
|
||||
azure-client-id:
|
||||
description: Client ID of the Entra app with the Trusted Signing Certificate Profile Signer role (OIDC federated credential, no client secret).
|
||||
required: false
|
||||
default: ""
|
||||
azure-tenant-id:
|
||||
description: Entra tenant ID.
|
||||
required: false
|
||||
default: ""
|
||||
azure-subscription-id:
|
||||
description: Azure subscription ID containing the Trusted Signing account.
|
||||
required: false
|
||||
default: ""
|
||||
endpoint:
|
||||
description: Trusted Signing account endpoint, for example https://eus.codesigning.azure.net.
|
||||
required: false
|
||||
default: ""
|
||||
account:
|
||||
description: Trusted Signing account name.
|
||||
required: false
|
||||
default: ""
|
||||
certificate-profile:
|
||||
description: Trusted Signing certificate profile name.
|
||||
required: false
|
||||
default: ""
|
||||
files:
|
||||
description: Newline-separated list of PE files to sign.
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Check signing configuration
|
||||
id: check
|
||||
shell: bash
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ inputs.azure-client-id }}
|
||||
AZURE_TENANT_ID: ${{ inputs.azure-tenant-id }}
|
||||
AZURE_SUBSCRIPTION_ID: ${{ inputs.azure-subscription-id }}
|
||||
SIGNING_ENDPOINT: ${{ inputs.endpoint }}
|
||||
SIGNING_ACCOUNT: ${{ inputs.account }}
|
||||
SIGNING_PROFILE: ${{ inputs.certificate-profile }}
|
||||
run: |
|
||||
missing=()
|
||||
set_count=0
|
||||
for var in AZURE_CLIENT_ID AZURE_TENANT_ID AZURE_SUBSCRIPTION_ID SIGNING_ENDPOINT SIGNING_ACCOUNT SIGNING_PROFILE; do
|
||||
if [ -z "${!var}" ]; then
|
||||
missing+=("$var")
|
||||
else
|
||||
set_count=$((set_count + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "${#missing[@]}" -eq 0 ]; then
|
||||
echo "Azure Trusted Signing is configured; Windows binaries will be signed."
|
||||
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||
elif [ "$set_count" -eq 0 ]; then
|
||||
echo "::warning::Azure Trusted Signing is not configured; publishing UNSIGNED Windows binaries. Set the AZURE_* and AZURE_TRUSTED_SIGNING_* repository secrets to enable signing."
|
||||
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
# Partial configuration is almost certainly a typo'd or renamed
|
||||
# secret. Fail loudly instead of silently publishing unsigned.
|
||||
echo "::error::Azure Trusted Signing is PARTIALLY configured; refusing to publish. Missing: ${missing[*]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Azure login (OIDC)
|
||||
if: steps.check.outputs.enabled == 'true'
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
|
||||
with:
|
||||
client-id: ${{ inputs.azure-client-id }}
|
||||
tenant-id: ${{ inputs.azure-tenant-id }}
|
||||
subscription-id: ${{ inputs.azure-subscription-id }}
|
||||
|
||||
- name: Sign Windows binaries
|
||||
if: steps.check.outputs.enabled == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
SIGNING_ENDPOINT: ${{ inputs.endpoint }}
|
||||
SIGNING_ACCOUNT: ${{ inputs.account }}
|
||||
SIGNING_PROFILE: ${{ inputs.certificate-profile }}
|
||||
FILES: ${{ inputs.files }}
|
||||
JSIGN_VERSION: "7.5"
|
||||
JSIGN_SHA256: "602a51c3545a6dc4fb99bd2ea7152b26d1345916d0c93ddfbd5936cb735af91c"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
JSIGN_JAR="${RUNNER_TEMP}/jsign-${JSIGN_VERSION}.jar"
|
||||
curl -fsSL -o "$JSIGN_JAR" "https://github.com/ebourg/jsign/releases/download/${JSIGN_VERSION}/jsign-${JSIGN_VERSION}.jar"
|
||||
echo "${JSIGN_SHA256} ${JSIGN_JAR}" | sha256sum --check --strict
|
||||
|
||||
JSIGN_STOREPASS=$(az account get-access-token --resource https://codesigning.azure.net --query accessToken --output tsv)
|
||||
echo "::add-mask::${JSIGN_STOREPASS}"
|
||||
export JSIGN_STOREPASS
|
||||
|
||||
# jsign expects the endpoint host, not the URL. Tolerate both the
|
||||
# portal's display form (trailing slash) and the bare form.
|
||||
KEYSTORE="${SIGNING_ENDPOINT#https://}"
|
||||
KEYSTORE="${KEYSTORE%/}"
|
||||
|
||||
while IFS= read -r file; do
|
||||
[ -z "$file" ] && continue
|
||||
echo "Signing ${file}"
|
||||
java -jar "$JSIGN_JAR" \
|
||||
--storetype TRUSTEDSIGNING \
|
||||
--keystore "$KEYSTORE" \
|
||||
--storepass env:JSIGN_STOREPASS \
|
||||
--alias "${SIGNING_ACCOUNT}/${SIGNING_PROFILE}" \
|
||||
--alg SHA-256 \
|
||||
--tsaurl http://timestamp.acs.microsoft.com \
|
||||
--tsmode RFC3161 \
|
||||
--replace \
|
||||
"$file"
|
||||
done <<< "$FILES"
|
||||
|
||||
- name: Verify signatures
|
||||
if: steps.check.outputs.enabled == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
FILES: ${{ inputs.files }}
|
||||
# Authenticode chains anchor to the Microsoft Identity Verification
|
||||
# Root CA 2020, which is not in the Mozilla TLS bundle, so fetch it
|
||||
# explicitly (pinned) for osslsigncode chain validation.
|
||||
MS_ROOT_URL: "https://www.microsoft.com/pkiops/certs/Microsoft%20Identity%20Verification%20Root%20Certificate%20Authority%202020.crt"
|
||||
MS_ROOT_SHA256: "5367f20c7ade0e2bca790915056d086b720c33c1fa2a2661acf787e3292e1270"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if ! command -v osslsigncode >/dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq osslsigncode
|
||||
fi
|
||||
|
||||
MS_ROOT_DER="${RUNNER_TEMP}/ms-identity-root-2020.crt"
|
||||
MS_ROOT_PEM="${RUNNER_TEMP}/ms-identity-root-2020.pem"
|
||||
curl -fsSL -o "$MS_ROOT_DER" "$MS_ROOT_URL"
|
||||
echo "${MS_ROOT_SHA256} ${MS_ROOT_DER}" | sha256sum --check --strict
|
||||
openssl x509 -inform DER -in "$MS_ROOT_DER" -out "$MS_ROOT_PEM"
|
||||
|
||||
while IFS= read -r file; do
|
||||
[ -z "$file" ] && continue
|
||||
echo "Verifying signature on ${file}"
|
||||
# Timestamp countersignature chain is checked separately by Windows;
|
||||
# -ignore-timestamp only skips TSA chain validation here, not the
|
||||
# Authenticode chain itself.
|
||||
osslsigncode verify -in "$file" -CAfile "$MS_ROOT_PEM" -ignore-timestamp
|
||||
done <<< "$FILES"
|
||||
@@ -5,18 +5,19 @@ 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`.
|
||||
|
||||
## Build & Test (Critical — non-obvious commands)
|
||||
- **Build**: `bun run compile` — NOT `bun run build`.
|
||||
- **Watch**: `bun run watch` (extension + webview).
|
||||
- **Protos**: `bun run protos` — run **immediately** after any `.proto` change. Generates into `src/shared/proto/`, `src/generated/`.
|
||||
- **Tests**: `bun run test:unit`. After prompt/tool changes: `UPDATE_SNAPSHOTS=true bun run test:unit`.
|
||||
- **Build**: `npm run compile` — NOT `npm run build`.
|
||||
- **Watch**: `npm run watch` (extension + webview).
|
||||
- **Protos**: `npm run protos` — run **immediately** after any `.proto` change. Generates into `src/shared/proto/`, `src/generated/`.
|
||||
- **Tests**: `npm run test:unit`. After prompt/tool changes: `UPDATE_SNAPSHOTS=true npm run test:unit`.
|
||||
|
||||
## Protobuf RPC Workflow (4 steps)
|
||||
1. **Define** in `proto/cline/*.proto`. Naming: `PascalCaseService`, `camelCase` RPCs, `PascalCase` Messages. Use `common.proto` shared types for simple data.
|
||||
2. **Generate**: `bun run protos`.
|
||||
2. **Generate**: `npm run protos`.
|
||||
3. **Backend handler**: `src/core/controller/<domain>/`.
|
||||
4. **Frontend call**: `UiServiceClient.myMethod(Request.create({...}))`.
|
||||
- Adding enums (e.g. `ClineSay`) → also update `src/shared/proto-conversions/cline-message.ts`.
|
||||
@@ -27,7 +28,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`.
|
||||
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`.
|
||||
|
||||
For Responses API providers: add to `isNextGenModelProvider()` in `src/utils/model-utils.ts` and set `apiFormat: ApiFormat.OPENAI_RESPONSES` on models.
|
||||
|
||||
@@ -38,13 +39,13 @@ For Responses API providers: add to `isNextGenModelProvider()` in `src/utils/mod
|
||||
4. Whitelist in `src/core/prompts/system-prompt/variants/*/config.ts` for each model family.
|
||||
5. Handler in `src/core/task/tools/handlers/`, wire in `ToolExecutor.ts`.
|
||||
6. If tool has UI: add `ClineSay` enum in proto → `ExtensionMessage.ts` → `cline-message.ts` → `ChatRow.tsx`.
|
||||
7. Regenerate snapshots: `UPDATE_SNAPSHOTS=true bun run test:unit`.
|
||||
7. Regenerate snapshots: `UPDATE_SNAPSHOTS=true npm run test:unit`.
|
||||
|
||||
## Modifying System Prompt
|
||||
Modular: `components/` (shared) + `variants/` (model-specific) + `templates/` (`{{PLACEHOLDER}}`). Variants override components via `componentOverrides` in `config.ts` or custom `template.ts`. XS variant is heavily condensed inline. Always regenerate snapshots after changes.
|
||||
|
||||
## Global State Keys (silent failure risk)
|
||||
Adding a key requires updating the typed storage definitions in `src/shared/storage/state-keys.ts`; runtime reads and writes should go through `StateManager`, not VS Code `ExtensionContext` storage. Persistent state is file-backed so it works across VS Code, CLI, and JetBrains hosts.
|
||||
Adding a key requires: type in `src/shared/storage/state-keys.ts`, read via `context.globalState.get()` in `src/core/storage/utils/state-helpers.ts` `readGlobalStateFromDisk()`, and add to return object. Missing the `.get()` call compiles fine but value is always `undefined`.
|
||||
|
||||
## Slash Commands (3 places)
|
||||
- `src/core/slash-commands/index.ts` — definitions.
|
||||
|
||||
@@ -2,7 +2,7 @@ version: 2
|
||||
updates:
|
||||
# Main extension dependencies
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/apps/vscode"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
# Group all updates into a single PR
|
||||
@@ -20,7 +20,7 @@ updates:
|
||||
|
||||
# Webview UI dependencies
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/apps/vscode/webview-ui"
|
||||
directory: "/webview-ui"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
groups:
|
||||
|
||||
@@ -59,7 +59,7 @@ We're not looking for exhaustive documentation - just evidence that you've thoug
|
||||
<!-- Put an 'x' in all boxes that apply -->
|
||||
|
||||
- [ ] Changes are limited to a single feature, bugfix or chore (split larger changes into separate PRs)
|
||||
- [ ] Tests are passing (`bun test`) and code is formatted and linted (`bun run format && bun run lint`)
|
||||
- [ ] Tests are passing (`npm test`) and code is formatted and linted (`npm run format && npm run lint`)
|
||||
- [ ] I have reviewed [contributor guidelines](https://github.com/cline/cline/blob/main/CONTRIBUTING.md)
|
||||
|
||||
### Screenshots
|
||||
|
||||
@@ -1,490 +0,0 @@
|
||||
name: cli-publish
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 12 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish_target:
|
||||
description: "Which publish flow to run"
|
||||
required: true
|
||||
default: "main"
|
||||
type: choice
|
||||
options:
|
||||
- main
|
||||
- nightly
|
||||
git_tag:
|
||||
description: "Existing release tag to publish when publish_target=main, for example cli-v0.1.0"
|
||||
required: false
|
||||
type: string
|
||||
confirm_publish:
|
||||
description: 'Required when publish_target=main. Type "publish" to confirm release publish.'
|
||||
required: false
|
||||
type: string
|
||||
force_nightly_publish:
|
||||
description: "Force nightly publish even with no commits in last 24h"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: .
|
||||
|
||||
jobs:
|
||||
publish-main:
|
||||
name: Publish cline
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
if: |
|
||||
github.repository == 'cline/cline' &&
|
||||
github.ref == 'refs/heads/main' &&
|
||||
github.event_name == 'workflow_dispatch' &&
|
||||
github.event.inputs.publish_target == 'main' &&
|
||||
github.event.inputs.confirm_publish == 'publish' &&
|
||||
!endsWith(github.actor, '[bot]')
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.git_tag }}
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24.x"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Verify publish tooling
|
||||
run: |
|
||||
NPM_VERSION=$(npm --version)
|
||||
echo "npm ${NPM_VERSION}"
|
||||
IFS=. read -r major minor patch <<EOF
|
||||
${NPM_VERSION}
|
||||
EOF
|
||||
if [ "$major" -lt 11 ] || { [ "$major" -eq 11 ] && [ "$minor" -lt 5 ]; } || { [ "$major" -eq 11 ] && [ "$minor" -eq 5 ] && [ "$patch" -lt 1 ]; }; then
|
||||
echo "npm 11.5.1 or newer is required for trusted publishing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Validate release tag
|
||||
id: version
|
||||
env:
|
||||
TAG: ${{ github.event.inputs.git_tag }}
|
||||
run: |
|
||||
if [ -z "$TAG" ]; then
|
||||
echo "git_tag is required when publish_target=main"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! printf "%s\n" "$TAG" | grep -Eq '^cli-v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
|
||||
echo "git_tag must look like cli-vX.Y.Z, got: ${TAG}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION="${TAG#cli-v}"
|
||||
PACKAGE_VERSION=$(node -p "require('./apps/cli/package.json').version")
|
||||
|
||||
if [ "$PACKAGE_VERSION" != "$VERSION" ]; then
|
||||
echo "apps/cli/package.json version ${PACKAGE_VERSION} does not match ${TAG}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! printf "%s\n" "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
|
||||
echo "apps/cli/package.json has invalid version: ${VERSION}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TAG_COMMIT=$(git rev-parse "${TAG}^{commit}")
|
||||
HEAD_COMMIT=$(git rev-parse HEAD)
|
||||
if [ "$TAG_COMMIT" != "$HEAD_COMMIT" ]; then
|
||||
echo "${TAG} does not point at the checked out commit"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch origin +main:refs/remotes/origin/main
|
||||
if ! git merge-base --is-ancestor "$HEAD_COMMIT" origin/main; then
|
||||
echo "${TAG} is not reachable from origin/main"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build SDK packages
|
||||
run: bun run build:sdk
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
|
||||
- name: Run tests
|
||||
run: bun run test
|
||||
|
||||
- name: Build platform binaries
|
||||
run: bun script/build.ts --install-native-variants --skip-sdk-build
|
||||
working-directory: apps/cli
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
|
||||
- name: Verify build output
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
EXPECTED=(
|
||||
"@cline/cli-darwin-arm64"
|
||||
"@cline/cli-darwin-x64"
|
||||
"@cline/cli-linux-arm64"
|
||||
"@cline/cli-linux-x64"
|
||||
"@cline/cli-windows-arm64"
|
||||
"@cline/cli-windows-x64"
|
||||
)
|
||||
|
||||
for package_name in "${EXPECTED[@]}"; do
|
||||
dir="apps/cli/dist/${package_name#@cline/}"
|
||||
if [ ! -f "$dir/package.json" ]; then
|
||||
echo "Missing package manifest: $dir/package.json"
|
||||
exit 1
|
||||
fi
|
||||
actual_name=$(node -p "require('./$dir/package.json').name")
|
||||
actual_version=$(node -p "require('./$dir/package.json').version")
|
||||
if [ "$actual_name" != "$package_name" ]; then
|
||||
echo "Expected $package_name, got $actual_name"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$actual_version" != "$VERSION" ]; then
|
||||
echo "Expected $package_name@$VERSION, got $actual_version"
|
||||
exit 1
|
||||
fi
|
||||
ls -lh "$dir/bin/"
|
||||
done
|
||||
|
||||
- name: Sign Windows binaries
|
||||
uses: ./.github/actions/sign-windows-cli
|
||||
with:
|
||||
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
|
||||
account: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
|
||||
certificate-profile: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_CLI }}
|
||||
files: |
|
||||
apps/cli/dist/cli-windows-x64/bin/cline.exe
|
||||
apps/cli/dist/cli-windows-arm64/bin/cline.exe
|
||||
|
||||
- name: Publish to NPM with latest tag
|
||||
env:
|
||||
NPM_CONFIG_PROVENANCE: "true"
|
||||
run: bun script/publish-npm.ts --tag latest
|
||||
working-directory: apps/cli
|
||||
|
||||
- name: Get Previous CLI Tag
|
||||
id: prev_tag
|
||||
env:
|
||||
CURRENT_TAG: ${{ steps.version.outputs.tag }}
|
||||
run: |
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 --match 'cli-v*' "$CURRENT_TAG^" 2>/dev/null || echo "")
|
||||
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get Changelog Entry
|
||||
id: changelog
|
||||
env:
|
||||
RELEASE_URL: https://github.com/${{ github.repository }}/releases/tag/${{ steps.version.outputs.tag }}
|
||||
run: |
|
||||
# Grab content between the first "## " header and the next one in apps/cli/CHANGELOG.md
|
||||
CONTENT=$(awk '/^## [0-9]/{if(found) exit; found=1; next} found{print}' apps/cli/CHANGELOG.md)
|
||||
echo "content<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
# Slack section blocks reject text longer than 3000 characters, and the
|
||||
# Slack action logs that rejection WITHOUT failing the step - so an
|
||||
# over-long changelog silently drops the release announcement while the
|
||||
# run stays green (cline@3.0.50 hit this). Post a trimmed copy to Slack
|
||||
# and link out to the full notes. The GitHub release body stays whole.
|
||||
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
|
||||
import os
|
||||
content = os.environ["CONTENT"]
|
||||
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
|
||||
if len(content) <= 3000:
|
||||
print(content, end="")
|
||||
else:
|
||||
budget = 3000 - len(more)
|
||||
kept, used = [], 0
|
||||
for line in content.splitlines(keepends=True):
|
||||
if used + len(line) > budget:
|
||||
break
|
||||
kept.append(line)
|
||||
used += len(line)
|
||||
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
|
||||
print(body + more, end="")
|
||||
')
|
||||
echo "slack_content<<SLACK_EOF" >> $GITHUB_OUTPUT
|
||||
echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "SLACK_EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ steps.version.outputs.tag }}
|
||||
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) || '' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Summary
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
echo "Published cline@${VERSION} to npm with dist-tag 'latest'"
|
||||
echo "Install with: npm install -g cline"
|
||||
|
||||
- name: Post release to Slack
|
||||
uses: slackapi/slack-github-action@v3.0.1
|
||||
with:
|
||||
method: chat.postMessage
|
||||
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
|
||||
payload: |
|
||||
channel: "C0APVKGGZFC"
|
||||
text: "Cline CLI v${{ steps.version.outputs.version }}"
|
||||
blocks:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: "Cline CLI v${{ steps.version.outputs.version }}"
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
text: "<https://www.npmjs.com/package/cline/v/${{ steps.version.outputs.version }}|View on npm>${{ 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) || '' }}"
|
||||
|
||||
publish-nightly:
|
||||
name: Publish cline nightly
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
if: |
|
||||
github.repository == 'cline/cline' &&
|
||||
github.ref == 'refs/heads/main' &&
|
||||
(
|
||||
github.event_name == 'schedule' ||
|
||||
(
|
||||
github.event_name == 'workflow_dispatch' &&
|
||||
github.event.inputs.publish_target == 'nightly'
|
||||
)
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check for recent commits
|
||||
id: check_commits
|
||||
env:
|
||||
FORCE_PUBLISH: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.force_nightly_publish == 'true' }}
|
||||
run: |
|
||||
if [ "$FORCE_PUBLISH" = "true" ]; then
|
||||
echo "force_nightly_publish enabled, proceeding with publish"
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$(git rev-list --count HEAD --since='24 hours ago')" -eq 0 ]; then
|
||||
echo "No commits in last 24 hours, skipping publish"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "Found recent commits, proceeding with publish"
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Setup Bun
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24.x"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Verify publish tooling
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: |
|
||||
NPM_VERSION=$(npm --version)
|
||||
echo "npm ${NPM_VERSION}"
|
||||
IFS=. read -r major minor patch <<EOF
|
||||
${NPM_VERSION}
|
||||
EOF
|
||||
if [ "$major" -lt 11 ] || { [ "$major" -eq 11 ] && [ "$minor" -lt 5 ]; } || { [ "$major" -eq 11 ] && [ "$minor" -eq 5 ] && [ "$patch" -lt 1 ]; }; then
|
||||
echo "npm 11.5.1 or newer is required for trusted publishing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: bun install
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build SDK packages
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: bun run build:sdk
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
|
||||
- name: Run tests
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: bun run test
|
||||
|
||||
- name: Generate nightly version
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
id: version
|
||||
run: |
|
||||
BASE_VERSION=$(node -p "require('./apps/cli/package.json').version")
|
||||
TIMESTAMP=$(date +%s)
|
||||
VERSION="${BASE_VERSION}-nightly.${TIMESTAMP}"
|
||||
|
||||
echo "Base version: ${BASE_VERSION}"
|
||||
echo "Generated nightly version: ${VERSION}"
|
||||
echo "base_version=${BASE_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Update nightly package version
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
node -e '
|
||||
const fs = require("node:fs");
|
||||
const path = "apps/cli/package.json";
|
||||
const pkg = JSON.parse(fs.readFileSync(path, "utf8"));
|
||||
pkg.version = process.env.VERSION;
|
||||
fs.writeFileSync(path, `${JSON.stringify(pkg, null, "\t")}\n`);
|
||||
'
|
||||
cat apps/cli/package.json | grep '"version"'
|
||||
|
||||
- name: Build platform binaries
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: bun script/build.ts --install-native-variants --skip-sdk-build
|
||||
working-directory: apps/cli
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
|
||||
- name: Verify build output
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
EXPECTED=(
|
||||
"@cline/cli-darwin-arm64"
|
||||
"@cline/cli-darwin-x64"
|
||||
"@cline/cli-linux-arm64"
|
||||
"@cline/cli-linux-x64"
|
||||
"@cline/cli-windows-arm64"
|
||||
"@cline/cli-windows-x64"
|
||||
)
|
||||
|
||||
for package_name in "${EXPECTED[@]}"; do
|
||||
dir="apps/cli/dist/${package_name#@cline/}"
|
||||
if [ ! -f "$dir/package.json" ]; then
|
||||
echo "Missing package manifest: $dir/package.json"
|
||||
exit 1
|
||||
fi
|
||||
actual_name=$(node -p "require('./$dir/package.json').name")
|
||||
actual_version=$(node -p "require('./$dir/package.json').version")
|
||||
if [ "$actual_name" != "$package_name" ]; then
|
||||
echo "Expected $package_name, got $actual_name"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$actual_version" != "$VERSION" ]; then
|
||||
echo "Expected $package_name@$VERSION, got $actual_version"
|
||||
exit 1
|
||||
fi
|
||||
ls -lh "$dir/bin/"
|
||||
done
|
||||
|
||||
- name: Sign Windows binaries
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
uses: ./.github/actions/sign-windows-cli
|
||||
with:
|
||||
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
|
||||
account: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
|
||||
certificate-profile: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_CLI }}
|
||||
files: |
|
||||
apps/cli/dist/cli-windows-x64/bin/cline.exe
|
||||
apps/cli/dist/cli-windows-arm64/bin/cline.exe
|
||||
|
||||
- name: Publish to NPM with nightly tag
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
NPM_CONFIG_PROVENANCE: "true"
|
||||
run: bun script/publish-npm.ts --tag nightly
|
||||
working-directory: apps/cli
|
||||
|
||||
- name: Summary
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
echo "Published cline@${VERSION} to npm with dist-tag 'nightly'"
|
||||
echo "Install with: npm install -g cline@nightly"
|
||||
@@ -0,0 +1,83 @@
|
||||
name: CLI TUI Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
cli-tui-tests:
|
||||
name: CLI TUI Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build CLI
|
||||
run: npm run cli:build
|
||||
|
||||
- name: Run TUI Tests
|
||||
id: tui_tests
|
||||
run: |
|
||||
npm run test:e2e:cli:tui 2>&1 | tee tui-test-output.log
|
||||
exit_code=${PIPESTATUS[0]}
|
||||
echo "tui_exit_code=$exit_code" >> $GITHUB_OUTPUT
|
||||
exit $exit_code
|
||||
|
||||
- name: Write failure summary
|
||||
if: always() && steps.tui_tests.outcome != 'success' && steps.tui_tests.outcome != 'skipped'
|
||||
run: |
|
||||
echo "## ❌ CLI TUI Tests Failed" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Step outcome:** \`${{ steps.tui_tests.outcome }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Test Output" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
if [ -f tui-test-output.log ]; then
|
||||
cat tui-test-output.log >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "(no test output captured — process may have been killed before output was flushed)" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Debugging" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **TUI traces** are attached as artifacts below — download and inspect them to see terminal state at the point of failure." >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **To view a trace replay/Run a TUI Trace: ** run \`npx tui-test show-trace path/to/trace/file\` in your terminal" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Full test log** is also attached as an artifact." >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Tests run with \`retries: 2\` so any failure shown is a consistent failure, not a flake." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Upload TUI traces
|
||||
if: always() && steps.tui_tests.outcome != 'success' && steps.tui_tests.outcome != 'skipped'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: tui-test-traces
|
||||
path: tests/e2e/cli/tui-traces/
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload test log
|
||||
if: always() && steps.tui_tests.outcome != 'success' && steps.tui_tests.outcome != 'skipped'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: tui-test-log
|
||||
path: tui-test-output.log
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
@@ -0,0 +1,85 @@
|
||||
name: Smoke Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'src/core/**'
|
||||
- 'src/shared/**'
|
||||
- 'proto/**'
|
||||
- 'evals/**'
|
||||
- '.github/workflows/cline-evals-regression.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/core/**'
|
||||
- 'src/shared/**'
|
||||
- 'proto/**'
|
||||
- 'evals/**'
|
||||
- '.github/workflows/cline-evals-regression.yml'
|
||||
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,928 +0,0 @@
|
||||
name: desktop-publish
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
git_tag:
|
||||
description: "Existing release tag to publish, for example desktop-v0.1.0"
|
||||
required: true
|
||||
type: string
|
||||
confirm_publish:
|
||||
description: 'Type "publish" to confirm the desktop release.'
|
||||
required: true
|
||||
type: string
|
||||
channel:
|
||||
description: "Release channel"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- stable
|
||||
- beta
|
||||
default: stable
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: .
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate release tag
|
||||
if: |
|
||||
github.repository == 'cline/cline' &&
|
||||
github.event.inputs.confirm_publish == 'publish' &&
|
||||
!endsWith(github.actor, '[bot]')
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
channel: ${{ steps.version.outputs.channel }}
|
||||
feed: ${{ steps.version.outputs.feed }}
|
||||
product: ${{ steps.version.outputs.product }}
|
||||
steps:
|
||||
# Companion to the presence check in `build`, and the half that actually
|
||||
# establishes scope. This job declares no environment, so a signing secret
|
||||
# that resolves here can only be a repository or organization secret —
|
||||
# meaning it is still readable by every workflow in the repo, which is the
|
||||
# thing the PublishDesktop environment exists to prevent. Neither check
|
||||
# proves provenance alone (an environment-gated job resolves repository
|
||||
# secrets too, with environment values merely taking precedence), but
|
||||
# together they do: empty here plus present in `build` means the value came
|
||||
# from the environment.
|
||||
- name: Verify signing secrets are not repository-scoped
|
||||
env:
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_CONTENT }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
unscoped=()
|
||||
for name in APPLE_API_ISSUER APPLE_API_KEY APPLE_API_KEY_CONTENT \
|
||||
APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY \
|
||||
TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do
|
||||
[ -z "${!name}" ] || unscoped+=("$name")
|
||||
done
|
||||
|
||||
if [ ${#unscoped[@]} -gt 0 ]; then
|
||||
echo "These signing secrets resolve in a job with no environment:"
|
||||
printf ' - %s\n' "${unscoped[@]}"
|
||||
echo
|
||||
echo "That means they are still repository or organization secrets and"
|
||||
echo "are readable by any workflow in this repo. Delete them at that"
|
||||
echo "level and add them to the PublishDesktop environment instead."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "No signing secret resolves outside the PublishDesktop environment."
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.git_tag }}
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Validate release tag
|
||||
id: version
|
||||
env:
|
||||
TAG: ${{ github.event.inputs.git_tag }}
|
||||
# inputs.* (not github.event.inputs.*) so the declared default
|
||||
# applies when an API dispatch omits the channel input entirely.
|
||||
CHANNEL: ${{ inputs.channel }}
|
||||
run: |
|
||||
# Fail-closed channel mapping: every channel defines its tag shape,
|
||||
# its ancestry source, its feed, and its product name, and an unknown
|
||||
# channel dies here. The feed assignment is the load-bearing one —
|
||||
# the updater comparator is a plain semver "newer than", so a beta
|
||||
# manifest landing on desktop-latest would auto-update every stable
|
||||
# install onto the beta. The stable regex rejects prerelease
|
||||
# suffixes for the same reason.
|
||||
case "$CHANNEL" in
|
||||
stable)
|
||||
if ! printf "%s\n" "$TAG" | grep -Eq '^desktop-v[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "stable git_tag must look like desktop-vX.Y.Z with no suffix, got: ${TAG}"
|
||||
exit 1
|
||||
fi
|
||||
ANCESTOR_REF=main
|
||||
FEED=desktop-latest
|
||||
PRODUCT="Cline"
|
||||
;;
|
||||
beta)
|
||||
if ! printf "%s\n" "$TAG" | grep -Eq '^desktop-v[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$'; then
|
||||
echo "beta git_tag must look like desktop-vX.Y.Z-beta.N, got: ${TAG}"
|
||||
exit 1
|
||||
fi
|
||||
ANCESTOR_REF=desktop-experimental
|
||||
FEED=desktop-beta
|
||||
PRODUCT="Cline Beta"
|
||||
;;
|
||||
*)
|
||||
echo "unknown channel: ${CHANNEL}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
VERSION="${TAG#desktop-v}"
|
||||
PACKAGE_VERSION=$(node -p "require('./apps/examples/desktop-app/package.json').version")
|
||||
TAURI_VERSION=$(node -p "require('./apps/examples/desktop-app/src-tauri/tauri.conf.json').version")
|
||||
|
||||
if [ "$PACKAGE_VERSION" != "$VERSION" ]; then
|
||||
echo "apps/examples/desktop-app/package.json version ${PACKAGE_VERSION} does not match ${TAG}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$TAURI_VERSION" != "$VERSION" ]; then
|
||||
echo "apps/examples/desktop-app/src-tauri/tauri.conf.json version ${TAURI_VERSION} does not match ${TAG}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TAG_COMMIT=$(git rev-parse "${TAG}^{commit}")
|
||||
HEAD_COMMIT=$(git rev-parse HEAD)
|
||||
if [ "$TAG_COMMIT" != "$HEAD_COMMIT" ]; then
|
||||
echo "${TAG} does not point at the checked out commit"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch origin "+${ANCESTOR_REF}:refs/remotes/origin/${ANCESTOR_REF}"
|
||||
if ! git merge-base --is-ancestor "$HEAD_COMMIT" "origin/${ANCESTOR_REF}"; then
|
||||
echo "${TAG} is not reachable from origin/${ANCESTOR_REF}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "channel=${CHANNEL}" >> "$GITHUB_OUTPUT"
|
||||
echo "feed=${FEED}" >> "$GITHUB_OUTPUT"
|
||||
echo "product=${PRODUCT}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
name: Build macOS (universal)
|
||||
needs: validate
|
||||
# The Apple signing/notarization and Tauri updater secrets live in the
|
||||
# PublishDesktop environment rather than at repository level, so they are
|
||||
# readable only by this job and only once a required reviewer approves the
|
||||
# run. Defense in depth: this `if` is advisory because a dispatched branch
|
||||
# runs its own copy of this file; the enforced gate is the PublishDesktop
|
||||
# environment's deployment-branch policy, which must also allow only main.
|
||||
#
|
||||
# Beta releases do not weaken this: a beta publish is ALSO dispatched from
|
||||
# main (so this gate, the branch policy, and the workflow file executed all
|
||||
# stay main's) — only the checked-out tag points into desktop-experimental,
|
||||
# which validate pins via the ancestry check. A workflow copy edited on
|
||||
# desktop-experimental can therefore never reach the signing secrets.
|
||||
#
|
||||
# What dispatch-from-main does NOT protect: the checked-out tag's own
|
||||
# build scripts (bun install hooks, build:sdk, Tauri's beforeBuildCommand,
|
||||
# build.rs) run inside this job with the signing secrets in scope, for
|
||||
# stable and beta alike. The control for that is this environment's
|
||||
# required-reviewer approval — the approver is vouching for the code the
|
||||
# tag points at, not just for "a release happening". Two consequences:
|
||||
# desktop-experimental must keep main-grade merge controls (branch
|
||||
# protection, maintainer-only pushes), and an approval should only follow
|
||||
# a look at what the tag actually contains. Building betas without these
|
||||
# secrets is not an option: unsigned bundles fail Gatekeeper and updater
|
||||
# artifacts must be signed with the same key or beta installs cannot
|
||||
# verify their updates.
|
||||
if: github.ref == 'refs/heads/main'
|
||||
environment: PublishDesktop
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
# A secret missing here is dangerous rather than merely broken: Tauri skips
|
||||
# code signing when APPLE_CERTIFICATE is empty and skips notarization when
|
||||
# APPLE_API_KEY is empty, both silently, so the build would still succeed
|
||||
# and publish an unsigned, un-notarized bundle. Only the missing updater
|
||||
# key is caught later (by the .sig check in "Collect artifacts"). Fail up
|
||||
# front instead, before any build work, if the environment is misconfigured.
|
||||
- name: Verify PublishDesktop secrets are present
|
||||
env:
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_CONTENT }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
missing=()
|
||||
for name in APPLE_API_ISSUER APPLE_API_KEY APPLE_API_KEY_CONTENT \
|
||||
APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY \
|
||||
TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do
|
||||
[ -n "${!name}" ] || missing+=("$name")
|
||||
done
|
||||
|
||||
if [ ${#missing[@]} -gt 0 ]; then
|
||||
echo "Missing from the PublishDesktop environment:"
|
||||
printf ' - %s\n' "${missing[@]}"
|
||||
echo
|
||||
echo "Check that every secret above is set on the PublishDesktop"
|
||||
echo "environment and that this job still declares"
|
||||
echo "'environment: PublishDesktop'."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Deliberately not phrased as "resolved from PublishDesktop": a
|
||||
# non-empty value here could also be a repository or organization
|
||||
# secret. The repository-scope check in `validate` is what rules that
|
||||
# out.
|
||||
echo "All 8 signing secrets are present."
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.tag }}
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
|
||||
# A universal (fat) macOS bundle needs both architecture slices, so
|
||||
# install both Rust targets; `tauri build --target universal-apple-darwin`
|
||||
# compiles each and lipos the results into one binary.
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin,x86_64-apple-darwin
|
||||
|
||||
# No Rust build cache here, deliberately. This is the only job that can
|
||||
# read the Apple signing certificate and the Tauri updater key, and a
|
||||
# restored cache archive is attacker-controlled the moment the Actions
|
||||
# cache is poisoned.
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build SDK packages
|
||||
run: bun run build:sdk
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
|
||||
- name: Write App Store Connect API key
|
||||
env:
|
||||
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_CONTENT }}
|
||||
run: |
|
||||
if [ -z "$APPLE_API_KEY_CONTENT" ]; then
|
||||
echo "APPLE_API_KEY_CONTENT secret is not configured"
|
||||
exit 1
|
||||
fi
|
||||
printf "%s" "$APPLE_API_KEY_CONTENT" > "$RUNNER_TEMP/AuthKey.p8"
|
||||
|
||||
- name: Build, sign, and notarize desktop bundle
|
||||
working-directory: apps/examples/desktop-app
|
||||
# Tauri merges repeated --config flags in order, so the beta overlay
|
||||
# (product name, bundle identifier, beta update feed) layers on top of
|
||||
# the release overlay without duplicating it. $CONFIG_ARGS is
|
||||
# deliberately unquoted: it must word-split into separate flags.
|
||||
run: bunx tauri build --target universal-apple-darwin $CONFIG_ARGS
|
||||
env:
|
||||
CONFIG_ARGS: ${{ needs.validate.outputs.channel == 'beta' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.beta.conf.json' || '--config src-tauri/tauri.release.conf.json' }}
|
||||
# Telemetry config for the sidecar binary. Tauri's beforeBuildCommand
|
||||
# (`bun run build` -> build:sidecar:bin) compiles the sidecar during
|
||||
# this step and inlines these values into the binary via `--define`
|
||||
# (scripts/telemetry-define-args.ts); a packaged app launched from
|
||||
# Finder/the Dock has no runtime env, so build-time inlining is the
|
||||
# only way the shipped sidecar can ever report telemetry.
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
# Developer ID signing (Tauri imports the cert into a temp keychain)
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
# Notarization via App Store Connect API key. Tauri reads the Key ID
|
||||
# from APPLE_API_KEY; APPLE_API_KEY_ID alone silently skips notarization.
|
||||
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||
APPLE_API_KEY_PATH: ${{ runner.temp }}/AuthKey.p8
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
# Updater artifact signing (minisign keypair, independent of Apple)
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
# Tauri lipos the main binary itself but sidecars are merged by our own
|
||||
# build-sidecar-bin.ts, so assert every Mach-O in the bundle really
|
||||
# carries both slices before anything is published. A single-arch
|
||||
# sidecar would otherwise ship fine and only crash on the other arch.
|
||||
- name: Verify bundle is a universal binary
|
||||
working-directory: apps/examples/desktop-app
|
||||
env:
|
||||
PRODUCT: ${{ needs.validate.outputs.product }}
|
||||
run: |
|
||||
APP="src-tauri/target/universal-apple-darwin/release/bundle/macos/${PRODUCT}.app"
|
||||
if [ ! -d "$APP" ]; then
|
||||
echo "app bundle not found at $APP"
|
||||
exit 1
|
||||
fi
|
||||
for bin in "$APP/Contents/MacOS/"*; do
|
||||
archs=$(lipo -archs "$bin")
|
||||
echo "$bin: $archs"
|
||||
case "$archs" in
|
||||
*arm64*x86_64*|*x86_64*arm64*) ;;
|
||||
*)
|
||||
echo "$bin is not a universal binary (archs: $archs)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Guardrail: the updater endpoint is compiled into the main binary as a
|
||||
# string literal (tauri-build embeds the merged config via codegen), so
|
||||
# assert the bundle carries this channel's feed URL and not the other
|
||||
# channel's, before anything gets signed into a release. This catches a
|
||||
# --config overlay that silently failed to apply: a beta bundle polling
|
||||
# desktop-latest would pull its users onto stable builds, and a stable
|
||||
# bundle polling desktop-beta would push betas to every stable install.
|
||||
- name: Verify updater feed endpoint
|
||||
working-directory: apps/examples/desktop-app
|
||||
env:
|
||||
CHANNEL: ${{ needs.validate.outputs.channel }}
|
||||
PRODUCT: ${{ needs.validate.outputs.product }}
|
||||
run: |
|
||||
APP="src-tauri/target/universal-apple-darwin/release/bundle/macos/${PRODUCT}.app"
|
||||
case "$CHANNEL" in
|
||||
stable)
|
||||
WANT="releases/download/desktop-latest/latest.json"
|
||||
FORBID="releases/download/desktop-beta/latest.json"
|
||||
;;
|
||||
beta)
|
||||
WANT="releases/download/desktop-beta/latest.json"
|
||||
FORBID="releases/download/desktop-latest/latest.json"
|
||||
;;
|
||||
*)
|
||||
echo "unknown channel: ${CHANNEL}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Plain grep >/dev/null rather than grep -q: -q exits at the first
|
||||
# match, SIGPIPEs strings, and would read as a failed pipeline under
|
||||
# pipefail.
|
||||
found=0
|
||||
for bin in "$APP/Contents/MacOS/"*; do
|
||||
if strings -a "$bin" | grep "$FORBID" >/dev/null; then
|
||||
echo "$bin embeds the other channel's feed URL (${FORBID})"
|
||||
exit 1
|
||||
fi
|
||||
if strings -a "$bin" | grep "$WANT" >/dev/null; then
|
||||
found=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$found" -ne 1 ]; then
|
||||
echo "No binary in ${APP}/Contents/MacOS embeds ${WANT}."
|
||||
echo "The updater endpoint overlay did not apply; check the"
|
||||
echo "--config flags on the build step and tauri.beta.conf.json."
|
||||
exit 1
|
||||
fi
|
||||
echo "Updater endpoint verified: ${WANT}"
|
||||
|
||||
# Guardrail: assert the telemetry config actually made it into the
|
||||
# compiled sidecar. Missing env on the build step (or a regression in
|
||||
# the --define inlining) would otherwise ship a release with telemetry
|
||||
# silently disabled — exactly what happened for every release before
|
||||
# this check existed. Being enabled is not enough on its own: an empty,
|
||||
# malformed, or non-http(s) OTLP endpoint would still drop every event
|
||||
# at runtime (the SDK exporters speak OTLP http/json only), so the
|
||||
# selfcheck must also report a usable endpoint host.
|
||||
- name: Verify sidecar telemetry config was inlined
|
||||
working-directory: apps/examples/desktop-app
|
||||
run: |
|
||||
SELFCHECK=$(./src-tauri/bin/code-sidecar-universal-apple-darwin --telemetry-selfcheck)
|
||||
echo "$SELFCHECK"
|
||||
if ! printf '%s' "$SELFCHECK" | grep -q '"enabled":true'; then
|
||||
echo "Packaged sidecar reports telemetry disabled."
|
||||
echo "Check the OTEL_* / TELEMETRY_SERVICE_API_KEY env on the"
|
||||
echo "'Build, sign, and notarize desktop bundle' step and the"
|
||||
echo "--define inlining in scripts/build-sidecar-bin.ts."
|
||||
exit 1
|
||||
fi
|
||||
if printf '%s' "$SELFCHECK" | grep -Eq '"otlp_endpoint_host":"(invalid-endpoint-url)?"'; then
|
||||
echo "Packaged sidecar reports telemetry enabled but its OTLP"
|
||||
echo "endpoint is missing, unparseable, or not an http(s) URL, so"
|
||||
echo "every event would be dropped at runtime. Check the"
|
||||
echo "OTEL_EXPORTER_OTLP_ENDPOINT secret."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Collect artifacts
|
||||
working-directory: apps/examples/desktop-app
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
PRODUCT: ${{ needs.validate.outputs.product }}
|
||||
run: |
|
||||
BUNDLE_DIR="src-tauri/target/universal-apple-darwin/release/bundle"
|
||||
OUT="dist/publish"
|
||||
mkdir -p "$OUT"
|
||||
|
||||
# "Cline" -> Cline, "Cline Beta" -> Cline-Beta
|
||||
PREFIX="${PRODUCT// /-}"
|
||||
|
||||
DMG=$(find "$BUNDLE_DIR/dmg" -name '*.dmg' -print -quit)
|
||||
if [ -z "$DMG" ]; then
|
||||
echo "no DMG produced under $BUNDLE_DIR/dmg"
|
||||
exit 1
|
||||
fi
|
||||
cp "$DMG" "$OUT/${PREFIX}_${VERSION}_universal.dmg"
|
||||
|
||||
TARBALL=$(find "$BUNDLE_DIR/macos" -name '*.app.tar.gz' -print -quit)
|
||||
if [ -z "$TARBALL" ] || [ ! -f "${TARBALL}.sig" ]; then
|
||||
echo "updater artifact or signature missing under $BUNDLE_DIR/macos"
|
||||
exit 1
|
||||
fi
|
||||
cp "$TARBALL" "$OUT/${PREFIX}_${VERSION}_universal.app.tar.gz"
|
||||
cp "${TARBALL}.sig" "$OUT/${PREFIX}_${VERSION}_universal.app.tar.gz.sig"
|
||||
|
||||
ls -lh "$OUT"
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-universal
|
||||
path: apps/examples/desktop-app/dist/publish/*
|
||||
if-no-files-found: error
|
||||
|
||||
build-windows:
|
||||
name: Build Windows (x64)
|
||||
needs: validate
|
||||
# Same gate rationale as the macOS build job above. This job additionally
|
||||
# needs id-token: write for Azure OIDC: Windows binaries are
|
||||
# Authenticode-signed with Azure Trusted Signing, authenticated through the
|
||||
# PublishDesktop-environment federated credential on the cline-cli-signing
|
||||
# Entra app (subject repo:cline/cline:environment:PublishDesktop).
|
||||
if: github.ref == 'refs/heads/main'
|
||||
environment: PublishDesktop
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
# All-or-nothing: an unsigned Windows desktop build is never acceptable
|
||||
# (Smart App Control / WDAC block unsigned exes and SmartScreen flags
|
||||
# unsigned installers), and Tauri would skip updater-artifact signing
|
||||
# silently if the updater key were missing. Unlike the CLI pipeline
|
||||
# there is no unsigned fallback here.
|
||||
- name: Verify signing secrets are present
|
||||
shell: bash
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
|
||||
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
|
||||
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
missing=()
|
||||
for name in AZURE_CLIENT_ID AZURE_TENANT_ID AZURE_SUBSCRIPTION_ID \
|
||||
AZURE_TRUSTED_SIGNING_ENDPOINT AZURE_TRUSTED_SIGNING_ACCOUNT_NAME \
|
||||
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP \
|
||||
TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do
|
||||
[ -n "${!name}" ] || missing+=("$name")
|
||||
done
|
||||
|
||||
if [ ${#missing[@]} -gt 0 ]; then
|
||||
echo "Missing signing secrets for the Windows desktop build:"
|
||||
printf ' - %s\n' "${missing[@]}"
|
||||
echo
|
||||
echo "The AZURE_* names are repository secrets; the TAURI_* names"
|
||||
echo "live in the PublishDesktop environment. Refusing to build an"
|
||||
echo "unsigned Windows desktop release."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "All Windows signing secrets are present."
|
||||
|
||||
# Every action in this job is SHA-pinned (unlike elsewhere in this
|
||||
# file): they run with id-token: write and the updater signing key in
|
||||
# scope, so a hijacked upstream tag must not be able to reach the
|
||||
# signing identity or tamper with what gets signed and uploaded.
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.tag }}
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable branch
|
||||
with:
|
||||
# With a SHA-pinned action the toolchain no longer comes from the
|
||||
# ref name, so it must be set explicitly.
|
||||
toolchain: stable
|
||||
|
||||
# No Rust build cache, mirroring the macOS job: this job holds the
|
||||
# updater signing key and an Azure signing session, and a restored cache
|
||||
# archive is attacker-controlled if the Actions cache is poisoned.
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build SDK packages
|
||||
run: bun run build:sdk
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
|
||||
- name: Azure login (OIDC)
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
# Tauri invokes signCommand once per staged binary (main exe, sidecar,
|
||||
# NSIS uninstaller, and the installer itself). The overlay is generated
|
||||
# here rather than committed because signCommand needs an absolute path
|
||||
# to the signing script on this runner.
|
||||
- name: Write signing config overlay
|
||||
shell: bash
|
||||
run: |
|
||||
SCRIPT_PATH="${GITHUB_WORKSPACE//\\//}/apps/examples/desktop-app/scripts/tauri-sign-windows.ps1"
|
||||
SIGN_CONF="${RUNNER_TEMP//\\//}/tauri-windows-sign.conf.json"
|
||||
cat > "$SIGN_CONF" <<EOF
|
||||
{
|
||||
"\$schema": "https://schema.tauri.app/config/2",
|
||||
"bundle": {
|
||||
"windows": {
|
||||
"signCommand": "pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File ${SCRIPT_PATH} %1"
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
cat "$SIGN_CONF"
|
||||
echo "SIGN_CONF=${SIGN_CONF}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build and sign desktop bundle
|
||||
shell: bash
|
||||
working-directory: apps/examples/desktop-app
|
||||
# NSIS only: the MSI (WiX) target adds nothing for direct-download
|
||||
# distribution and the updater uses the NSIS artifact. $CONFIG_ARGS is
|
||||
# deliberately unquoted: it must word-split into separate flags.
|
||||
run: bunx tauri build --bundles nsis $CONFIG_ARGS --config "$SIGN_CONF"
|
||||
env:
|
||||
CONFIG_ARGS: ${{ needs.validate.outputs.channel == 'beta' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.beta.conf.json' || '--config src-tauri/tauri.release.conf.json' }}
|
||||
# Telemetry inlined into the sidecar at compile time, same as macOS.
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
# Authenticode signing via scripts/tauri-sign-windows.ps1 (jsign +
|
||||
# Azure Trusted Signing; the token comes from the azure/login session)
|
||||
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
|
||||
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
|
||||
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP }}
|
||||
# Updater artifact signing (minisign keypair, same key as macOS)
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
# Same guardrail as the macOS job: assert the compiled binary embeds
|
||||
# this channel's updater feed URL and not the other channel's. Checked
|
||||
# on the unbundled main exe because NSIS compresses the installer
|
||||
# contents, which defeats a string search on the installer itself.
|
||||
- name: Verify updater feed endpoint
|
||||
shell: bash
|
||||
working-directory: apps/examples/desktop-app
|
||||
env:
|
||||
CHANNEL: ${{ needs.validate.outputs.channel }}
|
||||
run: |
|
||||
case "$CHANNEL" in
|
||||
stable)
|
||||
WANT="releases/download/desktop-latest/latest.json"
|
||||
FORBID="releases/download/desktop-beta/latest.json"
|
||||
;;
|
||||
beta)
|
||||
WANT="releases/download/desktop-beta/latest.json"
|
||||
FORBID="releases/download/desktop-latest/latest.json"
|
||||
;;
|
||||
*)
|
||||
echo "unknown channel: ${CHANNEL}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
found=0
|
||||
for bin in src-tauri/target/release/*.exe; do
|
||||
if grep -a "$FORBID" "$bin" >/dev/null; then
|
||||
echo "$bin embeds the other channel's feed URL (${FORBID})"
|
||||
exit 1
|
||||
fi
|
||||
if grep -a "$WANT" "$bin" >/dev/null; then
|
||||
found=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$found" -ne 1 ]; then
|
||||
echo "No exe in src-tauri/target/release embeds ${WANT}."
|
||||
echo "The updater endpoint overlay did not apply; check the"
|
||||
echo "--config flags on the build step and tauri.beta.conf.json."
|
||||
exit 1
|
||||
fi
|
||||
echo "Updater endpoint verified: ${WANT}"
|
||||
|
||||
# Same guardrail as the macOS job, run natively on the Windows sidecar.
|
||||
- name: Verify sidecar telemetry config was inlined
|
||||
shell: bash
|
||||
working-directory: apps/examples/desktop-app
|
||||
run: |
|
||||
SELFCHECK=$(./src-tauri/bin/code-sidecar-x86_64-pc-windows-msvc.exe --telemetry-selfcheck)
|
||||
echo "$SELFCHECK"
|
||||
if ! printf '%s' "$SELFCHECK" | grep -q '"enabled":true'; then
|
||||
echo "Packaged sidecar reports telemetry disabled."
|
||||
echo "Check the OTEL_* / TELEMETRY_SERVICE_API_KEY env on the"
|
||||
echo "'Build and sign desktop bundle' step and the --define"
|
||||
echo "inlining in scripts/build-sidecar-bin.ts."
|
||||
exit 1
|
||||
fi
|
||||
if printf '%s' "$SELFCHECK" | grep -Eq '"otlp_endpoint_host":"(invalid-endpoint-url)?"'; then
|
||||
echo "Packaged sidecar reports telemetry enabled but its OTLP"
|
||||
echo "endpoint is missing, unparseable, or not an http(s) URL."
|
||||
echo "Check the OTEL_EXPORTER_OTLP_ENDPOINT secret."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Collect artifacts
|
||||
shell: bash
|
||||
working-directory: apps/examples/desktop-app
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
PRODUCT: ${{ needs.validate.outputs.product }}
|
||||
run: |
|
||||
BUNDLE_DIR="src-tauri/target/release/bundle"
|
||||
OUT="dist/publish"
|
||||
mkdir -p "$OUT"
|
||||
|
||||
# "Cline" -> Cline, "Cline Beta" -> Cline-Beta
|
||||
PREFIX="${PRODUCT// /-}"
|
||||
|
||||
SETUP=$(find "$BUNDLE_DIR/nsis" -name '*-setup.exe' -print -quit)
|
||||
if [ -z "$SETUP" ]; then
|
||||
echo "no NSIS installer produced under $BUNDLE_DIR/nsis"
|
||||
exit 1
|
||||
fi
|
||||
# The .sig is the updater (minisign) signature; without it the
|
||||
# manifest generator cannot publish a windows-x86_64 entry.
|
||||
if [ ! -f "${SETUP}.sig" ]; then
|
||||
echo "updater signature missing next to $SETUP"
|
||||
exit 1
|
||||
fi
|
||||
cp "$SETUP" "$OUT/${PREFIX}_${VERSION}_x64-setup.exe"
|
||||
cp "${SETUP}.sig" "$OUT/${PREFIX}_${VERSION}_x64-setup.exe.sig"
|
||||
|
||||
ls -lh "$OUT"
|
||||
|
||||
# Independent Authenticode gate on the exact artifact users download.
|
||||
# The signing script already verifies each file it signs, but this step
|
||||
# would still catch an installer that skipped signCommand entirely.
|
||||
- name: Verify Authenticode signatures
|
||||
shell: pwsh
|
||||
working-directory: apps/examples/desktop-app
|
||||
run: |
|
||||
# The Tauri bundler signs the sidecar in place, so check it here too;
|
||||
# a WDAC-locked machine blocks the app at runtime if the sidecar it
|
||||
# spawns is unsigned, even when the installer itself is fine.
|
||||
$files = @(Get-ChildItem dist/publish/*.exe) + @(Get-Item src-tauri/bin/code-sidecar-x86_64-pc-windows-msvc.exe)
|
||||
if ($files.Count -lt 2) { throw "expected at least the installer and the sidecar to verify" }
|
||||
foreach ($file in $files) {
|
||||
$sig = Get-AuthenticodeSignature $file.FullName
|
||||
if ($sig.Status -ne "Valid") {
|
||||
throw "Invalid Authenticode signature for $($file.Name): $($sig.Status) - $($sig.StatusMessage)"
|
||||
}
|
||||
Write-Host "$($file.Name): Valid ($($sig.SignerCertificate.Subject))"
|
||||
}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: desktop-windows-x64
|
||||
path: apps/examples/desktop-app/dist/publish/*
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
name: Create GitHub release
|
||||
needs: [validate, build, build-windows]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.tag }}
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist/desktop
|
||||
merge-multiple: true
|
||||
|
||||
- name: Get Changelog Entry
|
||||
id: changelog
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
run: |
|
||||
# Grab content between this release's "## <version>" header and the
|
||||
# next one. Exact match, not "first section": once main and
|
||||
# desktop-experimental cross-merge, stable and beta sections
|
||||
# interleave and the top section may belong to the other channel.
|
||||
CONTENT=$(awk -v ver="$VERSION" '$0 == "## " ver {found=1; next} /^## [0-9]/ {if (found) exit} found {print}' apps/examples/desktop-app/CHANGELOG.md)
|
||||
if [ -z "$CONTENT" ]; then
|
||||
echo "No '## ${VERSION}' section found in apps/examples/desktop-app/CHANGELOG.md"
|
||||
exit 1
|
||||
fi
|
||||
echo "content<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
printf "%s\n" "$CONTENT" > "$RUNNER_TEMP/release-notes.md"
|
||||
|
||||
# Slack section blocks reject text longer than 3000 characters, and the
|
||||
# Slack action logs that rejection WITHOUT failing the step - so an
|
||||
# over-long changelog silently drops the release announcement while the
|
||||
# run stays green. Post a trimmed copy to Slack and link out to the full
|
||||
# notes. The GitHub release body and updater manifest stay whole.
|
||||
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${{ needs.validate.outputs.tag }}"
|
||||
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
|
||||
import os
|
||||
content = os.environ["CONTENT"]
|
||||
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
|
||||
if len(content) <= 3000:
|
||||
print(content, end="")
|
||||
else:
|
||||
budget = 3000 - len(more)
|
||||
kept, used = [], 0
|
||||
for line in content.splitlines(keepends=True):
|
||||
if used + len(line) > budget:
|
||||
break
|
||||
kept.append(line)
|
||||
used += len(line)
|
||||
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
|
||||
print(body + more, end="")
|
||||
')
|
||||
echo "slack_content<<SLACK_EOF" >> $GITHUB_OUTPUT
|
||||
echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "SLACK_EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate updater manifest
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
TAG: ${{ needs.validate.outputs.tag }}
|
||||
run: |
|
||||
bun apps/examples/desktop-app/scripts/generate-update-manifest.ts \
|
||||
--version "$VERSION" \
|
||||
--tag "$TAG" \
|
||||
--dir dist/desktop \
|
||||
--out dist/desktop/latest.json \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--notes-file "$RUNNER_TEMP/release-notes.md"
|
||||
cat dist/desktop/latest.json
|
||||
|
||||
- name: Get Previous Desktop Tag
|
||||
id: prev_tag
|
||||
env:
|
||||
CURRENT_TAG: ${{ needs.validate.outputs.tag }}
|
||||
CHANNEL: ${{ needs.validate.outputs.channel }}
|
||||
run: |
|
||||
# Stable compare links skip beta tags so they read stable -> stable;
|
||||
# beta compares against whatever shipped last on either channel.
|
||||
if [ "$CHANNEL" = "stable" ]; then
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 --match 'desktop-v*' --exclude 'desktop-v*-beta*' "$CURRENT_TAG^" 2>/dev/null || echo "")
|
||||
else
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 --match 'desktop-v*' "$CURRENT_TAG^" 2>/dev/null || echo "")
|
||||
fi
|
||||
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ needs.validate.outputs.tag }}
|
||||
name: "Desktop v${{ needs.validate.outputs.version }}"
|
||||
# The repo-wide "latest" release stays owned by CLI releases; the
|
||||
# desktop auto-update feed is the rolling desktop-latest release.
|
||||
make_latest: "false"
|
||||
prerelease: ${{ needs.validate.outputs.channel == 'beta' }}
|
||||
files: dist/desktop/*
|
||||
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, needs.validate.outputs.tag) || '' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Update auto-update feed
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CHANNEL: ${{ needs.validate.outputs.channel }}
|
||||
FEED: ${{ needs.validate.outputs.feed }}
|
||||
run: |
|
||||
# Belt and braces: recompute the feed from the channel and require it
|
||||
# to agree with validate's output, so no single threading bug can
|
||||
# point a publish at the other channel's feed. Stable installs poll
|
||||
# desktop-latest and beta installs poll desktop-beta; crossing the
|
||||
# streams either pushes betas to every stable user or strands beta
|
||||
# users on stale builds.
|
||||
case "$CHANNEL" in
|
||||
stable) EXPECTED_FEED=desktop-latest ;;
|
||||
beta) EXPECTED_FEED=desktop-beta ;;
|
||||
*)
|
||||
echo "unknown channel: ${CHANNEL}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
if [ "$FEED" != "$EXPECTED_FEED" ]; then
|
||||
echo "feed mismatch: validate says '${FEED}' but channel '${CHANNEL}' expects '${EXPECTED_FEED}'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! gh release view "$FEED" >/dev/null 2>&1; then
|
||||
if [ "$CHANNEL" = "beta" ]; then
|
||||
gh release create "$FEED" \
|
||||
--title "Cline desktop beta (auto-update feed)" \
|
||||
--notes "Rolling release backing the beta desktop app auto-updater. The latest.json asset points at the newest desktop-vX.Y.Z-beta.N release. Only beta installs poll this feed; stable installs use desktop-latest. Do not delete." \
|
||||
--latest=false \
|
||||
--prerelease \
|
||||
--target "$(git rev-parse HEAD)"
|
||||
else
|
||||
gh release create "$FEED" \
|
||||
--title "Cline desktop (auto-update feed)" \
|
||||
--notes "Rolling release backing the desktop app auto-updater. The latest.json asset points at the newest desktop-vX.Y.Z release. Do not delete." \
|
||||
--latest=false \
|
||||
--target "$(git rev-parse HEAD)"
|
||||
fi
|
||||
fi
|
||||
gh release upload "$FEED" dist/desktop/latest.json --clobber
|
||||
|
||||
- name: Summary
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
TAG: ${{ needs.validate.outputs.tag }}
|
||||
FEED: ${{ needs.validate.outputs.feed }}
|
||||
run: |
|
||||
echo "Published Cline desktop v${VERSION}"
|
||||
echo "Release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${TAG}"
|
||||
echo "Auto-update feed refreshed: https://github.com/${GITHUB_REPOSITORY}/releases/download/${FEED}/latest.json"
|
||||
|
||||
- name: Post release to Slack
|
||||
uses: slackapi/slack-github-action@v3.0.1
|
||||
with:
|
||||
method: chat.postMessage
|
||||
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
|
||||
payload: |
|
||||
channel: "C0APVKGGZFC"
|
||||
text: "Cline desktop v${{ needs.validate.outputs.version }}${{ needs.validate.outputs.channel == 'beta' && ' (beta)' || '' }}"
|
||||
blocks:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: "Cline desktop v${{ needs.validate.outputs.version }}${{ needs.validate.outputs.channel == 'beta' && ' (beta)' || '' }}"
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
text: "<https://github.com/${{ github.repository }}/releases/tag/${{ needs.validate.outputs.tag }}|Download DMG> — ${{ needs.validate.outputs.channel == 'beta' && 'beta channel: installs side by side with the stable app and only beta installs auto-update; stable users are unaffected' || 'installed apps auto-update on next launch' }}${{ steps.prev_tag.outputs.prev_tag != '' && format(' | Full Changelog: https://github.com/{0}/compare/{1}...{2}', github.repository, steps.prev_tag.outputs.prev_tag, needs.validate.outputs.tag) || '' }}"
|
||||
@@ -1,50 +0,0 @@
|
||||
name: desktop-test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- desktop-experimental
|
||||
paths:
|
||||
- "apps/examples/desktop-app/package.json"
|
||||
- "apps/examples/desktop-app/scripts/dmg-background.ts"
|
||||
- "apps/examples/desktop-app/scripts/dmg-background.test.ts"
|
||||
- "apps/examples/desktop-app/src-tauri/dmg/background.png"
|
||||
- "apps/examples/desktop-app/src-tauri/dmg/background@2x.png"
|
||||
- ".github/workflows/desktop-test.yml"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- desktop-experimental
|
||||
paths:
|
||||
- "apps/examples/desktop-app/package.json"
|
||||
- "apps/examples/desktop-app/scripts/dmg-background.ts"
|
||||
- "apps/examples/desktop-app/scripts/dmg-background.test.ts"
|
||||
- "apps/examples/desktop-app/src-tauri/dmg/background.png"
|
||||
- "apps/examples/desktop-app/src-tauri/dmg/background@2x.png"
|
||||
- ".github/workflows/desktop-test.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
dmg-background:
|
||||
name: Test DMG background tooling
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/examples/desktop-app
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
|
||||
# The suite only uses Bun/Node built-ins and committed artwork, so it does
|
||||
# not need a workspace dependency install or macOS runner.
|
||||
- name: Test DMG background tooling
|
||||
run: bun run test:dmg-background
|
||||
@@ -0,0 +1,111 @@
|
||||
name: E2E Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize, ready_for_review]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
matrix_prep:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
steps:
|
||||
- id: set-matrix
|
||||
run: |
|
||||
echo 'matrix=[{"runner":"ubuntu"},{"runner":"windows"},{"runner":"macos"}]' >> $GITHUB_OUTPUT
|
||||
|
||||
e2e:
|
||||
needs: matrix_prep
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include: ${{ fromJson(needs.matrix_prep.outputs.matrix) }}
|
||||
runs-on: ${{ matrix.runner }}-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
# Cache root dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache root dependencies
|
||||
uses: actions/cache@v4
|
||||
id: root-cache
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache webview-ui dependencies
|
||||
uses: actions/cache@v4
|
||||
id: webview-cache
|
||||
with:
|
||||
path: webview-ui/node_modules
|
||||
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
|
||||
|
||||
# Cache VS Code installation
|
||||
- name: Cache VS Code
|
||||
uses: actions/cache@v4
|
||||
id: vscode-cache
|
||||
with:
|
||||
path: .vscode-test
|
||||
key: vscode-${{ runner.os }}-stable-${{ hashFiles('.vscode-test.mjs', 'package.json') }}
|
||||
restore-keys: |
|
||||
vscode-${{ runner.os }}-stable-
|
||||
|
||||
# Cache Playwright browsers
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v4
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: |
|
||||
~/.cache/ms-playwright
|
||||
~/Library/Caches/ms-playwright
|
||||
~/AppData/Local/ms-playwright
|
||||
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: |
|
||||
playwright-browsers-${{ runner.os }}-
|
||||
|
||||
- name: Install root dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
run: cd webview-ui && npm ci
|
||||
|
||||
- name: Install vsce
|
||||
run: npm install -g @vscode/vsce
|
||||
|
||||
- name: Install xvfb on Linux
|
||||
if: matrix.runner == 'ubuntu'
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb
|
||||
|
||||
# Run optimized E2E tests (eliminates redundant builds)
|
||||
- name: Run E2E tests - Linux
|
||||
if: matrix.runner == 'ubuntu'
|
||||
run: xvfb-run -a npm run test:e2e:optimal
|
||||
|
||||
- name: Run E2E tests - Non-Linux
|
||||
if: matrix.runner != 'ubuntu'
|
||||
run: npm run test:e2e:optimal
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ failure() }}
|
||||
with:
|
||||
name: playwright-recordings-${{ matrix.runner }}
|
||||
path: |
|
||||
test-results/playwright/
|
||||
@@ -1,100 +0,0 @@
|
||||
name: ext-jb-test-integration
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
concurrency:
|
||||
group: jetbrains-trigger-${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
trigger-integration-test:
|
||||
name: Run Tests
|
||||
runs-on: ubuntu-latest
|
||||
# Auto-run only for trusted PR authors. Anyone else needs a maintainer
|
||||
# to opt their PR in by commenting /test-jetbrains.
|
||||
if: |
|
||||
(github.event_name == 'pull_request_target' &&
|
||||
contains(fromJSON('["MEMBER","OWNER","COLLABORATOR"]'), github.event.pull_request.author_association)) ||
|
||||
(github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, '/test-jetbrains') &&
|
||||
contains(fromJSON('["MEMBER","OWNER","COLLABORATOR"]'), github.event.comment.author_association))
|
||||
steps:
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ vars.CLINE_JETBRAINS_APP_ID }}
|
||||
private-key: ${{ secrets.CLINE_JETBRAINS_APP_KEY }}
|
||||
owner: cline
|
||||
repositories: intellij-plugin
|
||||
|
||||
- name: Get PR details (for issue_comment trigger)
|
||||
id: pr-details
|
||||
if: github.event_name == 'issue_comment'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
PR_DATA=$(gh api repos/${{ github.repository }}/pulls/${{ github.event.issue.number }})
|
||||
echo "head_ref=$(echo "$PR_DATA" | jq -r '.head.ref')" >> $GITHUB_OUTPUT
|
||||
echo "head_sha=$(echo "$PR_DATA" | jq -r '.head.sha')" >> $GITHUB_OUTPUT
|
||||
echo "title=$(echo "$PR_DATA" | jq -r '.title')" >> $GITHUB_OUTPUT
|
||||
echo "html_url=$(echo "$PR_DATA" | jq -r '.html_url')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Sanitize untrusted inputs
|
||||
id: sanitize
|
||||
env:
|
||||
RAW_BRANCH_NAME: ${{ github.event_name == 'pull_request_target' && github.head_ref || steps.pr-details.outputs.head_ref }}
|
||||
RAW_PR_TITLE: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.title || steps.pr-details.outputs.title }}
|
||||
run: |
|
||||
# Sanitize branch name for JSON
|
||||
BRANCH_NAME_JSON=$(jq -n --arg b "$RAW_BRANCH_NAME" '$b')
|
||||
echo "branch_name=$BRANCH_NAME_JSON" >> $GITHUB_OUTPUT
|
||||
|
||||
# Sanitize PR title for JSON
|
||||
PR_TITLE_JSON=$(jq -n --arg t "$RAW_PR_TITLE" '$t')
|
||||
echo "pr_title=$PR_TITLE_JSON" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Trigger IntelliJ Plugin Integration Test
|
||||
env:
|
||||
BRANCH_NAME: ${{ steps.sanitize.outputs.branch_name }}
|
||||
PR_TITLE: ${{ steps.sanitize.outputs.pr_title }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
PR_SHA: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || steps.pr-details.outputs.head_sha }}
|
||||
PR_URL: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.html_url || steps.pr-details.outputs.html_url }}
|
||||
run: |
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer ${{ steps.app-token.outputs.token }}" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
-H "User-Agent: cline-pr-trigger" \
|
||||
-H "Content-Type: application/json" \
|
||||
https://api.github.com/repos/cline/intellij-plugin/dispatches \
|
||||
-d @- <<EOF
|
||||
{
|
||||
"event_type": "cline-pr-check",
|
||||
"client_payload": {
|
||||
"pr_number": "$PR_NUMBER",
|
||||
"branch_name": $BRANCH_NAME,
|
||||
"action": "${{ github.event.action }}",
|
||||
"sha": "$PR_SHA",
|
||||
"pr_title": $PR_TITLE,
|
||||
"pr_url": "$PR_URL"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
- name: Log trigger details
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
PR_SHA: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || steps.pr-details.outputs.head_sha }}
|
||||
run: |
|
||||
echo "Triggered IntelliJ Plugin integration test for:"
|
||||
echo " PR #$PR_NUMBER"
|
||||
echo " Trigger: ${{ github.event_name }}"
|
||||
echo " Action: ${{ github.event.action }}"
|
||||
echo " SHA: $PR_SHA"
|
||||
@@ -1,581 +0,0 @@
|
||||
name: ext-vscode-ab-package
|
||||
|
||||
# Build (and optionally publish) the combined A/B VSIX: a tiny loader plus two
|
||||
# complete extension bundles — `next/` from the SDK-based apps/vscode on main,
|
||||
# `legacy/` from the legacy-extension branch. Cohort selection happens at
|
||||
# runtime via PostHog flags; see apps/vscode-rollout/README.md for the design
|
||||
# and the rollout runbook.
|
||||
#
|
||||
# Job layout: cheap input gates (preflight) and the two bundle test suites run
|
||||
# ungated; the build job packages the VSIX with no environment attached, so
|
||||
# publish=false rehearsals complete without any approval; only the publish job
|
||||
# — Marketplace + Open VSX + bookkeeping — waits on the `publish` environment.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Combined VSIX version — must exceed every previously published version (e.g. 4.1.0)"
|
||||
required: true
|
||||
type: string
|
||||
next-ref:
|
||||
description: "Ref to build the next (SDK) bundle from"
|
||||
required: true
|
||||
default: "main"
|
||||
type: string
|
||||
publish:
|
||||
description: "Publish to the VS Code Marketplace and Open VSX (unchecked: just build the .vsix artifact)"
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ext-vscode-ab-package-${{ github.event.inputs.version }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Input gates that need no checkout: fail in seconds — before the test
|
||||
# suites, the ~20-minute build, and the environment approval — instead of
|
||||
# at publish time.
|
||||
preflight:
|
||||
name: Validate inputs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# The input reaches the shell ONLY via env here (never inline
|
||||
# expression interpolation, which is evaluated before bash runs and
|
||||
# would allow script injection from the dispatch form). Because
|
||||
# every later job `needs` preflight, passing this regex is what
|
||||
# makes the plain-string `${{ inputs.version }}` interpolations
|
||||
# downstream safe.
|
||||
- name: Validate version format
|
||||
env:
|
||||
VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Error: version must be plain X.Y.Z with no leading 'v' and no suffix (got '$VERSION')."
|
||||
echo "It is stamped verbatim into the union manifest and both bundle manifests."
|
||||
exit 1
|
||||
fi
|
||||
echo "Version format ok: $VERSION"
|
||||
|
||||
# The reusable bun suite tests the dispatch revision (main), so
|
||||
# publishing any other next-ref would ship an untested bundle.
|
||||
# Build-only runs (publish=false) may still use arbitrary next-refs
|
||||
# for artifact rehearsals.
|
||||
- name: Refuse to publish an untested next-ref
|
||||
if: ${{ github.event.inputs.publish == 'true' && github.event.inputs.next-ref != 'main' }}
|
||||
run: |
|
||||
echo "Error: publish=true requires next-ref=main — the test gate only covers main."
|
||||
exit 1
|
||||
|
||||
# Marketplace versions are monotonic and cannot be unpublished:
|
||||
# every publish must exceed the highest version ever published to
|
||||
# the claude-dev listing FROM ANY BRANCH (combined stable or legacy
|
||||
# hotfix). The publish job re-checks right before publishing — the
|
||||
# environment-approval wait can last days and a legacy hotfix can
|
||||
# land in between. Keep both copies of this check in sync.
|
||||
- name: Verify version exceeds the live Marketplace version
|
||||
if: ${{ github.event.inputs.publish == 'true' }}
|
||||
env:
|
||||
VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
LIVE=$(curl -sf --retry 3 -X POST "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery" \
|
||||
-H "Content-Type: application/json" -H "Accept: application/json;api-version=3.0-preview.1" \
|
||||
--data '{"filters":[{"criteria":[{"filterType":7,"value":"saoudrizwan.claude-dev"}]}],"flags":16}' \
|
||||
| node -e 'let d="";process.stdin.on("data",c=>d+=c);process.stdin.on("end",()=>{process.stdout.write(JSON.parse(d).results[0].extensions[0].versions[0].version)})')
|
||||
if [[ -z "$LIVE" ]]; then
|
||||
echo "Error: could not resolve the live Marketplace version for saoudrizwan.claude-dev."
|
||||
exit 1
|
||||
fi
|
||||
node -e '
|
||||
const [next, live] = process.argv.slice(1).map((v) => v.split(".").map(Number));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (next[i] > live[i]) process.exit(0);
|
||||
if (next[i] < live[i]) break;
|
||||
}
|
||||
console.error(`Error: version ${process.argv[1]} does not exceed the live Marketplace version ${process.argv[2]}.`);
|
||||
process.exit(1);
|
||||
' "$VERSION" "$LIVE"
|
||||
echo "Version ok: $VERSION exceeds live Marketplace version $LIVE"
|
||||
|
||||
# Gate the build/publish on BOTH bundles' own test suites, mirroring the two
|
||||
# standalone publish paths (nightly gates on the bun suite via the same
|
||||
# reusable workflow; the legacy publish inlines the npm suite).
|
||||
#
|
||||
# Caveat (shared with the nightly workflow): the reusable bun suite tests the
|
||||
# DISPATCH revision — main's tip at dispatch, since this workflow is only
|
||||
# dispatched from main — not `next-ref`. The build job therefore pins the
|
||||
# default next-ref checkout to that same revision (tested == built) and
|
||||
# preflight refuses publish=true for any other next-ref; build-only artifact
|
||||
# runs may still build untested refs.
|
||||
test-next:
|
||||
name: Test next (SDK) bundle
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
uses: ./.github/workflows/ext-vscode-test.yml
|
||||
|
||||
# The legacy branch is the npm codebase, so the bun-based reusable workflow
|
||||
# cannot test it. Inlined npm steps, kept in sync with the `test` job in
|
||||
# ext-vscode-publish-legacy.yml (same suite, different ref input name).
|
||||
test-legacy:
|
||||
name: Test legacy bundle
|
||||
runs-on: ubuntu-latest
|
||||
# The tested revision, exported so the build job builds EXACTLY what
|
||||
# this suite ran against. legacy-extension is a mutable branch name and
|
||||
# the build job starts later — re-resolving the name there could pick
|
||||
# up commits this gate never saw.
|
||||
outputs:
|
||||
tested-sha: ${{ steps.rev.outputs.sha }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
steps:
|
||||
# Always the protected legacy-extension branch — deliberately not
|
||||
# an input. An arbitrary ref here would be built into the published
|
||||
# VSIX by the environment-less build job, and the publish
|
||||
# environment approver only ever sees an opaque prebuilt artifact:
|
||||
# the approval would protect the marketplace PAT but not the
|
||||
# shipped bytes. Hardcoding the branch makes its protection rules
|
||||
# load-bearing for releases. Legacy hotfix testing has its own
|
||||
# workflow (ext-vscode-publish-legacy.yml).
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: legacy-extension
|
||||
|
||||
- name: Record tested revision
|
||||
id: rev
|
||||
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Deliberately no dependency cache here: publish workflows do clean
|
||||
# installs and should not restore actions caches.
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui ci
|
||||
|
||||
- name: Run Quality Checks (lint + typecheck)
|
||||
run: npm run ci:check-all
|
||||
|
||||
- name: Build Tests and Extension
|
||||
id: build_step
|
||||
run: npm run ci:build
|
||||
|
||||
- name: Unit Tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: npm run test:unit
|
||||
|
||||
- name: Extension Integration Tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: xvfb-run -a npm run test:coverage
|
||||
|
||||
- name: Webview Tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: |
|
||||
cd webview-ui
|
||||
npm run test:coverage
|
||||
|
||||
build:
|
||||
name: Build combined (legacy + next) VSIX
|
||||
needs: [preflight, test-next, test-legacy]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# For the default next-ref (main), pin the checkout to the exact
|
||||
# revision the test-next gate ran against: a moving branch name could
|
||||
# otherwise drift past the tested commit during the test phase.
|
||||
- name: Checkout next (SDK) source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.next-ref == 'main' && github.sha || github.event.inputs.next-ref }}
|
||||
path: next-src
|
||||
lfs: true
|
||||
|
||||
# Fail fast (before the ~20-min build) if a real publish is missing
|
||||
# its changelog entry — same contract the standalone publish
|
||||
# workflows enforce. Build-only rehearsals are exempt.
|
||||
- name: Verify changelog entry
|
||||
if: ${{ github.event.inputs.publish == 'true' }}
|
||||
working-directory: next-src
|
||||
run: |
|
||||
EXPECTED_HEADING="## [${{ github.event.inputs.version }}]"
|
||||
FIRST_HEADING=$(grep -m 1 '^## \[' CHANGELOG.md || true)
|
||||
if [[ "$FIRST_HEADING" != "$EXPECTED_HEADING" ]]; then
|
||||
echo "Error: CHANGELOG.md must start with '$EXPECTED_HEADING' before publishing (found '$FIRST_HEADING')."
|
||||
exit 1
|
||||
fi
|
||||
echo "Found changelog entry for ${{ github.event.inputs.version }}"
|
||||
|
||||
# Pin to the revision test-legacy actually tested (see that job's
|
||||
# outputs comment) — never re-resolve the mutable branch name here.
|
||||
- name: Checkout legacy source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.test-legacy.outputs.tested-sha }}
|
||||
path: legacy-src
|
||||
lfs: true
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
# --frozen-lockfile so the built bundle resolves the exact
|
||||
# dependency set the test-next gate ran against (the reusable suite
|
||||
# installs frozen too) — a bare install could silently re-resolve.
|
||||
- name: Install next workspace dependencies
|
||||
working-directory: next-src
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
# @cline/* are local workspace symlinks to source packages; apps/vscode's
|
||||
# `package` script does NOT build them, so without this the esbuild step
|
||||
# fails on a fresh checkout. (The nightly workflow already does this.)
|
||||
- name: Build SDK packages
|
||||
working-directory: next-src
|
||||
run: bun run build:sdk
|
||||
|
||||
- name: Assert better-sqlite3 native binary present
|
||||
working-directory: next-src/apps/vscode
|
||||
run: |
|
||||
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
|
||||
if [ ! -f "$NODE_FILE" ]; then
|
||||
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
|
||||
echo "(bun trustedDependencies postinstall likely did not run)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found better-sqlite3 native binary: $NODE_FILE"
|
||||
|
||||
# Stamp the combined version into each bundle's package.json AFTER
|
||||
# install and BEFORE its build: the About tab and telemetry
|
||||
# extension_version read the bundle's own manifest, so without this
|
||||
# the VSIX reports three different versions depending on where you
|
||||
# look. (The nightly workflow gets the same alignment via nightlify.mjs.)
|
||||
- name: Align next bundle version
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/set-version.mjs --dir "$GITHUB_WORKSPACE/next-src/apps/vscode" --version "${{ github.event.inputs.version }}"
|
||||
|
||||
- name: Build next bundle
|
||||
working-directory: next-src/apps/vscode
|
||||
env:
|
||||
CLINE_ENVIRONMENT: production
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
# Inlined by esbuild: attributes every telemetry event with
|
||||
# extension_variant and unlocks the bundle's authoritative
|
||||
# extension.rollout.bundle_activated capture. Rollout builds only.
|
||||
CLINE_ROLLOUT_VARIANT: next
|
||||
# Match the stable publish workflow's OpenTelemetry production defaults.
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
run: bun run package
|
||||
|
||||
- name: Install legacy dependencies
|
||||
working-directory: legacy-src
|
||||
run: |
|
||||
npm --prefix apps/vscode install --include=optional
|
||||
npm --prefix apps/vscode/webview-ui install --include=optional
|
||||
|
||||
- name: Align legacy bundle version
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/set-version.mjs --dir "$GITHUB_WORKSPACE/legacy-src/apps/vscode" --version "${{ github.event.inputs.version }}"
|
||||
|
||||
- name: Build legacy bundle
|
||||
working-directory: legacy-src/apps/vscode
|
||||
env:
|
||||
CLINE_ENVIRONMENT: production
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
CLINE_ROLLOUT_VARIANT: legacy
|
||||
# Match the stable publish workflow's OpenTelemetry production defaults.
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
run: npm run package
|
||||
|
||||
- name: Build loader and run rollout tests
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
run: |
|
||||
bun run typecheck
|
||||
bun run test
|
||||
bun run build:production
|
||||
|
||||
- name: Stitch combined VSIX staging
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: |
|
||||
node scripts/stitch.mjs \
|
||||
--next "$GITHUB_WORKSPACE/next-src/apps/vscode" \
|
||||
--legacy "$GITHUB_WORKSPACE/legacy-src/apps/vscode" \
|
||||
--loader dist/extension.js \
|
||||
--version "${{ github.event.inputs.version }}" \
|
||||
--out "$GITHUB_WORKSPACE/staging"
|
||||
|
||||
- name: Smoke-test loader against staging
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/smoke-loader.mjs "$GITHUB_WORKSPACE/staging"
|
||||
|
||||
# This workflow publishes the STABLE identity. If nightlify ever leaks
|
||||
# into this path the union manifest would ship under the wrong name.
|
||||
# The bundle sub-manifest checks guard the set-version.mjs stamping:
|
||||
# the About tab and telemetry extension_version read those files.
|
||||
- name: Assert stable manifest identity
|
||||
working-directory: staging
|
||||
env:
|
||||
EXPECTED_VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
node -e '
|
||||
const assert = require("node:assert");
|
||||
const expected = process.env.EXPECTED_VERSION;
|
||||
const pkg = require("./package.json");
|
||||
assert.equal(pkg.name, "claude-dev", `unexpected name ${pkg.name}`);
|
||||
assert.equal(pkg.publisher, "saoudrizwan", `unexpected publisher ${pkg.publisher}`);
|
||||
assert.equal(pkg.version, expected, `unexpected union version ${pkg.version}`);
|
||||
for (const bundle of ["next", "legacy"]) {
|
||||
const sub = require(`./${bundle}/package.json`);
|
||||
assert.equal(sub.version, expected, `unexpected ${bundle} bundle version ${sub.version}`);
|
||||
}
|
||||
console.log(`stable identity ok: ${pkg.publisher}.${pkg.name}@${pkg.version} (bundle versions aligned)`);
|
||||
'
|
||||
|
||||
- name: Package VSIX
|
||||
working-directory: staging
|
||||
run: |
|
||||
npm install -g @vscode/vsce
|
||||
# Preserve the narrowly scoped VSCE `sendgrid` scanner exemption used by
|
||||
# both standalone bundle workflows. No SendGrid credential is intentionally
|
||||
# supplied here; inspect the reported artifact before widening the exemption.
|
||||
vsce package --no-dependencies --allow-package-secrets sendgrid --out "claude-dev-${{ github.event.inputs.version }}.vsix"
|
||||
|
||||
- name: Upload VSIX artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: claude-dev-${{ github.event.inputs.version }}
|
||||
path: staging/claude-dev-${{ github.event.inputs.version }}.vsix
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
name: Publish to Marketplace and Open VSX
|
||||
needs: build
|
||||
if: ${{ github.event.inputs.publish == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: publish
|
||||
# contents: write is required by the post-publish bookkeeping (tag +
|
||||
# GitHub Release), mirroring the standalone publish workflows.
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
# The built next revision: preflight refused publish=true for any
|
||||
# next-ref other than main, and the build job pinned main to the
|
||||
# dispatch SHA — so github.sha IS the published commit. Used for the
|
||||
# changelog, the release tag, and the previous-tag lookup.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Download VSIX artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: claude-dev-${{ github.event.inputs.version }}
|
||||
path: staging
|
||||
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
# Re-check monotonicity at the last moment: the environment-approval
|
||||
# wait can last days, and a legacy hotfix published in the meantime
|
||||
# would otherwise be silently superseded by this older code line.
|
||||
# Keep in sync with the preflight copy of this check.
|
||||
- name: Re-verify version exceeds the live Marketplace version
|
||||
env:
|
||||
VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
LIVE=$(curl -sf --retry 3 -X POST "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery" \
|
||||
-H "Content-Type: application/json" -H "Accept: application/json;api-version=3.0-preview.1" \
|
||||
--data '{"filters":[{"criteria":[{"filterType":7,"value":"saoudrizwan.claude-dev"}]}],"flags":16}' \
|
||||
| node -e 'let d="";process.stdin.on("data",c=>d+=c);process.stdin.on("end",()=>{process.stdout.write(JSON.parse(d).results[0].extensions[0].versions[0].version)})')
|
||||
if [[ -z "$LIVE" ]]; then
|
||||
echo "Error: could not resolve the live Marketplace version for saoudrizwan.claude-dev."
|
||||
exit 1
|
||||
fi
|
||||
node -e '
|
||||
const [next, live] = process.argv.slice(1).map((v) => v.split(".").map(Number));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (next[i] > live[i]) process.exit(0);
|
||||
if (next[i] < live[i]) break;
|
||||
}
|
||||
console.error(`Error: version ${process.argv[1]} does not exceed the live Marketplace version ${process.argv[2]}.`);
|
||||
process.exit(1);
|
||||
' "$VERSION" "$LIVE"
|
||||
echo "Version ok: $VERSION exceeds live Marketplace version $LIVE"
|
||||
|
||||
# Both PATs are verified BEFORE the first irreversible publish so a
|
||||
# missing Open VSX token can't strand us half-published. The two
|
||||
# registries are separate steps: if Open VSX fails after the
|
||||
# Marketplace accepted the VSIX, the run goes red (so the operator
|
||||
# notices Open VSX lagged) but the bookkeeping below still runs —
|
||||
# it is keyed off the Marketplace outcome, which is what "shipped"
|
||||
# means for this listing.
|
||||
- name: Publish to Marketplace
|
||||
id: publish_marketplace
|
||||
working-directory: staging
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
run: |
|
||||
if [[ -z "$VSCE_PAT" ]]; then
|
||||
echo "Error: VSCE_PAT is required to publish."
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$OVSX_PAT" ]]; then
|
||||
echo "Error: OVSX_PAT is required to publish to Open VSX."
|
||||
exit 1
|
||||
fi
|
||||
vsce publish --no-dependencies --packagePath "claude-dev-${{ github.event.inputs.version }}.vsix"
|
||||
|
||||
- name: Publish to Open VSX
|
||||
working-directory: staging
|
||||
env:
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
run: npx ovsx publish --packagePath "claude-dev-${{ github.event.inputs.version }}.vsix" --pat "$OVSX_PAT"
|
||||
|
||||
# ---- Post-publish bookkeeping (tag / GitHub Release / Slack) ----
|
||||
# Mirrors the standalone publish workflows. Every step here is
|
||||
# continue-on-error, and gated on the MARKETPLACE outcome rather
|
||||
# than plain step ordering: the Marketplace publish already
|
||||
# happened, so bookkeeping must still run when only the Open VSX
|
||||
# step failed, and a red run after a successful publish is exactly
|
||||
# the confusion the nightly workflow taught us to avoid (tag pushes
|
||||
# fail whenever the built commit touches .github/workflows/** — no
|
||||
# grantable permission fixes that; push the tag manually in that
|
||||
# case, see the publish-extension skill).
|
||||
|
||||
- name: Extract changelog entry
|
||||
id: changelog
|
||||
if: ${{ !cancelled() && steps.publish_marketplace.outcome == 'success' }}
|
||||
continue-on-error: true
|
||||
run: |
|
||||
CONTENT=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
|
||||
{
|
||||
echo "content<<CHANGELOG_EOF"
|
||||
echo "$CONTENT"
|
||||
echo "CHANGELOG_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Slack section blocks reject text longer than 3000 characters, and
|
||||
# the Slack action logs that rejection WITHOUT failing the step - so
|
||||
# an over-long changelog silently drops the release announcement
|
||||
# while the run stays green. Post a trimmed copy to Slack and link
|
||||
# out to the full notes. The GitHub release body stays whole.
|
||||
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/v${{ github.event.inputs.version }}"
|
||||
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
|
||||
import os
|
||||
content = os.environ["CONTENT"]
|
||||
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
|
||||
if len(content) <= 3000:
|
||||
print(content, end="")
|
||||
else:
|
||||
budget = 3000 - len(more)
|
||||
kept, used = [], 0
|
||||
for line in content.splitlines(keepends=True):
|
||||
if used + len(line) > budget:
|
||||
break
|
||||
kept.append(line)
|
||||
used += len(line)
|
||||
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
|
||||
print(body + more, end="")
|
||||
')
|
||||
{
|
||||
echo "slack_content<<CHANGELOG_EOF"
|
||||
echo "$SLACK_CONTENT"
|
||||
echo "CHANGELOG_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Resolve previous release tag
|
||||
id: prev_tag
|
||||
if: ${{ !cancelled() && steps.publish_marketplace.outcome == 'success' }}
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# ls-remote needs no local tag objects; take the highest v* tag
|
||||
# below the one being released.
|
||||
PREV=$(git ls-remote --tags origin 'v*' \
|
||||
| awk -F/ '{print $NF}' | grep -v '\^{}' \
|
||||
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||
| grep -vx "v${{ github.event.inputs.version }}" \
|
||||
| sort -V | tail -1)
|
||||
echo "prev_tag=$PREV" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create and push release tag
|
||||
if: ${{ !cancelled() && steps.publish_marketplace.outcome == 'success' }}
|
||||
continue-on-error: true
|
||||
run: |
|
||||
TAG="v${{ github.event.inputs.version }}"
|
||||
git tag "$TAG" HEAD
|
||||
git push origin "refs/tags/$TAG"
|
||||
echo "Pushed $TAG at $(git rev-parse HEAD)"
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: ${{ !cancelled() && steps.publish_marketplace.outcome == 'success' }}
|
||||
continue-on-error: true
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: v${{ github.event.inputs.version }}
|
||||
files: staging/claude-dev-${{ github.event.inputs.version }}.vsix
|
||||
body: |
|
||||
${{ steps.changelog.outputs.content }}
|
||||
|
||||
**Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...v${{ github.event.inputs.version }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Post release to Slack
|
||||
if: ${{ !cancelled() && steps.publish_marketplace.outcome == 'success' }}
|
||||
continue-on-error: true
|
||||
uses: slackapi/slack-github-action@v3.0.1
|
||||
with:
|
||||
method: chat.postMessage
|
||||
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
|
||||
payload: |
|
||||
channel: "C0APVKGGZFC"
|
||||
text: "Cline v${{ github.event.inputs.version }}"
|
||||
blocks:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: "*Cline v${{ github.event.inputs.version }}*"
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
text: "Full Changelog: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...v${{ github.event.inputs.version }}"
|
||||
@@ -1,327 +0,0 @@
|
||||
name: ext-vscode-publish-legacy
|
||||
|
||||
# Publishes the legacy (pre-SDK-migration) VS Code extension from the
|
||||
# `legacy-extension` branch. This branch holds the npm-based 3.89.x codebase,
|
||||
# rolled forward under a 4.0.x version so existing 4.0.0 users still receive
|
||||
# the update. The main `ext-vscode-publish-stable.yml` workflow (bun-based)
|
||||
# stays the path for releasing main once the SDK migration is solid.
|
||||
#
|
||||
# This workflow lives on and is dispatched from `main` (so it satisfies the
|
||||
# default-branch dispatch requirement), but it checks out and builds the
|
||||
# `legacy-extension` branch.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release-type:
|
||||
description: "Choose release type (release or pre-release)"
|
||||
required: true
|
||||
default: "release"
|
||||
type: choice
|
||||
options:
|
||||
- pre-release
|
||||
- release
|
||||
|
||||
# Read-only by default. The publish job elevates itself to contents: write for
|
||||
# the tag push and GitHub release; nothing here needs packages/checks/PR
|
||||
# write. Keeping the default minimal matters doubly in this workflow because
|
||||
# the test job runs BEFORE any environment approval — it must never hold a
|
||||
# write token while executing checked-out code.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ext-vscode-publish-legacy
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Gate the publish on the legacy branch's own npm-based test suite. We can't
|
||||
# reuse ./.github/workflows/ext-vscode-test.yml here — on main that's the
|
||||
# bun-based suite and it would test main, not the legacy branch — so the
|
||||
# essential quality + test steps are inlined against the checked-out legacy
|
||||
# branch.
|
||||
test:
|
||||
name: Test Legacy Extension
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
steps:
|
||||
# Always the protected legacy-extension branch — deliberately not
|
||||
# an input. This job runs full npm lifecycle scripts from the
|
||||
# checked-out code with no environment approval, and the publish
|
||||
# job below does the same next to the marketplace PATs; an
|
||||
# arbitrary ref here would hand both of them attacker-controlled
|
||||
# code. Hardcoding the branch makes its protection rules
|
||||
# load-bearing for releases.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: legacy-extension
|
||||
|
||||
# Deliberately no dependency cache here: publish workflows do clean
|
||||
# installs and should not restore actions caches.
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui ci
|
||||
|
||||
- name: Run Quality Checks (lint + typecheck)
|
||||
run: npm run ci:check-all
|
||||
|
||||
- name: Build Tests and Extension
|
||||
id: build_step
|
||||
run: npm run ci:build
|
||||
|
||||
- name: Unit Tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: npm run test:unit
|
||||
|
||||
- name: Extension Integration Tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: xvfb-run -a npm run test:coverage
|
||||
|
||||
- name: Webview Tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: |
|
||||
cd webview-ui
|
||||
npm run test:coverage
|
||||
|
||||
publish:
|
||||
needs: test
|
||||
name: Publish Legacy Extension
|
||||
runs-on: ubuntu-latest
|
||||
environment: publish
|
||||
# For the tag push in Resolve Release Tag and the GitHub release.
|
||||
permissions:
|
||||
contents: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
|
||||
steps:
|
||||
# Check out the legacy branch (NOT main; hardcoded — see the test
|
||||
# job's checkout comment). fetch-depth: 0 + tags so we can
|
||||
# create/push the release tag and compute the previous tag.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: legacy-extension
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
lfs: true
|
||||
|
||||
- name: Resolve Release Tag
|
||||
id: resolve_tag
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
BRANCH: legacy-extension
|
||||
run: |
|
||||
# Tag is derived from the package version on the legacy branch.
|
||||
VERSION=$(node -p "require('./apps/vscode/package.json').version")
|
||||
TAG="v$VERSION"
|
||||
|
||||
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$ ]]; then
|
||||
echo "Error: derived tag '$TAG' does not match vX.Y.Z"
|
||||
exit 1
|
||||
fi
|
||||
TAG_REF="refs/tags/$TAG"
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
|
||||
if git show-ref --verify --quiet "$TAG_REF"; then
|
||||
TAG_SHA=$(git rev-list -n 1 "$TAG_REF^{commit}")
|
||||
if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then
|
||||
echo "Error: tag '$TAG' already exists at $TAG_SHA, not at branch head ($HEAD_SHA)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Tag '$TAG' already exists at branch head. Continuing."
|
||||
else
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag "$TAG" "$HEAD_SHA"
|
||||
git push origin "$TAG_REF"
|
||||
echo "Created and pushed tag '$TAG' from $BRANCH head $HEAD_SHA."
|
||||
fi
|
||||
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode install --include=optional
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui install --include=optional
|
||||
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
- name: Get Version
|
||||
id: get_version
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Verify Tag Matches Package Version
|
||||
run: |
|
||||
TAG="${{ steps.resolve_tag.outputs.tag }}"
|
||||
VERSION="v${{ steps.get_version.outputs.version }}"
|
||||
if [[ "$TAG" != "$VERSION" ]]; then
|
||||
echo "Error: tag '$TAG' does not match package version '$VERSION'"
|
||||
exit 1
|
||||
fi
|
||||
echo "Tag and package version match: $TAG"
|
||||
|
||||
- name: Verify Changelog Entry
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
EXPECTED_HEADING="## [${{ steps.get_version.outputs.version }}]"
|
||||
FIRST_HEADING=$(grep -m 1 '^## \[' CHANGELOG.md || true)
|
||||
if [[ "$FIRST_HEADING" != "$EXPECTED_HEADING" ]]; then
|
||||
echo "Error: CHANGELOG.md must start with '$EXPECTED_HEADING' before publishing."
|
||||
echo "Current first release heading: ${FIRST_HEADING:-<none>}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found changelog entry for ${{ steps.get_version.outputs.version }}"
|
||||
|
||||
- name: Verify Marketplace Tokens
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
run: |
|
||||
if [[ -z "$VSCE_PAT" ]]; then
|
||||
echo "Error: VSCE_PAT is required to publish the stable VS Code extension."
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$OVSX_PAT" ]]; then
|
||||
echo "Error: OVSX_PAT is required to publish the stable Open VSX extension."
|
||||
exit 1
|
||||
fi
|
||||
echo "Marketplace publish tokens are configured."
|
||||
|
||||
- name: Package and Publish Extension
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
CLINE_ENVIRONMENT: production
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
# OpenTelemetry production defaults (can be overridden at runtime)
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
RELEASE_TYPE: ${{ github.event.inputs.release-type }}
|
||||
run: |
|
||||
# Swap README.marketplace.md into README.md so both the GitHub
|
||||
# release artifact (vsce package below) and the marketplace
|
||||
# publish (npm run publish:marketplace below, which swaps
|
||||
# internally as an idempotent no-op) ship the same README.
|
||||
node scripts/marketplace-readme.mjs swap-in
|
||||
trap 'node scripts/marketplace-readme.mjs restore' EXIT
|
||||
|
||||
# Required to generate the .vsix
|
||||
vsce package --allow-package-secrets sendgrid --out "cline-${{ steps.get_version.outputs.version }}.vsix"
|
||||
|
||||
if [ "$RELEASE_TYPE" = "pre-release" ]; then
|
||||
npm run publish:marketplace:prerelease
|
||||
echo "Successfully published pre-release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
|
||||
else
|
||||
npm run publish:marketplace
|
||||
echo "Successfully published release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
|
||||
fi
|
||||
|
||||
- name: Get Previous Tag
|
||||
id: prev_tag
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
CURRENT_TAG="${{ steps.resolve_tag.outputs.tag }}"
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 "$CURRENT_TAG^" 2>/dev/null || echo "")
|
||||
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get Changelog Entry
|
||||
id: changelog
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
# Get content between first ## [ and second ## [
|
||||
CONTENT=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
|
||||
echo "content<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
# Slack section blocks reject text longer than 3000 characters, and
|
||||
# the Slack action logs that rejection WITHOUT failing the step - so
|
||||
# an over-long changelog silently drops the release announcement
|
||||
# while the run stays green. Post a trimmed copy to Slack and link
|
||||
# out to the full notes. The GitHub release body stays whole.
|
||||
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${{ steps.resolve_tag.outputs.tag }}"
|
||||
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
|
||||
import os
|
||||
content = os.environ["CONTENT"]
|
||||
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
|
||||
if len(content) <= 3000:
|
||||
print(content, end="")
|
||||
else:
|
||||
budget = 3000 - len(more)
|
||||
kept, used = [], 0
|
||||
for line in content.splitlines(keepends=True):
|
||||
if used + len(line) > budget:
|
||||
break
|
||||
kept.append(line)
|
||||
used += len(line)
|
||||
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
|
||||
print(body + more, end="")
|
||||
')
|
||||
echo "slack_content<<SLACK_EOF" >> $GITHUB_OUTPUT
|
||||
echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "SLACK_EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ steps.resolve_tag.outputs.tag }}
|
||||
files: "apps/vscode/*.vsix"
|
||||
body: |
|
||||
${{ steps.changelog.outputs.content }}
|
||||
|
||||
**Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...${{ steps.resolve_tag.outputs.tag }}
|
||||
prerelease: ${{ github.event.inputs.release-type == 'pre-release' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Post release to Slack
|
||||
uses: slackapi/slack-github-action@v3.0.1
|
||||
with:
|
||||
method: chat.postMessage
|
||||
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
|
||||
payload: |
|
||||
channel: "C0APVKGGZFC"
|
||||
text: "Cline ${{ steps.resolve_tag.outputs.tag }} (legacy)"
|
||||
blocks:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: "*Cline ${{ steps.resolve_tag.outputs.tag }} (legacy)*"
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
text: "Full Changelog: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...${{ steps.resolve_tag.outputs.tag }}"
|
||||
@@ -1,306 +0,0 @@
|
||||
name: ext-vscode-publish-nightly
|
||||
|
||||
# Publishes saoudrizwan.cline-nightly as the COMBINED A/B VSIX: the rollout
|
||||
# loader plus two complete extension bundles — `next/` from this ref's
|
||||
# apps/vscode (SDK-based) and `legacy/` from the legacy-extension branch.
|
||||
# Cohort selection happens at runtime via PostHog flags; see
|
||||
# apps/vscode-rollout/README.md for the design and rollout runbook.
|
||||
#
|
||||
# The stable-identity equivalent of this pipeline is ext-vscode-ab-package.yml
|
||||
# (manual dispatch, publishes claude-dev). Shared logic lives in
|
||||
# apps/vscode-rollout/scripts (nightlify/gen-manifest/stitch/smoke) so both
|
||||
# workflows stay thin. The single-bundle nightly path this replaced
|
||||
# (apps/vscode/scripts/publish-nightly.mjs) remains for manual feature-branch
|
||||
# pre-release publishes.
|
||||
|
||||
on:
|
||||
# Manual dispatch only. The nightly cron was removed deliberately: the
|
||||
# PublishNightly environment gained required reviewers, and an unattended
|
||||
# cron run would just sit `waiting` on that approval, hold this workflow's
|
||||
# concurrency group, and silently cancel every later scheduled run behind it
|
||||
# (that is exactly what happened between 2026-07-31 and 2026-08-21, killing
|
||||
# 20 consecutive nightlies). Cut a nightly by dispatching this workflow.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
legacy-ref:
|
||||
description: "Ref to build the legacy bundle from"
|
||||
required: false
|
||||
default: "legacy-extension"
|
||||
type: string
|
||||
dry-run:
|
||||
description: "Build and upload the .vsix artifact without publishing or tagging"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
run-name: "Publish Combined Nightly from ${{ github.ref_name }} @ ${{ github.sha }}"
|
||||
|
||||
# Prevent concurrent publish runs on the same branch: the version is generated
|
||||
# from a seconds-resolution timestamp, so parallel runs on the same ref can
|
||||
# collide on the same version and cause publish failures or inconsistent tagging.
|
||||
concurrency:
|
||||
group: ext-vscode-publish-nightly-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
test:
|
||||
if: github.repository == 'cline/cline'
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
uses: ./.github/workflows/ext-vscode-test.yml
|
||||
|
||||
publish:
|
||||
needs: test
|
||||
permissions:
|
||||
contents: write
|
||||
name: Publish Cline (Nightly) Combined Extension
|
||||
# Defense in depth: only protected main may enter the publishing environment.
|
||||
# This `if` is advisory because a dispatched branch runs its own copy of this
|
||||
# file; the enforced gate is the PublishNightly environment's deployment-branch
|
||||
# policy, which must also allow only main.
|
||||
if: github.repository == 'cline/cline' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
environment: PublishNightly
|
||||
|
||||
steps:
|
||||
- name: Checkout next (SDK) source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
path: next-src
|
||||
lfs: true
|
||||
persist-credentials: false
|
||||
|
||||
- name: Checkout legacy source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# NOTE: the || fallback is retained so this stays correct if a
|
||||
# non-dispatch trigger is ever added back (inputs are empty strings
|
||||
# on e.g. `schedule` events, where the declared default does not apply).
|
||||
ref: ${{ inputs.legacy-ref || 'legacy-extension' }}
|
||||
path: legacy-src
|
||||
lfs: true
|
||||
persist-credentials: false
|
||||
|
||||
- name: Show build sources
|
||||
env:
|
||||
# Routed through env rather than interpolated into the script body so
|
||||
# a crafted dispatch input can't inject shell (hygiene: dispatchers
|
||||
# need write access anyway, but keep the pattern clean).
|
||||
LEGACY_REF: ${{ inputs.legacy-ref || 'legacy-extension' }}
|
||||
run: |
|
||||
echo "next: $(git -C next-src rev-parse HEAD)"
|
||||
echo "legacy: $(git -C legacy-src rev-parse HEAD) ($LEGACY_REF)"
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
|
||||
# Node is required beyond install: the rollout scripts run under node and
|
||||
# publishing shells out to vsce/ovsx. Pinned to Node 22 because newer LTS
|
||||
# (Node 24 / npm 11) can make vsce's dependency detection fail.
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
# ONE version for the next bundle, the legacy bundle, and the union
|
||||
# manifest: gen-manifest hard-fails if the bundle identities diverge.
|
||||
# Same scheme as the standalone nightly: <major>.<minor>.<unix-seconds>
|
||||
# from next's base version, so it keeps outranking earlier nightlies.
|
||||
- name: Compute nightly version
|
||||
id: version
|
||||
run: |
|
||||
BASE=$(node -p "require('./next-src/apps/vscode/package.json').version")
|
||||
VERSION="$(echo "$BASE" | cut -d. -f1,2).$(date +%s)"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Combined nightly version: $VERSION (base $BASE)"
|
||||
|
||||
- name: Install next workspace dependencies
|
||||
working-directory: next-src
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Build SDK packages
|
||||
working-directory: next-src
|
||||
run: bun run build:sdk
|
||||
|
||||
- name: Assert better-sqlite3 native binary present
|
||||
working-directory: next-src/apps/vscode
|
||||
run: |
|
||||
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
|
||||
if [ ! -f "$NODE_FILE" ]; then
|
||||
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
|
||||
echo "(bun trustedDependencies postinstall likely did not run)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found better-sqlite3 native binary: $NODE_FILE"
|
||||
|
||||
# Rewrite each bundle's package.json to the cline-nightly identity BEFORE
|
||||
# its build (runtime command/config IDs derive from the manifest) and
|
||||
# AFTER dependency install (workspace self-links key off the original
|
||||
# package name).
|
||||
- name: Nightlify next bundle manifest
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/nightlify.mjs --dir "$GITHUB_WORKSPACE/next-src/apps/vscode" --version "${{ steps.version.outputs.version }}"
|
||||
|
||||
- name: Build next bundle
|
||||
working-directory: next-src/apps/vscode
|
||||
env:
|
||||
CLINE_ENVIRONMENT: production
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
# Inlined by esbuild: attributes every telemetry event with
|
||||
# extension_variant and unlocks the bundle's authoritative
|
||||
# extension.rollout.bundle_activated capture. Rollout builds only.
|
||||
CLINE_ROLLOUT_VARIANT: next
|
||||
# OpenTelemetry production defaults (can be overridden at runtime)
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
run: bun run package
|
||||
|
||||
- name: Install legacy dependencies
|
||||
working-directory: legacy-src
|
||||
run: |
|
||||
npm --prefix apps/vscode install --include=optional
|
||||
npm --prefix apps/vscode/webview-ui install --include=optional
|
||||
|
||||
- name: Nightlify legacy bundle manifest
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/nightlify.mjs --dir "$GITHUB_WORKSPACE/legacy-src/apps/vscode" --version "${{ steps.version.outputs.version }}"
|
||||
|
||||
- name: Build legacy bundle
|
||||
working-directory: legacy-src/apps/vscode
|
||||
env:
|
||||
CLINE_ENVIRONMENT: production
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
CLINE_ROLLOUT_VARIANT: legacy
|
||||
# Legacy's esbuild inlines these too (its own publish workflow passes
|
||||
# them) — omitting them here would ship the legacy bundle with the
|
||||
# OTel pipeline dead, unlike what legacy users get today.
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
run: npm run package
|
||||
|
||||
- name: Build loader and run rollout tests
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
run: |
|
||||
bun run typecheck
|
||||
bun run test
|
||||
bun run build:production
|
||||
|
||||
- name: Stitch combined VSIX staging
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: |
|
||||
node scripts/stitch.mjs \
|
||||
--next "$GITHUB_WORKSPACE/next-src/apps/vscode" \
|
||||
--legacy "$GITHUB_WORKSPACE/legacy-src/apps/vscode" \
|
||||
--loader dist/extension.js \
|
||||
--version "${{ steps.version.outputs.version }}" \
|
||||
--out "$GITHUB_WORKSPACE/staging"
|
||||
|
||||
- name: Smoke-test loader against staging
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/smoke-loader.mjs "$GITHUB_WORKSPACE/staging"
|
||||
|
||||
# The nightly identity must have fully propagated (nightlify -> both
|
||||
# bundle manifests -> union manifest) or we'd publish over the stable
|
||||
# extension ID. The bundle sub-manifest checks guard the version
|
||||
# stamping: the About tab and telemetry extension_version read those.
|
||||
- name: Assert nightly manifest identity
|
||||
working-directory: staging
|
||||
env:
|
||||
EXPECTED_VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
node -e '
|
||||
const assert = require("node:assert");
|
||||
const expected = process.env.EXPECTED_VERSION;
|
||||
const pkg = require("./package.json");
|
||||
assert.equal(pkg.name, "cline-nightly", `unexpected name ${pkg.name}`);
|
||||
assert.equal(pkg.publisher, "saoudrizwan", `unexpected publisher ${pkg.publisher}`);
|
||||
assert.equal(pkg.version, expected, `unexpected union version ${pkg.version}`);
|
||||
for (const bundle of ["next", "legacy"]) {
|
||||
const sub = require(`./${bundle}/package.json`);
|
||||
assert.equal(sub.name, "cline-nightly", `unexpected ${bundle} bundle name ${sub.name}`);
|
||||
assert.equal(sub.version, expected, `unexpected ${bundle} bundle version ${sub.version}`);
|
||||
}
|
||||
console.log(`nightly identity ok: ${pkg.publisher}.${pkg.name}@${pkg.version} (bundle identities aligned)`);
|
||||
'
|
||||
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
- name: Package VSIX
|
||||
working-directory: staging
|
||||
# Preserve the narrowly scoped VSCE `sendgrid` scanner exemption used by
|
||||
# both standalone bundle workflows. No SendGrid credential is intentionally
|
||||
# supplied here; inspect the reported artifact before widening the exemption.
|
||||
run: vsce package --no-dependencies --allow-package-secrets sendgrid --out "cline-nightly-${{ steps.version.outputs.version }}.vsix"
|
||||
|
||||
- name: Upload VSIX artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cline-nightly-${{ steps.version.outputs.version }}
|
||||
path: staging/cline-nightly-${{ steps.version.outputs.version }}.vsix
|
||||
if-no-files-found: error
|
||||
|
||||
# The job is main-only; step-level dry-run gating still permits a build-only
|
||||
# rehearsal without publishing or tagging.
|
||||
- name: Publish to VS Code Marketplace and Open VSX
|
||||
if: github.ref == 'refs/heads/main' && inputs.dry-run != true
|
||||
working-directory: staging
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
run: |
|
||||
if [[ -z "$VSCE_PAT" ]]; then
|
||||
echo "Error: VSCE_PAT is required to publish."
|
||||
exit 1
|
||||
fi
|
||||
vsce publish --no-dependencies --packagePath "cline-nightly-${{ steps.version.outputs.version }}.vsix"
|
||||
if [[ -n "$OVSX_PAT" ]]; then
|
||||
npx ovsx publish --packagePath "cline-nightly-${{ steps.version.outputs.version }}.vsix" --pat "$OVSX_PAT"
|
||||
else
|
||||
echo "WARNING: OVSX_PAT not set; skipping Open VSX publish."
|
||||
fi
|
||||
|
||||
- name: Tag published commit
|
||||
if: github.ref == 'refs/heads/main' && inputs.dry-run != true
|
||||
# Best-effort bookkeeping: the default GITHUB_TOKEN cannot create a ref
|
||||
# whose commit modifies workflow files (no workflows permission exists
|
||||
# for it), so this step fails whenever HEAD touched .github/workflows.
|
||||
# The publish already succeeded by this point — don't mark the run red;
|
||||
# push the tag manually with user credentials when it matters.
|
||||
continue-on-error: true
|
||||
working-directory: next-src
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
SAFE_REF=$(echo "$GITHUB_REF_NAME" | tr '/[:upper:]' '-[:lower:]' | tr -cd 'a-z0-9._-')
|
||||
SHORT_SHA=$(git rev-parse --short=12 HEAD)
|
||||
TIMESTAMP=$(date -u +"%Y%m%d%H%M%S")
|
||||
TAG="nightly-${SAFE_REF}-${TIMESTAMP}-${SHORT_SHA}"
|
||||
LEGACY_SHA=$(git -C "$GITHUB_WORKSPACE/legacy-src" rev-parse HEAD)
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag -a "$TAG" -m "Cline Nightly (combined A/B) published from ${GITHUB_REF_NAME} at ${GITHUB_SHA} (legacy bundle: ${LEGACY_SHA})"
|
||||
# Use an explicit HTTPS remote with GH_TOKEN because checkout was run with
|
||||
# persist-credentials: false, so actions/checkout did not persist a git credential helper.
|
||||
git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "refs/tags/${TAG}"
|
||||
|
||||
echo "Tagged published commit: $TAG"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user