mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-18 13:25:52 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1880523422 | |||
| eeecd5a843 | |||
| 7747fc2033 | |||
| 864230bd0a | |||
| 0de0040e63 | |||
| 98efaae960 | |||
| 53ae47a1bd | |||
| 584b215803 | |||
| 3009b5530b | |||
| 1309c4bd12 | |||
| 2a829fb98f | |||
| ad07086900 | |||
| 596eeede2f | |||
| 879ec65609 | |||
| 2fce8731e1 |
@@ -39,6 +39,53 @@ Keep this managed block so 'openspec update' can refresh the instructions.
|
||||
- Multi-provider LLM support via `llm.Provider` interface
|
||||
- MCP client-server for tool integration
|
||||
- Builtin servers: bash, fetch, todo, fs
|
||||
- **Extension system** (`internal/extensions/`): Yaegi-interpreted Go, 13 lifecycle events, custom tools/commands/widgets/overlays/editor interceptors
|
||||
- **TUI** (`internal/ui/`): Bubble Tea v2 parent-child model (`AppModel` → `InputComponent`, `StreamComponent`, etc.)
|
||||
- **Decoupling pattern**: `cmd/root.go` has converter functions (e.g. `widgetProviderForUI()`) that bridge `internal/extensions/` types to `internal/ui/` types — the UI never imports extensions directly
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### Yaegi (Extension Interpreter) Gotchas
|
||||
- **No interfaces across boundary**: All extension-facing API types must be concrete structs, never interfaces. Yaegi crashes on interface wrapper generation.
|
||||
- **Function field bug**: Named function references assigned to struct fields return zero values across the interpreter boundary. Always use anonymous closure literals:
|
||||
```go
|
||||
// WRONG: ctx.SetEditor(ext.EditorConfig{HandleKey: myHandler})
|
||||
// RIGHT: ctx.SetEditor(ext.EditorConfig{HandleKey: func(k, t string) ext.EditorKeyAction { return myHandler(k, t) }})
|
||||
```
|
||||
- **Symbol exports**: Every new type exposed to extensions must be added to `internal/extensions/symbols.go`
|
||||
|
||||
### BubbleTea Integration
|
||||
- **No `prog.Send()` from inside `Update()`**: Calling `prog.Send()` synchronously within a BubbleTea `Update()` handler deadlocks the event loop. Use `go appInstance.NotifyWidgetUpdate()` (async goroutine) instead.
|
||||
- **Height measurement**: `distributeHeight()` in `model.go` must measure using the same render path as `View()`. If an interceptor wraps rendering, measure with the wrapper too, or layout will mismatch.
|
||||
- **Channel-based prompts**: Extension prompt calls (PromptSelect, etc.) block on a `chan PromptResponse`. Extension slash commands run in dedicated goroutines (not `tea.Cmd`) to avoid stalling BubbleTea's Cmd scheduler.
|
||||
|
||||
### Extension State Management
|
||||
- **Thread-safe maps on Runner**: Widget/header/footer/editor state lives on the Runner with `sync.RWMutex`, queried by UI via callbacks
|
||||
- **Context function fields**: The `Context` struct uses function fields (`Print func(string)`, `SetWidget func(WidgetConfig)`) wired by closures in `cmd/root.go`
|
||||
- **Package-level vars in extensions**: Yaegi supports package-level variables captured in closures — this is how extensions maintain state across event callbacks
|
||||
|
||||
### Unicode in Widget Text
|
||||
- Widget content renders through `lipgloss.Style.Render()` which preserves ANSI escape codes
|
||||
- Use rune-based width calculations (`len([]rune(s))`) not byte length (`len(s)`) when aligning box-drawing characters or multi-byte symbols
|
||||
|
||||
## Testing
|
||||
|
||||
### Interactive TUI Testing with tmux
|
||||
Use tmux to test Kit interactively without blocking the agent:
|
||||
```bash
|
||||
tmux new-session -d -s kittest -x 120 -y 40 "output/kit -e examples/extensions/my-ext.go --no-session 2>kit_stderr.log"
|
||||
sleep 3
|
||||
tmux capture-pane -t kittest -p # read screen
|
||||
tmux send-keys -t kittest '/command' Enter # send input
|
||||
tmux kill-session -t kittest # cleanup
|
||||
```
|
||||
|
||||
### Non-Interactive Kit (Subprocess Spawning)
|
||||
Extensions can spawn Kit as a subprocess for sub-agent patterns:
|
||||
```bash
|
||||
kit --prompt "question" --quiet --no-session --no-extensions --system-prompt /path/to/prompt.txt --model provider/model
|
||||
```
|
||||
Key flags: `--quiet` (stdout only, no TUI), `--no-session` (ephemeral), `--no-extensions` (prevent recursive loading), `--system-prompt` (string or file path).
|
||||
|
||||
## External Repo Research
|
||||
- **ALWAYS use `btca`** to search external repos (e.g. iteratr, other reference codebases)
|
||||
|
||||
@@ -2,836 +2,15 @@
|
||||
<img src="logo.jpg" alt="KIT" width="400">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/mark3labs/kit/actions/workflows/ci.yml"><img src="https://github.com/mark3labs/kit/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
||||
<a href="https://github.com/mark3labs/kit/releases/latest"><img src="https://img.shields.io/github/v/release/mark3labs/kit?style=flat&color=blue" alt="Release"></a>
|
||||
<a href="https://www.npmjs.com/package/@mark3labs/kit"><img src="https://img.shields.io/npm/v/@mark3labs/kit?style=flat&color=cb3837" alt="npm"></a>
|
||||
<a href="https://pkg.go.dev/github.com/mark3labs/kit"><img src="https://pkg.go.dev/badge/github.com/mark3labs/kit.svg" alt="Go Reference"></a>
|
||||
<a href="https://github.com/mark3labs/kit/blob/master/LICENSE"><img src="https://img.shields.io/github/license/mark3labs/kit?style=flat" alt="License"></a>
|
||||
<a href="https://discord.gg/RqSS2NQVsY"><img src="https://img.shields.io/badge/Discord-community-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
</p>
|
||||
|
||||
# KIT (Knowledge Inference Tool)
|
||||
|
||||
A lightweight AI agent for coding. Supports Claude, OpenAI, Google Gemini, Ollama, and any OpenAI-compatible endpoint.
|
||||
|
||||
Discuss the Project on [Discord](https://discord.gg/RqSS2NQVsY)
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview-)
|
||||
- [Features](#features-)
|
||||
- [Requirements](#requirements-)
|
||||
- [Environment Setup](#environment-setup-)
|
||||
- [Installation](#installation-)
|
||||
- [SDK Usage](#sdk-usage-)
|
||||
- [Configuration](#configuration-)
|
||||
- [MCP Servers](#mcp-servers)
|
||||
- [Environment Variable Substitution](#environment-variable-substitution)
|
||||
- [Simplified Configuration Schema](#simplified-configuration-schema)
|
||||
- [Tool Filtering](#tool-filtering)
|
||||
- [Legacy Configuration Support](#legacy-configuration-support)
|
||||
- [Transport Types](#transport-types)
|
||||
- [System Prompt](#system-prompt)
|
||||
- [Usage](#usage-)
|
||||
- [Interactive Mode](#interactive-mode-default)
|
||||
- [Script Mode](#script-mode)
|
||||
- [Hooks System](#hooks-system)
|
||||
- [Non-Interactive Mode](#non-interactive-mode)
|
||||
- [Model Generation Parameters](#model-generation-parameters)
|
||||
- [Available Models](#available-models)
|
||||
- [Examples](#examples)
|
||||
- [Flags](#flags)
|
||||
- [Authentication Subcommands](#authentication-subcommands)
|
||||
- [Configuration File Support](#configuration-file-support)
|
||||
- [Interactive Commands](#interactive-commands)
|
||||
- [Automation & Scripting](#automation--scripting-)
|
||||
- [MCP Server Compatibility](#mcp-server-compatibility-)
|
||||
- [Contributing](#contributing-)
|
||||
- [License](#license-)
|
||||
- [Acknowledgments](#acknowledgments-)
|
||||
|
||||
## Overview 🌟
|
||||
|
||||
KIT acts as a host in the MCP client-server architecture, where:
|
||||
- **Hosts** (like KIT) are LLM applications that manage connections and interactions
|
||||
- **Clients** maintain 1:1 connections with MCP servers
|
||||
- **Servers** provide context, tools, and capabilities to the LLMs
|
||||
|
||||
This architecture allows language models to:
|
||||
- Access external tools and data sources 🛠️
|
||||
- Maintain consistent context across interactions 🔄
|
||||
- Execute commands and retrieve information safely 🔒
|
||||
|
||||
Currently supports:
|
||||
- Anthropic Claude models (Claude 3.5 Sonnet, Claude 3.5 Haiku, etc.)
|
||||
- OpenAI models (GPT-4, GPT-4 Turbo, GPT-3.5, etc.)
|
||||
- Google Gemini models (Gemini 2.0 Flash, Gemini 1.5 Pro, etc.)
|
||||
- Any Ollama-compatible model with function calling support
|
||||
- Any OpenAI-compatible API endpoint
|
||||
|
||||
## Features ✨
|
||||
|
||||
- Interactive conversations with multiple AI models
|
||||
- **Non-interactive mode** for scripting and automation
|
||||
- **Script mode** for executable YAML-based automation scripts
|
||||
- Support for multiple concurrent MCP servers
|
||||
- **Tool filtering** with `allowedTools` and `excludedTools` per server
|
||||
- Dynamic tool discovery and integration
|
||||
- Tool calling capabilities across all supported models
|
||||
- Configurable MCP server locations and arguments
|
||||
- Consistent command interface across model types
|
||||
- Configurable message history window for context management
|
||||
- **OAuth authentication** support for Anthropic (alternative to API keys)
|
||||
- **Hooks system** for custom integrations and security policies
|
||||
- **Environment variable substitution** in configs and scripts
|
||||
- **Builtin servers** for common functionality (filesystem, bash, todo, http)
|
||||
|
||||
## Requirements 📋
|
||||
|
||||
- Go 1.23 or later
|
||||
- For OpenAI/Anthropic: API key for the respective provider
|
||||
- For Ollama: Local Ollama installation with desired models
|
||||
- For Google/Gemini: Google API key (see https://aistudio.google.com/app/apikey)
|
||||
- One or more MCP-compatible tool servers
|
||||
|
||||
## Environment Setup 🔧
|
||||
|
||||
1. API Keys:
|
||||
```bash
|
||||
# For all providers (use --provider-api-key flag or these environment variables)
|
||||
export OPENAI_API_KEY='your-openai-key' # For OpenAI
|
||||
export ANTHROPIC_API_KEY='your-anthropic-key' # For Anthropic
|
||||
export GOOGLE_API_KEY='your-google-key' # For Google/Gemini
|
||||
```
|
||||
|
||||
2. Ollama Setup:
|
||||
- Install Ollama from https://ollama.ai
|
||||
- Pull your desired model:
|
||||
```bash
|
||||
ollama pull mistral
|
||||
```
|
||||
- Ensure Ollama is running:
|
||||
```bash
|
||||
ollama serve
|
||||
```
|
||||
|
||||
You can also configure the Ollama client using standard environment variables, such as `OLLAMA_HOST` for the Ollama base URL.
|
||||
|
||||
3. Google API Key (for Gemini):
|
||||
```bash
|
||||
export GOOGLE_API_KEY='your-api-key'
|
||||
```
|
||||
|
||||
4. OpenAI Compatible Setup:
|
||||
- Get your API server base URL, API key and model name
|
||||
- Use `--provider-url` and `--provider-api-key` flags or set environment variables
|
||||
|
||||
5. Self-Signed Certificates (TLS):
|
||||
If your provider uses self-signed certificates (e.g., local Ollama with HTTPS), you can skip certificate verification:
|
||||
```bash
|
||||
kit --provider-url https://192.168.1.100:443 --tls-skip-verify
|
||||
```
|
||||
⚠️ **WARNING**: Only use `--tls-skip-verify` for development or when connecting to trusted servers with self-signed certificates. This disables TLS certificate verification and is insecure for production use.
|
||||
|
||||
## Installation 📦
|
||||
|
||||
```bash
|
||||
go install github.com/mark3labs/kit/cmd/kit@latest
|
||||
```
|
||||
|
||||
## SDK Usage 🛠️
|
||||
|
||||
KIT also provides a Go SDK for programmatic access without spawning OS processes. The SDK maintains identical behavior to the CLI, including configuration loading, environment variables, and defaults.
|
||||
|
||||
### Quick Example
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create Kit instance with default configuration
|
||||
host, err := kit.New(ctx, nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer host.Close()
|
||||
|
||||
// Send a prompt and get response
|
||||
response, err := host.Prompt(ctx, "What is 2+2?")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Println(response)
|
||||
}
|
||||
```
|
||||
|
||||
### SDK Features
|
||||
|
||||
- ✅ Programmatic access without spawning processes
|
||||
- ✅ Identical configuration behavior to CLI
|
||||
- ✅ Session management (save/load/clear)
|
||||
- ✅ Tool execution callbacks for monitoring
|
||||
- ✅ Streaming support
|
||||
- ✅ Full compatibility with all providers and MCP servers
|
||||
|
||||
For detailed SDK documentation, examples, and API reference, see the [SDK README](pkg/kit/README.md).
|
||||
|
||||
## Configuration ⚙️
|
||||
|
||||
### MCP Servers
|
||||
KIT will automatically create a configuration file in your home directory if it doesn't exist. It looks for config files in this order:
|
||||
- `.kit.yml` or `.kit.json`
|
||||
|
||||
**Config file locations by OS:**
|
||||
- **Linux/macOS**: `~/.kit.yml`, `~/.kit.json`
|
||||
- **Windows**: `%USERPROFILE%\.kit.yml`, `%USERPROFILE%\.kit.json`
|
||||
|
||||
You can also specify a custom location using the `--config` flag.
|
||||
|
||||
### Environment Variable Substitution
|
||||
|
||||
KIT supports environment variable substitution in both config files and script frontmatter using the syntax:
|
||||
- **`${env://VAR}`** - Required environment variable (fails if not set)
|
||||
- **`${env://VAR:-default}`** - Optional environment variable with default value
|
||||
|
||||
This allows you to keep sensitive information like API keys in environment variables while maintaining flexible configuration.
|
||||
|
||||
**Example:**
|
||||
```yaml
|
||||
mcpServers:
|
||||
github:
|
||||
type: local
|
||||
command: ["docker", "run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN=${env://GITHUB_TOKEN}", "ghcr.io/github/github-mcp-server"]
|
||||
environment:
|
||||
DEBUG: "${env://DEBUG:-false}"
|
||||
LOG_LEVEL: "${env://LOG_LEVEL:-info}"
|
||||
|
||||
model: "${env://MODEL:-anthropic/claude-sonnet-4-5-20250929}"
|
||||
provider-api-key: "${env://OPENAI_API_KEY}" # Required - will fail if not set
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
# Set required environment variables
|
||||
export GITHUB_TOKEN="ghp_your_token_here"
|
||||
export OPENAI_API_KEY="your_openai_key"
|
||||
|
||||
# Optionally override defaults
|
||||
export DEBUG="true"
|
||||
export MODEL="openai/gpt-4"
|
||||
|
||||
# Run kit
|
||||
kit
|
||||
```
|
||||
|
||||
### Simplified Configuration Schema
|
||||
|
||||
KIT now supports a simplified configuration schema with three server types:
|
||||
|
||||
#### Local Servers
|
||||
For local MCP servers that run commands on your machine:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"filesystem": {
|
||||
"type": "local",
|
||||
"command": ["npx", "@modelcontextprotocol/server-filesystem", "${env://WORK_DIR:-/tmp}"],
|
||||
"environment": {
|
||||
"DEBUG": "${env://DEBUG:-false}",
|
||||
"LOG_LEVEL": "${env://LOG_LEVEL:-info}",
|
||||
"API_TOKEN": "${env://FS_API_TOKEN}"
|
||||
},
|
||||
"allowedTools": ["read_file", "write_file"],
|
||||
"excludedTools": ["delete_file"]
|
||||
},
|
||||
"github": {
|
||||
"type": "local",
|
||||
"command": ["docker", "run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN=${env://GITHUB_TOKEN}", "ghcr.io/github/github-mcp-server"],
|
||||
"environment": {
|
||||
"DEBUG": "${env://DEBUG:-false}"
|
||||
}
|
||||
},
|
||||
"sqlite": {
|
||||
"type": "local",
|
||||
"command": ["uvx", "mcp-server-sqlite", "--db-path", "${env://DB_PATH:-/tmp/foo.db}"],
|
||||
"environment": {
|
||||
"SQLITE_DEBUG": "${env://DEBUG:-0}",
|
||||
"DATABASE_URL": "${env://DATABASE_URL:-sqlite:///tmp/foo.db}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each local server entry requires:
|
||||
- `type`: Must be set to `"local"`
|
||||
- `command`: Array containing the command and all its arguments
|
||||
- `environment`: (Optional) Object with environment variables as key-value pairs
|
||||
- `allowedTools`: (Optional) Array of tool names to include (whitelist)
|
||||
- `excludedTools`: (Optional) Array of tool names to exclude (blacklist)
|
||||
|
||||
#### Remote Servers
|
||||
For remote MCP servers accessible via HTTP:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"websearch": {
|
||||
"type": "remote",
|
||||
"url": "${env://WEBSEARCH_URL:-https://api.example.com/mcp}",
|
||||
"headers": ["Authorization: Bearer ${env://WEBSEARCH_TOKEN}"]
|
||||
},
|
||||
"weather": {
|
||||
"type": "remote",
|
||||
"url": "${env://WEATHER_URL:-https://weather-mcp.example.com}"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each remote server entry requires:
|
||||
- `type`: Must be set to `"remote"`
|
||||
- `url`: The URL where the MCP server is accessible
|
||||
- `headers`: (Optional) Array of HTTP headers for authentication and custom headers
|
||||
|
||||
Remote servers automatically use the StreamableHTTP transport for optimal performance.
|
||||
|
||||
#### Builtin Servers
|
||||
For builtin MCP servers that run in-process for optimal performance:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"filesystem": {
|
||||
"type": "builtin",
|
||||
"name": "fs",
|
||||
"options": {
|
||||
"allowed_directories": ["${env://WORK_DIR:-/tmp}", "${env://HOME}/documents"]
|
||||
},
|
||||
"allowedTools": ["read_file", "write_file", "list_directory"]
|
||||
},
|
||||
"filesystem-cwd": {
|
||||
"type": "builtin",
|
||||
"name": "fs"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each builtin server entry requires:
|
||||
- `type`: Must be set to `"builtin"`
|
||||
- `name`: Internal name of the builtin server (e.g., `"fs"` for filesystem)
|
||||
- `options`: Configuration options specific to the builtin server
|
||||
|
||||
**Available Builtin Servers:**
|
||||
- `fs` (filesystem): Secure filesystem access with configurable allowed directories
|
||||
- `allowed_directories`: Array of directory paths that the server can access (defaults to current working directory if not specified)
|
||||
- `bash`: Execute bash commands with security restrictions and timeout controls
|
||||
- No configuration options required
|
||||
- `todo`: Manage ephemeral todo lists for task tracking during sessions
|
||||
- No configuration options required (todos are stored in memory and reset on restart)
|
||||
- `http`: Fetch web content and convert to text, markdown, or HTML formats
|
||||
- Tools: `fetch` (fetch and convert web content), `fetch_summarize` (fetch and summarize web content using AI), `fetch_extract` (fetch and extract specific data using AI), `fetch_filtered_json` (fetch JSON and filter using gjson path syntax)
|
||||
- No configuration options required
|
||||
|
||||
#### Builtin Server Examples
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"filesystem": {
|
||||
"type": "builtin",
|
||||
"name": "fs",
|
||||
"options": {
|
||||
"allowed_directories": ["/tmp", "/home/user/documents"]
|
||||
}
|
||||
},
|
||||
"bash-commands": {
|
||||
"type": "builtin",
|
||||
"name": "bash"
|
||||
},
|
||||
"task-manager": {
|
||||
"type": "builtin",
|
||||
"name": "todo"
|
||||
},
|
||||
"web-fetcher": {
|
||||
"type": "builtin",
|
||||
"name": "http"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tool Filtering
|
||||
|
||||
All MCP server types support tool filtering to restrict which tools are available:
|
||||
|
||||
- **`allowedTools`**: Whitelist - only specified tools are available from the server
|
||||
- **`excludedTools`**: Blacklist - all tools except specified ones are available
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"filesystem-readonly": {
|
||||
"type": "builtin",
|
||||
"name": "fs",
|
||||
"allowedTools": ["read_file", "list_directory"]
|
||||
},
|
||||
"filesystem-safe": {
|
||||
"type": "local",
|
||||
"command": ["npx", "@modelcontextprotocol/server-filesystem", "/tmp"],
|
||||
"excludedTools": ["delete_file"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: `allowedTools` and `excludedTools` are mutually exclusive - you can only use one per server.
|
||||
|
||||
### Legacy Configuration Support
|
||||
|
||||
KIT maintains full backward compatibility with the previous configuration format. **Note**: A recent bug fix improved legacy stdio transport reliability for external MCP servers (Docker, NPX, etc.).
|
||||
|
||||
#### Legacy STDIO Format
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"sqlite": {
|
||||
"command": "uvx",
|
||||
"args": ["mcp-server-sqlite", "--db-path", "/tmp/foo.db"],
|
||||
"env": {
|
||||
"DEBUG": "true"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Legacy SSE Format
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"server_name": {
|
||||
"url": "http://some_host:8000/sse",
|
||||
"headers": ["Authorization: Bearer my-token"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Legacy Docker/Container Format
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"phalcon": {
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"run",
|
||||
"-i",
|
||||
"--rm",
|
||||
"ghcr.io/mark3labs/phalcon-mcp:latest",
|
||||
"serve"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Legacy Streamable HTTP Format
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"websearch": {
|
||||
"transport": "streamable",
|
||||
"url": "https://api.example.com/mcp",
|
||||
"headers": ["Authorization: Bearer your-api-token"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Transport Types
|
||||
|
||||
KIT supports four transport types:
|
||||
- **`stdio`**: Launches a local process and communicates via stdin/stdout (used by `"local"` servers)
|
||||
- **`sse`**: Connects to a server using Server-Sent Events (legacy format)
|
||||
- **`streamable`**: Connects to a server using Streamable HTTP protocol (used by `"remote"` servers)
|
||||
- **`inprocess`**: Runs builtin servers in-process for optimal performance (used by `"builtin"` servers)
|
||||
|
||||
The simplified schema automatically maps:
|
||||
- `"local"` type → `stdio` transport
|
||||
- `"remote"` type → `streamable` transport
|
||||
- `"builtin"` type → `inprocess` transport
|
||||
|
||||
### System Prompt
|
||||
|
||||
You can specify a custom system prompt using the `--system-prompt` flag. You can either:
|
||||
|
||||
1. **Pass the prompt directly as text:**
|
||||
```bash
|
||||
kit --system-prompt "You are a helpful assistant that responds in a friendly tone."
|
||||
```
|
||||
|
||||
2. **Pass a path to a text file containing the prompt:**
|
||||
```bash
|
||||
kit --system-prompt ./prompts/assistant.md
|
||||
```
|
||||
|
||||
Example `assistant.md` file:
|
||||
```markdown
|
||||
You are a helpful coding assistant.
|
||||
|
||||
Please:
|
||||
- Write clean, readable code
|
||||
- Include helpful comments
|
||||
- Follow best practices
|
||||
- Explain your reasoning
|
||||
```
|
||||
|
||||
|
||||
## Usage 🚀
|
||||
|
||||
KIT is a CLI tool that allows you to interact with various AI models through a unified interface. It supports various tools through MCP servers and can run in both interactive and non-interactive modes.
|
||||
|
||||
### Interactive Mode (Default)
|
||||
|
||||
Start an interactive conversation session:
|
||||
|
||||
```bash
|
||||
kit
|
||||
```
|
||||
|
||||
### Hooks System
|
||||
|
||||
KIT supports a powerful hooks system that allows you to execute custom commands at specific points during execution. This enables security policies, logging, custom integrations, and automated workflows.
|
||||
|
||||
#### Quick Start
|
||||
|
||||
1. Initialize a hooks configuration:
|
||||
```bash
|
||||
kit hooks init
|
||||
```
|
||||
|
||||
2. View active hooks:
|
||||
```bash
|
||||
kit hooks list
|
||||
```
|
||||
|
||||
3. Validate your configuration:
|
||||
```bash
|
||||
kit hooks validate
|
||||
```
|
||||
|
||||
#### Configuration
|
||||
|
||||
Hooks are configured in YAML files with the following precedence (highest to lowest):
|
||||
- `.kit/hooks.yml` (project-specific hooks)
|
||||
- `$XDG_CONFIG_HOME/kit/hooks.yml` (user global hooks, defaults to `~/.config/kit/hooks.yml`)
|
||||
|
||||
Example configuration:
|
||||
```yaml
|
||||
hooks:
|
||||
PreToolUse:
|
||||
- matcher: "bash"
|
||||
hooks:
|
||||
- type: command
|
||||
command: "/usr/local/bin/validate-bash.py"
|
||||
timeout: 5
|
||||
|
||||
UserPromptSubmit:
|
||||
- hooks:
|
||||
- type: command
|
||||
command: "~/.kit/hooks/log-prompt.sh"
|
||||
```
|
||||
|
||||
#### Available Hook Events
|
||||
|
||||
- **PreToolUse**: Before any tool execution (bash, fetch, todo, MCP tools)
|
||||
- **PostToolUse**: After tool execution completes
|
||||
- **UserPromptSubmit**: When user submits a prompt
|
||||
- **Stop**: When the agent finishes responding
|
||||
- **SubagentStop**: When a subagent (Task tool) finishes
|
||||
- **Notification**: When KIT sends notifications
|
||||
|
||||
#### Security
|
||||
|
||||
⚠️ **WARNING**: Hooks execute arbitrary commands on your system. Only use hooks from trusted sources and always review hook commands before enabling them.
|
||||
|
||||
To temporarily disable all hooks, use the `--no-hooks` flag:
|
||||
```bash
|
||||
kit --no-hooks
|
||||
```
|
||||
|
||||
See the example hook scripts in `examples/hooks/`:
|
||||
- `bash-validator.py` - Validates and blocks dangerous bash commands
|
||||
- `prompt-logger.sh` - Logs all user prompts with timestamps
|
||||
- `mcp-monitor.py` - Monitors and enforces policies on MCP tool usage
|
||||
|
||||
### Non-Interactive Mode
|
||||
|
||||
Run a single prompt and exit - perfect for scripting and automation:
|
||||
|
||||
```bash
|
||||
# Basic non-interactive usage
|
||||
kit -p "What is the weather like today?"
|
||||
|
||||
# Quiet mode - only output the AI response (no UI elements)
|
||||
kit -p "What is 2+2?" --quiet
|
||||
|
||||
# Use with different models
|
||||
kit -m ollama/qwen2.5:3b -p "Explain quantum computing" --quiet
|
||||
```
|
||||
|
||||
### Model Generation Parameters
|
||||
|
||||
KIT supports fine-tuning model behavior through various parameters:
|
||||
|
||||
```bash
|
||||
# Control response length
|
||||
kit -p "Explain AI" --max-tokens 1000
|
||||
|
||||
# Adjust creativity (0.0 = focused, 1.0 = creative)
|
||||
kit -p "Write a story" --temperature 0.9
|
||||
|
||||
# Control diversity with nucleus sampling
|
||||
kit -p "Generate ideas" --top-p 0.8
|
||||
|
||||
# Limit token choices for more focused responses
|
||||
kit -p "Answer precisely" --top-k 20
|
||||
|
||||
# Set custom stop sequences
|
||||
kit -p "Generate code" --stop-sequences "```","END"
|
||||
```
|
||||
|
||||
These parameters work with all supported providers (OpenAI, Anthropic, Google, Ollama) where supported by the underlying model.
|
||||
|
||||
### Available Models
|
||||
Models can be specified using the `--model` (`-m`) flag:
|
||||
- **Anthropic Claude** (default): `anthropic/claude-sonnet-4-5-20250929`, `anthropic/claude-3-5-sonnet-latest`, `anthropic/claude-3-5-haiku-latest`
|
||||
- **OpenAI**: `openai/gpt-4`, `openai/gpt-4-turbo`, `openai/gpt-3.5-turbo`
|
||||
- **Google Gemini**: `google/gemini-2.0-flash`, `google/gemini-1.5-pro`
|
||||
- **Ollama models**: `ollama/llama3.2`, `ollama/qwen2.5:3b`, `ollama/mistral`
|
||||
- **OpenAI-compatible**: Any model via custom endpoint with `--provider-url`
|
||||
|
||||
### Examples
|
||||
|
||||
#### Interactive Mode
|
||||
```bash
|
||||
# Use Ollama with Qwen model
|
||||
kit -m ollama/qwen2.5:3b
|
||||
|
||||
# Use OpenAI's GPT-4
|
||||
kit -m openai/gpt-4
|
||||
|
||||
# Use OpenAI-compatible model with custom URL and API key
|
||||
kit --model openai/<your-model-name> \
|
||||
--provider-url <your-base-url> \
|
||||
--provider-api-key <your-api-key>
|
||||
```
|
||||
|
||||
#### Non-Interactive Mode
|
||||
```bash
|
||||
# Single prompt with full UI
|
||||
kit -p "List files in the current directory"
|
||||
|
||||
# Compact mode for cleaner output without fancy styling
|
||||
kit -p "List files in the current directory" --compact
|
||||
|
||||
# Quiet mode for scripting (only AI response output, no UI elements)
|
||||
kit -p "What is the capital of France?" --quiet
|
||||
|
||||
# Use in shell scripts
|
||||
RESULT=$(kit -p "Calculate 15 * 23" --quiet)
|
||||
echo "The answer is: $RESULT"
|
||||
|
||||
# Pipe to other commands
|
||||
kit -p "Generate a random UUID" --quiet | tr '[:lower:]' '[:upper:]'
|
||||
```
|
||||
|
||||
### Flags
|
||||
- `--provider-url string`: Base URL for the provider API (applies to OpenAI, Anthropic, Ollama, and Google)
|
||||
- `--provider-api-key string`: API key for the provider (applies to OpenAI, Anthropic, and Google)
|
||||
- `--tls-skip-verify`: Skip TLS certificate verification (WARNING: insecure, use only for self-signed certificates)
|
||||
- `--config string`: Config file location (default is $HOME/.kit.yml)
|
||||
- `--system-prompt string`: system-prompt file location
|
||||
- `--debug`: Enable debug logging
|
||||
- `--max-steps int`: Maximum number of agent steps (0 for unlimited, default: 0)
|
||||
- `-m, --model string`: Model to use (format: provider/model) (default "anthropic/claude-sonnet-4-5-20250929")
|
||||
- `-p, --prompt string`: **Run in non-interactive mode with the given prompt**
|
||||
- `--quiet`: **Suppress all output except the AI response (only works with --prompt)**
|
||||
- `--compact`: **Enable compact output mode without fancy styling (ideal for scripting and automation)**
|
||||
- `--stream`: Enable streaming responses (default: true, use `--stream=false` to disable)
|
||||
|
||||
### Authentication Subcommands
|
||||
- `kit auth login anthropic`: Authenticate with Anthropic using OAuth (alternative to API keys)
|
||||
- `kit auth logout anthropic`: Remove stored OAuth credentials
|
||||
- `kit auth status`: Show authentication status
|
||||
|
||||
**Note**: OAuth credentials (when present) take precedence over API keys from environment variables and `--provider-api-key` flags.
|
||||
|
||||
#### Model Generation Parameters
|
||||
- `--max-tokens int`: Maximum number of tokens in the response (default: 4096)
|
||||
- `--temperature float32`: Controls randomness in responses (0.0-1.0, default: 0.7)
|
||||
- `--top-p float32`: Controls diversity via nucleus sampling (0.0-1.0, default: 0.95)
|
||||
- `--top-k int32`: Controls diversity by limiting top K tokens to sample from (default: 40)
|
||||
- `--stop-sequences strings`: Custom stop sequences (comma-separated)
|
||||
|
||||
### Configuration File Support
|
||||
|
||||
All command-line flags can be configured via the config file. KIT will look for configuration in this order:
|
||||
1. `~/.kit.yml` or `~/.kit.json`
|
||||
|
||||
Example config file (`~/.kit.yml`):
|
||||
```yaml
|
||||
# MCP Servers - New Simplified Format
|
||||
mcpServers:
|
||||
filesystem-local:
|
||||
type: "local"
|
||||
command: ["npx", "@modelcontextprotocol/server-filesystem", "/path/to/files"]
|
||||
environment:
|
||||
DEBUG: "true"
|
||||
filesystem-builtin:
|
||||
type: "builtin"
|
||||
name: "fs"
|
||||
options:
|
||||
allowed_directories: ["/tmp", "/home/user/documents"]
|
||||
websearch:
|
||||
type: "remote"
|
||||
url: "https://api.example.com/mcp"
|
||||
|
||||
# Application settings
|
||||
model: "anthropic/claude-sonnet-4-5-20250929"
|
||||
max-steps: 20
|
||||
debug: false
|
||||
system-prompt: "/path/to/system-prompt.txt"
|
||||
|
||||
# Model generation parameters
|
||||
max-tokens: 4096
|
||||
temperature: 0.7
|
||||
top-p: 0.95
|
||||
top-k: 40
|
||||
stop-sequences: ["Human:", "Assistant:"]
|
||||
|
||||
# Streaming configuration
|
||||
stream: false # Disable streaming (default: true)
|
||||
|
||||
# API Configuration
|
||||
provider-api-key: "your-api-key" # For OpenAI, Anthropic, or Google
|
||||
provider-url: "https://api.openai.com/v1" # Custom base URL
|
||||
tls-skip-verify: false # Skip TLS certificate verification (default: false)
|
||||
```
|
||||
|
||||
**Note**: Command-line flags take precedence over config file values.
|
||||
|
||||
|
||||
### Interactive Commands
|
||||
|
||||
While chatting, you can use:
|
||||
- `/help`: Show available commands
|
||||
- `/tools`: List all available tools
|
||||
- `/servers`: List configured MCP servers
|
||||
- `/history`: Display conversation history
|
||||
- `/quit`: Exit the application
|
||||
- `Ctrl+C`: Exit at any time
|
||||
|
||||
### Authentication Commands
|
||||
|
||||
Optional OAuth authentication for Anthropic (alternative to API keys):
|
||||
- `kit auth login anthropic`: Authenticate using OAuth
|
||||
- `kit auth logout anthropic`: Remove stored OAuth credentials
|
||||
- `kit auth status`: Show authentication status
|
||||
|
||||
### Global Flags
|
||||
- `--config`: Specify custom config file location
|
||||
|
||||
## Automation & Scripting 🤖
|
||||
|
||||
KIT's non-interactive mode makes it perfect for automation, scripting, and integration with other tools.
|
||||
|
||||
### Use Cases
|
||||
|
||||
#### Shell Scripts
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Get weather and save to file
|
||||
kit -p "What's the weather in New York?" --quiet > weather.txt
|
||||
|
||||
# Process files with AI
|
||||
for file in *.txt; do
|
||||
summary=$(kit -p "Summarize this file: $(cat $file)" --quiet)
|
||||
echo "$file: $summary" >> summaries.txt
|
||||
done
|
||||
```
|
||||
|
||||
#### CI/CD Integration
|
||||
```bash
|
||||
# Code review automation
|
||||
DIFF=$(git diff HEAD~1)
|
||||
kit -p "Review this code diff and suggest improvements: $DIFF" --quiet
|
||||
|
||||
# Generate release notes
|
||||
COMMITS=$(git log --oneline HEAD~10..HEAD)
|
||||
kit -p "Generate release notes from these commits: $COMMITS" --quiet
|
||||
```
|
||||
|
||||
#### Data Processing
|
||||
```bash
|
||||
# Process CSV data
|
||||
kit -p "Analyze this CSV data and provide insights: $(cat data.csv)" --quiet
|
||||
|
||||
# Generate reports
|
||||
kit -p "Create a summary report from this JSON: $(cat metrics.json)" --quiet
|
||||
```
|
||||
|
||||
#### API Integration
|
||||
```bash
|
||||
# Use as a microservice
|
||||
curl -X POST http://localhost:8080/process \
|
||||
-d "$(kit -p 'Generate a UUID' --quiet)"
|
||||
```
|
||||
|
||||
### Tips
|
||||
- Use `--quiet` flag to get clean output suitable for parsing (only AI response, no UI)
|
||||
- Use `--compact` flag for simplified output without fancy styling (when you want to see UI elements)
|
||||
- Note: `--compact` and `--quiet` are mutually exclusive - `--compact` has no effect with `--quiet`
|
||||
- **Use environment variables for sensitive data** like API keys instead of hardcoding them
|
||||
- **Use `${env://VAR}` syntax** in config files for environment variable substitution
|
||||
- Use environment variables for API keys in production
|
||||
|
||||
#### Environment Variable Best Practices
|
||||
```bash
|
||||
# Set sensitive variables in environment
|
||||
export GITHUB_TOKEN="ghp_your_token_here"
|
||||
export OPENAI_API_KEY="your_openai_key"
|
||||
export DATABASE_URL="postgresql://user:pass@localhost/db"
|
||||
|
||||
# Use in config files
|
||||
mcpServers:
|
||||
github:
|
||||
environment:
|
||||
GITHUB_TOKEN: "${env://GITHUB_TOKEN}"
|
||||
DEBUG: "${env://DEBUG:-false}"
|
||||
```
|
||||
|
||||
## MCP Server Compatibility 🔌
|
||||
|
||||
KIT can work with any MCP-compliant server. For examples and reference implementations, see the [MCP Servers Repository](https://github.com/modelcontextprotocol/servers).
|
||||
|
||||
## Contributing 🤝
|
||||
|
||||
Contributions are welcome! Feel free to:
|
||||
- Submit bug reports or feature requests through issues
|
||||
- Create pull requests for improvements
|
||||
- Share your custom MCP servers
|
||||
- Improve documentation
|
||||
|
||||
Please ensure your contributions follow good coding practices and include appropriate tests.
|
||||
|
||||
## License 📄
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## Acknowledgments 🙏
|
||||
|
||||
- Thanks to the Anthropic team for Claude and the MCP specification
|
||||
- Thanks to the Ollama team for their local LLM runtime
|
||||
- Thanks to all contributors who have helped improve this tool
|
||||
TBD
|
||||
|
||||
+309
-69
@@ -10,7 +10,6 @@ import (
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/fantasy"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/mark3labs/kit/internal/agent"
|
||||
"github.com/mark3labs/kit/internal/app"
|
||||
"github.com/mark3labs/kit/internal/config"
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
@@ -63,34 +62,35 @@ var (
|
||||
tlsSkipVerify bool
|
||||
)
|
||||
|
||||
// agentUIAdapter adapts agent.Agent to ui.AgentInterface
|
||||
type agentUIAdapter struct {
|
||||
agent *agent.Agent
|
||||
// kitUIAdapter adapts *kit.Kit to ui.AgentInterface so the CLI setup layer
|
||||
// can display tool/server metadata without importing internal types.
|
||||
type kitUIAdapter struct {
|
||||
kit *kit.Kit
|
||||
}
|
||||
|
||||
func (a *agentUIAdapter) GetLoadingMessage() string {
|
||||
return a.agent.GetLoadingMessage()
|
||||
func (a *kitUIAdapter) GetLoadingMessage() string {
|
||||
return a.kit.GetLoadingMessage()
|
||||
}
|
||||
|
||||
func (a *agentUIAdapter) GetTools() []any {
|
||||
tools := a.agent.GetTools()
|
||||
result := make([]any, len(tools))
|
||||
for i, tool := range tools {
|
||||
result[i] = tool
|
||||
func (a *kitUIAdapter) GetTools() []any {
|
||||
names := a.kit.GetToolNames()
|
||||
result := make([]any, len(names))
|
||||
for i, name := range names {
|
||||
result[i] = name
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (a *agentUIAdapter) GetLoadedServerNames() []string {
|
||||
return a.agent.GetLoadedServerNames()
|
||||
func (a *kitUIAdapter) GetLoadedServerNames() []string {
|
||||
return a.kit.GetLoadedServerNames()
|
||||
}
|
||||
|
||||
func (a *agentUIAdapter) GetMCPToolCount() int {
|
||||
return a.agent.GetMCPToolCount()
|
||||
func (a *kitUIAdapter) GetMCPToolCount() int {
|
||||
return a.kit.GetMCPToolCount()
|
||||
}
|
||||
|
||||
func (a *agentUIAdapter) GetExtensionToolCount() int {
|
||||
return a.agent.GetExtensionToolCount()
|
||||
func (a *kitUIAdapter) GetExtensionToolCount() int {
|
||||
return a.kit.GetExtensionToolCount()
|
||||
}
|
||||
|
||||
// rootCmd represents the base command when called without any subcommands.
|
||||
@@ -280,11 +280,8 @@ func runKit(ctx context.Context) error {
|
||||
// ui.ExtensionCommand type used by the interactive TUI. Command names are
|
||||
// normalised to start with "/" so they integrate with the slash-command
|
||||
// autocomplete and dispatch pipeline.
|
||||
func extensionCommandsForUI(runner *extensions.Runner) []ui.ExtensionCommand {
|
||||
if runner == nil {
|
||||
return nil
|
||||
}
|
||||
defs := runner.RegisteredCommands()
|
||||
func extensionCommandsForUI(k *kit.Kit) []ui.ExtensionCommand {
|
||||
defs := k.ExtensionCommands()
|
||||
if len(defs) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -298,13 +295,143 @@ func extensionCommandsForUI(runner *extensions.Runner) []ui.ExtensionCommand {
|
||||
Name: name,
|
||||
Description: d.Description,
|
||||
Execute: func(args string) (string, error) {
|
||||
return d.Execute(args, runner.GetContext())
|
||||
return d.Execute(args, k.GetExtensionContext())
|
||||
},
|
||||
})
|
||||
}
|
||||
return cmds
|
||||
}
|
||||
|
||||
// widgetProviderForUI returns a function that converts extension widgets to
|
||||
// ui.WidgetData for the given placement. Returns nil if extensions are
|
||||
// disabled, which is safe — the UI treats a nil GetWidgets as "no widgets".
|
||||
func widgetProviderForUI(k *kit.Kit) func(string) []ui.WidgetData {
|
||||
if !k.HasExtensions() {
|
||||
return nil
|
||||
}
|
||||
return func(placement string) []ui.WidgetData {
|
||||
configs := k.GetExtensionWidgets(extensions.WidgetPlacement(placement))
|
||||
if len(configs) == 0 {
|
||||
return nil
|
||||
}
|
||||
widgets := make([]ui.WidgetData, len(configs))
|
||||
for i, c := range configs {
|
||||
widgets[i] = ui.WidgetData{
|
||||
Text: c.Content.Text,
|
||||
Markdown: c.Content.Markdown,
|
||||
BorderColor: c.Style.BorderColor,
|
||||
NoBorder: c.Style.NoBorder,
|
||||
}
|
||||
}
|
||||
return widgets
|
||||
}
|
||||
}
|
||||
|
||||
// headerProviderForUI returns a function that converts the extension header
|
||||
// to a *ui.WidgetData for the TUI. Returns nil if extensions are disabled,
|
||||
// which is safe — the UI treats a nil GetHeader as "no header".
|
||||
func headerProviderForUI(k *kit.Kit) func() *ui.WidgetData {
|
||||
if !k.HasExtensions() {
|
||||
return nil
|
||||
}
|
||||
return func() *ui.WidgetData {
|
||||
config := k.GetExtensionHeader()
|
||||
if config == nil {
|
||||
return nil
|
||||
}
|
||||
return &ui.WidgetData{
|
||||
Text: config.Content.Text,
|
||||
Markdown: config.Content.Markdown,
|
||||
BorderColor: config.Style.BorderColor,
|
||||
NoBorder: config.Style.NoBorder,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// toolRendererProviderForUI returns a function that converts extension tool
|
||||
// renderers to ui.ToolRendererData for the TUI. Returns nil if extensions are
|
||||
// disabled, which is safe — the UI treats a nil GetToolRenderer as "no
|
||||
// custom renderers".
|
||||
func toolRendererProviderForUI(k *kit.Kit) func(string) *ui.ToolRendererData {
|
||||
if !k.HasExtensions() {
|
||||
return nil
|
||||
}
|
||||
return func(toolName string) *ui.ToolRendererData {
|
||||
config := k.GetExtensionToolRenderer(toolName)
|
||||
if config == nil {
|
||||
return nil
|
||||
}
|
||||
return &ui.ToolRendererData{
|
||||
DisplayName: config.DisplayName,
|
||||
BorderColor: config.BorderColor,
|
||||
Background: config.Background,
|
||||
BodyMarkdown: config.BodyMarkdown,
|
||||
RenderHeader: config.RenderHeader,
|
||||
RenderBody: config.RenderBody,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// editorInterceptorProviderForUI returns a function that converts the
|
||||
// extension editor interceptor to a *ui.EditorInterceptor for the TUI.
|
||||
// Returns nil if extensions are disabled, which is safe — the UI treats a
|
||||
// nil GetEditorInterceptor as "no interceptor".
|
||||
func editorInterceptorProviderForUI(k *kit.Kit) func() *ui.EditorInterceptor {
|
||||
if !k.HasExtensions() {
|
||||
return nil
|
||||
}
|
||||
return func() *ui.EditorInterceptor {
|
||||
config := k.GetExtensionEditor()
|
||||
if config == nil {
|
||||
return nil
|
||||
}
|
||||
var handleKey func(string, string) ui.EditorKeyAction
|
||||
if config.HandleKey != nil {
|
||||
extHandleKey := config.HandleKey
|
||||
handleKey = func(key, text string) ui.EditorKeyAction {
|
||||
r := extHandleKey(key, text)
|
||||
return ui.EditorKeyAction{
|
||||
Type: ui.EditorKeyActionType(r.Type),
|
||||
RemappedKey: r.RemappedKey,
|
||||
SubmitText: r.SubmitText,
|
||||
}
|
||||
}
|
||||
}
|
||||
var render func(int, string) string
|
||||
if config.Render != nil {
|
||||
extRender := config.Render
|
||||
render = func(width int, defaultContent string) string {
|
||||
return extRender(width, defaultContent)
|
||||
}
|
||||
}
|
||||
return &ui.EditorInterceptor{
|
||||
HandleKey: handleKey,
|
||||
Render: render,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// footerProviderForUI returns a function that converts the extension footer
|
||||
// to a *ui.WidgetData for the TUI. Returns nil if extensions are disabled,
|
||||
// which is safe — the UI treats a nil GetFooter as "no footer".
|
||||
func footerProviderForUI(k *kit.Kit) func() *ui.WidgetData {
|
||||
if !k.HasExtensions() {
|
||||
return nil
|
||||
}
|
||||
return func() *ui.WidgetData {
|
||||
config := k.GetExtensionFooter()
|
||||
if config == nil {
|
||||
return nil
|
||||
}
|
||||
return &ui.WidgetData{
|
||||
Text: config.Content.Text,
|
||||
Markdown: config.Content.Markdown,
|
||||
BorderColor: config.Style.BorderColor,
|
||||
NoBorder: config.Style.NoBorder,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runNormalMode(ctx context.Context) error {
|
||||
// Validate flag combinations
|
||||
if quietFlag && promptFlag == "" {
|
||||
@@ -346,16 +473,18 @@ func runNormalMode(ctx context.Context) error {
|
||||
// Build Kit options from CLI flags and create the SDK instance.
|
||||
// kit.New() handles: config → skills → agent → session → extension bridge.
|
||||
kitOpts := &kit.Options{
|
||||
MCPConfig: mcpConfig,
|
||||
ShowSpinner: true,
|
||||
SpinnerFunc: spinnerFunc,
|
||||
UseBufferedLogger: true,
|
||||
Quiet: quietFlag,
|
||||
Debug: debugMode,
|
||||
NoSession: noSessionFlag,
|
||||
Continue: continueFlag,
|
||||
SessionPath: sessionPath,
|
||||
AutoCompact: autoCompactFlag,
|
||||
Quiet: quietFlag,
|
||||
Debug: debugMode,
|
||||
NoSession: noSessionFlag,
|
||||
Continue: continueFlag,
|
||||
SessionPath: sessionPath,
|
||||
AutoCompact: autoCompactFlag,
|
||||
CLI: &kit.CLIOptions{
|
||||
MCPConfig: mcpConfig,
|
||||
ShowSpinner: true,
|
||||
SpinnerFunc: spinnerFunc,
|
||||
UseBufferedLogger: true,
|
||||
},
|
||||
}
|
||||
if resumeFlag {
|
||||
// TODO: TUI session picker.
|
||||
@@ -371,27 +500,23 @@ func runNormalMode(ctx context.Context) error {
|
||||
}
|
||||
defer func() { _ = kitInstance.Close() }()
|
||||
|
||||
// Extract agent + metadata for display and app options.
|
||||
mcpAgent := kitInstance.GetAgent()
|
||||
parsedProvider, modelName, serverNames, toolNames, mcpToolCount, extensionToolCount := CollectAgentMetadata(mcpAgent, mcpConfig)
|
||||
// Extract metadata for display and app options.
|
||||
parsedProvider, modelName, serverNames, toolNames, mcpToolCount, extensionToolCount := CollectAgentMetadata(kitInstance, mcpConfig)
|
||||
|
||||
// Create CLI for non-interactive mode only.
|
||||
var cli *ui.CLI
|
||||
if promptFlag != "" {
|
||||
cli, err = SetupCLIForNonInteractive(mcpAgent)
|
||||
cli, err = SetupCLIForNonInteractive(kitInstance)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to setup CLI: %v", err)
|
||||
}
|
||||
|
||||
// Display buffered debug messages if any (non-interactive path only).
|
||||
if bl := kitInstance.GetBufferedLogger(); bl != nil && cli != nil {
|
||||
msgs := bl.GetMessages()
|
||||
if len(msgs) > 0 {
|
||||
cli.DisplayDebugMessage(strings.Join(msgs, "\n "))
|
||||
}
|
||||
if msgs := kitInstance.GetBufferedDebugMessages(); len(msgs) > 0 && cli != nil {
|
||||
cli.DisplayDebugMessage(strings.Join(msgs, "\n "))
|
||||
}
|
||||
|
||||
DisplayDebugConfig(cli, mcpAgent, mcpConfig, parsedProvider)
|
||||
DisplayDebugConfig(cli, kitInstance, mcpConfig, parsedProvider)
|
||||
}
|
||||
|
||||
// Load existing messages from resumed/continued sessions.
|
||||
@@ -402,7 +527,6 @@ func runNormalMode(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// Create the app.App instance.
|
||||
extRunner := kitInstance.GetExtRunner()
|
||||
appOpts := BuildAppOptions(mcpConfig, modelName, serverNames, toolNames)
|
||||
appOpts.Kit = kitInstance
|
||||
appOpts.TreeSession = treeSession
|
||||
@@ -423,9 +547,9 @@ func runNormalMode(ctx context.Context) error {
|
||||
defer appInstance.Close()
|
||||
|
||||
// Set up extension context and emit SessionStart.
|
||||
if extRunner != nil {
|
||||
if kitInstance.HasExtensions() {
|
||||
cwd, _ := os.Getwd()
|
||||
extRunner.SetContext(extensions.Context{
|
||||
kitInstance.SetExtensionContext(extensions.Context{
|
||||
CWD: cwd,
|
||||
Model: modelName,
|
||||
Interactive: promptFlag == "",
|
||||
@@ -434,14 +558,118 @@ func runNormalMode(ctx context.Context) error {
|
||||
PrintError: func(text string) { appInstance.PrintFromExtension("error", text) },
|
||||
PrintBlock: appInstance.PrintBlockFromExtension,
|
||||
SendMessage: func(text string) { appInstance.Run(text) },
|
||||
SetWidget: func(config extensions.WidgetConfig) {
|
||||
kitInstance.SetExtensionWidget(config)
|
||||
appInstance.NotifyWidgetUpdate()
|
||||
},
|
||||
RemoveWidget: func(id string) {
|
||||
kitInstance.RemoveExtensionWidget(id)
|
||||
appInstance.NotifyWidgetUpdate()
|
||||
},
|
||||
SetHeader: func(config extensions.HeaderFooterConfig) {
|
||||
kitInstance.SetExtensionHeader(config)
|
||||
appInstance.NotifyWidgetUpdate()
|
||||
},
|
||||
RemoveHeader: func() {
|
||||
kitInstance.RemoveExtensionHeader()
|
||||
appInstance.NotifyWidgetUpdate()
|
||||
},
|
||||
SetFooter: func(config extensions.HeaderFooterConfig) {
|
||||
kitInstance.SetExtensionFooter(config)
|
||||
appInstance.NotifyWidgetUpdate()
|
||||
},
|
||||
RemoveFooter: func() {
|
||||
kitInstance.RemoveExtensionFooter()
|
||||
appInstance.NotifyWidgetUpdate()
|
||||
},
|
||||
PromptSelect: func(config extensions.PromptSelectConfig) extensions.PromptSelectResult {
|
||||
ch := make(chan app.PromptResponse, 1)
|
||||
appInstance.SendPromptRequest(app.PromptRequestEvent{
|
||||
PromptType: "select",
|
||||
Message: config.Message,
|
||||
Options: config.Options,
|
||||
ResponseCh: ch,
|
||||
})
|
||||
resp := <-ch
|
||||
if resp.Cancelled {
|
||||
return extensions.PromptSelectResult{Cancelled: true}
|
||||
}
|
||||
return extensions.PromptSelectResult{Value: resp.Value, Index: resp.Index}
|
||||
},
|
||||
PromptConfirm: func(config extensions.PromptConfirmConfig) extensions.PromptConfirmResult {
|
||||
ch := make(chan app.PromptResponse, 1)
|
||||
def := "false"
|
||||
if config.DefaultValue {
|
||||
def = "true"
|
||||
}
|
||||
appInstance.SendPromptRequest(app.PromptRequestEvent{
|
||||
PromptType: "confirm",
|
||||
Message: config.Message,
|
||||
Default: def,
|
||||
ResponseCh: ch,
|
||||
})
|
||||
resp := <-ch
|
||||
if resp.Cancelled {
|
||||
return extensions.PromptConfirmResult{Cancelled: true}
|
||||
}
|
||||
return extensions.PromptConfirmResult{Value: resp.Confirmed}
|
||||
},
|
||||
PromptInput: func(config extensions.PromptInputConfig) extensions.PromptInputResult {
|
||||
ch := make(chan app.PromptResponse, 1)
|
||||
appInstance.SendPromptRequest(app.PromptRequestEvent{
|
||||
PromptType: "input",
|
||||
Message: config.Message,
|
||||
Placeholder: config.Placeholder,
|
||||
Default: config.Default,
|
||||
ResponseCh: ch,
|
||||
})
|
||||
resp := <-ch
|
||||
if resp.Cancelled {
|
||||
return extensions.PromptInputResult{Cancelled: true}
|
||||
}
|
||||
return extensions.PromptInputResult{Value: resp.Value}
|
||||
},
|
||||
SetEditor: func(config extensions.EditorConfig) {
|
||||
kitInstance.SetExtensionEditor(config)
|
||||
// Use a goroutine for NotifyWidgetUpdate because this may be
|
||||
// called from within an editor HandleKey callback, which runs
|
||||
// synchronously inside BubbleTea's Update(). Calling prog.Send()
|
||||
// directly from Update() deadlocks the event loop.
|
||||
go appInstance.NotifyWidgetUpdate()
|
||||
},
|
||||
ResetEditor: func() {
|
||||
kitInstance.ResetExtensionEditor()
|
||||
go appInstance.NotifyWidgetUpdate()
|
||||
},
|
||||
ShowOverlay: func(config extensions.OverlayConfig) extensions.OverlayResult {
|
||||
ch := make(chan app.OverlayResponse, 1)
|
||||
appInstance.SendOverlayRequest(app.OverlayRequestEvent{
|
||||
Title: config.Title,
|
||||
Content: config.Content.Text,
|
||||
Markdown: config.Content.Markdown,
|
||||
BorderColor: config.Style.BorderColor,
|
||||
Background: config.Style.Background,
|
||||
Width: config.Width,
|
||||
MaxHeight: config.MaxHeight,
|
||||
Anchor: string(config.Anchor),
|
||||
Actions: config.Actions,
|
||||
ResponseCh: ch,
|
||||
})
|
||||
resp := <-ch
|
||||
if resp.Cancelled {
|
||||
return extensions.OverlayResult{Cancelled: true, Index: -1}
|
||||
}
|
||||
return extensions.OverlayResult{
|
||||
Action: resp.Action,
|
||||
Index: resp.Index,
|
||||
}
|
||||
},
|
||||
})
|
||||
if extRunner.HasHandlers(extensions.SessionStart) {
|
||||
_, _ = extRunner.Emit(extensions.SessionStartEvent{})
|
||||
}
|
||||
kitInstance.EmitSessionStart()
|
||||
}
|
||||
|
||||
// Convert extension commands to UI-layer type for the interactive TUI.
|
||||
extCommands := extensionCommandsForUI(extRunner)
|
||||
extCommands := extensionCommandsForUI(kitInstance)
|
||||
|
||||
// Build context/skills display metadata for the startup banner.
|
||||
var contextPaths []string
|
||||
@@ -462,9 +690,16 @@ func runNormalMode(ctx context.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
// Build extension UI providers once (shared between both modes).
|
||||
getWidgets := widgetProviderForUI(kitInstance)
|
||||
getHeader := headerProviderForUI(kitInstance)
|
||||
getFooter := footerProviderForUI(kitInstance)
|
||||
getToolRenderer := toolRendererProviderForUI(kitInstance)
|
||||
getEditorInterceptor := editorInterceptorProviderForUI(kitInstance)
|
||||
|
||||
// Check if running in non-interactive mode
|
||||
if promptFlag != "" {
|
||||
return runNonInteractiveModeApp(ctx, appInstance, cli, promptFlag, quietFlag, noExitFlag, modelName, parsedProvider, mcpAgent.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems)
|
||||
return runNonInteractiveModeApp(ctx, appInstance, cli, promptFlag, quietFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor)
|
||||
}
|
||||
|
||||
// Quiet mode is not allowed in interactive mode
|
||||
@@ -472,7 +707,7 @@ func runNormalMode(ctx context.Context) error {
|
||||
return fmt.Errorf("--quiet flag can only be used with --prompt/-p")
|
||||
}
|
||||
|
||||
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, mcpAgent.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems)
|
||||
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor)
|
||||
}
|
||||
|
||||
// runNonInteractiveModeApp executes a single prompt via the app layer and exits,
|
||||
@@ -485,7 +720,7 @@ func runNormalMode(ctx context.Context) error {
|
||||
//
|
||||
// When --no-exit is set, after the prompt completes the interactive BubbleTea
|
||||
// TUI is started so the user can continue the conversation.
|
||||
func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui.CLI, prompt string, quiet, noExit bool, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, contextPaths []string, skillItems []ui.SkillItem) error {
|
||||
func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui.CLI, prompt string, quiet, noExit bool, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor) error {
|
||||
if quiet {
|
||||
// Quiet mode: no intermediate display, just print final response.
|
||||
if err := appInstance.RunOnce(ctx, prompt); err != nil {
|
||||
@@ -511,7 +746,7 @@ func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui
|
||||
|
||||
// If --no-exit was requested, hand off to the interactive TUI.
|
||||
if noExit {
|
||||
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems)
|
||||
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -528,7 +763,7 @@ func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui
|
||||
// 4. Calls program.Run() which blocks until the user quits (Ctrl+C or /quit).
|
||||
//
|
||||
// SetupCLI is not used for interactive mode; the TUI (AppModel) handles its own rendering.
|
||||
func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, contextPaths []string, skillItems []ui.SkillItem) error {
|
||||
func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor) error {
|
||||
// Determine terminal size; fall back gracefully.
|
||||
termWidth, termHeight, err := term.GetSize(int(os.Stdout.Fd()))
|
||||
if err != nil || termWidth == 0 {
|
||||
@@ -537,20 +772,25 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN
|
||||
}
|
||||
|
||||
appModel := ui.NewAppModel(appInstance, ui.AppModelOptions{
|
||||
CompactMode: viper.GetBool("compact"),
|
||||
ModelName: modelName,
|
||||
ProviderName: providerName,
|
||||
LoadingMessage: loadingMessage,
|
||||
Width: termWidth,
|
||||
Height: termHeight,
|
||||
ServerNames: serverNames,
|
||||
ToolNames: toolNames,
|
||||
MCPToolCount: mcpToolCount,
|
||||
ExtensionToolCount: extensionToolCount,
|
||||
UsageTracker: usageTracker,
|
||||
ExtensionCommands: extCommands,
|
||||
ContextPaths: contextPaths,
|
||||
SkillItems: skillItems,
|
||||
CompactMode: viper.GetBool("compact"),
|
||||
ModelName: modelName,
|
||||
ProviderName: providerName,
|
||||
LoadingMessage: loadingMessage,
|
||||
Width: termWidth,
|
||||
Height: termHeight,
|
||||
ServerNames: serverNames,
|
||||
ToolNames: toolNames,
|
||||
MCPToolCount: mcpToolCount,
|
||||
ExtensionToolCount: extensionToolCount,
|
||||
UsageTracker: usageTracker,
|
||||
ExtensionCommands: extCommands,
|
||||
ContextPaths: contextPaths,
|
||||
SkillItems: skillItems,
|
||||
GetWidgets: getWidgets,
|
||||
GetHeader: getHeader,
|
||||
GetFooter: getFooter,
|
||||
GetToolRenderer: getToolRenderer,
|
||||
GetEditorInterceptor: getEditorInterceptor,
|
||||
})
|
||||
|
||||
// Print startup info to stdout before Bubble Tea takes over the screen.
|
||||
|
||||
+9
-14
@@ -3,7 +3,6 @@ package cmd
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/mark3labs/kit/internal/agent"
|
||||
"github.com/mark3labs/kit/internal/app"
|
||||
"github.com/mark3labs/kit/internal/config"
|
||||
"github.com/mark3labs/kit/internal/ui"
|
||||
@@ -12,9 +11,9 @@ import (
|
||||
)
|
||||
|
||||
// CollectAgentMetadata extracts model display info and tool/server name lists
|
||||
// from the agent, used to populate app.Options and UI setup.
|
||||
// from the Kit instance, used to populate app.Options and UI setup.
|
||||
// It also returns the number of MCP tools and extension tools separately.
|
||||
func CollectAgentMetadata(mcpAgent *agent.Agent, mcpConfig *config.Config) (provider, modelName string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int) {
|
||||
func CollectAgentMetadata(k *kit.Kit, mcpConfig *config.Config) (provider, modelName string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int) {
|
||||
modelString := viper.GetString("model")
|
||||
provider, modelName, _ = kit.ParseModelString(modelString)
|
||||
if modelName == "" {
|
||||
@@ -25,13 +24,9 @@ func CollectAgentMetadata(mcpAgent *agent.Agent, mcpConfig *config.Config) (prov
|
||||
serverNames = append(serverNames, name)
|
||||
}
|
||||
|
||||
for _, tool := range mcpAgent.GetTools() {
|
||||
info := tool.Info()
|
||||
toolNames = append(toolNames, info.Name)
|
||||
}
|
||||
|
||||
mcpToolCount = mcpAgent.GetMCPToolCount()
|
||||
extensionToolCount = mcpAgent.GetExtensionToolCount()
|
||||
toolNames = k.GetToolNames()
|
||||
mcpToolCount = k.GetMCPToolCount()
|
||||
extensionToolCount = k.GetExtensionToolCount()
|
||||
|
||||
return provider, modelName, serverNames, toolNames, mcpToolCount, extensionToolCount
|
||||
}
|
||||
@@ -52,7 +47,7 @@ func BuildAppOptions(mcpConfig *config.Config, modelName string, serverNames, to
|
||||
|
||||
// DisplayDebugConfig builds and displays the debug configuration map through
|
||||
// the CLI for non-interactive mode.
|
||||
func DisplayDebugConfig(cli *ui.CLI, mcpAgent *agent.Agent, mcpConfig *config.Config, provider string) {
|
||||
func DisplayDebugConfig(cli *ui.CLI, k *kit.Kit, mcpConfig *config.Config, provider string) {
|
||||
if quietFlag || cli == nil || !viper.GetBool("debug") {
|
||||
return
|
||||
}
|
||||
@@ -89,7 +84,7 @@ func DisplayDebugConfig(cli *ui.CLI, mcpAgent *agent.Agent, mcpConfig *config.Co
|
||||
if len(mcpConfig.MCPServers) > 0 {
|
||||
mcpServers := make(map[string]any)
|
||||
loadedServerSet := make(map[string]bool)
|
||||
for _, name := range mcpAgent.GetLoadedServerNames() {
|
||||
for _, name := range k.GetLoadedServerNames() {
|
||||
loadedServerSet[name] = true
|
||||
}
|
||||
|
||||
@@ -130,8 +125,8 @@ func DisplayDebugConfig(cli *ui.CLI, mcpAgent *agent.Agent, mcpConfig *config.Co
|
||||
|
||||
// SetupCLIForNonInteractive creates the CLI display layer for non-interactive
|
||||
// mode (--prompt). Returns nil when quiet mode is active.
|
||||
func SetupCLIForNonInteractive(mcpAgent *agent.Agent) (*ui.CLI, error) {
|
||||
agentAdapter := &agentUIAdapter{agent: mcpAgent}
|
||||
func SetupCLIForNonInteractive(k *kit.Kit) (*ui.CLI, error) {
|
||||
agentAdapter := &kitUIAdapter{kit: k}
|
||||
return ui.SetupCLI(&ui.CLISetupOptions{
|
||||
Agent: agentAdapter,
|
||||
ModelString: viper.GetString("model"),
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// vimActive tracks whether the vim interceptor is installed at all.
|
||||
// normalMode tracks whether we are in normal mode (true) or insert mode (false).
|
||||
var vimActive bool
|
||||
var normalMode bool
|
||||
|
||||
// Init demonstrates the editor interceptor system. Extensions can intercept
|
||||
// key events before they reach the built-in editor and wrap the editor's
|
||||
// rendered output. This example implements a simple vim-like modal editor
|
||||
// with normal/insert mode switching.
|
||||
//
|
||||
// Slash commands:
|
||||
// - /vim — toggle vim mode on/off
|
||||
// - /vim-info — show current editor mode
|
||||
func Init(api ext.API) {
|
||||
// /vim — toggle the vim interceptor on/off.
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "vim",
|
||||
Description: "Toggle vim-like modal editing",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
if vimActive {
|
||||
// Turn off vim mode entirely.
|
||||
vimActive = false
|
||||
normalMode = false
|
||||
ctx.ResetEditor()
|
||||
return "Vim mode OFF. Default editor restored.", nil
|
||||
}
|
||||
// Turn on vim mode, start in normal mode.
|
||||
vimActive = true
|
||||
normalMode = true
|
||||
ctx.SetEditor(ext.EditorConfig{
|
||||
HandleKey: func(key string, currentText string) ext.EditorKeyAction {
|
||||
return handleVimKey(key, currentText)
|
||||
},
|
||||
Render: func(width int, defaultContent string) string {
|
||||
return renderVimMode(width, defaultContent)
|
||||
},
|
||||
})
|
||||
return "Vim mode ON (NORMAL). Press 'i' to insert, Esc to return to normal, h/j/k/l to navigate.", nil
|
||||
},
|
||||
})
|
||||
|
||||
// /vim-info — show the current editor mode.
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "vim-info",
|
||||
Description: "Show current vim mode",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
if !vimActive {
|
||||
return "Vim mode is OFF (default editor).", nil
|
||||
}
|
||||
if normalMode {
|
||||
return "Vim mode ON — NORMAL mode", nil
|
||||
}
|
||||
return "Vim mode ON — INSERT mode (Esc to return to normal)", nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// handleVimKey processes keys for both normal and insert modes.
|
||||
// The interceptor stays active in both modes so Esc can switch back.
|
||||
func handleVimKey(key string, currentText string) ext.EditorKeyAction {
|
||||
if !normalMode {
|
||||
// ── Insert mode: pass everything through except Esc ──
|
||||
if key == "esc" {
|
||||
normalMode = true
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyConsumed}
|
||||
}
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
|
||||
}
|
||||
|
||||
// ── Normal mode ──
|
||||
switch key {
|
||||
// Navigation: remap hjkl to arrow keys.
|
||||
case "h":
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "left"}
|
||||
case "j":
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "down"}
|
||||
case "k":
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "up"}
|
||||
case "l":
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "right"}
|
||||
|
||||
// Mode switching.
|
||||
case "i":
|
||||
normalMode = false
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyConsumed}
|
||||
|
||||
// Editing shortcuts.
|
||||
case "x":
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "delete"}
|
||||
case "0":
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "home"}
|
||||
case "$":
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "end"}
|
||||
|
||||
// Submission.
|
||||
case "enter":
|
||||
if strings.TrimSpace(currentText) != "" {
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeySubmit}
|
||||
}
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyConsumed}
|
||||
|
||||
// Block most printable keys in normal mode.
|
||||
default:
|
||||
// Let control sequences and special keys through (e.g., ctrl+c).
|
||||
if len(key) > 1 && key != "space" {
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
|
||||
}
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyConsumed}
|
||||
}
|
||||
}
|
||||
|
||||
// renderVimMode wraps the default editor rendering with a mode indicator.
|
||||
func renderVimMode(width int, defaultContent string) string {
|
||||
mode := "-- NORMAL --"
|
||||
if !normalMode {
|
||||
mode = "-- INSERT --"
|
||||
}
|
||||
|
||||
indicator := fmt.Sprintf(" %s", mode)
|
||||
padding := width - len(indicator)
|
||||
if padding > 0 {
|
||||
indicator += strings.Repeat(" ", padding)
|
||||
}
|
||||
|
||||
return indicator + "\n" + defaultContent
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init demonstrates the custom header/footer system. The header shows
|
||||
// project context (branch, CWD) and the footer shows a running summary
|
||||
// of agent activity. Slash commands toggle them on/off.
|
||||
func Init(api ext.API) {
|
||||
var turnCount int
|
||||
var lastResponse string
|
||||
|
||||
// Show a custom header with project context when the session starts.
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
ctx.SetHeader(ext.HeaderFooterConfig{
|
||||
Content: ext.WidgetContent{
|
||||
Text: fmt.Sprintf("Project: %s | Model: %s | %s",
|
||||
ctx.CWD, ctx.Model, time.Now().Format("Jan 2, 15:04")),
|
||||
},
|
||||
Style: ext.WidgetStyle{
|
||||
BorderColor: "#89b4fa",
|
||||
},
|
||||
})
|
||||
|
||||
ctx.SetFooter(ext.HeaderFooterConfig{
|
||||
Content: ext.WidgetContent{
|
||||
Text: "Ready | 0 turns",
|
||||
},
|
||||
Style: ext.WidgetStyle{
|
||||
BorderColor: "#a6e3a1",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Update footer after each agent turn with activity summary.
|
||||
api.OnAgentEnd(func(ae ext.AgentEndEvent, ctx ext.Context) {
|
||||
turnCount++
|
||||
lastResponse = ae.Response
|
||||
if len(lastResponse) > 60 {
|
||||
lastResponse = lastResponse[:57] + "..."
|
||||
}
|
||||
|
||||
ctx.SetFooter(ext.HeaderFooterConfig{
|
||||
Content: ext.WidgetContent{
|
||||
Text: fmt.Sprintf("Turns: %d | Last: %s | %s",
|
||||
turnCount, ae.StopReason, time.Now().Format("15:04:05")),
|
||||
},
|
||||
Style: ext.WidgetStyle{
|
||||
BorderColor: "#a6e3a1",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// /header-off — remove the custom header.
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "header-off",
|
||||
Description: "Remove the custom header",
|
||||
Execute: func(_ string, ctx ext.Context) (string, error) {
|
||||
ctx.RemoveHeader()
|
||||
return "Header removed.", nil
|
||||
},
|
||||
})
|
||||
|
||||
// /header-on — restore the custom header.
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "header-on",
|
||||
Description: "Restore the custom header",
|
||||
Execute: func(_ string, ctx ext.Context) (string, error) {
|
||||
ctx.SetHeader(ext.HeaderFooterConfig{
|
||||
Content: ext.WidgetContent{
|
||||
Text: fmt.Sprintf("Project: %s | Model: %s | %s",
|
||||
ctx.CWD, ctx.Model, time.Now().Format("Jan 2, 15:04")),
|
||||
},
|
||||
Style: ext.WidgetStyle{
|
||||
BorderColor: "#89b4fa",
|
||||
},
|
||||
})
|
||||
return "Header restored.", nil
|
||||
},
|
||||
})
|
||||
|
||||
// /footer-off — remove the custom footer.
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "footer-off",
|
||||
Description: "Remove the custom footer",
|
||||
Execute: func(_ string, ctx ext.Context) (string, error) {
|
||||
ctx.RemoveFooter()
|
||||
return "Footer removed.", nil
|
||||
},
|
||||
})
|
||||
|
||||
// /footer-on — restore the custom footer.
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "footer-on",
|
||||
Description: "Restore the custom footer",
|
||||
Execute: func(_ string, ctx ext.Context) (string, error) {
|
||||
ctx.SetFooter(ext.HeaderFooterConfig{
|
||||
Content: ext.WidgetContent{
|
||||
Text: fmt.Sprintf("Turns: %d | %s", turnCount, time.Now().Format("15:04:05")),
|
||||
},
|
||||
Style: ext.WidgetStyle{
|
||||
BorderColor: "#a6e3a1",
|
||||
},
|
||||
})
|
||||
return "Footer restored.", nil
|
||||
},
|
||||
})
|
||||
|
||||
// Clean up on shutdown.
|
||||
api.OnSessionShutdown(func(_ ext.SessionShutdownEvent, ctx ext.Context) {
|
||||
ctx.RemoveHeader()
|
||||
ctx.RemoveFooter()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: ext-expert
|
||||
description: Kit extensions — tools, events, commands, widgets, editor interceptors
|
||||
tools: read,grep,glob
|
||||
---
|
||||
You are an expert on Kit's extension system. Your job is to research and answer questions about how Kit extensions work.
|
||||
|
||||
## Key Files
|
||||
|
||||
- `internal/extensions/api.go` — Extension API surface, Context struct, all types
|
||||
- `internal/extensions/runner.go` — Event dispatch, extension registry, widget/header/footer storage
|
||||
- `internal/extensions/loader.go` — Yaegi interpreter setup, extension loading
|
||||
- `internal/extensions/symbols.go` — Yaegi symbol exports
|
||||
- `internal/extensions/events.go` — Event type definitions
|
||||
- `examples/extensions/` — Example extensions demonstrating all features
|
||||
|
||||
## Architecture
|
||||
|
||||
Kit extensions are Go files interpreted at runtime by Yaegi. Each extension exports `func Init(api ext.API)` and uses the API to register:
|
||||
|
||||
- **Event handlers**: OnSessionStart, OnToolCall, OnToolResult, OnInput, OnAgentEnd, etc.
|
||||
- **Custom tools**: ToolDef with name, description, JSON Schema parameters, Execute function
|
||||
- **Slash commands**: CommandDef with name, description, Execute function (receives Context)
|
||||
- **Tool renderers**: ToolRenderConfig with custom RenderHeader/RenderBody
|
||||
- **Widgets**: ctx.SetWidget/RemoveWidget for persistent UI elements
|
||||
- **Headers/Footers**: ctx.SetHeader/SetFooter for chrome customization
|
||||
- **Editor interceptors**: ctx.SetEditor for key interception and render wrapping
|
||||
- **Prompts/Overlays**: ctx.PromptSelect/PromptConfirm/PromptInput/ShowOverlay
|
||||
|
||||
## Critical Yaegi Limitations
|
||||
|
||||
- All function fields in structs must be anonymous closures, NOT named function references
|
||||
- No interfaces exported to extensions — only concrete structs
|
||||
- Extensions run in isolated interpreters with stdlib + os/exec access
|
||||
|
||||
When answering, cite specific file paths and line numbers. Provide concrete code examples.
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: llm-expert
|
||||
description: Kit LLM system — providers, streaming, agent loop, tool execution
|
||||
tools: read,grep,glob
|
||||
---
|
||||
You are an expert on Kit's LLM integration and agent system. Your job is to research and answer questions about how Kit communicates with language models and runs the agent loop.
|
||||
|
||||
## Key Files
|
||||
|
||||
- `internal/llm/provider.go` — Provider interface definition
|
||||
- `internal/llm/anthropic/` — Anthropic Claude provider
|
||||
- `internal/llm/openai/` — OpenAI-compatible provider (also used for Ollama)
|
||||
- `internal/llm/google/` — Google Gemini provider
|
||||
- `internal/agent/agent.go` — Agent loop: prompt -> LLM -> tool calls -> repeat
|
||||
- `internal/agent/tools.go` — Tool registry, built-in tool definitions
|
||||
- `internal/app/app.go` — App layer: RunOnce, RunOnceWithDisplay, event routing
|
||||
- `pkg/kit/kit.go` — SDK: New(), configuration, extension management
|
||||
|
||||
## Architecture
|
||||
|
||||
Kit supports multiple LLM providers through the `llm.Provider` interface. The model flag format is `provider/model-name` (e.g., `anthropic/claude-sonnet-4-5`).
|
||||
|
||||
The agent loop in `internal/agent/` follows a standard ReAct pattern:
|
||||
1. Send conversation history + system prompt to LLM
|
||||
2. LLM responds with text and/or tool calls
|
||||
3. Execute tool calls (MCP servers + extension tools)
|
||||
4. Append tool results to conversation
|
||||
5. Repeat until LLM produces a final text response (no tool calls)
|
||||
|
||||
Tool execution goes through MCP (Model Context Protocol) client-server architecture. Built-in MCP servers provide bash, file system, fetch, and todo tools.
|
||||
|
||||
The App layer (`internal/app/`) manages the lifecycle: creating the agent, routing events to the UI or CLI renderer, handling cancellation, and coordinating with extensions.
|
||||
|
||||
When answering, cite specific file paths and line numbers. Provide concrete code examples.
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: orchestrator
|
||||
description: Kit Kit orchestrator system prompt template
|
||||
---
|
||||
You are Kit Kit, an orchestrator agent with {{EXPERT_COUNT}} domain experts: {{EXPERT_NAMES}}.
|
||||
|
||||
Your role is to coordinate these experts to research Kit's codebase and then synthesize their findings into working implementations.
|
||||
|
||||
## Available Experts
|
||||
|
||||
{{EXPERT_CATALOG}}
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Analyze** the user's request to identify which domains are relevant.
|
||||
2. **Query** the relevant experts IN PARALLEL using the `query_experts` tool. Ask specific, targeted questions.
|
||||
3. **Synthesize** the expert findings into a coherent understanding.
|
||||
4. **Implement** — you are the ONLY agent that writes files. Experts are read-only researchers.
|
||||
|
||||
## Rules
|
||||
|
||||
- ALWAYS query experts before implementing. Never guess about Kit internals.
|
||||
- Ask SPECIFIC questions: "How does SetWidget update the UI?" beats "Tell me about widgets."
|
||||
- Query MULTIPLE experts in a single tool call when the task spans domains (they run in parallel).
|
||||
- If an expert's answer is insufficient, query again with a more targeted question.
|
||||
- Cite the file paths and patterns from expert responses in your implementation.
|
||||
- When writing Kit extensions, remember the Yaegi closure wrapper pattern for all function fields.
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: tui-expert
|
||||
description: Kit TUI — Bubble Tea v2 components, rendering, theming, layout
|
||||
tools: read,grep,glob
|
||||
---
|
||||
You are an expert on Kit's terminal user interface. Your job is to research and answer questions about how Kit's TUI works.
|
||||
|
||||
## Key Files
|
||||
|
||||
- `internal/ui/model.go` — AppModel root component, View(), Update(), key handling, layout
|
||||
- `internal/ui/input.go` — InputComponent wrapping textarea + autocomplete
|
||||
- `internal/ui/overlay.go` — Modal overlay dialogs
|
||||
- `internal/ui/prompt.go` — Interactive prompt overlays (select, confirm, input)
|
||||
- `internal/ui/messages.go` — MessageRenderer for streaming messages
|
||||
- `internal/ui/compact_renderer.go` — CompactRenderer for compact mode
|
||||
- `internal/ui/block_renderer.go` — renderContentBlock() with functional options
|
||||
- `internal/ui/theme.go` — Catppuccin-based theming (GetTheme)
|
||||
- `internal/ui/commands.go` — ExtensionCommand type, slash command registry
|
||||
- `internal/ui/model_test.go` — Tests with stubAppController mock
|
||||
|
||||
## Architecture
|
||||
|
||||
Kit uses Bubble Tea v2 for the TUI. The component hierarchy:
|
||||
|
||||
- **AppModel** — root component managing layout, key routing, and child components
|
||||
- **InputComponent** — text area with autocomplete popup
|
||||
- **StreamComponent** — streaming message display
|
||||
- **TreeSelectorComponent** — session/model picker
|
||||
- **promptOverlay** — interactive prompts (select, confirm, input)
|
||||
- **overlayDialog** — modal overlay dialogs
|
||||
|
||||
Layout (top to bottom): header, stream, separator, widgets-above, input, widgets-below, footer, status bar.
|
||||
|
||||
Rendering uses lipgloss for styling with the Catppuccin Mocha color palette. Content blocks use `renderContentBlock()` with functional options for border, padding, background, and alignment.
|
||||
|
||||
Extension widgets integrate via callback functions (getWidgets, getHeader, getFooter) that query the extension runner through the SDK layer, keeping the UI decoupled from extensions.
|
||||
|
||||
When answering, cite specific file paths and line numbers. Provide concrete code examples.
|
||||
@@ -0,0 +1,845 @@
|
||||
//go:build ignore
|
||||
|
||||
// Kit Kit — Meta-agent that builds Kit agents
|
||||
//
|
||||
// A team of domain-specific research experts operate IN PARALLEL to gather
|
||||
// documentation and patterns. The primary agent synthesizes their findings
|
||||
// and WRITES the actual files.
|
||||
//
|
||||
// Each expert runs as a separate `kit` subprocess with a domain-specific
|
||||
// system prompt. Experts are read-only researchers; the primary agent is
|
||||
// the only writer.
|
||||
//
|
||||
// Commands:
|
||||
//
|
||||
// /experts — list available experts and their status
|
||||
// /experts-grid N — set dashboard column count (default 3)
|
||||
//
|
||||
// Usage: kit -e examples/extensions/kit-kit.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type expertDef struct {
|
||||
Name string
|
||||
Description string
|
||||
Tools string
|
||||
System string // system prompt body
|
||||
File string
|
||||
}
|
||||
|
||||
type expertState struct {
|
||||
Def expertDef
|
||||
Status string // "idle", "researching", "done", "error"
|
||||
Question string
|
||||
Elapsed time.Duration
|
||||
LastLine string
|
||||
QueryCount int
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (s *expertState) set(status, question, lastLine string, elapsed time.Duration) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if status != "" {
|
||||
s.Status = status
|
||||
}
|
||||
if question != "" {
|
||||
s.Question = question
|
||||
}
|
||||
if lastLine != "" {
|
||||
s.LastLine = lastLine
|
||||
}
|
||||
if elapsed > 0 {
|
||||
s.Elapsed = elapsed
|
||||
}
|
||||
}
|
||||
|
||||
func (s *expertState) snapshot() (string, string, string, time.Duration, int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.Status, s.Question, s.LastLine, s.Elapsed, s.QueryCount
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Package-level state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
experts = map[string]*expertState{}
|
||||
gridCols = 3
|
||||
latestCtx ext.Context
|
||||
hasCtx bool
|
||||
kitBinary string // resolved path to kit executable
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func displayName(name string) string {
|
||||
parts := strings.Split(name, "-")
|
||||
for i, w := range parts {
|
||||
if len(w) > 0 {
|
||||
parts[i] = strings.ToUpper(w[:1]) + w[1:]
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func runeWidth(s string) int {
|
||||
return len([]rune(s))
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) <= max {
|
||||
return s
|
||||
}
|
||||
if max < 4 {
|
||||
return string(runes[:max])
|
||||
}
|
||||
return string(runes[:max-3]) + "..."
|
||||
}
|
||||
|
||||
func pad(s string, width int) string {
|
||||
w := runeWidth(s)
|
||||
if w >= width {
|
||||
return string([]rune(s)[:width])
|
||||
}
|
||||
return s + strings.Repeat(" ", width-w)
|
||||
}
|
||||
|
||||
// parseAgentFile reads a .md file with YAML-like frontmatter.
|
||||
//
|
||||
// ---
|
||||
// name: ext-expert
|
||||
// description: Extensions documentation
|
||||
// tools: read,grep,glob
|
||||
// ---
|
||||
// System prompt body here ...
|
||||
func parseAgentFile(path string) *expertDef {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
text := string(raw)
|
||||
|
||||
// Must start with "---\n"
|
||||
if !strings.HasPrefix(text, "---\n") {
|
||||
return nil
|
||||
}
|
||||
rest := text[4:]
|
||||
idx := strings.Index(rest, "\n---\n")
|
||||
if idx < 0 {
|
||||
return nil
|
||||
}
|
||||
frontmatter := rest[:idx]
|
||||
body := strings.TrimSpace(rest[idx+5:])
|
||||
|
||||
fm := map[string]string{}
|
||||
for _, line := range strings.Split(frontmatter, "\n") {
|
||||
i := strings.Index(line, ":")
|
||||
if i > 0 {
|
||||
fm[strings.TrimSpace(line[:i])] = strings.TrimSpace(line[i+1:])
|
||||
}
|
||||
}
|
||||
if fm["name"] == "" {
|
||||
return nil
|
||||
}
|
||||
return &expertDef{
|
||||
Name: fm["name"],
|
||||
Description: fm["description"],
|
||||
Tools: fm["tools"],
|
||||
System: body,
|
||||
File: path,
|
||||
}
|
||||
}
|
||||
|
||||
func loadExperts(cwd string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
experts = map[string]*expertState{}
|
||||
dir := filepath.Join(cwd, ".kit", "agents", "kit-kit")
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
|
||||
continue
|
||||
}
|
||||
if e.Name() == "orchestrator.md" {
|
||||
continue
|
||||
}
|
||||
def := parseAgentFile(filepath.Join(dir, e.Name()))
|
||||
if def == nil {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(def.Name)
|
||||
experts[key] = &expertState{
|
||||
Def: *def,
|
||||
Status: "idle",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func expertList() []*expertState {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
list := make([]*expertState, 0, len(experts))
|
||||
for _, s := range experts {
|
||||
list = append(list, s)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func expertNames() string {
|
||||
list := expertList()
|
||||
names := make([]string, len(list))
|
||||
for i, s := range list {
|
||||
names[i] = displayName(s.Def.Name)
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Widget grid rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func renderCard(s *expertState, w int) []string {
|
||||
status, question, lastLine, elapsed, queryCount := s.snapshot()
|
||||
inner := w - 2 // inside the box-drawing borders
|
||||
|
||||
// Name line
|
||||
name := truncate(displayName(s.Def.Name), inner-1)
|
||||
|
||||
// Status line
|
||||
var icon string
|
||||
switch status {
|
||||
case "idle":
|
||||
icon = "○"
|
||||
case "researching":
|
||||
icon = "◉"
|
||||
case "done":
|
||||
icon = "✓"
|
||||
default:
|
||||
icon = "✗"
|
||||
}
|
||||
statusText := icon + " " + status
|
||||
if status != "idle" {
|
||||
statusText += fmt.Sprintf(" %ds", int(elapsed.Seconds()))
|
||||
}
|
||||
if queryCount > 0 {
|
||||
statusText += fmt.Sprintf(" (%d)", queryCount)
|
||||
}
|
||||
statusText = truncate(statusText, inner-1)
|
||||
|
||||
// Work line (question or description)
|
||||
work := question
|
||||
if work == "" {
|
||||
work = s.Def.Description
|
||||
}
|
||||
work = truncate(work, inner-1)
|
||||
|
||||
// Last output line
|
||||
last := lastLine
|
||||
if last == "" {
|
||||
last = "—"
|
||||
}
|
||||
last = truncate(last, inner-1)
|
||||
|
||||
// Build card (use rune width for box-drawing alignment)
|
||||
topBar := "─ " + name + " "
|
||||
if runeWidth(topBar) < inner {
|
||||
topBar += strings.Repeat("─", inner-runeWidth(topBar))
|
||||
}
|
||||
|
||||
return []string{
|
||||
"┌" + truncate(topBar, inner) + "┐",
|
||||
"│ " + pad(statusText, inner-1) + "│",
|
||||
"│ " + pad(work, inner-1) + "│",
|
||||
"│ " + pad(last, inner-1) + "│",
|
||||
"└" + strings.Repeat("─", inner) + "┘",
|
||||
}
|
||||
}
|
||||
|
||||
func buildGrid() string {
|
||||
list := expertList()
|
||||
if len(list) == 0 {
|
||||
return "No experts found. Add agent .md files to .kit/agents/kit-kit/"
|
||||
}
|
||||
|
||||
cols := gridCols
|
||||
if cols > len(list) {
|
||||
cols = len(list)
|
||||
}
|
||||
|
||||
// Card width: aim for ~28 chars per card
|
||||
cardWidth := 28
|
||||
gap := 1
|
||||
|
||||
var lines []string
|
||||
for i := 0; i < len(list); i += cols {
|
||||
end := i + cols
|
||||
if end > len(list) {
|
||||
end = len(list)
|
||||
}
|
||||
row := list[i:end]
|
||||
|
||||
// Render each card in this row
|
||||
cards := make([][]string, len(row))
|
||||
maxHeight := 0
|
||||
for j, s := range row {
|
||||
cards[j] = renderCard(s, cardWidth)
|
||||
if len(cards[j]) > maxHeight {
|
||||
maxHeight = len(cards[j])
|
||||
}
|
||||
}
|
||||
|
||||
// Merge columns line by line
|
||||
for line := 0; line < maxHeight; line++ {
|
||||
var parts []string
|
||||
for _, card := range cards {
|
||||
if line < len(card) {
|
||||
parts = append(parts, card[line])
|
||||
} else {
|
||||
parts = append(parts, strings.Repeat(" ", cardWidth))
|
||||
}
|
||||
}
|
||||
lines = append(lines, strings.Join(parts, strings.Repeat(" ", gap)))
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func updateWidget() {
|
||||
mu.Lock()
|
||||
ctx := latestCtx
|
||||
ok := hasCtx
|
||||
mu.Unlock()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx.SetWidget(ext.WidgetConfig{
|
||||
ID: "kit-kit:grid",
|
||||
Placement: ext.WidgetAbove,
|
||||
Content: ext.WidgetContent{
|
||||
Text: buildGrid(),
|
||||
},
|
||||
Style: ext.WidgetStyle{
|
||||
NoBorder: true,
|
||||
BorderColor: "",
|
||||
},
|
||||
Priority: 10,
|
||||
})
|
||||
}
|
||||
|
||||
func updateFooter() {
|
||||
mu.Lock()
|
||||
ctx := latestCtx
|
||||
ok := hasCtx
|
||||
mu.Unlock()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
list := expertList()
|
||||
active := 0
|
||||
done := 0
|
||||
for _, s := range list {
|
||||
st, _, _, _, _ := s.snapshot()
|
||||
switch st {
|
||||
case "researching":
|
||||
active++
|
||||
case "done":
|
||||
done++
|
||||
}
|
||||
}
|
||||
|
||||
var mid string
|
||||
if active > 0 {
|
||||
mid = fmt.Sprintf(" ◉ %d researching", active)
|
||||
} else if done > 0 {
|
||||
mid = fmt.Sprintf(" ✓ %d done", done)
|
||||
}
|
||||
|
||||
text := fmt.Sprintf("%s | Kit Kit%s", ctx.Model, mid)
|
||||
|
||||
ctx.SetFooter(ext.HeaderFooterConfig{
|
||||
Content: ext.WidgetContent{Text: text},
|
||||
Style: ext.WidgetStyle{BorderColor: "#89b4fa"},
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Kit binary resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func findKitBinary() string {
|
||||
// Try the current process executable first.
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
if _, err := os.Stat(exe); err == nil {
|
||||
return exe
|
||||
}
|
||||
}
|
||||
// Fall back to PATH lookup.
|
||||
if p, err := exec.LookPath("kit"); err == nil {
|
||||
return p
|
||||
}
|
||||
return "kit"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Expert query (subprocess)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func queryExpert(name, question string) (output string, exitCode int, elapsed time.Duration) {
|
||||
mu.Lock()
|
||||
state, ok := experts[strings.ToLower(name)]
|
||||
mu.Unlock()
|
||||
if !ok {
|
||||
return fmt.Sprintf("Expert %q not found.", name), 1, 0
|
||||
}
|
||||
|
||||
// Mark as researching.
|
||||
state.mu.Lock()
|
||||
if state.Status == "researching" {
|
||||
state.mu.Unlock()
|
||||
return fmt.Sprintf("Expert %q is already researching.", displayName(name)), 1, 0
|
||||
}
|
||||
state.Status = "researching"
|
||||
state.Question = question
|
||||
state.Elapsed = 0
|
||||
state.LastLine = ""
|
||||
state.QueryCount++
|
||||
state.mu.Unlock()
|
||||
updateWidget()
|
||||
|
||||
start := time.Now()
|
||||
|
||||
// Timer goroutine: update widget every second while researching.
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
state.set("", "", "", time.Since(start))
|
||||
updateWidget()
|
||||
updateFooter()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Write system prompt to temp file.
|
||||
tmpFile, err := os.CreateTemp("", "kit-kit-*.txt")
|
||||
if err != nil {
|
||||
close(done)
|
||||
state.set("error", "", "temp file error: "+err.Error(), time.Since(start))
|
||||
updateWidget()
|
||||
updateFooter()
|
||||
return "Error creating temp file: " + err.Error(), 1, time.Since(start)
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
|
||||
if _, err := tmpFile.WriteString(state.Def.System); err != nil {
|
||||
tmpFile.Close()
|
||||
close(done)
|
||||
state.set("error", "", "write error: "+err.Error(), time.Since(start))
|
||||
updateWidget()
|
||||
updateFooter()
|
||||
return "Error writing system prompt: " + err.Error(), 1, time.Since(start)
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
// Build subprocess arguments. Don't pass --model; the subprocess
|
||||
// inherits the same config/env and will use the same default.
|
||||
args := []string{
|
||||
"--prompt", question,
|
||||
"--quiet",
|
||||
"--no-session",
|
||||
"--no-extensions",
|
||||
"--system-prompt", tmpFile.Name(),
|
||||
}
|
||||
|
||||
cmd := exec.Command(kitBinary, args...)
|
||||
cmd.Env = os.Environ()
|
||||
|
||||
outBytes, err := cmd.CombinedOutput()
|
||||
close(done)
|
||||
elapsed = time.Since(start)
|
||||
result := strings.TrimSpace(string(outBytes))
|
||||
|
||||
if err != nil {
|
||||
// Extract a single-line summary for the card (no newlines).
|
||||
errLine := result
|
||||
if idx := strings.Index(errLine, "\n"); idx >= 0 {
|
||||
errLine = errLine[:idx]
|
||||
}
|
||||
state.set("error", "", truncate(strings.TrimSpace(errLine), 80), elapsed)
|
||||
updateWidget()
|
||||
updateFooter()
|
||||
code := 1
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
code = exitErr.ExitCode()
|
||||
}
|
||||
return result, code, elapsed
|
||||
}
|
||||
|
||||
// Success — extract last non-empty line for the card.
|
||||
lines := strings.Split(result, "\n")
|
||||
var lastLine string
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
if strings.TrimSpace(lines[i]) != "" {
|
||||
lastLine = lines[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
state.set("done", "", truncate(lastLine, 60), elapsed)
|
||||
updateWidget()
|
||||
updateFooter()
|
||||
|
||||
return result, 0, elapsed
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Orchestrator system prompt
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func buildOrchestratorPrompt(cwd string) string {
|
||||
orchPath := filepath.Join(cwd, ".kit", "agents", "kit-kit", "orchestrator.md")
|
||||
raw, err := os.ReadFile(orchPath)
|
||||
if err != nil {
|
||||
// Fallback: generate a basic orchestrator prompt.
|
||||
return buildDefaultOrchestratorPrompt()
|
||||
}
|
||||
|
||||
text := string(raw)
|
||||
// Strip frontmatter if present.
|
||||
if strings.HasPrefix(text, "---\n") {
|
||||
if idx := strings.Index(text[4:], "\n---\n"); idx >= 0 {
|
||||
text = strings.TrimSpace(text[4+idx+5:])
|
||||
}
|
||||
}
|
||||
|
||||
list := expertList()
|
||||
catalog := buildExpertCatalog(list)
|
||||
names := make([]string, len(list))
|
||||
for i, s := range list {
|
||||
names[i] = displayName(s.Def.Name)
|
||||
}
|
||||
|
||||
text = strings.ReplaceAll(text, "{{EXPERT_COUNT}}", fmt.Sprintf("%d", len(list)))
|
||||
text = strings.ReplaceAll(text, "{{EXPERT_NAMES}}", strings.Join(names, ", "))
|
||||
text = strings.ReplaceAll(text, "{{EXPERT_CATALOG}}", catalog)
|
||||
return text
|
||||
}
|
||||
|
||||
func buildExpertCatalog(list []*expertState) string {
|
||||
var sb strings.Builder
|
||||
for _, s := range list {
|
||||
fmt.Fprintf(&sb, "### %s\n", displayName(s.Def.Name))
|
||||
fmt.Fprintf(&sb, "**Query as:** `%s`\n", s.Def.Name)
|
||||
fmt.Fprintf(&sb, "%s\n\n", s.Def.Description)
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func buildDefaultOrchestratorPrompt() string {
|
||||
list := expertList()
|
||||
names := make([]string, len(list))
|
||||
for i, s := range list {
|
||||
names[i] = displayName(s.Def.Name)
|
||||
}
|
||||
catalog := buildExpertCatalog(list)
|
||||
|
||||
return fmt.Sprintf(`You are Kit Kit, an orchestrator agent with %d domain experts: %s.
|
||||
|
||||
Use the query_experts tool to consult experts IN PARALLEL before writing code.
|
||||
Always query multiple experts at once when the task spans multiple domains.
|
||||
|
||||
## Available Experts
|
||||
|
||||
%s
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Analyze the user's request to identify which domains are relevant.
|
||||
2. Use query_experts to ask specific questions of the relevant experts.
|
||||
3. Synthesize the expert findings into a coherent implementation.
|
||||
4. Write the actual code/files — you are the only agent that writes.
|
||||
|
||||
## Rules
|
||||
|
||||
- ALWAYS query experts before implementing. Never guess.
|
||||
- Ask SPECIFIC questions. "How does X work?" is better than "Tell me about X".
|
||||
- Query multiple experts in a single call when possible (they run in parallel).
|
||||
- If an expert returns insufficient info, query again with a more specific question.
|
||||
`, len(list), strings.Join(names, ", "), catalog)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Init
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func Init(api ext.API) {
|
||||
kitBinary = findKitBinary()
|
||||
|
||||
// ── Session Start: load experts, show grid ──
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
mu.Lock()
|
||||
latestCtx = ctx
|
||||
hasCtx = true
|
||||
mu.Unlock()
|
||||
|
||||
loadExperts(ctx.CWD)
|
||||
updateWidget()
|
||||
updateFooter()
|
||||
|
||||
names := expertNames()
|
||||
n := len(expertList())
|
||||
if n > 0 {
|
||||
ctx.PrintInfo(fmt.Sprintf(
|
||||
"Kit Kit loaded — %d experts: %s\n\n"+
|
||||
"/experts List experts and status\n"+
|
||||
"/experts-grid N Set grid columns (1-5)\n\n"+
|
||||
"Ask me to build any Kit component!",
|
||||
n, names))
|
||||
} else {
|
||||
ctx.PrintInfo(
|
||||
"Kit Kit loaded — no experts found.\n\n" +
|
||||
"Add agent .md files to .kit/agents/kit-kit/ to get started.\n" +
|
||||
"See examples/extensions/kit-kit-agents/ for samples.")
|
||||
}
|
||||
})
|
||||
|
||||
// ── Before Agent Start: inject orchestrator system prompt ──
|
||||
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
|
||||
mu.Lock()
|
||||
latestCtx = ctx
|
||||
mu.Unlock()
|
||||
|
||||
prompt := buildOrchestratorPrompt(ctx.CWD)
|
||||
return &ext.BeforeAgentStartResult{SystemPrompt: &prompt}
|
||||
})
|
||||
|
||||
// ── Agent End: update footer ──
|
||||
api.OnAgentEnd(func(_ ext.AgentEndEvent, ctx ext.Context) {
|
||||
mu.Lock()
|
||||
latestCtx = ctx
|
||||
mu.Unlock()
|
||||
updateFooter()
|
||||
})
|
||||
|
||||
// ── Session Shutdown: cleanup ──
|
||||
api.OnSessionShutdown(func(_ ext.SessionShutdownEvent, ctx ext.Context) {
|
||||
ctx.RemoveWidget("kit-kit:grid")
|
||||
ctx.RemoveFooter()
|
||||
})
|
||||
|
||||
// ── Tool: query_experts ──
|
||||
api.RegisterTool(ext.ToolDef{
|
||||
Name: "query_experts",
|
||||
Description: `Query one or more Kit domain experts IN PARALLEL. All experts run simultaneously as concurrent subprocesses.
|
||||
|
||||
Pass an array of queries — each with an expert name and a specific question. All experts start at the same time and their results are returned together.
|
||||
|
||||
Available experts are loaded from .kit/agents/kit-kit/*.md at session start. The default set includes:
|
||||
- ext-expert: Kit extensions — tools, events, commands, widgets, editor interceptors
|
||||
- tui-expert: Kit TUI — Bubble Tea v2 components, rendering, theming, layout
|
||||
- llm-expert: Kit LLM system — providers, streaming, agent loop, tool execution
|
||||
|
||||
Ask specific questions about what you need to BUILD. Each expert will return documentation excerpts, code patterns, and implementation guidance.`,
|
||||
Parameters: `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"queries": {
|
||||
"type": "array",
|
||||
"description": "Array of expert queries to run in parallel",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expert": {
|
||||
"type": "string",
|
||||
"description": "Expert name (e.g. ext-expert, tui-expert, llm-expert)"
|
||||
},
|
||||
"question": {
|
||||
"type": "string",
|
||||
"description": "Specific question about what you need to build"
|
||||
}
|
||||
},
|
||||
"required": ["expert", "question"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["queries"]
|
||||
}`,
|
||||
Execute: func(input string) (string, error) {
|
||||
var params struct {
|
||||
Queries []struct {
|
||||
Expert string `json:"expert"`
|
||||
Question string `json:"question"`
|
||||
} `json:"queries"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(input), ¶ms); err != nil {
|
||||
return "", fmt.Errorf("invalid parameters: %w", err)
|
||||
}
|
||||
if len(params.Queries) == 0 {
|
||||
return "No queries provided.", nil
|
||||
}
|
||||
|
||||
// Launch all experts in parallel.
|
||||
type result struct {
|
||||
Expert string
|
||||
Question string
|
||||
Output string
|
||||
ExitCode int
|
||||
Elapsed time.Duration
|
||||
}
|
||||
results := make([]result, len(params.Queries))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i, q := range params.Queries {
|
||||
wg.Add(1)
|
||||
go func(idx int, expert, question string) {
|
||||
defer wg.Done()
|
||||
out, code, elapsed := queryExpert(expert, question)
|
||||
results[idx] = result{
|
||||
Expert: expert,
|
||||
Question: question,
|
||||
Output: out,
|
||||
ExitCode: code,
|
||||
Elapsed: elapsed,
|
||||
}
|
||||
}(i, q.Expert, q.Question)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Build combined response.
|
||||
var sb strings.Builder
|
||||
for _, r := range results {
|
||||
icon := "✓"
|
||||
if r.ExitCode != 0 {
|
||||
icon = "✗"
|
||||
}
|
||||
fmt.Fprintf(&sb, "## [%s] %s (%ds)\n\n",
|
||||
icon, displayName(r.Expert), int(r.Elapsed.Seconds()))
|
||||
|
||||
out := r.Output
|
||||
if len(out) > 12000 {
|
||||
out = out[:12000] + "\n\n... [truncated — ask follow-up for more]"
|
||||
}
|
||||
sb.WriteString(out)
|
||||
sb.WriteString("\n\n---\n\n")
|
||||
}
|
||||
return sb.String(), nil
|
||||
},
|
||||
})
|
||||
|
||||
// ── Tool Renderer: query_experts ──
|
||||
api.RegisterToolRenderer(ext.ToolRenderConfig{
|
||||
ToolName: "query_experts",
|
||||
DisplayName: "Query Experts",
|
||||
BorderColor: "#89b4fa",
|
||||
RenderHeader: func(toolArgs string, width int) string {
|
||||
var args struct {
|
||||
Queries []struct {
|
||||
Expert string `json:"expert"`
|
||||
} `json:"queries"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(toolArgs), &args); err != nil {
|
||||
return ""
|
||||
}
|
||||
names := make([]string, len(args.Queries))
|
||||
for i, q := range args.Queries {
|
||||
names[i] = displayName(q.Expert)
|
||||
}
|
||||
header := fmt.Sprintf("%d experts in parallel: %s",
|
||||
len(args.Queries), strings.Join(names, ", "))
|
||||
return truncate(header, width)
|
||||
},
|
||||
RenderBody: func(toolResult string, isError bool, width int) string {
|
||||
if isError {
|
||||
return "" // fall back to default
|
||||
}
|
||||
// Show compact summary: extract ## headers with status
|
||||
var lines []string
|
||||
for _, line := range strings.Split(toolResult, "\n") {
|
||||
if strings.HasPrefix(line, "## [") {
|
||||
lines = append(lines, line[3:]) // strip "## "
|
||||
}
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(lines, " · ")
|
||||
},
|
||||
})
|
||||
|
||||
// ── Command: /experts ──
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "experts",
|
||||
Description: "List available Kit Kit experts and their status",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
mu.Lock()
|
||||
latestCtx = ctx
|
||||
mu.Unlock()
|
||||
|
||||
list := expertList()
|
||||
if len(list) == 0 {
|
||||
return "No experts loaded. Add agent .md files to .kit/agents/kit-kit/", nil
|
||||
}
|
||||
var sb strings.Builder
|
||||
for _, s := range list {
|
||||
status, _, _, _, qc := s.snapshot()
|
||||
fmt.Fprintf(&sb, "%s (%s, queries: %d): %s\n",
|
||||
displayName(s.Def.Name), status, qc, s.Def.Description)
|
||||
}
|
||||
return sb.String(), nil
|
||||
},
|
||||
})
|
||||
|
||||
// ── Command: /experts-grid ──
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "experts-grid",
|
||||
Description: "Set expert grid columns: /experts-grid <1-5>",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
mu.Lock()
|
||||
latestCtx = ctx
|
||||
mu.Unlock()
|
||||
|
||||
args = strings.TrimSpace(args)
|
||||
n := 0
|
||||
if _, err := fmt.Sscanf(args, "%d", &n); err != nil || n < 1 || n > 5 {
|
||||
return "Usage: /experts-grid <1-5>", nil
|
||||
}
|
||||
mu.Lock()
|
||||
gridCols = n
|
||||
mu.Unlock()
|
||||
updateWidget()
|
||||
return fmt.Sprintf("Grid set to %d columns.", n), nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init demonstrates the overlay dialog system. Extensions can show modal
|
||||
// overlay dialogs that block until the user dismisses them or selects an
|
||||
// action. Four slash commands illustrate different overlay use cases.
|
||||
func Init(api ext.API) {
|
||||
// /overlay-info — simple information dialog (no actions, dismissed with Enter or ESC).
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "overlay-info",
|
||||
Description: "Show an info overlay dialog",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
content := "This is a simple informational overlay.\n\n" +
|
||||
"Overlays are modal dialogs that appear over the TUI.\n" +
|
||||
"They can display plain text or markdown content.\n\n" +
|
||||
"Press Enter or ESC to dismiss."
|
||||
|
||||
result := ctx.ShowOverlay(ext.OverlayConfig{
|
||||
Title: "Information",
|
||||
Content: ext.WidgetContent{Text: content},
|
||||
Style: ext.OverlayStyle{BorderColor: "#89b4fa"},
|
||||
})
|
||||
|
||||
if result.Cancelled {
|
||||
return "Info dialog cancelled.", nil
|
||||
}
|
||||
return "Info dialog dismissed.", nil
|
||||
},
|
||||
})
|
||||
|
||||
// /overlay-actions — overlay with action buttons.
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "overlay-actions",
|
||||
Description: "Show an overlay with action buttons",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
result := ctx.ShowOverlay(ext.OverlayConfig{
|
||||
Title: "Deploy to Production?",
|
||||
Content: ext.WidgetContent{
|
||||
Text: "You are about to deploy the following changes:\n\n" +
|
||||
" - Updated API handlers (3 files)\n" +
|
||||
" - New database migration (v42)\n" +
|
||||
" - Config change: increased rate limit\n\n" +
|
||||
"All tests are passing. Last deploy: 2 hours ago.",
|
||||
},
|
||||
Style: ext.OverlayStyle{BorderColor: "#f38ba8"},
|
||||
Width: 65,
|
||||
Actions: []string{"Deploy", "Cancel", "Show Diff"},
|
||||
})
|
||||
|
||||
if result.Cancelled {
|
||||
return "Deployment cancelled (ESC).", nil
|
||||
}
|
||||
return fmt.Sprintf("Selected action: %q (index %d)", result.Action, result.Index), nil
|
||||
},
|
||||
})
|
||||
|
||||
// /overlay-markdown — overlay with markdown content.
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "overlay-md",
|
||||
Description: "Show an overlay with markdown content",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
md := "## Build Report\n\n" +
|
||||
"| Component | Status | Duration |\n" +
|
||||
"|-----------|--------|----------|\n" +
|
||||
"| Frontend | Pass | 12.3s |\n" +
|
||||
"| Backend | Pass | 8.7s |\n" +
|
||||
"| E2E Tests | Pass | 45.1s |\n\n" +
|
||||
"**Total time:** 66.1s\n\n" +
|
||||
"All checks passed. Ready to merge."
|
||||
|
||||
result := ctx.ShowOverlay(ext.OverlayConfig{
|
||||
Title: "Build Report",
|
||||
Content: ext.WidgetContent{Text: md, Markdown: true},
|
||||
Style: ext.OverlayStyle{BorderColor: "#a6e3a1"},
|
||||
Width: 70,
|
||||
Actions: []string{"Merge", "Close"},
|
||||
})
|
||||
|
||||
if result.Cancelled {
|
||||
return "Build report closed.", nil
|
||||
}
|
||||
return fmt.Sprintf("Build report action: %q", result.Action), nil
|
||||
},
|
||||
})
|
||||
|
||||
// /overlay-scroll — overlay with long scrollable content.
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "overlay-scroll",
|
||||
Description: "Show an overlay with scrollable content",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
var lines []string
|
||||
lines = append(lines, "This overlay has a lot of content to demonstrate scrolling.")
|
||||
lines = append(lines, "Use j/k or arrow keys to scroll through the content.")
|
||||
lines = append(lines, "")
|
||||
for i := 1; i <= 50; i++ {
|
||||
lines = append(lines, fmt.Sprintf(" Line %02d: The quick brown fox jumps over the lazy dog.", i))
|
||||
}
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, "End of content. Press Enter to dismiss or ESC to cancel.")
|
||||
|
||||
result := ctx.ShowOverlay(ext.OverlayConfig{
|
||||
Title: "Log Output (50 lines)",
|
||||
Content: ext.WidgetContent{Text: strings.Join(lines, "\n")},
|
||||
Style: ext.OverlayStyle{BorderColor: "#fab387"},
|
||||
MaxHeight: 20,
|
||||
Actions: []string{"OK", "Copy to Clipboard"},
|
||||
})
|
||||
|
||||
if result.Cancelled {
|
||||
return "Log viewer cancelled.", nil
|
||||
}
|
||||
return fmt.Sprintf("Log viewer action: %q", result.Action), nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init demonstrates the interactive prompt system. It registers three slash
|
||||
// commands that show each prompt type (select, confirm, input), plus a
|
||||
// combined workflow command that chains prompts together.
|
||||
func Init(api ext.API) {
|
||||
|
||||
// /demo-select — shows a selection list.
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "demo-select",
|
||||
Description: "Demo: pick from a list",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
result := ctx.PromptSelect(ext.PromptSelectConfig{
|
||||
Message: "Choose your deployment target:",
|
||||
Options: []string{"local", "staging", "production"},
|
||||
})
|
||||
if result.Cancelled {
|
||||
return "Selection cancelled.", nil
|
||||
}
|
||||
return fmt.Sprintf("Selected: %s (index %d)", result.Value, result.Index), nil
|
||||
},
|
||||
})
|
||||
|
||||
// /demo-confirm — shows a yes/no confirmation.
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "demo-confirm",
|
||||
Description: "Demo: yes/no confirmation",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
|
||||
Message: "Are you sure you want to deploy?",
|
||||
DefaultValue: false,
|
||||
})
|
||||
if result.Cancelled {
|
||||
return "Confirmation cancelled.", nil
|
||||
}
|
||||
if result.Value {
|
||||
return "Confirmed! Deploying...", nil
|
||||
}
|
||||
return "Declined. Deployment aborted.", nil
|
||||
},
|
||||
})
|
||||
|
||||
// /demo-input — shows a text input.
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "demo-input",
|
||||
Description: "Demo: free-form text input",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
result := ctx.PromptInput(ext.PromptInputConfig{
|
||||
Message: "Enter the release tag:",
|
||||
Placeholder: "v1.0.0",
|
||||
})
|
||||
if result.Cancelled {
|
||||
return "Input cancelled.", nil
|
||||
}
|
||||
return fmt.Sprintf("Release tag: %s", result.Value), nil
|
||||
},
|
||||
})
|
||||
|
||||
// /demo-workflow — chains multiple prompts into a workflow.
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "demo-workflow",
|
||||
Description: "Demo: chained prompt workflow",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
// Step 1: select environment
|
||||
env := ctx.PromptSelect(ext.PromptSelectConfig{
|
||||
Message: "Step 1/3: Select environment:",
|
||||
Options: []string{"development", "staging", "production"},
|
||||
})
|
||||
if env.Cancelled {
|
||||
return "Workflow cancelled at step 1.", nil
|
||||
}
|
||||
|
||||
// Step 2: enter version tag
|
||||
tag := ctx.PromptInput(ext.PromptInputConfig{
|
||||
Message: "Step 2/3: Enter the version tag:",
|
||||
Placeholder: "v1.0.0",
|
||||
})
|
||||
if tag.Cancelled {
|
||||
return "Workflow cancelled at step 2.", nil
|
||||
}
|
||||
|
||||
// Step 3: confirm
|
||||
confirm := ctx.PromptConfirm(ext.PromptConfirmConfig{
|
||||
Message: fmt.Sprintf(
|
||||
"Step 3/3: Deploy %s to %s?",
|
||||
tag.Value, env.Value),
|
||||
DefaultValue: false,
|
||||
})
|
||||
if confirm.Cancelled {
|
||||
return "Workflow cancelled at step 3.", nil
|
||||
}
|
||||
if !confirm.Value {
|
||||
return "Deployment declined.", nil
|
||||
}
|
||||
|
||||
var summary strings.Builder
|
||||
summary.WriteString("Deployment summary:\n")
|
||||
fmt.Fprintf(&summary, " Environment: %s\n", env.Value)
|
||||
fmt.Fprintf(&summary, " Version: %s\n", tag.Value)
|
||||
summary.WriteString(" Status: initiated")
|
||||
return summary.String(), nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,792 @@
|
||||
//go:build ignore
|
||||
|
||||
// Subagent Widget — /sub, /subclear, /subrm, /subcont commands with live widgets
|
||||
//
|
||||
// Each /sub spawns a background Kit subagent as a subprocess with its own
|
||||
// live widget showing status, task, elapsed time, and last output line.
|
||||
// /subcont continues a finished subagent by passing conversation history.
|
||||
//
|
||||
// Commands:
|
||||
//
|
||||
// /sub <task> — spawn a new subagent
|
||||
// /subcont <id> <prompt> — continue subagent #<id>'s conversation
|
||||
// /subrm <id> — remove subagent #<id> widget
|
||||
// /subclear — clear all subagent widgets
|
||||
//
|
||||
// The LLM can also use tools: subagent_create, subagent_continue,
|
||||
// subagent_remove, subagent_list.
|
||||
//
|
||||
// Ported from https://github.com/disler/pi-vs-claude-code extensions/subagent-widget.ts
|
||||
//
|
||||
// Usage: kit -e examples/extensions/subagent-widget.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type subState struct {
|
||||
ID int
|
||||
Status string // "running", "done", "error"
|
||||
Task string
|
||||
Chunks []string // accumulated output chunks
|
||||
Elapsed time.Duration
|
||||
TurnCount int
|
||||
History string // conversation history for /subcont
|
||||
Proc *os.Process // active process for killing
|
||||
Removed bool // set when /subrm or /subclear removes this agent
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (s *subState) appendChunk(chunk string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.Chunks = append(s.Chunks, chunk)
|
||||
}
|
||||
|
||||
func (s *subState) setElapsed(d time.Duration) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.Elapsed = d
|
||||
}
|
||||
|
||||
func (s *subState) setProc(p *os.Process) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.Proc = p
|
||||
}
|
||||
|
||||
func (s *subState) snapshot() (int, string, string, string, time.Duration, int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
fullText := strings.Join(s.Chunks, "")
|
||||
return s.ID, s.Status, s.Task, fullText, s.Elapsed, s.TurnCount
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Package-level state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
latestCtx ext.Context
|
||||
hasCtx bool
|
||||
agents = map[int]*subState{}
|
||||
nextID = 1
|
||||
kitBinary string
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func findKitBinary() string {
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
if _, err := os.Stat(exe); err == nil {
|
||||
return exe
|
||||
}
|
||||
}
|
||||
if p, err := exec.LookPath("kit"); err == nil {
|
||||
return p
|
||||
}
|
||||
return "kit"
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) <= max {
|
||||
return s
|
||||
}
|
||||
if max < 4 {
|
||||
return string(runes[:max])
|
||||
}
|
||||
return string(runes[:max-3]) + "..."
|
||||
}
|
||||
|
||||
func lastNonEmptyLine(text string) string {
|
||||
lines := strings.Split(text, "\n")
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
trimmed := strings.TrimSpace(lines[i])
|
||||
if trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Widget rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func updateWidgets() {
|
||||
mu.Lock()
|
||||
ctx := latestCtx
|
||||
ok := hasCtx
|
||||
agentsCopy := make([]*subState, 0, len(agents))
|
||||
for _, s := range agents {
|
||||
agentsCopy = append(agentsCopy, s)
|
||||
}
|
||||
mu.Unlock()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
for _, state := range agentsCopy {
|
||||
id, status, task, fullText, elapsed, turnCount := state.snapshot()
|
||||
|
||||
var icon, color string
|
||||
switch status {
|
||||
case "running":
|
||||
icon = "●"
|
||||
color = "#89b4fa" // blue
|
||||
case "done":
|
||||
icon = "✓"
|
||||
color = "#a6e3a1" // green
|
||||
default:
|
||||
icon = "✗"
|
||||
color = "#f38ba8" // red
|
||||
}
|
||||
|
||||
taskPreview := truncate(task, 40)
|
||||
|
||||
turnLabel := ""
|
||||
if turnCount > 1 {
|
||||
turnLabel = fmt.Sprintf(" · Turn %d", turnCount)
|
||||
}
|
||||
|
||||
header := fmt.Sprintf("%s Subagent #%d%s %s (%ds)",
|
||||
icon, id, turnLabel, taskPreview, int(elapsed.Seconds()))
|
||||
|
||||
lastLine := truncate(lastNonEmptyLine(fullText), 80)
|
||||
|
||||
text := header
|
||||
if lastLine != "" {
|
||||
text += "\n " + lastLine
|
||||
}
|
||||
|
||||
ctx.SetWidget(ext.WidgetConfig{
|
||||
ID: fmt.Sprintf("subagent:%d", id),
|
||||
Placement: ext.WidgetAbove,
|
||||
Content: ext.WidgetContent{Text: text},
|
||||
Style: ext.WidgetStyle{BorderColor: color},
|
||||
Priority: id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subprocess spawning
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func spawnAgent(state *subState) {
|
||||
prompt := state.Task
|
||||
|
||||
state.mu.Lock()
|
||||
history := state.History
|
||||
state.mu.Unlock()
|
||||
|
||||
if history != "" {
|
||||
prompt = "Previous conversation:\n" + history + "\n\nNew instruction: " + state.Task
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"--prompt", prompt,
|
||||
"--quiet",
|
||||
"--no-session",
|
||||
"--no-extensions",
|
||||
}
|
||||
|
||||
cmd := exec.Command(kitBinary, args...)
|
||||
cmd.Env = os.Environ()
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
state.mu.Lock()
|
||||
state.Status = "error"
|
||||
state.Chunks = append(state.Chunks, "Pipe error: "+err.Error())
|
||||
state.mu.Unlock()
|
||||
updateWidgets()
|
||||
return
|
||||
}
|
||||
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
state.mu.Lock()
|
||||
state.Status = "error"
|
||||
state.Chunks = append(state.Chunks, "Pipe error: "+err.Error())
|
||||
state.mu.Unlock()
|
||||
updateWidgets()
|
||||
return
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
if err := cmd.Start(); err != nil {
|
||||
state.mu.Lock()
|
||||
state.Status = "error"
|
||||
state.Chunks = append(state.Chunks, "Start error: "+err.Error())
|
||||
state.mu.Unlock()
|
||||
updateWidgets()
|
||||
return
|
||||
}
|
||||
|
||||
state.setProc(cmd.Process)
|
||||
|
||||
// Timer goroutine: update widget every second with elapsed time.
|
||||
doneCh := make(chan struct{})
|
||||
go func() {
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-doneCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
state.setElapsed(time.Since(start))
|
||||
updateWidgets()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Read stderr in background goroutine.
|
||||
var readWg sync.WaitGroup
|
||||
readWg.Add(1)
|
||||
go func() {
|
||||
defer readWg.Done()
|
||||
scanner := bufio.NewScanner(stderr)
|
||||
scanner.Buffer(make([]byte, 256*1024), 256*1024)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.TrimSpace(line) != "" {
|
||||
state.appendChunk(line + "\n")
|
||||
updateWidgets()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Read stdout in foreground.
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 256*1024), 256*1024)
|
||||
for scanner.Scan() {
|
||||
state.appendChunk(scanner.Text() + "\n")
|
||||
updateWidgets()
|
||||
}
|
||||
|
||||
// Wait for all pipe readers, then the process.
|
||||
readWg.Wait()
|
||||
waitErr := cmd.Wait()
|
||||
close(doneCh) // stop timer
|
||||
|
||||
state.mu.Lock()
|
||||
state.Elapsed = time.Since(start)
|
||||
state.Proc = nil
|
||||
if waitErr != nil {
|
||||
state.Status = "error"
|
||||
} else {
|
||||
state.Status = "done"
|
||||
}
|
||||
result := strings.Join(state.Chunks, "")
|
||||
|
||||
// Save history for /subcont continuations (cap at 16 KB).
|
||||
state.History += fmt.Sprintf("\n--- Turn %d ---\nTask: %s\nResult:\n%s\n",
|
||||
state.TurnCount, state.Task, result)
|
||||
if len(state.History) > 16000 {
|
||||
state.History = state.History[len(state.History)-16000:]
|
||||
}
|
||||
|
||||
removed := state.Removed
|
||||
id := state.ID
|
||||
elapsed := state.Elapsed
|
||||
turnCount := state.TurnCount
|
||||
task := state.Task
|
||||
state.mu.Unlock()
|
||||
|
||||
updateWidgets()
|
||||
|
||||
// Don't deliver follow-up for agents removed via /subrm or /subclear.
|
||||
if removed {
|
||||
return
|
||||
}
|
||||
|
||||
// Deliver result as a follow-up message so the LLM can act on it.
|
||||
mu.Lock()
|
||||
ctx := latestCtx
|
||||
ok := hasCtx
|
||||
mu.Unlock()
|
||||
|
||||
if ok {
|
||||
resultText := result
|
||||
if len(resultText) > 8000 {
|
||||
resultText = resultText[:8000] + "\n\n... [truncated]"
|
||||
}
|
||||
turnSuffix := ""
|
||||
if turnCount > 1 {
|
||||
turnSuffix = fmt.Sprintf(" (Turn %d)", turnCount)
|
||||
}
|
||||
ctx.SendMessage(fmt.Sprintf(
|
||||
"Subagent #%d%s finished \"%s\" in %ds.\n\nResult:\n%s",
|
||||
id, turnSuffix, task, int(elapsed.Seconds()), resultText,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Init
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func Init(api ext.API) {
|
||||
kitBinary = findKitBinary()
|
||||
|
||||
// ── Session Start: reset state, show help ──
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
mu.Lock()
|
||||
latestCtx = ctx
|
||||
hasCtx = true
|
||||
mu.Unlock()
|
||||
|
||||
// Kill lingering agents from previous session.
|
||||
mu.Lock()
|
||||
for id, state := range agents {
|
||||
state.mu.Lock()
|
||||
if state.Proc != nil && state.Status == "running" {
|
||||
state.Proc.Kill()
|
||||
}
|
||||
state.mu.Unlock()
|
||||
ctx.RemoveWidget(fmt.Sprintf("subagent:%d", id))
|
||||
}
|
||||
agents = map[int]*subState{}
|
||||
nextID = 1
|
||||
mu.Unlock()
|
||||
|
||||
ctx.PrintInfo(
|
||||
"Subagent Widget loaded\n\n" +
|
||||
"/sub <task> Spawn a new subagent\n" +
|
||||
"/subcont <id> <prompt> Continue a finished subagent\n" +
|
||||
"/subrm <id> Remove a subagent\n" +
|
||||
"/subclear Clear all subagents\n\n" +
|
||||
"The LLM can also spawn subagents with the subagent_create tool.")
|
||||
})
|
||||
|
||||
// ── Agent End: keep context fresh ──
|
||||
api.OnAgentEnd(func(_ ext.AgentEndEvent, ctx ext.Context) {
|
||||
mu.Lock()
|
||||
latestCtx = ctx
|
||||
mu.Unlock()
|
||||
})
|
||||
|
||||
// ── Session Shutdown: cleanup ──
|
||||
api.OnSessionShutdown(func(_ ext.SessionShutdownEvent, ctx ext.Context) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for id, state := range agents {
|
||||
state.mu.Lock()
|
||||
if state.Proc != nil && state.Status == "running" {
|
||||
state.Proc.Kill()
|
||||
}
|
||||
state.mu.Unlock()
|
||||
ctx.RemoveWidget(fmt.Sprintf("subagent:%d", id))
|
||||
}
|
||||
agents = map[int]*subState{}
|
||||
})
|
||||
|
||||
// ── Tool: subagent_create ──
|
||||
api.RegisterTool(ext.ToolDef{
|
||||
Name: "subagent_create",
|
||||
Description: `Spawn a background subagent to perform a task. Returns the subagent ID immediately while it runs in the background. Results are delivered as a follow-up message when the subagent finishes.
|
||||
|
||||
Each subagent runs as a separate Kit subprocess with full tool access. Use this to delegate independent subtasks that can run in parallel with your main work.`,
|
||||
Parameters: `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task": {
|
||||
"type": "string",
|
||||
"description": "The complete task description for the subagent to perform"
|
||||
}
|
||||
},
|
||||
"required": ["task"]
|
||||
}`,
|
||||
Execute: func(input string) (string, error) {
|
||||
var params struct {
|
||||
Task string `json:"task"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(input), ¶ms); err != nil {
|
||||
return "", fmt.Errorf("invalid parameters: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(params.Task) == "" {
|
||||
return "", fmt.Errorf("task is required")
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
id := nextID
|
||||
nextID++
|
||||
state := &subState{
|
||||
ID: id,
|
||||
Status: "running",
|
||||
Task: params.Task,
|
||||
TurnCount: 1,
|
||||
}
|
||||
agents[id] = state
|
||||
mu.Unlock()
|
||||
|
||||
updateWidgets()
|
||||
go spawnAgent(state)
|
||||
|
||||
return fmt.Sprintf("Subagent #%d spawned and running in background.", id), nil
|
||||
},
|
||||
})
|
||||
|
||||
// ── Tool: subagent_continue ──
|
||||
api.RegisterTool(ext.ToolDef{
|
||||
Name: "subagent_continue",
|
||||
Description: `Continue an existing subagent's conversation with a follow-up prompt. The subagent receives its previous conversation history as context. Use this to refine or extend a finished subagent's work.`,
|
||||
Parameters: `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "number",
|
||||
"description": "The ID of the subagent to continue"
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "The follow-up prompt or new instructions"
|
||||
}
|
||||
},
|
||||
"required": ["id", "prompt"]
|
||||
}`,
|
||||
Execute: func(input string) (string, error) {
|
||||
var params struct {
|
||||
ID int `json:"id"`
|
||||
Prompt string `json:"prompt"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(input), ¶ms); err != nil {
|
||||
return "", fmt.Errorf("invalid parameters: %w", err)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
state, ok := agents[params.ID]
|
||||
mu.Unlock()
|
||||
if !ok {
|
||||
return fmt.Sprintf("Error: No subagent #%d found.", params.ID), nil
|
||||
}
|
||||
|
||||
state.mu.Lock()
|
||||
if state.Status == "running" {
|
||||
state.mu.Unlock()
|
||||
return fmt.Sprintf("Error: Subagent #%d is still running.", params.ID), nil
|
||||
}
|
||||
state.Status = "running"
|
||||
state.Task = params.Prompt
|
||||
state.Chunks = nil
|
||||
state.Elapsed = 0
|
||||
state.TurnCount++
|
||||
turn := state.TurnCount
|
||||
state.mu.Unlock()
|
||||
|
||||
updateWidgets()
|
||||
go spawnAgent(state)
|
||||
|
||||
return fmt.Sprintf("Subagent #%d continuing conversation in background (Turn %d).", params.ID, turn), nil
|
||||
},
|
||||
})
|
||||
|
||||
// ── Tool: subagent_remove ──
|
||||
api.RegisterTool(ext.ToolDef{
|
||||
Name: "subagent_remove",
|
||||
Description: "Remove a specific subagent. Kills it if currently running.",
|
||||
Parameters: `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "number",
|
||||
"description": "The ID of the subagent to remove"
|
||||
}
|
||||
},
|
||||
"required": ["id"]
|
||||
}`,
|
||||
Execute: func(input string) (string, error) {
|
||||
var params struct {
|
||||
ID int `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(input), ¶ms); err != nil {
|
||||
return "", fmt.Errorf("invalid parameters: %w", err)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
state, ok := agents[params.ID]
|
||||
if !ok {
|
||||
mu.Unlock()
|
||||
return fmt.Sprintf("Error: No subagent #%d found.", params.ID), nil
|
||||
}
|
||||
delete(agents, params.ID)
|
||||
mu.Unlock()
|
||||
|
||||
state.mu.Lock()
|
||||
state.Removed = true
|
||||
if state.Proc != nil && state.Status == "running" {
|
||||
state.Proc.Kill()
|
||||
}
|
||||
state.mu.Unlock()
|
||||
|
||||
mu.Lock()
|
||||
ctx := latestCtx
|
||||
ok2 := hasCtx
|
||||
mu.Unlock()
|
||||
if ok2 {
|
||||
ctx.RemoveWidget(fmt.Sprintf("subagent:%d", params.ID))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Subagent #%d removed.", params.ID), nil
|
||||
},
|
||||
})
|
||||
|
||||
// ── Tool: subagent_list ──
|
||||
api.RegisterTool(ext.ToolDef{
|
||||
Name: "subagent_list",
|
||||
Description: "List all active and finished subagents with their IDs, tasks, and status.",
|
||||
Parameters: `{"type": "object", "properties": {}}`,
|
||||
Execute: func(input string) (string, error) {
|
||||
mu.Lock()
|
||||
agentsCopy := make([]*subState, 0, len(agents))
|
||||
for _, s := range agents {
|
||||
agentsCopy = append(agentsCopy, s)
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
if len(agentsCopy) == 0 {
|
||||
return "No active subagents.", nil
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Subagents:\n")
|
||||
for _, s := range agentsCopy {
|
||||
id, status, task, _, _, turnCount := s.snapshot()
|
||||
fmt.Fprintf(&sb, "#%d [%s] (Turn %d) — %s\n",
|
||||
id, strings.ToUpper(status), turnCount, task)
|
||||
}
|
||||
return sb.String(), nil
|
||||
},
|
||||
})
|
||||
|
||||
// ── Tool Renderers ──
|
||||
api.RegisterToolRenderer(ext.ToolRenderConfig{
|
||||
ToolName: "subagent_create",
|
||||
DisplayName: "Spawn Subagent",
|
||||
BorderColor: "#89b4fa",
|
||||
RenderHeader: func(toolArgs string, width int) string {
|
||||
var args struct {
|
||||
Task string `json:"task"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(toolArgs), &args); err != nil {
|
||||
return ""
|
||||
}
|
||||
return truncate(args.Task, width)
|
||||
},
|
||||
RenderBody: func(toolResult string, isError bool, width int) string {
|
||||
return truncate(toolResult, width)
|
||||
},
|
||||
})
|
||||
|
||||
api.RegisterToolRenderer(ext.ToolRenderConfig{
|
||||
ToolName: "subagent_continue",
|
||||
DisplayName: "Continue Subagent",
|
||||
BorderColor: "#cba6f7",
|
||||
RenderHeader: func(toolArgs string, width int) string {
|
||||
var args struct {
|
||||
ID int `json:"id"`
|
||||
Prompt string `json:"prompt"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(toolArgs), &args); err != nil {
|
||||
return ""
|
||||
}
|
||||
return truncate(fmt.Sprintf("#%d: %s", args.ID, args.Prompt), width)
|
||||
},
|
||||
RenderBody: func(toolResult string, isError bool, width int) string {
|
||||
return truncate(toolResult, width)
|
||||
},
|
||||
})
|
||||
|
||||
// ── Command: /sub <task> ──
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "sub",
|
||||
Description: "Spawn a subagent with live widget: /sub <task>",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
mu.Lock()
|
||||
latestCtx = ctx
|
||||
hasCtx = true
|
||||
mu.Unlock()
|
||||
|
||||
task := strings.TrimSpace(args)
|
||||
if task == "" {
|
||||
return "Usage: /sub <task>", nil
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
id := nextID
|
||||
nextID++
|
||||
state := &subState{
|
||||
ID: id,
|
||||
Status: "running",
|
||||
Task: task,
|
||||
TurnCount: 1,
|
||||
}
|
||||
agents[id] = state
|
||||
mu.Unlock()
|
||||
|
||||
updateWidgets()
|
||||
go spawnAgent(state)
|
||||
|
||||
return fmt.Sprintf("Subagent #%d spawned: %s", id, truncate(task, 60)), nil
|
||||
},
|
||||
})
|
||||
|
||||
// ── Command: /subcont <id> <prompt> ──
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "subcont",
|
||||
Description: "Continue subagent conversation: /subcont <id> <prompt>",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
mu.Lock()
|
||||
latestCtx = ctx
|
||||
hasCtx = true
|
||||
mu.Unlock()
|
||||
|
||||
trimmed := strings.TrimSpace(args)
|
||||
spaceIdx := strings.IndexByte(trimmed, ' ')
|
||||
if spaceIdx < 0 {
|
||||
return "Usage: /subcont <id> <prompt>", nil
|
||||
}
|
||||
|
||||
num, err := strconv.Atoi(trimmed[:spaceIdx])
|
||||
if err != nil {
|
||||
return "Usage: /subcont <id> <prompt>", nil
|
||||
}
|
||||
prompt := strings.TrimSpace(trimmed[spaceIdx+1:])
|
||||
if prompt == "" {
|
||||
return "Usage: /subcont <id> <prompt>", nil
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
state, ok := agents[num]
|
||||
mu.Unlock()
|
||||
if !ok {
|
||||
return fmt.Sprintf("No subagent #%d found. Use /sub to create one.", num), nil
|
||||
}
|
||||
|
||||
state.mu.Lock()
|
||||
if state.Status == "running" {
|
||||
state.mu.Unlock()
|
||||
return fmt.Sprintf("Subagent #%d is still running — wait for it to finish.", num), nil
|
||||
}
|
||||
state.Status = "running"
|
||||
state.Task = prompt
|
||||
state.Chunks = nil
|
||||
state.Elapsed = 0
|
||||
state.TurnCount++
|
||||
turn := state.TurnCount
|
||||
state.mu.Unlock()
|
||||
|
||||
updateWidgets()
|
||||
go spawnAgent(state)
|
||||
|
||||
return fmt.Sprintf("Continuing subagent #%d (Turn %d): %s", num, turn, truncate(prompt, 50)), nil
|
||||
},
|
||||
})
|
||||
|
||||
// ── Command: /subrm <id> ──
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "subrm",
|
||||
Description: "Remove a subagent widget: /subrm <id>",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
mu.Lock()
|
||||
latestCtx = ctx
|
||||
hasCtx = true
|
||||
mu.Unlock()
|
||||
|
||||
num, err := strconv.Atoi(strings.TrimSpace(args))
|
||||
if err != nil {
|
||||
return "Usage: /subrm <id>", nil
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
state, ok := agents[num]
|
||||
if !ok {
|
||||
mu.Unlock()
|
||||
return fmt.Sprintf("No subagent #%d found.", num), nil
|
||||
}
|
||||
delete(agents, num)
|
||||
mu.Unlock()
|
||||
|
||||
state.mu.Lock()
|
||||
state.Removed = true
|
||||
killed := false
|
||||
if state.Proc != nil && state.Status == "running" {
|
||||
state.Proc.Kill()
|
||||
killed = true
|
||||
}
|
||||
state.mu.Unlock()
|
||||
|
||||
ctx.RemoveWidget(fmt.Sprintf("subagent:%d", num))
|
||||
|
||||
if killed {
|
||||
return fmt.Sprintf("Subagent #%d killed and removed.", num), nil
|
||||
}
|
||||
return fmt.Sprintf("Subagent #%d removed.", num), nil
|
||||
},
|
||||
})
|
||||
|
||||
// ── Command: /subclear ──
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "subclear",
|
||||
Description: "Clear all subagent widgets",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
mu.Lock()
|
||||
latestCtx = ctx
|
||||
hasCtx = true
|
||||
agentsCopy := make(map[int]*subState, len(agents))
|
||||
for k, v := range agents {
|
||||
agentsCopy[k] = v
|
||||
}
|
||||
agents = map[int]*subState{}
|
||||
nextID = 1
|
||||
mu.Unlock()
|
||||
|
||||
killed := 0
|
||||
total := len(agentsCopy)
|
||||
for id, state := range agentsCopy {
|
||||
state.mu.Lock()
|
||||
state.Removed = true
|
||||
if state.Proc != nil && state.Status == "running" {
|
||||
state.Proc.Kill()
|
||||
killed++
|
||||
}
|
||||
state.mu.Unlock()
|
||||
ctx.RemoveWidget(fmt.Sprintf("subagent:%d", id))
|
||||
}
|
||||
|
||||
if total == 0 {
|
||||
return "No subagents to clear.", nil
|
||||
}
|
||||
msg := fmt.Sprintf("Cleared %d subagent", total)
|
||||
if total != 1 {
|
||||
msg += "s"
|
||||
}
|
||||
if killed > 0 {
|
||||
msg += fmt.Sprintf(" (%d killed)", killed)
|
||||
}
|
||||
msg += "."
|
||||
return msg, nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init demonstrates the custom tool rendering system. It registers
|
||||
// renderers that override how specific tools display their headers,
|
||||
// result bodies, display names, border colors, and backgrounds.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// kit -e examples/extensions/tool-renderer-demo.go
|
||||
//
|
||||
// Then ask the agent to read a file or run a bash command to see
|
||||
// the custom rendering in action.
|
||||
func Init(api ext.API) {
|
||||
// Custom renderer for the "read" tool: custom display name,
|
||||
// blue border, compact filename-only header.
|
||||
api.RegisterToolRenderer(ext.ToolRenderConfig{
|
||||
ToolName: "read",
|
||||
DisplayName: "File",
|
||||
BorderColor: "#89b4fa", // Catppuccin blue
|
||||
RenderHeader: func(toolArgs string, width int) string {
|
||||
var args map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(toolArgs), &args); err != nil {
|
||||
return ""
|
||||
}
|
||||
path, _ := args["path"].(string)
|
||||
if path == "" {
|
||||
return "" // fall back to default
|
||||
}
|
||||
|
||||
// Show just the filename, not the full path.
|
||||
parts := strings.Split(path, "/")
|
||||
name := parts[len(parts)-1]
|
||||
|
||||
// Include offset/limit if present.
|
||||
var extras []string
|
||||
if offset, ok := args["offset"]; ok {
|
||||
extras = append(extras, fmt.Sprintf("from line %v", offset))
|
||||
}
|
||||
if limit, ok := args["limit"]; ok {
|
||||
extras = append(extras, fmt.Sprintf("max %v lines", limit))
|
||||
}
|
||||
|
||||
result := name
|
||||
if len(extras) > 0 {
|
||||
result += " (" + strings.Join(extras, ", ") + ")"
|
||||
}
|
||||
|
||||
if len(result) > width {
|
||||
return result[:width-3] + "..."
|
||||
}
|
||||
return result
|
||||
},
|
||||
// RenderBody is nil — fall back to the builtin read renderer
|
||||
// which already provides syntax-highlighted code blocks.
|
||||
})
|
||||
|
||||
// Custom renderer for the "bash" tool: renamed to "Shell",
|
||||
// dark background, custom header with $ prefix.
|
||||
api.RegisterToolRenderer(ext.ToolRenderConfig{
|
||||
ToolName: "bash",
|
||||
DisplayName: "Shell",
|
||||
Background: "#1e1e2e", // Dark background
|
||||
BorderColor: "#a6e3a1", // Catppuccin green
|
||||
RenderHeader: func(toolArgs string, width int) string {
|
||||
var args map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(toolArgs), &args); err != nil {
|
||||
return ""
|
||||
}
|
||||
cmd, _ := args["command"].(string)
|
||||
if cmd == "" {
|
||||
return "" // fall back to default
|
||||
}
|
||||
|
||||
// Show first line of command with a $ prefix.
|
||||
lines := strings.SplitN(cmd, "\n", 2)
|
||||
display := "$ " + lines[0]
|
||||
if len(lines) > 1 {
|
||||
display += " ..."
|
||||
}
|
||||
|
||||
if len(display) > width {
|
||||
return display[:width-3] + "..."
|
||||
}
|
||||
return display
|
||||
},
|
||||
RenderBody: func(toolResult string, isError bool, width int) string {
|
||||
if isError {
|
||||
return "" // fall back to default error rendering
|
||||
}
|
||||
|
||||
// Count lines and show a summary at the end.
|
||||
lines := strings.Split(toolResult, "\n")
|
||||
lineCount := len(lines)
|
||||
|
||||
// Show the first few lines of output.
|
||||
maxShow := 10
|
||||
if lineCount <= maxShow {
|
||||
return toolResult
|
||||
}
|
||||
|
||||
shown := strings.Join(lines[:maxShow], "\n")
|
||||
return fmt.Sprintf("%s\n\n[%d lines total, showing first %d]",
|
||||
shown, lineCount, maxShow)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init demonstrates the widget system by showing a persistent status
|
||||
// widget above the input area. The widget updates on each agent turn
|
||||
// to show a running count of tool calls and the last tool used.
|
||||
func Init(api ext.API) {
|
||||
var toolCallCount int
|
||||
var lastToolName string
|
||||
|
||||
// Show initial status widget when session starts.
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
ctx.SetWidget(ext.WidgetConfig{
|
||||
ID: "widget-status:info",
|
||||
Placement: ext.WidgetAbove,
|
||||
Content: ext.WidgetContent{
|
||||
Text: fmt.Sprintf("Session started | CWD: %s | Model: %s", ctx.CWD, ctx.Model),
|
||||
},
|
||||
Style: ext.WidgetStyle{
|
||||
BorderColor: "#89b4fa",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Update the widget after each tool call with a running count.
|
||||
api.OnToolResult(func(tr ext.ToolResultEvent, ctx ext.Context) *ext.ToolResultResult {
|
||||
toolCallCount++
|
||||
lastToolName = tr.ToolName
|
||||
|
||||
status := "ok"
|
||||
if tr.IsError {
|
||||
status = "error"
|
||||
}
|
||||
|
||||
ctx.SetWidget(ext.WidgetConfig{
|
||||
ID: "widget-status:info",
|
||||
Placement: ext.WidgetAbove,
|
||||
Content: ext.WidgetContent{
|
||||
Text: fmt.Sprintf(
|
||||
"Tools: %d calls | Last: %s (%s) | %s",
|
||||
toolCallCount, lastToolName, status,
|
||||
time.Now().Format("15:04:05"),
|
||||
),
|
||||
},
|
||||
Style: ext.WidgetStyle{
|
||||
BorderColor: "#a6e3a1",
|
||||
},
|
||||
})
|
||||
return nil
|
||||
})
|
||||
|
||||
// "!widget-off" — removes the status widget.
|
||||
// "!widget-on" — restores the status widget.
|
||||
api.OnInput(func(ie ext.InputEvent, ctx ext.Context) *ext.InputResult {
|
||||
switch ie.Text {
|
||||
case "!widget-off":
|
||||
ctx.RemoveWidget("widget-status:info")
|
||||
ctx.PrintInfo("Status widget removed.")
|
||||
return &ext.InputResult{Action: "handled"}
|
||||
|
||||
case "!widget-on":
|
||||
ctx.SetWidget(ext.WidgetConfig{
|
||||
ID: "widget-status:info",
|
||||
Placement: ext.WidgetAbove,
|
||||
Content: ext.WidgetContent{
|
||||
Text: fmt.Sprintf("Tools: %d calls | %s", toolCallCount, time.Now().Format("15:04:05")),
|
||||
},
|
||||
Style: ext.WidgetStyle{
|
||||
BorderColor: "#a6e3a1",
|
||||
},
|
||||
})
|
||||
ctx.PrintInfo("Status widget restored.")
|
||||
return &ext.InputResult{Action: "handled"}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// Clean up widget on shutdown.
|
||||
api.OnSessionShutdown(func(_ ext.SessionShutdownEvent, ctx ext.Context) {
|
||||
ctx.RemoveWidget("widget-status:info")
|
||||
})
|
||||
}
|
||||
@@ -491,6 +491,62 @@ func (a *App) PrintFromExtension(level, text string) {
|
||||
fmt.Println(text)
|
||||
}
|
||||
|
||||
// NotifyWidgetUpdate sends a WidgetUpdateEvent to the TUI so it re-renders
|
||||
// extension widgets. Called from the extension context's SetWidget/RemoveWidget
|
||||
// closures. In non-interactive mode this is a no-op (widgets are TUI-only).
|
||||
func (a *App) NotifyWidgetUpdate() {
|
||||
a.mu.Lock()
|
||||
prog := a.program
|
||||
a.mu.Unlock()
|
||||
if prog != nil {
|
||||
prog.Send(WidgetUpdateEvent{})
|
||||
}
|
||||
}
|
||||
|
||||
// SendEvent sends a tea.Msg to the registered program. Safe to call from
|
||||
// any goroutine. No-op when no program is registered.
|
||||
//
|
||||
// Satisfies ui.AppController.
|
||||
func (a *App) SendEvent(msg tea.Msg) {
|
||||
a.sendEvent(msg)
|
||||
}
|
||||
|
||||
// SendPromptRequest sends a PromptRequestEvent to the TUI so the user can
|
||||
// respond interactively. In non-interactive mode (no program registered) it
|
||||
// immediately responds with a cancelled result via the channel, ensuring the
|
||||
// calling extension goroutine never blocks indefinitely.
|
||||
func (a *App) SendPromptRequest(evt PromptRequestEvent) {
|
||||
a.mu.Lock()
|
||||
prog := a.program
|
||||
a.mu.Unlock()
|
||||
if prog != nil {
|
||||
prog.Send(evt)
|
||||
return
|
||||
}
|
||||
// Non-interactive fallback: immediately cancel.
|
||||
if evt.ResponseCh != nil {
|
||||
evt.ResponseCh <- PromptResponse{Cancelled: true}
|
||||
}
|
||||
}
|
||||
|
||||
// SendOverlayRequest sends an OverlayRequestEvent to the TUI so the user
|
||||
// can interact with a modal overlay dialog. In non-interactive mode (no
|
||||
// program registered) it immediately responds with a cancelled result via the
|
||||
// channel, ensuring the calling extension goroutine never blocks indefinitely.
|
||||
func (a *App) SendOverlayRequest(evt OverlayRequestEvent) {
|
||||
a.mu.Lock()
|
||||
prog := a.program
|
||||
a.mu.Unlock()
|
||||
if prog != nil {
|
||||
prog.Send(evt)
|
||||
return
|
||||
}
|
||||
// Non-interactive fallback: immediately cancel.
|
||||
if evt.ResponseCh != nil {
|
||||
evt.ResponseCh <- OverlayResponse{Cancelled: true}
|
||||
}
|
||||
}
|
||||
|
||||
// PrintBlockFromExtension outputs a custom styled block from an extension.
|
||||
func (a *App) PrintBlockFromExtension(opts extensions.PrintBlockOpts) {
|
||||
a.mu.Lock()
|
||||
|
||||
@@ -113,6 +113,11 @@ type CompactErrorEvent struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
// WidgetUpdateEvent is sent when an extension adds, updates, or removes a
|
||||
// widget via ctx.SetWidget or ctx.RemoveWidget. The TUI re-reads widget state
|
||||
// from its WidgetProvider on the next render cycle.
|
||||
type WidgetUpdateEvent struct{}
|
||||
|
||||
// ExtensionPrintEvent is sent when an extension calls ctx.Print, ctx.PrintInfo,
|
||||
// ctx.PrintError, or ctx.PrintBlock. The TUI renders it via the appropriate
|
||||
// renderer and tea.Println (scrollback); the CLI handler uses
|
||||
@@ -132,3 +137,89 @@ type ExtensionPrintEvent struct {
|
||||
// Subtitle is optional muted text below the content for Level="block".
|
||||
Subtitle string
|
||||
}
|
||||
|
||||
// PromptResponse carries the user's answer to an interactive prompt. The TUI
|
||||
// sends exactly one PromptResponse through the channel embedded in
|
||||
// PromptRequestEvent when the user completes or cancels the prompt.
|
||||
type PromptResponse struct {
|
||||
// Value is the response text — the selected option (select), or the
|
||||
// entered text (input). Unused for confirm prompts.
|
||||
Value string
|
||||
// Index is the zero-based index of the selected option (select only).
|
||||
Index int
|
||||
// Confirmed is the boolean answer for confirm prompts.
|
||||
Confirmed bool
|
||||
// Cancelled is true if the user dismissed the prompt (ESC) or the
|
||||
// prompt could not be shown (e.g. app shutting down).
|
||||
Cancelled bool
|
||||
}
|
||||
|
||||
// PromptRequestEvent is sent when an extension requests an interactive
|
||||
// prompt from the user (select, confirm, or text input). The TUI enters a
|
||||
// modal prompt state, renders the prompt, and sends a single PromptResponse
|
||||
// through ResponseCh when the user completes or cancels.
|
||||
//
|
||||
// The extension goroutine blocks on the read side of ResponseCh until the
|
||||
// TUI sends a response. The channel must have buffer size >= 1.
|
||||
type PromptRequestEvent struct {
|
||||
// PromptType is "select", "confirm", or "input".
|
||||
PromptType string
|
||||
// Message is the question displayed to the user.
|
||||
Message string
|
||||
// Options lists the choices for select prompts.
|
||||
Options []string
|
||||
// Default is the pre-filled value: "true"/"false" for confirm prompts,
|
||||
// or the initial text for input prompts.
|
||||
Default string
|
||||
// Placeholder is the ghost text for input prompts.
|
||||
Placeholder string
|
||||
// ResponseCh receives the user's answer. The TUI must send exactly one
|
||||
// value. The channel must be buffered (cap >= 1) so sending never
|
||||
// blocks inside Update().
|
||||
ResponseCh chan<- PromptResponse
|
||||
}
|
||||
|
||||
// OverlayResponse carries the user's answer to a modal overlay dialog. The
|
||||
// TUI sends exactly one OverlayResponse through the channel embedded in
|
||||
// OverlayRequestEvent when the user completes or cancels the overlay.
|
||||
type OverlayResponse struct {
|
||||
// Action is the text of the selected action button, or "" if no actions
|
||||
// were configured or the dialog was dismissed without selection.
|
||||
Action string
|
||||
// Index is the zero-based index of the selected action, or -1 if no
|
||||
// action was selected.
|
||||
Index int
|
||||
// Cancelled is true if the user dismissed the overlay (ESC) or the
|
||||
// overlay could not be shown (e.g. non-interactive mode).
|
||||
Cancelled bool
|
||||
}
|
||||
|
||||
// OverlayRequestEvent is sent when an extension requests a modal overlay
|
||||
// dialog. The TUI enters an overlay state, renders the dialog, and sends a
|
||||
// single OverlayResponse through ResponseCh when the user dismisses or
|
||||
// selects an action.
|
||||
//
|
||||
// The extension goroutine blocks on the read side of ResponseCh until the
|
||||
// TUI sends a response. The channel must have buffer size >= 1.
|
||||
type OverlayRequestEvent struct {
|
||||
// Title is displayed at the top of the dialog. Empty means no title.
|
||||
Title string
|
||||
// Content is the text to render inside the dialog body.
|
||||
Content string
|
||||
// Markdown, when true, renders Content as styled markdown.
|
||||
Markdown bool
|
||||
// BorderColor is a hex color for the dialog border. Empty uses default.
|
||||
BorderColor string
|
||||
// Background is a hex color for the dialog background. Empty = none.
|
||||
Background string
|
||||
// Width is the dialog width in columns. 0 = auto (60% of terminal).
|
||||
Width int
|
||||
// MaxHeight limits dialog height. 0 = auto (80% of terminal).
|
||||
MaxHeight int
|
||||
// Anchor is the vertical positioning: "center", "top-center", "bottom-center".
|
||||
Anchor string
|
||||
// Actions lists the action button labels. Empty = simple dismiss dialog.
|
||||
Actions []string
|
||||
// ResponseCh receives the user's response. Must have buffer size >= 1.
|
||||
ResponseCh chan<- OverlayResponse
|
||||
}
|
||||
|
||||
+537
-15
@@ -63,6 +63,149 @@ type Context struct {
|
||||
// ctx.SendMessage("Subagent result:\n" + string(out))
|
||||
// }()
|
||||
SendMessage func(string)
|
||||
|
||||
// SetWidget places or updates a persistent widget in the TUI. Widgets
|
||||
// remain visible across agent turns until explicitly removed. The
|
||||
// widget is identified by WidgetConfig.ID; calling SetWidget with the
|
||||
// same ID replaces the previous content.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// ctx.SetWidget(ext.WidgetConfig{
|
||||
// ID: "my-status",
|
||||
// Placement: ext.WidgetAbove,
|
||||
// Content: ext.WidgetContent{Text: "Build: passing"},
|
||||
// Style: ext.WidgetStyle{BorderColor: "#a6e3a1"},
|
||||
// })
|
||||
SetWidget func(WidgetConfig)
|
||||
|
||||
// RemoveWidget removes a previously placed widget by its ID. No-op if
|
||||
// the ID does not exist.
|
||||
RemoveWidget func(id string)
|
||||
|
||||
// SetHeader places a custom header at the top of the TUI view, above
|
||||
// the stream region. Only one header can be active at a time; calling
|
||||
// SetHeader replaces any previous header. The header persists across
|
||||
// agent turns until explicitly removed.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// ctx.SetHeader(ext.HeaderFooterConfig{
|
||||
// Content: ext.WidgetContent{Text: "Project: my-app | Branch: main"},
|
||||
// Style: ext.WidgetStyle{BorderColor: "#89b4fa"},
|
||||
// })
|
||||
SetHeader func(HeaderFooterConfig)
|
||||
|
||||
// RemoveHeader removes the custom header. No-op if no header is set.
|
||||
RemoveHeader func()
|
||||
|
||||
// SetFooter places a custom footer at the bottom of the TUI view,
|
||||
// below the status bar. Only one footer can be active at a time;
|
||||
// calling SetFooter replaces any previous footer. The footer persists
|
||||
// across agent turns until explicitly removed.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// ctx.SetFooter(ext.HeaderFooterConfig{
|
||||
// Content: ext.WidgetContent{Text: "Ready | 3 tasks remaining"},
|
||||
// Style: ext.WidgetStyle{BorderColor: "#a6e3a1"},
|
||||
// })
|
||||
SetFooter func(HeaderFooterConfig)
|
||||
|
||||
// RemoveFooter removes the custom footer. No-op if no footer is set.
|
||||
RemoveFooter func()
|
||||
|
||||
// PromptSelect shows a selection list to the user and blocks until
|
||||
// they pick an option or cancel (ESC). Returns a cancelled result in
|
||||
// non-interactive mode. Safe to call from event handlers and slash
|
||||
// command handlers.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// result := ctx.PromptSelect(ext.PromptSelectConfig{
|
||||
// Message: "Choose a deployment target:",
|
||||
// Options: []string{"staging", "production", "local"},
|
||||
// })
|
||||
// if !result.Cancelled {
|
||||
// fmt.Println("Selected:", result.Value)
|
||||
// }
|
||||
PromptSelect func(PromptSelectConfig) PromptSelectResult
|
||||
|
||||
// PromptConfirm shows a yes/no confirmation to the user and blocks
|
||||
// until they respond or cancel. Returns a cancelled result in
|
||||
// non-interactive mode.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// result := ctx.PromptConfirm(ext.PromptConfirmConfig{
|
||||
// Message: "Deploy to production?",
|
||||
// DefaultValue: false,
|
||||
// })
|
||||
// if !result.Cancelled && result.Value {
|
||||
// // proceed with deployment
|
||||
// }
|
||||
PromptConfirm func(PromptConfirmConfig) PromptConfirmResult
|
||||
|
||||
// PromptInput shows a text input field to the user and blocks until
|
||||
// they submit text or cancel. Returns a cancelled result in
|
||||
// non-interactive mode.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// result := ctx.PromptInput(ext.PromptInputConfig{
|
||||
// Message: "Enter the release tag:",
|
||||
// Placeholder: "v1.0.0",
|
||||
// })
|
||||
// if !result.Cancelled {
|
||||
// fmt.Println("Tag:", result.Value)
|
||||
// }
|
||||
PromptInput func(PromptInputConfig) PromptInputResult
|
||||
|
||||
// ShowOverlay displays a modal overlay dialog that blocks until the
|
||||
// user dismisses it or selects an action. The overlay renders as a
|
||||
// centered (or anchored) bordered box over the TUI. Returns a
|
||||
// cancelled result in non-interactive mode.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// result := ctx.ShowOverlay(ext.OverlayConfig{
|
||||
// Title: "Deployment Summary",
|
||||
// Content: ext.WidgetContent{Text: "All 3 services deployed."},
|
||||
// Style: ext.OverlayStyle{BorderColor: "#a6e3a1"},
|
||||
// Actions: []string{"Continue", "Rollback", "Details"},
|
||||
// })
|
||||
// if !result.Cancelled {
|
||||
// fmt.Println("Selected:", result.Action)
|
||||
// }
|
||||
ShowOverlay func(OverlayConfig) OverlayResult
|
||||
|
||||
// SetEditor installs an editor interceptor that wraps the built-in
|
||||
// input editor. The interceptor can intercept keys (remap, consume,
|
||||
// submit) and modify the rendered output. Only one interceptor is
|
||||
// active at a time; calling SetEditor replaces any previous interceptor.
|
||||
//
|
||||
// Example — vim-like normal mode:
|
||||
//
|
||||
// ctx.SetEditor(ext.EditorConfig{
|
||||
// HandleKey: func(key, text string) ext.EditorKeyAction {
|
||||
// switch key {
|
||||
// case "h":
|
||||
// return ext.EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "left"}
|
||||
// case "i":
|
||||
// ctx.ResetEditor()
|
||||
// return ext.EditorKeyAction{Type: ext.EditorKeyConsumed}
|
||||
// }
|
||||
// return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
|
||||
// },
|
||||
// Render: func(width int, content string) string {
|
||||
// return "[NORMAL]\n" + content
|
||||
// },
|
||||
// })
|
||||
SetEditor func(EditorConfig)
|
||||
|
||||
// ResetEditor removes the active editor interceptor and restores the
|
||||
// default built-in editor behavior. No-op if no interceptor is set.
|
||||
ResetEditor func()
|
||||
}
|
||||
|
||||
// PrintBlockOpts configures a custom styled block for PrintBlock.
|
||||
@@ -90,21 +233,22 @@ type PrintBlockOpts struct {
|
||||
// register typed event handlers, custom tools, and slash commands.
|
||||
type API struct {
|
||||
// Event-specific registration functions (wired by the loader).
|
||||
onToolCall func(func(ToolCallEvent, Context) *ToolCallResult)
|
||||
onToolExecStart func(func(ToolExecutionStartEvent, Context))
|
||||
onToolExecEnd func(func(ToolExecutionEndEvent, Context))
|
||||
onToolResult func(func(ToolResultEvent, Context) *ToolResultResult)
|
||||
onInput func(func(InputEvent, Context) *InputResult)
|
||||
onBeforeAgentStart func(func(BeforeAgentStartEvent, Context) *BeforeAgentStartResult)
|
||||
onAgentStart func(func(AgentStartEvent, Context))
|
||||
onAgentEnd func(func(AgentEndEvent, Context))
|
||||
onMessageStart func(func(MessageStartEvent, Context))
|
||||
onMessageUpdate func(func(MessageUpdateEvent, Context))
|
||||
onMessageEnd func(func(MessageEndEvent, Context))
|
||||
onSessionStart func(func(SessionStartEvent, Context))
|
||||
onSessionShutdown func(func(SessionShutdownEvent, Context))
|
||||
registerToolFn func(ToolDef)
|
||||
registerCmdFn func(CommandDef)
|
||||
onToolCall func(func(ToolCallEvent, Context) *ToolCallResult)
|
||||
onToolExecStart func(func(ToolExecutionStartEvent, Context))
|
||||
onToolExecEnd func(func(ToolExecutionEndEvent, Context))
|
||||
onToolResult func(func(ToolResultEvent, Context) *ToolResultResult)
|
||||
onInput func(func(InputEvent, Context) *InputResult)
|
||||
onBeforeAgentStart func(func(BeforeAgentStartEvent, Context) *BeforeAgentStartResult)
|
||||
onAgentStart func(func(AgentStartEvent, Context))
|
||||
onAgentEnd func(func(AgentEndEvent, Context))
|
||||
onMessageStart func(func(MessageStartEvent, Context))
|
||||
onMessageUpdate func(func(MessageUpdateEvent, Context))
|
||||
onMessageEnd func(func(MessageEndEvent, Context))
|
||||
onSessionStart func(func(SessionStartEvent, Context))
|
||||
onSessionShutdown func(func(SessionShutdownEvent, Context))
|
||||
registerToolFn func(ToolDef)
|
||||
registerCmdFn func(CommandDef)
|
||||
registerToolRendererFn func(ToolRenderConfig)
|
||||
}
|
||||
|
||||
// OnToolCall registers a handler that fires before a tool executes.
|
||||
@@ -185,6 +329,240 @@ func (a *API) RegisterCommand(cmd CommandDef) {
|
||||
a.registerCmdFn(cmd)
|
||||
}
|
||||
|
||||
// RegisterToolRenderer registers a custom renderer for a specific tool's
|
||||
// display in the TUI. The renderer controls the header (parameter summary)
|
||||
// and/or body (result display) of the tool's output block. If multiple
|
||||
// extensions register renderers for the same tool name, the last one wins.
|
||||
func (a *API) RegisterToolRenderer(config ToolRenderConfig) {
|
||||
a.registerToolRendererFn(config)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Widget types (exposed to Yaegi — concrete structs, no interfaces)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// WidgetPlacement determines where a widget appears in the TUI layout
|
||||
// relative to the input area.
|
||||
type WidgetPlacement string
|
||||
|
||||
const (
|
||||
// WidgetAbove places the widget above the input area, between the
|
||||
// separator and queued messages.
|
||||
WidgetAbove WidgetPlacement = "above"
|
||||
|
||||
// WidgetBelow places the widget below the input area, between the
|
||||
// input and the status bar.
|
||||
WidgetBelow WidgetPlacement = "below"
|
||||
)
|
||||
|
||||
// WidgetContent describes what to render in a widget slot.
|
||||
type WidgetContent struct {
|
||||
// Text is the content to display.
|
||||
Text string
|
||||
|
||||
// Markdown, when true, renders Text as styled markdown instead of
|
||||
// plain text.
|
||||
Markdown bool
|
||||
}
|
||||
|
||||
// WidgetStyle configures the visual appearance of a widget.
|
||||
type WidgetStyle struct {
|
||||
// BorderColor is a hex color (e.g. "#a6e3a1") for the left border.
|
||||
// Empty uses the theme's default accent color.
|
||||
BorderColor string
|
||||
|
||||
// NoBorder disables the left border entirely.
|
||||
NoBorder bool
|
||||
}
|
||||
|
||||
// WidgetConfig fully describes a widget for placement in the TUI.
|
||||
// Extensions identify widgets by ID; calling SetWidget with the same ID
|
||||
// replaces the previous widget. IDs should be descriptive to avoid
|
||||
// collisions across extensions (e.g. "myext:token-counter").
|
||||
type WidgetConfig struct {
|
||||
// ID uniquely identifies this widget. Must be non-empty.
|
||||
ID string
|
||||
|
||||
// Placement determines where the widget appears (above or below input).
|
||||
Placement WidgetPlacement
|
||||
|
||||
// Content describes what to render.
|
||||
Content WidgetContent
|
||||
|
||||
// Style configures the appearance.
|
||||
Style WidgetStyle
|
||||
|
||||
// Priority controls ordering within a placement slot. Lower values
|
||||
// render first. Widgets with equal priority are ordered by insertion
|
||||
// time.
|
||||
Priority int
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interactive prompt types (exposed to Yaegi — concrete structs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// PromptSelectConfig configures a selection prompt that presents the user
|
||||
// with a list of options to choose from.
|
||||
type PromptSelectConfig struct {
|
||||
// Message is the question or instruction displayed to the user.
|
||||
Message string
|
||||
// Options is the list of choices the user can select from.
|
||||
Options []string
|
||||
}
|
||||
|
||||
// PromptSelectResult is the response from a selection prompt.
|
||||
type PromptSelectResult struct {
|
||||
// Value is the text of the selected option.
|
||||
Value string
|
||||
// Index is the zero-based index of the selected option.
|
||||
Index int
|
||||
// Cancelled is true if the user dismissed the prompt (ESC) or
|
||||
// the prompt was unavailable (non-interactive mode).
|
||||
Cancelled bool
|
||||
}
|
||||
|
||||
// PromptConfirmConfig configures a yes/no confirmation prompt.
|
||||
type PromptConfirmConfig struct {
|
||||
// Message is the question displayed to the user.
|
||||
Message string
|
||||
// DefaultValue is the pre-selected answer (true = Yes).
|
||||
DefaultValue bool
|
||||
}
|
||||
|
||||
// PromptConfirmResult is the response from a confirmation prompt.
|
||||
type PromptConfirmResult struct {
|
||||
// Value is true for "Yes", false for "No".
|
||||
Value bool
|
||||
// Cancelled is true if the user dismissed the prompt.
|
||||
Cancelled bool
|
||||
}
|
||||
|
||||
// PromptInputConfig configures a free-form text input prompt.
|
||||
type PromptInputConfig struct {
|
||||
// Message is the question displayed to the user.
|
||||
Message string
|
||||
// Placeholder is ghost text shown when the input is empty.
|
||||
Placeholder string
|
||||
// Default is the pre-filled value in the input field.
|
||||
Default string
|
||||
}
|
||||
|
||||
// PromptInputResult is the response from a text input prompt.
|
||||
type PromptInputResult struct {
|
||||
// Value is the text the user entered.
|
||||
Value string
|
||||
// Cancelled is true if the user dismissed the prompt.
|
||||
Cancelled bool
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Header/Footer types (exposed to Yaegi — concrete structs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// HeaderFooterConfig describes a custom header or footer region that replaces
|
||||
// or augments the default TUI chrome. Extensions use ctx.SetHeader/SetFooter
|
||||
// to place one; only one header and one footer can be active at a time (the
|
||||
// latest call wins). Reuses WidgetContent and WidgetStyle for consistency.
|
||||
type HeaderFooterConfig struct {
|
||||
// Content describes what to render.
|
||||
Content WidgetContent
|
||||
|
||||
// Style configures the appearance.
|
||||
Style WidgetStyle
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Overlay types (exposed to Yaegi — concrete structs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// OverlayAnchor determines the vertical position of an overlay dialog
|
||||
// within the TUI view.
|
||||
type OverlayAnchor string
|
||||
|
||||
const (
|
||||
// OverlayCenter positions the dialog in the vertical center.
|
||||
OverlayCenter OverlayAnchor = "center"
|
||||
|
||||
// OverlayTopCenter positions the dialog near the top of the view.
|
||||
OverlayTopCenter OverlayAnchor = "top-center"
|
||||
|
||||
// OverlayBottomCenter positions the dialog near the bottom of the view.
|
||||
OverlayBottomCenter OverlayAnchor = "bottom-center"
|
||||
)
|
||||
|
||||
// OverlayStyle configures the visual appearance of an overlay dialog.
|
||||
type OverlayStyle struct {
|
||||
// BorderColor is a hex color (e.g. "#89b4fa") for the dialog border.
|
||||
// Empty uses a default blue accent.
|
||||
BorderColor string
|
||||
|
||||
// Background is a hex color (e.g. "#1e1e2e") for the dialog background.
|
||||
// Empty means no explicit background (inherits terminal default).
|
||||
Background string
|
||||
}
|
||||
|
||||
// OverlayConfig fully describes a modal overlay dialog. Extensions call
|
||||
// ctx.ShowOverlay(config) to display the dialog and block until the user
|
||||
// dismisses it or selects an action. The dialog renders as a bordered box
|
||||
// positioned within the TUI, with optional scrollable content and action
|
||||
// buttons.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// result := ctx.ShowOverlay(ext.OverlayConfig{
|
||||
// Title: "Build Results",
|
||||
// Content: ext.WidgetContent{Text: "All 42 tests passed."},
|
||||
// Style: ext.OverlayStyle{BorderColor: "#a6e3a1"},
|
||||
// Width: 60,
|
||||
// Actions: []string{"Continue", "Show Details"},
|
||||
// })
|
||||
type OverlayConfig struct {
|
||||
// Title is displayed at the top of the dialog. Empty means no title.
|
||||
Title string
|
||||
|
||||
// Content describes what to render inside the dialog body. The Text
|
||||
// field is required; set Markdown=true to render as styled markdown.
|
||||
Content WidgetContent
|
||||
|
||||
// Style configures the appearance.
|
||||
Style OverlayStyle
|
||||
|
||||
// Width is the dialog width in columns. 0 = 60% of terminal width.
|
||||
// Clamped to [30, termWidth-4].
|
||||
Width int
|
||||
|
||||
// MaxHeight limits the dialog height in lines. 0 = 80% of terminal
|
||||
// height. Content exceeding this height becomes scrollable.
|
||||
MaxHeight int
|
||||
|
||||
// Anchor determines vertical positioning. Default is "center".
|
||||
Anchor OverlayAnchor
|
||||
|
||||
// Actions, if non-empty, shows selectable action buttons at the
|
||||
// bottom of the dialog. The user navigates with left/right arrows
|
||||
// and selects with Enter. The selected action's text and index are
|
||||
// returned in OverlayResult.
|
||||
//
|
||||
// If empty, the dialog is a simple info panel dismissed with ESC
|
||||
// or Enter (result.Cancelled=false, result.Action="", result.Index=-1).
|
||||
Actions []string
|
||||
}
|
||||
|
||||
// OverlayResult is the response from a ShowOverlay call.
|
||||
type OverlayResult struct {
|
||||
// Action is the text of the selected action, or "" if no actions
|
||||
// were configured or the dialog was dismissed without selection.
|
||||
Action string
|
||||
|
||||
// Index is the zero-based index of the selected action, or -1 if
|
||||
// no action was selected.
|
||||
Index int
|
||||
|
||||
// Cancelled is true if the user dismissed the dialog with ESC.
|
||||
Cancelled bool
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ToolDef / CommandDef
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -204,6 +582,150 @@ type CommandDef struct {
|
||||
Execute func(args string, ctx Context) (string, error)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom tool rendering (exposed to Yaegi — concrete structs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ToolRenderConfig provides custom rendering functions for a tool's display
|
||||
// in the TUI. Extensions register tool renderers via API.RegisterToolRenderer()
|
||||
// during Init. Both render functions are optional — if nil or if they return
|
||||
// an empty string, the builtin renderer (or default) is used as a fallback.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// api.RegisterToolRenderer(ext.ToolRenderConfig{
|
||||
// ToolName: "my-tool",
|
||||
// RenderHeader: func(toolArgs string, width int) string {
|
||||
// // Parse args and return a compact summary for the header
|
||||
// return "my-tool: doing something"
|
||||
// },
|
||||
// RenderBody: func(toolResult string, isError bool, width int) string {
|
||||
// // Return custom formatted result body
|
||||
// if isError {
|
||||
// return "ERROR: " + toolResult
|
||||
// }
|
||||
// return "Result: " + toolResult
|
||||
// },
|
||||
// })
|
||||
type ToolRenderConfig struct {
|
||||
// ToolName is the name of the tool this renderer applies to. Must match
|
||||
// the tool's registered name exactly (e.g. "bash", "read", "my-tool").
|
||||
ToolName string
|
||||
|
||||
// DisplayName, if non-empty, replaces the auto-capitalized tool name
|
||||
// shown in the header line (e.g. "Shell" instead of "Bash").
|
||||
DisplayName string
|
||||
|
||||
// BorderColor, if non-empty, overrides the default border color for
|
||||
// the tool result block. Accepts a hex color string (e.g. "#89b4fa").
|
||||
// By default, the border is green for success and red for error.
|
||||
BorderColor string
|
||||
|
||||
// Background, if non-empty, sets a background color for the entire
|
||||
// tool result block. Accepts a hex color string (e.g. "#1e1e2e").
|
||||
// By default, no background is applied.
|
||||
Background string
|
||||
|
||||
// BodyMarkdown, when true, passes the RenderBody output through the
|
||||
// glamour markdown renderer before display. This lets extensions return
|
||||
// markdown-formatted text without needing access to Kit's internal
|
||||
// rendering functions. Ignored when RenderBody is nil or returns empty.
|
||||
BodyMarkdown bool
|
||||
|
||||
// RenderHeader, if non-nil, replaces the default parameter formatting
|
||||
// in the tool header line. Receives the JSON-encoded arguments and the
|
||||
// maximum width in columns. Return a short summary string for display
|
||||
// after the tool name, or empty string to fall back to default formatting.
|
||||
RenderHeader func(toolArgs string, width int) string
|
||||
|
||||
// RenderBody, if non-nil, replaces the default tool result body rendering.
|
||||
// Receives the result text, error flag, and available width in columns.
|
||||
// Return the full styled body content, or empty string to fall back to
|
||||
// the builtin renderer (or default).
|
||||
RenderBody func(toolResult string, isError bool, width int) string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Editor interceptor types (exposed to Yaegi — concrete structs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// EditorKeyActionType defines the outcome of an editor key interception.
|
||||
type EditorKeyActionType string
|
||||
|
||||
const (
|
||||
// EditorKeyPassthrough lets the built-in editor handle the key normally.
|
||||
EditorKeyPassthrough EditorKeyActionType = "passthrough"
|
||||
|
||||
// EditorKeyConsumed means the extension handled the key. The editor
|
||||
// should re-render but not process the key further.
|
||||
EditorKeyConsumed EditorKeyActionType = "consumed"
|
||||
|
||||
// EditorKeyRemap transforms the key into a different key before passing
|
||||
// it to the built-in editor. Use RemappedKey to specify the target
|
||||
// (e.g., "left", "right", "up", "down", "backspace", "delete", "enter",
|
||||
// "tab", "home", "end", or a single character like "a").
|
||||
EditorKeyRemap EditorKeyActionType = "remap"
|
||||
|
||||
// EditorKeySubmit forces immediate text submission. The SubmitText field
|
||||
// specifies the text to submit (empty = use editor's current text).
|
||||
EditorKeySubmit EditorKeyActionType = "submit"
|
||||
)
|
||||
|
||||
// EditorKeyAction is returned by an editor interceptor's HandleKey function
|
||||
// to indicate how a key press should be handled.
|
||||
type EditorKeyAction struct {
|
||||
// Type determines the action taken.
|
||||
Type EditorKeyActionType
|
||||
|
||||
// RemappedKey is the target key name for EditorKeyRemap. Must be a
|
||||
// recognized key name (e.g., "left", "right", "up", "down", "backspace",
|
||||
// "delete", "enter", "tab", "home", "end", "esc", "space", or a single
|
||||
// printable character).
|
||||
RemappedKey string
|
||||
|
||||
// SubmitText is the text to submit for EditorKeySubmit. If empty, the
|
||||
// editor's current content is submitted instead.
|
||||
SubmitText string
|
||||
}
|
||||
|
||||
// EditorConfig defines an editor interceptor/decorator that wraps the built-in
|
||||
// input editor. Extensions can intercept key events (remap, consume, or force
|
||||
// submit) and/or modify the rendered output (add mode indicators, apply visual
|
||||
// effects).
|
||||
//
|
||||
// This follows Pi's extension editor pattern (modal editor, rainbow editor)
|
||||
// but uses concrete function fields instead of interfaces for Yaegi safety.
|
||||
//
|
||||
// IMPORTANT (Yaegi limitation): Function fields MUST be set using anonymous
|
||||
// function literals (closures), NOT bare function references. Yaegi does not
|
||||
// correctly propagate return values from named function references assigned to
|
||||
// struct fields. Wrap any named function in a closure:
|
||||
//
|
||||
// // WRONG — Yaegi returns zero values:
|
||||
// ctx.SetEditor(ext.EditorConfig{HandleKey: myHandler, Render: myRender})
|
||||
//
|
||||
// // CORRECT — closure wrapper works:
|
||||
// ctx.SetEditor(ext.EditorConfig{
|
||||
// HandleKey: func(k string, t string) ext.EditorKeyAction { return myHandler(k, t) },
|
||||
// Render: func(w int, c string) string { return myRender(w, c) },
|
||||
// })
|
||||
type EditorConfig struct {
|
||||
// HandleKey intercepts key presses before they reach the built-in editor.
|
||||
// It receives the key name (e.g., "a", "enter", "ctrl+c", "backspace")
|
||||
// and the editor's current text content. Return an EditorKeyAction to
|
||||
// control how the key is handled.
|
||||
//
|
||||
// If nil, all keys pass through to the built-in editor unchanged.
|
||||
HandleKey func(key string, currentText string) EditorKeyAction
|
||||
|
||||
// Render wraps the built-in editor's rendered output. It receives the
|
||||
// available width and the default-rendered content (including title,
|
||||
// textarea, popup, and help text). Return the modified content to display.
|
||||
//
|
||||
// If nil, the default rendering is used unchanged.
|
||||
Render func(width int, defaultContent string) string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Typed events (all concrete structs — safe for Yaegi)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -41,7 +41,8 @@ func LoadExtensions(extraPaths []string) ([]LoadedExtension, error) {
|
||||
log.Debug("loaded extension", "path", p,
|
||||
"handlers", countHandlers(ext),
|
||||
"tools", len(ext.Tools),
|
||||
"commands", len(ext.Commands))
|
||||
"commands", len(ext.Commands),
|
||||
"tool_renderers", len(ext.ToolRenderers))
|
||||
}
|
||||
return loaded, nil
|
||||
}
|
||||
@@ -288,6 +289,9 @@ func loadSingleExtension(path string) (*LoadedExtension, error) {
|
||||
registerCmdFn: func(cmd CommandDef) {
|
||||
ext.Commands = append(ext.Commands, cmd)
|
||||
},
|
||||
registerToolRendererFn: func(config ToolRenderConfig) {
|
||||
ext.ToolRenderers = append(ext.ToolRenderers, config)
|
||||
},
|
||||
}
|
||||
|
||||
// Call Init — the extension registers its handlers, tools, commands.
|
||||
|
||||
@@ -2,6 +2,7 @@ package extensions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
@@ -11,19 +12,24 @@ import (
|
||||
// sequentially, mirroring Pi's ExtensionRunner. Handlers execute in extension
|
||||
// load order; for cancellable events the first blocking result wins.
|
||||
type Runner struct {
|
||||
extensions []LoadedExtension
|
||||
ctx Context
|
||||
mu sync.RWMutex
|
||||
extensions []LoadedExtension
|
||||
ctx Context
|
||||
widgets map[string]WidgetConfig // keyed by widget ID
|
||||
header *HeaderFooterConfig // nil = no custom header
|
||||
footer *HeaderFooterConfig // nil = no custom footer
|
||||
customEditor *EditorConfig // nil = no custom editor interceptor
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// LoadedExtension represents a single extension that has been discovered,
|
||||
// loaded, and initialised. It holds the registered handlers and any custom
|
||||
// tools or commands the extension provided.
|
||||
// tools, commands, or tool renderers the extension provided.
|
||||
type LoadedExtension struct {
|
||||
Path string
|
||||
Handlers map[EventType][]HandlerFunc
|
||||
Tools []ToolDef
|
||||
Commands []CommandDef
|
||||
Path string
|
||||
Handlers map[EventType][]HandlerFunc
|
||||
Tools []ToolDef
|
||||
Commands []CommandDef
|
||||
ToolRenderers []ToolRenderConfig
|
||||
}
|
||||
|
||||
// NewRunner creates a Runner from a set of loaded extensions.
|
||||
@@ -127,6 +133,163 @@ func (r *Runner) Extensions() []LoadedExtension {
|
||||
return r.extensions
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Widget management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SetWidget places or updates a persistent widget. The widget is identified
|
||||
// by config.ID; calling SetWidget with the same ID replaces the previous
|
||||
// content. Thread-safe.
|
||||
func (r *Runner) SetWidget(config WidgetConfig) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.widgets == nil {
|
||||
r.widgets = make(map[string]WidgetConfig)
|
||||
}
|
||||
r.widgets[config.ID] = config
|
||||
}
|
||||
|
||||
// RemoveWidget removes a widget by ID. No-op if the ID does not exist.
|
||||
// Thread-safe.
|
||||
func (r *Runner) RemoveWidget(id string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
delete(r.widgets, id)
|
||||
}
|
||||
|
||||
// GetWidgets returns all widgets matching the given placement, sorted by
|
||||
// priority (ascending). Thread-safe.
|
||||
func (r *Runner) GetWidgets(placement WidgetPlacement) []WidgetConfig {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
var result []WidgetConfig
|
||||
for _, w := range r.widgets {
|
||||
if w.Placement == placement {
|
||||
result = append(result, w)
|
||||
}
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
if result[i].Priority != result[j].Priority {
|
||||
return result[i].Priority < result[j].Priority
|
||||
}
|
||||
return result[i].ID < result[j].ID // stable tie-break
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Header/Footer management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SetHeader places or replaces the custom header. Thread-safe.
|
||||
func (r *Runner) SetHeader(config HeaderFooterConfig) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.header = &config
|
||||
}
|
||||
|
||||
// RemoveHeader removes the custom header. No-op if none is set. Thread-safe.
|
||||
func (r *Runner) RemoveHeader() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.header = nil
|
||||
}
|
||||
|
||||
// GetHeader returns the current custom header, or nil if none is set.
|
||||
// Thread-safe.
|
||||
func (r *Runner) GetHeader() *HeaderFooterConfig {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
if r.header == nil {
|
||||
return nil
|
||||
}
|
||||
// Return a copy to avoid races on the caller side.
|
||||
h := *r.header
|
||||
return &h
|
||||
}
|
||||
|
||||
// SetFooter places or replaces the custom footer. Thread-safe.
|
||||
func (r *Runner) SetFooter(config HeaderFooterConfig) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.footer = &config
|
||||
}
|
||||
|
||||
// RemoveFooter removes the custom footer. No-op if none is set. Thread-safe.
|
||||
func (r *Runner) RemoveFooter() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.footer = nil
|
||||
}
|
||||
|
||||
// GetFooter returns the current custom footer, or nil if none is set.
|
||||
// Thread-safe.
|
||||
func (r *Runner) GetFooter() *HeaderFooterConfig {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
if r.footer == nil {
|
||||
return nil
|
||||
}
|
||||
// Return a copy to avoid races on the caller side.
|
||||
f := *r.footer
|
||||
return &f
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Editor interceptor management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SetEditor installs an editor interceptor that wraps the built-in input
|
||||
// editor. Only one interceptor is active at a time; calling SetEditor replaces
|
||||
// any previous interceptor. Thread-safe.
|
||||
func (r *Runner) SetEditor(config EditorConfig) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.customEditor = &config
|
||||
}
|
||||
|
||||
// ResetEditor removes the active editor interceptor and restores the default
|
||||
// built-in editor behavior. No-op if no interceptor is set. Thread-safe.
|
||||
func (r *Runner) ResetEditor() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.customEditor = nil
|
||||
}
|
||||
|
||||
// GetEditor returns the current editor interceptor, or nil if none is set.
|
||||
// Thread-safe. Returns a shallow copy — function fields are reference types
|
||||
// so the copy is safe.
|
||||
func (r *Runner) GetEditor() *EditorConfig {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
if r.customEditor == nil {
|
||||
return nil
|
||||
}
|
||||
e := *r.customEditor
|
||||
return &e
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool renderer management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GetToolRenderer returns the custom renderer for the named tool, or nil if
|
||||
// no extension registered a renderer for it. If multiple extensions register
|
||||
// renderers for the same tool, the last one (by load order) wins. Thread-safe
|
||||
// (extensions are immutable after loading).
|
||||
func (r *Runner) GetToolRenderer(toolName string) *ToolRenderConfig {
|
||||
// Walk extensions in reverse so last-registered wins.
|
||||
for i := len(r.extensions) - 1; i >= 0; i-- {
|
||||
for j := len(r.extensions[i].ToolRenderers) - 1; j >= 0; j-- {
|
||||
if r.extensions[i].ToolRenderers[j].ToolName == toolName {
|
||||
config := r.extensions[i].ToolRenderers[j]
|
||||
return &config
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -26,6 +26,46 @@ func Symbols() interp.Exports {
|
||||
"CommandDef": reflect.ValueOf((*CommandDef)(nil)),
|
||||
"PrintBlockOpts": reflect.ValueOf((*PrintBlockOpts)(nil)),
|
||||
|
||||
// Widget types
|
||||
"WidgetConfig": reflect.ValueOf((*WidgetConfig)(nil)),
|
||||
"WidgetContent": reflect.ValueOf((*WidgetContent)(nil)),
|
||||
"WidgetStyle": reflect.ValueOf((*WidgetStyle)(nil)),
|
||||
"WidgetPlacement": reflect.ValueOf((*WidgetPlacement)(nil)),
|
||||
"WidgetAbove": reflect.ValueOf(WidgetAbove),
|
||||
"WidgetBelow": reflect.ValueOf(WidgetBelow),
|
||||
|
||||
// Header/Footer types
|
||||
"HeaderFooterConfig": reflect.ValueOf((*HeaderFooterConfig)(nil)),
|
||||
|
||||
// Overlay types
|
||||
"OverlayAnchor": reflect.ValueOf((*OverlayAnchor)(nil)),
|
||||
"OverlayCenter": reflect.ValueOf(OverlayCenter),
|
||||
"OverlayTopCenter": reflect.ValueOf(OverlayTopCenter),
|
||||
"OverlayBottomCenter": reflect.ValueOf(OverlayBottomCenter),
|
||||
"OverlayStyle": reflect.ValueOf((*OverlayStyle)(nil)),
|
||||
"OverlayConfig": reflect.ValueOf((*OverlayConfig)(nil)),
|
||||
"OverlayResult": reflect.ValueOf((*OverlayResult)(nil)),
|
||||
|
||||
// Tool renderer types
|
||||
"ToolRenderConfig": reflect.ValueOf((*ToolRenderConfig)(nil)),
|
||||
|
||||
// Editor interceptor types
|
||||
"EditorKeyActionType": reflect.ValueOf((*EditorKeyActionType)(nil)),
|
||||
"EditorKeyPassthrough": reflect.ValueOf(EditorKeyPassthrough),
|
||||
"EditorKeyConsumed": reflect.ValueOf(EditorKeyConsumed),
|
||||
"EditorKeyRemap": reflect.ValueOf(EditorKeyRemap),
|
||||
"EditorKeySubmit": reflect.ValueOf(EditorKeySubmit),
|
||||
"EditorKeyAction": reflect.ValueOf((*EditorKeyAction)(nil)),
|
||||
"EditorConfig": reflect.ValueOf((*EditorConfig)(nil)),
|
||||
|
||||
// Prompt types
|
||||
"PromptSelectConfig": reflect.ValueOf((*PromptSelectConfig)(nil)),
|
||||
"PromptSelectResult": reflect.ValueOf((*PromptSelectResult)(nil)),
|
||||
"PromptConfirmConfig": reflect.ValueOf((*PromptConfirmConfig)(nil)),
|
||||
"PromptConfirmResult": reflect.ValueOf((*PromptConfirmResult)(nil)),
|
||||
"PromptInputConfig": reflect.ValueOf((*PromptInputConfig)(nil)),
|
||||
"PromptInputResult": reflect.ValueOf((*PromptInputResult)(nil)),
|
||||
|
||||
// Event structs
|
||||
"ToolCallEvent": reflect.ValueOf((*ToolCallEvent)(nil)),
|
||||
"ToolCallResult": reflect.ValueOf((*ToolCallResult)(nil)),
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
package kit
|
||||
// Package kitsetup contains agent creation logic used by both the CLI binary
|
||||
// and the SDK's kit.New(). It is internal — external SDK consumers should use
|
||||
// kit.New() which delegates here.
|
||||
package kitsetup
|
||||
|
||||
import (
|
||||
"context"
|
||||
+38
-101
@@ -10,7 +10,7 @@ import (
|
||||
type blockRenderer struct {
|
||||
align *lipgloss.Position
|
||||
borderColor *color.Color
|
||||
bgColor *color.Color
|
||||
background *color.Color
|
||||
fullWidth bool
|
||||
noBorder bool
|
||||
paddingTop int
|
||||
@@ -34,14 +34,6 @@ func WithFullWidth() renderingOption {
|
||||
}
|
||||
}
|
||||
|
||||
// WithBackground returns a renderingOption that sets a background color
|
||||
// for the entire block.
|
||||
func WithBackground(c color.Color) renderingOption {
|
||||
return func(br *blockRenderer) {
|
||||
br.bgColor = &c
|
||||
}
|
||||
}
|
||||
|
||||
// WithNoBorder returns a renderingOption that disables all borders on the
|
||||
// block, rendering content with only padding.
|
||||
func WithNoBorder() renderingOption {
|
||||
@@ -122,6 +114,15 @@ func WithPaddingBottom(padding int) renderingOption {
|
||||
}
|
||||
}
|
||||
|
||||
// WithBackground returns a renderingOption that sets the background color
|
||||
// for the entire block. The color parameter accepts any color.Color value,
|
||||
// typically a lipgloss hex color (e.g. lipgloss.Color("#1e1e2e")).
|
||||
func WithBackground(c color.Color) renderingOption {
|
||||
return func(br *blockRenderer) {
|
||||
br.background = &c
|
||||
}
|
||||
}
|
||||
|
||||
// WithWidth returns a renderingOption that sets a specific width for the block
|
||||
// in characters. This overrides the default container width and allows precise
|
||||
// control over the block's horizontal dimensions.
|
||||
@@ -165,104 +166,40 @@ func renderContentBlock(content string, containerWidth int, options ...rendering
|
||||
}
|
||||
|
||||
theme := GetTheme()
|
||||
hasBg := renderer.bgColor != nil
|
||||
|
||||
if hasBg {
|
||||
// When a background color is set we use a three-phase render so
|
||||
// the border extends the full block height including padding:
|
||||
// 1. Render content with bg + horizontal padding (no border,
|
||||
// no vertical padding).
|
||||
// 2. Use Place() to add vertical padding with uniform bg fill.
|
||||
// 3. Apply the border to the padded block.
|
||||
// Single-pass render: padding, border, and foreground in one style.
|
||||
style := lipgloss.NewStyle().
|
||||
PaddingLeft(renderer.paddingLeft).
|
||||
PaddingRight(renderer.paddingRight).
|
||||
PaddingTop(renderer.paddingTop).
|
||||
PaddingBottom(renderer.paddingBottom).
|
||||
Foreground(theme.Text)
|
||||
|
||||
// Phase 1 — content with background + horizontal padding.
|
||||
innerStyle := lipgloss.NewStyle().
|
||||
PaddingLeft(renderer.paddingLeft).
|
||||
PaddingRight(renderer.paddingRight).
|
||||
Foreground(theme.Text).
|
||||
Background(*renderer.bgColor)
|
||||
if hasBorder {
|
||||
style = style.BorderStyle(lipgloss.ThickBorder())
|
||||
|
||||
if renderer.fullWidth {
|
||||
innerStyle = innerStyle.Width(renderer.width - borderChars)
|
||||
switch borderAlign {
|
||||
case lipgloss.Right:
|
||||
style = style.
|
||||
BorderRight(true).
|
||||
BorderRightForeground(borderColor)
|
||||
default:
|
||||
style = style.
|
||||
BorderLeft(true).
|
||||
BorderLeftForeground(borderColor)
|
||||
}
|
||||
|
||||
content = innerStyle.Render(content)
|
||||
|
||||
// Phase 2 — vertical padding via Place() with bg-filled whitespace.
|
||||
if renderer.paddingTop > 0 || renderer.paddingBottom > 0 {
|
||||
renderedH := lipgloss.Height(content)
|
||||
renderedW := lipgloss.Width(content)
|
||||
totalH := renderedH + renderer.paddingTop + renderer.paddingBottom
|
||||
|
||||
bgStyle := lipgloss.NewStyle().Background(*renderer.bgColor)
|
||||
|
||||
// Determine vertical position so padding distributes correctly.
|
||||
vPos := lipgloss.Center
|
||||
switch {
|
||||
case renderer.paddingTop > 0 && renderer.paddingBottom == 0:
|
||||
vPos = lipgloss.Bottom
|
||||
case renderer.paddingBottom > 0 && renderer.paddingTop == 0:
|
||||
vPos = lipgloss.Top
|
||||
}
|
||||
|
||||
content = lipgloss.Place(
|
||||
renderedW, totalH,
|
||||
lipgloss.Left, vPos,
|
||||
content,
|
||||
lipgloss.WithWhitespaceStyle(bgStyle),
|
||||
)
|
||||
}
|
||||
|
||||
// Phase 3 — apply border to the full-height block.
|
||||
if hasBorder {
|
||||
borderStyle := lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.ThickBorder())
|
||||
|
||||
switch borderAlign {
|
||||
case lipgloss.Right:
|
||||
borderStyle = borderStyle.
|
||||
BorderRight(true).
|
||||
BorderRightForeground(borderColor)
|
||||
default:
|
||||
borderStyle = borderStyle.
|
||||
BorderLeft(true).
|
||||
BorderLeftForeground(borderColor)
|
||||
}
|
||||
|
||||
content = borderStyle.Render(content)
|
||||
}
|
||||
} else {
|
||||
// No background — PaddingTop/PaddingBottom work fine (no visible
|
||||
// banding), so render everything in a single style pass.
|
||||
style := lipgloss.NewStyle().
|
||||
PaddingLeft(renderer.paddingLeft).
|
||||
PaddingRight(renderer.paddingRight).
|
||||
PaddingTop(renderer.paddingTop).
|
||||
PaddingBottom(renderer.paddingBottom).
|
||||
Foreground(theme.Text)
|
||||
|
||||
if hasBorder {
|
||||
style = style.BorderStyle(lipgloss.ThickBorder())
|
||||
|
||||
switch borderAlign {
|
||||
case lipgloss.Right:
|
||||
style = style.
|
||||
BorderRight(true).
|
||||
BorderRightForeground(borderColor)
|
||||
default:
|
||||
style = style.
|
||||
BorderLeft(true).
|
||||
BorderLeftForeground(borderColor)
|
||||
}
|
||||
}
|
||||
|
||||
if renderer.fullWidth {
|
||||
style = style.Width(renderer.width - borderChars)
|
||||
}
|
||||
|
||||
content = style.Render(content)
|
||||
}
|
||||
|
||||
if renderer.background != nil {
|
||||
style = style.Background(*renderer.background)
|
||||
}
|
||||
|
||||
if renderer.fullWidth {
|
||||
style = style.Width(renderer.width - borderChars)
|
||||
}
|
||||
|
||||
content = style.Render(content)
|
||||
|
||||
// Add margins
|
||||
if renderer.marginTop > 0 {
|
||||
for range renderer.marginTop {
|
||||
|
||||
+25
-109
@@ -15,15 +15,12 @@ import (
|
||||
// display modes, handles streaming responses, tracks token usage, and manages the
|
||||
// overall conversation flow between the user and AI assistants.
|
||||
type CLI struct {
|
||||
messageRenderer *MessageRenderer
|
||||
compactRenderer *CompactRenderer
|
||||
messageContainer *MessageContainer
|
||||
usageTracker *UsageTracker
|
||||
width int
|
||||
height int
|
||||
compactMode bool
|
||||
debug bool
|
||||
modelName string
|
||||
renderer Renderer
|
||||
usageTracker *UsageTracker
|
||||
width int
|
||||
compactMode bool
|
||||
debug bool
|
||||
modelName string
|
||||
}
|
||||
|
||||
// NewCLI creates and initializes a new CLI instance with the specified display modes.
|
||||
@@ -36,9 +33,11 @@ func NewCLI(debug bool, compact bool) (*CLI, error) {
|
||||
debug: debug,
|
||||
}
|
||||
cli.updateSize()
|
||||
cli.messageRenderer = NewMessageRenderer(cli.width, debug)
|
||||
cli.compactRenderer = NewCompactRenderer(cli.width, debug)
|
||||
cli.messageContainer = NewMessageContainer(cli.width, cli.height-4, compact) // Pass compact mode
|
||||
if compact {
|
||||
cli.renderer = NewCompactRenderer(cli.width, debug)
|
||||
} else {
|
||||
cli.renderer = NewMessageRenderer(cli.width, debug)
|
||||
}
|
||||
|
||||
return cli, nil
|
||||
}
|
||||
@@ -71,9 +70,6 @@ func (c *CLI) GetDebugLogger() *CLIDebugLogger {
|
||||
// This name is displayed in message headers to indicate which model is responding.
|
||||
func (c *CLI) SetModelName(modelName string) {
|
||||
c.modelName = modelName
|
||||
if c.messageContainer != nil {
|
||||
c.messageContainer.SetModelName(modelName)
|
||||
}
|
||||
}
|
||||
|
||||
// ShowSpinner displays an animated spinner while executing the provided action
|
||||
@@ -94,14 +90,7 @@ func (c *CLI) ShowSpinner(action func() error) error {
|
||||
// formatting based on the current display mode (standard or compact). The message
|
||||
// is timestamped and styled according to the active theme.
|
||||
func (c *CLI) DisplayUserMessage(message string) {
|
||||
var msg UIMessage
|
||||
if c.compactMode {
|
||||
msg = c.compactRenderer.RenderUserMessage(message, time.Now())
|
||||
} else {
|
||||
msg = c.messageRenderer.RenderUserMessage(message, time.Now())
|
||||
}
|
||||
c.messageContainer.AddMessage(msg)
|
||||
c.displayContainer()
|
||||
fmt.Println(c.renderer.RenderUserMessage(message, time.Now()).Content)
|
||||
}
|
||||
|
||||
// DisplayAssistantMessage renders and displays an AI assistant's response message
|
||||
@@ -115,14 +104,7 @@ func (c *CLI) DisplayAssistantMessage(message string) error {
|
||||
// with the specified model name shown in the message header. The message is
|
||||
// formatted according to the current display mode and includes timestamp information.
|
||||
func (c *CLI) DisplayAssistantMessageWithModel(message, modelName string) error {
|
||||
var msg UIMessage
|
||||
if c.compactMode {
|
||||
msg = c.compactRenderer.RenderAssistantMessage(message, time.Now(), modelName)
|
||||
} else {
|
||||
msg = c.messageRenderer.RenderAssistantMessage(message, time.Now(), modelName)
|
||||
}
|
||||
c.messageContainer.AddMessage(msg)
|
||||
c.displayContainer()
|
||||
fmt.Println(c.renderer.RenderAssistantMessage(message, time.Now(), modelName).Content)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -137,44 +119,21 @@ func (c *CLI) DisplayToolCallMessage(toolName, toolArgs string) {
|
||||
// including the tool name, arguments, and result. The isError parameter determines
|
||||
// whether the result should be displayed as an error or success message.
|
||||
func (c *CLI) DisplayToolMessage(toolName, toolArgs, toolResult string, isError bool) {
|
||||
var msg UIMessage
|
||||
if c.compactMode {
|
||||
msg = c.compactRenderer.RenderToolMessage(toolName, toolArgs, toolResult, isError)
|
||||
} else {
|
||||
msg = c.messageRenderer.RenderToolMessage(toolName, toolArgs, toolResult, isError)
|
||||
}
|
||||
|
||||
// Always display immediately - spinner management is handled externally
|
||||
c.messageContainer.AddMessage(msg)
|
||||
c.displayContainer()
|
||||
fmt.Println(c.renderer.RenderToolMessage(toolName, toolArgs, toolResult, isError).Content)
|
||||
}
|
||||
|
||||
// DisplayError renders and displays an error message with distinctive formatting
|
||||
// to ensure visibility. The error is timestamped and styled according to the
|
||||
// current display mode's error theme.
|
||||
func (c *CLI) DisplayError(err error) {
|
||||
var msg UIMessage
|
||||
if c.compactMode {
|
||||
msg = c.compactRenderer.RenderErrorMessage(err.Error(), time.Now())
|
||||
} else {
|
||||
msg = c.messageRenderer.RenderErrorMessage(err.Error(), time.Now())
|
||||
}
|
||||
c.messageContainer.AddMessage(msg)
|
||||
c.displayContainer()
|
||||
fmt.Println(c.renderer.RenderErrorMessage(err.Error(), time.Now()).Content)
|
||||
}
|
||||
|
||||
// DisplayInfo renders and displays an informational system message. These messages
|
||||
// are typically used for status updates, notifications, or other non-error system
|
||||
// communications to the user.
|
||||
func (c *CLI) DisplayInfo(message string) {
|
||||
var msg UIMessage
|
||||
if c.compactMode {
|
||||
msg = c.compactRenderer.RenderSystemMessage(message, time.Now())
|
||||
} else {
|
||||
msg = c.messageRenderer.RenderSystemMessage(message, time.Now())
|
||||
}
|
||||
c.messageContainer.AddMessage(msg)
|
||||
c.displayContainer()
|
||||
fmt.Println(c.renderer.RenderSystemMessage(message, time.Now()).Content)
|
||||
}
|
||||
|
||||
// DisplayExtensionBlock renders a custom styled block with the given border
|
||||
@@ -195,7 +154,7 @@ func (c *CLI) DisplayExtensionBlock(text, borderColor, subtitle string) {
|
||||
|
||||
rendered := renderContentBlock(
|
||||
content,
|
||||
c.messageRenderer.width,
|
||||
c.width,
|
||||
WithAlign(lipgloss.Left),
|
||||
WithBorderColor(borderClr),
|
||||
WithMarginBottom(1),
|
||||
@@ -206,14 +165,7 @@ func (c *CLI) DisplayExtensionBlock(text, borderColor, subtitle string) {
|
||||
// DisplayCancellation displays a system message indicating that the current
|
||||
// AI generation has been cancelled by the user (typically via ESC key).
|
||||
func (c *CLI) DisplayCancellation() {
|
||||
var msg UIMessage
|
||||
if c.compactMode {
|
||||
msg = c.compactRenderer.RenderSystemMessage("Generation cancelled by user (ESC pressed)", time.Now())
|
||||
} else {
|
||||
msg = c.messageRenderer.RenderSystemMessage("Generation cancelled by user (ESC pressed)", time.Now())
|
||||
}
|
||||
c.messageContainer.AddMessage(msg)
|
||||
c.displayContainer()
|
||||
fmt.Println(c.renderer.RenderSystemMessage("Generation cancelled by user (ESC pressed)", time.Now()).Content)
|
||||
}
|
||||
|
||||
// DisplayDebugMessage renders and displays a debug message if debug mode is enabled.
|
||||
@@ -223,42 +175,14 @@ func (c *CLI) DisplayDebugMessage(message string) {
|
||||
if !c.debug {
|
||||
return
|
||||
}
|
||||
var msg UIMessage
|
||||
if c.compactMode {
|
||||
msg = c.compactRenderer.RenderDebugMessage(message, time.Now())
|
||||
} else {
|
||||
msg = c.messageRenderer.RenderDebugMessage(message, time.Now())
|
||||
}
|
||||
c.messageContainer.AddMessage(msg)
|
||||
c.displayContainer()
|
||||
fmt.Println(c.renderer.RenderDebugMessage(message, time.Now()).Content)
|
||||
}
|
||||
|
||||
// DisplayDebugConfig renders and displays configuration settings in a formatted
|
||||
// debug message. The config parameter should contain key-value pairs representing
|
||||
// configuration options that will be displayed for debugging purposes.
|
||||
func (c *CLI) DisplayDebugConfig(config map[string]any) {
|
||||
var msg UIMessage
|
||||
if c.compactMode {
|
||||
msg = c.compactRenderer.RenderDebugConfigMessage(config, time.Now())
|
||||
} else {
|
||||
msg = c.messageRenderer.RenderDebugConfigMessage(config, time.Now())
|
||||
}
|
||||
c.messageContainer.AddMessage(msg)
|
||||
c.displayContainer()
|
||||
}
|
||||
|
||||
// displayContainer renders and displays the message container for one-shot
|
||||
// (non-streaming) messages. Output matches the interactive TUI's tea.Println
|
||||
// path — no extra padding or width wrapping is applied so both modes produce
|
||||
// identical visual output.
|
||||
func (c *CLI) displayContainer() {
|
||||
content := c.messageContainer.Render()
|
||||
if content != "" {
|
||||
fmt.Println(content)
|
||||
}
|
||||
|
||||
// Clear messages after display; one-shot messages don't need to persist.
|
||||
c.messageContainer.messages = nil
|
||||
fmt.Println(c.renderer.RenderDebugConfigMessage(config, time.Now()).Content)
|
||||
}
|
||||
|
||||
// UpdateUsageFromResponse records token usage using metadata from the fantasy
|
||||
@@ -309,27 +233,19 @@ func (c *CLI) DisplayUsageAfterResponse() {
|
||||
|
||||
// updateSize updates the CLI size based on terminal dimensions
|
||||
func (c *CLI) updateSize() {
|
||||
width, height, err := term.GetSize(int(os.Stdout.Fd()))
|
||||
width, _, err := term.GetSize(int(os.Stdout.Fd()))
|
||||
if err != nil {
|
||||
c.width = 80 // Fallback width
|
||||
c.height = 24 // Fallback height
|
||||
c.width = 80 // Fallback width
|
||||
return
|
||||
}
|
||||
|
||||
// Add left and right padding (4 characters total: 2 on each side)
|
||||
paddingTotal := 4
|
||||
c.width = width - paddingTotal
|
||||
c.height = height
|
||||
|
||||
// Update renderers if they exist
|
||||
if c.messageRenderer != nil {
|
||||
c.messageRenderer.SetWidth(c.width)
|
||||
}
|
||||
if c.compactRenderer != nil {
|
||||
c.compactRenderer.SetWidth(c.width)
|
||||
}
|
||||
if c.messageContainer != nil {
|
||||
c.messageContainer.SetSize(c.width, c.height-4)
|
||||
// Update renderer if it exists
|
||||
if c.renderer != nil {
|
||||
c.renderer.SetWidth(c.width)
|
||||
}
|
||||
if c.usageTracker != nil {
|
||||
c.usageTracker.SetWidth(c.width)
|
||||
|
||||
@@ -14,6 +14,12 @@ import (
|
||||
type CompactRenderer struct {
|
||||
width int
|
||||
debug bool
|
||||
|
||||
// getToolRenderer returns extension-provided rendering overrides for a
|
||||
// specific tool. May be nil if no extensions are loaded. Used in
|
||||
// RenderToolMessage to check for custom header/body formatting before
|
||||
// falling back to builtin renderers.
|
||||
getToolRenderer func(toolName string) *ToolRendererData
|
||||
}
|
||||
|
||||
// NewCompactRenderer creates and initializes a new CompactRenderer with the specified
|
||||
@@ -141,6 +147,12 @@ func (r *CompactRenderer) RenderToolCallMessage(toolName, toolArgs string, times
|
||||
func (r *CompactRenderer) RenderToolMessage(toolName, toolArgs, toolResult string, isError bool) UIMessage {
|
||||
theme := getTheme()
|
||||
|
||||
// Resolve extension renderer once for all overrides.
|
||||
var extRd *ToolRendererData
|
||||
if r.getToolRenderer != nil {
|
||||
extRd = r.getToolRenderer(toolName)
|
||||
}
|
||||
|
||||
// Status icon
|
||||
var icon string
|
||||
iconColor := theme.Success
|
||||
@@ -152,12 +164,23 @@ func (r *CompactRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
|
||||
}
|
||||
|
||||
iconStr := lipgloss.NewStyle().Foreground(iconColor).Bold(true).Render(icon)
|
||||
|
||||
// Extension can override display name.
|
||||
displayName := toolDisplayName(toolName)
|
||||
if extRd != nil && extRd.DisplayName != "" {
|
||||
displayName = extRd.DisplayName
|
||||
}
|
||||
nameStr := lipgloss.NewStyle().Foreground(theme.Tool).Bold(true).Render(displayName)
|
||||
|
||||
// Format params
|
||||
// Format params — check extension renderer first.
|
||||
paramBudget := max(r.width-10-len(displayName), 20)
|
||||
params := formatToolParams(toolArgs, paramBudget)
|
||||
var params string
|
||||
if extRd != nil && extRd.RenderHeader != nil {
|
||||
params = extRd.RenderHeader(toolArgs, paramBudget)
|
||||
}
|
||||
if params == "" {
|
||||
params = formatToolParams(toolArgs, paramBudget)
|
||||
}
|
||||
|
||||
// Build header line
|
||||
header := iconStr + " " + nameStr
|
||||
@@ -165,18 +188,27 @@ func (r *CompactRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
|
||||
header += " " + lipgloss.NewStyle().Foreground(theme.Muted).Render(params)
|
||||
}
|
||||
|
||||
// Format body: try tool-specific renderer, then fall back to default
|
||||
// Format body: check extension renderer first, then builtin, then default.
|
||||
var body string
|
||||
if isError {
|
||||
body = lipgloss.NewStyle().Foreground(theme.Error).Render(r.formatToolResult(toolResult))
|
||||
} else {
|
||||
body = renderToolBody(toolName, toolArgs, toolResult, r.width-4)
|
||||
if body == "" {
|
||||
formatted := r.formatToolResult(toolResult)
|
||||
if formatted == "" {
|
||||
body = lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render("(no output)")
|
||||
} else {
|
||||
body = lipgloss.NewStyle().Foreground(theme.Muted).Render(formatted)
|
||||
if extRd != nil && extRd.RenderBody != nil {
|
||||
body = extRd.RenderBody(toolResult, isError, r.width-4)
|
||||
// Apply markdown rendering if requested and body is non-empty.
|
||||
if body != "" && extRd.BodyMarkdown {
|
||||
body = strings.TrimSuffix(toMarkdown(body, r.width-4), "\n")
|
||||
}
|
||||
}
|
||||
if body == "" {
|
||||
if isError {
|
||||
body = lipgloss.NewStyle().Foreground(theme.Error).Render(r.formatToolResult(toolResult))
|
||||
} else {
|
||||
body = renderToolBody(toolName, toolArgs, toolResult, r.width-4)
|
||||
if body == "" {
|
||||
formatted := r.formatToolResult(toolResult)
|
||||
if formatted == "" {
|
||||
body = lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render("(no output)")
|
||||
} else {
|
||||
body = lipgloss.NewStyle().Foreground(theme.Muted).Render(formatted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -443,70 +475,9 @@ func (r *CompactRenderer) formatToolResult(result string) string {
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// formatBashOutput formats bash command output by removing stdout/stderr tags and styling appropriately
|
||||
// formatBashOutput formats bash command output by removing stdout/stderr tags
|
||||
// and styling appropriately. Delegates tag parsing to the shared parseBashOutput
|
||||
// helper.
|
||||
func (r *CompactRenderer) formatBashOutput(result string) string {
|
||||
theme := getTheme()
|
||||
|
||||
// Replace tag pairs with styled content
|
||||
var formattedResult strings.Builder
|
||||
remaining := result
|
||||
|
||||
for {
|
||||
// Find stderr tags
|
||||
stderrStart := strings.Index(remaining, "<stderr>")
|
||||
stderrEnd := strings.Index(remaining, "</stderr>")
|
||||
|
||||
// Find stdout tags
|
||||
stdoutStart := strings.Index(remaining, "<stdout>")
|
||||
stdoutEnd := strings.Index(remaining, "</stdout>")
|
||||
|
||||
// Process whichever comes first
|
||||
if stderrStart != -1 && stderrEnd != -1 && stderrEnd > stderrStart &&
|
||||
(stdoutStart == -1 || stderrStart < stdoutStart) {
|
||||
// Process stderr
|
||||
// Add content before the tag
|
||||
if stderrStart > 0 {
|
||||
formattedResult.WriteString(remaining[:stderrStart])
|
||||
}
|
||||
|
||||
// Extract and style stderr content
|
||||
stderrContent := remaining[stderrStart+8 : stderrEnd]
|
||||
// Trim leading/trailing newlines but preserve internal ones
|
||||
stderrContent = strings.Trim(stderrContent, "\n")
|
||||
if len(stderrContent) > 0 {
|
||||
// Style stderr content with error color, same as non-compact mode
|
||||
styledContent := lipgloss.NewStyle().Foreground(theme.Error).Render(stderrContent)
|
||||
formattedResult.WriteString(styledContent)
|
||||
}
|
||||
|
||||
// Continue with remaining content
|
||||
remaining = remaining[stderrEnd+9:] // Skip past </stderr>
|
||||
|
||||
} else if stdoutStart != -1 && stdoutEnd != -1 && stdoutEnd > stdoutStart {
|
||||
// Process stdout
|
||||
// Add content before the tag
|
||||
if stdoutStart > 0 {
|
||||
formattedResult.WriteString(remaining[:stdoutStart])
|
||||
}
|
||||
|
||||
// Extract stdout content (no special styling needed)
|
||||
stdoutContent := remaining[stdoutStart+8 : stdoutEnd]
|
||||
// Trim leading/trailing newlines but preserve internal ones
|
||||
stdoutContent = strings.Trim(stdoutContent, "\n")
|
||||
if len(stdoutContent) > 0 {
|
||||
formattedResult.WriteString(stdoutContent)
|
||||
}
|
||||
|
||||
// Continue with remaining content
|
||||
remaining = remaining[stdoutEnd+9:] // Skip past </stdout>
|
||||
|
||||
} else {
|
||||
// No more tags, add remaining content
|
||||
formattedResult.WriteString(remaining)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Trim any leading/trailing whitespace from the final result
|
||||
return strings.TrimSpace(formattedResult.String())
|
||||
return parseBashOutput(result, getTheme())
|
||||
}
|
||||
|
||||
@@ -68,14 +68,7 @@ func (l *CLIDebugLogger) LogDebug(message string) {
|
||||
}
|
||||
|
||||
// Use the CLI's debug message rendering
|
||||
var msg UIMessage
|
||||
if l.cli.compactMode {
|
||||
msg = l.cli.compactRenderer.RenderDebugMessage(formattedMessage, time.Now())
|
||||
} else {
|
||||
msg = l.cli.messageRenderer.RenderDebugMessage(formattedMessage, time.Now())
|
||||
}
|
||||
l.cli.messageContainer.AddMessage(msg)
|
||||
l.cli.displayContainer()
|
||||
fmt.Println(l.cli.renderer.RenderDebugMessage(formattedMessage, time.Now()).Content)
|
||||
}
|
||||
|
||||
// IsDebugEnabled checks whether debug logging is currently active. Returns true
|
||||
|
||||
@@ -14,13 +14,6 @@ import (
|
||||
// isDarkBg caches the terminal background detection result at package init.
|
||||
var isDarkBg = lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
|
||||
|
||||
// colorHex returns the hex string representation of a color.Color by
|
||||
// converting its RGBA values.
|
||||
func colorHex(c color.Color) string {
|
||||
r, g, b, _ := c.RGBA()
|
||||
return fmt.Sprintf("#%02x%02x%02x", r>>8, g>>8, b>>8)
|
||||
}
|
||||
|
||||
// AdaptiveColor picks between a light-mode and dark-mode hex color string
|
||||
// based on the detected terminal background. This replaces the old
|
||||
// lipgloss.AdaptiveColor{Light: ..., Dark: ...} pattern from v1.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// Renderer is the interface satisfied by both MessageRenderer and
|
||||
// CompactRenderer. It allows model.go and cli.go to call rendering methods
|
||||
// without branching on compact mode.
|
||||
type Renderer interface {
|
||||
RenderUserMessage(content string, timestamp time.Time) UIMessage
|
||||
RenderAssistantMessage(content string, timestamp time.Time, modelName string) UIMessage
|
||||
RenderToolMessage(toolName, toolArgs, toolResult string, isError bool) UIMessage
|
||||
RenderSystemMessage(content string, timestamp time.Time) UIMessage
|
||||
RenderErrorMessage(errorMsg string, timestamp time.Time) UIMessage
|
||||
RenderDebugMessage(message string, timestamp time.Time) UIMessage
|
||||
RenderDebugConfigMessage(config map[string]any, timestamp time.Time) UIMessage
|
||||
SetWidth(width int)
|
||||
}
|
||||
|
||||
// Compile-time checks that both renderers satisfy the Renderer interface.
|
||||
var _ Renderer = (*MessageRenderer)(nil)
|
||||
var _ Renderer = (*CompactRenderer)(nil)
|
||||
|
||||
// parseBashOutput parses <stdout>/<stderr> tagged output from bash tool
|
||||
// results, styling stderr with the theme's error color. Returns the
|
||||
// combined, styled output string with tags stripped.
|
||||
//
|
||||
// Shared by both MessageRenderer and CompactRenderer.
|
||||
func parseBashOutput(result string, theme Theme) string {
|
||||
var formattedResult strings.Builder
|
||||
remaining := result
|
||||
|
||||
for {
|
||||
// Find stderr tags
|
||||
stderrStart := strings.Index(remaining, "<stderr>")
|
||||
stderrEnd := strings.Index(remaining, "</stderr>")
|
||||
|
||||
// Find stdout tags
|
||||
stdoutStart := strings.Index(remaining, "<stdout>")
|
||||
stdoutEnd := strings.Index(remaining, "</stdout>")
|
||||
|
||||
// Process whichever comes first
|
||||
if stderrStart != -1 && stderrEnd != -1 && stderrEnd > stderrStart &&
|
||||
(stdoutStart == -1 || stderrStart < stdoutStart) {
|
||||
// Process stderr
|
||||
if stderrStart > 0 {
|
||||
formattedResult.WriteString(remaining[:stderrStart])
|
||||
}
|
||||
stderrContent := remaining[stderrStart+8 : stderrEnd]
|
||||
stderrContent = strings.Trim(stderrContent, "\n")
|
||||
if len(stderrContent) > 0 {
|
||||
styledContent := lipgloss.NewStyle().Foreground(theme.Error).Render(stderrContent)
|
||||
formattedResult.WriteString(styledContent)
|
||||
}
|
||||
remaining = remaining[stderrEnd+9:] // Skip past </stderr>
|
||||
|
||||
} else if stdoutStart != -1 && stdoutEnd != -1 && stdoutEnd > stdoutStart {
|
||||
// Process stdout
|
||||
if stdoutStart > 0 {
|
||||
formattedResult.WriteString(remaining[:stdoutStart])
|
||||
}
|
||||
stdoutContent := remaining[stdoutStart+8 : stdoutEnd]
|
||||
stdoutContent = strings.Trim(stdoutContent, "\n")
|
||||
if len(stdoutContent) > 0 {
|
||||
formattedResult.WriteString(stdoutContent)
|
||||
}
|
||||
remaining = remaining[stdoutEnd+9:] // Skip past </stdout>
|
||||
|
||||
} else {
|
||||
// No more tags, add remaining content
|
||||
formattedResult.WriteString(remaining)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(formattedResult.String())
|
||||
}
|
||||
+68
-317
@@ -146,6 +146,12 @@ func formatToolParams(toolArgs string, maxWidth int) string {
|
||||
type MessageRenderer struct {
|
||||
width int
|
||||
debug bool
|
||||
|
||||
// getToolRenderer returns extension-provided rendering overrides for a
|
||||
// specific tool. May be nil if no extensions are loaded. Used in
|
||||
// RenderToolMessage to check for custom header/body formatting before
|
||||
// falling back to builtin renderers.
|
||||
getToolRenderer func(toolName string) *ToolRendererData
|
||||
}
|
||||
|
||||
// getSystemUsername returns the current system username, fallback to "User"
|
||||
@@ -193,10 +199,7 @@ func (r *MessageRenderer) RenderUserMessage(content string, timestamp time.Time)
|
||||
|
||||
theme := getTheme()
|
||||
|
||||
// Render the message content with the user-message background so that
|
||||
// glamour-rendered markdown inherits the highlight color.
|
||||
bgHex := colorHex(theme.Highlight)
|
||||
messageContent := r.renderMarkdownWithBg(content, r.width-8, bgHex) // Account for padding and borders
|
||||
messageContent := r.renderMarkdown(content, r.width-8) // Account for padding and borders
|
||||
|
||||
// Create info line
|
||||
info := fmt.Sprintf(" %s (%s)", username, timeStr)
|
||||
@@ -205,13 +208,12 @@ func (r *MessageRenderer) RenderUserMessage(content string, timestamp time.Time)
|
||||
fullContent := strings.TrimSuffix(messageContent, "\n") + "\n" +
|
||||
lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(info)
|
||||
|
||||
// Use the new block renderer
|
||||
// Use the block renderer — left border with Primary color, no background.
|
||||
rendered := renderContentBlock(
|
||||
fullContent,
|
||||
r.width,
|
||||
WithAlign(lipgloss.Left),
|
||||
WithBorderColor(theme.Primary),
|
||||
WithBackground(theme.Highlight),
|
||||
WithMarginBottom(1),
|
||||
)
|
||||
|
||||
@@ -527,6 +529,12 @@ func (r *MessageRenderer) RenderToolCallMessage(toolName, toolArgs string, times
|
||||
func (r *MessageRenderer) RenderToolMessage(toolName, toolArgs, toolResult string, isError bool) UIMessage {
|
||||
theme := getTheme()
|
||||
|
||||
// Resolve extension renderer once for all overrides.
|
||||
var extRd *ToolRendererData
|
||||
if r.getToolRenderer != nil {
|
||||
extRd = r.getToolRenderer(toolName)
|
||||
}
|
||||
|
||||
// --- Header: [icon] [name] [params] ---
|
||||
var icon string
|
||||
borderColor := theme.Success
|
||||
@@ -539,29 +547,55 @@ func (r *MessageRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
|
||||
icon = "✓"
|
||||
}
|
||||
|
||||
// Extension can override border color (applies to both success and error).
|
||||
if extRd != nil && extRd.BorderColor != "" {
|
||||
borderColor = lipgloss.Color(extRd.BorderColor)
|
||||
}
|
||||
|
||||
iconStr := lipgloss.NewStyle().Foreground(iconColor).Bold(true).Render(icon)
|
||||
|
||||
// Extension can override display name.
|
||||
displayName := toolDisplayName(toolName)
|
||||
if extRd != nil && extRd.DisplayName != "" {
|
||||
displayName = extRd.DisplayName
|
||||
}
|
||||
nameStr := lipgloss.NewStyle().Foreground(theme.Tool).Bold(true).Render(displayName)
|
||||
|
||||
// Format params with width budget for the header line
|
||||
// Format params with width budget for the header line.
|
||||
// Check extension renderer for custom header params first.
|
||||
paramBudget := max(r.width-10-len(displayName), 20)
|
||||
params := formatToolParams(toolArgs, paramBudget)
|
||||
var params string
|
||||
if extRd != nil && extRd.RenderHeader != nil {
|
||||
params = extRd.RenderHeader(toolArgs, paramBudget)
|
||||
}
|
||||
if params == "" {
|
||||
params = formatToolParams(toolArgs, paramBudget)
|
||||
}
|
||||
|
||||
header := iconStr + " " + nameStr
|
||||
if params != "" {
|
||||
header += " " + lipgloss.NewStyle().Foreground(theme.Muted).Render(params)
|
||||
}
|
||||
|
||||
// --- Body: try tool-specific renderer first, then fall back ---
|
||||
// --- Body: check extension renderer first, then builtin, then default ---
|
||||
var body string
|
||||
if isError {
|
||||
body = lipgloss.NewStyle().
|
||||
Foreground(theme.Error).
|
||||
Render(toolResult)
|
||||
} else {
|
||||
body = renderToolBody(toolName, toolArgs, toolResult, r.width-8)
|
||||
if body == "" {
|
||||
body = r.formatToolResult(toolName, toolResult, r.width-8)
|
||||
if extRd != nil && extRd.RenderBody != nil {
|
||||
body = extRd.RenderBody(toolResult, isError, r.width-8)
|
||||
// Apply markdown rendering if requested and body is non-empty.
|
||||
if body != "" && extRd.BodyMarkdown {
|
||||
body = strings.TrimSuffix(toMarkdown(body, r.width-8), "\n")
|
||||
}
|
||||
}
|
||||
if body == "" {
|
||||
if isError {
|
||||
body = lipgloss.NewStyle().
|
||||
Foreground(theme.Error).
|
||||
Render(toolResult)
|
||||
} else {
|
||||
body = renderToolBody(toolName, toolArgs, toolResult, r.width-8)
|
||||
if body == "" {
|
||||
body = r.formatToolResult(toolName, toolResult, r.width-8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -572,15 +606,23 @@ func (r *MessageRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
|
||||
Render("(no output)")
|
||||
}
|
||||
|
||||
// Combine header + body into a single block
|
||||
// Combine header + body into a single block.
|
||||
fullContent := header + "\n\n" + strings.TrimSuffix(body, "\n")
|
||||
|
||||
// Build rendering options; extension can override background.
|
||||
blockOpts := []renderingOption{
|
||||
WithAlign(lipgloss.Left),
|
||||
WithBorderColor(borderColor),
|
||||
WithMarginBottom(1),
|
||||
}
|
||||
if extRd != nil && extRd.Background != "" {
|
||||
blockOpts = append(blockOpts, WithBackground(lipgloss.Color(extRd.Background)))
|
||||
}
|
||||
|
||||
rendered := renderContentBlock(
|
||||
fullContent,
|
||||
r.width,
|
||||
WithAlign(lipgloss.Left),
|
||||
WithBorderColor(borderColor),
|
||||
WithMarginBottom(1),
|
||||
blockOpts...,
|
||||
)
|
||||
|
||||
return UIMessage{
|
||||
@@ -653,75 +695,14 @@ func (r *MessageRenderer) formatToolResult(toolName, result string, width int) s
|
||||
Render(result)
|
||||
}
|
||||
|
||||
// formatBashOutput formats bash command output with proper section handling
|
||||
// formatBashOutput formats bash command output with proper section handling.
|
||||
// Delegates tag parsing to the shared parseBashOutput helper.
|
||||
func (r *MessageRenderer) formatBashOutput(result string, width int, theme Theme) string {
|
||||
baseStyle := lipgloss.NewStyle()
|
||||
|
||||
// Replace tag pairs with styled content
|
||||
var formattedResult strings.Builder
|
||||
remaining := result
|
||||
|
||||
for {
|
||||
// Find stderr tags
|
||||
stderrStart := strings.Index(remaining, "<stderr>")
|
||||
stderrEnd := strings.Index(remaining, "</stderr>")
|
||||
|
||||
// Find stdout tags
|
||||
stdoutStart := strings.Index(remaining, "<stdout>")
|
||||
stdoutEnd := strings.Index(remaining, "</stdout>")
|
||||
|
||||
// Process whichever comes first
|
||||
if stderrStart != -1 && stderrEnd != -1 && stderrEnd > stderrStart &&
|
||||
(stdoutStart == -1 || stderrStart < stdoutStart) {
|
||||
// Process stderr
|
||||
// Add content before the tag
|
||||
if stderrStart > 0 {
|
||||
formattedResult.WriteString(remaining[:stderrStart])
|
||||
}
|
||||
// Extract and style stderr content
|
||||
stderrContent := remaining[stderrStart+8 : stderrEnd]
|
||||
// Trim leading/trailing newlines but preserve internal ones
|
||||
stderrContent = strings.Trim(stderrContent, "\n")
|
||||
if len(stderrContent) > 0 {
|
||||
styledContent := baseStyle.Foreground(theme.Error).Render(stderrContent)
|
||||
formattedResult.WriteString(styledContent)
|
||||
}
|
||||
|
||||
// Continue with remaining content
|
||||
remaining = remaining[stderrEnd+9:] // Skip past </stderr>
|
||||
|
||||
} else if stdoutStart != -1 && stdoutEnd != -1 && stdoutEnd > stdoutStart {
|
||||
// Process stdout
|
||||
// Add content before the tag
|
||||
if stdoutStart > 0 {
|
||||
formattedResult.WriteString(remaining[:stdoutStart])
|
||||
}
|
||||
|
||||
// Extract stdout content (no special styling needed)
|
||||
stdoutContent := remaining[stdoutStart+8 : stdoutEnd]
|
||||
// Trim leading/trailing newlines but preserve internal ones
|
||||
stdoutContent = strings.Trim(stdoutContent, "\n")
|
||||
if len(stdoutContent) > 0 {
|
||||
formattedResult.WriteString(stdoutContent)
|
||||
}
|
||||
|
||||
// Continue with remaining content
|
||||
remaining = remaining[stdoutEnd+9:] // Skip past </stdout>
|
||||
|
||||
} else {
|
||||
// No more tags, add remaining content
|
||||
formattedResult.WriteString(remaining)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Trim any leading/trailing whitespace from the final result
|
||||
finalResult := strings.TrimSpace(formattedResult.String())
|
||||
|
||||
return baseStyle.
|
||||
parsed := parseBashOutput(result, theme)
|
||||
return lipgloss.NewStyle().
|
||||
Width(width).
|
||||
Foreground(theme.Muted).
|
||||
Render(finalResult)
|
||||
Render(parsed)
|
||||
}
|
||||
|
||||
// renderMarkdown renders markdown content using glamour
|
||||
@@ -729,233 +710,3 @@ func (r *MessageRenderer) renderMarkdown(content string, width int) string {
|
||||
rendered := toMarkdown(content, width)
|
||||
return strings.TrimSuffix(rendered, "\n")
|
||||
}
|
||||
|
||||
// renderMarkdownWithBg renders markdown content using glamour with a background
|
||||
// color applied to every element so the output blends with a colored block.
|
||||
func (r *MessageRenderer) renderMarkdownWithBg(content string, width int, bgHex string) string {
|
||||
rendered := toMarkdownWithBg(content, width, bgHex)
|
||||
return strings.TrimSuffix(rendered, "\n")
|
||||
}
|
||||
|
||||
// MessageContainer manages a collection of UI messages, handling their display,
|
||||
// updates, and layout within the terminal. It supports both standard and compact
|
||||
// display modes and maintains state for streaming message updates.
|
||||
type MessageContainer struct {
|
||||
messages []UIMessage
|
||||
width int
|
||||
height int
|
||||
compactMode bool // Add compact mode flag
|
||||
modelName string // Store current model name
|
||||
wasCleared bool // Track if container was explicitly cleared
|
||||
}
|
||||
|
||||
// NewMessageContainer creates and initializes a new MessageContainer with the
|
||||
// specified dimensions and display mode. The container starts empty and will
|
||||
// display a welcome message until the first message is added.
|
||||
func NewMessageContainer(width, height int, compact bool) *MessageContainer {
|
||||
return &MessageContainer{
|
||||
messages: make([]UIMessage, 0),
|
||||
width: width,
|
||||
height: height,
|
||||
compactMode: compact,
|
||||
}
|
||||
}
|
||||
|
||||
// AddMessage appends a new UIMessage to the container's collection and resets
|
||||
// the cleared state flag. Messages are displayed in the order they were added.
|
||||
func (c *MessageContainer) AddMessage(msg UIMessage) {
|
||||
c.messages = append(c.messages, msg)
|
||||
c.wasCleared = false // Reset the cleared flag when adding messages
|
||||
}
|
||||
|
||||
// SetModelName updates the AI model name used for rendering assistant messages.
|
||||
// This name is displayed in message headers to indicate which model is responding.
|
||||
func (c *MessageContainer) SetModelName(modelName string) {
|
||||
c.modelName = modelName
|
||||
}
|
||||
|
||||
// UpdateLastMessage efficiently updates the content of the most recent message
|
||||
// in the container. This is primarily used for streaming responses where the
|
||||
// assistant's message is progressively built. Only works for assistant messages.
|
||||
func (c *MessageContainer) UpdateLastMessage(content string) {
|
||||
if len(c.messages) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
lastIdx := len(c.messages) - 1
|
||||
lastMsg := &c.messages[lastIdx]
|
||||
|
||||
// Only re-render if content actually changed and it's an assistant message
|
||||
if lastMsg.Type == AssistantMessage {
|
||||
// Create appropriate renderer based on compact mode
|
||||
var newMsg UIMessage
|
||||
if c.compactMode {
|
||||
compactRenderer := NewCompactRenderer(c.width, false)
|
||||
newMsg = compactRenderer.RenderAssistantMessage(content, lastMsg.Timestamp, c.modelName)
|
||||
} else {
|
||||
renderer := NewMessageRenderer(c.width, false)
|
||||
newMsg = renderer.RenderAssistantMessage(content, lastMsg.Timestamp, c.modelName)
|
||||
}
|
||||
newMsg.Streaming = lastMsg.Streaming // Preserve streaming state
|
||||
c.messages[lastIdx] = newMsg
|
||||
}
|
||||
}
|
||||
|
||||
// Clear removes all messages from the container and sets a flag to prevent
|
||||
// showing the welcome screen. Used when starting a fresh conversation.
|
||||
func (c *MessageContainer) Clear() {
|
||||
c.messages = make([]UIMessage, 0)
|
||||
c.wasCleared = true
|
||||
}
|
||||
|
||||
// SetSize updates the container's dimensions, typically called when the terminal
|
||||
// is resized. This affects how messages are wrapped and displayed.
|
||||
func (c *MessageContainer) SetSize(width, height int) {
|
||||
c.width = width
|
||||
c.height = height
|
||||
}
|
||||
|
||||
// Render generates the complete visual representation of all messages in the
|
||||
// container. Returns an empty state display if no messages exist, or formats
|
||||
// all messages according to the current display mode (standard or compact).
|
||||
func (c *MessageContainer) Render() string {
|
||||
if len(c.messages) == 0 {
|
||||
// Don't show welcome box if explicitly cleared
|
||||
if c.wasCleared {
|
||||
return ""
|
||||
}
|
||||
if c.compactMode {
|
||||
return c.renderCompactEmptyState()
|
||||
}
|
||||
return c.renderEmptyState()
|
||||
}
|
||||
|
||||
if c.compactMode {
|
||||
return c.renderCompactMessages()
|
||||
}
|
||||
|
||||
var parts []string
|
||||
|
||||
for i, msg := range c.messages {
|
||||
// Center each message horizontally
|
||||
centeredMsg := lipgloss.PlaceHorizontal(
|
||||
c.width,
|
||||
lipgloss.Center,
|
||||
msg.Content,
|
||||
)
|
||||
parts = append(parts, centeredMsg)
|
||||
|
||||
// Add spacing between messages (except after the last one)
|
||||
if i < len(c.messages)-1 {
|
||||
parts = append(parts, "")
|
||||
}
|
||||
}
|
||||
|
||||
style := lipgloss.NewStyle().
|
||||
Width(c.width)
|
||||
|
||||
// No padding needed between messages
|
||||
|
||||
return style.Render(
|
||||
lipgloss.JoinVertical(lipgloss.Top, parts...),
|
||||
)
|
||||
}
|
||||
|
||||
// renderEmptyState renders an enhanced initial empty state
|
||||
func (c *MessageContainer) renderEmptyState() string {
|
||||
baseStyle := lipgloss.NewStyle()
|
||||
|
||||
// Create a welcome box with border
|
||||
theme := getTheme()
|
||||
welcomeBox := baseStyle.
|
||||
Width(c.width-4).
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(theme.System).
|
||||
Padding(2, 4).
|
||||
Align(lipgloss.Center)
|
||||
|
||||
// Main title
|
||||
title := baseStyle.
|
||||
Foreground(theme.System).
|
||||
Bold(true).
|
||||
Render("KIT")
|
||||
|
||||
// Subtitle with better typography
|
||||
subtitle := baseStyle.
|
||||
Foreground(theme.Primary).
|
||||
Bold(true).
|
||||
MarginTop(1).
|
||||
Render("AI Assistant with MCP Tools")
|
||||
|
||||
// Feature highlights
|
||||
features := []string{
|
||||
"Natural language conversations",
|
||||
"Powerful tool integrations",
|
||||
"Multi-provider LLM support",
|
||||
"Usage tracking & analytics",
|
||||
}
|
||||
|
||||
var featureList []string
|
||||
for _, feature := range features {
|
||||
featureList = append(featureList, baseStyle.
|
||||
Foreground(theme.Muted).
|
||||
MarginLeft(2).
|
||||
Render("• "+feature))
|
||||
}
|
||||
|
||||
// Getting started prompt
|
||||
prompt := baseStyle.
|
||||
Foreground(theme.Accent).
|
||||
Italic(true).
|
||||
MarginTop(2).
|
||||
Render("Start by typing your message below or use /help for commands")
|
||||
|
||||
// Combine all elements
|
||||
content := lipgloss.JoinVertical(
|
||||
lipgloss.Center,
|
||||
title,
|
||||
subtitle,
|
||||
"",
|
||||
lipgloss.JoinVertical(lipgloss.Left, featureList...),
|
||||
"",
|
||||
prompt,
|
||||
)
|
||||
|
||||
welcomeContent := welcomeBox.Render(content)
|
||||
|
||||
// Center the welcome box vertically
|
||||
return baseStyle.
|
||||
Width(c.width).
|
||||
Height(c.height).
|
||||
Align(lipgloss.Center).
|
||||
AlignVertical(lipgloss.Center).
|
||||
Render(welcomeContent)
|
||||
}
|
||||
|
||||
// renderCompactMessages renders messages in compact format
|
||||
func (c *MessageContainer) renderCompactMessages() string {
|
||||
var lines []string
|
||||
|
||||
for _, msg := range c.messages {
|
||||
lines = append(lines, msg.Content)
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// renderCompactEmptyState renders a simple empty state for compact mode
|
||||
func (c *MessageContainer) renderCompactEmptyState() string {
|
||||
theme := getTheme()
|
||||
|
||||
// Simple compact welcome
|
||||
welcome := lipgloss.NewStyle().
|
||||
Foreground(theme.System).
|
||||
Bold(true).
|
||||
Render("KIT - AI Assistant with MCP Tools")
|
||||
|
||||
help := lipgloss.NewStyle().
|
||||
Foreground(theme.Muted).
|
||||
Render("Type your message or /help for commands")
|
||||
|
||||
return fmt.Sprintf("%s\n%s\n\n", welcome, help)
|
||||
}
|
||||
|
||||
+723
-87
File diff suppressed because it is too large
Load Diff
@@ -53,6 +53,10 @@ func (s *stubAppController) GetTreeSession() *session.TreeManager {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubAppController) SendEvent(_ tea.Msg) {
|
||||
// no-op in tests
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Stub child components
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -101,7 +105,6 @@ func newTestAppModel(ctrl AppController) (*AppModel, *stubStreamComponent, *stub
|
||||
stream: stream,
|
||||
input: input,
|
||||
renderer: NewMessageRenderer(80, false),
|
||||
compactRdr: NewCompactRenderer(80, false),
|
||||
compactMode: false,
|
||||
modelName: "test-model",
|
||||
width: 80,
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Overlay dialog — modal overlay rendered by AppModel when active
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// overlayResult carries the synchronous outcome of an overlay dialog update.
|
||||
// A non-nil value means the overlay is done (completed or cancelled); nil
|
||||
// means the overlay is still active.
|
||||
type overlayResult struct {
|
||||
completed bool
|
||||
cancelled bool
|
||||
action string
|
||||
index int
|
||||
}
|
||||
|
||||
// overlayDialog holds the state of an active modal overlay dialog. It is
|
||||
// created when an OverlayRequestEvent arrives and destroyed when the user
|
||||
// completes or cancels. The AppModel owns the overlay and routes messages
|
||||
// to it while in stateOverlay.
|
||||
type overlayDialog struct {
|
||||
title string
|
||||
content string
|
||||
markdown bool
|
||||
borderColor string
|
||||
background string
|
||||
actions []string
|
||||
selAction int // selected action index
|
||||
scrollOff int // scroll offset for content body
|
||||
totalLines int // total body lines (computed on render)
|
||||
width int // terminal width
|
||||
height int // terminal height
|
||||
dialogWidth int // configured dialog width (0 = auto)
|
||||
maxHeight int // configured max height (0 = auto)
|
||||
anchor string
|
||||
}
|
||||
|
||||
// newOverlayDialog creates an overlay dialog from an OverlayRequestEvent's
|
||||
// parameters.
|
||||
func newOverlayDialog(title, content string, markdown bool, borderColor, background string, width, maxHeight int, anchor string, actions []string, termWidth, termHeight int) *overlayDialog {
|
||||
return &overlayDialog{
|
||||
title: title,
|
||||
content: content,
|
||||
markdown: markdown,
|
||||
borderColor: borderColor,
|
||||
background: background,
|
||||
actions: actions,
|
||||
dialogWidth: width,
|
||||
maxHeight: maxHeight,
|
||||
anchor: anchor,
|
||||
width: termWidth,
|
||||
height: termHeight,
|
||||
}
|
||||
}
|
||||
|
||||
// Init returns the initial command for the overlay. Currently no-op.
|
||||
func (o *overlayDialog) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update handles messages for the overlay dialog. It returns a non-nil
|
||||
// *overlayResult when the user completes or cancels. The returned tea.Cmd
|
||||
// is always nil (overlays don't produce async commands).
|
||||
func (o *overlayDialog) Update(msg tea.Msg) (*overlayResult, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
o.width = msg.Width
|
||||
o.height = msg.Height
|
||||
return nil, nil
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
return o.handleKey(msg)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (o *overlayDialog) handleKey(msg tea.KeyPressMsg) (*overlayResult, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
return &overlayResult{cancelled: true}, nil
|
||||
|
||||
case "enter":
|
||||
if len(o.actions) > 0 {
|
||||
action := ""
|
||||
if o.selAction < len(o.actions) {
|
||||
action = o.actions[o.selAction]
|
||||
}
|
||||
return &overlayResult{completed: true, action: action, index: o.selAction}, nil
|
||||
}
|
||||
// No actions — Enter dismisses (not cancelled).
|
||||
return &overlayResult{completed: true, action: "", index: -1}, nil
|
||||
|
||||
// Content scrolling
|
||||
case "up", "k":
|
||||
if o.scrollOff > 0 {
|
||||
o.scrollOff--
|
||||
}
|
||||
case "down", "j":
|
||||
// Clamped in Render; allow incrementing freely.
|
||||
o.scrollOff++
|
||||
case "home", "g":
|
||||
o.scrollOff = 0
|
||||
case "end", "G":
|
||||
// Set to a large value; Render will clamp.
|
||||
o.scrollOff = o.totalLines
|
||||
|
||||
// Action navigation
|
||||
case "left", "h":
|
||||
if len(o.actions) > 0 && o.selAction > 0 {
|
||||
o.selAction--
|
||||
}
|
||||
case "right", "l":
|
||||
if len(o.actions) > 0 && o.selAction < len(o.actions)-1 {
|
||||
o.selAction++
|
||||
}
|
||||
case "tab":
|
||||
if len(o.actions) > 0 {
|
||||
o.selAction = (o.selAction + 1) % len(o.actions)
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Render returns the overlay dialog as a styled string for full-view
|
||||
// composition. The dialog is a bordered box centered (or anchored)
|
||||
// horizontally within the terminal width.
|
||||
func (o *overlayDialog) Render() string {
|
||||
theme := GetTheme()
|
||||
|
||||
// Calculate dialog dimensions.
|
||||
dw := o.dialogWidth
|
||||
if dw == 0 {
|
||||
dw = o.width * 60 / 100
|
||||
}
|
||||
if dw < 30 {
|
||||
dw = 30
|
||||
}
|
||||
if dw > o.width-4 {
|
||||
dw = o.width - 4
|
||||
}
|
||||
|
||||
mh := o.maxHeight
|
||||
if mh == 0 {
|
||||
mh = o.height * 80 / 100
|
||||
}
|
||||
if mh < 8 {
|
||||
mh = 8
|
||||
}
|
||||
if mh > o.height-2 {
|
||||
mh = o.height - 2
|
||||
}
|
||||
|
||||
// Inner width accounts for border (2) + horizontal padding (2 left + 1 right).
|
||||
innerWidth := max(dw-5, 10)
|
||||
|
||||
// Render body text (potentially as markdown).
|
||||
bodyText := o.content
|
||||
if o.markdown {
|
||||
bodyText = toMarkdown(bodyText, innerWidth)
|
||||
}
|
||||
bodyText = strings.TrimRight(bodyText, "\n")
|
||||
|
||||
bodyLines := strings.Split(bodyText, "\n")
|
||||
o.totalLines = len(bodyLines)
|
||||
|
||||
// Calculate available height for the scrollable body.
|
||||
// Chrome: border(2) + padTop(1) + padBottom(1) + hintLine(1) = 5
|
||||
chromeLines := 5
|
||||
if o.title != "" {
|
||||
chromeLines += 2 // title line + separator line
|
||||
}
|
||||
if len(o.actions) > 0 {
|
||||
chromeLines += 2 // separator line + action bar
|
||||
}
|
||||
|
||||
maxBodyLines := max(mh-chromeLines, 1)
|
||||
|
||||
scrollable := len(bodyLines) > maxBodyLines
|
||||
if scrollable {
|
||||
// Clamp scroll offset.
|
||||
maxOff := len(bodyLines) - maxBodyLines
|
||||
if o.scrollOff > maxOff {
|
||||
o.scrollOff = maxOff
|
||||
}
|
||||
if o.scrollOff < 0 {
|
||||
o.scrollOff = 0
|
||||
}
|
||||
bodyLines = bodyLines[o.scrollOff : o.scrollOff+maxBodyLines]
|
||||
} else {
|
||||
o.scrollOff = 0
|
||||
}
|
||||
|
||||
// Build the content to render inside the border.
|
||||
var parts []string
|
||||
|
||||
// Title + separator.
|
||||
if o.title != "" {
|
||||
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(theme.Text)
|
||||
parts = append(parts, titleStyle.Render(o.title))
|
||||
parts = append(parts, lipgloss.NewStyle().
|
||||
Foreground(theme.Muted).
|
||||
Render(repeatRune('─', innerWidth)))
|
||||
}
|
||||
|
||||
// Body content.
|
||||
parts = append(parts, "")
|
||||
parts = append(parts, strings.Join(bodyLines, "\n"))
|
||||
|
||||
// Scroll indicator.
|
||||
if scrollable {
|
||||
indicator := fmt.Sprintf("(%d–%d of %d lines)",
|
||||
o.scrollOff+1,
|
||||
min(o.scrollOff+maxBodyLines, o.totalLines),
|
||||
o.totalLines)
|
||||
parts = append(parts, lipgloss.NewStyle().
|
||||
Foreground(theme.VeryMuted).
|
||||
Render(indicator))
|
||||
} else {
|
||||
parts = append(parts, "")
|
||||
}
|
||||
|
||||
// Action bar.
|
||||
if len(o.actions) > 0 {
|
||||
parts = append(parts, lipgloss.NewStyle().
|
||||
Foreground(theme.Muted).
|
||||
Render(repeatRune('─', innerWidth)))
|
||||
|
||||
var actionParts []string
|
||||
for i, a := range o.actions {
|
||||
if i == o.selAction {
|
||||
actionParts = append(actionParts,
|
||||
lipgloss.NewStyle().Bold(true).Foreground(theme.Accent).Render("> "+a))
|
||||
} else {
|
||||
actionParts = append(actionParts,
|
||||
lipgloss.NewStyle().Foreground(theme.Text).Render(" "+a))
|
||||
}
|
||||
}
|
||||
parts = append(parts, strings.Join(actionParts, " "))
|
||||
}
|
||||
|
||||
innerContent := strings.Join(parts, "\n")
|
||||
|
||||
// Resolve border color.
|
||||
borderClr := lipgloss.Color("#89b4fa") // default blue
|
||||
if o.borderColor != "" {
|
||||
borderClr = lipgloss.Color(o.borderColor)
|
||||
}
|
||||
|
||||
// Build the dialog box style.
|
||||
dialogStyle := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(borderClr).
|
||||
Width(dw-2). // -2 for border chars
|
||||
Padding(1, 1, 1, 2).
|
||||
Foreground(theme.Text)
|
||||
|
||||
if o.background != "" {
|
||||
dialogStyle = dialogStyle.Background(lipgloss.Color(o.background))
|
||||
}
|
||||
|
||||
dialog := dialogStyle.Render(innerContent)
|
||||
|
||||
// Key hints below the dialog.
|
||||
var hints []string
|
||||
if scrollable {
|
||||
hints = append(hints, "↑/↓ scroll")
|
||||
}
|
||||
if len(o.actions) > 0 {
|
||||
hints = append(hints, "←/→ switch")
|
||||
hints = append(hints, "Enter select")
|
||||
} else {
|
||||
hints = append(hints, "Enter dismiss")
|
||||
}
|
||||
hints = append(hints, "Esc cancel")
|
||||
hintText := lipgloss.NewStyle().
|
||||
Foreground(theme.Muted).
|
||||
Render(" " + strings.Join(hints, " "))
|
||||
|
||||
full := lipgloss.JoinVertical(lipgloss.Left, dialog, hintText)
|
||||
|
||||
// Center horizontally within the terminal width.
|
||||
centered := lipgloss.PlaceHorizontal(o.width, lipgloss.Center, full)
|
||||
|
||||
// Apply vertical positioning based on anchor.
|
||||
// Calculate how many lines we have and how many we need.
|
||||
contentHeight := lipgloss.Height(centered)
|
||||
if contentHeight < o.height {
|
||||
switch o.anchor {
|
||||
case "top-center":
|
||||
// Add one blank line at top for breathing room.
|
||||
centered = "\n" + centered
|
||||
case "bottom-center":
|
||||
// Pad from the top so the dialog sits near the bottom.
|
||||
topPad := o.height - contentHeight - 1
|
||||
if topPad > 0 {
|
||||
centered = strings.Repeat("\n", topPad) + centered
|
||||
}
|
||||
default: // "center"
|
||||
// Vertically center within available height.
|
||||
topPad := (o.height - contentHeight) / 2
|
||||
if topPad > 0 {
|
||||
centered = strings.Repeat("\n", topPad) + centered
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return centered
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
"charm.land/bubbles/v2/textarea"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prompt overlay — modal prompt rendered by AppModel when active
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// promptMode indicates the type of interactive prompt being displayed.
|
||||
type promptMode string
|
||||
|
||||
const (
|
||||
promptModeSelect promptMode = "select"
|
||||
promptModeConfirm promptMode = "confirm"
|
||||
promptModeInput promptMode = "input"
|
||||
)
|
||||
|
||||
// promptResult carries the synchronous outcome of a prompt overlay update.
|
||||
// A non-nil value means the prompt is done (completed or cancelled); nil
|
||||
// means the overlay is still active.
|
||||
type promptResult struct {
|
||||
completed bool
|
||||
cancelled bool
|
||||
value string
|
||||
index int
|
||||
confirmed bool
|
||||
}
|
||||
|
||||
// promptOverlay holds the state of an active interactive prompt. It is
|
||||
// created when a PromptRequestEvent arrives and destroyed when the user
|
||||
// completes or cancels. The AppModel owns the overlay and routes messages
|
||||
// to it while in statePrompt.
|
||||
type promptOverlay struct {
|
||||
mode promptMode
|
||||
message string
|
||||
options []string // select: available choices
|
||||
selected int // select: currently highlighted index
|
||||
confirmed bool // confirm: current yes/no value
|
||||
inputTA textarea.Model // input: text editor
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
// newSelectPrompt creates a prompt overlay for a selection list.
|
||||
func newSelectPrompt(message string, options []string, width, height int) *promptOverlay {
|
||||
return &promptOverlay{
|
||||
mode: promptModeSelect,
|
||||
message: message,
|
||||
options: options,
|
||||
width: width,
|
||||
height: height,
|
||||
}
|
||||
}
|
||||
|
||||
// newConfirmPrompt creates a prompt overlay for a yes/no confirmation.
|
||||
func newConfirmPrompt(message string, defaultValue bool, width, height int) *promptOverlay {
|
||||
return &promptOverlay{
|
||||
mode: promptModeConfirm,
|
||||
message: message,
|
||||
confirmed: defaultValue,
|
||||
width: width,
|
||||
height: height,
|
||||
}
|
||||
}
|
||||
|
||||
// newInputPrompt creates a prompt overlay for free-form text input.
|
||||
func newInputPrompt(message, placeholder, defaultValue string, width, height int) *promptOverlay {
|
||||
ta := textarea.New()
|
||||
ta.Placeholder = placeholder
|
||||
ta.ShowLineNumbers = false
|
||||
ta.Prompt = ""
|
||||
ta.CharLimit = 1000
|
||||
ta.SetWidth(width - 12) // account for border + padding
|
||||
ta.SetHeight(1)
|
||||
ta.Focus()
|
||||
|
||||
// Prevent Enter from inserting a newline — we intercept it for submit.
|
||||
ta.KeyMap.InsertNewline = key.NewBinding(
|
||||
key.WithKeys("ctrl+j", "alt+enter"),
|
||||
)
|
||||
|
||||
if defaultValue != "" {
|
||||
ta.SetValue(defaultValue)
|
||||
ta.CursorEnd()
|
||||
}
|
||||
|
||||
return &promptOverlay{
|
||||
mode: promptModeInput,
|
||||
message: message,
|
||||
inputTA: ta,
|
||||
width: width,
|
||||
height: height,
|
||||
}
|
||||
}
|
||||
|
||||
// Init returns the initial command for the prompt overlay. For input mode
|
||||
// this starts the cursor blink animation.
|
||||
func (p *promptOverlay) Init() tea.Cmd {
|
||||
if p.mode == promptModeInput {
|
||||
return textarea.Blink
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update handles messages for the prompt overlay. It returns a non-nil
|
||||
// *promptResult when the user completes or cancels the prompt. The returned
|
||||
// tea.Cmd is for textarea blink ticks (input mode only).
|
||||
func (p *promptOverlay) Update(msg tea.Msg) (*promptResult, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
p.width = msg.Width
|
||||
p.height = msg.Height
|
||||
if p.mode == promptModeInput {
|
||||
p.inputTA.SetWidth(p.width - 12)
|
||||
}
|
||||
return nil, nil
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
switch p.mode {
|
||||
case promptModeSelect:
|
||||
return p.updateSelect(msg)
|
||||
case promptModeConfirm:
|
||||
return p.updateConfirm(msg)
|
||||
case promptModeInput:
|
||||
return p.updateInput(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Pass non-key messages to textarea for blink animation.
|
||||
if p.mode == promptModeInput {
|
||||
var cmd tea.Cmd
|
||||
p.inputTA, cmd = p.inputTA.Update(msg)
|
||||
return nil, cmd
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (p *promptOverlay) updateSelect(msg tea.KeyPressMsg) (*promptResult, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "up", "k":
|
||||
if p.selected > 0 {
|
||||
p.selected--
|
||||
}
|
||||
case "down", "j":
|
||||
if p.selected < len(p.options)-1 {
|
||||
p.selected++
|
||||
}
|
||||
case "home":
|
||||
p.selected = 0
|
||||
case "end":
|
||||
if len(p.options) > 0 {
|
||||
p.selected = len(p.options) - 1
|
||||
}
|
||||
case "enter":
|
||||
value := ""
|
||||
if p.selected < len(p.options) {
|
||||
value = p.options[p.selected]
|
||||
}
|
||||
return &promptResult{completed: true, value: value, index: p.selected}, nil
|
||||
case "esc":
|
||||
return &promptResult{cancelled: true}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (p *promptOverlay) updateConfirm(msg tea.KeyPressMsg) (*promptResult, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "left", "h", "y", "Y":
|
||||
p.confirmed = true
|
||||
case "right", "l", "n", "N":
|
||||
p.confirmed = false
|
||||
case "tab":
|
||||
p.confirmed = !p.confirmed
|
||||
case "enter":
|
||||
return &promptResult{completed: true, confirmed: p.confirmed}, nil
|
||||
case "esc":
|
||||
return &promptResult{cancelled: true}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (p *promptOverlay) updateInput(msg tea.KeyPressMsg) (*promptResult, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "enter":
|
||||
return &promptResult{completed: true, value: p.inputTA.Value()}, nil
|
||||
case "esc":
|
||||
return &promptResult{cancelled: true}, nil
|
||||
default:
|
||||
// Delegate character input, backspace, cursor movement, etc.
|
||||
var cmd tea.Cmd
|
||||
p.inputTA, cmd = p.inputTA.Update(msg)
|
||||
return nil, cmd
|
||||
}
|
||||
}
|
||||
|
||||
// Render returns the prompt as a styled string for inline composition in the
|
||||
// AppModel layout. The prompt replaces the normal input area (below the
|
||||
// separator and above the status bar) rather than taking over the full screen.
|
||||
func (p *promptOverlay) Render() string {
|
||||
theme := GetTheme()
|
||||
var content string
|
||||
|
||||
switch p.mode {
|
||||
case promptModeSelect:
|
||||
content = p.viewSelect(theme)
|
||||
case promptModeConfirm:
|
||||
content = p.viewConfirm(theme)
|
||||
case promptModeInput:
|
||||
content = p.viewInput(theme)
|
||||
}
|
||||
|
||||
return renderContentBlock(content, p.width,
|
||||
WithAlign(lipgloss.Left),
|
||||
WithBorderColor(theme.Accent),
|
||||
WithPaddingTop(0),
|
||||
WithPaddingBottom(0),
|
||||
)
|
||||
}
|
||||
|
||||
func (p *promptOverlay) viewSelect(theme Theme) string {
|
||||
var lines []string
|
||||
lines = append(lines, lipgloss.NewStyle().Bold(true).Foreground(theme.Text).Render(p.message))
|
||||
lines = append(lines, "")
|
||||
|
||||
for i, opt := range p.options {
|
||||
if i == p.selected {
|
||||
cursor := lipgloss.NewStyle().Foreground(theme.Accent).Bold(true).Render("> ")
|
||||
label := lipgloss.NewStyle().Foreground(theme.Accent).Bold(true).Render(opt)
|
||||
lines = append(lines, " "+cursor+label)
|
||||
} else {
|
||||
lines = append(lines, " "+lipgloss.NewStyle().Foreground(theme.Text).Render(opt))
|
||||
}
|
||||
}
|
||||
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, lipgloss.NewStyle().
|
||||
Foreground(theme.Muted).
|
||||
Render(" up/down navigate Enter select Esc cancel"))
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (p *promptOverlay) viewConfirm(theme Theme) string {
|
||||
var lines []string
|
||||
lines = append(lines, lipgloss.NewStyle().Bold(true).Foreground(theme.Text).Render(p.message))
|
||||
lines = append(lines, "")
|
||||
|
||||
yesStyle := lipgloss.NewStyle().Foreground(theme.Text)
|
||||
noStyle := lipgloss.NewStyle().Foreground(theme.Text)
|
||||
if p.confirmed {
|
||||
yesStyle = yesStyle.Bold(true).Foreground(theme.Accent)
|
||||
} else {
|
||||
noStyle = noStyle.Bold(true).Foreground(theme.Accent)
|
||||
}
|
||||
|
||||
yes := yesStyle.Render("[Yes]")
|
||||
no := noStyle.Render("[No]")
|
||||
lines = append(lines, " "+yes+" "+no)
|
||||
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, lipgloss.NewStyle().
|
||||
Foreground(theme.Muted).
|
||||
Render(" left/right switch y/n Enter confirm Esc cancel"))
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (p *promptOverlay) viewInput(theme Theme) string {
|
||||
var lines []string
|
||||
lines = append(lines, lipgloss.NewStyle().Bold(true).Foreground(theme.Text).Render(p.message))
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, p.inputTA.View())
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, lipgloss.NewStyle().
|
||||
Foreground(theme.Muted).
|
||||
Render(" Enter submit Esc cancel"))
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
+14
-9
@@ -15,18 +15,20 @@ import (
|
||||
// The KITT-style frames are generated by knightRiderFrames() in stream.go
|
||||
// (same package) and use the active theme colors.
|
||||
type Spinner struct {
|
||||
frames []string
|
||||
fps time.Duration
|
||||
done chan struct{}
|
||||
once sync.Once
|
||||
frames []string
|
||||
fps time.Duration
|
||||
done chan struct{}
|
||||
finished chan struct{} // closed by run() after cleanup
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
// NewSpinner creates a new animated KITT-style spinner using theme colors.
|
||||
func NewSpinner() *Spinner {
|
||||
return &Spinner{
|
||||
frames: knightRiderFrames(),
|
||||
fps: time.Second / 14,
|
||||
done: make(chan struct{}),
|
||||
frames: knightRiderFrames(),
|
||||
fps: time.Second / 14,
|
||||
done: make(chan struct{}),
|
||||
finished: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,14 +38,17 @@ func (s *Spinner) Start() {
|
||||
go s.run()
|
||||
}
|
||||
|
||||
// Stop halts the spinner animation and cleans up. This method blocks until
|
||||
// the animation goroutine has exited and the line is cleared.
|
||||
// Stop halts the spinner animation and blocks until the animation goroutine
|
||||
// has exited and the line is cleared. Safe to call multiple times.
|
||||
func (s *Spinner) Stop() {
|
||||
s.once.Do(func() { close(s.done) })
|
||||
<-s.finished
|
||||
}
|
||||
|
||||
// run is the animation loop that renders spinner frames to stderr.
|
||||
func (s *Spinner) run() {
|
||||
defer close(s.finished) // unblock Stop()
|
||||
|
||||
ticker := time.NewTicker(s.fps)
|
||||
defer ticker.Stop()
|
||||
|
||||
|
||||
+9
-45
@@ -23,15 +23,9 @@ func BaseStyle() lipgloss.Style {
|
||||
// GetMarkdownRenderer creates and returns a configured glamour.TermRenderer for
|
||||
// rendering markdown content with syntax highlighting and proper formatting. The
|
||||
// renderer is customized with our theme colors and adapted to the specified width.
|
||||
// An optional background color hex string (e.g. "#45475a") can be provided so
|
||||
// that the rendered markdown inherits the background color.
|
||||
func GetMarkdownRenderer(width int, bgHex ...string) *glamour.TermRenderer {
|
||||
var bg string
|
||||
if len(bgHex) > 0 {
|
||||
bg = bgHex[0]
|
||||
}
|
||||
func GetMarkdownRenderer(width int) *glamour.TermRenderer {
|
||||
r, _ := glamour.NewTermRenderer(
|
||||
glamour.WithStyles(generateMarkdownStyleConfig(bg)),
|
||||
glamour.WithStyles(generateMarkdownStyleConfig()),
|
||||
glamour.WithWordWrap(width),
|
||||
)
|
||||
return r
|
||||
@@ -100,32 +94,15 @@ func resolveColorScheme() colorScheme {
|
||||
}
|
||||
|
||||
// generateMarkdownStyleConfig creates an ansi.StyleConfig for markdown rendering.
|
||||
// An optional background color hex string can be provided; when non-empty it is
|
||||
// applied to the Document, Paragraph, List, and BlockQuote elements so that
|
||||
// glamour-rendered content inherits the background uniformly.
|
||||
func generateMarkdownStyleConfig(bgHex ...string) ansi.StyleConfig {
|
||||
func generateMarkdownStyleConfig() ansi.StyleConfig {
|
||||
cs := resolveColorScheme()
|
||||
|
||||
// Background color for indent/whitespace tokens inside glamour.
|
||||
// When empty the tokens are transparent.
|
||||
bgColor := ""
|
||||
if len(bgHex) > 0 && bgHex[0] != "" {
|
||||
bgColor = bgHex[0]
|
||||
}
|
||||
|
||||
// Document-level background (propagates to child elements).
|
||||
var docBg *string
|
||||
if bgColor != "" {
|
||||
docBg = &bgColor
|
||||
}
|
||||
|
||||
return ansi.StyleConfig{
|
||||
Document: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
BlockPrefix: "",
|
||||
BlockSuffix: "",
|
||||
Color: &cs.text,
|
||||
BackgroundColor: docBg,
|
||||
BlockPrefix: "",
|
||||
BlockSuffix: "",
|
||||
Color: &cs.text,
|
||||
},
|
||||
Margin: uintPtr(0), // Remove margin to prevent spacing
|
||||
},
|
||||
@@ -135,13 +112,11 @@ func generateMarkdownStyleConfig(bgHex ...string) ansi.StyleConfig {
|
||||
Italic: new(true),
|
||||
Prefix: "┃ ",
|
||||
},
|
||||
Indent: uintPtr(1),
|
||||
IndentToken: new(lipgloss.NewStyle().Background(lipgloss.Color(bgColor)).Render(" ")),
|
||||
Indent: uintPtr(1),
|
||||
},
|
||||
List: ansi.StyleList{
|
||||
LevelIndent: 0, // Remove list indentation
|
||||
StyleBlock: ansi.StyleBlock{
|
||||
IndentToken: new(lipgloss.NewStyle().Background(lipgloss.Color(bgColor)).Render(" ")),
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
Color: &cs.text,
|
||||
},
|
||||
@@ -316,13 +291,11 @@ func generateMarkdownStyleConfig(bgHex ...string) ansi.StyleConfig {
|
||||
Color: &cs.link,
|
||||
},
|
||||
Text: ansi.StylePrimitive{
|
||||
Color: &cs.text,
|
||||
BackgroundColor: docBg,
|
||||
Color: &cs.text,
|
||||
},
|
||||
Paragraph: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
Color: &cs.text,
|
||||
BackgroundColor: docBg,
|
||||
Color: &cs.text,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -334,12 +307,3 @@ func toMarkdown(content string, width int) string {
|
||||
rendered, _ := r.Render(content)
|
||||
return rendered
|
||||
}
|
||||
|
||||
// toMarkdownWithBg renders markdown content using glamour with a background
|
||||
// color applied to all elements so the rendered text blends with the block's
|
||||
// background.
|
||||
func toMarkdownWithBg(content string, width int, bgHex string) string {
|
||||
r := GetMarkdownRenderer(width, bgHex)
|
||||
rendered, _ := r.Render(content)
|
||||
return rendered
|
||||
}
|
||||
|
||||
+243
-20
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/mark3labs/kit/internal/agent"
|
||||
"github.com/mark3labs/kit/internal/config"
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
"github.com/mark3labs/kit/internal/kitsetup"
|
||||
"github.com/mark3labs/kit/internal/session"
|
||||
"github.com/mark3labs/kit/internal/skills"
|
||||
"github.com/mark3labs/kit/internal/tools"
|
||||
@@ -57,15 +58,216 @@ func (m *Kit) Subscribe(listener EventListener) func() {
|
||||
}
|
||||
|
||||
// GetExtRunner returns the extension runner (nil if extensions are disabled).
|
||||
//
|
||||
// Deprecated: Use SetExtensionContext and EmitSessionStart instead. GetExtRunner
|
||||
// leaks the internal extensions.Runner type across the SDK boundary.
|
||||
func (m *Kit) GetExtRunner() *extensions.Runner { return m.extRunner }
|
||||
|
||||
// GetBufferedLogger returns the buffered debug logger (nil if not configured).
|
||||
//
|
||||
// Deprecated: Use GetBufferedDebugMessages instead.
|
||||
func (m *Kit) GetBufferedLogger() *tools.BufferedDebugLogger { return m.bufferedLogger }
|
||||
|
||||
// GetAgent returns the underlying agent. Callers that need the raw agent
|
||||
// (e.g. for GetTools(), GetLoadingMessage()) can use this.
|
||||
// GetAgent returns the underlying agent.
|
||||
//
|
||||
// Deprecated: Use GetToolNames, GetLoadingMessage, GetLoadedServerNames,
|
||||
// GetMCPToolCount, GetExtensionToolCount instead.
|
||||
func (m *Kit) GetAgent() *agent.Agent { return m.agent }
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Narrow accessors — prefer these over GetAgent/GetExtRunner/GetBufferedLogger
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// GetToolNames returns the names of all tools available to the agent.
|
||||
func (m *Kit) GetToolNames() []string {
|
||||
agentTools := m.agent.GetTools()
|
||||
names := make([]string, len(agentTools))
|
||||
for i, t := range agentTools {
|
||||
names[i] = t.Info().Name
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// GetLoadingMessage returns the agent's startup info message (e.g. GPU
|
||||
// fallback info), or empty string if none.
|
||||
func (m *Kit) GetLoadingMessage() string {
|
||||
return m.agent.GetLoadingMessage()
|
||||
}
|
||||
|
||||
// GetLoadedServerNames returns the names of successfully loaded MCP servers.
|
||||
func (m *Kit) GetLoadedServerNames() []string {
|
||||
return m.agent.GetLoadedServerNames()
|
||||
}
|
||||
|
||||
// GetMCPToolCount returns the number of tools loaded from external MCP servers.
|
||||
func (m *Kit) GetMCPToolCount() int {
|
||||
return m.agent.GetMCPToolCount()
|
||||
}
|
||||
|
||||
// GetExtensionToolCount returns the number of tools registered by extensions.
|
||||
func (m *Kit) GetExtensionToolCount() int {
|
||||
return m.agent.GetExtensionToolCount()
|
||||
}
|
||||
|
||||
// GetBufferedDebugMessages returns any debug messages that were buffered
|
||||
// during initialization, then clears the buffer. Returns nil if no messages
|
||||
// were buffered or if buffered logging was not configured.
|
||||
func (m *Kit) GetBufferedDebugMessages() []string {
|
||||
if m.bufferedLogger == nil {
|
||||
return nil
|
||||
}
|
||||
return m.bufferedLogger.GetMessages()
|
||||
}
|
||||
|
||||
// SetExtensionContext configures the extension runner with the given context
|
||||
// functions. No-op if extensions are disabled.
|
||||
func (m *Kit) SetExtensionContext(ctx extensions.Context) {
|
||||
if m.extRunner != nil {
|
||||
m.extRunner.SetContext(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// GetExtensionContext returns the current extension runtime context.
|
||||
// Returns a zero Context if extensions are disabled.
|
||||
func (m *Kit) GetExtensionContext() extensions.Context {
|
||||
if m.extRunner != nil {
|
||||
return m.extRunner.GetContext()
|
||||
}
|
||||
return extensions.Context{}
|
||||
}
|
||||
|
||||
// EmitSessionStart fires the SessionStart event for extensions.
|
||||
// No-op if extensions are disabled or no handlers are registered.
|
||||
func (m *Kit) EmitSessionStart() {
|
||||
if m.extRunner != nil && m.extRunner.HasHandlers(extensions.SessionStart) {
|
||||
_, _ = m.extRunner.Emit(extensions.SessionStartEvent{})
|
||||
}
|
||||
}
|
||||
|
||||
// ExtensionCommands returns the slash commands registered by extensions.
|
||||
// Returns nil if extensions are disabled or no commands are registered.
|
||||
func (m *Kit) ExtensionCommands() []extensions.CommandDef {
|
||||
if m.extRunner == nil {
|
||||
return nil
|
||||
}
|
||||
return m.extRunner.RegisteredCommands()
|
||||
}
|
||||
|
||||
// SetExtensionWidget places or updates a persistent extension widget.
|
||||
// Delegates to the extension runner. No-op if extensions are disabled.
|
||||
func (m *Kit) SetExtensionWidget(config extensions.WidgetConfig) {
|
||||
if m.extRunner != nil {
|
||||
m.extRunner.SetWidget(config)
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveExtensionWidget removes a previously placed extension widget by ID.
|
||||
// Delegates to the extension runner. No-op if extensions are disabled.
|
||||
func (m *Kit) RemoveExtensionWidget(id string) {
|
||||
if m.extRunner != nil {
|
||||
m.extRunner.RemoveWidget(id)
|
||||
}
|
||||
}
|
||||
|
||||
// GetExtensionWidgets returns extension widgets matching the given placement.
|
||||
// Returns nil if extensions are disabled or no widgets match.
|
||||
func (m *Kit) GetExtensionWidgets(placement extensions.WidgetPlacement) []extensions.WidgetConfig {
|
||||
if m.extRunner == nil {
|
||||
return nil
|
||||
}
|
||||
return m.extRunner.GetWidgets(placement)
|
||||
}
|
||||
|
||||
// SetExtensionHeader places or replaces the custom header from extensions.
|
||||
// Delegates to the extension runner. No-op if extensions are disabled.
|
||||
func (m *Kit) SetExtensionHeader(config extensions.HeaderFooterConfig) {
|
||||
if m.extRunner != nil {
|
||||
m.extRunner.SetHeader(config)
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveExtensionHeader removes the custom extension header.
|
||||
// Delegates to the extension runner. No-op if extensions are disabled.
|
||||
func (m *Kit) RemoveExtensionHeader() {
|
||||
if m.extRunner != nil {
|
||||
m.extRunner.RemoveHeader()
|
||||
}
|
||||
}
|
||||
|
||||
// GetExtensionHeader returns the current custom header, or nil if none is set.
|
||||
// Returns nil if extensions are disabled.
|
||||
func (m *Kit) GetExtensionHeader() *extensions.HeaderFooterConfig {
|
||||
if m.extRunner == nil {
|
||||
return nil
|
||||
}
|
||||
return m.extRunner.GetHeader()
|
||||
}
|
||||
|
||||
// SetExtensionFooter places or replaces the custom footer from extensions.
|
||||
// Delegates to the extension runner. No-op if extensions are disabled.
|
||||
func (m *Kit) SetExtensionFooter(config extensions.HeaderFooterConfig) {
|
||||
if m.extRunner != nil {
|
||||
m.extRunner.SetFooter(config)
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveExtensionFooter removes the custom extension footer.
|
||||
// Delegates to the extension runner. No-op if extensions are disabled.
|
||||
func (m *Kit) RemoveExtensionFooter() {
|
||||
if m.extRunner != nil {
|
||||
m.extRunner.RemoveFooter()
|
||||
}
|
||||
}
|
||||
|
||||
// GetExtensionFooter returns the current custom footer, or nil if none is set.
|
||||
// Returns nil if extensions are disabled.
|
||||
func (m *Kit) GetExtensionFooter() *extensions.HeaderFooterConfig {
|
||||
if m.extRunner == nil {
|
||||
return nil
|
||||
}
|
||||
return m.extRunner.GetFooter()
|
||||
}
|
||||
|
||||
// GetExtensionToolRenderer returns the custom renderer for the named tool, or
|
||||
// nil if no extension registered a renderer for it. Returns nil if extensions
|
||||
// are disabled.
|
||||
func (m *Kit) GetExtensionToolRenderer(toolName string) *extensions.ToolRenderConfig {
|
||||
if m.extRunner == nil {
|
||||
return nil
|
||||
}
|
||||
return m.extRunner.GetToolRenderer(toolName)
|
||||
}
|
||||
|
||||
// SetExtensionEditor installs an editor interceptor from extensions.
|
||||
// Delegates to the extension runner. No-op if extensions are disabled.
|
||||
func (m *Kit) SetExtensionEditor(config extensions.EditorConfig) {
|
||||
if m.extRunner != nil {
|
||||
m.extRunner.SetEditor(config)
|
||||
}
|
||||
}
|
||||
|
||||
// ResetExtensionEditor removes the active editor interceptor from extensions.
|
||||
// Delegates to the extension runner. No-op if extensions are disabled.
|
||||
func (m *Kit) ResetExtensionEditor() {
|
||||
if m.extRunner != nil {
|
||||
m.extRunner.ResetEditor()
|
||||
}
|
||||
}
|
||||
|
||||
// GetExtensionEditor returns the current editor interceptor, or nil if none
|
||||
// is set. Returns nil if extensions are disabled.
|
||||
func (m *Kit) GetExtensionEditor() *extensions.EditorConfig {
|
||||
if m.extRunner == nil {
|
||||
return nil
|
||||
}
|
||||
return m.extRunner.GetEditor()
|
||||
}
|
||||
|
||||
// HasExtensions returns true if the extension runner is configured and active.
|
||||
func (m *Kit) HasExtensions() bool {
|
||||
return m.extRunner != nil
|
||||
}
|
||||
|
||||
// Options configures Kit creation with optional overrides for model,
|
||||
// prompts, configuration, and behavior settings. All fields are optional
|
||||
// and will use CLI defaults if not specified.
|
||||
@@ -93,12 +295,25 @@ type Options struct {
|
||||
AutoCompact bool // Auto-compact when near context limit
|
||||
CompactionOptions *CompactionOptions // Config for auto-compaction (nil = defaults)
|
||||
|
||||
// CLI-specific fields (ignored by programmatic SDK users)
|
||||
MCPConfig *config.Config // Pre-loaded MCP config (skips LoadAndValidateConfig if set)
|
||||
ShowSpinner bool // Show loading spinner for Ollama models
|
||||
SpinnerFunc SpinnerFunc // Spinner implementation (nil = no spinner)
|
||||
UseBufferedLogger bool // Buffer debug messages for later display
|
||||
Debug bool // Enable debug logging
|
||||
// Debug enables debug logging for the SDK.
|
||||
Debug bool
|
||||
|
||||
// CLI is optional CLI-specific configuration. SDK users leave this nil.
|
||||
CLI *CLIOptions
|
||||
}
|
||||
|
||||
// CLIOptions holds fields only relevant to the CLI binary. SDK users should
|
||||
// not need these; they are separated to keep the main Options struct clean.
|
||||
type CLIOptions struct {
|
||||
// MCPConfig is a pre-loaded MCP config. When set, LoadAndValidateConfig
|
||||
// is skipped during Kit creation.
|
||||
MCPConfig *config.Config
|
||||
// ShowSpinner shows a loading spinner for Ollama models.
|
||||
ShowSpinner bool
|
||||
// SpinnerFunc provides the spinner implementation (nil = no spinner).
|
||||
SpinnerFunc SpinnerFunc
|
||||
// UseBufferedLogger buffers debug messages for later display.
|
||||
UseBufferedLogger bool
|
||||
}
|
||||
|
||||
// InitTreeSession creates or opens a tree session based on the given options.
|
||||
@@ -211,8 +426,11 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
|
||||
viper.Set("system-prompt", pb.Build())
|
||||
}
|
||||
|
||||
// Load MCP configuration. Use pre-loaded config if provided.
|
||||
mcpConfig := opts.MCPConfig
|
||||
// Load MCP configuration. Use pre-loaded config if provided via CLI options.
|
||||
var mcpConfig *config.Config
|
||||
if opts.CLI != nil {
|
||||
mcpConfig = opts.CLI.MCPConfig
|
||||
}
|
||||
if mcpConfig == nil {
|
||||
mcpConfig, err = config.LoadAndValidateConfig()
|
||||
if err != nil {
|
||||
@@ -228,17 +446,22 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
|
||||
beforeTurn := newHookRegistry[BeforeTurnHook, BeforeTurnResult]()
|
||||
afterTurn := newHookRegistry[AfterTurnHook, AfterTurnResult]()
|
||||
|
||||
// Build agent setup options, pulling CLI-specific fields when available.
|
||||
setupOpts := kitsetup.AgentSetupOptions{
|
||||
MCPConfig: mcpConfig,
|
||||
Quiet: opts.Quiet,
|
||||
CoreTools: opts.Tools,
|
||||
ExtraTools: opts.ExtraTools,
|
||||
ToolWrapper: hookToolWrapper(beforeToolCall, afterToolResult),
|
||||
}
|
||||
if opts.CLI != nil {
|
||||
setupOpts.ShowSpinner = opts.CLI.ShowSpinner
|
||||
setupOpts.SpinnerFunc = opts.CLI.SpinnerFunc
|
||||
setupOpts.UseBufferedLogger = opts.CLI.UseBufferedLogger
|
||||
}
|
||||
|
||||
// Create agent using shared setup with the hook tool wrapper.
|
||||
agentResult, err := SetupAgent(ctx, AgentSetupOptions{
|
||||
MCPConfig: mcpConfig,
|
||||
Quiet: opts.Quiet,
|
||||
ShowSpinner: opts.ShowSpinner,
|
||||
SpinnerFunc: opts.SpinnerFunc,
|
||||
UseBufferedLogger: opts.UseBufferedLogger,
|
||||
CoreTools: opts.Tools,
|
||||
ExtraTools: opts.ExtraTools,
|
||||
ToolWrapper: hookToolWrapper(beforeToolCall, afterToolResult),
|
||||
})
|
||||
agentResult, err := kitsetup.SetupAgent(ctx, setupOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1,503 +0,0 @@
|
||||
# Plan 00: Create `pkg/kit` SDK Package & Extract Init from `cmd`
|
||||
|
||||
**Priority**: P0
|
||||
**Effort**: Medium-High
|
||||
**Goal**: Create `pkg/kit` as the canonical SDK package; extract shared logic from `cmd/` so both the CLI and external users consume the same API
|
||||
|
||||
## Background
|
||||
|
||||
Currently the SDK lives in `sdk/` and imports `cmd/` to access `InitConfig`, `SetupAgent`, etc. This creates a circular dependency problem: if the CLI app wants to consume the SDK, `cmd` would import `sdk` which imports `cmd`.
|
||||
|
||||
The fix is two-fold:
|
||||
1. Move the SDK to `pkg/kit/` (idiomatic Go for public library packages)
|
||||
2. Extract configuration/agent-setup logic from `cmd/` into `pkg/kit/` so both the CLI and SDK share the same code path without circular deps
|
||||
|
||||
### Architecture Before
|
||||
|
||||
```
|
||||
main.go → cmd/ → internal/agent, internal/session, internal/config, ...
|
||||
|
||||
sdk/kit.go → cmd.InitConfig() ← SDK depends on cmd (problem!)
|
||||
→ cmd.SetupAgent()
|
||||
→ internal/session
|
||||
```
|
||||
|
||||
### Architecture After
|
||||
|
||||
```
|
||||
cmd/kit/main.go → cmd/ → pkg/kit/ → internal/agent, internal/session, ...
|
||||
← CLI consumes SDK
|
||||
|
||||
pkg/kit/ → internal/agent, internal/session, internal/config, ...
|
||||
← External users consume SDK
|
||||
|
||||
internal/app/ → pkg/kit/ ← App consumes SDK (gradual migration)
|
||||
→ internal/ui/ ← App owns UI only
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- None. This is the foundation for all other plans.
|
||||
|
||||
## Step-by-Step
|
||||
|
||||
### Step 1: Create `pkg/kit/` directory
|
||||
|
||||
```bash
|
||||
mkdir -p pkg/kit
|
||||
```
|
||||
|
||||
### Step 2: Extract config-loading logic from `cmd/root.go` into `pkg/kit/config.go`
|
||||
|
||||
The two functions `InitConfig()` and `LoadConfigWithEnvSubstitution()` currently live in `cmd/root.go` and depend on package-level variables (`configFile`, `debugMode`). Extract them as pure functions that accept parameters.
|
||||
|
||||
**File**: Create `pkg/kit/config.go`
|
||||
|
||||
```go
|
||||
package kit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/mark3labs/kit/internal/config"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// InitConfig initializes the viper configuration system.
|
||||
// It searches for config files in standard locations and loads them with
|
||||
// environment variable substitution.
|
||||
//
|
||||
// configFile: explicit config file path (empty = search defaults)
|
||||
// debug: if true, print warnings about missing configs
|
||||
func InitConfig(configFile string, debug bool) error {
|
||||
if configFile != "" {
|
||||
return LoadConfigWithEnvSubstitution(configFile)
|
||||
}
|
||||
|
||||
// Ensure a config file exists (create default if none found)
|
||||
if err := config.EnsureConfigExists(); err != nil {
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "Warning: Could not create default config file: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error finding home directory: %w", err)
|
||||
}
|
||||
|
||||
viper.AddConfigPath(".")
|
||||
viper.AddConfigPath(home)
|
||||
|
||||
configNames := []string{".kit"}
|
||||
configLoaded := false
|
||||
|
||||
for _, name := range configNames {
|
||||
viper.SetConfigName(name)
|
||||
if err := viper.ReadInConfig(); err == nil {
|
||||
configPath := viper.ConfigFileUsed()
|
||||
if err := LoadConfigWithEnvSubstitution(configPath); err != nil {
|
||||
if strings.Contains(err.Error(), "environment variable substitution failed") {
|
||||
return fmt.Errorf("error reading config file '%s': %w", configPath, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
configLoaded = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !configLoaded && debug {
|
||||
fmt.Fprintf(os.Stderr, "No config file found in current directory or home directory\n")
|
||||
}
|
||||
|
||||
viper.SetEnvPrefix("KIT")
|
||||
viper.AutomaticEnv()
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadConfigWithEnvSubstitution loads a config file with ${ENV_VAR} expansion.
|
||||
func LoadConfigWithEnvSubstitution(configPath string) error {
|
||||
rawContent, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read config file: %w", err)
|
||||
}
|
||||
|
||||
substituter := &config.EnvSubstituter{}
|
||||
processedContent, err := substituter.SubstituteEnvVars(string(rawContent))
|
||||
if err != nil {
|
||||
return fmt.Errorf("config env substitution failed: %w", err)
|
||||
}
|
||||
|
||||
configType := "yaml"
|
||||
if strings.HasSuffix(configPath, ".json") {
|
||||
configType = "json"
|
||||
}
|
||||
|
||||
config.SetConfigPath(configPath)
|
||||
viper.SetConfigType(configType)
|
||||
return viper.ReadConfig(strings.NewReader(processedContent))
|
||||
}
|
||||
```
|
||||
|
||||
**Source**: Extracted from `cmd/root.go:119-213`
|
||||
|
||||
### Step 3: Extract agent setup logic from `cmd/setup.go` into `pkg/kit/setup.go`
|
||||
|
||||
Move `BuildProviderConfig`, `AgentSetupOptions`, `AgentSetupResult`, `SetupAgent`, and `setupExtensions` to the SDK. The key change: replace the `quietFlag` package-level variable dependency with an explicit `Quiet` field on `AgentSetupOptions`.
|
||||
|
||||
**File**: Create `pkg/kit/setup.go`
|
||||
|
||||
```go
|
||||
package kit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/mark3labs/kit/internal/agent"
|
||||
"github.com/mark3labs/kit/internal/config"
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
"github.com/mark3labs/kit/internal/hooks"
|
||||
"github.com/mark3labs/kit/internal/models"
|
||||
"github.com/mark3labs/kit/internal/tools"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// AgentSetupOptions configures agent creation.
|
||||
type AgentSetupOptions struct {
|
||||
MCPConfig *config.Config
|
||||
ShowSpinner bool
|
||||
SpinnerFunc agent.SpinnerFunc
|
||||
UseBufferedLogger bool
|
||||
Quiet bool // Replaces cmd's quietFlag package var
|
||||
}
|
||||
|
||||
// AgentSetupResult contains the created agent and related components.
|
||||
type AgentSetupResult struct {
|
||||
Agent *agent.Agent
|
||||
BufferedLogger *tools.BufferedDebugLogger
|
||||
ExtRunner *extensions.Runner
|
||||
}
|
||||
|
||||
// BuildProviderConfig creates a ProviderConfig from the current viper state.
|
||||
func BuildProviderConfig() (*models.ProviderConfig, string, error) {
|
||||
systemPrompt, err := config.LoadSystemPrompt(viper.GetString("system-prompt"))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to load system prompt: %w", err)
|
||||
}
|
||||
|
||||
temperature := float32(viper.GetFloat64("temperature"))
|
||||
topP := float32(viper.GetFloat64("top-p"))
|
||||
topK := int32(viper.GetInt("top-k"))
|
||||
numGPU := int32(viper.GetInt("num-gpu-layers"))
|
||||
mainGPU := int32(viper.GetInt("main-gpu"))
|
||||
|
||||
cfg := &models.ProviderConfig{
|
||||
ModelString: viper.GetString("model"),
|
||||
SystemPrompt: systemPrompt,
|
||||
ProviderAPIKey: viper.GetString("provider-api-key"),
|
||||
ProviderURL: viper.GetString("provider-url"),
|
||||
MaxTokens: viper.GetInt("max-tokens"),
|
||||
Temperature: &temperature,
|
||||
TopP: &topP,
|
||||
TopK: &topK,
|
||||
StopSequences: viper.GetStringSlice("stop-sequences"),
|
||||
NumGPU: &numGPU,
|
||||
MainGPU: &mainGPU,
|
||||
TLSSkipVerify: viper.GetBool("tls-skip-verify"),
|
||||
}
|
||||
|
||||
return cfg, systemPrompt, nil
|
||||
}
|
||||
|
||||
// SetupAgent creates an agent from the current configuration state.
|
||||
func SetupAgent(ctx context.Context, opts AgentSetupOptions) (*AgentSetupResult, error) {
|
||||
modelConfig, systemPrompt, err := BuildProviderConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var debugLogger tools.DebugLogger
|
||||
var bufferedLogger *tools.BufferedDebugLogger
|
||||
if viper.GetBool("debug") {
|
||||
if opts.UseBufferedLogger {
|
||||
bufferedLogger = tools.NewBufferedDebugLogger(true)
|
||||
debugLogger = bufferedLogger
|
||||
} else {
|
||||
debugLogger = tools.NewSimpleDebugLogger(true)
|
||||
}
|
||||
}
|
||||
|
||||
var extRunner *extensions.Runner
|
||||
var extOpts extensionCreationOpts
|
||||
if !viper.GetBool("no-extensions") {
|
||||
var extErr error
|
||||
extRunner, extOpts, extErr = loadExtensions()
|
||||
if extErr != nil {
|
||||
fmt.Printf("Warning: Failed to load extensions: %v\n", extErr)
|
||||
}
|
||||
}
|
||||
|
||||
a, err := agent.CreateAgent(ctx, &agent.AgentCreationOptions{
|
||||
ModelConfig: modelConfig,
|
||||
MCPConfig: opts.MCPConfig,
|
||||
SystemPrompt: systemPrompt,
|
||||
MaxSteps: viper.GetInt("max-steps"),
|
||||
StreamingEnabled: viper.GetBool("stream"),
|
||||
ShowSpinner: opts.ShowSpinner,
|
||||
Quiet: opts.Quiet,
|
||||
SpinnerFunc: opts.SpinnerFunc,
|
||||
DebugLogger: debugLogger,
|
||||
ToolWrapper: extOpts.toolWrapper,
|
||||
ExtraTools: extOpts.extraTools,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create agent: %w", err)
|
||||
}
|
||||
|
||||
return &AgentSetupResult{
|
||||
Agent: a,
|
||||
ExtRunner: extRunner,
|
||||
BufferedLogger: bufferedLogger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// unexported helpers
|
||||
|
||||
type extensionCreationOpts struct {
|
||||
toolWrapper func([]fantasy.AgentTool) []fantasy.AgentTool
|
||||
extraTools []fantasy.AgentTool
|
||||
}
|
||||
|
||||
func loadExtensions() (*extensions.Runner, extensionCreationOpts, error) {
|
||||
extraPaths := viper.GetStringSlice("extension")
|
||||
loaded, err := extensions.LoadExtensions(extraPaths)
|
||||
if err != nil {
|
||||
return nil, extensionCreationOpts{}, err
|
||||
}
|
||||
|
||||
hooksCfg, _ := hooks.LoadHooksConfig()
|
||||
if hooksCfg != nil && len(hooksCfg.Hooks) > 0 {
|
||||
compat := extensions.HooksAsExtension(hooksCfg)
|
||||
if compat != nil {
|
||||
loaded = append([]extensions.LoadedExtension{*compat}, loaded...)
|
||||
}
|
||||
}
|
||||
|
||||
if len(loaded) == 0 {
|
||||
return nil, extensionCreationOpts{}, nil
|
||||
}
|
||||
|
||||
runner := extensions.NewRunner(loaded)
|
||||
wrapper := func(tools []fantasy.AgentTool) []fantasy.AgentTool {
|
||||
return extensions.WrapToolsWithExtensions(tools, runner)
|
||||
}
|
||||
extTools := extensions.ExtensionToolsAsFantasy(runner.RegisteredTools())
|
||||
|
||||
return runner, extensionCreationOpts{
|
||||
toolWrapper: wrapper,
|
||||
extraTools: extTools,
|
||||
}, nil
|
||||
}
|
||||
```
|
||||
|
||||
**Source**: Extracted from `cmd/setup.go:28-185`
|
||||
|
||||
### Step 4: Move SDK core (`sdk/kit.go`, `sdk/types.go`) into `pkg/kit/`
|
||||
|
||||
Move the files and update them to import from the local package (no more `cmd` import):
|
||||
|
||||
**File**: Move `sdk/kit.go` to `pkg/kit/kit.go`
|
||||
|
||||
Key changes:
|
||||
- `package sdk` → `package kit`
|
||||
- Remove `import "github.com/mark3labs/kit/cmd"` entirely
|
||||
- Replace `cmd.InitConfig()` → `InitConfig(...)` (same package)
|
||||
- Replace `cmd.LoadConfigWithEnvSubstitution(...)` → `LoadConfigWithEnvSubstitution(...)` (same package)
|
||||
- Replace `cmd.SetupAgent(...)` → `SetupAgent(...)` (same package)
|
||||
- Replace `cmd.AgentSetupOptions{...}` → `AgentSetupOptions{...}` (same package)
|
||||
|
||||
**File**: Move `sdk/types.go` to `pkg/kit/types.go`
|
||||
|
||||
Key change: `package sdk` → `package kit`
|
||||
|
||||
### Step 5: Move `main.go` to `cmd/kit/main.go`
|
||||
|
||||
**File**: Create `cmd/kit/main.go` with the current `main.go` contents
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/charmbracelet/fang"
|
||||
"github.com/mark3labs/kit/cmd"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 && (os.Args[1] == "--version" || os.Args[1] == "-v") {
|
||||
fmt.Println(version)
|
||||
os.Exit(0)
|
||||
}
|
||||
ctx := context.Background()
|
||||
rootCmd := cmd.GetRootCommand(version)
|
||||
if err := fang.Execute(ctx, rootCmd); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Delete root `main.go`.
|
||||
|
||||
### Step 6: Update `cmd/root.go` to delegate to `pkg/kit`
|
||||
|
||||
**File**: `cmd/root.go`
|
||||
|
||||
Replace the `InitConfig` function body with a call to the SDK:
|
||||
|
||||
```go
|
||||
import kit "github.com/mark3labs/kit/pkg/kit"
|
||||
|
||||
func InitConfig() {
|
||||
if err := kit.InitConfig(configFile, debugMode); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Keep `LoadConfigWithEnvSubstitution` as a thin wrapper or remove it entirely (callers use `kit.LoadConfigWithEnvSubstitution` directly).
|
||||
|
||||
### Step 7: Update `cmd/setup.go` to delegate to `pkg/kit`
|
||||
|
||||
**File**: `cmd/setup.go`
|
||||
|
||||
Replace `BuildProviderConfig`, `SetupAgent`, etc. with thin wrappers that inject CLI-specific state:
|
||||
|
||||
```go
|
||||
import kit "github.com/mark3labs/kit/pkg/kit"
|
||||
|
||||
// BuildProviderConfig delegates to the SDK.
|
||||
func BuildProviderConfig() (*models.ProviderConfig, string, error) {
|
||||
return kit.BuildProviderConfig()
|
||||
}
|
||||
|
||||
// SetupAgent delegates to the SDK, injecting CLI-specific quiet flag.
|
||||
func SetupAgent(ctx context.Context, opts AgentSetupOptions) (*AgentSetupResult, error) {
|
||||
result, err := kit.SetupAgent(ctx, kit.AgentSetupOptions{
|
||||
MCPConfig: opts.MCPConfig,
|
||||
ShowSpinner: opts.ShowSpinner,
|
||||
SpinnerFunc: opts.SpinnerFunc,
|
||||
UseBufferedLogger: opts.UseBufferedLogger,
|
||||
Quiet: quietFlag, // Inject CLI package-level state
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Map SDK result back to cmd types (or make cmd use SDK types directly)
|
||||
return &AgentSetupResult{
|
||||
Agent: result.Agent,
|
||||
BufferedLogger: result.BufferedLogger,
|
||||
ExtRunner: result.ExtRunner,
|
||||
}, nil
|
||||
}
|
||||
```
|
||||
|
||||
**Alternative (cleaner)**: Remove `cmd` wrapper types entirely and have all callers in `cmd/` use `kit.AgentSetupOptions` and `kit.AgentSetupResult` directly. This is the app-as-consumer pattern.
|
||||
|
||||
### Step 8: Update `.goreleaser.yaml`
|
||||
|
||||
Add `main: ./cmd/kit`:
|
||||
|
||||
```yaml
|
||||
builds:
|
||||
- id: kit
|
||||
main: ./cmd/kit
|
||||
binary: kit
|
||||
ldflags:
|
||||
- -s -w -X main.version={{.Version}}
|
||||
```
|
||||
|
||||
### Step 9: Update examples and tests
|
||||
|
||||
**Move**: `sdk/examples/` → `pkg/kit/examples/`
|
||||
|
||||
Update all imports:
|
||||
- `"github.com/mark3labs/kit/sdk"` → `kit "github.com/mark3labs/kit/pkg/kit"`
|
||||
- All `sdk.` → `kit.`
|
||||
|
||||
**Move**: `sdk/kit_test.go` → `pkg/kit/kit_test.go`
|
||||
- `package sdk_test` → `package kit_test`
|
||||
- Update import path
|
||||
|
||||
### Step 10: Clean up old `sdk/` directory
|
||||
|
||||
Remove `sdk/` entirely after all files are moved.
|
||||
|
||||
### Step 11: Update documentation
|
||||
|
||||
- `README.md`: Update import paths to `"github.com/mark3labs/kit/pkg/kit"`
|
||||
- Move `sdk/README.md` → `pkg/kit/README.md` with updated paths
|
||||
|
||||
### Step 12: Verify
|
||||
|
||||
```bash
|
||||
go build -o output/kit ./cmd/kit
|
||||
go test -race ./...
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
Confirm no remaining imports of `"github.com/mark3labs/kit/sdk"` or `"github.com/mark3labs/kit/cmd"` from `pkg/kit/`.
|
||||
|
||||
## Files Changed Summary
|
||||
|
||||
| Action | File | Change |
|
||||
|--------|------|--------|
|
||||
| CREATE | `pkg/kit/config.go` | Extracted InitConfig, LoadConfigWithEnvSubstitution |
|
||||
| CREATE | `pkg/kit/setup.go` | Extracted BuildProviderConfig, SetupAgent, AgentSetupOptions/Result |
|
||||
| MOVE | `sdk/kit.go` → `pkg/kit/kit.go` | Change package, remove cmd import |
|
||||
| MOVE | `sdk/types.go` → `pkg/kit/types.go` | Change package |
|
||||
| MOVE | `sdk/kit_test.go` → `pkg/kit/kit_test.go` | Change package and imports |
|
||||
| MOVE | `sdk/examples/` → `pkg/kit/examples/` | Update imports |
|
||||
| CREATE | `cmd/kit/main.go` | New CLI entrypoint |
|
||||
| DELETE | `main.go` | Moved to cmd/kit/ |
|
||||
| EDIT | `cmd/root.go` | Delegate InitConfig to pkg/kit |
|
||||
| EDIT | `cmd/setup.go` | Delegate SetupAgent to pkg/kit (or use SDK types directly) |
|
||||
| EDIT | `.goreleaser.yaml` | Add `main: ./cmd/kit` |
|
||||
| DELETE | `sdk/` | Entire directory after moves |
|
||||
|
||||
## Dependency Graph After
|
||||
|
||||
```
|
||||
cmd/kit/main.go → cmd/
|
||||
cmd/ → pkg/kit/ (CLI uses SDK)
|
||||
→ internal/app/ (CLI uses app for TUI)
|
||||
→ internal/ui/ (CLI uses UI)
|
||||
pkg/kit/ → internal/agent, internal/session, internal/config, ...
|
||||
(SDK uses internals, never cmd)
|
||||
internal/app/ → pkg/kit/ (App uses SDK — gradual migration)
|
||||
→ internal/ui/ (App owns TUI)
|
||||
```
|
||||
|
||||
**No circular dependencies.**
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [ ] `go build -o output/kit ./cmd/kit` succeeds
|
||||
- [ ] `go test -race ./...` passes
|
||||
- [ ] `go vet ./...` clean
|
||||
- [ ] No `pkg/kit/` file imports `cmd/`
|
||||
- [ ] `cmd/` files import `pkg/kit/` for shared logic
|
||||
- [ ] No remaining references to `"github.com/mark3labs/kit/sdk"`
|
||||
- [ ] Examples compile with new import path
|
||||
- [ ] `.goreleaser.yaml` builds from `./cmd/kit`
|
||||
- [ ] CI passes (`go test ./...`)
|
||||
@@ -1,253 +0,0 @@
|
||||
# Plan 01: Export Tools and Tool Factories
|
||||
|
||||
**Priority**: P0
|
||||
**Effort**: Medium
|
||||
**Goal**: Expose built-in tools as public APIs with pre-built instances and factory functions. The Kit CLI app should also consume these exports instead of reaching into `internal/core` directly.
|
||||
|
||||
## Background
|
||||
|
||||
Pi SDK exports individual tools and tool factories:
|
||||
- Pre-built: `readTool`, `bashTool`, `editTool`, etc.
|
||||
- Factories: `createReadTool(cwd)`, `createBashTool(cwd)`, etc.
|
||||
- Bundles: `allTools`, `codingTools`, `readOnlyTools`
|
||||
|
||||
Kit currently keeps all tools internal (`internal/core/`). The agent setup in `internal/agent/agent.go:97` calls `core.AllTools()` directly. After this plan, both SDK users AND the agent use the same public tool constructors.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Plan 00 (Create `pkg/kit/` package)
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
pkg/kit/
|
||||
├── kit.go # Kit struct, New(), Prompt(), etc.
|
||||
├── types.go # Type aliases
|
||||
├── tools.go # NEW: Public tool exports, factories, bundles
|
||||
├── config.go # Extracted from cmd
|
||||
├── setup.go # Extracted from cmd
|
||||
internal/core/
|
||||
├── tools.go # MODIFY: Add WithWorkDir option
|
||||
├── read.go # MODIFY: Accept workdir param
|
||||
├── write.go # MODIFY: Accept workdir param
|
||||
├── bash.go # MODIFY: Accept workdir param + cmd.Dir
|
||||
├── edit.go # MODIFY: Accept workdir param
|
||||
├── grep.go # MODIFY: Accept workdir param + cmd.Dir
|
||||
├── find.go # MODIFY: Accept workdir param + cmd.Dir
|
||||
├── ls.go # MODIFY: Accept workdir param
|
||||
├── truncate.go # Unchanged
|
||||
internal/agent/
|
||||
├── agent.go # MODIFY: Use public constructors via core package
|
||||
```
|
||||
|
||||
## Step-by-Step
|
||||
|
||||
### Step 1: Add ToolOption pattern to `internal/core/tools.go`
|
||||
|
||||
**File**: `internal/core/tools.go`
|
||||
|
||||
Add a functional options pattern for tool creation:
|
||||
|
||||
```go
|
||||
// ToolOption configures tool behavior.
|
||||
type ToolOption func(*toolConfig)
|
||||
|
||||
type toolConfig struct {
|
||||
workDir string
|
||||
}
|
||||
|
||||
// WithWorkDir sets the working directory for file-based tools.
|
||||
// If empty, os.Getwd() is used at execution time.
|
||||
func WithWorkDir(dir string) ToolOption {
|
||||
return func(c *toolConfig) {
|
||||
c.workDir = dir
|
||||
}
|
||||
}
|
||||
|
||||
func applyOptions(opts []ToolOption) toolConfig {
|
||||
var cfg toolConfig
|
||||
for _, o := range opts {
|
||||
o(&cfg)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
```
|
||||
|
||||
Update all collection functions to accept variadic options:
|
||||
|
||||
```go
|
||||
func CodingTools(opts ...ToolOption) []fantasy.AgentTool { ... }
|
||||
func ReadOnlyTools(opts ...ToolOption) []fantasy.AgentTool { ... }
|
||||
func AllTools(opts ...ToolOption) []fantasy.AgentTool { ... }
|
||||
```
|
||||
|
||||
### Step 2: Update path resolution to accept workDir
|
||||
|
||||
**File**: `internal/core/read.go`
|
||||
|
||||
Replace `resolvePath()` at line 134-144 with configurable version:
|
||||
|
||||
```go
|
||||
func resolvePathWithWorkDir(path, workDir string) (string, error) {
|
||||
if filepath.IsAbs(path) {
|
||||
return filepath.Clean(path), nil
|
||||
}
|
||||
baseDir := workDir
|
||||
if baseDir == "" {
|
||||
var err error
|
||||
baseDir, err = os.Getwd()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
}
|
||||
return filepath.Clean(filepath.Join(baseDir, path)), nil
|
||||
}
|
||||
|
||||
// Backward-compat wrapper
|
||||
func resolvePath(path string) (string, error) {
|
||||
return resolvePathWithWorkDir(path, "")
|
||||
}
|
||||
```
|
||||
|
||||
### Steps 3-9: Update each tool constructor
|
||||
|
||||
For each tool (`read.go`, `write.go`, `edit.go`, `bash.go`, `grep.go`, `find.go`, `ls.go`):
|
||||
- Change `NewXxxTool()` to `NewXxxTool(opts ...ToolOption)`
|
||||
- Apply `cfg := applyOptions(opts)` in the constructor
|
||||
- Pass `cfg.workDir` to path resolution or `cmd.Dir`
|
||||
- For bash/grep/find (subprocess tools): set `cmd.Dir = cfg.workDir` on `exec.CommandContext`
|
||||
- Existing callers pass no args, so they get default behavior (backward compatible)
|
||||
|
||||
### Step 10: Create `pkg/kit/tools.go`
|
||||
|
||||
**File**: `pkg/kit/tools.go`
|
||||
|
||||
```go
|
||||
package kit
|
||||
|
||||
import (
|
||||
"charm.land/fantasy"
|
||||
"github.com/mark3labs/kit/internal/core"
|
||||
)
|
||||
|
||||
// Tool is the interface that all Kit tools implement.
|
||||
type Tool = fantasy.AgentTool
|
||||
|
||||
// ToolOption configures tool behavior.
|
||||
type ToolOption = core.ToolOption
|
||||
|
||||
// WithWorkDir sets the working directory for file-based tools.
|
||||
var WithWorkDir = core.WithWorkDir
|
||||
|
||||
// Individual tool constructors
|
||||
func NewReadTool(opts ...ToolOption) Tool { return core.NewReadTool(opts...) }
|
||||
func NewWriteTool(opts ...ToolOption) Tool { return core.NewWriteTool(opts...) }
|
||||
func NewEditTool(opts ...ToolOption) Tool { return core.NewEditTool(opts...) }
|
||||
func NewBashTool(opts ...ToolOption) Tool { return core.NewBashTool(opts...) }
|
||||
func NewGrepTool(opts ...ToolOption) Tool { return core.NewGrepTool(opts...) }
|
||||
func NewFindTool(opts ...ToolOption) Tool { return core.NewFindTool(opts...) }
|
||||
func NewLsTool(opts ...ToolOption) Tool { return core.NewLsTool(opts...) }
|
||||
|
||||
// Tool bundles
|
||||
func AllTools(opts ...ToolOption) []Tool { return core.AllTools(opts...) }
|
||||
func CodingTools(opts ...ToolOption) []Tool { return core.CodingTools(opts...) }
|
||||
func ReadOnlyTools(opts ...ToolOption) []Tool { return core.ReadOnlyTools(opts...) }
|
||||
```
|
||||
|
||||
### Step 11: Add GetTools() to Kit struct
|
||||
|
||||
**File**: `pkg/kit/kit.go`
|
||||
|
||||
```go
|
||||
// GetTools returns all tools available to the agent (core + MCP + extensions).
|
||||
func (m *Kit) GetTools() []Tool {
|
||||
return m.agent.GetTools()
|
||||
}
|
||||
```
|
||||
|
||||
### Step 12: App-as-Consumer — Agent uses SDK tool constructors
|
||||
|
||||
This is the key "dog-fooding" step. Currently `internal/agent/agent.go:97` calls `core.AllTools()` directly. After this change, the agent setup should get its tool list from the caller (via `AgentConfig.Tools`) rather than hardcoding `core.AllTools()`.
|
||||
|
||||
**File**: `internal/agent/agent.go`
|
||||
|
||||
Change the `AgentConfig` struct to accept tools explicitly:
|
||||
|
||||
```go
|
||||
type AgentConfig struct {
|
||||
// ... existing fields ...
|
||||
CoreTools []fantasy.AgentTool // NEW: if empty, defaults to core.AllTools()
|
||||
}
|
||||
```
|
||||
|
||||
In `NewAgent()` at line 96-97, change:
|
||||
```go
|
||||
// Before:
|
||||
coreTools := core.AllTools()
|
||||
|
||||
// After:
|
||||
coreTools := agentConfig.CoreTools
|
||||
if len(coreTools) == 0 {
|
||||
coreTools = core.AllTools() // Default fallback
|
||||
}
|
||||
```
|
||||
|
||||
Then in `pkg/kit/setup.go`, the `SetupAgent` function passes tools from the SDK:
|
||||
|
||||
```go
|
||||
a, err := agent.CreateAgent(ctx, &agent.AgentCreationOptions{
|
||||
// ... existing fields ...
|
||||
CoreTools: core.AllTools(), // Explicit — could be customized via Options
|
||||
})
|
||||
```
|
||||
|
||||
And in `pkg/kit/kit.go`, the `Options` struct gets a `Tools` field:
|
||||
|
||||
```go
|
||||
type Options struct {
|
||||
// ... existing fields ...
|
||||
Tools []Tool // Custom tool set. If empty, AllTools() is used.
|
||||
}
|
||||
```
|
||||
|
||||
This allows SDK users to pass custom tools:
|
||||
|
||||
```go
|
||||
k, _ := kit.New(ctx, &kit.Options{
|
||||
Tools: kit.CodingTools(kit.WithWorkDir("/my/project")),
|
||||
})
|
||||
```
|
||||
|
||||
### Step 13: Write tests and verify
|
||||
|
||||
```bash
|
||||
go build -o output/kit ./cmd/kit
|
||||
go test -race ./...
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
## Files Changed Summary
|
||||
|
||||
| Action | File | Change |
|
||||
|--------|------|--------|
|
||||
| EDIT | `internal/core/tools.go` | Add ToolOption, WithWorkDir, update collection funcs |
|
||||
| EDIT | `internal/core/read.go` | resolvePathWithWorkDir, accept opts |
|
||||
| EDIT | `internal/core/write.go` | Accept opts |
|
||||
| EDIT | `internal/core/edit.go` | Accept opts |
|
||||
| EDIT | `internal/core/bash.go` | Accept opts, set cmd.Dir |
|
||||
| EDIT | `internal/core/grep.go` | Accept opts, set cmd.Dir |
|
||||
| EDIT | `internal/core/find.go` | Accept opts, set cmd.Dir |
|
||||
| EDIT | `internal/core/ls.go` | Accept opts |
|
||||
| CREATE | `pkg/kit/tools.go` | Public tool exports and factories |
|
||||
| EDIT | `pkg/kit/kit.go` | Add GetTools(), Tools option |
|
||||
| EDIT | `internal/agent/agent.go` | Accept CoreTools in config instead of hardcoding |
|
||||
| EDIT | `pkg/kit/setup.go` | Pass tools through to agent creation |
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [ ] `go build -o output/kit ./cmd/kit` succeeds
|
||||
- [ ] `go test -race ./...` passes (agent still gets default tools)
|
||||
- [ ] Tools with `WithWorkDir("/tmp")` resolve paths relative to `/tmp`
|
||||
- [ ] Tools with no options use `os.Getwd()` (backward compatible)
|
||||
- [ ] SDK users can pass custom tool sets via `kit.Options{Tools: ...}`
|
||||
- [ ] Agent accepts injected tools instead of hardcoding `core.AllTools()`
|
||||
@@ -1,196 +0,0 @@
|
||||
# Plan 02: Richer Type Exports
|
||||
|
||||
**Priority**: P0
|
||||
**Effort**: Low
|
||||
**Goal**: Export 40+ internal types so SDK users and the CLI app share the same type surface
|
||||
|
||||
## Background
|
||||
|
||||
Currently only 3 type aliases are exported: `Message`, `ToolCall`, `ToolResult`. Pi exports 50+ types. SDK users and the CLI app both need access to messages, sessions, config, agents, models, and callback types. By exporting from `pkg/kit`, both external consumers and the CLI share the same types — no parallel definitions.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Plan 00 (Create `pkg/kit/`)
|
||||
|
||||
## Key Principle: Shared Types
|
||||
|
||||
After this plan, `cmd/` should progressively adopt types from `pkg/kit/` instead of importing from `internal/` directly. For example:
|
||||
- `cmd/setup.go` should reference `kit.ProviderConfig` rather than `models.ProviderConfig`
|
||||
- `cmd/root.go` session setup should use `kit.SessionInfo` rather than `session.SessionInfo`
|
||||
|
||||
This is a gradual migration — the type aliases make this zero-cost since `kit.ProviderConfig = models.ProviderConfig` (same underlying type).
|
||||
|
||||
## Step-by-Step
|
||||
|
||||
### Step 1: Expand `pkg/kit/types.go` with all type groups
|
||||
|
||||
**File**: `pkg/kit/types.go`
|
||||
|
||||
```go
|
||||
package kit
|
||||
|
||||
import (
|
||||
"charm.land/fantasy"
|
||||
"github.com/mark3labs/kit/internal/agent"
|
||||
"github.com/mark3labs/kit/internal/config"
|
||||
"github.com/mark3labs/kit/internal/message"
|
||||
"github.com/mark3labs/kit/internal/models"
|
||||
"github.com/mark3labs/kit/internal/session"
|
||||
)
|
||||
|
||||
// ==== Message Types (internal/message/content.go) ====
|
||||
|
||||
type Message = message.Message
|
||||
type MessageRole = message.MessageRole
|
||||
|
||||
const (
|
||||
RoleUser = message.RoleUser
|
||||
RoleAssistant = message.RoleAssistant
|
||||
RoleTool = message.RoleTool
|
||||
RoleSystem = message.RoleSystem
|
||||
)
|
||||
|
||||
type ContentPart = message.ContentPart
|
||||
type TextContent = message.TextContent
|
||||
type ReasoningContent = message.ReasoningContent
|
||||
type ToolCall = message.ToolCall
|
||||
type ToolResult = message.ToolResult
|
||||
type Finish = message.Finish
|
||||
|
||||
// ==== Session Types (internal/session/) ====
|
||||
|
||||
type Session = session.Session
|
||||
type SessionMetadata = session.Metadata
|
||||
type SessionManager = session.Manager
|
||||
type SessionInfo = session.SessionInfo
|
||||
type TreeManager = session.TreeManager
|
||||
type SessionHeader = session.SessionHeader
|
||||
type MessageEntry = session.MessageEntry
|
||||
|
||||
// ==== Config Types (internal/config/) ====
|
||||
|
||||
type Config = config.Config
|
||||
type MCPServerConfig = config.MCPServerConfig
|
||||
|
||||
// ==== Agent Types (internal/agent/) ====
|
||||
|
||||
type AgentConfig = agent.AgentConfig
|
||||
type GenerateResult = agent.GenerateWithLoopResult
|
||||
|
||||
type (
|
||||
ToolCallHandler = agent.ToolCallHandler
|
||||
ToolExecutionHandler = agent.ToolExecutionHandler
|
||||
ToolResultHandler = agent.ToolResultHandler
|
||||
ResponseHandler = agent.ResponseHandler
|
||||
StreamingResponseHandler = agent.StreamingResponseHandler
|
||||
ToolCallContentHandler = agent.ToolCallContentHandler
|
||||
)
|
||||
|
||||
// ==== Provider & Model Types (internal/models/) ====
|
||||
|
||||
type ProviderConfig = models.ProviderConfig
|
||||
type ProviderResult = models.ProviderResult
|
||||
type ModelInfo = models.ModelInfo
|
||||
type ModelCost = models.Cost
|
||||
type ModelLimit = models.Limit
|
||||
type ProviderInfo = models.ProviderInfo
|
||||
type ModelsRegistry = models.ModelsRegistry
|
||||
|
||||
// ==== Fantasy Types (re-exported) ====
|
||||
|
||||
type FantasyMessage = fantasy.Message
|
||||
type FantasyUsage = fantasy.Usage
|
||||
type FantasyResponse = fantasy.Response
|
||||
|
||||
// ==== Constructor & Helper Functions ====
|
||||
|
||||
var (
|
||||
NewSession = session.NewSession
|
||||
NewSessionManager = session.NewManager
|
||||
ListSessions = session.ListSessions
|
||||
ListAllSessions = session.ListAllSessions
|
||||
ParseModelString = models.ParseModelString
|
||||
CreateProvider = models.CreateProvider
|
||||
GetGlobalRegistry = models.GetGlobalRegistry
|
||||
LoadSystemPrompt = config.LoadSystemPrompt
|
||||
)
|
||||
|
||||
// ==== Conversion Helpers ====
|
||||
|
||||
func ConvertToFantasyMessages(msg *Message) []fantasy.Message {
|
||||
return msg.ToFantasyMessages()
|
||||
}
|
||||
|
||||
func ConvertFromFantasyMessage(msg fantasy.Message) Message {
|
||||
return message.FromFantasyMessage(msg)
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: App-as-Consumer — Migrate `cmd/` to use SDK types
|
||||
|
||||
After this plan, start migrating `cmd/` callers to use `kit.*` types. Since these are aliases, this is purely cosmetic and zero-cost, but it establishes the pattern:
|
||||
|
||||
**Example in `cmd/setup.go`**:
|
||||
```go
|
||||
// Before:
|
||||
import "github.com/mark3labs/kit/internal/models"
|
||||
cfg := &models.ProviderConfig{...}
|
||||
|
||||
// After (preferred, gradual migration):
|
||||
import kit "github.com/mark3labs/kit/pkg/kit"
|
||||
cfg := &kit.ProviderConfig{...}
|
||||
```
|
||||
|
||||
This is not blocking — both work simultaneously due to Go type aliases.
|
||||
|
||||
### Step 3: Write a compilation test
|
||||
|
||||
**File**: `pkg/kit/types_test.go`
|
||||
|
||||
```go
|
||||
package kit_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
)
|
||||
|
||||
func TestTypeExports(t *testing.T) {
|
||||
if kit.RoleUser != "user" { t.Error("RoleUser") }
|
||||
if kit.RoleAssistant != "assistant" { t.Error("RoleAssistant") }
|
||||
|
||||
msg := kit.Message{
|
||||
Role: kit.RoleUser,
|
||||
Parts: []kit.ContentPart{
|
||||
kit.TextContent{Text: "hello"},
|
||||
},
|
||||
}
|
||||
if msg.Content() != "hello" { t.Error("message content") }
|
||||
|
||||
s := kit.NewSession()
|
||||
if s == nil { t.Error("NewSession") }
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Verify
|
||||
|
||||
```bash
|
||||
go build -o output/kit ./cmd/kit
|
||||
go test -race ./...
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
## Files Changed Summary
|
||||
|
||||
| Action | File | Change |
|
||||
|--------|------|--------|
|
||||
| EDIT | `pkg/kit/types.go` | Add ~40 type aliases, constants, constructors |
|
||||
| CREATE | `pkg/kit/types_test.go` | Compilation test |
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [ ] `go build -o output/kit ./cmd/kit` succeeds
|
||||
- [ ] `go test -race ./...` passes
|
||||
- [ ] No circular import errors
|
||||
- [ ] Type aliases are interchangeable with internal types
|
||||
- [ ] `cmd/` can import and use `kit.*` types alongside internal types
|
||||
@@ -1,348 +0,0 @@
|
||||
# Plan 03: Event/Subscriber System
|
||||
|
||||
**Priority**: P1
|
||||
**Effort**: High
|
||||
**Goal**: Create a unified event system in the SDK that replaces the three parallel event systems currently in the codebase
|
||||
|
||||
## Background
|
||||
|
||||
Kit currently has **three separate event systems** that overlap:
|
||||
|
||||
1. **SDK callbacks** (`sdk/kit.go`) — 3 function pointers on `PromptWithCallbacks`
|
||||
2. **Extension events** (`internal/extensions/events.go`) — 13 typed events dispatched via `Runner.Emit()`
|
||||
3. **App/TUI events** (`internal/app/events.go`) — 13 `tea.Msg` structs for BubbleTea UI updates
|
||||
|
||||
Pi uses a single `session.subscribe(listener)` pattern. This plan creates a unified event system in `pkg/kit/` that:
|
||||
- Replaces SDK callbacks
|
||||
- Becomes the canonical event layer that extensions and the app emit through
|
||||
- The TUI adapts SDK events into `tea.Msg` for rendering (TUI-specific concern stays in `internal/ui/`)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Plan 00 (Create `pkg/kit/`)
|
||||
- Plan 02 (Richer type exports)
|
||||
|
||||
## Design Decisions
|
||||
|
||||
1. **Single source of truth** — events are defined in `pkg/kit/`, not scattered across packages
|
||||
2. **Multiple subscribers** supported with unsubscribe
|
||||
3. **Thread-safe** emission
|
||||
4. **App subscribes to SDK events** — the TUI layer adapts them to `tea.Msg`
|
||||
5. **Extensions emit through SDK** — the extension runner emits SDK events, not its own types
|
||||
|
||||
## Step-by-Step
|
||||
|
||||
### Step 1: Define public event types
|
||||
|
||||
**File**: `pkg/kit/events.go` (new)
|
||||
|
||||
```go
|
||||
package kit
|
||||
|
||||
import "sync"
|
||||
|
||||
// EventType identifies the kind of event.
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
EventTurnStart EventType = "turn_start"
|
||||
EventTurnEnd EventType = "turn_end"
|
||||
EventMessageStart EventType = "message_start"
|
||||
EventMessageUpdate EventType = "message_update"
|
||||
EventMessageEnd EventType = "message_end"
|
||||
EventToolCall EventType = "tool_call"
|
||||
EventToolExecutionStart EventType = "tool_execution_start"
|
||||
EventToolExecutionEnd EventType = "tool_execution_end"
|
||||
EventToolResult EventType = "tool_result"
|
||||
EventToolCallContent EventType = "tool_call_content"
|
||||
EventResponse EventType = "response"
|
||||
EventSessionStart EventType = "session_start"
|
||||
EventSessionShutdown EventType = "session_shutdown"
|
||||
)
|
||||
|
||||
// Event is the interface for all event types.
|
||||
type Event interface {
|
||||
EventType() EventType
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Define concrete event structs
|
||||
|
||||
These cover the union of all three current event systems:
|
||||
|
||||
```go
|
||||
type TurnStartEvent struct{ Prompt string }
|
||||
func (e TurnStartEvent) EventType() EventType { return EventTurnStart }
|
||||
|
||||
type TurnEndEvent struct{ Response string; Error error }
|
||||
func (e TurnEndEvent) EventType() EventType { return EventTurnEnd }
|
||||
|
||||
type MessageStartEvent struct{}
|
||||
func (e MessageStartEvent) EventType() EventType { return EventMessageStart }
|
||||
|
||||
type MessageUpdateEvent struct{ Chunk string }
|
||||
func (e MessageUpdateEvent) EventType() EventType { return EventMessageUpdate }
|
||||
|
||||
type MessageEndEvent struct{ Content string }
|
||||
func (e MessageEndEvent) EventType() EventType { return EventMessageEnd }
|
||||
|
||||
type ToolCallEvent struct{ ToolName string; ToolArgs string }
|
||||
func (e ToolCallEvent) EventType() EventType { return EventToolCall }
|
||||
|
||||
type ToolExecutionStartEvent struct{ ToolName string }
|
||||
func (e ToolExecutionStartEvent) EventType() EventType { return EventToolExecutionStart }
|
||||
|
||||
type ToolExecutionEndEvent struct{ ToolName string }
|
||||
func (e ToolExecutionEndEvent) EventType() EventType { return EventToolExecutionEnd }
|
||||
|
||||
type ToolResultEvent struct{ ToolName, ToolArgs, Result string; IsError bool }
|
||||
func (e ToolResultEvent) EventType() EventType { return EventToolResult }
|
||||
|
||||
type ToolCallContentEvent struct{ Content string }
|
||||
func (e ToolCallContentEvent) EventType() EventType { return EventToolCallContent }
|
||||
|
||||
type ResponseEvent struct{ Content string }
|
||||
func (e ResponseEvent) EventType() EventType { return EventResponse }
|
||||
```
|
||||
|
||||
### Step 3: Implement EventBus
|
||||
|
||||
```go
|
||||
type EventListener func(event Event)
|
||||
|
||||
type eventBus struct {
|
||||
mu sync.RWMutex
|
||||
listeners map[int]EventListener
|
||||
nextID int
|
||||
}
|
||||
|
||||
func newEventBus() *eventBus {
|
||||
return &eventBus{listeners: make(map[int]EventListener)}
|
||||
}
|
||||
|
||||
func (eb *eventBus) subscribe(listener EventListener) func() {
|
||||
eb.mu.Lock()
|
||||
id := eb.nextID
|
||||
eb.nextID++
|
||||
eb.listeners[id] = listener
|
||||
eb.mu.Unlock()
|
||||
return func() {
|
||||
eb.mu.Lock()
|
||||
delete(eb.listeners, id)
|
||||
eb.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (eb *eventBus) emit(event Event) {
|
||||
eb.mu.RLock()
|
||||
snapshot := make([]EventListener, 0, len(eb.listeners))
|
||||
for _, l := range eb.listeners {
|
||||
snapshot = append(snapshot, l)
|
||||
}
|
||||
eb.mu.RUnlock()
|
||||
for _, l := range snapshot {
|
||||
l(event)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Wire EventBus into Kit struct
|
||||
|
||||
**File**: `pkg/kit/kit.go`
|
||||
|
||||
```go
|
||||
type Kit struct {
|
||||
agent *agent.Agent
|
||||
sessionMgr *session.Manager
|
||||
modelString string
|
||||
events *eventBus
|
||||
}
|
||||
|
||||
func (m *Kit) Subscribe(listener EventListener) func() {
|
||||
return m.events.subscribe(listener)
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Wire all agent callbacks to emit events
|
||||
|
||||
Update `Prompt()` and `PromptWithCallbacks()` to emit events at every stage of the agent generation flow. Events fire at these points (matching the lifecycle in `internal/app/app.go:364-520`):
|
||||
|
||||
1. Before generation: `TurnStartEvent`, `MessageStartEvent`
|
||||
2. During streaming: `MessageUpdateEvent` per chunk
|
||||
3. On tool call: `ToolCallEvent`, `ToolExecutionStartEvent`
|
||||
4. On tool result: `ToolExecutionEndEvent`, `ToolResultEvent`
|
||||
5. On response: `ResponseEvent`
|
||||
6. After generation: `MessageEndEvent`, `TurnEndEvent`
|
||||
|
||||
Extract shared callback helpers to avoid duplication:
|
||||
|
||||
```go
|
||||
func (m *Kit) makeToolCallHandler() agent.ToolCallHandler {
|
||||
return func(name, args string) {
|
||||
m.events.emit(ToolCallEvent{ToolName: name, ToolArgs: args})
|
||||
}
|
||||
}
|
||||
// ... similar for all callback types
|
||||
```
|
||||
|
||||
### Step 6: App-as-Consumer — TUI subscribes to SDK events
|
||||
|
||||
This is the critical refactor. Currently `internal/app/app.go:executeStep()` emits TUI events directly via `sendFn(StreamChunkEvent{...})`. After this change:
|
||||
|
||||
1. The SDK's `Prompt()` emits SDK events
|
||||
2. The app subscribes to SDK events and converts them to `tea.Msg`
|
||||
|
||||
**File**: `internal/app/app.go` (migration pattern)
|
||||
|
||||
```go
|
||||
// In App initialization, subscribe to SDK events and bridge to TUI
|
||||
func (a *App) setupEventBridge() {
|
||||
a.kit.Subscribe(func(e kit.Event) {
|
||||
switch ev := e.(type) {
|
||||
case kit.MessageUpdateEvent:
|
||||
a.sendToTUI(StreamChunkEvent{Content: ev.Chunk})
|
||||
case kit.ToolCallEvent:
|
||||
a.sendToTUI(ToolCallStartedEvent{ToolName: ev.ToolName, ToolArgs: ev.ToolArgs})
|
||||
case kit.ToolResultEvent:
|
||||
a.sendToTUI(ToolResultEvent{
|
||||
ToolName: ev.ToolName, ToolArgs: ev.ToolArgs,
|
||||
Result: ev.Result, IsError: ev.IsError,
|
||||
})
|
||||
case kit.ResponseEvent:
|
||||
a.sendToTUI(ResponseCompleteEvent{Content: ev.Content})
|
||||
// ... etc
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Migration steps**:
|
||||
1. First: app subscribes to SDK events AND keeps its own emission (dual-emit phase)
|
||||
2. Then: remove direct emission from `executeStep()`, rely solely on SDK events
|
||||
3. Finally: remove `internal/app/events.go` types that are now redundant
|
||||
|
||||
### Step 7: Extension events bridge to SDK events
|
||||
|
||||
The extension `Runner` should emit through the SDK event bus rather than its own parallel system. This can be bridged:
|
||||
|
||||
```go
|
||||
// In Kit initialization, bridge extension events to SDK events
|
||||
func (m *Kit) bridgeExtensionEvents(runner *extensions.Runner) {
|
||||
// When extensions emit events, forward them as SDK events
|
||||
// This is done by having the Runner call back into the SDK
|
||||
runner.SetEventForwarder(func(event extensions.Event) {
|
||||
switch e := event.(type) {
|
||||
case extensions.ToolCallEvent:
|
||||
m.events.emit(ToolCallEvent{ToolName: e.ToolName, ToolArgs: e.Input})
|
||||
// ... etc
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: This is a gradual migration. The extension Runner keeps its typed events for Yaegi compatibility, but forwards them to the SDK bus. Eventually the extension system could be refactored to emit SDK events natively.
|
||||
|
||||
### Step 8: Typed convenience subscribers
|
||||
|
||||
```go
|
||||
func (m *Kit) OnToolCall(handler func(ToolCallEvent)) func() {
|
||||
return m.Subscribe(func(e Event) {
|
||||
if tc, ok := e.(ToolCallEvent); ok { handler(tc) }
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Kit) OnToolResult(handler func(ToolResultEvent)) func() {
|
||||
return m.Subscribe(func(e Event) {
|
||||
if tr, ok := e.(ToolResultEvent); ok { handler(tr) }
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Kit) OnStreaming(handler func(MessageUpdateEvent)) func() {
|
||||
return m.Subscribe(func(e Event) {
|
||||
if mu, ok := e.(MessageUpdateEvent); ok { handler(mu) }
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Step 9: Write tests and verify
|
||||
|
||||
```bash
|
||||
go build -o output/kit ./cmd/kit
|
||||
go test -race ./...
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
## Files Changed Summary
|
||||
|
||||
| Action | File | Change |
|
||||
|--------|------|--------|
|
||||
| CREATE | `pkg/kit/events.go` | Event types, EventBus, Subscribe() |
|
||||
| EDIT | `pkg/kit/kit.go` | Add eventBus field, Subscribe(), callback helpers |
|
||||
| EDIT | `internal/app/app.go` | Subscribe to SDK events (gradual migration) |
|
||||
| EDIT | `internal/extensions/runner.go` | Optional: event forwarding to SDK bus |
|
||||
|
||||
## Event Flow After This Plan
|
||||
|
||||
```
|
||||
Agent.GenerateWithLoopAndStreaming()
|
||||
↓ fantasy callbacks
|
||||
pkg/kit/kit.go (SDK Prompt method)
|
||||
↓ emits SDK events
|
||||
EventBus
|
||||
↓ dispatches to all subscribers
|
||||
├── External SDK user's listener
|
||||
├── App TUI bridge → tea.Msg → BubbleTea Update()
|
||||
└── Extension bridge → Runner.Emit() → Yaegi handlers
|
||||
```
|
||||
|
||||
**Single source of truth**: The SDK EventBus is the only event dispatcher.
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [x] `go build -o output/kit ./cmd/kit` succeeds
|
||||
- [x] `go test -race ./...` passes
|
||||
- [x] Events fire in correct order: TurnStart → MessageStart → updates → ToolCall → ToolResult → MessageEnd → TurnEnd
|
||||
- [x] Multiple subscribers receive all events
|
||||
- [x] Unsubscribe removes listener
|
||||
- [ ] App TUI still renders correctly via event bridge (deferred — see below)
|
||||
- [x] Thread-safe under concurrent calls
|
||||
|
||||
## Implemented (Steps 1-5, 8-9)
|
||||
|
||||
Core event system is complete:
|
||||
- Event types, concrete structs, EventBus in `pkg/kit/events.go`
|
||||
- `Kit.Subscribe()` + typed helpers (`OnToolCall`, `OnToolResult`, `OnStreaming`, `OnResponse`, `OnTurnStart`, `OnTurnEnd`)
|
||||
- `Prompt()` and `PromptWithCallbacks()` emit full lifecycle events
|
||||
- 10 tests covering subscribe/unsubscribe, ordering, concurrency, self-unsubscribe
|
||||
- Example updated to use `Subscribe` API; `PromptWithCallbacks` marked deprecated
|
||||
|
||||
## Deferred (Steps 6-7)
|
||||
|
||||
### Step 6: App TUI bridge — app subscribes to SDK events
|
||||
|
||||
The app (`internal/app/app.go`) currently owns an `AgentRunner` interface (not a `*Kit`),
|
||||
and emits `tea.Msg` events directly from `executeStep()` callbacks. To bridge through the
|
||||
SDK EventBus:
|
||||
|
||||
1. The app needs a `*Kit` reference (or at minimum an `*eventBus` / `Subscribe` func)
|
||||
2. `executeStep()` would stop emitting `tea.Msg` directly and instead let the SDK emit
|
||||
SDK events, with a subscriber in the app that converts them to `tea.Msg`
|
||||
3. Dual-emit phase first (both old and new), then remove direct emission
|
||||
|
||||
**Why deferred**: The app doesn't have a `Kit` reference — it receives an `AgentRunner`.
|
||||
Changing this requires restructuring `internal/app/options.go` and `cmd/root.go` where
|
||||
the app is created. This is better done as part of the gradual "app consumes SDK" migration
|
||||
(tracked in the README architecture diagram).
|
||||
|
||||
### Step 7: Extension events bridge — Runner emits through SDK EventBus
|
||||
|
||||
The extension `Runner` (`internal/extensions/runner.go`) has its own typed events. To
|
||||
forward them through the SDK bus:
|
||||
|
||||
1. Add `SetEventForwarder(func(extensions.Event))` to Runner
|
||||
2. In Kit initialization, bridge extension events to SDK events
|
||||
3. Extensions keep their typed events for Yaegi compatibility but forward to SDK bus
|
||||
|
||||
**Why deferred**: Same dependency as Step 6 — requires the Kit instance to be wired
|
||||
into the extension runner initialization path. Plan 09 (Extension hook system) is the
|
||||
natural place to complete this bridge.
|
||||
@@ -1,298 +0,0 @@
|
||||
# Plan 04: Enhanced Session Management
|
||||
|
||||
**Priority**: P1
|
||||
**Effort**: High
|
||||
**Goal**: Expose session management in the SDK; CLI session flags map to SDK options
|
||||
|
||||
## Background
|
||||
|
||||
Kit has rich session infrastructure internally (`store.go`, `tree_manager.go`) but none of it is in the SDK. The CLI handles sessions in `cmd/root.go:479-557` with flags like `--continue`, `--resume`, `--session`, `--no-session`. After this plan, both the CLI and external users configure sessions through `kit.Options`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Plan 00 (Create `pkg/kit/`)
|
||||
- Plan 02 (Richer type exports)
|
||||
|
||||
## Key Principle
|
||||
|
||||
The CLI should NOT have its own session setup logic. Instead:
|
||||
1. CLI parses `--continue`, `--session`, etc. into `kit.Options` fields
|
||||
2. `kit.New()` handles all session initialization
|
||||
3. The CLI gets back a `*Kit` with the session already configured
|
||||
|
||||
## Step-by-Step
|
||||
|
||||
### Step 1: Add session options to Kit Options
|
||||
|
||||
**File**: `pkg/kit/kit.go`
|
||||
|
||||
```go
|
||||
type Options struct {
|
||||
// ... existing fields (Model, SystemPrompt, ConfigFile, etc.) ...
|
||||
|
||||
// Session configuration
|
||||
SessionDir string // Base directory for session discovery (default: cwd)
|
||||
SessionPath string // Open a specific session file
|
||||
Continue bool // Continue most recent session for SessionDir
|
||||
NoSession bool // Ephemeral mode — no persistence
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Add tree session to Kit struct
|
||||
|
||||
```go
|
||||
type Kit struct {
|
||||
agent *agent.Agent
|
||||
sessionMgr *session.Manager
|
||||
treeSession *session.TreeManager
|
||||
modelString string
|
||||
events *eventBus
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Initialize tree session in New()
|
||||
|
||||
```go
|
||||
func New(ctx context.Context, opts *Options) (*Kit, error) {
|
||||
// ... existing config + agent setup ...
|
||||
|
||||
cwd, _ := os.Getwd()
|
||||
sessionDir := cwd
|
||||
if opts != nil && opts.SessionDir != "" {
|
||||
sessionDir = opts.SessionDir
|
||||
}
|
||||
|
||||
var treeSession *session.TreeManager
|
||||
if opts != nil && opts.NoSession {
|
||||
treeSession = session.InMemoryTreeSession(sessionDir)
|
||||
} else if opts != nil && opts.Continue {
|
||||
ts, err := session.ContinueRecent(sessionDir)
|
||||
if err != nil {
|
||||
ts, err = session.CreateTreeSession(sessionDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create session: %w", err)
|
||||
}
|
||||
}
|
||||
treeSession = ts
|
||||
} else if opts != nil && opts.SessionPath != "" {
|
||||
ts, err := session.OpenTreeSession(opts.SessionPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open session: %w", err)
|
||||
}
|
||||
treeSession = ts
|
||||
} else {
|
||||
ts, err := session.CreateTreeSession(sessionDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create session: %w", err)
|
||||
}
|
||||
treeSession = ts
|
||||
}
|
||||
|
||||
return &Kit{
|
||||
agent: setupResult.Agent,
|
||||
sessionMgr: sessionMgr,
|
||||
treeSession: treeSession,
|
||||
modelString: modelString,
|
||||
events: newEventBus(),
|
||||
}, nil
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Wire Prompt() to use tree session
|
||||
|
||||
```go
|
||||
func (m *Kit) Prompt(ctx context.Context, message string) (string, error) {
|
||||
var messages []fantasy.Message
|
||||
if m.treeSession != nil {
|
||||
msgs, _, _ := m.treeSession.BuildContext()
|
||||
messages = msgs
|
||||
} else {
|
||||
messages = m.sessionMgr.GetMessages()
|
||||
}
|
||||
|
||||
// ... generation ...
|
||||
|
||||
// Persist to tree session
|
||||
if m.treeSession != nil {
|
||||
m.treeSession.AppendFantasyMessage(userMsg)
|
||||
for _, msg := range result.Messages {
|
||||
m.treeSession.AppendMessage(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Keep legacy manager in sync
|
||||
_ = m.sessionMgr.ReplaceAllMessages(result.ConversationMessages)
|
||||
return response, nil
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Add session management methods
|
||||
|
||||
**File**: `pkg/kit/sessions.go` (new)
|
||||
|
||||
```go
|
||||
package kit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"github.com/mark3labs/kit/internal/session"
|
||||
)
|
||||
|
||||
// Package-level session operations (don't require a Kit instance)
|
||||
|
||||
func ListSessions(dir string) ([]SessionInfo, error) {
|
||||
if dir == "" {
|
||||
var err error
|
||||
dir, err = os.Getwd()
|
||||
if err != nil { return nil, err }
|
||||
}
|
||||
return session.ListSessions(dir)
|
||||
}
|
||||
|
||||
func ListAllSessions() ([]SessionInfo, error) {
|
||||
return session.ListAllSessions()
|
||||
}
|
||||
|
||||
func DeleteSession(path string) error {
|
||||
return session.DeleteSession(path)
|
||||
}
|
||||
|
||||
// Instance methods
|
||||
|
||||
func (m *Kit) GetTreeSession() *TreeManager { return m.treeSession }
|
||||
|
||||
func (m *Kit) GetSessionPath() string {
|
||||
if m.treeSession != nil { return m.treeSession.GetFilePath() }
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *Kit) GetSessionID() string {
|
||||
if m.treeSession != nil { return m.treeSession.GetSessionID() }
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *Kit) Branch(entryID string) error {
|
||||
if m.treeSession == nil {
|
||||
return fmt.Errorf("branching requires tree session")
|
||||
}
|
||||
m.treeSession.Branch(entryID)
|
||||
msgs, _, _ := m.treeSession.BuildContext()
|
||||
return m.sessionMgr.ReplaceAllMessages(msgs)
|
||||
}
|
||||
|
||||
func (m *Kit) SetSessionName(name string) error {
|
||||
if m.treeSession == nil {
|
||||
return fmt.Errorf("session naming requires tree session")
|
||||
}
|
||||
m.treeSession.AppendSessionInfo(name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Kit) ClearSession() {
|
||||
m.sessionMgr = session.NewManager("")
|
||||
if m.treeSession != nil {
|
||||
m.treeSession.ResetLeaf()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: App-as-Consumer — CLI delegates session setup to SDK
|
||||
|
||||
This is the critical step. Currently `cmd/root.go:479-557` has its own session setup logic with if/else chains for each flag. Replace it with `kit.Options`:
|
||||
|
||||
**File**: `cmd/root.go` (migration)
|
||||
|
||||
```go
|
||||
// Before (cmd/root.go:479-557):
|
||||
// Complex if/else chain checking noSessionFlag, continueFlag, resumeFlag, sessionPath
|
||||
|
||||
// After:
|
||||
import kit "github.com/mark3labs/kit/pkg/kit"
|
||||
|
||||
func buildKitOptions() *kit.Options {
|
||||
opts := &kit.Options{
|
||||
Model: modelFlag,
|
||||
ConfigFile: configFile,
|
||||
Quiet: quietFlag,
|
||||
}
|
||||
|
||||
// Map CLI flags to SDK options
|
||||
if noSessionFlag {
|
||||
opts.NoSession = true
|
||||
} else if continueFlag {
|
||||
opts.Continue = true
|
||||
} else if sessionPath != "" {
|
||||
opts.SessionPath = sessionPath
|
||||
}
|
||||
// resumeFlag: handled by listing sessions then picking one
|
||||
// (call kit.ListSessions first, then set opts.SessionPath)
|
||||
|
||||
return opts
|
||||
}
|
||||
|
||||
// The Kit instance handles all session init internally:
|
||||
k, err := kit.New(ctx, buildKitOptions())
|
||||
```
|
||||
|
||||
**For --resume** (currently half-implemented with a TODO for TUI picker):
|
||||
```go
|
||||
if resumeFlag {
|
||||
sessions, err := kit.ListSessions("")
|
||||
if err != nil || len(sessions) == 0 {
|
||||
// Fall back to new session
|
||||
} else {
|
||||
// TODO: Show TUI picker. For now, pick most recent.
|
||||
opts.SessionPath = sessions[0].Path
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 7: App uses Kit's session instead of creating its own TreeManager
|
||||
|
||||
Currently `internal/app/app.go` receives a `TreeSession` via its `Options`. After migration, the app receives a `*Kit` instance and uses its tree session:
|
||||
|
||||
```go
|
||||
// Before:
|
||||
type Options struct {
|
||||
TreeSession *session.TreeManager
|
||||
// ...
|
||||
}
|
||||
|
||||
// After (gradual):
|
||||
type Options struct {
|
||||
Kit *kit.Kit // The SDK instance
|
||||
// ...
|
||||
}
|
||||
|
||||
// App gets messages:
|
||||
msgs := a.opts.Kit.GetTreeSession().GetFantasyMessages()
|
||||
```
|
||||
|
||||
### Step 8: Verify
|
||||
|
||||
```bash
|
||||
go build -o output/kit ./cmd/kit
|
||||
go test -race ./...
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
## Files Changed Summary
|
||||
|
||||
| Action | File | Change |
|
||||
|--------|------|--------|
|
||||
| EDIT | `pkg/kit/kit.go` | Add treeSession, session Options fields, wire Prompt |
|
||||
| CREATE | `pkg/kit/sessions.go` | ListSessions, Branch, SetSessionName, etc. |
|
||||
| EDIT | `cmd/root.go` | Replace session setup logic with kit.Options mapping |
|
||||
| EDIT | `internal/app/app.go` | Accept Kit instance for session access (gradual) |
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [ ] `go build -o output/kit ./cmd/kit` succeeds
|
||||
- [ ] `go test -race ./...` passes
|
||||
- [ ] `kit.New(ctx, &kit.Options{Continue: true})` resumes recent session
|
||||
- [ ] `kit.New(ctx, &kit.Options{NoSession: true})` creates ephemeral session
|
||||
- [ ] `kit.ListSessions("")` returns sessions
|
||||
- [ ] CLI `--continue` flag maps to `kit.Options{Continue: true}`
|
||||
- [ ] CLI `--no-session` flag maps to `kit.Options{NoSession: true}`
|
||||
- [ ] CLI no longer has its own session initialization logic
|
||||
@@ -1,276 +0,0 @@
|
||||
# Plan 05: Additional Prompt Modes
|
||||
|
||||
**Priority**: P1
|
||||
**Effort**: Medium
|
||||
**Goal**: Add `Steer()`, `FollowUp()`, `PromptWithOptions()` methods; app's `executeStep()` should call SDK methods
|
||||
|
||||
## Background
|
||||
|
||||
Pi has `session.prompt()`, `session.steer()`, `session.followUp()`, `session.compact()`. Kit only has `Prompt()` and `PromptWithCallbacks()`. The Kit CLI app implements its own agent loop in `internal/app/app.go:executeStep()` which duplicates SDK logic. After this plan, both the app and SDK users call the same methods.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Plan 00 (Create `pkg/kit/`)
|
||||
- Plan 03 (Event subscriber system)
|
||||
|
||||
## Step-by-Step
|
||||
|
||||
### Step 1: Extract shared callback helpers
|
||||
|
||||
To avoid duplicating callback wiring across `Prompt`, `Steer`, `FollowUp`, etc., extract internal helpers:
|
||||
|
||||
**File**: `pkg/kit/kit.go`
|
||||
|
||||
```go
|
||||
func (m *Kit) makeToolCallHandler() agent.ToolCallHandler {
|
||||
return func(name, args string) {
|
||||
m.events.emit(ToolCallEvent{ToolName: name, ToolArgs: args})
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Kit) makeToolExecutionHandler() agent.ToolExecutionHandler {
|
||||
return func(name string, isStarting bool) {
|
||||
if isStarting {
|
||||
m.events.emit(ToolExecutionStartEvent{ToolName: name})
|
||||
} else {
|
||||
m.events.emit(ToolExecutionEndEvent{ToolName: name})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Kit) makeToolResultHandler() agent.ToolResultHandler {
|
||||
return func(name, args, result string, isError bool) {
|
||||
m.events.emit(ToolResultEvent{ToolName: name, ToolArgs: args, Result: result, IsError: isError})
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Kit) makeResponseHandler() agent.ResponseHandler {
|
||||
return func(content string) { m.events.emit(ResponseEvent{Content: content}) }
|
||||
}
|
||||
|
||||
func (m *Kit) makeStreamingHandler() agent.StreamingResponseHandler {
|
||||
return func(chunk string) { m.events.emit(MessageUpdateEvent{Chunk: chunk}) }
|
||||
}
|
||||
|
||||
// getMessages retrieves conversation history from the best available source.
|
||||
func (m *Kit) getMessages() []fantasy.Message {
|
||||
if m.treeSession != nil {
|
||||
msgs, _, _ := m.treeSession.BuildContext()
|
||||
return msgs
|
||||
}
|
||||
return m.sessionMgr.GetMessages()
|
||||
}
|
||||
|
||||
// updateSession persists generation results.
|
||||
func (m *Kit) updateSession(userMsg fantasy.Message, result *agent.GenerateWithLoopResult) {
|
||||
if m.treeSession != nil {
|
||||
m.treeSession.AppendFantasyMessage(userMsg)
|
||||
for _, msg := range result.Messages {
|
||||
m.treeSession.AppendMessage(msg)
|
||||
}
|
||||
}
|
||||
_ = m.sessionMgr.ReplaceAllMessages(result.ConversationMessages)
|
||||
}
|
||||
|
||||
// generate is the shared generation path for all prompt modes.
|
||||
func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent.GenerateWithLoopResult, error) {
|
||||
return m.agent.GenerateWithLoopAndStreaming(
|
||||
ctx, messages,
|
||||
m.makeToolCallHandler(),
|
||||
m.makeToolExecutionHandler(),
|
||||
m.makeToolResultHandler(),
|
||||
m.makeResponseHandler(),
|
||||
nil, // onToolCallContent
|
||||
m.makeStreamingHandler(),
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Refactor Prompt() to use shared helpers
|
||||
|
||||
```go
|
||||
func (m *Kit) Prompt(ctx context.Context, msg string) (string, error) {
|
||||
messages := m.getMessages()
|
||||
userMsg := fantasy.NewUserMessage(msg)
|
||||
messages = append(messages, userMsg)
|
||||
|
||||
m.events.emit(TurnStartEvent{Prompt: msg})
|
||||
m.events.emit(MessageStartEvent{})
|
||||
|
||||
result, err := m.generate(ctx, messages)
|
||||
if err != nil {
|
||||
m.events.emit(TurnEndEvent{Error: err})
|
||||
return "", fmt.Errorf("generation failed: %w", err)
|
||||
}
|
||||
|
||||
m.updateSession(userMsg, result)
|
||||
response := result.FinalResponse.Content.Text()
|
||||
m.events.emit(MessageEndEvent{Content: response})
|
||||
m.events.emit(TurnEndEvent{Response: response})
|
||||
return response, nil
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Add Steer()
|
||||
|
||||
```go
|
||||
// Steer injects a system message and triggers a new agent turn.
|
||||
// Use for dynamically adjusting behavior without a visible user message.
|
||||
func (m *Kit) Steer(ctx context.Context, instruction string) (string, error) {
|
||||
messages := m.getMessages()
|
||||
sysMsg := fantasy.NewSystemMessage(instruction)
|
||||
messages = append(messages, sysMsg)
|
||||
userMsg := fantasy.NewUserMessage("Please acknowledge and follow the above instruction.")
|
||||
messages = append(messages, userMsg)
|
||||
|
||||
m.events.emit(TurnStartEvent{Prompt: "[steer] " + instruction})
|
||||
m.events.emit(MessageStartEvent{})
|
||||
|
||||
result, err := m.generate(ctx, messages)
|
||||
if err != nil {
|
||||
m.events.emit(TurnEndEvent{Error: err})
|
||||
return "", fmt.Errorf("steer failed: %w", err)
|
||||
}
|
||||
|
||||
m.updateSession(userMsg, result)
|
||||
response := result.FinalResponse.Content.Text()
|
||||
m.events.emit(MessageEndEvent{Content: response})
|
||||
m.events.emit(TurnEndEvent{Response: response})
|
||||
return response, nil
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Add FollowUp()
|
||||
|
||||
```go
|
||||
// FollowUp continues the conversation without new user input.
|
||||
func (m *Kit) FollowUp(ctx context.Context) (string, error) {
|
||||
messages := m.getMessages()
|
||||
if len(messages) == 0 {
|
||||
return "", fmt.Errorf("cannot follow up: no previous messages")
|
||||
}
|
||||
userMsg := fantasy.NewUserMessage("Continue.")
|
||||
messages = append(messages, userMsg)
|
||||
|
||||
m.events.emit(TurnStartEvent{Prompt: "[follow-up]"})
|
||||
m.events.emit(MessageStartEvent{})
|
||||
|
||||
result, err := m.generate(ctx, messages)
|
||||
if err != nil {
|
||||
m.events.emit(TurnEndEvent{Error: err})
|
||||
return "", fmt.Errorf("follow-up failed: %w", err)
|
||||
}
|
||||
|
||||
m.updateSession(userMsg, result)
|
||||
response := result.FinalResponse.Content.Text()
|
||||
m.events.emit(MessageEndEvent{Content: response})
|
||||
m.events.emit(TurnEndEvent{Response: response})
|
||||
return response, nil
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Add PromptWithOptions()
|
||||
|
||||
```go
|
||||
type PromptOptions struct {
|
||||
SystemMessage string // Injected before the prompt
|
||||
MaxSteps int // Override max steps for this call (0 = default)
|
||||
}
|
||||
|
||||
func (m *Kit) PromptWithOptions(ctx context.Context, msg string, opts PromptOptions) (string, error) {
|
||||
messages := m.getMessages()
|
||||
if opts.SystemMessage != "" {
|
||||
messages = append(messages, fantasy.NewSystemMessage(opts.SystemMessage))
|
||||
}
|
||||
userMsg := fantasy.NewUserMessage(msg)
|
||||
messages = append(messages, userMsg)
|
||||
|
||||
m.events.emit(TurnStartEvent{Prompt: msg})
|
||||
m.events.emit(MessageStartEvent{})
|
||||
|
||||
result, err := m.generate(ctx, messages)
|
||||
if err != nil {
|
||||
m.events.emit(TurnEndEvent{Error: err})
|
||||
return "", fmt.Errorf("generation failed: %w", err)
|
||||
}
|
||||
|
||||
m.updateSession(userMsg, result)
|
||||
response := result.FinalResponse.Content.Text()
|
||||
m.events.emit(MessageEndEvent{Content: response})
|
||||
m.events.emit(TurnEndEvent{Response: response})
|
||||
return response, nil
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: App-as-Consumer — Refactor `executeStep()` to use SDK
|
||||
|
||||
Currently `internal/app/app.go:executeStep()` (lines 364-520) contains a full agent loop with extension events, message building, and session persistence. It should be replaced by SDK method calls.
|
||||
|
||||
**File**: `internal/app/app.go` (migration)
|
||||
|
||||
```go
|
||||
// Before: 150+ lines of agent loop logic in executeStep()
|
||||
|
||||
// After: executeStep delegates to the Kit SDK
|
||||
func (a *App) executeStep(ctx context.Context, prompt string, sendFn func(tea.Msg)) (*agent.GenerateWithLoopResult, error) {
|
||||
// Extension Input hook (stays in app — it's a pre-SDK concern)
|
||||
if a.opts.Extensions != nil && a.opts.Extensions.HasHandlers(extensions.Input) {
|
||||
result, _ := a.opts.Extensions.Emit(extensions.InputEvent{Text: prompt})
|
||||
if r, ok := result.(extensions.InputResult); ok && r.Action == "handled" {
|
||||
return nil, nil
|
||||
}
|
||||
if r, ok := result.(extensions.InputResult); ok && r.Text != "" {
|
||||
prompt = r.Text
|
||||
}
|
||||
}
|
||||
|
||||
sendFn(SpinnerEvent{Show: true})
|
||||
|
||||
// Use SDK prompt — events handled by subscriber bridge (Plan 03)
|
||||
response, err := a.kit.Prompt(ctx, prompt)
|
||||
if err != nil {
|
||||
sendFn(StepErrorEvent{Err: err})
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sendFn(SpinnerEvent{Show: false})
|
||||
sendFn(StepCompleteEvent{})
|
||||
_ = response
|
||||
|
||||
return nil, nil // Result comes through events
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: This is a simplification. The real migration needs to handle:
|
||||
- Extension `BeforeAgentStart` events (map to Plan 09 hooks)
|
||||
- Spinner show/hide
|
||||
- The fact that `executeStep` returns `*GenerateWithLoopResult` for further processing
|
||||
|
||||
The migration is gradual:
|
||||
1. **Phase 1**: App calls `kit.Prompt()` for simple cases
|
||||
2. **Phase 2**: Extension events bridge through SDK hooks (Plan 09)
|
||||
3. **Phase 3**: `executeStep()` becomes a thin adapter
|
||||
|
||||
### Step 7: Verify
|
||||
|
||||
```bash
|
||||
go build -o output/kit ./cmd/kit
|
||||
go test -race ./...
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
## Files Changed Summary
|
||||
|
||||
| Action | File | Change |
|
||||
|--------|------|--------|
|
||||
| EDIT | `pkg/kit/kit.go` | Steer(), FollowUp(), PromptWithOptions(), shared helpers |
|
||||
| EDIT | `internal/app/app.go` | Gradual migration of executeStep to use SDK |
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [ ] `Steer()` injects system message and triggers response
|
||||
- [ ] `FollowUp()` continues without user message
|
||||
- [ ] `PromptWithOptions()` accepts per-call system message
|
||||
- [ ] All methods emit events via EventBus
|
||||
- [ ] Shared helpers eliminate callback duplication
|
||||
- [ ] App's `executeStep()` uses SDK (at least for simple paths)
|
||||
@@ -1,192 +0,0 @@
|
||||
# Plan 06: Auth & Model Management APIs
|
||||
|
||||
**Priority**: P2
|
||||
**Effort**: Medium
|
||||
**Goal**: Expose provider management, model validation, and API key handling in the SDK; CLI auth commands consume SDK APIs
|
||||
|
||||
## Background
|
||||
|
||||
Pi exports `AuthStorage`, `ModelRegistry`, `SettingsManager` for programmatic auth/model management. Kit has this internally (`internal/models/registry.go`, `internal/auth/credentials.go`, `internal/models/providers.go`) but none is exposed through the SDK.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Plan 00 (Create `pkg/kit/`)
|
||||
- Plan 02 (Richer type exports)
|
||||
|
||||
## Step-by-Step
|
||||
|
||||
### Step 1: Export model registry functions
|
||||
|
||||
**File**: `pkg/kit/models.go` (new)
|
||||
|
||||
```go
|
||||
package kit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/mark3labs/kit/internal/models"
|
||||
)
|
||||
|
||||
// LookupModel returns information about a model, or nil if unknown.
|
||||
func LookupModel(provider, modelID string) *ModelInfo {
|
||||
return models.GetGlobalRegistry().LookupModel(provider, modelID)
|
||||
}
|
||||
|
||||
// GetSupportedProviders returns all known provider names.
|
||||
func GetSupportedProviders() []string {
|
||||
return models.GetGlobalRegistry().GetSupportedProviders()
|
||||
}
|
||||
|
||||
// GetModelsForProvider returns all known models for a provider.
|
||||
func GetModelsForProvider(provider string) (map[string]ModelInfo, error) {
|
||||
return models.GetGlobalRegistry().GetModelsForProvider(provider)
|
||||
}
|
||||
|
||||
// GetProviderInfo returns information about a provider (env vars, API URL, etc.).
|
||||
func GetProviderInfo(provider string) *ProviderInfo {
|
||||
return models.GetGlobalRegistry().GetProviderInfo(provider)
|
||||
}
|
||||
|
||||
// ValidateEnvironment checks if required API keys are set for a provider.
|
||||
func ValidateEnvironment(provider string, apiKey string) error {
|
||||
return models.GetGlobalRegistry().ValidateEnvironment(provider, apiKey)
|
||||
}
|
||||
|
||||
// SuggestModels returns model names similar to an invalid model string.
|
||||
func SuggestModels(provider, invalidModel string) []string {
|
||||
return models.GetGlobalRegistry().SuggestModels(provider, invalidModel)
|
||||
}
|
||||
|
||||
// RefreshModelRegistry reloads the model database from models.dev.
|
||||
func RefreshModelRegistry() {
|
||||
models.ReloadGlobalRegistry()
|
||||
}
|
||||
|
||||
// ParseModelString splits a "provider/model" string into components.
|
||||
func ParseModelString(modelString string) (provider, model string, err error) {
|
||||
return models.ParseModelString(modelString)
|
||||
}
|
||||
|
||||
// CheckProviderReady validates that a provider is properly configured.
|
||||
func CheckProviderReady(provider string) error {
|
||||
info := models.GetGlobalRegistry().GetProviderInfo(provider)
|
||||
if info == nil {
|
||||
return fmt.Errorf("unknown provider: %s", provider)
|
||||
}
|
||||
return models.GetGlobalRegistry().ValidateEnvironment(provider, "")
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Add model info to Kit instance
|
||||
|
||||
**File**: `pkg/kit/kit.go`
|
||||
|
||||
```go
|
||||
// GetModel returns the current model string (e.g., "anthropic/claude-sonnet-4-5-20250929").
|
||||
func (m *Kit) GetModel() string {
|
||||
return m.modelString
|
||||
}
|
||||
|
||||
// GetModelInfo returns detailed information about the current model.
|
||||
// Returns nil if the model is not in the registry.
|
||||
func (m *Kit) GetModelInfo() *ModelInfo {
|
||||
provider, modelID, err := models.ParseModelString(m.modelString)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return models.GetGlobalRegistry().LookupModel(provider, modelID)
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Export auth credential management
|
||||
|
||||
**File**: `pkg/kit/auth.go` (new)
|
||||
|
||||
```go
|
||||
package kit
|
||||
|
||||
import "github.com/mark3labs/kit/internal/auth"
|
||||
|
||||
// CredentialManager manages API keys and OAuth credentials.
|
||||
type CredentialManager = auth.CredentialManager
|
||||
|
||||
// NewCredentialManager creates a credential manager.
|
||||
func NewCredentialManager() (*CredentialManager, error) {
|
||||
return auth.NewCredentialManager()
|
||||
}
|
||||
|
||||
// HasAnthropicCredentials checks if Anthropic credentials are stored.
|
||||
func HasAnthropicCredentials() bool {
|
||||
cm, err := auth.NewCredentialManager()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return cm.GetAnthropicCredentials() != nil
|
||||
}
|
||||
|
||||
// GetAnthropicAPIKey resolves the Anthropic API key using the standard
|
||||
// resolution order: stored credentials -> ANTHROPIC_API_KEY env var.
|
||||
func GetAnthropicAPIKey() string {
|
||||
key, err := auth.GetAnthropicAPIKey("")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return key
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: App-as-Consumer — CLI commands use SDK APIs
|
||||
|
||||
Currently CLI commands like `kit models`, `kit update-models`, and provider validation logic directly import `internal/models` and `internal/auth`. They should use `pkg/kit` functions instead.
|
||||
|
||||
**File**: `cmd/root.go` or wherever model validation happens
|
||||
|
||||
```go
|
||||
// Before:
|
||||
import "github.com/mark3labs/kit/internal/models"
|
||||
registry := models.GetGlobalRegistry()
|
||||
info := registry.LookupModel(provider, model)
|
||||
|
||||
// After:
|
||||
import kit "github.com/mark3labs/kit/pkg/kit"
|
||||
info := kit.LookupModel(provider, model)
|
||||
```
|
||||
|
||||
**File**: `cmd/` auth-related commands
|
||||
|
||||
```go
|
||||
// Before:
|
||||
import "github.com/mark3labs/kit/internal/auth"
|
||||
cm, _ := auth.NewCredentialManager()
|
||||
|
||||
// After:
|
||||
import kit "github.com/mark3labs/kit/pkg/kit"
|
||||
cm, _ := kit.NewCredentialManager()
|
||||
```
|
||||
|
||||
Since these are type aliases, existing code continues to work during gradual migration.
|
||||
|
||||
### Step 5: Write tests and verify
|
||||
|
||||
```bash
|
||||
go build -o output/kit ./cmd/kit
|
||||
go test -race ./...
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
## Files Changed Summary
|
||||
|
||||
| Action | File | Change |
|
||||
|--------|------|--------|
|
||||
| CREATE | `pkg/kit/models.go` | Model registry, parsing, validation, suggestions |
|
||||
| CREATE | `pkg/kit/auth.go` | Credential management exports |
|
||||
| EDIT | `pkg/kit/kit.go` | Add GetModel(), GetModelInfo() |
|
||||
| EDIT | `cmd/` | Migrate to use pkg/kit functions (gradual) |
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [ ] `ParseModelString` handles "provider/model" format
|
||||
- [ ] `GetSupportedProviders` returns provider list
|
||||
- [ ] `LookupModel` returns info for known models
|
||||
- [ ] `CheckProviderReady` gives clear error messages
|
||||
- [ ] CLI commands use SDK functions instead of internal imports
|
||||
@@ -1,166 +0,0 @@
|
||||
# Plan 07: Compaction APIs
|
||||
|
||||
**Priority**: P2
|
||||
**Effort**: Medium
|
||||
**Goal**: Add context window management with token estimation, compaction triggers, and summarization. CLI `--compact` flag should use the SDK.
|
||||
|
||||
## Background
|
||||
|
||||
Pi exports `compact()`, `generateBranchSummary()`, `shouldCompact()`, `calculateContextTokens()`. Kit has no compaction — only `len(text)/4` estimation in `ui/usage_tracker.go:69` for display. This plan adds compaction from scratch, designed SDK-first so the CLI consumes it.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Plan 00 (Create `pkg/kit/`)
|
||||
- Plan 03 (Event subscriber system)
|
||||
- Plan 04 (Enhanced session management — tree sessions for branch summaries)
|
||||
|
||||
## Step-by-Step
|
||||
|
||||
### Step 1: Create `internal/compaction/` package
|
||||
|
||||
**File**: `internal/compaction/compaction.go` (new)
|
||||
|
||||
```go
|
||||
package compaction
|
||||
|
||||
// EstimateTokens provides a rough token count (~4 chars per token).
|
||||
func EstimateTokens(text string) int {
|
||||
return len(text) / 4
|
||||
}
|
||||
|
||||
// EstimateMessageTokens estimates tokens for a slice of fantasy messages.
|
||||
func EstimateMessageTokens(messages []fantasy.Message) int { ... }
|
||||
|
||||
// ShouldCompact checks if conversation exceeds threshold percentage of limit.
|
||||
func ShouldCompact(messages []fantasy.Message, contextLimit int, thresholdPct float64) bool { ... }
|
||||
|
||||
// CompactionResult contains statistics from a compaction.
|
||||
type CompactionResult struct {
|
||||
Summary string
|
||||
OriginalTokens int
|
||||
CompactedTokens int
|
||||
MessagesRemoved int
|
||||
}
|
||||
|
||||
// CompactionOptions configures compaction behavior.
|
||||
type CompactionOptions struct {
|
||||
ContextLimit int // Model's context window (tokens)
|
||||
ThresholdPct float64 // Trigger threshold (0.0-1.0), default 0.8
|
||||
PreserveRecent int // Recent messages to keep, default 10
|
||||
SummaryPrompt string // Custom summary prompt (empty = default)
|
||||
}
|
||||
|
||||
// FindCutPoint determines where to cut for compaction.
|
||||
func FindCutPoint(messages []fantasy.Message, preserveRecent int) int { ... }
|
||||
|
||||
// Compact summarizes older messages using the LLM.
|
||||
func Compact(ctx context.Context, model fantasy.LanguageModel, messages []fantasy.Message, opts CompactionOptions) (*CompactionResult, []fantasy.Message, error) { ... }
|
||||
```
|
||||
|
||||
Full implementations as described in the original plan (summarize messages before cut point using LLM, return summary + preserved recent messages).
|
||||
|
||||
### Step 2: Export compaction in SDK
|
||||
|
||||
**File**: `pkg/kit/types.go` — add type aliases:
|
||||
|
||||
```go
|
||||
type CompactionResult = compaction.CompactionResult
|
||||
type CompactionOptions = compaction.CompactionOptions
|
||||
```
|
||||
|
||||
### Step 3: Add Compact() and context methods to Kit
|
||||
|
||||
**File**: `pkg/kit/kit.go`
|
||||
|
||||
```go
|
||||
// Compact summarizes older messages to reduce context usage.
|
||||
func (m *Kit) Compact(ctx context.Context, opts *CompactionOptions) (*CompactionResult, error) { ... }
|
||||
|
||||
// EstimateContextTokens returns estimated token count of current conversation.
|
||||
func (m *Kit) EstimateContextTokens() int { ... }
|
||||
|
||||
// ShouldCompact checks if conversation is near the context limit.
|
||||
func (m *Kit) ShouldCompact() bool { ... }
|
||||
|
||||
// ContextStats returns current context usage statistics.
|
||||
type ContextStats struct {
|
||||
EstimatedTokens int
|
||||
ContextLimit int
|
||||
UsagePercent float64
|
||||
MessageCount int
|
||||
}
|
||||
|
||||
func (m *Kit) GetContextStats() ContextStats { ... }
|
||||
```
|
||||
|
||||
### Step 4: Add auto-compaction option
|
||||
|
||||
```go
|
||||
type Options struct {
|
||||
// ... existing fields ...
|
||||
AutoCompact bool // Auto-compact when near limit
|
||||
CompactionOptions *CompactionOptions // Config for auto-compact
|
||||
}
|
||||
```
|
||||
|
||||
In `Prompt()`, check before generation:
|
||||
```go
|
||||
if m.autoCompact && m.ShouldCompact() {
|
||||
m.Compact(ctx, m.compactionOpts) // best-effort
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: App-as-Consumer — CLI `--compact` flag uses SDK
|
||||
|
||||
Currently `cmd/root.go` has a `compactMode` flag (line 37) but compaction is not implemented. After this plan:
|
||||
|
||||
**File**: `cmd/root.go`
|
||||
|
||||
```go
|
||||
// Map --compact flag to SDK option
|
||||
if compactMode {
|
||||
kitOpts.AutoCompact = true
|
||||
}
|
||||
```
|
||||
|
||||
The CLI could also expose a `/compact` slash command in interactive mode that calls `kit.Compact()`:
|
||||
|
||||
```go
|
||||
// In interactive command handler:
|
||||
case "/compact":
|
||||
result, err := k.Compact(ctx, nil)
|
||||
if err != nil {
|
||||
fmt.Printf("Compaction failed: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("Compacted: %d messages removed, %d -> %d tokens\n",
|
||||
result.MessagesRemoved, result.OriginalTokens, result.CompactedTokens)
|
||||
}
|
||||
```
|
||||
|
||||
The usage tracker in `internal/ui/usage_tracker.go` should also use `kit.EstimateContextTokens()` instead of its own `len(text)/4` heuristic — single source of truth.
|
||||
|
||||
### Step 6: Write tests and verify
|
||||
|
||||
```bash
|
||||
go build -o output/kit ./cmd/kit
|
||||
go test -race ./...
|
||||
```
|
||||
|
||||
## Files Changed Summary
|
||||
|
||||
| Action | File | Change |
|
||||
|--------|------|--------|
|
||||
| CREATE | `internal/compaction/compaction.go` | Core compaction logic |
|
||||
| EDIT | `pkg/kit/types.go` | Export CompactionResult, CompactionOptions |
|
||||
| EDIT | `pkg/kit/kit.go` | Compact(), ShouldCompact(), GetContextStats(), auto-compact |
|
||||
| EDIT | `cmd/root.go` | Map --compact to SDK option |
|
||||
| EDIT | `internal/ui/usage_tracker.go` | Use SDK token estimation |
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [ ] Token estimation is reasonable
|
||||
- [ ] `ShouldCompact()` triggers near context limit
|
||||
- [ ] `Compact()` reduces message count and tokens
|
||||
- [ ] Auto-compaction triggers before prompts
|
||||
- [ ] CLI `--compact` flag maps to `kit.Options{AutoCompact: true}`
|
||||
- [ ] Usage tracker uses SDK estimation
|
||||
@@ -1,133 +0,0 @@
|
||||
# Plan 08: Skills & Prompts System
|
||||
|
||||
**Priority**: P2
|
||||
**Effort**: Medium
|
||||
**Goal**: Expose skills loading, prompt templates, and dynamic system prompt management. CLI and SDK share the same skills infrastructure.
|
||||
|
||||
## Background
|
||||
|
||||
Pi exports `loadSkills()`, `formatSkillsForPrompt()`, `PromptTemplate`, `expandPromptTemplate()`. Kit has an extension system but no "skills" concept (markdown-based instruction files) or prompt template system. This plan introduces a skills layer designed SDK-first.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Plan 00 (Create `pkg/kit/`)
|
||||
- Plan 02 (Richer type exports)
|
||||
|
||||
## Step-by-Step
|
||||
|
||||
### Step 1: Create `internal/skills/` package
|
||||
|
||||
**File**: `internal/skills/skills.go` — Skill loading and parsing
|
||||
|
||||
```go
|
||||
type Skill struct {
|
||||
Name string
|
||||
Description string
|
||||
Content string
|
||||
Path string
|
||||
Tags []string
|
||||
When string // "always", "on-demand", "file:*.go"
|
||||
}
|
||||
|
||||
func LoadSkill(path string) (*Skill, error) { ... } // Markdown with YAML frontmatter
|
||||
func LoadSkillsFromDir(dir string) ([]*Skill, error) { ... } // .md/.txt files + SKILL.md subdirs
|
||||
func LoadSkills(cwd string) ([]*Skill, error) { ... } // Auto-discover .kit/skills/ + ~/.config/kit/skills/
|
||||
func FormatForPrompt(skills []*Skill) string { ... } // Format for system prompt
|
||||
```
|
||||
|
||||
**File**: `internal/skills/templates.go` — Prompt templates
|
||||
|
||||
```go
|
||||
type PromptTemplate struct {
|
||||
Name string
|
||||
Content string
|
||||
Variables []string
|
||||
}
|
||||
|
||||
func NewPromptTemplate(name, content string) *PromptTemplate { ... }
|
||||
func LoadPromptTemplate(path string) (*PromptTemplate, error) { ... }
|
||||
func (t *PromptTemplate) Expand(values map[string]string) string { ... }
|
||||
func (t *PromptTemplate) ExpandStrict(values map[string]string) (string, error) { ... }
|
||||
```
|
||||
|
||||
**File**: `internal/skills/prompt_builder.go` — System prompt composition
|
||||
|
||||
```go
|
||||
type PromptBuilder struct { ... }
|
||||
|
||||
func NewPromptBuilder(basePrompt string) *PromptBuilder { ... }
|
||||
func (pb *PromptBuilder) WithSkills(skills []*Skill) *PromptBuilder { ... }
|
||||
func (pb *PromptBuilder) WithSection(name, content string) *PromptBuilder { ... }
|
||||
func (pb *PromptBuilder) Build() string { ... }
|
||||
```
|
||||
|
||||
### Step 2: Export in SDK
|
||||
|
||||
**File**: `pkg/kit/skills.go` (new)
|
||||
|
||||
```go
|
||||
package kit
|
||||
|
||||
import "github.com/mark3labs/kit/internal/skills"
|
||||
|
||||
type Skill = skills.Skill
|
||||
type PromptTemplate = skills.PromptTemplate
|
||||
type PromptBuilder = skills.PromptBuilder
|
||||
|
||||
func LoadSkill(path string) (*Skill, error) { return skills.LoadSkill(path) }
|
||||
func LoadSkillsFromDir(dir string) ([]*Skill, error) { return skills.LoadSkillsFromDir(dir) }
|
||||
func LoadSkills(cwd string) ([]*Skill, error) { return skills.LoadSkills(cwd) }
|
||||
func FormatSkillsForPrompt(s []*Skill) string { return skills.FormatForPrompt(s) }
|
||||
func NewPromptTemplate(name, content string) *PromptTemplate { return skills.NewPromptTemplate(name, content) }
|
||||
func LoadPromptTemplate(path string) (*PromptTemplate, error) { return skills.LoadPromptTemplate(path) }
|
||||
func NewPromptBuilder(basePrompt string) *PromptBuilder { return skills.NewPromptBuilder(basePrompt) }
|
||||
```
|
||||
|
||||
### Step 3: Integrate skills into Kit Options
|
||||
|
||||
```go
|
||||
type Options struct {
|
||||
// ... existing fields ...
|
||||
Skills []string // Skill files/dirs to load (empty = auto-discover)
|
||||
SkillsDir string // Override default skills directory
|
||||
}
|
||||
```
|
||||
|
||||
In `New()`, load skills and compose system prompt via `PromptBuilder`.
|
||||
|
||||
### Step 4: App-as-Consumer — CLI uses SDK for skills
|
||||
|
||||
Currently Kit's extension loader (`internal/extensions/loader.go`) discovers extensions from `.kit/extensions/` and `~/.config/kit/extensions/`. The skills system follows the same pattern but for instruction files.
|
||||
|
||||
The CLI should:
|
||||
1. Use `kit.LoadSkills(cwd)` to discover skills
|
||||
2. Pass them via `kit.Options{Skills: ...}` or let auto-discovery handle it
|
||||
3. A `/skills` slash command in interactive mode could list loaded skills
|
||||
|
||||
The existing `.agents/skills/` directory in the repo (used by btca) aligns with this convention. The SDK auto-discovers from `.kit/skills/` to avoid conflict with the `.agents/` convention used by other tools.
|
||||
|
||||
### Step 5: Write tests and verify
|
||||
|
||||
```bash
|
||||
go build -o output/kit ./cmd/kit
|
||||
go test -race ./...
|
||||
```
|
||||
|
||||
## Files Changed Summary
|
||||
|
||||
| Action | File | Change |
|
||||
|--------|------|--------|
|
||||
| CREATE | `internal/skills/skills.go` | Skill loading/parsing |
|
||||
| CREATE | `internal/skills/templates.go` | PromptTemplate |
|
||||
| CREATE | `internal/skills/prompt_builder.go` | System prompt composition |
|
||||
| CREATE | `pkg/kit/skills.go` | Public SDK exports |
|
||||
| EDIT | `pkg/kit/kit.go` | Skills option, auto-loading |
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [ ] Skills with YAML frontmatter parse correctly
|
||||
- [ ] Skills without frontmatter load (name from filename)
|
||||
- [ ] PromptTemplate expansion works
|
||||
- [ ] PromptBuilder composes multi-section prompts
|
||||
- [ ] Auto-discovery finds skills in standard directories
|
||||
- [ ] CLI uses SDK for skill loading
|
||||
@@ -1,275 +0,0 @@
|
||||
# Plan 09: Extension Hook System
|
||||
|
||||
**Priority**: P3
|
||||
**Effort**: High
|
||||
**Goal**: Expose Go-native interception hooks in the SDK. The Kit CLI app registers its own extension handlers as SDK hooks, proving the API is complete.
|
||||
|
||||
## Background
|
||||
|
||||
Pi has 20+ lifecycle hooks. Kit already has an internal extension system (`internal/extensions/`) with 13 event types, a `Runner` for dispatch, and tool wrapping. But none of this is accessible through the SDK.
|
||||
|
||||
This plan exposes hooks in the SDK and migrates the app's extension dispatch to use them — making the CLI the proof that the hook API is production-ready.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Plan 00 (Create `pkg/kit/`)
|
||||
- Plan 01 (Export tools — for custom tool registration)
|
||||
- Plan 02 (Richer type exports)
|
||||
- Plan 03 (Event subscriber system — observation layer)
|
||||
|
||||
## Design: Events vs Hooks
|
||||
|
||||
| | Events (Plan 03) | Hooks (This Plan) |
|
||||
|--|------------------|-------------------|
|
||||
| Purpose | **Observe** | **Intercept** |
|
||||
| Can block? | No | Yes (BeforeToolCall) |
|
||||
| Can modify? | No | Yes (AfterToolResult) |
|
||||
| Pattern | `Subscribe(func(Event))` | `OnBeforeToolCall(func(Hook) *Result)` |
|
||||
| Priority | N/A | High/Normal/Low ordering |
|
||||
|
||||
Both coexist — events fire regardless; hooks run before/after and can alter execution.
|
||||
|
||||
## Step-by-Step
|
||||
|
||||
### Step 1: Define hook input/result types
|
||||
|
||||
**File**: `pkg/kit/hooks.go` (new)
|
||||
|
||||
```go
|
||||
package kit
|
||||
|
||||
type HookPriority int
|
||||
|
||||
const (
|
||||
HookPriorityHigh HookPriority = 0
|
||||
HookPriorityNormal HookPriority = 50
|
||||
HookPriorityLow HookPriority = 100
|
||||
)
|
||||
|
||||
// BeforeToolCall — can block tool execution
|
||||
type BeforeToolCallHook struct {
|
||||
ToolName string
|
||||
ToolArgs string
|
||||
}
|
||||
type BeforeToolCallResult struct {
|
||||
Block bool
|
||||
Reason string
|
||||
}
|
||||
|
||||
// AfterToolResult — can modify tool output
|
||||
type AfterToolResultHook struct {
|
||||
ToolName string
|
||||
ToolArgs string
|
||||
Result string
|
||||
IsError bool
|
||||
}
|
||||
type AfterToolResultResult struct {
|
||||
Result *string // non-nil overrides
|
||||
IsError *bool // non-nil overrides
|
||||
}
|
||||
|
||||
// BeforeTurn — can modify prompt, inject context
|
||||
type BeforeTurnHook struct {
|
||||
Prompt string
|
||||
}
|
||||
type BeforeTurnResult struct {
|
||||
Prompt *string // override prompt
|
||||
SystemPrompt *string // prepend system message
|
||||
InjectText *string // prepend user message (context)
|
||||
}
|
||||
|
||||
// AfterTurn — observe completion
|
||||
type AfterTurnHook struct {
|
||||
Response string
|
||||
Error error
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Implement generic hook registry with priority ordering
|
||||
|
||||
```go
|
||||
type hookRegistry[In any, Out any] struct {
|
||||
mu sync.RWMutex
|
||||
hooks []hookEntry[In, Out]
|
||||
next int
|
||||
}
|
||||
|
||||
type hookEntry[In any, Out any] struct {
|
||||
id int
|
||||
priority HookPriority
|
||||
handler func(In) *Out
|
||||
}
|
||||
|
||||
func (hr *hookRegistry[In, Out]) register(p HookPriority, h func(In) *Out) func() { ... }
|
||||
func (hr *hookRegistry[In, Out]) run(input In) *Out { ... } // first non-nil result wins
|
||||
```
|
||||
|
||||
### Step 3: Add registries to Kit struct and expose registration methods
|
||||
|
||||
```go
|
||||
type Kit struct {
|
||||
// ... existing fields ...
|
||||
beforeToolCall *hookRegistry[BeforeToolCallHook, BeforeToolCallResult]
|
||||
afterToolResult *hookRegistry[AfterToolResultHook, AfterToolResultResult]
|
||||
beforeTurn *hookRegistry[BeforeTurnHook, BeforeTurnResult]
|
||||
afterTurn *hookRegistry[AfterTurnHook, struct{}]
|
||||
}
|
||||
|
||||
func (m *Kit) OnBeforeToolCall(p HookPriority, h func(BeforeToolCallHook) *BeforeToolCallResult) func() { ... }
|
||||
func (m *Kit) OnAfterToolResult(p HookPriority, h func(AfterToolResultHook) *AfterToolResultResult) func() { ... }
|
||||
func (m *Kit) OnBeforeTurn(p HookPriority, h func(BeforeTurnHook) *BeforeTurnResult) func() { ... }
|
||||
func (m *Kit) OnAfterTurn(p HookPriority, h func(AfterTurnHook)) func() { ... }
|
||||
```
|
||||
|
||||
### Step 4: Wire hooks into Prompt flow
|
||||
|
||||
In `Prompt()`:
|
||||
1. Run `beforeTurn` hooks — can modify prompt, inject system/context messages
|
||||
2. Wrap tools with `hookedTool` that runs `beforeToolCall` (can block) and `afterToolResult` (can modify)
|
||||
3. Run `afterTurn` hooks after generation
|
||||
|
||||
### Step 5: Tool wrapping via hooks
|
||||
|
||||
```go
|
||||
type hookedTool struct {
|
||||
inner fantasy.AgentTool
|
||||
kit *Kit
|
||||
}
|
||||
|
||||
func (h *hookedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
// 1. BeforeToolCall hook — can block
|
||||
result := h.kit.beforeToolCall.run(BeforeToolCallHook{...})
|
||||
if result != nil && result.Block { return error }
|
||||
|
||||
// 2. Execute actual tool
|
||||
resp, err := h.inner.Run(ctx, call)
|
||||
|
||||
// 3. AfterToolResult hook — can modify
|
||||
after := h.kit.afterToolResult.run(AfterToolResultHook{...})
|
||||
if after != nil { /* apply overrides */ }
|
||||
|
||||
return resp, err
|
||||
}
|
||||
```
|
||||
|
||||
The hook wrapper composes with the existing extension wrapper:
|
||||
```go
|
||||
// Extension wrapper runs first (inner), SDK hooks run outside (outer)
|
||||
tools = extensionWrapper(tools) // extensions wrap
|
||||
tools = m.wrapToolsWithHooks(tools) // SDK hooks wrap on top
|
||||
```
|
||||
|
||||
### Step 6: App-as-Consumer — Extension system registers as SDK hooks
|
||||
|
||||
This is the payoff step. The app's extension `Runner` currently dispatches events directly in `internal/app/app.go:executeStep()`. After this plan, extensions register as SDK hooks during initialization:
|
||||
|
||||
**File**: `pkg/kit/setup.go` or a new `pkg/kit/extensions_bridge.go`
|
||||
|
||||
```go
|
||||
// bridgeExtensions registers extension handlers as SDK hooks.
|
||||
// This makes the extension system a consumer of the SDK hook API.
|
||||
func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
|
||||
// Extension BeforeAgentStart → SDK BeforeTurn hook
|
||||
if runner.HasHandlers(extensions.BeforeAgentStart) {
|
||||
m.OnBeforeTurn(HookPriorityNormal, func(h BeforeTurnHook) *BeforeTurnResult {
|
||||
result, _ := runner.Emit(extensions.BeforeAgentStartEvent{Prompt: h.Prompt})
|
||||
if r, ok := result.(extensions.BeforeAgentStartResult); ok {
|
||||
return &BeforeTurnResult{
|
||||
SystemPrompt: r.SystemPrompt,
|
||||
InjectText: r.InjectText,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Extension Input → SDK BeforeTurn hook (higher priority, runs first)
|
||||
if runner.HasHandlers(extensions.Input) {
|
||||
m.OnBeforeTurn(HookPriorityHigh, func(h BeforeTurnHook) *BeforeTurnResult {
|
||||
result, _ := runner.Emit(extensions.InputEvent{Text: h.Prompt})
|
||||
if r, ok := result.(extensions.InputResult); ok {
|
||||
if r.Action == "transform" {
|
||||
return &BeforeTurnResult{Prompt: &r.Text}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Extension ToolCall → SDK BeforeToolCall hook
|
||||
// (Already handled by extensions.WrapToolsWithExtensions, but could also
|
||||
// be bridged here for SDK-only consumers)
|
||||
}
|
||||
```
|
||||
|
||||
Called during `Kit.New()`:
|
||||
```go
|
||||
if setupResult.ExtRunner != nil {
|
||||
k.bridgeExtensions(setupResult.ExtRunner)
|
||||
}
|
||||
```
|
||||
|
||||
**Migration path**:
|
||||
1. **Phase 1** (this plan): Bridge existing extensions as SDK hooks
|
||||
2. **Phase 2** (future): `executeStep()` in app.go uses only SDK hooks, removes direct runner calls
|
||||
3. **Phase 3** (future): Extension runner emits SDK events/hooks natively instead of its own types
|
||||
|
||||
### Step 7: Custom tool registration via Options
|
||||
|
||||
```go
|
||||
type Options struct {
|
||||
// ... existing fields ...
|
||||
ExtraTools []Tool // Additional tools for the agent
|
||||
}
|
||||
```
|
||||
|
||||
### Step 8: Write tests and verify
|
||||
|
||||
```bash
|
||||
go build -o output/kit ./cmd/kit
|
||||
go test -race ./...
|
||||
```
|
||||
|
||||
## Files Changed Summary
|
||||
|
||||
| Action | File | Change |
|
||||
|--------|------|--------|
|
||||
| CREATE | `pkg/kit/hooks.go` | Hook types, registry, registration methods |
|
||||
| EDIT | `pkg/kit/kit.go` | Hook registries, tool wrapper, Prompt hook invocation |
|
||||
| CREATE | `pkg/kit/extensions_bridge.go` | Bridge extension events to SDK hooks |
|
||||
| EDIT | `internal/app/app.go` | Gradual migration to use SDK hooks |
|
||||
|
||||
## API Surface After This Plan
|
||||
|
||||
```go
|
||||
// Block dangerous tool calls
|
||||
k.OnBeforeToolCall(kit.HookPriorityHigh, func(h kit.BeforeToolCallHook) *kit.BeforeToolCallResult {
|
||||
if h.ToolName == "bash" && isDangerous(h.ToolArgs) {
|
||||
return &kit.BeforeToolCallResult{Block: true, Reason: "dangerous"}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// Modify tool results
|
||||
k.OnAfterToolResult(kit.HookPriorityNormal, func(h kit.AfterToolResultHook) *kit.AfterToolResultResult {
|
||||
sanitized := redact(h.Result)
|
||||
return &kit.AfterToolResultResult{Result: &sanitized}
|
||||
})
|
||||
|
||||
// Inject context before each turn
|
||||
k.OnBeforeTurn(kit.HookPriorityNormal, func(h kit.BeforeTurnHook) *kit.BeforeTurnResult {
|
||||
ctx := loadProjectContext()
|
||||
return &kit.BeforeTurnResult{InjectText: &ctx}
|
||||
})
|
||||
```
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [ ] BeforeToolCall hooks can block tool calls
|
||||
- [ ] AfterToolResult hooks can modify results
|
||||
- [ ] BeforeTurn hooks can modify prompts and inject context
|
||||
- [ ] Priority ordering works correctly
|
||||
- [ ] Unregister removes hooks
|
||||
- [ ] Extension system bridges to SDK hooks
|
||||
- [ ] Hooks compose with existing extension wrapper
|
||||
- [ ] Thread-safe under concurrent access
|
||||
@@ -1,714 +0,0 @@
|
||||
# Plan 10: App-as-SDK-Consumer — Complete Integration
|
||||
|
||||
**Priority**: P4
|
||||
**Effort**: High
|
||||
**Goal**: Make the CLI app a full consumer of the SDK. `cmd/root.go` creates a `*Kit` via `kit.New()`. The app receives `*Kit`, calls `kit.PromptResult()`, subscribes to SDK events for TUI rendering, and extension observation events route through the SDK EventBus. This closes all deferred work from Plans 03, 05, and 09.
|
||||
|
||||
## Background
|
||||
|
||||
Plans 00–09 built the SDK surface (`pkg/kit/`) but the CLI app still bypasses it for the critical path:
|
||||
|
||||
- `cmd/root.go` calls `SetupAgent()` directly instead of `kit.New()`
|
||||
- `internal/app/app.go:executeStep()` calls `agent.GenerateWithLoopAndStreaming()` directly with 150+ lines of manual callback wiring, extension event dispatch, and session persistence — all of which the SDK already handles in `runTurn()`
|
||||
- Extension observation events (AgentStart, AgentEnd, MessageStart, MessageUpdate, MessageEnd) are emitted from `executeStep()`, not from the SDK
|
||||
- The app receives an `AgentRunner` interface, not a `*Kit`
|
||||
|
||||
After this plan, `executeStep()` becomes a thin wrapper around `kit.PromptResult()`, and extension events flow through the SDK's EventBus.
|
||||
|
||||
### Deferred Items Resolved
|
||||
|
||||
| Source | What | How |
|
||||
|--------|------|-----|
|
||||
| Plan 03 Step 6 | App TUI subscribes to SDK events | Step 5 |
|
||||
| Plan 03 Step 7 | Extension observation events forward to SDK EventBus | Step 4 |
|
||||
| Plan 05 Step 6 | `executeStep()` delegates to SDK `Prompt()` | Step 6 |
|
||||
| Plan 09 Phase 2 | App uses only SDK hooks, no direct runner calls | Step 6 |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Plans 00–09 (all complete)
|
||||
|
||||
## Step-by-Step
|
||||
|
||||
### Step 1: Extend Kit for CLI consumption
|
||||
|
||||
**Files**: `pkg/kit/kit.go`, `pkg/kit/setup.go`
|
||||
|
||||
The CLI needs fields that the programmatic SDK doesn't: spinner for Ollama loading, buffered debug logger, pre-loaded MCP config. Add these to `Options` and expose results via getters.
|
||||
|
||||
**1a. Add CLI fields to `Options`** (`pkg/kit/kit.go:48-71`):
|
||||
|
||||
```go
|
||||
type Options struct {
|
||||
// ... existing fields ...
|
||||
|
||||
// CLI-specific fields (ignored by programmatic SDK users)
|
||||
MCPConfig *config.Config // Pre-loaded MCP config (skips LoadAndValidateConfig if set)
|
||||
ShowSpinner bool // Show loading spinner for Ollama models
|
||||
SpinnerFunc agent.SpinnerFunc // Spinner implementation (nil = no spinner)
|
||||
UseBufferedLogger bool // Buffer debug messages for later display
|
||||
Debug bool // Enable debug logging
|
||||
}
|
||||
```
|
||||
|
||||
**1b. Add fields and getters to `Kit` struct** (`pkg/kit/kit.go:22-36`):
|
||||
|
||||
```go
|
||||
type Kit struct {
|
||||
// ... existing fields ...
|
||||
extRunner *extensions.Runner
|
||||
bufferedLogger *tools.BufferedDebugLogger
|
||||
}
|
||||
```
|
||||
|
||||
Getters:
|
||||
|
||||
```go
|
||||
// GetExtRunner returns the extension runner (nil if extensions are disabled).
|
||||
func (m *Kit) GetExtRunner() *extensions.Runner { return m.extRunner }
|
||||
|
||||
// GetBufferedLogger returns the buffered debug logger (nil if not configured).
|
||||
func (m *Kit) GetBufferedLogger() *tools.BufferedDebugLogger { return m.bufferedLogger }
|
||||
|
||||
// GetAgent returns the underlying agent. Callers that need the raw agent
|
||||
// (e.g. for GetTools(), GetLoadingMessage()) can use this.
|
||||
func (m *Kit) GetAgent() *agent.Agent { return m.agent }
|
||||
|
||||
// GetTreeSession returns the current tree session manager.
|
||||
// (Already exists as a method — verify it's public.)
|
||||
```
|
||||
|
||||
**1c. Update `New()`** (`pkg/kit/kit.go:111-204`):
|
||||
|
||||
- If `opts.MCPConfig != nil`, skip `config.LoadAndValidateConfig()` and use it directly
|
||||
- If `opts.Debug`, set `viper.Set("debug", true)`
|
||||
- Pass `ShowSpinner`, `SpinnerFunc`, `UseBufferedLogger` through to `SetupAgent()`
|
||||
- Store `agentResult.ExtRunner` and `agentResult.BufferedLogger` on the Kit struct
|
||||
|
||||
```go
|
||||
// In New(), replace lines 152-176:
|
||||
mcpConfig := opts.MCPConfig
|
||||
if mcpConfig == nil {
|
||||
var err error
|
||||
mcpConfig, err = config.LoadAndValidateConfig()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load MCP config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
agentResult, err := SetupAgent(ctx, AgentSetupOptions{
|
||||
MCPConfig: mcpConfig,
|
||||
Quiet: opts.Quiet,
|
||||
ShowSpinner: opts.ShowSpinner,
|
||||
SpinnerFunc: opts.SpinnerFunc,
|
||||
UseBufferedLogger: opts.UseBufferedLogger,
|
||||
CoreTools: opts.Tools,
|
||||
ExtraTools: opts.ExtraTools,
|
||||
ToolWrapper: hookToolWrapper(beforeToolCall, afterToolResult),
|
||||
})
|
||||
|
||||
// Store on Kit struct:
|
||||
k := &Kit{
|
||||
// ... existing fields ...
|
||||
extRunner: agentResult.ExtRunner,
|
||||
bufferedLogger: agentResult.BufferedLogger,
|
||||
}
|
||||
```
|
||||
|
||||
**Verification**:
|
||||
```bash
|
||||
go build -o output/kit ./cmd/kit
|
||||
go test -race ./...
|
||||
golangci-lint run ./...
|
||||
```
|
||||
|
||||
Existing behavior is unchanged — the new fields default to zero values.
|
||||
|
||||
---
|
||||
|
||||
### Step 2: Add TurnResult and PromptResult method
|
||||
|
||||
**File**: `pkg/kit/kit.go`
|
||||
|
||||
The current `Prompt()` returns `(string, error)`, which is fine for simple SDK usage but the app needs usage stats and conversation messages. Add a richer return path.
|
||||
|
||||
**2a. Define TurnResult** (new, in `pkg/kit/kit.go`):
|
||||
|
||||
```go
|
||||
// TurnResult contains the full result of a prompt turn, including usage
|
||||
// statistics and the updated conversation. Use PromptResult() instead of
|
||||
// Prompt() when you need access to this data.
|
||||
type TurnResult struct {
|
||||
// Response is the assistant's final text response.
|
||||
Response string
|
||||
|
||||
// TotalUsage is the aggregate token usage across all steps in the turn
|
||||
// (includes tool-calling loop iterations). Nil if the provider didn't
|
||||
// report usage.
|
||||
TotalUsage *FantasyUsage
|
||||
|
||||
// FinalUsage is the token usage from the last API call only. Use this
|
||||
// for context window fill estimation (InputTokens + OutputTokens ≈
|
||||
// current context size). Nil if unavailable.
|
||||
FinalUsage *FantasyUsage
|
||||
|
||||
// Messages is the full updated conversation after the turn, including
|
||||
// any tool call/result messages added during the agent loop.
|
||||
Messages []FantasyMessage
|
||||
}
|
||||
```
|
||||
|
||||
**2b. Modify `runTurn()` to return `*TurnResult`** (`pkg/kit/kit.go:319`):
|
||||
|
||||
Change signature from:
|
||||
```go
|
||||
func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, preMessages []fantasy.Message) (string, error)
|
||||
```
|
||||
To:
|
||||
```go
|
||||
func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, preMessages []fantasy.Message) (*TurnResult, error)
|
||||
```
|
||||
|
||||
Build and return `TurnResult` from the `agent.GenerateWithLoopResult`:
|
||||
|
||||
```go
|
||||
responseText := result.FinalResponse.Content.Text()
|
||||
|
||||
turnResult := &TurnResult{
|
||||
Response: responseText,
|
||||
Messages: result.ConversationMessages,
|
||||
}
|
||||
if result.TotalUsage != nil {
|
||||
turnResult.TotalUsage = result.TotalUsage
|
||||
}
|
||||
if result.FinalResponse != nil {
|
||||
turnResult.FinalUsage = &result.FinalResponse.Usage
|
||||
}
|
||||
|
||||
// ... existing event emission and persistence ...
|
||||
|
||||
return turnResult, nil
|
||||
```
|
||||
|
||||
On the error path, return `nil, err` (as before, but with `*TurnResult` instead of `""`).
|
||||
|
||||
**2c. Update all prompt methods** to extract the string from `TurnResult`:
|
||||
|
||||
```go
|
||||
func (m *Kit) Prompt(ctx context.Context, message string) (string, error) {
|
||||
result, err := m.runTurn(ctx, message, message, []fantasy.Message{
|
||||
fantasy.NewUserMessage(message),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return result.Response, nil
|
||||
}
|
||||
```
|
||||
|
||||
Same pattern for `Steer()`, `FollowUp()`, `PromptWithOptions()`, `PromptWithCallbacks()`.
|
||||
|
||||
**2d. Add `PromptResult()` method**:
|
||||
|
||||
```go
|
||||
// PromptResult sends a message and returns the full turn result including
|
||||
// usage statistics and conversation messages. Use this instead of Prompt()
|
||||
// when you need more than just the response text.
|
||||
func (m *Kit) PromptResult(ctx context.Context, message string) (*TurnResult, error) {
|
||||
return m.runTurn(ctx, message, message, []fantasy.Message{
|
||||
fantasy.NewUserMessage(message),
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Verification**:
|
||||
```bash
|
||||
go build -o output/kit ./cmd/kit
|
||||
go test -race ./...
|
||||
golangci-lint run ./...
|
||||
```
|
||||
|
||||
Existing `Prompt()` callers (examples, tests) are unaffected.
|
||||
|
||||
---
|
||||
|
||||
### Step 3: Migrate cmd/root.go to use kit.New()
|
||||
|
||||
**Files**: `cmd/root.go`, `cmd/setup.go`
|
||||
|
||||
Replace the manual `SetupAgent()` → `InitTreeSession()` → `BuildAppOptions()` chain with a single `kit.New()` call.
|
||||
|
||||
**3a. Replace agent creation** in `runNormalMode()` (`cmd/root.go:336-362`):
|
||||
|
||||
Before:
|
||||
```go
|
||||
agentResult, err := SetupAgent(ctx, AgentSetupOptions{...})
|
||||
mcpAgent := agentResult.Agent
|
||||
defer func() { _ = mcpAgent.Close() }()
|
||||
provider, modelName, serverNames, toolNames := CollectAgentMetadata(mcpAgent, mcpConfig)
|
||||
```
|
||||
|
||||
After:
|
||||
```go
|
||||
// Build Kit options from CLI flags.
|
||||
kitOpts := &kit.Options{
|
||||
MCPConfig: mcpConfig,
|
||||
ShowSpinner: true,
|
||||
SpinnerFunc: spinnerFunc,
|
||||
UseBufferedLogger: true,
|
||||
Quiet: quietFlag,
|
||||
Debug: debugMode,
|
||||
NoSession: noSessionFlag,
|
||||
Continue: continueFlag,
|
||||
SessionPath: sessionPath,
|
||||
AutoCompact: autoCompactFlag,
|
||||
}
|
||||
if resumeFlag {
|
||||
sessions, _ := kit.ListSessions("")
|
||||
if len(sessions) > 0 {
|
||||
kitOpts.SessionPath = sessions[0].Path
|
||||
}
|
||||
}
|
||||
|
||||
kitInstance, err := kit.New(ctx, kitOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer kitInstance.Close()
|
||||
```
|
||||
|
||||
**3b. Extract metadata from Kit instead of raw agent**:
|
||||
|
||||
```go
|
||||
mcpAgent := kitInstance.GetAgent()
|
||||
provider, modelName, serverNames, toolNames := CollectAgentMetadata(mcpAgent, mcpConfig)
|
||||
```
|
||||
|
||||
**3c. Get buffered logger and tree session from Kit**:
|
||||
|
||||
```go
|
||||
bufferedLogger := kitInstance.GetBufferedLogger()
|
||||
// ... display buffered debug messages ...
|
||||
|
||||
treeSession := kitInstance.GetTreeSession()
|
||||
var messages []fantasy.Message
|
||||
if treeSession != nil {
|
||||
messages = treeSession.GetFantasyMessages()
|
||||
}
|
||||
```
|
||||
|
||||
**3d. Build app options using Kit**:
|
||||
|
||||
```go
|
||||
appOpts := BuildAppOptions(mcpAgent, mcpConfig, modelName, serverNames, toolNames, kitInstance.GetExtRunner())
|
||||
appOpts.TreeSession = treeSession
|
||||
appOpts.Kit = kitInstance // NEW — added in Step 5
|
||||
```
|
||||
|
||||
**3e. Extension context setup** — use Kit's extension runner:
|
||||
|
||||
```go
|
||||
extRunner := kitInstance.GetExtRunner()
|
||||
if extRunner != nil {
|
||||
extRunner.SetContext(extensions.Context{...})
|
||||
// Emit SessionStart
|
||||
}
|
||||
```
|
||||
|
||||
**3f. Remove the separate `kit.InitTreeSession()` call** — Kit.New() handles session creation.
|
||||
|
||||
**3g. Remove the `defer func() { _ = mcpAgent.Close() }()`** — `kitInstance.Close()` handles cleanup.
|
||||
|
||||
**Verification**:
|
||||
```bash
|
||||
go build -o output/kit ./cmd/kit
|
||||
go test -race ./...
|
||||
golangci-lint run ./...
|
||||
# Manual: run `kit -p "hello"` to verify non-interactive mode
|
||||
# Manual: run `kit` to verify interactive mode
|
||||
```
|
||||
|
||||
The app still uses its own `executeStep()` at this point — that migrates in Step 6.
|
||||
|
||||
---
|
||||
|
||||
### Step 4: Bridge extension observation events through SDK EventBus
|
||||
|
||||
**File**: `pkg/kit/extensions_bridge.go`
|
||||
|
||||
Currently `bridgeExtensions()` only bridges `Input` and `BeforeAgentStart` (hook events). The observation events (AgentStart, AgentEnd, MessageStart, MessageUpdate, MessageEnd) are emitted from `app.executeStep()` directly to the extension runner. After this step, the SDK emits them from `runTurn()`/`generate()` and the bridge forwards to extensions.
|
||||
|
||||
**4a. Subscribe to SDK events and forward to extension runner**:
|
||||
|
||||
Add to `bridgeExtensions()` (`pkg/kit/extensions_bridge.go:16`):
|
||||
|
||||
```go
|
||||
func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
|
||||
// ... existing Input and BeforeAgentStart hooks ...
|
||||
|
||||
// Forward SDK observation events to extension runner.
|
||||
// These events are emitted by runTurn()/generate() and forwarded here
|
||||
// so extensions see them without the app having to emit them manually.
|
||||
|
||||
if runner.HasHandlers(extensions.AgentStart) {
|
||||
m.Subscribe(func(e Event) {
|
||||
if ev, ok := e.(TurnStartEvent); ok {
|
||||
runner.Emit(extensions.AgentStartEvent{Prompt: ev.Prompt})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if runner.HasHandlers(extensions.MessageStart) {
|
||||
m.Subscribe(func(e Event) {
|
||||
if _, ok := e.(MessageStartEvent); ok {
|
||||
runner.Emit(extensions.MessageStartEvent{})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if runner.HasHandlers(extensions.MessageUpdate) {
|
||||
m.Subscribe(func(e Event) {
|
||||
if ev, ok := e.(MessageUpdateEvent); ok {
|
||||
runner.Emit(extensions.MessageUpdateEvent{Chunk: ev.Chunk})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if runner.HasHandlers(extensions.MessageEnd) {
|
||||
m.Subscribe(func(e Event) {
|
||||
if ev, ok := e.(MessageEndEvent); ok {
|
||||
runner.Emit(extensions.MessageEndEvent{Content: ev.Content})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if runner.HasHandlers(extensions.AgentEnd) {
|
||||
m.Subscribe(func(e Event) {
|
||||
if ev, ok := e.(TurnEndEvent); ok {
|
||||
stopReason := "completed"
|
||||
response := ev.Response
|
||||
if ev.Error != nil {
|
||||
stopReason = "error"
|
||||
response = ""
|
||||
}
|
||||
runner.Emit(extensions.AgentEndEvent{
|
||||
Response: response,
|
||||
StopReason: stopReason,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**4b. Add SessionShutdown to Kit.Close()**:
|
||||
|
||||
In `pkg/kit/kit.go:Close()`:
|
||||
|
||||
```go
|
||||
func (m *Kit) Close() error {
|
||||
// Emit SessionShutdown for extensions.
|
||||
if m.extRunner != nil && m.extRunner.HasHandlers(extensions.SessionShutdown) {
|
||||
m.extRunner.Emit(extensions.SessionShutdownEvent{})
|
||||
}
|
||||
if m.treeSession != nil {
|
||||
_ = m.treeSession.Close()
|
||||
}
|
||||
return m.agent.Close()
|
||||
}
|
||||
```
|
||||
|
||||
**Verification**:
|
||||
```bash
|
||||
go build -o output/kit ./cmd/kit
|
||||
go test -race ./...
|
||||
golangci-lint run ./...
|
||||
```
|
||||
|
||||
At this point, extension observation events will fire from BOTH `executeStep()` (app) and the SDK bridge. This is intentional for the transition — Step 6 removes the app-side emission.
|
||||
|
||||
---
|
||||
|
||||
### Step 5: Wire app to Kit — add Kit field and SDK event → tea.Msg bridge
|
||||
|
||||
**Files**: `internal/app/options.go`, `internal/app/app.go`
|
||||
|
||||
Give the app a `*Kit` reference so it can call SDK prompt methods and subscribe to events.
|
||||
|
||||
**5a. Add Kit field to `app.Options`** (`internal/app/options.go:50`):
|
||||
|
||||
```go
|
||||
import kit "github.com/mark3labs/kit/pkg/kit"
|
||||
|
||||
type Options struct {
|
||||
// Kit is the SDK instance. When set, executeStep() delegates to
|
||||
// kit.PromptResult() and events flow through SDK subscriptions.
|
||||
Kit *kit.Kit
|
||||
|
||||
// Agent is the agent used to run the agentic loop. Required when Kit
|
||||
// is nil. When Kit is set, this field is ignored (Kit owns the agent).
|
||||
Agent AgentRunner
|
||||
|
||||
// ... rest unchanged ...
|
||||
}
|
||||
```
|
||||
|
||||
**5b. Create SDK event → tea.Msg bridge function** (`internal/app/app.go`):
|
||||
|
||||
```go
|
||||
// subscribeSDKEvents registers temporary SDK event subscribers that convert
|
||||
// SDK events to tea.Msg events and dispatch them via sendFn. Returns an
|
||||
// unsubscribe function that removes all listeners.
|
||||
func (a *App) subscribeSDKEvents(sendFn func(tea.Msg)) func() {
|
||||
k := a.opts.Kit
|
||||
var unsubs []func()
|
||||
|
||||
unsubs = append(unsubs, k.Subscribe(func(e kit.Event) {
|
||||
switch ev := e.(type) {
|
||||
case kit.ToolCallEvent:
|
||||
sendFn(ToolCallStartedEvent{ToolName: ev.ToolName, ToolArgs: ev.ToolArgs})
|
||||
case kit.ToolExecutionStartEvent:
|
||||
sendFn(ToolExecutionEvent{ToolName: ev.ToolName, IsStarting: true})
|
||||
case kit.ToolExecutionEndEvent:
|
||||
sendFn(ToolExecutionEvent{ToolName: ev.ToolName, IsStarting: false})
|
||||
case kit.ToolResultEvent:
|
||||
sendFn(ToolResultEvent{
|
||||
ToolName: ev.ToolName, ToolArgs: ev.ToolArgs,
|
||||
Result: ev.Result, IsError: ev.IsError,
|
||||
})
|
||||
case kit.ToolCallContentEvent:
|
||||
sendFn(ToolCallContentEvent{Content: ev.Content})
|
||||
case kit.ResponseEvent:
|
||||
sendFn(ResponseCompleteEvent{Content: ev.Content})
|
||||
case kit.MessageUpdateEvent:
|
||||
sendFn(StreamChunkEvent{Content: ev.Chunk})
|
||||
}
|
||||
}))
|
||||
|
||||
return func() {
|
||||
for _, unsub := range unsubs {
|
||||
unsub()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**5c. Pass Kit in `cmd/root.go`**:
|
||||
|
||||
In the `BuildAppOptions` call or directly after:
|
||||
|
||||
```go
|
||||
appOpts.Kit = kitInstance
|
||||
```
|
||||
|
||||
**Verification**:
|
||||
```bash
|
||||
go build -o output/kit ./cmd/kit
|
||||
go test -race ./...
|
||||
golangci-lint run ./...
|
||||
```
|
||||
|
||||
The bridge function exists but is not called yet. Step 6 wires it in.
|
||||
|
||||
---
|
||||
|
||||
### Step 6: Migrate executeStep() to use kit.PromptResult()
|
||||
|
||||
**File**: `internal/app/app.go`
|
||||
|
||||
Replace the 150+ line `executeStep()` with a thin wrapper around `kit.PromptResult()`.
|
||||
|
||||
**6a. Rewrite executeStep()**:
|
||||
|
||||
The new `executeStep()` when `opts.Kit` is set:
|
||||
|
||||
```go
|
||||
func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(tea.Msg)) (*agent.GenerateWithLoopResult, error) {
|
||||
if a.opts.Kit == nil {
|
||||
return a.executeStepLegacy(ctx, prompt, eventFn)
|
||||
}
|
||||
|
||||
sendFn := func(msg tea.Msg) {
|
||||
if eventFn != nil {
|
||||
eventFn(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to SDK events for TUI rendering. The subscription is
|
||||
// temporary — it lives only for the duration of this step.
|
||||
unsub := a.subscribeSDKEvents(sendFn)
|
||||
defer unsub()
|
||||
|
||||
// Show spinner while the agent works.
|
||||
sendFn(SpinnerEvent{Show: true})
|
||||
|
||||
result, err := a.opts.Kit.PromptResult(ctx, prompt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Sync in-memory store with the SDK's authoritative conversation.
|
||||
a.store.Replace(result.Messages)
|
||||
|
||||
// Update usage tracker.
|
||||
a.updateUsageFromTurnResult(result, prompt)
|
||||
|
||||
return &agent.GenerateWithLoopResult{
|
||||
ConversationMessages: result.Messages,
|
||||
}, nil
|
||||
}
|
||||
```
|
||||
|
||||
**6b. Rename existing executeStep to executeStepLegacy**:
|
||||
|
||||
Keep the old implementation as `executeStepLegacy()` so the transition is safe. It remains as a fallback when `opts.Kit == nil` (e.g. in tests that supply a stub `AgentRunner`).
|
||||
|
||||
**6c. Add `updateUsageFromTurnResult` helper**:
|
||||
|
||||
```go
|
||||
func (a *App) updateUsageFromTurnResult(result *kit.TurnResult, userPrompt string) {
|
||||
if a.opts.UsageTracker == nil || result == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if result.TotalUsage != nil {
|
||||
inputTokens := int(result.TotalUsage.InputTokens)
|
||||
outputTokens := int(result.TotalUsage.OutputTokens)
|
||||
if inputTokens > 0 && outputTokens > 0 {
|
||||
cacheReadTokens := int(result.TotalUsage.CacheReadTokens)
|
||||
cacheWriteTokens := int(result.TotalUsage.CacheCreationTokens)
|
||||
a.opts.UsageTracker.UpdateUsage(inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens)
|
||||
} else {
|
||||
a.opts.UsageTracker.EstimateAndUpdateUsage(userPrompt, result.Response)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if result.FinalUsage != nil {
|
||||
if ct := int(result.FinalUsage.InputTokens) + int(result.FinalUsage.OutputTokens); ct > 0 {
|
||||
a.opts.UsageTracker.SetContextTokens(ct)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**6d. Remove extension event emission from `executeStepLegacy()`**:
|
||||
|
||||
Since the SDK bridge (Step 4) now forwards extension observation events, remove these direct calls from `executeStepLegacy()`:
|
||||
- `extensions.AgentStart` emission (line 432-434)
|
||||
- `extensions.MessageStart` emission (line 440-442)
|
||||
- `extensions.MessageUpdate` emission (line 473-475)
|
||||
- `extensions.MessageEnd` emission (line 496-498)
|
||||
- `extensions.AgentEnd` emission (lines 482-487, 501-506)
|
||||
|
||||
The `Input` and `BeforeAgentStart` extensions are already handled by the SDK hooks (bridged in Plan 09). Remove those too from `executeStepLegacy()`:
|
||||
- `extensions.Input` emission (lines 372-387)
|
||||
- `extensions.BeforeAgentStart` emission (lines 414-429)
|
||||
|
||||
What remains in `executeStepLegacy()` is just the core generation call — which is now essentially the same as calling `kit.PromptResult()`.
|
||||
|
||||
**6e. Remove SessionShutdown from `app.Close()`**:
|
||||
|
||||
Since `Kit.Close()` now handles SessionShutdown (Step 4b), remove:
|
||||
|
||||
```go
|
||||
// In app.Close() — remove:
|
||||
if a.opts.Extensions != nil && a.opts.Extensions.HasHandlers(extensions.SessionShutdown) {
|
||||
_, _ = a.opts.Extensions.Emit(extensions.SessionShutdownEvent{})
|
||||
}
|
||||
```
|
||||
|
||||
**Verification**:
|
||||
```bash
|
||||
go build -o output/kit ./cmd/kit
|
||||
go test -race ./...
|
||||
golangci-lint run ./...
|
||||
# Manual: run `kit -p "list files in the current directory"` — verify tool calls render
|
||||
# Manual: run `kit` in interactive mode — verify streaming, tool results, spinner
|
||||
# Manual: create a .kit/extensions/ extension with AgentStart handler — verify it fires
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 7: Clean up dead code
|
||||
|
||||
**Files**: `internal/app/app.go`, `internal/app/options.go`, `internal/app/events.go`, `cmd/setup.go`
|
||||
|
||||
**7a. Remove `executeStepLegacy()`**:
|
||||
|
||||
Once confident the SDK path works, delete `executeStepLegacy()` entirely. Update `executeStep()` to remove the `if a.opts.Kit == nil` guard.
|
||||
|
||||
**7b. Remove `AgentRunner` interface**:
|
||||
|
||||
`internal/app/options.go:17-28` — delete `AgentRunner`. The `Agent AgentRunner` field is no longer used when `Kit` is set. Remove the `Agent` field from `Options`.
|
||||
|
||||
**7c. Remove `Extensions` field from `app.Options`**:
|
||||
|
||||
`internal/app/options.go:94-98` — the app no longer calls `a.opts.Extensions.Emit()` directly. Extension dispatch goes through SDK hooks/events. Remove the field and all `a.opts.Extensions` references in `app.go`.
|
||||
|
||||
**7d. Simplify `BuildAppOptions()` in `cmd/setup.go`**:
|
||||
|
||||
Remove the `mcpAgent` and `extRunner` parameters since the app gets these from `Kit`:
|
||||
|
||||
```go
|
||||
func BuildAppOptions(kitInstance *kit.Kit, mcpConfig *config.Config,
|
||||
modelName string, serverNames, toolNames []string) app.Options {
|
||||
return app.Options{
|
||||
Kit: kitInstance,
|
||||
MCPConfig: mcpConfig,
|
||||
ModelName: modelName,
|
||||
ServerNames: serverNames,
|
||||
ToolNames: toolNames,
|
||||
StreamingEnabled: viper.GetBool("stream"),
|
||||
Quiet: quietFlag,
|
||||
Debug: viper.GetBool("debug"),
|
||||
CompactMode: viper.GetBool("compact"),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**7e. Remove `updateUsage()` from `app.go`** (`app.go:596-627`):
|
||||
|
||||
Replaced by `updateUsageFromTurnResult()` which works with `TurnResult` instead of raw `GenerateWithLoopResult`.
|
||||
|
||||
**7f. Simplify `SessionStart` emission**:
|
||||
|
||||
Move SessionStart from `cmd/root.go:448` into `Kit.New()` or a new `Kit.EmitSessionStart()` method called by the CLI after extension context is configured.
|
||||
|
||||
**7g. Remove `inputSource()` helper** (`app.go:524-532`):
|
||||
|
||||
Only used by the now-removed Input extension emission.
|
||||
|
||||
**7h. Run final verification**:
|
||||
|
||||
```bash
|
||||
go build -o output/kit ./cmd/kit
|
||||
go test -race ./...
|
||||
golangci-lint run ./...
|
||||
```
|
||||
|
||||
Confirm no references to removed types/functions. Confirm no unused imports.
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [ ] `go build -o output/kit ./cmd/kit` succeeds
|
||||
- [ ] `go test -race ./...` passes
|
||||
- [ ] `golangci-lint run ./...` — 0 issues
|
||||
- [ ] `kit.New()` creates agent, session, extensions in one call
|
||||
- [ ] `cmd/root.go` no longer calls `SetupAgent()` directly
|
||||
- [ ] `executeStep()` delegates to `kit.PromptResult()`
|
||||
- [ ] SDK events drive TUI rendering (tool calls, streaming, results)
|
||||
- [ ] Extension observation events (AgentStart/End, MessageStart/Update/End) fire via SDK bridge
|
||||
- [ ] Extension interception events (Input, BeforeAgentStart, ToolCall, ToolResult) still work
|
||||
- [ ] Usage tracker receives correct token counts
|
||||
- [ ] Session persistence works (tree session)
|
||||
- [ ] `--continue` / `--no-session` / `--session` flags work
|
||||
- [ ] Spinner shows/hides correctly
|
||||
- [ ] Interactive mode (BubbleTea) works
|
||||
- [ ] Non-interactive mode (`-p "..."`) works
|
||||
- [ ] Extension SessionShutdown fires on close
|
||||
- [ ] No remaining direct `extensions.Emit()` calls in `app.go`
|
||||
- [ ] `AgentRunner` interface removed
|
||||
- [ ] `app.Options.Extensions` field removed
|
||||
-104
@@ -1,104 +0,0 @@
|
||||
# SDK Revamp Plans
|
||||
|
||||
## Core Architectural Principle
|
||||
|
||||
**The Kit CLI app is the primary consumer of the SDK.**
|
||||
|
||||
The SDK is not a thin wrapper for external users. The CLI is built on top of it:
|
||||
|
||||
1. `pkg/kit/` defines the canonical API for agents, sessions, events, and hooks
|
||||
2. `cmd/` parses CLI flags, maps them to `kit.Options`, and calls `kit.New()`
|
||||
3. `internal/app/` subscribes to SDK events for TUI rendering and uses SDK prompt methods
|
||||
4. If the app needs a capability, it is added to the SDK first, then consumed by the app
|
||||
5. External users get the exact same API the CLI uses
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
cmd/kit/main.go
|
||||
|
|
||||
v
|
||||
cmd/ Parses flags, maps to kit.Options
|
||||
|
|
||||
v
|
||||
pkg/kit/ Canonical SDK: New(), Prompt(), Subscribe(), hooks
|
||||
|
|
||||
+---> internal/agent/ Agent creation, generation loop
|
||||
+---> internal/session/ Session persistence, tree manager
|
||||
+---> internal/config/ Config loading, MCP server config
|
||||
+---> internal/core/ Built-in tools (read, write, bash, etc.)
|
||||
+---> internal/models/ Provider registry, model validation
|
||||
+---> internal/auth/ Credential management, OAuth
|
||||
+---> internal/compaction/ Context summarization (Plan 07)
|
||||
+---> internal/skills/ Skill loading, templates (Plan 08)
|
||||
+---> internal/extensions/ Yaegi extension runtime
|
||||
|
||||
internal/app/ TUI/interactive mode — subscribes to SDK events
|
||||
|
|
||||
+---> pkg/kit/ Uses SDK for prompts, sessions, tools
|
||||
+---> internal/ui/ Owns BubbleTea rendering only
|
||||
```
|
||||
|
||||
**No circular dependencies.** `pkg/kit/` never imports `cmd/`. `cmd/` imports `pkg/kit/`.
|
||||
|
||||
### Before vs After
|
||||
|
||||
| Concern | Before (Parallel) | After (SDK-First) |
|
||||
|---------|-------------------|-------------------|
|
||||
| Config init | `cmd.InitConfig()` called by both CLI and SDK | `kit.InitConfig()` in `pkg/kit/`, `cmd/` delegates |
|
||||
| Agent creation | `cmd.SetupAgent()` called by both | `kit.SetupAgent()` in `pkg/kit/`, `cmd/` delegates |
|
||||
| Session setup | `cmd/root.go` has 80-line if/else chain | `kit.Options{Continue: true}`, SDK handles it |
|
||||
| Events | 3 parallel systems (SDK callbacks, extension events, TUI msgs) | Single SDK EventBus, TUI bridges via `Subscribe()` |
|
||||
| Tool exposure | Internal only | `kit.AllTools()`, `kit.NewReadTool(kit.WithWorkDir(...))` |
|
||||
| Hooks | Only via Yaegi extensions | `kit.OnBeforeToolCall()` — extensions bridge to SDK hooks |
|
||||
|
||||
## Plan Execution Order
|
||||
|
||||
| Plan | Priority | Description | Depends On |
|
||||
|------|----------|-------------|------------|
|
||||
| **00** | P0 | Create `pkg/kit/`, extract init from `cmd/` | None |
|
||||
| **01** | P0 | Export tools and tool factories | 00 |
|
||||
| **02** | P0 | Richer type exports (40+ types) | 00 |
|
||||
| **03** | P1 | Unified event/subscriber system (core done; app/ext bridge deferred) | 00, 02 |
|
||||
| **04** | P1 | Enhanced session management | 00, 02 |
|
||||
| **05** | P1 | Additional prompt modes (Steer, FollowUp) | 00, 03 |
|
||||
| **06** | P2 | Auth & model management APIs | 00, 02 |
|
||||
| **07** | P2 | Compaction APIs | 00, 03, 04 |
|
||||
| **08** | P2 | Skills & prompts system | 00, 02 |
|
||||
| **09** | P3 | Extension hook system | 00, 01, 02, 03 |
|
||||
| **10** | P4 | App-as-SDK-consumer — complete integration | 00–09 |
|
||||
|
||||
### Recommended Batches
|
||||
|
||||
**Batch 1 — Foundation** (Plans 00, 01, 02):
|
||||
Restructure package, expose tools and types. SDK is usable for basic programmatic access. CLI starts delegating to SDK.
|
||||
|
||||
**Batch 2 — Rich Interaction** (Plans 03, 04, 05):
|
||||
Unified events, sessions, prompt modes. App migrates to SDK for event handling and session setup.
|
||||
|
||||
**Batch 3 — Management** (Plans 06, 07, 08):
|
||||
Auth, compaction, skills. CLI commands use SDK functions.
|
||||
|
||||
**Batch 4 — Extensibility** (Plan 09):
|
||||
Hook system with extension bridge. App's extension dispatch routes through SDK hooks.
|
||||
|
||||
**Batch 5 — Full Integration** (Plan 10):
|
||||
CLI uses `kit.New()`, app calls `kit.PromptResult()`, extension events route through SDK EventBus. Closes all deferred items from Plans 03, 05, 09. Removes `AgentRunner` interface, `app.Options.Extensions`, and legacy `executeStep` code.
|
||||
|
||||
## Parity with Pi SDK
|
||||
|
||||
After all plans:
|
||||
|
||||
| Capability | Pi | Kit (After) |
|
||||
|-----------|-----|-------------|
|
||||
| Top-level package imports | Yes | `pkg/kit/` |
|
||||
| Tool exports + factories | Yes | Plan 01 |
|
||||
| Rich type surface (50+) | Yes | Plan 02 |
|
||||
| Event subscriber system | Yes | Plan 03 |
|
||||
| Session management (list/continue/branch) | Yes | Plan 04 |
|
||||
| Multiple prompt modes | Yes | Plan 05 |
|
||||
| Auth/model management | Yes | Plan 06 |
|
||||
| Compaction APIs | Yes | Plan 07 |
|
||||
| Skills/prompts system | Yes | Plan 08 |
|
||||
| Extension hooks (20+ events) | Yes | Plan 09 |
|
||||
| App built on SDK | Yes | Plan 10 (completes deferred work from 03, 05, 09) |
|
||||
Reference in New Issue
Block a user