mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-18 13:25:52 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 879723fe90 | |||
| 57250a3a3d | |||
| 7e1686e572 | |||
| 4a8b10cde7 | |||
| cc5611eff7 | |||
| 51c70b63a7 | |||
| c9ee80d98a | |||
| 3ecedcbc2d | |||
| dbfa410fc1 | |||
| 512ecb92dc | |||
| aede76d807 | |||
| 9e1df38836 | |||
| 8f5efee837 | |||
| a392d3e572 | |||
| c40dc2f4fb | |||
| 37e82781b1 | |||
| 23c16bb197 | |||
| 9449f1fcdf | |||
| dc59cfc81e | |||
| 8407d924b9 |
@@ -83,9 +83,9 @@ 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
|
||||
kit --quiet --no-session --no-extensions --system-prompt /path/to/prompt.txt --model provider/model "question"
|
||||
```
|
||||
Key flags: `--quiet` (stdout only, no TUI), `--no-session` (ephemeral), `--no-extensions` (prevent recursive loading), `--system-prompt` (string or file path).
|
||||
Positional args are the prompt. `@file` args attach file content. 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)
|
||||
|
||||
@@ -13,4 +13,497 @@
|
||||
|
||||
# KIT (Knowledge Inference Tool)
|
||||
|
||||
TBD
|
||||
A powerful, extensible AI coding agent CLI with multi-provider support, built-in tools, and a rich extension system.
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-Provider LLM Support**: Anthropic, OpenAI, Google Gemini, Ollama, Azure OpenAI, AWS Bedrock, OpenRouter, and more
|
||||
- **Built-in Core Tools**: bash, read, write, edit, grep, find, ls - no MCP overhead
|
||||
- **MCP Integration**: Connect external MCP servers for expanded capabilities
|
||||
- **Extension System**: Write custom tools, commands, widgets, and UI modifications in Go
|
||||
- **Interactive TUI**: Rich terminal interface powered by Bubble Tea with streaming, syntax highlighting, and custom rendering
|
||||
- **Session Management**: Tree-based conversation history with branching support
|
||||
- **Non-Interactive Mode**: Script-friendly positional args with JSON output
|
||||
- **Go SDK**: Embed Kit in your own applications
|
||||
|
||||
## Installation
|
||||
|
||||
### Using npm (recommended)
|
||||
|
||||
```bash
|
||||
npm install -g @mark3labs/kit
|
||||
```
|
||||
|
||||
### Using Go
|
||||
|
||||
```bash
|
||||
go install github.com/mark3labs/kit/cmd/kit@latest
|
||||
```
|
||||
|
||||
### Building from source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mark3labs/kit.git
|
||||
cd kit
|
||||
go build -o kit ./cmd/kit
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```bash
|
||||
# Start interactive session
|
||||
kit
|
||||
|
||||
# Run a one-off prompt
|
||||
kit "List files in src/"
|
||||
|
||||
# Attach files as context
|
||||
kit @main.go @test.go "Review these files"
|
||||
|
||||
# Continue the most recent session
|
||||
kit --continue
|
||||
|
||||
# Use specific model
|
||||
kit --model anthropic/claude-sonnet-4-5-20250929
|
||||
```
|
||||
|
||||
### Non-Interactive Mode
|
||||
|
||||
```bash
|
||||
# Get JSON output for scripting
|
||||
kit "Explain main.go" --json
|
||||
|
||||
# Quiet mode (final response only)
|
||||
kit "Run tests" --quiet
|
||||
|
||||
# Ephemeral mode (no session file)
|
||||
kit "Quick question" --no-session
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Kit looks for configuration in the following locations (in order of priority):
|
||||
|
||||
1. CLI flags
|
||||
2. Environment variables (with `KIT_` prefix)
|
||||
3. `./.kit.yml` (project-local)
|
||||
4. `~/.kit.yml` (global)
|
||||
|
||||
### Basic Configuration
|
||||
|
||||
Create `~/.kit.yml`:
|
||||
|
||||
```yaml
|
||||
model: anthropic/claude-sonnet-4-5-20250929
|
||||
max-tokens: 4096
|
||||
temperature: 0.7
|
||||
stream: true
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY="sk-..."
|
||||
export OPENAI_API_KEY="sk-..."
|
||||
export KIT_MODEL="openai/gpt-4o"
|
||||
```
|
||||
|
||||
### MCP Server Configuration
|
||||
|
||||
Add external MCP servers to `.kit.yml`:
|
||||
|
||||
```yaml
|
||||
mcpServers:
|
||||
filesystem:
|
||||
type: local
|
||||
command: ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed"]
|
||||
environment:
|
||||
LOG_LEVEL: "info"
|
||||
allowedTools: ["read_file", "write_file"]
|
||||
|
||||
search:
|
||||
type: remote
|
||||
url: "https://mcp.example.com/search"
|
||||
```
|
||||
|
||||
## CLI Reference
|
||||
|
||||
### Global Flags
|
||||
|
||||
```bash
|
||||
# Model and provider
|
||||
--model, -m Model to use (provider/model format)
|
||||
--provider-api-key API key for the provider
|
||||
--provider-url Base URL for provider API
|
||||
--tls-skip-verify Skip TLS certificate verification
|
||||
|
||||
# Session management
|
||||
--session, -s Open specific JSONL session file
|
||||
--continue, -c Resume most recent session for current directory
|
||||
--resume, -r Interactive session picker
|
||||
--no-session Ephemeral mode, no persistence
|
||||
|
||||
# Behavior (non-interactive: pass prompt as positional arg)
|
||||
--quiet Suppress all output (non-interactive only)
|
||||
--json Output response as JSON (non-interactive only)
|
||||
--no-exit Enter interactive mode after prompt completes
|
||||
--max-steps Maximum agent steps (0 for unlimited)
|
||||
--stream Enable streaming output (default: true)
|
||||
--compact Enable compact output mode
|
||||
--auto-compact Auto-compact conversation near context limit
|
||||
|
||||
# Extensions
|
||||
--extension, -e Load additional extension file(s) (repeatable)
|
||||
--no-extensions Disable all extensions
|
||||
|
||||
# Generation parameters
|
||||
--max-tokens Maximum tokens in response (default: 4096)
|
||||
--temperature Randomness 0.0-1.0 (default: 0.7)
|
||||
--top-p Nucleus sampling 0.0-1.0 (default: 0.95)
|
||||
--top-k Limit top K tokens (default: 40)
|
||||
--stop-sequences Custom stop sequences (comma-separated)
|
||||
|
||||
# System
|
||||
--config Config file path (default: ~/.kit.yml)
|
||||
--system-prompt System prompt text or file path
|
||||
--debug Enable debug logging
|
||||
```
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
# Authentication (for OAuth-enabled providers)
|
||||
kit auth login # Start OAuth flow
|
||||
kit auth logout # Remove credentials
|
||||
kit auth status # Check authentication status
|
||||
|
||||
# Model database
|
||||
kit models # List available models
|
||||
kit models --all # Show all providers (not just Fantasy-compatible)
|
||||
kit update-models # Update local model database from models.dev
|
||||
|
||||
# Extension management
|
||||
kit extensions list # List discovered extensions
|
||||
kit extensions validate # Validate extension files
|
||||
kit extensions init # Generate example extension template
|
||||
```
|
||||
|
||||
## Extension System
|
||||
|
||||
Extensions are Go source files that run via Yaegi interpreter. They can add custom tools, slash commands, widgets, keyboard shortcuts, and intercept lifecycle events.
|
||||
|
||||
### Minimal Extension
|
||||
|
||||
```go
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import "kit/ext"
|
||||
|
||||
func Init(api ext.API) {
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
ctx.SetFooter(ext.HeaderFooterConfig{
|
||||
Content: ext.WidgetContent{Text: "Custom Footer"},
|
||||
})
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```bash
|
||||
kit -e examples/extensions/minimal.go
|
||||
```
|
||||
|
||||
### Extension Capabilities
|
||||
|
||||
**Lifecycle Events**: OnSessionStart, OnSessionShutdown, OnAgentStart, OnAgentEnd, OnToolCall, OnToolResult, OnInput, OnMessageStart, OnMessageUpdate, OnMessageEnd, OnModelChange, OnContextPrepare, OnBeforeFork, OnBeforeSessionSwitch, OnBeforeCompact
|
||||
|
||||
**Custom Components**:
|
||||
- **Tools**: Add new tools the LLM can invoke
|
||||
- **Commands**: Register slash commands (e.g., `/mycommand`)
|
||||
- **Widgets**: Persistent status displays above/below input
|
||||
- **Shortcuts**: Global keyboard shortcuts
|
||||
- **Overlays**: Modal dialogs with markdown content
|
||||
- **Tool Renderers**: Customize how tool calls display
|
||||
- **Editor Interceptors**: Handle key events and wrap rendering
|
||||
|
||||
### Extension Examples
|
||||
|
||||
See the `examples/extensions/` directory:
|
||||
|
||||
- `minimal.go` - Clean UI with custom footer
|
||||
- `notify.go` - Desktop notifications
|
||||
- `widget-status.go` - Persistent status widgets
|
||||
- `custom-editor-demo.go` - Vim-like modal editor
|
||||
- `prompt-demo.go` - Interactive prompts (select/confirm/input)
|
||||
- `tool-logger.go` - Log all tool calls
|
||||
- `overlay-demo.go` - Modal dialogs
|
||||
- `plan-mode.go` - Read-only planning mode
|
||||
- `subagent-widget.go` - Multi-agent orchestration
|
||||
- `auto-commit.go` - Auto-commit on shutdown
|
||||
|
||||
### Loading Extensions
|
||||
|
||||
**Auto-discovery** (loads automatically):
|
||||
- `./.kit/extensions/*.go` (project-local)
|
||||
- `~/.config/kit/extensions/*.go` (global)
|
||||
|
||||
**Explicit loading**:
|
||||
```bash
|
||||
kit -e path/to/extension.go
|
||||
kit -e ext1.go -e ext2.go # Multiple extensions
|
||||
```
|
||||
|
||||
**Disable auto-load**:
|
||||
```bash
|
||||
kit --no-extensions
|
||||
```
|
||||
|
||||
## Session Management
|
||||
|
||||
Kit uses a tree-based session model that supports branching and forking conversations.
|
||||
|
||||
### Session Locations
|
||||
|
||||
- Default: `~/.local/share/kit/sessions/<cwd-hash>/<uuid>.jsonl`
|
||||
- Each line is a session entry (messages, tool calls, extension data)
|
||||
- Supports branching from any message to explore alternate paths
|
||||
|
||||
### Session Commands
|
||||
|
||||
```bash
|
||||
# Resume most recent session for current directory
|
||||
kit --continue
|
||||
kit -c
|
||||
|
||||
# Interactive session picker
|
||||
kit --resume
|
||||
kit -r
|
||||
|
||||
# Open specific session file
|
||||
kit --session path/to/session.jsonl
|
||||
kit -s path/to/session.jsonl
|
||||
|
||||
# Ephemeral mode (no file persistence)
|
||||
kit --no-session
|
||||
```
|
||||
|
||||
## Go SDK
|
||||
|
||||
Embed Kit in your Go applications:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
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 {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer host.Close()
|
||||
|
||||
// Send a prompt
|
||||
response, err := host.Prompt(ctx, "What is 2+2?")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
println(response)
|
||||
}
|
||||
```
|
||||
|
||||
### With Options
|
||||
|
||||
```go
|
||||
host, err := kit.New(ctx, &kit.Options{
|
||||
Model: "ollama/llama3",
|
||||
SystemPrompt: "You are a helpful bot",
|
||||
ConfigFile: "/path/to/config.yml",
|
||||
MaxSteps: 10,
|
||||
Streaming: true,
|
||||
Quiet: true,
|
||||
})
|
||||
```
|
||||
|
||||
### With Callbacks
|
||||
|
||||
```go
|
||||
response, err := host.PromptWithCallbacks(
|
||||
ctx,
|
||||
"List files in current directory",
|
||||
func(name, args string) {
|
||||
// Tool call started
|
||||
println("Calling tool:", name)
|
||||
},
|
||||
func(name, args, result string, isError bool) {
|
||||
// Tool call completed
|
||||
if isError {
|
||||
println("Tool failed:", name)
|
||||
}
|
||||
},
|
||||
func(chunk string) {
|
||||
// Streaming text chunk
|
||||
print(chunk)
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
### Session Management
|
||||
|
||||
```go
|
||||
host.Prompt(ctx, "My name is Alice")
|
||||
response, _ := host.Prompt(ctx, "What's my name?")
|
||||
|
||||
host.SaveSession("./session.json")
|
||||
host.LoadSession("./session.json")
|
||||
host.ClearSession()
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Subagent Pattern
|
||||
|
||||
Spawn Kit as a subprocess for multi-agent orchestration:
|
||||
|
||||
```bash
|
||||
kit "Analyze codebase" \
|
||||
--json \
|
||||
--no-session \
|
||||
--no-extensions \
|
||||
--quiet \
|
||||
--model anthropic/claude-haiku-3-5-20241022
|
||||
```
|
||||
|
||||
Parse the JSON output:
|
||||
|
||||
```json
|
||||
{
|
||||
"response": "Final assistant response text",
|
||||
"model": "anthropic/claude-haiku-3-5-20241022",
|
||||
"usage": {
|
||||
"input_tokens": 1024,
|
||||
"output_tokens": 512,
|
||||
"total_tokens": 1536
|
||||
},
|
||||
"messages": [...]
|
||||
}
|
||||
```
|
||||
|
||||
### Testing with tmux
|
||||
|
||||
Test the TUI non-interactively:
|
||||
|
||||
```bash
|
||||
# Start Kit in detached tmux session
|
||||
tmux new-session -d -s kittest -x 120 -y 40 \
|
||||
"kit -e ext.go --no-session 2>kit.log"
|
||||
|
||||
# Wait for startup
|
||||
sleep 3
|
||||
|
||||
# Capture screen
|
||||
tmux capture-pane -t kittest -p
|
||||
|
||||
# Send input
|
||||
tmux send-keys -t kittest '/command' Enter
|
||||
|
||||
# Cleanup
|
||||
tmux kill-session -t kittest
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Build and Test
|
||||
|
||||
```bash
|
||||
# Build
|
||||
go build -o output/kit ./cmd/kit
|
||||
|
||||
# Run tests
|
||||
go test -race ./...
|
||||
|
||||
# Run specific test
|
||||
go test -race ./cmd -run TestScriptExecution
|
||||
|
||||
# Lint
|
||||
go vet ./...
|
||||
|
||||
# Format
|
||||
go fmt ./...
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
cmd/kit/ - CLI entry point
|
||||
cmd/ - CLI command implementations
|
||||
pkg/kit/ - Go SDK
|
||||
internal/agent/ - Agent loop and tool execution
|
||||
internal/ui/ - Bubble Tea TUI components
|
||||
internal/extensions/ - Yaegi extension system
|
||||
internal/core/ - Built-in tools
|
||||
internal/tools/ - MCP tool integration
|
||||
internal/config/ - Configuration management
|
||||
internal/session/ - Session persistence
|
||||
internal/models/ - Provider and model management
|
||||
examples/extensions/ - Example extension files
|
||||
```
|
||||
|
||||
## Supported Providers
|
||||
|
||||
- **Anthropic** - Claude models (native, prompt caching, OAuth)
|
||||
- **OpenAI** - GPT models
|
||||
- **Google** - Gemini models
|
||||
- **Ollama** - Local models
|
||||
- **Azure OpenAI** - Azure-hosted OpenAI
|
||||
- **AWS Bedrock** - Bedrock models
|
||||
- **Google Vertex** - Claude on Vertex AI
|
||||
- **OpenRouter** - Multi-provider router
|
||||
- **Vercel AI** - Vercel AI SDK models
|
||||
- **Auto-routed** - Any provider from models.dev database
|
||||
|
||||
### Model String Format
|
||||
|
||||
```bash
|
||||
provider/model # Standard format
|
||||
anthropic/claude-sonnet-4-5-20250929
|
||||
openai/gpt-4o
|
||||
ollama/llama3
|
||||
google/gemini-2.0-flash-exp
|
||||
```
|
||||
|
||||
### Model Aliases
|
||||
|
||||
```bash
|
||||
claude-opus-latest → claude-opus-4-20250514
|
||||
claude-sonnet-latest → claude-sonnet-4-5-20250929
|
||||
claude-3-5-haiku-latest → claude-3-5-haiku-20241022
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
## License
|
||||
|
||||
[Apache 2.0](LICENSE)
|
||||
|
||||
## Community
|
||||
|
||||
- [Discord](https://discord.gg/RqSS2NQVsY)
|
||||
- [GitHub Issues](https://github.com/mark3labs/kit/issues)
|
||||
- [Documentation](https://github.com/mark3labs/kit/wiki)
|
||||
|
||||
+415
-48
@@ -2,6 +2,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"github.com/mark3labs/kit/internal/app"
|
||||
"github.com/mark3labs/kit/internal/config"
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
"github.com/mark3labs/kit/internal/models"
|
||||
"github.com/mark3labs/kit/internal/ui"
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -27,8 +29,9 @@ var (
|
||||
providerURL string
|
||||
providerAPIKey string
|
||||
debugMode bool
|
||||
promptFlag string
|
||||
positionalPrompt string // set by processPositionalArgs from CLI positional args
|
||||
quietFlag bool
|
||||
jsonFlag bool
|
||||
noExitFlag bool
|
||||
maxSteps int
|
||||
streamFlag bool // Enable streaming output
|
||||
@@ -98,10 +101,16 @@ func (a *kitUIAdapter) GetExtensionToolCount() int {
|
||||
// an interface to interact with various AI models through a unified interface
|
||||
// with support for MCP servers and tool integration.
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "kit",
|
||||
Use: "kit [@file...] [prompt]",
|
||||
Short: "Chat with AI models through a unified interface",
|
||||
Long: `KIT (Knowledge Inference Tool) — A lightweight AI agent for coding`,
|
||||
Args: cobra.ArbitraryArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Parse positional args: @-prefixed args are file attachments,
|
||||
// remaining args form the prompt (like Pi: kit @code.ts "Review this").
|
||||
if len(args) > 0 {
|
||||
processPositionalArgs(args)
|
||||
}
|
||||
return runKit(context.Background())
|
||||
},
|
||||
}
|
||||
@@ -199,12 +208,13 @@ func init() {
|
||||
"model to use (format: provider/model)")
|
||||
rootCmd.PersistentFlags().
|
||||
BoolVar(&debugMode, "debug", false, "enable debug logging")
|
||||
|
||||
rootCmd.PersistentFlags().
|
||||
StringVarP(&promptFlag, "prompt", "p", "", "run in non-interactive mode with the given prompt")
|
||||
BoolVar(&quietFlag, "quiet", false, "suppress all output (non-interactive mode only)")
|
||||
rootCmd.PersistentFlags().
|
||||
BoolVar(&quietFlag, "quiet", false, "suppress all output (only works with --prompt)")
|
||||
BoolVar(&jsonFlag, "json", false, "output response as JSON (non-interactive mode only)")
|
||||
rootCmd.PersistentFlags().
|
||||
BoolVar(&noExitFlag, "no-exit", false, "prevent non-interactive mode from exiting, show input prompt instead")
|
||||
BoolVar(&noExitFlag, "no-exit", false, "enter interactive mode after non-interactive prompt completes")
|
||||
rootCmd.PersistentFlags().
|
||||
IntVar(&maxSteps, "max-steps", 0, "maximum number of agent steps (0 for unlimited)")
|
||||
rootCmd.PersistentFlags().
|
||||
@@ -247,7 +257,6 @@ func init() {
|
||||
_ = viper.BindPFlag("system-prompt", rootCmd.PersistentFlags().Lookup("system-prompt"))
|
||||
_ = viper.BindPFlag("model", rootCmd.PersistentFlags().Lookup("model"))
|
||||
_ = viper.BindPFlag("debug", rootCmd.PersistentFlags().Lookup("debug"))
|
||||
_ = viper.BindPFlag("prompt", rootCmd.PersistentFlags().Lookup("prompt"))
|
||||
_ = viper.BindPFlag("max-steps", rootCmd.PersistentFlags().Lookup("max-steps"))
|
||||
_ = viper.BindPFlag("stream", rootCmd.PersistentFlags().Lookup("stream"))
|
||||
_ = viper.BindPFlag("compact", rootCmd.PersistentFlags().Lookup("compact"))
|
||||
@@ -272,6 +281,62 @@ func init() {
|
||||
rootCmd.AddCommand(authCmd)
|
||||
}
|
||||
|
||||
// processPositionalArgs separates positional CLI arguments into @file
|
||||
// attachments and prompt text. File content is read and prepended to
|
||||
// positionalPrompt so the agent receives it. Positional args are the primary
|
||||
// way to run non-interactive mode:
|
||||
//
|
||||
// kit "Explain this codebase"
|
||||
// kit @code.ts @test.ts "Review these files"
|
||||
func processPositionalArgs(args []string) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
cwd = "."
|
||||
}
|
||||
|
||||
var fileTokens []string
|
||||
var promptParts []string
|
||||
|
||||
for _, arg := range args {
|
||||
if strings.HasPrefix(arg, "@") && len(arg) > 1 {
|
||||
fileTokens = append(fileTokens, arg)
|
||||
} else {
|
||||
promptParts = append(promptParts, arg)
|
||||
}
|
||||
}
|
||||
|
||||
// Build file content prefix from @file arguments.
|
||||
var fileContent strings.Builder
|
||||
for _, token := range fileTokens {
|
||||
expanded := ui.ProcessFileAttachments(token, cwd)
|
||||
if expanded != token {
|
||||
// File was resolved — add it.
|
||||
fileContent.WriteString(expanded)
|
||||
fileContent.WriteString("\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Combine: positional prompt text is appended to any existing --prompt
|
||||
// value (for backward compat with subprocess invocations).
|
||||
if len(promptParts) > 0 {
|
||||
extra := strings.Join(promptParts, " ")
|
||||
if positionalPrompt != "" {
|
||||
positionalPrompt = positionalPrompt + " " + extra
|
||||
} else {
|
||||
positionalPrompt = extra
|
||||
}
|
||||
}
|
||||
|
||||
// Prepend file content to the prompt.
|
||||
if fileContent.Len() > 0 {
|
||||
if positionalPrompt == "" {
|
||||
positionalPrompt = strings.TrimSpace(fileContent.String())
|
||||
} else {
|
||||
positionalPrompt = strings.TrimSpace(fileContent.String()) + "\n\n" + positionalPrompt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runKit(ctx context.Context) error {
|
||||
return runNormalMode(ctx)
|
||||
}
|
||||
@@ -291,13 +356,19 @@ func extensionCommandsForUI(k *kit.Kit) []ui.ExtensionCommand {
|
||||
if len(name) > 0 && name[0] != '/' {
|
||||
name = "/" + name
|
||||
}
|
||||
cmds = append(cmds, ui.ExtensionCommand{
|
||||
ec := ui.ExtensionCommand{
|
||||
Name: name,
|
||||
Description: d.Description,
|
||||
Execute: func(args string) (string, error) {
|
||||
return d.Execute(args, k.GetExtensionContext())
|
||||
},
|
||||
})
|
||||
}
|
||||
if d.Complete != nil {
|
||||
ec.Complete = func(prefix string) []string {
|
||||
return d.Complete(prefix, k.GetExtensionContext())
|
||||
}
|
||||
}
|
||||
cmds = append(cmds, ec)
|
||||
}
|
||||
return cmds
|
||||
}
|
||||
@@ -411,6 +482,27 @@ func editorInterceptorProviderForUI(k *kit.Kit) func() *ui.EditorInterceptor {
|
||||
}
|
||||
}
|
||||
|
||||
// uiVisibilityProviderForUI returns a function that converts extension UI
|
||||
// visibility overrides to a *ui.UIVisibility for the TUI. Returns nil if
|
||||
// extensions are disabled — the UI treats nil as "show everything".
|
||||
func uiVisibilityProviderForUI(k *kit.Kit) func() *ui.UIVisibility {
|
||||
if !k.HasExtensions() {
|
||||
return nil
|
||||
}
|
||||
return func() *ui.UIVisibility {
|
||||
v := k.GetExtensionUIVisibility()
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
return &ui.UIVisibility{
|
||||
HideStartupMessage: v.HideStartupMessage,
|
||||
HideStatusBar: v.HideStatusBar,
|
||||
HideSeparator: v.HideSeparator,
|
||||
HideInputHint: v.HideInputHint,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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".
|
||||
@@ -432,13 +524,74 @@ func footerProviderForUI(k *kit.Kit) func() *ui.WidgetData {
|
||||
}
|
||||
}
|
||||
|
||||
// statusBarProviderForUI returns a function that fetches extension status bar
|
||||
// entries and converts them to ui.StatusBarEntryData for the TUI. Returns nil
|
||||
// if extensions are disabled, which is safe — the TUI treats a nil
|
||||
// GetStatusBarEntries as "no extension entries".
|
||||
func statusBarProviderForUI(k *kit.Kit) func() []ui.StatusBarEntryData {
|
||||
if !k.HasExtensions() {
|
||||
return nil
|
||||
}
|
||||
return func() []ui.StatusBarEntryData {
|
||||
entries := k.GetExtensionStatusEntries()
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]ui.StatusBarEntryData, len(entries))
|
||||
for i, e := range entries {
|
||||
result[i] = ui.StatusBarEntryData{
|
||||
Key: e.Key,
|
||||
Text: e.Text,
|
||||
Priority: e.Priority,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// beforeForkProviderForUI returns a callback that emits a BeforeFork event
|
||||
// and returns (cancelled, reason). Returns nil if extensions are disabled —
|
||||
// the UI treats nil as "no hook".
|
||||
func beforeForkProviderForUI(k *kit.Kit) func(string, bool, string) (bool, string) {
|
||||
if !k.HasExtensions() {
|
||||
return nil
|
||||
}
|
||||
return k.EmitBeforeFork
|
||||
}
|
||||
|
||||
// beforeSessionSwitchProviderForUI returns a callback that emits a
|
||||
// BeforeSessionSwitch event and returns (cancelled, reason). Returns nil
|
||||
// if extensions are disabled — the UI treats nil as "no hook".
|
||||
func beforeSessionSwitchProviderForUI(k *kit.Kit) func(string) (bool, string) {
|
||||
if !k.HasExtensions() {
|
||||
return nil
|
||||
}
|
||||
return k.EmitBeforeSessionSwitch
|
||||
}
|
||||
|
||||
// globalShortcutsProviderForUI returns a callback that queries the extension
|
||||
// runner for registered keyboard shortcuts. Returns nil if extensions are
|
||||
// disabled — the UI treats nil as "no shortcuts".
|
||||
func globalShortcutsProviderForUI(k *kit.Kit) func() map[string]func() {
|
||||
if !k.HasExtensions() {
|
||||
return nil
|
||||
}
|
||||
return k.GetExtensionShortcuts
|
||||
}
|
||||
|
||||
func runNormalMode(ctx context.Context) error {
|
||||
// Validate flag combinations
|
||||
if quietFlag && promptFlag == "" {
|
||||
return fmt.Errorf("--quiet flag can only be used with --prompt/-p")
|
||||
if quietFlag && positionalPrompt == "" {
|
||||
return fmt.Errorf("--quiet requires a prompt (e.g. kit \"your question\" --quiet)")
|
||||
}
|
||||
if noExitFlag && promptFlag == "" {
|
||||
return fmt.Errorf("--no-exit flag can only be used with --prompt/-p")
|
||||
if jsonFlag && positionalPrompt == "" {
|
||||
return fmt.Errorf("--json requires a prompt (e.g. kit \"your question\" --json)")
|
||||
}
|
||||
if jsonFlag && noExitFlag {
|
||||
return fmt.Errorf("--json and --no-exit flags cannot be used together")
|
||||
}
|
||||
if noExitFlag && positionalPrompt == "" {
|
||||
return fmt.Errorf("--no-exit requires a prompt (e.g. kit \"your question\" --no-exit)")
|
||||
}
|
||||
|
||||
// Set up logging
|
||||
@@ -505,7 +658,7 @@ func runNormalMode(ctx context.Context) error {
|
||||
|
||||
// Create CLI for non-interactive mode only.
|
||||
var cli *ui.CLI
|
||||
if promptFlag != "" {
|
||||
if positionalPrompt != "" {
|
||||
cli, err = SetupCLIForNonInteractive(kitInstance)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to setup CLI: %v", err)
|
||||
@@ -550,14 +703,16 @@ func runNormalMode(ctx context.Context) error {
|
||||
if kitInstance.HasExtensions() {
|
||||
cwd, _ := os.Getwd()
|
||||
kitInstance.SetExtensionContext(extensions.Context{
|
||||
CWD: cwd,
|
||||
Model: modelName,
|
||||
Interactive: promptFlag == "",
|
||||
Print: func(text string) { appInstance.PrintFromExtension("", text) },
|
||||
PrintInfo: func(text string) { appInstance.PrintFromExtension("info", text) },
|
||||
PrintError: func(text string) { appInstance.PrintFromExtension("error", text) },
|
||||
PrintBlock: appInstance.PrintBlockFromExtension,
|
||||
SendMessage: func(text string) { appInstance.Run(text) },
|
||||
CWD: cwd,
|
||||
Model: modelName,
|
||||
Interactive: positionalPrompt == "",
|
||||
Print: func(text string) { appInstance.PrintFromExtension("", text) },
|
||||
PrintInfo: func(text string) { appInstance.PrintFromExtension("info", text) },
|
||||
PrintError: func(text string) { appInstance.PrintFromExtension("error", text) },
|
||||
PrintBlock: appInstance.PrintBlockFromExtension,
|
||||
SendMessage: func(text string) { appInstance.Run(text) },
|
||||
CancelAndSend: func(text string) { appInstance.Steer(text) },
|
||||
Exit: func() { appInstance.QuitFromExtension() },
|
||||
SetWidget: func(config extensions.WidgetConfig) {
|
||||
kitInstance.SetExtensionWidget(config)
|
||||
appInstance.NotifyWidgetUpdate()
|
||||
@@ -629,6 +784,19 @@ func runNormalMode(ctx context.Context) error {
|
||||
}
|
||||
return extensions.PromptInputResult{Value: resp.Value}
|
||||
},
|
||||
SetUIVisibility: func(v extensions.UIVisibility) {
|
||||
kitInstance.SetExtensionUIVisibility(v)
|
||||
appInstance.NotifyWidgetUpdate()
|
||||
},
|
||||
GetContextStats: func() extensions.ContextStats {
|
||||
s := kitInstance.GetContextStats()
|
||||
return extensions.ContextStats{
|
||||
EstimatedTokens: s.EstimatedTokens,
|
||||
ContextLimit: s.ContextLimit,
|
||||
UsagePercent: s.UsagePercent,
|
||||
MessageCount: s.MessageCount,
|
||||
}
|
||||
},
|
||||
SetEditor: func(config extensions.EditorConfig) {
|
||||
kitInstance.SetExtensionEditor(config)
|
||||
// Use a goroutine for NotifyWidgetUpdate because this may be
|
||||
@@ -641,6 +809,95 @@ func runNormalMode(ctx context.Context) error {
|
||||
kitInstance.ResetExtensionEditor()
|
||||
go appInstance.NotifyWidgetUpdate()
|
||||
},
|
||||
GetMessages: func() []extensions.SessionMessage {
|
||||
return kitInstance.GetSessionMessages()
|
||||
},
|
||||
GetSessionPath: func() string {
|
||||
return kitInstance.GetSessionFilePath()
|
||||
},
|
||||
AppendEntry: func(entryType string, data string) (string, error) {
|
||||
return kitInstance.AppendExtensionEntry(entryType, data)
|
||||
},
|
||||
GetEntries: func(entryType string) []extensions.ExtensionEntry {
|
||||
return kitInstance.GetExtensionEntries(entryType)
|
||||
},
|
||||
SetEditorText: func(text string) {
|
||||
appInstance.SetEditorTextFromExtension(text)
|
||||
},
|
||||
SetStatus: func(key string, text string, priority int) {
|
||||
kitInstance.SetExtensionStatus(extensions.StatusBarEntry{
|
||||
Key: key,
|
||||
Text: text,
|
||||
Priority: priority,
|
||||
})
|
||||
appInstance.NotifyWidgetUpdate()
|
||||
},
|
||||
RemoveStatus: func(key string) {
|
||||
kitInstance.RemoveExtensionStatus(key)
|
||||
appInstance.NotifyWidgetUpdate()
|
||||
},
|
||||
GetOption: func(name string) string {
|
||||
return kitInstance.GetExtensionOption(name)
|
||||
},
|
||||
SetOption: func(name string, value string) {
|
||||
kitInstance.SetExtensionOption(name, value)
|
||||
},
|
||||
SetModel: func(modelString string) error {
|
||||
// Capture previous model for the ModelChange event.
|
||||
previousModel := kitInstance.GetExtensionContext().Model
|
||||
err := kitInstance.SetModel(context.Background(), modelString)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Notify TUI so it updates model in status bar.
|
||||
p, m, _ := models.ParseModelString(modelString)
|
||||
appInstance.NotifyModelChanged(p, m)
|
||||
// Update the context's Model field so handlers see it.
|
||||
kitInstance.UpdateExtensionContextModel(modelString)
|
||||
// Fire OnModelChange event to extensions.
|
||||
kitInstance.EmitModelChange(modelString, previousModel, "extension")
|
||||
return nil
|
||||
},
|
||||
GetAvailableModels: func() []extensions.ModelInfoEntry {
|
||||
return kitInstance.GetAvailableModels()
|
||||
},
|
||||
EmitCustomEvent: func(name string, data string) {
|
||||
kitInstance.EmitExtensionCustomEvent(name, data)
|
||||
},
|
||||
Complete: func(req extensions.CompleteRequest) (extensions.CompleteResponse, error) {
|
||||
return kitInstance.ExecuteCompletion(context.Background(), req)
|
||||
},
|
||||
SuspendTUI: func(callback func()) error {
|
||||
return appInstance.SuspendTUI(callback)
|
||||
},
|
||||
RenderMessage: func(rendererName, content string) {
|
||||
renderer := kitInstance.GetExtensionMessageRenderer(rendererName)
|
||||
if renderer == nil || renderer.Render == nil {
|
||||
appInstance.PrintFromExtension("", content)
|
||||
return
|
||||
}
|
||||
w, _, _ := term.GetSize(int(os.Stdout.Fd()))
|
||||
if w == 0 {
|
||||
w = 80
|
||||
}
|
||||
rendered := renderer.Render(content, w)
|
||||
appInstance.PrintFromExtension("", rendered)
|
||||
},
|
||||
ReloadExtensions: func() error {
|
||||
err := kitInstance.ReloadExtensions()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Notify TUI that widgets/status/commands may have changed.
|
||||
appInstance.NotifyWidgetUpdate()
|
||||
return nil
|
||||
},
|
||||
GetAllTools: func() []extensions.ToolInfo {
|
||||
return kitInstance.GetExtensionToolInfos()
|
||||
},
|
||||
SetActiveTools: func(names []string) {
|
||||
kitInstance.SetExtensionActiveTools(names)
|
||||
},
|
||||
ShowOverlay: func(config extensions.OverlayConfig) extensions.OverlayResult {
|
||||
ch := make(chan app.OverlayResponse, 1)
|
||||
appInstance.SendOverlayRequest(app.OverlayRequestEvent{
|
||||
@@ -696,18 +953,26 @@ func runNormalMode(ctx context.Context) error {
|
||||
getFooter := footerProviderForUI(kitInstance)
|
||||
getToolRenderer := toolRendererProviderForUI(kitInstance)
|
||||
getEditorInterceptor := editorInterceptorProviderForUI(kitInstance)
|
||||
getUIVisibility := uiVisibilityProviderForUI(kitInstance)
|
||||
getStatusBarEntries := statusBarProviderForUI(kitInstance)
|
||||
emitBeforeFork := beforeForkProviderForUI(kitInstance)
|
||||
emitBeforeSessionSwitch := beforeSessionSwitchProviderForUI(kitInstance)
|
||||
getGlobalShortcuts := globalShortcutsProviderForUI(kitInstance)
|
||||
getExtensionCommands := func() []ui.ExtensionCommand {
|
||||
return extensionCommandsForUI(kitInstance)
|
||||
}
|
||||
|
||||
// Check if running in non-interactive mode
|
||||
if promptFlag != "" {
|
||||
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)
|
||||
if positionalPrompt != "" {
|
||||
return runNonInteractiveModeApp(ctx, appInstance, cli, positionalPrompt, quietFlag, jsonFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands)
|
||||
}
|
||||
|
||||
// Quiet mode is not allowed in interactive mode
|
||||
if quietFlag {
|
||||
return fmt.Errorf("--quiet flag can only be used with --prompt/-p")
|
||||
return fmt.Errorf("--quiet requires a prompt")
|
||||
}
|
||||
|
||||
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor)
|
||||
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands)
|
||||
}
|
||||
|
||||
// runNonInteractiveModeApp executes a single prompt via the app layer and exits,
|
||||
@@ -720,8 +985,25 @@ 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, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor) error {
|
||||
if quiet {
|
||||
func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui.CLI, prompt string, quiet, jsonOutput, 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, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []ui.ExtensionCommand) error {
|
||||
// Expand @file references in the prompt before sending to the agent.
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
prompt = ui.ProcessFileAttachments(prompt, cwd)
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
// JSON mode: no intermediate display, structured JSON output.
|
||||
result, err := appInstance.RunOnceResult(ctx, prompt)
|
||||
if err != nil {
|
||||
writeJSONError(err)
|
||||
return err
|
||||
}
|
||||
data, err := buildJSONOutput(result, modelName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal JSON output: %w", err)
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
} else if quiet {
|
||||
// Quiet mode: no intermediate display, just print final response.
|
||||
if err := appInstance.RunOnce(ctx, prompt); err != nil {
|
||||
return err
|
||||
@@ -746,12 +1028,89 @@ 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, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor)
|
||||
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON output helpers (--json mode)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// buildJSONOutput converts a TurnResult into a structured JSON byte slice
|
||||
// suitable for machine consumption.
|
||||
func buildJSONOutput(result *kit.TurnResult, model string) ([]byte, error) {
|
||||
type jsonPart struct {
|
||||
Type string `json:"type"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
type jsonMessage struct {
|
||||
Role string `json:"role"`
|
||||
Parts []jsonPart `json:"parts"`
|
||||
}
|
||||
type jsonUsage struct {
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
CacheReadTokens int64 `json:"cache_read_tokens"`
|
||||
CacheCreationTokens int64 `json:"cache_creation_tokens"`
|
||||
}
|
||||
type jsonEnvelope struct {
|
||||
Response string `json:"response"`
|
||||
Model string `json:"model"`
|
||||
Usage *jsonUsage `json:"usage,omitempty"`
|
||||
Messages []jsonMessage `json:"messages"`
|
||||
}
|
||||
|
||||
out := jsonEnvelope{
|
||||
Response: result.Response,
|
||||
Model: model,
|
||||
}
|
||||
|
||||
if result.TotalUsage != nil {
|
||||
out.Usage = &jsonUsage{
|
||||
InputTokens: result.TotalUsage.InputTokens,
|
||||
OutputTokens: result.TotalUsage.OutputTokens,
|
||||
TotalTokens: result.TotalUsage.TotalTokens,
|
||||
CacheReadTokens: result.TotalUsage.CacheReadTokens,
|
||||
CacheCreationTokens: result.TotalUsage.CacheCreationTokens,
|
||||
}
|
||||
}
|
||||
|
||||
for _, fmsg := range result.Messages {
|
||||
converted := kit.ConvertFromFantasyMessage(fmsg)
|
||||
m := jsonMessage{Role: string(converted.Role)}
|
||||
for _, p := range converted.Parts {
|
||||
switch c := p.(type) {
|
||||
case kit.TextContent:
|
||||
m.Parts = append(m.Parts, jsonPart{Type: "text", Data: c})
|
||||
case kit.ToolCall:
|
||||
m.Parts = append(m.Parts, jsonPart{Type: "tool_call", Data: c})
|
||||
case kit.ToolResult:
|
||||
m.Parts = append(m.Parts, jsonPart{Type: "tool_result", Data: c})
|
||||
case kit.ReasoningContent:
|
||||
m.Parts = append(m.Parts, jsonPart{Type: "reasoning", Data: c})
|
||||
case kit.Finish:
|
||||
m.Parts = append(m.Parts, jsonPart{Type: "finish", Data: c})
|
||||
}
|
||||
}
|
||||
out.Messages = append(out.Messages, m)
|
||||
}
|
||||
|
||||
return json.MarshalIndent(out, "", " ")
|
||||
}
|
||||
|
||||
// writeJSONError writes a JSON-formatted error object to stdout so that
|
||||
// callers using --json always receive parseable output.
|
||||
func writeJSONError(err error) {
|
||||
type jsonError struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
data, _ := json.MarshalIndent(jsonError{Error: err.Error()}, "", " ")
|
||||
fmt.Fprintln(os.Stderr, string(data))
|
||||
}
|
||||
|
||||
// runInteractiveModeBubbleTea starts the new unified Bubble Tea interactive TUI.
|
||||
//
|
||||
// It:
|
||||
@@ -763,7 +1122,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, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor) 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, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []ui.ExtensionCommand) error {
|
||||
// Determine terminal size; fall back gracefully.
|
||||
termWidth, termHeight, err := term.GetSize(int(os.Stdout.Fd()))
|
||||
if err != nil || termWidth == 0 {
|
||||
@@ -771,26 +1130,34 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN
|
||||
termHeight = 24
|
||||
}
|
||||
|
||||
cwd, _ := os.Getwd()
|
||||
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,
|
||||
GetWidgets: getWidgets,
|
||||
GetHeader: getHeader,
|
||||
GetFooter: getFooter,
|
||||
GetToolRenderer: getToolRenderer,
|
||||
GetEditorInterceptor: getEditorInterceptor,
|
||||
CompactMode: viper.GetBool("compact"),
|
||||
ModelName: modelName,
|
||||
ProviderName: providerName,
|
||||
LoadingMessage: loadingMessage,
|
||||
Cwd: cwd,
|
||||
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,
|
||||
GetUIVisibility: getUIVisibility,
|
||||
GetStatusBarEntries: getStatusBarEntries,
|
||||
EmitBeforeFork: emitBeforeFork,
|
||||
EmitBeforeSessionSwitch: emitBeforeSessionSwitch,
|
||||
GetGlobalShortcuts: getGlobalShortcuts,
|
||||
GetExtensionCommands: getExtensionCommands,
|
||||
})
|
||||
|
||||
// Print startup info to stdout before Bubble Tea takes over the screen.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init automatically commits staged changes when the session shuts down,
|
||||
// using the last assistant message as the commit message.
|
||||
//
|
||||
// Only commits if:
|
||||
// - There are staged changes (git diff --cached is non-empty)
|
||||
// - There is at least one assistant message to use as commit message
|
||||
//
|
||||
// The commit message is derived from the last assistant response, trimmed
|
||||
// to the first paragraph (max 72 chars for the subject line).
|
||||
//
|
||||
// Usage: kit -e examples/extensions/auto-commit.go
|
||||
func Init(api ext.API) {
|
||||
api.OnSessionShutdown(func(_ ext.SessionShutdownEvent, ctx ext.Context) {
|
||||
// Check for staged changes.
|
||||
diff, err := exec.Command("git", "diff", "--cached", "--quiet").CombinedOutput()
|
||||
_ = diff
|
||||
if err == nil {
|
||||
return // exit code 0 means no staged changes
|
||||
}
|
||||
|
||||
// Get the last assistant message.
|
||||
msgs := ctx.GetMessages()
|
||||
var lastAssistant string
|
||||
for i := len(msgs) - 1; i >= 0; i-- {
|
||||
if msgs[i].Role == "assistant" {
|
||||
lastAssistant = msgs[i].Content
|
||||
break
|
||||
}
|
||||
}
|
||||
if lastAssistant == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Build commit message: first paragraph, subject line max 72 chars.
|
||||
subject := firstParagraph(lastAssistant)
|
||||
if len(subject) > 72 {
|
||||
subject = subject[:69] + "..."
|
||||
}
|
||||
|
||||
// Commit.
|
||||
cmd := exec.Command("git", "commit", "-m", subject)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
ctx.PrintError("Auto-commit failed: " + string(output))
|
||||
return
|
||||
}
|
||||
ctx.PrintInfo("Auto-committed: " + subject)
|
||||
})
|
||||
}
|
||||
|
||||
// firstParagraph returns the first non-empty paragraph of text.
|
||||
func firstParagraph(text string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
// Split on double newlines (paragraph breaks).
|
||||
parts := strings.SplitN(text, "\n\n", 2)
|
||||
line := strings.TrimSpace(parts[0])
|
||||
// Collapse to single line.
|
||||
line = strings.ReplaceAll(line, "\n", " ")
|
||||
return line
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init adds bookmark commands for marking and recalling important points in
|
||||
// a conversation. Bookmarks are persisted in the session tree and survive
|
||||
// restarts.
|
||||
//
|
||||
// Commands:
|
||||
//
|
||||
// /bookmark <label> — bookmark the current point with a label
|
||||
// /bookmarks — list all bookmarks in this session
|
||||
//
|
||||
// Usage: kit -e examples/extensions/bookmark.go
|
||||
func Init(api ext.API) {
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "bookmark",
|
||||
Description: "Bookmark the current point in the conversation",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
label := strings.TrimSpace(args)
|
||||
if label == "" {
|
||||
label = time.Now().Format("15:04:05")
|
||||
}
|
||||
|
||||
// Count existing messages to record position.
|
||||
msgs := ctx.GetMessages()
|
||||
|
||||
data, _ := json.Marshal(map[string]any{
|
||||
"label": label,
|
||||
"messages": len(msgs),
|
||||
})
|
||||
|
||||
_, err := ctx.AppendEntry("bookmark", string(data))
|
||||
if err != nil {
|
||||
ctx.PrintError("Failed to save bookmark: " + err.Error())
|
||||
return "", nil
|
||||
}
|
||||
|
||||
ctx.PrintInfo(fmt.Sprintf("Bookmarked: %s (at message %d)", label, len(msgs)))
|
||||
return "", nil
|
||||
},
|
||||
Complete: func(prefix string, ctx ext.Context) []string {
|
||||
// Suggest existing bookmark labels so the user can quickly
|
||||
// re-bookmark at the same label.
|
||||
entries := ctx.GetEntries("bookmark")
|
||||
var labels []string
|
||||
seen := map[string]bool{}
|
||||
for _, e := range entries {
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(e.Data), &data); err != nil {
|
||||
continue
|
||||
}
|
||||
label, _ := data["label"].(string)
|
||||
if label == "" || seen[label] {
|
||||
continue
|
||||
}
|
||||
if prefix == "" || strings.HasPrefix(strings.ToLower(label), strings.ToLower(prefix)) {
|
||||
labels = append(labels, label)
|
||||
seen[label] = true
|
||||
}
|
||||
}
|
||||
return labels
|
||||
},
|
||||
})
|
||||
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "bookmarks",
|
||||
Description: "List all bookmarks in this session",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
entries := ctx.GetEntries("bookmark")
|
||||
if len(entries) == 0 {
|
||||
ctx.PrintInfo("No bookmarks yet. Use /bookmark <label> to create one.")
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for i, e := range entries {
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(e.Data), &data); err != nil {
|
||||
continue
|
||||
}
|
||||
label, _ := data["label"].(string)
|
||||
msgCount, _ := data["messages"].(float64)
|
||||
lines = append(lines, fmt.Sprintf(" %d. %s (msg %d, %s)",
|
||||
i+1, label, int(msgCount), e.Timestamp[:19]))
|
||||
}
|
||||
|
||||
ctx.PrintInfo("Bookmarks:\n" + strings.Join(lines, "\n"))
|
||||
return "", nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//go:build ignore
|
||||
|
||||
// branded-output.go — Custom Message Rendering example extension for Kit.
|
||||
//
|
||||
// Demonstrates api.RegisterMessageRenderer() and ctx.RenderMessage() which
|
||||
// let extensions define reusable visual styles for output. Each renderer has
|
||||
// a name and a render function that receives content and terminal width.
|
||||
//
|
||||
// This extension registers three renderers:
|
||||
// "success" — green-bordered block for success messages
|
||||
// "warning" — yellow-bordered block for warnings
|
||||
// "metric" — compact key=value display for metrics
|
||||
//
|
||||
// Commands:
|
||||
// /demo-render — shows all three renderers in action
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
ext "kit/ext"
|
||||
)
|
||||
|
||||
func Init(api ext.API) {
|
||||
// Register a "success" renderer — green-accented block.
|
||||
api.RegisterMessageRenderer(ext.MessageRendererConfig{
|
||||
Name: "success",
|
||||
Render: func(content string, width int) string {
|
||||
maxW := width - 6
|
||||
if maxW < 20 {
|
||||
maxW = 20
|
||||
}
|
||||
bar := strings.Repeat("─", maxW)
|
||||
return fmt.Sprintf(" \033[32m┌%s┐\033[0m\n \033[32m│\033[0m \033[1;32m%s\033[0m\n \033[32m└%s┘\033[0m",
|
||||
bar, content, bar)
|
||||
},
|
||||
})
|
||||
|
||||
// Register a "warning" renderer — yellow-accented block.
|
||||
api.RegisterMessageRenderer(ext.MessageRendererConfig{
|
||||
Name: "warning",
|
||||
Render: func(content string, width int) string {
|
||||
maxW := width - 6
|
||||
if maxW < 20 {
|
||||
maxW = 20
|
||||
}
|
||||
bar := strings.Repeat("─", maxW)
|
||||
return fmt.Sprintf(" \033[33m┌%s┐\033[0m\n \033[33m│\033[0m \033[1;33m%s\033[0m\n \033[33m└%s┘\033[0m",
|
||||
bar, content, bar)
|
||||
},
|
||||
})
|
||||
|
||||
// Register a "metric" renderer — compact label: value format.
|
||||
api.RegisterMessageRenderer(ext.MessageRendererConfig{
|
||||
Name: "metric",
|
||||
Render: func(content string, width int) string {
|
||||
return fmt.Sprintf(" \033[36m▸\033[0m %s", content)
|
||||
},
|
||||
})
|
||||
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "demo-render",
|
||||
Description: "Demonstrate custom message renderers",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
ctx.RenderMessage("success", "All 42 tests passed in 3.2s")
|
||||
ctx.RenderMessage("warning", "3 deprecation warnings detected")
|
||||
ctx.RenderMessage("metric", fmt.Sprintf("build_time=%.1fs tests=42 coverage=87%% timestamp=%s",
|
||||
3.2, time.Now().Format("15:04:05")))
|
||||
|
||||
return "Rendered three message styles.", nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init registers a before-compact hook that notifies the user when
|
||||
// compaction is about to happen and optionally blocks automatic compaction.
|
||||
//
|
||||
// When automatic compaction is triggered (via --auto-compact), the extension
|
||||
// asks for user confirmation. Manual /compact commands are always allowed.
|
||||
//
|
||||
// This demonstrates the OnBeforeCompact event which allows extensions to
|
||||
// inspect context usage stats and gate the compaction process.
|
||||
//
|
||||
// Usage: kit -e examples/extensions/compact-notify.go --auto-compact
|
||||
func Init(api ext.API) {
|
||||
api.OnBeforeCompact(func(e ext.BeforeCompactEvent, ctx ext.Context) *ext.BeforeCompactResult {
|
||||
pct := int(e.UsagePercent * 100)
|
||||
summary := fmt.Sprintf("Context: %dk/%dk tokens (%d%%), %d messages",
|
||||
e.EstimatedTokens/1000, e.ContextLimit/1000, pct, e.MessageCount)
|
||||
|
||||
if e.IsAutomatic {
|
||||
// Auto-compaction: ask user first.
|
||||
ctx.PrintBlock(ext.PrintBlockOpts{
|
||||
Text: "Auto-compaction triggered.\n" + summary,
|
||||
BorderColor: "#f9e2af",
|
||||
Subtitle: "compact-notify",
|
||||
})
|
||||
|
||||
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
|
||||
Message: "Allow automatic compaction?",
|
||||
DefaultValue: true,
|
||||
})
|
||||
if result.Cancelled || !result.Value {
|
||||
return &ext.BeforeCompactResult{
|
||||
Cancel: true,
|
||||
Reason: "Auto-compaction skipped by user.",
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Manual /compact: just notify.
|
||||
ctx.PrintBlock(ext.PrintBlockOpts{
|
||||
Text: "Compacting conversation...\n" + summary,
|
||||
BorderColor: "#89b4fa",
|
||||
Subtitle: "compact-notify",
|
||||
})
|
||||
}
|
||||
|
||||
return nil // allow compaction
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init registers before-hooks for destructive session operations:
|
||||
// - Forks: Asks for confirmation before branching to a different tree node.
|
||||
// - New sessions: Checks for uncommitted git changes and warns before
|
||||
// starting a new branch if the working tree is dirty.
|
||||
//
|
||||
// This demonstrates the OnBeforeFork and OnBeforeSessionSwitch events
|
||||
// which allow extensions to cancel session lifecycle operations.
|
||||
//
|
||||
// Usage: kit -e examples/extensions/confirm-destructive.go --continue
|
||||
func Init(api ext.API) {
|
||||
// Gate /new command: warn if there are uncommitted git changes.
|
||||
api.OnBeforeSessionSwitch(func(e ext.BeforeSessionSwitchEvent, ctx ext.Context) *ext.BeforeSessionSwitchResult {
|
||||
if !isGitDirty() {
|
||||
return nil // clean repo, allow switch
|
||||
}
|
||||
|
||||
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
|
||||
Message: "Working tree has uncommitted changes. Start new session anyway?",
|
||||
})
|
||||
if result.Cancelled || !result.Value {
|
||||
return &ext.BeforeSessionSwitchResult{
|
||||
Cancel: true,
|
||||
Reason: "Session switch cancelled: uncommitted git changes.",
|
||||
}
|
||||
}
|
||||
return nil // user approved
|
||||
})
|
||||
|
||||
// Gate fork: ask for confirmation before branching.
|
||||
api.OnBeforeFork(func(e ext.BeforeForkEvent, ctx ext.Context) *ext.BeforeForkResult {
|
||||
msg := "Branch to this point in the conversation?"
|
||||
if e.IsUserMessage && e.UserText != "" {
|
||||
// Show a preview of the user message being forked to.
|
||||
preview := e.UserText
|
||||
if len(preview) > 80 {
|
||||
preview = preview[:77] + "..."
|
||||
}
|
||||
msg = "Fork and edit: " + preview + "\n\nContinue?"
|
||||
}
|
||||
|
||||
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
|
||||
Message: msg,
|
||||
})
|
||||
if result.Cancelled || !result.Value {
|
||||
return &ext.BeforeForkResult{
|
||||
Cancel: true,
|
||||
Reason: "Fork cancelled by user.",
|
||||
}
|
||||
}
|
||||
return nil // user approved
|
||||
})
|
||||
}
|
||||
|
||||
// isGitDirty returns true if the git working tree has uncommitted changes.
|
||||
func isGitDirty() bool {
|
||||
out, err := exec.Command("git", "status", "--porcelain").Output()
|
||||
if err != nil {
|
||||
return false // not a git repo or git not available
|
||||
}
|
||||
return len(strings.TrimSpace(string(out))) > 0
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//go:build ignore
|
||||
|
||||
// context-inject.go — Injects context from a local file into every LLM turn.
|
||||
//
|
||||
// Reads a context file (default: .kit/context.md) and prepends it as a system
|
||||
// message to every LLM context window via OnContextPrepare. This is useful for
|
||||
// injecting project-specific knowledge, coding standards, or RAG results that
|
||||
// should always be visible to the model — without cluttering the session history.
|
||||
//
|
||||
// The injected message does NOT persist in the session tree (it's ephemeral,
|
||||
// added at query time only). This means:
|
||||
// - Changing the context file immediately affects future turns
|
||||
// - No session bloat from repeated context injection
|
||||
// - The model always sees the latest version of the context
|
||||
//
|
||||
// Configuration:
|
||||
//
|
||||
// KIT_OPT_CONTEXT_FILE — path to context file (default: .kit/context.md)
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// kit -e examples/extensions/context-inject.go
|
||||
// echo "Always use error wrapping with fmt.Errorf" > .kit/context.md
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
ext "kit/ext"
|
||||
)
|
||||
|
||||
func Init(api ext.API) {
|
||||
api.RegisterOption(ext.OptionDef{
|
||||
Name: "context-file",
|
||||
Description: "Path to the context file to inject into every turn",
|
||||
Default: ".kit/context.md",
|
||||
})
|
||||
|
||||
api.OnContextPrepare(func(e ext.ContextPrepareEvent, ctx ext.Context) *ext.ContextPrepareResult {
|
||||
path := ctx.GetOption("context-file")
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
// File doesn't exist or can't be read — skip silently.
|
||||
return nil
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(string(data))
|
||||
if content == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Prepend a system message with the context file contents.
|
||||
injected := ext.ContextMessage{
|
||||
Index: -1,
|
||||
Role: "system",
|
||||
Content: fmt.Sprintf("[Project Context from %s]\n\n%s", path, content),
|
||||
}
|
||||
|
||||
msgs := make([]ext.ContextMessage, 0, len(e.Messages)+1)
|
||||
msgs = append(msgs, injected)
|
||||
msgs = append(msgs, e.Messages...)
|
||||
|
||||
return &ext.ContextPrepareResult{Messages: msgs}
|
||||
})
|
||||
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "context",
|
||||
Description: "Show or edit the injected context file path",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
path := ctx.GetOption("context-file")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("Context file: %s (not found or unreadable)", path), nil
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||
preview := strings.Join(lines, "\n")
|
||||
if len(lines) > 10 {
|
||||
preview = strings.Join(lines[:10], "\n") + "\n..."
|
||||
}
|
||||
return fmt.Sprintf("Context file: %s (%d lines)\n\n%s", path, len(lines), preview), nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//go:build ignore
|
||||
|
||||
// dev-reload.go — Extension Hot-Reload example extension for Kit.
|
||||
//
|
||||
// Demonstrates ctx.ReloadExtensions() which hot-reloads all extensions
|
||||
// from disk without restarting Kit. This is invaluable during extension
|
||||
// development: edit your extension source, then type /reload to pick up
|
||||
// changes immediately.
|
||||
//
|
||||
// Event handlers, slash commands, tool renderers, message renderers, and
|
||||
// keyboard shortcuts update immediately. Extension-defined tools are NOT
|
||||
// updated (they are baked into the agent at creation time and require a
|
||||
// restart).
|
||||
//
|
||||
// Commands:
|
||||
// /reload — hot-reload all extensions from disk
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
ext "kit/ext"
|
||||
)
|
||||
|
||||
var loadedAt string
|
||||
|
||||
func Init(api ext.API) {
|
||||
loadedAt = time.Now().Format("15:04:05")
|
||||
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "reload",
|
||||
Description: "Hot-reload all extensions from disk",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
ctx.Print("Reloading extensions...")
|
||||
err := ctx.ReloadExtensions()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reload failed: %w", err)
|
||||
}
|
||||
return "Extensions reloaded successfully.", nil
|
||||
},
|
||||
})
|
||||
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "load-time",
|
||||
Description: "Show when this extension was loaded",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
return fmt.Sprintf("This extension was loaded at %s", loadedAt), nil
|
||||
},
|
||||
})
|
||||
|
||||
api.OnSessionStart(func(e ext.SessionStartEvent, ctx ext.Context) {
|
||||
ctx.Print(fmt.Sprintf("[dev-reload] Extension loaded at %s", loadedAt))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init expands inline bash expressions in user prompts before they reach the
|
||||
// LLM. Text like !{git branch --show-current} is replaced with the command's
|
||||
// stdout.
|
||||
//
|
||||
// Examples:
|
||||
//
|
||||
// "Fix the tests on !{git branch --show-current}"
|
||||
// → "Fix the tests on main"
|
||||
//
|
||||
// "The current directory is !{pwd}"
|
||||
// → "The current directory is /home/user/project"
|
||||
//
|
||||
// Usage: kit -e examples/extensions/inline-bash.go
|
||||
func Init(api ext.API) {
|
||||
// Matches !{...} with non-greedy content.
|
||||
re := regexp.MustCompile(`!\{([^}]+)\}`)
|
||||
|
||||
api.OnInput(func(ev ext.InputEvent, ctx ext.Context) *ext.InputResult {
|
||||
if !re.MatchString(ev.Text) {
|
||||
return nil
|
||||
}
|
||||
|
||||
expanded := re.ReplaceAllStringFunc(ev.Text, func(match string) string {
|
||||
// Extract the command between !{ and }.
|
||||
cmd := re.FindStringSubmatch(match)[1]
|
||||
cmd = strings.TrimSpace(cmd)
|
||||
|
||||
out, err := exec.Command("bash", "-c", cmd).Output()
|
||||
if err != nil {
|
||||
return match // keep original on error
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
})
|
||||
|
||||
return &ext.InputResult{
|
||||
Action: "transform",
|
||||
Text: expanded,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//go:build ignore
|
||||
|
||||
// interactive-shell.go — TUI Suspend example extension for Kit.
|
||||
//
|
||||
// Demonstrates ctx.SuspendTUI() which temporarily releases the terminal
|
||||
// from the TUI so interactive subprocesses can run with full terminal
|
||||
// control. The TUI is automatically restored when the callback returns.
|
||||
//
|
||||
// Commands:
|
||||
// /edit <file> — opens $EDITOR (or vi) to edit a file
|
||||
// /shell — drops into an interactive shell session
|
||||
// /run <cmd> — runs a command with full terminal I/O (no TUI capture)
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
ext "kit/ext"
|
||||
)
|
||||
|
||||
func Init(api ext.API) {
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "edit",
|
||||
Description: "Open $EDITOR to edit a file (TUI suspends)",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
file := strings.TrimSpace(args)
|
||||
if file == "" {
|
||||
return "", fmt.Errorf("usage: /edit <file>")
|
||||
}
|
||||
|
||||
editor := os.Getenv("EDITOR")
|
||||
if editor == "" {
|
||||
editor = "vi"
|
||||
}
|
||||
|
||||
ctx.Print(fmt.Sprintf("Opening %s in %s...", file, editor))
|
||||
|
||||
err := ctx.SuspendTUI(func() {
|
||||
cmd := exec.Command(editor, file)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Run()
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("editor session failed: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Finished editing %s", file), nil
|
||||
},
|
||||
Complete: func(prefix string, ctx ext.Context) []string {
|
||||
// Suggest files in the current directory.
|
||||
entries, err := os.ReadDir(".")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var results []string
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
results = append(results, name)
|
||||
}
|
||||
}
|
||||
return results
|
||||
},
|
||||
})
|
||||
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "shell",
|
||||
Description: "Drop into an interactive shell (TUI suspends)",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
shell := os.Getenv("SHELL")
|
||||
if shell == "" {
|
||||
shell = "/bin/sh"
|
||||
}
|
||||
|
||||
ctx.Print(fmt.Sprintf("Starting %s... (type 'exit' to return to Kit)", shell))
|
||||
|
||||
err := ctx.SuspendTUI(func() {
|
||||
cmd := exec.Command(shell)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Run()
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("shell session failed: %w", err)
|
||||
}
|
||||
|
||||
return "Shell session ended, TUI restored.", nil
|
||||
},
|
||||
})
|
||||
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "run",
|
||||
Description: "Run a command with full terminal I/O (TUI suspends)",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
cmdStr := strings.TrimSpace(args)
|
||||
if cmdStr == "" {
|
||||
return "", fmt.Errorf("usage: /run <command>")
|
||||
}
|
||||
|
||||
ctx.Print(fmt.Sprintf("Running: %s", cmdStr))
|
||||
|
||||
err := ctx.SuspendTUI(func() {
|
||||
cmd := exec.Command("sh", "-c", cmdStr)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Run()
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("command failed: %w", err)
|
||||
}
|
||||
|
||||
return "Command finished, TUI restored.", nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -31,6 +32,16 @@ import (
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// kitJSONOutput matches the JSON envelope produced by `kit --json`.
|
||||
type kitJSONOutput struct {
|
||||
Response string `json:"response"`
|
||||
Model string `json:"model"`
|
||||
Usage *struct {
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
} `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -474,27 +485,33 @@ func queryExpert(name, question string) (output string, exitCode int, elapsed ti
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
// Build subprocess arguments. Don't pass --model; the subprocess
|
||||
// inherits the same config/env and will use the same default.
|
||||
// Build subprocess arguments. Use --json for structured output parsing.
|
||||
// Don't pass --model; the subprocess inherits the same config/env default.
|
||||
args := []string{
|
||||
"--prompt", question,
|
||||
"--quiet",
|
||||
"--json",
|
||||
"--no-session",
|
||||
"--no-extensions",
|
||||
"--system-prompt", tmpFile.Name(),
|
||||
question,
|
||||
}
|
||||
|
||||
var stdoutBuf, stderrBuf bytes.Buffer
|
||||
cmd := exec.Command(kitBinary, args...)
|
||||
cmd.Env = os.Environ()
|
||||
cmd.Stdout = &stdoutBuf
|
||||
cmd.Stderr = &stderrBuf
|
||||
|
||||
outBytes, err := cmd.CombinedOutput()
|
||||
err = cmd.Run()
|
||||
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
|
||||
// On error, prefer stderr for the error message; fall back to stdout.
|
||||
errText := strings.TrimSpace(stderrBuf.String())
|
||||
if errText == "" {
|
||||
errText = strings.TrimSpace(stdoutBuf.String())
|
||||
}
|
||||
errLine := errText
|
||||
if idx := strings.Index(errLine, "\n"); idx >= 0 {
|
||||
errLine = errLine[:idx]
|
||||
}
|
||||
@@ -505,10 +522,18 @@ func queryExpert(name, question string) (output string, exitCode int, elapsed ti
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
code = exitErr.ExitCode()
|
||||
}
|
||||
return result, code, elapsed
|
||||
return errText, code, elapsed
|
||||
}
|
||||
|
||||
// Success — extract last non-empty line for the card.
|
||||
// Parse JSON output from subprocess.
|
||||
var parsed kitJSONOutput
|
||||
result := strings.TrimSpace(stdoutBuf.String())
|
||||
if err := json.Unmarshal([]byte(result), &parsed); err == nil {
|
||||
result = parsed.Response
|
||||
}
|
||||
// else: fall back to raw stdout (e.g. older kit binary without --json)
|
||||
|
||||
// Extract last non-empty line for the card.
|
||||
lines := strings.Split(result, "\n")
|
||||
var lastLine string
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init demonstrates a minimal-chrome extension.
|
||||
// Hides the startup banner, status bar, separator, and input hint, replacing
|
||||
// them with a compact footer showing model name and a context usage bar:
|
||||
//
|
||||
// claude-sonnet-4-5-20250929 [###-------] 30% (3.9K/200K tokens)
|
||||
//
|
||||
// Usage: kit -e examples/extensions/minimal.go
|
||||
func Init(api ext.API) {
|
||||
// updateFooter builds the footer text from current context stats.
|
||||
updateFooter := func(ctx ext.Context) {
|
||||
stats := ctx.GetContextStats()
|
||||
pct := stats.UsagePercent * 100
|
||||
if pct > 100 {
|
||||
pct = 100
|
||||
}
|
||||
filled := int(math.Round(pct)) / 10
|
||||
bar := strings.Repeat("#", filled) + strings.Repeat("-", 10-filled)
|
||||
|
||||
// Format token counts like the built-in status bar (e.g. "3.9K/200K").
|
||||
fmtTokens := func(n int) string {
|
||||
if n >= 1000 {
|
||||
return fmt.Sprintf("%.1fK", float64(n)/1000)
|
||||
}
|
||||
return fmt.Sprintf("%d", n)
|
||||
}
|
||||
|
||||
text := fmt.Sprintf("%s [%s] %d%%", ctx.Model, bar, int(math.Round(pct)))
|
||||
if stats.ContextLimit > 0 {
|
||||
text += fmt.Sprintf(" (%s/%s tokens)",
|
||||
fmtTokens(stats.EstimatedTokens), fmtTokens(stats.ContextLimit))
|
||||
}
|
||||
|
||||
ctx.SetFooter(ext.HeaderFooterConfig{
|
||||
Content: ext.WidgetContent{Text: text},
|
||||
Style: ext.WidgetStyle{BorderColor: "#585b70"},
|
||||
})
|
||||
}
|
||||
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
// Strip built-in chrome for a minimal look.
|
||||
ctx.SetUIVisibility(ext.UIVisibility{
|
||||
HideStartupMessage: true,
|
||||
HideStatusBar: true,
|
||||
HideSeparator: true,
|
||||
HideInputHint: true,
|
||||
})
|
||||
|
||||
updateFooter(ctx)
|
||||
})
|
||||
|
||||
// Refresh after each agent turn — context usage changes here.
|
||||
api.OnAgentEnd(func(_ ext.AgentEndEvent, ctx ext.Context) {
|
||||
updateFooter(ctx)
|
||||
})
|
||||
|
||||
api.OnSessionShutdown(func(_ ext.SessionShutdownEvent, ctx ext.Context) {
|
||||
ctx.RemoveFooter()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"runtime"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init sends a desktop notification when the agent finishes responding.
|
||||
// Useful for long-running tasks — get notified without watching the terminal.
|
||||
|
||||
// Supports: Linux (notify-send), macOS (osascript).
|
||||
//
|
||||
// Usage: kit -e examples/extensions/notify.go
|
||||
func Init(api ext.API) {
|
||||
api.OnAgentEnd(func(_ ext.AgentEndEvent, ctx ext.Context) {
|
||||
sendNotification("Kit", "Agent finished responding")
|
||||
})
|
||||
}
|
||||
|
||||
func sendNotification(title, body string) {
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
// Uses notify-send (libnotify) — available on most Linux desktops.
|
||||
_ = exec.Command("notify-send", "-a", "Kit", title, body).Start()
|
||||
case "darwin":
|
||||
// Uses macOS built-in osascript for native notifications.
|
||||
script := `display notification "` + body + `" with title "` + title + `"`
|
||||
_ = exec.Command("osascript", "-e", script).Start()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init intercepts potentially dangerous bash commands and asks the user for
|
||||
// confirmation before allowing execution.
|
||||
//
|
||||
// Dangerous patterns: rm -rf, sudo, chmod 777, mkfs, dd, > /dev/
|
||||
//
|
||||
// Usage: kit -e examples/extensions/permission-gate.go
|
||||
func Init(api ext.API) {
|
||||
// Patterns that require user confirmation.
|
||||
dangerousPatterns := []string{
|
||||
"rm -rf",
|
||||
"rm -r /",
|
||||
"sudo ",
|
||||
"chmod 777",
|
||||
"chmod -R 777",
|
||||
"mkfs",
|
||||
"dd if=",
|
||||
"> /dev/",
|
||||
":(){ :|:& };:",
|
||||
}
|
||||
|
||||
api.OnToolCall(func(tc ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
|
||||
if tc.ToolName != "Bash" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract the command from the tool input JSON.
|
||||
var input struct {
|
||||
Command string `json:"command"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(tc.Input), &input); err != nil {
|
||||
return nil
|
||||
}
|
||||
cmd := strings.ToLower(input.Command)
|
||||
|
||||
// Check for dangerous patterns.
|
||||
for _, pattern := range dangerousPatterns {
|
||||
if strings.Contains(cmd, strings.ToLower(pattern)) {
|
||||
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
|
||||
Message: "Dangerous command detected: " + input.Command + "\n\nAllow execution?",
|
||||
})
|
||||
if result.Cancelled || !result.Value {
|
||||
return &ext.ToolCallResult{
|
||||
Block: true,
|
||||
Reason: "User denied execution of dangerous command: " + input.Command,
|
||||
}
|
||||
}
|
||||
return nil // user approved
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import "kit/ext"
|
||||
|
||||
// Init injects a pirate persona into the system prompt, causing the LLM to
|
||||
// respond in pirate-speak. Demonstrates OnBeforeAgentStart system prompt
|
||||
// injection.
|
||||
//
|
||||
// Usage: kit -e examples/extensions/pirate.go
|
||||
func Init(api ext.API) {
|
||||
piratePrompt := `
|
||||
You are a pirate! You must:
|
||||
- Start every response with "Ahoy!"
|
||||
- Use pirate slang (ye, matey, arr, landlubber, etc.)
|
||||
- Refer to files as "scrolls" and directories as "treasure chests"
|
||||
- Call errors "cursed mishaps" and bugs "sea monsters"
|
||||
- End responses with a pirate saying
|
||||
|
||||
Despite the pirate persona, your technical advice must remain accurate and helpful.`
|
||||
|
||||
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
|
||||
return &ext.BeforeAgentStartResult{
|
||||
SystemPrompt: &piratePrompt,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init implements a plan/explore mode that restricts the agent to read-only
|
||||
// tools. Toggle with /plan (or start in plan mode via KIT_OPT_PLAN=true).
|
||||
|
||||
// In plan mode the agent can only use read, grep, find, and ls — it cannot
|
||||
// write files, run bash, or make edits. This is useful for exploring a
|
||||
// codebase, reviewing architecture, or generating plans before executing.
|
||||
//
|
||||
// The status bar shows the current mode and the system prompt is augmented
|
||||
// with planning instructions when active.
|
||||
//
|
||||
// Usage: kit -e examples/extensions/plan-mode.go
|
||||
//
|
||||
// Start in plan mode: KIT_OPT_PLAN=true kit -e examples/extensions/plan-mode.go
|
||||
func Init(api ext.API) {
|
||||
// Read-only tool set (matches core.ReadOnlyTools).
|
||||
readOnlyTools := []string{"read", "grep", "find", "ls"}
|
||||
|
||||
var planActive bool
|
||||
|
||||
// Register "plan" option so users can start in plan mode via env/config.
|
||||
api.RegisterOption(ext.OptionDef{
|
||||
Name: "plan",
|
||||
Description: "Start in plan mode (read-only tools)",
|
||||
Default: "false",
|
||||
})
|
||||
|
||||
// ctrl+alt+p — global shortcut to toggle plan mode.
|
||||
api.RegisterShortcut(ext.ShortcutDef{
|
||||
Key: "ctrl+alt+p",
|
||||
Description: "Toggle plan/explore mode",
|
||||
}, func(ctx ext.Context) {
|
||||
planActive = !planActive
|
||||
applyMode(ctx, planActive, readOnlyTools)
|
||||
})
|
||||
|
||||
// /plan — toggle plan mode on or off.
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "plan",
|
||||
Description: "Toggle plan/explore mode (ctrl+alt+p)",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
planActive = !planActive
|
||||
applyMode(ctx, planActive, readOnlyTools)
|
||||
return "", nil
|
||||
},
|
||||
})
|
||||
|
||||
// Check option at session start to enable plan mode from env/config.
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
opt := strings.ToLower(ctx.GetOption("plan"))
|
||||
if opt == "true" || opt == "1" || opt == "yes" {
|
||||
planActive = true
|
||||
applyMode(ctx, true, readOnlyTools)
|
||||
}
|
||||
})
|
||||
|
||||
// Inject planning instructions into the system prompt when active.
|
||||
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
|
||||
if !planActive {
|
||||
return nil
|
||||
}
|
||||
prompt := `You are in PLAN MODE (read-only exploration).
|
||||
You can ONLY read, search, and explore the codebase. You CANNOT write files,
|
||||
run commands, or make edits. Focus on:
|
||||
- Understanding the codebase structure and architecture
|
||||
- Identifying relevant files and patterns
|
||||
- Generating detailed plans and recommendations
|
||||
- Answering questions about how the code works
|
||||
|
||||
When the user is ready to execute, they will exit plan mode with /plan.`
|
||||
return &ext.BeforeAgentStartResult{
|
||||
SystemPrompt: &prompt,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func applyMode(ctx ext.Context, active bool, readOnlyTools []string) {
|
||||
if active {
|
||||
ctx.SetActiveTools(readOnlyTools)
|
||||
ctx.SetStatus("plan-mode", "PLAN MODE (read-only)", 10)
|
||||
ctx.PrintInfo("Plan mode ON — agent restricted to read-only tools (read, grep, find, ls).\nUse /plan to toggle off.")
|
||||
} else {
|
||||
ctx.SetActiveTools(nil) // re-enable all tools
|
||||
ctx.RemoveStatus("plan-mode")
|
||||
ctx.PrintInfo("Plan mode OFF — all tools re-enabled.")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init loads project-specific rules from .kit/rules/ into the system prompt.
|
||||
// Each .md file in the rules directory is injected as additional context,
|
||||
// giving projects a way to customise LLM behaviour without editing the
|
||||
// main system prompt.
|
||||
//
|
||||
// Place rule files in:
|
||||
//
|
||||
// .kit/rules/code-style.md
|
||||
// .kit/rules/testing.md
|
||||
// .kit/rules/security.md
|
||||
//
|
||||
// Usage: kit -e examples/extensions/project-rules.go
|
||||
func Init(api ext.API) {
|
||||
var rules string
|
||||
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
rulesDir := filepath.Join(ctx.CWD, ".kit", "rules")
|
||||
entries, err := os.ReadDir(rulesDir)
|
||||
if err != nil {
|
||||
return // no rules directory, nothing to do
|
||||
}
|
||||
|
||||
var parts []string
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if !strings.HasSuffix(name, ".md") && !strings.HasSuffix(name, ".txt") {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(rulesDir, name))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
content := strings.TrimSpace(string(data))
|
||||
if content != "" {
|
||||
parts = append(parts, "## "+strings.TrimSuffix(name, filepath.Ext(name))+"\n\n"+content)
|
||||
}
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
rules = "# Project Rules\n\n" + strings.Join(parts, "\n\n---\n\n")
|
||||
ctx.PrintInfo(fmt.Sprintf("[project-rules] Loaded %d rule file(s) from .kit/rules/", len(parts)))
|
||||
})
|
||||
|
||||
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
|
||||
if rules == "" {
|
||||
return nil
|
||||
}
|
||||
return &ext.BeforeAgentStartResult{
|
||||
SystemPrompt: &rules,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init blocks tool calls that attempt to write, edit, or delete files in
|
||||
// protected paths.
|
||||
//
|
||||
// Protected: .env*, .git/, secrets/, credentials*, *.pem, *.key
|
||||
//
|
||||
// Usage: kit -e examples/extensions/protected-paths.go
|
||||
func Init(api ext.API) {
|
||||
// Tools that modify files.
|
||||
writeTools := map[string]bool{
|
||||
"Write": true,
|
||||
"Edit": true,
|
||||
"Bash": true,
|
||||
}
|
||||
|
||||
// Path patterns to protect (checked against the file_path / filePath field).
|
||||
protectedPatterns := []string{
|
||||
".env",
|
||||
".git/",
|
||||
"secrets/",
|
||||
"credentials",
|
||||
".pem",
|
||||
".key",
|
||||
"id_rsa",
|
||||
"id_ed25519",
|
||||
}
|
||||
|
||||
// Bash commands that could modify protected files.
|
||||
bashWritePatterns := []string{
|
||||
"rm ", "mv ", "cp ", "> ",
|
||||
"cat >", "echo >", "tee ",
|
||||
"chmod ", "chown ",
|
||||
}
|
||||
|
||||
isProtected := func(path string) bool {
|
||||
lower := strings.ToLower(path)
|
||||
for _, p := range protectedPatterns {
|
||||
if strings.Contains(lower, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
api.OnToolCall(func(tc ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
|
||||
if !writeTools[tc.ToolName] {
|
||||
return nil
|
||||
}
|
||||
|
||||
// For Write/Edit: check the file_path / filePath field.
|
||||
if tc.ToolName == "Write" || tc.ToolName == "Edit" {
|
||||
var input map[string]any
|
||||
if err := json.Unmarshal([]byte(tc.Input), &input); err != nil {
|
||||
return nil
|
||||
}
|
||||
// Try both naming conventions.
|
||||
filePath, _ := input["file_path"].(string)
|
||||
if filePath == "" {
|
||||
filePath, _ = input["filePath"].(string)
|
||||
}
|
||||
if isProtected(filePath) {
|
||||
return &ext.ToolCallResult{
|
||||
Block: true,
|
||||
Reason: "Blocked: writing to protected path: " + filePath,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// For Bash: check if the command references protected paths.
|
||||
if tc.ToolName == "Bash" {
|
||||
var input struct {
|
||||
Command string `json:"command"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(tc.Input), &input); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Only check bash commands that look like file mutations.
|
||||
isMutation := false
|
||||
for _, pat := range bashWritePatterns {
|
||||
if strings.Contains(input.Command, pat) {
|
||||
isMutation = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isMutation {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if any protected pattern appears in the command.
|
||||
for _, p := range protectedPatterns {
|
||||
if strings.Contains(input.Command, p) {
|
||||
return &ext.ToolCallResult{
|
||||
Block: true,
|
||||
Reason: "Blocked: bash command references protected path (" + p + "): " + input.Command,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -35,6 +35,11 @@ import (
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// subJSONOutput matches the JSON envelope produced by `kit --json`.
|
||||
type subJSONOutput struct {
|
||||
Response string `json:"response"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -204,10 +209,10 @@ func spawnAgent(state *subState) {
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"--prompt", prompt,
|
||||
"--quiet",
|
||||
"--json",
|
||||
"--no-session",
|
||||
"--no-extensions",
|
||||
prompt,
|
||||
}
|
||||
|
||||
cmd := exec.Command(kitBinary, args...)
|
||||
@@ -261,7 +266,7 @@ func spawnAgent(state *subState) {
|
||||
}
|
||||
}()
|
||||
|
||||
// Read stderr in background goroutine.
|
||||
// Read stderr in background goroutine (live widget updates).
|
||||
var readWg sync.WaitGroup
|
||||
readWg.Add(1)
|
||||
go func() {
|
||||
@@ -277,12 +282,12 @@ func spawnAgent(state *subState) {
|
||||
}
|
||||
}()
|
||||
|
||||
// Read stdout in foreground.
|
||||
// Read stdout into a separate buffer (JSON output from --json mode).
|
||||
var stdoutBuf strings.Builder
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 256*1024), 256*1024)
|
||||
for scanner.Scan() {
|
||||
state.appendChunk(scanner.Text() + "\n")
|
||||
updateWidgets()
|
||||
stdoutBuf.WriteString(scanner.Text() + "\n")
|
||||
}
|
||||
|
||||
// Wait for all pipe readers, then the process.
|
||||
@@ -290,6 +295,17 @@ func spawnAgent(state *subState) {
|
||||
waitErr := cmd.Wait()
|
||||
close(doneCh) // stop timer
|
||||
|
||||
// Parse JSON output from --json mode to extract the response.
|
||||
var result string
|
||||
rawStdout := strings.TrimSpace(stdoutBuf.String())
|
||||
var parsed subJSONOutput
|
||||
if rawStdout != "" && json.Unmarshal([]byte(rawStdout), &parsed) == nil && parsed.Response != "" {
|
||||
result = parsed.Response
|
||||
} else {
|
||||
// Fallback: use raw stdout (e.g. older kit binary without --json).
|
||||
result = rawStdout
|
||||
}
|
||||
|
||||
state.mu.Lock()
|
||||
state.Elapsed = time.Since(start)
|
||||
state.Proc = nil
|
||||
@@ -298,7 +314,6 @@ func spawnAgent(state *subState) {
|
||||
} 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",
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init adds a /summarize command that generates a concise summary of the
|
||||
// current conversation using a direct LLM completion. Demonstrates the
|
||||
// ctx.Complete API.
|
||||
//
|
||||
// The summary is displayed in a styled block and can optionally be saved
|
||||
// to the session via AppendEntry for later retrieval.
|
||||
//
|
||||
// Usage: kit -e examples/extensions/summarize.go
|
||||
func Init(api ext.API) {
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "summarize",
|
||||
Description: "Summarize the current conversation",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
msgs := ctx.GetMessages()
|
||||
if len(msgs) == 0 {
|
||||
ctx.PrintInfo("Nothing to summarize — no messages yet.")
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Build a text representation of the conversation.
|
||||
var parts []string
|
||||
for _, m := range msgs {
|
||||
content := m.Content
|
||||
if len(content) > 2000 {
|
||||
content = content[:1997] + "..."
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("[%s]: %s", m.Role, content))
|
||||
}
|
||||
conversation := strings.Join(parts, "\n\n")
|
||||
|
||||
ctx.PrintInfo("Generating summary...")
|
||||
|
||||
resp, err := ctx.Complete(ext.CompleteRequest{
|
||||
System: `You are a concise summarization assistant. Summarize the conversation below in 3-5 bullet points. Focus on:
|
||||
- What was discussed or requested
|
||||
- Key decisions or outcomes
|
||||
- Any pending action items
|
||||
|
||||
Be concise. Use plain text, no markdown headers.`,
|
||||
Prompt: conversation,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.PrintError("Summary failed: " + err.Error())
|
||||
return "", nil
|
||||
}
|
||||
|
||||
summary := strings.TrimSpace(resp.Text)
|
||||
|
||||
ctx.PrintBlock(ext.PrintBlockOpts{
|
||||
Text: summary,
|
||||
BorderColor: "#89b4fa",
|
||||
Subtitle: fmt.Sprintf("Summary (%d messages, %d tokens used)", len(msgs), resp.InputTokens+resp.OutputTokens),
|
||||
})
|
||||
|
||||
// Persist the summary in the session for later retrieval.
|
||||
ctx.AppendEntry("summary", summary)
|
||||
|
||||
return "", nil
|
||||
},
|
||||
})
|
||||
|
||||
// /summaries — list all saved summaries.
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "summaries",
|
||||
Description: "List saved conversation summaries",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
entries := ctx.GetEntries("summary")
|
||||
if len(entries) == 0 {
|
||||
ctx.PrintInfo("No summaries saved yet. Use /summarize to create one.")
|
||||
return "", nil
|
||||
}
|
||||
for i, e := range entries {
|
||||
ctx.PrintBlock(ext.PrintBlockOpts{
|
||||
Text: e.Data,
|
||||
BorderColor: "#89b4fa",
|
||||
Subtitle: fmt.Sprintf("Summary #%d (%s)", i+1, e.Timestamp[:19]),
|
||||
})
|
||||
}
|
||||
return "", nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -74,6 +74,7 @@ type Agent struct {
|
||||
streamingEnabled bool
|
||||
coreTools []fantasy.AgentTool
|
||||
extraTools []fantasy.AgentTool
|
||||
toolWrapper func([]fantasy.AgentTool) []fantasy.AgentTool // stored for SetModel rebuild
|
||||
}
|
||||
|
||||
// GenerateWithLoopResult contains the result and conversation history from an agent interaction.
|
||||
@@ -179,6 +180,7 @@ func NewAgent(ctx context.Context, agentConfig *AgentConfig) (*Agent, error) {
|
||||
streamingEnabled: agentConfig.StreamingEnabled,
|
||||
coreTools: coreTools,
|
||||
extraTools: agentConfig.ExtraTools,
|
||||
toolWrapper: agentConfig.ToolWrapper,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -455,6 +457,11 @@ func (a *Agent) GetTools() []fantasy.AgentTool {
|
||||
return allTools
|
||||
}
|
||||
|
||||
// GetCoreToolCount returns the number of core tools.
|
||||
func (a *Agent) GetCoreToolCount() int {
|
||||
return len(a.coreTools)
|
||||
}
|
||||
|
||||
// GetMCPToolCount returns the number of tools loaded from external MCP servers.
|
||||
func (a *Agent) GetMCPToolCount() int {
|
||||
if a.toolManager == nil {
|
||||
@@ -481,6 +488,69 @@ func (a *Agent) GetLoadedServerNames() []string {
|
||||
return a.toolManager.GetLoadedServerNames()
|
||||
}
|
||||
|
||||
// SetModel swaps the agent's LLM provider to a new model. The existing tools,
|
||||
// system prompt, and configuration are preserved. The old provider is closed
|
||||
// if it has a closer. Returns the previous model string for notification.
|
||||
func (a *Agent) SetModel(ctx context.Context, config *models.ProviderConfig) error {
|
||||
providerResult, err := models.CreateProvider(ctx, config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create model provider: %v", err)
|
||||
}
|
||||
|
||||
// Rebuild tool list (same as NewAgent).
|
||||
allTools := make([]fantasy.AgentTool, len(a.coreTools))
|
||||
copy(allTools, a.coreTools)
|
||||
if a.toolManager != nil {
|
||||
allTools = append(allTools, a.toolManager.GetTools()...)
|
||||
}
|
||||
if len(a.extraTools) > 0 {
|
||||
allTools = append(allTools, a.extraTools...)
|
||||
}
|
||||
if a.toolWrapper != nil {
|
||||
allTools = a.toolWrapper(allTools)
|
||||
}
|
||||
|
||||
// Rebuild fantasy agent options.
|
||||
var agentOpts []fantasy.AgentOption
|
||||
if a.systemPrompt != "" {
|
||||
agentOpts = append(agentOpts, fantasy.WithSystemPrompt(a.systemPrompt))
|
||||
}
|
||||
if len(allTools) > 0 {
|
||||
agentOpts = append(agentOpts, fantasy.WithTools(allTools...))
|
||||
}
|
||||
if a.maxSteps > 0 {
|
||||
agentOpts = append(agentOpts, fantasy.WithStopConditions(
|
||||
fantasy.StepCountIs(a.maxSteps),
|
||||
))
|
||||
}
|
||||
|
||||
newFantasyAgent := fantasy.NewAgent(providerResult.Model, agentOpts...)
|
||||
|
||||
// Close old provider.
|
||||
if a.providerCloser != nil {
|
||||
_ = a.providerCloser.Close()
|
||||
}
|
||||
|
||||
// Update model info on MCP tool manager.
|
||||
if a.toolManager != nil {
|
||||
a.toolManager.SetModel(providerResult.Model)
|
||||
}
|
||||
|
||||
// Swap fields.
|
||||
a.fantasyAgent = newFantasyAgent
|
||||
a.model = providerResult.Model
|
||||
a.providerCloser = providerResult.Closer
|
||||
|
||||
// Update provider type.
|
||||
if config.ModelString != "" {
|
||||
if p, _, err := models.ParseModelString(config.ModelString); err == nil {
|
||||
a.providerType = p
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetModel returns the underlying fantasy LanguageModel.
|
||||
func (a *Agent) GetModel() fantasy.LanguageModel {
|
||||
return a.model
|
||||
|
||||
@@ -141,6 +141,34 @@ func (a *App) QueueLength() int {
|
||||
return len(a.queue)
|
||||
}
|
||||
|
||||
// Steer cancels the current agent step (if running), clears the queue, and
|
||||
// sends a new message that will execute as soon as the current step finishes
|
||||
// cancelling. If the agent is idle, the message executes immediately.
|
||||
// This is the "steer" delivery mode for SendMessage.
|
||||
func (a *App) Steer(prompt string) {
|
||||
a.mu.Lock()
|
||||
|
||||
if a.closed {
|
||||
a.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
if !a.busy {
|
||||
// Not busy — start immediately, same as Run().
|
||||
a.busy = true
|
||||
a.wg.Add(1)
|
||||
a.mu.Unlock()
|
||||
go a.drainQueue(prompt)
|
||||
return
|
||||
}
|
||||
|
||||
// Agent is busy: clear queue, insert steer message, then cancel.
|
||||
a.queue = []string{prompt}
|
||||
cancel := a.cancelStep
|
||||
a.mu.Unlock()
|
||||
cancel()
|
||||
}
|
||||
|
||||
// ClearQueue discards all queued prompts. The caller is responsible for
|
||||
// updating any UI state (e.g. queue badge) — ClearQueue does NOT send
|
||||
// events to the program, because it may be called synchronously from
|
||||
@@ -169,6 +197,22 @@ func (a *App) GetTreeSession() *session.TreeManager {
|
||||
return a.opts.TreeSession
|
||||
}
|
||||
|
||||
// AddContextMessage adds a user-role message to the conversation history
|
||||
// without triggering an LLM response. Used by the ! shell command prefix
|
||||
// to inject command output into context so the LLM can reference it in
|
||||
// subsequent turns.
|
||||
//
|
||||
// Satisfies ui.AppController.
|
||||
func (a *App) AddContextMessage(text string) {
|
||||
msg := fantasy.NewUserMessage(text)
|
||||
a.store.Add(msg)
|
||||
|
||||
// Persist to tree session if active.
|
||||
if ts := a.opts.TreeSession; ts != nil {
|
||||
_, _ = ts.AppendFantasyMessage(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// CompactConversation summarises older messages to free context space. It
|
||||
// returns an error synchronously if compaction cannot start (agent busy or
|
||||
// app closed). The actual compaction runs in a background goroutine and
|
||||
@@ -254,6 +298,20 @@ func (a *App) RunOnce(ctx context.Context, prompt string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunOnceResult executes a single agent step synchronously and returns the
|
||||
// full TurnResult without printing anything. This is used by --json mode to
|
||||
// capture structured output for serialization.
|
||||
func (a *App) RunOnceResult(ctx context.Context, prompt string) (*kit.TurnResult, error) {
|
||||
stepCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
a.mu.Lock()
|
||||
a.cancelStep = cancel
|
||||
a.mu.Unlock()
|
||||
|
||||
return a.executeStep(stepCtx, prompt, nil)
|
||||
}
|
||||
|
||||
// RunOnceWithDisplay executes a single agent step synchronously, sending
|
||||
// intermediate display events (spinner, tool calls, streaming chunks, etc.)
|
||||
// to eventFn. This is the non-TUI equivalent of the interactive Run() path —
|
||||
@@ -474,6 +532,22 @@ func (a *App) subscribeSDKEvents(sendFn func(tea.Msg)) func() {
|
||||
}
|
||||
}
|
||||
|
||||
// QuitFromExtension triggers a graceful shutdown. In interactive mode it
|
||||
// sends a tea.QuitMsg to the program so the TUI exits cleanly. In
|
||||
// non-interactive mode it cancels the root context, stopping any in-flight
|
||||
// step. Safe to call from any goroutine; idempotent.
|
||||
func (a *App) QuitFromExtension() {
|
||||
a.mu.Lock()
|
||||
prog := a.program
|
||||
a.mu.Unlock()
|
||||
if prog != nil {
|
||||
prog.Send(tea.QuitMsg{})
|
||||
return
|
||||
}
|
||||
// Non-interactive: cancel the root context.
|
||||
a.rootCancel()
|
||||
}
|
||||
|
||||
// PrintFromExtension outputs text from an extension to the user. The level
|
||||
// controls styling: "" for plain text, "info" for a system message block,
|
||||
// "error" for an error block. In interactive mode it sends an
|
||||
@@ -491,6 +565,28 @@ func (a *App) PrintFromExtension(level, text string) {
|
||||
fmt.Println(text)
|
||||
}
|
||||
|
||||
// SetEditorTextFromExtension sends an EditorTextSetEvent to the TUI to
|
||||
// pre-fill the input editor. In non-interactive mode this is a no-op.
|
||||
func (a *App) SetEditorTextFromExtension(text string) {
|
||||
a.mu.Lock()
|
||||
prog := a.program
|
||||
a.mu.Unlock()
|
||||
if prog != nil {
|
||||
prog.Send(EditorTextSetEvent{Text: text})
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyModelChanged sends a ModelChangedEvent to the TUI so it updates
|
||||
// the model name in the status bar and message attribution.
|
||||
func (a *App) NotifyModelChanged(provider, model string) {
|
||||
a.mu.Lock()
|
||||
prog := a.program
|
||||
a.mu.Unlock()
|
||||
if prog != nil {
|
||||
prog.Send(ModelChangedEvent{ProviderName: provider, ModelName: model})
|
||||
}
|
||||
}
|
||||
|
||||
// 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).
|
||||
@@ -547,6 +643,32 @@ func (a *App) SendOverlayRequest(evt OverlayRequestEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
// SuspendTUI temporarily releases the terminal from the TUI, runs the
|
||||
// callback (which may spawn interactive subprocesses), and then restores
|
||||
// the TUI. In non-interactive mode (no program registered) the callback
|
||||
// runs directly with no terminal state changes.
|
||||
//
|
||||
// Safe to call from any goroutine (extension command handlers run in
|
||||
// goroutines). Blocks until the callback returns.
|
||||
func (a *App) SuspendTUI(callback func()) error {
|
||||
a.mu.Lock()
|
||||
prog := a.program
|
||||
a.mu.Unlock()
|
||||
if prog == nil {
|
||||
// Non-interactive: just run the callback directly.
|
||||
callback()
|
||||
return nil
|
||||
}
|
||||
if err := prog.ReleaseTerminal(); err != nil {
|
||||
return fmt.Errorf("release terminal: %w", err)
|
||||
}
|
||||
callback()
|
||||
if err := prog.RestoreTerminal(); err != nil {
|
||||
return fmt.Errorf("restore terminal: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PrintBlockFromExtension outputs a custom styled block from an extension.
|
||||
func (a *App) PrintBlockFromExtension(opts extensions.PrintBlockOpts) {
|
||||
a.mu.Lock()
|
||||
|
||||
@@ -113,11 +113,28 @@ type CompactErrorEvent struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
// ModelChangedEvent is sent when an extension changes the active model via
|
||||
// ctx.SetModel. The TUI updates the model name shown in the status bar and
|
||||
// message attribution.
|
||||
type ModelChangedEvent struct {
|
||||
// ProviderName is the new provider (e.g. "anthropic").
|
||||
ProviderName string
|
||||
// ModelName is the new model ID (e.g. "claude-3-5-haiku-20241022").
|
||||
ModelName string
|
||||
}
|
||||
|
||||
// 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{}
|
||||
|
||||
// EditorTextSetEvent is sent when an extension calls ctx.SetEditorText to
|
||||
// pre-fill the input editor with text. The TUI handles this by setting the
|
||||
// textarea content and moving the cursor to the end.
|
||||
type EditorTextSetEvent struct {
|
||||
Text string
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package compaction provides context window management with token estimation,
|
||||
// compaction triggers, and LLM-based conversation summarization.
|
||||
//
|
||||
// The algorithm mirrors Pi's approach: preserve a token budget of recent
|
||||
// The algorithm preserves a token budget of recent
|
||||
// messages (KeepRecentTokens, default 20 000) rather than a fixed message
|
||||
// count. Auto-compaction fires when estimated context usage exceeds
|
||||
// contextWindow − ReserveTokens.
|
||||
@@ -50,8 +50,8 @@ func estimateSingleMessageTokens(msg fantasy.Message) int {
|
||||
// Auto-compact trigger
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ShouldCompact reports whether auto-compaction should fire. It uses Pi's
|
||||
// formula: contextTokens > contextWindow − reserveTokens.
|
||||
// ShouldCompact reports whether auto-compaction should fire.
|
||||
// Formula: contextTokens > contextWindow − reserveTokens.
|
||||
func ShouldCompact(messages []fantasy.Message, contextWindow int, reserveTokens int) bool {
|
||||
if contextWindow <= 0 || reserveTokens <= 0 {
|
||||
return false
|
||||
@@ -72,8 +72,8 @@ type CompactionResult struct {
|
||||
MessagesRemoved int // Number of messages replaced by the summary
|
||||
}
|
||||
|
||||
// CompactionOptions configures compaction behaviour. Pi-style token-based
|
||||
// defaults are applied for zero-value fields.
|
||||
// CompactionOptions configures compaction behaviour. Token-based defaults
|
||||
// are applied for zero-value fields.
|
||||
type CompactionOptions struct {
|
||||
ContextWindow int // Model's context window size (tokens)
|
||||
ReserveTokens int // Tokens to reserve for LLM response, default 16384
|
||||
@@ -81,7 +81,7 @@ type CompactionOptions struct {
|
||||
SummaryPrompt string // Custom summary prompt (empty = use default)
|
||||
}
|
||||
|
||||
// defaults fills zero-value fields with sensible Pi-style defaults.
|
||||
// defaults fills zero-value fields with sensible defaults.
|
||||
func (o *CompactionOptions) defaults() {
|
||||
if o.ReserveTokens <= 0 {
|
||||
o.ReserveTokens = 16384
|
||||
@@ -92,13 +92,13 @@ func (o *CompactionOptions) defaults() {
|
||||
}
|
||||
|
||||
// defaultSystemPrompt is the system prompt sent to the summarisation LLM.
|
||||
// Matches Pi's compaction system prompt.
|
||||
|
||||
const defaultSystemPrompt = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified.
|
||||
|
||||
Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`
|
||||
|
||||
// defaultSummaryPrompt is the user prompt appended after the serialised
|
||||
// conversation. Matches Pi's initial-compaction format.
|
||||
// conversation.
|
||||
const defaultSummaryPrompt = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.
|
||||
|
||||
Use this EXACT format:
|
||||
@@ -133,7 +133,7 @@ Use this EXACT format:
|
||||
Keep each section concise. Preserve exact file paths, function names, and error messages.`
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cut point (token-based, Pi-style)
|
||||
// Cut point (token-based)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// isValidCutPoint returns true if the message at index i is a valid place to
|
||||
@@ -208,11 +208,11 @@ func forceCutPoint(messages []fantasy.Message) int {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message serialisation (Pi-style)
|
||||
// Message serialisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// roleLabel returns a human-readable label for a fantasy message role,
|
||||
// matching Pi's serialisation format.
|
||||
|
||||
func roleLabel(role fantasy.MessageRole) string {
|
||||
switch role {
|
||||
case fantasy.MessageRoleUser:
|
||||
@@ -230,7 +230,7 @@ func roleLabel(role fantasy.MessageRole) string {
|
||||
|
||||
// serializeMessages converts a slice of fantasy messages into a plain-text
|
||||
// representation suitable for sending to the summarisation LLM. The format
|
||||
// mirrors Pi's compaction serialisation.
|
||||
|
||||
func serializeMessages(messages []fantasy.Message) string {
|
||||
var sb strings.Builder
|
||||
for _, msg := range messages {
|
||||
@@ -277,8 +277,8 @@ func Compact(
|
||||
cutPoint := FindCutPoint(messages, opts.KeepRecentTokens)
|
||||
if cutPoint == 0 {
|
||||
// All messages fit within the keep budget. Force a cut that
|
||||
// keeps only the last non-tool message — matching Pi, which
|
||||
// always compacts when the user explicitly requests it.
|
||||
// keeps only the last non-tool message — always compact when
|
||||
// the user explicitly requests it.
|
||||
cutPoint = forceCutPoint(messages)
|
||||
if cutPoint == 0 {
|
||||
return nil, messages, nil
|
||||
@@ -289,7 +289,7 @@ func Compact(
|
||||
recentMessages := messages[cutPoint:]
|
||||
originalTokens := EstimateMessageTokens(messages)
|
||||
|
||||
// Serialise old messages to text, matching Pi's format.
|
||||
// Serialise old messages to text.
|
||||
conversationText := serializeMessages(oldMessages)
|
||||
|
||||
// Build the user-facing prompt: conversation text + summary instructions.
|
||||
|
||||
@@ -63,7 +63,7 @@ func TestEstimateMessageTokens_Empty(t *testing.T) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ShouldCompact (Pi-style: contextTokens > contextWindow - reserveTokens)
|
||||
// ShouldCompact (contextTokens > contextWindow - reserveTokens)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestShouldCompact(t *testing.T) {
|
||||
@@ -94,7 +94,7 @@ func TestShouldCompact(t *testing.T) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FindCutPoint (token-based, Pi-style)
|
||||
// FindCutPoint (token-based)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestFindCutPoint_TokenBased(t *testing.T) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package core provides the built-in core tools for KIT's coding agent.
|
||||
// These tools are direct fantasy.AgentTool implementations — no MCP layer,
|
||||
// no JSON-RPC, no serialization overhead. They match the pi coding agent's
|
||||
// core tool set: bash, read, write, edit, grep, find, ls.
|
||||
// no JSON-RPC, no serialization overhead. Core tool set: bash, read, write,
|
||||
// edit, grep, find, ls.
|
||||
package core
|
||||
|
||||
import (
|
||||
@@ -65,7 +65,7 @@ func parseArgs(input string, target any) error {
|
||||
}
|
||||
|
||||
// CodingTools returns the default set of core tools for a coding agent:
|
||||
// bash, read, write, edit. This matches pi's codingTools collection.
|
||||
// bash, read, write, edit.
|
||||
func CodingTools(opts ...ToolOption) []fantasy.AgentTool {
|
||||
return []fantasy.AgentTool{
|
||||
NewBashTool(opts...),
|
||||
@@ -76,7 +76,7 @@ func CodingTools(opts ...ToolOption) []fantasy.AgentTool {
|
||||
}
|
||||
|
||||
// ReadOnlyTools returns tools for read-only exploration:
|
||||
// read, grep, find, ls. This matches pi's readOnlyTools collection.
|
||||
// read, grep, find, ls.
|
||||
func ReadOnlyTools(opts ...ToolOption) []fantasy.AgentTool {
|
||||
return []fantasy.AgentTool{
|
||||
NewReadTool(opts...),
|
||||
|
||||
+802
-19
@@ -64,6 +64,19 @@ type Context struct {
|
||||
// }()
|
||||
SendMessage func(string)
|
||||
|
||||
// CancelAndSend cancels the current agent turn (if running), clears
|
||||
// the message queue, and sends a new message that executes as soon as
|
||||
// cancellation completes. If the agent is idle, the message executes
|
||||
// immediately. This is the "steer" delivery mode.
|
||||
//
|
||||
// Use this for directive changes that should interrupt the current
|
||||
// operation, e.g. switching modes or redirecting the agent.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// ctx.CancelAndSend("Stop what you're doing and focus on the tests")
|
||||
CancelAndSend 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
|
||||
@@ -206,6 +219,403 @@ type Context struct {
|
||||
// ResetEditor removes the active editor interceptor and restores the
|
||||
// default built-in editor behavior. No-op if no interceptor is set.
|
||||
ResetEditor func()
|
||||
|
||||
// SetUIVisibility controls which built-in TUI chrome elements are
|
||||
// visible. By default all elements are shown (zero value = show all).
|
||||
// Call this during OnSessionStart to configure the initial layout.
|
||||
//
|
||||
// Example — minimal chrome:
|
||||
//
|
||||
// ctx.SetUIVisibility(ext.UIVisibility{
|
||||
// HideStartupMessage: true,
|
||||
// HideStatusBar: true,
|
||||
// HideSeparator: true,
|
||||
// HideInputHint: true,
|
||||
// })
|
||||
SetUIVisibility func(UIVisibility)
|
||||
|
||||
// GetContextStats returns current context-window usage information
|
||||
// (estimated tokens, context limit, usage percentage, message count).
|
||||
// Useful for building context meters, auto-compaction triggers, etc.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// stats := ctx.GetContextStats()
|
||||
// pct := int(stats.UsagePercent * 100)
|
||||
// fmt.Sprintf("[%s%s] %d%%", strings.Repeat("#", pct/10), strings.Repeat("-", 10-pct/10), pct)
|
||||
GetContextStats func() ContextStats
|
||||
|
||||
// GetMessages returns the conversation messages on the current branch,
|
||||
// ordered from root to leaf. This is a read-only view; extensions
|
||||
// cannot modify messages directly.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// msgs := ctx.GetMessages()
|
||||
// for _, m := range msgs {
|
||||
// if m.Role == "assistant" {
|
||||
// lastResponse = m.Content
|
||||
// }
|
||||
// }
|
||||
GetMessages func() []SessionMessage
|
||||
|
||||
// GetSessionPath returns the file path of the current session's JSONL
|
||||
// file. Returns empty string for in-memory (ephemeral) sessions.
|
||||
GetSessionPath func() string
|
||||
|
||||
// AppendEntry persists custom extension data in the session tree.
|
||||
// The data survives across session restarts and can be retrieved via
|
||||
// GetEntries. Use entryType to namespace your data (e.g. "myext:state").
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// data, _ := json.Marshal(myState)
|
||||
// ctx.AppendEntry("myext:state", string(data))
|
||||
AppendEntry func(entryType string, data string) (string, error)
|
||||
|
||||
// GetEntries retrieves all persisted extension data entries matching
|
||||
// the given type on the current branch, ordered root to leaf. Pass
|
||||
// empty string to retrieve all extension data entries.
|
||||
//
|
||||
// Example — restore state on session resume:
|
||||
//
|
||||
// entries := ctx.GetEntries("myext:state")
|
||||
// if len(entries) > 0 {
|
||||
// last := entries[len(entries)-1]
|
||||
// json.Unmarshal([]byte(last.Data), &myState)
|
||||
// }
|
||||
GetEntries func(entryType string) []ExtensionEntry
|
||||
|
||||
// SetEditorText sets the text content of the input editor. This can
|
||||
// be used to pre-fill the editor with suggested text (e.g. extracted
|
||||
// questions, handoff prompts). The cursor is moved to the end.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// ctx.SetEditorText("Please review the changes in src/main.go")
|
||||
SetEditorText func(text string)
|
||||
|
||||
// SetStatus places or updates a keyed entry in the TUI status bar.
|
||||
// Multiple entries from different extensions coexist; each is identified
|
||||
// by a unique key. Lower priority values render further left.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// ctx.SetStatus("myext:branch", "main", 50)
|
||||
SetStatus func(key string, text string, priority int)
|
||||
|
||||
// RemoveStatus removes a keyed status bar entry. No-op if the key
|
||||
// does not exist.
|
||||
RemoveStatus func(key string)
|
||||
|
||||
// GetOption returns the value of a named extension option. Options are
|
||||
// resolved in priority order:
|
||||
// 1. Runtime override (via SetOption)
|
||||
// 2. Environment variable: KIT_OPT_<NAME> (uppercase, dashes → underscores)
|
||||
// 3. Config file: options.<name> in .kit.yml
|
||||
// 4. Default value registered by the extension
|
||||
//
|
||||
// Returns empty string if the option was not registered.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// preset := ctx.GetOption("preset")
|
||||
// if preset == "fast" {
|
||||
// ctx.SetModel("anthropic/claude-haiku-3-5-20241022")
|
||||
// }
|
||||
GetOption func(name string) string
|
||||
|
||||
// SetOption sets a runtime override for a named extension option. This
|
||||
// takes highest priority over env vars, config, and defaults. Useful for
|
||||
// persisting user choices during a session.
|
||||
SetOption func(name string, value string)
|
||||
|
||||
// SetModel changes the active LLM model at runtime. The model string
|
||||
// should be in "provider/model" format (e.g. "anthropic/claude-sonnet-4-5-20250929").
|
||||
// Existing tools, system prompt, and session are preserved. Returns an
|
||||
// error if the model string is invalid or the provider cannot be created.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// err := ctx.SetModel("openai/gpt-4o")
|
||||
// if err != nil {
|
||||
// ctx.PrintError("Failed to switch model: " + err.Error())
|
||||
// }
|
||||
SetModel func(modelString string) error
|
||||
|
||||
// GetAvailableModels returns a list of known models from the registry.
|
||||
// This is an advisory list — models not in the registry can still be
|
||||
// used by specifying their provider/model string directly.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// models := ctx.GetAvailableModels()
|
||||
// for _, m := range models {
|
||||
// fmt.Printf("%s/%s (ctx: %dk)\n", m.Provider, m.ModelID, m.ContextLimit/1000)
|
||||
// }
|
||||
GetAvailableModels func() []ModelInfoEntry
|
||||
|
||||
// EmitCustomEvent publishes a named event that other extensions can
|
||||
// subscribe to via api.OnCustomEvent(). Data is an arbitrary string
|
||||
// (JSON-encode complex payloads). Handlers run synchronously in
|
||||
// registration order.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// ctx.EmitCustomEvent("plan-mode:toggled", `{"active":true}`)
|
||||
EmitCustomEvent func(name string, data string)
|
||||
|
||||
// GetAllTools returns information about all tools available to the agent,
|
||||
// including core tools (bash, read, write, etc.), MCP server tools, and
|
||||
// extension-registered tools. Each entry includes the tool's enabled status.
|
||||
//
|
||||
// Example — list read-only tools:
|
||||
//
|
||||
// for _, t := range ctx.GetAllTools() {
|
||||
// if t.Source == "core" && t.Enabled {
|
||||
// fmt.Println(t.Name, "-", t.Description)
|
||||
// }
|
||||
// }
|
||||
GetAllTools func() []ToolInfo
|
||||
|
||||
// SetActiveTools restricts the agent to only the named tools. Tools not
|
||||
// in the list are blocked from execution (the LLM receives an error if
|
||||
// it tries to call them). Pass nil or an empty slice to re-enable all
|
||||
// tools. Tool names are case-sensitive.
|
||||
//
|
||||
// Example — plan mode (read-only):
|
||||
//
|
||||
// ctx.SetActiveTools([]string{"Read", "Glob", "Grep", "LS"})
|
||||
SetActiveTools func(names []string)
|
||||
|
||||
// Exit triggers a graceful application shutdown. In interactive mode
|
||||
// this sends a quit signal to the TUI; in non-interactive mode it
|
||||
// cancels the current operation. Safe to call from any goroutine.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// api.RegisterCommand(ext.CommandDef{
|
||||
// Name: "quit",
|
||||
// Description: "Exit the application",
|
||||
// Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
// ctx.Exit()
|
||||
// return "", nil
|
||||
// },
|
||||
// })
|
||||
Exit func()
|
||||
|
||||
// Complete makes a standalone LLM completion call, bypassing the agent
|
||||
// tool loop. Use this for summarisation, question extraction, or any
|
||||
// sub-task that needs an LLM response without tool access.
|
||||
//
|
||||
// If Model is empty the current session model is reused (no extra
|
||||
// provider creation overhead). Specify a different model string to
|
||||
// use a cheaper/faster model for the sub-task.
|
||||
//
|
||||
// Example — summarise with a fast model:
|
||||
//
|
||||
// resp, err := ctx.Complete(ext.CompleteRequest{
|
||||
// Model: "anthropic/claude-haiku-3-5-20241022",
|
||||
// System: "You are a concise summarisation assistant.",
|
||||
// Prompt: "Summarise this conversation:\n" + text,
|
||||
// })
|
||||
// if err != nil {
|
||||
// ctx.PrintError("completion failed: " + err.Error())
|
||||
// return
|
||||
// }
|
||||
// ctx.PrintInfo(resp.Text)
|
||||
//
|
||||
// Example — streaming completion:
|
||||
//
|
||||
// resp, err := ctx.Complete(ext.CompleteRequest{
|
||||
// Prompt: "Explain quantum computing",
|
||||
// OnChunk: func(chunk string) {
|
||||
// fmt.Print(chunk) // stream to stdout
|
||||
// },
|
||||
// })
|
||||
Complete func(CompleteRequest) (CompleteResponse, error)
|
||||
|
||||
// SuspendTUI temporarily releases the terminal from the TUI, runs the
|
||||
// provided callback (which may spawn interactive processes like vim or
|
||||
// htop), and then restores the TUI. In non-interactive mode the
|
||||
// callback runs directly with no terminal changes.
|
||||
//
|
||||
// The callback has full access to stdin/stdout/stderr while the TUI is
|
||||
// suspended. Return from the callback to restore the TUI.
|
||||
//
|
||||
// Example — launch $EDITOR:
|
||||
//
|
||||
// err := ctx.SuspendTUI(func() {
|
||||
// editor := os.Getenv("EDITOR")
|
||||
// if editor == "" { editor = "vim" }
|
||||
// cmd := exec.Command(editor, "file.go")
|
||||
// cmd.Stdin = os.Stdin
|
||||
// cmd.Stdout = os.Stdout
|
||||
// cmd.Stderr = os.Stderr
|
||||
// cmd.Run()
|
||||
// })
|
||||
SuspendTUI func(callback func()) error
|
||||
|
||||
// RenderMessage outputs text using a named message renderer registered
|
||||
// by an extension via api.RegisterMessageRenderer(). If no renderer
|
||||
// with the given name exists, the content is printed as plain text.
|
||||
//
|
||||
// This allows extensions to define reusable visual styles (borders,
|
||||
// colors, formatting) for specific message categories and invoke them
|
||||
// by name at runtime.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// ctx.RenderMessage("build-status", "All 42 tests passed.")
|
||||
RenderMessage func(rendererName string, content string)
|
||||
|
||||
// ReloadExtensions hot-reloads all extensions from disk. Existing
|
||||
// extensions receive a SessionShutdown event, then new code is loaded
|
||||
// and receives a SessionStart event. Event handlers, commands,
|
||||
// renderers, and shortcuts update immediately; extension-defined tools
|
||||
// are NOT updated (they are baked into the agent at creation time).
|
||||
//
|
||||
// After calling ReloadExtensions the calling extension's code has been
|
||||
// replaced; the caller should return promptly.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// api.RegisterCommand(ext.CommandDef{
|
||||
// Name: "reload",
|
||||
// Description: "Hot-reload all extensions",
|
||||
// Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
// if err := ctx.ReloadExtensions(); err != nil {
|
||||
// return "", err
|
||||
// }
|
||||
// return "Extensions reloaded", nil
|
||||
// },
|
||||
// })
|
||||
ReloadExtensions func() error
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session types (exposed to Yaegi — concrete structs for session access)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SessionMessage represents a conversation message exposed to extensions.
|
||||
// This is a simplified, read-only view of the internal message structures.
|
||||
type SessionMessage struct {
|
||||
// ID is the unique entry identifier in the session tree.
|
||||
ID string
|
||||
// ParentID links this entry to its parent in the tree.
|
||||
ParentID string
|
||||
// Role is the message role: "user", "assistant", "tool", or "system".
|
||||
Role string
|
||||
// Content is the text content of the message (tool calls and results
|
||||
// are serialized as text summaries).
|
||||
Content string
|
||||
// Model is the model that generated this message (empty for user messages).
|
||||
Model string
|
||||
// Provider is the provider used (empty for user messages).
|
||||
Provider string
|
||||
// Timestamp is the RFC3339-formatted creation time.
|
||||
Timestamp string
|
||||
}
|
||||
|
||||
// ExtensionEntry represents persisted extension data stored in the session.
|
||||
// Extensions use AppendEntry to save custom state and GetEntries to retrieve
|
||||
// it on session resume.
|
||||
type ExtensionEntry struct {
|
||||
// ID is the unique entry identifier.
|
||||
ID string
|
||||
// EntryType is the extension-defined type string (e.g. "plan-mode:state").
|
||||
EntryType string
|
||||
// Data is the extension-defined payload (JSON or plain text).
|
||||
Data string
|
||||
// Timestamp is the RFC3339-formatted creation time.
|
||||
Timestamp string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context filtering types (exposed to Yaegi — concrete structs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ContextMessage represents a single message in the LLM context window.
|
||||
// Used by OnContextPrepare to let extensions inspect and modify the messages
|
||||
// that will be sent to the LLM.
|
||||
type ContextMessage struct {
|
||||
// Index is the position of this message in the original context array
|
||||
// (0-based). When returning messages from a ContextPrepareResult,
|
||||
// messages with Index >= 0 reuse the original fantasy.Message at that
|
||||
// position (preserving tool calls, reasoning, and other complex parts).
|
||||
// Set Index to -1 for newly injected messages (created from Role + Content).
|
||||
Index int
|
||||
|
||||
// Role is the message role: "user", "assistant", "system", or "tool".
|
||||
Role string
|
||||
|
||||
// Content is the text content of the message. For assistant messages
|
||||
// with tool calls, this includes a text summary of the calls.
|
||||
Content string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LLM completion types (exposed to Yaegi — concrete structs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// CompleteRequest configures a standalone LLM completion call. Extensions use
|
||||
// this with ctx.Complete() to make direct LLM calls without the agent tool loop.
|
||||
type CompleteRequest struct {
|
||||
// Model is the model to use in "provider/model" format (e.g.
|
||||
// "anthropic/claude-haiku-3-5-20241022"). Empty string uses the current
|
||||
// session model, avoiding extra provider creation overhead.
|
||||
Model string
|
||||
|
||||
// Prompt is the user input text sent to the model.
|
||||
Prompt string
|
||||
|
||||
// System is an optional system prompt. Empty uses no system prompt.
|
||||
System string
|
||||
|
||||
// Messages is optional conversation history. If provided, Prompt is
|
||||
// appended as the final user message.
|
||||
Messages []SessionMessage
|
||||
|
||||
// MaxTokens limits the response length (0 = provider default).
|
||||
MaxTokens int
|
||||
|
||||
// OnChunk is called for each streaming text delta. When set, the
|
||||
// completion is performed in streaming mode. When nil, the call blocks
|
||||
// until the full response is available.
|
||||
OnChunk func(chunk string)
|
||||
}
|
||||
|
||||
// CompleteResponse contains the LLM response and usage metadata from a
|
||||
// standalone completion call.
|
||||
type CompleteResponse struct {
|
||||
// Text is the complete response text.
|
||||
Text string
|
||||
|
||||
// InputTokens is the number of tokens in the request.
|
||||
InputTokens int
|
||||
|
||||
// OutputTokens is the number of tokens in the response.
|
||||
OutputTokens int
|
||||
|
||||
// Model is the actual model used (useful when CompleteRequest.Model was empty).
|
||||
Model string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status bar types (exposed to Yaegi — concrete structs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// StatusBarEntry represents a keyed entry in the TUI status bar. Extensions
|
||||
// can set multiple independent entries that render alongside the built-in
|
||||
// model name and token usage display.
|
||||
type StatusBarEntry struct {
|
||||
// Key uniquely identifies this entry (e.g. "myext:git-branch").
|
||||
Key string
|
||||
// Text is the rendered content shown in the status bar.
|
||||
Text string
|
||||
// Priority controls ordering. Lower values render further left.
|
||||
// Built-in entries (model, usage) have implicit priority 100-110.
|
||||
Priority int
|
||||
}
|
||||
|
||||
// PrintBlockOpts configures a custom styled block for PrintBlock.
|
||||
@@ -233,22 +643,31 @@ 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)
|
||||
registerToolRendererFn func(ToolRenderConfig)
|
||||
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)
|
||||
onModelChange func(func(ModelChangeEvent, Context))
|
||||
onContextPrepare func(func(ContextPrepareEvent, Context) *ContextPrepareResult)
|
||||
onBeforeFork func(func(BeforeForkEvent, Context) *BeforeForkResult)
|
||||
onBeforeSessionSwitch func(func(BeforeSessionSwitchEvent, Context) *BeforeSessionSwitchResult)
|
||||
onBeforeCompact func(func(BeforeCompactEvent, Context) *BeforeCompactResult)
|
||||
onCustomEvent func(name string, handler func(string))
|
||||
registerOption func(OptionDef)
|
||||
registerShortcutFn func(ShortcutDef, func(Context))
|
||||
registerMessageRendererFn func(MessageRendererConfig)
|
||||
}
|
||||
|
||||
// OnToolCall registers a handler that fires before a tool executes.
|
||||
@@ -319,6 +738,36 @@ func (a *API) OnSessionShutdown(handler func(SessionShutdownEvent, Context)) {
|
||||
a.onSessionShutdown(handler)
|
||||
}
|
||||
|
||||
// OnModelChange registers a handler that fires after the active model is
|
||||
// changed via ctx.SetModel(). The handler receives the new and previous model
|
||||
// strings plus the source of the change.
|
||||
func (a *API) OnModelChange(handler func(ModelChangeEvent, Context)) {
|
||||
a.onModelChange(handler)
|
||||
}
|
||||
|
||||
// OnContextPrepare registers a handler that fires after the context window is
|
||||
// built from the session tree (including compaction) and before the messages
|
||||
// are sent to the LLM. The handler can inspect the context and return a
|
||||
// modified message set to filter, reorder, or inject messages.
|
||||
//
|
||||
// Return nil to leave the context unchanged. Return a non-nil result with
|
||||
// a Messages slice to replace the context window entirely. Messages with a
|
||||
// non-negative Index reuse the original message at that position (preserving
|
||||
// tool calls, reasoning parts, etc.); messages with Index < 0 are created
|
||||
// fresh from Role + Content.
|
||||
//
|
||||
// Example — inject a RAG context message:
|
||||
//
|
||||
// api.OnContextPrepare(func(e ext.ContextPrepareEvent, ctx ext.Context) *ext.ContextPrepareResult {
|
||||
// ragContext := fetchRelevantDocs(e.Messages[len(e.Messages)-1].Content)
|
||||
// injected := ext.ContextMessage{Index: -1, Role: "system", Content: ragContext}
|
||||
// msgs := append([]ext.ContextMessage{injected}, e.Messages...)
|
||||
// return &ext.ContextPrepareResult{Messages: msgs}
|
||||
// })
|
||||
func (a *API) OnContextPrepare(handler func(ContextPrepareEvent, Context) *ContextPrepareResult) {
|
||||
a.onContextPrepare(handler)
|
||||
}
|
||||
|
||||
// RegisterTool adds a custom tool that the LLM can invoke.
|
||||
func (a *API) RegisterTool(tool ToolDef) {
|
||||
a.registerToolFn(tool)
|
||||
@@ -329,6 +778,55 @@ func (a *API) RegisterCommand(cmd CommandDef) {
|
||||
a.registerCmdFn(cmd)
|
||||
}
|
||||
|
||||
// RegisterOption declares a named configuration option. The option can be set
|
||||
// via environment variables (KIT_OPT_<NAME>) or config file (options.<name>).
|
||||
// Multiple extensions can register options with the same name; the last default
|
||||
// wins.
|
||||
func (a *API) RegisterOption(opt OptionDef) {
|
||||
a.registerOption(opt)
|
||||
}
|
||||
|
||||
// RegisterShortcut registers a global keyboard shortcut that fires across
|
||||
// all app states except modal prompts/overlays. Use modifier combinations
|
||||
// like "ctrl+p", "alt+t", or "f1" — avoid bare characters that conflict
|
||||
// with text input. If multiple extensions register the same key, the last
|
||||
// registration wins. The handler runs in a goroutine so it can call blocking
|
||||
// APIs like PromptSelect without stalling the TUI event loop.
|
||||
func (a *API) RegisterShortcut(def ShortcutDef, handler func(Context)) {
|
||||
if a.registerShortcutFn != nil {
|
||||
a.registerShortcutFn(def, handler)
|
||||
}
|
||||
}
|
||||
|
||||
// OnCustomEvent registers a handler for a custom inter-extension event.
|
||||
// The handler receives the data string published by EmitCustomEvent.
|
||||
// Multiple handlers can subscribe to the same event name; they execute
|
||||
// in registration order.
|
||||
func (a *API) OnCustomEvent(name string, handler func(string)) {
|
||||
a.onCustomEvent(name, handler)
|
||||
}
|
||||
|
||||
// OnBeforeFork registers a handler that fires before the session tree is
|
||||
// branched to a different entry point. Return a non-nil BeforeForkResult
|
||||
// with Cancel=true to prevent the fork.
|
||||
func (a *API) OnBeforeFork(handler func(BeforeForkEvent, Context) *BeforeForkResult) {
|
||||
a.onBeforeFork(handler)
|
||||
}
|
||||
|
||||
// OnBeforeSessionSwitch registers a handler that fires before the session
|
||||
// is switched to a new branch (e.g. /new command). Return a non-nil
|
||||
// BeforeSessionSwitchResult with Cancel=true to prevent the switch.
|
||||
func (a *API) OnBeforeSessionSwitch(handler func(BeforeSessionSwitchEvent, Context) *BeforeSessionSwitchResult) {
|
||||
a.onBeforeSessionSwitch(handler)
|
||||
}
|
||||
|
||||
// OnBeforeCompact registers a handler that fires before context compaction
|
||||
// runs. Return a non-nil BeforeCompactResult with Cancel=true to prevent
|
||||
// compaction from proceeding.
|
||||
func (a *API) OnBeforeCompact(handler func(BeforeCompactEvent, Context) *BeforeCompactResult) {
|
||||
a.onBeforeCompact(handler)
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -337,6 +835,17 @@ func (a *API) RegisterToolRenderer(config ToolRenderConfig) {
|
||||
a.registerToolRendererFn(config)
|
||||
}
|
||||
|
||||
// RegisterMessageRenderer registers a named message renderer that extensions
|
||||
// can invoke via ctx.RenderMessage(name, content). Use this to define
|
||||
// reusable visual styles for branded output, progress reports, or custom
|
||||
// notification formats. If multiple extensions register the same name, the
|
||||
// last one wins.
|
||||
func (a *API) RegisterMessageRenderer(config MessageRendererConfig) {
|
||||
if a.registerMessageRendererFn != nil {
|
||||
a.registerMessageRendererFn(config)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Widget types (exposed to Yaegi — concrete structs, no interfaces)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -472,6 +981,36 @@ type HeaderFooterConfig struct {
|
||||
Style WidgetStyle
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI visibility (exposed to Yaegi — concrete struct)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// UIVisibility controls which built-in TUI chrome elements are visible.
|
||||
// The zero value shows everything (backward compatible). Extensions call
|
||||
// ctx.SetUIVisibility to customise the layout — for example, a "minimal"
|
||||
// theme can hide the startup banner, status bar, and input hint and replace
|
||||
// them with a single custom footer.
|
||||
type UIVisibility struct {
|
||||
HideStartupMessage bool // Hide the "Model loaded..." startup block
|
||||
HideStatusBar bool // Hide the "provider · model Tokens: ..." line
|
||||
HideSeparator bool // Hide the "────────" divider between stream and input
|
||||
HideInputHint bool // Hide the "enter submit · ctrl+j..." hint below input
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context stats (exposed to Yaegi — concrete struct)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ContextStats contains current context-window usage information.
|
||||
// Extensions can poll this via ctx.GetContextStats() to build usage
|
||||
// meters, auto-compaction triggers, etc.
|
||||
type ContextStats struct {
|
||||
EstimatedTokens int // Estimated token count of the current conversation
|
||||
ContextLimit int // Model's context window size (tokens), 0 if unknown
|
||||
UsagePercent float64 // Fraction of context used (0.0–1.0), 0 if limit unknown
|
||||
MessageCount int // Number of messages in the conversation
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Overlay types (exposed to Yaegi — concrete structs)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -563,16 +1102,73 @@ type OverlayResult struct {
|
||||
Cancelled bool
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Model info types (exposed to Yaegi — concrete structs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ModelInfoEntry represents a known model from the registry. Used by
|
||||
// GetAvailableModels to let extensions discover which models are available.
|
||||
type ModelInfoEntry struct {
|
||||
// Provider is the provider ID (e.g. "anthropic", "openai").
|
||||
Provider string
|
||||
// ModelID is the model identifier (e.g. "claude-sonnet-4-5-20250929").
|
||||
ModelID string
|
||||
// Name is the human-readable model name.
|
||||
Name string
|
||||
// ContextLimit is the maximum context window in tokens (0 if unknown).
|
||||
ContextLimit int
|
||||
// OutputLimit is the maximum output tokens (0 if unknown).
|
||||
OutputLimit int
|
||||
// Reasoning is true if the model supports extended thinking.
|
||||
Reasoning bool
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool info types (exposed to Yaegi — concrete structs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ToolInfo provides read-only information about a tool available to the agent.
|
||||
// Used by GetAllTools to let extensions inspect and filter the tool set.
|
||||
type ToolInfo struct {
|
||||
// Name is the tool's unique identifier.
|
||||
Name string
|
||||
// Description is the tool's human-readable description.
|
||||
Description string
|
||||
// Source indicates where the tool came from: "core", "mcp", or "extension".
|
||||
Source string
|
||||
// Enabled is true if the tool is currently active.
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ToolDef / CommandDef
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ToolContext provides runtime context to a tool's ExecuteWithContext handler.
|
||||
// It allows tools to check for cancellation and report progress while running.
|
||||
type ToolContext struct {
|
||||
// IsCancelled returns true when the tool's execution has been cancelled
|
||||
// (e.g. the user interrupted the agent or the request timed out).
|
||||
// Long-running tools should poll this periodically and return early.
|
||||
IsCancelled func() bool
|
||||
// OnProgress sends a progress message that is displayed in the TUI
|
||||
// while the tool is executing. Useful for long-running operations
|
||||
// that want to show incremental status.
|
||||
OnProgress func(text string)
|
||||
}
|
||||
|
||||
// ToolDef describes a custom tool registered by an extension.
|
||||
type ToolDef struct {
|
||||
Name string
|
||||
Description string
|
||||
Parameters string // JSON Schema string
|
||||
Execute func(input string) (string, error)
|
||||
// Execute is the simple handler — receives JSON input, returns text result.
|
||||
// Use this for tools that don't need cancellation or progress reporting.
|
||||
Execute func(input string) (string, error)
|
||||
// ExecuteWithContext is the rich handler — receives JSON input plus a
|
||||
// ToolContext that provides cancellation checking and progress reporting.
|
||||
// If both Execute and ExecuteWithContext are set, ExecuteWithContext wins.
|
||||
ExecuteWithContext func(input string, tc ToolContext) (string, error)
|
||||
}
|
||||
|
||||
// CommandDef describes a slash command registered by an extension.
|
||||
@@ -580,6 +1176,74 @@ type CommandDef struct {
|
||||
Name string
|
||||
Description string
|
||||
Execute func(args string, ctx Context) (string, error)
|
||||
// Complete provides argument tab-completion for this command.
|
||||
// Called with the partial argument text typed so far; returns
|
||||
// candidate completions. Nil means no argument completion.
|
||||
Complete func(prefix string, ctx Context) []string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keyboard shortcuts (exposed to Yaegi — concrete structs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ShortcutDef describes a global keyboard shortcut registered by an extension.
|
||||
// Shortcuts fire across all app states except modal prompts/overlays.
|
||||
// Use modifier combinations (e.g., "ctrl+p", "alt+t", "f1") — avoid bare
|
||||
// characters like "a" or "x" which conflict with text input.
|
||||
type ShortcutDef struct {
|
||||
// Key is the key binding (e.g., "ctrl+p", "alt+t", "f1", "ctrl+shift+s").
|
||||
Key string
|
||||
// Description explains what the shortcut does (shown in /shortcuts help).
|
||||
Description string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom message rendering (exposed to Yaegi — concrete structs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// MessageRendererConfig provides a named rendering function that extensions
|
||||
// can invoke via ctx.RenderMessage(name, content). Unlike tool renderers
|
||||
// (which hook into the automatic tool result display), message renderers are
|
||||
// invoked explicitly by extension code for branded status updates, progress
|
||||
// reports, or any custom visual output.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// api.RegisterMessageRenderer(ext.MessageRendererConfig{
|
||||
// Name: "build-status",
|
||||
// Render: func(content string, width int) string {
|
||||
// border := strings.Repeat("─", width-4)
|
||||
// return "╭" + border + "╮\n│ " + content + "\n╰" + border + "╯"
|
||||
// },
|
||||
// })
|
||||
type MessageRendererConfig struct {
|
||||
// Name uniquely identifies this renderer. Used by ctx.RenderMessage
|
||||
// to look it up at call time. Should be namespaced to avoid collisions
|
||||
// (e.g. "myext:build-status").
|
||||
Name string
|
||||
|
||||
// Render produces the styled output string from raw content. Receives
|
||||
// the content and the terminal width in columns. Return the final
|
||||
// ANSI-styled string to print; it will be emitted via tea.Println
|
||||
// (or plain stdout in non-interactive mode).
|
||||
Render func(content string, width int) string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extension options (exposed to Yaegi — concrete structs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// OptionDef describes a configuration option that an extension can register.
|
||||
// Options are resolved from env vars, config file, or default value.
|
||||
type OptionDef struct {
|
||||
// Name is the option identifier. Used as:
|
||||
// - Env var: KIT_OPT_<NAME> (uppercased, dashes → underscores)
|
||||
// - Config key: options.<name> in .kit.yml
|
||||
Name string
|
||||
// Description explains what the option controls.
|
||||
Description string
|
||||
// Default is the fallback value if not set via env or config.
|
||||
Default string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -693,8 +1357,7 @@ type EditorKeyAction struct {
|
||||
// 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.
|
||||
// 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
|
||||
@@ -735,6 +1398,10 @@ type ToolCallEvent struct {
|
||||
ToolName string
|
||||
ToolCallID string
|
||||
Input string // JSON-encoded tool parameters
|
||||
// Source indicates who initiated the tool call.
|
||||
// Currently always "llm" (all tool calls originate from the LLM agent loop).
|
||||
// Future user-initiated tool features may set this to "user".
|
||||
Source string
|
||||
}
|
||||
|
||||
func (e ToolCallEvent) Type() EventType { return ToolCall }
|
||||
@@ -856,4 +1523,120 @@ func (e SessionStartEvent) Type() EventType { return SessionStart }
|
||||
// SessionShutdownEvent fires when the application is closing.
|
||||
type SessionShutdownEvent struct{}
|
||||
|
||||
// ModelChangeEvent fires after the active model is changed via ctx.SetModel().
|
||||
type ModelChangeEvent struct {
|
||||
// NewModel is the model string that was set (e.g. "anthropic/claude-sonnet-4-5-20250929").
|
||||
NewModel string
|
||||
// PreviousModel is the model string before the change.
|
||||
PreviousModel string
|
||||
// Source indicates what triggered the change: "extension" for ctx.SetModel(),
|
||||
// "user" for interactive model selection.
|
||||
Source string
|
||||
}
|
||||
|
||||
func (e SessionShutdownEvent) Type() EventType { return SessionShutdown }
|
||||
|
||||
func (e ModelChangeEvent) Type() EventType { return ModelChange }
|
||||
|
||||
// ContextPrepareEvent fires after the context window is built from the session
|
||||
// tree and before the messages are sent to the LLM. Handlers can inspect the
|
||||
// messages and return a modified set to filter, reorder, or inject context.
|
||||
type ContextPrepareEvent struct {
|
||||
// Messages is the current context window that will be sent to the LLM.
|
||||
// Each ContextMessage includes an Index field that maps back to the
|
||||
// position in the original message array (for identity-preserving edits).
|
||||
Messages []ContextMessage
|
||||
}
|
||||
|
||||
func (e ContextPrepareEvent) Type() EventType { return ContextPrepare }
|
||||
|
||||
// ContextPrepareResult allows extensions to replace the context window.
|
||||
// Return nil to leave the context unchanged.
|
||||
type ContextPrepareResult struct {
|
||||
// Messages replaces the entire context window. Each entry with a
|
||||
// non-negative Index reuses the original message at that position
|
||||
// (preserving tool calls, reasoning, etc.); entries with Index < 0
|
||||
// are created fresh from Role + Content.
|
||||
Messages []ContextMessage
|
||||
}
|
||||
|
||||
func (ContextPrepareResult) isResult() {}
|
||||
|
||||
// BeforeForkEvent fires before the session tree is branched to a different
|
||||
// entry point (via the tree selector or /fork command).
|
||||
type BeforeForkEvent struct {
|
||||
// TargetID is the session entry ID being branched to.
|
||||
TargetID string
|
||||
// IsUserMessage is true if the selected entry is a user message
|
||||
// (which causes the fork to target the parent entry).
|
||||
IsUserMessage bool
|
||||
// UserText is the user message text (non-empty only when IsUserMessage is true).
|
||||
UserText string
|
||||
}
|
||||
|
||||
func (e BeforeForkEvent) Type() EventType { return BeforeFork }
|
||||
|
||||
// BeforeForkResult controls whether the fork proceeds. Return Cancel=true
|
||||
// with an optional Reason to block the fork.
|
||||
type BeforeForkResult struct {
|
||||
// Cancel, when true, prevents the fork from proceeding.
|
||||
Cancel bool
|
||||
// Reason is a human-readable explanation shown to the user when
|
||||
// Cancel is true. Empty string uses a default message.
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (BeforeForkResult) isResult() {}
|
||||
|
||||
// BeforeSessionSwitchEvent fires before the session is switched to a new
|
||||
// branch (e.g. /new or /clear commands).
|
||||
type BeforeSessionSwitchEvent struct {
|
||||
// Reason describes why the switch is happening: "new" for /new command,
|
||||
// "clear" for /clear command.
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e BeforeSessionSwitchEvent) Type() EventType { return BeforeSessionSwitch }
|
||||
|
||||
// BeforeSessionSwitchResult controls whether the session switch proceeds.
|
||||
// Return Cancel=true with an optional Reason to block the switch.
|
||||
type BeforeSessionSwitchResult struct {
|
||||
// Cancel, when true, prevents the session switch from proceeding.
|
||||
Cancel bool
|
||||
// Reason is a human-readable explanation shown to the user when
|
||||
// Cancel is true. Empty string uses a default message.
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (BeforeSessionSwitchResult) isResult() {}
|
||||
|
||||
// BeforeCompactEvent fires before context compaction runs. Provides
|
||||
// information about the current context state to help extensions decide
|
||||
// whether to allow or block compaction.
|
||||
type BeforeCompactEvent struct {
|
||||
// EstimatedTokens is the estimated token count of the conversation.
|
||||
EstimatedTokens int
|
||||
// ContextLimit is the model's context window size in tokens.
|
||||
ContextLimit int
|
||||
// UsagePercent is the fraction of context used (0.0–1.0).
|
||||
UsagePercent float64
|
||||
// MessageCount is the number of messages in the conversation.
|
||||
MessageCount int
|
||||
// IsAutomatic is true when compaction was triggered automatically
|
||||
// (as opposed to manual /compact command).
|
||||
IsAutomatic bool
|
||||
}
|
||||
|
||||
func (e BeforeCompactEvent) Type() EventType { return BeforeCompact }
|
||||
|
||||
// BeforeCompactResult controls whether compaction proceeds. Return
|
||||
// Cancel=true with an optional Reason to block compaction.
|
||||
type BeforeCompactResult struct {
|
||||
// Cancel, when true, prevents compaction from proceeding.
|
||||
Cancel bool
|
||||
// Reason is a human-readable explanation shown to the user when
|
||||
// Cancel is true. Empty string uses a default message.
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (BeforeCompactResult) isResult() {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package extensions implements a Pi-style in-process extension system for KIT.
|
||||
// Package extensions implements an in-process extension system for KIT.
|
||||
// Extensions are plain Go files loaded at runtime via Yaegi (a Go interpreter).
|
||||
// They register event handlers using an API object, enabling tool interception,
|
||||
// input transformation, and lifecycle observation — all without recompilation.
|
||||
@@ -48,6 +48,26 @@ const (
|
||||
|
||||
// SessionShutdown fires when the application is closing.
|
||||
SessionShutdown EventType = "session_shutdown"
|
||||
|
||||
// ModelChange fires after the active model is changed via ctx.SetModel().
|
||||
ModelChange EventType = "model_change"
|
||||
|
||||
// ContextPrepare fires after context is built from the session tree and
|
||||
// before the messages are sent to the LLM. Handlers can filter, reorder,
|
||||
// or inject messages into the context window.
|
||||
ContextPrepare EventType = "context_prepare"
|
||||
|
||||
// BeforeFork fires before the session tree is branched to a different
|
||||
// entry point. Handlers can cancel the fork by returning Cancel=true.
|
||||
BeforeFork EventType = "before_fork"
|
||||
|
||||
// BeforeSessionSwitch fires before the session is switched to a new
|
||||
// branch (e.g. /new command). Handlers can cancel by returning Cancel=true.
|
||||
BeforeSessionSwitch EventType = "before_session_switch"
|
||||
|
||||
// BeforeCompact fires before context compaction runs. Handlers can
|
||||
// cancel compaction by returning Cancel=true.
|
||||
BeforeCompact EventType = "before_compact"
|
||||
)
|
||||
|
||||
// AllEventTypes returns every supported event type.
|
||||
@@ -57,6 +77,8 @@ func AllEventTypes() []EventType {
|
||||
Input, BeforeAgentStart, AgentStart, AgentEnd,
|
||||
MessageStart, MessageUpdate, MessageEnd,
|
||||
SessionStart, SessionShutdown,
|
||||
ModelChange, ContextPrepare,
|
||||
BeforeFork, BeforeSessionSwitch, BeforeCompact,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import "testing"
|
||||
|
||||
func TestAllEventTypes_Count(t *testing.T) {
|
||||
all := AllEventTypes()
|
||||
if len(all) != 13 {
|
||||
t.Fatalf("expected 13 event types, got %d", len(all))
|
||||
if len(all) != 18 {
|
||||
t.Fatalf("expected 18 event types, got %d", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,11 @@ func TestEventType_TypeMethod(t *testing.T) {
|
||||
{MessageEndEvent{Content: "done"}, MessageEnd},
|
||||
{SessionStartEvent{SessionID: "abc"}, SessionStart},
|
||||
{SessionShutdownEvent{}, SessionShutdown},
|
||||
{ModelChangeEvent{NewModel: "a/b"}, ModelChange},
|
||||
{ContextPrepareEvent{Messages: []ContextMessage{{Index: 0, Role: "user", Content: "hi"}}}, ContextPrepare},
|
||||
{BeforeForkEvent{TargetID: "abc"}, BeforeFork},
|
||||
{BeforeSessionSwitchEvent{Reason: "new"}, BeforeSessionSwitch},
|
||||
{BeforeCompactEvent{EstimatedTokens: 1000}, BeforeCompact},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -283,6 +283,48 @@ func loadSingleExtension(path string) (*LoadedExtension, error) {
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onModelChange: func(h func(ModelChangeEvent, Context)) {
|
||||
reg(ModelChange, func(e Event, c Context) Result {
|
||||
h(e.(ModelChangeEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onContextPrepare: func(h func(ContextPrepareEvent, Context) *ContextPrepareResult) {
|
||||
reg(ContextPrepare, func(e Event, c Context) Result {
|
||||
r := h(e.(ContextPrepareEvent), c)
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return *r
|
||||
})
|
||||
},
|
||||
onBeforeFork: func(h func(BeforeForkEvent, Context) *BeforeForkResult) {
|
||||
reg(BeforeFork, func(e Event, c Context) Result {
|
||||
r := h(e.(BeforeForkEvent), c)
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return *r
|
||||
})
|
||||
},
|
||||
onBeforeSessionSwitch: func(h func(BeforeSessionSwitchEvent, Context) *BeforeSessionSwitchResult) {
|
||||
reg(BeforeSessionSwitch, func(e Event, c Context) Result {
|
||||
r := h(e.(BeforeSessionSwitchEvent), c)
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return *r
|
||||
})
|
||||
},
|
||||
onBeforeCompact: func(h func(BeforeCompactEvent, Context) *BeforeCompactResult) {
|
||||
reg(BeforeCompact, func(e Event, c Context) Result {
|
||||
r := h(e.(BeforeCompactEvent), c)
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return *r
|
||||
})
|
||||
},
|
||||
registerToolFn: func(tool ToolDef) {
|
||||
ext.Tools = append(ext.Tools, tool)
|
||||
},
|
||||
@@ -292,6 +334,21 @@ func loadSingleExtension(path string) (*LoadedExtension, error) {
|
||||
registerToolRendererFn: func(config ToolRenderConfig) {
|
||||
ext.ToolRenderers = append(ext.ToolRenderers, config)
|
||||
},
|
||||
registerMessageRendererFn: func(config MessageRendererConfig) {
|
||||
ext.MessageRenderers = append(ext.MessageRenderers, config)
|
||||
},
|
||||
onCustomEvent: func(name string, handler func(string)) {
|
||||
if ext.CustomEventHandlers == nil {
|
||||
ext.CustomEventHandlers = make(map[string][]func(string))
|
||||
}
|
||||
ext.CustomEventHandlers[name] = append(ext.CustomEventHandlers[name], handler)
|
||||
},
|
||||
registerOption: func(opt OptionDef) {
|
||||
ext.Options = append(ext.Options, opt)
|
||||
},
|
||||
registerShortcutFn: func(def ShortcutDef, handler func(Context)) {
|
||||
ext.Shortcuts = append(ext.Shortcuts, ShortcutEntry{Def: def, Handler: handler})
|
||||
},
|
||||
}
|
||||
|
||||
// Call Init — the extension registers its handlers, tools, commands.
|
||||
|
||||
+333
-20
@@ -2,34 +2,52 @@ package extensions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// Runner manages loaded extensions and dispatches events to their handlers
|
||||
// sequentially, mirroring Pi's ExtensionRunner. Handlers execute in extension
|
||||
// sequentially. Handlers execute in extension
|
||||
// load order; for cancellable events the first blocking result wins.
|
||||
type Runner struct {
|
||||
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
|
||||
extensions []LoadedExtension
|
||||
ctx Context
|
||||
widgets map[string]WidgetConfig // keyed by widget ID
|
||||
statusEntries map[string]StatusBarEntry // keyed by status key
|
||||
header *HeaderFooterConfig // nil = no custom header
|
||||
footer *HeaderFooterConfig // nil = no custom footer
|
||||
customEditor *EditorConfig // nil = no custom editor interceptor
|
||||
uiVisibility *UIVisibility // nil = show everything (default)
|
||||
disabledTools map[string]bool // nil = all tools enabled
|
||||
customEventSubs map[string][]func(string) // inter-extension event bus
|
||||
optionOverrides map[string]string // runtime option overrides
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// ShortcutEntry pairs a shortcut definition with its handler.
|
||||
type ShortcutEntry struct {
|
||||
Def ShortcutDef
|
||||
Handler func(Context)
|
||||
}
|
||||
|
||||
// LoadedExtension represents a single extension that has been discovered,
|
||||
// loaded, and initialised. It holds the registered handlers and any custom
|
||||
// tools, commands, or tool renderers the extension provided.
|
||||
type LoadedExtension struct {
|
||||
Path string
|
||||
Handlers map[EventType][]HandlerFunc
|
||||
Tools []ToolDef
|
||||
Commands []CommandDef
|
||||
ToolRenderers []ToolRenderConfig
|
||||
Path string
|
||||
Handlers map[EventType][]HandlerFunc
|
||||
Tools []ToolDef
|
||||
Commands []CommandDef
|
||||
ToolRenderers []ToolRenderConfig
|
||||
MessageRenderers []MessageRendererConfig // named message renderers
|
||||
CustomEventHandlers map[string][]func(string) // inter-extension event bus
|
||||
Options []OptionDef // registered configuration options
|
||||
Shortcuts []ShortcutEntry // global keyboard shortcuts
|
||||
}
|
||||
|
||||
// NewRunner creates a Runner from a set of loaded extensions.
|
||||
@@ -45,6 +63,13 @@ func (r *Runner) SetContext(ctx Context) {
|
||||
r.ctx = ctx
|
||||
}
|
||||
|
||||
// GetContext returns a snapshot of the current runtime context. Thread-safe.
|
||||
func (r *Runner) GetContext() Context {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.ctx
|
||||
}
|
||||
|
||||
// HasHandlers returns true if any loaded extension has at least one handler
|
||||
// registered for the given event type.
|
||||
func (r *Runner) HasHandlers(event EventType) bool {
|
||||
@@ -121,13 +146,6 @@ func (r *Runner) RegisteredCommands() []CommandDef {
|
||||
return cmds
|
||||
}
|
||||
|
||||
// GetContext returns the current runtime context. Thread-safe.
|
||||
func (r *Runner) GetContext() Context {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.ctx
|
||||
}
|
||||
|
||||
// Extensions returns the loaded extensions for inspection (e.g. CLI list).
|
||||
func (r *Runner) Extensions() []LoadedExtension {
|
||||
return r.extensions
|
||||
@@ -177,6 +195,45 @@ func (r *Runner) GetWidgets(placement WidgetPlacement) []WidgetConfig {
|
||||
return result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status bar management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SetStatusEntry places or updates a keyed status bar entry. Thread-safe.
|
||||
func (r *Runner) SetStatusEntry(entry StatusBarEntry) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.statusEntries == nil {
|
||||
r.statusEntries = make(map[string]StatusBarEntry)
|
||||
}
|
||||
r.statusEntries[entry.Key] = entry
|
||||
}
|
||||
|
||||
// RemoveStatusEntry removes a status bar entry by key. Thread-safe.
|
||||
func (r *Runner) RemoveStatusEntry(key string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
delete(r.statusEntries, key)
|
||||
}
|
||||
|
||||
// GetStatusEntries returns all status bar entries, sorted by priority
|
||||
// (ascending). Thread-safe.
|
||||
func (r *Runner) GetStatusEntries() []StatusBarEntry {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
result := make([]StatusBarEntry, 0, len(r.statusEntries))
|
||||
for _, e := range r.statusEntries {
|
||||
result = append(result, e)
|
||||
}
|
||||
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].Key < result[j].Key
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Header/Footer management
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -269,6 +326,29 @@ func (r *Runner) GetEditor() *EditorConfig {
|
||||
return &e
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI visibility management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SetUIVisibility updates the UI visibility overrides. Thread-safe.
|
||||
func (r *Runner) SetUIVisibility(v UIVisibility) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.uiVisibility = &v
|
||||
}
|
||||
|
||||
// GetUIVisibility returns the current UI visibility overrides, or nil if
|
||||
// none have been set (meaning show everything). Thread-safe.
|
||||
func (r *Runner) GetUIVisibility() *UIVisibility {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
if r.uiVisibility == nil {
|
||||
return nil
|
||||
}
|
||||
v := *r.uiVisibility
|
||||
return &v
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool renderer management
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -290,6 +370,233 @@ func (r *Runner) GetToolRenderer(toolName string) *ToolRenderConfig {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message renderer management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GetMessageRenderer returns the named message renderer, or nil if no
|
||||
// extension registered a renderer with that name. If multiple extensions
|
||||
// register the same name, the last one (by load order) wins.
|
||||
func (r *Runner) GetMessageRenderer(name string) *MessageRendererConfig {
|
||||
for i := len(r.extensions) - 1; i >= 0; i-- {
|
||||
for j := len(r.extensions[i].MessageRenderers) - 1; j >= 0; j-- {
|
||||
if r.extensions[i].MessageRenderers[j].Name == name {
|
||||
config := r.extensions[i].MessageRenderers[j]
|
||||
return &config
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hot-reload
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Reload replaces the loaded extensions with a fresh set and clears all
|
||||
// dynamic state (widgets, status, header/footer, editor, visibility,
|
||||
// disabled tools, custom event subscriptions). Option overrides are
|
||||
// preserved across reloads since they represent user intent.
|
||||
//
|
||||
// The caller is responsible for emitting SessionShutdown before calling
|
||||
// Reload and SessionStart after.
|
||||
func (r *Runner) Reload(exts []LoadedExtension) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.extensions = exts
|
||||
r.widgets = nil
|
||||
r.statusEntries = nil
|
||||
r.header = nil
|
||||
r.footer = nil
|
||||
r.customEditor = nil
|
||||
r.uiVisibility = nil
|
||||
r.disabledTools = nil
|
||||
r.customEventSubs = nil
|
||||
// optionOverrides are intentionally preserved.
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inter-extension event bus
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SubscribeCustomEvent registers a handler for a named custom event. Handlers
|
||||
// execute in registration order when EmitCustomEvent is called. Thread-safe.
|
||||
func (r *Runner) SubscribeCustomEvent(name string, handler func(string)) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.customEventSubs == nil {
|
||||
r.customEventSubs = make(map[string][]func(string))
|
||||
}
|
||||
r.customEventSubs[name] = append(r.customEventSubs[name], handler)
|
||||
}
|
||||
|
||||
// EmitCustomEvent dispatches a named event to all subscribed handlers.
|
||||
// Handlers run synchronously in extension load order. Panics are recovered
|
||||
// and logged. Thread-safe.
|
||||
func (r *Runner) EmitCustomEvent(name, data string) {
|
||||
// Collect handlers: extension-registered (Init-time) + dynamic subs.
|
||||
r.mu.RLock()
|
||||
dynamicHandlers := r.customEventSubs[name]
|
||||
r.mu.RUnlock()
|
||||
|
||||
safeInvoke := func(h func(string)) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
log.Warn("custom event handler panicked",
|
||||
"event", name,
|
||||
"err", fmt.Sprintf("%v", rec))
|
||||
}
|
||||
}()
|
||||
h(data)
|
||||
}
|
||||
|
||||
// Extension-registered handlers first (in load order).
|
||||
for i := range r.extensions {
|
||||
for _, h := range r.extensions[i].CustomEventHandlers[name] {
|
||||
safeInvoke(h)
|
||||
}
|
||||
}
|
||||
// Then dynamic subscriptions.
|
||||
for _, h := range dynamicHandlers {
|
||||
safeInvoke(h)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SetActiveTools restricts the tool set to the named tools. All tools not in
|
||||
// the list are disabled. Passing nil or an empty slice re-enables all tools.
|
||||
// Thread-safe.
|
||||
func (r *Runner) SetActiveTools(names []string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if len(names) == 0 {
|
||||
r.disabledTools = nil
|
||||
return
|
||||
}
|
||||
active := make(map[string]bool, len(names))
|
||||
for _, n := range names {
|
||||
active[n] = true
|
||||
}
|
||||
r.disabledTools = active // non-nil = only these tools are allowed
|
||||
}
|
||||
|
||||
// IsToolDisabled returns true if the tool has been disabled via SetActiveTools.
|
||||
// Thread-safe.
|
||||
func (r *Runner) IsToolDisabled(toolName string) bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
if r.disabledTools == nil {
|
||||
return false // no filter = all enabled
|
||||
}
|
||||
return !r.disabledTools[toolName]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extension options
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GetOption resolves a named option value in priority order:
|
||||
// 1. Runtime override (via SetOption)
|
||||
// 2. Environment variable: KIT_OPT_<NAME> (uppercased, dashes → underscores)
|
||||
// 3. Viper config: options.<name>
|
||||
// 4. Default value from RegisterOption
|
||||
//
|
||||
// Returns empty string if the option was never registered.
|
||||
// Thread-safe.
|
||||
func (r *Runner) GetOption(name string) string {
|
||||
// 1. Runtime override.
|
||||
r.mu.RLock()
|
||||
if v, ok := r.optionOverrides[name]; ok {
|
||||
r.mu.RUnlock()
|
||||
return v
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
|
||||
// 2. Environment variable: KIT_OPT_<NAME>
|
||||
envKey := "KIT_OPT_" + strings.ToUpper(strings.ReplaceAll(name, "-", "_"))
|
||||
if v := os.Getenv(envKey); v != "" {
|
||||
return v
|
||||
}
|
||||
|
||||
// 3. Viper config: options.<name>
|
||||
configKey := "options." + name
|
||||
if v := viper.GetString(configKey); v != "" {
|
||||
return v
|
||||
}
|
||||
|
||||
// 4. Default from registered option defs.
|
||||
for i := range r.extensions {
|
||||
for _, opt := range r.extensions[i].Options {
|
||||
if opt.Name == name {
|
||||
return opt.Default
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// SetOption stores a runtime override for a named option. This takes highest
|
||||
// priority over env vars, config, and defaults. Thread-safe.
|
||||
func (r *Runner) SetOption(name, value string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.optionOverrides == nil {
|
||||
r.optionOverrides = make(map[string]string)
|
||||
}
|
||||
r.optionOverrides[name] = value
|
||||
}
|
||||
|
||||
// RegisteredOptions returns all option definitions from all loaded extensions.
|
||||
func (r *Runner) RegisteredOptions() []OptionDef {
|
||||
var opts []OptionDef
|
||||
for i := range r.extensions {
|
||||
opts = append(opts, r.extensions[i].Options...)
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keyboard shortcuts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GetShortcuts returns all registered keyboard shortcuts as a map of
|
||||
// key binding → handler. If multiple extensions register the same key,
|
||||
// the last registration wins. Thread-safe (reads extension list which is
|
||||
// immutable after loading).
|
||||
func (r *Runner) GetShortcuts() map[string]ShortcutEntry {
|
||||
result := make(map[string]ShortcutEntry)
|
||||
for i := range r.extensions {
|
||||
for _, sc := range r.extensions[i].Shortcuts {
|
||||
result[sc.Def.Key] = sc
|
||||
}
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// RegisteredShortcuts returns all shortcut definitions from all loaded
|
||||
// extensions. Used for help/listing commands.
|
||||
func (r *Runner) RegisteredShortcuts() []ShortcutDef {
|
||||
var defs []ShortcutDef
|
||||
seen := make(map[string]bool)
|
||||
// Iterate in reverse so last registration for a key wins.
|
||||
for i := len(r.extensions) - 1; i >= 0; i-- {
|
||||
for _, sc := range r.extensions[i].Shortcuts {
|
||||
if !seen[sc.Def.Key] {
|
||||
seen[sc.Def.Key] = true
|
||||
defs = append(defs, sc.Def)
|
||||
}
|
||||
}
|
||||
}
|
||||
return defs
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -311,6 +618,12 @@ func isBlocking(result Result) bool {
|
||||
return r.Block
|
||||
case InputResult:
|
||||
return r.Action == "handled"
|
||||
case BeforeForkResult:
|
||||
return r.Cancel
|
||||
case BeforeSessionSwitchResult:
|
||||
return r.Cancel
|
||||
case BeforeCompactResult:
|
||||
return r.Cancel
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -23,9 +23,31 @@ func Symbols() interp.Exports {
|
||||
"API": reflect.ValueOf((*API)(nil)),
|
||||
"Context": reflect.ValueOf((*Context)(nil)),
|
||||
"ToolDef": reflect.ValueOf((*ToolDef)(nil)),
|
||||
"ToolContext": reflect.ValueOf((*ToolContext)(nil)),
|
||||
"ShortcutDef": reflect.ValueOf((*ShortcutDef)(nil)),
|
||||
"CommandDef": reflect.ValueOf((*CommandDef)(nil)),
|
||||
"PrintBlockOpts": reflect.ValueOf((*PrintBlockOpts)(nil)),
|
||||
|
||||
// Session types
|
||||
"SessionMessage": reflect.ValueOf((*SessionMessage)(nil)),
|
||||
"ExtensionEntry": reflect.ValueOf((*ExtensionEntry)(nil)),
|
||||
|
||||
// Option types
|
||||
"OptionDef": reflect.ValueOf((*OptionDef)(nil)),
|
||||
|
||||
// Model info types
|
||||
"ModelInfoEntry": reflect.ValueOf((*ModelInfoEntry)(nil)),
|
||||
|
||||
// Tool info types
|
||||
"ToolInfo": reflect.ValueOf((*ToolInfo)(nil)),
|
||||
|
||||
// LLM completion types
|
||||
"CompleteRequest": reflect.ValueOf((*CompleteRequest)(nil)),
|
||||
"CompleteResponse": reflect.ValueOf((*CompleteResponse)(nil)),
|
||||
|
||||
// Status bar types
|
||||
"StatusBarEntry": reflect.ValueOf((*StatusBarEntry)(nil)),
|
||||
|
||||
// Widget types
|
||||
"WidgetConfig": reflect.ValueOf((*WidgetConfig)(nil)),
|
||||
"WidgetContent": reflect.ValueOf((*WidgetContent)(nil)),
|
||||
@@ -37,6 +59,12 @@ func Symbols() interp.Exports {
|
||||
// Header/Footer types
|
||||
"HeaderFooterConfig": reflect.ValueOf((*HeaderFooterConfig)(nil)),
|
||||
|
||||
// UI visibility
|
||||
"UIVisibility": reflect.ValueOf((*UIVisibility)(nil)),
|
||||
|
||||
// Context stats
|
||||
"ContextStats": reflect.ValueOf((*ContextStats)(nil)),
|
||||
|
||||
// Overlay types
|
||||
"OverlayAnchor": reflect.ValueOf((*OverlayAnchor)(nil)),
|
||||
"OverlayCenter": reflect.ValueOf(OverlayCenter),
|
||||
@@ -49,6 +77,9 @@ func Symbols() interp.Exports {
|
||||
// Tool renderer types
|
||||
"ToolRenderConfig": reflect.ValueOf((*ToolRenderConfig)(nil)),
|
||||
|
||||
// Message renderer types
|
||||
"MessageRendererConfig": reflect.ValueOf((*MessageRendererConfig)(nil)),
|
||||
|
||||
// Editor interceptor types
|
||||
"EditorKeyActionType": reflect.ValueOf((*EditorKeyActionType)(nil)),
|
||||
"EditorKeyPassthrough": reflect.ValueOf(EditorKeyPassthrough),
|
||||
@@ -66,6 +97,19 @@ func Symbols() interp.Exports {
|
||||
"PromptInputConfig": reflect.ValueOf((*PromptInputConfig)(nil)),
|
||||
"PromptInputResult": reflect.ValueOf((*PromptInputResult)(nil)),
|
||||
|
||||
// Context filtering types
|
||||
"ContextMessage": reflect.ValueOf((*ContextMessage)(nil)),
|
||||
"ContextPrepareEvent": reflect.ValueOf((*ContextPrepareEvent)(nil)),
|
||||
"ContextPrepareResult": reflect.ValueOf((*ContextPrepareResult)(nil)),
|
||||
|
||||
// Session lifecycle types
|
||||
"BeforeForkEvent": reflect.ValueOf((*BeforeForkEvent)(nil)),
|
||||
"BeforeForkResult": reflect.ValueOf((*BeforeForkResult)(nil)),
|
||||
"BeforeSessionSwitchEvent": reflect.ValueOf((*BeforeSessionSwitchEvent)(nil)),
|
||||
"BeforeSessionSwitchResult": reflect.ValueOf((*BeforeSessionSwitchResult)(nil)),
|
||||
"BeforeCompactEvent": reflect.ValueOf((*BeforeCompactEvent)(nil)),
|
||||
"BeforeCompactResult": reflect.ValueOf((*BeforeCompactResult)(nil)),
|
||||
|
||||
// Event structs
|
||||
"ToolCallEvent": reflect.ValueOf((*ToolCallEvent)(nil)),
|
||||
"ToolCallResult": reflect.ValueOf((*ToolCallResult)(nil)),
|
||||
@@ -84,6 +128,7 @@ func Symbols() interp.Exports {
|
||||
"MessageEndEvent": reflect.ValueOf((*MessageEndEvent)(nil)),
|
||||
"SessionStartEvent": reflect.ValueOf((*SessionStartEvent)(nil)),
|
||||
"SessionShutdownEvent": reflect.ValueOf((*SessionShutdownEvent)(nil)),
|
||||
"ModelChangeEvent": reflect.ValueOf((*ModelChangeEvent)(nil)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,19 +9,17 @@ import (
|
||||
|
||||
// WrapToolsWithExtensions wraps each tool so that ToolCall and ToolResult
|
||||
// events are emitted through the extension runner before and after execution.
|
||||
// This is the Go equivalent of Pi's wrapper.ts pattern.
|
||||
//
|
||||
|
||||
// If the runner has no relevant handlers the original tools are returned
|
||||
// unchanged (zero overhead).
|
||||
func WrapToolsWithExtensions(tools []fantasy.AgentTool, runner *Runner) []fantasy.AgentTool {
|
||||
if runner == nil {
|
||||
return tools
|
||||
}
|
||||
if !runner.HasHandlers(ToolCall) && !runner.HasHandlers(ToolResult) &&
|
||||
!runner.HasHandlers(ToolExecutionStart) && !runner.HasHandlers(ToolExecutionEnd) {
|
||||
return tools
|
||||
}
|
||||
|
||||
// Always wrap tools through the runner so that SetActiveTools
|
||||
// (disabled-tool checking) and event handlers both work. The
|
||||
// overhead for disabled-tool checking is a single map lookup
|
||||
// per tool call, which is negligible.
|
||||
wrapped := make([]fantasy.AgentTool, len(tools))
|
||||
for i, tool := range tools {
|
||||
wrapped[i] = &wrappedTool{inner: tool, runner: runner}
|
||||
@@ -31,10 +29,12 @@ func WrapToolsWithExtensions(tools []fantasy.AgentTool, runner *Runner) []fantas
|
||||
|
||||
// ExtensionToolsAsFantasy converts ToolDef values registered by extensions
|
||||
// into fantasy.AgentTool implementations so the LLM can invoke them.
|
||||
func ExtensionToolsAsFantasy(defs []ToolDef) []fantasy.AgentTool {
|
||||
// The runner is optional; if provided, ToolContext.OnProgress routes
|
||||
// progress messages through the runner's Print function.
|
||||
func ExtensionToolsAsFantasy(defs []ToolDef, runner *Runner) []fantasy.AgentTool {
|
||||
tools := make([]fantasy.AgentTool, 0, len(defs))
|
||||
for _, def := range defs {
|
||||
tools = append(tools, &extensionTool{def: def})
|
||||
tools = append(tools, &extensionTool{def: def, runner: runner})
|
||||
}
|
||||
return tools
|
||||
}
|
||||
@@ -55,12 +55,20 @@ func (w *wrappedTool) SetProviderOptions(o fantasy.ProviderOptions) { w.inner.Se
|
||||
func (w *wrappedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
toolName := w.inner.Info().Name
|
||||
|
||||
// 0. Check if tool is disabled via SetActiveTools.
|
||||
if w.runner.IsToolDisabled(toolName) {
|
||||
return fantasy.NewTextErrorResponse(
|
||||
fmt.Sprintf("Error: tool %q is currently disabled", toolName)),
|
||||
fmt.Errorf("tool %q disabled by extension", toolName)
|
||||
}
|
||||
|
||||
// 1. Emit ToolCall — extensions can block execution.
|
||||
if w.runner.HasHandlers(ToolCall) {
|
||||
result, _ := w.runner.Emit(ToolCallEvent{
|
||||
ToolName: toolName,
|
||||
ToolCallID: call.ID,
|
||||
Input: call.Input,
|
||||
Source: "llm",
|
||||
})
|
||||
if r, ok := result.(ToolCallResult); ok && r.Block {
|
||||
reason := r.Reason
|
||||
@@ -112,6 +120,7 @@ func (w *wrappedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.T
|
||||
|
||||
type extensionTool struct {
|
||||
def ToolDef
|
||||
runner *Runner // optional; enables ToolContext.OnProgress
|
||||
providerOptions fantasy.ProviderOptions
|
||||
}
|
||||
|
||||
@@ -125,8 +134,31 @@ func (t *extensionTool) Info() fantasy.ToolInfo {
|
||||
func (t *extensionTool) ProviderOptions() fantasy.ProviderOptions { return t.providerOptions }
|
||||
func (t *extensionTool) SetProviderOptions(o fantasy.ProviderOptions) { t.providerOptions = o }
|
||||
|
||||
func (t *extensionTool) Run(_ context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
result, err := t.def.Execute(call.Input)
|
||||
func (t *extensionTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
var result string
|
||||
var err error
|
||||
|
||||
if t.def.ExecuteWithContext != nil {
|
||||
tc := ToolContext{
|
||||
IsCancelled: func() bool {
|
||||
return ctx.Err() != nil
|
||||
},
|
||||
OnProgress: func(text string) {
|
||||
if t.runner != nil {
|
||||
t.runner.mu.RLock()
|
||||
printFn := t.runner.ctx.Print
|
||||
t.runner.mu.RUnlock()
|
||||
if printFn != nil {
|
||||
printFn(text)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
result, err = t.def.ExecuteWithContext(call.Input, tc)
|
||||
} else {
|
||||
result, err = t.def.Execute(call.Input)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), err
|
||||
}
|
||||
|
||||
@@ -48,8 +48,13 @@ func TestWrapToolsWithExtensions_NoRelevantHandlers(t *testing.T) {
|
||||
}))
|
||||
tools := []fantasy.AgentTool{newMockTool("test")}
|
||||
result := WrapToolsWithExtensions(tools, r)
|
||||
if result[0] != tools[0] {
|
||||
t.Error("expected original tool when no tool handlers exist")
|
||||
// Tools are always wrapped now (for SetActiveTools support),
|
||||
// but Info() should pass through correctly.
|
||||
if result[0] == tools[0] {
|
||||
t.Error("expected wrapped tool (always wraps for SetActiveTools)")
|
||||
}
|
||||
if result[0].Info().Name != "test" {
|
||||
t.Errorf("expected name 'test', got %q", result[0].Info().Name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +107,22 @@ func TestWrappedTool_NormalExecution(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrappedTool_SourceField(t *testing.T) {
|
||||
var gotSource string
|
||||
r := makeRunner(makeHandlerExt("source.go", map[EventType][]HandlerFunc{
|
||||
ToolCall: {func(e Event, c Context) Result {
|
||||
gotSource = e.(ToolCallEvent).Source
|
||||
return nil
|
||||
}},
|
||||
}))
|
||||
|
||||
tools := WrapToolsWithExtensions([]fantasy.AgentTool{newMockTool("bash")}, r)
|
||||
_, _ = tools[0].Run(context.Background(), fantasy.ToolCall{ID: "1", Input: "{}"})
|
||||
if gotSource != "llm" {
|
||||
t.Errorf("expected Source='llm', got %q", gotSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrappedTool_BlockExecution(t *testing.T) {
|
||||
var toolRan bool
|
||||
r := makeRunner(makeHandlerExt("blocker.go", map[EventType][]HandlerFunc{
|
||||
@@ -181,7 +202,7 @@ func TestExtensionToolsAsFantasy(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
tools := ExtensionToolsAsFantasy(defs)
|
||||
tools := ExtensionToolsAsFantasy(defs, nil)
|
||||
if len(tools) != 1 {
|
||||
t.Fatalf("expected 1 tool, got %d", len(tools))
|
||||
}
|
||||
@@ -211,7 +232,7 @@ func TestExtensionTool_Error(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
tools := ExtensionToolsAsFantasy(defs)
|
||||
tools := ExtensionToolsAsFantasy(defs, nil)
|
||||
resp, err := tools[0].Run(context.Background(), fantasy.ToolCall{Input: "x"})
|
||||
if err == nil {
|
||||
t.Error("expected error")
|
||||
@@ -221,9 +242,104 @@ func TestExtensionTool_Error(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionTool_ExecuteWithContext(t *testing.T) {
|
||||
var gotCancelled bool
|
||||
var gotProgress []string
|
||||
|
||||
defs := []ToolDef{
|
||||
{
|
||||
Name: "rich",
|
||||
ExecuteWithContext: func(input string, tc ToolContext) (string, error) {
|
||||
gotCancelled = tc.IsCancelled()
|
||||
tc.OnProgress("step 1")
|
||||
tc.OnProgress("step 2")
|
||||
return "done: " + input, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Without runner, OnProgress is a no-op.
|
||||
tools := ExtensionToolsAsFantasy(defs, nil)
|
||||
resp, err := tools[0].Run(context.Background(), fantasy.ToolCall{Input: "test"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.Content != "done: test" {
|
||||
t.Errorf("expected 'done: test', got %q", resp.Content)
|
||||
}
|
||||
if gotCancelled {
|
||||
t.Error("expected IsCancelled=false for non-cancelled context")
|
||||
}
|
||||
|
||||
// With runner, OnProgress routes through Print.
|
||||
runner := NewRunner(nil)
|
||||
runner.SetContext(Context{
|
||||
Print: func(text string) { gotProgress = append(gotProgress, text) },
|
||||
})
|
||||
defs2 := []ToolDef{
|
||||
{
|
||||
Name: "rich2",
|
||||
ExecuteWithContext: func(input string, tc ToolContext) (string, error) {
|
||||
tc.OnProgress("hello")
|
||||
return "ok", nil
|
||||
},
|
||||
},
|
||||
}
|
||||
tools2 := ExtensionToolsAsFantasy(defs2, runner)
|
||||
_, err = tools2[0].Run(context.Background(), fantasy.ToolCall{Input: ""})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(gotProgress) != 1 || gotProgress[0] != "hello" {
|
||||
t.Errorf("expected [hello], got %v", gotProgress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionTool_ExecuteWithContextPriority(t *testing.T) {
|
||||
// When both Execute and ExecuteWithContext are set, ExecuteWithContext wins.
|
||||
defs := []ToolDef{
|
||||
{
|
||||
Name: "both",
|
||||
Execute: func(input string) (string, error) { return "simple", nil },
|
||||
ExecuteWithContext: func(input string, tc ToolContext) (string, error) {
|
||||
return "rich", nil
|
||||
},
|
||||
},
|
||||
}
|
||||
tools := ExtensionToolsAsFantasy(defs, nil)
|
||||
resp, err := tools[0].Run(context.Background(), fantasy.ToolCall{Input: ""})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.Content != "rich" {
|
||||
t.Errorf("expected 'rich' (ExecuteWithContext), got %q", resp.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionTool_CancelledContext(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // cancel immediately
|
||||
|
||||
var sawCancelled bool
|
||||
defs := []ToolDef{
|
||||
{
|
||||
Name: "checkcancel",
|
||||
ExecuteWithContext: func(input string, tc ToolContext) (string, error) {
|
||||
sawCancelled = tc.IsCancelled()
|
||||
return "ok", nil
|
||||
},
|
||||
},
|
||||
}
|
||||
tools := ExtensionToolsAsFantasy(defs, nil)
|
||||
_, _ = tools[0].Run(ctx, fantasy.ToolCall{Input: ""})
|
||||
if !sawCancelled {
|
||||
t.Error("expected IsCancelled=true for cancelled context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionTool_ProviderOptions(t *testing.T) {
|
||||
defs := []ToolDef{{Name: "test", Execute: func(string) (string, error) { return "", nil }}}
|
||||
tools := ExtensionToolsAsFantasy(defs)
|
||||
tools := ExtensionToolsAsFantasy(defs, nil)
|
||||
|
||||
// Initially nil.
|
||||
opts := tools[0].ProviderOptions()
|
||||
|
||||
@@ -186,7 +186,7 @@ func loadExtensions() (*extensions.Runner, extensionCreationOpts, error) {
|
||||
return extensions.WrapToolsWithExtensions(tools, runner)
|
||||
}
|
||||
|
||||
extTools := extensions.ExtensionToolsAsFantasy(runner.RegisteredTools())
|
||||
extTools := extensions.ExtensionToolsAsFantasy(runner.RegisteredTools(), runner)
|
||||
|
||||
return runner, extensionCreationOpts{
|
||||
toolWrapper: wrapper,
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
)
|
||||
|
||||
// EntryType identifies the kind of entry stored in a JSONL session file.
|
||||
// Following pi's design, sessions are append-only JSONL files where each line
|
||||
// is a typed entry linked by id/parent_id to form a tree structure.
|
||||
// Sessions are append-only JSONL files where each line is a typed entry
|
||||
// linked by id/parent_id to form a tree structure.
|
||||
type EntryType string
|
||||
|
||||
const (
|
||||
@@ -22,6 +22,7 @@ const (
|
||||
EntryTypeBranchSummary EntryType = "branch_summary"
|
||||
EntryTypeLabel EntryType = "label"
|
||||
EntryTypeSessionInfo EntryType = "session_info"
|
||||
EntryTypeExtensionData EntryType = "extension_data"
|
||||
)
|
||||
|
||||
// CurrentVersion is the session format version for JSONL tree sessions.
|
||||
@@ -89,6 +90,14 @@ type SessionInfoEntry struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// ExtensionDataEntry stores custom extension data in the session tree.
|
||||
// Extensions use this to persist state that survives across session restarts.
|
||||
type ExtensionDataEntry struct {
|
||||
Entry
|
||||
ExtType string `json:"ext_type"` // Extension-defined type string (e.g. "plan-mode:state")
|
||||
Data string `json:"data"` // Extension-defined data (JSON or plain text)
|
||||
}
|
||||
|
||||
// GenerateEntryID creates a unique entry identifier (16 hex chars).
|
||||
func GenerateEntryID() string {
|
||||
bytes := make([]byte, 8)
|
||||
@@ -177,6 +186,15 @@ func NewSessionInfoEntry(parentID, name string) *SessionInfoEntry {
|
||||
}
|
||||
}
|
||||
|
||||
// NewExtensionDataEntry creates an ExtensionDataEntry.
|
||||
func NewExtensionDataEntry(parentID, extType, data string) *ExtensionDataEntry {
|
||||
return &ExtensionDataEntry{
|
||||
Entry: NewEntry(EntryTypeExtensionData, parentID),
|
||||
ExtType: extType,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
// --- JSONL marshaling helpers ---
|
||||
|
||||
// MarshalEntry serializes any entry to a JSON line (no trailing newline).
|
||||
@@ -241,6 +259,13 @@ func UnmarshalEntry(data []byte) (any, error) {
|
||||
}
|
||||
return &e, nil
|
||||
|
||||
case EntryTypeExtensionData:
|
||||
var e ExtensionDataEntry
|
||||
if err := json.Unmarshal(data, &e); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal extension_data entry: %w", err)
|
||||
}
|
||||
return &e, nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown entry type: %q", env.Type)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
)
|
||||
|
||||
// SessionInfo contains metadata about a discovered session, used for listing
|
||||
// and session picker display. Follows pi's SessionInfo design.
|
||||
// and session picker display.
|
||||
type SessionInfo struct {
|
||||
// Path is the absolute path to the JSONL session file.
|
||||
Path string
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
)
|
||||
|
||||
// TreeNode represents a node in the session tree for display purposes.
|
||||
// It mirrors pi's SessionTreeNode design.
|
||||
|
||||
type TreeNode struct {
|
||||
Entry any // the underlying entry (*MessageEntry, *ModelChangeEntry, etc.)
|
||||
ID string // entry ID
|
||||
@@ -25,7 +25,7 @@ type TreeNode struct {
|
||||
}
|
||||
|
||||
// TreeManager manages a tree-structured JSONL session. It is the replacement
|
||||
// for the linear session.Manager, following pi's design decisions:
|
||||
// for the linear session.Manager:
|
||||
//
|
||||
// - JSONL append-only format (one JSON object per line)
|
||||
// - Tree structure via id/parent_id on every entry
|
||||
@@ -283,6 +283,44 @@ func (tm *TreeManager) AppendSessionInfo(name string) (string, error) {
|
||||
return entry.ID, nil
|
||||
}
|
||||
|
||||
// AppendExtensionData adds an extension data entry to the tree and persists it.
|
||||
// Extensions use this to store custom state that survives across session restarts.
|
||||
func (tm *TreeManager) AppendExtensionData(extType, data string) (string, error) {
|
||||
tm.mu.Lock()
|
||||
defer tm.mu.Unlock()
|
||||
|
||||
entry := NewExtensionDataEntry(tm.leafID, extType, data)
|
||||
if err := tm.appendAndPersist(entry); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
tm.leafID = entry.ID
|
||||
return entry.ID, nil
|
||||
}
|
||||
|
||||
// GetExtensionData returns all extension data entries matching the given type,
|
||||
// walking the current branch from root to leaf. If extType is empty, all
|
||||
// extension data entries on the branch are returned.
|
||||
func (tm *TreeManager) GetExtensionData(extType string) []*ExtensionDataEntry {
|
||||
tm.mu.RLock()
|
||||
defer tm.mu.RUnlock()
|
||||
|
||||
if tm.leafID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
branch := tm.getBranchLocked(tm.leafID)
|
||||
var results []*ExtensionDataEntry
|
||||
for _, entry := range branch {
|
||||
if e, ok := entry.(*ExtensionDataEntry); ok {
|
||||
if extType == "" || e.ExtType == extType {
|
||||
results = append(results, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// --- Tree navigation ---
|
||||
|
||||
// Branch moves the leaf pointer to the given entry ID, creating a branch
|
||||
@@ -601,6 +639,8 @@ func (tm *TreeManager) entryID(entry any) string {
|
||||
return e.ID
|
||||
case *SessionInfoEntry:
|
||||
return e.ID
|
||||
case *ExtensionDataEntry:
|
||||
return e.ID
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
@@ -619,6 +659,8 @@ func (tm *TreeManager) entryParentID(entry any) string {
|
||||
return e.ParentID
|
||||
case *SessionInfoEntry:
|
||||
return e.ParentID
|
||||
case *ExtensionDataEntry:
|
||||
return e.ParentID
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
@@ -675,7 +717,7 @@ func (tm *TreeManager) buildTreeNode(id string) *TreeNode {
|
||||
// --- Path conventions ---
|
||||
|
||||
// DefaultSessionDir returns the default session storage directory for a cwd.
|
||||
// Following pi's convention: ~/.kit/sessions/--<cwd-path>--/
|
||||
// Convention: ~/.kit/sessions/--<cwd-path>--/
|
||||
func DefaultSessionDir(cwd string) string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
|
||||
@@ -202,7 +202,7 @@ func LoadSkills(cwd string) ([]*Skill, error) {
|
||||
// FormatForPrompt formats skills as metadata-only XML for inclusion in a
|
||||
// system prompt. Only the name, description, and file location are included;
|
||||
// the agent reads the full skill file on demand using the read tool. This
|
||||
// matches the Pi SDK's formatSkillsForPrompt convention.
|
||||
|
||||
func FormatForPrompt(skills []*Skill) string {
|
||||
if len(skills) == 0 {
|
||||
return ""
|
||||
|
||||
@@ -9,7 +9,8 @@ type SlashCommand struct {
|
||||
Name string
|
||||
Description string
|
||||
Aliases []string
|
||||
Category string // e.g., "Navigation", "System", "Info"
|
||||
Category string // e.g., "Navigation", "System", "Info"
|
||||
Complete func(prefix string) []string // optional argument tab-completion
|
||||
}
|
||||
|
||||
// SlashCommands provides the global registry of all available slash commands
|
||||
@@ -136,6 +137,7 @@ type ExtensionCommand struct {
|
||||
Name string
|
||||
Description string
|
||||
Execute func(args string) (string, error)
|
||||
Complete func(prefix string) []string // optional argument tab-completion
|
||||
}
|
||||
|
||||
// FindExtensionCommand looks up an extension command by name from the given
|
||||
|
||||
@@ -188,7 +188,7 @@ func (r *CompactRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
|
||||
header += " " + lipgloss.NewStyle().Foreground(theme.Muted).Render(params)
|
||||
}
|
||||
|
||||
// Format body: check extension renderer first, then builtin, then default.
|
||||
// Format body: check extension renderer first, then compact builtin, then default.
|
||||
var body string
|
||||
if extRd != nil && extRd.RenderBody != nil {
|
||||
body = extRd.RenderBody(toolResult, isError, r.width-4)
|
||||
@@ -201,7 +201,8 @@ func (r *CompactRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
|
||||
if isError {
|
||||
body = lipgloss.NewStyle().Foreground(theme.Error).Render(r.formatToolResult(toolResult))
|
||||
} else {
|
||||
body = renderToolBody(toolName, toolArgs, toolResult, r.width-4)
|
||||
// Use compact summary renderers instead of full tool body renderers.
|
||||
body = renderToolBodyCompact(toolName, toolArgs, toolResult, r.width-4)
|
||||
if body == "" {
|
||||
formatted := r.formatToolResult(toolResult)
|
||||
if formatted == "" {
|
||||
|
||||
@@ -28,3 +28,33 @@ type TreeNodeSelectedMsg struct {
|
||||
|
||||
// TreeCancelledMsg is sent when the user cancels the tree selector (ESC).
|
||||
type TreeCancelledMsg struct{}
|
||||
|
||||
// shellCommandMsg is sent by the InputComponent when the user submits a
|
||||
// ! or !! prefixed command. The parent model intercepts this to execute
|
||||
// the shell command directly instead of forwarding to the LLM.
|
||||
//
|
||||
// Matching pi's behavior:
|
||||
// - !cmd → run shell command, output INCLUDED in LLM context
|
||||
// - !!cmd → run shell command, output EXCLUDED from LLM context
|
||||
type shellCommandMsg struct {
|
||||
// Command is the shell command to execute (prefix stripped).
|
||||
Command string
|
||||
// ExcludeFromContext is true for !! (output excluded from LLM context),
|
||||
// false for ! (output included in LLM context).
|
||||
ExcludeFromContext bool
|
||||
}
|
||||
|
||||
// shellCommandResultMsg carries the result of a shell command execution
|
||||
// back to the parent model for display.
|
||||
type shellCommandResultMsg struct {
|
||||
// Command is the original shell command that was executed.
|
||||
Command string
|
||||
// Output is the combined stdout/stderr output.
|
||||
Output string
|
||||
// ExitCode is the process exit code (0 = success).
|
||||
ExitCode int
|
||||
// Err is non-nil if the command failed to start or timed out.
|
||||
Err error
|
||||
// ExcludeFromContext mirrors the flag from shellCommandMsg.
|
||||
ExcludeFromContext bool
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// fileTokenPattern matches @file references in user text. Supports:
|
||||
// - @"path with spaces.txt" (quoted)
|
||||
// - @path/to/file.txt (unquoted, no spaces)
|
||||
var fileTokenPattern = regexp.MustCompile(`@"[^"]+"|@[^\s]+`)
|
||||
|
||||
// ProcessFileAttachments scans the user's input text for @file references,
|
||||
// reads each referenced file, and returns the text with @tokens replaced by
|
||||
// XML-wrapped file content. Non-file @ tokens (like email addresses) are left
|
||||
// unchanged.
|
||||
//
|
||||
// Returns the original text unchanged if no valid @file references are found.
|
||||
func ProcessFileAttachments(text string, cwd string) string {
|
||||
tokens := fileTokenPattern.FindAllString(text, -1)
|
||||
if len(tokens) == 0 {
|
||||
return text
|
||||
}
|
||||
|
||||
result := text
|
||||
for _, token := range tokens {
|
||||
path := tokenToPath(token)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
absPath, err := resolvePath(path, cwd)
|
||||
if err != nil {
|
||||
// Not a valid file reference — leave the token as-is.
|
||||
// This handles cases like email addresses (@user) gracefully.
|
||||
continue
|
||||
}
|
||||
|
||||
info, err := os.Stat(absPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip directories — we only attach file content.
|
||||
if info.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip empty files.
|
||||
if info.Size() == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Build the XML-wrapped replacement.
|
||||
wrapped := wrapFileContent(absPath, content)
|
||||
result = strings.Replace(result, token, wrapped, 1)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// tokenToPath strips the @ prefix and optional quotes from a token,
|
||||
// returning the raw file path. Returns "" for invalid tokens.
|
||||
func tokenToPath(token string) string {
|
||||
if !strings.HasPrefix(token, "@") {
|
||||
return ""
|
||||
}
|
||||
path := token[1:]
|
||||
|
||||
// Strip quotes.
|
||||
if strings.HasPrefix(path, `"`) && strings.HasSuffix(path, `"`) {
|
||||
path = path[1 : len(path)-1]
|
||||
}
|
||||
|
||||
// Reject obviously non-file tokens (e.g. bare @ or @-flags).
|
||||
if path == "" || strings.HasPrefix(path, "-") {
|
||||
return ""
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
// resolvePath resolves a potentially relative file path to an absolute path.
|
||||
// Supports ~/ expansion and relative paths. No CWD restriction — the user
|
||||
// can reference any file they have read access to.
|
||||
func resolvePath(path string, cwd string) (string, error) {
|
||||
// Expand ~/
|
||||
if strings.HasPrefix(path, "~/") {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot expand ~: %w", err)
|
||||
}
|
||||
path = filepath.Join(home, path[2:])
|
||||
}
|
||||
|
||||
// Resolve relative to cwd.
|
||||
if !filepath.IsAbs(path) {
|
||||
path = filepath.Join(cwd, path)
|
||||
}
|
||||
|
||||
// Clean and resolve symlinks for consistent paths.
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid path: %w", err)
|
||||
}
|
||||
|
||||
// Resolve symlinks so the displayed path is canonical.
|
||||
resolved, err := filepath.EvalSymlinks(absPath)
|
||||
if err != nil {
|
||||
// EvalSymlinks fails if the file doesn't exist — fall back to
|
||||
// the cleaned absolute path and let the caller's Stat handle it.
|
||||
return absPath, nil
|
||||
}
|
||||
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// wrapFileContent wraps file content in XML tags for LLM consumption.
|
||||
func wrapFileContent(absPath string, content []byte) string {
|
||||
return fmt.Sprintf("<file path=\"%s\">\n%s\n</file>", absPath, string(content))
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// FileSuggestion represents a single file or directory suggestion for the @
|
||||
// autocomplete popup.
|
||||
type FileSuggestion struct {
|
||||
// RelPath is the path relative to the search base (e.g. "cmd/kit/main.go").
|
||||
RelPath string
|
||||
// IsDir is true when the entry is a directory.
|
||||
IsDir bool
|
||||
// Score is the fuzzy match score (higher is better).
|
||||
Score int
|
||||
}
|
||||
|
||||
// maxFileSuggestions is the maximum number of file suggestions returned.
|
||||
const maxFileSuggestions = 20
|
||||
|
||||
// ExtractAtPrefix checks the current line for an @-file trigger at cursorCol.
|
||||
// It returns:
|
||||
// - hasAt: true if a valid @ trigger was found
|
||||
// - prefix: the text after @ (possibly empty) that the user has typed so far
|
||||
// - startIdx: byte offset of the @ character in the line
|
||||
//
|
||||
// The @ must appear at the start of the line or after whitespace. Quoted paths
|
||||
// are supported: @"path with spaces" — the returned prefix strips quotes.
|
||||
func ExtractAtPrefix(line string, cursorCol int) (hasAt bool, prefix string, startIdx int) {
|
||||
if cursorCol > len(line) {
|
||||
cursorCol = len(line)
|
||||
}
|
||||
|
||||
// Walk backwards from cursorCol to find the @ character.
|
||||
text := line[:cursorCol]
|
||||
|
||||
// Find the last @ that is preceded by whitespace or is at position 0.
|
||||
atIdx := -1
|
||||
for i := len(text) - 1; i >= 0; i-- {
|
||||
if text[i] == '@' {
|
||||
// Must be at start of line or preceded by whitespace.
|
||||
if i == 0 || text[i-1] == ' ' || text[i-1] == '\t' {
|
||||
atIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
// Stop scanning if we hit a space — the @ we want must be in the
|
||||
// current "word".
|
||||
if text[i] == ' ' || text[i] == '\t' {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if atIdx < 0 {
|
||||
return false, "", 0
|
||||
}
|
||||
|
||||
raw := text[atIdx+1:]
|
||||
|
||||
// Handle quoted paths: @"some path" — strip leading quote.
|
||||
if strings.HasPrefix(raw, `"`) {
|
||||
raw = strings.TrimPrefix(raw, `"`)
|
||||
raw = strings.TrimSuffix(raw, `"`)
|
||||
}
|
||||
|
||||
return true, raw, atIdx
|
||||
}
|
||||
|
||||
// GetFileSuggestions returns file/directory suggestions matching the given
|
||||
// prefix. It tries `git ls-files` first (fast, respects .gitignore), then
|
||||
// falls back to a simple directory walk.
|
||||
//
|
||||
// If prefix contains a path separator the search is scoped to that
|
||||
// subdirectory. For example, prefix "cmd/k" searches inside "cmd/" for
|
||||
// entries matching "k".
|
||||
func GetFileSuggestions(prefix string, cwd string) []FileSuggestion {
|
||||
// Resolve the base directory and filter query from the prefix.
|
||||
baseDir, query := splitPrefixPath(prefix)
|
||||
|
||||
searchDir := cwd
|
||||
if baseDir != "" {
|
||||
candidate := resolveSearchDir(baseDir, cwd)
|
||||
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
|
||||
searchDir = candidate
|
||||
} else {
|
||||
return nil // invalid base directory
|
||||
}
|
||||
}
|
||||
|
||||
files := listFiles(searchDir, cwd)
|
||||
if len(files) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Prepend baseDir so results display as "cmd/main.go" not just "main.go".
|
||||
if baseDir != "" {
|
||||
for i := range files {
|
||||
files[i].RelPath = baseDir + files[i].RelPath
|
||||
}
|
||||
}
|
||||
|
||||
return fuzzyFilterFiles(files, prefix, query)
|
||||
}
|
||||
|
||||
// splitPrefixPath separates a prefix like "cmd/kit/m" into
|
||||
// baseDir="cmd/kit/" and query="m". If there is no separator the
|
||||
// baseDir is empty and query is the full prefix.
|
||||
func splitPrefixPath(prefix string) (baseDir, query string) {
|
||||
// Handle ~ expansion display (we keep it in the prefix for display
|
||||
// but resolve it when actually searching).
|
||||
idx := strings.LastIndex(prefix, "/")
|
||||
if idx < 0 {
|
||||
return "", prefix
|
||||
}
|
||||
return prefix[:idx+1], prefix[idx+1:]
|
||||
}
|
||||
|
||||
// resolveSearchDir converts a baseDir from the prefix into an absolute path.
|
||||
// Supports ~/, ../, and absolute paths.
|
||||
func resolveSearchDir(baseDir, cwd string) string {
|
||||
// Expand ~/
|
||||
if strings.HasPrefix(baseDir, "~/") {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
return filepath.Join(home, baseDir[2:])
|
||||
}
|
||||
}
|
||||
|
||||
// Absolute paths
|
||||
if filepath.IsAbs(baseDir) {
|
||||
return filepath.Clean(baseDir)
|
||||
}
|
||||
|
||||
// Relative to cwd
|
||||
return filepath.Join(cwd, baseDir)
|
||||
}
|
||||
|
||||
// listFiles returns files and directories within searchDir, relative to that
|
||||
// directory. Uses `git ls-files` when inside a git repo for speed and
|
||||
// .gitignore awareness, otherwise falls back to os.ReadDir.
|
||||
func listFiles(searchDir, cwd string) []FileSuggestion {
|
||||
// Try git ls-files first (fast, respects .gitignore).
|
||||
if files := listFilesGit(searchDir, cwd); files != nil {
|
||||
return files
|
||||
}
|
||||
return listFilesReadDir(searchDir)
|
||||
}
|
||||
|
||||
// listFilesGit uses `git ls-files` and `git ls-files --others --exclude-standard`
|
||||
// to list tracked and untracked-but-not-ignored files.
|
||||
func listFilesGit(searchDir, cwd string) []FileSuggestion {
|
||||
// Check if we're in a git repo.
|
||||
check := exec.Command("git", "rev-parse", "--show-toplevel")
|
||||
check.Dir = cwd
|
||||
if err := check.Run(); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
var results []FileSuggestion
|
||||
|
||||
// Tracked files.
|
||||
cmd := exec.Command("git", "ls-files")
|
||||
cmd.Dir = searchDir
|
||||
out, err := cmd.Output()
|
||||
if err == nil {
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
// Normalize separators.
|
||||
line = filepath.ToSlash(line)
|
||||
addFileEntries(&results, seen, line, searchDir)
|
||||
}
|
||||
}
|
||||
|
||||
// Untracked, non-ignored files.
|
||||
cmd2 := exec.Command("git", "ls-files", "--others", "--exclude-standard")
|
||||
cmd2.Dir = searchDir
|
||||
out2, err := cmd2.Output()
|
||||
if err == nil {
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out2)), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
line = filepath.ToSlash(line)
|
||||
addFileEntries(&results, seen, line, searchDir)
|
||||
}
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// addFileEntries adds the file and any intermediate directory entries to
|
||||
// results if not already seen. Paths are stored with forward slashes.
|
||||
func addFileEntries(results *[]FileSuggestion, seen map[string]bool, relPath string, searchDir string) {
|
||||
// Add intermediate directories as suggestions (first component only).
|
||||
parts := strings.SplitN(relPath, "/", 2)
|
||||
if len(parts) > 1 {
|
||||
dir := parts[0] + "/"
|
||||
if !seen[dir] {
|
||||
seen[dir] = true
|
||||
*results = append(*results, FileSuggestion{RelPath: dir, IsDir: true})
|
||||
}
|
||||
}
|
||||
|
||||
// Add the file itself.
|
||||
if !seen[relPath] {
|
||||
seen[relPath] = true
|
||||
*results = append(*results, FileSuggestion{RelPath: relPath, IsDir: false})
|
||||
}
|
||||
}
|
||||
|
||||
// listFilesReadDir is the fallback when git is not available. Lists immediate
|
||||
// children of dir via os.ReadDir, skipping hidden dirs and common noise.
|
||||
func listFilesReadDir(dir string) []FileSuggestion {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
skip := map[string]bool{
|
||||
".git": true, "node_modules": true, ".kit": true,
|
||||
"__pycache__": true, ".venv": true, "vendor": true,
|
||||
}
|
||||
|
||||
var results []FileSuggestion
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if skip[name] {
|
||||
continue
|
||||
}
|
||||
// Skip hidden files/dirs (except common config files).
|
||||
if strings.HasPrefix(name, ".") && name != ".env" && name != ".gitignore" {
|
||||
continue
|
||||
}
|
||||
if e.IsDir() {
|
||||
results = append(results, FileSuggestion{RelPath: name + "/", IsDir: true})
|
||||
} else {
|
||||
results = append(results, FileSuggestion{RelPath: name, IsDir: false})
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// fuzzyFilterFiles scores and filters file suggestions against the query,
|
||||
// returning the top maxFileSuggestions results sorted by score descending.
|
||||
// Directories are boosted slightly so they appear near the top.
|
||||
func fuzzyFilterFiles(files []FileSuggestion, fullPrefix, query string) []FileSuggestion {
|
||||
if query == "" && fullPrefix == "" {
|
||||
// No filter — return all (capped).
|
||||
if len(files) > maxFileSuggestions {
|
||||
files = files[:maxFileSuggestions]
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
// When there's a base dir but no query (e.g. "cmd/"), show everything
|
||||
// in that directory.
|
||||
if query == "" {
|
||||
var filtered []FileSuggestion
|
||||
for i := range files {
|
||||
if strings.HasPrefix(files[i].RelPath, fullPrefix) {
|
||||
// Only show direct children of the base directory.
|
||||
rest := files[i].RelPath[len(fullPrefix):]
|
||||
if rest == "" {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, files[i])
|
||||
}
|
||||
}
|
||||
if len(filtered) > maxFileSuggestions {
|
||||
filtered = filtered[:maxFileSuggestions]
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
var scored []FileSuggestion
|
||||
queryLower := strings.ToLower(query)
|
||||
|
||||
for i := range files {
|
||||
path := files[i].RelPath
|
||||
// When we have a fullPrefix with a dir component, only consider
|
||||
// files under that directory.
|
||||
if fullPrefix != query && !strings.HasPrefix(path, fullPrefix[:len(fullPrefix)-len(query)]) {
|
||||
continue
|
||||
}
|
||||
|
||||
score := scoreFilePath(queryLower, path)
|
||||
if score <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Boost directories so they appear near the top for navigation.
|
||||
if files[i].IsDir {
|
||||
score += 10
|
||||
}
|
||||
|
||||
files[i].Score = score
|
||||
scored = append(scored, files[i])
|
||||
}
|
||||
|
||||
// Sort by score descending.
|
||||
sort.Slice(scored, func(i, j int) bool {
|
||||
return scored[i].Score > scored[j].Score
|
||||
})
|
||||
|
||||
if len(scored) > maxFileSuggestions {
|
||||
scored = scored[:maxFileSuggestions]
|
||||
}
|
||||
return scored
|
||||
}
|
||||
|
||||
// scoreFilePath scores a file path against a fuzzy query. Higher is better.
|
||||
// Returns 0 if there is no match.
|
||||
func scoreFilePath(query, path string) int {
|
||||
pathLower := strings.ToLower(path)
|
||||
baseName := filepath.Base(strings.TrimSuffix(path, "/"))
|
||||
baseNameLower := strings.ToLower(baseName)
|
||||
|
||||
// Exact basename match.
|
||||
if baseNameLower == query {
|
||||
return 1000
|
||||
}
|
||||
|
||||
// Basename starts with query.
|
||||
if strings.HasPrefix(baseNameLower, query) {
|
||||
return 800 - len(baseName) + len(query)
|
||||
}
|
||||
|
||||
// Basename contains query as substring.
|
||||
if strings.Contains(baseNameLower, query) {
|
||||
return 500 - len(baseName) + len(query)
|
||||
}
|
||||
|
||||
// Full path contains query as substring.
|
||||
if strings.Contains(pathLower, query) {
|
||||
return 300 - len(path) + len(query)
|
||||
}
|
||||
|
||||
// Fuzzy character match on basename.
|
||||
if score := fuzzyCharMatch(query, baseNameLower); score > 0 {
|
||||
return score
|
||||
}
|
||||
|
||||
// Fuzzy character match on full path.
|
||||
if score := fuzzyCharMatch(query, pathLower); score > 0 {
|
||||
return score - 50
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// fuzzyCharMatch performs character-by-character fuzzy matching. Returns a
|
||||
// positive score if all query characters appear in order in the target.
|
||||
func fuzzyCharMatch(query, target string) int {
|
||||
if utf8.RuneCountInString(query) > utf8.RuneCountInString(target) {
|
||||
return 0
|
||||
}
|
||||
|
||||
qRunes := []rune(query)
|
||||
tRunes := []rune(target)
|
||||
qi := 0
|
||||
score := 100
|
||||
consecutive := 0
|
||||
|
||||
for ti := 0; ti < len(tRunes) && qi < len(qRunes); ti++ {
|
||||
if tRunes[ti] == qRunes[qi] {
|
||||
qi++
|
||||
consecutive++
|
||||
score += consecutive * 5
|
||||
} else {
|
||||
consecutive = 0
|
||||
score -= 2
|
||||
}
|
||||
}
|
||||
|
||||
if qi < len(qRunes) {
|
||||
return 0
|
||||
}
|
||||
return score
|
||||
}
|
||||
+251
-23
@@ -36,9 +36,31 @@ type InputComponent struct {
|
||||
title string
|
||||
submitNext bool // defer submit one tick so popup dismisses cleanly
|
||||
|
||||
// Argument completion state. When the user types "/cmd " followed by
|
||||
// a partial argument and the command has a Complete function, the popup
|
||||
// switches to argument-completion mode showing suggestions from Complete.
|
||||
argMode bool // true when showing arg completions
|
||||
argCommand string // command prefix for arg mode (e.g. "/bookmark")
|
||||
argSynthCmds []SlashCommand // backing storage for synthetic arg entries
|
||||
|
||||
// File completion state. When the user types @ followed by a partial
|
||||
// file path, the popup shows file/directory suggestions from the cwd.
|
||||
fileMode bool // true when showing @file completions
|
||||
filePrefix string // current text after @ being matched
|
||||
fileAtStartIdx int // byte offset of @ in the textarea value
|
||||
fileSuggestions []FileSuggestion // backing storage for file entries
|
||||
fileSynthCmds []SlashCommand // synthetic SlashCommands wrapping file entries
|
||||
|
||||
// cwd is the working directory used for @file path resolution and
|
||||
// autocomplete suggestions. Set by the parent via SetCwd.
|
||||
cwd string
|
||||
|
||||
// appCtrl is used for slash commands that mutate app state.
|
||||
// May be nil in tests; nil-safe.
|
||||
appCtrl AppController
|
||||
|
||||
// hideHint suppresses the "enter submit · ctrl+j..." hint text.
|
||||
hideHint bool
|
||||
}
|
||||
|
||||
// NewInputComponent creates a new InputComponent with the given width, title,
|
||||
@@ -80,6 +102,12 @@ func NewInputComponent(width int, title string, appCtrl AppController) *InputCom
|
||||
}
|
||||
}
|
||||
|
||||
// SetCwd sets the working directory used for @file autocomplete suggestions
|
||||
// and path resolution. Should be called by the parent after construction.
|
||||
func (s *InputComponent) SetCwd(cwd string) {
|
||||
s.cwd = cwd
|
||||
}
|
||||
|
||||
// Init implements tea.Model. Starts the cursor blink animation.
|
||||
func (s *InputComponent) Init() tea.Cmd {
|
||||
return textarea.Blink
|
||||
@@ -138,17 +166,35 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))):
|
||||
if s.selected < len(s.filtered) {
|
||||
s.textarea.SetValue(s.filtered[s.selected].Command.Name)
|
||||
s.showPopup = false
|
||||
s.selected = 0
|
||||
if s.fileMode {
|
||||
s.applyFileCompletion(s.selected)
|
||||
} else if s.argMode {
|
||||
s.textarea.SetValue(s.argCommand + " " + s.filtered[s.selected].Command.Name)
|
||||
s.showPopup = false
|
||||
s.selected = 0
|
||||
} else {
|
||||
s.textarea.SetValue(s.filtered[s.selected].Command.Name)
|
||||
s.showPopup = false
|
||||
s.selected = 0
|
||||
}
|
||||
s.textarea.CursorEnd()
|
||||
}
|
||||
return s, nil
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
|
||||
if s.selected < len(s.filtered) {
|
||||
// Populate textarea with selected command and submit on next tick.
|
||||
s.textarea.SetValue(s.filtered[s.selected].Command.Name)
|
||||
if s.fileMode {
|
||||
// Apply file completion but don't submit.
|
||||
s.applyFileCompletion(s.selected)
|
||||
s.textarea.CursorEnd()
|
||||
return s, nil
|
||||
}
|
||||
// Populate textarea with selected item and submit on next tick.
|
||||
if s.argMode {
|
||||
s.textarea.SetValue(s.argCommand + " " + s.filtered[s.selected].Command.Name)
|
||||
} else {
|
||||
s.textarea.SetValue(s.filtered[s.selected].Command.Name)
|
||||
}
|
||||
s.textarea.CursorEnd()
|
||||
s.showPopup = false
|
||||
s.selected = 0
|
||||
@@ -172,12 +218,57 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if value != s.lastValue {
|
||||
s.lastValue = value
|
||||
lines := strings.Split(value, "\n")
|
||||
if len(lines) == 1 && strings.HasPrefix(lines[0], "/") && !strings.Contains(lines[0], " ") {
|
||||
s.showPopup = true
|
||||
s.filtered = FuzzyMatchCommands(lines[0], s.commands)
|
||||
s.selected = 0
|
||||
line := lines[len(lines)-1] // current line (last line for multi-line)
|
||||
|
||||
// Check for @file trigger first.
|
||||
cursorCol := len(line) // approximate: cursor is at end after typing
|
||||
if hasAt, prefix, atIdx := ExtractAtPrefix(line, cursorCol); hasAt && s.cwd != "" {
|
||||
suggestions := GetFileSuggestions(prefix, s.cwd)
|
||||
if len(suggestions) > 0 {
|
||||
s.showPopup = true
|
||||
s.fileMode = true
|
||||
s.argMode = false
|
||||
s.filePrefix = prefix
|
||||
s.fileAtStartIdx = atIdx
|
||||
s.fileSuggestions = suggestions
|
||||
s.fileSynthCmds = make([]SlashCommand, len(suggestions))
|
||||
s.filtered = make([]FuzzyMatch, len(suggestions))
|
||||
for i, fs := range suggestions {
|
||||
name := fs.RelPath
|
||||
desc := ""
|
||||
if fs.IsDir {
|
||||
desc = "directory"
|
||||
}
|
||||
s.fileSynthCmds[i] = SlashCommand{Name: name, Description: desc}
|
||||
s.filtered[i] = FuzzyMatch{Command: &s.fileSynthCmds[i], Score: fs.Score}
|
||||
}
|
||||
s.selected = 0
|
||||
} else {
|
||||
s.showPopup = false
|
||||
s.fileMode = false
|
||||
}
|
||||
} else if len(lines) == 1 && strings.HasPrefix(lines[0], "/") {
|
||||
s.fileMode = false
|
||||
if !strings.Contains(lines[0], " ") {
|
||||
// Command name completion.
|
||||
s.showPopup = true
|
||||
s.argMode = false
|
||||
s.filtered = FuzzyMatchCommands(lines[0], s.commands)
|
||||
s.selected = 0
|
||||
} else if suggestions := s.completeArgs(lines[0]); len(suggestions) > 0 {
|
||||
// Argument completion for a command with a Complete function.
|
||||
s.showPopup = true
|
||||
// s.argMode, s.argCommand, s.argSynthCmds, s.filtered
|
||||
// are set by completeArgs.
|
||||
s.selected = 0
|
||||
} else {
|
||||
s.showPopup = false
|
||||
s.argMode = false
|
||||
}
|
||||
} else {
|
||||
s.showPopup = false
|
||||
s.argMode = false
|
||||
s.fileMode = false
|
||||
}
|
||||
}
|
||||
return s, cmd
|
||||
@@ -191,12 +282,34 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// handleSubmit processes the submitted text. Slash commands that affect app
|
||||
// state are executed here; /quit returns tea.Quit; everything else returns a
|
||||
// submitMsg tea.Cmd for the parent to forward to app.Run().
|
||||
//
|
||||
// Shell command prefixes (matching pi's behavior):
|
||||
// - !cmd → execute shell command, output INCLUDED in LLM context
|
||||
// - !!cmd → execute shell command, output EXCLUDED from LLM context
|
||||
func (s *InputComponent) handleSubmit(value string) tea.Cmd {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check for shell command prefixes before slash commands. Test !! first
|
||||
// (more specific) to avoid matching the single-! case for double-bang.
|
||||
if strings.HasPrefix(trimmed, "!!") {
|
||||
cmd := strings.TrimSpace(trimmed[2:])
|
||||
if cmd != "" {
|
||||
return func() tea.Msg {
|
||||
return shellCommandMsg{Command: cmd, ExcludeFromContext: true}
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(trimmed, "!") {
|
||||
cmd := strings.TrimSpace(trimmed[1:])
|
||||
if cmd != "" {
|
||||
return func() tea.Msg {
|
||||
return shellCommandMsg{Command: cmd, ExcludeFromContext: false}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve via canonical command lookup so aliases are handled uniformly.
|
||||
// Only /quit and /clear are handled locally — /clear-queue must go
|
||||
// through the parent model so it can update queueCount directly
|
||||
@@ -254,13 +367,15 @@ func (s *InputComponent) View() tea.View {
|
||||
view.WriteString(s.renderPopup())
|
||||
}
|
||||
|
||||
helpStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("240")).
|
||||
MarginTop(1).
|
||||
PaddingLeft(3)
|
||||
if !s.hideHint {
|
||||
helpStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("240")).
|
||||
MarginTop(1).
|
||||
PaddingLeft(3)
|
||||
|
||||
view.WriteString("\n")
|
||||
view.WriteString(helpStyle.Render("enter submit • ctrl+j / alt+enter new line"))
|
||||
view.WriteString("\n")
|
||||
view.WriteString(helpStyle.Render("enter submit • ctrl+j / alt+enter new line"))
|
||||
}
|
||||
|
||||
return tea.NewView(containerStyle.Render(view.String()))
|
||||
}
|
||||
@@ -301,16 +416,32 @@ func (s *InputComponent) renderPopup() string {
|
||||
descStyle = descStyle.Foreground(lipgloss.Color("250"))
|
||||
}
|
||||
|
||||
nameWidth := 15
|
||||
name := nameStyle.Width(nameWidth - 2).Render(sc.Name)
|
||||
if s.fileMode {
|
||||
// File mode: use full width for the path, show description
|
||||
// (e.g. "directory") inline after a gap.
|
||||
maxNameLen := s.width - 24
|
||||
displayName := sc.Name
|
||||
if len(displayName) > maxNameLen && maxNameLen > 3 {
|
||||
displayName = displayName[:maxNameLen-3] + "..."
|
||||
}
|
||||
name := nameStyle.Render(displayName)
|
||||
if sc.Description != "" {
|
||||
items = append(items, indicator+name+" "+descStyle.Render(sc.Description))
|
||||
} else {
|
||||
items = append(items, indicator+name)
|
||||
}
|
||||
} else {
|
||||
nameWidth := 15
|
||||
name := nameStyle.Width(nameWidth - 2).Render(sc.Name)
|
||||
|
||||
desc := sc.Description
|
||||
maxDescLen := s.width - nameWidth - 14
|
||||
if len(desc) > maxDescLen && maxDescLen > 3 {
|
||||
desc = desc[:maxDescLen-3] + "..."
|
||||
desc := sc.Description
|
||||
maxDescLen := s.width - nameWidth - 14
|
||||
if len(desc) > maxDescLen && maxDescLen > 3 {
|
||||
desc = desc[:maxDescLen-3] + "..."
|
||||
}
|
||||
|
||||
items = append(items, indicator+name+descStyle.Render(desc))
|
||||
}
|
||||
|
||||
items = append(items, indicator+name+descStyle.Render(desc))
|
||||
}
|
||||
|
||||
if startIdx > 0 {
|
||||
@@ -326,3 +457,100 @@ func (s *InputComponent) renderPopup() string {
|
||||
|
||||
return popupStyle.Render(content + "\n\n" + footer)
|
||||
}
|
||||
|
||||
// completeArgs checks whether the input line matches a command with a Complete
|
||||
// function, calls it, and populates the arg-mode state on success. Returns the
|
||||
// list of suggestions (empty means no completions available).
|
||||
func (s *InputComponent) completeArgs(line string) []FuzzyMatch {
|
||||
parts := strings.SplitN(line, " ", 2)
|
||||
cmdName := parts[0]
|
||||
argPrefix := ""
|
||||
if len(parts) > 1 {
|
||||
argPrefix = parts[1]
|
||||
}
|
||||
|
||||
cmd := s.findCommandWithComplete(cmdName)
|
||||
if cmd == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
suggestions := cmd.Complete(argPrefix)
|
||||
if len(suggestions) == 0 {
|
||||
s.argMode = false
|
||||
return nil
|
||||
}
|
||||
|
||||
s.argMode = true
|
||||
s.argCommand = cmdName
|
||||
s.argSynthCmds = make([]SlashCommand, len(suggestions))
|
||||
s.filtered = make([]FuzzyMatch, len(suggestions))
|
||||
for i, sug := range suggestions {
|
||||
s.argSynthCmds[i] = SlashCommand{Name: sug}
|
||||
s.filtered[i] = FuzzyMatch{Command: &s.argSynthCmds[i]}
|
||||
}
|
||||
return s.filtered
|
||||
}
|
||||
|
||||
// findCommandWithComplete looks up a command by name that has a non-nil
|
||||
// Complete function.
|
||||
func (s *InputComponent) findCommandWithComplete(name string) *SlashCommand {
|
||||
for i := range s.commands {
|
||||
if s.commands[i].Name == name && s.commands[i].Complete != nil {
|
||||
return &s.commands[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyFileCompletion replaces the @prefix in the textarea with the selected
|
||||
// file suggestion. For directories, it keeps the popup open for further
|
||||
// drilling. For files, it closes the popup and adds a trailing space.
|
||||
func (s *InputComponent) applyFileCompletion(idx int) {
|
||||
if idx >= len(s.fileSuggestions) {
|
||||
return
|
||||
}
|
||||
|
||||
suggestion := s.fileSuggestions[idx]
|
||||
value := s.textarea.Value()
|
||||
|
||||
// Build the replacement text. The @ and everything after it up to the
|
||||
// cursor should be replaced with @<selected path>.
|
||||
// Find the current line's contribution.
|
||||
lines := strings.Split(value, "\n")
|
||||
lastLine := lines[len(lines)-1]
|
||||
|
||||
// Reconstruct: everything before the @ on the last line + @<path>
|
||||
beforeAt := lastLine[:s.fileAtStartIdx]
|
||||
needsQuote := strings.Contains(suggestion.RelPath, " ")
|
||||
|
||||
var replacement string
|
||||
if needsQuote {
|
||||
replacement = `@"` + suggestion.RelPath + `"`
|
||||
} else {
|
||||
replacement = "@" + suggestion.RelPath
|
||||
}
|
||||
|
||||
// For files, add a trailing space. For directories, don't — allow
|
||||
// continued drilling into the directory.
|
||||
if !suggestion.IsDir {
|
||||
replacement += " "
|
||||
}
|
||||
|
||||
newLastLine := beforeAt + replacement
|
||||
|
||||
// Reconstruct the full value with the updated last line.
|
||||
lines[len(lines)-1] = newLastLine
|
||||
newValue := strings.Join(lines, "\n")
|
||||
|
||||
s.textarea.SetValue(newValue)
|
||||
s.textarea.CursorEnd()
|
||||
|
||||
if suggestion.IsDir {
|
||||
// Keep popup open — trigger a refresh for the new directory.
|
||||
s.lastValue = "" // force re-evaluation on next update tick
|
||||
} else {
|
||||
s.showPopup = false
|
||||
s.fileMode = false
|
||||
s.selected = 0
|
||||
}
|
||||
}
|
||||
|
||||
+491
-32
@@ -1,8 +1,11 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -72,6 +75,11 @@ type AppController interface {
|
||||
// results back to the TUI without going through tea.Cmd (which can stall
|
||||
// when the goroutine blocks on interactive prompts).
|
||||
SendEvent(tea.Msg)
|
||||
// AddContextMessage adds a user-role message to the conversation history
|
||||
// without triggering an LLM response. Used by the ! shell command prefix
|
||||
// to inject command output into context so the LLM can reference it in
|
||||
// subsequent turns.
|
||||
AddContextMessage(text string)
|
||||
}
|
||||
|
||||
// SkillItem holds display metadata about a loaded skill for the startup
|
||||
@@ -167,6 +175,24 @@ type WidgetData struct {
|
||||
NoBorder bool
|
||||
}
|
||||
|
||||
// StatusBarEntryData represents a keyed extension entry in the TUI status bar.
|
||||
// Multiple entries from different extensions coexist, ordered by Priority
|
||||
// (lower values render further left).
|
||||
type StatusBarEntryData struct {
|
||||
Key string // unique identifier (e.g. "myext:git-branch")
|
||||
Text string // rendered content shown in the status bar
|
||||
Priority int // lower = further left; built-in entries use 100-110
|
||||
}
|
||||
|
||||
// UIVisibility controls which built-in TUI chrome elements are visible.
|
||||
// The zero value shows everything (backward compatible).
|
||||
type UIVisibility struct {
|
||||
HideStartupMessage bool // Hide the "Model loaded..." startup block
|
||||
HideStatusBar bool // Hide the "provider · model Tokens: ..." line
|
||||
HideSeparator bool // Hide the "────────" divider between stream and input
|
||||
HideInputHint bool // Hide the "enter submit · ctrl+j..." hint below input
|
||||
}
|
||||
|
||||
// AppModelOptions holds configuration passed to NewAppModel.
|
||||
type AppModelOptions struct {
|
||||
// CompactMode selects the compact renderer for message formatting.
|
||||
@@ -183,6 +209,10 @@ type AppModelOptions struct {
|
||||
// (e.g. GPU fallback info). Displayed at startup when non-empty.
|
||||
LoadingMessage string
|
||||
|
||||
// Cwd is the working directory for @file autocomplete and path resolution.
|
||||
// If empty, @file features are disabled.
|
||||
Cwd string
|
||||
|
||||
// Width is the initial terminal width in columns.
|
||||
Width int
|
||||
|
||||
@@ -242,6 +272,40 @@ type AppModelOptions struct {
|
||||
// intercept key events and during View() to wrap input rendering.
|
||||
// May be nil if no extensions are loaded.
|
||||
GetEditorInterceptor func() *EditorInterceptor
|
||||
|
||||
// GetUIVisibility returns the current UI visibility overrides set by
|
||||
// an extension, or nil if none have been set (show everything).
|
||||
// Called during View() and PrintStartupInfo() to conditionally hide
|
||||
// built-in chrome elements. May be nil if no extensions are loaded.
|
||||
GetUIVisibility func() *UIVisibility
|
||||
|
||||
// GetStatusBarEntries returns extension-provided status bar entries,
|
||||
// sorted by priority. Called during renderStatusBar() to inject
|
||||
// extension entries alongside the built-in model/usage display.
|
||||
// May be nil if no extensions are loaded.
|
||||
GetStatusBarEntries func() []StatusBarEntryData
|
||||
|
||||
// EmitBeforeFork, if non-nil, is called before branching to a
|
||||
// different session tree entry. Returns (cancelled, reason) where
|
||||
// cancelled=true means the fork should be aborted. May be nil if
|
||||
// no extensions are loaded.
|
||||
EmitBeforeFork func(targetID string, isUserMsg bool, userText string) (bool, string)
|
||||
|
||||
// EmitBeforeSessionSwitch, if non-nil, is called before switching
|
||||
// to a new session branch (e.g. /new, /clear). Returns (cancelled,
|
||||
// reason). May be nil if no extensions are loaded.
|
||||
EmitBeforeSessionSwitch func(reason string) (bool, string)
|
||||
|
||||
// GetGlobalShortcuts, if non-nil, returns extension-registered global
|
||||
// keyboard shortcuts. Keys are binding strings (e.g., "ctrl+p").
|
||||
// Handlers are called in a goroutine to avoid blocking the TUI event
|
||||
// loop. May be nil if no extensions are loaded.
|
||||
GetGlobalShortcuts func() map[string]func()
|
||||
|
||||
// GetExtensionCommands, if non-nil, returns the current extension
|
||||
// commands. Called on WidgetUpdateEvent to refresh the command list
|
||||
// after an extension hot-reload. May be nil if no extensions loaded.
|
||||
GetExtensionCommands func() []ExtensionCommand
|
||||
}
|
||||
|
||||
// AppModel is the root Bubble Tea model for the interactive TUI. It owns the
|
||||
@@ -349,6 +413,28 @@ type AppModel struct {
|
||||
// getEditorInterceptor returns the current editor interceptor. May be nil.
|
||||
getEditorInterceptor func() *EditorInterceptor
|
||||
|
||||
// getUIVisibility returns extension-provided UI visibility overrides. May be nil.
|
||||
getUIVisibility func() *UIVisibility
|
||||
|
||||
// getStatusBarEntries returns extension-provided status bar entries. May be nil.
|
||||
getStatusBarEntries func() []StatusBarEntryData
|
||||
|
||||
// emitBeforeFork emits a before-fork event to extensions. Returns
|
||||
// (cancelled, reason). May be nil if no extensions are loaded.
|
||||
emitBeforeFork func(targetID string, isUserMsg bool, userText string) (bool, string)
|
||||
|
||||
// emitBeforeSessionSwitch emits a before-session-switch event to extensions.
|
||||
// Returns (cancelled, reason). May be nil if no extensions are loaded.
|
||||
emitBeforeSessionSwitch func(reason string) (bool, string)
|
||||
|
||||
// getGlobalShortcuts returns extension-registered keyboard shortcuts.
|
||||
// May be nil if no extensions are loaded.
|
||||
getGlobalShortcuts func() map[string]func()
|
||||
|
||||
// getExtensionCommands returns the current extension commands. Used
|
||||
// to refresh the command list after an extension hot-reload. May be nil.
|
||||
getExtensionCommands func() []ExtensionCommand
|
||||
|
||||
// prompt holds the state of an active interactive prompt overlay. Nil
|
||||
// when no prompt is active. Managed by updatePromptState().
|
||||
prompt *promptOverlay
|
||||
@@ -375,6 +461,9 @@ type AppModel struct {
|
||||
// so the model can return to it when the overlay completes.
|
||||
preOverlayState appState
|
||||
|
||||
// cwd is the working directory for @file path resolution.
|
||||
cwd string
|
||||
|
||||
// width and height track the terminal dimensions.
|
||||
width int
|
||||
height int
|
||||
@@ -452,6 +541,7 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
|
||||
serverNames: opts.ServerNames,
|
||||
toolNames: opts.ToolNames,
|
||||
usageTracker: opts.UsageTracker,
|
||||
cwd: opts.Cwd,
|
||||
width: width,
|
||||
height: height,
|
||||
}
|
||||
@@ -462,6 +552,12 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
|
||||
m.getHeader = opts.GetHeader
|
||||
m.getFooter = opts.GetFooter
|
||||
m.getEditorInterceptor = opts.GetEditorInterceptor
|
||||
m.getUIVisibility = opts.GetUIVisibility
|
||||
m.getStatusBarEntries = opts.GetStatusBarEntries
|
||||
m.emitBeforeFork = opts.EmitBeforeFork
|
||||
m.emitBeforeSessionSwitch = opts.EmitBeforeSessionSwitch
|
||||
m.getGlobalShortcuts = opts.GetGlobalShortcuts
|
||||
m.getExtensionCommands = opts.GetExtensionCommands
|
||||
|
||||
// Store context/skills metadata and tool counts for startup display.
|
||||
m.contextPaths = opts.ContextPaths
|
||||
@@ -472,6 +568,11 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
|
||||
// Wire up child components now that we have the concrete implementations.
|
||||
m.input = NewInputComponent(width, "Enter your prompt (Type /help for commands, Ctrl+C to quit)", appCtrl)
|
||||
|
||||
// Wire up cwd for @file autocomplete.
|
||||
if ic, ok := m.input.(*InputComponent); ok && opts.Cwd != "" {
|
||||
ic.SetCwd(opts.Cwd)
|
||||
}
|
||||
|
||||
// Merge extension commands into the InputComponent's autocomplete source.
|
||||
if ic, ok := m.input.(*InputComponent); ok && len(opts.ExtensionCommands) > 0 {
|
||||
for _, ec := range opts.ExtensionCommands {
|
||||
@@ -479,6 +580,7 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
|
||||
Name: ec.Name,
|
||||
Description: ec.Description,
|
||||
Category: "Extensions",
|
||||
Complete: ec.Complete,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -510,12 +612,27 @@ func (m *AppModel) Init() tea.Cmd {
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// uiVis returns the current UIVisibility, defaulting to zero value (show all)
|
||||
// if no extension has set visibility overrides.
|
||||
func (m *AppModel) uiVis() UIVisibility {
|
||||
if m.getUIVisibility != nil {
|
||||
if v := m.getUIVisibility(); v != nil {
|
||||
return *v
|
||||
}
|
||||
}
|
||||
return UIVisibility{}
|
||||
}
|
||||
|
||||
// PrintStartupInfo prints the startup banner (model name, context, skills,
|
||||
// tool counts) to stdout. Call this before program.Run() so the messages are
|
||||
// visible above the Bubble Tea managed region.
|
||||
//
|
||||
// All startup information is rendered inside a single system message block.
|
||||
func (m *AppModel) PrintStartupInfo() {
|
||||
if m.uiVis().HideStartupMessage {
|
||||
return
|
||||
}
|
||||
|
||||
render := func(text string) string {
|
||||
return m.renderer.RenderSystemMessage(text, time.Now()).Content
|
||||
}
|
||||
@@ -608,25 +725,32 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = ts.Branch(targetID)
|
||||
m.appCtrl.ClearMessages()
|
||||
|
||||
// If it was a user message, populate the input with the text.
|
||||
if msg.IsUser && msg.UserText != "" {
|
||||
if ic, ok := m.input.(*InputComponent); ok {
|
||||
ic.textarea.SetValue(msg.UserText)
|
||||
ic.textarea.CursorEnd()
|
||||
}
|
||||
// Emit before-fork event in a goroutine so that extension handlers
|
||||
// can call blocking operations (e.g. ctx.PromptConfirm) without
|
||||
// deadlocking the BubbleTea event loop.
|
||||
if m.emitBeforeFork != nil {
|
||||
emit := m.emitBeforeFork
|
||||
ctrl := m.appCtrl
|
||||
forkTargetID := targetID
|
||||
forkIsUser := msg.IsUser
|
||||
forkUserText := msg.UserText
|
||||
go func() {
|
||||
cancelled, reason := emit(forkTargetID, forkIsUser, forkUserText)
|
||||
ctrl.SendEvent(beforeForkResultMsg{
|
||||
cancelled: cancelled,
|
||||
reason: reason,
|
||||
targetID: forkTargetID,
|
||||
isUser: forkIsUser,
|
||||
userText: forkUserText,
|
||||
})
|
||||
}()
|
||||
m.treeSelector = nil
|
||||
m.state = stateInput
|
||||
return m, func() tea.Msg { return nil }
|
||||
}
|
||||
|
||||
cmds = append(cmds, m.printSystemMessage(
|
||||
fmt.Sprintf("Navigated to branch point. %s",
|
||||
func() string {
|
||||
if msg.IsUser {
|
||||
return "Edit and resubmit to create a new branch."
|
||||
}
|
||||
return "Continue from this point."
|
||||
}())))
|
||||
cmds = append(cmds, m.performFork(targetID, msg.IsUser, msg.UserText))
|
||||
}
|
||||
m.treeSelector = nil
|
||||
m.state = stateInput
|
||||
@@ -672,6 +796,21 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
// Check extension-registered global keyboard shortcuts. These fire
|
||||
// in all app states except modal prompts/overlays (which return early
|
||||
// above). Matched shortcuts are consumed — the key does not propagate
|
||||
// to child components.
|
||||
if m.getGlobalShortcuts != nil {
|
||||
if shortcuts := m.getGlobalShortcuts(); shortcuts != nil {
|
||||
if handler, ok := shortcuts[msg.String()]; ok {
|
||||
// Run in goroutine so blocking extension calls
|
||||
// (PromptSelect, etc.) don't stall the event loop.
|
||||
go handler()
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Route to tree selector when active.
|
||||
if m.state == stateTreeSelector && m.treeSelector != nil {
|
||||
updated, cmd := m.treeSelector.Update(msg)
|
||||
@@ -780,12 +919,20 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
// Regular prompt — forward to the app layer.
|
||||
// Preprocess @file references: expand them into XML-wrapped file
|
||||
// content before sending to the agent. The display text (shown in
|
||||
// scrollback) uses the original user text so the UI stays clean.
|
||||
processedText := msg.Text
|
||||
if m.cwd != "" {
|
||||
processedText = ProcessFileAttachments(msg.Text, m.cwd)
|
||||
}
|
||||
|
||||
if m.appCtrl != nil {
|
||||
// Run returns the queue depth: >0 means the prompt was queued
|
||||
// (agent is busy). We update queuedMessages directly here
|
||||
// instead of relying on an event from prog.Send(), which would
|
||||
// deadlock when called synchronously from within Update().
|
||||
if qLen := m.appCtrl.Run(msg.Text); qLen > 0 {
|
||||
if qLen := m.appCtrl.Run(processedText); qLen > 0 {
|
||||
// Queued: anchor the message text above the input with a
|
||||
// "queued" badge. It will be printed to scrollback when
|
||||
// the agent picks it up (on QueueUpdatedEvent).
|
||||
@@ -802,6 +949,14 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.state = stateWorking
|
||||
}
|
||||
|
||||
// ── Shell command (! / !!) ───────────────────────────────────────────────
|
||||
case shellCommandMsg:
|
||||
// Execute the shell command asynchronously so the TUI stays responsive.
|
||||
cmds = append(cmds, m.executeShellCommand(msg))
|
||||
|
||||
case shellCommandResultMsg:
|
||||
cmds = append(cmds, m.handleShellCommandResult(msg))
|
||||
|
||||
// ── App layer events ─────────────────────────────────────────────────────
|
||||
|
||||
case app.SpinnerEvent:
|
||||
@@ -930,12 +1085,50 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.state = stateInput
|
||||
cmds = append(cmds, m.printSystemMessage(fmt.Sprintf("Compaction failed: %v", msg.Err)))
|
||||
|
||||
case app.ModelChangedEvent:
|
||||
// Extension changed the model — update display name in status bar
|
||||
// and message attribution.
|
||||
m.providerName = msg.ProviderName
|
||||
m.modelName = msg.ModelName
|
||||
|
||||
case app.WidgetUpdateEvent:
|
||||
// Extension widget changed — recalculate height distribution so the
|
||||
// stream region accounts for widget space. View() will read the
|
||||
// latest widget state on the next render.
|
||||
m.distributeHeight()
|
||||
|
||||
// Refresh extension commands (e.g. after hot-reload). The callback
|
||||
// returns the current set from the runner which may have changed.
|
||||
if m.getExtensionCommands != nil {
|
||||
newCmds := m.getExtensionCommands()
|
||||
m.extensionCommands = newCmds
|
||||
if ic, ok := m.input.(*InputComponent); ok {
|
||||
// Remove old extension commands and add fresh ones.
|
||||
var builtins []SlashCommand
|
||||
for _, sc := range ic.commands {
|
||||
if sc.Category != "Extensions" {
|
||||
builtins = append(builtins, sc)
|
||||
}
|
||||
}
|
||||
for _, ec := range newCmds {
|
||||
builtins = append(builtins, SlashCommand{
|
||||
Name: ec.Name,
|
||||
Description: ec.Description,
|
||||
Category: "Extensions",
|
||||
Complete: ec.Complete,
|
||||
})
|
||||
}
|
||||
ic.commands = builtins
|
||||
}
|
||||
}
|
||||
|
||||
case app.EditorTextSetEvent:
|
||||
// Extension wants to pre-fill the input editor with text.
|
||||
if ic, ok := m.input.(*InputComponent); ok {
|
||||
ic.textarea.SetValue(msg.Text)
|
||||
ic.textarea.CursorEnd()
|
||||
}
|
||||
|
||||
case app.PromptRequestEvent:
|
||||
// Extension wants to show an interactive prompt. Enter prompt state.
|
||||
// If already in prompt state (concurrent prompt from another
|
||||
@@ -1004,6 +1197,24 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
cmds = append(cmds, m.printSystemMessage(msg.output))
|
||||
}
|
||||
|
||||
case beforeSessionSwitchResultMsg:
|
||||
// Async before-session-switch hook completed. Proceed with the
|
||||
// session reset if the hook did not cancel.
|
||||
if msg.cancelled {
|
||||
cmds = append(cmds, m.printSystemMessage(msg.reason))
|
||||
} else {
|
||||
cmds = append(cmds, m.performNewSession())
|
||||
}
|
||||
|
||||
case beforeForkResultMsg:
|
||||
// Async before-fork hook completed. Proceed with the fork if the
|
||||
// hook did not cancel.
|
||||
if msg.cancelled {
|
||||
cmds = append(cmds, m.printSystemMessage(msg.reason))
|
||||
} else {
|
||||
cmds = append(cmds, m.performFork(msg.targetID, msg.isUser, msg.userText))
|
||||
}
|
||||
|
||||
case app.ExtensionPrintEvent:
|
||||
// Extension output — route through styled renderers when a level is set.
|
||||
switch msg.Level {
|
||||
@@ -1049,8 +1260,14 @@ func (m *AppModel) View() tea.View {
|
||||
return tea.NewView(m.overlay.Render())
|
||||
}
|
||||
|
||||
vis := m.uiVis()
|
||||
|
||||
streamView := m.renderStream()
|
||||
separator := m.renderSeparator()
|
||||
|
||||
// Propagate hint visibility to the input component before rendering.
|
||||
if ic, ok := m.input.(*InputComponent); ok {
|
||||
ic.hideHint = vis.HideInputHint
|
||||
}
|
||||
|
||||
// When a prompt is active, it replaces the input area for consistency
|
||||
// (appears below the separator, in the same position as the input).
|
||||
@@ -1060,7 +1277,6 @@ func (m *AppModel) View() tea.View {
|
||||
} else {
|
||||
inputView = m.renderInput()
|
||||
}
|
||||
statusBar := m.renderStatusBar()
|
||||
|
||||
// Build the stacked layout. Optional header/footer wrap the core layout.
|
||||
var parts []string
|
||||
@@ -1076,7 +1292,10 @@ func (m *AppModel) View() tea.View {
|
||||
if streamView != "" {
|
||||
parts = append(parts, streamView)
|
||||
}
|
||||
parts = append(parts, separator)
|
||||
|
||||
if !vis.HideSeparator {
|
||||
parts = append(parts, m.renderSeparator())
|
||||
}
|
||||
|
||||
// Render "above" widgets between separator and queued messages.
|
||||
if aboveView := m.renderWidgetSlot("above"); aboveView != "" {
|
||||
@@ -1094,7 +1313,9 @@ func (m *AppModel) View() tea.View {
|
||||
parts = append(parts, belowView)
|
||||
}
|
||||
|
||||
parts = append(parts, statusBar)
|
||||
if !vis.HideStatusBar {
|
||||
parts = append(parts, m.renderStatusBar())
|
||||
}
|
||||
|
||||
// Custom footer (if set by extension) — below everything.
|
||||
if footerView := m.renderHeaderFooter(m.getFooter); footerView != "" {
|
||||
@@ -1132,7 +1353,8 @@ func (m *AppModel) renderStream() string {
|
||||
}
|
||||
|
||||
// renderStatusBar renders a persistent single-line status bar below the input.
|
||||
// Left side: spinner (when active). Right side: provider · model + usage stats.
|
||||
// Left side: spinner (when active). Middle: extension status entries (sorted by
|
||||
// priority). Right side: provider · model + usage stats.
|
||||
// This bar is always present so its height is constant, eliminating layout
|
||||
// shifts from spinner or usage info appearing/disappearing.
|
||||
func (m *AppModel) renderStatusBar() string {
|
||||
@@ -1143,7 +1365,21 @@ func (m *AppModel) renderStatusBar() string {
|
||||
if m.stream != nil {
|
||||
leftSide = m.stream.SpinnerView()
|
||||
}
|
||||
leftWidth := lipgloss.Width(leftSide)
|
||||
|
||||
// Middle: extension status bar entries (sorted by priority).
|
||||
var middleParts []string
|
||||
if m.getStatusBarEntries != nil {
|
||||
entries := m.getStatusBarEntries()
|
||||
for _, e := range entries {
|
||||
middleParts = append(middleParts, lipgloss.NewStyle().
|
||||
Foreground(theme.Muted).
|
||||
Render(e.Text))
|
||||
}
|
||||
}
|
||||
middleSide := strings.Join(middleParts, " ")
|
||||
if middleSide != "" && leftSide != "" {
|
||||
middleSide = " " + middleSide
|
||||
}
|
||||
|
||||
// Right side: provider · model + usage stats.
|
||||
var rightParts []string
|
||||
@@ -1167,12 +1403,12 @@ func (m *AppModel) renderStatusBar() string {
|
||||
}
|
||||
|
||||
rightSide := strings.Join(rightParts, " ")
|
||||
rightWidth := lipgloss.Width(rightSide)
|
||||
|
||||
// Fill the gap between left and right with spaces.
|
||||
gap := max(m.width-leftWidth-rightWidth, 1)
|
||||
// Fill the gap between left+middle and right with spaces.
|
||||
usedWidth := lipgloss.Width(leftSide) + lipgloss.Width(middleSide) + lipgloss.Width(rightSide)
|
||||
gap := max(m.width-usedWidth, 1)
|
||||
|
||||
return leftSide + strings.Repeat(" ", gap) + rightSide
|
||||
return leftSide + middleSide + strings.Repeat(" ", gap) + rightSide
|
||||
}
|
||||
|
||||
// renderSeparator renders the separator line with an optional queue count badge.
|
||||
@@ -1513,7 +1749,10 @@ func (m *AppModel) printHelpMessage() tea.Cmd {
|
||||
help += skillHelp.String()
|
||||
}
|
||||
|
||||
help += "**Keys:**\n" +
|
||||
help += "**Shell Commands:**\n" +
|
||||
"- `!command`: Run shell command, output included in LLM context\n" +
|
||||
"- `!!command`: Run shell command, output excluded from LLM context\n\n" +
|
||||
"**Keys:**\n" +
|
||||
"- `Ctrl+C`: Exit at any time\n" +
|
||||
"- `ESC` (x2): Cancel ongoing LLM generation\n\n" +
|
||||
"You can also just type your message to chat with the AI assistant."
|
||||
@@ -1631,7 +1870,7 @@ func (m *AppModel) printCompactResult(evt app.CompactCompleteEvent) tea.Cmd {
|
||||
// and on step completion.
|
||||
//
|
||||
// After flushing, a ClearScreen is issued to force a full terminal redraw.
|
||||
// This is the bubbletea equivalent of pi's "clearOnShrink" mechanism: when
|
||||
// When
|
||||
// the stream content is moved to scrollback the view height shrinks, and
|
||||
// bubbletea's inline renderer doesn't clear the orphaned terminal rows
|
||||
// below the managed region. ClearScreen ensures a clean redraw.
|
||||
@@ -1666,11 +1905,24 @@ func (m *AppModel) flushStreamContent() tea.Cmd {
|
||||
// status bar = 1 line (always present)
|
||||
// footer = measured dynamically (0 if not set)
|
||||
func (m *AppModel) distributeHeight() {
|
||||
const separatorLines = 1
|
||||
const statusBarLines = 1 // always-present status bar
|
||||
vis := m.uiVis()
|
||||
|
||||
separatorLines := 1
|
||||
if vis.HideSeparator {
|
||||
separatorLines = 0
|
||||
}
|
||||
statusBarLines := 1
|
||||
if vis.HideStatusBar {
|
||||
statusBarLines = 0
|
||||
}
|
||||
const linesPerQueuedMsg = 5
|
||||
queuedLines := len(m.queuedMessages) * linesPerQueuedMsg
|
||||
|
||||
// Propagate hint visibility before measuring input height.
|
||||
if ic, ok := m.input.(*InputComponent); ok {
|
||||
ic.hideHint = vis.HideInputHint
|
||||
}
|
||||
|
||||
// Measure the actual rendered input (or prompt overlay) height so we
|
||||
// don't rely on a fragile constant that drifts when styling changes.
|
||||
// Use renderInput() which includes the editor interceptor's Render
|
||||
@@ -1807,6 +2059,28 @@ func (m *AppModel) handleForkCommand() tea.Cmd {
|
||||
|
||||
// handleNewCommand starts a fresh session by resetting the tree leaf.
|
||||
func (m *AppModel) handleNewCommand() tea.Cmd {
|
||||
// Emit before-session-switch event in a goroutine so that extension
|
||||
// handlers can call blocking operations (e.g. ctx.PromptConfirm) without
|
||||
// deadlocking the BubbleTea event loop.
|
||||
if m.emitBeforeSessionSwitch != nil {
|
||||
emit := m.emitBeforeSessionSwitch
|
||||
ctrl := m.appCtrl
|
||||
go func() {
|
||||
cancelled, reason := emit("new")
|
||||
ctrl.SendEvent(beforeSessionSwitchResultMsg{
|
||||
cancelled: cancelled,
|
||||
reason: reason,
|
||||
})
|
||||
}()
|
||||
return func() tea.Msg { return nil }
|
||||
}
|
||||
|
||||
return m.performNewSession()
|
||||
}
|
||||
|
||||
// performNewSession performs the actual session reset. Called either directly
|
||||
// (when no before-hook exists) or after the async hook completes.
|
||||
func (m *AppModel) performNewSession() tea.Cmd {
|
||||
ts := m.appCtrl.GetTreeSession()
|
||||
if ts == nil {
|
||||
// No tree session — just clear messages.
|
||||
@@ -1823,6 +2097,35 @@ func (m *AppModel) handleNewCommand() tea.Cmd {
|
||||
return m.printSystemMessage("New branch started. Previous conversation is preserved in the tree.")
|
||||
}
|
||||
|
||||
// performFork performs the actual tree branch. Called either directly (when no
|
||||
// before-hook exists) or after the async before-fork hook completes.
|
||||
func (m *AppModel) performFork(targetID string, isUser bool, userText string) tea.Cmd {
|
||||
ts := m.appCtrl.GetTreeSession()
|
||||
if ts == nil {
|
||||
return m.printSystemMessage("No tree session active.")
|
||||
}
|
||||
|
||||
_ = ts.Branch(targetID)
|
||||
m.appCtrl.ClearMessages()
|
||||
|
||||
// If it was a user message, populate the input with the text.
|
||||
if isUser && userText != "" {
|
||||
if ic, ok := m.input.(*InputComponent); ok {
|
||||
ic.textarea.SetValue(userText)
|
||||
ic.textarea.CursorEnd()
|
||||
}
|
||||
}
|
||||
|
||||
return m.printSystemMessage(
|
||||
fmt.Sprintf("Navigated to branch point. %s",
|
||||
func() string {
|
||||
if isUser {
|
||||
return "Edit and resubmit to create a new branch."
|
||||
}
|
||||
return "Continue from this point."
|
||||
}()))
|
||||
}
|
||||
|
||||
// handleNameCommand sets a display name for the current session.
|
||||
func (m *AppModel) handleNameCommand() tea.Cmd {
|
||||
ts := m.appCtrl.GetTreeSession()
|
||||
@@ -1831,7 +2134,7 @@ func (m *AppModel) handleNameCommand() tea.Cmd {
|
||||
}
|
||||
// For now, prompt user to provide name via input. We print instructions
|
||||
// and the next non-command input starting with "name:" will be captured.
|
||||
// TODO: inline input dialog like pi's implementation.
|
||||
// TODO: inline input dialog.
|
||||
currentName := ts.GetSessionName()
|
||||
if currentName != "" {
|
||||
return m.printSystemMessage(fmt.Sprintf("Current session name: %q\nTo rename, type: `/name <new name>` (not yet implemented — use the session file directly).", currentName))
|
||||
@@ -1896,6 +2199,26 @@ type extensionCmdResultMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
// beforeSessionSwitchResultMsg carries the result of an asynchronously
|
||||
// executed before-session-switch hook. The hook runs in a goroutine so that
|
||||
// blocking operations like ctx.PromptConfirm() do not deadlock the TUI.
|
||||
type beforeSessionSwitchResultMsg struct {
|
||||
cancelled bool
|
||||
reason string
|
||||
}
|
||||
|
||||
// beforeForkResultMsg carries the result of an asynchronously executed
|
||||
// before-fork hook along with the fork context needed to complete the
|
||||
// operation if the hook allows it.
|
||||
type beforeForkResultMsg struct {
|
||||
cancelled bool
|
||||
reason string
|
||||
// Fork context — preserved so the operation can proceed after the hook.
|
||||
targetID string
|
||||
isUser bool
|
||||
userText string
|
||||
}
|
||||
|
||||
// updatePromptState handles all messages while the prompt overlay is active.
|
||||
// It routes keys to the prompt overlay, detects completion/cancellation, and
|
||||
// restores the previous state when done.
|
||||
@@ -2027,3 +2350,139 @@ func (m *AppModel) resolveOverlay(resp app.OverlayResponse) {
|
||||
m.overlay = nil
|
||||
m.state = m.preOverlayState
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Shell command execution (! and !!)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// shellCommandTimeout is the maximum duration for a user shell command.
|
||||
const shellCommandTimeout = 120 * time.Second
|
||||
|
||||
// executeShellCommand runs a shell command asynchronously and returns the
|
||||
// result as a shellCommandResultMsg. This is launched from Update() as a
|
||||
// tea.Cmd so the TUI stays responsive during execution.
|
||||
func (m *AppModel) executeShellCommand(msg shellCommandMsg) tea.Cmd {
|
||||
command := msg.Command
|
||||
excludeFromContext := msg.ExcludeFromContext
|
||||
cwd := m.cwd
|
||||
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shellCommandTimeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "bash", "-c", command)
|
||||
if cwd != "" {
|
||||
cmd.Dir = cwd
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
|
||||
exitCode := 0
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = exitErr.ExitCode()
|
||||
// Non-zero exit is reported via exitCode, not as an error.
|
||||
err = nil
|
||||
} else if ctx.Err() == context.DeadlineExceeded {
|
||||
return shellCommandResultMsg{
|
||||
Command: command,
|
||||
Output: fmt.Sprintf("command timed out after %v", shellCommandTimeout),
|
||||
ExitCode: -1,
|
||||
Err: fmt.Errorf("command timed out after %v", shellCommandTimeout),
|
||||
ExcludeFromContext: excludeFromContext,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Combine stdout + stderr.
|
||||
var output strings.Builder
|
||||
if stdout.Len() > 0 {
|
||||
output.WriteString(stdout.String())
|
||||
}
|
||||
if stderr.Len() > 0 {
|
||||
if output.Len() > 0 {
|
||||
output.WriteString("\n")
|
||||
}
|
||||
output.WriteString(stderr.String())
|
||||
}
|
||||
|
||||
return shellCommandResultMsg{
|
||||
Command: command,
|
||||
Output: output.String(),
|
||||
ExitCode: exitCode,
|
||||
Err: err,
|
||||
ExcludeFromContext: excludeFromContext,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleShellCommandResult processes the result of a shell command execution.
|
||||
// It prints the output to scrollback and optionally injects it into the
|
||||
// conversation context (for ! commands) so the LLM can see it.
|
||||
func (m *AppModel) handleShellCommandResult(msg shellCommandResultMsg) tea.Cmd {
|
||||
theme := GetTheme()
|
||||
|
||||
// Build the display header.
|
||||
var header string
|
||||
if msg.ExcludeFromContext {
|
||||
header = fmt.Sprintf("$ %s (excluded from context)", msg.Command)
|
||||
} else {
|
||||
header = fmt.Sprintf("$ %s", msg.Command)
|
||||
}
|
||||
|
||||
// Build the output content.
|
||||
var content strings.Builder
|
||||
content.WriteString(header)
|
||||
|
||||
if msg.Err != nil {
|
||||
content.WriteString(fmt.Sprintf("\n\nError: %v", msg.Err))
|
||||
} else if msg.Output != "" {
|
||||
content.WriteString("\n\n")
|
||||
content.WriteString(msg.Output)
|
||||
} else {
|
||||
content.WriteString("\n\n(no output)")
|
||||
}
|
||||
|
||||
if msg.ExitCode != 0 {
|
||||
content.WriteString(fmt.Sprintf("\n\nExit code: %d", msg.ExitCode))
|
||||
}
|
||||
|
||||
// Choose border color: dim for excluded, accent for included.
|
||||
borderClr := theme.Accent
|
||||
if msg.ExcludeFromContext {
|
||||
borderClr = theme.Muted
|
||||
}
|
||||
|
||||
rendered := renderContentBlock(
|
||||
content.String(),
|
||||
m.width,
|
||||
WithAlign(lipgloss.Left),
|
||||
WithBorderColor(borderClr),
|
||||
WithMarginBottom(1),
|
||||
)
|
||||
|
||||
var cmds []tea.Cmd
|
||||
cmds = append(cmds, tea.Println(rendered))
|
||||
|
||||
// For ! (included in context): inject the command output into the
|
||||
// conversation as a user message so the LLM can reference it on the
|
||||
// next turn. This does NOT trigger an LLM response — it only adds
|
||||
// to the conversation history.
|
||||
if !msg.ExcludeFromContext && m.appCtrl != nil {
|
||||
var output string
|
||||
if msg.Output != "" {
|
||||
output = msg.Output
|
||||
} else {
|
||||
output = "(no output)"
|
||||
}
|
||||
contextMsg := fmt.Sprintf("<shell_command>\n<command>%s</command>\n<output>\n%s</output>\n<exit_code>%d</exit_code>\n</shell_command>",
|
||||
msg.Command, output, msg.ExitCode)
|
||||
m.appCtrl.AddContextMessage(contextMsg)
|
||||
}
|
||||
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
@@ -57,6 +57,10 @@ func (s *stubAppController) SendEvent(_ tea.Msg) {
|
||||
// no-op in tests
|
||||
}
|
||||
|
||||
func (s *stubAppController) AddContextMessage(_ string) {
|
||||
// no-op in tests
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Stub child components
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -26,6 +26,7 @@ type SlashCommandInput struct {
|
||||
value string
|
||||
submitNext bool // Flag to submit on next update
|
||||
renderedLines int // Track how many lines were rendered
|
||||
hideHint bool // Suppress the "enter submit · ctrl+j..." hint
|
||||
}
|
||||
|
||||
// NewSlashCommandInput creates and initializes a new slash command input field with
|
||||
@@ -219,17 +220,19 @@ func (s *SlashCommandInput) View() tea.View {
|
||||
s.renderedLines += 1 + popupLines // newline + popup
|
||||
}
|
||||
|
||||
// Add help text at bottom
|
||||
helpStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("240")).
|
||||
MarginTop(1).
|
||||
PaddingLeft(3)
|
||||
// Add help text at bottom (unless hidden by extension).
|
||||
if !s.hideHint {
|
||||
helpStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("240")).
|
||||
MarginTop(1).
|
||||
PaddingLeft(3)
|
||||
|
||||
helpText := "enter submit • ctrl+j / alt+enter new line"
|
||||
helpText := "enter submit • ctrl+j / alt+enter new line"
|
||||
|
||||
view.WriteString("\n")
|
||||
view.WriteString(helpStyle.Render(helpText))
|
||||
s.renderedLines += 2 // newline + help text
|
||||
view.WriteString("\n")
|
||||
view.WriteString(helpStyle.Render(helpText))
|
||||
s.renderedLines += 2 // newline + help text
|
||||
}
|
||||
|
||||
// Apply container padding to entire view
|
||||
return tea.NewView(containerStyle.Render(view.String()))
|
||||
|
||||
@@ -696,3 +696,177 @@ func truncateLine(s string, maxWidth int) string {
|
||||
}
|
||||
return s[:maxWidth-1] + "…"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compact tool body renderers — one-line summaries for compact mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// renderToolBodyCompact returns a brief summary string for tool results in
|
||||
// compact display mode. Returns empty string to fall back to default.
|
||||
func renderToolBodyCompact(toolName, toolArgs, toolResult string, width int) string {
|
||||
switch {
|
||||
case toolName == "edit":
|
||||
return renderEditCompact(toolArgs, toolResult)
|
||||
case toolName == "ls":
|
||||
return renderLsCompact(toolResult)
|
||||
case toolName == "read":
|
||||
return renderReadCompact(toolResult)
|
||||
case toolName == "write":
|
||||
return renderWriteCompact(toolArgs)
|
||||
case toolName == "bash" || toolName == "run_shell_cmd" ||
|
||||
strings.Contains(toolName, "shell") || strings.Contains(toolName, "command"):
|
||||
return renderBashCompact(toolResult, width)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// renderReadCompact returns a line-count summary for Read tool output.
|
||||
func renderReadCompact(toolResult string) string {
|
||||
content := strings.TrimSpace(toolResult)
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := strings.Split(content, "\n")
|
||||
|
||||
// Count actual code lines (those with "N: " line-number prefix)
|
||||
codeLines := 0
|
||||
for _, line := range lines {
|
||||
if idx := strings.Index(line, ": "); idx > 0 && idx <= 7 {
|
||||
numPart := line[:idx]
|
||||
if _, err := strconv.Atoi(strings.TrimSpace(numPart)); err == nil {
|
||||
codeLines++
|
||||
}
|
||||
}
|
||||
}
|
||||
if codeLines == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
theme := getTheme()
|
||||
summary := fmt.Sprintf("%d lines", codeLines)
|
||||
return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(summary)
|
||||
}
|
||||
|
||||
// renderEditCompact returns a change-count summary for Edit tool output.
|
||||
func renderEditCompact(toolArgs, toolResult string) string {
|
||||
var args map[string]any
|
||||
if err := json.Unmarshal([]byte(toolArgs), &args); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
oldText, _ := args["old_text"].(string)
|
||||
newText, _ := args["new_text"].(string)
|
||||
if oldText == "" && newText == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
oldCount := len(strings.Split(oldText, "\n"))
|
||||
newCount := len(strings.Split(newText, "\n"))
|
||||
|
||||
theme := getTheme()
|
||||
var summary string
|
||||
if oldCount == newCount {
|
||||
summary = fmt.Sprintf("%d lines modified", oldCount)
|
||||
} else {
|
||||
summary = fmt.Sprintf("-%d/+%d lines", oldCount, newCount)
|
||||
}
|
||||
return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(summary)
|
||||
}
|
||||
|
||||
// renderWriteCompact returns a line-count summary for Write tool output.
|
||||
func renderWriteCompact(toolArgs string) string {
|
||||
var args map[string]any
|
||||
if err := json.Unmarshal([]byte(toolArgs), &args); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
content, _ := args["content"].(string)
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
count := len(strings.Split(content, "\n"))
|
||||
theme := getTheme()
|
||||
summary := fmt.Sprintf("%d lines written", count)
|
||||
return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(summary)
|
||||
}
|
||||
|
||||
// renderLsCompact returns an entry-count summary for Ls tool output.
|
||||
func renderLsCompact(toolResult string) string {
|
||||
content := strings.TrimSpace(toolResult)
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
entries := strings.Split(content, "\n")
|
||||
theme := getTheme()
|
||||
summary := fmt.Sprintf("%d entries", len(entries))
|
||||
return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(summary)
|
||||
}
|
||||
|
||||
// renderBashCompact returns the first few lines of bash output as a compact
|
||||
// summary. Shows up to 3 meaningful output lines.
|
||||
func renderBashCompact(toolResult string, width int) string {
|
||||
result := strings.TrimSpace(toolResult)
|
||||
if result == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := strings.Split(result, "\n")
|
||||
|
||||
// Filter to meaningful output lines (skip STDERR: label, keep exit codes separate)
|
||||
var outputLines []string
|
||||
var exitCode string
|
||||
inStderr := false
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "STDERR:" {
|
||||
inStderr = true
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "Exit code:") {
|
||||
exitCode = trimmed
|
||||
continue
|
||||
}
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
outputLines = append(outputLines, line)
|
||||
_ = inStderr // stderr lines are included in output
|
||||
}
|
||||
|
||||
if len(outputLines) == 0 {
|
||||
if exitCode != "" {
|
||||
theme := getTheme()
|
||||
return lipgloss.NewStyle().Foreground(theme.Error).Render(exitCode)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
const maxLines = 3
|
||||
theme := getTheme()
|
||||
|
||||
display := outputLines
|
||||
if len(display) > maxLines {
|
||||
display = display[:maxLines]
|
||||
}
|
||||
|
||||
// Truncate each line to available width
|
||||
lineMax := max(width-4, 20)
|
||||
for i, line := range display {
|
||||
if len(line) > lineMax {
|
||||
display[i] = line[:lineMax-3] + "..."
|
||||
}
|
||||
}
|
||||
|
||||
summary := strings.Join(display, "\n")
|
||||
if len(outputLines) > maxLines {
|
||||
summary += fmt.Sprintf("\n...(%d more lines)", len(outputLines)-maxLines)
|
||||
}
|
||||
if exitCode != "" {
|
||||
summary += "\n" + lipgloss.NewStyle().Foreground(theme.Error).Render(exitCode)
|
||||
}
|
||||
|
||||
return lipgloss.NewStyle().Foreground(theme.Muted).Render(summary)
|
||||
}
|
||||
|
||||
@@ -52,8 +52,7 @@ type FlatNode struct {
|
||||
}
|
||||
|
||||
// TreeSelectorComponent is a Bubble Tea component that renders the session
|
||||
// tree as an ASCII art list with navigation and selection. It follows pi's
|
||||
// tree selector design.
|
||||
// tree as an ASCII art list with navigation and selection.
|
||||
type TreeSelectorComponent struct {
|
||||
tm *session.TreeManager
|
||||
flatNodes []FlatNode
|
||||
|
||||
+41
-3
@@ -23,8 +23,8 @@ func (m *Kit) EstimateContextTokens() int {
|
||||
}
|
||||
|
||||
// ShouldCompact reports whether the conversation is near the model's context
|
||||
// limit and should be compacted. Uses Pi's formula:
|
||||
// contextTokens > contextWindow − reserveTokens.
|
||||
// limit and should be compacted.
|
||||
// Formula: contextTokens > contextWindow − reserveTokens.
|
||||
// Returns false if the model's context limit is unknown.
|
||||
func (m *Kit) ShouldCompact() bool {
|
||||
info := m.GetModelInfo()
|
||||
@@ -43,9 +43,23 @@ func (m *Kit) ShouldCompact() bool {
|
||||
|
||||
// GetContextStats returns current context usage statistics including
|
||||
// estimated token count, context limit, usage percentage, and message count.
|
||||
//
|
||||
// When API-reported token counts are available (after at least one turn),
|
||||
// EstimatedTokens uses the real input token count from the most recent API
|
||||
// response. This is significantly more accurate than the text-based heuristic
|
||||
// because it includes system prompts, tool definitions, and other overhead
|
||||
// that the heuristic cannot account for.
|
||||
func (m *Kit) GetContextStats() ContextStats {
|
||||
messages := m.treeSession.GetFantasyMessages()
|
||||
estimated := compaction.EstimateMessageTokens(messages)
|
||||
|
||||
// Prefer the real API-reported input token count when available.
|
||||
m.lastInputTokensMu.RLock()
|
||||
estimated := m.lastInputTokens
|
||||
m.lastInputTokensMu.RUnlock()
|
||||
if estimated == 0 {
|
||||
// Fall back to heuristic before first turn completes.
|
||||
estimated = compaction.EstimateMessageTokens(messages)
|
||||
}
|
||||
|
||||
stats := ContextStats{
|
||||
EstimatedTokens: estimated,
|
||||
@@ -72,6 +86,12 @@ func (m *Kit) GetContextStats() ContextStats {
|
||||
// After compaction, the tree session is cleared and replaced with the
|
||||
// compacted messages (summary + preserved recent messages).
|
||||
func (m *Kit) Compact(ctx context.Context, opts *CompactionOptions, customInstructions string) (*CompactionResult, error) {
|
||||
return m.compactInternal(ctx, opts, customInstructions, false)
|
||||
}
|
||||
|
||||
// compactInternal is the shared compaction implementation. The isAutomatic
|
||||
// flag distinguishes auto-triggered compaction from manual /compact.
|
||||
func (m *Kit) compactInternal(ctx context.Context, opts *CompactionOptions, customInstructions string, isAutomatic bool) (*CompactionResult, error) {
|
||||
if opts == nil {
|
||||
if m.compactionOpts != nil {
|
||||
opts = m.compactionOpts
|
||||
@@ -92,6 +112,24 @@ func (m *Kit) Compact(ctx context.Context, opts *CompactionOptions, customInstru
|
||||
return nil, fmt.Errorf("cannot compact: need at least 2 messages")
|
||||
}
|
||||
|
||||
// Run before-compact hook — extensions can cancel compaction.
|
||||
if m.beforeCompact.hasHooks() {
|
||||
stats := m.GetContextStats()
|
||||
if hookResult := m.beforeCompact.run(BeforeCompactHook{
|
||||
EstimatedTokens: stats.EstimatedTokens,
|
||||
ContextLimit: stats.ContextLimit,
|
||||
UsagePercent: stats.UsagePercent,
|
||||
MessageCount: stats.MessageCount,
|
||||
IsAutomatic: isAutomatic,
|
||||
}); hookResult != nil && hookResult.Cancel {
|
||||
reason := hookResult.Reason
|
||||
if reason == "" {
|
||||
reason = "compaction cancelled by extension"
|
||||
}
|
||||
return nil, fmt.Errorf("%s", reason)
|
||||
}
|
||||
}
|
||||
|
||||
model := m.agent.GetModel()
|
||||
result, newMessages, err := compaction.Compact(ctx, model, messages, *opts, customInstructions)
|
||||
if err != nil {
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
// defaultSystemPrompt is the built-in system prompt used when no custom
|
||||
// prompt is configured. It describes the available core tools and provides
|
||||
// usage guidelines, matching the Pi SDK's default prompt style.
|
||||
// usage guidelines.
|
||||
const defaultSystemPrompt = `You are an expert coding assistant operating inside kit, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.
|
||||
|
||||
Available tools:
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package kit
|
||||
|
||||
import "github.com/mark3labs/kit/internal/extensions"
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
)
|
||||
|
||||
// bridgeExtensions registers extension event handlers as SDK hooks and
|
||||
// subscribes to SDK observation events to forward them to the extension runner.
|
||||
@@ -97,4 +102,82 @@ func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- Context filtering hook ---
|
||||
// Extension ContextPrepare → SDK ContextPrepare hook.
|
||||
if runner.HasHandlers(extensions.ContextPrepare) {
|
||||
m.OnContextPrepare(HookPriorityNormal, func(h ContextPrepareHook) *ContextPrepareResult {
|
||||
// Convert fantasy.Message slice to extension ContextMessage slice.
|
||||
extMsgs := make([]extensions.ContextMessage, len(h.Messages))
|
||||
for i, msg := range h.Messages {
|
||||
// Extract text from content parts.
|
||||
var text strings.Builder
|
||||
for _, part := range msg.Content {
|
||||
if tp, ok := part.(fantasy.TextPart); ok {
|
||||
text.WriteString(tp.Text)
|
||||
}
|
||||
}
|
||||
extMsgs[i] = extensions.ContextMessage{
|
||||
Index: i,
|
||||
Role: string(msg.Role),
|
||||
Content: text.String(),
|
||||
}
|
||||
}
|
||||
|
||||
result, _ := runner.Emit(extensions.ContextPrepareEvent{Messages: extMsgs})
|
||||
r, ok := result.(extensions.ContextPrepareResult)
|
||||
if !ok || r.Messages == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Rebuild fantasy.Message slice from extension result.
|
||||
rebuilt := make([]fantasy.Message, 0, len(r.Messages))
|
||||
for _, cm := range r.Messages {
|
||||
if cm.Index >= 0 && cm.Index < len(h.Messages) {
|
||||
// Reuse original message (preserves tool calls, reasoning, etc.)
|
||||
rebuilt = append(rebuilt, h.Messages[cm.Index])
|
||||
} else {
|
||||
// New message injected by extension.
|
||||
role := fantasy.MessageRoleUser
|
||||
switch cm.Role {
|
||||
case "assistant":
|
||||
role = fantasy.MessageRoleAssistant
|
||||
case "system":
|
||||
role = fantasy.MessageRoleSystem
|
||||
case "tool":
|
||||
role = fantasy.MessageRoleTool
|
||||
}
|
||||
rebuilt = append(rebuilt, fantasy.Message{
|
||||
Role: role,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: cm.Content},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return &ContextPrepareResult{Messages: rebuilt}
|
||||
})
|
||||
}
|
||||
|
||||
// --- Compaction hook ---
|
||||
// Extension BeforeCompact → SDK BeforeCompact hook.
|
||||
if runner.HasHandlers(extensions.BeforeCompact) {
|
||||
m.OnBeforeCompact(HookPriorityNormal, func(h BeforeCompactHook) *BeforeCompactResult {
|
||||
result, _ := runner.Emit(extensions.BeforeCompactEvent{
|
||||
EstimatedTokens: h.EstimatedTokens,
|
||||
ContextLimit: h.ContextLimit,
|
||||
UsagePercent: h.UsagePercent,
|
||||
MessageCount: h.MessageCount,
|
||||
IsAutomatic: h.IsAutomatic,
|
||||
})
|
||||
if r, ok := result.(extensions.BeforeCompactResult); ok && r.Cancel {
|
||||
return &BeforeCompactResult{
|
||||
Cancel: true,
|
||||
Reason: r.Reason,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,43 @@ type AfterTurnHook struct {
|
||||
// AfterTurnResult is a placeholder — after-turn hooks are observation-only.
|
||||
type AfterTurnResult struct{}
|
||||
|
||||
// ContextPrepareHook is the input for hooks that fire after the context window
|
||||
// is assembled from the session tree (including compaction) and before the
|
||||
// messages are sent to the LLM. Hooks can filter, reorder, or inject messages.
|
||||
type ContextPrepareHook struct {
|
||||
// Messages is the current context as fantasy.Message objects.
|
||||
Messages []fantasy.Message
|
||||
}
|
||||
|
||||
// ContextPrepareResult can replace the context window.
|
||||
type ContextPrepareResult struct {
|
||||
// Messages replaces the entire context window. If nil, the original
|
||||
// messages are used.
|
||||
Messages []fantasy.Message
|
||||
}
|
||||
|
||||
// BeforeCompactHook is the input for hooks that fire before compaction runs.
|
||||
type BeforeCompactHook struct {
|
||||
// EstimatedTokens is the estimated token count of the conversation.
|
||||
EstimatedTokens int
|
||||
// ContextLimit is the model's context window size in tokens.
|
||||
ContextLimit int
|
||||
// UsagePercent is the fraction of context used (0.0–1.0).
|
||||
UsagePercent float64
|
||||
// MessageCount is the number of messages in the conversation.
|
||||
MessageCount int
|
||||
// IsAutomatic is true when compaction was triggered automatically.
|
||||
IsAutomatic bool
|
||||
}
|
||||
|
||||
// BeforeCompactResult controls whether compaction proceeds.
|
||||
type BeforeCompactResult struct {
|
||||
// Cancel, when true, prevents compaction from proceeding.
|
||||
Cancel bool
|
||||
// Reason is a human-readable explanation when Cancel is true.
|
||||
Reason string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic hook registry with priority ordering
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -181,6 +218,23 @@ func (m *Kit) OnAfterTurn(p HookPriority, h func(AfterTurnHook)) func() {
|
||||
})
|
||||
}
|
||||
|
||||
// OnContextPrepare registers a hook that fires after the context window is
|
||||
// built from the session tree and before messages are sent to the LLM. Return
|
||||
// a non-nil ContextPrepareResult with Messages to replace the entire context.
|
||||
// Hooks execute in priority order; the first non-nil result wins.
|
||||
// Returns an unregister function.
|
||||
func (m *Kit) OnContextPrepare(p HookPriority, h func(ContextPrepareHook) *ContextPrepareResult) func() {
|
||||
return m.contextPrepare.register(p, h)
|
||||
}
|
||||
|
||||
// OnBeforeCompact registers a hook that fires before context compaction runs.
|
||||
// Return a non-nil BeforeCompactResult with Cancel=true to prevent compaction.
|
||||
// Hooks execute in priority order; the first non-nil result wins.
|
||||
// Returns an unregister function.
|
||||
func (m *Kit) OnBeforeCompact(p HookPriority, h func(BeforeCompactHook) *BeforeCompactResult) func() {
|
||||
return m.beforeCompact.register(p, h)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool wrapping via hooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+520
-8
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
@@ -14,6 +15,8 @@ import (
|
||||
"github.com/mark3labs/kit/internal/config"
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
"github.com/mark3labs/kit/internal/kitsetup"
|
||||
"github.com/mark3labs/kit/internal/message"
|
||||
"github.com/mark3labs/kit/internal/models"
|
||||
"github.com/mark3labs/kit/internal/session"
|
||||
"github.com/mark3labs/kit/internal/skills"
|
||||
"github.com/mark3labs/kit/internal/tools"
|
||||
@@ -48,6 +51,15 @@ type Kit struct {
|
||||
afterToolResult *hookRegistry[AfterToolResultHook, AfterToolResultResult]
|
||||
beforeTurn *hookRegistry[BeforeTurnHook, BeforeTurnResult]
|
||||
afterTurn *hookRegistry[AfterTurnHook, AfterTurnResult]
|
||||
contextPrepare *hookRegistry[ContextPrepareHook, ContextPrepareResult]
|
||||
beforeCompact *hookRegistry[BeforeCompactHook, BeforeCompactResult]
|
||||
|
||||
// lastInputTokens stores the API-reported input token count from the
|
||||
// most recent turn. Used by GetContextStats() to return accurate usage
|
||||
// instead of the text-based heuristic which misses system prompts,
|
||||
// tool definitions, etc.
|
||||
lastInputTokensMu sync.RWMutex
|
||||
lastInputTokens int
|
||||
}
|
||||
|
||||
// Subscribe registers an EventListener that will be called for every lifecycle
|
||||
@@ -136,6 +148,17 @@ func (m *Kit) GetExtensionContext() extensions.Context {
|
||||
return extensions.Context{}
|
||||
}
|
||||
|
||||
// UpdateExtensionContextModel updates the Model field on the extension
|
||||
// context so subsequent event handlers see the new model. This is a
|
||||
// targeted update that avoids replacing the entire Context struct.
|
||||
func (m *Kit) UpdateExtensionContextModel(model string) {
|
||||
if m.extRunner != nil {
|
||||
ctx := m.extRunner.GetContext()
|
||||
ctx.Model = model
|
||||
m.extRunner.SetContext(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// EmitSessionStart fires the SessionStart event for extensions.
|
||||
// No-op if extensions are disabled or no handlers are registered.
|
||||
func (m *Kit) EmitSessionStart() {
|
||||
@@ -263,6 +286,472 @@ func (m *Kit) GetExtensionEditor() *extensions.EditorConfig {
|
||||
return m.extRunner.GetEditor()
|
||||
}
|
||||
|
||||
// SetExtensionUIVisibility stores extension-provided UI visibility overrides.
|
||||
// No-op if extensions are disabled.
|
||||
func (m *Kit) SetExtensionUIVisibility(v extensions.UIVisibility) {
|
||||
if m.extRunner != nil {
|
||||
m.extRunner.SetUIVisibility(v)
|
||||
}
|
||||
}
|
||||
|
||||
// GetExtensionUIVisibility returns extension-provided UI visibility overrides,
|
||||
// or nil if none have been set. Returns nil if extensions are disabled.
|
||||
func (m *Kit) GetExtensionUIVisibility() *extensions.UIVisibility {
|
||||
if m.extRunner == nil {
|
||||
return nil
|
||||
}
|
||||
return m.extRunner.GetUIVisibility()
|
||||
}
|
||||
|
||||
// GetSessionMessages returns the conversation messages on the current branch
|
||||
// as extension-facing SessionMessage structs, ordered root to leaf.
|
||||
func (m *Kit) GetSessionMessages() []extensions.SessionMessage {
|
||||
if m.treeSession == nil {
|
||||
return nil
|
||||
}
|
||||
branch := m.treeSession.GetBranch("")
|
||||
var msgs []extensions.SessionMessage
|
||||
for _, entry := range branch {
|
||||
me, ok := entry.(*session.MessageEntry)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
msg, err := me.ToMessage()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// Flatten content parts into a single text string.
|
||||
var content strings.Builder
|
||||
for _, p := range msg.Parts {
|
||||
switch pt := p.(type) {
|
||||
case message.TextContent:
|
||||
content.WriteString(pt.Text)
|
||||
case message.ReasoningContent:
|
||||
content.WriteString(pt.Thinking)
|
||||
case message.ToolCall:
|
||||
fmt.Fprintf(&content, "[tool_call: %s(%s)]", pt.Name, pt.Input)
|
||||
case message.ToolResult:
|
||||
fmt.Fprintf(&content, "[tool_result: %s]", pt.Content)
|
||||
}
|
||||
}
|
||||
msgs = append(msgs, extensions.SessionMessage{
|
||||
ID: me.ID,
|
||||
ParentID: me.ParentID,
|
||||
Role: string(msg.Role),
|
||||
Content: content.String(),
|
||||
Model: msg.Model,
|
||||
Provider: msg.Provider,
|
||||
Timestamp: me.Timestamp.Format("2006-01-02T15:04:05Z07:00"),
|
||||
})
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
|
||||
// GetSessionFilePath returns the JSONL file path of the current session.
|
||||
func (m *Kit) GetSessionFilePath() string {
|
||||
if m.treeSession == nil {
|
||||
return ""
|
||||
}
|
||||
return m.treeSession.GetFilePath()
|
||||
}
|
||||
|
||||
// AppendExtensionEntry persists custom extension data in the session tree.
|
||||
func (m *Kit) AppendExtensionEntry(extType, data string) (string, error) {
|
||||
if m.treeSession == nil {
|
||||
return "", fmt.Errorf("no session available")
|
||||
}
|
||||
return m.treeSession.AppendExtensionData(extType, data)
|
||||
}
|
||||
|
||||
// GetExtensionEntries retrieves persisted extension data entries for a type.
|
||||
func (m *Kit) GetExtensionEntries(extType string) []extensions.ExtensionEntry {
|
||||
if m.treeSession == nil {
|
||||
return nil
|
||||
}
|
||||
entries := m.treeSession.GetExtensionData(extType)
|
||||
result := make([]extensions.ExtensionEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
result = append(result, extensions.ExtensionEntry{
|
||||
ID: e.ID,
|
||||
EntryType: e.ExtType,
|
||||
Data: e.Data,
|
||||
Timestamp: e.Timestamp.Format("2006-01-02T15:04:05Z07:00"),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// SetExtensionStatus places or updates a keyed status bar entry.
|
||||
func (m *Kit) SetExtensionStatus(entry extensions.StatusBarEntry) {
|
||||
if m.extRunner != nil {
|
||||
m.extRunner.SetStatusEntry(entry)
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveExtensionStatus removes a keyed status bar entry.
|
||||
func (m *Kit) RemoveExtensionStatus(key string) {
|
||||
if m.extRunner != nil {
|
||||
m.extRunner.RemoveStatusEntry(key)
|
||||
}
|
||||
}
|
||||
|
||||
// GetExtensionStatusEntries returns all extension status bar entries sorted by priority.
|
||||
func (m *Kit) GetExtensionStatusEntries() []extensions.StatusBarEntry {
|
||||
if m.extRunner == nil {
|
||||
return nil
|
||||
}
|
||||
return m.extRunner.GetStatusEntries()
|
||||
}
|
||||
|
||||
// GetExtensionShortcuts returns a map of key bindings to handler functions
|
||||
// from all loaded extensions. Returns nil if no shortcuts are registered or
|
||||
// extensions are disabled. Handlers are closures that capture the runner's
|
||||
// current context, so they can call Print/SetStatus/etc.
|
||||
func (m *Kit) GetExtensionShortcuts() map[string]func() {
|
||||
if m.extRunner == nil {
|
||||
return nil
|
||||
}
|
||||
entries := m.extRunner.GetShortcuts()
|
||||
if entries == nil {
|
||||
return nil
|
||||
}
|
||||
result := make(map[string]func(), len(entries))
|
||||
for key, entry := range entries {
|
||||
h := entry.Handler
|
||||
r := m.extRunner
|
||||
result[key] = func() {
|
||||
ctx := r.GetContext()
|
||||
h(ctx)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetExtensionToolInfos returns information about all tools available to the
|
||||
// agent, including enabled/disabled status from SetActiveTools. Each tool is
|
||||
// categorized by source: "core", "mcp", or "extension".
|
||||
func (m *Kit) GetExtensionToolInfos() []extensions.ToolInfo {
|
||||
agentTools := m.agent.GetTools()
|
||||
coreCount := m.agent.GetCoreToolCount()
|
||||
mcpCount := m.agent.GetMCPToolCount()
|
||||
|
||||
result := make([]extensions.ToolInfo, 0, len(agentTools))
|
||||
for i, t := range agentTools {
|
||||
info := t.Info()
|
||||
source := "core"
|
||||
if i >= coreCount && i < coreCount+mcpCount {
|
||||
source = "mcp"
|
||||
} else if i >= coreCount+mcpCount {
|
||||
source = "extension"
|
||||
}
|
||||
enabled := true
|
||||
if m.extRunner != nil && m.extRunner.IsToolDisabled(info.Name) {
|
||||
enabled = false
|
||||
}
|
||||
result = append(result, extensions.ToolInfo{
|
||||
Name: info.Name,
|
||||
Description: info.Description,
|
||||
Source: source,
|
||||
Enabled: enabled,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// SetExtensionActiveTools restricts the tool set to the named tools. All
|
||||
// other tools are blocked from execution. Pass nil to re-enable all tools.
|
||||
// No-op if extensions are disabled.
|
||||
func (m *Kit) SetExtensionActiveTools(names []string) {
|
||||
if m.extRunner != nil {
|
||||
m.extRunner.SetActiveTools(names)
|
||||
}
|
||||
}
|
||||
|
||||
// SetModel changes the active model at runtime. The existing tools, system
|
||||
// prompt, and session are preserved. The model string should be in
|
||||
// "provider/model" format (e.g. "anthropic/claude-sonnet-4-5-20250929").
|
||||
// Returns an error if the model string is invalid or the provider cannot
|
||||
// be created.
|
||||
func (m *Kit) SetModel(ctx context.Context, modelString string) error {
|
||||
// Validate the model string first.
|
||||
if _, _, err := ParseModelString(modelString); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build a provider config from current settings, overriding the model.
|
||||
config := &models.ProviderConfig{
|
||||
ModelString: modelString,
|
||||
ProviderAPIKey: viper.GetString("provider-api-key"),
|
||||
ProviderURL: viper.GetString("provider-url"),
|
||||
MaxTokens: viper.GetInt("max-tokens"),
|
||||
TLSSkipVerify: viper.GetBool("tls-skip-verify"),
|
||||
}
|
||||
temperature := float32(viper.GetFloat64("temperature"))
|
||||
config.Temperature = &temperature
|
||||
topP := float32(viper.GetFloat64("top-p"))
|
||||
config.TopP = &topP
|
||||
topK := int32(viper.GetInt("top-k"))
|
||||
config.TopK = &topK
|
||||
|
||||
if err := m.agent.SetModel(ctx, config); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.modelString = modelString
|
||||
|
||||
// Update extension context's Model field.
|
||||
if m.extRunner != nil {
|
||||
extCtx := m.extRunner.GetContext()
|
||||
extCtx.Model = modelString
|
||||
m.extRunner.SetContext(extCtx)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAvailableModels returns a list of known models from the registry. Each
|
||||
// entry includes provider, model ID, context limit, and whether the model
|
||||
// supports reasoning. This is an advisory list — models not in the registry
|
||||
// can still be used by specifying their provider/model string.
|
||||
func (m *Kit) GetAvailableModels() []extensions.ModelInfoEntry {
|
||||
registry := models.GetGlobalRegistry()
|
||||
var result []extensions.ModelInfoEntry
|
||||
for _, providerID := range registry.GetFantasyProviders() {
|
||||
modelsMap, err := registry.GetModelsForProvider(providerID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for modelID, info := range modelsMap {
|
||||
result = append(result, extensions.ModelInfoEntry{
|
||||
Provider: providerID,
|
||||
ModelID: modelID,
|
||||
Name: info.Name,
|
||||
ContextLimit: info.Limit.Context,
|
||||
OutputLimit: info.Limit.Output,
|
||||
Reasoning: info.Reasoning,
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetExtensionOption resolves a named extension option value.
|
||||
func (m *Kit) GetExtensionOption(name string) string {
|
||||
if m.extRunner == nil {
|
||||
return ""
|
||||
}
|
||||
return m.extRunner.GetOption(name)
|
||||
}
|
||||
|
||||
// SetExtensionOption stores a runtime override for a named extension option.
|
||||
func (m *Kit) SetExtensionOption(name, value string) {
|
||||
if m.extRunner != nil {
|
||||
m.extRunner.SetOption(name, value)
|
||||
}
|
||||
}
|
||||
|
||||
// EmitModelChange fires the ModelChange event for extensions.
|
||||
// No-op if extensions are disabled or no handlers are registered.
|
||||
func (m *Kit) EmitModelChange(newModel, previousModel, source string) {
|
||||
if m.extRunner != nil && m.extRunner.HasHandlers(extensions.ModelChange) {
|
||||
_, _ = m.extRunner.Emit(extensions.ModelChangeEvent{
|
||||
NewModel: newModel,
|
||||
PreviousModel: previousModel,
|
||||
Source: source,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// EmitExtensionCustomEvent dispatches a named event to all extension handlers.
|
||||
// No-op if extensions are disabled.
|
||||
func (m *Kit) EmitExtensionCustomEvent(name, data string) {
|
||||
if m.extRunner != nil {
|
||||
m.extRunner.EmitCustomEvent(name, data)
|
||||
}
|
||||
}
|
||||
|
||||
// GetExtensionMessageRenderer returns the named message renderer, or nil
|
||||
// if no extension registered a renderer with that name.
|
||||
func (m *Kit) GetExtensionMessageRenderer(name string) *extensions.MessageRendererConfig {
|
||||
if m.extRunner == nil {
|
||||
return nil
|
||||
}
|
||||
return m.extRunner.GetMessageRenderer(name)
|
||||
}
|
||||
|
||||
// ReloadExtensions hot-reloads all extensions from disk. Event handlers,
|
||||
// commands, renderers, and shortcuts update immediately. Extension-defined
|
||||
// tools are NOT updated (they are baked into the agent at creation time).
|
||||
func (m *Kit) ReloadExtensions() error {
|
||||
if m.extRunner == nil {
|
||||
return fmt.Errorf("no extensions loaded")
|
||||
}
|
||||
|
||||
// Emit shutdown to old extensions.
|
||||
if m.extRunner.HasHandlers(extensions.SessionShutdown) {
|
||||
_, _ = m.extRunner.Emit(extensions.SessionShutdownEvent{})
|
||||
}
|
||||
|
||||
// Re-load from disk.
|
||||
extraPaths := viper.GetStringSlice("extension")
|
||||
loaded, err := extensions.LoadExtensions(extraPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reloading extensions: %w", err)
|
||||
}
|
||||
|
||||
// Swap extensions on the runner (clears dynamic state).
|
||||
m.extRunner.Reload(loaded)
|
||||
|
||||
// Re-set context and emit SessionStart.
|
||||
ctx := m.extRunner.GetContext()
|
||||
m.extRunner.SetContext(ctx)
|
||||
if m.extRunner.HasHandlers(extensions.SessionStart) {
|
||||
_, _ = m.extRunner.Emit(extensions.SessionStartEvent{SessionID: ctx.SessionID})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExecuteCompletion makes a standalone LLM completion call for extensions.
|
||||
// When req.Model is empty the current agent model is reused (no provider
|
||||
// creation overhead). When req.Model is set a temporary provider is created,
|
||||
// used, and closed.
|
||||
func (m *Kit) ExecuteCompletion(ctx context.Context, req extensions.CompleteRequest) (extensions.CompleteResponse, error) {
|
||||
var (
|
||||
llmModel fantasy.LanguageModel
|
||||
closer func()
|
||||
usedModel string
|
||||
)
|
||||
|
||||
if req.Model == "" {
|
||||
// Reuse the active agent's model.
|
||||
llmModel = m.agent.GetModel()
|
||||
usedModel = m.modelString
|
||||
closer = func() {} // nothing to clean up
|
||||
} else {
|
||||
// Create a temporary provider for the requested model.
|
||||
config := &models.ProviderConfig{
|
||||
ModelString: req.Model,
|
||||
TLSSkipVerify: viper.GetBool("tls-skip-verify"),
|
||||
}
|
||||
if req.MaxTokens > 0 {
|
||||
config.MaxTokens = req.MaxTokens
|
||||
}
|
||||
providerResult, err := models.CreateProvider(ctx, config)
|
||||
if err != nil {
|
||||
return extensions.CompleteResponse{}, fmt.Errorf("create provider for %q: %w", req.Model, err)
|
||||
}
|
||||
llmModel = providerResult.Model
|
||||
usedModel = req.Model
|
||||
closer = func() {
|
||||
if providerResult.Closer != nil {
|
||||
_ = providerResult.Closer.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
defer closer()
|
||||
|
||||
// Build fantasy agent options (no tools — just a simple completion).
|
||||
var agentOpts []fantasy.AgentOption
|
||||
if req.System != "" {
|
||||
agentOpts = append(agentOpts, fantasy.WithSystemPrompt(req.System))
|
||||
}
|
||||
if req.MaxTokens > 0 {
|
||||
agentOpts = append(agentOpts, fantasy.WithMaxOutputTokens(int64(req.MaxTokens)))
|
||||
}
|
||||
|
||||
completionAgent := fantasy.NewAgent(llmModel, agentOpts...)
|
||||
|
||||
// Convert extension SessionMessage history to fantasy.Message slice.
|
||||
var messages []fantasy.Message
|
||||
for _, sm := range req.Messages {
|
||||
messages = append(messages, fantasy.Message{
|
||||
Role: fantasy.MessageRole(sm.Role),
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: sm.Content},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Streaming path.
|
||||
if req.OnChunk != nil {
|
||||
result, err := completionAgent.Stream(ctx, fantasy.AgentStreamCall{
|
||||
Prompt: req.Prompt,
|
||||
Messages: messages,
|
||||
OnTextDelta: func(_, text string) error {
|
||||
req.OnChunk(text)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return extensions.CompleteResponse{}, fmt.Errorf("streaming completion: %w", err)
|
||||
}
|
||||
return extensions.CompleteResponse{
|
||||
Text: result.Response.Content.Text(),
|
||||
InputTokens: int(result.Response.Usage.InputTokens),
|
||||
OutputTokens: int(result.Response.Usage.OutputTokens),
|
||||
Model: usedModel,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Non-streaming path.
|
||||
result, err := completionAgent.Generate(ctx, fantasy.AgentCall{
|
||||
Prompt: req.Prompt,
|
||||
Messages: messages,
|
||||
})
|
||||
if err != nil {
|
||||
return extensions.CompleteResponse{}, fmt.Errorf("completion: %w", err)
|
||||
}
|
||||
return extensions.CompleteResponse{
|
||||
Text: result.Response.Content.Text(),
|
||||
InputTokens: int(result.Response.Usage.InputTokens),
|
||||
OutputTokens: int(result.Response.Usage.OutputTokens),
|
||||
Model: usedModel,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// EmitBeforeFork emits a BeforeFork event to extensions and returns
|
||||
// whether the fork was cancelled and the reason. No-op if extensions are
|
||||
// disabled (returns false, "").
|
||||
func (m *Kit) EmitBeforeFork(targetID string, isUserMsg bool, userText string) (cancelled bool, reason string) {
|
||||
if m.extRunner == nil || !m.extRunner.HasHandlers(extensions.BeforeFork) {
|
||||
return false, ""
|
||||
}
|
||||
result, _ := m.extRunner.Emit(extensions.BeforeForkEvent{
|
||||
TargetID: targetID,
|
||||
IsUserMessage: isUserMsg,
|
||||
UserText: userText,
|
||||
})
|
||||
if r, ok := result.(extensions.BeforeForkResult); ok && r.Cancel {
|
||||
reason := r.Reason
|
||||
if reason == "" {
|
||||
reason = "Fork cancelled by extension."
|
||||
}
|
||||
return true, reason
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// EmitBeforeSessionSwitch emits a BeforeSessionSwitch event to extensions
|
||||
// and returns whether the switch was cancelled and the reason. No-op if
|
||||
// extensions are disabled (returns false, "").
|
||||
func (m *Kit) EmitBeforeSessionSwitch(switchReason string) (cancelled bool, reason string) {
|
||||
if m.extRunner == nil || !m.extRunner.HasHandlers(extensions.BeforeSessionSwitch) {
|
||||
return false, ""
|
||||
}
|
||||
result, _ := m.extRunner.Emit(extensions.BeforeSessionSwitchEvent{
|
||||
Reason: switchReason,
|
||||
})
|
||||
if r, ok := result.(extensions.BeforeSessionSwitchResult); ok && r.Cancel {
|
||||
reason := r.Reason
|
||||
if reason == "" {
|
||||
reason = "Session switch cancelled by extension."
|
||||
}
|
||||
return true, reason
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// HasExtensions returns true if the extension runner is configured and active.
|
||||
func (m *Kit) HasExtensions() bool {
|
||||
return m.extRunner != nil
|
||||
@@ -401,8 +890,7 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
|
||||
}
|
||||
|
||||
// Always compose the system prompt with runtime context: base prompt +
|
||||
// AGENTS.md context + skills metadata + date/cwd. This matches Pi's
|
||||
// buildSystemPrompt() convention.
|
||||
// AGENTS.md context + skills metadata + date/cwd.
|
||||
{
|
||||
basePrompt := viper.GetString("system-prompt")
|
||||
pb := skills.NewPromptBuilder(basePrompt)
|
||||
@@ -445,6 +933,8 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
|
||||
afterToolResult := newHookRegistry[AfterToolResultHook, AfterToolResultResult]()
|
||||
beforeTurn := newHookRegistry[BeforeTurnHook, BeforeTurnResult]()
|
||||
afterTurn := newHookRegistry[AfterTurnHook, AfterTurnResult]()
|
||||
contextPrepare := newHookRegistry[ContextPrepareHook, ContextPrepareResult]()
|
||||
beforeCompact := newHookRegistry[BeforeCompactHook, BeforeCompactResult]()
|
||||
|
||||
// Build agent setup options, pulling CLI-specific fields when available.
|
||||
setupOpts := kitsetup.AgentSetupOptions{
|
||||
@@ -488,6 +978,8 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
|
||||
afterToolResult: afterToolResult,
|
||||
beforeTurn: beforeTurn,
|
||||
afterTurn: afterTurn,
|
||||
contextPrepare: contextPrepare,
|
||||
beforeCompact: beforeCompact,
|
||||
}
|
||||
|
||||
// Bridge extension events to SDK hooks.
|
||||
@@ -535,7 +1027,7 @@ func loadContextFiles(cwd string) []*ContextFile {
|
||||
// so, re-reads the skill file, strips its YAML frontmatter, wraps the body in
|
||||
// a <skill> block with baseDir metadata, and appends any trailing user args.
|
||||
// Returns the original text unchanged when the prefix is absent or the skill is
|
||||
// not found. This matches Pi's _expandSkillCommand() convention.
|
||||
// not found.
|
||||
func (m *Kit) expandSkillCommand(prompt string) string {
|
||||
if !strings.HasPrefix(prompt, "/skill:") {
|
||||
return prompt
|
||||
@@ -763,11 +1255,19 @@ func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, pr
|
||||
|
||||
// Auto-compact if enabled and conversation is near the context limit.
|
||||
if m.autoCompact && m.ShouldCompact() {
|
||||
_, _ = m.Compact(ctx, m.compactionOpts, "") // best-effort
|
||||
_, _ = m.compactInternal(ctx, m.compactionOpts, "", true) // best-effort, automatic
|
||||
}
|
||||
|
||||
// Build context from the tree so only the current branch is sent.
|
||||
messages := m.treeSession.GetFantasyMessages()
|
||||
|
||||
// Run ContextPrepare hooks — extensions can filter, reorder, or inject messages.
|
||||
if m.contextPrepare.hasHooks() {
|
||||
if hookResult := m.contextPrepare.run(ContextPrepareHook{Messages: messages}); hookResult != nil && hookResult.Messages != nil {
|
||||
messages = hookResult.Messages
|
||||
}
|
||||
}
|
||||
|
||||
sentCount := len(messages)
|
||||
|
||||
m.events.emit(TurnStartEvent{Prompt: promptLabel})
|
||||
@@ -785,16 +1285,28 @@ func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, pr
|
||||
|
||||
responseText := result.FinalResponse.Content.Text()
|
||||
|
||||
m.events.emit(MessageEndEvent{Content: responseText})
|
||||
m.events.emit(TurnEndEvent{Response: responseText})
|
||||
|
||||
// Persist new messages (tool calls, tool results, assistant response).
|
||||
// Persist new messages (tool calls, tool results, assistant response)
|
||||
// BEFORE emitting events so that extension handlers calling
|
||||
// GetContextStats() see up-to-date token counts.
|
||||
if len(result.ConversationMessages) > sentCount {
|
||||
for _, msg := range result.ConversationMessages[sentCount:] {
|
||||
_, _ = m.treeSession.AppendFantasyMessage(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Store the API-reported token count so GetContextStats() matches the
|
||||
// built-in status bar (which uses input + output tokens). The
|
||||
// text-based heuristic misses system prompts, tool definitions, etc.
|
||||
if result.FinalResponse != nil {
|
||||
u := result.FinalResponse.Usage
|
||||
m.lastInputTokensMu.Lock()
|
||||
m.lastInputTokens = int(u.InputTokens) + int(u.OutputTokens)
|
||||
m.lastInputTokensMu.Unlock()
|
||||
}
|
||||
|
||||
m.events.emit(MessageEndEvent{Content: responseText})
|
||||
m.events.emit(TurnEndEvent{Response: responseText})
|
||||
|
||||
// Run AfterTurn hooks.
|
||||
if m.afterTurn.hasHooks() {
|
||||
m.afterTurn.run(AfterTurnHook{Response: responseText})
|
||||
|
||||
Reference in New Issue
Block a user