mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 09919b6307 | |||
| 7a2de4cc3c | |||
| acd7fd7f45 | |||
| 3446f38516 | |||
| db4bb19bac | |||
| d1cffb85ef | |||
| 329cd4ea4a | |||
| 4e779d576f | |||
| fc054f50e8 | |||
| d8f1b32885 | |||
| 1e2a3e2589 | |||
| c7f43917b1 | |||
| 6a8833a7b1 | |||
| 82cbf1d457 | |||
| ab09d5c9e4 | |||
| 2347e0e506 | |||
| 3e1c19442b | |||
| 3fc0ad906e | |||
| f373c34f54 | |||
| 1206837af4 | |||
| f79601feb1 | |||
| eb3219e7ca | |||
| 7e7632ad3c | |||
| 0ef46a75f2 | |||
| 7f9a9da40a | |||
| 7ff9e84894 | |||
| 017eb99d44 | |||
| 15a1550205 | |||
| 2d14b3461f | |||
| b99aafaeaa | |||
| a55f6d3d9a | |||
| 027c2de849 | |||
| d24540693c | |||
| f7c8e7757b | |||
| 0d5374b17b | |||
| 25f17a104d | |||
| 20125f939b | |||
| d3b67ffd14 | |||
| 915dc066dd | |||
| 3b14814740 | |||
| a1decf9cff | |||
| ec4ac64343 | |||
| a95117686e | |||
| c0880e1ef6 | |||
| 4e66c0b4f7 | |||
| 131ce8f2cc | |||
| 3d0f3358cb | |||
| 25da02fa65 | |||
| 4ae03aab7c | |||
| 93895392e6 | |||
| 473070e78b | |||
| 12268a777f | |||
| 351c10d814 | |||
| 9de3843605 | |||
| 1d5473e111 | |||
| b6adcf159e | |||
| b1da4a28e6 | |||
| 95abb6fa6e | |||
| a9970cf346 | |||
| 13060a20f9 | |||
| adf603e944 | |||
| af486133a5 | |||
| a97cd47ced | |||
| 68518a2bdb | |||
| fd61db3e12 | |||
| e49066a119 | |||
| efaff7f44f | |||
| d3c970b607 | |||
| 23254fee64 | |||
| fe072ad2e1 | |||
| 8840cbfabc | |||
| a11b41cda4 | |||
| 8b7be8b735 | |||
| caa6d1c178 | |||
| 001156053d | |||
| 54717e32bc |
@@ -1,64 +0,0 @@
|
||||
---
|
||||
name: btca-cli
|
||||
description: Operate the btca CLI for local resources and source-first answers. Use when setting up btca in a project, connecting a provider, adding or managing resources, and asking questions via btca commands. Invoke this skill when the user says "use btca" or needs to do more detailed research on a specific library or framework.
|
||||
---
|
||||
|
||||
# btca CLI
|
||||
|
||||
`btca` is a source-first research CLI. It hydrates resources (git, local, npm) into searchable context, then answers questions grounded in those sources. Use configured resources for ongoing work, or one-off anonymous resources directly in `btca ask`.
|
||||
|
||||
Full CLI reference: https://docs.btca.dev/guides/cli-reference
|
||||
|
||||
Add resources:
|
||||
|
||||
```bash
|
||||
# Git resource
|
||||
btca add -n svelte-dev https://github.com/sveltejs/svelte.dev
|
||||
|
||||
# Local directory
|
||||
btca add -n my-docs -t local /absolute/path/to/docs
|
||||
|
||||
# npm package
|
||||
btca add npm:@types/node@22.10.1 -n node-types -t npm
|
||||
```
|
||||
|
||||
Verify resources:
|
||||
|
||||
```bash
|
||||
btca resources
|
||||
```
|
||||
|
||||
Ask a question:
|
||||
|
||||
```bash
|
||||
btca ask -r svelte-dev -q "How do I define remote functions?"
|
||||
```
|
||||
|
||||
## Common Tasks
|
||||
|
||||
- Ask with multiple resources:
|
||||
|
||||
```bash
|
||||
btca ask -r react -r typescript -q "How do I type useState?"
|
||||
```
|
||||
|
||||
- Ask with anonymous one-off resources (not saved to config):
|
||||
|
||||
```bash
|
||||
# One-off git repo
|
||||
btca ask -r https://github.com/sveltejs/svelte -q "Where is the implementation of writable stores?"
|
||||
|
||||
# One-off npm package
|
||||
btca ask -r npm:react@19.0.0 -q "How is useTransition exported?"
|
||||
```
|
||||
|
||||
## Config Overview
|
||||
|
||||
- Config lives in `btca.config.jsonc` (project) and `~/.config/btca/btca.config.jsonc` (global).
|
||||
- Project config overrides global and controls provider/model and resources.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- "No resources configured": add resources with `btca add ...` and re-run `btca resources`.
|
||||
- "Provider not connected": run `btca connect` and follow the prompts.
|
||||
- "Unknown resource": use `btca resources` for configured names, or pass a valid HTTPS git URL / `npm:<package>` as an anonymous one-off in `btca ask`.
|
||||
@@ -1,3 +0,0 @@
|
||||
interface:
|
||||
display_name: "BTCA CLI"
|
||||
short_description: "Help with BTCA CLI setup and usage workflows"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
name: Build and Deploy Docs to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install Dependencies
|
||||
working-directory: ./www
|
||||
run: bun install
|
||||
|
||||
- name: Build
|
||||
working-directory: ./www
|
||||
run: bun run build
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
uses: JamesIves/github-pages-deploy-action@v4
|
||||
with:
|
||||
folder: www/out
|
||||
branch: gh-pages
|
||||
+4
-2
@@ -1,14 +1,16 @@
|
||||
.aider*
|
||||
.task/
|
||||
.env
|
||||
.kit/
|
||||
.kit/*
|
||||
!.kit/extensions/
|
||||
aidocs/
|
||||
*.log
|
||||
/kit
|
||||
.idea
|
||||
test/
|
||||
build/
|
||||
dist/
|
||||
contribute/output/
|
||||
CONTEXT.md
|
||||
output/
|
||||
.agents/
|
||||
skills-lock.json
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
const (
|
||||
diagnosticsTimeout = 20 * time.Second
|
||||
maxOutputBytes = 12_000
|
||||
)
|
||||
|
||||
type toolPathInput struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type lintResult struct {
|
||||
Output string
|
||||
Err error
|
||||
}
|
||||
|
||||
func Init(api ext.API) {
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
ctx.Print("go-edit-lint extension loaded - will run gopls and golangci-lint on Go file edits")
|
||||
})
|
||||
|
||||
api.OnToolResult(func(e ext.ToolResultEvent, ctx ext.Context) *ext.ToolResultResult {
|
||||
if e.IsError || !isEditOrWrite(e.ToolName) {
|
||||
return nil
|
||||
}
|
||||
|
||||
absPath, ok := resolveGoFilePath(e.Input, ctx.CWD)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
report := runGoDiagnostics(ctx.CWD, absPath)
|
||||
|
||||
// Check if there are issues and add explicit prompt for the LLM to react
|
||||
goplsIssues, lintIssues := countIssues(report)
|
||||
hasIssues := goplsIssues > 0 || lintIssues > 0
|
||||
|
||||
var enhanced string
|
||||
if hasIssues {
|
||||
enhanced = e.Content + "\n\n" + report + "\n\n⚠️ DIAGNOSTICS FOUND: Please review the issues above and fix them before proceeding."
|
||||
} else {
|
||||
enhanced = e.Content + "\n\n" + report
|
||||
}
|
||||
|
||||
// Show TUI message block for diagnostics visibility (only if there are issues)
|
||||
if hasIssues {
|
||||
var msgLines []string
|
||||
msgLines = append(msgLines, fmt.Sprintf("File: %s", filepath.Base(absPath)))
|
||||
if goplsIssues > 0 {
|
||||
msgLines = append(msgLines, fmt.Sprintf("gopls: %d issue(s)", goplsIssues))
|
||||
}
|
||||
if lintIssues > 0 {
|
||||
msgLines = append(msgLines, fmt.Sprintf("golangci-lint: %d issue(s)", lintIssues))
|
||||
}
|
||||
msgLines = append(msgLines, "", "⚠️ Please fix these issues before proceeding.")
|
||||
|
||||
borderColor := "#f9e2af" // yellow
|
||||
if goplsIssues > 0 && lintIssues > 0 {
|
||||
borderColor = "#f38ba8" // red
|
||||
}
|
||||
|
||||
ctx.PrintBlock(ext.PrintBlockOpts{
|
||||
Text: strings.Join(msgLines, "\n"),
|
||||
BorderColor: borderColor,
|
||||
Subtitle: "go-edit-lint",
|
||||
})
|
||||
}
|
||||
|
||||
return &ext.ToolResultResult{Content: &enhanced}
|
||||
})
|
||||
}
|
||||
|
||||
func isEditOrWrite(toolName string) bool {
|
||||
return strings.EqualFold(toolName, "edit") || strings.EqualFold(toolName, "write")
|
||||
}
|
||||
|
||||
func resolveGoFilePath(inputJSON, cwd string) (string, bool) {
|
||||
var args toolPathInput
|
||||
if err := json.Unmarshal([]byte(inputJSON), &args); err != nil || args.Path == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
absPath := args.Path
|
||||
if !filepath.IsAbs(absPath) {
|
||||
absPath = filepath.Join(cwd, absPath)
|
||||
}
|
||||
|
||||
if strings.ToLower(filepath.Ext(absPath)) != ".go" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return absPath, true
|
||||
}
|
||||
|
||||
func runGoDiagnostics(cwd, absPath string) string {
|
||||
gopls := runGopls(cwd, absPath)
|
||||
lint := runGolangCILint(cwd, "./...")
|
||||
|
||||
return fmt.Sprintf(
|
||||
"<go_diagnostics file=%q>\n[gopls]\n%s\n\n[golangci-lint]\n%s\n</go_diagnostics>",
|
||||
filepath.Base(absPath),
|
||||
formatToolResult(gopls, "No diagnostics."),
|
||||
formatToolResult(lint, "No lint issues."),
|
||||
)
|
||||
}
|
||||
|
||||
func runGopls(cwd, absPath string) lintResult {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), diagnosticsTimeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "gopls", "check", absPath)
|
||||
cmd.Dir = cwd
|
||||
out, err := cmd.CombinedOutput()
|
||||
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return lintResult{Err: fmt.Errorf("timed out after %s", diagnosticsTimeout)}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return lintResult{Output: truncate(string(out), maxOutputBytes), Err: fmt.Errorf("failed to run gopls check: %w", err)}
|
||||
}
|
||||
|
||||
return lintResult{Output: truncate(string(out), maxOutputBytes)}
|
||||
}
|
||||
|
||||
func runGolangCILint(cwd, target string) lintResult {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), diagnosticsTimeout)
|
||||
defer cancel()
|
||||
|
||||
args := []string{
|
||||
"run",
|
||||
target,
|
||||
"--show-stats=false",
|
||||
"--output.text.path", "stdout",
|
||||
"--output.text.colors=false",
|
||||
"--output.text.print-issued-lines=false",
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "golangci-lint", args...)
|
||||
cmd.Dir = cwd
|
||||
out, err := cmd.CombinedOutput()
|
||||
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return lintResult{Err: fmt.Errorf("timed out after %s", diagnosticsTimeout)}
|
||||
}
|
||||
|
||||
trimmed := truncate(string(out), maxOutputBytes)
|
||||
if err == nil {
|
||||
return lintResult{Output: trimmed}
|
||||
}
|
||||
|
||||
exitErr, ok := err.(*exec.ExitError)
|
||||
if ok && exitErr.ExitCode() == 1 {
|
||||
return lintResult{Output: trimmed}
|
||||
}
|
||||
|
||||
return lintResult{Output: trimmed, Err: fmt.Errorf("failed to run golangci-lint: %w", err)}
|
||||
}
|
||||
|
||||
func formatToolResult(res lintResult, emptyFallback string) string {
|
||||
var lines []string
|
||||
if res.Err != nil {
|
||||
lines = append(lines, "ERROR: "+res.Err.Error())
|
||||
}
|
||||
out := strings.TrimSpace(res.Output)
|
||||
if out == "" {
|
||||
if res.Err == nil {
|
||||
lines = append(lines, emptyFallback)
|
||||
}
|
||||
} else {
|
||||
lines = append(lines, out)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return emptyFallback
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max] + "\n... output truncated ..."
|
||||
}
|
||||
|
||||
func countIssues(report string) (goplsCount, lintCount int) {
|
||||
// Extract gopls section
|
||||
goplsStart := strings.Index(report, "[gopls]")
|
||||
lintStart := strings.Index(report, "[golangci-lint]")
|
||||
endTag := strings.Index(report, "</go_diagnostics>")
|
||||
|
||||
if goplsStart != -1 && lintStart != -1 {
|
||||
goplsSection := report[goplsStart:lintStart]
|
||||
// Count non-empty lines excluding the header and "No diagnostics." message
|
||||
for _, line := range strings.Split(goplsSection, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" && line != "[gopls]" && line != "No diagnostics." {
|
||||
goplsCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if lintStart != -1 && endTag != -1 {
|
||||
lintSection := report[lintStart:endTag]
|
||||
// Count non-empty lines excluding the header and "No lint issues." message
|
||||
for _, line := range strings.Split(lintSection, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" && line != "[golangci-lint]" && line != "No lint issues." {
|
||||
lintCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return goplsCount, lintCount
|
||||
}
|
||||
@@ -18,9 +18,12 @@ A powerful, extensible AI coding agent CLI with multi-provider support, built-in
|
||||
## 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
|
||||
- **Built-in Core Tools**: bash, read, write, edit, grep, find, ls, spawn_subagent - no MCP overhead
|
||||
- **MCP Integration**: Connect external MCP servers for expanded capabilities
|
||||
- **Extension System**: Write custom tools, commands, widgets, and UI modifications in Go
|
||||
- **Theming**: 22 built-in color themes (KITT, Catppuccin, Dracula, Nord, etc.) with runtime switching, persistence, and custom theme files
|
||||
- **Model Persistence**: Model and thinking level selections are automatically saved and restored across sessions
|
||||
- **Prompt Templates**: Create reusable prompt templates with shell-style argument substitution
|
||||
- **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
|
||||
@@ -29,10 +32,14 @@ A powerful, extensible AI coding agent CLI with multi-provider support, built-in
|
||||
|
||||
## Installation
|
||||
|
||||
### Using npm (recommended)
|
||||
### Using npm / bun / pnpm
|
||||
|
||||
```bash
|
||||
npm install -g @mark3labs/kit
|
||||
# or
|
||||
bun install -g @mark3labs/kit
|
||||
# or
|
||||
pnpm install -g @mark3labs/kit
|
||||
```
|
||||
|
||||
### Using Go
|
||||
@@ -66,8 +73,11 @@ kit @main.go @test.go "Review these files"
|
||||
# Continue the most recent session
|
||||
kit --continue
|
||||
|
||||
# Model and thinking level selections are automatically persisted
|
||||
# across sessions and restored on next launch
|
||||
|
||||
# Use specific model
|
||||
kit --model anthropic/claude-sonnet-4-5-20250929
|
||||
kit --model anthropic/claude-sonnet-latest
|
||||
```
|
||||
|
||||
### Non-Interactive Mode
|
||||
@@ -103,15 +113,15 @@ 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)
|
||||
3. `./.kit.yml` / `./.kit.yaml` / `./.kit.json` (project-local)
|
||||
4. `~/.kit.yml` / `~/.kit.yaml` / `~/.kit.json` (global)
|
||||
|
||||
### Basic Configuration
|
||||
|
||||
Create `~/.kit.yml`:
|
||||
|
||||
```yaml
|
||||
model: anthropic/claude-sonnet-4-5-20250929
|
||||
model: anthropic/claude-sonnet-latest
|
||||
max-tokens: 4096
|
||||
temperature: 0.7
|
||||
stream: true
|
||||
@@ -172,6 +182,8 @@ mcpServers:
|
||||
# Extensions
|
||||
--extension, -e Load additional extension file(s) (repeatable)
|
||||
--no-extensions Disable all extensions
|
||||
--prompt-template Load a specific prompt template by name
|
||||
--no-prompt-templates Disable prompt template loading
|
||||
|
||||
# Generation parameters
|
||||
--max-tokens Maximum tokens in response (default: 4096)
|
||||
@@ -179,6 +191,7 @@ mcpServers:
|
||||
--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)
|
||||
--thinking-level Extended thinking level: off, minimal, low, medium, high (default: off)
|
||||
|
||||
# System
|
||||
--config Config file path (default: ~/.kit.yml)
|
||||
@@ -190,28 +203,63 @@ mcpServers:
|
||||
|
||||
```bash
|
||||
# Authentication (for OAuth-enabled providers)
|
||||
kit auth login # Start OAuth flow
|
||||
kit auth logout # Remove credentials
|
||||
kit auth status # Check authentication status
|
||||
kit auth login [provider] # Start OAuth flow (e.g., anthropic)
|
||||
kit auth logout [provider] # Remove credentials for provider
|
||||
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
|
||||
kit models [provider] # List available models (optionally filter by provider)
|
||||
kit models --all # Show all providers (not just Fantasy-compatible)
|
||||
kit update-models [source] # Update model database (from models.dev, URL, file, or 'embedded')
|
||||
|
||||
# Extension management
|
||||
kit extensions list # List discovered extensions
|
||||
kit extensions validate # Validate extension files
|
||||
kit extensions init # Generate example extension template
|
||||
kit extensions list # List discovered extensions
|
||||
kit extensions validate # Validate extension files
|
||||
kit extensions init # Generate example extension template
|
||||
kit install <git-url> # Install extensions from git repositories
|
||||
kit install -l <git-url> # Install to project-local .kit/git/ directory
|
||||
kit install -u <git-url> # Update an already-installed package
|
||||
kit install --uninstall <pkg> # Remove an installed package
|
||||
|
||||
# Skills
|
||||
kit skill # Install the Kit extensions skill via skills.sh
|
||||
|
||||
# ACP server
|
||||
kit acp # Start as ACP agent (stdio JSON-RPC)
|
||||
kit acp --debug # With debug logging to stderr
|
||||
kit acp # Start as ACP agent (stdio JSON-RPC)
|
||||
kit acp --debug # With debug logging to stderr
|
||||
```
|
||||
|
||||
## Themes
|
||||
|
||||
Kit ships with 22 built-in color themes that control all UI elements. Switch at runtime:
|
||||
|
||||
```
|
||||
/theme dracula
|
||||
/theme catppuccin
|
||||
/theme tokyonight
|
||||
```
|
||||
|
||||
Theme selections are automatically saved and restored on next launch (stored in `~/.config/kit/preferences.yml`). This persistence also applies to **model** and **thinking level** selections — all are saved together and restored on startup.
|
||||
|
||||
### Custom themes
|
||||
|
||||
Drop a `.yml` file in `~/.config/kit/themes/` (user) or `.kit/themes/` (project):
|
||||
|
||||
```yaml
|
||||
# ~/.config/kit/themes/my-theme.yml
|
||||
primary:
|
||||
light: "#8839ef"
|
||||
dark: "#cba6f7"
|
||||
success:
|
||||
light: "#40a02b"
|
||||
dark: "#a6e3a1"
|
||||
```
|
||||
|
||||
Built-in themes: `kitt`, `catppuccin`, `dracula`, `tokyonight`, `nord`, `gruvbox`, `monokai`, `solarized`, `github`, `one-dark`, `rose-pine`, `ayu`, `material`, `everforest`, `kanagawa`, `amoled`, `synthwave`, `vesper`, `flexoki`, `matrix`, `vercel`, `zenburn`
|
||||
|
||||
## 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.
|
||||
Extensions are Go source files that run via Yaegi interpreter. They can add custom tools, slash commands, widgets, keyboard shortcuts, themes, and intercept lifecycle events.
|
||||
|
||||
### Minimal Extension
|
||||
|
||||
@@ -239,37 +287,71 @@ 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
|
||||
**Lifecycle Events**: OnSessionStart, OnSessionShutdown, OnBeforeAgentStart, OnAgentStart, OnAgentEnd, OnToolCall, OnToolExecutionStart, OnToolOutput, OnToolExecutionEnd, OnToolResult, OnInput, OnMessageStart, OnMessageUpdate, OnMessageEnd, OnModelChange, OnContextPrepare, OnBeforeFork, OnBeforeSessionSwitch, OnBeforeCompact, OnCustomEvent
|
||||
|
||||
**Custom Components**:
|
||||
- **Tools**: Add new tools the LLM can invoke
|
||||
- **Commands**: Register slash commands (e.g., `/mycommand`)
|
||||
- **Options**: Register configurable extension options
|
||||
- **Widgets**: Persistent status displays above/below input
|
||||
- **Headers/Footers**: Persistent content above/below the conversation
|
||||
- **Status Bar**: Custom status bar entries
|
||||
- **Shortcuts**: Global keyboard shortcuts
|
||||
- **Overlays**: Modal dialogs with markdown content
|
||||
- **Tool Renderers**: Customize how tool calls display
|
||||
- **Message Renderers**: Custom rendering for assistant messages
|
||||
- **Editor Interceptors**: Handle key events and wrap rendering
|
||||
- **Interactive Prompts**: Select, confirm, input, and multi-select dialogs
|
||||
- **Subagents**: Spawn in-process child Kit instances
|
||||
- **LLM Completion**: Direct model calls via `Complete()`
|
||||
- **Themes**: Register and switch color themes via `RegisterTheme`, `SetTheme`, `ListThemes`
|
||||
- **Custom Events**: Inter-extension communication via `EmitCustomEvent`
|
||||
|
||||
### 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
|
||||
- `bookmark.go` - Bookmark conversations
|
||||
- `branded-output.go` - Branded output rendering
|
||||
- `compact-notify.go` - Notification on compaction
|
||||
- `confirm-destructive.go` - Confirm destructive operations
|
||||
- `context-inject.go` - Inject context into conversations
|
||||
- `custom-editor-demo.go` - Vim-like modal editor
|
||||
- `dev-reload.go` - Development live-reload
|
||||
- `header-footer-demo.go` - Custom headers and footers
|
||||
- `inline-bash.go` - Inline bash execution
|
||||
- `interactive-shell.go` - Interactive shell integration
|
||||
- `kit-kit.go` - Kit-in-Kit (sub-agent spawning)
|
||||
- `lsp-diagnostics.go` - LSP diagnostic integration
|
||||
- `notify.go` - Desktop notifications
|
||||
- `overlay-demo.go` - Modal dialogs
|
||||
- `permission-gate.go` - Permission gating for tools
|
||||
- `pirate.go` - Pirate-themed personality
|
||||
- `plan-mode.go` - Read-only planning mode
|
||||
- `project-rules.go` - Project-specific rules
|
||||
- `prompt-demo.go` - Interactive prompts (select/confirm/input)
|
||||
- `protected-paths.go` - Path protection for sensitive files
|
||||
- `subagent-widget.go` - Multi-agent orchestration with status widget
|
||||
- `subagent-test.go` - Subagent testing utilities
|
||||
- `summarize.go` - Conversation summarization
|
||||
- `tool-logger.go` - Log all tool calls
|
||||
- `neon-theme.go` - Custom theme registration and switching
|
||||
- `tool-renderer-demo.go` - Custom tool call rendering
|
||||
- `widget-status.go` - Persistent status widgets
|
||||
|
||||
Also see `.kit/extensions/go-edit-lint.go` (in this repo) for a project-local extension example that runs gopls and golangci-lint on Go file edits.
|
||||
|
||||
### Loading Extensions
|
||||
|
||||
**Auto-discovery** (loads automatically):
|
||||
- `./.kit/extensions/*.go` (project-local)
|
||||
- `~/.config/kit/extensions/*.go` (global)
|
||||
- `~/.config/kit/extensions/*.go` (global single files)
|
||||
- `~/.config/kit/extensions/*/main.go` (global subdirectory extensions)
|
||||
- `.kit/extensions/*.go` (project-local single files)
|
||||
- `.kit/extensions/*/main.go` (project-local subdirectory extensions)
|
||||
- `~/.local/share/kit/git/` (global git-installed packages)
|
||||
- `.kit/git/` (project-local git-installed packages)
|
||||
|
||||
**Explicit loading**:
|
||||
```bash
|
||||
@@ -282,13 +364,76 @@ kit -e ext1.go -e ext2.go # Multiple extensions
|
||||
kit --no-extensions
|
||||
```
|
||||
|
||||
### Testing Extensions
|
||||
|
||||
Kit provides a testing package to help you write unit tests for your extensions:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"github.com/mark3labs/kit/pkg/extensions/test"
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
)
|
||||
|
||||
func TestMyExtension(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("my-ext.go")
|
||||
|
||||
// Emit events and verify behavior
|
||||
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify the extension printed something
|
||||
test.AssertPrinted(t, harness, "session started")
|
||||
}
|
||||
```
|
||||
|
||||
**Available assertions:**
|
||||
- `AssertBlocked()`, `AssertNotBlocked()` — Verify tool blocking
|
||||
- `AssertWidgetSet()`, `AssertWidgetText()` — Verify widget content
|
||||
- `AssertPrinted()`, `AssertPrintedContains()` — Verify output
|
||||
- `AssertToolRegistered()`, `AssertCommandRegistered()` — Verify registration
|
||||
|
||||
See `examples/extensions/tool-logger_test.go` for a complete example with 14 test cases covering tool calls, input handling, and session lifecycle.
|
||||
|
||||
### Prompt Templates
|
||||
|
||||
Create reusable prompt templates with shell-style argument substitution. Templates are loaded from `~/.kit/prompts/*.md` and `.kit/prompts/*.md`.
|
||||
|
||||
**Example template** (`~/.kit/prompts/review.md`):
|
||||
```markdown
|
||||
---
|
||||
description: Review code for issues
|
||||
---
|
||||
Review the following code for bugs and security issues.
|
||||
Focus on $1 specifically.
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```
|
||||
/review error handling
|
||||
```
|
||||
|
||||
**Argument placeholders:**
|
||||
- `$1`, `$2`, etc. — Individual arguments
|
||||
- `$@` or `$ARGUMENTS` — All arguments
|
||||
- `${@:2}` — Arguments from position 2 onwards
|
||||
- `${@:1:3}` — 3 arguments starting at position 1
|
||||
|
||||
Disable templates with `--no-prompt-templates` or load a specific template with `--prompt-template <name>`.
|
||||
|
||||
## 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`
|
||||
- Default: `~/.kit/sessions/<cwd-path>/<timestamp>_<id>.jsonl`
|
||||
- Path separators in the working directory are replaced with `--` (e.g., `/home/user/project` becomes `home--user--project`)
|
||||
- Each line is a session entry (messages, tool calls, extension data)
|
||||
- Supports branching from any message to explore alternate paths
|
||||
|
||||
@@ -311,6 +456,22 @@ kit -s path/to/session.jsonl
|
||||
kit --no-session
|
||||
```
|
||||
|
||||
### Interactive Session Commands
|
||||
|
||||
During an interactive session, use these slash commands:
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/name [name]` | Set or display the session's display name |
|
||||
| `/session` | Show session info (path, ID, message count) |
|
||||
| `/resume` | Open the session picker to switch sessions |
|
||||
| `/export [path]` | Export session as JSONL (auto-generates path if omitted) |
|
||||
| `/import <path>` | Import and switch to a session from a JSONL file |
|
||||
| `/share` | Upload session to GitHub Gist and get a shareable viewer URL |
|
||||
| `/tree` | Navigate the session tree |
|
||||
| `/fork` | Branch from an earlier message |
|
||||
| `/new` | Start a fresh session |
|
||||
|
||||
## Go SDK
|
||||
|
||||
Embed Kit in your Go applications:
|
||||
@@ -355,6 +516,19 @@ host, err := kit.New(ctx, &kit.Options{
|
||||
MaxSteps: 10,
|
||||
Streaming: true,
|
||||
Quiet: true,
|
||||
|
||||
// Session options
|
||||
SessionPath: "./session.jsonl", // Open specific session
|
||||
Continue: true, // Resume most recent session
|
||||
NoSession: true, // Ephemeral mode
|
||||
|
||||
// Tool options
|
||||
ExtraTools: []kit.Tool{...}, // Additional tools alongside defaults
|
||||
|
||||
// Compaction
|
||||
AutoCompact: true, // Auto-compact near context limit
|
||||
|
||||
Debug: true, // Debug logging
|
||||
})
|
||||
```
|
||||
|
||||
@@ -384,14 +558,29 @@ response, err := host.PromptWithCallbacks(
|
||||
### Session Management
|
||||
|
||||
```go
|
||||
// Multi-turn conversations retain context automatically
|
||||
host.Prompt(ctx, "My name is Alice")
|
||||
response, _ := host.Prompt(ctx, "What's my name?")
|
||||
|
||||
host.SaveSession("./session.json")
|
||||
host.LoadSession("./session.json")
|
||||
// Sessions are persisted automatically to JSONL files.
|
||||
// Access session info:
|
||||
path := host.GetSessionPath()
|
||||
id := host.GetSessionID()
|
||||
|
||||
// Clear conversation history
|
||||
host.ClearSession()
|
||||
```
|
||||
|
||||
Session persistence is configured via `Options`:
|
||||
|
||||
```go
|
||||
host, _ := kit.New(ctx, &kit.Options{
|
||||
SessionPath: "./my-session.jsonl", // Open specific session
|
||||
Continue: true, // Resume most recent session
|
||||
NoSession: true, // Ephemeral mode
|
||||
})
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Subagent Pattern
|
||||
@@ -413,12 +602,25 @@ Parse the JSON output:
|
||||
{
|
||||
"response": "Final assistant response text",
|
||||
"model": "anthropic/claude-haiku-3-5-20241022",
|
||||
"stop_reason": "end_turn",
|
||||
"session_id": "a1b2c3d4e5f6",
|
||||
"usage": {
|
||||
"input_tokens": 1024,
|
||||
"output_tokens": 512,
|
||||
"total_tokens": 1536
|
||||
"total_tokens": 1536,
|
||||
"cache_read_tokens": 0,
|
||||
"cache_creation_tokens": 0
|
||||
},
|
||||
"messages": [...]
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"parts": [
|
||||
{"type": "text", "data": "..."},
|
||||
{"type": "tool_call", "data": {"name": "...", "args": "..."}},
|
||||
{"type": "tool_result", "data": {"name": "...", "result": "..."}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -468,19 +670,27 @@ 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
|
||||
cmd/kit/ - CLI entry point (main.go)
|
||||
cmd/ - CLI command implementations (root, auth, models, etc.)
|
||||
pkg/kit/ - Go SDK for embedding Kit
|
||||
internal/app/ - Application orchestrator (agent loop, message store, queue)
|
||||
internal/agent/ - Agent execution and tool dispatch
|
||||
internal/auth/ - OAuth authentication and credential storage
|
||||
internal/acpserver/ - ACP (Agent Client Protocol) server
|
||||
internal/clipboard/ - Cross-platform clipboard operations
|
||||
internal/compaction/ - Conversation compaction and summarization
|
||||
internal/config/ - Configuration management
|
||||
internal/core/ - Built-in tools (bash, read, write, edit, grep, find, ls)
|
||||
internal/extensions/ - Yaegi extension system
|
||||
internal/core/ - Built-in tools
|
||||
internal/tools/ - MCP tool integration
|
||||
internal/config/ - Configuration management
|
||||
internal/acpserver/ - ACP (Agent Client Protocol) server
|
||||
internal/session/ - Session persistence
|
||||
internal/models/ - Provider and model management
|
||||
internal/kitsetup/ - Initial setup wizard
|
||||
internal/message/ - Message content types and structured content blocks
|
||||
internal/models/ - Provider and model management
|
||||
internal/session/ - Session persistence (tree-based JSONL)
|
||||
internal/skills/ - Skill loading and system prompt composition
|
||||
internal/tools/ - MCP tool integration
|
||||
internal/ui/ - Bubble Tea TUI components
|
||||
examples/extensions/ - Example extension files
|
||||
npm/ - NPM package wrapper for distribution
|
||||
```
|
||||
|
||||
## Supported Providers
|
||||
@@ -494,13 +704,29 @@ examples/extensions/ - Example extension files
|
||||
- **Google Vertex** - Claude on Vertex AI
|
||||
- **OpenRouter** - Multi-provider router
|
||||
- **Vercel AI** - Vercel AI SDK models
|
||||
- **Custom** - Any OpenAI-compatible endpoint via `--provider-url`
|
||||
- **Auto-routed** - Any provider from models.dev database
|
||||
|
||||
### Custom Provider
|
||||
|
||||
Use `custom/custom` when pointing Kit at any OpenAI-compatible endpoint with `--provider-url`:
|
||||
|
||||
```bash
|
||||
kit --provider-url "http://localhost:8080/v1" "Hello"
|
||||
```
|
||||
|
||||
This automatically defaults to `custom/custom` without needing to specify a model. The custom provider routes through fantasy's `openaicompat` provider and supports:
|
||||
|
||||
- Zero cost tracking (input/output = 0)
|
||||
- 262K context window, 65K output limit
|
||||
- Reasoning and temperature support
|
||||
- Optional `CUSTOM_API_KEY` environment variable or `--provider-api-key` flag
|
||||
|
||||
### Model String Format
|
||||
|
||||
```bash
|
||||
provider/model # Standard format
|
||||
anthropic/claude-sonnet-4-5-20250929
|
||||
anthropic/claude-sonnet-latest
|
||||
openai/gpt-4o
|
||||
ollama/llama3
|
||||
google/gemini-2.0-flash-exp
|
||||
@@ -509,18 +735,44 @@ 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
|
||||
# Anthropic Claude
|
||||
claude-opus-latest → claude-opus-4-6
|
||||
claude-sonnet-latest → claude-sonnet-4-6
|
||||
claude-haiku-latest → claude-haiku-4-5
|
||||
claude-4-opus-latest → claude-opus-4-6
|
||||
claude-4-sonnet-latest → claude-sonnet-4-6
|
||||
claude-4-haiku-latest → claude-haiku-4-5
|
||||
claude-3-7-sonnet-latest → claude-3-7-sonnet-20250219
|
||||
claude-3-5-sonnet-latest → claude-3-5-sonnet-20241022
|
||||
claude-3-5-haiku-latest → claude-3-5-haiku-20241022
|
||||
claude-3-opus-latest → claude-3-opus-20240229
|
||||
|
||||
# OpenAI GPT
|
||||
o1-latest → o1
|
||||
o3-latest → o3
|
||||
o4-latest → o4-mini
|
||||
gpt-5-latest → gpt-5.4
|
||||
gpt-5-chat-latest → gpt-5.4
|
||||
gpt-4-latest → gpt-4o
|
||||
gpt-4 → gpt-4o
|
||||
gpt-3.5-latest → gpt-3.5-turbo
|
||||
gpt-3.5 → gpt-3.5-turbo
|
||||
codex-latest → codex-mini-latest
|
||||
|
||||
# Google Gemini
|
||||
gemini-pro-latest → gemini-2.5-pro
|
||||
gemini-flash-latest → gemini-2.5-flash
|
||||
gemini-flash → gemini-2.5-flash
|
||||
gemini-pro → gemini-2.5-pro
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
Contributions are welcome! Please see the [contribution guide](contribute/contribute.md) for guidelines.
|
||||
|
||||
## License
|
||||
|
||||
[Apache 2.0](LICENSE)
|
||||
[MIT](LICENSE)
|
||||
|
||||
## Community
|
||||
|
||||
|
||||
+32
-60
@@ -13,7 +13,6 @@ var (
|
||||
installLocalFlag bool
|
||||
installUpdateFlag bool
|
||||
installUninstallFlag bool
|
||||
installSelectFlag bool
|
||||
installAllFlag bool
|
||||
)
|
||||
|
||||
@@ -26,6 +25,9 @@ The install command downloads and installs Kit extensions from git repositories.
|
||||
Extensions are stored in the global extensions directory by default, or in the
|
||||
project's .kit/git/ directory when using the --local flag.
|
||||
|
||||
When a repo contains multiple extensions, an interactive multi-select is shown
|
||||
so you can choose which to install. Use --all to skip selection and install everything.
|
||||
|
||||
Supported URL formats:
|
||||
- github.com/user/repo (shorthand, defaults to HTTPS)
|
||||
- git:github.com/user/repo
|
||||
@@ -38,17 +40,11 @@ You can pin to a specific version, tag, or commit using @:
|
||||
- github.com/user/repo@main
|
||||
- github.com/user/repo@abc1234
|
||||
|
||||
Selection modes for repos with multiple extensions:
|
||||
- Default: install all extensions
|
||||
- --select: interactively choose which extensions to install
|
||||
- --all: explicitly install all extensions (same as default)
|
||||
|
||||
Examples:
|
||||
kit install github.com/user/my-extension
|
||||
kit install github.com/user/my-extension@v1.0.0
|
||||
kit install git:github.com/user/my-extension --local
|
||||
kit install https://github.com/user/my-extension --select
|
||||
kit install github.com/user/collection --select --local`,
|
||||
kit install github.com/user/my-extension --local
|
||||
kit install github.com/user/collection --all`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runInstall,
|
||||
}
|
||||
@@ -57,8 +53,7 @@ func init() {
|
||||
installCmd.Flags().BoolVarP(&installLocalFlag, "local", "l", false, "Install to project-local .kit/git/ directory")
|
||||
installCmd.Flags().BoolVarP(&installUpdateFlag, "update", "u", false, "Update an already-installed package")
|
||||
installCmd.Flags().BoolVar(&installUninstallFlag, "uninstall", false, "Remove an installed package")
|
||||
installCmd.Flags().BoolVarP(&installSelectFlag, "select", "i", false, "Interactively select which extensions to install")
|
||||
installCmd.Flags().BoolVar(&installAllFlag, "all", false, "Install all extensions (default behavior)")
|
||||
installCmd.Flags().BoolVar(&installAllFlag, "all", false, "Install all extensions without prompting")
|
||||
|
||||
rootCmd.AddCommand(installCmd)
|
||||
}
|
||||
@@ -106,34 +101,7 @@ func runInstallPackage(installer *extensions.Installer, source *extensions.GitSo
|
||||
return fmt.Errorf("extension already installed (scope: %s). Use --update to update or --uninstall to remove", existingScope)
|
||||
}
|
||||
|
||||
// If --select flag is used, show interactive selection
|
||||
if installSelectFlag {
|
||||
return runInstallWithSelection(installer, source, scope)
|
||||
}
|
||||
|
||||
// Install all extensions
|
||||
if err := installer.Install(source, scope); err != nil {
|
||||
return fmt.Errorf("install failed: %w", err)
|
||||
}
|
||||
|
||||
// Show success message
|
||||
scopeStr := "globally"
|
||||
if scope == extensions.ScopeProject {
|
||||
scopeStr = "locally in .kit/git/"
|
||||
}
|
||||
|
||||
if source.Pinned {
|
||||
fmt.Printf("Installed %s at %s %s\n", source.String(), source.Ref, scopeStr)
|
||||
} else {
|
||||
fmt.Printf("Installed %s %s\n", source.String(), scopeStr)
|
||||
}
|
||||
|
||||
log.Info("extension installed", "source", source.String(), "scope", scope)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runInstallWithSelection(installer *extensions.Installer, source *extensions.GitSource, scope extensions.InstallScope) error {
|
||||
// Preview extensions in the repo
|
||||
// Preview extensions to decide if we need multi-select
|
||||
previews, tempDir, err := installer.PreviewExtensions(source)
|
||||
if err != nil {
|
||||
return fmt.Errorf("previewing extensions: %w", err)
|
||||
@@ -144,43 +112,47 @@ func runInstallWithSelection(installer *extensions.Installer, source *extensions
|
||||
return fmt.Errorf("no extensions found in %s", source.String())
|
||||
}
|
||||
|
||||
// If only one extension, just install it
|
||||
if len(previews) == 1 {
|
||||
fmt.Printf("Found 1 extension in %s:\n - %s (%s)\n\n", source.String(), previews[0].Name, previews[0].Path)
|
||||
return runInstallPackage(installer, source, scope)
|
||||
scopeStr := "globally"
|
||||
if scope == extensions.ScopeProject {
|
||||
scopeStr = "locally in .kit/git/"
|
||||
}
|
||||
|
||||
// Use multi-select UI for selection
|
||||
// Single extension or --all flag: install everything directly
|
||||
if len(previews) == 1 || installAllFlag {
|
||||
if err := installer.Install(source, scope); err != nil {
|
||||
return fmt.Errorf("install failed: %w", err)
|
||||
}
|
||||
|
||||
if source.Pinned {
|
||||
fmt.Printf("Installed %s at %s %s\n", source.String(), source.Ref, scopeStr)
|
||||
} else {
|
||||
fmt.Printf("Installed %d extension(s) from %s %s\n", len(previews), source.String(), scopeStr)
|
||||
}
|
||||
|
||||
log.Info("extension installed", "source", source.String(), "scope", scope)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Multiple extensions: show interactive selection
|
||||
includePaths, err := multiSelectForInstall(previews)
|
||||
if err != nil {
|
||||
if err.Error() == "selection cancelled" {
|
||||
if err.Error() == "selection cancelled" || err.Error() == "no extensions selected" {
|
||||
fmt.Println("Install cancelled.")
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("selection failed: %w", err)
|
||||
}
|
||||
|
||||
// Install with includes (if empty, installs all)
|
||||
if err := installer.InstallWithInclude(source, scope, includePaths); err != nil {
|
||||
return fmt.Errorf("install failed: %w", err)
|
||||
}
|
||||
|
||||
// Show success message
|
||||
scopeStr := "globally"
|
||||
if scope == extensions.ScopeProject {
|
||||
scopeStr = "locally in .kit/git/"
|
||||
fmt.Printf("Installed %d extension(s) from %s %s\n", len(includePaths), source.String(), scopeStr)
|
||||
for _, path := range includePaths {
|
||||
fmt.Printf(" - %s\n", path)
|
||||
}
|
||||
|
||||
if len(includePaths) > 0 {
|
||||
fmt.Printf("Installed %d extension(s) from %s %s\n", len(includePaths), source.String(), scopeStr)
|
||||
for _, path := range includePaths {
|
||||
fmt.Printf(" - %s\n", path)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("Installed %s %s\n", source.String(), scopeStr)
|
||||
}
|
||||
|
||||
log.Info("extension installed with selection", "source", source.String(), "scope", scope, "selected", len(includePaths))
|
||||
log.Info("extension installed", "source", source.String(), "scope", scope, "selected", len(includePaths))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/mark3labs/kit/internal/models"
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -47,6 +48,9 @@ func runModels(_ *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
func printAllProviders(showAll bool) error {
|
||||
// Reload the registry to pick up any custom models from config
|
||||
models.ReloadGlobalRegistry()
|
||||
|
||||
var providerIDs []string
|
||||
if showAll {
|
||||
providerIDs = kit.GetSupportedProviders()
|
||||
@@ -98,6 +102,9 @@ func printAllProviders(showAll bool) error {
|
||||
}
|
||||
|
||||
func printProvider(provider string) error {
|
||||
// Reload the registry to pick up any custom models from config
|
||||
models.ReloadGlobalRegistry()
|
||||
|
||||
m, err := kit.GetModelsForProvider(provider)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unknown provider %q. Run 'kit models' to see all providers", provider)
|
||||
|
||||
+218
-26
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image/color"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -12,9 +13,11 @@ import (
|
||||
"charm.land/fantasy"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/mark3labs/kit/internal/app"
|
||||
"github.com/mark3labs/kit/internal/auth"
|
||||
"github.com/mark3labs/kit/internal/config"
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
"github.com/mark3labs/kit/internal/models"
|
||||
"github.com/mark3labs/kit/internal/prompts"
|
||||
"github.com/mark3labs/kit/internal/ui"
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -64,6 +67,15 @@ var (
|
||||
|
||||
// TLS configuration
|
||||
tlsSkipVerify bool
|
||||
|
||||
// Prompt templates
|
||||
promptTemplatePaths []string
|
||||
noPromptTemplates bool
|
||||
|
||||
// Preference restoration flags — set in RunE after cobra parses, used
|
||||
// in runNormalMode to decide whether to apply saved preferences.
|
||||
modelFlagChanged bool
|
||||
thinkingFlagChanged bool
|
||||
)
|
||||
|
||||
// kitUIAdapter adapts *kit.Kit to ui.AgentInterface so the CLI setup layer
|
||||
@@ -112,6 +124,17 @@ var rootCmd = &cobra.Command{
|
||||
if len(args) > 0 {
|
||||
processPositionalArgs(args)
|
||||
}
|
||||
// Record whether --model / --thinking-level were explicitly set by the
|
||||
// user so that runNormalMode can fall back to saved preferences when
|
||||
// they weren't. Must be captured here (after cobra parses) and before
|
||||
// runKit because rootCmd can't be referenced inside runNormalMode
|
||||
// without creating an initialization cycle.
|
||||
if f := cmd.PersistentFlags().Lookup("model"); f != nil {
|
||||
modelFlagChanged = f.Changed
|
||||
}
|
||||
if f := cmd.PersistentFlags().Lookup("thinking-level"); f != nil {
|
||||
thinkingFlagChanged = f.Changed
|
||||
}
|
||||
return runKit(context.Background())
|
||||
},
|
||||
}
|
||||
@@ -141,24 +164,58 @@ func LoadConfigWithEnvSubstitution(configPath string) error {
|
||||
return kit.LoadConfigWithEnvSubstitution(configPath)
|
||||
}
|
||||
|
||||
func configToUiTheme(theme config.Theme) ui.Theme {
|
||||
// adaptiveOrDefault converts a config.AdaptiveColor to a resolved color.Color,
|
||||
// falling back to fallback when both Light and Dark are empty.
|
||||
func adaptiveOrDefault(ac config.AdaptiveColor, fallback color.Color) color.Color {
|
||||
if ac.Light == "" && ac.Dark == "" {
|
||||
return fallback
|
||||
}
|
||||
return ui.AdaptiveColor(ac.Light, ac.Dark)
|
||||
}
|
||||
|
||||
func configToUiTheme(cfg config.Theme) ui.Theme {
|
||||
def := ui.DefaultTheme()
|
||||
return ui.Theme{
|
||||
Primary: ui.AdaptiveColor(theme.Primary.Light, theme.Primary.Dark),
|
||||
Secondary: ui.AdaptiveColor(theme.Secondary.Light, theme.Secondary.Dark),
|
||||
Success: ui.AdaptiveColor(theme.Success.Light, theme.Success.Dark),
|
||||
Warning: ui.AdaptiveColor(theme.Warning.Light, theme.Warning.Dark),
|
||||
Error: ui.AdaptiveColor(theme.Error.Light, theme.Error.Dark),
|
||||
Info: ui.AdaptiveColor(theme.Info.Light, theme.Info.Dark),
|
||||
Text: ui.AdaptiveColor(theme.Text.Light, theme.Text.Dark),
|
||||
Muted: ui.AdaptiveColor(theme.Muted.Light, theme.Muted.Dark),
|
||||
VeryMuted: ui.AdaptiveColor(theme.VeryMuted.Light, theme.VeryMuted.Dark),
|
||||
Background: ui.AdaptiveColor(theme.Background.Light, theme.Background.Dark),
|
||||
Border: ui.AdaptiveColor(theme.Border.Light, theme.Border.Dark),
|
||||
MutedBorder: ui.AdaptiveColor(theme.MutedBorder.Light, theme.MutedBorder.Dark),
|
||||
System: ui.AdaptiveColor(theme.System.Light, theme.System.Dark),
|
||||
Tool: ui.AdaptiveColor(theme.Tool.Light, theme.Tool.Dark),
|
||||
Accent: ui.AdaptiveColor(theme.Accent.Light, theme.Accent.Dark),
|
||||
Highlight: ui.AdaptiveColor(theme.Highlight.Light, theme.Highlight.Dark),
|
||||
Primary: adaptiveOrDefault(cfg.Primary, def.Primary),
|
||||
Secondary: adaptiveOrDefault(cfg.Secondary, def.Secondary),
|
||||
Success: adaptiveOrDefault(cfg.Success, def.Success),
|
||||
Warning: adaptiveOrDefault(cfg.Warning, def.Warning),
|
||||
Error: adaptiveOrDefault(cfg.Error, def.Error),
|
||||
Info: adaptiveOrDefault(cfg.Info, def.Info),
|
||||
Text: adaptiveOrDefault(cfg.Text, def.Text),
|
||||
Muted: adaptiveOrDefault(cfg.Muted, def.Muted),
|
||||
VeryMuted: adaptiveOrDefault(cfg.VeryMuted, def.VeryMuted),
|
||||
Background: adaptiveOrDefault(cfg.Background, def.Background),
|
||||
Border: adaptiveOrDefault(cfg.Border, def.Border),
|
||||
MutedBorder: adaptiveOrDefault(cfg.MutedBorder, def.MutedBorder),
|
||||
System: adaptiveOrDefault(cfg.System, def.System),
|
||||
Tool: adaptiveOrDefault(cfg.Tool, def.Tool),
|
||||
Accent: adaptiveOrDefault(cfg.Accent, def.Accent),
|
||||
Highlight: adaptiveOrDefault(cfg.Highlight, def.Highlight),
|
||||
|
||||
DiffInsertBg: adaptiveOrDefault(cfg.DiffInsertBg, def.DiffInsertBg),
|
||||
DiffDeleteBg: adaptiveOrDefault(cfg.DiffDeleteBg, def.DiffDeleteBg),
|
||||
DiffEqualBg: adaptiveOrDefault(cfg.DiffEqualBg, def.DiffEqualBg),
|
||||
DiffMissingBg: adaptiveOrDefault(cfg.DiffMissingBg, def.DiffMissingBg),
|
||||
|
||||
CodeBg: adaptiveOrDefault(cfg.CodeBg, def.CodeBg),
|
||||
GutterBg: adaptiveOrDefault(cfg.GutterBg, def.GutterBg),
|
||||
WriteBg: adaptiveOrDefault(cfg.WriteBg, def.WriteBg),
|
||||
|
||||
Markdown: ui.MarkdownThemeColors{
|
||||
Text: adaptiveOrDefault(cfg.Markdown.Text, def.Markdown.Text),
|
||||
Muted: adaptiveOrDefault(cfg.Markdown.Muted, def.Markdown.Muted),
|
||||
Heading: adaptiveOrDefault(cfg.Markdown.Heading, def.Markdown.Heading),
|
||||
Emph: adaptiveOrDefault(cfg.Markdown.Emph, def.Markdown.Emph),
|
||||
Strong: adaptiveOrDefault(cfg.Markdown.Strong, def.Markdown.Strong),
|
||||
Link: adaptiveOrDefault(cfg.Markdown.Link, def.Markdown.Link),
|
||||
Code: adaptiveOrDefault(cfg.Markdown.Code, def.Markdown.Code),
|
||||
Error: adaptiveOrDefault(cfg.Markdown.Error, def.Markdown.Error),
|
||||
Keyword: adaptiveOrDefault(cfg.Markdown.Keyword, def.Markdown.Keyword),
|
||||
String: adaptiveOrDefault(cfg.Markdown.String, def.Markdown.String),
|
||||
Number: adaptiveOrDefault(cfg.Markdown.Number, def.Markdown.Number),
|
||||
Comment: adaptiveOrDefault(cfg.Markdown.Comment, def.Markdown.Comment),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +254,9 @@ func init() {
|
||||
if err == nil && viper.InConfig("theme") {
|
||||
uiTheme := configToUiTheme(theme)
|
||||
ui.SetTheme(uiTheme)
|
||||
} else if pref := ui.LoadThemePreference(); pref != "" {
|
||||
// No explicit theme in config — fall back to persisted preference.
|
||||
_ = ui.ApplyThemeWithoutSave(pref)
|
||||
}
|
||||
|
||||
rootCmd.PersistentFlags().
|
||||
@@ -242,6 +302,10 @@ func init() {
|
||||
flags.StringVar(&providerAPIKey, "provider-api-key", "", "API key for the provider (applies to OpenAI, Anthropic, and Google)")
|
||||
flags.BoolVar(&tlsSkipVerify, "tls-skip-verify", false, "skip TLS certificate verification (WARNING: insecure, use only for self-signed certificates)")
|
||||
|
||||
// Prompt template flags
|
||||
flags.StringArrayVar(&promptTemplatePaths, "prompt-template", nil, "load prompt template file or directory (repeatable)")
|
||||
flags.BoolVar(&noPromptTemplates, "no-prompt-templates", false, "disable prompt template discovery")
|
||||
|
||||
// Model generation parameters
|
||||
flags.IntVar(&maxTokens, "max-tokens", 4096, "maximum number of tokens in the response")
|
||||
flags.Float32Var(&temperature, "temperature", 0.7, "controls randomness in responses (0.0-1.0)")
|
||||
@@ -277,6 +341,8 @@ func init() {
|
||||
_ = viper.BindPFlag("tls-skip-verify", rootCmd.PersistentFlags().Lookup("tls-skip-verify"))
|
||||
_ = viper.BindPFlag("no-extensions", rootCmd.PersistentFlags().Lookup("no-extensions"))
|
||||
_ = viper.BindPFlag("extension", rootCmd.PersistentFlags().Lookup("extension"))
|
||||
_ = viper.BindPFlag("prompt-template", rootCmd.PersistentFlags().Lookup("prompt-template"))
|
||||
_ = viper.BindPFlag("no-prompt-templates", rootCmd.PersistentFlags().Lookup("no-prompt-templates"))
|
||||
|
||||
// Defaults are already set in flag definitions, no need to duplicate in viper
|
||||
|
||||
@@ -608,6 +674,32 @@ func runNormalMode(ctx context.Context) error {
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
}
|
||||
|
||||
// Restore persisted model preference when no explicit --model flag or
|
||||
// config file model is set. Precedence: CLI flag > config file > saved
|
||||
// preference > built-in default. This mirrors how themes are persisted.
|
||||
if !modelFlagChanged && !viper.InConfig("model") {
|
||||
if pref := ui.LoadModelPreference(); pref != "" {
|
||||
viper.Set("model", pref)
|
||||
}
|
||||
}
|
||||
|
||||
// Restore persisted thinking level preference (same precedence chain).
|
||||
if !thinkingFlagChanged && !viper.InConfig("thinking-level") {
|
||||
if pref := ui.LoadThinkingLevelPreference(); pref != "" {
|
||||
viper.Set("thinking-level", pref)
|
||||
}
|
||||
}
|
||||
|
||||
// When --provider-url is set but no explicit --model was provided,
|
||||
// default to "custom/custom" so the user doesn't need to remember a
|
||||
// provider/model pair for custom OpenAI-compatible endpoints.
|
||||
// This intentionally overrides saved preferences but respects config-file
|
||||
// models — if you specify a model in ~/.kit.yml, it will be used with
|
||||
// custom/custom's provider routing.
|
||||
if viper.GetString("provider-url") != "" && !modelFlagChanged && !viper.InConfig("model") {
|
||||
viper.Set("model", "custom/custom")
|
||||
}
|
||||
|
||||
// Load MCP configuration.
|
||||
mcpConfig, err := config.LoadAndValidateConfig()
|
||||
if err != nil {
|
||||
@@ -643,11 +735,16 @@ func runNormalMode(ctx context.Context) error {
|
||||
},
|
||||
}
|
||||
if resumeFlag {
|
||||
// TODO: TUI session picker.
|
||||
sessions, _ := kit.ListSessions("")
|
||||
if len(sessions) > 0 {
|
||||
kitOpts.SessionPath = sessions[0].Path
|
||||
// When --resume is combined with interactive mode, the TUI session
|
||||
// picker will be shown at startup. For non-interactive mode, fall
|
||||
// back to auto-selecting the most recent session.
|
||||
if positionalPrompt != "" {
|
||||
sessions, _ := kit.ListSessions("")
|
||||
if len(sessions) > 0 {
|
||||
kitOpts.SessionPath = sessions[0].Path
|
||||
}
|
||||
}
|
||||
// Interactive mode: ShowSessionPicker is set below on AppModelOptions.
|
||||
}
|
||||
|
||||
kitInstance, err := kit.New(ctx, kitOpts)
|
||||
@@ -859,6 +956,24 @@ func runNormalMode(ctx context.Context) error {
|
||||
kitInstance.UpdateExtensionContextModel(modelString)
|
||||
// Fire OnModelChange event to extensions.
|
||||
kitInstance.EmitModelChange(modelString, previousModel, "extension")
|
||||
// Update usage tracker with new model info for correct token counting.
|
||||
if usageTracker != nil {
|
||||
newProvider, newModel, _ := models.ParseModelString(modelString)
|
||||
if newProvider != "unknown" && newModel != "unknown" && newProvider != "ollama" {
|
||||
registry := models.GetGlobalRegistry()
|
||||
if modelInfo := registry.LookupModel(newProvider, newModel); modelInfo != nil {
|
||||
// Check OAuth status for Anthropic models
|
||||
isOAuth := false
|
||||
if newProvider == "anthropic" {
|
||||
_, source, err := auth.GetAnthropicAPIKey(viper.GetString("provider-api-key"))
|
||||
if err == nil && strings.HasPrefix(source, "stored OAuth") {
|
||||
isOAuth = true
|
||||
}
|
||||
}
|
||||
usageTracker.UpdateModelInfo(modelInfo, newProvider, isOAuth)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
GetAvailableModels: func() []extensions.ModelInfoEntry {
|
||||
@@ -901,6 +1016,28 @@ func runNormalMode(ctx context.Context) error {
|
||||
SetActiveTools: func(names []string) {
|
||||
kitInstance.SetExtensionActiveTools(names)
|
||||
},
|
||||
RegisterTheme: func(name string, config extensions.ThemeColorConfig) {
|
||||
tc := func(c extensions.ThemeColor) [2]string { return [2]string{c.Light, c.Dark} }
|
||||
ui.RegisterThemeFromConfig(name,
|
||||
tc(config.Primary), tc(config.Secondary),
|
||||
tc(config.Success), tc(config.Warning),
|
||||
tc(config.Error), tc(config.Info),
|
||||
tc(config.Text), tc(config.Muted),
|
||||
tc(config.VeryMuted), tc(config.Background),
|
||||
tc(config.Border), tc(config.MutedBorder),
|
||||
tc(config.System), tc(config.Tool),
|
||||
tc(config.Accent), tc(config.Highlight),
|
||||
tc(config.MdHeading), tc(config.MdLink),
|
||||
tc(config.MdKeyword), tc(config.MdString),
|
||||
tc(config.MdNumber), tc(config.MdComment),
|
||||
)
|
||||
},
|
||||
SetTheme: func(name string) error {
|
||||
return ui.ApplyTheme(name)
|
||||
},
|
||||
ListThemes: func() []string {
|
||||
return ui.ListThemes()
|
||||
},
|
||||
ShowOverlay: func(config extensions.OverlayConfig) extensions.OverlayResult {
|
||||
ch := make(chan app.OverlayResponse, 1)
|
||||
appInstance.SendOverlayRequest(app.OverlayRequestEvent{
|
||||
@@ -967,6 +1104,27 @@ func runNormalMode(ctx context.Context) error {
|
||||
// Convert extension commands to UI-layer type for the interactive TUI.
|
||||
extCommands := extensionCommandsForUI(kitInstance)
|
||||
|
||||
// Load prompt templates from standard locations and explicit paths.
|
||||
var promptTemplates []*prompts.PromptTemplate
|
||||
if !noPromptTemplates {
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
cwd, _ := os.Getwd()
|
||||
tpls, diags, err := prompts.LoadAll(prompts.LoadOptions{
|
||||
Cwd: cwd,
|
||||
HomeDir: homeDir,
|
||||
ExtraPaths: promptTemplatePaths,
|
||||
ConfigPaths: viper.GetStringSlice("prompts"),
|
||||
IncludeDefaults: true,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Warning: failed to load some prompt templates: %v", err)
|
||||
}
|
||||
promptTemplates = tpls
|
||||
for _, d := range diags {
|
||||
log.Printf("Prompt template collision: /%s kept from %s, dropped from %s", d.Name, d.KeptPath, d.DroppedPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Build context/skills display metadata for the startup banner.
|
||||
var contextPaths []string
|
||||
for _, cf := range kitInstance.GetContextFiles() {
|
||||
@@ -1013,6 +1171,24 @@ func runNormalMode(ctx context.Context) error {
|
||||
// this callback runs synchronously inside BubbleTea's Update(), and
|
||||
// NotifyModelChanged calls prog.Send() which deadlocks. The UI layer
|
||||
// updates m.providerName and m.modelName directly after setModel returns.
|
||||
// Update usage tracker with new model info for correct token counting.
|
||||
if usageTracker != nil {
|
||||
newProvider, newModel, _ := models.ParseModelString(modelString)
|
||||
if newProvider != "unknown" && newModel != "unknown" && newProvider != "ollama" {
|
||||
registry := models.GetGlobalRegistry()
|
||||
if modelInfo := registry.LookupModel(newProvider, newModel); modelInfo != nil {
|
||||
// Check OAuth status for Anthropic models
|
||||
isOAuth := false
|
||||
if newProvider == "anthropic" {
|
||||
_, source, err := auth.GetAnthropicAPIKey(viper.GetString("provider-api-key"))
|
||||
if err == nil && strings.HasPrefix(source, "stored OAuth") {
|
||||
isOAuth = true
|
||||
}
|
||||
}
|
||||
usageTracker.UpdateModelInfo(modelInfo, newProvider, isOAuth)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
emitModelChangeForUI := func(newModel, previousModel, source string) {
|
||||
@@ -1024,9 +1200,21 @@ func runNormalMode(ctx context.Context) error {
|
||||
return kitInstance.SetThinkingLevel(context.Background(), level)
|
||||
}
|
||||
|
||||
// Build session-switching callback. Opens a JSONL session file and
|
||||
// replaces the active tree session on both the Kit SDK and App layer.
|
||||
switchSessionForUI := func(path string) error {
|
||||
ts, err := kit.OpenTreeSession(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open session: %w", err)
|
||||
}
|
||||
kitInstance.SetTreeSession(ts)
|
||||
appInstance.SwitchTreeSession(ts)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if running in non-interactive mode
|
||||
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, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI)
|
||||
return runNonInteractiveModeApp(ctx, appInstance, cli, positionalPrompt, quietFlag, jsonFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI, switchSessionForUI)
|
||||
}
|
||||
|
||||
// Quiet mode is not allowed in interactive mode
|
||||
@@ -1034,7 +1222,7 @@ func runNormalMode(ctx context.Context) error {
|
||||
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, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI)
|
||||
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI, switchSessionForUI)
|
||||
}
|
||||
|
||||
// runNonInteractiveModeApp executes a single prompt via the app layer and exits,
|
||||
@@ -1047,7 +1235,7 @@ func runNormalMode(ctx context.Context) error {
|
||||
//
|
||||
// When --no-exit is set, after the prompt completes the interactive BubbleTea
|
||||
// TUI is started so the user can continue the conversation.
|
||||
func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui.CLI, prompt string, quiet, 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, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error) error {
|
||||
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, promptTemplates []*prompts.PromptTemplate, 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, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error, switchSession func(string) error) error {
|
||||
// Expand @file references in the prompt before sending to the agent.
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
prompt = ui.ProcessFileAttachments(prompt, cwd)
|
||||
@@ -1090,7 +1278,7 @@ func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui
|
||||
|
||||
// If --no-exit was requested, hand off to the interactive TUI.
|
||||
if noExit {
|
||||
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModel, emitModelChange, isReasoningModel, thinkingLevel, setThinkingLevel)
|
||||
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModel, emitModelChange, isReasoningModel, thinkingLevel, setThinkingLevel, switchSession)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -1188,7 +1376,7 @@ func writeJSONError(err error) {
|
||||
// 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, 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, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error) error {
|
||||
func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, promptTemplates []*prompts.PromptTemplate, 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, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error, switchSession func(string) error) error {
|
||||
// Determine terminal size; fall back gracefully.
|
||||
termWidth, termHeight, err := term.GetSize(int(os.Stdout.Fd()))
|
||||
if err != nil || termWidth == 0 {
|
||||
@@ -1197,6 +1385,7 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN
|
||||
}
|
||||
|
||||
cwd, _ := os.Getwd()
|
||||
|
||||
appModel := ui.NewAppModel(appInstance, ui.AppModelOptions{
|
||||
CompactMode: viper.GetBool("compact"),
|
||||
ModelName: modelName,
|
||||
@@ -1211,6 +1400,7 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN
|
||||
ExtensionToolCount: extensionToolCount,
|
||||
UsageTracker: usageTracker,
|
||||
ExtensionCommands: extCommands,
|
||||
PromptTemplates: promptTemplates,
|
||||
ContextPaths: contextPaths,
|
||||
SkillItems: skillItems,
|
||||
GetWidgets: getWidgets,
|
||||
@@ -1229,6 +1419,8 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN
|
||||
ThinkingLevel: thinkingLevel,
|
||||
IsReasoningModel: isReasoningModel,
|
||||
SetThinkingLevel: setThinkingLevel,
|
||||
SwitchSession: switchSession,
|
||||
ShowSessionPicker: resumeFlag,
|
||||
})
|
||||
|
||||
// Print startup info to stdout before Bubble Tea takes over the screen.
|
||||
|
||||
+12
-12
@@ -8,19 +8,21 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// skillCmd installs the kit-extensions skill via the skills.sh CLI (npx skills).
|
||||
// This teaches AI agents how to create Kit extensions with full knowledge of
|
||||
// the extension API, lifecycle events, widgets, tools, commands, and Yaegi constraints.
|
||||
// skillCmd installs Kit skills via the skills.sh CLI (npx skills).
|
||||
var skillCmd = &cobra.Command{
|
||||
Use: "skill",
|
||||
Short: "Install the Kit extensions skill via skills.sh",
|
||||
Long: `Install the kit-extensions skill that teaches AI agents how to create
|
||||
Kit extensions. Uses the skills.sh CLI (npx skills) to install the skill
|
||||
from the Kit repository.
|
||||
Short: "Install Kit skills via skills.sh",
|
||||
Long: `Install Kit skills that teach AI agents how to build with Kit.
|
||||
Uses the skills.sh CLI (npx skills) to install all skills from the Kit repository.
|
||||
|
||||
The skill provides comprehensive documentation of Kit's extension API including
|
||||
lifecycle events, custom tools, slash commands, widgets, editor interceptors,
|
||||
tool renderers, and critical Yaegi interpreter constraints.
|
||||
Two skills are provided:
|
||||
|
||||
1. Extensions — creating Kit extensions with full knowledge of the extension
|
||||
API, lifecycle events, widgets, tools, commands, editor interceptors,
|
||||
tool renderers, and Yaegi interpreter constraints.
|
||||
|
||||
2. SDK — building AI-powered applications with the Kit Go SDK, including
|
||||
providers, agents, tools, and MCP integration.
|
||||
|
||||
Example:
|
||||
kit skill`,
|
||||
@@ -41,8 +43,6 @@ func runSkill(_ *cobra.Command, _ []string) error {
|
||||
"skills",
|
||||
"add",
|
||||
"mark3labs/kit",
|
||||
"--skill",
|
||||
"kit-extensions",
|
||||
}
|
||||
|
||||
cmd := exec.Command(npx, args...)
|
||||
|
||||
@@ -77,6 +77,18 @@ kit install github.com/mark3labs/kit/examples/extensions --local
|
||||
| `subagent-widget.go` | Widget with subagent updates | Goroutines + widgets |
|
||||
| `dev-reload.go` | Hot reload extensions | `ReloadExtensions` |
|
||||
|
||||
### Integrations
|
||||
|
||||
| Extension | Description | Key API |
|
||||
|-----------|-------------|---------|
|
||||
| `kit-telegram/` | Telegram relay for remote monitoring & control | `RegisterCommand`, `OnAgentStart/End`, `SetStatus`, `SendMessage` |
|
||||
|
||||
### Themes
|
||||
|
||||
| Extension | Description | Key API |
|
||||
|-----------|-------------|---------|
|
||||
| `neon-theme.go` | Register and switch custom themes | `RegisterTheme`, `SetTheme` |
|
||||
|
||||
### Rendering
|
||||
|
||||
| Extension | Description | Key API |
|
||||
@@ -122,6 +134,17 @@ Complex real-world example:
|
||||
- File watching
|
||||
- Diagnostics aggregation
|
||||
|
||||
### kit-telegram/
|
||||
Full-featured Telegram integration:
|
||||
- Slash command with subcommands and tab completion
|
||||
- Interactive guided setup flow with prompts
|
||||
- Background long-polling goroutine
|
||||
- Progress message rendering edited in place
|
||||
- Message queue with edit-before-dispatch
|
||||
- Remote command handling from Telegram
|
||||
- Status bar and widget updates
|
||||
- Config persistence with atomic writes
|
||||
|
||||
## Multi-File Extension Example
|
||||
|
||||
The `kit-kit-agents/` directory demonstrates the multi-file pattern:
|
||||
|
||||
@@ -23,8 +23,7 @@ import (
|
||||
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
|
||||
err := exec.Command("git", "diff", "--cached", "--quiet").Run()
|
||||
if err == nil {
|
||||
return // exit code 0 means no staged changes
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// Extension Test Template
|
||||
//
|
||||
// This is a template for writing tests for your Kit extension.
|
||||
// Copy this file to your extension directory, rename it to something like
|
||||
// "my-ext_test.go", and customize it for your extension.
|
||||
//
|
||||
// Run tests with: go test -v
|
||||
//
|
||||
// IMPORTANT: This file should be in the same directory as your extension
|
||||
// and use package main, NOT package test.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
"github.com/mark3labs/kit/pkg/extensions/test"
|
||||
)
|
||||
|
||||
// Test that your extension loads without errors
|
||||
func TestExtension_Loads(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
ext := harness.LoadFile("my-ext.go") // Change to your extension filename
|
||||
|
||||
// Verify the extension was loaded
|
||||
if ext == nil {
|
||||
t.Fatal("extension should not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
// Test your event handlers are registered
|
||||
func TestExtension_EventHandlers(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("my-ext.go")
|
||||
|
||||
// Uncomment the handlers your extension uses:
|
||||
// test.AssertHasHandlers(t, harness, extensions.ToolCall)
|
||||
// test.AssertHasHandlers(t, harness, extensions.Input)
|
||||
// test.AssertHasHandlers(t, harness, extensions.SessionStart)
|
||||
// test.AssertHasHandlers(t, harness, extensions.AgentEnd)
|
||||
}
|
||||
|
||||
// Test tool registration
|
||||
func TestExtension_Tools(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("my-ext.go")
|
||||
|
||||
// Test that your tools are registered
|
||||
// test.AssertToolRegistered(t, harness, "my_tool")
|
||||
|
||||
// Or test all registered tools
|
||||
tools := harness.RegisteredTools()
|
||||
t.Logf("Registered %d tools", len(tools))
|
||||
for _, tool := range tools {
|
||||
t.Logf(" - %s: %s", tool.Name, tool.Description)
|
||||
}
|
||||
}
|
||||
|
||||
// Test command registration
|
||||
func TestExtension_Commands(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("my-ext.go")
|
||||
|
||||
// Test that your commands are registered
|
||||
// test.AssertCommandRegistered(t, harness, "mycommand")
|
||||
|
||||
// Or test all registered commands
|
||||
cmds := harness.RegisteredCommands()
|
||||
t.Logf("Registered %d commands", len(cmds))
|
||||
for _, cmd := range cmds {
|
||||
t.Logf(" - %s: %s", cmd.Name, cmd.Description)
|
||||
}
|
||||
}
|
||||
|
||||
// Test session start behavior
|
||||
func TestExtension_SessionStart(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("my-ext.go")
|
||||
|
||||
// Emit session start event
|
||||
_, err := harness.Emit(extensions.SessionStartEvent{
|
||||
SessionID: "test-session",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify expected behavior:
|
||||
// - Did it print something?
|
||||
// test.AssertPrinted(t, harness, "expected output")
|
||||
|
||||
// - Did it set a widget?
|
||||
// test.AssertWidgetSet(t, harness, "my-widget")
|
||||
// test.AssertWidgetText(t, harness, "my-widget", "expected text")
|
||||
|
||||
// - Did it set the header/footer?
|
||||
// test.AssertHeaderSet(t, harness)
|
||||
// test.AssertFooterSet(t, harness)
|
||||
|
||||
// - Did it set a status?
|
||||
// test.AssertStatusSet(t, harness, "myext:status")
|
||||
}
|
||||
|
||||
// Test tool call handling
|
||||
func TestExtension_ToolCall(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("my-ext.go")
|
||||
|
||||
// Test a specific tool call
|
||||
result, err := harness.Emit(extensions.ToolCallEvent{
|
||||
ToolName: "some_tool",
|
||||
Input: `{"key": "value"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// If your extension blocks certain tools:
|
||||
// test.AssertNotBlocked(t, result)
|
||||
// OR
|
||||
// test.AssertBlocked(t, result, "expected reason")
|
||||
|
||||
// Suppress unused variable warning (remove this when using result)
|
||||
_ = result
|
||||
|
||||
// Check for print output
|
||||
// test.AssertPrinted(t, harness, "expected message")
|
||||
}
|
||||
|
||||
// Test input handling
|
||||
func TestExtension_InputHandling(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("my-ext.go")
|
||||
|
||||
// Test input that should be handled
|
||||
result, err := harness.Emit(extensions.InputEvent{
|
||||
Text: "test input",
|
||||
Source: "cli",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// If your extension handles/transforms input:
|
||||
// test.AssertInputHandled(t, result, "handled")
|
||||
// OR
|
||||
// test.AssertInputTransformed(t, result, "transformed text")
|
||||
|
||||
// Suppress unused variable warning (remove this when using result)
|
||||
_ = result
|
||||
}
|
||||
|
||||
// Test with configured prompt results
|
||||
func TestExtension_WithPrompts(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("my-ext.go")
|
||||
|
||||
// Configure what prompts should return
|
||||
harness.Context().SetPromptSelectResult(extensions.PromptSelectResult{
|
||||
Value: "option1",
|
||||
Index: 0,
|
||||
Cancelled: false,
|
||||
})
|
||||
|
||||
// Now when your extension calls ctx.PromptSelect(), it gets the configured result
|
||||
_, _ = harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
|
||||
|
||||
// Verify behavior based on the selected options
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
# kit-telegram
|
||||
|
||||
A Kit extension that relays all Kit agent runs to Telegram and lets approved Telegram users reply back into Kit.
|
||||
|
||||
## What it does
|
||||
|
||||
- Relays **all Kit runs** to one Telegram chat while connected
|
||||
- Edits one Telegram progress message in place during a run
|
||||
- Lets approved Telegram users send normal text replies back into Kit
|
||||
- Shows `Telegram Connected` or `Telegram Disconnected` in the status bar
|
||||
- Shows a small spinner animation as `⠋ Telegram Connecting` only while the relay is still connecting
|
||||
- On startup with an already validated enabled config, sends a short Telegram connection message to confirm the relay is up
|
||||
|
||||
## Requirements
|
||||
|
||||
- `kit` installed and working
|
||||
- A Telegram bot token from `@BotFather`
|
||||
- Either:
|
||||
- A Telegram chat where you can message the bot, or
|
||||
- A numeric Telegram chat id you want to enter manually
|
||||
- For group chats, one or more allowed Telegram user ids
|
||||
|
||||
## Quickstart
|
||||
|
||||
### 1. Install the extension
|
||||
|
||||
```bash
|
||||
kit install github.com/mark3labs/kit/examples/extensions/kit-telegram
|
||||
```
|
||||
|
||||
Or run directly:
|
||||
```bash
|
||||
kit -e path/to/kit-telegram/main.go
|
||||
```
|
||||
|
||||
### 2. Start Kit and connect Telegram
|
||||
|
||||
```bash
|
||||
kit
|
||||
```
|
||||
|
||||
Inside Kit, run:
|
||||
|
||||
```
|
||||
/telegram connect
|
||||
```
|
||||
|
||||
You will be prompted for:
|
||||
|
||||
- Bot token from `@BotFather`
|
||||
- Whether to auto-detect the chat by messaging the bot or enter the chat id manually
|
||||
- Allowed user ids when needed
|
||||
|
||||
### 3. Verify the relay
|
||||
|
||||
```
|
||||
/telegram test
|
||||
```
|
||||
|
||||
Reply in Telegram with the code from the test message.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/telegram` | Human-friendly overview and subcommand list |
|
||||
| `/telegram status` | Raw deterministic relay state |
|
||||
| `/telegram test` | Verify outbound and inbound relay |
|
||||
| `/telegram toggle` | Enable or disable relay without deleting credentials |
|
||||
| `/telegram logout` | Remove saved credentials and disconnect relay |
|
||||
| `/telegram connect` | Run the setup flow again |
|
||||
| `/telegram clear` | Clear Telegram status and working messages from the TUI |
|
||||
|
||||
## Remote commands (from Telegram)
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/telegram` | Sends the overview back to Telegram |
|
||||
| `/telegram status` | Sends the deterministic state report to Telegram |
|
||||
| `/telegram test` | Sends a reply-code test message from Telegram |
|
||||
| `/telegram toggle` | Flips the enabled flag |
|
||||
| `/telegram logout yes` | Logs out (requires `yes` confirmation) |
|
||||
| `/telegram clear` | Clears the TUI footer and working messages |
|
||||
|
||||
## Key APIs Used
|
||||
|
||||
- `RegisterCommand` — Slash command with subcommands and tab completion
|
||||
- `OnSessionStart` / `OnSessionShutdown` — Lifecycle management
|
||||
- `OnAgentStart` / `OnAgentEnd` — Run tracking and progress rendering
|
||||
- `OnToolCall` / `OnToolResult` — Action tracking
|
||||
- `OnMessageEnd` — Capture assistant responses
|
||||
- `OnInput` — Mirror local messages to Telegram
|
||||
- `SetStatus` / `RemoveStatus` — Status bar indicators
|
||||
- `SetWidget` / `RemoveWidget` — Working message display
|
||||
- `PromptInput` / `PromptSelect` / `PromptConfirm` — Interactive setup flow
|
||||
- `SendMessage` — Inject Telegram replies as Kit prompts
|
||||
|
||||
## Architecture
|
||||
|
||||
Single Go file interpreted by Yaegi at runtime. Core components:
|
||||
|
||||
- **Telegram Bot API client** — HTTP calls via `net/http` for getMe, getChat, getChatMember, getUpdates (long-polling), sendMessage, editMessageText
|
||||
- **Config persistence** — JSON file at `.kit/kit-telegram.json` with atomic writes
|
||||
- **Long-polling goroutine** — Background polling for Telegram updates with warmup poll, retry, and client-side timeouts
|
||||
- **Message queue** — In-memory FIFO queue for Telegram prompt input with edit-before-dispatch support
|
||||
- **Progress rendering** — `⏳ elapsed · step N` with action lines, edited in place
|
||||
- **Final rendering** — `✅/❌ elapsed` with response text, split into chunks for long output
|
||||
|
||||
## Debug mode
|
||||
|
||||
Set environment variable `KIT_TELEGRAM_DEBUG=1` to enable verbose debug logging.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import "kit/ext"
|
||||
|
||||
// Init registers a "neon" theme and a /neon slash command to apply it.
|
||||
// Demonstrates how extensions can create and set themes programmatically.
|
||||
//
|
||||
// Usage: kit -e examples/extensions/neon-theme.go
|
||||
func Init(api ext.API) {
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
// Register a cyberpunk neon theme at startup.
|
||||
ctx.RegisterTheme("neon", ext.ThemeColorConfig{
|
||||
Primary: ext.ThemeColor{Light: "#CC00FF", Dark: "#FF00FF"},
|
||||
Secondary: ext.ThemeColor{Light: "#0088CC", Dark: "#00FFFF"},
|
||||
Success: ext.ThemeColor{Light: "#00CC44", Dark: "#00FF66"},
|
||||
Warning: ext.ThemeColor{Light: "#CCAA00", Dark: "#FFFF00"},
|
||||
Error: ext.ThemeColor{Light: "#CC0033", Dark: "#FF0055"},
|
||||
Info: ext.ThemeColor{Light: "#0088CC", Dark: "#00CCFF"},
|
||||
Text: ext.ThemeColor{Light: "#111111", Dark: "#F0F0F0"},
|
||||
Background: ext.ThemeColor{Light: "#F0F0F0", Dark: "#0A0A14"},
|
||||
MdKeyword: ext.ThemeColor{Light: "#CC00FF", Dark: "#FF00FF"},
|
||||
MdString: ext.ThemeColor{Light: "#00CC44", Dark: "#00FF66"},
|
||||
MdComment: ext.ThemeColor{Light: "#888888", Dark: "#555555"},
|
||||
})
|
||||
|
||||
ctx.PrintInfo("Neon theme registered! Use /theme neon to activate.")
|
||||
})
|
||||
|
||||
// Also register a /neon slash command as a shortcut.
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "neon",
|
||||
Description: "Switch to the neon cyberpunk theme",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
if err := ctx.SetTheme("neon"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "Neon theme activated!", nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
"github.com/mark3labs/kit/pkg/extensions/test"
|
||||
)
|
||||
|
||||
// Test that the tool-logger extension loads and registers handlers
|
||||
func TestToolLogger_Loads(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
ext := harness.LoadFile("tool-logger.go")
|
||||
|
||||
if ext == nil {
|
||||
t.Fatal("extension should not be nil")
|
||||
}
|
||||
|
||||
// Verify all expected handlers are registered
|
||||
test.AssertHasHandlers(t, harness, extensions.ToolCall)
|
||||
test.AssertHasHandlers(t, harness, extensions.ToolResult)
|
||||
test.AssertHasHandlers(t, harness, extensions.SessionStart)
|
||||
test.AssertHasHandlers(t, harness, extensions.SessionShutdown)
|
||||
test.AssertHasHandlers(t, harness, extensions.Input)
|
||||
}
|
||||
|
||||
// Test that tool calls are logged (handlers run without errors)
|
||||
func TestToolLogger_ToolCall(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("tool-logger.go")
|
||||
|
||||
// Emit a tool call event
|
||||
result, err := harness.Emit(extensions.ToolCallEvent{
|
||||
ToolName: "Read",
|
||||
ToolCallID: "call-123",
|
||||
Input: `{"file": "test.txt"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Tool logger should not block any tools
|
||||
test.AssertNotBlocked(t, result)
|
||||
}
|
||||
|
||||
// Test that tool results are processed
|
||||
func TestToolLogger_ToolResult(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("tool-logger.go")
|
||||
|
||||
content := "Hello, World!"
|
||||
result, err := harness.Emit(extensions.ToolResultEvent{
|
||||
ToolName: "Read",
|
||||
Content: content,
|
||||
IsError: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Tool logger should not modify results
|
||||
if result != nil {
|
||||
t.Error("expected nil result (no modification)")
|
||||
}
|
||||
}
|
||||
|
||||
// Test that error tool results are handled
|
||||
func TestToolLogger_ToolResultError(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("tool-logger.go")
|
||||
|
||||
result, err := harness.Emit(extensions.ToolResultEvent{
|
||||
ToolName: "Bash",
|
||||
Content: "command not found",
|
||||
IsError: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result != nil {
|
||||
t.Error("expected nil result (no modification)")
|
||||
}
|
||||
}
|
||||
|
||||
// Test session start handler
|
||||
func TestToolLogger_SessionStart(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("tool-logger.go")
|
||||
|
||||
_, err := harness.Emit(extensions.SessionStartEvent{
|
||||
SessionID: "test-session-123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Handler should run without errors (logs to file)
|
||||
// Since file logging happens outside our mock, we just verify no errors
|
||||
}
|
||||
|
||||
// Test session shutdown handler
|
||||
func TestToolLogger_SessionShutdown(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("tool-logger.go")
|
||||
|
||||
_, err := harness.Emit(extensions.SessionShutdownEvent{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Test the !time command
|
||||
func TestToolLogger_TimeCommand(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("tool-logger.go")
|
||||
|
||||
result, err := harness.Emit(extensions.InputEvent{
|
||||
Text: "!time",
|
||||
Source: "cli",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
test.AssertInputHandled(t, result, "handled")
|
||||
|
||||
// Verify PrintInfo was called with a time message
|
||||
infos := harness.Context().GetPrintInfos()
|
||||
found := false
|
||||
for _, info := range infos {
|
||||
if strings.Contains(info, "Current time:") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected PrintInfo with 'Current time:', got: %v", infos)
|
||||
}
|
||||
}
|
||||
|
||||
// Test the !status command
|
||||
func TestToolLogger_StatusCommand(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("tool-logger.go")
|
||||
|
||||
result, err := harness.Emit(extensions.InputEvent{
|
||||
Text: "!status",
|
||||
Source: "cli",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
test.AssertInputHandled(t, result, "handled")
|
||||
|
||||
// Verify PrintBlock was called
|
||||
blocks := harness.Context().PrintBlocks
|
||||
if len(blocks) != 1 {
|
||||
t.Fatalf("expected 1 PrintBlock call, got %d", len(blocks))
|
||||
}
|
||||
|
||||
block := blocks[0]
|
||||
if block.Subtitle != "tool-logger extension" {
|
||||
t.Errorf("expected subtitle 'tool-logger extension', got %q", block.Subtitle)
|
||||
}
|
||||
if block.BorderColor != "#a6e3a1" {
|
||||
t.Errorf("expected border color '#a6e3a1', got %q", block.BorderColor)
|
||||
}
|
||||
if !strings.Contains(block.Text, "Session active") {
|
||||
t.Errorf("expected text to contain 'Session active', got %q", block.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// Test that unknown commands are not handled
|
||||
func TestToolLogger_UnknownCommand(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("tool-logger.go")
|
||||
|
||||
result, err := harness.Emit(extensions.InputEvent{
|
||||
Text: "!unknown",
|
||||
Source: "cli",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result != nil {
|
||||
t.Errorf("expected nil result for unknown command, got %v", result)
|
||||
}
|
||||
|
||||
// Verify no info/block prints for unknown commands
|
||||
if len(harness.Context().GetPrintInfos()) != 0 {
|
||||
t.Error("expected no PrintInfo calls for unknown command")
|
||||
}
|
||||
if len(harness.Context().PrintBlocks) != 0 {
|
||||
t.Error("expected no PrintBlock calls for unknown command")
|
||||
}
|
||||
}
|
||||
|
||||
// Test regular text input (not a command)
|
||||
func TestToolLogger_RegularInput(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("tool-logger.go")
|
||||
|
||||
result, err := harness.Emit(extensions.InputEvent{
|
||||
Text: "This is a normal message",
|
||||
Source: "cli",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result != nil {
|
||||
t.Errorf("expected nil result for regular input, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// Test complete session flow
|
||||
func TestToolLogger_FullSession(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("tool-logger.go")
|
||||
|
||||
// Simulate a full session
|
||||
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Several tool calls
|
||||
tools := []string{"Read", "Glob", "Grep", "Bash"}
|
||||
for _, tool := range tools {
|
||||
_, err := harness.Emit(extensions.ToolCallEvent{
|
||||
ToolName: tool,
|
||||
Input: "{}",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("error for tool %s: %v", tool, err)
|
||||
}
|
||||
|
||||
_, err = harness.Emit(extensions.ToolResultEvent{
|
||||
ToolName: tool,
|
||||
Content: "result",
|
||||
IsError: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("error for tool result %s: %v", tool, err)
|
||||
}
|
||||
}
|
||||
|
||||
// User issues a command
|
||||
_, err = harness.Emit(extensions.InputEvent{Text: "!time", Source: "cli"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
_, err = harness.Emit(extensions.SessionShutdownEvent{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify the !time command was handled
|
||||
if len(harness.Context().GetPrintInfos()) != 1 {
|
||||
t.Errorf("expected 1 PrintInfo call, got %d", len(harness.Context().GetPrintInfos()))
|
||||
}
|
||||
}
|
||||
|
||||
// Test that the extension handles file write errors gracefully
|
||||
func TestToolLogger_FileError(t *testing.T) {
|
||||
// This test verifies the extension doesn't panic when file operations fail
|
||||
// Since we can't easily mock os.OpenFile, we rely on the extension code
|
||||
// properly checking for errors (which it does)
|
||||
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("tool-logger.go")
|
||||
|
||||
// Just verify the handlers run without panicking
|
||||
_, err := harness.Emit(extensions.ToolCallEvent{ToolName: "Read", Input: "{}"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
_, err = harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Test concurrent tool calls (race condition check)
|
||||
func TestToolLogger_ConcurrentToolCalls(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("tool-logger.go")
|
||||
|
||||
// Run multiple tool calls concurrently
|
||||
done := make(chan bool, 10)
|
||||
for i := range 10 {
|
||||
go func(index int) {
|
||||
defer func() { done <- true }()
|
||||
|
||||
toolName := "Tool" + string(rune('0'+index))
|
||||
_, err := harness.Emit(extensions.ToolCallEvent{
|
||||
ToolName: toolName,
|
||||
Input: "{}",
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("error in goroutine %d: %v", index, err)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Wait for all goroutines
|
||||
for range 10 {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
// Test the actual log file is created and written to
|
||||
func TestToolLogger_LogFile(t *testing.T) {
|
||||
logFile := "/tmp/kit-tool-log.txt"
|
||||
|
||||
// Clean up before test
|
||||
_ = os.Remove(logFile)
|
||||
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("tool-logger.go")
|
||||
|
||||
// Emit events
|
||||
_, _ = harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
|
||||
_, _ = harness.Emit(extensions.ToolCallEvent{ToolName: "Read", Input: "{}"})
|
||||
_, _ = harness.Emit(extensions.ToolResultEvent{ToolName: "Read", Content: "data", IsError: false})
|
||||
|
||||
// Note: Since the extension writes to a real file and the test harness
|
||||
// mocks the context, the file writes actually happen. Let's verify.
|
||||
|
||||
// Give it a moment for file operations
|
||||
if _, err := os.Stat(logFile); err == nil {
|
||||
// File exists - read and verify content
|
||||
content, err := os.ReadFile(logFile)
|
||||
if err != nil {
|
||||
t.Logf("Could not read log file: %v", err)
|
||||
} else {
|
||||
contentStr := string(content)
|
||||
if !strings.Contains(contentStr, "SESSION_START") {
|
||||
t.Error("log file should contain SESSION_START")
|
||||
}
|
||||
if !strings.Contains(contentStr, "CALL tool=Read") {
|
||||
t.Error("log file should contain CALL tool=Read")
|
||||
}
|
||||
if !strings.Contains(contentStr, "RESULT tool=Read") {
|
||||
t.Error("log file should contain RESULT tool=Read")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
t.Log("Note: Log file not created - this is expected since the extension writes directly to disk")
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ func Init(api ext.API) {
|
||||
DisplayName: "File",
|
||||
BorderColor: "#89b4fa", // Catppuccin blue
|
||||
RenderHeader: func(toolArgs string, width int) string {
|
||||
var args map[string]interface{}
|
||||
var args map[string]any
|
||||
if err := json.Unmarshal([]byte(toolArgs), &args); err != nil {
|
||||
return ""
|
||||
}
|
||||
@@ -72,7 +72,7 @@ func Init(api ext.API) {
|
||||
Background: "#1e1e2e", // Dark background
|
||||
BorderColor: "#a6e3a1", // Catppuccin green
|
||||
RenderHeader: func(toolArgs string, width int) string {
|
||||
var args map[string]interface{}
|
||||
var args map[string]any
|
||||
if err := json.Unmarshal([]byte(toolArgs), &args); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# SDK Examples
|
||||
|
||||
These examples demonstrate how to use the Kit SDK (`pkg/kit`) to build agents programmatically in Go.
|
||||
|
||||
## Examples
|
||||
|
||||
### [basic](basic/)
|
||||
|
||||
Shows core SDK usage: creating a Kit instance, sending prompts, overriding the model, subscribing to events (tool calls, streaming), and session management.
|
||||
|
||||
```bash
|
||||
go run ./examples/sdk/basic
|
||||
```
|
||||
|
||||
### [scripting](scripting/)
|
||||
|
||||
A minimal script-friendly wrapper that takes a prompt from the command line and prints the response — useful for piping and automation.
|
||||
|
||||
```bash
|
||||
go run ./examples/sdk/scripting "Explain what this repo does"
|
||||
```
|
||||
|
||||
### [crypto-monitor](crypto-monitor/)
|
||||
|
||||
A background agent that checks Bitcoin and Ethereum prices every 30 minutes and sends desktop notifications via `notify-send` (dbus). Demonstrates using the SDK for a long-running autonomous task with a single tool.
|
||||
|
||||
```bash
|
||||
go run ./examples/sdk/crypto-monitor
|
||||
|
||||
# Override the check interval:
|
||||
CRYPTO_INTERVAL=5m go run ./examples/sdk/crypto-monitor
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
|
||||
```go
|
||||
import kit "github.com/mark3labs/kit/pkg/kit"
|
||||
|
||||
host, err := kit.New(ctx, nil) // uses ~/.kit.yml defaults
|
||||
defer host.Close()
|
||||
|
||||
response, err := host.Prompt(ctx, "Hello!")
|
||||
```
|
||||
|
||||
See the [SDK README](../../pkg/kit/README.md) for the full API reference.
|
||||
@@ -0,0 +1,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
)
|
||||
|
||||
const systemPrompt = `You are a cryptocurrency price monitor. Your job is to:
|
||||
|
||||
1. Fetch the current prices of Bitcoin and Ethereum using bash with curl
|
||||
2. Send a desktop notification with the results using notify-send
|
||||
|
||||
To fetch prices, use this CoinGecko API endpoint (no API key needed):
|
||||
curl -s 'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin,ethereum&vs_currencies=usd&include_24hr_change=true'
|
||||
|
||||
To send a desktop notification:
|
||||
notify-send -i dialog-information "Crypto Prices" "BTC: $XX,XXX (+X.X%)\nETH: $X,XXX (+X.X%)"
|
||||
|
||||
Include the 24h percentage change in the notification. Use a green arrow (▲) for
|
||||
positive changes and a red arrow (▼) for negative. Format prices with commas.
|
||||
|
||||
If the API call fails, send a notification about the failure instead.
|
||||
|
||||
Always complete both steps: fetch then notify. Be concise — no commentary needed.`
|
||||
|
||||
func main() {
|
||||
interval := 30 * time.Minute
|
||||
if os.Getenv("CRYPTO_INTERVAL") != "" {
|
||||
d, err := time.ParseDuration(os.Getenv("CRYPTO_INTERVAL"))
|
||||
if err == nil {
|
||||
interval = d
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer cancel()
|
||||
|
||||
host, err := kit.New(ctx, &kit.Options{
|
||||
SystemPrompt: systemPrompt,
|
||||
Tools: []kit.Tool{kit.NewBashTool()},
|
||||
NoSession: true,
|
||||
Quiet: true,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create kit instance: %v", err)
|
||||
}
|
||||
defer func() { _ = host.Close() }()
|
||||
|
||||
fmt.Printf("Crypto price monitor started (every %s)\n", interval)
|
||||
fmt.Println("Press Ctrl+C to stop")
|
||||
|
||||
// Run immediately on startup, then on each tick.
|
||||
check(ctx, host)
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
check(ctx, host)
|
||||
case <-ctx.Done():
|
||||
fmt.Println("\nStopping price monitor")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func check(ctx context.Context, host *kit.Kit) {
|
||||
fmt.Printf("[%s] Checking prices...\n", time.Now().Format("15:04:05"))
|
||||
|
||||
// Clear session so each check is independent.
|
||||
host.ClearSession()
|
||||
|
||||
_, err := host.Prompt(ctx, "Fetch current Bitcoin and Ethereum prices and send a desktop notification.")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,27 @@
|
||||
module github.com/mark3labs/kit
|
||||
|
||||
go 1.26.0
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.0.0
|
||||
charm.land/bubbletea/v2 v2.0.2
|
||||
charm.land/fantasy v0.11.1
|
||||
charm.land/lipgloss/v2 v2.0.1
|
||||
charm.land/fantasy v0.16.0
|
||||
charm.land/huh/v2 v2.0.3
|
||||
charm.land/lipgloss/v2 v2.0.2
|
||||
github.com/alecthomas/chroma/v2 v2.23.1
|
||||
github.com/aymanbagabas/go-udiff v0.4.1
|
||||
github.com/charmbracelet/fang v0.4.4
|
||||
github.com/charmbracelet/log v0.4.2
|
||||
github.com/mark3labs/mcp-go v0.44.1
|
||||
github.com/charmbracelet/fang v1.0.0
|
||||
github.com/charmbracelet/log v1.0.0
|
||||
github.com/coder/acp-go-sdk v0.6.3
|
||||
github.com/mark3labs/mcp-go v0.45.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/traefik/yaegi v0.16.1
|
||||
golang.org/x/term v0.40.0
|
||||
golang.org/x/term v0.41.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
charm.land/huh/v2 v2.0.3 // indirect
|
||||
cloud.google.com/go v0.123.0 // indirect
|
||||
cloud.google.com/go/auth v0.18.2 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
@@ -28,42 +29,42 @@ require (
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect
|
||||
github.com/aws/smithy-go v1.24.2 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/buger/jsonparser v1.1.1 // indirect
|
||||
github.com/catppuccin/go v0.2.0 // indirect
|
||||
github.com/buger/jsonparser v1.1.2 // indirect
|
||||
github.com/catppuccin/go v0.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.2 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff // indirect
|
||||
github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260316091819-b93f6a3b8502 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260305213658-fe36e8c10185 // indirect
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260322003602-9b007323c5cd // indirect
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260305213658-fe36e8c10185 // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260322003602-9b007323c5cd // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.1.0 // indirect
|
||||
github.com/charmbracelet/x/json v0.2.0 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/coder/acp-go-sdk v0.6.3 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
@@ -79,15 +80,15 @@ require (
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.19.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/invopop/jsonschema v0.13.0 // indirect
|
||||
github.com/kaptinlin/go-i18n v0.2.12 // indirect
|
||||
github.com/kaptinlin/jsonpointer v0.4.17 // indirect
|
||||
github.com/kaptinlin/jsonschema v0.7.5 // indirect
|
||||
github.com/kaptinlin/jsonschema v0.7.6 // indirect
|
||||
github.com/kaptinlin/messageformat-go v0.4.18 // indirect
|
||||
github.com/mailru/easyjson v0.9.1 // indirect
|
||||
github.com/mailru/easyjson v0.9.2 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/muesli/mango v0.2.0 // indirect
|
||||
@@ -95,7 +96,6 @@ require (
|
||||
github.com/muesli/mango-pflag v0.2.0 // indirect
|
||||
github.com/muesli/reflow v0.3.0 // indirect
|
||||
github.com/muesli/roff v0.1.0 // indirect
|
||||
github.com/openai/openai-go/v2 v2.7.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
@@ -108,42 +108,42 @@ require (
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
github.com/yuin/goldmark v1.7.16 // indirect
|
||||
github.com/yuin/goldmark v1.7.17 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.66.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0 // indirect
|
||||
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
|
||||
go.opentelemetry.io/otel v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.42.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/oauth2 v0.35.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
google.golang.org/api v0.269.0 // indirect
|
||||
google.golang.org/genai v1.49.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
||||
google.golang.org/grpc v1.79.2 // indirect
|
||||
golang.org/x/crypto v0.49.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
google.golang.org/api v0.272.0 // indirect
|
||||
google.golang.org/genai v1.51.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect
|
||||
google.golang.org/grpc v1.79.3 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/glamour v0.10.0
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/glamour v1.0.0
|
||||
github.com/charmbracelet/x/ansi v0.11.6
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.21 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/text v0.35.0
|
||||
)
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
|
||||
charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
|
||||
charm.land/bubbletea/v2 v2.0.1 h1:B8e9zzK7x9JJ+XvHGF4xnYu9Xa0E0y0MyggY6dbaCfQ=
|
||||
charm.land/bubbletea/v2 v2.0.1/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
|
||||
charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
|
||||
charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
|
||||
charm.land/fantasy v0.11.1 h1:G1dRqkzEQ0RJN1Ls5mte8HOi0wFKxYd5bfnRAmeYvDk=
|
||||
charm.land/fantasy v0.11.1/go.mod h1:C8wNxWlw+b2z54zsTor9r1tG2GE2C4QotvAlgXh9KF8=
|
||||
charm.land/fantasy v0.16.0 h1:vE/6sR9nPcSD8qXJXX6wR8NXjtWlBVAzwQmTh5pHVrs=
|
||||
charm.land/fantasy v0.16.0/go.mod h1:VZjpXVh7IgeiIzGQybEnKzd68ofDsRj94+kzH1ZCAfQ=
|
||||
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
|
||||
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
|
||||
charm.land/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo=
|
||||
charm.land/lipgloss/v2 v2.0.0/go.mod h1:w6SnmsBFBmEFBodiEDurGS/sdUY/u1+v72DqUzc6J14=
|
||||
charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys=
|
||||
charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
|
||||
charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs=
|
||||
charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
|
||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
|
||||
@@ -38,82 +34,86 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs
|
||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 h1:N4lRUXZpZ1KVEUn6hxtco/1d2lgYhNn1fHkkl8WhlyQ=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.11 h1:ftxI5sgz8jZkckuUHXfC/wMUc8u3fG1vQS0plr2F2Zs=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.11/go.mod h1:twF11+6ps9aNRKEDimksp923o44w/Thk9+8YIlzWMmo=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.11 h1:NdV8cwCcAXrCWyxArt58BrvZJ9pZ9Fhf9w6Uh5W3Uyc=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.11/go.mod h1:30yY2zqkMPdrvxBqzI9xQCM+WrlrZKSOpSJEsylVU+8=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 h1:INUvJxmhdEbVulJYHI061k4TVuS3jzzthNvjqvVvTKM=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19/go.mod h1:FpZN2QISLdEBWkayloda+sZjVJL+e9Gl0k1SyTgcswU=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 h1:/sECfyq2JTifMI2JPyZ4bdRN77zJmr6SrS1eL3augIA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19/go.mod h1:dMf8A5oAqr9/oxOfLkC/c2LU/uMcALP0Rgn2BD5LWn0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 h1:AWeJMk33GTBf6J20XJe6qZoRSJo0WfUhsMdUKhoODXE=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19/go.mod h1:+GWrYoaAsV7/4pNHpwh1kiNLXkKaSoppxQq9lbH8Ejw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 h1:clHU5fm//kWS1C2HgtgWxfQbFbx4b6rx+5jzhgX9HrI=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 h1:XAq62tBTJP/85lFD5oqOOe7YYgWxY9LvWq8plyDvDVg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 h1:X1Tow7suZk9UCJHE1Iw9GMZJJl0dAnKXXP1NaSDHwmw=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19/go.mod h1:/rARO8psX+4sfjUQXp5LLifjUt8DuATZ31WptNJTyQA=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 h1:Y2cAXlClHsXkkOvWZFXATr34b0hxxloeQu/pAZz2row=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7/go.mod h1:idzZ7gmDeqeNrSPkdbtMp9qWMgcBwykA7P7Rzh5DXVU=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 h1:iSsvB9EtQ09YrsmIc44Heqlx5ByGErqhPK1ZQLppias=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12/go.mod h1:fEWYKTRGoZNl8tZ77i61/ccwOMJdGxwOhWCkp6TXAr0=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 h1:EnUdUqRP1CNzt2DkV67tJx6XDN4xlfBFm+bzeNOQVb0=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16/go.mod h1:Jic/xv0Rq/pFNCh3WwpH4BEqdbSAl+IyHro8LbibHD8=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 h1:XQTQTF75vnug2TXS8m7CVJfC2nniYPZnO1D4Np761Oo=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8/go.mod h1:Xgx+PR1NUOjNmQY+tRMnouRp83JRM8pRMw/vCaVhPkI=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 h1:3kGOqnh1pPeddVa/E37XNTaWJ8W6vrbYV9lJEkCnhuY=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 h1:kiIDLZ005EcKomYYITtfsjn7dtOwHDOFy7IbPXKek2o=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13/go.mod h1:2h/xGEowcW/g38g06g3KpRWDlT+OTfxxI0o1KqayAB8=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 h1:jzKAXIlhZhJbnYwHbvUQZEB8KfgAEuG0dc08Bkda7NU=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17/go.mod h1:Al9fFsXjv4KfbzQHGe6V4NZSZQXecFcvaIF4e70FoRA=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8MSU6Ch5i9PgBkcU=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk=
|
||||
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
||||
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.4.0 h1:TKnLPh7IbnizJIBKFWa9mKayRUBQ9Kh1BPCk6w2PnYM=
|
||||
github.com/aymanbagabas/go-udiff v0.4.0/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
||||
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
|
||||
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||
github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
|
||||
github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab h1:J7XQLgl9sefgTnTGrmX3xqvp5o6MCiBzEjGv5igAlc4=
|
||||
github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab/go.mod h1:hqlYqR7uPKOKfnNeicUbZp0Ps0GeYFlKYtwh5HGDCx8=
|
||||
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
|
||||
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
|
||||
github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=
|
||||
github.com/charmbracelet/fang v0.4.4/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo=
|
||||
github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
|
||||
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
|
||||
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
github.com/charmbracelet/fang v1.0.0 h1:jESBY40agJOlLYnnv9jE0mLqDGTxEk0hkOnx7YGyRlQ=
|
||||
github.com/charmbracelet/fang v1.0.0/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo=
|
||||
github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
|
||||
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
|
||||
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
||||
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
|
||||
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff h1:uY7A6hTokHPJBHfq7rj9Y/wm+IAjOghZTxKfVW6QLvw=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff/go.mod h1:E6/0abq9uG2SnM8IbLB9Y5SW09uIgfaFETk8aRzgXUQ=
|
||||
github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdRc4=
|
||||
github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA=
|
||||
github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266 h1:BW/sZtyd1JyYy0h5adMm3tzpNyL857LWjuTRET6OhpY=
|
||||
github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266/go.mod h1:1DahUaExbUZx/jD+FNT2PKP4L9rLE5+ZBRuI8mZjd/E=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260316091819-b93f6a3b8502 h1:hzWNs3UQRSUTS6YCbLaQnwqKBFXT5Yh1OOw6+26apqg=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260316091819-b93f6a3b8502/go.mod h1:mkUCcxn9w9j89JJp3pOza5tmDQZPgIB75UfmQlFYvas=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260305213658-fe36e8c10185 h1:/192monmpmRICpSPrFRzkIO+xfhioV6/nwrQdkDTj10=
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260305213658-fe36e8c10185/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY=
|
||||
github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
|
||||
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260322003602-9b007323c5cd h1:eStB6uX52pgrm6TxQcEKctPrEC+a/9ubJC+P671idOc=
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260322003602-9b007323c5cd/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260305213658-fe36e8c10185 h1:bloHJLweYZeIkBVgi8AF94DrTdx3eoEB57VOpFuFi3U=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260305213658-fe36e8c10185/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260322003602-9b007323c5cd h1:U8xj0UXwqHzO+UYHZJopKF+gWaQEW8oj60fmiq9TFY4=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260322003602-9b007323c5cd/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
|
||||
github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA=
|
||||
github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
|
||||
github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
|
||||
github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
@@ -122,6 +122,8 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||
github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw=
|
||||
github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
@@ -131,6 +133,8 @@ github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7X
|
||||
github.com/coder/acp-go-sdk v0.6.3 h1:LsXQytehdjKIYJnoVWON/nf7mqbiarnyuyE3rrjBsXQ=
|
||||
github.com/coder/acp-go-sdk v0.6.3/go.mod h1:yKzM/3R9uELp4+nBAwwtkS0aN1FOFjo11CNPy37yFko=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
@@ -175,8 +179,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
|
||||
github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
|
||||
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
|
||||
github.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE=
|
||||
github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
@@ -191,8 +195,8 @@ github.com/kaptinlin/go-i18n v0.2.12 h1:ywDsvb4KDFddMC2dpI/rrIzGU2mWUSvHmWUm9BMs
|
||||
github.com/kaptinlin/go-i18n v0.2.12/go.mod h1:pVcu9qsW5pOIOoZFJXesRYmLos1vMQrby70JPAoWmJU=
|
||||
github.com/kaptinlin/jsonpointer v0.4.17 h1:mY9k8ciWncxbsECyaxKnR0MdmxamNdp2tLQkAKVrtSk=
|
||||
github.com/kaptinlin/jsonpointer v0.4.17/go.mod h1:SsfsjqnHG5zuKo1DTBzk1VknaHlL4osHw+X9kZKukpU=
|
||||
github.com/kaptinlin/jsonschema v0.7.5 h1:jkK4a3NyzNoGlvu12CsL3IcqNMVa5sL51HPVa0nWcPY=
|
||||
github.com/kaptinlin/jsonschema v0.7.5/go.mod h1:3gIWnptl+SWMyfMR2r4TXXd0xsQZ1m50AKrwmcUONSg=
|
||||
github.com/kaptinlin/jsonschema v0.7.6 h1:UUMqZGFAk7nOzQsYAxvgygm4wpDp/nwXxA4VP9mCPCs=
|
||||
github.com/kaptinlin/jsonschema v0.7.6/go.mod h1:GGk/oE+F1lWUfYrzKaCf4QWZmMdytt0LL4XdFEFB0LE=
|
||||
github.com/kaptinlin/messageformat-go v0.4.18 h1:RBlHVWgZyoxTcUgGWBsl2AcyScq/urqbLZvzgryTmSI=
|
||||
github.com/kaptinlin/messageformat-go v0.4.18/go.mod h1:ntI3154RnqJgr7GaC+vZBnIExl2V3sv9selvRNNEM24=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@@ -203,15 +207,15 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
|
||||
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mark3labs/mcp-go v0.44.1 h1:2PKppYlT9X2fXnE8SNYQLAX4hNjfPB0oNLqQVcN6mE8=
|
||||
github.com/mark3labs/mcp-go v0.44.1/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
|
||||
github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M=
|
||||
github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mark3labs/mcp-go v0.45.0 h1:s0S8qR/9fWaQ3pHxz7pm1uQ0DrswoSnRIxKIjbiQtkc=
|
||||
github.com/mark3labs/mcp-go v0.45.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
|
||||
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
|
||||
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||
@@ -230,8 +234,6 @@ github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
|
||||
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/openai/openai-go/v2 v2.7.1 h1:/tfvTJhfv7hTSL8mWwc5VL4WLLSDL5yn9VqVykdu9r8=
|
||||
github.com/openai/openai-go/v2 v2.7.1/go.mod h1:jrJs23apqJKKbT+pqtFgNKpRju/KP9zpUTZhz3GElQE=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
@@ -283,59 +285,57 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
||||
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark v1.7.17 h1:p36OVWwRb246iHxA/U4p8OPEpOTESm4n+g+8t0EE5uA=
|
||||
github.com/yuin/goldmark v1.7.17/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
|
||||
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.66.0 h1:w/o339tDd6Qtu3+ytwt+/jon2yjAs3Ot8Xq8pelfhSo=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.66.0/go.mod h1:pdhNtM9C4H5fRdrnwO7NjxzQWhKSSxCHk/KluVqDVC0=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0 h1:PnV4kVnw0zOmwwFkAzCN5O07fw1YOIQor120zrh0AVo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0/go.mod h1:ofAwF4uinaf8SXdVzzbL4OsxJ3VfeEg3f/F6CeF49/Y=
|
||||
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
|
||||
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
|
||||
go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8=
|
||||
go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.41.0 h1:siZQIYBAUd1rlIWQT2uCxWJxcCO7q3TriaMlf08rXw8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.41.0/go.mod h1:HNBuSvT7ROaGtGI50ArdRLUnvRTRGniSUZbxiWxSO8Y=
|
||||
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
||||
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
|
||||
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
|
||||
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
|
||||
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
|
||||
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
|
||||
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
|
||||
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
|
||||
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
|
||||
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/api v0.269.0 h1:qDrTOxKUQ/P0MveH6a7vZ+DNHxJQjtGm/uvdbdGXCQg=
|
||||
google.golang.org/api v0.269.0/go.mod h1:N8Wpcu23Tlccl0zSHEkcAZQKDLdquxK+l9r2LkwAauE=
|
||||
google.golang.org/genai v1.49.0 h1:Se+QJaH2GYK1aaR1o5S38mlU2GD5FnVvP76nfkV7LH0=
|
||||
google.golang.org/genai v1.49.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
|
||||
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/api v0.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA=
|
||||
google.golang.org/api v0.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA=
|
||||
google.golang.org/genai v1.51.0 h1:IZGuUqgfx40INv3hLFGCbOSGp0qFqm7LVmDghzNIYqg=
|
||||
google.golang.org/genai v1.51.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
+49
-1
@@ -63,6 +63,18 @@ type ToolCallContentHandler func(content string)
|
||||
// ReasoningDeltaHandler is a function type for handling streaming reasoning/thinking deltas.
|
||||
type ReasoningDeltaHandler func(delta string)
|
||||
|
||||
// ToolOutputHandler is a function type for handling streaming tool output chunks.
|
||||
// Used by tools like bash to stream output as it arrives rather than waiting
|
||||
// for the command to complete. The isStderr flag indicates if the chunk
|
||||
// contains stderr output.
|
||||
// Note: This is an alias for core.ToolOutputCallback to avoid import cycles.
|
||||
type ToolOutputHandler = core.ToolOutputCallback
|
||||
|
||||
// StepUsageHandler is a function type for handling token usage after each
|
||||
// complete step in a multi-step agent turn. This enables real-time cost
|
||||
// tracking during long-running tool-calling conversations.
|
||||
type StepUsageHandler func(inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens int64)
|
||||
|
||||
// Agent represents an AI agent with core tool integration using the fantasy library.
|
||||
// Core tools (bash, read, write, edit, grep, find, ls) are registered as direct
|
||||
// fantasy.AgentTool implementations — no MCP layer, no serialization overhead.
|
||||
@@ -218,7 +230,7 @@ func (a *Agent) GenerateWithLoop(ctx context.Context, messages []fantasy.Message
|
||||
onResponse ResponseHandler, onToolCallContent ToolCallContentHandler,
|
||||
) (*GenerateWithLoopResult, error) {
|
||||
return a.GenerateWithLoopAndStreaming(ctx, messages, onToolCall, onToolExecution, onToolResult,
|
||||
onResponse, onToolCallContent, nil, nil)
|
||||
onResponse, onToolCallContent, nil, nil, nil, nil)
|
||||
}
|
||||
|
||||
// GenerateWithLoopAndStreaming processes messages using the fantasy agent with streaming and callbacks.
|
||||
@@ -229,8 +241,15 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
onResponse ResponseHandler, onToolCallContent ToolCallContentHandler,
|
||||
onStreamingResponse StreamingResponseHandler,
|
||||
onReasoningDelta ReasoningDeltaHandler,
|
||||
onToolOutput ToolOutputHandler,
|
||||
onStepUsage StepUsageHandler,
|
||||
) (*GenerateWithLoopResult, error) {
|
||||
|
||||
// Inject tool output handler into context for use by core tools (e.g., bash).
|
||||
if onToolOutput != nil {
|
||||
ctx = core.ContextWithToolOutputCallback(ctx, onToolOutput)
|
||||
}
|
||||
|
||||
// Fantasy requires the current user input as Prompt, with prior messages as history.
|
||||
// Extract the last user message text and files as the prompt, and pass everything
|
||||
// before it as Messages. Files (e.g. clipboard images) are passed via the Files
|
||||
@@ -249,6 +268,12 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
onToolCallContent != nil || onStreamingResponse != nil || onReasoningDelta != nil
|
||||
|
||||
if a.streamingEnabled || hasCallbacks {
|
||||
// Track completed step messages so we can return partial results
|
||||
// on cancellation. Fantasy's Stream() discards accumulated steps
|
||||
// when it returns an error, but the OnStepFinish callback fires
|
||||
// for every step that completed before the error occurred.
|
||||
var completedStepMessages []fantasy.Message
|
||||
|
||||
// Use fantasy's streaming agent
|
||||
result, err := a.fantasyAgent.Stream(ctx, fantasy.AgentStreamCall{
|
||||
Prompt: prompt,
|
||||
@@ -319,6 +344,10 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
|
||||
// Step callbacks for content that accompanies tool calls
|
||||
OnStepFinish: func(step fantasy.StepResult) error {
|
||||
// Accumulate messages from completed steps so they can be
|
||||
// persisted even if a later step is cancelled.
|
||||
completedStepMessages = append(completedStepMessages, step.Messages...)
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
@@ -328,10 +357,29 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
if text != "" && len(toolCalls) > 0 && onToolCallContent != nil {
|
||||
onToolCallContent(text)
|
||||
}
|
||||
// Emit step usage for real-time cost tracking
|
||||
if onStepUsage != nil {
|
||||
onStepUsage(step.Usage.InputTokens, step.Usage.OutputTokens,
|
||||
step.Usage.CacheReadTokens, step.Usage.CacheCreationTokens)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
// On cancellation (or any error), return a partial result
|
||||
// containing messages from completed steps so the caller can
|
||||
// persist tool calls and results that finished before the
|
||||
// cancellation. The original input messages are included so
|
||||
// the caller sees the full conversation up to the point of
|
||||
// cancellation.
|
||||
if len(completedStepMessages) > 0 {
|
||||
partialMessages := make([]fantasy.Message, 0, len(messages)+len(completedStepMessages))
|
||||
partialMessages = append(partialMessages, messages...)
|
||||
partialMessages = append(partialMessages, completedStepMessages...)
|
||||
return &GenerateWithLoopResult{
|
||||
ConversationMessages: partialMessages,
|
||||
}, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
+169
-32
@@ -217,6 +217,22 @@ func (a *App) GetTreeSession() *session.TreeManager {
|
||||
return a.opts.TreeSession
|
||||
}
|
||||
|
||||
// SwitchTreeSession replaces the active tree session with a new one and
|
||||
// reloads the in-memory message store from the new session's messages.
|
||||
// The old tree session is closed. Used by /resume to switch sessions.
|
||||
func (a *App) SwitchTreeSession(ts *session.TreeManager) {
|
||||
// Close old session.
|
||||
if old := a.opts.TreeSession; old != nil {
|
||||
_ = old.Close()
|
||||
}
|
||||
a.opts.TreeSession = ts
|
||||
// Reload messages from new session.
|
||||
a.store.Clear()
|
||||
if ts != nil {
|
||||
a.store.Replace(ts.GetFantasyMessages())
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -391,41 +407,63 @@ func (a *App) Close() {
|
||||
// Internal: queue drain loop
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// drainQueue runs in a goroutine. It executes the given item and then
|
||||
// continues draining the queue until it is empty.
|
||||
// drainQueue runs in a goroutine. It collects all queued items (including the
|
||||
// first one) and submits them together as a single batch. This ensures that
|
||||
// when multiple messages are queued while the agent is working, they are all
|
||||
// submitted together in one turn rather than sequentially.
|
||||
// Must be called with a.busy == true and a.wg incremented.
|
||||
func (a *App) drainQueue(first queueItem) {
|
||||
defer a.wg.Done()
|
||||
|
||||
item := first
|
||||
for {
|
||||
a.runQueueItem(item)
|
||||
// Collect all items to process in this batch
|
||||
var items []queueItem
|
||||
items = append(items, first)
|
||||
|
||||
// Process batches until no more items are queued
|
||||
for {
|
||||
// Drain the queue to collect any pending items
|
||||
a.mu.Lock()
|
||||
// Stop draining if the app is shutting down.
|
||||
if a.closed || a.rootCtx.Err() != nil {
|
||||
a.busy = false
|
||||
a.queue = a.queue[:0]
|
||||
a.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if len(a.queue) == 0 {
|
||||
a.busy = false
|
||||
a.mu.Unlock()
|
||||
return
|
||||
}
|
||||
item = a.queue[0]
|
||||
a.queue = a.queue[1:]
|
||||
qLen := len(a.queue)
|
||||
items = append(items, a.queue...)
|
||||
a.queue = a.queue[:0] // Clear the queue
|
||||
queueLen := len(a.queue)
|
||||
a.mu.Unlock()
|
||||
// sendEvent must be called without a.mu held (see sendEvent comment).
|
||||
a.sendEvent(QueueUpdatedEvent{Length: qLen})
|
||||
|
||||
// Send queue updated event (queue is now empty)
|
||||
a.sendEvent(QueueUpdatedEvent{Length: queueLen})
|
||||
|
||||
// Process all collected items as a single batch
|
||||
a.runQueueBatch(items)
|
||||
|
||||
// Check if more items were queued while we were processing
|
||||
a.mu.Lock()
|
||||
hasMore := len(a.queue) > 0
|
||||
if hasMore {
|
||||
// Start a new batch with the newly queued items
|
||||
items = a.queue
|
||||
a.queue = a.queue[:0]
|
||||
}
|
||||
a.mu.Unlock()
|
||||
|
||||
if !hasMore {
|
||||
// No more items, we're done
|
||||
break
|
||||
}
|
||||
// Process the new batch
|
||||
}
|
||||
|
||||
// Mark as no longer busy
|
||||
a.mu.Lock()
|
||||
a.busy = false
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
// runQueueItem executes a single queue item: adds the user message to the store,
|
||||
// runs the agent step, and sends the appropriate event to the program.
|
||||
func (a *App) runQueueItem(item queueItem) {
|
||||
// runQueueBatch executes multiple queue items as a single agent turn.
|
||||
// All items are submitted together, and the agent responds once to the combined context.
|
||||
func (a *App) runQueueBatch(items []queueItem) {
|
||||
if len(items) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Create a per-step cancellable context.
|
||||
stepCtx, cancel := context.WithCancel(a.rootCtx)
|
||||
a.mu.Lock()
|
||||
@@ -444,12 +482,17 @@ func (a *App) runQueueItem(item queueItem) {
|
||||
}
|
||||
}
|
||||
|
||||
result, err := a.executeStep(stepCtx, item.Prompt, eventFn, item.Files)
|
||||
// Execute the batch
|
||||
result, err := a.executeBatch(stepCtx, items, eventFn)
|
||||
if err != nil {
|
||||
if stepCtx.Err() != nil {
|
||||
// Step was cancelled by the user (e.g. double-ESC). Send a
|
||||
// cancellation event so the TUI can cut off the response
|
||||
// cleanly without printing an error.
|
||||
// Step was cancelled by the user (double-ESC). The SDK
|
||||
// preserves the user message and any completed tool
|
||||
// call/result pairs; only the in-progress message or tool
|
||||
// call is discarded. Sync the in-memory store to match.
|
||||
if ts := a.opts.TreeSession; ts != nil {
|
||||
a.store.Replace(ts.GetFantasyMessages())
|
||||
}
|
||||
a.sendEvent(StepCancelledEvent{})
|
||||
return
|
||||
}
|
||||
@@ -507,9 +550,87 @@ func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(tea.M
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Internal: event helpers
|
||||
// --------------------------------------------------------------------------
|
||||
// executeBatch runs a batch of queue items as a single agent step by delegating
|
||||
// to the SDK's PromptResultWithMessages(), which handles session persistence,
|
||||
// hooks, extension events, and the generation loop.
|
||||
func (a *App) executeBatch(ctx context.Context, items []queueItem, eventFn func(tea.Msg)) (*kit.TurnResult, error) {
|
||||
// Test hook: bypass SDK entirely (single item only for test compatibility).
|
||||
if a.opts.PromptFunc != nil {
|
||||
if len(items) == 1 {
|
||||
return a.opts.PromptFunc(ctx, items[0].Prompt)
|
||||
}
|
||||
// For batch mode with PromptFunc, just use the first item
|
||||
return a.opts.PromptFunc(ctx, items[0].Prompt)
|
||||
}
|
||||
|
||||
sendFn := func(msg tea.Msg) {
|
||||
if eventFn != nil {
|
||||
eventFn(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to SDK events for TUI rendering. The subscription is
|
||||
// temporary — it lives only for the duration of this step.
|
||||
unsub := a.subscribeSDKEvents(sendFn)
|
||||
defer unsub()
|
||||
|
||||
// Show spinner while the agent works.
|
||||
sendFn(SpinnerEvent{Show: true})
|
||||
|
||||
// Check if any items have file attachments
|
||||
hasFiles := false
|
||||
for _, item := range items {
|
||||
if len(item.Files) > 0 {
|
||||
hasFiles = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var result *kit.TurnResult
|
||||
var err error
|
||||
|
||||
if len(items) == 1 {
|
||||
// Single item: use the original path for compatibility
|
||||
item := items[0]
|
||||
if len(item.Files) > 0 || hasFiles {
|
||||
result, err = a.opts.Kit.PromptResultWithFiles(ctx, item.Prompt, item.Files)
|
||||
} else {
|
||||
result, err = a.opts.Kit.PromptResult(ctx, item.Prompt)
|
||||
}
|
||||
} else {
|
||||
// Multiple items: batch them together
|
||||
var messages []string
|
||||
for _, item := range items {
|
||||
messages = append(messages, item.Prompt)
|
||||
}
|
||||
|
||||
// TODO: Handle file attachments in batch mode
|
||||
// For now, files are ignored in batch mode (rare edge case)
|
||||
if hasFiles {
|
||||
// If files exist, fall back to processing just the first item with files
|
||||
for _, item := range items {
|
||||
if len(item.Files) > 0 {
|
||||
result, err = a.opts.Kit.PromptResultWithFiles(ctx, item.Prompt, item.Files)
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result, err = a.opts.Kit.PromptResultWithMessages(ctx, messages)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Sync in-memory store with the SDK's authoritative conversation.
|
||||
a.store.Replace(result.Messages)
|
||||
|
||||
// Update usage tracker (using last item's prompt for tracking).
|
||||
a.updateUsageFromTurnResult(result, items[len(items)-1].Prompt)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// sendEvent sends a tea.Msg to the registered program if one is set.
|
||||
// Must NOT be called with a.mu held (to avoid deadlock with the program).
|
||||
@@ -550,6 +671,22 @@ func (a *App) subscribeSDKEvents(sendFn func(tea.Msg)) func() {
|
||||
sendFn(StreamChunkEvent{Content: ev.Chunk})
|
||||
case kit.ReasoningDeltaEvent:
|
||||
sendFn(ReasoningChunkEvent{Delta: ev.Delta})
|
||||
case kit.ToolOutputEvent:
|
||||
sendFn(ToolOutputEvent{
|
||||
ToolCallID: ev.ToolCallID,
|
||||
ToolName: ev.ToolName,
|
||||
Chunk: ev.Chunk,
|
||||
IsStderr: ev.IsStderr,
|
||||
})
|
||||
case kit.StepUsageEvent:
|
||||
if a.opts.UsageTracker != nil {
|
||||
a.opts.UsageTracker.UpdateUsage(
|
||||
int(ev.InputTokens),
|
||||
int(ev.OutputTokens),
|
||||
int(ev.CacheReadTokens),
|
||||
int(ev.CacheWriteTokens),
|
||||
)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
+20
-36
@@ -120,9 +120,8 @@ func TestRun_single(t *testing.T) {
|
||||
// Run (queued prompts)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// TestRun_queued verifies that a second Run() call while the first is in-flight
|
||||
// enqueues the prompt rather than spawning a second goroutine, and that the
|
||||
// queue is drained after the first step completes.
|
||||
// TestRun_queued verifies that queued prompts are batched together and submitted
|
||||
// as a single agent turn rather than individually.
|
||||
func TestRun_queued(t *testing.T) {
|
||||
gate := make(chan struct{})
|
||||
callCount := 0
|
||||
@@ -134,13 +133,7 @@ func TestRun_queued(t *testing.T) {
|
||||
callCount++
|
||||
mu.Unlock()
|
||||
<-gate
|
||||
return turnResult("first"), nil
|
||||
},
|
||||
func(_ context.Context) (*kit.TurnResult, error) {
|
||||
mu.Lock()
|
||||
callCount++
|
||||
mu.Unlock()
|
||||
return turnResult("second"), nil
|
||||
return turnResult("batch result"), nil
|
||||
},
|
||||
)
|
||||
app := newTestApp(stub)
|
||||
@@ -165,11 +158,15 @@ func TestRun_queued(t *testing.T) {
|
||||
t.Fatal("app did not become idle within 3s after queued runs")
|
||||
}
|
||||
|
||||
// Wait for the goroutine to fully finish (avoid race with queue check)
|
||||
app.wg.Wait()
|
||||
|
||||
mu.Lock()
|
||||
total := callCount
|
||||
mu.Unlock()
|
||||
if total != 2 {
|
||||
t.Fatalf("expected 2 calls, got %d", total)
|
||||
// With batching, both prompts should be processed in a single call
|
||||
if total != 1 {
|
||||
t.Fatalf("expected 1 batched call, got %d", total)
|
||||
}
|
||||
if got := app.QueueLength(); got != 0 {
|
||||
t.Fatalf("expected empty queue after drain, got %d", got)
|
||||
@@ -180,31 +177,22 @@ func TestRun_queued(t *testing.T) {
|
||||
// Queue drain ordering
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// TestQueueDrainOrdering verifies that queued prompts are consumed in FIFO order.
|
||||
// TestQueueDrainOrdering verifies that queued prompts are batched together and
|
||||
// processed in a single agent turn.
|
||||
func TestQueueDrainOrdering(t *testing.T) {
|
||||
gate := make(chan struct{})
|
||||
var order []string
|
||||
var receivedPrompt string
|
||||
var mu sync.Mutex
|
||||
|
||||
stub := newStubWithFuncs(
|
||||
func(ctx context.Context) (*kit.TurnResult, error) {
|
||||
mu.Lock()
|
||||
order = append(order, "first")
|
||||
// In test mode with PromptFunc, we receive the first prompt
|
||||
// but all messages are batched together
|
||||
receivedPrompt = "batched"
|
||||
mu.Unlock()
|
||||
<-gate
|
||||
return turnResult("first"), nil
|
||||
},
|
||||
func(_ context.Context) (*kit.TurnResult, error) {
|
||||
mu.Lock()
|
||||
order = append(order, "second")
|
||||
mu.Unlock()
|
||||
return turnResult("second"), nil
|
||||
},
|
||||
func(_ context.Context) (*kit.TurnResult, error) {
|
||||
mu.Lock()
|
||||
order = append(order, "third")
|
||||
mu.Unlock()
|
||||
return turnResult("third"), nil
|
||||
return turnResult("batch result"), nil
|
||||
},
|
||||
)
|
||||
|
||||
@@ -228,16 +216,12 @@ func TestQueueDrainOrdering(t *testing.T) {
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
got := order
|
||||
got := receivedPrompt
|
||||
mu.Unlock()
|
||||
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("expected 3 calls, got %d: %v", len(got), got)
|
||||
}
|
||||
for i, want := range []string{"first", "second", "third"} {
|
||||
if got[i] != want {
|
||||
t.Fatalf("call[%d]: expected %q, got %q", i, want, got[i])
|
||||
}
|
||||
// With batching, all 3 prompts should be processed in a single call
|
||||
if got != "batched" {
|
||||
t.Fatalf("expected batched processing, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,19 @@ type ToolResultEvent struct {
|
||||
IsError bool
|
||||
}
|
||||
|
||||
// ToolOutputEvent is sent when a tool produces streaming output chunks (e.g., bash output).
|
||||
// This allows the TUI to display tool output as it arrives, before the tool completes.
|
||||
type ToolOutputEvent struct {
|
||||
// ToolCallID is the stable identifier for the tool call producing output.
|
||||
ToolCallID string
|
||||
// ToolName is the name of the tool producing output.
|
||||
ToolName string
|
||||
// Chunk is a piece of the tool's output text.
|
||||
Chunk string
|
||||
// IsStderr indicates whether this chunk came from stderr.
|
||||
IsStderr bool
|
||||
}
|
||||
|
||||
// ToolCallContentEvent is sent when a step includes text content alongside tool calls.
|
||||
// This allows the TUI to display assistant commentary that accompanies tool usage.
|
||||
type ToolCallContentEvent struct {
|
||||
|
||||
@@ -49,12 +49,12 @@ func NewOAuthClient() *OAuthClient {
|
||||
}
|
||||
}
|
||||
|
||||
// GeneratePKCE generates a cryptographically secure PKCE verifier and challenge pair
|
||||
// generatePKCE generates a cryptographically secure PKCE verifier and challenge pair
|
||||
// for the OAuth 2.0 PKCE flow. The verifier is a random 32-byte string encoded as
|
||||
// base64url, and the challenge is the SHA256 hash of the verifier, also base64url encoded.
|
||||
// Returns the verifier (to be stored securely), challenge (to be sent with auth request),
|
||||
// and any error encountered during generation.
|
||||
func GeneratePKCE() (verifier, challenge string, err error) {
|
||||
func generatePKCE() (verifier, challenge string, err error) {
|
||||
// Generate 32 bytes of random data
|
||||
verifierBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(verifierBytes); err != nil {
|
||||
@@ -76,7 +76,7 @@ func GeneratePKCE() (verifier, challenge string, err error) {
|
||||
// and PKCE challenge. Returns an AuthData structure containing the URL for user
|
||||
// authentication and the PKCE verifier for the subsequent code exchange.
|
||||
func (c *OAuthClient) GetAuthorizationURL() (*AuthData, error) {
|
||||
verifier, challenge, err := GeneratePKCE()
|
||||
verifier, challenge, err := generatePKCE()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate PKCE: %w", err)
|
||||
}
|
||||
|
||||
@@ -71,5 +71,5 @@ func DetectMediaType(data []byte) string {
|
||||
// ErrNoImage is returned when the clipboard does not contain image data.
|
||||
var ErrNoImage = fmt.Errorf("no image data on clipboard")
|
||||
|
||||
// ErrNoClipboardTool is returned when no suitable clipboard tool is found.
|
||||
var ErrNoClipboardTool = fmt.Errorf("no clipboard tool available (install xclip, wl-paste, or use macOS)")
|
||||
// errNoClipboardTool is returned when no suitable clipboard tool is found.
|
||||
var errNoClipboardTool = fmt.Errorf("no clipboard tool available (install xclip, wl-paste, or use macOS)")
|
||||
|
||||
@@ -7,9 +7,8 @@ import (
|
||||
)
|
||||
|
||||
// ReadImage reads image data from the system clipboard on macOS.
|
||||
// It uses osascript to check if the clipboard contains an image and then
|
||||
// reads the data using a temporary approach. If the clipboard contains
|
||||
// an image, it writes it to stdout as PNG data.
|
||||
// It uses osascript to check if the clipboard contains an image via
|
||||
// NSPasteboard and writes it to stdout as PNG data.
|
||||
func ReadImage() (*ImageData, error) {
|
||||
// Use osascript to write clipboard image to stdout via a pipe.
|
||||
// The script checks if the clipboard has a «class PNGf» item.
|
||||
|
||||
@@ -41,7 +41,7 @@ func ReadImage() (*ImageData, error) {
|
||||
return nil, ErrNoImage
|
||||
}
|
||||
|
||||
return nil, ErrNoClipboardTool
|
||||
return nil, errNoClipboardTool
|
||||
}
|
||||
|
||||
// readWithXclip reads image data using xclip.
|
||||
|
||||
@@ -5,5 +5,5 @@ package clipboard
|
||||
// ReadImage reads image data from the system clipboard on Windows.
|
||||
// Windows clipboard image support is not yet implemented.
|
||||
func ReadImage() (*ImageData, error) {
|
||||
return nil, ErrNoClipboardTool
|
||||
return nil, errNoClipboardTool
|
||||
}
|
||||
|
||||
@@ -5,10 +5,18 @@
|
||||
// messages (KeepRecentTokens, default 20 000) rather than a fixed message
|
||||
// count. Auto-compaction fires when estimated context usage exceeds
|
||||
// contextWindow − ReserveTokens.
|
||||
//
|
||||
// Features modelled after pi's compaction system:
|
||||
// - Tool result truncation (2000 char max) during serialisation
|
||||
// - Split turn handling: when a single turn exceeds the keep budget,
|
||||
// the turn prefix is summarised separately and merged
|
||||
// - Cumulative file tracking: read and modified files extracted from
|
||||
// tool calls and carried forward across compactions
|
||||
package compaction
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -19,8 +27,8 @@ import (
|
||||
// Token estimation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// EstimateTokens provides a rough token count (~4 chars per token).
|
||||
func EstimateTokens(text string) int {
|
||||
// estimateTokens provides a rough token count (~4 chars per token).
|
||||
func estimateTokens(text string) int {
|
||||
return len(text) / 4
|
||||
}
|
||||
|
||||
@@ -40,7 +48,7 @@ func estimateSingleMessageTokens(msg fantasy.Message) int {
|
||||
total := 0
|
||||
for _, part := range msg.Content {
|
||||
if tp, ok := part.(fantasy.TextPart); ok {
|
||||
total += EstimateTokens(tp.Text)
|
||||
total += estimateTokens(tp.Text)
|
||||
}
|
||||
}
|
||||
return total
|
||||
@@ -66,10 +74,13 @@ func ShouldCompact(messages []fantasy.Message, contextWindow int, reserveTokens
|
||||
|
||||
// CompactionResult contains statistics from a compaction operation.
|
||||
type CompactionResult struct {
|
||||
Summary string // LLM-generated summary of compacted messages
|
||||
OriginalTokens int // Estimated token count before compaction
|
||||
CompactedTokens int // Estimated token count after compaction
|
||||
MessagesRemoved int // Number of messages replaced by the summary
|
||||
Summary string // LLM-generated summary of compacted messages
|
||||
OriginalTokens int // Estimated token count before compaction
|
||||
CompactedTokens int // Estimated token count after compaction
|
||||
MessagesRemoved int // Number of messages replaced by the summary
|
||||
CutPoint int // Index in the original messages where the cut was made
|
||||
ReadFiles []string // Files read during the compacted conversation
|
||||
ModifiedFiles []string // Files modified during the compacted conversation
|
||||
}
|
||||
|
||||
// CompactionOptions configures compaction behaviour. Token-based defaults
|
||||
@@ -130,8 +141,34 @@ Use this EXACT format:
|
||||
- [Any data, examples, or references needed to continue]
|
||||
- [Or "(none)" if not applicable]
|
||||
|
||||
<read-files>
|
||||
[One file path per line for files that were read during the conversation]
|
||||
</read-files>
|
||||
|
||||
<modified-files>
|
||||
[One file path per line for files that were created, edited, or written during the conversation]
|
||||
</modified-files>
|
||||
|
||||
Keep each section concise. Preserve exact file paths, function names, and error messages.`
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool result truncation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// maxToolResultChars is the maximum length of tool result text preserved
|
||||
// during serialisation. Longer results are truncated with a marker.
|
||||
const maxToolResultChars = 2000
|
||||
|
||||
// truncateToolResult truncates text to maxToolResultChars, appending a
|
||||
// marker indicating how many characters were removed.
|
||||
func truncateToolResult(text string) string {
|
||||
if len(text) <= maxToolResultChars {
|
||||
return text
|
||||
}
|
||||
truncated := len(text) - maxToolResultChars
|
||||
return text[:maxToolResultChars] + fmt.Sprintf("\n[...%d chars truncated]", truncated)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cut point (token-based)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -143,11 +180,26 @@ func isValidCutPoint(msg fantasy.Message) bool {
|
||||
return msg.Role != fantasy.MessageRoleTool
|
||||
}
|
||||
|
||||
// findTurnStart returns the index of the user message that starts the turn
|
||||
// containing messages[idx]. A "turn" starts with a user message and includes
|
||||
// all subsequent assistant/tool messages until the next user message.
|
||||
func findTurnStart(messages []fantasy.Message, idx int) int {
|
||||
for i := idx; i >= 0; i-- {
|
||||
if messages[i].Role == fantasy.MessageRoleUser {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// FindCutPoint walks backward from the end of messages, accumulating tokens
|
||||
// until the keepRecentTokens budget is filled. Returns the index that
|
||||
// separates "old" messages (0..cutPoint-1, to be summarised) from "recent"
|
||||
// messages (cutPoint..end, to be preserved).
|
||||
//
|
||||
// The cut point prefers turn boundaries (user messages). When a single turn
|
||||
// exceeds the budget, the cut lands mid-turn (IsSplitTurn returns true).
|
||||
//
|
||||
// Returns 0 if there are fewer than 2 messages or all messages fit within
|
||||
// the keep budget.
|
||||
func FindCutPoint(messages []fantasy.Message, keepRecentTokens int) int {
|
||||
@@ -193,6 +245,23 @@ func FindCutPoint(messages []fantasy.Message, keepRecentTokens int) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// IsSplitTurn returns true if the cut point lands in the middle of a turn
|
||||
// (i.e. the message at cutPoint is not a user message, meaning we're
|
||||
// splitting a single turn's assistant/tool messages).
|
||||
func IsSplitTurn(messages []fantasy.Message, cutPoint int) bool {
|
||||
if cutPoint <= 0 || cutPoint >= len(messages) {
|
||||
return false
|
||||
}
|
||||
// If the cut point is at a user message, it's a clean turn boundary.
|
||||
if messages[cutPoint].Role == fantasy.MessageRoleUser {
|
||||
return false
|
||||
}
|
||||
// Otherwise we're cutting mid-turn — check if the turn started before
|
||||
// the cut point.
|
||||
turnStart := findTurnStart(messages, cutPoint)
|
||||
return turnStart < cutPoint
|
||||
}
|
||||
|
||||
// forceCutPoint returns a cut point that keeps only the last non-tool
|
||||
// message, summarising everything before it. Used when the budget-based
|
||||
// FindCutPoint returns 0 but the caller wants to compact anyway (manual
|
||||
@@ -207,12 +276,104 @@ func forceCutPoint(messages []fantasy.Message) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File tracking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// fileOps contains cumulative file operation tracking.
|
||||
type fileOps struct {
|
||||
ReadFiles map[string]bool
|
||||
ModifiedFiles map[string]bool
|
||||
}
|
||||
|
||||
func newFileOps() *fileOps {
|
||||
return &fileOps{
|
||||
ReadFiles: make(map[string]bool),
|
||||
ModifiedFiles: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// extractFileOps scans messages for tool calls and extracts file paths.
|
||||
// It recognises the built-in Kit tools: read, write, edit, bash, grep, find, ls.
|
||||
func extractFileOps(messages []fantasy.Message) *fileOps {
|
||||
ops := newFileOps()
|
||||
for _, msg := range messages {
|
||||
for _, part := range msg.Content {
|
||||
tc, ok := part.(fantasy.ToolCallPart)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse the JSON input to extract path arguments.
|
||||
var args map[string]any
|
||||
if err := json.Unmarshal([]byte(tc.Input), &args); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
path, _ := args["path"].(string)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
switch tc.ToolName {
|
||||
case "read", "grep", "find", "ls":
|
||||
ops.ReadFiles[path] = true
|
||||
case "write", "edit":
|
||||
ops.ModifiedFiles[path] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
// merge combines another fileOps into this one (for cumulative tracking).
|
||||
func (f *fileOps) merge(other *fileOps) {
|
||||
if other == nil {
|
||||
return
|
||||
}
|
||||
for k := range other.ReadFiles {
|
||||
f.ReadFiles[k] = true
|
||||
}
|
||||
for k := range other.ModifiedFiles {
|
||||
f.ModifiedFiles[k] = true
|
||||
}
|
||||
}
|
||||
|
||||
// mergeSlices adds previously tracked file lists (from a prior compaction).
|
||||
func (f *fileOps) mergeSlices(readFiles, modifiedFiles []string) {
|
||||
for _, p := range readFiles {
|
||||
f.ReadFiles[p] = true
|
||||
}
|
||||
for _, p := range modifiedFiles {
|
||||
f.ModifiedFiles[p] = true
|
||||
}
|
||||
}
|
||||
|
||||
// sortedKeys returns the keys of a bool map sorted alphabetically.
|
||||
func sortedKeys(m map[string]bool) []string {
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
// Simple sort — no need for sort package for small lists.
|
||||
for i := 0; i < len(keys); i++ {
|
||||
for j := i + 1; j < len(keys); j++ {
|
||||
if keys[j] < keys[i] {
|
||||
keys[i], keys[j] = keys[j], keys[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message serialisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// roleLabel returns a human-readable label for a fantasy message role,
|
||||
|
||||
// roleLabel returns a human-readable label for a fantasy message role.
|
||||
func roleLabel(role fantasy.MessageRole) string {
|
||||
switch role {
|
||||
case fantasy.MessageRoleUser:
|
||||
@@ -229,16 +390,26 @@ 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
|
||||
|
||||
// representation suitable for sending to the summarisation LLM. Tool result
|
||||
// text is truncated to maxToolResultChars to keep the summarisation request
|
||||
// within reasonable token budgets.
|
||||
func serializeMessages(messages []fantasy.Message) string {
|
||||
var sb strings.Builder
|
||||
for _, msg := range messages {
|
||||
sb.WriteString(roleLabel(msg.Role))
|
||||
sb.WriteString(":\n")
|
||||
for _, part := range msg.Content {
|
||||
if tp, ok := part.(fantasy.TextPart); ok {
|
||||
sb.WriteString(tp.Text)
|
||||
switch p := part.(type) {
|
||||
case fantasy.TextPart:
|
||||
if msg.Role == fantasy.MessageRoleTool {
|
||||
sb.WriteString(truncateToolResult(p.Text))
|
||||
} else {
|
||||
sb.WriteString(p.Text)
|
||||
}
|
||||
case fantasy.ToolCallPart:
|
||||
fmt.Fprintf(&sb, "[Tool call: %s(%s)]", p.ToolName, truncateToolResult(p.Input))
|
||||
case fantasy.ReasoningPart:
|
||||
fmt.Fprintf(&sb, "[Thinking]: %s", truncateToolResult(p.Text))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n\n")
|
||||
@@ -250,6 +421,13 @@ func serializeMessages(messages []fantasy.Message) string {
|
||||
// Compact
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// PreviousCompaction carries file tracking state from a prior compaction so
|
||||
// that file operations accumulate across multiple compactions.
|
||||
type PreviousCompaction struct {
|
||||
ReadFiles []string
|
||||
ModifiedFiles []string
|
||||
}
|
||||
|
||||
// Compact summarises older messages using the LLM, returning the compaction
|
||||
// result and a new message slice (summary message + preserved recent
|
||||
// messages).
|
||||
@@ -261,12 +439,16 @@ func serializeMessages(messages []fantasy.Message) string {
|
||||
// customInstructions is optional text appended to the summary prompt (e.g.
|
||||
// "Focus on the API design decisions"). Pass "" to use the default prompt
|
||||
// only.
|
||||
//
|
||||
// prev carries file tracking from a previous compaction for cumulative
|
||||
// tracking. Pass nil if there is no prior compaction.
|
||||
func Compact(
|
||||
ctx context.Context,
|
||||
model fantasy.LanguageModel,
|
||||
messages []fantasy.Message,
|
||||
opts CompactionOptions,
|
||||
customInstructions string,
|
||||
prev *PreviousCompaction,
|
||||
) (*CompactionResult, []fantasy.Message, error) {
|
||||
opts.defaults()
|
||||
|
||||
@@ -289,30 +471,30 @@ func Compact(
|
||||
recentMessages := messages[cutPoint:]
|
||||
originalTokens := EstimateMessageTokens(messages)
|
||||
|
||||
// Serialise old messages to text.
|
||||
conversationText := serializeMessages(oldMessages)
|
||||
|
||||
// Build the user-facing prompt: conversation text + summary instructions.
|
||||
userPrompt := opts.SummaryPrompt
|
||||
if userPrompt == "" {
|
||||
userPrompt = defaultSummaryPrompt
|
||||
}
|
||||
if customInstructions != "" {
|
||||
userPrompt += "\n\nAdditional instructions: " + customInstructions
|
||||
// Extract file operations from old messages.
|
||||
ops := extractFileOps(oldMessages)
|
||||
// Accumulate from previous compaction if present.
|
||||
if prev != nil {
|
||||
ops.mergeSlices(prev.ReadFiles, prev.ModifiedFiles)
|
||||
}
|
||||
// Also scan recent messages for file ops (they'll be carried forward).
|
||||
recentOps := extractFileOps(recentMessages)
|
||||
ops.merge(recentOps)
|
||||
|
||||
// Create a lightweight agent (no tools) just for summarisation.
|
||||
summaryAgent := fantasy.NewAgent(model,
|
||||
fantasy.WithSystemPrompt(defaultSystemPrompt),
|
||||
)
|
||||
result, err := summaryAgent.Generate(ctx, fantasy.AgentCall{
|
||||
Prompt: conversationText + "\n\n" + userPrompt,
|
||||
})
|
||||
// Handle split turns: when the cut lands mid-turn, summarise the turn
|
||||
// prefix separately and merge with the history summary.
|
||||
var summaryText string
|
||||
var err error
|
||||
|
||||
if IsSplitTurn(messages, cutPoint) {
|
||||
summaryText, err = compactSplitTurn(ctx, model, oldMessages, messages, cutPoint, opts, customInstructions)
|
||||
} else {
|
||||
summaryText, err = compactNormal(ctx, model, oldMessages, opts, customInstructions)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("compaction summarisation failed: %w", err)
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
summaryText := result.Response.Content.Text()
|
||||
if summaryText == "" {
|
||||
return nil, nil, fmt.Errorf("compaction produced an empty summary")
|
||||
}
|
||||
@@ -338,5 +520,120 @@ func Compact(
|
||||
OriginalTokens: originalTokens,
|
||||
CompactedTokens: compactedTokens,
|
||||
MessagesRemoved: len(oldMessages),
|
||||
CutPoint: cutPoint,
|
||||
ReadFiles: sortedKeys(ops.ReadFiles),
|
||||
ModifiedFiles: sortedKeys(ops.ModifiedFiles),
|
||||
}, newMessages, nil
|
||||
}
|
||||
|
||||
// compactNormal generates a summary for a clean turn-boundary cut.
|
||||
func compactNormal(
|
||||
ctx context.Context,
|
||||
model fantasy.LanguageModel,
|
||||
oldMessages []fantasy.Message,
|
||||
opts CompactionOptions,
|
||||
customInstructions string,
|
||||
) (string, error) {
|
||||
conversationText := serializeMessages(oldMessages)
|
||||
return generateSummary(ctx, model, conversationText, opts, customInstructions)
|
||||
}
|
||||
|
||||
// compactSplitTurn handles the case where the cut point lands mid-turn.
|
||||
// It generates two summaries and merges them:
|
||||
// 1. History summary: all complete turns before the split turn
|
||||
// 2. Turn prefix summary: the early part of the split turn (from the turn's
|
||||
// user message up to the cut point)
|
||||
//
|
||||
// The merged result preserves context from both the older history and the
|
||||
// beginning of the current long turn.
|
||||
func compactSplitTurn(
|
||||
ctx context.Context,
|
||||
model fantasy.LanguageModel,
|
||||
oldMessages []fantasy.Message,
|
||||
allMessages []fantasy.Message,
|
||||
cutPoint int,
|
||||
opts CompactionOptions,
|
||||
customInstructions string,
|
||||
) (string, error) {
|
||||
// Find where the split turn starts.
|
||||
turnStart := findTurnStart(allMessages, cutPoint)
|
||||
|
||||
// Messages before the turn are the "history" portion.
|
||||
historyMessages := oldMessages
|
||||
if turnStart > 0 && turnStart < len(oldMessages) {
|
||||
historyMessages = oldMessages[:turnStart]
|
||||
}
|
||||
|
||||
// The turn prefix: from turnStart to cutPoint.
|
||||
turnPrefixMessages := allMessages[turnStart:cutPoint]
|
||||
|
||||
var historySummary string
|
||||
var err error
|
||||
|
||||
// Generate history summary if there are complete turns before the split.
|
||||
if len(historyMessages) >= 2 {
|
||||
historySummary, err = generateSummary(ctx, model,
|
||||
serializeMessages(historyMessages), opts, "")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("split turn history summary failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate turn prefix summary.
|
||||
turnPrefixText := serializeMessages(turnPrefixMessages)
|
||||
turnPrefixPrompt := "The messages above are the BEGINNING of a long turn that was split. " +
|
||||
"Summarize the work done so far in this turn, preserving tool call results, " +
|
||||
"file changes, and progress. Another LLM will continue this turn."
|
||||
if customInstructions != "" {
|
||||
turnPrefixPrompt += "\n\nAdditional instructions: " + customInstructions
|
||||
}
|
||||
|
||||
summaryAgent := fantasy.NewAgent(model,
|
||||
fantasy.WithSystemPrompt(defaultSystemPrompt),
|
||||
)
|
||||
result, err := summaryAgent.Generate(ctx, fantasy.AgentCall{
|
||||
Prompt: turnPrefixText + "\n\n" + turnPrefixPrompt,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("split turn prefix summary failed: %w", err)
|
||||
}
|
||||
turnPrefixSummary := result.Response.Content.Text()
|
||||
|
||||
// Merge the two summaries.
|
||||
if historySummary != "" && turnPrefixSummary != "" {
|
||||
return historySummary + "\n\n---\n\n## Current Turn (in progress)\n\n" + turnPrefixSummary, nil
|
||||
}
|
||||
if turnPrefixSummary != "" {
|
||||
return turnPrefixSummary, nil
|
||||
}
|
||||
return historySummary, nil
|
||||
}
|
||||
|
||||
// generateSummary calls the LLM to produce a structured summary.
|
||||
func generateSummary(
|
||||
ctx context.Context,
|
||||
model fantasy.LanguageModel,
|
||||
conversationText string,
|
||||
opts CompactionOptions,
|
||||
customInstructions string,
|
||||
) (string, error) {
|
||||
userPrompt := opts.SummaryPrompt
|
||||
if userPrompt == "" {
|
||||
userPrompt = defaultSummaryPrompt
|
||||
}
|
||||
if customInstructions != "" {
|
||||
userPrompt += "\n\nAdditional instructions: " + customInstructions
|
||||
}
|
||||
|
||||
summaryAgent := fantasy.NewAgent(model,
|
||||
fantasy.WithSystemPrompt(defaultSystemPrompt),
|
||||
)
|
||||
result, err := summaryAgent.Generate(ctx, fantasy.AgentCall{
|
||||
Prompt: conversationText + "\n\n" + userPrompt,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("compaction summarisation failed: %w", err)
|
||||
}
|
||||
|
||||
return result.Response.Content.Text(), nil
|
||||
}
|
||||
|
||||
@@ -36,9 +36,9 @@ func TestEstimateTokens(t *testing.T) {
|
||||
{"hello world", 2}, // 11 / 4 = 2
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := EstimateTokens(tt.text)
|
||||
got := estimateTokens(tt.text)
|
||||
if got != tt.want {
|
||||
t.Errorf("EstimateTokens(%q) = %d, want %d", tt.text, got, tt.want)
|
||||
t.Errorf("estimateTokens(%q) = %d, want %d", tt.text, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -243,7 +243,7 @@ func TestCompact_TooFewMessages(t *testing.T) {
|
||||
makeTextMessageN(fantasy.MessageRoleUser, 400),
|
||||
}
|
||||
|
||||
result, newMsgs, err := Compact(context.TODO(), nil, msgs, CompactionOptions{}, "")
|
||||
result, newMsgs, err := Compact(context.TODO(), nil, msgs, CompactionOptions{}, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -262,7 +262,7 @@ func TestCompact_WithinBudget(t *testing.T) {
|
||||
makeTextMessageN(fantasy.MessageRoleAssistant, 400),
|
||||
}
|
||||
|
||||
result, newMsgs, err := Compact(context.TODO(), nil, msgs, CompactionOptions{}, "")
|
||||
result, newMsgs, err := Compact(context.TODO(), nil, msgs, CompactionOptions{}, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -273,3 +273,169 @@ func TestCompact_WithinBudget(t *testing.T) {
|
||||
t.Errorf("messages changed: got %d, want %d", len(newMsgs), len(msgs))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool result truncation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestTruncateToolResult(t *testing.T) {
|
||||
// Short text — no truncation.
|
||||
short := strings.Repeat("x", 100)
|
||||
if got := truncateToolResult(short); got != short {
|
||||
t.Errorf("truncated short text unexpectedly")
|
||||
}
|
||||
|
||||
// Exactly at limit.
|
||||
exact := strings.Repeat("x", maxToolResultChars)
|
||||
if got := truncateToolResult(exact); got != exact {
|
||||
t.Errorf("truncated text at exact limit")
|
||||
}
|
||||
|
||||
// Over limit.
|
||||
over := strings.Repeat("x", maxToolResultChars+500)
|
||||
got := truncateToolResult(over)
|
||||
if len(got) > maxToolResultChars+50 { // allow room for marker
|
||||
t.Errorf("truncated text too long: %d chars", len(got))
|
||||
}
|
||||
if !strings.Contains(got, "500 chars truncated") {
|
||||
t.Errorf("truncation marker missing, got: %s", got[maxToolResultChars:])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSerializeMessages_TruncatesToolResults(t *testing.T) {
|
||||
longResult := strings.Repeat("R", maxToolResultChars+1000)
|
||||
msgs := []fantasy.Message{
|
||||
makeTextMessage(fantasy.MessageRoleUser, "question"),
|
||||
{
|
||||
Role: fantasy.MessageRoleTool,
|
||||
Content: []fantasy.MessagePart{fantasy.TextPart{Text: longResult}},
|
||||
},
|
||||
}
|
||||
|
||||
serialized := serializeMessages(msgs)
|
||||
if strings.Contains(serialized, longResult) {
|
||||
t.Error("tool result was not truncated during serialisation")
|
||||
}
|
||||
if !strings.Contains(serialized, "chars truncated") {
|
||||
t.Error("truncation marker missing in serialised output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSerializeMessages_PreservesNonToolText(t *testing.T) {
|
||||
longText := strings.Repeat("T", maxToolResultChars+1000)
|
||||
msgs := []fantasy.Message{
|
||||
makeTextMessage(fantasy.MessageRoleUser, longText),
|
||||
}
|
||||
|
||||
serialized := serializeMessages(msgs)
|
||||
if !strings.Contains(serialized, longText) {
|
||||
t.Error("non-tool text was unexpectedly truncated")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Split turn detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIsSplitTurn(t *testing.T) {
|
||||
msgs := []fantasy.Message{
|
||||
makeTextMessageN(fantasy.MessageRoleUser, 400), // 0: turn 1 user
|
||||
makeTextMessageN(fantasy.MessageRoleAssistant, 400), // 1: turn 1 assistant
|
||||
makeTextMessageN(fantasy.MessageRoleUser, 400), // 2: turn 2 user
|
||||
makeTextMessageN(fantasy.MessageRoleAssistant, 400), // 3: turn 2 assistant
|
||||
makeTextMessageN(fantasy.MessageRoleTool, 400), // 4: turn 2 tool result
|
||||
makeTextMessageN(fantasy.MessageRoleAssistant, 400), // 5: turn 2 assistant
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cutPoint int
|
||||
want bool
|
||||
}{
|
||||
{"at user message (turn boundary)", 2, false},
|
||||
{"at assistant mid-turn", 3, true},
|
||||
{"at assistant after tool (mid-turn)", 5, true},
|
||||
{"at 0 (no cut)", 0, false},
|
||||
{"beyond range", 10, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := IsSplitTurn(msgs, tt.cutPoint)
|
||||
if got != tt.want {
|
||||
t.Errorf("IsSplitTurn(msgs, %d) = %v, want %v", tt.cutPoint, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File operations extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestExtractFileOps(t *testing.T) {
|
||||
// Create messages with tool calls.
|
||||
msgs := []fantasy.Message{
|
||||
{
|
||||
Role: fantasy.MessageRoleAssistant,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.ToolCallPart{ToolCallID: "1", ToolName: "read", Input: `{"path":"src/main.go"}`},
|
||||
fantasy.ToolCallPart{ToolCallID: "2", ToolName: "write", Input: `{"path":"src/out.go"}`},
|
||||
fantasy.ToolCallPart{ToolCallID: "3", ToolName: "edit", Input: `{"path":"src/edit.go"}`},
|
||||
fantasy.ToolCallPart{ToolCallID: "4", ToolName: "grep", Input: `{"path":"src/search"}`},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ops := extractFileOps(msgs)
|
||||
if !ops.ReadFiles["src/main.go"] {
|
||||
t.Error("read file not tracked: src/main.go")
|
||||
}
|
||||
if !ops.ReadFiles["src/search"] {
|
||||
t.Error("grep path not tracked as read: src/search")
|
||||
}
|
||||
if !ops.ModifiedFiles["src/out.go"] {
|
||||
t.Error("write file not tracked: src/out.go")
|
||||
}
|
||||
if !ops.ModifiedFiles["src/edit.go"] {
|
||||
t.Error("edit file not tracked: src/edit.go")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileOps_MergeSlices(t *testing.T) {
|
||||
ops := newFileOps()
|
||||
ops.ReadFiles["a.go"] = true
|
||||
ops.ModifiedFiles["b.go"] = true
|
||||
|
||||
ops.mergeSlices(
|
||||
[]string{"c.go", "a.go"},
|
||||
[]string{"d.go"},
|
||||
)
|
||||
|
||||
if len(ops.ReadFiles) != 2 { // a.go, c.go
|
||||
t.Errorf("ReadFiles len = %d, want 2", len(ops.ReadFiles))
|
||||
}
|
||||
if len(ops.ModifiedFiles) != 2 { // b.go, d.go
|
||||
t.Errorf("ModifiedFiles len = %d, want 2", len(ops.ModifiedFiles))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortedKeys(t *testing.T) {
|
||||
m := map[string]bool{"c": true, "a": true, "b": true}
|
||||
got := sortedKeys(m)
|
||||
want := []string{"a", "b", "c"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("sortedKeys len = %d, want %d", len(got), len(want))
|
||||
}
|
||||
for i, v := range got {
|
||||
if v != want[i] {
|
||||
t.Errorf("sortedKeys[%d] = %q, want %q", i, v, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortedKeys_Empty(t *testing.T) {
|
||||
got := sortedKeys(nil)
|
||||
if got != nil {
|
||||
t.Errorf("sortedKeys(nil) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
+81
-36
@@ -105,42 +105,82 @@ type AdaptiveColor struct {
|
||||
Dark string `json:"dark,omitempty" yaml:"dark,omitempty"`
|
||||
}
|
||||
|
||||
// MarkdownThemeConfig defines color overrides for markdown rendering and
|
||||
// syntax highlighting.
|
||||
type MarkdownThemeConfig struct {
|
||||
Text AdaptiveColor `json:"text,omitzero" yaml:"text,omitempty"`
|
||||
Muted AdaptiveColor `json:"muted,omitzero" yaml:"muted,omitempty"`
|
||||
Heading AdaptiveColor `json:"heading,omitzero" yaml:"heading,omitempty"`
|
||||
Emph AdaptiveColor `json:"emph,omitzero" yaml:"emph,omitempty"`
|
||||
Strong AdaptiveColor `json:"strong,omitzero" yaml:"strong,omitempty"`
|
||||
Link AdaptiveColor `json:"link,omitzero" yaml:"link,omitempty"`
|
||||
Code AdaptiveColor `json:"code,omitzero" yaml:"code,omitempty"`
|
||||
Error AdaptiveColor `json:"error,omitzero" yaml:"error,omitempty"`
|
||||
Keyword AdaptiveColor `json:"keyword,omitzero" yaml:"keyword,omitempty"`
|
||||
String AdaptiveColor `json:"string,omitzero" yaml:"string,omitempty"`
|
||||
Number AdaptiveColor `json:"number,omitzero" yaml:"number,omitempty"`
|
||||
Comment AdaptiveColor `json:"comment,omitzero" yaml:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// Theme defines the color scheme for the application UI with adaptive colors
|
||||
// that support both light and dark modes.
|
||||
type Theme struct {
|
||||
Primary AdaptiveColor `json:"primary" yaml:"primary"`
|
||||
Secondary AdaptiveColor `json:"secondary" yaml:"secondary"`
|
||||
Success AdaptiveColor `json:"success" yaml:"success"`
|
||||
Warning AdaptiveColor `json:"warning" yaml:"warning"`
|
||||
Error AdaptiveColor `json:"error" yaml:"error"`
|
||||
Info AdaptiveColor `json:"info" yaml:"info"`
|
||||
Text AdaptiveColor `json:"text" yaml:"text"`
|
||||
Muted AdaptiveColor `json:"muted" yaml:"muted"`
|
||||
VeryMuted AdaptiveColor `json:"very-muted" yaml:"very-muted"`
|
||||
Background AdaptiveColor `json:"background" yaml:"background"`
|
||||
Border AdaptiveColor `json:"border" yaml:"border"`
|
||||
MutedBorder AdaptiveColor `json:"muted-border" yaml:"muted-border"`
|
||||
System AdaptiveColor `json:"system" yaml:"system"`
|
||||
Tool AdaptiveColor `json:"tool" yaml:"tool"`
|
||||
Accent AdaptiveColor `json:"accent" yaml:"accent"`
|
||||
Highlight AdaptiveColor `json:"highlight" yaml:"highlight"`
|
||||
Primary AdaptiveColor `json:"primary,omitzero" yaml:"primary,omitempty"`
|
||||
Secondary AdaptiveColor `json:"secondary,omitzero" yaml:"secondary,omitempty"`
|
||||
Success AdaptiveColor `json:"success,omitzero" yaml:"success,omitempty"`
|
||||
Warning AdaptiveColor `json:"warning,omitzero" yaml:"warning,omitempty"`
|
||||
Error AdaptiveColor `json:"error,omitzero" yaml:"error,omitempty"`
|
||||
Info AdaptiveColor `json:"info,omitzero" yaml:"info,omitempty"`
|
||||
Text AdaptiveColor `json:"text,omitzero" yaml:"text,omitempty"`
|
||||
Muted AdaptiveColor `json:"muted,omitzero" yaml:"muted,omitempty"`
|
||||
VeryMuted AdaptiveColor `json:"very-muted,omitzero" yaml:"very-muted,omitempty"`
|
||||
Background AdaptiveColor `json:"background,omitzero" yaml:"background,omitempty"`
|
||||
Border AdaptiveColor `json:"border,omitzero" yaml:"border,omitempty"`
|
||||
MutedBorder AdaptiveColor `json:"muted-border,omitzero" yaml:"muted-border,omitempty"`
|
||||
System AdaptiveColor `json:"system,omitzero" yaml:"system,omitempty"`
|
||||
Tool AdaptiveColor `json:"tool,omitzero" yaml:"tool,omitempty"`
|
||||
Accent AdaptiveColor `json:"accent,omitzero" yaml:"accent,omitempty"`
|
||||
Highlight AdaptiveColor `json:"highlight,omitzero" yaml:"highlight,omitempty"`
|
||||
|
||||
// Diff block backgrounds
|
||||
DiffInsertBg AdaptiveColor `json:"diff-insert-bg,omitzero" yaml:"diff-insert-bg,omitempty"`
|
||||
DiffDeleteBg AdaptiveColor `json:"diff-delete-bg,omitzero" yaml:"diff-delete-bg,omitempty"`
|
||||
DiffEqualBg AdaptiveColor `json:"diff-equal-bg,omitzero" yaml:"diff-equal-bg,omitempty"`
|
||||
DiffMissingBg AdaptiveColor `json:"diff-missing-bg,omitzero" yaml:"diff-missing-bg,omitempty"`
|
||||
|
||||
// Code/output block backgrounds
|
||||
CodeBg AdaptiveColor `json:"code-bg,omitzero" yaml:"code-bg,omitempty"`
|
||||
GutterBg AdaptiveColor `json:"gutter-bg,omitzero" yaml:"gutter-bg,omitempty"`
|
||||
WriteBg AdaptiveColor `json:"write-bg,omitzero" yaml:"write-bg,omitempty"`
|
||||
|
||||
// Markdown rendering and syntax highlighting
|
||||
Markdown MarkdownThemeConfig `json:"markdown,omitzero" yaml:"markdown,omitempty"`
|
||||
}
|
||||
|
||||
// MarkdownTheme defines the color scheme for markdown rendering with syntax
|
||||
// highlighting support and adaptive colors for light and dark modes.
|
||||
type MarkdownTheme struct {
|
||||
Text AdaptiveColor `json:"text" yaml:"text"`
|
||||
Muted AdaptiveColor `json:"muted" yaml:"muted"`
|
||||
Heading AdaptiveColor `json:"heading" yaml:"heading"`
|
||||
Emph AdaptiveColor `json:"emph" yaml:"emph"`
|
||||
Strong AdaptiveColor `json:"strong" yaml:"strong"`
|
||||
Link AdaptiveColor `json:"link" yaml:"link"`
|
||||
Code AdaptiveColor `json:"code" yaml:"code"`
|
||||
Error AdaptiveColor `json:"error" yaml:"error"`
|
||||
Keyword AdaptiveColor `json:"keyword" yaml:"keyword"`
|
||||
String AdaptiveColor `json:"string" yaml:"string"`
|
||||
Number AdaptiveColor `json:"number" yaml:"number"`
|
||||
Comment AdaptiveColor `json:"comment" yaml:"comment"`
|
||||
// CustomModelConfig defines a custom model that can be used with custom/custom
|
||||
// or other custom/ prefixed models. These models are loaded from the config file
|
||||
// and merged into the custom provider in the model registry.
|
||||
type CustomModelConfig struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Family string `json:"family,omitempty" yaml:"family,omitempty"`
|
||||
Attachment bool `json:"attachment,omitempty" yaml:"attachment,omitempty"`
|
||||
Reasoning bool `json:"reasoning,omitempty" yaml:"reasoning,omitempty"`
|
||||
Temperature bool `json:"temperature,omitempty" yaml:"temperature,omitempty"`
|
||||
Knowledge string `json:"knowledge,omitempty" yaml:"knowledge,omitempty"`
|
||||
Cost CostConfig `json:"cost" yaml:"cost"`
|
||||
Limit LimitConfig `json:"limit" yaml:"limit"`
|
||||
}
|
||||
|
||||
// CostConfig defines the pricing for a custom model.
|
||||
type CostConfig struct {
|
||||
Input float64 `json:"input" yaml:"input"`
|
||||
Output float64 `json:"output" yaml:"output"`
|
||||
}
|
||||
|
||||
// LimitConfig defines context and output limits for a custom model.
|
||||
type LimitConfig struct {
|
||||
Context int `json:"context" yaml:"context"`
|
||||
Output int `json:"output" yaml:"output"`
|
||||
}
|
||||
|
||||
// Config represents the complete application configuration including MCP servers,
|
||||
@@ -157,7 +197,6 @@ type Config struct {
|
||||
ProviderURL string `json:"provider-url,omitempty" yaml:"provider-url,omitempty"`
|
||||
Stream *bool `json:"stream,omitempty" yaml:"stream,omitempty"`
|
||||
Theme any `json:"theme" yaml:"theme"`
|
||||
MarkdownTheme any `json:"markdown-theme" yaml:"markdown-theme"`
|
||||
// Model generation parameters
|
||||
MaxTokens int `json:"max-tokens,omitempty" yaml:"max-tokens,omitempty"`
|
||||
Temperature *float32 `json:"temperature,omitempty" yaml:"temperature,omitempty"`
|
||||
@@ -170,6 +209,13 @@ type Config struct {
|
||||
|
||||
// TLS configuration
|
||||
TLSSkipVerify bool `json:"tls-skip-verify,omitempty" yaml:"tls-skip-verify,omitempty"`
|
||||
|
||||
// Prompt templates configuration
|
||||
Prompts []string `json:"prompts,omitempty" yaml:"prompts,omitempty"`
|
||||
NoPromptTemplates bool `json:"no-prompt-templates,omitempty" yaml:"no-prompt-templates,omitempty"`
|
||||
|
||||
// Custom model definitions (under custom/ provider)
|
||||
CustomModels map[string]CustomModelConfig `json:"customModels,omitempty" yaml:"customModels,omitempty"`
|
||||
}
|
||||
|
||||
// GetTransportType returns the transport type for the server config, mapping
|
||||
@@ -373,11 +419,10 @@ func FilepathOr[T any](key string, value *T) error {
|
||||
fmt.Fprintf(os.Stderr, "%q", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if filepath.Ext(absPath) == ".json" {
|
||||
switch filepath.Ext(absPath) {
|
||||
case ".json":
|
||||
return json.Unmarshal(b, value)
|
||||
}
|
||||
|
||||
if filepath.Ext(absPath) == ".yaml" {
|
||||
case ".yaml", ".yml":
|
||||
return yaml.Unmarshal(b, value)
|
||||
}
|
||||
}
|
||||
|
||||
+169
-11
@@ -1,16 +1,41 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
)
|
||||
|
||||
// ToolOutputCallback is the signature for streaming tool output.
|
||||
// It receives tool call ID, tool name, output chunk, and whether it's stderr.
|
||||
type ToolOutputCallback func(toolCallID, toolName, chunk string, isStderr bool)
|
||||
|
||||
// contextKey is a custom type for context keys to avoid collisions.
|
||||
type contextKey string
|
||||
|
||||
const toolOutputCallbackKey contextKey = "toolOutputCallback"
|
||||
|
||||
// ContextWithToolOutputCallback returns a new context with the tool output callback set.
|
||||
func ContextWithToolOutputCallback(ctx context.Context, callback ToolOutputCallback) context.Context {
|
||||
return context.WithValue(ctx, toolOutputCallbackKey, callback)
|
||||
}
|
||||
|
||||
// toolOutputCallbackFromContext retrieves the tool output callback from context.
|
||||
func toolOutputCallbackFromContext(ctx context.Context) ToolOutputCallback {
|
||||
if cb, ok := ctx.Value(toolOutputCallbackKey).(ToolOutputCallback); ok {
|
||||
return cb
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const defaultBashTimeout = 120 * time.Second
|
||||
const maxBashTimeout = 600 * time.Second
|
||||
|
||||
@@ -90,32 +115,165 @@ func executeBash(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
|
||||
cmd.Dir = workDir
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
// Ensure SHELL is set to bash so child processes (e.g. tmux) use bash
|
||||
// rather than the user's login shell (which may be nushell, fish, etc.).
|
||||
bashPath, err := exec.LookPath("bash")
|
||||
if err != nil {
|
||||
bashPath = "/bin/bash"
|
||||
}
|
||||
cmd.Env = append(os.Environ(), "SHELL="+bashPath)
|
||||
|
||||
err := cmd.Run()
|
||||
// Get the output callback if present (for streaming support)
|
||||
outputCallback := toolOutputCallbackFromContext(ctx)
|
||||
|
||||
if outputCallback != nil {
|
||||
// Streaming mode: use pipes to capture output as it arrives
|
||||
return executeBashStreaming(cmdCtx, call, cmd, outputCallback)
|
||||
}
|
||||
|
||||
// Non-streaming mode: collect all output at once (original behavior)
|
||||
return executeBashBuffered(cmdCtx, call, cmd)
|
||||
}
|
||||
|
||||
// executeBashBuffered collects all output before returning (original behavior).
|
||||
// It uses explicit pipes (not cmd.Stdout) so that cmd.WaitDelay can forcibly
|
||||
// close them when grandchild processes hold pipe handles open after the
|
||||
// direct child exits.
|
||||
func executeBashBuffered(cmdCtx context.Context, call fantasy.ToolCall, cmd *exec.Cmd) (fantasy.ToolResponse, error) {
|
||||
stdoutPipe, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse("failed to create stdout pipe"), nil
|
||||
}
|
||||
stderrPipe, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse("failed to create stderr pipe"), nil
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to start command: %v", err)), nil
|
||||
}
|
||||
|
||||
// Read pipes concurrently
|
||||
var wg sync.WaitGroup
|
||||
var stdout, stderr strings.Builder
|
||||
var stdoutErr, stderrErr error
|
||||
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, stdoutErr = io.Copy(&stdout, stdoutPipe)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, stderrErr = io.Copy(&stderr, stderrPipe)
|
||||
}()
|
||||
|
||||
// Wait for the process to exit first. cmd.WaitDelay ensures that if
|
||||
// pipes remain open (held by grandchild processes), they'll be forcibly
|
||||
// closed after the grace period, which unblocks the io.Copy goroutines.
|
||||
waitErr := cmd.Wait()
|
||||
|
||||
// Wait for pipe readers to finish draining.
|
||||
wg.Wait()
|
||||
|
||||
// Ignore pipe read errors caused by WaitDelay force-closing —
|
||||
// we still have whatever was read before the close.
|
||||
_ = stdoutErr
|
||||
_ = stderrErr
|
||||
|
||||
exitCode := 0
|
||||
if waitErr != nil {
|
||||
if exitErr, ok := waitErr.(*exec.ExitError); ok {
|
||||
exitCode = exitErr.ExitCode()
|
||||
} else if cmdCtx.Err() == context.DeadlineExceeded {
|
||||
return fantasy.NewTextErrorResponse("command timed out"), nil
|
||||
}
|
||||
}
|
||||
|
||||
return buildBashResponse(stdout.String(), stderr.String(), exitCode)
|
||||
}
|
||||
|
||||
// executeBashStreaming streams output as it arrives via the callback.
|
||||
func executeBashStreaming(cmdCtx context.Context, call fantasy.ToolCall, cmd *exec.Cmd, outputCallback ToolOutputCallback) (fantasy.ToolResponse, error) {
|
||||
stdoutPipe, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse("failed to create stdout pipe"), nil
|
||||
}
|
||||
stderrPipe, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse("failed to create stderr pipe"), nil
|
||||
}
|
||||
|
||||
// Start command execution
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to start command: %v", err)), nil
|
||||
}
|
||||
|
||||
// Stream stdout and stderr concurrently
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
var stdoutChunks, stderrChunks []string
|
||||
|
||||
streamOutput := func(reader io.Reader, isStderr bool) {
|
||||
defer wg.Done()
|
||||
scanner := bufio.NewScanner(reader)
|
||||
// Use larger buffer for long lines
|
||||
buf := make([]byte, 0, 64*1024)
|
||||
scanner.Buffer(buf, 1024*1024)
|
||||
|
||||
for scanner.Scan() {
|
||||
chunk := scanner.Text()
|
||||
// Send chunk to UI
|
||||
outputCallback(call.ID, "bash", chunk, isStderr)
|
||||
// Collect for final result
|
||||
mu.Lock()
|
||||
if isStderr {
|
||||
stderrChunks = append(stderrChunks, chunk)
|
||||
} else {
|
||||
stdoutChunks = append(stdoutChunks, chunk)
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
wg.Add(2)
|
||||
go streamOutput(stdoutPipe, false)
|
||||
go streamOutput(stderrPipe, true)
|
||||
|
||||
// Wait for the process to exit. cmd.WaitDelay ensures that if pipes
|
||||
// remain open (held by grandchild processes), they'll be forcibly closed
|
||||
// after the grace period, which unblocks the scanners above.
|
||||
err = cmd.Wait()
|
||||
|
||||
// Wait for the pipe readers to finish draining. This will complete
|
||||
// quickly since cmd.Wait() (with WaitDelay) has already ensured
|
||||
// the pipes are closed.
|
||||
wg.Wait()
|
||||
|
||||
exitCode := 0
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = exitErr.ExitCode()
|
||||
} else if cmdCtx.Err() == context.DeadlineExceeded {
|
||||
return fantasy.NewTextErrorResponse(fmt.Sprintf("command timed out after %v", timeout)), nil
|
||||
return fantasy.NewTextErrorResponse("command timed out"), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Build result
|
||||
return buildBashResponse(strings.Join(stdoutChunks, "\n"), strings.Join(stderrChunks, "\n"), exitCode)
|
||||
}
|
||||
|
||||
// buildBashResponse constructs the final tool response from stdout/stderr.
|
||||
func buildBashResponse(stdout, stderr string, exitCode int) (fantasy.ToolResponse, error) {
|
||||
var result strings.Builder
|
||||
if stdout.Len() > 0 {
|
||||
result.WriteString(stdout.String())
|
||||
if stdout != "" {
|
||||
result.WriteString(stdout)
|
||||
}
|
||||
if stderr.Len() > 0 {
|
||||
if stderr != "" {
|
||||
if result.Len() > 0 {
|
||||
result.WriteString("\n")
|
||||
}
|
||||
result.WriteString("STDERR:\n")
|
||||
result.WriteString(stderr.String())
|
||||
result.WriteString(stderr)
|
||||
}
|
||||
if exitCode != 0 {
|
||||
if result.Len() > 0 {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
)
|
||||
|
||||
// helper to create a bash tool call with the given command and optional timeout.
|
||||
func bashCall(command string, timeout float64) fantasy.ToolCall {
|
||||
args := map[string]any{"command": command}
|
||||
if timeout > 0 {
|
||||
args["timeout"] = timeout
|
||||
}
|
||||
input, _ := json.Marshal(args)
|
||||
return fantasy.ToolCall{
|
||||
ID: "test-call",
|
||||
Name: "bash",
|
||||
Input: string(input),
|
||||
}
|
||||
}
|
||||
|
||||
func TestBash_SimpleCommand(t *testing.T) {
|
||||
resp, err := executeBash(context.Background(), bashCall("echo hello", 0), "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.IsError {
|
||||
t.Fatalf("expected success, got error: %s", resp.Content)
|
||||
}
|
||||
if resp.Content != "hello\n" {
|
||||
t.Errorf("expected 'hello\\n', got %q", resp.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBash_TimeoutKillsProcess(t *testing.T) {
|
||||
start := time.Now()
|
||||
resp, err := executeBash(context.Background(), bashCall("sleep 60", 2), "")
|
||||
elapsed := time.Since(start)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !resp.IsError {
|
||||
t.Fatal("expected error response for timed-out command")
|
||||
}
|
||||
if elapsed > 10*time.Second {
|
||||
t.Errorf("command took %v, expected ~2s timeout", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBash_BackgroundProcessDoesNotHang(t *testing.T) {
|
||||
// This command spawns a background sleep that would hold pipes open
|
||||
// forever if we didn't have process group killing + WaitDelay.
|
||||
start := time.Now()
|
||||
resp, err := executeBash(context.Background(), bashCall("echo done; sleep 3600 &", 5), "")
|
||||
elapsed := time.Since(start)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// The foreground command (echo) should complete quickly
|
||||
if elapsed > 5*time.Second {
|
||||
t.Errorf("command took %v, should complete in <5s (background process should not block)", elapsed)
|
||||
}
|
||||
if resp.IsError {
|
||||
t.Fatalf("expected success, got error: %s", resp.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBash_BackgroundProcessDoesNotHang_Streaming(t *testing.T) {
|
||||
// Same test but in streaming mode (with output callback).
|
||||
ctx := ContextWithToolOutputCallback(context.Background(), func(_, _, _ string, _ bool) {})
|
||||
start := time.Now()
|
||||
resp, err := executeBash(ctx, bashCall("echo streaming; sleep 3600 &", 5), "")
|
||||
elapsed := time.Since(start)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if elapsed > 5*time.Second {
|
||||
t.Errorf("streaming command took %v, should complete in <5s", elapsed)
|
||||
}
|
||||
if resp.IsError {
|
||||
t.Fatalf("expected success, got error: %s", resp.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBash_ContextCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
_, _ = executeBash(ctx, bashCall("sleep 60", 0), "")
|
||||
}()
|
||||
|
||||
// Cancel after a short delay
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
// Should return promptly after cancellation
|
||||
select {
|
||||
case <-done:
|
||||
// success
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("executeBash did not return after context cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBash_BannedCommand(t *testing.T) {
|
||||
resp, err := executeBash(context.Background(), bashCall("alias foo=bar", 0), "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !resp.IsError {
|
||||
t.Fatal("expected error for banned command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBash_EmptyCommand(t *testing.T) {
|
||||
resp, err := executeBash(context.Background(), bashCall("", 0), "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !resp.IsError {
|
||||
t.Fatal("expected error for empty command")
|
||||
}
|
||||
}
|
||||
+94
-86
@@ -6,8 +6,11 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"charm.land/fantasy"
|
||||
|
||||
udiff "github.com/aymanbagabas/go-udiff"
|
||||
)
|
||||
|
||||
type editArgs struct {
|
||||
@@ -82,7 +85,7 @@ func executeEdit(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
|
||||
if err := os.WriteFile(absPath, []byte(newContent), 0644); err != nil {
|
||||
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to write file: %v", err)), nil
|
||||
}
|
||||
diff := generateDiff(absPath, normalized, newContent, idx)
|
||||
diff := generateDiff(absPath, normalized, newContent)
|
||||
resp := fantasy.NewTextResponse(fmt.Sprintf("Applied edit (fuzzy match) to %s\n%s", args.Path, diff))
|
||||
return fantasy.WithResponseMetadata(resp, editDiffMeta(absPath, matchedText, args.NewText)), nil
|
||||
}
|
||||
@@ -100,8 +103,7 @@ func executeEdit(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
|
||||
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to write file: %v", err)), nil
|
||||
}
|
||||
|
||||
idx := strings.Index(normalized, normalizedOld)
|
||||
diff := generateDiff(absPath, normalized, newContent, idx)
|
||||
diff := generateDiff(absPath, normalized, newContent)
|
||||
resp := fantasy.NewTextResponse(fmt.Sprintf("Applied edit to %s\n%s", args.Path, diff))
|
||||
return fantasy.WithResponseMetadata(resp, editDiffMeta(absPath, normalizedOld, args.NewText)), nil
|
||||
}
|
||||
@@ -122,102 +124,108 @@ func editDiffMeta(path, oldText, newText string) map[string]any {
|
||||
}
|
||||
|
||||
// fuzzyMatch tries to find old_text with relaxed matching:
|
||||
// - Strips trailing whitespace per line
|
||||
// - Normalizes unicode quotes to ASCII
|
||||
// - Normalizes unicode dashes/spaces
|
||||
// Returns (index, matchLength) or (-1, 0) if not found.
|
||||
// - Strips trailing whitespace per line
|
||||
// - Normalizes unicode quotes to ASCII
|
||||
// - Normalizes unicode dashes/spaces
|
||||
//
|
||||
// Returns (index, matchLength) in the original content, or (-1, 0) if not
|
||||
// found or ambiguous (multiple matches).
|
||||
func fuzzyMatch(content, search string) (int, int) {
|
||||
normalizedContent := normalizeForFuzzy(content)
|
||||
normalizedSearch := normalizeForFuzzy(search)
|
||||
normContent, contentMap := normalizeWithMap(content)
|
||||
normSearch := normalizeForFuzzy(search)
|
||||
|
||||
idx := strings.Index(normalizedContent, normalizedSearch)
|
||||
if normSearch == "" {
|
||||
return -1, 0
|
||||
}
|
||||
|
||||
idx := strings.Index(normContent, normSearch)
|
||||
if idx < 0 {
|
||||
return -1, 0
|
||||
}
|
||||
|
||||
// Map back to original content position
|
||||
// Since normalization can change lengths, we need to find the
|
||||
// corresponding region in the original content
|
||||
origIdx := mapFuzzyIndex(content, normalizedContent, idx)
|
||||
origEnd := mapFuzzyIndex(content, normalizedContent, idx+len(normalizedSearch))
|
||||
// Reject ambiguous matches — if there are multiple fuzzy matches
|
||||
// we can't safely pick one.
|
||||
if strings.Count(normContent, normSearch) > 1 {
|
||||
return -1, 0
|
||||
}
|
||||
|
||||
return origIdx, origEnd - origIdx
|
||||
// Map normalized byte positions back to original byte positions.
|
||||
origStart := contentMap[idx]
|
||||
endNorm := idx + len(normSearch)
|
||||
var origEnd int
|
||||
if endNorm >= len(normContent) {
|
||||
origEnd = len(content)
|
||||
} else {
|
||||
origEnd = contentMap[endNorm]
|
||||
}
|
||||
|
||||
return origStart, origEnd - origStart
|
||||
}
|
||||
|
||||
func normalizeForFuzzy(s string) string {
|
||||
// Strip trailing whitespace per line
|
||||
// normalizeWithMap normalizes s for fuzzy matching and returns both the
|
||||
// normalized string and a byte-position mapping where mapping[i] is the
|
||||
// original byte position corresponding to normalized byte position i.
|
||||
//
|
||||
// Normalization: trim trailing whitespace per line, replace unicode
|
||||
// quotes/dashes/spaces with their ASCII equivalents.
|
||||
func normalizeWithMap(s string) (string, []int) {
|
||||
var result []byte
|
||||
var mapping []int // mapping[i] = original byte position for result byte i
|
||||
|
||||
lines := strings.Split(s, "\n")
|
||||
for i, line := range lines {
|
||||
lines[i] = strings.TrimRightFunc(line, unicode.IsSpace)
|
||||
}
|
||||
result := strings.Join(lines, "\n")
|
||||
|
||||
// Normalize smart quotes
|
||||
replacer := strings.NewReplacer(
|
||||
"\u201c", "\"", // left double quote
|
||||
"\u201d", "\"", // right double quote
|
||||
"\u2018", "'", // left single quote
|
||||
"\u2019", "'", // right single quote
|
||||
"\u2013", "-", // en dash
|
||||
"\u2014", "-", // em dash
|
||||
"\u00a0", " ", // non-breaking space
|
||||
)
|
||||
return replacer.Replace(result)
|
||||
}
|
||||
|
||||
func mapFuzzyIndex(original, normalized string, normIdx int) int {
|
||||
// Simple approach: count runes up to normIdx in normalized,
|
||||
// then advance that many runes in original.
|
||||
// This works because our normalization only replaces runes 1:1.
|
||||
origRunes := []rune(original)
|
||||
normRunes := []rune(normalized)
|
||||
|
||||
if normIdx >= len(normRunes) {
|
||||
return len(original)
|
||||
}
|
||||
|
||||
// Count bytes for the first normIdx runes in original
|
||||
byteCount := 0
|
||||
for i := 0; i < normIdx && i < len(origRunes); i++ {
|
||||
byteCount += len(string(origRunes[i]))
|
||||
}
|
||||
return byteCount
|
||||
}
|
||||
|
||||
// generateDiff creates a simple unified diff showing the change.
|
||||
func generateDiff(path, old, new string, changeIdx int) string {
|
||||
oldLines := strings.Split(old, "\n")
|
||||
newLines := strings.Split(new, "\n")
|
||||
|
||||
// Find the line number where the change starts
|
||||
lineNum := strings.Count(old[:changeIdx], "\n") + 1
|
||||
|
||||
// Show context around the change
|
||||
contextLines := 3
|
||||
start := max(lineNum-contextLines-1, 0)
|
||||
|
||||
var diff strings.Builder
|
||||
fmt.Fprintf(&diff, "--- %s\n+++ %s\n", path, path)
|
||||
|
||||
// Find changed region
|
||||
endOld := min(lineNum+contextLines+countNewlines(old[changeIdx:])+1, len(oldLines))
|
||||
endNew := min(lineNum+contextLines+countNewlines(new[changeIdx:])+1, len(newLines))
|
||||
|
||||
fmt.Fprintf(&diff, "@@ -%d,%d +%d,%d @@\n", start+1, endOld-start, start+1, endNew-start)
|
||||
|
||||
// Very simplified diff: show old lines as removed, new lines as added
|
||||
// around the change region
|
||||
for i := start; i < endOld && i < len(oldLines); i++ {
|
||||
prefix := " "
|
||||
if i >= lineNum-1 && i < lineNum-1+countNewlines(old[changeIdx:])+1 {
|
||||
prefix = "-"
|
||||
origPos := 0
|
||||
for li, line := range lines {
|
||||
if li > 0 {
|
||||
result = append(result, '\n')
|
||||
mapping = append(mapping, origPos)
|
||||
origPos++ // skip \n in original
|
||||
}
|
||||
fmt.Fprintf(&diff, "%s %s\n", prefix, oldLines[i])
|
||||
|
||||
trimmed := strings.TrimRightFunc(line, unicode.IsSpace)
|
||||
|
||||
for j := 0; j < len(trimmed); {
|
||||
r, size := utf8.DecodeRuneInString(trimmed[j:])
|
||||
repl := normalizeRune(r)
|
||||
for k := 0; k < len(repl); k++ {
|
||||
mapping = append(mapping, origPos+j)
|
||||
}
|
||||
result = append(result, repl...)
|
||||
j += size
|
||||
}
|
||||
|
||||
origPos += len(line) // advance past full original line including trailing ws
|
||||
}
|
||||
|
||||
return diff.String()
|
||||
return string(result), mapping
|
||||
}
|
||||
|
||||
func countNewlines(s string) int {
|
||||
return strings.Count(s, "\n")
|
||||
// normalizeRune maps unicode quotes, dashes, and non-breaking spaces to
|
||||
// their ASCII equivalents. Returns the original rune as a string for all
|
||||
// other characters.
|
||||
func normalizeRune(r rune) string {
|
||||
switch r {
|
||||
case '\u201c', '\u201d': // left/right double quote
|
||||
return "\""
|
||||
case '\u2018', '\u2019': // left/right single quote
|
||||
return "'"
|
||||
case '\u2013', '\u2014': // en dash, em dash
|
||||
return "-"
|
||||
case '\u00a0': // non-breaking space
|
||||
return " "
|
||||
default:
|
||||
return string(r)
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeForFuzzy normalizes s for fuzzy matching (without position mapping).
|
||||
// Used for the search string where position mapping is not needed.
|
||||
func normalizeForFuzzy(s string) string {
|
||||
norm, _ := normalizeWithMap(s)
|
||||
return norm
|
||||
}
|
||||
|
||||
// generateDiff creates a unified diff showing the change between old and new
|
||||
// file contents. Uses the go-udiff library for correct diff computation.
|
||||
func generateDiff(path, old, new string) string {
|
||||
return udiff.Unified(path, path, old, new)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,717 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
)
|
||||
|
||||
func writeFileOrFail(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("failed to write test file %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fuzzyMatch — the core bug fix
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestFuzzyMatch_TrailingWhitespace(t *testing.T) {
|
||||
// The original bug: trailing whitespace on lines caused mapFuzzyIndex
|
||||
// to return wrong byte positions, corrupting the replacement splice.
|
||||
content := "line1 \nline2 \nline3 \nTAIL\n"
|
||||
search := "line2\nline3"
|
||||
|
||||
idx, matchLen := fuzzyMatch(content, search)
|
||||
if idx < 0 {
|
||||
t.Fatal("expected fuzzy match, got none")
|
||||
}
|
||||
|
||||
matched := content[idx : idx+matchLen]
|
||||
want := "line2 \nline3 "
|
||||
if matched != want {
|
||||
t.Errorf("matched=%q, want=%q", matched, want)
|
||||
}
|
||||
|
||||
// Verify replacement is correct
|
||||
repl := content[:idx] + "REPLACED" + content[idx+matchLen:]
|
||||
wantRepl := "line1 \nREPLACED\nTAIL\n"
|
||||
if repl != wantRepl {
|
||||
t.Errorf("replacement=%q, want=%q", repl, wantRepl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFuzzyMatch_TrailingWhitespace_FirstLine(t *testing.T) {
|
||||
content := "line1 \nline2 \nline3\n"
|
||||
search := "line1\nline2"
|
||||
|
||||
idx, matchLen := fuzzyMatch(content, search)
|
||||
if idx < 0 {
|
||||
t.Fatal("expected fuzzy match")
|
||||
}
|
||||
|
||||
matched := content[idx : idx+matchLen]
|
||||
want := "line1 \nline2 "
|
||||
if matched != want {
|
||||
t.Errorf("matched=%q, want=%q", matched, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFuzzyMatch_TrailingWhitespace_LastLine(t *testing.T) {
|
||||
content := "HEAD\nline1 \nline2 \n"
|
||||
search := "line1\nline2"
|
||||
|
||||
idx, matchLen := fuzzyMatch(content, search)
|
||||
if idx < 0 {
|
||||
t.Fatal("expected fuzzy match")
|
||||
}
|
||||
|
||||
matched := content[idx : idx+matchLen]
|
||||
want := "line1 \nline2 "
|
||||
if matched != want {
|
||||
t.Errorf("matched=%q, want=%q", matched, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFuzzyMatch_TrailingWhitespace_AtEOF(t *testing.T) {
|
||||
// Match extends to the very end of the content
|
||||
content := "HEAD\nline1 \nline2 "
|
||||
search := "line1\nline2"
|
||||
|
||||
idx, matchLen := fuzzyMatch(content, search)
|
||||
if idx < 0 {
|
||||
t.Fatal("expected fuzzy match")
|
||||
}
|
||||
|
||||
matched := content[idx : idx+matchLen]
|
||||
want := "line1 \nline2 "
|
||||
if matched != want {
|
||||
t.Errorf("matched=%q, want=%q", matched, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFuzzyMatch_UnicodeQuotes(t *testing.T) {
|
||||
content := "say \u201chello\u201d\n"
|
||||
search := "say \"hello\"\n"
|
||||
|
||||
idx, matchLen := fuzzyMatch(content, search)
|
||||
if idx < 0 {
|
||||
t.Fatal("expected fuzzy match for unicode quotes")
|
||||
}
|
||||
|
||||
matched := content[idx : idx+matchLen]
|
||||
if matched != content { // entire content should match
|
||||
t.Errorf("matched=%q, want=%q", matched, content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFuzzyMatch_SmartSingleQuotes(t *testing.T) {
|
||||
content := "it\u2019s a test\n"
|
||||
search := "it's a test\n"
|
||||
|
||||
idx, matchLen := fuzzyMatch(content, search)
|
||||
if idx < 0 {
|
||||
t.Fatal("expected fuzzy match for smart single quotes")
|
||||
}
|
||||
matched := content[idx : idx+matchLen]
|
||||
if matched != content {
|
||||
t.Errorf("matched=%q, want=%q", matched, content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFuzzyMatch_EmDash(t *testing.T) {
|
||||
content := "foo \u2014 bar\n"
|
||||
search := "foo - bar\n"
|
||||
|
||||
idx, matchLen := fuzzyMatch(content, search)
|
||||
if idx < 0 {
|
||||
t.Fatal("expected fuzzy match for em dash")
|
||||
}
|
||||
matched := content[idx : idx+matchLen]
|
||||
if matched != content {
|
||||
t.Errorf("matched=%q, want=%q", matched, content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFuzzyMatch_NonBreakingSpace(t *testing.T) {
|
||||
content := "hello\u00a0world\n"
|
||||
search := "hello world\n"
|
||||
|
||||
idx, matchLen := fuzzyMatch(content, search)
|
||||
if idx < 0 {
|
||||
t.Fatal("expected fuzzy match for non-breaking space")
|
||||
}
|
||||
matched := content[idx : idx+matchLen]
|
||||
if matched != content {
|
||||
t.Errorf("matched=%q, want=%q", matched, content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFuzzyMatch_NoMatch(t *testing.T) {
|
||||
content := "hello world\n"
|
||||
search := "goodbye world\n"
|
||||
|
||||
idx, _ := fuzzyMatch(content, search)
|
||||
if idx >= 0 {
|
||||
t.Error("expected no match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFuzzyMatch_AmbiguousReturnsNoMatch(t *testing.T) {
|
||||
// Two identical blocks — fuzzy match should refuse to pick one
|
||||
content := "block\nblock\n"
|
||||
search := "block"
|
||||
|
||||
idx, _ := fuzzyMatch(content, search)
|
||||
if idx >= 0 {
|
||||
t.Error("expected no match for ambiguous fuzzy hit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFuzzyMatch_EmptySearch(t *testing.T) {
|
||||
idx, _ := fuzzyMatch("content", "")
|
||||
if idx >= 0 {
|
||||
t.Error("expected no match for empty search")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFuzzyMatch_MultiLineWithMixedWhitespace(t *testing.T) {
|
||||
content := "func foo() {\t \n\treturn 1 \n}\t \n"
|
||||
search := "func foo() {\n\treturn 1\n}"
|
||||
|
||||
idx, matchLen := fuzzyMatch(content, search)
|
||||
if idx < 0 {
|
||||
t.Fatal("expected fuzzy match")
|
||||
}
|
||||
|
||||
// Replacement should preserve surrounding content
|
||||
repl := content[:idx] + "func bar() {\n\treturn 2\n}" + content[idx+matchLen:]
|
||||
if !strings.HasPrefix(repl, "func bar()") {
|
||||
t.Errorf("unexpected replacement start: %q", repl[:20])
|
||||
}
|
||||
if !strings.HasSuffix(repl, "\n") {
|
||||
t.Errorf("replacement should end with newline: %q", repl)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// normalizeWithMap — position mapping correctness
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNormalizeWithMap_NoTrailingWhitespace(t *testing.T) {
|
||||
s := "abc\ndef"
|
||||
norm, mapping := normalizeWithMap(s)
|
||||
if norm != s {
|
||||
t.Errorf("norm=%q, want=%q", norm, s)
|
||||
}
|
||||
// Each byte should map to itself
|
||||
for i, orig := range mapping {
|
||||
if orig != i {
|
||||
t.Errorf("mapping[%d]=%d, want=%d", i, orig, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeWithMap_TrailingWhitespace(t *testing.T) {
|
||||
s := "ab \ncd"
|
||||
norm, mapping := normalizeWithMap(s)
|
||||
wantNorm := "ab\ncd"
|
||||
if norm != wantNorm {
|
||||
t.Errorf("norm=%q, want=%q", norm, wantNorm)
|
||||
}
|
||||
// 'a'→0, 'b'→1, '\n'→5, 'c'→6, 'd'→7
|
||||
wantMapping := []int{0, 1, 5, 6, 7}
|
||||
if len(mapping) != len(wantMapping) {
|
||||
t.Fatalf("mapping len=%d, want=%d", len(mapping), len(wantMapping))
|
||||
}
|
||||
for i, want := range wantMapping {
|
||||
if mapping[i] != want {
|
||||
t.Errorf("mapping[%d]=%d, want=%d", i, mapping[i], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeWithMap_UnicodeReplacement(t *testing.T) {
|
||||
// \u201c is 3 bytes in UTF-8, replaced with " which is 1 byte
|
||||
s := "\u201chello\u201d"
|
||||
norm, mapping := normalizeWithMap(s)
|
||||
wantNorm := "\"hello\""
|
||||
if norm != wantNorm {
|
||||
t.Errorf("norm=%q, want=%q", norm, wantNorm)
|
||||
}
|
||||
// " maps to byte 0 (start of \u201c), h maps to 3, e→4, l→5, l→6, o→7, " maps to 8 (start of \u201d)
|
||||
wantMapping := []int{0, 3, 4, 5, 6, 7, 8}
|
||||
if len(mapping) != len(wantMapping) {
|
||||
t.Fatalf("mapping len=%d, want=%d", len(mapping), len(wantMapping))
|
||||
}
|
||||
for i, want := range wantMapping {
|
||||
if mapping[i] != want {
|
||||
t.Errorf("mapping[%d]=%d, want=%d", i, mapping[i], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeWithMap_EmptyString(t *testing.T) {
|
||||
norm, mapping := normalizeWithMap("")
|
||||
if norm != "" {
|
||||
t.Errorf("norm=%q, want empty", norm)
|
||||
}
|
||||
if len(mapping) != 0 {
|
||||
t.Errorf("mapping len=%d, want 0", len(mapping))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeWithMap_OnlyWhitespace(t *testing.T) {
|
||||
norm, _ := normalizeWithMap(" \n ")
|
||||
if norm != "\n" {
|
||||
t.Errorf("norm=%q, want %q", norm, "\n")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// normalizeForFuzzy — consistency with normalizeWithMap
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNormalizeForFuzzy_ConsistentWithMap(t *testing.T) {
|
||||
inputs := []string{
|
||||
"hello \nworld ",
|
||||
"\u201chello\u201d\u2014world",
|
||||
"a\u00a0b\u2013c\n trailing \n",
|
||||
"no changes here",
|
||||
"",
|
||||
}
|
||||
for _, s := range inputs {
|
||||
norm := normalizeForFuzzy(s)
|
||||
normMap, _ := normalizeWithMap(s)
|
||||
if norm != normMap {
|
||||
t.Errorf("normalizeForFuzzy(%q) = %q, normalizeWithMap = %q", s, norm, normMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// generateDiff — correct unified diff output
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGenerateDiff_SingleLineChange(t *testing.T) {
|
||||
old := "line1\nline2\nline3\nline4\nline5\nline6\nline7\n"
|
||||
new := "line1\nline2\nline3\nLINE4\nline5\nline6\nline7\n"
|
||||
|
||||
diff := generateDiff("test.go", old, new)
|
||||
|
||||
// Should contain standard unified diff markers
|
||||
if !strings.Contains(diff, "--- test.go") {
|
||||
t.Error("diff should contain --- header")
|
||||
}
|
||||
if !strings.Contains(diff, "+++ test.go") {
|
||||
t.Error("diff should contain +++ header")
|
||||
}
|
||||
if !strings.Contains(diff, "@@") {
|
||||
t.Error("diff should contain @@ hunk header")
|
||||
}
|
||||
|
||||
// Should show the actual change
|
||||
if !strings.Contains(diff, "-line4") {
|
||||
t.Error("diff should show removed line")
|
||||
}
|
||||
if !strings.Contains(diff, "+LINE4") {
|
||||
t.Error("diff should show added line")
|
||||
}
|
||||
|
||||
// Should NOT mark all remaining lines as changed (the old bug)
|
||||
deletedCount := strings.Count(diff, "\n-")
|
||||
if deletedCount > 2 { // at most 1 deleted line + some tolerance
|
||||
t.Errorf("diff shows %d deletions, expected ~1 (old bug: marked rest of file as deleted)", deletedCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDiff_MultiLineChange(t *testing.T) {
|
||||
old := "aaa\nbbb\nccc\nddd\n"
|
||||
new := "aaa\nBBB\nCCC\nddd\n"
|
||||
|
||||
diff := generateDiff("x.go", old, new)
|
||||
if !strings.Contains(diff, "-bbb") {
|
||||
t.Error("diff should show bbb removed")
|
||||
}
|
||||
if !strings.Contains(diff, "-ccc") {
|
||||
t.Error("diff should show ccc removed")
|
||||
}
|
||||
if !strings.Contains(diff, "+BBB") {
|
||||
t.Error("diff should show BBB added")
|
||||
}
|
||||
if !strings.Contains(diff, "+CCC") {
|
||||
t.Error("diff should show CCC added")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDiff_NoChange(t *testing.T) {
|
||||
content := "hello\nworld\n"
|
||||
diff := generateDiff("x.go", content, content)
|
||||
if diff != "" {
|
||||
t.Errorf("expected empty diff for identical content, got %q", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDiff_Addition(t *testing.T) {
|
||||
old := "line1\nline2\n"
|
||||
new := "line1\nnew line\nline2\n"
|
||||
|
||||
diff := generateDiff("x.go", old, new)
|
||||
if !strings.Contains(diff, "+new line") {
|
||||
t.Error("diff should show added line")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDiff_Deletion(t *testing.T) {
|
||||
old := "line1\nremove me\nline2\n"
|
||||
new := "line1\nline2\n"
|
||||
|
||||
diff := generateDiff("x.go", old, new)
|
||||
if !strings.Contains(diff, "-remove me") {
|
||||
t.Error("diff should show deleted line")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// End-to-end: executeEdit via tool call
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestExecuteEdit_ExactMatch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.go")
|
||||
original := "func main() {\n\tfmt.Println(\"hello\")\n}\n"
|
||||
writeFileOrFail(t, path, original)
|
||||
|
||||
input, _ := json.Marshal(editArgs{
|
||||
Path: path,
|
||||
OldText: "fmt.Println(\"hello\")",
|
||||
NewText: "fmt.Println(\"world\")",
|
||||
})
|
||||
|
||||
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("executeEdit error: %v", err)
|
||||
}
|
||||
if resp.IsError {
|
||||
t.Fatalf("tool returned error: %s", resp.Content)
|
||||
}
|
||||
|
||||
got, _ := os.ReadFile(path)
|
||||
want := "func main() {\n\tfmt.Println(\"world\")\n}\n"
|
||||
if string(got) != want {
|
||||
t.Errorf("file content=%q, want=%q", string(got), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteEdit_ExactMatch_DoesNotCorruptRest(t *testing.T) {
|
||||
// This is the key regression test for the screenshot bug: editing a
|
||||
// small section must NOT delete/corrupt the rest of the file.
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "big.go")
|
||||
|
||||
var lines []string
|
||||
for i := 1; i <= 100; i++ {
|
||||
lines = append(lines, fmt.Sprintf("line_%03d_%s", i, strings.Repeat("x", 40)))
|
||||
}
|
||||
original := strings.Join(lines, "\n") + "\n"
|
||||
writeFileOrFail(t, path, original)
|
||||
|
||||
// Replace just line 50
|
||||
target := lines[49]
|
||||
replacement := "REPLACED_LINE_50"
|
||||
input, _ := json.Marshal(editArgs{
|
||||
Path: path,
|
||||
OldText: target,
|
||||
NewText: replacement,
|
||||
})
|
||||
|
||||
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("executeEdit error: %v", err)
|
||||
}
|
||||
if resp.IsError {
|
||||
t.Fatalf("tool returned error: %s", resp.Content)
|
||||
}
|
||||
|
||||
got, _ := os.ReadFile(path)
|
||||
gotLines := strings.Split(string(got), "\n")
|
||||
|
||||
// File should still have 101 elements (100 lines + trailing empty)
|
||||
if len(gotLines) != 101 {
|
||||
t.Fatalf("file has %d lines, want 101 (content was corrupted)", len(gotLines))
|
||||
}
|
||||
|
||||
// Line 50 should be replaced
|
||||
if gotLines[49] != replacement {
|
||||
t.Errorf("line 50=%q, want=%q", gotLines[49], replacement)
|
||||
}
|
||||
|
||||
// Lines before and after should be untouched
|
||||
if gotLines[0] != lines[0] {
|
||||
t.Errorf("line 1 corrupted: %q", gotLines[0])
|
||||
}
|
||||
if gotLines[98] != lines[98] {
|
||||
t.Errorf("line 99 corrupted: %q", gotLines[98])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteEdit_FuzzyMatch_TrailingWhitespace(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "ws.go")
|
||||
// File has trailing whitespace on some lines
|
||||
original := "func foo() { \n\treturn 1 \n}\nfunc bar() {\n}\n"
|
||||
writeFileOrFail(t, path, original)
|
||||
|
||||
// Search without trailing whitespace (common LLM behavior)
|
||||
input, _ := json.Marshal(editArgs{
|
||||
Path: path,
|
||||
OldText: "func foo() {\n\treturn 1\n}",
|
||||
NewText: "func foo() {\n\treturn 2\n}",
|
||||
})
|
||||
|
||||
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("executeEdit error: %v", err)
|
||||
}
|
||||
if resp.IsError {
|
||||
t.Fatalf("tool returned error: %s", resp.Content)
|
||||
}
|
||||
|
||||
got, _ := os.ReadFile(path)
|
||||
gotStr := string(got)
|
||||
|
||||
// The fuzzy match replaces the matched region (which includes trailing
|
||||
// whitespace) with the new_text. The key invariant is that the rest of
|
||||
// the file (func bar) must be preserved.
|
||||
if !strings.Contains(gotStr, "return 2") {
|
||||
t.Error("edit was not applied: missing 'return 2'")
|
||||
}
|
||||
if !strings.Contains(gotStr, "func bar()") {
|
||||
t.Errorf("file was corrupted: missing func bar(). got=%q", gotStr)
|
||||
}
|
||||
|
||||
// Verify response mentions fuzzy match
|
||||
if !strings.Contains(resp.Content, "fuzzy match") {
|
||||
t.Error("response should mention fuzzy match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteEdit_FuzzyMatch_DoesNotCorruptRest(t *testing.T) {
|
||||
// Regression test: fuzzy match must not corrupt content after the match.
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "fuzzy.txt")
|
||||
|
||||
// 20 lines, each with trailing whitespace
|
||||
var lines []string
|
||||
for i := 1; i <= 20; i++ {
|
||||
lines = append(lines, strings.Repeat("x", 10)+" ") // trailing spaces
|
||||
}
|
||||
original := strings.Join(lines, "\n") + "\nEND\n"
|
||||
writeFileOrFail(t, path, original)
|
||||
|
||||
// Search for lines 10-11 without trailing whitespace
|
||||
search := strings.Repeat("x", 10) + "\n" + strings.Repeat("x", 10)
|
||||
// But this matches lines 1-2, 2-3, etc. — should fail due to ambiguity.
|
||||
input, _ := json.Marshal(editArgs{
|
||||
Path: path,
|
||||
OldText: search,
|
||||
NewText: "REPLACED",
|
||||
})
|
||||
|
||||
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("executeEdit error: %v", err)
|
||||
}
|
||||
|
||||
// This should either fail (ambiguous) or produce correct output.
|
||||
// With identical lines, fuzzy match should refuse (ambiguous).
|
||||
got, _ := os.ReadFile(path)
|
||||
if !resp.IsError {
|
||||
// If it didn't error, verify the file is not corrupted
|
||||
if !strings.HasSuffix(string(got), "END\n") {
|
||||
t.Error("file was corrupted: missing END marker")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteEdit_MultipleMatches_Fails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "dup.txt")
|
||||
writeFileOrFail(t, path, "hello\nworld\nhello\n")
|
||||
|
||||
input, _ := json.Marshal(editArgs{
|
||||
Path: path,
|
||||
OldText: "hello",
|
||||
NewText: "goodbye",
|
||||
})
|
||||
|
||||
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("executeEdit error: %v", err)
|
||||
}
|
||||
if !resp.IsError {
|
||||
t.Error("expected error for multiple matches")
|
||||
}
|
||||
if !strings.Contains(resp.Content, "2 matches") {
|
||||
t.Errorf("expected '2 matches' in error, got: %s", resp.Content)
|
||||
}
|
||||
|
||||
// File should be untouched
|
||||
got, _ := os.ReadFile(path)
|
||||
if string(got) != "hello\nworld\nhello\n" {
|
||||
t.Error("file was modified despite error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteEdit_NoMatch_Fails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "nomatch.txt")
|
||||
writeFileOrFail(t, path, "hello world\n")
|
||||
|
||||
input, _ := json.Marshal(editArgs{
|
||||
Path: path,
|
||||
OldText: "nonexistent text",
|
||||
NewText: "replacement",
|
||||
})
|
||||
|
||||
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("executeEdit error: %v", err)
|
||||
}
|
||||
if !resp.IsError {
|
||||
t.Error("expected error for no match")
|
||||
}
|
||||
|
||||
// File should be untouched
|
||||
got, _ := os.ReadFile(path)
|
||||
if string(got) != "hello world\n" {
|
||||
t.Error("file was modified despite error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteEdit_CRLFNormalization(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "crlf.txt")
|
||||
writeFileOrFail(t, path, "line1\r\nline2\r\nline3\r\n")
|
||||
|
||||
input, _ := json.Marshal(editArgs{
|
||||
Path: path,
|
||||
OldText: "line2",
|
||||
NewText: "LINE2",
|
||||
})
|
||||
|
||||
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("executeEdit error: %v", err)
|
||||
}
|
||||
if resp.IsError {
|
||||
t.Fatalf("tool returned error: %s", resp.Content)
|
||||
}
|
||||
|
||||
got, _ := os.ReadFile(path)
|
||||
if !strings.Contains(string(got), "LINE2") {
|
||||
t.Error("edit was not applied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteEdit_MissingPath(t *testing.T) {
|
||||
input, _ := json.Marshal(editArgs{
|
||||
OldText: "x",
|
||||
NewText: "y",
|
||||
})
|
||||
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !resp.IsError {
|
||||
t.Error("expected error for missing path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteEdit_NonexistentFile(t *testing.T) {
|
||||
input, _ := json.Marshal(editArgs{
|
||||
Path: "/tmp/nonexistent_edit_test_file_12345.go",
|
||||
OldText: "x",
|
||||
NewText: "y",
|
||||
})
|
||||
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !resp.IsError {
|
||||
t.Error("expected error for nonexistent file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteEdit_DiffContainsHunkHeader(t *testing.T) {
|
||||
// The UI's extractDiffStartLine parses @@ -N from the result.
|
||||
// Verify the diff output contains it.
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "hunk.go")
|
||||
var lines []string
|
||||
for i := 1; i <= 20; i++ {
|
||||
lines = append(lines, fmt.Sprintf("line_%02d_content", i))
|
||||
}
|
||||
writeFileOrFail(t, path, strings.Join(lines, "\n")+"\n")
|
||||
|
||||
input, _ := json.Marshal(editArgs{
|
||||
Path: path,
|
||||
OldText: "line_10_content",
|
||||
NewText: "REPLACED",
|
||||
})
|
||||
|
||||
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("error: %v", err)
|
||||
}
|
||||
if resp.IsError {
|
||||
t.Fatalf("tool error: %s", resp.Content)
|
||||
}
|
||||
if !strings.Contains(resp.Content, "@@ ") {
|
||||
t.Error("diff output should contain @@ hunk header for UI parsing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteEdit_MetadataContainsFileDiffs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "meta.go")
|
||||
writeFileOrFail(t, path, "old content\n")
|
||||
|
||||
input, _ := json.Marshal(editArgs{
|
||||
Path: path,
|
||||
OldText: "old content",
|
||||
NewText: "new content",
|
||||
})
|
||||
|
||||
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("error: %v", err)
|
||||
}
|
||||
|
||||
// Check metadata is present
|
||||
metaJSON := resp.Metadata
|
||||
if metaJSON == "" {
|
||||
t.Fatal("expected metadata on response")
|
||||
}
|
||||
|
||||
var meta map[string]any
|
||||
if err := json.Unmarshal([]byte(metaJSON), &meta); err != nil {
|
||||
t.Fatalf("metadata is not valid JSON: %v", err)
|
||||
}
|
||||
|
||||
diffs, ok := meta["file_diffs"]
|
||||
if !ok {
|
||||
t.Fatal("metadata missing file_diffs key")
|
||||
}
|
||||
|
||||
diffList, ok := diffs.([]any)
|
||||
if !ok || len(diffList) == 0 {
|
||||
t.Fatal("file_diffs should be a non-empty array")
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,9 @@ type SubagentSpawnResult struct {
|
||||
// SubagentSpawnFunc is a callback that spawns an in-process subagent. The
|
||||
// parent Kit instance injects this into the context so the core tool can
|
||||
// call back without importing pkg/kit (which would create a cycle).
|
||||
type SubagentSpawnFunc func(ctx context.Context, prompt, model, systemPrompt string, timeout time.Duration) (*SubagentSpawnResult, error)
|
||||
// The toolCallID parameter is the LLM-assigned ID of the spawn_subagent
|
||||
// tool call, enabling the parent to correlate subagent events.
|
||||
type SubagentSpawnFunc func(ctx context.Context, toolCallID, prompt, model, systemPrompt string, timeout time.Duration) (*SubagentSpawnResult, error)
|
||||
|
||||
type subagentCtxKey struct{}
|
||||
|
||||
@@ -128,8 +130,16 @@ func executeSubagent(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolRe
|
||||
), fmt.Errorf("no subagent spawner in context")
|
||||
}
|
||||
|
||||
// Detach from the parent's deadline so the subagent gets its own
|
||||
// independent timeout (applied downstream in Kit.Subagent). The parent
|
||||
// context may carry a tight deadline from the LLM generation loop or
|
||||
// other tool timeouts that would prematurely kill the subagent.
|
||||
// We preserve context values (spawner, etc.) and propagate parent
|
||||
// cancellation (e.g. user hits Ctrl-C) without inheriting the deadline.
|
||||
spawnCtx := detachedWithCancel(ctx)
|
||||
|
||||
// Spawn in-process subagent.
|
||||
result, err := spawner(ctx, args.Task, args.Model, args.SystemPrompt, timeout)
|
||||
result, err := spawner(spawnCtx, call.ID, args.Task, args.Model, args.SystemPrompt, timeout)
|
||||
if err != nil || result.Error != nil {
|
||||
spawnErr := err
|
||||
if spawnErr == nil {
|
||||
@@ -162,6 +172,39 @@ func executeSubagent(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolRe
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context detachment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// detachedContext wraps a parent context, preserving its values but removing
|
||||
// its deadline and cancellation. This allows the subagent to have its own
|
||||
// independent timeout while still accessing context-stored values (e.g. the
|
||||
// subagent spawner function).
|
||||
type detachedContext struct {
|
||||
parent context.Context
|
||||
}
|
||||
|
||||
func (d detachedContext) Deadline() (time.Time, bool) { return time.Time{}, false }
|
||||
func (d detachedContext) Done() <-chan struct{} { return nil }
|
||||
func (d detachedContext) Err() error { return nil }
|
||||
func (d detachedContext) Value(key any) any { return d.parent.Value(key) }
|
||||
|
||||
// detachedWithCancel creates a new context that inherits values from the
|
||||
// parent but has no deadline. Cancellation of the parent is propagated: when
|
||||
// the parent is cancelled the returned context is also cancelled, but the
|
||||
// parent's deadline does not apply to the child.
|
||||
func detachedWithCancel(parent context.Context) context.Context {
|
||||
child, cancel := context.WithCancel(detachedContext{parent: parent})
|
||||
go func() {
|
||||
select {
|
||||
case <-parent.Done():
|
||||
cancel()
|
||||
case <-child.Done():
|
||||
}
|
||||
}()
|
||||
return child
|
||||
}
|
||||
|
||||
// truncateResponse limits the response length to avoid overwhelming context windows.
|
||||
func truncateResponse(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
|
||||
+28
-10
@@ -6,14 +6,17 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMaxLines = 2000
|
||||
defaultMaxBytes = 50 * 1024 // 50KB
|
||||
grepMaxLineLen = 500
|
||||
defaultMaxLines = 2000
|
||||
defaultMaxBytes = 50 * 1024 // 50KB
|
||||
defaultMaxLineLen = 2000 // max characters per line before truncation
|
||||
grepMaxLineLen = 500
|
||||
|
||||
// DefaultMaxLines is the exported default line limit for truncation.
|
||||
DefaultMaxLines = defaultMaxLines
|
||||
// DefaultMaxBytes is the exported default byte limit for truncation.
|
||||
DefaultMaxBytes = defaultMaxBytes
|
||||
// DefaultMaxLineLen is the exported default per-line character limit.
|
||||
DefaultMaxLineLen = defaultMaxLineLen
|
||||
)
|
||||
|
||||
// TruncationResult describes how output was truncated.
|
||||
@@ -26,6 +29,8 @@ type TruncationResult struct {
|
||||
}
|
||||
|
||||
// TruncateTail keeps the last maxLines lines and at most maxBytes bytes.
|
||||
// Individual lines longer than defaultMaxLineLen are truncated to prevent
|
||||
// extremely long single lines from blowing up the TUI when wrapped.
|
||||
// Used for bash output where the tail is most relevant.
|
||||
func TruncateTail(content string, maxLines, maxBytes int) TruncationResult {
|
||||
if maxLines <= 0 {
|
||||
@@ -38,11 +43,11 @@ func TruncateTail(content string, maxLines, maxBytes int) TruncationResult {
|
||||
lines := strings.Split(content, "\n")
|
||||
total := len(lines)
|
||||
|
||||
if len(content) <= maxBytes && total <= maxLines {
|
||||
return TruncationResult{Content: content, Total: total, Kept: total}
|
||||
}
|
||||
// Truncate individual long lines first to prevent single lines from
|
||||
// wrapping into hundreds of visual lines in the TUI.
|
||||
lines = truncateLongLines(lines, defaultMaxLineLen)
|
||||
|
||||
// Truncate by lines first (keep tail)
|
||||
// Truncate by lines (keep tail)
|
||||
truncBy := ""
|
||||
if total > maxLines {
|
||||
lines = lines[total-maxLines:]
|
||||
@@ -78,6 +83,7 @@ func TruncateTail(content string, maxLines, maxBytes int) TruncationResult {
|
||||
}
|
||||
|
||||
// truncateHead keeps the first maxLines lines and at most maxBytes bytes.
|
||||
// Individual lines longer than defaultMaxLineLen are truncated.
|
||||
// Used for read, grep, find, ls output where the head is most relevant.
|
||||
func truncateHead(content string, maxLines, maxBytes int) TruncationResult {
|
||||
if maxLines <= 0 {
|
||||
@@ -90,9 +96,8 @@ func truncateHead(content string, maxLines, maxBytes int) TruncationResult {
|
||||
lines := strings.Split(content, "\n")
|
||||
total := len(lines)
|
||||
|
||||
if len(content) <= maxBytes && total <= maxLines {
|
||||
return TruncationResult{Content: content, Total: total, Kept: total}
|
||||
}
|
||||
// Truncate individual long lines first.
|
||||
lines = truncateLongLines(lines, defaultMaxLineLen)
|
||||
|
||||
truncBy := ""
|
||||
if total > maxLines {
|
||||
@@ -125,6 +130,19 @@ func truncateHead(content string, maxLines, maxBytes int) TruncationResult {
|
||||
}
|
||||
}
|
||||
|
||||
// truncateLongLines caps each line to maxLen characters, appending a
|
||||
// "[...N chars truncated]" marker to any line that exceeds the limit.
|
||||
// This prevents a single very long line (e.g. minified JSON/JS) from
|
||||
// wrapping into hundreds of visual rows and blowing up the TUI.
|
||||
func truncateLongLines(lines []string, maxLen int) []string {
|
||||
for i, line := range lines {
|
||||
if len(line) > maxLen {
|
||||
lines[i] = line[:maxLen] + fmt.Sprintf("... [%d chars truncated]", len(line)-maxLen)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// truncateLine truncates a single line to maxChars, appending "..." if cut.
|
||||
func truncateLine(line string, maxChars int) string {
|
||||
if maxChars <= 0 {
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTruncateTail_LongLines(t *testing.T) {
|
||||
// A single line of 5000 chars should be truncated to defaultMaxLineLen.
|
||||
longLine := strings.Repeat("x", 5000)
|
||||
tr := TruncateTail(longLine, 2000, 50*1024)
|
||||
|
||||
if len(tr.Content) > defaultMaxLineLen+100 { // +100 for the "[...N chars truncated]" suffix
|
||||
t.Errorf("single long line not truncated: got %d chars, want <= %d", len(tr.Content), defaultMaxLineLen+100)
|
||||
}
|
||||
if !strings.Contains(tr.Content, "chars truncated]") {
|
||||
t.Error("truncated line should contain truncation marker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateTail_NormalLines(t *testing.T) {
|
||||
// Lines within the limit should pass through unchanged.
|
||||
content := "line1\nline2\nline3"
|
||||
tr := TruncateTail(content, 2000, 50*1024)
|
||||
if tr.Content != content {
|
||||
t.Errorf("got %q, want %q", tr.Content, content)
|
||||
}
|
||||
if tr.Truncated {
|
||||
t.Error("should not be marked as truncated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateTail_LineCount(t *testing.T) {
|
||||
lines := make([]string, 100)
|
||||
for i := range lines {
|
||||
lines[i] = "line"
|
||||
}
|
||||
content := strings.Join(lines, "\n")
|
||||
tr := TruncateTail(content, 10, 50*1024)
|
||||
|
||||
if !tr.Truncated {
|
||||
t.Error("should be marked as truncated")
|
||||
}
|
||||
if tr.Total != 100 {
|
||||
t.Errorf("total = %d, want 100", tr.Total)
|
||||
}
|
||||
if tr.Kept != 10 {
|
||||
t.Errorf("kept = %d, want 10", tr.Kept)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateHead_LongLines(t *testing.T) {
|
||||
longLine := strings.Repeat("y", 5000)
|
||||
tr := truncateHead(longLine, 2000, 50*1024)
|
||||
|
||||
if len(tr.Content) > defaultMaxLineLen+100 {
|
||||
t.Errorf("single long line not truncated: got %d chars, want <= %d", len(tr.Content), defaultMaxLineLen+100)
|
||||
}
|
||||
if !strings.Contains(tr.Content, "chars truncated]") {
|
||||
t.Error("truncated line should contain truncation marker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateHead_NormalLines(t *testing.T) {
|
||||
content := "line1\nline2\nline3"
|
||||
tr := truncateHead(content, 2000, 50*1024)
|
||||
if tr.Content != content {
|
||||
t.Errorf("got %q, want %q", tr.Content, content)
|
||||
}
|
||||
if tr.Truncated {
|
||||
t.Error("should not be marked as truncated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateHead_LineCount(t *testing.T) {
|
||||
lines := make([]string, 100)
|
||||
for i := range lines {
|
||||
lines[i] = "line"
|
||||
}
|
||||
content := strings.Join(lines, "\n")
|
||||
tr := truncateHead(content, 10, 50*1024)
|
||||
|
||||
if !tr.Truncated {
|
||||
t.Error("should be marked as truncated")
|
||||
}
|
||||
if tr.Total != 100 {
|
||||
t.Errorf("total = %d, want 100", tr.Total)
|
||||
}
|
||||
if tr.Kept != 10 {
|
||||
t.Errorf("kept = %d, want 10", tr.Kept)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateLongLines(t *testing.T) {
|
||||
lines := []string{
|
||||
"short",
|
||||
strings.Repeat("a", 3000),
|
||||
"also short",
|
||||
}
|
||||
result := truncateLongLines(lines, 100)
|
||||
|
||||
if result[0] != "short" {
|
||||
t.Error("short line should be unchanged")
|
||||
}
|
||||
if len(result[1]) > 200 { // 100 chars + marker
|
||||
t.Errorf("long line not truncated: len=%d", len(result[1]))
|
||||
}
|
||||
if !strings.Contains(result[1], "chars truncated]") {
|
||||
t.Error("should contain truncation marker")
|
||||
}
|
||||
if result[2] != "also short" {
|
||||
t.Error("short line should be unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateTail_MixedLongAndManyLines(t *testing.T) {
|
||||
// 50 lines, each 3000 chars — tests both per-line and total truncation.
|
||||
lines := make([]string, 50)
|
||||
for i := range lines {
|
||||
lines[i] = strings.Repeat("z", 3000)
|
||||
}
|
||||
content := strings.Join(lines, "\n")
|
||||
|
||||
tr := TruncateTail(content, 10, 50*1024)
|
||||
|
||||
// Should keep 10 lines.
|
||||
if tr.Kept != 10 {
|
||||
t.Errorf("kept = %d, want 10", tr.Kept)
|
||||
}
|
||||
// Each line should be capped at ~defaultMaxLineLen.
|
||||
resultLines := strings.Split(tr.Content, "\n")
|
||||
for i, line := range resultLines {
|
||||
if len(line) > defaultMaxLineLen+100 {
|
||||
t.Errorf("line %d too long: %d chars", i, len(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateLine(t *testing.T) {
|
||||
short := "hello"
|
||||
if truncateLine(short, 10) != short {
|
||||
t.Error("short line should be unchanged")
|
||||
}
|
||||
|
||||
long := strings.Repeat("x", 100)
|
||||
result := truncateLine(long, 10)
|
||||
if len(result) != 13 { // 10 + "..."
|
||||
t.Errorf("got len %d, want 13", len(result))
|
||||
}
|
||||
|
||||
// Default max for 0 — input shorter than default, so unchanged
|
||||
result2 := truncateLine(long, 0)
|
||||
if result2 != long {
|
||||
t.Errorf("100-char line should be unchanged when maxChars defaults to %d", grepMaxLineLen)
|
||||
}
|
||||
|
||||
// Longer input with default
|
||||
veryLong := strings.Repeat("x", 1000)
|
||||
result3 := truncateLine(veryLong, 0)
|
||||
if len(result3) != grepMaxLineLen+3 {
|
||||
t.Errorf("got len %d, want %d", len(result3), grepMaxLineLen+3)
|
||||
}
|
||||
}
|
||||
@@ -485,6 +485,36 @@ type Context struct {
|
||||
// ctx.RenderMessage("build-status", "All 42 tests passed.")
|
||||
RenderMessage func(rendererName string, content string)
|
||||
|
||||
// RegisterTheme adds a named theme to the runtime theme registry.
|
||||
// If a theme with the same name already exists it is replaced.
|
||||
// The theme becomes available via /theme and ctx.SetTheme().
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// ctx.RegisterTheme("neon", ext.ThemeColorConfig{
|
||||
// Primary: ext.ThemeColor{Dark: "#FF00FF"},
|
||||
// Secondary: ext.ThemeColor{Dark: "#00FFFF"},
|
||||
// Success: ext.ThemeColor{Dark: "#00FF00"},
|
||||
// Warning: ext.ThemeColor{Dark: "#FFFF00"},
|
||||
// Error: ext.ThemeColor{Dark: "#FF0000"},
|
||||
// Info: ext.ThemeColor{Dark: "#00FFFF"},
|
||||
// Text: ext.ThemeColor{Dark: "#FFFFFF"},
|
||||
// Background: ext.ThemeColor{Dark: "#000000"},
|
||||
// })
|
||||
RegisterTheme func(name string, config ThemeColorConfig)
|
||||
|
||||
// SetTheme switches the active color theme by name. The name must
|
||||
// match a built-in theme, a user/project theme file, or a theme
|
||||
// registered via RegisterTheme. Returns an error if not found.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// err := ctx.SetTheme("neon")
|
||||
SetTheme func(name string) error
|
||||
|
||||
// ListThemes returns the names of all available themes.
|
||||
ListThemes func() []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,
|
||||
@@ -697,6 +727,7 @@ type API struct {
|
||||
onToolCall func(func(ToolCallEvent, Context) *ToolCallResult)
|
||||
onToolExecStart func(func(ToolExecutionStartEvent, Context))
|
||||
onToolExecEnd func(func(ToolExecutionEndEvent, Context))
|
||||
onToolOutput func(func(ToolOutputEvent, Context))
|
||||
onToolResult func(func(ToolResultEvent, Context) *ToolResultResult)
|
||||
onInput func(func(InputEvent, Context) *InputResult)
|
||||
onBeforeAgentStart func(func(BeforeAgentStartEvent, Context) *BeforeAgentStartResult)
|
||||
@@ -737,6 +768,13 @@ func (a *API) OnToolExecutionEnd(handler func(ToolExecutionEndEvent, Context)) {
|
||||
a.onToolExecEnd(handler)
|
||||
}
|
||||
|
||||
// OnToolOutput registers a handler for streaming tool output chunks.
|
||||
// This fires for each output line as it arrives from tools like bash,
|
||||
// allowing extensions to observe or process output in real-time.
|
||||
func (a *API) OnToolOutput(handler func(ToolOutputEvent, Context)) {
|
||||
a.onToolOutput(handler)
|
||||
}
|
||||
|
||||
// OnToolResult registers a handler that fires after tool execution.
|
||||
// Return a non-nil ToolResultResult to modify the output.
|
||||
func (a *API) OnToolResult(handler func(ToolResultEvent, Context) *ToolResultResult) {
|
||||
@@ -1508,6 +1546,19 @@ type ToolExecutionEndEvent struct {
|
||||
|
||||
func (e ToolExecutionEndEvent) Type() EventType { return ToolExecutionEnd }
|
||||
|
||||
// ToolOutputEvent fires when a tool produces streaming output chunks.
|
||||
// This is primarily used for long-running tools like bash to show output
|
||||
// in real-time as it arrives, before the tool completes.
|
||||
type ToolOutputEvent struct {
|
||||
ToolCallID string
|
||||
ToolName string
|
||||
ToolKind string
|
||||
Chunk string // Output text chunk
|
||||
IsStderr bool // Whether this chunk came from stderr
|
||||
}
|
||||
|
||||
func (e ToolOutputEvent) Type() EventType { return ToolOutput }
|
||||
|
||||
// ToolResultEvent fires after tool execution with the output.
|
||||
type ToolResultEvent struct {
|
||||
ToolCallID string
|
||||
@@ -1713,13 +1764,59 @@ type BeforeCompactEvent struct {
|
||||
func (e BeforeCompactEvent) Type() EventType { return BeforeCompact }
|
||||
|
||||
// BeforeCompactResult controls whether compaction proceeds. Return
|
||||
// Cancel=true with an optional Reason to block compaction.
|
||||
// Cancel=true with an optional Reason to block compaction, or provide
|
||||
// a custom Summary to replace the default LLM-generated one.
|
||||
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
|
||||
// Summary, when non-empty, replaces the default LLM-generated summary.
|
||||
// The extension is responsible for generating a useful summary.
|
||||
// Ignored when Cancel is true.
|
||||
Summary string
|
||||
}
|
||||
|
||||
func (BeforeCompactResult) isResult() {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Theme types (exposed to Yaegi — concrete structs, string hex colors)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ThemeColor is an adaptive color pair with light and dark hex values.
|
||||
// Either field may be empty to inherit from the default theme.
|
||||
type ThemeColor struct {
|
||||
Light string
|
||||
Dark string
|
||||
}
|
||||
|
||||
// ThemeColorConfig defines a complete color theme that extensions can register
|
||||
// programmatically via ctx.RegisterTheme(). Uses plain hex strings (not
|
||||
// color.Color) so the type is safe to pass across the Yaegi boundary.
|
||||
type ThemeColorConfig struct {
|
||||
Primary ThemeColor
|
||||
Secondary ThemeColor
|
||||
Success ThemeColor
|
||||
Warning ThemeColor
|
||||
Error ThemeColor
|
||||
Info ThemeColor
|
||||
Text ThemeColor
|
||||
Muted ThemeColor
|
||||
VeryMuted ThemeColor
|
||||
Background ThemeColor
|
||||
Border ThemeColor
|
||||
MutedBorder ThemeColor
|
||||
System ThemeColor
|
||||
Tool ThemeColor
|
||||
Accent ThemeColor
|
||||
Highlight ThemeColor
|
||||
|
||||
// Markdown/syntax highlighting overrides.
|
||||
MdHeading ThemeColor
|
||||
MdLink ThemeColor
|
||||
MdKeyword ThemeColor
|
||||
MdString ThemeColor
|
||||
MdNumber ThemeColor
|
||||
MdComment ThemeColor
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ const (
|
||||
// ToolExecutionEnd fires when a tool finishes executing.
|
||||
ToolExecutionEnd EventType = "tool_execution_end"
|
||||
|
||||
// ToolOutput fires when a tool produces streaming output chunks.
|
||||
ToolOutput EventType = "tool_output"
|
||||
|
||||
// ToolResult fires after a tool executes. Handlers can modify the result.
|
||||
ToolResult EventType = "tool_result"
|
||||
|
||||
|
||||
@@ -439,6 +439,12 @@ func loadSingleExtension(path string) (*LoadedExtension, error) {
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onToolOutput: func(h func(ToolOutputEvent, Context)) {
|
||||
reg(ToolOutput, func(e Event, c Context) Result {
|
||||
h(e.(ToolOutputEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onToolResult: func(h func(ToolResultEvent, Context) *ToolResultResult) {
|
||||
reg(ToolResult, func(e Event, c Context) Result {
|
||||
r := h(e.(ToolResultEvent), c)
|
||||
|
||||
@@ -304,6 +304,15 @@ func Init(api ext.API) {
|
||||
func TestLoadExtensions_SkipsBadFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Isolate from host environment so globally-installed extensions
|
||||
// are not discovered alongside the test fixtures.
|
||||
isolated := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(isolated, "config"))
|
||||
t.Setenv("XDG_DATA_HOME", filepath.Join(isolated, "data"))
|
||||
origWd, _ := os.Getwd()
|
||||
_ = os.Chdir(isolated)
|
||||
t.Cleanup(func() { _ = os.Chdir(origWd) })
|
||||
|
||||
// Good extension
|
||||
good := `package main
|
||||
import "kit/ext"
|
||||
|
||||
@@ -383,8 +383,8 @@ func deriveExtensionName(relPath string, isMain bool) string {
|
||||
base := filepath.Base(relPath)
|
||||
|
||||
if isMain && dir != "." {
|
||||
// Use directory name for main.go files
|
||||
name := strings.ReplaceAll(dir, "/", " ")
|
||||
// Use immediate parent directory name for main.go files
|
||||
name := filepath.Base(dir)
|
||||
name = strings.ReplaceAll(name, "_", " ")
|
||||
name = strings.ReplaceAll(name, "-", " ")
|
||||
return cases.Title(language.English).String(name) + " Extension"
|
||||
|
||||
@@ -119,11 +119,16 @@ func Symbols() interp.Exports {
|
||||
"SubagentHandle": reflect.ValueOf((*SubagentHandle)(nil)),
|
||||
"SubagentEvent": reflect.ValueOf((*SubagentEvent)(nil)),
|
||||
|
||||
// Theme types
|
||||
"ThemeColor": reflect.ValueOf((*ThemeColor)(nil)),
|
||||
"ThemeColorConfig": reflect.ValueOf((*ThemeColorConfig)(nil)),
|
||||
|
||||
// Event structs
|
||||
"ToolCallEvent": reflect.ValueOf((*ToolCallEvent)(nil)),
|
||||
"ToolCallResult": reflect.ValueOf((*ToolCallResult)(nil)),
|
||||
"ToolExecutionStartEvent": reflect.ValueOf((*ToolExecutionStartEvent)(nil)),
|
||||
"ToolExecutionEndEvent": reflect.ValueOf((*ToolExecutionEndEvent)(nil)),
|
||||
"ToolOutputEvent": reflect.ValueOf((*ToolOutputEvent)(nil)),
|
||||
"ToolResultEvent": reflect.ValueOf((*ToolResultEvent)(nil)),
|
||||
"ToolResultResult": reflect.ValueOf((*ToolResultResult)(nil)),
|
||||
"InputEvent": reflect.ValueOf((*InputEvent)(nil)),
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package extensions
|
||||
|
||||
// NewTestAPI creates an API object wired for testing.
|
||||
// This is used by the test harness to load extensions and verify behavior.
|
||||
// The registration functions wire handlers directly to the provided extension.
|
||||
func NewTestAPI(ext *LoadedExtension) API {
|
||||
reg := func(event EventType, fn HandlerFunc) {
|
||||
ext.Handlers[event] = append(ext.Handlers[event], fn)
|
||||
}
|
||||
|
||||
return API{
|
||||
onToolCall: func(h func(ToolCallEvent, Context) *ToolCallResult) {
|
||||
reg(ToolCall, func(e Event, c Context) Result {
|
||||
r := h(e.(ToolCallEvent), c)
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return *r
|
||||
})
|
||||
},
|
||||
onToolExecStart: func(h func(ToolExecutionStartEvent, Context)) {
|
||||
reg(ToolExecutionStart, func(e Event, c Context) Result {
|
||||
h(e.(ToolExecutionStartEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onToolExecEnd: func(h func(ToolExecutionEndEvent, Context)) {
|
||||
reg(ToolExecutionEnd, func(e Event, c Context) Result {
|
||||
h(e.(ToolExecutionEndEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onToolOutput: func(h func(ToolOutputEvent, Context)) {
|
||||
reg(ToolOutput, func(e Event, c Context) Result {
|
||||
h(e.(ToolOutputEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onToolResult: func(h func(ToolResultEvent, Context) *ToolResultResult) {
|
||||
reg(ToolResult, func(e Event, c Context) Result {
|
||||
r := h(e.(ToolResultEvent), c)
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return *r
|
||||
})
|
||||
},
|
||||
onInput: func(h func(InputEvent, Context) *InputResult) {
|
||||
reg(Input, func(e Event, c Context) Result {
|
||||
r := h(e.(InputEvent), c)
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return *r
|
||||
})
|
||||
},
|
||||
onBeforeAgentStart: func(h func(BeforeAgentStartEvent, Context) *BeforeAgentStartResult) {
|
||||
reg(BeforeAgentStart, func(e Event, c Context) Result {
|
||||
r := h(e.(BeforeAgentStartEvent), c)
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return *r
|
||||
})
|
||||
},
|
||||
onAgentStart: func(h func(AgentStartEvent, Context)) {
|
||||
reg(AgentStart, func(e Event, c Context) Result {
|
||||
h(e.(AgentStartEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onAgentEnd: func(h func(AgentEndEvent, Context)) {
|
||||
reg(AgentEnd, func(e Event, c Context) Result {
|
||||
h(e.(AgentEndEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onMessageStart: func(h func(MessageStartEvent, Context)) {
|
||||
reg(MessageStart, func(e Event, c Context) Result {
|
||||
h(e.(MessageStartEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onMessageUpdate: func(h func(MessageUpdateEvent, Context)) {
|
||||
reg(MessageUpdate, func(e Event, c Context) Result {
|
||||
h(e.(MessageUpdateEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onMessageEnd: func(h func(MessageEndEvent, Context)) {
|
||||
reg(MessageEnd, func(e Event, c Context) Result {
|
||||
h(e.(MessageEndEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onSessionStart: func(h func(SessionStartEvent, Context)) {
|
||||
reg(SessionStart, func(e Event, c Context) Result {
|
||||
h(e.(SessionStartEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onSessionShutdown: func(h func(SessionShutdownEvent, Context)) {
|
||||
reg(SessionShutdown, func(e Event, c Context) Result {
|
||||
h(e.(SessionShutdownEvent), c)
|
||||
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)
|
||||
},
|
||||
registerCmdFn: func(cmd CommandDef) {
|
||||
ext.Commands = append(ext.Commands, cmd)
|
||||
},
|
||||
registerToolRendererFn: func(config ToolRenderConfig) {
|
||||
ext.ToolRenderers = append(ext.ToolRenderers, 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})
|
||||
},
|
||||
registerMessageRendererFn: func(config MessageRendererConfig) {
|
||||
ext.MessageRenderers = append(ext.MessageRenderers, config)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// loadCustomModelsFromConfig loads custom model definitions from the config file
|
||||
// and returns them as a map of model ID -> ModelInfo. Returns nil if no custom
|
||||
// models are configured.
|
||||
func loadCustomModelsFromConfig() map[string]ModelInfo {
|
||||
if !viper.IsSet("customModels") {
|
||||
return nil
|
||||
}
|
||||
|
||||
var customModels map[string]CustomModelConfig
|
||||
if err := viper.UnmarshalKey("customModels", &customModels); err != nil {
|
||||
log.Printf("Warning: Failed to parse customModels: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make(map[string]ModelInfo, len(customModels))
|
||||
for modelID, cfg := range customModels {
|
||||
info := modelConfigToModelInfo(modelID, cfg)
|
||||
result[modelID] = info
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// modelConfigToModelInfo converts a CustomModelConfig to a ModelInfo.
|
||||
func modelConfigToModelInfo(modelID string, cfg CustomModelConfig) ModelInfo {
|
||||
return ModelInfo{
|
||||
ID: modelID,
|
||||
Name: cfg.Name,
|
||||
Attachment: cfg.Attachment,
|
||||
Reasoning: cfg.Reasoning,
|
||||
Temperature: cfg.Temperature,
|
||||
Cost: Cost{
|
||||
Input: cfg.Cost.Input,
|
||||
Output: cfg.Cost.Output,
|
||||
},
|
||||
Limit: Limit{
|
||||
Context: cfg.Limit.Context,
|
||||
Output: cfg.Limit.Output,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// CustomModelConfig defines a custom model configuration loaded from the config file.
|
||||
// This is a duplicate here to avoid circular dependencies with internal/config.
|
||||
type CustomModelConfig struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Family string `json:"family,omitempty" yaml:"family,omitempty"`
|
||||
Attachment bool `json:"attachment,omitempty" yaml:"attachment,omitempty"`
|
||||
Reasoning bool `json:"reasoning,omitempty" yaml:"reasoning,omitempty"`
|
||||
Temperature bool `json:"temperature,omitempty" yaml:"temperature,omitempty"`
|
||||
Knowledge string `json:"knowledge,omitempty" yaml:"knowledge,omitempty"`
|
||||
Cost CostConfig `json:"cost" yaml:"cost"`
|
||||
Limit LimitConfig `json:"limit" yaml:"limit"`
|
||||
}
|
||||
|
||||
// CostConfig defines the pricing for a custom model.
|
||||
type CostConfig struct {
|
||||
Input float64 `json:"input" yaml:"input"`
|
||||
Output float64 `json:"output" yaml:"output"`
|
||||
}
|
||||
|
||||
// LimitConfig defines context and output limits for a custom model.
|
||||
type LimitConfig struct {
|
||||
Context int `json:"context" yaml:"context"`
|
||||
Output int `json:"output" yaml:"output"`
|
||||
}
|
||||
@@ -166,28 +166,3 @@ func (p *ProviderPool) Close() {
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
// Stats returns current pool statistics.
|
||||
func (p *ProviderPool) Stats() PoolStats {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
|
||||
stats := PoolStats{
|
||||
TotalProviders: len(p.providers),
|
||||
}
|
||||
for _, pp := range p.providers {
|
||||
if pp.refs > 0 {
|
||||
stats.ActiveProviders++
|
||||
} else {
|
||||
stats.IdleProviders++
|
||||
}
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
// PoolStats contains provider pool statistics.
|
||||
type PoolStats struct {
|
||||
TotalProviders int
|
||||
ActiveProviders int
|
||||
IdleProviders int
|
||||
}
|
||||
|
||||
@@ -37,19 +37,42 @@ func resolveModelAlias(provider, modelName string) string {
|
||||
registry := GetGlobalRegistry()
|
||||
|
||||
aliasMap := map[string]string{
|
||||
"claude-opus-latest": "claude-opus-4-20250514",
|
||||
"claude-sonnet-latest": "claude-sonnet-4-5-20250929",
|
||||
"claude-4-opus-latest": "claude-opus-4-20250514",
|
||||
"claude-4-sonnet-latest": "claude-sonnet-4-5-20250929",
|
||||
|
||||
// Anthropic aliases
|
||||
"claude-opus-latest": "claude-opus-4-6",
|
||||
"claude-sonnet-latest": "claude-sonnet-4-6",
|
||||
"claude-haiku-latest": "claude-haiku-4-5",
|
||||
"claude-4-opus-latest": "claude-opus-4-6",
|
||||
"claude-4-sonnet-latest": "claude-sonnet-4-6",
|
||||
"claude-4-haiku-latest": "claude-haiku-4-5",
|
||||
"claude-3-5-haiku-latest": "claude-3-5-haiku-20241022",
|
||||
"claude-3-5-sonnet-latest": "claude-3-5-sonnet-20241022",
|
||||
"claude-3-7-sonnet-latest": "claude-3-7-sonnet-20250219",
|
||||
"claude-3-opus-latest": "claude-3-opus-20240229",
|
||||
|
||||
// OpenAI aliases
|
||||
"gpt-5-latest": "gpt-5.4",
|
||||
"gpt-5-chat-latest": "gpt-5.4",
|
||||
"gpt-4-latest": "gpt-4o",
|
||||
"gpt-4": "gpt-4o",
|
||||
"gpt-3.5": "gpt-3.5-turbo",
|
||||
"gpt-3.5-latest": "gpt-3.5-turbo",
|
||||
"o1-latest": "o1",
|
||||
"o3-latest": "o3",
|
||||
"o4-latest": "o4-mini",
|
||||
"codex-latest": "codex-mini-latest",
|
||||
|
||||
// Google Gemini aliases
|
||||
"gemini-pro-latest": "gemini-2.5-pro",
|
||||
"gemini-flash": "gemini-2.5-flash",
|
||||
"gemini-pro": "gemini-2.5-pro",
|
||||
"gemini-2-flash": "gemini-2.0-flash",
|
||||
"gemini-2-pro": "gemini-2.5-pro",
|
||||
"gemini-1.5-flash": "gemini-1.5-flash",
|
||||
"gemini-1.5-pro": "gemini-1.5-pro",
|
||||
}
|
||||
|
||||
if resolved, exists := aliasMap[modelName]; exists {
|
||||
if _, err := registry.ValidateModel(provider, resolved); err == nil {
|
||||
if registry.LookupModel(provider, resolved) != nil {
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
@@ -73,8 +96,8 @@ func ThinkingLevels() []ThinkingLevel {
|
||||
return []ThinkingLevel{ThinkingOff, ThinkingMinimal, ThinkingLow, ThinkingMedium, ThinkingHigh}
|
||||
}
|
||||
|
||||
// ThinkingBudgetTokens returns the token budget for a thinking level, or 0 for "off".
|
||||
func ThinkingBudgetTokens(level ThinkingLevel) int64 {
|
||||
// thinkingBudgetTokens returns the token budget for a thinking level, or 0 for "off".
|
||||
func thinkingBudgetTokens(level ThinkingLevel) int64 {
|
||||
switch level {
|
||||
case ThinkingMinimal:
|
||||
return 1024
|
||||
@@ -162,16 +185,6 @@ func ParseModelString(modelString string) (provider, model string, err error) {
|
||||
return "", "", fmt.Errorf("invalid model format %q: expected provider/model (e.g. anthropic/claude-sonnet-4-5)", modelString)
|
||||
}
|
||||
|
||||
// Legacy colon-separated format
|
||||
if strings.Contains(modelString, ":") {
|
||||
parts := strings.SplitN(modelString, ":", 2)
|
||||
if len(parts) == 2 && parts[0] != "" && parts[1] != "" {
|
||||
fmt.Fprintf(os.Stderr, "Warning: model format %q uses deprecated colon separator. Use %s/%s instead.\n",
|
||||
modelString, parts[0], parts[1])
|
||||
return parts[0], parts[1], nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", "", fmt.Errorf("invalid model format %q: expected provider/model (e.g. anthropic/claude-sonnet-4-5)", modelString)
|
||||
}
|
||||
|
||||
@@ -190,8 +203,8 @@ func CreateProvider(ctx context.Context, config *ProviderConfig) (*ProviderResul
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Resolve model aliases (for OAuth compatibility)
|
||||
if provider == "anthropic" || provider == "google-vertex-anthropic" {
|
||||
// Resolve model aliases to full model names
|
||||
if provider == "anthropic" || provider == "google-vertex-anthropic" || provider == "openai" || provider == "google" {
|
||||
modelName = resolveModelAlias(provider, modelName)
|
||||
}
|
||||
|
||||
@@ -240,6 +253,8 @@ func CreateProvider(ctx context.Context, config *ProviderConfig) (*ProviderResul
|
||||
return createBedrockProvider(ctx, config, modelName)
|
||||
case "vercel":
|
||||
return createVercelProvider(ctx, config, modelName)
|
||||
case "custom":
|
||||
return createCustomProvider(ctx, config, modelName)
|
||||
default:
|
||||
return autoRouteProvider(ctx, config, provider, modelName, registry)
|
||||
}
|
||||
@@ -489,7 +504,7 @@ func buildAnthropicProviderOptions(config *ProviderConfig, modelName string) fan
|
||||
return nil
|
||||
}
|
||||
|
||||
budget := ThinkingBudgetTokens(config.ThinkingLevel)
|
||||
budget := thinkingBudgetTokens(config.ThinkingLevel)
|
||||
if budget == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -766,6 +781,42 @@ func createVercelProvider(ctx context.Context, config *ProviderConfig, modelName
|
||||
return &ProviderResult{Model: model}, nil
|
||||
}
|
||||
|
||||
func createCustomProvider(ctx context.Context, config *ProviderConfig, modelName string) (*ProviderResult, error) {
|
||||
if config.ProviderURL == "" {
|
||||
return nil, fmt.Errorf("custom provider requires --provider-url")
|
||||
}
|
||||
|
||||
apiKey := config.ProviderAPIKey
|
||||
if apiKey == "" {
|
||||
apiKey = os.Getenv("CUSTOM_API_KEY")
|
||||
}
|
||||
if apiKey == "" {
|
||||
// Many local/custom endpoints don't require a key; use a placeholder.
|
||||
apiKey = "custom"
|
||||
}
|
||||
|
||||
var opts []openaicompat.Option
|
||||
opts = append(opts, openaicompat.WithBaseURL(config.ProviderURL))
|
||||
opts = append(opts, openaicompat.WithAPIKey(apiKey))
|
||||
opts = append(opts, openaicompat.WithName("custom"))
|
||||
|
||||
if config.TLSSkipVerify {
|
||||
opts = append(opts, openaicompat.WithHTTPClient(createHTTPClientWithTLSConfig(true)))
|
||||
}
|
||||
|
||||
p, err := openaicompat.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create custom provider: %w", err)
|
||||
}
|
||||
|
||||
model, err := p.LanguageModel(ctx, modelName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create custom model: %w", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
}
|
||||
|
||||
func createOllamaProvider(ctx context.Context, config *ProviderConfig, modelName string) (*ProviderResult, error) {
|
||||
baseURL := "http://localhost:11434"
|
||||
if host := os.Getenv("OLLAMA_HOST"); host != "" {
|
||||
|
||||
+44
-19
@@ -116,6 +116,47 @@ func buildFromModelsDB() map[string]ProviderInfo {
|
||||
}
|
||||
}
|
||||
|
||||
// Register the "custom" provider stub for --provider-url without --model.
|
||||
// This allows users to point kit at any OpenAI-compatible endpoint without
|
||||
// needing to specify a model from the database.
|
||||
providers["custom"] = ProviderInfo{
|
||||
ID: "custom",
|
||||
Name: "Custom",
|
||||
Models: map[string]ModelInfo{
|
||||
"custom": {
|
||||
ID: "custom",
|
||||
Name: "Custom",
|
||||
Attachment: false,
|
||||
Reasoning: true,
|
||||
Temperature: true,
|
||||
Cost: Cost{
|
||||
Input: 0,
|
||||
Output: 0,
|
||||
},
|
||||
Limit: Limit{
|
||||
Context: 262_144,
|
||||
Output: 65_536,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Load custom models from config file and merge into custom provider.
|
||||
// Config file models take precedence - if a model ID exists in both
|
||||
// models.dev and config, the config version wins.
|
||||
if customModels := loadCustomModelsFromConfig(); customModels != nil {
|
||||
for modelID, info := range customModels {
|
||||
// Validate custom model config
|
||||
if info.Limit.Context <= 0 {
|
||||
fmt.Fprintf(os.Stderr, "Warning: custom model %q has invalid context limit: %d\n", modelID, info.Limit.Context)
|
||||
}
|
||||
if info.Limit.Output <= 0 {
|
||||
fmt.Fprintf(os.Stderr, "Warning: custom model %q has invalid output limit: %d\n", modelID, info.Limit.Output)
|
||||
}
|
||||
providers["custom"].Models[modelID] = info
|
||||
}
|
||||
}
|
||||
|
||||
return providers
|
||||
}
|
||||
|
||||
@@ -147,24 +188,8 @@ func (r *ModelsRegistry) LookupModel(provider, modelID string) *ModelInfo {
|
||||
return &modelInfo
|
||||
}
|
||||
|
||||
// ValidateModel validates if a model exists and returns detailed information.
|
||||
// Deprecated: Use LookupModel instead — it returns nil for unknown models
|
||||
// rather than an error, letting the provider API be the authority.
|
||||
func (r *ModelsRegistry) ValidateModel(provider, modelID string) (*ModelInfo, error) {
|
||||
if info := r.LookupModel(provider, modelID); info != nil {
|
||||
return info, nil
|
||||
}
|
||||
|
||||
providerInfo, exists := r.providers[provider]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("unsupported provider: %s", provider)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("model %s not found for provider %s", modelID, providerInfo.ID)
|
||||
}
|
||||
|
||||
// GetRequiredEnvVars returns the required environment variables for a provider.
|
||||
func (r *ModelsRegistry) GetRequiredEnvVars(provider string) ([]string, error) {
|
||||
// getRequiredEnvVars returns the required environment variables for a provider.
|
||||
func (r *ModelsRegistry) getRequiredEnvVars(provider string) ([]string, error) {
|
||||
providerInfo, exists := r.providers[provider]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("unsupported provider: %s", provider)
|
||||
@@ -194,7 +219,7 @@ func (r *ModelsRegistry) ValidateEnvironment(provider string, apiKey string) err
|
||||
}
|
||||
}
|
||||
|
||||
envVars, err := r.GetRequiredEnvVars(provider)
|
||||
envVars, err := r.getRequiredEnvVars(provider)
|
||||
if err != nil {
|
||||
// Unknown provider — nothing to validate
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package prompts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// frontmatterSep is the YAML frontmatter delimiter.
|
||||
const frontmatterSep = "---"
|
||||
|
||||
// Frontmatter represents the YAML frontmatter in a prompt template file.
|
||||
type Frontmatter struct {
|
||||
// Description summarises what this template provides.
|
||||
Description string `yaml:"description"`
|
||||
}
|
||||
|
||||
// ParseFrontmatter parses YAML frontmatter content into a Frontmatter struct.
|
||||
func ParseFrontmatter(content string) (*Frontmatter, error) {
|
||||
var fm Frontmatter
|
||||
if err := yaml.Unmarshal([]byte(content), &fm); err != nil {
|
||||
return nil, fmt.Errorf("parsing frontmatter: %w", err)
|
||||
}
|
||||
return &fm, nil
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package prompts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
)
|
||||
|
||||
// LoadOptions configures how templates are discovered and loaded.
|
||||
type LoadOptions struct {
|
||||
// Cwd is the current working directory for project-local discovery.
|
||||
// If empty, the current working directory is used.
|
||||
Cwd string
|
||||
// HomeDir is the user's home directory. If empty, os.UserHomeDir() is used.
|
||||
HomeDir string
|
||||
// ExtraPaths are additional explicit paths to search for templates.
|
||||
ExtraPaths []string
|
||||
// ConfigPaths are paths from configuration files to search.
|
||||
ConfigPaths []string
|
||||
// IncludeDefaults determines whether to include built-in default templates.
|
||||
IncludeDefaults bool
|
||||
}
|
||||
|
||||
// Diagnostic reports a template collision or loading issue.
|
||||
type Diagnostic struct {
|
||||
// Name is the template name that had a collision.
|
||||
Name string
|
||||
// KeptPath is the path of the template that was kept (higher precedence).
|
||||
KeptPath string
|
||||
// DroppedPath is the path of the template that was dropped.
|
||||
DroppedPath string
|
||||
// Reason explains why the collision occurred.
|
||||
Reason string
|
||||
}
|
||||
|
||||
// LoadAll discovers and loads all prompt templates from standard locations
|
||||
// and any extra paths. Templates are loaded in order of precedence (lowest
|
||||
// to highest), with later templates overriding earlier ones of the same name.
|
||||
//
|
||||
// Discovery paths searched in order:
|
||||
// 1. Default templates (if IncludeDefaults)
|
||||
// 2. ~/.kit/prompts/ (global user templates)
|
||||
// 3. .kit/prompts/ (project-local templates)
|
||||
// 4. ConfigPaths (from configuration)
|
||||
// 5. ExtraPaths (explicit paths, highest precedence)
|
||||
func LoadAll(opts LoadOptions) ([]*PromptTemplate, []Diagnostic, error) {
|
||||
if opts.Cwd == "" {
|
||||
opts.Cwd, _ = os.Getwd()
|
||||
}
|
||||
|
||||
if opts.HomeDir == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("getting home directory: %w", err)
|
||||
}
|
||||
opts.HomeDir = home
|
||||
}
|
||||
|
||||
var all []*PromptTemplate
|
||||
var diagnostics []Diagnostic
|
||||
seen := make(map[string]*PromptTemplate) // name -> template
|
||||
|
||||
// Helper to add templates with deduplication tracking
|
||||
addTemplates := func(templates []*PromptTemplate, source string) {
|
||||
for _, tpl := range templates {
|
||||
if existing, ok := seen[tpl.Name]; ok {
|
||||
// Collision: report diagnostic, keep existing (lower precedence wins)
|
||||
diagnostics = append(diagnostics, Diagnostic{
|
||||
Name: tpl.Name,
|
||||
KeptPath: existing.FilePath,
|
||||
DroppedPath: tpl.FilePath,
|
||||
Reason: fmt.Sprintf("template from %s overridden by %s", source, existing.Source),
|
||||
})
|
||||
log.Debug("template collision",
|
||||
"name", tpl.Name,
|
||||
"dropped", tpl.FilePath,
|
||||
"kept", existing.FilePath)
|
||||
} else {
|
||||
tpl.Source = source
|
||||
seen[tpl.Name] = tpl
|
||||
all = append(all, tpl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Default templates (lowest precedence)
|
||||
if opts.IncludeDefaults {
|
||||
defaults := loadDefaultTemplates()
|
||||
addTemplates(defaults, "default")
|
||||
}
|
||||
|
||||
// 2. Global user templates: ~/.kit/prompts/
|
||||
globalDir := filepath.Join(opts.HomeDir, ".kit", "prompts")
|
||||
if templates, err := LoadFromDir(globalDir); err == nil {
|
||||
addTemplates(templates, "global")
|
||||
}
|
||||
|
||||
// 3. Project-local templates: .kit/prompts/
|
||||
localDir := filepath.Join(opts.Cwd, ".kit", "prompts")
|
||||
if templates, err := LoadFromDir(localDir); err == nil {
|
||||
addTemplates(templates, "local")
|
||||
}
|
||||
|
||||
// 4. Config paths
|
||||
for _, path := range opts.ConfigPaths {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.IsDir() {
|
||||
if templates, err := LoadFromDir(path); err == nil {
|
||||
addTemplates(templates, "config")
|
||||
}
|
||||
} else if strings.HasSuffix(path, ".md") {
|
||||
if tpl, err := ParseTemplate(path); err == nil {
|
||||
addTemplates([]*PromptTemplate{tpl}, "config")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Extra paths (highest precedence)
|
||||
for _, path := range opts.ExtraPaths {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.IsDir() {
|
||||
if templates, err := LoadFromDir(path); err == nil {
|
||||
addTemplates(templates, "explicit")
|
||||
}
|
||||
} else if strings.HasSuffix(path, ".md") {
|
||||
if tpl, err := ParseTemplate(path); err == nil {
|
||||
addTemplates([]*PromptTemplate{tpl}, "explicit")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return all, diagnostics, nil
|
||||
}
|
||||
|
||||
// LoadFromDir scans a directory for .md files and loads them as templates.
|
||||
// It looks for *.md files directly in the directory.
|
||||
// Files that fail to parse are logged and skipped.
|
||||
func LoadFromDir(dir string) ([]*PromptTemplate, error) {
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil || !info.IsDir() {
|
||||
return nil, nil // directory doesn't exist — not an error
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading prompts directory %s: %w", dir, err)
|
||||
}
|
||||
|
||||
var templates []*PromptTemplate
|
||||
var errs []string
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
name := entry.Name()
|
||||
if !strings.HasSuffix(name, ".md") {
|
||||
continue
|
||||
}
|
||||
|
||||
full := filepath.Join(dir, name)
|
||||
tpl, err := ParseTemplate(full)
|
||||
if err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
continue
|
||||
}
|
||||
templates = append(templates, tpl)
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return templates, fmt.Errorf("some templates failed to load: %s", strings.Join(errs, "; "))
|
||||
}
|
||||
return templates, nil
|
||||
}
|
||||
|
||||
// Deduplicate removes duplicate templates by name, keeping the first occurrence.
|
||||
// It returns the deduplicated list and diagnostics for any collisions.
|
||||
// This is a standalone function for when you need to deduplicate an existing list.
|
||||
func Deduplicate(templates []*PromptTemplate) ([]*PromptTemplate, []Diagnostic) {
|
||||
seen := make(map[string]*PromptTemplate)
|
||||
var result []*PromptTemplate
|
||||
var diagnostics []Diagnostic
|
||||
|
||||
for _, tpl := range templates {
|
||||
if existing, ok := seen[tpl.Name]; ok {
|
||||
diagnostics = append(diagnostics, Diagnostic{
|
||||
Name: tpl.Name,
|
||||
KeptPath: existing.FilePath,
|
||||
DroppedPath: tpl.FilePath,
|
||||
Reason: "duplicate template name (first-match-wins)",
|
||||
})
|
||||
} else {
|
||||
seen[tpl.Name] = tpl
|
||||
result = append(result, tpl)
|
||||
}
|
||||
}
|
||||
|
||||
return result, diagnostics
|
||||
}
|
||||
|
||||
// loadDefaultTemplates returns the built-in default templates.
|
||||
// These are embedded templates that ship with Kit.
|
||||
func loadDefaultTemplates() []*PromptTemplate {
|
||||
// Default templates can be added here as needed
|
||||
// For now, return an empty slice - users can define their own templates
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package prompts
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadAll_Integration(t *testing.T) {
|
||||
// Create a temp directory for testing
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create the .kit/prompts subdirectory structure
|
||||
promptsDir := filepath.Join(tempDir, ".kit", "prompts")
|
||||
if err := os.MkdirAll(promptsDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create prompts dir: %v", err)
|
||||
}
|
||||
|
||||
// Create a test template file
|
||||
templateContent := `---
|
||||
description: Test template for integration
|
||||
---
|
||||
Review $1 with focus on $2`
|
||||
|
||||
testFile := filepath.Join(promptsDir, "test.md")
|
||||
if err := os.WriteFile(testFile, []byte(templateContent), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
// Test loading from the temp directory
|
||||
tpls, diags, err := LoadAll(LoadOptions{
|
||||
HomeDir: tempDir,
|
||||
IncludeDefaults: false, // Skip default locations for this test
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAll failed: %v", err)
|
||||
}
|
||||
|
||||
if len(diags) > 0 {
|
||||
t.Logf("Got %d diagnostics", len(diags))
|
||||
}
|
||||
|
||||
if len(tpls) != 1 {
|
||||
t.Fatalf("Expected 1 template, got %d", len(tpls))
|
||||
}
|
||||
|
||||
tpl := tpls[0]
|
||||
if tpl.Name != "test" {
|
||||
t.Errorf("Expected name 'test', got '%s'", tpl.Name)
|
||||
}
|
||||
|
||||
if tpl.Description != "Test template for integration" {
|
||||
t.Errorf("Expected description 'Test template for integration', got '%s'", tpl.Description)
|
||||
}
|
||||
|
||||
// Test expansion
|
||||
expanded := tpl.Expand("code security")
|
||||
expected := "Review code with focus on security"
|
||||
if expanded != expected {
|
||||
t.Errorf("Expected '%s', got '%s'", expected, expanded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTemplate_WithFrontmatter(t *testing.T) {
|
||||
// Create a temp file with frontmatter
|
||||
tempDir := t.TempDir()
|
||||
templateContent := `---
|
||||
description: A test template
|
||||
---
|
||||
Create a $1 component with $2 features`
|
||||
|
||||
testFile := filepath.Join(tempDir, "component.md")
|
||||
if err := os.WriteFile(testFile, []byte(templateContent), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
tpl, err := ParseTemplate(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseTemplate failed: %v", err)
|
||||
}
|
||||
|
||||
if tpl.Name != "component" {
|
||||
t.Errorf("Expected name 'component', got '%s'", tpl.Name)
|
||||
}
|
||||
|
||||
if tpl.Description != "A test template" {
|
||||
t.Errorf("Expected description 'A test template', got '%s'", tpl.Description)
|
||||
}
|
||||
|
||||
expectedContent := "Create a $1 component with $2 features"
|
||||
if tpl.Content != expectedContent {
|
||||
t.Errorf("Expected content '%s', got '%s'", expectedContent, tpl.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTemplate_WithoutFrontmatter(t *testing.T) {
|
||||
// Create a temp file without frontmatter
|
||||
tempDir := t.TempDir()
|
||||
templateContent := `Simple template without frontmatter
|
||||
Supports $1 and $2 placeholders`
|
||||
|
||||
testFile := filepath.Join(tempDir, "simple.md")
|
||||
if err := os.WriteFile(testFile, []byte(templateContent), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
tpl, err := ParseTemplate(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseTemplate failed: %v", err)
|
||||
}
|
||||
|
||||
if tpl.Name != "simple" {
|
||||
t.Errorf("Expected name 'simple', got '%s'", tpl.Name)
|
||||
}
|
||||
|
||||
// Description should be empty since there's no frontmatter
|
||||
if tpl.Description != "" {
|
||||
t.Errorf("Expected empty description, got '%s'", tpl.Description)
|
||||
}
|
||||
|
||||
// Content should include everything
|
||||
if tpl.Content != templateContent {
|
||||
t.Errorf("Content mismatch\nExpected:\n%s\nGot:\n%s", templateContent, tpl.Content)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package prompts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PromptTemplate is a named prompt template with shell-style argument placeholders.
|
||||
// It supports Pi-style $1, $2, $@, $ARGUMENTS, ${@:N}, ${@:N:L} syntax.
|
||||
type PromptTemplate struct {
|
||||
// Name is the human-readable identifier for this template.
|
||||
Name string
|
||||
// Description summarises what this template provides.
|
||||
Description string
|
||||
// Content is the raw template text with placeholders.
|
||||
Content string
|
||||
// Source indicates where the template was loaded from (e.g., "default", "user").
|
||||
Source string
|
||||
// FilePath is the absolute filesystem path the template was loaded from.
|
||||
FilePath string
|
||||
}
|
||||
|
||||
// ParseTemplate reads a template from a file. The template name is derived
|
||||
// from the filename (without extension). If the file contains YAML frontmatter,
|
||||
// the description is extracted from it.
|
||||
func ParseTemplate(path string) (*PromptTemplate, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading template %s: %w", path, err)
|
||||
}
|
||||
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
abs = path
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
tpl := &PromptTemplate{
|
||||
FilePath: abs,
|
||||
Content: content,
|
||||
}
|
||||
|
||||
// Parse frontmatter if present
|
||||
if strings.HasPrefix(strings.TrimSpace(content), frontmatterSep) {
|
||||
trimmed := strings.TrimSpace(content)
|
||||
rest := trimmed[len(frontmatterSep):]
|
||||
frontmatter, body, found := strings.Cut(rest, "\n"+frontmatterSep)
|
||||
if found {
|
||||
body = strings.TrimPrefix(body, "\n")
|
||||
fm, err := ParseFrontmatter(frontmatter)
|
||||
if err == nil {
|
||||
tpl.Description = fm.Description
|
||||
}
|
||||
tpl.Content = strings.TrimSpace(body)
|
||||
}
|
||||
}
|
||||
|
||||
// Derive name from filename
|
||||
base := filepath.Base(path)
|
||||
ext := filepath.Ext(base)
|
||||
tpl.Name = strings.TrimSuffix(base, ext)
|
||||
|
||||
return tpl, nil
|
||||
}
|
||||
|
||||
// ParseCommandArgs splits a command line into arguments respecting quotes.
|
||||
// It handles single quotes, double quotes, and backslash escaping.
|
||||
func ParseCommandArgs(input string) []string {
|
||||
var args []string
|
||||
var current strings.Builder
|
||||
inSingleQuote := false
|
||||
inDoubleQuote := false
|
||||
escaped := false
|
||||
|
||||
for i, r := range input {
|
||||
if escaped {
|
||||
current.WriteRune(r)
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
|
||||
if r == '\\' && !inSingleQuote {
|
||||
// Backslash escapes next char, but not in single quotes
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
|
||||
if r == '\'' && !inDoubleQuote {
|
||||
inSingleQuote = !inSingleQuote
|
||||
continue
|
||||
}
|
||||
|
||||
if r == '"' && !inSingleQuote {
|
||||
inDoubleQuote = !inDoubleQuote
|
||||
continue
|
||||
}
|
||||
|
||||
if r == ' ' && !inSingleQuote && !inDoubleQuote {
|
||||
if current.Len() > 0 {
|
||||
args = append(args, current.String())
|
||||
current.Reset()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
current.WriteRune(r)
|
||||
_ = i // silence unused warning when we need position later
|
||||
}
|
||||
|
||||
if current.Len() > 0 {
|
||||
args = append(args, current.String())
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
// argPlaceholder matches shell-style argument placeholders:
|
||||
// - $1, $2, etc. - positional arguments
|
||||
// - $@ - all arguments
|
||||
// - $ARGUMENTS - all arguments (alias for $@)
|
||||
// - ${@:N} - arguments from N onwards
|
||||
// - ${@:N:L} - L arguments starting from N
|
||||
var argPlaceholder = regexp.MustCompile(`\$\{(\d+)\}|\$\{(\d+):(\d+)\}|\$\{ARGUMENTS\}|\$\{@(:\d+)?(:\d+)?\}|\$(\d+)|\$@|\$ARGUMENTS`)
|
||||
|
||||
// SubstituteArgs replaces argument placeholders in content with values from args.
|
||||
// Supported placeholders:
|
||||
// - $N, ${N} - the Nth argument (1-indexed)
|
||||
// - $@, $ARGUMENTS, ${ARGUMENTS} - all arguments joined with spaces
|
||||
// - ${@:N} - arguments from index N onwards (0-indexed)
|
||||
// - ${@:N:L} - L arguments starting from index N (0-indexed)
|
||||
func SubstituteArgs(content string, args []string) string {
|
||||
return argPlaceholder.ReplaceAllStringFunc(content, func(match string) string {
|
||||
// Check for ${N} or ${N:M} format
|
||||
if strings.HasPrefix(match, "${") && strings.Contains(match, "}") {
|
||||
inner := match[2 : len(match)-1] // Remove ${ and }
|
||||
|
||||
// Check for ${ARGUMENTS}
|
||||
if inner == "ARGUMENTS" {
|
||||
return strings.Join(args, " ")
|
||||
}
|
||||
|
||||
// Check for ${@...} format
|
||||
if strings.HasPrefix(inner, "@") {
|
||||
return expandAtArgs(inner, args)
|
||||
}
|
||||
|
||||
// Check for ${N:M} format (positional with length)
|
||||
if colonIdx := strings.Index(inner, ":"); colonIdx > 0 {
|
||||
startStr := inner[:colonIdx]
|
||||
rest := inner[colonIdx+1:]
|
||||
|
||||
start, err := strconv.Atoi(startStr)
|
||||
if err != nil || start < 1 {
|
||||
return match
|
||||
}
|
||||
|
||||
// Check if there's a second colon for length ${N:M:L}
|
||||
lengthStr, _, ok := strings.Cut(rest, ":")
|
||||
if ok {
|
||||
length, err := strconv.Atoi(lengthStr)
|
||||
if err != nil || length < 0 {
|
||||
return match
|
||||
}
|
||||
return joinArgsRange(args, start-1, length)
|
||||
}
|
||||
|
||||
// Single colon ${N:M} - M is length
|
||||
length, err := strconv.Atoi(rest)
|
||||
if err != nil || length < 0 {
|
||||
return match
|
||||
}
|
||||
return joinArgsRange(args, start-1, length)
|
||||
}
|
||||
|
||||
// Simple ${N} format
|
||||
n, err := strconv.Atoi(inner)
|
||||
if err != nil || n < 1 {
|
||||
return match
|
||||
}
|
||||
if n <= len(args) {
|
||||
return args[n-1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Check for $N format (without braces)
|
||||
if strings.HasPrefix(match, "$") && !strings.HasPrefix(match, "${") {
|
||||
suffix := match[1:]
|
||||
|
||||
// $@ or $ARGUMENTS
|
||||
if suffix == "@" || suffix == "ARGUMENTS" {
|
||||
return strings.Join(args, " ")
|
||||
}
|
||||
|
||||
// $N
|
||||
n, err := strconv.Atoi(suffix)
|
||||
if err != nil || n < 1 {
|
||||
return match
|
||||
}
|
||||
if n <= len(args) {
|
||||
return args[n-1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
return match
|
||||
})
|
||||
}
|
||||
|
||||
// expandAtArgs handles ${@...} patterns (1-indexed like bash)
|
||||
func expandAtArgs(inner string, args []string) string {
|
||||
// Remove the @ prefix
|
||||
rest := inner[1:]
|
||||
|
||||
if rest == "" {
|
||||
// ${@} - all arguments
|
||||
return strings.Join(args, " ")
|
||||
}
|
||||
|
||||
// Must start with :
|
||||
if !strings.HasPrefix(rest, ":") {
|
||||
return "${" + inner + "}"
|
||||
}
|
||||
rest = rest[1:]
|
||||
|
||||
// Parse start index
|
||||
startStr, lengthStr, hasLength := strings.Cut(rest, ":")
|
||||
|
||||
start, err := strconv.Atoi(startStr)
|
||||
if err != nil || start < 0 {
|
||||
return "${" + inner + "}"
|
||||
}
|
||||
|
||||
// Convert from 1-indexed to 0-indexed (bash convention)
|
||||
// Treat 0 as 1 (bash convention: args start at 1)
|
||||
if start > 0 {
|
||||
start--
|
||||
}
|
||||
|
||||
if hasLength {
|
||||
length, err := strconv.Atoi(lengthStr)
|
||||
if err != nil || length < 0 {
|
||||
return "${" + inner + "}"
|
||||
}
|
||||
return joinArgsRange(args, start, length)
|
||||
}
|
||||
|
||||
// ${@:N} - from N to end
|
||||
if start >= len(args) {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(args[start:], " ")
|
||||
}
|
||||
|
||||
// joinArgsRange joins args from start index, taking up to length elements
|
||||
func joinArgsRange(args []string, start, length int) string {
|
||||
if start >= len(args) || length <= 0 {
|
||||
return ""
|
||||
}
|
||||
end := start + length
|
||||
end = min(end, len(args))
|
||||
return strings.Join(args[start:end], " ")
|
||||
}
|
||||
|
||||
// Expand substitutes arguments into the template content and returns the result.
|
||||
// It first parses args from the input string, then substitutes them into the template.
|
||||
func (t *PromptTemplate) Expand(argsInput string) string {
|
||||
args := ParseCommandArgs(argsInput)
|
||||
return SubstituteArgs(t.Content, args)
|
||||
}
|
||||
|
||||
// ExpandWithArgs substitutes the provided arguments into the template content.
|
||||
func (t *PromptTemplate) ExpandWithArgs(args []string) string {
|
||||
return SubstituteArgs(t.Content, args)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package prompts
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseCommandArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected []string
|
||||
}{
|
||||
{"", []string{}},
|
||||
{"hello", []string{"hello"}},
|
||||
{"hello world", []string{"hello", "world"}},
|
||||
{`"hello world"`, []string{"hello world"}},
|
||||
{`'hello world'`, []string{"hello world"}},
|
||||
{`hello "world foo" bar`, []string{"hello", "world foo", "bar"}},
|
||||
{`hello 'world foo' bar`, []string{"hello", "world foo", "bar"}},
|
||||
{`hello \"world\"`, []string{"hello", `"world"`}},
|
||||
{`hello \\world`, []string{"hello", `\world`}},
|
||||
{` hello world `, []string{"hello", "world"}},
|
||||
{`Button "onClick handler" "disabled support"`, []string{"Button", "onClick handler", "disabled support"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := ParseCommandArgs(tt.input)
|
||||
if len(got) != len(tt.expected) {
|
||||
t.Errorf("ParseCommandArgs(%q) = %v, want %v", tt.input, got, tt.expected)
|
||||
return
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.expected[i] {
|
||||
t.Errorf("ParseCommandArgs(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.expected[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubstituteArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
args []string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "no placeholders",
|
||||
content: "Hello world",
|
||||
args: []string{},
|
||||
expected: "Hello world",
|
||||
},
|
||||
{
|
||||
name: "positional $1",
|
||||
content: "Hello $1",
|
||||
args: []string{"world"},
|
||||
expected: "Hello world",
|
||||
},
|
||||
{
|
||||
name: "positional $1 $2",
|
||||
content: "$1 and $2",
|
||||
args: []string{"first", "second"},
|
||||
expected: "first and second",
|
||||
},
|
||||
{
|
||||
name: "missing arg",
|
||||
content: "Hello $1 and $2",
|
||||
args: []string{"world"},
|
||||
expected: "Hello world and ",
|
||||
},
|
||||
{
|
||||
name: "$@ wildcard",
|
||||
content: "Args: $@",
|
||||
args: []string{"a", "b", "c"},
|
||||
expected: "Args: a b c",
|
||||
},
|
||||
{
|
||||
name: "$ARGUMENTS wildcard",
|
||||
content: "Args: $ARGUMENTS",
|
||||
args: []string{"a", "b", "c"},
|
||||
expected: "Args: a b c",
|
||||
},
|
||||
{
|
||||
name: "${@} all args",
|
||||
content: "Args: ${@}",
|
||||
args: []string{"a", "b", "c"},
|
||||
expected: "Args: a b c",
|
||||
},
|
||||
{
|
||||
name: "${@:2} slice from index 2",
|
||||
content: "Rest: ${@:2}",
|
||||
args: []string{"a", "b", "c", "d"},
|
||||
expected: "Rest: b c d",
|
||||
},
|
||||
{
|
||||
name: "${@:1:2} slice with length",
|
||||
content: "First two: ${@:1:2}",
|
||||
args: []string{"a", "b", "c", "d"},
|
||||
expected: "First two: a b",
|
||||
},
|
||||
{
|
||||
name: "${@:0} from start",
|
||||
content: "All: ${@:0}",
|
||||
args: []string{"a", "b", "c"},
|
||||
expected: "All: a b c",
|
||||
},
|
||||
{
|
||||
name: "${@:3:1} single arg",
|
||||
content: "Third: ${@:3:1}",
|
||||
args: []string{"a", "b", "c", "d"},
|
||||
expected: "Third: c",
|
||||
},
|
||||
{
|
||||
name: "combined placeholders",
|
||||
content: "Create $1 with features: $ARGUMENTS",
|
||||
args: []string{"Button", "onClick", "disabled"},
|
||||
expected: "Create Button with features: Button onClick disabled",
|
||||
},
|
||||
{
|
||||
name: "slice beyond bounds",
|
||||
content: "${@:10}",
|
||||
args: []string{"a", "b"},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "empty args with wildcard",
|
||||
content: "Args: $@",
|
||||
args: []string{},
|
||||
expected: "Args: ",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := SubstituteArgs(tt.content, tt.args)
|
||||
if got != tt.expected {
|
||||
t.Errorf("SubstituteArgs(%q, %v) = %q, want %q", tt.content, tt.args, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFrontmatter(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
wantDesc string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "simple description",
|
||||
content: "description: Review code\n",
|
||||
wantDesc: "Review code",
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
content: "",
|
||||
wantDesc: "",
|
||||
},
|
||||
{
|
||||
name: "invalid yaml",
|
||||
content: "description: [unclosed",
|
||||
wantDesc: "",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fm, err := ParseFrontmatter(tt.content)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ParseFrontmatter() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if fm.Description != tt.wantDesc {
|
||||
t.Errorf("ParseFrontmatter() Description = %q, want %q", fm.Description, tt.wantDesc)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptTemplateExpand(t *testing.T) {
|
||||
tpl := &PromptTemplate{
|
||||
Name: "component",
|
||||
Description: "Create a component",
|
||||
Content: "Create a React component named $1 with features: $ARGUMENTS",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
input: "Button",
|
||||
expected: "Create a React component named Button with features: Button",
|
||||
},
|
||||
{
|
||||
input: `Button "onClick handler"`,
|
||||
expected: "Create a React component named Button with features: Button onClick handler",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := tpl.Expand(tt.input)
|
||||
if got != tt.expected {
|
||||
t.Errorf("Expand(%q) = %q, want %q", tt.input, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+36
-11
@@ -23,6 +23,7 @@ const (
|
||||
EntryTypeLabel EntryType = "label"
|
||||
EntryTypeSessionInfo EntryType = "session_info"
|
||||
EntryTypeExtensionData EntryType = "extension_data"
|
||||
EntryTypeCompaction EntryType = "compaction"
|
||||
)
|
||||
|
||||
// CurrentVersion is the session format version for JSONL tree sessions.
|
||||
@@ -102,6 +103,20 @@ type ExtensionDataEntry struct {
|
||||
Data string `json:"data"` // Extension-defined data (JSON or plain text)
|
||||
}
|
||||
|
||||
// CompactionEntry records an LLM-generated summary of older messages.
|
||||
// Instead of deleting old messages, the tree manager skips entries before
|
||||
// FirstKeptEntryID when building the LLM context, preserving full history.
|
||||
type CompactionEntry struct {
|
||||
Entry
|
||||
Summary string `json:"summary"`
|
||||
FirstKeptEntryID string `json:"first_kept_entry_id"`
|
||||
TokensBefore int `json:"tokens_before"`
|
||||
TokensAfter int `json:"tokens_after"`
|
||||
MessagesRemoved int `json:"messages_removed"`
|
||||
ReadFiles []string `json:"read_files,omitempty"`
|
||||
ModifiedFiles []string `json:"modified_files,omitempty"`
|
||||
}
|
||||
|
||||
// GenerateEntryID creates a unique entry identifier (16 hex chars).
|
||||
func GenerateEntryID() string {
|
||||
bytes := make([]byte, 8)
|
||||
@@ -144,17 +159,6 @@ func NewMessageEntry(parentID string, msg message.Message) (*MessageEntry, error
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewMessageEntryFromRaw creates a MessageEntry with pre-marshaled parts.
|
||||
func NewMessageEntryFromRaw(parentID, role string, parts json.RawMessage, model, provider string) *MessageEntry {
|
||||
return &MessageEntry{
|
||||
Entry: NewEntry(EntryTypeMessage, parentID),
|
||||
Role: role,
|
||||
Parts: parts,
|
||||
Model: model,
|
||||
Provider: provider,
|
||||
}
|
||||
}
|
||||
|
||||
// NewModelChangeEntry creates a ModelChangeEntry.
|
||||
func NewModelChangeEntry(parentID, provider, modelID string) *ModelChangeEntry {
|
||||
return &ModelChangeEntry{
|
||||
@@ -199,6 +203,20 @@ func NewExtensionDataEntry(parentID, extType, data string) *ExtensionDataEntry {
|
||||
}
|
||||
}
|
||||
|
||||
// NewCompactionEntry creates a CompactionEntry.
|
||||
func NewCompactionEntry(parentID, summary, firstKeptEntryID string, tokensBefore, tokensAfter, messagesRemoved int, readFiles, modifiedFiles []string) *CompactionEntry {
|
||||
return &CompactionEntry{
|
||||
Entry: NewEntry(EntryTypeCompaction, parentID),
|
||||
Summary: summary,
|
||||
FirstKeptEntryID: firstKeptEntryID,
|
||||
TokensBefore: tokensBefore,
|
||||
TokensAfter: tokensAfter,
|
||||
MessagesRemoved: messagesRemoved,
|
||||
ReadFiles: readFiles,
|
||||
ModifiedFiles: modifiedFiles,
|
||||
}
|
||||
}
|
||||
|
||||
// --- JSONL marshaling helpers ---
|
||||
|
||||
// MarshalEntry serializes any entry to a JSON line (no trailing newline).
|
||||
@@ -270,6 +288,13 @@ func UnmarshalEntry(data []byte) (any, error) {
|
||||
}
|
||||
return &e, nil
|
||||
|
||||
case EntryTypeCompaction:
|
||||
var e CompactionEntry
|
||||
if err := json.Unmarshal(data, &e); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal compaction entry: %w", err)
|
||||
}
|
||||
return &e, nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown entry type: %q", env.Type)
|
||||
}
|
||||
|
||||
@@ -253,27 +253,3 @@ func extractTextPreview(partsJSON json.RawMessage) string {
|
||||
func DeleteSession(path string) error {
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
// ListChildSessions returns all sessions that have the given session ID as
|
||||
// their parent. This is useful for finding subagent sessions spawned from
|
||||
// a parent session. Results are sorted by creation time (newest first).
|
||||
func ListChildSessions(parentID string) ([]SessionInfo, error) {
|
||||
if parentID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
allSessions, err := ListAllSessions()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var children []SessionInfo
|
||||
for _, s := range allSessions {
|
||||
if s.ParentSessionID == parentID {
|
||||
children = append(children, s)
|
||||
}
|
||||
}
|
||||
|
||||
// Already sorted by modification time from ListAllSessions
|
||||
return children, nil
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -128,10 +129,34 @@ func OpenTreeSession(path string) (*TreeManager, error) {
|
||||
filePath: path,
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(strings.NewReader(string(data)))
|
||||
reader := bufio.NewReader(strings.NewReader(string(data)))
|
||||
lineNum := 0
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
// Process the last line if it's not empty
|
||||
if strings.TrimSpace(line) != "" {
|
||||
lineNum++
|
||||
entry, err := UnmarshalEntry([]byte(line))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("line %d: %w", lineNum, err)
|
||||
}
|
||||
if lineNum == 1 {
|
||||
h, ok := entry.(*SessionHeader)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("first line must be a session header, got %T", entry)
|
||||
}
|
||||
tm.header = *h
|
||||
} else {
|
||||
tm.addEntryToIndex(entry)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read session file: %w", err)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
@@ -153,9 +178,6 @@ func OpenTreeSession(path string) (*TreeManager, error) {
|
||||
|
||||
tm.addEntryToIndex(entry)
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan session file: %w", err)
|
||||
}
|
||||
|
||||
// Set leaf to the last entry.
|
||||
if len(tm.entries) > 0 {
|
||||
@@ -298,6 +320,22 @@ func (tm *TreeManager) AppendExtensionData(extType, data string) (string, error)
|
||||
return entry.ID, nil
|
||||
}
|
||||
|
||||
// AppendCompaction adds a compaction entry to the tree. The entry records
|
||||
// the summary and the ID of the first entry that should be preserved in the
|
||||
// LLM context. Messages before that entry are replaced by the summary.
|
||||
func (tm *TreeManager) AppendCompaction(summary, firstKeptEntryID string, tokensBefore, tokensAfter, messagesRemoved int, readFiles, modifiedFiles []string) (string, error) {
|
||||
tm.mu.Lock()
|
||||
defer tm.mu.Unlock()
|
||||
|
||||
entry := NewCompactionEntry(tm.leafID, summary, firstKeptEntryID, tokensBefore, tokensAfter, messagesRemoved, readFiles, modifiedFiles)
|
||||
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.
|
||||
@@ -441,8 +479,9 @@ func (tm *TreeManager) GetTree() []*TreeNode {
|
||||
// --- Context building ---
|
||||
|
||||
// BuildContext walks from the current leaf to the root and returns the
|
||||
// conversation messages suitable for sending to the LLM. Branch summaries
|
||||
// are converted to user messages to provide context from abandoned branches.
|
||||
// conversation messages suitable for sending to the LLM. Compaction entries
|
||||
// cause older messages to be replaced by the summary. Branch summaries are
|
||||
// converted to user messages to provide context from abandoned branches.
|
||||
// Also returns the latest model/provider settings encountered on the path.
|
||||
func (tm *TreeManager) BuildContext() (messages []fantasy.Message, provider string, modelID string) {
|
||||
tm.mu.RLock()
|
||||
@@ -455,7 +494,41 @@ func (tm *TreeManager) BuildContext() (messages []fantasy.Message, provider stri
|
||||
// Walk from leaf to root collecting entries.
|
||||
branch := tm.getBranchLocked(tm.leafID)
|
||||
|
||||
// Find the last compaction entry on this branch — it determines
|
||||
// which older messages are replaced by the summary.
|
||||
var lastCompaction *CompactionEntry
|
||||
for i := len(branch) - 1; i >= 0; i-- {
|
||||
if c, ok := branch[i].(*CompactionEntry); ok {
|
||||
lastCompaction = c
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If there is a compaction, inject the summary first.
|
||||
if lastCompaction != nil {
|
||||
messages = append(messages, fantasy.Message{
|
||||
Role: fantasy.MessageRoleSystem,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{
|
||||
Text: fmt.Sprintf("[Conversation summary — earlier messages were compacted]\n\n%s", lastCompaction.Summary),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Determine whether to skip entries (everything before firstKeptEntryID).
|
||||
skipping := lastCompaction != nil
|
||||
for _, entry := range branch {
|
||||
// Once we reach the first kept entry, stop skipping.
|
||||
if skipping {
|
||||
entryID := tm.entryID(entry)
|
||||
if entryID == lastCompaction.FirstKeptEntryID {
|
||||
skipping = false
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
switch e := entry.(type) {
|
||||
case *MessageEntry:
|
||||
msg, err := e.ToMessage()
|
||||
@@ -481,6 +554,10 @@ func (tm *TreeManager) BuildContext() (messages []fantasy.Message, provider stri
|
||||
case *ModelChangeEntry:
|
||||
provider = e.Provider
|
||||
modelID = e.ModelID
|
||||
|
||||
case *CompactionEntry:
|
||||
// Already handled above (the last one on the branch).
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -563,6 +640,96 @@ func (tm *TreeManager) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetContextEntryIDs returns the entry IDs corresponding to the fantasy
|
||||
// messages returned by BuildContext, in the same order. Each entry ID maps
|
||||
// to the session entry that produced the fantasy message at the same index.
|
||||
// This is used by compaction to map a cut point index back to an entry ID.
|
||||
//
|
||||
// Note: A single MessageEntry produces at most one fantasy message. Branch
|
||||
// summary entries also produce one message each. The returned slice has the
|
||||
// same length as the messages slice from BuildContext (excluding the
|
||||
// compaction summary system message, which has no entry ID — it gets the
|
||||
// empty string "").
|
||||
func (tm *TreeManager) GetContextEntryIDs() []string {
|
||||
tm.mu.RLock()
|
||||
defer tm.mu.RUnlock()
|
||||
|
||||
if tm.leafID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
branch := tm.getBranchLocked(tm.leafID)
|
||||
|
||||
// Find the last compaction entry for skip logic.
|
||||
var lastCompaction *CompactionEntry
|
||||
for i := len(branch) - 1; i >= 0; i-- {
|
||||
if c, ok := branch[i].(*CompactionEntry); ok {
|
||||
lastCompaction = c
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var ids []string
|
||||
|
||||
// If there's a compaction summary injected, it has no entry ID.
|
||||
if lastCompaction != nil {
|
||||
ids = append(ids, "") // placeholder for the summary system message
|
||||
}
|
||||
|
||||
skipping := lastCompaction != nil
|
||||
for _, entry := range branch {
|
||||
if skipping {
|
||||
entryID := tm.entryID(entry)
|
||||
if entryID == lastCompaction.FirstKeptEntryID {
|
||||
skipping = false
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
switch e := entry.(type) {
|
||||
case *MessageEntry:
|
||||
msg, err := e.ToMessage()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
msgs := msg.ToFantasyMessages()
|
||||
for range msgs {
|
||||
ids = append(ids, e.ID)
|
||||
}
|
||||
|
||||
case *BranchSummaryEntry:
|
||||
if e.Summary != "" {
|
||||
ids = append(ids, e.ID)
|
||||
}
|
||||
|
||||
case *CompactionEntry:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
// GetLastCompaction returns the most recent CompactionEntry on the current
|
||||
// branch, or nil if none exists. Used to carry forward file tracking.
|
||||
func (tm *TreeManager) GetLastCompaction() *CompactionEntry {
|
||||
tm.mu.RLock()
|
||||
defer tm.mu.RUnlock()
|
||||
|
||||
if tm.leafID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
branch := tm.getBranchLocked(tm.leafID)
|
||||
for i := len(branch) - 1; i >= 0; i-- {
|
||||
if c, ok := branch[i].(*CompactionEntry); ok {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Legacy bridge ---
|
||||
|
||||
// AddFantasyMessages appends multiple fantasy messages as entries. This is
|
||||
@@ -641,6 +808,8 @@ func (tm *TreeManager) entryID(entry any) string {
|
||||
return e.ID
|
||||
case *ExtensionDataEntry:
|
||||
return e.ID
|
||||
case *CompactionEntry:
|
||||
return e.ID
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
@@ -661,6 +830,8 @@ func (tm *TreeManager) entryParentID(entry any) string {
|
||||
return e.ParentID
|
||||
case *ExtensionDataEntry:
|
||||
return e.ParentID
|
||||
case *CompactionEntry:
|
||||
return e.ParentID
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -560,9 +560,10 @@ func TestStreamComponent_SpinnerTick_AdvancesFrame(t *testing.T) {
|
||||
// Start spinning first.
|
||||
c = sendStreamMsg(c, app.SpinnerEvent{Show: true})
|
||||
initialFrame := c.spinnerFrame
|
||||
gen := c.spinnerGeneration
|
||||
|
||||
// Send a tick.
|
||||
_, cmd := c.Update(streamSpinnerTickMsg{})
|
||||
// Send a tick with the current generation.
|
||||
_, cmd := c.Update(streamSpinnerTickMsg{generation: gen})
|
||||
|
||||
if c.spinnerFrame != initialFrame+1 {
|
||||
t.Fatalf("expected spinnerFrame=%d, got %d", initialFrame+1, c.spinnerFrame)
|
||||
@@ -583,3 +584,40 @@ func TestStreamComponent_SpinnerTick_NoReschedule_WhenNotSpinning(t *testing.T)
|
||||
t.Fatal("expected no tick reschedule when not spinning")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamComponent_StaleTick_Discarded verifies that a tick from a previous
|
||||
// spinner generation is silently discarded, preventing duplicate concurrent
|
||||
// tick loops that would double the animation speed.
|
||||
func TestStreamComponent_StaleTick_Discarded(t *testing.T) {
|
||||
c := newTestStream()
|
||||
|
||||
// Start spinner → generation 1.
|
||||
c = sendStreamMsg(c, app.SpinnerEvent{Show: true})
|
||||
staleGen := c.spinnerGeneration
|
||||
|
||||
// Stop spinner → generation bumped to 2.
|
||||
c = sendStreamMsg(c, app.SpinnerEvent{Show: false})
|
||||
|
||||
// Restart spinner → generation bumped to 3.
|
||||
c = sendStreamMsg(c, app.SpinnerEvent{Show: true})
|
||||
currentGen := c.spinnerGeneration
|
||||
frameBefore := c.spinnerFrame
|
||||
|
||||
// Simulate a stale tick from the first spinner session arriving.
|
||||
_, cmd := c.Update(streamSpinnerTickMsg{generation: staleGen})
|
||||
if c.spinnerFrame != frameBefore {
|
||||
t.Fatalf("stale tick should not advance frame: expected %d, got %d", frameBefore, c.spinnerFrame)
|
||||
}
|
||||
if cmd != nil {
|
||||
t.Fatal("stale tick should not reschedule")
|
||||
}
|
||||
|
||||
// A tick from the current generation should still work.
|
||||
_, cmd = c.Update(streamSpinnerTickMsg{generation: currentGen})
|
||||
if c.spinnerFrame != frameBefore+1 {
|
||||
t.Fatalf("current-gen tick should advance frame: expected %d, got %d", frameBefore+1, c.spinnerFrame)
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("current-gen tick should reschedule")
|
||||
}
|
||||
}
|
||||
|
||||
+2
-9
@@ -36,7 +36,7 @@ func NewCLI(debug bool, compact bool) (*CLI, error) {
|
||||
if compact {
|
||||
cli.renderer = NewCompactRenderer(cli.width, debug)
|
||||
} else {
|
||||
cli.renderer = NewMessageRenderer(cli.width, debug)
|
||||
cli.renderer = newMessageRenderer(cli.width, debug)
|
||||
}
|
||||
|
||||
return cli, nil
|
||||
@@ -108,13 +108,6 @@ func (c *CLI) DisplayAssistantMessageWithModel(message, modelName string) error
|
||||
return nil
|
||||
}
|
||||
|
||||
// DisplayToolCallMessage is a no-op retained for backward compatibility. Tool
|
||||
// calls are now rendered as part of the unified tool block in DisplayToolMessage,
|
||||
// which combines the invocation header with the execution result.
|
||||
func (c *CLI) DisplayToolCallMessage(toolName, toolArgs string) {
|
||||
// No-op: unified tool blocks are rendered in DisplayToolMessage.
|
||||
}
|
||||
|
||||
// DisplayToolMessage renders and displays the complete result of a tool execution,
|
||||
// including the tool name, arguments, and result. The isError parameter determines
|
||||
// whether the result should be displayed as an error or success message.
|
||||
@@ -141,7 +134,7 @@ func (c *CLI) DisplayInfo(message string) {
|
||||
func (c *CLI) DisplayExtensionBlock(text, borderColor, subtitle string) {
|
||||
theme := GetTheme()
|
||||
|
||||
var borderClr = lipgloss.Color("#89b4fa")
|
||||
borderClr := theme.Info
|
||||
if borderColor != "" {
|
||||
borderClr = lipgloss.Color(borderColor)
|
||||
}
|
||||
|
||||
@@ -94,6 +94,24 @@ var SlashCommands = []SlashCommand{
|
||||
return matches
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "/theme",
|
||||
Description: "Switch color theme (e.g. /theme catppuccin)",
|
||||
Category: "System",
|
||||
Complete: func(prefix string) []string {
|
||||
names := ListThemes()
|
||||
if prefix == "" {
|
||||
return names
|
||||
}
|
||||
var matches []string
|
||||
for _, n := range names {
|
||||
if strings.HasPrefix(n, strings.ToLower(prefix)) {
|
||||
matches = append(matches, n)
|
||||
}
|
||||
}
|
||||
return matches
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "/quit",
|
||||
Description: "Exit the application",
|
||||
@@ -123,6 +141,27 @@ var SlashCommands = []SlashCommand{
|
||||
Description: "Set a display name for this session",
|
||||
Category: "Navigation",
|
||||
},
|
||||
{
|
||||
Name: "/resume",
|
||||
Description: "Open session picker to switch sessions",
|
||||
Category: "Navigation",
|
||||
Aliases: []string{"/r"},
|
||||
},
|
||||
{
|
||||
Name: "/export",
|
||||
Description: "Export session (JSONL by default, or /export path.jsonl)",
|
||||
Category: "System",
|
||||
},
|
||||
{
|
||||
Name: "/share",
|
||||
Description: "Share session via GitHub Gist (requires gh CLI)",
|
||||
Category: "System",
|
||||
},
|
||||
{
|
||||
Name: "/import",
|
||||
Description: "Import a session from a JSONL file (/import path.jsonl)",
|
||||
Category: "System",
|
||||
},
|
||||
{
|
||||
Name: "/session",
|
||||
Description: "Show session info and statistics",
|
||||
|
||||
@@ -82,9 +82,20 @@ func (r *CompactRenderer) RenderUserMessage(content string, timestamp time.Time)
|
||||
}
|
||||
|
||||
// RenderAssistantMessage renders an AI assistant's response in compact format with
|
||||
// a distinctive symbol (<) and the model name as label. Empty content is displayed
|
||||
// as "(no output)". Returns a UIMessage with formatted content and metadata.
|
||||
// a distinctive symbol (<) and the model name as label. Empty content is ignored
|
||||
// and returns an empty message. Returns a UIMessage with formatted content and metadata.
|
||||
func (r *CompactRenderer) RenderAssistantMessage(content string, timestamp time.Time, modelName string) UIMessage {
|
||||
// Ignore empty responses - don't render anything
|
||||
compactContent := r.formatUserAssistantContent(content)
|
||||
if compactContent == "" {
|
||||
return UIMessage{
|
||||
Type: AssistantMessage,
|
||||
Content: "",
|
||||
Height: 0,
|
||||
Timestamp: timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
theme := getTheme()
|
||||
symbol := lipgloss.NewStyle().Foreground(theme.Primary).Render("<")
|
||||
|
||||
@@ -94,12 +105,6 @@ func (r *CompactRenderer) RenderAssistantMessage(content string, timestamp time.
|
||||
}
|
||||
label := lipgloss.NewStyle().Foreground(theme.Primary).Bold(true).Render(modelName)
|
||||
|
||||
// Format content for assistant messages (preserve formatting, no truncation)
|
||||
compactContent := r.formatUserAssistantContent(content)
|
||||
if compactContent == "" {
|
||||
compactContent = lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render("(no output)")
|
||||
}
|
||||
|
||||
// Handle multi-line content
|
||||
lines := strings.Split(compactContent, "\n")
|
||||
var formattedLines []string
|
||||
|
||||
@@ -39,9 +39,26 @@ func SetTheme(theme Theme) {
|
||||
currentTheme = theme
|
||||
}
|
||||
|
||||
// MarkdownThemeColors defines colors for markdown rendering and syntax highlighting.
|
||||
type MarkdownThemeColors struct {
|
||||
Text color.Color
|
||||
Muted color.Color
|
||||
Heading color.Color
|
||||
Emph color.Color
|
||||
Strong color.Color
|
||||
Link color.Color
|
||||
Code color.Color
|
||||
Error color.Color
|
||||
Keyword color.Color
|
||||
String color.Color
|
||||
Number color.Color
|
||||
Comment color.Color
|
||||
}
|
||||
|
||||
// Theme defines a comprehensive color scheme for the application's UI, supporting
|
||||
// both light and dark terminal modes through adaptive colors. It includes semantic
|
||||
// colors for different message types and UI elements, based on the Catppuccin color palette.
|
||||
// both light and dark terminal modes through adaptive colors. Inspired by the
|
||||
// Knight Rider KITT aesthetic — scanner reds, amber dashboard glows, and dark
|
||||
// cockpit tones.
|
||||
type Theme struct {
|
||||
Primary color.Color
|
||||
Secondary color.Color
|
||||
@@ -70,40 +87,60 @@ type Theme struct {
|
||||
CodeBg color.Color // Background for code blocks (Read tool)
|
||||
GutterBg color.Color // Line-number gutter background
|
||||
WriteBg color.Color // Green-tinted bg for Write tool content
|
||||
|
||||
// Markdown rendering and syntax highlighting colors
|
||||
Markdown MarkdownThemeColors
|
||||
}
|
||||
|
||||
// DefaultTheme creates and returns the default KIT theme based on the Catppuccin
|
||||
// Mocha (dark) and Latte (light) color palettes. This theme provides a cohesive,
|
||||
// pleasant visual experience with carefully selected colors for different UI elements.
|
||||
// DefaultTheme creates and returns the default KIT theme inspired by the
|
||||
// Knight Rider KITT aesthetic — scanner reds, amber dashboard glows, and a
|
||||
// dark cockpit. No blues or bright greens; everything stays in the warm
|
||||
// red/amber/gray family of KITT's instrument panel.
|
||||
func DefaultTheme() Theme {
|
||||
return Theme{
|
||||
Primary: AdaptiveColor("#8839ef", "#cba6f7"), // Latte/Mocha Mauve
|
||||
Secondary: AdaptiveColor("#04a5e5", "#89dceb"), // Latte/Mocha Sky
|
||||
Success: AdaptiveColor("#40a02b", "#a6e3a1"), // Latte/Mocha Green
|
||||
Warning: AdaptiveColor("#df8e1d", "#f9e2af"), // Latte/Mocha Yellow
|
||||
Error: AdaptiveColor("#d20f39", "#f38ba8"), // Latte/Mocha Red
|
||||
Info: AdaptiveColor("#1e66f5", "#89b4fa"), // Latte/Mocha Blue
|
||||
Text: AdaptiveColor("#4c4f69", "#cdd6f4"), // Latte/Mocha Text
|
||||
Muted: AdaptiveColor("#6c6f85", "#a6adc8"), // Latte/Mocha Subtext 0
|
||||
VeryMuted: AdaptiveColor("#9ca0b0", "#6c7086"), // Latte/Mocha Overlay 0
|
||||
Background: AdaptiveColor("#eff1f5", "#1e1e2e"), // Latte/Mocha Base
|
||||
Border: AdaptiveColor("#acb0be", "#585b70"), // Latte/Mocha Surface 2
|
||||
MutedBorder: AdaptiveColor("#ccd0da", "#313244"), // Latte/Mocha Surface 0
|
||||
System: AdaptiveColor("#179299", "#94e2d5"), // Latte/Mocha Teal
|
||||
Tool: AdaptiveColor("#fe640b", "#fab387"), // Latte/Mocha Peach
|
||||
Accent: AdaptiveColor("#ea76cb", "#f5c2e7"), // Latte/Mocha Pink
|
||||
Highlight: AdaptiveColor("#e6e9ef", "#181825"), // Latte Mantle / Mocha Mantle
|
||||
Primary: AdaptiveColor("#CC1100", "#FF2200"), // KITT scanner red
|
||||
Secondary: AdaptiveColor("#CC6600", "#FF8800"), // Amber dashboard glow
|
||||
Success: AdaptiveColor("#998800", "#CCAA00"), // Warm gold — system OK
|
||||
Warning: AdaptiveColor("#CC8800", "#FFB800"), // Amber caution light
|
||||
Error: AdaptiveColor("#CC0000", "#FF3333"), // Alert red
|
||||
Info: AdaptiveColor("#BB6600", "#DD8833"), // Warm amber readout
|
||||
Text: AdaptiveColor("#1A1A1A", "#E0E0E0"), // Console text
|
||||
Muted: AdaptiveColor("#707070", "#808080"), // Dimmed readout
|
||||
VeryMuted: AdaptiveColor("#A0A0A0", "#505050"), // Inactive element
|
||||
Background: AdaptiveColor("#F0F0F0", "#0D0D0D"), // Cockpit interior
|
||||
Border: AdaptiveColor("#B0B0B0", "#3A3A3A"), // Panel edge
|
||||
MutedBorder: AdaptiveColor("#D0D0D0", "#222222"), // Subtle divider
|
||||
System: AdaptiveColor("#CC6600", "#FF8800"), // Amber system status
|
||||
Tool: AdaptiveColor("#CC6600", "#FF8800"), // Amber instrument
|
||||
Accent: AdaptiveColor("#DD2222", "#FF4444"), // Secondary scanner glow
|
||||
Highlight: AdaptiveColor("#FFF0F0", "#1A1010"), // Red-tinted mantle
|
||||
|
||||
// Diff backgrounds — subtle tinted variants of the base palette
|
||||
DiffInsertBg: AdaptiveColor("#d5f0d5", "#1a3a2a"), // Green tint
|
||||
DiffDeleteBg: AdaptiveColor("#f5d5d5", "#3a1a2a"), // Red tint
|
||||
DiffEqualBg: AdaptiveColor("#eceef3", "#232336"), // Neutral
|
||||
DiffMissingBg: AdaptiveColor("#e4e6eb", "#1a1a2e"), // Darker neutral
|
||||
// Diff backgrounds
|
||||
DiffInsertBg: AdaptiveColor("#F0E8D0", "#2A2410"), // Warm amber tint (added)
|
||||
DiffDeleteBg: AdaptiveColor("#F5D5D5", "#2E1A1A"), // Red tint (removed)
|
||||
DiffEqualBg: AdaptiveColor("#E8E8E8", "#161616"), // Neutral
|
||||
DiffMissingBg: AdaptiveColor("#E0E0E0", "#111111"), // Darker neutral
|
||||
|
||||
// Code & output backgrounds
|
||||
CodeBg: AdaptiveColor("#eceef3", "#232336"), // Matches DiffEqualBg
|
||||
GutterBg: AdaptiveColor("#e4e6eb", "#1a1a2e"), // Slightly darker
|
||||
WriteBg: AdaptiveColor("#d5f0d5", "#1a3a2a"), // Matches DiffInsertBg (green tint)
|
||||
CodeBg: AdaptiveColor("#E8E8E8", "#161616"), // Matches DiffEqualBg
|
||||
GutterBg: AdaptiveColor("#E0E0E0", "#111111"), // Slightly darker
|
||||
WriteBg: AdaptiveColor("#F0E8D0", "#2A2410"), // Warm amber tint
|
||||
|
||||
// Markdown & syntax highlighting — all warm tones
|
||||
Markdown: MarkdownThemeColors{
|
||||
Text: AdaptiveColor("#1A1A1A", "#E0E0E0"), // Console text
|
||||
Muted: AdaptiveColor("#707070", "#808080"), // Dimmed readout
|
||||
Heading: AdaptiveColor("#CC1100", "#FF4444"), // Scanner red accent
|
||||
Emph: AdaptiveColor("#CC8800", "#FFB800"), // Amber emphasis
|
||||
Strong: AdaptiveColor("#1A1A1A", "#E0E0E0"), // Bright text
|
||||
Link: AdaptiveColor("#CC4400", "#FF7744"), // Warm orange link
|
||||
Code: AdaptiveColor("#333333", "#CCCCCC"), // Inline code
|
||||
Error: AdaptiveColor("#CC0000", "#FF3333"), // Alert red
|
||||
Keyword: AdaptiveColor("#CC3300", "#FF6644"), // Orange-red keyword
|
||||
String: AdaptiveColor("#BB7700", "#DDAA33"), // Amber string
|
||||
Number: AdaptiveColor("#CC8800", "#FFB800"), // Amber number
|
||||
Comment: AdaptiveColor("#909090", "#606060"), // Dark gray comment
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,8 +51,8 @@ func CreateUsageTracker(modelString, providerAPIKey string) *UsageTracker {
|
||||
}
|
||||
|
||||
registry := models.GetGlobalRegistry()
|
||||
modelInfo, err := registry.ValidateModel(provider, model)
|
||||
if err != nil {
|
||||
modelInfo := registry.LookupModel(provider, model)
|
||||
if modelInfo == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ func SetupCLI(opts *CLISetupOptions) (*CLI, error) {
|
||||
// Skip usage tracking for ollama as it's not in models.dev
|
||||
if provider != "ollama" {
|
||||
registry := models.GetGlobalRegistry()
|
||||
if modelInfo, err := registry.ValidateModel(provider, model); err == nil {
|
||||
if modelInfo := registry.LookupModel(provider, model); modelInfo != nil {
|
||||
// Check if OAuth credentials are being used for Anthropic models
|
||||
isOAuth := false
|
||||
if provider == "anthropic" {
|
||||
|
||||
+109
-16
@@ -68,8 +68,26 @@ type InputComponent struct {
|
||||
// pendingImages holds clipboard images attached to the next submission.
|
||||
// Images are added via Ctrl+V and cleared on submit or Ctrl+U.
|
||||
pendingImages []ImageAttachment
|
||||
|
||||
// history stores previously submitted prompts (most recent last).
|
||||
// Limited to maxHistory entries; duplicates of the previous entry are
|
||||
// skipped. Empty strings are never stored.
|
||||
history []string
|
||||
// historyIndex is the current position when browsing history.
|
||||
// When not browsing, historyIndex == len(history).
|
||||
historyIndex int
|
||||
// savedInput holds the user's in-progress text before they started
|
||||
// browsing history, so it can be restored when they press down past
|
||||
// the end of history.
|
||||
savedInput string
|
||||
// browsingHistory is true when the user is navigating history with
|
||||
// up/down arrows. Set to false when they type a character or submit.
|
||||
browsingHistory bool
|
||||
}
|
||||
|
||||
// maxHistory is the maximum number of prompt entries kept in history.
|
||||
const maxHistory = 100
|
||||
|
||||
// clipboardImageMsg is the result of an async clipboard image read.
|
||||
type clipboardImageMsg struct {
|
||||
image *ImageAttachment
|
||||
@@ -96,11 +114,12 @@ func NewInputComponent(width int, title string, appCtrl AppController) *InputCom
|
||||
key.WithHelp("ctrl+j", "insert newline"),
|
||||
)
|
||||
|
||||
// Style the textarea to match huh theme
|
||||
// Style the textarea using theme colors.
|
||||
theme := GetTheme()
|
||||
styles := ta.Styles()
|
||||
styles.Focused.Base = lipgloss.NewStyle()
|
||||
styles.Focused.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
|
||||
styles.Focused.Text = lipgloss.NewStyle().Foreground(lipgloss.Color("252"))
|
||||
styles.Focused.Placeholder = lipgloss.NewStyle().Foreground(theme.VeryMuted)
|
||||
styles.Focused.Text = lipgloss.NewStyle().Foreground(theme.Text)
|
||||
styles.Focused.Prompt = lipgloss.NewStyle()
|
||||
styles.Focused.CursorLine = lipgloss.NewStyle()
|
||||
ta.SetStyles(styles)
|
||||
@@ -137,6 +156,7 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if s.submitNext {
|
||||
s.submitNext = false
|
||||
value := s.textarea.Value()
|
||||
s.pushHistory(value)
|
||||
s.textarea.SetValue("")
|
||||
s.textarea.CursorEnd()
|
||||
s.showPopup = false
|
||||
@@ -165,10 +185,47 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "ctrl+d", "enter":
|
||||
value := s.textarea.Value()
|
||||
s.pushHistory(value)
|
||||
s.textarea.SetValue("")
|
||||
s.textarea.CursorEnd()
|
||||
s.lastValue = ""
|
||||
return s, s.handleSubmit(value)
|
||||
case "up":
|
||||
// Navigate prompt history backward (older entries).
|
||||
if len(s.history) > 0 {
|
||||
if !s.browsingHistory {
|
||||
// Start browsing — save current input.
|
||||
s.savedInput = s.textarea.Value()
|
||||
s.browsingHistory = true
|
||||
s.historyIndex = len(s.history)
|
||||
}
|
||||
if s.historyIndex > 0 {
|
||||
s.historyIndex--
|
||||
s.textarea.SetValue(s.history[s.historyIndex])
|
||||
s.textarea.CursorEnd()
|
||||
s.lastValue = s.textarea.Value()
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
case "down":
|
||||
// Navigate prompt history forward (newer entries).
|
||||
if s.browsingHistory {
|
||||
if s.historyIndex < len(s.history)-1 {
|
||||
s.historyIndex++
|
||||
s.textarea.SetValue(s.history[s.historyIndex])
|
||||
s.textarea.CursorEnd()
|
||||
s.lastValue = s.textarea.Value()
|
||||
} else {
|
||||
// Past the end — restore saved input.
|
||||
s.historyIndex = len(s.history)
|
||||
s.browsingHistory = false
|
||||
s.textarea.SetValue(s.savedInput)
|
||||
s.textarea.CursorEnd()
|
||||
s.lastValue = s.textarea.Value()
|
||||
s.savedInput = ""
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
case "ctrl+v":
|
||||
// Try to read an image from the clipboard asynchronously.
|
||||
return s, readClipboardImageCmd()
|
||||
@@ -249,6 +306,11 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
value := s.textarea.Value()
|
||||
if value != s.lastValue {
|
||||
s.lastValue = value
|
||||
// User typed something — exit history browsing mode.
|
||||
if s.browsingHistory {
|
||||
s.browsingHistory = false
|
||||
s.savedInput = ""
|
||||
}
|
||||
lines := strings.Split(value, "\n")
|
||||
line := lines[len(lines)-1] // current line (last line for multi-line)
|
||||
|
||||
@@ -371,14 +433,44 @@ func (s *InputComponent) handleSubmit(value string) tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
// pushHistory adds a prompt to the history ring buffer. Empty strings and
|
||||
// consecutive duplicates of the last entry are skipped. When the buffer
|
||||
// exceeds maxHistory, the oldest entry is dropped.
|
||||
func (s *InputComponent) pushHistory(value string) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return
|
||||
}
|
||||
// Skip consecutive duplicates.
|
||||
if len(s.history) > 0 && s.history[len(s.history)-1] == trimmed {
|
||||
s.resetHistoryBrowsing()
|
||||
return
|
||||
}
|
||||
s.history = append(s.history, trimmed)
|
||||
if len(s.history) > maxHistory {
|
||||
s.history = s.history[len(s.history)-maxHistory:]
|
||||
}
|
||||
s.resetHistoryBrowsing()
|
||||
}
|
||||
|
||||
// resetHistoryBrowsing resets the history browsing state so the index
|
||||
// points past the end (ready for new input).
|
||||
func (s *InputComponent) resetHistoryBrowsing() {
|
||||
s.historyIndex = len(s.history)
|
||||
s.browsingHistory = false
|
||||
s.savedInput = ""
|
||||
}
|
||||
|
||||
// View implements tea.Model. Renders the title, textarea, autocomplete popup
|
||||
// (if visible), and help text.
|
||||
func (s *InputComponent) View() tea.View {
|
||||
containerStyle := lipgloss.NewStyle()
|
||||
|
||||
theme := GetTheme()
|
||||
|
||||
// PaddingLeft(3) aligns with message content: border(1) + paddingLeft(2).
|
||||
titleStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("252")).
|
||||
Foreground(theme.Text).
|
||||
MarginBottom(1).
|
||||
PaddingLeft(3)
|
||||
|
||||
@@ -388,7 +480,7 @@ func (s *InputComponent) View() tea.View {
|
||||
BorderRight(false).
|
||||
BorderTop(false).
|
||||
BorderBottom(false).
|
||||
BorderForeground(lipgloss.Color("39")).
|
||||
BorderForeground(theme.Primary).
|
||||
PaddingLeft(2). // match message block paddingLeft
|
||||
Width(s.width - 1) // full width minus left border
|
||||
|
||||
@@ -405,7 +497,7 @@ func (s *InputComponent) View() tea.View {
|
||||
// Show image attachment indicator when images are pending.
|
||||
if len(s.pendingImages) > 0 {
|
||||
imgStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("39")).
|
||||
Foreground(theme.Secondary).
|
||||
PaddingLeft(3)
|
||||
|
||||
label := fmt.Sprintf("[%d image(s) attached] ctrl+u to clear", len(s.pendingImages))
|
||||
@@ -415,7 +507,7 @@ func (s *InputComponent) View() tea.View {
|
||||
|
||||
if !s.hideHint {
|
||||
helpStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("240")).
|
||||
Foreground(theme.VeryMuted).
|
||||
MarginTop(1).
|
||||
PaddingLeft(3)
|
||||
|
||||
@@ -440,10 +532,11 @@ func (s *InputComponent) View() tea.View {
|
||||
|
||||
// renderPopup renders the autocomplete popup for slash command suggestions.
|
||||
func (s *InputComponent) renderPopup() string {
|
||||
theme := GetTheme()
|
||||
popupWidth := max(s.width-4, 20)
|
||||
popupStyle := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("236")).
|
||||
BorderForeground(theme.MutedBorder).
|
||||
Padding(1, 2).
|
||||
Width(popupWidth).
|
||||
MarginLeft(0)
|
||||
@@ -466,16 +559,16 @@ func (s *InputComponent) renderPopup() string {
|
||||
|
||||
var indicator string
|
||||
if i == s.selected {
|
||||
indicator = lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Render("> ")
|
||||
indicator = lipgloss.NewStyle().Foreground(theme.Primary).Render("> ")
|
||||
} else {
|
||||
indicator = " "
|
||||
}
|
||||
|
||||
nameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Bold(true)
|
||||
descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("243"))
|
||||
nameStyle := lipgloss.NewStyle().Foreground(theme.Secondary).Bold(true)
|
||||
descStyle := lipgloss.NewStyle().Foreground(theme.Muted)
|
||||
if i == s.selected {
|
||||
nameStyle = nameStyle.Foreground(lipgloss.Color("87"))
|
||||
descStyle = descStyle.Foreground(lipgloss.Color("250"))
|
||||
nameStyle = nameStyle.Foreground(theme.Primary)
|
||||
descStyle = descStyle.Foreground(theme.Text)
|
||||
}
|
||||
|
||||
if s.fileMode {
|
||||
@@ -530,10 +623,10 @@ func (s *InputComponent) renderPopup() string {
|
||||
}
|
||||
|
||||
if startIdx > 0 {
|
||||
items = append([]string{lipgloss.NewStyle().Foreground(lipgloss.Color("238")).Render(" ↑ more above")}, items...)
|
||||
items = append([]string{lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(" ↑ more above")}, items...)
|
||||
}
|
||||
if endIdx < len(s.filtered) {
|
||||
items = append(items, lipgloss.NewStyle().Foreground(lipgloss.Color("238")).Render(" ↓ more below"))
|
||||
items = append(items, lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(" ↓ more below"))
|
||||
}
|
||||
|
||||
content := strings.Join(items, "\n")
|
||||
@@ -547,7 +640,7 @@ func (s *InputComponent) renderPopup() string {
|
||||
} else {
|
||||
footerText = "↑↓ tab ↵ esc"
|
||||
}
|
||||
footer := lipgloss.NewStyle().Foreground(lipgloss.Color("238")).Italic(true).
|
||||
footer := lipgloss.NewStyle().Foreground(theme.VeryMuted).Italic(true).
|
||||
Render(footerText)
|
||||
|
||||
return popupStyle.Render(content + "\n\n" + footer)
|
||||
|
||||
+26
-16
@@ -111,14 +111,25 @@ func formatToolParams(toolArgs string, maxWidth int) string {
|
||||
result.WriteString(primaryVal)
|
||||
}
|
||||
|
||||
// Collect remaining parameters (skip large values like file content)
|
||||
// Collect remaining parameters, skipping body-content keys (already
|
||||
// rendered in the tool body) and any values that are too large.
|
||||
bodyKeys := map[string]bool{
|
||||
"content": true,
|
||||
"old_text": true,
|
||||
"new_text": true,
|
||||
"oldText": true,
|
||||
"newText": true,
|
||||
"todos": true,
|
||||
}
|
||||
var remaining []string
|
||||
for key, val := range params {
|
||||
if key == primaryKey {
|
||||
continue
|
||||
}
|
||||
if bodyKeys[key] {
|
||||
continue
|
||||
}
|
||||
valStr := fmt.Sprintf("%v", val)
|
||||
// Skip very large values (e.g., oldString, newString, content, todos)
|
||||
if len(valStr) > 100 {
|
||||
continue
|
||||
}
|
||||
@@ -156,10 +167,10 @@ type MessageRenderer struct {
|
||||
getToolRenderer func(toolName string) *ToolRendererData
|
||||
}
|
||||
|
||||
// NewMessageRenderer creates and initializes a new MessageRenderer with the specified
|
||||
// newMessageRenderer creates and initializes a new MessageRenderer with the specified
|
||||
// terminal width and debug mode setting. The width parameter determines line wrapping
|
||||
// and layout calculations.
|
||||
func NewMessageRenderer(width int, debug bool) *MessageRenderer {
|
||||
func newMessageRenderer(width int, debug bool) *MessageRenderer {
|
||||
return &MessageRenderer{
|
||||
width: width,
|
||||
debug: debug,
|
||||
@@ -213,22 +224,21 @@ func (r *MessageRenderer) RenderUserMessage(content string, timestamp time.Time)
|
||||
|
||||
// RenderAssistantMessage renders an AI assistant's response with left-aligned formatting,
|
||||
// including the model name, timestamp, and markdown-rendered content. Empty responses
|
||||
// are displayed with a special "Finished without output" message. The message features
|
||||
// a colored left border for visual distinction.
|
||||
// are ignored and return an empty message. The message features a colored left border
|
||||
// for visual distinction.
|
||||
func (r *MessageRenderer) RenderAssistantMessage(content string, timestamp time.Time, modelName string) UIMessage {
|
||||
theme := getTheme()
|
||||
|
||||
var messageContent string
|
||||
// Ignore empty responses - don't render anything
|
||||
if strings.TrimSpace(content) == "" {
|
||||
messageContent = lipgloss.NewStyle().
|
||||
Italic(true).
|
||||
Foreground(theme.Muted).
|
||||
Align(lipgloss.Center).
|
||||
Render("Finished without output")
|
||||
} else {
|
||||
messageContent = r.renderMarkdown(content, r.width-8)
|
||||
return UIMessage{
|
||||
Type: AssistantMessage,
|
||||
Content: "",
|
||||
Height: 0,
|
||||
Timestamp: timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
theme := getTheme()
|
||||
messageContent := r.renderMarkdown(content, r.width-8)
|
||||
fullContent := strings.TrimSuffix(messageContent, "\n")
|
||||
|
||||
// Left border with Primary (Mauve) color for assistant messages.
|
||||
|
||||
+685
-24
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
@@ -15,7 +16,9 @@ import (
|
||||
|
||||
"github.com/mark3labs/kit/internal/app"
|
||||
"github.com/mark3labs/kit/internal/core"
|
||||
"github.com/mark3labs/kit/internal/message"
|
||||
"github.com/mark3labs/kit/internal/models"
|
||||
"github.com/mark3labs/kit/internal/prompts"
|
||||
"github.com/mark3labs/kit/internal/session"
|
||||
)
|
||||
|
||||
@@ -44,6 +47,9 @@ const (
|
||||
|
||||
// stateModelSelector means the /model selector overlay is active.
|
||||
stateModelSelector
|
||||
|
||||
// stateSessionSelector means the /resume session picker is active.
|
||||
stateSessionSelector
|
||||
)
|
||||
|
||||
// AppController is the interface the parent TUI model uses to interact with the
|
||||
@@ -245,6 +251,11 @@ type AppModelOptions struct {
|
||||
// appear in autocomplete, /help, and are dispatched when submitted.
|
||||
ExtensionCommands []ExtensionCommand
|
||||
|
||||
// PromptTemplates are user-defined prompt templates loaded from ~/.kit/prompts/,
|
||||
// .kit/prompts/, or explicit --prompt-template paths. They appear in autocomplete
|
||||
// and are expanded when submitted (e.g., /review → full prompt text).
|
||||
PromptTemplates []*prompts.PromptTemplate
|
||||
|
||||
// ContextPaths lists absolute paths of loaded context files (e.g.
|
||||
// AGENTS.md). Displayed in the [Context] startup section.
|
||||
ContextPaths []string
|
||||
@@ -330,6 +341,16 @@ type AppModelOptions struct {
|
||||
// May be nil if extensions are not loaded.
|
||||
EmitModelChange func(newModel, previousModel, source string)
|
||||
|
||||
// SwitchSession opens a session by JSONL file path, replacing the
|
||||
// active tree session and reloading messages. Called when the user
|
||||
// picks a session from /resume. May be nil if session switching is
|
||||
// not supported.
|
||||
SwitchSession func(path string) error
|
||||
|
||||
// ShowSessionPicker, when true, opens the session picker immediately
|
||||
// on startup (used by --resume flag).
|
||||
ShowSessionPicker bool
|
||||
|
||||
// ThinkingLevel is the initial thinking level (e.g. "off", "medium").
|
||||
ThinkingLevel string
|
||||
// IsReasoningModel is true when the current model supports reasoning.
|
||||
@@ -372,11 +393,9 @@ type AppModel struct {
|
||||
appCtrl AppController
|
||||
|
||||
// input is the child input component (slash commands + autocomplete).
|
||||
// Placeholder until InputComponent is implemented in TAS-15.
|
||||
input inputComponentIface
|
||||
|
||||
// stream is the child streaming display component (spinner + streaming text).
|
||||
// Placeholder until StreamComponent is implemented in TAS-16.
|
||||
stream streamComponentIface
|
||||
|
||||
// renderer renders completed messages for tea.Println output. It is either
|
||||
@@ -432,6 +451,10 @@ type AppModel struct {
|
||||
// handleExtensionCommand when submitted.
|
||||
extensionCommands []ExtensionCommand
|
||||
|
||||
// promptTemplates are user-defined prompt templates for expansion.
|
||||
// They appear in autocomplete and are expanded when submitted.
|
||||
promptTemplates []*prompts.PromptTemplate
|
||||
|
||||
// treeSelector is the tree navigation overlay, active in stateTreeSelector.
|
||||
treeSelector *TreeSelectorComponent
|
||||
|
||||
@@ -499,6 +522,13 @@ type AppModel struct {
|
||||
// modelSelector is the model selection overlay, active in stateModelSelector.
|
||||
modelSelector *ModelSelectorComponent
|
||||
|
||||
// sessionSelector is the session picker overlay, active in stateSessionSelector.
|
||||
sessionSelector *SessionSelectorComponent
|
||||
|
||||
// switchSession opens a session by JSONL path, replacing the active session.
|
||||
// Wired from cmd/root.go.
|
||||
switchSession func(path string) error
|
||||
|
||||
// prompt holds the state of an active interactive prompt overlay. Nil
|
||||
// when no prompt is active. Managed by updatePromptState().
|
||||
prompt *promptOverlay
|
||||
@@ -531,6 +561,16 @@ type AppModel struct {
|
||||
// width and height track the terminal dimensions.
|
||||
width int
|
||||
height int
|
||||
|
||||
// streamingBashOutput holds the current streaming bash output lines.
|
||||
// Lines are accumulated as they arrive and displayed in the stream region.
|
||||
streamingBashOutput []string
|
||||
// streamingBashStderr holds stderr lines separately (rendered differently).
|
||||
streamingBashStderr []string
|
||||
// streamingBashMaxLines caps how many lines to accumulate to prevent memory issues.
|
||||
streamingBashMaxLines int
|
||||
// streamingMu protects the streaming bash output fields from concurrent access.
|
||||
streamingMu sync.RWMutex
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -593,7 +633,7 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
|
||||
cr.getToolRenderer = opts.GetToolRenderer
|
||||
rdr = cr
|
||||
} else {
|
||||
mr := NewMessageRenderer(width, false)
|
||||
mr := newMessageRenderer(width, false)
|
||||
mr.getToolRenderer = opts.GetToolRenderer
|
||||
rdr = mr
|
||||
}
|
||||
@@ -616,6 +656,7 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
|
||||
|
||||
// Store extension commands for dispatch.
|
||||
m.extensionCommands = opts.ExtensionCommands
|
||||
m.promptTemplates = opts.PromptTemplates
|
||||
m.getWidgets = opts.GetWidgets
|
||||
m.getHeader = opts.GetHeader
|
||||
m.getFooter = opts.GetFooter
|
||||
@@ -632,6 +673,7 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
|
||||
m.thinkingVisible = true // default to showing thinking blocks
|
||||
m.isReasoningModel = opts.IsReasoningModel
|
||||
m.setThinkingLevel = opts.SetThinkingLevel
|
||||
m.switchSession = opts.SwitchSession
|
||||
|
||||
// Store context/skills metadata and tool counts for startup display.
|
||||
m.contextPaths = opts.ContextPaths
|
||||
@@ -639,6 +681,9 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
|
||||
m.mcpToolCount = opts.MCPToolCount
|
||||
m.extensionToolCount = opts.ExtensionToolCount
|
||||
|
||||
// Initialize streaming bash output buffer.
|
||||
m.streamingBashMaxLines = 50 // cap to prevent memory issues
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -659,9 +704,26 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
|
||||
}
|
||||
}
|
||||
|
||||
// Merge prompt templates into the InputComponent's autocomplete source.
|
||||
if ic, ok := m.input.(*InputComponent); ok && len(opts.PromptTemplates) > 0 {
|
||||
for _, tpl := range opts.PromptTemplates {
|
||||
ic.commands = append(ic.commands, SlashCommand{
|
||||
Name: "/" + tpl.Name,
|
||||
Description: tpl.Description,
|
||||
Category: "Prompts",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
m.stream = NewStreamComponent(opts.CompactMode, width, opts.ModelName)
|
||||
m.stream.SetThinkingVisible(m.thinkingVisible)
|
||||
|
||||
// If --resume was passed, open the session picker immediately.
|
||||
if opts.ShowSessionPicker {
|
||||
m.sessionSelector = NewSessionSelector(opts.Cwd, width, height)
|
||||
m.state = stateSessionSelector
|
||||
}
|
||||
|
||||
// Propagate initial height distribution to children.
|
||||
m.distributeHeight()
|
||||
|
||||
@@ -854,6 +916,8 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.modelName = parts[1]
|
||||
}
|
||||
m.printSystemMessage(fmt.Sprintf("Switched to %s", msg.ModelString))
|
||||
// Persist model selection for next launch.
|
||||
go func() { _ = SaveModelPreference(msg.ModelString) }()
|
||||
if m.emitModelChange != nil {
|
||||
emit := m.emitModelChange
|
||||
newModel := msg.ModelString
|
||||
@@ -870,6 +934,34 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.state = stateInput
|
||||
return m, nil
|
||||
|
||||
// ── Session selector events ──────────────────────────────────────────────
|
||||
case SessionSelectedMsg:
|
||||
m.sessionSelector = nil
|
||||
m.state = stateInput
|
||||
if m.switchSession != nil {
|
||||
if err := m.switchSession(msg.Path); err != nil {
|
||||
m.printSystemMessage(fmt.Sprintf("Failed to switch session: %v", err))
|
||||
} else {
|
||||
m.renderSessionHistory()
|
||||
m.printSystemMessage("Session loaded. Continue where you left off.")
|
||||
}
|
||||
} else {
|
||||
m.printSystemMessage("Session switching not available.")
|
||||
}
|
||||
cmds = append(cmds, m.drainScrollback())
|
||||
return m, tea.Batch(cmds...)
|
||||
|
||||
case SessionSelectorCancelledMsg:
|
||||
m.sessionSelector = nil
|
||||
m.state = stateInput
|
||||
return m, nil
|
||||
|
||||
case SessionDeletedMsg:
|
||||
// Session was deleted from picker — just show a message.
|
||||
m.printSystemMessage(fmt.Sprintf("Deleted session: %s", msg.Name))
|
||||
cmds = append(cmds, m.drainScrollback())
|
||||
return m, tea.Batch(cmds...)
|
||||
|
||||
// ── Window resize ────────────────────────────────────────────────────────
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
@@ -953,6 +1045,14 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// Route to session selector when active.
|
||||
if m.state == stateSessionSelector && m.sessionSelector != nil {
|
||||
updated, cmd := m.sessionSelector.Update(msg)
|
||||
m.sessionSelector = updated.(*SessionSelectorComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
if m.state == stateWorking {
|
||||
@@ -1061,6 +1161,30 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
cmds = append(cmds, m.drainScrollback())
|
||||
return m, tea.Batch(cmds...)
|
||||
case "/theme":
|
||||
if cmd := m.handleThemeCommand(strings.TrimSpace(args)); cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
cmds = append(cmds, m.drainScrollback())
|
||||
return m, tea.Batch(cmds...)
|
||||
case "/name":
|
||||
if cmd := m.handleNameCommand(strings.TrimSpace(args)); cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
cmds = append(cmds, m.drainScrollback())
|
||||
return m, tea.Batch(cmds...)
|
||||
case "/export":
|
||||
if cmd := m.handleExportCommand(strings.TrimSpace(args)); cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
cmds = append(cmds, m.drainScrollback())
|
||||
return m, tea.Batch(cmds...)
|
||||
case "/import":
|
||||
if cmd := m.handleImportCommand(strings.TrimSpace(args)); cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
cmds = append(cmds, m.drainScrollback())
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1072,6 +1196,12 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// Expand prompt templates. If the input matches a template name,
|
||||
// substitute arguments and use the expanded content as the prompt.
|
||||
if expanded, ok := m.expandPromptTemplate(msg.Text); ok {
|
||||
msg.Text = expanded
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -1130,10 +1260,22 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
// ── Shell command (! / !!) ───────────────────────────────────────────────
|
||||
case shellCommandMsg:
|
||||
// Show spinner while the shell command runs.
|
||||
m.state = stateWorking
|
||||
if m.stream != nil {
|
||||
_, cmd := m.stream.Update(app.SpinnerEvent{Show: true})
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
// Execute the shell command asynchronously so the TUI stays responsive.
|
||||
cmds = append(cmds, m.executeShellCommand(msg))
|
||||
|
||||
case shellCommandResultMsg:
|
||||
// Stop spinner now that the command has finished.
|
||||
if m.stream != nil {
|
||||
_, cmd := m.stream.Update(app.SpinnerEvent{Show: false})
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
m.state = stateInput
|
||||
cmds = append(cmds, m.handleShellCommandResult(msg))
|
||||
|
||||
// ── App layer events ─────────────────────────────────────────────────────
|
||||
@@ -1184,12 +1326,35 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case app.ToolResultEvent:
|
||||
// Buffer tool result for scrollback.
|
||||
m.printToolResult(msg)
|
||||
// Clear streaming bash output since tool completed.
|
||||
m.streamingMu.Lock()
|
||||
m.streamingBashOutput = nil
|
||||
m.streamingBashStderr = nil
|
||||
m.streamingMu.Unlock()
|
||||
// Start spinner again while waiting for the next LLM response.
|
||||
if m.stream != nil {
|
||||
_, cmd := m.stream.Update(app.SpinnerEvent{Show: true})
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
case app.ToolOutputEvent:
|
||||
// Accumulate streaming bash output for display.
|
||||
m.streamingMu.Lock()
|
||||
if msg.IsStderr {
|
||||
m.streamingBashStderr = append(m.streamingBashStderr, msg.Chunk)
|
||||
// Cap stderr lines to prevent memory issues.
|
||||
if len(m.streamingBashStderr) > m.streamingBashMaxLines {
|
||||
m.streamingBashStderr = m.streamingBashStderr[len(m.streamingBashStderr)-m.streamingBashMaxLines:]
|
||||
}
|
||||
} else {
|
||||
m.streamingBashOutput = append(m.streamingBashOutput, msg.Chunk)
|
||||
// Cap stdout lines to prevent memory issues.
|
||||
if len(m.streamingBashOutput) > m.streamingBashMaxLines {
|
||||
m.streamingBashOutput = m.streamingBashOutput[len(m.streamingBashOutput)-m.streamingBashMaxLines:]
|
||||
}
|
||||
}
|
||||
m.streamingMu.Unlock()
|
||||
|
||||
case app.ToolCallContentEvent:
|
||||
// In streaming mode this text was already delivered via StreamChunkEvents
|
||||
// and will be flushed before the next tool call. Ignore to avoid
|
||||
@@ -1202,7 +1367,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// print or reset — flushStreamContent() handles it on the next step.
|
||||
// In non-streaming mode (no stream content accumulated), print the text.
|
||||
hasStreamContent := m.stream != nil && m.stream.GetRenderedContent() != ""
|
||||
if !hasStreamContent && msg.Content != "" {
|
||||
if !hasStreamContent && strings.TrimSpace(msg.Content) != "" {
|
||||
m.printAssistantMessage(msg.Content)
|
||||
if m.stream != nil {
|
||||
m.stream.Reset()
|
||||
@@ -1405,6 +1570,14 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
cmds = append(cmds, m.performFork(msg.targetID, msg.isUser, msg.userText))
|
||||
}
|
||||
|
||||
case shareResultMsg:
|
||||
if msg.err != nil {
|
||||
m.printSystemMessage(fmt.Sprintf("Share failed: %v", msg.err))
|
||||
} else {
|
||||
m.printSystemMessage(fmt.Sprintf("Session shared!\n\n Viewer: %s\n Gist: %s", msg.viewerURL, msg.gistURL))
|
||||
}
|
||||
return m, m.drainScrollback()
|
||||
|
||||
case app.ExtensionPrintEvent:
|
||||
// Extension output — route through styled renderers when a level is set.
|
||||
switch msg.Level {
|
||||
@@ -1451,6 +1624,11 @@ func (m *AppModel) View() tea.View {
|
||||
return m.modelSelector.View()
|
||||
}
|
||||
|
||||
// Session selector overlay replaces the normal layout.
|
||||
if m.state == stateSessionSelector && m.sessionSelector != nil {
|
||||
return m.sessionSelector.View()
|
||||
}
|
||||
|
||||
// Overlay dialog replaces the normal layout.
|
||||
if m.state == stateOverlay && m.overlay != nil {
|
||||
return tea.NewView(m.overlay.Render())
|
||||
@@ -1529,23 +1707,114 @@ func (m *AppModel) View() tea.View {
|
||||
|
||||
// renderStream returns the stream region content.
|
||||
func (m *AppModel) renderStream() string {
|
||||
if m.stream == nil {
|
||||
theme := GetTheme()
|
||||
|
||||
var parts []string
|
||||
|
||||
// Stream component content (LLM streaming text, reasoning, spinner placeholder).
|
||||
if m.stream != nil {
|
||||
if content := m.stream.View().Content; content != "" {
|
||||
parts = append(parts, content)
|
||||
}
|
||||
}
|
||||
|
||||
// Streaming bash output section (if any).
|
||||
bashView := m.renderStreamingBashOutput(theme)
|
||||
if bashView != "" {
|
||||
parts = append(parts, bashView)
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Show canceling warning if set.
|
||||
if m.canceling {
|
||||
warning := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("214")).
|
||||
Foreground(theme.Warning).
|
||||
Bold(true).
|
||||
Render(" ⚠ Press ESC again to cancel")
|
||||
return lipgloss.JoinVertical(lipgloss.Left,
|
||||
m.stream.View().Content,
|
||||
warning,
|
||||
)
|
||||
parts = append(parts, warning)
|
||||
}
|
||||
|
||||
return m.stream.View().Content
|
||||
return lipgloss.JoinVertical(lipgloss.Left, parts...)
|
||||
}
|
||||
|
||||
// renderStreamingBashOutput renders accumulated streaming bash output (stdout + stderr)
|
||||
// below the LLM streaming text. Returns empty string if no bash output is present.
|
||||
// Lines are truncated to the terminal width and capped to maxBashLines to prevent
|
||||
// long-running commands from blowing up the TUI layout.
|
||||
func (m *AppModel) renderStreamingBashOutput(theme Theme) string {
|
||||
m.streamingMu.RLock()
|
||||
stdoutLines := make([]string, len(m.streamingBashOutput))
|
||||
copy(stdoutLines, m.streamingBashOutput)
|
||||
stderrLines := make([]string, len(m.streamingBashStderr))
|
||||
copy(stderrLines, m.streamingBashStderr)
|
||||
m.streamingMu.RUnlock()
|
||||
|
||||
if len(stdoutLines) == 0 && len(stderrLines) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
const lineIndent = " "
|
||||
lineWidth := max(m.width-2-len(lineIndent), 20)
|
||||
// Account for PaddingLeft(1) on the output/stderr styles.
|
||||
maxLineChars := lineWidth - 1
|
||||
|
||||
outputStyle := lipgloss.NewStyle().
|
||||
Background(theme.CodeBg).
|
||||
PaddingLeft(1)
|
||||
|
||||
stderrStyle := lipgloss.NewStyle().
|
||||
Foreground(theme.Error).
|
||||
Background(theme.CodeBg).
|
||||
PaddingLeft(1)
|
||||
|
||||
// Cap displayed lines to maxBashLines (show the tail, since streaming
|
||||
// output is most useful at the end). The buffer itself is larger to
|
||||
// preserve context, but we only render the last N lines.
|
||||
totalLines := len(stdoutLines) + len(stderrLines)
|
||||
var hiddenCount int
|
||||
if totalLines > maxBashLines {
|
||||
hiddenCount = totalLines - maxBashLines
|
||||
// Trim from stdout first (older output), then stderr.
|
||||
remaining := maxBashLines
|
||||
if len(stderrLines) >= remaining {
|
||||
stdoutLines = nil
|
||||
stderrLines = stderrLines[len(stderrLines)-remaining:]
|
||||
} else {
|
||||
remaining -= len(stderrLines)
|
||||
if len(stdoutLines) > remaining {
|
||||
stdoutLines = stdoutLines[len(stdoutLines)-remaining:]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var lines []string
|
||||
|
||||
// Truncation hint at the top.
|
||||
if hiddenCount > 0 {
|
||||
hint := fmt.Sprintf("...(%d more lines above)", hiddenCount)
|
||||
hintContent := outputStyle.Width(lineWidth).
|
||||
Foreground(theme.Muted).Italic(true).Render(hint)
|
||||
lines = append(lines, lineIndent+hintContent)
|
||||
}
|
||||
|
||||
// Render stdout lines.
|
||||
for _, line := range stdoutLines {
|
||||
line = truncateLine(strings.TrimRight(line, "\n"), maxLineChars)
|
||||
styled := outputStyle.Width(lineWidth).Render(line)
|
||||
lines = append(lines, lineIndent+styled)
|
||||
}
|
||||
|
||||
// Render stderr lines with error styling.
|
||||
for _, line := range stderrLines {
|
||||
line = truncateLine(strings.TrimRight(line, "\n"), maxLineChars)
|
||||
styled := stderrStyle.Width(lineWidth).Render(line)
|
||||
lines = append(lines, lineIndent+styled)
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// renderStatusBar renders a persistent single-line status bar below the input.
|
||||
@@ -1661,6 +1930,9 @@ func (m *AppModel) cycleThinkingLevel() {
|
||||
_ = m.setThinkingLevel(next)
|
||||
}()
|
||||
}
|
||||
|
||||
// Persist thinking level for next launch.
|
||||
go func() { _ = SaveThinkingLevelPreference(next) }()
|
||||
}
|
||||
|
||||
// renderSeparator renders the separator line with an optional queue count badge.
|
||||
@@ -1806,7 +2078,7 @@ func (m *AppModel) printUserMessage(text string) {
|
||||
|
||||
// printAssistantMessage renders an assistant message into the scrollback buffer.
|
||||
func (m *AppModel) printAssistantMessage(text string) {
|
||||
if text != "" {
|
||||
if strings.TrimSpace(text) != "" {
|
||||
m.appendScrollback(m.renderer.RenderAssistantMessage(text, time.Now(), m.modelName).Content)
|
||||
}
|
||||
}
|
||||
@@ -1846,6 +2118,8 @@ func (m *AppModel) handleSlashCommand(sc *SlashCommand) tea.Cmd {
|
||||
m.printResetUsage()
|
||||
case "/model":
|
||||
return m.handleModelCommand("")
|
||||
case "/theme":
|
||||
return m.handleThemeCommand("")
|
||||
case "/thinking":
|
||||
return m.handleThinkingCommand("")
|
||||
case "/compact":
|
||||
@@ -1869,7 +2143,15 @@ func (m *AppModel) handleSlashCommand(sc *SlashCommand) tea.Cmd {
|
||||
case "/new":
|
||||
return m.handleNewCommand()
|
||||
case "/name":
|
||||
return m.handleNameCommand()
|
||||
return m.handleNameCommand("")
|
||||
case "/resume":
|
||||
return m.handleResumeCommand()
|
||||
case "/export":
|
||||
return m.handleExportCommand("")
|
||||
case "/share":
|
||||
return m.handleShareCommand()
|
||||
case "/import":
|
||||
return m.handleImportCommand("")
|
||||
case "/session":
|
||||
return m.handleSessionInfoCommand()
|
||||
|
||||
@@ -1889,8 +2171,8 @@ func (m *AppModel) printSystemMessage(text string) {
|
||||
func (m *AppModel) printExtensionBlock(evt app.ExtensionPrintEvent) {
|
||||
theme := GetTheme()
|
||||
|
||||
// Resolve border color: use the extension's hex value, fall back to theme accent.
|
||||
var borderClr = lipgloss.Color("#89b4fa") // default blue
|
||||
// Resolve border color: use the extension's hex value, fall back to theme info.
|
||||
borderClr := theme.Info
|
||||
if evt.BorderColor != "" {
|
||||
borderClr = lipgloss.Color(evt.BorderColor)
|
||||
}
|
||||
@@ -1959,6 +2241,33 @@ func (m *AppModel) handleExtensionCommand(text string) tea.Cmd {
|
||||
return func() tea.Msg { return nil }
|
||||
}
|
||||
|
||||
// expandPromptTemplate checks if the submitted text matches a prompt template
|
||||
// and returns the expanded content with arguments substituted.
|
||||
// Returns (expanded, true) if a template was found and expanded, (text, false) otherwise.
|
||||
func (m *AppModel) expandPromptTemplate(text string) (string, bool) {
|
||||
if len(m.promptTemplates) == 0 {
|
||||
return text, false
|
||||
}
|
||||
|
||||
// Only consider inputs that look like slash commands.
|
||||
if !strings.HasPrefix(text, "/") {
|
||||
return text, false
|
||||
}
|
||||
|
||||
// Split: "/templatename arg1 arg2" → name="/templatename", args="arg1 arg2"
|
||||
name, args, _ := strings.Cut(text, " ")
|
||||
name = strings.TrimPrefix(name, "/")
|
||||
|
||||
// Find matching template
|
||||
for _, tpl := range m.promptTemplates {
|
||||
if tpl.Name == name {
|
||||
return tpl.Expand(args), true
|
||||
}
|
||||
}
|
||||
|
||||
return text, false
|
||||
}
|
||||
|
||||
// printHelpMessage renders the help text listing all available slash commands.
|
||||
func (m *AppModel) printHelpMessage() {
|
||||
help := "## Available Commands\n\n" +
|
||||
@@ -1971,10 +2280,14 @@ func (m *AppModel) printHelpMessage() {
|
||||
"**Navigation:**\n" +
|
||||
"- `/tree`: Navigate session tree (switch branches)\n" +
|
||||
"- `/fork`: Branch from an earlier message\n" +
|
||||
"- `/new`: Start a new branch (preserves history)\n\n" +
|
||||
"- `/new`: Start a new branch (preserves history)\n" +
|
||||
"- `/resume`: Open session picker to switch sessions\n" +
|
||||
"- `/name <name>`: Set a display name for this session\n\n" +
|
||||
"**System:**\n" +
|
||||
"- `/compact [instructions]`: Summarise older messages to free context space\n" +
|
||||
"- `/clear`: Clear message history\n" +
|
||||
"- `/export [path]`: Export session as JSONL\n" +
|
||||
"- `/import <path.jsonl>`: Import session from JSONL file\n" +
|
||||
"- `/reset-usage`: Reset usage statistics\n" +
|
||||
"- `/quit`: Exit the application\n\n"
|
||||
|
||||
@@ -2396,10 +2709,55 @@ func (m *AppModel) handleModelCommand(args string) tea.Cmd {
|
||||
go emit(newModel, prev, "user")
|
||||
}
|
||||
|
||||
// Persist model selection for next launch.
|
||||
go func() { _ = SaveModelPreference(args) }()
|
||||
|
||||
m.printSystemMessage(fmt.Sprintf("Switched to %s", args))
|
||||
return nil
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Theme command handler
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// handleThemeCommand switches the active color theme. With no arguments it
|
||||
// lists available themes and highlights the active one. With a name argument
|
||||
// (e.g. "/theme catppuccin") it switches immediately.
|
||||
func (m *AppModel) handleThemeCommand(args string) tea.Cmd {
|
||||
if args == "" {
|
||||
// List available themes.
|
||||
names := ListThemes()
|
||||
active := ActiveThemeName()
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, "Available themes:")
|
||||
for _, name := range names {
|
||||
if name == active {
|
||||
lines = append(lines, fmt.Sprintf(" * %s (active)", name))
|
||||
} else {
|
||||
lines = append(lines, fmt.Sprintf(" %s", name))
|
||||
}
|
||||
}
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, fmt.Sprintf("User themes: %s", userThemesDir()))
|
||||
if pdir := projectThemesDir(); pdir != "" {
|
||||
lines = append(lines, fmt.Sprintf("Project themes: %s", pdir))
|
||||
} else {
|
||||
lines = append(lines, "Project themes: .kit/themes/ (not found)")
|
||||
}
|
||||
m.printSystemMessage(strings.Join(lines, "\n"))
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := ApplyTheme(args); err != nil {
|
||||
m.printSystemMessage(fmt.Sprintf("Theme error: %v", err))
|
||||
return nil
|
||||
}
|
||||
|
||||
m.printSystemMessage(fmt.Sprintf("Switched to theme: %s", args))
|
||||
return nil
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Thinking command handler
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -2443,6 +2801,8 @@ func (m *AppModel) handleThinkingCommand(args string) tea.Cmd {
|
||||
_ = m.setThinkingLevel(string(level))
|
||||
}()
|
||||
}
|
||||
// Persist thinking level for next launch.
|
||||
go func() { _ = SaveThinkingLevelPreference(string(level)) }()
|
||||
m.printSystemMessage(fmt.Sprintf("Thinking level set to: %s — %s", level, models.ThinkingLevelDescription(level)))
|
||||
return nil
|
||||
}
|
||||
@@ -2560,24 +2920,300 @@ func (m *AppModel) performFork(targetID string, isUser bool, userText string) te
|
||||
}
|
||||
|
||||
// handleNameCommand sets a display name for the current session.
|
||||
func (m *AppModel) handleNameCommand() tea.Cmd {
|
||||
// Usage: /name <new name> — sets the session name.
|
||||
//
|
||||
// /name — shows the current name.
|
||||
func (m *AppModel) handleNameCommand(args string) tea.Cmd {
|
||||
ts := m.appCtrl.GetTreeSession()
|
||||
if ts == nil {
|
||||
m.printSystemMessage("No tree session active.")
|
||||
return nil
|
||||
}
|
||||
// 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.
|
||||
currentName := ts.GetSessionName()
|
||||
if currentName != "" {
|
||||
m.printSystemMessage(fmt.Sprintf("Current session name: %q\nTo rename, type: `/name <new name>` (not yet implemented — use the session file directly).", currentName))
|
||||
|
||||
if args == "" {
|
||||
// No argument — show current name.
|
||||
currentName := ts.GetSessionName()
|
||||
if currentName != "" {
|
||||
m.printSystemMessage(fmt.Sprintf("Session name: %q\nTo rename: `/name <new name>`", currentName))
|
||||
} else {
|
||||
m.printSystemMessage("Session has no name. Set one with: `/name <new name>`")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
m.printSystemMessage("To name this session, use: `/name <new name>` (not yet implemented — use the session file directly).")
|
||||
|
||||
// Set the session name.
|
||||
if _, err := ts.AppendSessionInfo(args); err != nil {
|
||||
m.printSystemMessage(fmt.Sprintf("Failed to set session name: %v", err))
|
||||
return nil
|
||||
}
|
||||
m.printSystemMessage(fmt.Sprintf("Session named %q", args))
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleExportCommand exports the current session to a file.
|
||||
// Usage: /export — copies the JSONL file to cwd with a descriptive name.
|
||||
//
|
||||
// /export path.jsonl — copies to the specified path.
|
||||
func (m *AppModel) handleExportCommand(args string) tea.Cmd {
|
||||
ts := m.appCtrl.GetTreeSession()
|
||||
if ts == nil {
|
||||
m.printSystemMessage("No tree session active.")
|
||||
return nil
|
||||
}
|
||||
|
||||
srcPath := ts.GetFilePath()
|
||||
if srcPath == "" {
|
||||
m.printSystemMessage("Session is in-memory (not persisted). Nothing to export.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Determine destination path.
|
||||
dstPath := args
|
||||
if dstPath == "" {
|
||||
// Generate a name based on session name or ID.
|
||||
name := ts.GetSessionName()
|
||||
if name == "" {
|
||||
name = ts.GetSessionID()[:12]
|
||||
}
|
||||
// Sanitize for filename.
|
||||
name = strings.Map(func(r rune) rune {
|
||||
if r == '/' || r == '\\' || r == ':' || r == ' ' {
|
||||
return '_'
|
||||
}
|
||||
return r
|
||||
}, name)
|
||||
dstPath = fmt.Sprintf("session_%s.jsonl", name)
|
||||
}
|
||||
|
||||
// Copy the file.
|
||||
data, err := os.ReadFile(srcPath)
|
||||
if err != nil {
|
||||
m.printSystemMessage(fmt.Sprintf("Failed to read session file: %v", err))
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.WriteFile(dstPath, data, 0644); err != nil {
|
||||
m.printSystemMessage(fmt.Sprintf("Failed to write export file: %v", err))
|
||||
return nil
|
||||
}
|
||||
|
||||
m.printSystemMessage(fmt.Sprintf("Session exported to: %s (%d bytes)", dstPath, len(data)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleShareCommand uploads the current session as a GitHub Gist and prints
|
||||
// a shareable viewer URL. Requires the GitHub CLI (gh) to be installed and
|
||||
// authenticated.
|
||||
func (m *AppModel) handleShareCommand() tea.Cmd {
|
||||
ts := m.appCtrl.GetTreeSession()
|
||||
if ts == nil {
|
||||
m.printSystemMessage("No tree session active.")
|
||||
return nil
|
||||
}
|
||||
|
||||
srcPath := ts.GetFilePath()
|
||||
if srcPath == "" {
|
||||
m.printSystemMessage("Session is in-memory (not persisted). Nothing to share.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check that gh CLI is available.
|
||||
if _, err := exec.LookPath("gh"); err != nil {
|
||||
m.printSystemMessage("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check that gh is authenticated.
|
||||
authCheck := exec.Command("gh", "auth", "status")
|
||||
if err := authCheck.Run(); err != nil {
|
||||
m.printSystemMessage("GitHub CLI is not logged in. Run 'gh auth login' first.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Copy session to a temp file with a clean name.
|
||||
data, err := os.ReadFile(srcPath)
|
||||
if err != nil {
|
||||
m.printSystemMessage(fmt.Sprintf("Failed to read session file: %v", err))
|
||||
return nil
|
||||
}
|
||||
|
||||
name := ts.GetSessionName()
|
||||
if name == "" {
|
||||
name = "session"
|
||||
}
|
||||
// Sanitize for filename.
|
||||
name = strings.Map(func(r rune) rune {
|
||||
if r == '/' || r == '\\' || r == ':' || r == ' ' {
|
||||
return '_'
|
||||
}
|
||||
return r
|
||||
}, name)
|
||||
|
||||
tmpFile, err := os.CreateTemp("", fmt.Sprintf("kit-%s-*.jsonl", name))
|
||||
if err != nil {
|
||||
m.printSystemMessage(fmt.Sprintf("Failed to create temp file: %v", err))
|
||||
return nil
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
|
||||
if _, err := tmpFile.Write(data); err != nil {
|
||||
_ = tmpFile.Close()
|
||||
_ = os.Remove(tmpPath)
|
||||
m.printSystemMessage(fmt.Sprintf("Failed to write temp file: %v", err))
|
||||
return nil
|
||||
}
|
||||
_ = tmpFile.Close()
|
||||
|
||||
m.printSystemMessage("Uploading session to GitHub Gist...")
|
||||
|
||||
// Run gh gist create in background to avoid blocking the UI.
|
||||
return func() tea.Msg {
|
||||
defer func() { _ = os.Remove(tmpPath) }()
|
||||
|
||||
cmd := exec.Command("gh", "gist", "create", tmpPath, "--desc", "Kit session shared via /share")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return shareResultMsg{err: fmt.Errorf("failed to create gist: %w", err)}
|
||||
}
|
||||
|
||||
// gh outputs the gist URL like: https://gist.github.com/username/abc123def456
|
||||
gistURL := strings.TrimSpace(string(output))
|
||||
|
||||
// Extract gist ID (last path segment).
|
||||
parts := strings.Split(gistURL, "/")
|
||||
gistID := parts[len(parts)-1]
|
||||
|
||||
viewerURL := fmt.Sprintf("https://go-kit.dev/session/#%s", gistID)
|
||||
return shareResultMsg{gistURL: gistURL, viewerURL: viewerURL}
|
||||
}
|
||||
}
|
||||
|
||||
// handleImportCommand imports a session from a JSONL file.
|
||||
// Usage: /import path.jsonl
|
||||
func (m *AppModel) handleImportCommand(args string) tea.Cmd {
|
||||
if args == "" {
|
||||
m.printSystemMessage("Usage: `/import <path.jsonl>`")
|
||||
return nil
|
||||
}
|
||||
|
||||
if m.switchSession == nil {
|
||||
m.printSystemMessage("Session switching is not available.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Verify file exists before attempting to switch.
|
||||
if _, err := os.Stat(args); err != nil {
|
||||
m.printSystemMessage(fmt.Sprintf("File not found: %s", args))
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := m.switchSession(args); err != nil {
|
||||
m.printSystemMessage(fmt.Sprintf("Failed to import session: %v", err))
|
||||
return nil
|
||||
}
|
||||
|
||||
m.renderSessionHistory()
|
||||
m.printSystemMessage(fmt.Sprintf("Session imported from: %s", args))
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleResumeCommand opens the session picker so the user can switch sessions.
|
||||
func (m *AppModel) handleResumeCommand() tea.Cmd {
|
||||
if m.switchSession == nil {
|
||||
m.printSystemMessage("Session switching is not available.")
|
||||
return nil
|
||||
}
|
||||
|
||||
m.sessionSelector = NewSessionSelector(m.cwd, m.width, m.height)
|
||||
m.state = stateSessionSelector
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderSessionHistory walks the current session branch and renders all
|
||||
// messages (user, assistant, tool calls/results) into the scrollback buffer.
|
||||
// This gives the user visual context of the conversation when resuming or
|
||||
// importing a session. Call this after switchSession succeeds.
|
||||
func (m *AppModel) renderSessionHistory() {
|
||||
ts := m.appCtrl.GetTreeSession()
|
||||
if ts == nil {
|
||||
return
|
||||
}
|
||||
|
||||
branch := ts.GetBranch("")
|
||||
if len(branch) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// First pass: build a map of tool call ID → {name, args} from assistant
|
||||
// messages so we can pair them with tool results.
|
||||
type toolCallInfo struct {
|
||||
Name string
|
||||
Args string
|
||||
}
|
||||
toolCallMap := make(map[string]toolCallInfo)
|
||||
for _, entry := range branch {
|
||||
me, ok := entry.(*session.MessageEntry)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if me.Role != "assistant" {
|
||||
continue
|
||||
}
|
||||
msg, err := me.ToMessage()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, tc := range msg.ToolCalls() {
|
||||
toolCallMap[tc.ID] = toolCallInfo{Name: tc.Name, Args: tc.Input}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: render each message in order.
|
||||
for _, entry := range branch {
|
||||
me, ok := entry.(*session.MessageEntry)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
msg, err := me.ToMessage()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch msg.Role {
|
||||
case message.RoleUser:
|
||||
text := msg.Content()
|
||||
if text != "" {
|
||||
m.appendScrollback(m.renderer.RenderUserMessage(text, msg.CreatedAt).Content)
|
||||
}
|
||||
|
||||
case message.RoleAssistant:
|
||||
text := msg.Content()
|
||||
if text != "" {
|
||||
modelName := m.modelName
|
||||
if msg.Model != "" {
|
||||
modelName = msg.Model
|
||||
}
|
||||
m.appendScrollback(m.renderer.RenderAssistantMessage(text, msg.CreatedAt, modelName).Content)
|
||||
}
|
||||
// Tool calls from assistant messages are rendered when we
|
||||
// encounter their corresponding tool results below.
|
||||
|
||||
case message.RoleTool:
|
||||
for _, tr := range msg.ToolResults() {
|
||||
toolName := tr.Name
|
||||
toolArgs := ""
|
||||
if info, ok := toolCallMap[tr.ToolCallID]; ok {
|
||||
if toolName == "" {
|
||||
toolName = info.Name
|
||||
}
|
||||
toolArgs = info.Args
|
||||
}
|
||||
m.appendScrollback(m.renderer.RenderToolMessage(toolName, toolArgs, tr.Content, tr.IsError).Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleSessionInfoCommand shows session statistics.
|
||||
func (m *AppModel) handleSessionInfoCommand() tea.Cmd {
|
||||
ts := m.appCtrl.GetTreeSession()
|
||||
@@ -2628,6 +3264,13 @@ func cancelTimerCmd() tea.Cmd {
|
||||
// Interactive prompt support
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// shareResultMsg carries the result of an async gist upload.
|
||||
type shareResultMsg struct {
|
||||
err error
|
||||
gistURL string
|
||||
viewerURL string
|
||||
}
|
||||
|
||||
// extensionCmdResultMsg carries the result of an asynchronously executed
|
||||
// extension slash command. Extension commands run async (via tea.Cmd) so they
|
||||
// can safely call blocking operations like ctx.PromptSelect().
|
||||
@@ -2813,6 +3456,14 @@ func (m *AppModel) executeShellCommand(msg shellCommandMsg) tea.Cmd {
|
||||
cmd.Dir = cwd
|
||||
}
|
||||
|
||||
// Ensure SHELL is set to bash so child processes (e.g. tmux) use bash
|
||||
// rather than the user's login shell (which may be nushell, fish, etc.).
|
||||
bashPath, _ := exec.LookPath("bash")
|
||||
if bashPath == "" {
|
||||
bashPath = "/bin/bash"
|
||||
}
|
||||
cmd.Env = append(os.Environ(), "SHELL="+bashPath)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
@@ -2884,9 +3535,19 @@ func (m *AppModel) handleShellCommandResult(msg shellCommandResultMsg) tea.Cmd {
|
||||
var displayHiddenCount int
|
||||
if displayOutput != "" {
|
||||
lines := strings.Split(displayOutput, "\n")
|
||||
// Cap individual line length to prevent long lines from wrapping
|
||||
// into excessive visual rows.
|
||||
maxLineChars := max(m.width*3, 200)
|
||||
for i, line := range lines {
|
||||
if len(line) > maxLineChars {
|
||||
lines[i] = line[:maxLineChars] + "…"
|
||||
}
|
||||
}
|
||||
if len(lines) > maxShellDisplayLines {
|
||||
displayHiddenCount = len(lines) - maxShellDisplayLines
|
||||
displayOutput = strings.Join(lines[:maxShellDisplayLines], "\n")
|
||||
} else {
|
||||
displayOutput = strings.Join(lines, "\n")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -112,15 +112,16 @@ func newTestAppModel(ctrl AppController) (*AppModel, *stubStreamComponent, *stub
|
||||
stream := &stubStreamComponent{}
|
||||
input := &stubInputComponent{}
|
||||
m := &AppModel{
|
||||
state: stateInput,
|
||||
appCtrl: ctrl,
|
||||
stream: stream,
|
||||
input: input,
|
||||
renderer: NewMessageRenderer(80, false),
|
||||
compactMode: false,
|
||||
modelName: "test-model",
|
||||
width: 80,
|
||||
height: 24,
|
||||
state: stateInput,
|
||||
appCtrl: ctrl,
|
||||
stream: stream,
|
||||
input: input,
|
||||
renderer: newMessageRenderer(80, false),
|
||||
compactMode: false,
|
||||
modelName: "test-model",
|
||||
width: 80,
|
||||
height: 24,
|
||||
streamingBashMaxLines: 50, // Initialize buffer cap like NewAppModel does
|
||||
}
|
||||
return m, stream, input
|
||||
}
|
||||
@@ -602,6 +603,82 @@ func TestToolResult_printsAndStartsSpinner(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolOutputEvent_accumulatesBashOutput verifies that ToolOutputEvent
|
||||
// accumulates stdout and stderr lines into the streaming bash output buffers.
|
||||
func TestToolOutputEvent_accumulatesBashOutput(t *testing.T) {
|
||||
ctrl := &stubAppController{}
|
||||
m, _, _ := newTestAppModel(ctrl)
|
||||
m.state = stateWorking
|
||||
|
||||
// Send stdout chunk.
|
||||
m = sendMsg(m, app.ToolOutputEvent{
|
||||
ToolCallID: "call-1",
|
||||
ToolName: "bash",
|
||||
Chunk: "line one\n",
|
||||
IsStderr: false,
|
||||
})
|
||||
|
||||
if len(m.streamingBashOutput) != 1 || m.streamingBashOutput[0] != "line one\n" {
|
||||
t.Fatalf("expected streamingBashOutput=['line one\\n'], got %v", m.streamingBashOutput)
|
||||
}
|
||||
if len(m.streamingBashStderr) != 0 {
|
||||
t.Fatalf("expected empty streamingBashStderr, got %v", m.streamingBashStderr)
|
||||
}
|
||||
|
||||
// Send another stdout chunk.
|
||||
m = sendMsg(m, app.ToolOutputEvent{
|
||||
ToolCallID: "call-1",
|
||||
ToolName: "bash",
|
||||
Chunk: "line two\n",
|
||||
IsStderr: false,
|
||||
})
|
||||
|
||||
if len(m.streamingBashOutput) != 2 {
|
||||
t.Fatalf("expected 2 stdout lines, got %d", len(m.streamingBashOutput))
|
||||
}
|
||||
|
||||
// Send stderr chunk.
|
||||
m = sendMsg(m, app.ToolOutputEvent{
|
||||
ToolCallID: "call-1",
|
||||
ToolName: "bash",
|
||||
Chunk: "error: something failed\n",
|
||||
IsStderr: true,
|
||||
})
|
||||
|
||||
if len(m.streamingBashStderr) != 1 {
|
||||
t.Fatalf("expected 1 stderr line, got %d", len(m.streamingBashStderr))
|
||||
}
|
||||
if m.streamingBashStderr[0] != "error: something failed\n" {
|
||||
t.Fatalf("expected stderr 'error: something failed\\n', got %q", m.streamingBashStderr[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolResult_clearsStreamingBashOutput verifies that ToolResultEvent clears
|
||||
// the streaming bash output buffers since the final result will be printed.
|
||||
func TestToolResult_clearsStreamingBashOutput(t *testing.T) {
|
||||
ctrl := &stubAppController{}
|
||||
m, _, _ := newTestAppModel(ctrl)
|
||||
m.state = stateWorking
|
||||
|
||||
// Accumulate some bash output.
|
||||
m.streamingBashOutput = []string{"output line"}
|
||||
m.streamingBashStderr = []string{"error line"}
|
||||
|
||||
_, _ = m.Update(app.ToolResultEvent{
|
||||
ToolName: "bash",
|
||||
ToolArgs: `{"cmd":"ls"}`,
|
||||
Result: "output line\nerror line\n",
|
||||
IsError: false,
|
||||
})
|
||||
|
||||
if len(m.streamingBashOutput) != 0 {
|
||||
t.Fatalf("expected streamingBashOutput cleared, got %v", m.streamingBashOutput)
|
||||
}
|
||||
if len(m.streamingBashStderr) != 0 {
|
||||
t.Fatalf("expected streamingBashStderr cleared, got %v", m.streamingBashStderr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStepError_printCmd verifies that StepErrorEvent with a non-nil error
|
||||
// produces a non-nil cmd (the tea.Println call for the error message).
|
||||
func TestStepError_printCmd(t *testing.T) {
|
||||
|
||||
@@ -242,7 +242,7 @@ func (o *overlayDialog) Render() string {
|
||||
innerContent := strings.Join(parts, "\n")
|
||||
|
||||
// Resolve border color.
|
||||
borderClr := lipgloss.Color("#89b4fa") // default blue
|
||||
borderClr := theme.Info
|
||||
if o.borderColor != "" {
|
||||
borderClr = lipgloss.Color(o.borderColor)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// preferences holds user-mutable runtime state that persists across sessions.
|
||||
// Stored at ~/.config/kit/preferences.yml, separate from the declarative
|
||||
// .kit.yml config so we never clobber user comments or formatting.
|
||||
type preferences struct {
|
||||
Theme string `yaml:"theme,omitempty"`
|
||||
Model string `yaml:"model,omitempty"`
|
||||
ThinkingLevel string `yaml:"thinking_level,omitempty"`
|
||||
}
|
||||
|
||||
// preferencesPath returns ~/.config/kit/preferences.yml.
|
||||
// Returns "" if the config directory cannot be determined.
|
||||
func preferencesPath() string {
|
||||
cfgDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(cfgDir, "kit", "preferences.yml")
|
||||
}
|
||||
|
||||
// loadPreferences reads and parses the preferences file.
|
||||
// Returns zero-value preferences if the file is missing or invalid.
|
||||
func loadPreferences() preferences {
|
||||
path := preferencesPath()
|
||||
if path == "" {
|
||||
return preferences{}
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return preferences{}
|
||||
}
|
||||
var prefs preferences
|
||||
if err := yaml.Unmarshal(data, &prefs); err != nil {
|
||||
return preferences{}
|
||||
}
|
||||
return prefs
|
||||
}
|
||||
|
||||
// savePreferences atomically writes the preferences file, merging into any
|
||||
// existing content. The mutate function receives the current preferences and
|
||||
// should modify them in place.
|
||||
func savePreferences(mutate func(*preferences)) error {
|
||||
path := preferencesPath()
|
||||
if path == "" {
|
||||
return nil // silently skip if config dir unavailable
|
||||
}
|
||||
|
||||
// Load existing preferences to preserve other fields.
|
||||
var prefs preferences
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
_ = yaml.Unmarshal(data, &prefs)
|
||||
}
|
||||
|
||||
mutate(&prefs)
|
||||
|
||||
data, err := yaml.Marshal(&prefs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Ensure parent directory exists.
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Atomic write: write to temp file, then rename.
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// ── Theme preference ────────────────────────────────────────────────────────
|
||||
|
||||
// LoadThemePreference reads the persisted theme name from preferences.yml.
|
||||
// Returns "" if no preference is saved or the file doesn't exist.
|
||||
func LoadThemePreference() string {
|
||||
return strings.TrimSpace(loadPreferences().Theme)
|
||||
}
|
||||
|
||||
// SaveThemePreference persists the theme name to ~/.config/kit/preferences.yml.
|
||||
// Preserves other preference fields. Uses atomic write (temp + rename) to
|
||||
// avoid corruption from concurrent Kit instances.
|
||||
func SaveThemePreference(name string) error {
|
||||
return savePreferences(func(p *preferences) {
|
||||
p.Theme = name
|
||||
})
|
||||
}
|
||||
|
||||
// ── Model preference ────────────────────────────────────────────────────────
|
||||
|
||||
// LoadModelPreference reads the persisted model string (e.g.
|
||||
// "anthropic/claude-sonnet-4-5-20250929") from preferences.yml.
|
||||
// Returns "" if no preference is saved.
|
||||
func LoadModelPreference() string {
|
||||
return strings.TrimSpace(loadPreferences().Model)
|
||||
}
|
||||
|
||||
// SaveModelPreference persists the model string to preferences.yml.
|
||||
func SaveModelPreference(model string) error {
|
||||
return savePreferences(func(p *preferences) {
|
||||
p.Model = model
|
||||
})
|
||||
}
|
||||
|
||||
// ── Thinking level preference ───────────────────────────────────────────────
|
||||
|
||||
// LoadThinkingLevelPreference reads the persisted thinking level from
|
||||
// preferences.yml. Returns "" if no preference is saved.
|
||||
func LoadThinkingLevelPreference() string {
|
||||
return strings.TrimSpace(loadPreferences().ThinkingLevel)
|
||||
}
|
||||
|
||||
// SaveThinkingLevelPreference persists the thinking level to preferences.yml.
|
||||
func SaveThinkingLevelPreference(level string) error {
|
||||
return savePreferences(func(p *preferences) {
|
||||
p.ThinkingLevel = level
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSaveAndLoadThemePreference(t *testing.T) {
|
||||
// Use a temp dir as XDG_CONFIG_HOME so we don't touch the real config.
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", tmp)
|
||||
|
||||
// Initially no preference is saved.
|
||||
if got := LoadThemePreference(); got != "" {
|
||||
t.Fatalf("expected empty preference, got %q", got)
|
||||
}
|
||||
|
||||
// Save a preference.
|
||||
if err := SaveThemePreference("dracula"); err != nil {
|
||||
t.Fatalf("SaveThemePreference: %v", err)
|
||||
}
|
||||
|
||||
// Load it back.
|
||||
if got := LoadThemePreference(); got != "dracula" {
|
||||
t.Fatalf("expected %q, got %q", "dracula", got)
|
||||
}
|
||||
|
||||
// Overwrite with a different theme.
|
||||
if err := SaveThemePreference("nord"); err != nil {
|
||||
t.Fatalf("SaveThemePreference: %v", err)
|
||||
}
|
||||
if got := LoadThemePreference(); got != "nord" {
|
||||
t.Fatalf("expected %q, got %q", "nord", got)
|
||||
}
|
||||
|
||||
// Verify the file exists and is valid YAML.
|
||||
path := filepath.Join(tmp, "kit", "preferences.yml")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("reading preferences file: %v", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Fatal("preferences file is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadThemePreference_MissingFile(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", tmp)
|
||||
|
||||
// No file exists — should return empty string, not error.
|
||||
if got := LoadThemePreference(); got != "" {
|
||||
t.Fatalf("expected empty string for missing file, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadThemePreference_InvalidYAML(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", tmp)
|
||||
|
||||
// Write invalid YAML.
|
||||
dir := filepath.Join(tmp, "kit")
|
||||
_ = os.MkdirAll(dir, 0o755)
|
||||
_ = os.WriteFile(filepath.Join(dir, "preferences.yml"), []byte(":::bad yaml"), 0o644)
|
||||
|
||||
// Should return empty string, not panic.
|
||||
if got := LoadThemePreference(); got != "" {
|
||||
t.Fatalf("expected empty string for invalid YAML, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveThemePreference_PreservesOtherFields(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", tmp)
|
||||
|
||||
// Pre-populate with extra content (simulating future fields).
|
||||
dir := filepath.Join(tmp, "kit")
|
||||
_ = os.MkdirAll(dir, 0o755)
|
||||
_ = os.WriteFile(filepath.Join(dir, "preferences.yml"), []byte("theme: old\n"), 0o644)
|
||||
|
||||
// Overwrite theme.
|
||||
if err := SaveThemePreference("catppuccin"); err != nil {
|
||||
t.Fatalf("SaveThemePreference: %v", err)
|
||||
}
|
||||
|
||||
if got := LoadThemePreference(); got != "catppuccin" {
|
||||
t.Fatalf("expected %q, got %q", "catppuccin", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAndLoadModelPreference(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", tmp)
|
||||
|
||||
// Initially empty.
|
||||
if got := LoadModelPreference(); got != "" {
|
||||
t.Fatalf("expected empty, got %q", got)
|
||||
}
|
||||
|
||||
// Save a model.
|
||||
if err := SaveModelPreference("anthropic/claude-sonnet-4-5-20250929"); err != nil {
|
||||
t.Fatalf("SaveModelPreference: %v", err)
|
||||
}
|
||||
if got := LoadModelPreference(); got != "anthropic/claude-sonnet-4-5-20250929" {
|
||||
t.Fatalf("expected %q, got %q", "anthropic/claude-sonnet-4-5-20250929", got)
|
||||
}
|
||||
|
||||
// Overwrite.
|
||||
if err := SaveModelPreference("openai/gpt-4o"); err != nil {
|
||||
t.Fatalf("SaveModelPreference: %v", err)
|
||||
}
|
||||
if got := LoadModelPreference(); got != "openai/gpt-4o" {
|
||||
t.Fatalf("expected %q, got %q", "openai/gpt-4o", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAndLoadThinkingLevelPreference(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", tmp)
|
||||
|
||||
// Initially empty.
|
||||
if got := LoadThinkingLevelPreference(); got != "" {
|
||||
t.Fatalf("expected empty, got %q", got)
|
||||
}
|
||||
|
||||
// Save a level.
|
||||
if err := SaveThinkingLevelPreference("medium"); err != nil {
|
||||
t.Fatalf("SaveThinkingLevelPreference: %v", err)
|
||||
}
|
||||
if got := LoadThinkingLevelPreference(); got != "medium" {
|
||||
t.Fatalf("expected %q, got %q", "medium", got)
|
||||
}
|
||||
|
||||
// Overwrite.
|
||||
if err := SaveThinkingLevelPreference("high"); err != nil {
|
||||
t.Fatalf("SaveThinkingLevelPreference: %v", err)
|
||||
}
|
||||
if got := LoadThinkingLevelPreference(); got != "high" {
|
||||
t.Fatalf("expected %q, got %q", "high", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreferencesPreserveEachOther(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", tmp)
|
||||
|
||||
// Save all three preferences.
|
||||
if err := SaveThemePreference("dracula"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SaveModelPreference("anthropic/claude-haiku-3-5-20241022"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SaveThinkingLevelPreference("high"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// All three should be preserved.
|
||||
if got := LoadThemePreference(); got != "dracula" {
|
||||
t.Fatalf("theme: expected %q, got %q", "dracula", got)
|
||||
}
|
||||
if got := LoadModelPreference(); got != "anthropic/claude-haiku-3-5-20241022" {
|
||||
t.Fatalf("model: expected %q, got %q", "anthropic/claude-haiku-3-5-20241022", got)
|
||||
}
|
||||
if got := LoadThinkingLevelPreference(); got != "high" {
|
||||
t.Fatalf("thinking_level: expected %q, got %q", "high", got)
|
||||
}
|
||||
|
||||
// Updating one should not affect the others.
|
||||
if err := SaveModelPreference("openai/gpt-4o"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := LoadThemePreference(); got != "dracula" {
|
||||
t.Fatalf("theme after model update: expected %q, got %q", "dracula", got)
|
||||
}
|
||||
if got := LoadThinkingLevelPreference(); got != "high" {
|
||||
t.Fatalf("thinking_level after model update: expected %q, got %q", "high", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/mark3labs/kit/internal/session"
|
||||
)
|
||||
|
||||
// SessionSelectedMsg is sent when the user selects a session from the picker.
|
||||
type SessionSelectedMsg struct {
|
||||
Path string // absolute path to the JSONL session file
|
||||
}
|
||||
|
||||
// SessionSelectorCancelledMsg is sent when the user cancels the picker.
|
||||
type SessionSelectorCancelledMsg struct{}
|
||||
|
||||
// SessionDeletedMsg is sent after a session is deleted so the parent can
|
||||
// react (e.g. print a message).
|
||||
type SessionDeletedMsg struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
// SessionScopeMode controls which sessions are shown.
|
||||
type SessionScopeMode int
|
||||
|
||||
const (
|
||||
SessionScopeCwd SessionScopeMode = iota // current folder only
|
||||
SessionScopeAll // all sessions across projects
|
||||
)
|
||||
|
||||
func (m SessionScopeMode) String() string {
|
||||
if m == SessionScopeAll {
|
||||
return "All"
|
||||
}
|
||||
return "Current Folder"
|
||||
}
|
||||
|
||||
// SessionFilterMode controls filtering of the session list.
|
||||
type SessionFilterMode int
|
||||
|
||||
const (
|
||||
SessionFilterAll SessionFilterMode = iota // show all sessions
|
||||
SessionFilterNamed // only named sessions
|
||||
)
|
||||
|
||||
func (m SessionFilterMode) String() string {
|
||||
if m == SessionFilterNamed {
|
||||
return "Named"
|
||||
}
|
||||
return "All"
|
||||
}
|
||||
|
||||
// controlCharsRe matches ASCII control characters for stripping from previews.
|
||||
var controlCharsRe = regexp.MustCompile(`[\x00-\x1f\x7f]`)
|
||||
|
||||
// SessionSelectorComponent is a full-screen Bubble Tea component that lets
|
||||
// the user browse and select from available sessions. Modeled after pi's
|
||||
// session picker: right-aligned metadata, background-highlighted selection,
|
||||
// scope/filter toggles, and inline search.
|
||||
type SessionSelectorComponent struct {
|
||||
allSessions []session.SessionInfo
|
||||
cwdSessions []session.SessionInfo
|
||||
filtered []session.SessionInfo
|
||||
|
||||
cursor int
|
||||
search string
|
||||
|
||||
scope SessionScopeMode
|
||||
filter SessionFilterMode
|
||||
|
||||
// currentPath is the active session file path for marking it in the list.
|
||||
currentPath string
|
||||
|
||||
width int
|
||||
height int
|
||||
active bool
|
||||
|
||||
// confirmDelete is non-negative when a delete confirmation is pending.
|
||||
confirmDelete int
|
||||
}
|
||||
|
||||
// NewSessionSelector creates a session selector. It loads sessions for the
|
||||
// current working directory and all sessions across projects. If cwd is
|
||||
// empty, only "All" scope is available.
|
||||
func NewSessionSelector(cwd string, width, height int) *SessionSelectorComponent {
|
||||
ss := &SessionSelectorComponent{
|
||||
width: width,
|
||||
height: height,
|
||||
active: true,
|
||||
confirmDelete: -1,
|
||||
}
|
||||
|
||||
// Load sessions (errors are swallowed — empty list is fine).
|
||||
if cwd != "" {
|
||||
ss.cwdSessions, _ = session.ListSessions(cwd)
|
||||
ss.scope = SessionScopeCwd
|
||||
}
|
||||
ss.allSessions, _ = session.ListAllSessions()
|
||||
|
||||
if cwd == "" || len(ss.cwdSessions) == 0 {
|
||||
ss.scope = SessionScopeAll
|
||||
}
|
||||
|
||||
ss.rebuildFiltered()
|
||||
return ss
|
||||
}
|
||||
|
||||
// SetCurrentPath sets the currently active session path so the picker can
|
||||
// highlight it in the list.
|
||||
func (ss *SessionSelectorComponent) SetCurrentPath(path string) {
|
||||
ss.currentPath = path
|
||||
}
|
||||
|
||||
// Init implements tea.Model.
|
||||
func (ss *SessionSelectorComponent) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update implements tea.Model.
|
||||
func (ss *SessionSelectorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
ss.width = msg.Width
|
||||
ss.height = msg.Height
|
||||
return ss, nil
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
// Delete confirmation mode.
|
||||
if ss.confirmDelete >= 0 {
|
||||
switch msg.String() {
|
||||
case "y", "Y":
|
||||
idx := ss.confirmDelete
|
||||
ss.confirmDelete = -1
|
||||
if idx < len(ss.filtered) {
|
||||
info := ss.filtered[idx]
|
||||
if err := session.DeleteSession(info.Path); err == nil {
|
||||
name := sessionDisplayName(info)
|
||||
ss.removeSession(info.Path)
|
||||
ss.rebuildFiltered()
|
||||
return ss, func() tea.Msg {
|
||||
return SessionDeletedMsg{Name: name}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ss, nil
|
||||
default:
|
||||
ss.confirmDelete = -1
|
||||
return ss, nil
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("up", "k"))):
|
||||
if ss.cursor > 0 {
|
||||
ss.cursor--
|
||||
}
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("down", "j"))):
|
||||
if ss.cursor < len(ss.filtered)-1 {
|
||||
ss.cursor++
|
||||
}
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("pgup"))):
|
||||
ss.cursor -= ss.visibleHeight()
|
||||
if ss.cursor < 0 {
|
||||
ss.cursor = 0
|
||||
}
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("pgdown"))):
|
||||
ss.cursor += ss.visibleHeight()
|
||||
if ss.cursor >= len(ss.filtered) {
|
||||
ss.cursor = len(ss.filtered) - 1
|
||||
}
|
||||
if ss.cursor < 0 {
|
||||
ss.cursor = 0
|
||||
}
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("home"))):
|
||||
ss.cursor = 0
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("end"))):
|
||||
ss.cursor = max(len(ss.filtered)-1, 0)
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
|
||||
if ss.cursor < len(ss.filtered) {
|
||||
info := ss.filtered[ss.cursor]
|
||||
ss.active = false
|
||||
return ss, func() tea.Msg {
|
||||
return SessionSelectedMsg{Path: info.Path}
|
||||
}
|
||||
}
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
|
||||
if ss.search != "" {
|
||||
ss.search = ""
|
||||
ss.rebuildFiltered()
|
||||
} else {
|
||||
ss.active = false
|
||||
return ss, func() tea.Msg {
|
||||
return SessionSelectorCancelledMsg{}
|
||||
}
|
||||
}
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))):
|
||||
if ss.scope == SessionScopeCwd {
|
||||
ss.scope = SessionScopeAll
|
||||
} else {
|
||||
ss.scope = SessionScopeCwd
|
||||
}
|
||||
ss.rebuildFiltered()
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+n"))):
|
||||
if ss.filter == SessionFilterAll {
|
||||
ss.filter = SessionFilterNamed
|
||||
} else {
|
||||
ss.filter = SessionFilterAll
|
||||
}
|
||||
ss.rebuildFiltered()
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("d"))):
|
||||
if ss.cursor < len(ss.filtered) {
|
||||
ss.confirmDelete = ss.cursor
|
||||
}
|
||||
return ss, nil
|
||||
|
||||
default:
|
||||
if msg.Text != "" && len(msg.Text) == 1 {
|
||||
ch := msg.Text[0]
|
||||
if ch >= 32 && ch < 127 {
|
||||
ss.search += string(ch)
|
||||
ss.rebuildFiltered()
|
||||
}
|
||||
}
|
||||
if key.Matches(msg, key.NewBinding(key.WithKeys("backspace"))) && len(ss.search) > 0 {
|
||||
ss.search = ss.search[:len(ss.search)-1]
|
||||
ss.rebuildFiltered()
|
||||
}
|
||||
}
|
||||
}
|
||||
return ss, nil
|
||||
}
|
||||
|
||||
// View implements tea.Model.
|
||||
func (ss *SessionSelectorComponent) View() tea.View {
|
||||
theme := GetTheme()
|
||||
w := ss.width
|
||||
var b strings.Builder
|
||||
|
||||
// ── Header: title + scope badges ─────────────────────────────
|
||||
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(theme.Accent).PaddingLeft(1)
|
||||
b.WriteString(titleStyle.Render(fmt.Sprintf("Resume Session (%s)", ss.scope)))
|
||||
b.WriteString("\n")
|
||||
|
||||
// ── Help / keybindings ───────────────────────────────────────
|
||||
helpStyle := lipgloss.NewStyle().Foreground(theme.Muted).PaddingLeft(1)
|
||||
if w >= 75 {
|
||||
b.WriteString(helpStyle.Render("tab: scope N: named D: delete R: rename type to search esc: cancel"))
|
||||
} else if w >= 50 {
|
||||
b.WriteString(helpStyle.Render("tab scope N named D del type to search esc"))
|
||||
} else {
|
||||
b.WriteString(helpStyle.Render("tab N D esc"))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
// ── Search (only shown when active) ──────────────────────────
|
||||
if ss.search != "" {
|
||||
searchStyle := lipgloss.NewStyle().Foreground(theme.Info).PaddingLeft(1)
|
||||
b.WriteString(searchStyle.Render(fmt.Sprintf("> %s", ss.search)))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
|
||||
// ── Delete confirmation ──────────────────────────────────────
|
||||
if ss.confirmDelete >= 0 && ss.confirmDelete < len(ss.filtered) {
|
||||
warnStyle := lipgloss.NewStyle().Foreground(theme.Error).Bold(true).PaddingLeft(1)
|
||||
name := sessionDisplayName(ss.filtered[ss.confirmDelete])
|
||||
b.WriteString(warnStyle.Render(fmt.Sprintf("Delete %q? (y/N)", truncateRunes(name, 40))))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// ── Session list ─────────────────────────────────────────────
|
||||
if len(ss.filtered) == 0 {
|
||||
emptyStyle := lipgloss.NewStyle().Foreground(theme.Muted).PaddingLeft(2)
|
||||
if ss.search != "" {
|
||||
b.WriteString(emptyStyle.Render(fmt.Sprintf("No sessions matching %q", ss.search)))
|
||||
} else if ss.filter == SessionFilterNamed {
|
||||
b.WriteString(emptyStyle.Render("No named sessions. Press N to show all."))
|
||||
} else if ss.scope == SessionScopeCwd {
|
||||
b.WriteString(emptyStyle.Render("No sessions in current folder. Press tab to view all."))
|
||||
} else {
|
||||
b.WriteString(emptyStyle.Render("No sessions found"))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
} else {
|
||||
visH := ss.visibleHeight()
|
||||
|
||||
// Center the cursor in the visible window.
|
||||
startIdx := max(0, min(ss.cursor-visH/2, len(ss.filtered)-visH))
|
||||
endIdx := min(startIdx+visH, len(ss.filtered))
|
||||
|
||||
for i := startIdx; i < endIdx; i++ {
|
||||
info := ss.filtered[i]
|
||||
isCursor := i == ss.cursor
|
||||
isCurrent := info.Path == ss.currentPath
|
||||
isDeleting := i == ss.confirmDelete
|
||||
line := ss.renderEntry(info, isCursor, isCurrent, isDeleting, w)
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Scroll position indicator.
|
||||
if len(ss.filtered) > visH {
|
||||
posStyle := lipgloss.NewStyle().Foreground(theme.Muted).PaddingLeft(2)
|
||||
b.WriteString(posStyle.Render(fmt.Sprintf("(%d/%d)", ss.cursor+1, len(ss.filtered))))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return tea.NewView(b.String())
|
||||
}
|
||||
|
||||
// IsActive returns whether the selector is still accepting input.
|
||||
func (ss *SessionSelectorComponent) IsActive() bool {
|
||||
return ss.active
|
||||
}
|
||||
|
||||
// --- Internal helpers ---
|
||||
|
||||
func (ss *SessionSelectorComponent) visibleHeight() int {
|
||||
// Reserve: title(1) + help(1) + blank(1) + scroll indicator(1) = 4.
|
||||
// Optional: search(1), delete confirm(1).
|
||||
chrome := 4
|
||||
if ss.search != "" {
|
||||
chrome++
|
||||
}
|
||||
if ss.confirmDelete >= 0 {
|
||||
chrome++
|
||||
}
|
||||
return max(ss.height-chrome, 3)
|
||||
}
|
||||
|
||||
func (ss *SessionSelectorComponent) rebuildFiltered() {
|
||||
var source []session.SessionInfo
|
||||
if ss.scope == SessionScopeCwd {
|
||||
source = ss.cwdSessions
|
||||
} else {
|
||||
source = ss.allSessions
|
||||
}
|
||||
|
||||
if ss.filter == SessionFilterNamed {
|
||||
var named []session.SessionInfo
|
||||
for _, s := range source {
|
||||
if s.Name != "" {
|
||||
named = append(named, s)
|
||||
}
|
||||
}
|
||||
source = named
|
||||
}
|
||||
|
||||
if ss.search != "" {
|
||||
query := strings.ToLower(ss.search)
|
||||
var matches []session.SessionInfo
|
||||
for _, s := range source {
|
||||
haystack := strings.ToLower(s.Name + " " + s.FirstMessage + " " + s.Cwd)
|
||||
if strings.Contains(haystack, query) {
|
||||
matches = append(matches, s)
|
||||
}
|
||||
}
|
||||
ss.filtered = matches
|
||||
} else {
|
||||
ss.filtered = source
|
||||
}
|
||||
|
||||
if ss.cursor >= len(ss.filtered) {
|
||||
ss.cursor = max(len(ss.filtered)-1, 0)
|
||||
}
|
||||
}
|
||||
|
||||
func (ss *SessionSelectorComponent) removeSession(path string) {
|
||||
ss.cwdSessions = removeByPath(ss.cwdSessions, path)
|
||||
ss.allSessions = removeByPath(ss.allSessions, path)
|
||||
}
|
||||
|
||||
func removeByPath(sessions []session.SessionInfo, path string) []session.SessionInfo {
|
||||
result := make([]session.SessionInfo, 0, len(sessions))
|
||||
for _, s := range sessions {
|
||||
if s.Path != path {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// renderEntry renders a single session line with right-aligned metadata.
|
||||
// Layout: [cursor 2] [message ...variable...] [padding] [count age] [cwd?]
|
||||
func (ss *SessionSelectorComponent) renderEntry(info session.SessionInfo, isCursor, isCurrent, isDeleting bool, width int) string {
|
||||
theme := GetTheme()
|
||||
|
||||
// ── Cursor indicator (2 chars) ───────────────────────────────
|
||||
cursorStr := " "
|
||||
if isCursor {
|
||||
cursorStr = lipgloss.NewStyle().Foreground(theme.Accent).Render("› ")
|
||||
}
|
||||
const cursorW = 2
|
||||
|
||||
// ── Right part: message count + relative time (+ optional cwd) ──
|
||||
age := relativeTime(info.Modified)
|
||||
msgCount := fmt.Sprintf("%d", info.MessageCount)
|
||||
rightPart := msgCount + " " + age
|
||||
if ss.scope == SessionScopeAll && info.Cwd != "" {
|
||||
shortCwd := shortenPath(info.Cwd)
|
||||
if len(shortCwd) > 25 {
|
||||
shortCwd = "..." + shortCwd[len(shortCwd)-22:]
|
||||
}
|
||||
rightPart = shortCwd + " " + rightPart
|
||||
}
|
||||
rightW := utf8.RuneCountInString(rightPart)
|
||||
|
||||
// ── Message text ─────────────────────────────────────────────
|
||||
displayText := sessionDisplayName(info)
|
||||
// Strip control characters and collapse whitespace.
|
||||
displayText = controlCharsRe.ReplaceAllString(displayText, " ")
|
||||
displayText = strings.Join(strings.Fields(displayText), " ")
|
||||
|
||||
availableForMsg := max(width-cursorW-rightW-2, 10) // 2 for min spacing
|
||||
displayText = truncateRunes(displayText, availableForMsg)
|
||||
msgW := utf8.RuneCountInString(displayText)
|
||||
|
||||
// ── Style the message ────────────────────────────────────────
|
||||
msgStyle := lipgloss.NewStyle()
|
||||
switch {
|
||||
case isDeleting:
|
||||
msgStyle = msgStyle.Foreground(theme.Error)
|
||||
case isCurrent:
|
||||
msgStyle = msgStyle.Foreground(theme.Accent)
|
||||
case info.Name != "":
|
||||
msgStyle = msgStyle.Foreground(theme.Warning)
|
||||
default:
|
||||
msgStyle = msgStyle.Foreground(theme.Text)
|
||||
}
|
||||
if isCursor {
|
||||
msgStyle = msgStyle.Bold(true)
|
||||
}
|
||||
|
||||
styledMsg := msgStyle.Render(displayText)
|
||||
|
||||
// ── Style the right part ─────────────────────────────────────
|
||||
rightColor := theme.Muted
|
||||
if isDeleting {
|
||||
rightColor = theme.Error
|
||||
}
|
||||
styledRight := lipgloss.NewStyle().Foreground(rightColor).Render(rightPart)
|
||||
|
||||
// ── Assemble with spacing ────────────────────────────────────
|
||||
spacing := max(width-cursorW-msgW-rightW, 1)
|
||||
|
||||
line := cursorStr + styledMsg + strings.Repeat(" ", spacing) + styledRight
|
||||
|
||||
// ── Background highlight for selected row ────────────────────
|
||||
if isCursor {
|
||||
// Use a subtle background highlight. We apply it by wrapping the
|
||||
// full line in a style with a background color.
|
||||
bgStyle := lipgloss.NewStyle().
|
||||
Background(theme.Highlight).
|
||||
Width(width)
|
||||
line = bgStyle.Render(line)
|
||||
}
|
||||
|
||||
return line
|
||||
}
|
||||
|
||||
// --- Package helpers ---
|
||||
|
||||
// sessionDisplayName returns the best display string for a session:
|
||||
// the name if set, the first message, or a fallback.
|
||||
func sessionDisplayName(info session.SessionInfo) string {
|
||||
if info.Name != "" {
|
||||
return info.Name
|
||||
}
|
||||
if info.FirstMessage != "" {
|
||||
return info.FirstMessage
|
||||
}
|
||||
return "(empty session)"
|
||||
}
|
||||
|
||||
// truncateRunes truncates a string to at most maxRunes runes, appending "..."
|
||||
// if truncated.
|
||||
func truncateRunes(s string, maxRunes int) string {
|
||||
if maxRunes <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(s)
|
||||
if len(runes) <= maxRunes {
|
||||
return s
|
||||
}
|
||||
if maxRunes <= 3 {
|
||||
return string(runes[:maxRunes])
|
||||
}
|
||||
return string(runes[:maxRunes-1]) + "…"
|
||||
}
|
||||
|
||||
// shortenPath replaces the user's home directory prefix with ~.
|
||||
func shortenPath(path string) string {
|
||||
return tildeHome(path)
|
||||
}
|
||||
|
||||
// relativeTime formats a time as a short relative string like "5m", "2h", "3d".
|
||||
func relativeTime(t time.Time) string {
|
||||
d := time.Since(t)
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return "now"
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm", int(d.Minutes()))
|
||||
case d < 24*time.Hour:
|
||||
return fmt.Sprintf("%dh", int(d.Hours()))
|
||||
case d < 7*24*time.Hour:
|
||||
return fmt.Sprintf("%dd", int(d.Hours()/24))
|
||||
case d < 30*24*time.Hour:
|
||||
return fmt.Sprintf("%dw", int(d.Hours()/(24*7)))
|
||||
case d < 365*24*time.Hour:
|
||||
return fmt.Sprintf("%dmo", int(d.Hours()/(24*30)))
|
||||
default:
|
||||
return fmt.Sprintf("%dy", int(d.Hours()/(24*365)))
|
||||
}
|
||||
}
|
||||
@@ -49,11 +49,12 @@ func NewSlashCommandInput(width int, title string) *SlashCommandInput {
|
||||
key.WithHelp("ctrl+j", "insert newline"),
|
||||
)
|
||||
|
||||
// Style the textarea to match huh theme
|
||||
// Style the textarea using theme colors.
|
||||
theme := GetTheme()
|
||||
styles := ta.Styles()
|
||||
styles.Focused.Base = lipgloss.NewStyle()
|
||||
styles.Focused.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
|
||||
styles.Focused.Text = lipgloss.NewStyle().Foreground(lipgloss.Color("252"))
|
||||
styles.Focused.Placeholder = lipgloss.NewStyle().Foreground(theme.VeryMuted)
|
||||
styles.Focused.Text = lipgloss.NewStyle().Foreground(theme.Text)
|
||||
styles.Focused.Prompt = lipgloss.NewStyle()
|
||||
styles.Focused.CursorLine = lipgloss.NewStyle()
|
||||
ta.SetStyles(styles)
|
||||
@@ -178,9 +179,11 @@ func (s *SlashCommandInput) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
func (s *SlashCommandInput) View() tea.View {
|
||||
containerStyle := lipgloss.NewStyle()
|
||||
|
||||
theme := GetTheme()
|
||||
|
||||
// PaddingLeft(3) aligns with message content: border(1) + paddingLeft(2).
|
||||
titleStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("252")).
|
||||
Foreground(theme.Text).
|
||||
MarginBottom(1).
|
||||
PaddingLeft(3)
|
||||
|
||||
@@ -191,7 +194,7 @@ func (s *SlashCommandInput) View() tea.View {
|
||||
BorderRight(false).
|
||||
BorderTop(false).
|
||||
BorderBottom(false).
|
||||
BorderForeground(lipgloss.Color("39")).
|
||||
BorderForeground(theme.Primary).
|
||||
PaddingLeft(2). // match message block paddingLeft
|
||||
Width(s.width - 1) // full width minus left border
|
||||
|
||||
@@ -223,7 +226,7 @@ func (s *SlashCommandInput) View() tea.View {
|
||||
// Add help text at bottom (unless hidden by extension).
|
||||
if !s.hideHint {
|
||||
helpStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("240")).
|
||||
Foreground(theme.VeryMuted).
|
||||
MarginTop(1).
|
||||
PaddingLeft(3)
|
||||
|
||||
@@ -240,10 +243,12 @@ func (s *SlashCommandInput) View() tea.View {
|
||||
|
||||
// renderPopup renders the autocomplete popup
|
||||
func (s *SlashCommandInput) renderPopup() string {
|
||||
theme := GetTheme()
|
||||
|
||||
// Popup styling
|
||||
popupStyle := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("236")).
|
||||
BorderForeground(theme.MutedBorder).
|
||||
Padding(1, 2).
|
||||
Width(s.width - 4). // Account for container padding
|
||||
MarginLeft(0) // No extra margin needed due to container padding
|
||||
@@ -268,7 +273,7 @@ func (s *SlashCommandInput) renderPopup() string {
|
||||
var indicator string
|
||||
if i == s.selected {
|
||||
indicator = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("39")).
|
||||
Foreground(theme.Primary).
|
||||
Render("> ")
|
||||
} else {
|
||||
indicator = " "
|
||||
@@ -276,16 +281,16 @@ func (s *SlashCommandInput) renderPopup() string {
|
||||
|
||||
// Format item
|
||||
nameStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("39")).
|
||||
Foreground(theme.Secondary).
|
||||
Bold(true)
|
||||
|
||||
descStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("243"))
|
||||
Foreground(theme.Muted)
|
||||
|
||||
// Highlight selected item
|
||||
if i == s.selected {
|
||||
nameStyle = nameStyle.Foreground(lipgloss.Color("87"))
|
||||
descStyle = descStyle.Foreground(lipgloss.Color("250"))
|
||||
nameStyle = nameStyle.Foreground(theme.Primary)
|
||||
descStyle = descStyle.Foreground(theme.Text)
|
||||
}
|
||||
|
||||
// Format with proper spacing
|
||||
@@ -305,11 +310,11 @@ func (s *SlashCommandInput) renderPopup() string {
|
||||
|
||||
// Add scroll indicators if needed
|
||||
if startIdx > 0 {
|
||||
scrollUpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("238"))
|
||||
scrollUpStyle := lipgloss.NewStyle().Foreground(theme.VeryMuted)
|
||||
items = append([]string{scrollUpStyle.Render(" ↑ more above")}, items...)
|
||||
}
|
||||
if endIdx < len(s.filtered) {
|
||||
scrollDownStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("238"))
|
||||
scrollDownStyle := lipgloss.NewStyle().Foreground(theme.VeryMuted)
|
||||
items = append(items, scrollDownStyle.Render(" ↓ more below"))
|
||||
}
|
||||
// Join items
|
||||
@@ -317,7 +322,7 @@ func (s *SlashCommandInput) renderPopup() string {
|
||||
|
||||
// Add footer hint
|
||||
footerStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("238")).
|
||||
Foreground(theme.VeryMuted).
|
||||
Italic(true)
|
||||
footer := footerStyle.Render("↑↓ navigate • tab complete • ↵ select • esc dismiss")
|
||||
|
||||
|
||||
+40
-13
@@ -59,14 +59,20 @@ func knightRiderFrames() []string {
|
||||
}
|
||||
|
||||
// streamSpinnerTickMsg is the internal tick message that drives the KITT-style
|
||||
// spinner animation inside StreamComponent.
|
||||
type streamSpinnerTickMsg struct{}
|
||||
// spinner animation inside StreamComponent. The generation field ties each tick
|
||||
// to the spinner session that created it so that stale ticks from a previous
|
||||
// start/stop cycle are silently discarded instead of creating a second
|
||||
// concurrent tick loop (which doubles the animation speed).
|
||||
type streamSpinnerTickMsg struct {
|
||||
generation uint64
|
||||
}
|
||||
|
||||
// streamSpinnerTickCmd returns a tea.Cmd that fires streamSpinnerTickMsg at the
|
||||
// KITT animation frame rate (14 fps).
|
||||
func streamSpinnerTickCmd() tea.Cmd {
|
||||
// KITT animation frame rate (14 fps). The generation parameter is embedded in
|
||||
// the message so the receiver can verify it matches the current spinner session.
|
||||
func streamSpinnerTickCmd(generation uint64) tea.Cmd {
|
||||
return tea.Tick(time.Second/14, func(_ time.Time) tea.Msg {
|
||||
return streamSpinnerTickMsg{}
|
||||
return streamSpinnerTickMsg{generation: generation}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -128,6 +134,15 @@ type StreamComponent struct {
|
||||
// remains visible alongside streaming text until Reset().
|
||||
spinning bool
|
||||
|
||||
// spinnerGeneration is incremented each time a new spinner tick loop
|
||||
// is started. Tick messages carry the generation they were created for;
|
||||
// if a tick's generation doesn't match the current one, it is a stale
|
||||
// tick from a previous start/stop cycle and is silently discarded.
|
||||
// This prevents multiple concurrent tick loops from accumulating when
|
||||
// the spinner is rapidly stopped and restarted (e.g. SpinnerEvent
|
||||
// hide → ToolExecutionEvent start before the old tick fires).
|
||||
spinnerGeneration uint64
|
||||
|
||||
// spinnerFrames are the pre-rendered KITT animation frames.
|
||||
spinnerFrames []string
|
||||
|
||||
@@ -207,7 +222,7 @@ func NewStreamComponent(compactMode bool, width int, modelName string) *StreamCo
|
||||
spinnerFrames: knightRiderFrames(),
|
||||
compactMode: compactMode,
|
||||
modelName: modelName,
|
||||
messageRenderer: NewMessageRenderer(width, false),
|
||||
messageRenderer: newMessageRenderer(width, false),
|
||||
compactRenderer: NewCompactRenderer(width, false),
|
||||
width: width,
|
||||
}
|
||||
@@ -233,6 +248,7 @@ func (s *StreamComponent) SetHeight(h int) {
|
||||
func (s *StreamComponent) Reset() {
|
||||
s.phase = streamPhaseIdle
|
||||
s.spinning = false
|
||||
s.spinnerGeneration++ // invalidate any in-flight tick commands
|
||||
s.spinnerFrame = 0
|
||||
s.activeTools = nil
|
||||
s.streamContent.Reset()
|
||||
@@ -313,11 +329,15 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
s.renderDirty = true
|
||||
|
||||
case streamSpinnerTickMsg:
|
||||
if s.spinning {
|
||||
// Only continue the tick loop if this tick belongs to the current
|
||||
// spinner session. Stale ticks from a previous start/stop cycle
|
||||
// are silently dropped, preventing duplicate concurrent tick loops
|
||||
// that would double (or worse) the animation speed.
|
||||
if s.spinning && msg.generation == s.spinnerGeneration {
|
||||
s.spinnerFrame++
|
||||
return s, streamSpinnerTickCmd()
|
||||
return s, streamSpinnerTickCmd(s.spinnerGeneration)
|
||||
}
|
||||
// Spinning stopped; let the tick loop die naturally.
|
||||
// Spinning stopped or generation mismatch; let the tick loop die.
|
||||
|
||||
// ── App-layer events ──────────────────────────────────────────────────
|
||||
|
||||
@@ -325,13 +345,17 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if msg.Show && !s.spinning {
|
||||
s.phase = streamPhaseActive
|
||||
s.spinning = true
|
||||
s.spinnerGeneration++ // new session; invalidate any stale ticks
|
||||
s.spinnerFrame = 0
|
||||
if s.timestamp.IsZero() {
|
||||
s.timestamp = time.Now()
|
||||
}
|
||||
return s, streamSpinnerTickCmd()
|
||||
return s, streamSpinnerTickCmd(s.spinnerGeneration)
|
||||
} else if !msg.Show && s.spinning {
|
||||
s.spinning = false
|
||||
// Bump generation so any in-flight tick from this session is
|
||||
// discarded if spinning is restarted before it fires.
|
||||
s.spinnerGeneration++
|
||||
}
|
||||
|
||||
case streamFlushTickMsg:
|
||||
@@ -376,7 +400,8 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if !s.spinning {
|
||||
s.phase = streamPhaseActive
|
||||
s.spinning = true
|
||||
return s, streamSpinnerTickCmd()
|
||||
s.spinnerGeneration++ // new session; invalidate stale ticks
|
||||
return s, streamSpinnerTickCmd(s.spinnerGeneration)
|
||||
}
|
||||
} else {
|
||||
// Tool finished — remove from active list but keep spinning if others remain.
|
||||
@@ -459,6 +484,7 @@ func (s *StreamComponent) renderReasoningBlock(reasoning string) string {
|
||||
|
||||
contentStyle := lipgloss.NewStyle().
|
||||
Foreground(theme.Muted).
|
||||
Background(theme.MutedBorder).
|
||||
Italic(true)
|
||||
|
||||
var parts []string
|
||||
@@ -470,6 +496,7 @@ func (s *StreamComponent) renderReasoningBlock(reasoning string) string {
|
||||
hidden := len(lines) - maxCollapsedLines
|
||||
hintStyle := lipgloss.NewStyle().
|
||||
Foreground(theme.VeryMuted).
|
||||
Background(theme.MutedBorder).
|
||||
Italic(true)
|
||||
parts = append(parts, hintStyle.Render(fmt.Sprintf("... (%d lines hidden)", hidden)))
|
||||
lines = lines[len(lines)-maxCollapsedLines:]
|
||||
@@ -492,8 +519,8 @@ func (s *StreamComponent) renderReasoningBlock(reasoning string) string {
|
||||
} else {
|
||||
durationStr = fmt.Sprintf("%.1fs", duration.Seconds())
|
||||
}
|
||||
footer := lipgloss.NewStyle().Foreground(theme.VeryMuted).Render("Thought for ") +
|
||||
lipgloss.NewStyle().Foreground(theme.Info).Render(durationStr)
|
||||
footer := lipgloss.NewStyle().Foreground(theme.VeryMuted).Background(theme.MutedBorder).Render("Thought for ") +
|
||||
lipgloss.NewStyle().Foreground(theme.Info).Background(theme.MutedBorder).Render(durationStr)
|
||||
parts = append(parts, footer)
|
||||
}
|
||||
|
||||
|
||||
+83
-124
@@ -1,11 +1,12 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/glamour"
|
||||
"github.com/charmbracelet/glamour/ansi"
|
||||
"github.com/mark3labs/kit/internal/config"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// uintPtr returns a pointer to u. Used by ansi.StyleConfig fields.
|
||||
@@ -20,6 +21,18 @@ func BaseStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle()
|
||||
}
|
||||
|
||||
// colorHex converts a color.Color to a hex string suitable for ansi.StyleConfig.
|
||||
func colorHex(c color.Color) string {
|
||||
r, g, b, _ := c.RGBA()
|
||||
return fmt.Sprintf("#%02x%02x%02x", r>>8, g>>8, b>>8)
|
||||
}
|
||||
|
||||
// colorHexPtr returns a pointer to the hex string of a color.Color.
|
||||
func colorHexPtr(c color.Color) *string {
|
||||
s := colorHex(c)
|
||||
return &s
|
||||
}
|
||||
|
||||
// GetMarkdownRenderer creates and returns a configured glamour.TermRenderer for
|
||||
// rendering markdown content with syntax highlighting and proper formatting. The
|
||||
// renderer is customized with our theme colors and adapted to the specified width.
|
||||
@@ -31,169 +44,119 @@ func GetMarkdownRenderer(width int) *glamour.TermRenderer {
|
||||
return r
|
||||
}
|
||||
|
||||
// colorScheme holds resolved color values for markdown rendering.
|
||||
type colorScheme struct {
|
||||
text string
|
||||
muted string
|
||||
heading string
|
||||
emph string
|
||||
strong string
|
||||
link string
|
||||
code string
|
||||
err string
|
||||
keyword string
|
||||
str string
|
||||
number string
|
||||
comment string
|
||||
}
|
||||
|
||||
// resolveColorScheme determines the color palette based on user config and background.
|
||||
func resolveColorScheme() colorScheme {
|
||||
var mdTheme config.MarkdownTheme
|
||||
err := config.FilepathOr("markdown-theme", &mdTheme)
|
||||
fromConfig := err == nil && viper.InConfig("markdown-theme")
|
||||
|
||||
if fromConfig && IsDarkBackground() {
|
||||
return colorScheme{
|
||||
text: mdTheme.Text.Light, muted: mdTheme.Muted.Light,
|
||||
heading: mdTheme.Heading.Light, emph: mdTheme.Emph.Light,
|
||||
strong: mdTheme.Strong.Light, link: mdTheme.Link.Light,
|
||||
code: mdTheme.Code.Light, err: mdTheme.Error.Light,
|
||||
keyword: mdTheme.Keyword.Light, str: mdTheme.String.Light,
|
||||
number: mdTheme.Number.Light, comment: mdTheme.Comment.Light,
|
||||
}
|
||||
}
|
||||
if fromConfig {
|
||||
return colorScheme{
|
||||
text: mdTheme.Text.Dark, muted: mdTheme.Muted.Dark,
|
||||
heading: mdTheme.Heading.Dark, emph: mdTheme.Emph.Dark,
|
||||
strong: mdTheme.Strong.Dark, link: mdTheme.Link.Dark,
|
||||
code: mdTheme.Code.Dark, err: mdTheme.Error.Dark,
|
||||
keyword: mdTheme.Keyword.Dark, str: mdTheme.String.Dark,
|
||||
number: mdTheme.Number.Dark, comment: mdTheme.Comment.Dark,
|
||||
}
|
||||
}
|
||||
if IsDarkBackground() {
|
||||
return colorScheme{
|
||||
text: "#F9FAFB", muted: "#9CA3AF",
|
||||
heading: "#22D3EE", emph: "#FDE047",
|
||||
strong: "#F9FAFB", link: "#60A5FA",
|
||||
code: "#D1D5DB", err: "#F87171",
|
||||
keyword: "#C084FC", str: "#34D399",
|
||||
number: "#FBBF24", comment: "#9CA3AF",
|
||||
}
|
||||
}
|
||||
return colorScheme{
|
||||
text: "#1F2937", muted: "#6B7280",
|
||||
heading: "#0891B2", emph: "#D97706",
|
||||
strong: "#1F2937", link: "#2563EB",
|
||||
code: "#374151", err: "#DC2626",
|
||||
keyword: "#7C3AED", str: "#059669",
|
||||
number: "#D97706", comment: "#6B7280",
|
||||
}
|
||||
}
|
||||
|
||||
// generateMarkdownStyleConfig creates an ansi.StyleConfig for markdown rendering.
|
||||
// generateMarkdownStyleConfig creates an ansi.StyleConfig from the active theme.
|
||||
func generateMarkdownStyleConfig() ansi.StyleConfig {
|
||||
cs := resolveColorScheme()
|
||||
md := GetTheme().Markdown
|
||||
text := colorHexPtr(md.Text)
|
||||
muted := colorHexPtr(md.Muted)
|
||||
heading := colorHexPtr(md.Heading)
|
||||
emph := colorHexPtr(md.Emph)
|
||||
strong := colorHexPtr(md.Strong)
|
||||
link := colorHexPtr(md.Link)
|
||||
code := colorHexPtr(md.Code)
|
||||
errClr := colorHexPtr(md.Error)
|
||||
keyword := colorHexPtr(md.Keyword)
|
||||
str := colorHexPtr(md.String)
|
||||
number := colorHexPtr(md.Number)
|
||||
comment := colorHexPtr(md.Comment)
|
||||
|
||||
return ansi.StyleConfig{
|
||||
Document: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
BlockPrefix: "",
|
||||
BlockSuffix: "",
|
||||
Color: &cs.text,
|
||||
Color: text,
|
||||
},
|
||||
Margin: uintPtr(0), // Remove margin to prevent spacing
|
||||
Margin: uintPtr(0),
|
||||
},
|
||||
BlockQuote: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
Color: &cs.muted,
|
||||
Color: muted,
|
||||
Italic: new(true),
|
||||
Prefix: "┃ ",
|
||||
},
|
||||
Indent: uintPtr(1),
|
||||
},
|
||||
List: ansi.StyleList{
|
||||
LevelIndent: 0, // Remove list indentation
|
||||
LevelIndent: 0,
|
||||
StyleBlock: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
Color: &cs.text,
|
||||
Color: text,
|
||||
},
|
||||
},
|
||||
},
|
||||
Heading: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
BlockSuffix: "\n",
|
||||
Color: &cs.heading,
|
||||
Color: heading,
|
||||
Bold: new(true),
|
||||
},
|
||||
},
|
||||
H1: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
Prefix: "# ",
|
||||
Color: &cs.heading,
|
||||
Color: heading,
|
||||
Bold: new(true),
|
||||
},
|
||||
},
|
||||
H2: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
Prefix: "## ",
|
||||
Color: &cs.heading,
|
||||
Color: heading,
|
||||
Bold: new(true),
|
||||
},
|
||||
},
|
||||
H3: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
Prefix: "### ",
|
||||
Color: &cs.heading,
|
||||
Color: heading,
|
||||
Bold: new(true),
|
||||
},
|
||||
},
|
||||
H4: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
Prefix: "#### ",
|
||||
Color: &cs.heading,
|
||||
Color: heading,
|
||||
Bold: new(true),
|
||||
},
|
||||
},
|
||||
H5: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
Prefix: "##### ",
|
||||
Color: &cs.heading,
|
||||
Color: heading,
|
||||
Bold: new(true),
|
||||
},
|
||||
},
|
||||
H6: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
Prefix: "###### ",
|
||||
Color: &cs.heading,
|
||||
Color: heading,
|
||||
Bold: new(true),
|
||||
},
|
||||
},
|
||||
Strikethrough: ansi.StylePrimitive{
|
||||
CrossedOut: new(true),
|
||||
Color: &cs.muted,
|
||||
Color: muted,
|
||||
},
|
||||
Emph: ansi.StylePrimitive{
|
||||
Color: &cs.emph,
|
||||
Color: emph,
|
||||
Italic: new(true),
|
||||
},
|
||||
Strong: ansi.StylePrimitive{
|
||||
Bold: new(true),
|
||||
Color: &cs.strong,
|
||||
Color: strong,
|
||||
},
|
||||
HorizontalRule: ansi.StylePrimitive{
|
||||
Color: &cs.muted,
|
||||
Color: muted,
|
||||
Format: "\n─────────────────────────────────────────\n",
|
||||
},
|
||||
Item: ansi.StylePrimitive{
|
||||
BlockPrefix: "• ",
|
||||
Color: &cs.text,
|
||||
Color: text,
|
||||
},
|
||||
Enumeration: ansi.StylePrimitive{
|
||||
BlockPrefix: ". ",
|
||||
Color: &cs.text,
|
||||
Color: text,
|
||||
},
|
||||
Task: ansi.StyleTask{
|
||||
StylePrimitive: ansi.StylePrimitive{},
|
||||
@@ -201,25 +164,25 @@ func generateMarkdownStyleConfig() ansi.StyleConfig {
|
||||
Unticked: "[ ] ",
|
||||
},
|
||||
Link: ansi.StylePrimitive{
|
||||
Color: &cs.link,
|
||||
Color: link,
|
||||
Underline: new(true),
|
||||
},
|
||||
LinkText: ansi.StylePrimitive{
|
||||
Color: &cs.link,
|
||||
Color: link,
|
||||
Bold: new(true),
|
||||
},
|
||||
Image: ansi.StylePrimitive{
|
||||
Color: &cs.link,
|
||||
Color: link,
|
||||
Underline: new(true),
|
||||
Format: "🖼 {{.text}}",
|
||||
},
|
||||
ImageText: ansi.StylePrimitive{
|
||||
Color: &cs.link,
|
||||
Color: link,
|
||||
Format: "{{.text}}",
|
||||
},
|
||||
Code: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
Color: &cs.code,
|
||||
Color: code,
|
||||
Prefix: "",
|
||||
Suffix: "",
|
||||
},
|
||||
@@ -228,50 +191,46 @@ func generateMarkdownStyleConfig() ansi.StyleConfig {
|
||||
StyleBlock: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
Prefix: "",
|
||||
Color: &cs.code,
|
||||
Color: code,
|
||||
},
|
||||
Margin: uintPtr(0), // Remove margin
|
||||
Margin: uintPtr(0),
|
||||
},
|
||||
Chroma: &ansi.Chroma{
|
||||
Text: ansi.StylePrimitive{Color: &cs.text},
|
||||
Error: ansi.StylePrimitive{Color: &cs.err},
|
||||
Comment: ansi.StylePrimitive{Color: &cs.comment},
|
||||
CommentPreproc: ansi.StylePrimitive{Color: &cs.keyword},
|
||||
Keyword: ansi.StylePrimitive{Color: &cs.keyword},
|
||||
KeywordReserved: ansi.StylePrimitive{
|
||||
Color: &cs.keyword,
|
||||
},
|
||||
KeywordNamespace: ansi.StylePrimitive{
|
||||
Color: &cs.keyword,
|
||||
},
|
||||
KeywordType: ansi.StylePrimitive{Color: &cs.keyword},
|
||||
Operator: ansi.StylePrimitive{Color: &cs.text},
|
||||
Punctuation: ansi.StylePrimitive{Color: &cs.text},
|
||||
Name: ansi.StylePrimitive{Color: &cs.text},
|
||||
NameBuiltin: ansi.StylePrimitive{Color: &cs.text},
|
||||
NameTag: ansi.StylePrimitive{Color: &cs.keyword},
|
||||
NameAttribute: ansi.StylePrimitive{Color: &cs.text},
|
||||
NameClass: ansi.StylePrimitive{Color: &cs.keyword},
|
||||
NameConstant: ansi.StylePrimitive{Color: &cs.text},
|
||||
NameDecorator: ansi.StylePrimitive{Color: &cs.text},
|
||||
NameFunction: ansi.StylePrimitive{Color: &cs.text},
|
||||
LiteralNumber: ansi.StylePrimitive{Color: &cs.number},
|
||||
LiteralString: ansi.StylePrimitive{Color: &cs.str},
|
||||
Text: ansi.StylePrimitive{Color: text},
|
||||
Error: ansi.StylePrimitive{Color: errClr},
|
||||
Comment: ansi.StylePrimitive{Color: comment},
|
||||
CommentPreproc: ansi.StylePrimitive{Color: keyword},
|
||||
Keyword: ansi.StylePrimitive{Color: keyword},
|
||||
KeywordReserved: ansi.StylePrimitive{Color: keyword},
|
||||
KeywordNamespace: ansi.StylePrimitive{Color: keyword},
|
||||
KeywordType: ansi.StylePrimitive{Color: keyword},
|
||||
Operator: ansi.StylePrimitive{Color: text},
|
||||
Punctuation: ansi.StylePrimitive{Color: text},
|
||||
Name: ansi.StylePrimitive{Color: text},
|
||||
NameBuiltin: ansi.StylePrimitive{Color: text},
|
||||
NameTag: ansi.StylePrimitive{Color: keyword},
|
||||
NameAttribute: ansi.StylePrimitive{Color: text},
|
||||
NameClass: ansi.StylePrimitive{Color: keyword},
|
||||
NameConstant: ansi.StylePrimitive{Color: text},
|
||||
NameDecorator: ansi.StylePrimitive{Color: text},
|
||||
NameFunction: ansi.StylePrimitive{Color: text},
|
||||
LiteralNumber: ansi.StylePrimitive{Color: number},
|
||||
LiteralString: ansi.StylePrimitive{Color: str},
|
||||
LiteralStringEscape: ansi.StylePrimitive{
|
||||
Color: &cs.keyword,
|
||||
Color: keyword,
|
||||
},
|
||||
GenericDeleted: ansi.StylePrimitive{Color: &cs.err},
|
||||
GenericDeleted: ansi.StylePrimitive{Color: errClr},
|
||||
GenericEmph: ansi.StylePrimitive{
|
||||
Color: &cs.emph,
|
||||
Color: emph,
|
||||
Italic: new(true),
|
||||
},
|
||||
GenericInserted: ansi.StylePrimitive{Color: &cs.str},
|
||||
GenericInserted: ansi.StylePrimitive{Color: str},
|
||||
GenericStrong: ansi.StylePrimitive{
|
||||
Color: &cs.strong,
|
||||
Color: strong,
|
||||
Bold: new(true),
|
||||
},
|
||||
GenericSubheading: ansi.StylePrimitive{
|
||||
Color: &cs.heading,
|
||||
Color: heading,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -288,14 +247,14 @@ func generateMarkdownStyleConfig() ansi.StyleConfig {
|
||||
},
|
||||
DefinitionDescription: ansi.StylePrimitive{
|
||||
BlockPrefix: "\n ❯ ",
|
||||
Color: &cs.link,
|
||||
Color: link,
|
||||
},
|
||||
Text: ansi.StylePrimitive{
|
||||
Color: &cs.text,
|
||||
Color: text,
|
||||
},
|
||||
Paragraph: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
Color: &cs.text,
|
||||
Color: text,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,748 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image/color"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Color derivation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// parseHexColor parses a "#RRGGBB" hex string into r, g, b components (0-255).
|
||||
func parseHexColor(hex string) (r, g, b int) {
|
||||
hex = strings.TrimPrefix(hex, "#")
|
||||
if len(hex) == 6 {
|
||||
if v, err := strconv.ParseUint(hex[0:2], 16, 8); err == nil {
|
||||
r = int(v)
|
||||
}
|
||||
if v, err := strconv.ParseUint(hex[2:4], 16, 8); err == nil {
|
||||
g = int(v)
|
||||
}
|
||||
if v, err := strconv.ParseUint(hex[4:6], 16, 8); err == nil {
|
||||
b = int(v)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// blendHex linearly interpolates between two hex colors by amount (0.0–1.0).
|
||||
func blendHex(base, tint string, amount float64) string {
|
||||
br, bg, bb := parseHexColor(base)
|
||||
tr, tg, tb := parseHexColor(tint)
|
||||
clamp := func(v int) int {
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
if v > 255 {
|
||||
return 255
|
||||
}
|
||||
return v
|
||||
}
|
||||
r := clamp(int(float64(br)*(1-amount) + float64(tr)*amount))
|
||||
g := clamp(int(float64(bg)*(1-amount) + float64(tg)*amount))
|
||||
b := clamp(int(float64(bb)*(1-amount) + float64(tb)*amount))
|
||||
return fmt.Sprintf("#%02x%02x%02x", r, g, b)
|
||||
}
|
||||
|
||||
// deriveDiffBg computes diff / code background colors from the theme's
|
||||
// background, success, and error hex pairs. Returns an adaptive color for each
|
||||
// diff element. The tint amounts are tuned for subtle differentiation.
|
||||
func deriveDiffBg(bgPair, successPair, errorPair [2]string) (diffInsert, diffDelete, diffEqual, diffMissing, codeBg, gutterBg, writeBg color.Color) {
|
||||
derive := func(idx int) (color.Color, color.Color, color.Color, color.Color) {
|
||||
bg := bgPair[idx]
|
||||
// Contrast target: darken for light mode (idx 0), lighten for dark (idx 1).
|
||||
contrast := "#000000"
|
||||
if idx == 1 {
|
||||
contrast = "#ffffff"
|
||||
}
|
||||
ins := blendHex(bg, successPair[idx], 0.13)
|
||||
del := blendHex(bg, errorPair[idx], 0.13)
|
||||
eq := blendHex(bg, contrast, 0.05)
|
||||
miss := blendHex(bg, contrast, 0.03)
|
||||
return AdaptiveColor(ins, ins), AdaptiveColor(del, del), AdaptiveColor(eq, eq), AdaptiveColor(miss, miss)
|
||||
}
|
||||
|
||||
// Pick the correct index based on detected background.
|
||||
idx := 0
|
||||
if isDarkBg {
|
||||
idx = 1
|
||||
}
|
||||
insL, delL, eqL, missL := derive(idx)
|
||||
diffInsert = insL
|
||||
diffDelete = delL
|
||||
diffEqual = eqL
|
||||
diffMissing = missL
|
||||
codeBg = eqL
|
||||
gutterBg = missL
|
||||
writeBg = insL
|
||||
return
|
||||
}
|
||||
|
||||
// ThemeEntry is a named, loadable theme — either built-in or discovered from disk.
|
||||
type ThemeEntry struct {
|
||||
Name string // Display name (filename stem or preset name)
|
||||
Source string // "builtin" or absolute file path
|
||||
theme Theme // Resolved theme (lazy-loaded for file-based)
|
||||
loaded bool
|
||||
}
|
||||
|
||||
// Theme returns the resolved ui.Theme, loading from disk on first access.
|
||||
func (e *ThemeEntry) Theme() (Theme, error) {
|
||||
if e.loaded {
|
||||
return e.theme, nil
|
||||
}
|
||||
if e.Source == "builtin" {
|
||||
// Already set at registration time.
|
||||
return e.theme, nil
|
||||
}
|
||||
t, err := loadThemeFile(e.Source)
|
||||
if err != nil {
|
||||
return Theme{}, fmt.Errorf("loading theme %q: %w", e.Name, err)
|
||||
}
|
||||
e.theme = t
|
||||
e.loaded = true
|
||||
return e.theme, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Built-in presets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// builtinThemes returns the set of themes shipped with Kit.
|
||||
// makeTheme builds a full Theme from a compact palette spec. Fields left as
|
||||
// zero color.Color inherit from the KITT default theme, keeping the preset
|
||||
// definitions focused on what differs.
|
||||
type presetColors struct {
|
||||
primary, secondary, success, warning, error_, info [2]string // [light, dark]
|
||||
text, muted, veryMuted, background, border, mutedBorder [2]string
|
||||
system, tool, accent, highlight [2]string
|
||||
mdKeyword, mdString, mdNumber, mdComment, mdHeading, mdLink [2]string
|
||||
}
|
||||
|
||||
func makeTheme(p presetColors) Theme {
|
||||
ac := func(pair [2]string) color.Color { return AdaptiveColor(pair[0], pair[1]) }
|
||||
def := DefaultTheme()
|
||||
acOr := func(pair [2]string, fb color.Color) color.Color {
|
||||
if pair[0] == "" && pair[1] == "" {
|
||||
return fb
|
||||
}
|
||||
return ac(pair)
|
||||
}
|
||||
t := Theme{
|
||||
Primary: ac(p.primary),
|
||||
Secondary: acOr(p.secondary, ac(p.primary)),
|
||||
Success: ac(p.success),
|
||||
Warning: ac(p.warning),
|
||||
Error: ac(p.error_),
|
||||
Info: ac(p.info),
|
||||
Text: ac(p.text),
|
||||
Muted: acOr(p.muted, def.Muted),
|
||||
VeryMuted: acOr(p.veryMuted, def.VeryMuted),
|
||||
Background: ac(p.background),
|
||||
Border: acOr(p.border, def.Border),
|
||||
MutedBorder: acOr(p.mutedBorder, def.MutedBorder),
|
||||
System: acOr(p.system, ac(p.info)),
|
||||
Tool: acOr(p.tool, ac(p.warning)),
|
||||
Accent: acOr(p.accent, ac(p.primary)),
|
||||
Highlight: acOr(p.highlight, def.Highlight),
|
||||
}
|
||||
// Derive diff/code backgrounds from the theme's own palette.
|
||||
t.DiffInsertBg, t.DiffDeleteBg, t.DiffEqualBg, t.DiffMissingBg,
|
||||
t.CodeBg, t.GutterBg, t.WriteBg = deriveDiffBg(p.background, p.success, p.error_)
|
||||
// Markdown colors.
|
||||
t.Markdown = MarkdownThemeColors{
|
||||
Text: t.Text,
|
||||
Muted: t.Muted,
|
||||
Heading: acOr(p.mdHeading, t.Primary),
|
||||
Emph: t.Warning,
|
||||
Strong: t.Text,
|
||||
Link: acOr(p.mdLink, t.Info),
|
||||
Code: t.Muted,
|
||||
Error: t.Error,
|
||||
Keyword: acOr(p.mdKeyword, t.Primary),
|
||||
String: acOr(p.mdString, t.Success),
|
||||
Number: acOr(p.mdNumber, t.Warning),
|
||||
Comment: acOr(p.mdComment, t.VeryMuted),
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// builtinThemes returns the set of themes shipped with Kit.
|
||||
// Inspired by the OpenCode theme collection.
|
||||
func builtinThemes() map[string]Theme {
|
||||
return map[string]Theme{
|
||||
"kitt": DefaultTheme(),
|
||||
|
||||
"catppuccin": makeTheme(presetColors{
|
||||
primary: [2]string{"#8839ef", "#cba6f7"}, secondary: [2]string{"#04a5e5", "#89dceb"},
|
||||
success: [2]string{"#40a02b", "#a6e3a1"}, warning: [2]string{"#df8e1d", "#f9e2af"},
|
||||
error_: [2]string{"#d20f39", "#f38ba8"}, info: [2]string{"#1e66f5", "#89b4fa"},
|
||||
text: [2]string{"#4c4f69", "#cdd6f4"}, muted: [2]string{"#6c6f85", "#a6adc8"},
|
||||
veryMuted: [2]string{"#9ca0b0", "#6c7086"}, background: [2]string{"#eff1f5", "#1e1e2e"},
|
||||
border: [2]string{"#acb0be", "#585b70"}, mutedBorder: [2]string{"#ccd0da", "#313244"},
|
||||
system: [2]string{"#179299", "#94e2d5"}, tool: [2]string{"#fe640b", "#fab387"},
|
||||
accent: [2]string{"#ea76cb", "#f5c2e7"}, highlight: [2]string{"#e6e9ef", "#181825"},
|
||||
mdKeyword: [2]string{"#8839ef", "#cba6f7"}, mdString: [2]string{"#40a02b", "#a6e3a1"},
|
||||
mdNumber: [2]string{"#fe640b", "#fab387"}, mdComment: [2]string{"#9ca0b0", "#6c7086"},
|
||||
}),
|
||||
|
||||
"dracula": makeTheme(presetColors{
|
||||
primary: [2]string{"#7c6bf5", "#bd93f9"}, secondary: [2]string{"#d16090", "#ff79c6"},
|
||||
success: [2]string{"#2fbf71", "#50fa7b"}, warning: [2]string{"#f7a14d", "#ffb86c"},
|
||||
error_: [2]string{"#d9536f", "#ff5555"}, info: [2]string{"#1d7fc5", "#8be9fd"},
|
||||
text: [2]string{"#1f1f2f", "#f8f8f2"}, background: [2]string{"#f8f8f2", "#1d1e28"},
|
||||
accent: [2]string{"#d16090", "#ff79c6"},
|
||||
mdKeyword: [2]string{"#7c6bf5", "#bd93f9"}, mdString: [2]string{"#2fbf71", "#50fa7b"},
|
||||
mdComment: [2]string{"#6272a4", "#6272a4"},
|
||||
}),
|
||||
|
||||
"tokyonight": makeTheme(presetColors{
|
||||
primary: [2]string{"#2e7de9", "#7aa2f7"}, secondary: [2]string{"#b15c00", "#ff9e64"},
|
||||
success: [2]string{"#587539", "#9ece6a"}, warning: [2]string{"#8c6c3e", "#e0af68"},
|
||||
error_: [2]string{"#c94060", "#f7768e"}, info: [2]string{"#007197", "#7dcfff"},
|
||||
text: [2]string{"#273153", "#c0caf5"}, background: [2]string{"#e1e2e7", "#1a1b26"},
|
||||
mdKeyword: [2]string{"#2e7de9", "#7aa2f7"}, mdString: [2]string{"#587539", "#9ece6a"},
|
||||
mdComment: [2]string{"#848cb5", "#565f89"},
|
||||
}),
|
||||
|
||||
"nord": makeTheme(presetColors{
|
||||
primary: [2]string{"#5e81ac", "#88c0d0"}, secondary: [2]string{"#bf616a", "#d57780"},
|
||||
success: [2]string{"#8fbcbb", "#a3be8c"}, warning: [2]string{"#d08770", "#d08770"},
|
||||
error_: [2]string{"#bf616a", "#bf616a"}, info: [2]string{"#81a1c1", "#81a1c1"},
|
||||
text: [2]string{"#2e3440", "#e5e9f0"}, background: [2]string{"#eceff4", "#2e3440"},
|
||||
mdKeyword: [2]string{"#5e81ac", "#81a1c1"}, mdString: [2]string{"#8fbcbb", "#a3be8c"},
|
||||
mdComment: [2]string{"#616e88", "#616e88"},
|
||||
}),
|
||||
|
||||
"gruvbox": makeTheme(presetColors{
|
||||
primary: [2]string{"#076678", "#83a598"}, secondary: [2]string{"#9d0006", "#fb4934"},
|
||||
success: [2]string{"#79740e", "#b8bb26"}, warning: [2]string{"#b57614", "#fabd2f"},
|
||||
error_: [2]string{"#9d0006", "#fb4934"}, info: [2]string{"#8f3f71", "#d3869b"},
|
||||
text: [2]string{"#3c3836", "#ebdbb2"}, background: [2]string{"#fbf1c7", "#282828"},
|
||||
mdKeyword: [2]string{"#9d0006", "#fb4934"}, mdString: [2]string{"#79740e", "#b8bb26"},
|
||||
mdComment: [2]string{"#928374", "#928374"},
|
||||
}),
|
||||
|
||||
"monokai": makeTheme(presetColors{
|
||||
primary: [2]string{"#bf7bff", "#ae81ff"}, secondary: [2]string{"#d9487c", "#f92672"},
|
||||
success: [2]string{"#4fb54b", "#a6e22e"}, warning: [2]string{"#f1a948", "#fd971f"},
|
||||
error_: [2]string{"#e54b4b", "#f92672"}, info: [2]string{"#2d9ad7", "#66d9ef"},
|
||||
text: [2]string{"#292318", "#f8f8f2"}, background: [2]string{"#fdf8ec", "#272822"},
|
||||
mdKeyword: [2]string{"#d9487c", "#f92672"}, mdString: [2]string{"#4fb54b", "#a6e22e"},
|
||||
mdComment: [2]string{"#888888", "#75715e"},
|
||||
}),
|
||||
|
||||
"solarized": makeTheme(presetColors{
|
||||
primary: [2]string{"#268bd2", "#6c71c4"}, secondary: [2]string{"#d33682", "#d33682"},
|
||||
success: [2]string{"#859900", "#859900"}, warning: [2]string{"#b58900", "#b58900"},
|
||||
error_: [2]string{"#dc322f", "#dc322f"}, info: [2]string{"#2aa198", "#2aa198"},
|
||||
text: [2]string{"#586e75", "#93a1a1"}, background: [2]string{"#fdf6e3", "#002b36"},
|
||||
mdKeyword: [2]string{"#268bd2", "#6c71c4"}, mdString: [2]string{"#859900", "#859900"},
|
||||
mdComment: [2]string{"#93a1a1", "#586e75"},
|
||||
}),
|
||||
|
||||
"github": makeTheme(presetColors{
|
||||
primary: [2]string{"#0969da", "#58a6ff"}, secondary: [2]string{"#1b7c83", "#39c5cf"},
|
||||
success: [2]string{"#1a7f37", "#3fb950"}, warning: [2]string{"#9a6700", "#e3b341"},
|
||||
error_: [2]string{"#cf222e", "#f85149"}, info: [2]string{"#bc4c00", "#d29922"},
|
||||
text: [2]string{"#24292f", "#c9d1d9"}, background: [2]string{"#ffffff", "#0d1117"},
|
||||
mdKeyword: [2]string{"#0969da", "#58a6ff"}, mdString: [2]string{"#1a7f37", "#3fb950"},
|
||||
mdComment: [2]string{"#6e7781", "#8b949e"},
|
||||
}),
|
||||
|
||||
"one-dark": makeTheme(presetColors{
|
||||
primary: [2]string{"#4078f2", "#61afef"}, secondary: [2]string{"#0184bc", "#56b6c2"},
|
||||
success: [2]string{"#50a14f", "#98c379"}, warning: [2]string{"#c18401", "#e5c07b"},
|
||||
error_: [2]string{"#e45649", "#e06c75"}, info: [2]string{"#986801", "#d19a66"},
|
||||
text: [2]string{"#383a42", "#abb2bf"}, background: [2]string{"#fafafa", "#282c34"},
|
||||
mdKeyword: [2]string{"#a626a4", "#c678dd"}, mdString: [2]string{"#50a14f", "#98c379"},
|
||||
mdComment: [2]string{"#a0a1a7", "#5c6370"},
|
||||
}),
|
||||
|
||||
"rose-pine": makeTheme(presetColors{
|
||||
primary: [2]string{"#31748f", "#9ccfd8"}, secondary: [2]string{"#d7827e", "#ebbcba"},
|
||||
success: [2]string{"#286983", "#31748f"}, warning: [2]string{"#ea9d34", "#f6c177"},
|
||||
error_: [2]string{"#b4637a", "#eb6f92"}, info: [2]string{"#56949f", "#9ccfd8"},
|
||||
text: [2]string{"#575279", "#e0def4"}, background: [2]string{"#faf4ed", "#191724"},
|
||||
mdKeyword: [2]string{"#31748f", "#9ccfd8"}, mdString: [2]string{"#ea9d34", "#f6c177"},
|
||||
mdComment: [2]string{"#9893a5", "#6e6a86"},
|
||||
}),
|
||||
|
||||
"ayu": makeTheme(presetColors{
|
||||
primary: [2]string{"#4aa8c8", "#3fb7e3"}, secondary: [2]string{"#ef7d71", "#f2856f"},
|
||||
success: [2]string{"#5fb978", "#78d05c"}, warning: [2]string{"#ea9f41", "#e4a75c"},
|
||||
error_: [2]string{"#e6656a", "#f58572"}, info: [2]string{"#2f9bce", "#66c6f1"},
|
||||
text: [2]string{"#4f5964", "#d6dae0"}, background: [2]string{"#fdfaf4", "#0f1419"},
|
||||
mdKeyword: [2]string{"#4aa8c8", "#3fb7e3"}, mdString: [2]string{"#5fb978", "#78d05c"},
|
||||
mdComment: [2]string{"#abb0b6", "#5c6773"},
|
||||
}),
|
||||
|
||||
"material": makeTheme(presetColors{
|
||||
primary: [2]string{"#6182b8", "#82aaff"}, secondary: [2]string{"#39adb5", "#89ddff"},
|
||||
success: [2]string{"#91b859", "#c3e88d"}, warning: [2]string{"#ffb300", "#ffcb6b"},
|
||||
error_: [2]string{"#e53935", "#f07178"}, info: [2]string{"#f4511e", "#ffcb6b"},
|
||||
text: [2]string{"#263238", "#eeffff"}, background: [2]string{"#fafafa", "#263238"},
|
||||
mdKeyword: [2]string{"#6182b8", "#82aaff"}, mdString: [2]string{"#91b859", "#c3e88d"},
|
||||
mdComment: [2]string{"#aabfc5", "#546e7a"},
|
||||
}),
|
||||
|
||||
"everforest": makeTheme(presetColors{
|
||||
primary: [2]string{"#8da101", "#a7c080"}, secondary: [2]string{"#df69ba", "#d699b6"},
|
||||
success: [2]string{"#8da101", "#a7c080"}, warning: [2]string{"#f57d26", "#e69875"},
|
||||
error_: [2]string{"#f85552", "#e67e80"}, info: [2]string{"#35a77c", "#83c092"},
|
||||
text: [2]string{"#5c6a72", "#d3c6aa"}, background: [2]string{"#fdf6e3", "#2d353b"},
|
||||
mdKeyword: [2]string{"#8da101", "#a7c080"}, mdString: [2]string{"#35a77c", "#83c092"},
|
||||
mdComment: [2]string{"#939b84", "#859289"},
|
||||
}),
|
||||
|
||||
"kanagawa": makeTheme(presetColors{
|
||||
primary: [2]string{"#2D4F67", "#7E9CD8"}, secondary: [2]string{"#D27E99", "#D27E99"},
|
||||
success: [2]string{"#98BB6C", "#98BB6C"}, warning: [2]string{"#D7A657", "#D7A657"},
|
||||
error_: [2]string{"#E82424", "#E82424"}, info: [2]string{"#76946A", "#76946A"},
|
||||
text: [2]string{"#54433A", "#DCD7BA"}, background: [2]string{"#F2E9DE", "#1F1F28"},
|
||||
mdKeyword: [2]string{"#2D4F67", "#7E9CD8"}, mdString: [2]string{"#98BB6C", "#98BB6C"},
|
||||
mdComment: [2]string{"#A09D98", "#727169"},
|
||||
}),
|
||||
|
||||
"amoled": makeTheme(presetColors{
|
||||
primary: [2]string{"#6200ff", "#b388ff"}, secondary: [2]string{"#ff0080", "#ff4081"},
|
||||
success: [2]string{"#00e676", "#00ff88"}, warning: [2]string{"#ffab00", "#ffea00"},
|
||||
error_: [2]string{"#ff1744", "#ff1744"}, info: [2]string{"#00b0ff", "#18ffff"},
|
||||
text: [2]string{"#0a0a0a", "#ffffff"}, background: [2]string{"#f0f0f0", "#000000"},
|
||||
mdKeyword: [2]string{"#6200ff", "#b388ff"}, mdString: [2]string{"#00e676", "#00ff88"},
|
||||
mdComment: [2]string{"#757575", "#424242"},
|
||||
}),
|
||||
|
||||
"synthwave": makeTheme(presetColors{
|
||||
primary: [2]string{"#00bcd4", "#36f9f6"}, secondary: [2]string{"#9c27b0", "#b084eb"},
|
||||
success: [2]string{"#4caf50", "#72f1b8"}, warning: [2]string{"#ff9800", "#fede5d"},
|
||||
error_: [2]string{"#f44336", "#fe4450"}, info: [2]string{"#ff5722", "#ff8b39"},
|
||||
text: [2]string{"#262335", "#ffffff"}, background: [2]string{"#fafafa", "#262335"},
|
||||
mdKeyword: [2]string{"#9c27b0", "#b084eb"}, mdString: [2]string{"#4caf50", "#72f1b8"},
|
||||
mdComment: [2]string{"#848bbd", "#848bbd"},
|
||||
}),
|
||||
|
||||
"vesper": makeTheme(presetColors{
|
||||
primary: [2]string{"#FFC799", "#FFC799"}, secondary: [2]string{"#B30000", "#FF8080"},
|
||||
success: [2]string{"#99FFE4", "#99FFE4"}, warning: [2]string{"#FFC799", "#FFC799"},
|
||||
error_: [2]string{"#FF8080", "#FF8080"}, info: [2]string{"#FFC799", "#FFC799"},
|
||||
text: [2]string{"#1a1a1a", "#FFF"}, background: [2]string{"#F0F0F0", "#101010"},
|
||||
mdKeyword: [2]string{"#FFC799", "#FFC799"}, mdString: [2]string{"#99FFE4", "#99FFE4"},
|
||||
mdComment: [2]string{"#7a7a7a", "#505050"},
|
||||
}),
|
||||
|
||||
"flexoki": makeTheme(presetColors{
|
||||
primary: [2]string{"#205EA6", "#DA702C"}, secondary: [2]string{"#BC5215", "#8B7EC8"},
|
||||
success: [2]string{"#66800B", "#879A39"}, warning: [2]string{"#BC5215", "#DA702C"},
|
||||
error_: [2]string{"#AF3029", "#D14D41"}, info: [2]string{"#24837B", "#3AA99F"},
|
||||
text: [2]string{"#100F0F", "#CECDC3"}, background: [2]string{"#FFFCF0", "#100F0F"},
|
||||
mdKeyword: [2]string{"#205EA6", "#DA702C"}, mdString: [2]string{"#66800B", "#879A39"},
|
||||
mdComment: [2]string{"#878580", "#878580"},
|
||||
}),
|
||||
|
||||
"matrix": makeTheme(presetColors{
|
||||
primary: [2]string{"#1cc24b", "#2eff6a"}, secondary: [2]string{"#c770ff", "#c770ff"},
|
||||
success: [2]string{"#1cc24b", "#62ff94"}, warning: [2]string{"#e6ff57", "#e6ff57"},
|
||||
error_: [2]string{"#ff4b4b", "#ff4b4b"}, info: [2]string{"#30b3ff", "#30b3ff"},
|
||||
text: [2]string{"#203022", "#62ff94"}, background: [2]string{"#eef3ea", "#0a0e0a"},
|
||||
mdKeyword: [2]string{"#1cc24b", "#2eff6a"}, mdString: [2]string{"#1cc24b", "#62ff94"},
|
||||
mdComment: [2]string{"#5a7a5e", "#3a5a3e"},
|
||||
}),
|
||||
|
||||
"vercel": makeTheme(presetColors{
|
||||
primary: [2]string{"#0070F3", "#0070F3"}, secondary: [2]string{"#8E4EC6", "#8E4EC6"},
|
||||
success: [2]string{"#388E3C", "#46A758"}, warning: [2]string{"#FF9500", "#FFB224"},
|
||||
error_: [2]string{"#DC3545", "#E5484D"}, info: [2]string{"#0070F3", "#52A8FF"},
|
||||
text: [2]string{"#171717", "#EDEDED"}, background: [2]string{"#FFFFFF", "#000000"},
|
||||
mdKeyword: [2]string{"#0070F3", "#0070F3"}, mdString: [2]string{"#388E3C", "#46A758"},
|
||||
mdComment: [2]string{"#6B6B6B", "#666666"},
|
||||
}),
|
||||
|
||||
"zenburn": makeTheme(presetColors{
|
||||
primary: [2]string{"#5f7f8f", "#8cd0d3"}, secondary: [2]string{"#5f8f8f", "#93e0e3"},
|
||||
success: [2]string{"#5f8f5f", "#7f9f7f"}, warning: [2]string{"#8f8f5f", "#f0dfaf"},
|
||||
error_: [2]string{"#8f5f5f", "#cc9393"}, info: [2]string{"#8f7f5f", "#dfaf8f"},
|
||||
text: [2]string{"#3f3f3f", "#dcdccc"}, background: [2]string{"#ffffef", "#3f3f3f"},
|
||||
mdKeyword: [2]string{"#5f7f8f", "#8cd0d3"}, mdString: [2]string{"#5f8f5f", "#cc9393"},
|
||||
mdComment: [2]string{"#7f7f7f", "#7f9f7f"},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Theme registry (global)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var themeRegistry []ThemeEntry
|
||||
|
||||
// initThemeRegistry populates the registry from built-ins, user themes, and
|
||||
// project-local themes. Later sources override earlier ones with the same name:
|
||||
// 1. Built-in presets
|
||||
// 2. User themes (~/.config/kit/themes/)
|
||||
// 3. Project-local (.kit/themes/ in the working directory)
|
||||
func initThemeRegistry() {
|
||||
themeRegistry = nil
|
||||
|
||||
// 1. Built-in presets.
|
||||
for name, t := range builtinThemes() {
|
||||
themeRegistry = append(themeRegistry, ThemeEntry{
|
||||
Name: name,
|
||||
Source: "builtin",
|
||||
theme: t,
|
||||
loaded: true,
|
||||
})
|
||||
}
|
||||
|
||||
// 2. User themes from ~/.config/kit/themes/
|
||||
scanThemesDir(userThemesDir())
|
||||
|
||||
// 3. Project-local themes from .kit/themes/
|
||||
scanThemesDir(projectThemesDir())
|
||||
|
||||
sortRegistry()
|
||||
}
|
||||
|
||||
// scanThemesDir adds all .yml/.yaml/.json theme files from dir to the registry.
|
||||
// Files override any existing entry with the same stem name.
|
||||
func scanThemesDir(dir string) {
|
||||
if dir == "" {
|
||||
return
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(entry.Name()))
|
||||
if ext != ".yml" && ext != ".yaml" && ext != ".json" {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name()))
|
||||
removeFromRegistry(name)
|
||||
themeRegistry = append(themeRegistry, ThemeEntry{
|
||||
Name: name,
|
||||
Source: filepath.Join(dir, entry.Name()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func sortRegistry() {
|
||||
sort.Slice(themeRegistry, func(i, j int) bool {
|
||||
return themeRegistry[i].Name < themeRegistry[j].Name
|
||||
})
|
||||
}
|
||||
|
||||
func removeFromRegistry(name string) {
|
||||
for i := range themeRegistry {
|
||||
if themeRegistry[i].Name == name {
|
||||
themeRegistry = append(themeRegistry[:i], themeRegistry[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// userThemesDir returns ~/.config/kit/themes, creating it if needed.
|
||||
func userThemesDir() string {
|
||||
cfgDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
dir := filepath.Join(cfgDir, "kit", "themes")
|
||||
_ = os.MkdirAll(dir, 0o755)
|
||||
return dir
|
||||
}
|
||||
|
||||
// projectThemesDir returns .kit/themes/ relative to the working directory.
|
||||
// Returns "" if the directory doesn't exist (does NOT create it).
|
||||
func projectThemesDir() string {
|
||||
dir := filepath.Join(".kit", "themes")
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil || !info.IsDir() {
|
||||
return ""
|
||||
}
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return dir
|
||||
}
|
||||
return abs
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ListThemes returns the names of all available themes (built-in + user).
|
||||
func ListThemes() []string {
|
||||
if themeRegistry == nil {
|
||||
initThemeRegistry()
|
||||
}
|
||||
names := make([]string, len(themeRegistry))
|
||||
for i := range themeRegistry {
|
||||
names[i] = themeRegistry[i].Name
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// LoadThemeByName looks up a theme by name, loads it if needed, and returns it.
|
||||
func LoadThemeByName(name string) (Theme, error) {
|
||||
if themeRegistry == nil {
|
||||
initThemeRegistry()
|
||||
}
|
||||
for i := range themeRegistry {
|
||||
if themeRegistry[i].Name == name {
|
||||
return themeRegistry[i].Theme()
|
||||
}
|
||||
}
|
||||
return Theme{}, fmt.Errorf("theme %q not found", name)
|
||||
}
|
||||
|
||||
// ApplyTheme loads a theme by name and sets it as the active global theme.
|
||||
// The selection is persisted to ~/.config/kit/preferences.yml so it survives
|
||||
// across sessions. Persistence errors are silently ignored — the theme is
|
||||
// still applied in-memory even if the write fails.
|
||||
func ApplyTheme(name string) error {
|
||||
t, err := LoadThemeByName(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
SetTheme(t)
|
||||
_ = SaveThemePreference(name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplyThemeWithoutSave loads a theme by name and sets it as the active global
|
||||
// theme without persisting the choice. Used at startup to restore a previously
|
||||
// saved preference without redundantly re-writing it.
|
||||
func ApplyThemeWithoutSave(name string) error {
|
||||
t, err := LoadThemeByName(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
SetTheme(t)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RefreshThemeRegistry re-scans the themes directory. Call after the user
|
||||
// drops a new file into ~/.config/kit/themes/.
|
||||
func RefreshThemeRegistry() {
|
||||
initThemeRegistry()
|
||||
}
|
||||
|
||||
// RegisterThemeFromConfig adds a theme to the runtime registry from an
|
||||
// extension's ThemeColorConfig (string hex pairs). Replaces any existing
|
||||
// entry with the same name. The theme is immediately available via
|
||||
// ListThemes, LoadThemeByName, and ApplyTheme.
|
||||
func RegisterThemeFromConfig(name string, primary, secondary, success, warning, error_, info, text, muted, veryMuted, background, border, mutedBorder, system, tool, accent, highlight, mdHeading, mdLink, mdKeyword, mdString, mdNumber, mdComment [2]string) {
|
||||
if themeRegistry == nil {
|
||||
initThemeRegistry()
|
||||
}
|
||||
t := makeTheme(presetColors{
|
||||
primary: primary, secondary: secondary,
|
||||
success: success, warning: warning,
|
||||
error_: error_, info: info,
|
||||
text: text, muted: muted,
|
||||
veryMuted: veryMuted, background: background,
|
||||
border: border, mutedBorder: mutedBorder,
|
||||
system: system, tool: tool,
|
||||
accent: accent, highlight: highlight,
|
||||
mdHeading: mdHeading, mdLink: mdLink,
|
||||
mdKeyword: mdKeyword, mdString: mdString,
|
||||
mdNumber: mdNumber, mdComment: mdComment,
|
||||
})
|
||||
removeFromRegistry(name)
|
||||
themeRegistry = append(themeRegistry, ThemeEntry{
|
||||
Name: name,
|
||||
Source: "extension",
|
||||
theme: t,
|
||||
loaded: true,
|
||||
})
|
||||
sortRegistry()
|
||||
}
|
||||
|
||||
// ActiveThemeName returns the name of the currently active theme by comparing
|
||||
// against known entries. Returns "custom" if no match is found.
|
||||
func ActiveThemeName() string {
|
||||
if themeRegistry == nil {
|
||||
initThemeRegistry()
|
||||
}
|
||||
current := GetTheme()
|
||||
for _, e := range themeRegistry {
|
||||
if !e.loaded {
|
||||
continue
|
||||
}
|
||||
if e.theme.Primary == current.Primary &&
|
||||
e.theme.Secondary == current.Secondary &&
|
||||
e.theme.Error == current.Error &&
|
||||
e.theme.Text == current.Text {
|
||||
return e.Name
|
||||
}
|
||||
}
|
||||
return "custom"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File loading
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// themeFileConfig mirrors config.Theme for unmarshaling theme files.
|
||||
// Uses the same adaptive color structure.
|
||||
type themeFileConfig struct {
|
||||
Primary adaptiveColorPair `json:"primary,omitzero" yaml:"primary,omitempty"`
|
||||
Secondary adaptiveColorPair `json:"secondary,omitzero" yaml:"secondary,omitempty"`
|
||||
Success adaptiveColorPair `json:"success,omitzero" yaml:"success,omitempty"`
|
||||
Warning adaptiveColorPair `json:"warning,omitzero" yaml:"warning,omitempty"`
|
||||
Error adaptiveColorPair `json:"error,omitzero" yaml:"error,omitempty"`
|
||||
Info adaptiveColorPair `json:"info,omitzero" yaml:"info,omitempty"`
|
||||
Text adaptiveColorPair `json:"text,omitzero" yaml:"text,omitempty"`
|
||||
Muted adaptiveColorPair `json:"muted,omitzero" yaml:"muted,omitempty"`
|
||||
VeryMuted adaptiveColorPair `json:"very-muted,omitzero" yaml:"very-muted,omitempty"`
|
||||
Background adaptiveColorPair `json:"background,omitzero" yaml:"background,omitempty"`
|
||||
Border adaptiveColorPair `json:"border,omitzero" yaml:"border,omitempty"`
|
||||
MutedBorder adaptiveColorPair `json:"muted-border,omitzero" yaml:"muted-border,omitempty"`
|
||||
System adaptiveColorPair `json:"system,omitzero" yaml:"system,omitempty"`
|
||||
Tool adaptiveColorPair `json:"tool,omitzero" yaml:"tool,omitempty"`
|
||||
Accent adaptiveColorPair `json:"accent,omitzero" yaml:"accent,omitempty"`
|
||||
Highlight adaptiveColorPair `json:"highlight,omitzero" yaml:"highlight,omitempty"`
|
||||
|
||||
DiffInsertBg adaptiveColorPair `json:"diff-insert-bg,omitzero" yaml:"diff-insert-bg,omitempty"`
|
||||
DiffDeleteBg adaptiveColorPair `json:"diff-delete-bg,omitzero" yaml:"diff-delete-bg,omitempty"`
|
||||
DiffEqualBg adaptiveColorPair `json:"diff-equal-bg,omitzero" yaml:"diff-equal-bg,omitempty"`
|
||||
DiffMissingBg adaptiveColorPair `json:"diff-missing-bg,omitzero" yaml:"diff-missing-bg,omitempty"`
|
||||
CodeBg adaptiveColorPair `json:"code-bg,omitzero" yaml:"code-bg,omitempty"`
|
||||
GutterBg adaptiveColorPair `json:"gutter-bg,omitzero" yaml:"gutter-bg,omitempty"`
|
||||
WriteBg adaptiveColorPair `json:"write-bg,omitzero" yaml:"write-bg,omitempty"`
|
||||
|
||||
Markdown struct {
|
||||
Text adaptiveColorPair `json:"text,omitzero" yaml:"text,omitempty"`
|
||||
Muted adaptiveColorPair `json:"muted,omitzero" yaml:"muted,omitempty"`
|
||||
Heading adaptiveColorPair `json:"heading,omitzero" yaml:"heading,omitempty"`
|
||||
Emph adaptiveColorPair `json:"emph,omitzero" yaml:"emph,omitempty"`
|
||||
Strong adaptiveColorPair `json:"strong,omitzero" yaml:"strong,omitempty"`
|
||||
Link adaptiveColorPair `json:"link,omitzero" yaml:"link,omitempty"`
|
||||
Code adaptiveColorPair `json:"code,omitzero" yaml:"code,omitempty"`
|
||||
Error adaptiveColorPair `json:"error,omitzero" yaml:"error,omitempty"`
|
||||
Keyword adaptiveColorPair `json:"keyword,omitzero" yaml:"keyword,omitempty"`
|
||||
String adaptiveColorPair `json:"string,omitzero" yaml:"string,omitempty"`
|
||||
Number adaptiveColorPair `json:"number,omitzero" yaml:"number,omitempty"`
|
||||
Comment adaptiveColorPair `json:"comment,omitzero" yaml:"comment,omitempty"`
|
||||
} `json:"markdown,omitzero" yaml:"markdown,omitempty"`
|
||||
}
|
||||
|
||||
type adaptiveColorPair struct {
|
||||
Light string `json:"light,omitempty" yaml:"light,omitempty"`
|
||||
Dark string `json:"dark,omitempty" yaml:"dark,omitempty"`
|
||||
}
|
||||
|
||||
// resolve converts an adaptiveColorPair to a resolved color.Color,
|
||||
// falling back to fallback when both Light and Dark are empty.
|
||||
func (a adaptiveColorPair) resolve(fallback color.Color) color.Color {
|
||||
if a.Light == "" && a.Dark == "" {
|
||||
return fallback
|
||||
}
|
||||
return AdaptiveColor(a.Light, a.Dark)
|
||||
}
|
||||
|
||||
func loadThemeFile(path string) (Theme, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Theme{}, err
|
||||
}
|
||||
|
||||
var cfg themeFileConfig
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
switch ext {
|
||||
case ".json":
|
||||
err = json.Unmarshal(data, &cfg)
|
||||
case ".yaml", ".yml":
|
||||
err = yaml.Unmarshal(data, &cfg)
|
||||
default:
|
||||
return Theme{}, fmt.Errorf("unsupported theme file format: %s", ext)
|
||||
}
|
||||
if err != nil {
|
||||
return Theme{}, err
|
||||
}
|
||||
|
||||
return fileConfigToTheme(cfg), nil
|
||||
}
|
||||
|
||||
func fileConfigToTheme(cfg themeFileConfig) Theme {
|
||||
def := DefaultTheme()
|
||||
|
||||
// Resolve the base background/success/error hex pairs for diff derivation.
|
||||
// We need the raw hex strings to feed deriveDiffBg.
|
||||
bgPair := resolveHexPair(cfg.Background, [2]string{"#F0F0F0", "#0D0D0D"})
|
||||
successPair := resolveHexPair(cfg.Success, [2]string{"#998800", "#CCAA00"})
|
||||
errorPair := resolveHexPair(cfg.Error, [2]string{"#CC0000", "#FF3333"})
|
||||
|
||||
// Derive diff backgrounds from the theme's own palette.
|
||||
derivedInsert, derivedDelete, derivedEqual, derivedMissing,
|
||||
derivedCodeBg, derivedGutterBg, derivedWriteBg := deriveDiffBg(bgPair, successPair, errorPair)
|
||||
|
||||
return Theme{
|
||||
Primary: cfg.Primary.resolve(def.Primary),
|
||||
Secondary: cfg.Secondary.resolve(def.Secondary),
|
||||
Success: cfg.Success.resolve(def.Success),
|
||||
Warning: cfg.Warning.resolve(def.Warning),
|
||||
Error: cfg.Error.resolve(def.Error),
|
||||
Info: cfg.Info.resolve(def.Info),
|
||||
Text: cfg.Text.resolve(def.Text),
|
||||
Muted: cfg.Muted.resolve(def.Muted),
|
||||
VeryMuted: cfg.VeryMuted.resolve(def.VeryMuted),
|
||||
Background: cfg.Background.resolve(def.Background),
|
||||
Border: cfg.Border.resolve(def.Border),
|
||||
MutedBorder: cfg.MutedBorder.resolve(def.MutedBorder),
|
||||
System: cfg.System.resolve(def.System),
|
||||
Tool: cfg.Tool.resolve(def.Tool),
|
||||
Accent: cfg.Accent.resolve(def.Accent),
|
||||
Highlight: cfg.Highlight.resolve(def.Highlight),
|
||||
|
||||
DiffInsertBg: cfg.DiffInsertBg.resolve(derivedInsert),
|
||||
DiffDeleteBg: cfg.DiffDeleteBg.resolve(derivedDelete),
|
||||
DiffEqualBg: cfg.DiffEqualBg.resolve(derivedEqual),
|
||||
DiffMissingBg: cfg.DiffMissingBg.resolve(derivedMissing),
|
||||
CodeBg: cfg.CodeBg.resolve(derivedCodeBg),
|
||||
GutterBg: cfg.GutterBg.resolve(derivedGutterBg),
|
||||
WriteBg: cfg.WriteBg.resolve(derivedWriteBg),
|
||||
|
||||
Markdown: MarkdownThemeColors{
|
||||
Text: cfg.Markdown.Text.resolve(def.Markdown.Text),
|
||||
Muted: cfg.Markdown.Muted.resolve(def.Markdown.Muted),
|
||||
Heading: cfg.Markdown.Heading.resolve(def.Markdown.Heading),
|
||||
Emph: cfg.Markdown.Emph.resolve(def.Markdown.Emph),
|
||||
Strong: cfg.Markdown.Strong.resolve(def.Markdown.Strong),
|
||||
Link: cfg.Markdown.Link.resolve(def.Markdown.Link),
|
||||
Code: cfg.Markdown.Code.resolve(def.Markdown.Code),
|
||||
Error: cfg.Markdown.Error.resolve(def.Markdown.Error),
|
||||
Keyword: cfg.Markdown.Keyword.resolve(def.Markdown.Keyword),
|
||||
String: cfg.Markdown.String.resolve(def.Markdown.String),
|
||||
Number: cfg.Markdown.Number.resolve(def.Markdown.Number),
|
||||
Comment: cfg.Markdown.Comment.resolve(def.Markdown.Comment),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// resolveHexPair returns the hex pair from an adaptiveColorPair, falling back
|
||||
// to defaults when the pair is empty.
|
||||
func resolveHexPair(a adaptiveColorPair, fallback [2]string) [2]string {
|
||||
light := a.Light
|
||||
if light == "" {
|
||||
light = fallback[0]
|
||||
}
|
||||
dark := a.Dark
|
||||
if dark == "" {
|
||||
dark = fallback[1]
|
||||
}
|
||||
return [2]string{light, dark}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseHexColor(t *testing.T) {
|
||||
tests := []struct {
|
||||
hex string
|
||||
r, g, b int
|
||||
}{
|
||||
{"#000000", 0, 0, 0},
|
||||
{"#ffffff", 255, 255, 255},
|
||||
{"#1e1e2e", 0x1e, 0x1e, 0x2e},
|
||||
{"#a6e3a1", 0xa6, 0xe3, 0xa1},
|
||||
{"#f38ba8", 0xf3, 0x8b, 0xa8},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
r, g, b := parseHexColor(tt.hex)
|
||||
if r != tt.r || g != tt.g || b != tt.b {
|
||||
t.Errorf("parseHexColor(%q) = (%d,%d,%d), want (%d,%d,%d)",
|
||||
tt.hex, r, g, b, tt.r, tt.g, tt.b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlendHex(t *testing.T) {
|
||||
// Blending with 0 amount should return the base color.
|
||||
got := blendHex("#1e1e2e", "#a6e3a1", 0.0)
|
||||
if got != "#1e1e2e" {
|
||||
t.Errorf("blendHex with 0.0 = %q, want #1e1e2e", got)
|
||||
}
|
||||
|
||||
// Blending with 1.0 amount should return the tint color.
|
||||
got = blendHex("#1e1e2e", "#a6e3a1", 1.0)
|
||||
if got != "#a6e3a1" {
|
||||
t.Errorf("blendHex with 1.0 = %q, want #a6e3a1", got)
|
||||
}
|
||||
|
||||
// Blending black and white at 0.5 should give mid gray.
|
||||
got = blendHex("#000000", "#ffffff", 0.5)
|
||||
// 127 = int(0 + 255*0.5) — truncated, so #7f7f7f
|
||||
if got != "#7f7f7f" {
|
||||
t.Errorf("blendHex black/white at 0.5 = %q, want #7f7f7f", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveDiffBgProducesDifferentColorsPerTheme(t *testing.T) {
|
||||
// Catppuccin palette
|
||||
catBg := [2]string{"#eff1f5", "#1e1e2e"}
|
||||
catSuccess := [2]string{"#40a02b", "#a6e3a1"}
|
||||
catError := [2]string{"#d20f39", "#f38ba8"}
|
||||
|
||||
// KITT palette
|
||||
kittBg := [2]string{"#F0F0F0", "#0D0D0D"}
|
||||
kittSuccess := [2]string{"#998800", "#CCAA00"}
|
||||
kittError := [2]string{"#CC0000", "#FF3333"}
|
||||
|
||||
catInsert, catDelete, _, _, _, _, _ := deriveDiffBg(catBg, catSuccess, catError)
|
||||
kittInsert, kittDelete, _, _, _, _, _ := deriveDiffBg(kittBg, kittSuccess, kittError)
|
||||
|
||||
if catInsert == kittInsert {
|
||||
t.Error("catppuccin DiffInsertBg should differ from kitt DiffInsertBg")
|
||||
}
|
||||
if catDelete == kittDelete {
|
||||
t.Error("catppuccin DiffDeleteBg should differ from kitt DiffDeleteBg")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeThemeDerivesUniqueDiffColors(t *testing.T) {
|
||||
themes := builtinThemes()
|
||||
kitt := themes["kitt"]
|
||||
cat := themes["catppuccin"]
|
||||
|
||||
// The catppuccin diff backgrounds should NOT equal the kitt defaults.
|
||||
if cat.DiffInsertBg == kitt.DiffInsertBg {
|
||||
t.Error("catppuccin DiffInsertBg should differ from kitt default")
|
||||
}
|
||||
if cat.DiffDeleteBg == kitt.DiffDeleteBg {
|
||||
t.Error("catppuccin DiffDeleteBg should differ from kitt default")
|
||||
}
|
||||
if cat.DiffEqualBg == kitt.DiffEqualBg {
|
||||
t.Error("catppuccin DiffEqualBg should differ from kitt default")
|
||||
}
|
||||
}
|
||||
@@ -28,11 +28,12 @@ func NewToolApprovalInput(toolName, toolArgs string, width int) *ToolApprovalInp
|
||||
ta.SetHeight(4) // Default to 3 lines like huh
|
||||
ta.Focus()
|
||||
|
||||
// Style the textarea to match huh theme
|
||||
// Style the textarea using theme colors.
|
||||
theme := GetTheme()
|
||||
styles := ta.Styles()
|
||||
styles.Focused.Base = lipgloss.NewStyle()
|
||||
styles.Focused.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
|
||||
styles.Focused.Text = lipgloss.NewStyle().Foreground(lipgloss.Color("252"))
|
||||
styles.Focused.Placeholder = lipgloss.NewStyle().Foreground(theme.VeryMuted)
|
||||
styles.Focused.Text = lipgloss.NewStyle().Foreground(theme.Text)
|
||||
styles.Focused.Prompt = lipgloss.NewStyle()
|
||||
styles.Focused.CursorLine = lipgloss.NewStyle()
|
||||
ta.SetStyles(styles)
|
||||
@@ -87,9 +88,11 @@ func (t *ToolApprovalInput) View() tea.View {
|
||||
}
|
||||
containerStyle := lipgloss.NewStyle()
|
||||
|
||||
theme := GetTheme()
|
||||
|
||||
// PaddingLeft(3) aligns with message content: border(1) + paddingLeft(2).
|
||||
titleStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("252")).
|
||||
Foreground(theme.Text).
|
||||
MarginBottom(1).
|
||||
PaddingLeft(3)
|
||||
|
||||
@@ -100,19 +103,19 @@ func (t *ToolApprovalInput) View() tea.View {
|
||||
BorderRight(false).
|
||||
BorderTop(false).
|
||||
BorderBottom(false).
|
||||
BorderForeground(lipgloss.Color("39")).
|
||||
BorderForeground(theme.Primary).
|
||||
PaddingLeft(2). // match message block paddingLeft
|
||||
Width(t.width - 1) // full width minus left border
|
||||
|
||||
// Style for the currently selected/highlighted option
|
||||
selectedStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("42")). // Bright green
|
||||
Foreground(theme.Success).
|
||||
Bold(true).
|
||||
Underline(true)
|
||||
|
||||
// Style for the unselected/unhighlighted option
|
||||
unselectedStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("240")) // Dark gray
|
||||
Foreground(theme.VeryMuted)
|
||||
|
||||
// Build the view
|
||||
var view strings.Builder
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/alecthomas/chroma/v2/lexers"
|
||||
"github.com/alecthomas/chroma/v2/styles"
|
||||
udiff "github.com/aymanbagabas/go-udiff"
|
||||
xansi "github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
// Maximum visible lines per tool type before truncation.
|
||||
@@ -22,6 +23,7 @@ const (
|
||||
maxCodeLines = 20 // lines for Read / code blocks
|
||||
maxWriteLines = 10 // lines for Write blocks
|
||||
maxBashLines = 20 // lines for Bash output (matches Read)
|
||||
maxLsLines = 20 // lines for Ls directory listings
|
||||
)
|
||||
|
||||
// renderToolBody dispatches to tool-specific body renderers based on tool name.
|
||||
@@ -228,7 +230,7 @@ func renderDiffBlock(before, after string, startLine int, width int) string {
|
||||
gutterMissing := lipgloss.NewStyle().Background(theme.DiffMissingBg)
|
||||
|
||||
contentInsert := lipgloss.NewStyle().Background(theme.DiffInsertBg)
|
||||
contentDelete := lipgloss.NewStyle().Background(theme.DiffDeleteBg).Strikethrough(true)
|
||||
contentDelete := lipgloss.NewStyle().Background(theme.DiffDeleteBg)
|
||||
contentEqual := lipgloss.NewStyle().Foreground(theme.Muted).Background(theme.DiffEqualBg)
|
||||
contentMissing := lipgloss.NewStyle().Background(theme.DiffMissingBg)
|
||||
|
||||
@@ -314,6 +316,13 @@ func renderLsBody(toolResult string, width int) string {
|
||||
|
||||
lines := strings.Split(content, "\n")
|
||||
|
||||
// Truncate to maxLsLines for display
|
||||
var hiddenCount int
|
||||
if len(lines) > maxLsLines {
|
||||
hiddenCount = len(lines) - maxLsLines
|
||||
lines = lines[:maxLsLines]
|
||||
}
|
||||
|
||||
const indent = " "
|
||||
codeWidth := max(width-len(indent), 20)
|
||||
|
||||
@@ -322,10 +331,19 @@ func renderLsBody(toolResult string, width int) string {
|
||||
|
||||
var result []string
|
||||
for _, line := range lines {
|
||||
// Truncate before styling to prevent wrapping.
|
||||
line = truncateLine(line, codeWidth-1) // account for PaddingLeft(1)
|
||||
styled := codeStyle.Width(codeWidth).Render(line)
|
||||
result = append(result, indent+styled)
|
||||
}
|
||||
|
||||
if hiddenCount > 0 {
|
||||
hint := fmt.Sprintf("...(%d more entries)", hiddenCount)
|
||||
hintContent := codeStyle.Width(codeWidth).
|
||||
Foreground(theme.Muted).Italic(true).Render(hint)
|
||||
result = append(result, indent+hintContent)
|
||||
}
|
||||
|
||||
return strings.Join(result, "\n")
|
||||
}
|
||||
|
||||
@@ -431,7 +449,8 @@ func renderCodeBlock(content, fileName string, width int) string {
|
||||
// If this line has no line number, it's a metadata/footer line (e.g. truncation notice).
|
||||
if p.lineNum == "" {
|
||||
// Render footer lines with code background but no gutter
|
||||
footer := codeStyle.Width(codeWidth).Render(p.code)
|
||||
truncatedFooter := truncateLine(p.code, codeWidth-1) // account for PaddingLeft(1)
|
||||
footer := codeStyle.Width(codeWidth).Render(truncatedFooter)
|
||||
emptyGutter := gutterStyle.Width(gutterWidth).Render("")
|
||||
result = append(result, codeIndent+lipgloss.JoinHorizontal(lipgloss.Top, emptyGutter, footer))
|
||||
continue
|
||||
@@ -445,6 +464,9 @@ func renderCodeBlock(content, fileName string, width int) string {
|
||||
} else {
|
||||
codePart = p.code
|
||||
}
|
||||
// Truncate the (possibly ANSI-highlighted) line to fit within
|
||||
// the code column, preventing lipgloss from wrapping it.
|
||||
codePart = truncateLine(codePart, codeWidth-1) // account for PaddingLeft(1)
|
||||
styledCode := codeStyle.Width(codeWidth).Render(codePart)
|
||||
|
||||
result = append(result, codeIndent+lipgloss.JoinHorizontal(lipgloss.Top, gutter, styledCode))
|
||||
@@ -528,6 +550,9 @@ func renderWriteBlock(content, fileName string, width int) string {
|
||||
} else {
|
||||
codePart = line
|
||||
}
|
||||
// Truncate the (possibly ANSI-highlighted) line to fit within
|
||||
// the code column, preventing lipgloss from wrapping it.
|
||||
codePart = truncateLine(codePart, codeWidth-1) // account for PaddingLeft(1)
|
||||
styledCode := writeStyle.Width(codeWidth).Render(codePart)
|
||||
|
||||
result = append(result, codeIndent+lipgloss.JoinHorizontal(lipgloss.Top, gutter, styledCode))
|
||||
@@ -578,9 +603,16 @@ func renderBashBody(toolResult string, width int) string {
|
||||
}
|
||||
|
||||
const lineIndent = " "
|
||||
// Truncate individual lines to the available width so they never wrap.
|
||||
// This mirrors Crush's approach: truncate, don't wrap.
|
||||
lineWidth := max(width-len(lineIndent), 20)
|
||||
// Account for PaddingLeft(1) on the output/stderr styles
|
||||
maxLineChars := lineWidth - 1
|
||||
|
||||
var rendered []string
|
||||
inStderr := false
|
||||
for _, line := range lines {
|
||||
line = truncateLine(line, maxLineChars)
|
||||
// Detect the STDERR: label that Kit's bash tool emits
|
||||
if strings.TrimSpace(line) == "STDERR:" {
|
||||
inStderr = true
|
||||
@@ -682,23 +714,28 @@ func syntaxHighlight(source, fileName string) string {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// padRight pads s with spaces to exactly width characters.
|
||||
// padRight pads s with spaces to exactly width visual characters.
|
||||
// This is ANSI-aware: it measures the visual width of s (ignoring escape
|
||||
// codes and accounting for wide characters) before padding or truncating.
|
||||
func padRight(s string, width int) string {
|
||||
if len(s) >= width {
|
||||
return s[:width]
|
||||
w := xansi.StringWidth(s)
|
||||
if w >= width {
|
||||
return xansi.Truncate(s, width, "")
|
||||
}
|
||||
return s + strings.Repeat(" ", width-len(s))
|
||||
return s + strings.Repeat(" ", width-w)
|
||||
}
|
||||
|
||||
// truncateLine truncates a line to maxWidth, adding "…" if truncated.
|
||||
// truncateLine truncates a line to maxWidth visual characters, adding "…"
|
||||
// if truncated. This is ANSI-aware: escape codes are preserved and wide
|
||||
// characters are measured correctly.
|
||||
func truncateLine(s string, maxWidth int) string {
|
||||
if len(s) <= maxWidth {
|
||||
if xansi.StringWidth(s) <= maxWidth {
|
||||
return s
|
||||
}
|
||||
if maxWidth < 2 {
|
||||
return s[:maxWidth]
|
||||
return xansi.Truncate(s, maxWidth, "")
|
||||
}
|
||||
return s[:maxWidth-1] + "…"
|
||||
return xansi.Truncate(s, maxWidth, "…")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -858,12 +895,10 @@ func renderBashCompact(toolResult string, width int) string {
|
||||
display = display[:maxLines]
|
||||
}
|
||||
|
||||
// Truncate each line to available width
|
||||
// Truncate each line to available width (ANSI-aware)
|
||||
lineMax := max(width-4, 20)
|
||||
for i, line := range display {
|
||||
if len(line) > lineMax {
|
||||
display[i] = line[:lineMax-3] + "..."
|
||||
}
|
||||
display[i] = truncateLine(line, lineMax)
|
||||
}
|
||||
|
||||
summary := strings.Join(display, "\n")
|
||||
@@ -940,10 +975,8 @@ func extractSubagentPreview(content string, maxLines, maxWidth int) string {
|
||||
continue
|
||||
}
|
||||
|
||||
// Truncate long lines
|
||||
if len(trimmed) > maxWidth {
|
||||
trimmed = trimmed[:maxWidth-3] + "..."
|
||||
}
|
||||
// Truncate long lines (ANSI-aware)
|
||||
trimmed = truncateLine(trimmed, maxWidth)
|
||||
preview = append(preview, trimmed)
|
||||
|
||||
if len(preview) >= maxLines {
|
||||
|
||||
@@ -430,6 +430,8 @@ func (ts *TreeSelectorComponent) renderNode(node FlatNode, isCursor, isLeaf bool
|
||||
}
|
||||
case *session.BranchSummaryEntry:
|
||||
style = lipgloss.NewStyle().Foreground(theme.Warning).Italic(true)
|
||||
case *session.CompactionEntry:
|
||||
style = lipgloss.NewStyle().Foreground(theme.Info).Italic(true)
|
||||
default:
|
||||
style = lipgloss.NewStyle().Foreground(theme.Muted)
|
||||
}
|
||||
@@ -483,6 +485,13 @@ func (ts *TreeSelectorComponent) entryDisplayText(entry any) string {
|
||||
}
|
||||
return fmt.Sprintf("branch summary: %s", summary)
|
||||
|
||||
case *session.CompactionEntry:
|
||||
summary := e.Summary
|
||||
if len(summary) > 60 {
|
||||
summary = summary[:60] + "..."
|
||||
}
|
||||
return fmt.Sprintf("compaction: %s", summary)
|
||||
|
||||
case *session.LabelEntry:
|
||||
return fmt.Sprintf("label: %s", e.Label)
|
||||
|
||||
|
||||
@@ -266,3 +266,14 @@ func (ut *UsageTracker) SetWidth(width int) {
|
||||
defer ut.mu.Unlock()
|
||||
ut.width = width
|
||||
}
|
||||
|
||||
// UpdateModelInfo updates the model information and OAuth status when the model
|
||||
// is switched mid-session. This ensures token costs and context limits are
|
||||
// calculated correctly for the new model.
|
||||
func (ut *UsageTracker) UpdateModelInfo(modelInfo *models.ModelInfo, provider string, isOAuth bool) {
|
||||
ut.mu.Lock()
|
||||
defer ut.mu.Unlock()
|
||||
ut.modelInfo = modelInfo
|
||||
ut.provider = provider
|
||||
ut.isOAuth = isOAuth
|
||||
}
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
# Testing Kit Extensions
|
||||
|
||||
The `github.com/mark3labs/kit/pkg/extensions/test` package provides utilities for testing Kit extensions using standard Go testing patterns.
|
||||
|
||||
## Overview
|
||||
|
||||
Extension tests run outside the Yaegi interpreter but load your extension code into an isolated interpreter instance. This allows you to:
|
||||
|
||||
- Test event handlers without running the full Kit TUI
|
||||
- Verify that your extension registers tools/commands correctly
|
||||
- Assert that context methods (Print, SetWidget, etc.) are called as expected
|
||||
- Test blocking and non-blocking event handling
|
||||
|
||||
## Installation
|
||||
|
||||
The test package is part of the Kit codebase. Import it in your extension tests:
|
||||
|
||||
```go
|
||||
import (
|
||||
"testing"
|
||||
"github.com/mark3labs/kit/pkg/extensions/test"
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
)
|
||||
```
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Testing an Extension File
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"github.com/mark3labs/kit/pkg/extensions/test"
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
)
|
||||
|
||||
func TestMyExtension(t *testing.T) {
|
||||
// Create a test harness
|
||||
harness := test.New(t)
|
||||
|
||||
// Load your extension
|
||||
harness.LoadFile("my-ext.go")
|
||||
|
||||
// Emit events and verify behavior
|
||||
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify the extension printed something
|
||||
test.AssertPrinted(t, harness, "session started")
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Inline Extension Code
|
||||
|
||||
For quick tests, you can load extension source directly:
|
||||
|
||||
```go
|
||||
func TestToolBlocking(t *testing.T) {
|
||||
src := `package main
|
||||
|
||||
import "kit/ext"
|
||||
|
||||
func Init(api ext.API) {
|
||||
api.OnToolCall(func(tc ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
|
||||
if tc.ToolName == "dangerous" {
|
||||
return &ext.ToolCallResult{Block: true, Reason: "not allowed"}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
`
|
||||
harness := test.New(t)
|
||||
harness.LoadString(src, "test-ext.go")
|
||||
|
||||
// Test the tool is blocked
|
||||
result, _ := harness.Emit(extensions.ToolCallEvent{
|
||||
ToolName: "dangerous",
|
||||
Input: "{}",
|
||||
})
|
||||
|
||||
test.AssertBlocked(t, result, "not allowed")
|
||||
}
|
||||
```
|
||||
|
||||
## Common Testing Patterns
|
||||
|
||||
### Testing Tool Registration
|
||||
|
||||
```go
|
||||
func TestToolRegistration(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("my-ext.go")
|
||||
|
||||
// Verify the tool was registered
|
||||
test.AssertToolRegistered(t, harness, "my_tool")
|
||||
|
||||
// Or inspect tools directly
|
||||
tools := harness.RegisteredTools()
|
||||
for _, tool := range tools {
|
||||
if tool.Name == "my_tool" {
|
||||
t.Logf("Tool description: %s", tool.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Command Registration
|
||||
|
||||
```go
|
||||
func TestCommandRegistration(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("my-ext.go")
|
||||
|
||||
test.AssertCommandRegistered(t, harness, "mycommand")
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Widgets
|
||||
|
||||
```go
|
||||
func TestWidgetBehavior(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("my-ext.go")
|
||||
|
||||
// Trigger the event that creates the widget
|
||||
_, _ = harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
|
||||
|
||||
// Verify the widget was set
|
||||
test.AssertWidgetSet(t, harness, "my-widget")
|
||||
|
||||
// Verify specific widget content
|
||||
test.AssertWidgetText(t, harness, "my-widget", "Expected Text")
|
||||
|
||||
// Or verify partial content
|
||||
test.AssertWidgetTextContains(t, harness, "my-widget", "partial")
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Input Handling
|
||||
|
||||
```go
|
||||
func TestInputHandling(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("my-ext.go")
|
||||
|
||||
// Test that the extension handles certain input
|
||||
result, _ := harness.Emit(extensions.InputEvent{
|
||||
Text: "secret password",
|
||||
Source: "cli",
|
||||
})
|
||||
|
||||
test.AssertInputHandled(t, result, "handled")
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Print Functions
|
||||
|
||||
```go
|
||||
func TestPrintOutput(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("my-ext.go")
|
||||
|
||||
_, _ = harness.Emit(extensions.ToolCallEvent{
|
||||
ToolName: "test",
|
||||
Input: "{}",
|
||||
})
|
||||
|
||||
// Assert exact match
|
||||
test.AssertPrinted(t, harness, "exact output")
|
||||
|
||||
// Or partial match
|
||||
test.AssertPrintedContains(t, harness, "partial")
|
||||
|
||||
// Assert info/error messages
|
||||
test.AssertPrintInfo(t, harness, "info message")
|
||||
test.AssertPrintError(t, harness, "error message")
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Status Bar
|
||||
|
||||
```go
|
||||
func TestStatusBar(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("my-ext.go")
|
||||
|
||||
_, _ = harness.Emit(extensions.AgentEndEvent{})
|
||||
|
||||
test.AssertStatusSet(t, harness, "myext:status")
|
||||
test.AssertStatusText(t, harness, "myext:status", "Ready")
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Prompt Results
|
||||
|
||||
Configure the mock context to return specific prompt results:
|
||||
|
||||
```go
|
||||
func TestWithPrompts(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("my-ext.go")
|
||||
|
||||
// Configure prompt results before emitting events
|
||||
harness.Context().SetPromptSelectResult(extensions.PromptSelectResult{
|
||||
Value: "option1",
|
||||
Index: 0,
|
||||
Cancelled: false,
|
||||
})
|
||||
|
||||
// Now when your extension calls ctx.PromptSelect(), it will get this result
|
||||
_, _ = harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
|
||||
}
|
||||
```
|
||||
|
||||
## Available Assertions
|
||||
|
||||
The test package provides these assertion helpers:
|
||||
|
||||
**Event Results:**
|
||||
- `AssertNotBlocked(t, result)` - Verify tool was not blocked
|
||||
- `AssertBlocked(t, result, reason)` - Verify tool was blocked with reason
|
||||
- `AssertInputHandled(t, result, action)` - Verify input was handled
|
||||
- `AssertInputTransformed(t, result, text)` - Verify input transformation
|
||||
|
||||
**Context Interactions:**
|
||||
- `AssertPrinted(t, harness, text)` - Verify exact print output
|
||||
- `AssertPrintedContains(t, harness, substring)` - Verify partial print output
|
||||
- `AssertPrintInfo(t, harness, text)` - Verify PrintInfo was called
|
||||
- `AssertPrintError(t, harness, text)` - Verify PrintError was called
|
||||
- `AssertWidgetSet(t, harness, id)` - Verify widget was set
|
||||
- `AssertWidgetNotSet(t, harness, id)` - Verify widget was not set
|
||||
- `AssertWidgetText(t, harness, id, text)` - Verify widget content
|
||||
- `AssertWidgetTextContains(t, harness, id, substring)` - Verify widget contains text
|
||||
- `AssertHeaderSet(t, harness)` - Verify header was set
|
||||
- `AssertFooterSet(t, harness)` - Verify footer was set
|
||||
- `AssertStatusSet(t, harness, key)` - Verify status was set
|
||||
- `AssertStatusText(t, harness, key, text)` - Verify status text
|
||||
|
||||
**Registration:**
|
||||
- `AssertToolRegistered(t, harness, name)` - Verify tool registration
|
||||
- `AssertCommandRegistered(t, harness, name)` - Verify command registration
|
||||
- `AssertHasHandlers(t, harness, eventType)` - Verify handlers exist
|
||||
- `AssertNoHandlers(t, harness, eventType)` - Verify no handlers
|
||||
|
||||
**Messaging:**
|
||||
- `AssertMessageSent(t, harness, text)` - Verify SendMessage was called
|
||||
- `AssertCancelAndSend(t, harness, text)` - Verify CancelAndSend was called
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Accessing the Mock Context
|
||||
|
||||
For custom assertions, access the mock context directly:
|
||||
|
||||
```go
|
||||
func TestCustomAssertion(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("my-ext.go")
|
||||
|
||||
_, _ = harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
|
||||
|
||||
// Get all recorded prints
|
||||
prints := harness.Context().GetPrints()
|
||||
|
||||
// Check widget directly
|
||||
widget, ok := harness.Context().GetWidget("my-widget")
|
||||
if ok && widget.Style.BorderColor == "#ff0000" {
|
||||
t.Log("Widget has red border")
|
||||
}
|
||||
|
||||
// Check options
|
||||
optionValue := harness.Context().GetOption("my-option")
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Multiple Extensions
|
||||
|
||||
Each harness is isolated:
|
||||
|
||||
```go
|
||||
func TestExtensionIsolation(t *testing.T) {
|
||||
// These run in completely separate interpreters
|
||||
harness1 := test.New(t)
|
||||
harness1.LoadFile("ext1.go")
|
||||
|
||||
harness2 := test.New(t)
|
||||
harness2.LoadFile("ext2.go")
|
||||
|
||||
// Events to one don't affect the other
|
||||
}
|
||||
```
|
||||
|
||||
### Direct Result Extraction
|
||||
|
||||
When you need to inspect result details:
|
||||
|
||||
```go
|
||||
result, _ := harness.Emit(extensions.ToolCallEvent{...})
|
||||
tcr := test.GetToolCallResult(result)
|
||||
if tcr != nil {
|
||||
t.Logf("Block: %v, Reason: %s", tcr.Block, tcr.Reason)
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Test one behavior per test** - Keep tests focused and readable
|
||||
2. **Use inline source for simple tests** - LoadString is great for isolated tests
|
||||
3. **Use LoadFile for integration tests** - Tests the actual extension file
|
||||
4. **Assert on context calls** - Verify your extension interacts with the context correctly
|
||||
5. **Test both positive and negative cases** - Verify tools are blocked AND allowed appropriately
|
||||
6. **Test all event handlers** - Make sure all registered handlers work correctly
|
||||
|
||||
## Limitations
|
||||
|
||||
The test harness has these limitations:
|
||||
|
||||
1. **No TUI rendering** - Widgets are recorded but not rendered visually
|
||||
2. **Prompts return configured values** - You must pre-configure prompt results in tests
|
||||
3. **Subagents don't spawn real processes** - SpawnSubagent returns nil/empty results
|
||||
4. **LLM completions are mocked** - Complete returns empty responses
|
||||
5. **Some context methods are no-ops** - Exit, SetActiveTools, etc. don't have side effects
|
||||
|
||||
These limitations are intentional - the test harness focuses on testing extension logic, not the full Kit runtime.
|
||||
|
||||
## Example: Complete Extension Test
|
||||
|
||||
Here's a complete example testing a realistic extension:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"github.com/mark3labs/kit/pkg/extensions/test"
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
)
|
||||
|
||||
// Test that the extension properly blocks dangerous tools
|
||||
func TestSafetyExtension_BlocksDangerousTools(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("safety-ext.go")
|
||||
|
||||
// Verify it handles tool calls
|
||||
test.AssertHasHandlers(t, harness, extensions.ToolCall)
|
||||
|
||||
// Test allowed tool
|
||||
result, _ := harness.Emit(extensions.ToolCallEvent{ToolName: "read", Input: "{}"})
|
||||
test.AssertNotBlocked(t, result)
|
||||
|
||||
// Test blocked tool
|
||||
result, _ = harness.Emit(extensions.ToolCallEvent{ToolName: "rm", Input: "{}"})
|
||||
test.AssertBlocked(t, result, "safety block")
|
||||
test.AssertPrintError(t, harness, "Tool rm is blocked")
|
||||
}
|
||||
|
||||
// Test that the extension shows status on agent completion
|
||||
func TestSafetyExtension_ShowsStatus(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("safety-ext.go")
|
||||
|
||||
_, _ = harness.Emit(extensions.AgentEndEvent{})
|
||||
|
||||
test.AssertWidgetSet(t, harness, "safety-widget")
|
||||
test.AssertWidgetTextContains(t, harness, "safety-widget", "Safe")
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,297 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
)
|
||||
|
||||
// AssertNotBlocked fails the test if the tool call result indicates the tool was blocked.
|
||||
func AssertNotBlocked(t *testing.T, result extensions.Result) {
|
||||
t.Helper()
|
||||
if result == nil {
|
||||
return
|
||||
}
|
||||
if tcr, ok := result.(extensions.ToolCallResult); ok {
|
||||
if tcr.Block {
|
||||
t.Errorf("expected tool to not be blocked, but it was blocked with reason: %q", tcr.Reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AssertBlocked fails the test if the tool call result does not indicate the tool was blocked.
|
||||
func AssertBlocked(t *testing.T, result extensions.Result, expectedReason string) {
|
||||
t.Helper()
|
||||
if result == nil {
|
||||
t.Error("expected tool to be blocked, but result was nil")
|
||||
return
|
||||
}
|
||||
tcr, ok := result.(extensions.ToolCallResult)
|
||||
if !ok {
|
||||
t.Errorf("expected ToolCallResult, got %T", result)
|
||||
return
|
||||
}
|
||||
if !tcr.Block {
|
||||
t.Error("expected tool to be blocked, but it was not blocked")
|
||||
return
|
||||
}
|
||||
if expectedReason != "" && tcr.Reason != expectedReason {
|
||||
t.Errorf("expected block reason %q, got %q", expectedReason, tcr.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// AssertInputHandled fails the test if the input result does not indicate the input was handled.
|
||||
func AssertInputHandled(t *testing.T, result extensions.Result, expectedAction string) {
|
||||
t.Helper()
|
||||
if result == nil {
|
||||
t.Error("expected input to be handled, but result was nil")
|
||||
return
|
||||
}
|
||||
ir, ok := result.(extensions.InputResult)
|
||||
if !ok {
|
||||
t.Errorf("expected InputResult, got %T", result)
|
||||
return
|
||||
}
|
||||
if ir.Action != expectedAction {
|
||||
t.Errorf("expected action %q, got %q", expectedAction, ir.Action)
|
||||
}
|
||||
}
|
||||
|
||||
// AssertInputTransformed fails the test if the input was not transformed to the expected text.
|
||||
func AssertInputTransformed(t *testing.T, result extensions.Result, expectedText string) {
|
||||
t.Helper()
|
||||
if result == nil {
|
||||
t.Errorf("expected input to be transformed to %q, but result was nil", expectedText)
|
||||
return
|
||||
}
|
||||
ir, ok := result.(extensions.InputResult)
|
||||
if !ok {
|
||||
t.Errorf("expected InputResult, got %T", result)
|
||||
return
|
||||
}
|
||||
if ir.Action != "transform" {
|
||||
t.Errorf("expected action 'transform', got %q", ir.Action)
|
||||
}
|
||||
if ir.Text != expectedText {
|
||||
t.Errorf("expected transformed text %q, got %q", expectedText, ir.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// AssertPrinted fails the test if the expected text was not printed.
|
||||
func AssertPrinted(t *testing.T, harness *Harness, expected string) {
|
||||
t.Helper()
|
||||
prints := harness.Context().GetPrints()
|
||||
if slices.Contains(prints, expected) {
|
||||
return
|
||||
}
|
||||
t.Errorf("expected text %q to be printed, but it was not found in prints: %v", expected, prints)
|
||||
}
|
||||
|
||||
// AssertPrintedContains fails the test if no printed text contains the expected substring.
|
||||
func AssertPrintedContains(t *testing.T, harness *Harness, substring string) {
|
||||
t.Helper()
|
||||
prints := harness.Context().GetPrints()
|
||||
for _, p := range prints {
|
||||
if strings.Contains(p, substring) {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("expected printed text to contain %q, but it was not found in prints: %v", substring, prints)
|
||||
}
|
||||
|
||||
// AssertPrintInfo fails the test if the expected info message was not printed.
|
||||
func AssertPrintInfo(t *testing.T, harness *Harness, expected string) {
|
||||
t.Helper()
|
||||
infos := harness.Context().GetPrintInfos()
|
||||
if slices.Contains(infos, expected) {
|
||||
return
|
||||
}
|
||||
t.Errorf("expected info message %q, but it was not found in PrintInfos: %v", expected, infos)
|
||||
}
|
||||
|
||||
// AssertPrintError fails the test if the expected error message was not printed.
|
||||
func AssertPrintError(t *testing.T, harness *Harness, expected string) {
|
||||
t.Helper()
|
||||
errors := harness.Context().GetPrintErrors()
|
||||
if slices.Contains(errors, expected) {
|
||||
return
|
||||
}
|
||||
t.Errorf("expected error message %q, but it was not found in PrintErrors: %v", expected, errors)
|
||||
}
|
||||
|
||||
// AssertWidgetSet fails the test if the widget with the given ID was not set.
|
||||
func AssertWidgetSet(t *testing.T, harness *Harness, id string) {
|
||||
t.Helper()
|
||||
if !harness.Context().HasWidget(id) {
|
||||
t.Errorf("expected widget %q to be set, but it was not", id)
|
||||
}
|
||||
}
|
||||
|
||||
// AssertWidgetNotSet fails the test if the widget with the given ID was set.
|
||||
func AssertWidgetNotSet(t *testing.T, harness *Harness, id string) {
|
||||
t.Helper()
|
||||
if harness.Context().HasWidget(id) {
|
||||
t.Errorf("expected widget %q to not be set, but it was", id)
|
||||
}
|
||||
}
|
||||
|
||||
// AssertWidgetText fails the test if the widget with the given ID does not have the expected text.
|
||||
func AssertWidgetText(t *testing.T, harness *Harness, id string, expected string) {
|
||||
t.Helper()
|
||||
widget, ok := harness.Context().GetWidget(id)
|
||||
if !ok {
|
||||
t.Errorf("expected widget %q to be set, but it was not", id)
|
||||
return
|
||||
}
|
||||
if widget.Content.Text != expected {
|
||||
t.Errorf("expected widget %q to have text %q, got %q", id, expected, widget.Content.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// AssertWidgetTextContains fails the test if the widget text does not contain the expected substring.
|
||||
func AssertWidgetTextContains(t *testing.T, harness *Harness, id string, substring string) {
|
||||
t.Helper()
|
||||
widget, ok := harness.Context().GetWidget(id)
|
||||
if !ok {
|
||||
t.Errorf("expected widget %q to be set, but it was not", id)
|
||||
return
|
||||
}
|
||||
if !strings.Contains(widget.Content.Text, substring) {
|
||||
t.Errorf("expected widget %q text to contain %q, but got %q", id, substring, widget.Content.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// AssertHeaderSet fails the test if no header was set.
|
||||
func AssertHeaderSet(t *testing.T, harness *Harness) {
|
||||
t.Helper()
|
||||
if harness.Context().GetHeader() == nil {
|
||||
t.Error("expected header to be set, but it was not")
|
||||
}
|
||||
}
|
||||
|
||||
// AssertFooterSet fails the test if no footer was set.
|
||||
func AssertFooterSet(t *testing.T, harness *Harness) {
|
||||
t.Helper()
|
||||
if harness.Context().GetFooter() == nil {
|
||||
t.Error("expected footer to be set, but it was not")
|
||||
}
|
||||
}
|
||||
|
||||
// AssertStatusSet fails the test if the status with the given key was not set.
|
||||
func AssertStatusSet(t *testing.T, harness *Harness, key string) {
|
||||
t.Helper()
|
||||
_, ok := harness.Context().GetStatus(key)
|
||||
if !ok {
|
||||
t.Errorf("expected status %q to be set, but it was not", key)
|
||||
}
|
||||
}
|
||||
|
||||
// AssertStatusText fails the test if the status with the given key does not have the expected text.
|
||||
func AssertStatusText(t *testing.T, harness *Harness, key string, expected string) {
|
||||
t.Helper()
|
||||
status, ok := harness.Context().GetStatus(key)
|
||||
if !ok {
|
||||
t.Errorf("expected status %q to be set, but it was not", key)
|
||||
return
|
||||
}
|
||||
if status.Text != expected {
|
||||
t.Errorf("expected status %q to have text %q, got %q", key, expected, status.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// AssertHasHandlers fails the test if no handlers are registered for the given event type.
|
||||
func AssertHasHandlers(t *testing.T, harness *Harness, eventType extensions.EventType) {
|
||||
t.Helper()
|
||||
if !harness.HasHandlers(eventType) {
|
||||
t.Errorf("expected handlers for event type %q, but none were registered", eventType)
|
||||
}
|
||||
}
|
||||
|
||||
// AssertNoHandlers fails the test if any handlers are registered for the given event type.
|
||||
func AssertNoHandlers(t *testing.T, harness *Harness, eventType extensions.EventType) {
|
||||
t.Helper()
|
||||
if harness.HasHandlers(eventType) {
|
||||
t.Errorf("expected no handlers for event type %q, but some were registered", eventType)
|
||||
}
|
||||
}
|
||||
|
||||
// AssertToolRegistered fails the test if the tool with the given name was not registered.
|
||||
func AssertToolRegistered(t *testing.T, harness *Harness, toolName string) {
|
||||
t.Helper()
|
||||
tools := harness.RegisteredTools()
|
||||
for _, tool := range tools {
|
||||
if tool.Name == toolName {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("expected tool %q to be registered, but it was not found in %v", toolName, tools)
|
||||
}
|
||||
|
||||
// AssertCommandRegistered fails the test if the command with the given name was not registered.
|
||||
func AssertCommandRegistered(t *testing.T, harness *Harness, cmdName string) {
|
||||
t.Helper()
|
||||
cmds := harness.RegisteredCommands()
|
||||
for _, cmd := range cmds {
|
||||
if cmd.Name == cmdName {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("expected command %q to be registered, but it was not found in %v", cmdName, cmds)
|
||||
}
|
||||
|
||||
// AssertMessageSent fails the test if the expected message was not sent.
|
||||
func AssertMessageSent(t *testing.T, harness *Harness, expected string) {
|
||||
t.Helper()
|
||||
ctx := harness.Context()
|
||||
if slices.Contains(ctx.Messages, expected) {
|
||||
return
|
||||
}
|
||||
t.Errorf("expected message %q to be sent, but it was not found in messages: %v", expected, ctx.Messages)
|
||||
}
|
||||
|
||||
// AssertCancelAndSend fails the test if the expected text was not sent via CancelAndSend.
|
||||
func AssertCancelAndSend(t *testing.T, harness *Harness, expected string) {
|
||||
t.Helper()
|
||||
ctx := harness.Context()
|
||||
if slices.Contains(ctx.CancelSends, expected) {
|
||||
return
|
||||
}
|
||||
t.Errorf("expected CancelAndSend with %q, but it was not found: %v", expected, ctx.CancelSends)
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
// GetToolCallResult extracts a ToolCallResult from a Result, or nil if not applicable.
|
||||
func GetToolCallResult(result extensions.Result) *extensions.ToolCallResult {
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
if tcr, ok := result.(extensions.ToolCallResult); ok {
|
||||
return &tcr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetInputResult extracts an InputResult from a Result, or nil if not applicable.
|
||||
func GetInputResult(result extensions.Result) *extensions.InputResult {
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
if ir, ok := result.(extensions.InputResult); ok {
|
||||
return &ir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetToolResultResult extracts a ToolResultResult from a Result, or nil if not applicable.
|
||||
func GetToolResultResult(result extensions.Result) *extensions.ToolResultResult {
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
if trr, ok := result.(extensions.ToolResultResult); ok {
|
||||
return &trr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
// Package test provides utilities for testing Kit extensions.
|
||||
//
|
||||
// This package allows extension authors to write standard Go tests that load
|
||||
// and exercise their extensions in a controlled environment. Extensions are
|
||||
// loaded into a Yaegi interpreter with all Kit API symbols available.
|
||||
//
|
||||
// Basic usage:
|
||||
//
|
||||
// package main
|
||||
//
|
||||
// import (
|
||||
// "testing"
|
||||
// "github.com/mark3labs/kit/pkg/extensions/test"
|
||||
// )
|
||||
//
|
||||
// func TestMyExtension(t *testing.T) {
|
||||
// // Create a test harness
|
||||
// harness := test.New(t)
|
||||
//
|
||||
// // Load your extension file
|
||||
// ext := harness.LoadFile("my-ext.go")
|
||||
//
|
||||
// // Emit events and check results
|
||||
// result := harness.Emit(test.ToolCallEvent{
|
||||
// ToolName: "my_tool",
|
||||
// Input: `{"key": "value"}`,
|
||||
// })
|
||||
//
|
||||
// // Use assertion helpers
|
||||
// test.AssertNotBlocked(t, result)
|
||||
// test.AssertPrinted(t, harness, "expected output")
|
||||
// }
|
||||
//
|
||||
// The harness provides a mock Context that records all interactions,
|
||||
// allowing you to verify that your extension called SetWidget, Print, etc.
|
||||
package test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
"github.com/traefik/yaegi/interp"
|
||||
"github.com/traefik/yaegi/stdlib"
|
||||
"github.com/traefik/yaegi/stdlib/unrestricted"
|
||||
)
|
||||
|
||||
// Harness provides a testing environment for Kit extensions.
|
||||
// It loads extensions into an isolated Yaegi interpreter and provides
|
||||
// methods to emit events and verify extension behavior.
|
||||
type Harness struct {
|
||||
t *testing.T
|
||||
runner *extensions.Runner
|
||||
context *MockContext
|
||||
extPath string
|
||||
}
|
||||
|
||||
// New creates a new test harness for the given test.
|
||||
// The harness must be used within a single test function.
|
||||
func New(t *testing.T) *Harness {
|
||||
return &Harness{
|
||||
t: t,
|
||||
context: NewMockContext(),
|
||||
}
|
||||
}
|
||||
|
||||
// LoadFile loads an extension from a file path.
|
||||
// The extension is evaluated in a fresh Yaegi interpreter with all
|
||||
// Kit API symbols available. The Init function is called automatically.
|
||||
//
|
||||
// Returns the loaded extension or fails the test on error.
|
||||
func (h *Harness) LoadFile(path string) *extensions.LoadedExtension {
|
||||
h.t.Helper()
|
||||
|
||||
// Verify file exists
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
h.t.Fatalf("extension file not found: %s: %v", path, err)
|
||||
}
|
||||
|
||||
// Read extension source
|
||||
src, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
h.t.Fatalf("failed to read extension file: %v", err)
|
||||
}
|
||||
|
||||
return h.loadSource(string(src), path)
|
||||
}
|
||||
|
||||
// LoadString loads an extension from a source string.
|
||||
// Useful for inline extension tests. The path is used for error reporting.
|
||||
func (h *Harness) LoadString(src string, path string) *extensions.LoadedExtension {
|
||||
h.t.Helper()
|
||||
return h.loadSource(src, path)
|
||||
}
|
||||
|
||||
// loadSource is the internal implementation that loads extension source
|
||||
// into a Yaegi interpreter.
|
||||
func (h *Harness) loadSource(src string, path string) *extensions.LoadedExtension {
|
||||
h.t.Helper()
|
||||
|
||||
// Create a fresh interpreter
|
||||
i := interp.New(interp.Options{})
|
||||
|
||||
// Expose Go stdlib
|
||||
if err := i.Use(stdlib.Symbols); err != nil {
|
||||
h.t.Fatalf("failed to load stdlib symbols: %v", err)
|
||||
}
|
||||
if err := i.Use(unrestricted.Symbols); err != nil {
|
||||
h.t.Fatalf("failed to load unrestricted symbols: %v", err)
|
||||
}
|
||||
|
||||
// Expose Kit extension API symbols
|
||||
if err := i.Use(extensions.Symbols()); err != nil {
|
||||
h.t.Fatalf("failed to load extension symbols: %v", err)
|
||||
}
|
||||
|
||||
// Evaluate the extension source
|
||||
if _, err := i.Eval(src); err != nil {
|
||||
h.t.Fatalf("failed to evaluate extension source: %v", err)
|
||||
}
|
||||
|
||||
// Extract the Init function
|
||||
initVal, err := i.Eval("Init")
|
||||
if err != nil {
|
||||
h.t.Fatalf("extension has no Init function: %v", err)
|
||||
}
|
||||
|
||||
initFn, ok := initVal.Interface().(func(extensions.API))
|
||||
if !ok {
|
||||
h.t.Fatalf("Init has wrong signature (want func(ext.API), got %T)", initVal.Interface())
|
||||
}
|
||||
|
||||
// Create the extension struct
|
||||
ext := &extensions.LoadedExtension{
|
||||
Path: path,
|
||||
Handlers: make(map[extensions.EventType][]extensions.HandlerFunc),
|
||||
}
|
||||
|
||||
// Create the API object using the test helper
|
||||
api := extensions.NewTestAPI(ext)
|
||||
|
||||
// Call Init to register handlers
|
||||
initFn(api)
|
||||
|
||||
// Create runner with the loaded extension
|
||||
h.runner = extensions.NewRunner([]extensions.LoadedExtension{*ext})
|
||||
h.extPath = path
|
||||
|
||||
// Wire the mock context
|
||||
h.runner.SetContext(h.context.ToContext())
|
||||
|
||||
return ext
|
||||
}
|
||||
|
||||
// Emit sends an event to the loaded extension(s) and returns the result.
|
||||
// Events are dispatched in order and blocking results stop propagation.
|
||||
func (h *Harness) Emit(event extensions.Event) (extensions.Result, error) {
|
||||
h.t.Helper()
|
||||
|
||||
if h.runner == nil {
|
||||
h.t.Fatal("no extension loaded, call LoadFile() or LoadString() first")
|
||||
}
|
||||
|
||||
return h.runner.Emit(event)
|
||||
}
|
||||
|
||||
// EmitJSON is a convenience method for emitting a ToolCallEvent with JSON input.
|
||||
func (h *Harness) EmitJSON(toolName string, input string) (*extensions.ToolCallResult, error) {
|
||||
h.t.Helper()
|
||||
|
||||
result, err := h.Emit(extensions.ToolCallEvent{
|
||||
ToolName: toolName,
|
||||
Input: input,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
tcr, ok := result.(extensions.ToolCallResult)
|
||||
if !ok {
|
||||
h.t.Fatalf("expected ToolCallResult, got %T", result)
|
||||
}
|
||||
|
||||
return &tcr, nil
|
||||
}
|
||||
|
||||
// Context returns the mock context for inspection.
|
||||
// Use this to verify Print calls, widget settings, etc.
|
||||
func (h *Harness) Context() *MockContext {
|
||||
return h.context
|
||||
}
|
||||
|
||||
// Runner returns the underlying runner for advanced use cases.
|
||||
func (h *Harness) Runner() *extensions.Runner {
|
||||
return h.runner
|
||||
}
|
||||
|
||||
// HasHandlers reports whether any handlers are registered for the given event type.
|
||||
func (h *Harness) HasHandlers(eventType extensions.EventType) bool {
|
||||
if h.runner == nil {
|
||||
return false
|
||||
}
|
||||
return h.runner.HasHandlers(eventType)
|
||||
}
|
||||
|
||||
// RegisteredTools returns all tools registered by the extension.
|
||||
func (h *Harness) RegisteredTools() []extensions.ToolDef {
|
||||
if h.runner == nil {
|
||||
return nil
|
||||
}
|
||||
return h.runner.RegisteredTools()
|
||||
}
|
||||
|
||||
// RegisteredCommands returns all commands registered by the extension.
|
||||
func (h *Harness) RegisteredCommands() []extensions.CommandDef {
|
||||
if h.runner == nil {
|
||||
return nil
|
||||
}
|
||||
return h.runner.RegisteredCommands()
|
||||
}
|
||||
|
||||
// MustLoad is like LoadFile but fails the test immediately on error.
|
||||
// It returns the harness for chaining.
|
||||
func (h *Harness) MustLoad(path string) *Harness {
|
||||
h.t.Helper()
|
||||
h.LoadFile(path)
|
||||
return h
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
)
|
||||
|
||||
// Test harness with a simple extension
|
||||
func TestHarness_LoadString(t *testing.T) {
|
||||
src := `package main
|
||||
|
||||
import "kit/ext"
|
||||
|
||||
func Init(api ext.API) {
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
ctx.Print("session started")
|
||||
})
|
||||
}
|
||||
`
|
||||
|
||||
harness := New(t)
|
||||
harness.LoadString(src, "test-ext.go")
|
||||
|
||||
// Emit session start event
|
||||
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify the extension printed something
|
||||
prints := harness.Context().GetPrints()
|
||||
if len(prints) != 1 || prints[0] != "session started" {
|
||||
t.Errorf("expected ['session started'], got %v", prints)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHarness_ToolCallBlocking(t *testing.T) {
|
||||
src := `package main
|
||||
|
||||
import "kit/ext"
|
||||
|
||||
func Init(api ext.API) {
|
||||
api.OnToolCall(func(tc ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
|
||||
if tc.ToolName == "banned" {
|
||||
return &ext.ToolCallResult{Block: true, Reason: "tool is banned"}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
`
|
||||
|
||||
harness := New(t)
|
||||
harness.LoadString(src, "blocker.go")
|
||||
|
||||
// Test blocked tool
|
||||
result, err := harness.Emit(extensions.ToolCallEvent{ToolName: "banned", Input: "{}"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
AssertBlocked(t, result, "tool is banned")
|
||||
|
||||
// Test allowed tool
|
||||
result2, err := harness.Emit(extensions.ToolCallEvent{ToolName: "allowed", Input: "{}"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result2 != nil {
|
||||
t.Errorf("expected nil result for allowed tool, got %v", result2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHarness_ToolRegistration(t *testing.T) {
|
||||
src := `package main
|
||||
|
||||
import "kit/ext"
|
||||
|
||||
func Init(api ext.API) {
|
||||
api.RegisterTool(ext.ToolDef{
|
||||
Name: "my_tool",
|
||||
Description: "does stuff",
|
||||
Parameters: "{}",
|
||||
Execute: func(input string) (string, error) {
|
||||
return "result: " + input, nil
|
||||
},
|
||||
})
|
||||
}
|
||||
`
|
||||
|
||||
harness := New(t)
|
||||
harness.LoadString(src, "tool-ext.go")
|
||||
|
||||
tools := harness.RegisteredTools()
|
||||
if len(tools) != 1 {
|
||||
t.Fatalf("expected 1 tool, got %d", len(tools))
|
||||
}
|
||||
|
||||
if tools[0].Name != "my_tool" {
|
||||
t.Errorf("expected tool name 'my_tool', got %q", tools[0].Name)
|
||||
}
|
||||
|
||||
AssertToolRegistered(t, harness, "my_tool")
|
||||
}
|
||||
|
||||
func TestHarness_CommandRegistration(t *testing.T) {
|
||||
src := `package main
|
||||
|
||||
import "kit/ext"
|
||||
|
||||
func Init(api ext.API) {
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "hello",
|
||||
Description: "says hello",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
ctx.Print("Hello, " + args)
|
||||
return "greeting sent", nil
|
||||
},
|
||||
})
|
||||
}
|
||||
`
|
||||
|
||||
harness := New(t)
|
||||
harness.LoadString(src, "cmd-ext.go")
|
||||
|
||||
cmds := harness.RegisteredCommands()
|
||||
if len(cmds) != 1 {
|
||||
t.Fatalf("expected 1 command, got %d", len(cmds))
|
||||
}
|
||||
|
||||
if cmds[0].Name != "hello" {
|
||||
t.Errorf("expected command name 'hello', got %q", cmds[0].Name)
|
||||
}
|
||||
|
||||
AssertCommandRegistered(t, harness, "hello")
|
||||
}
|
||||
|
||||
func TestHarness_WidgetSetting(t *testing.T) {
|
||||
src := `package main
|
||||
|
||||
import "kit/ext"
|
||||
|
||||
func Init(api ext.API) {
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
ctx.SetWidget(ext.WidgetConfig{
|
||||
ID: "my-widget",
|
||||
Placement: ext.WidgetAbove,
|
||||
Content: ext.WidgetContent{Text: "Hello, World!"},
|
||||
Style: ext.WidgetStyle{BorderColor: "#ff0000"},
|
||||
})
|
||||
})
|
||||
}
|
||||
`
|
||||
|
||||
harness := New(t)
|
||||
harness.LoadString(src, "widget-ext.go")
|
||||
|
||||
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
AssertWidgetSet(t, harness, "my-widget")
|
||||
AssertWidgetText(t, harness, "my-widget", "Hello, World!")
|
||||
|
||||
// Also verify directly
|
||||
widget, ok := harness.Context().GetWidget("my-widget")
|
||||
if !ok {
|
||||
t.Error("expected widget 'my-widget' to exist")
|
||||
}
|
||||
if widget.Style.BorderColor != "#ff0000" {
|
||||
t.Errorf("expected border color '#ff0000', got %q", widget.Style.BorderColor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHarness_FooterSetting(t *testing.T) {
|
||||
src := `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: "Status: OK"},
|
||||
Style: ext.WidgetStyle{BorderColor: "#00ff00"},
|
||||
})
|
||||
})
|
||||
}
|
||||
`
|
||||
|
||||
harness := New(t)
|
||||
harness.LoadString(src, "footer-ext.go")
|
||||
|
||||
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
AssertFooterSet(t, harness)
|
||||
|
||||
footer := harness.Context().GetFooter()
|
||||
if footer == nil {
|
||||
t.Fatal("expected footer to be set")
|
||||
}
|
||||
if footer.Content.Text != "Status: OK" {
|
||||
t.Errorf("expected footer text 'Status: OK', got %q", footer.Content.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHarness_PrintInfoAndError(t *testing.T) {
|
||||
src := `package main
|
||||
|
||||
import "kit/ext"
|
||||
|
||||
func Init(api ext.API) {
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
ctx.PrintInfo("Information message")
|
||||
ctx.PrintError("Error message")
|
||||
})
|
||||
}
|
||||
`
|
||||
|
||||
harness := New(t)
|
||||
harness.LoadString(src, "print-ext.go")
|
||||
|
||||
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
AssertPrintInfo(t, harness, "Information message")
|
||||
AssertPrintError(t, harness, "Error message")
|
||||
}
|
||||
|
||||
func TestHarness_EmitJSON(t *testing.T) {
|
||||
src := `package main
|
||||
|
||||
import "kit/ext"
|
||||
|
||||
func Init(api ext.API) {
|
||||
api.OnToolCall(func(tc ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
|
||||
if tc.ToolName == "test_tool" {
|
||||
return &ext.ToolCallResult{Block: true, Reason: "blocked"}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
`
|
||||
|
||||
harness := New(t)
|
||||
harness.LoadString(src, "json-ext.go")
|
||||
|
||||
result, err := harness.EmitJSON("test_tool", `{"key": "value"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
}
|
||||
|
||||
if !result.Block {
|
||||
t.Error("expected Block=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHarness_HasHandlers(t *testing.T) {
|
||||
src := `package main
|
||||
|
||||
import "kit/ext"
|
||||
|
||||
func Init(api ext.API) {
|
||||
api.OnToolCall(func(_ ext.ToolCallEvent, _ ext.Context) *ext.ToolCallResult {
|
||||
return nil
|
||||
})
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, _ ext.Context) {
|
||||
})
|
||||
}
|
||||
`
|
||||
|
||||
harness := New(t)
|
||||
harness.LoadString(src, "handlers-ext.go")
|
||||
|
||||
AssertHasHandlers(t, harness, extensions.ToolCall)
|
||||
AssertHasHandlers(t, harness, extensions.SessionStart)
|
||||
AssertNoHandlers(t, harness, extensions.AgentEnd)
|
||||
}
|
||||
|
||||
func TestHarness_MultipleExtensions(t *testing.T) {
|
||||
ext1 := `package main
|
||||
|
||||
import "kit/ext"
|
||||
|
||||
func Init(api ext.API) {
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
ctx.Print("extension 1")
|
||||
})
|
||||
}
|
||||
`
|
||||
|
||||
ext2 := `package main
|
||||
|
||||
import "kit/ext"
|
||||
|
||||
func Init(api ext.API) {
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
ctx.Print("extension 2")
|
||||
})
|
||||
}
|
||||
`
|
||||
|
||||
// Load first extension
|
||||
harness1 := New(t)
|
||||
harness1.LoadString(ext1, "ext1.go")
|
||||
|
||||
// Load second extension
|
||||
harness2 := New(t)
|
||||
harness2.LoadString(ext2, "ext2.go")
|
||||
|
||||
// Verify they are isolated
|
||||
_, _ = harness1.Emit(extensions.SessionStartEvent{SessionID: "test1"})
|
||||
_, _ = harness2.Emit(extensions.SessionStartEvent{SessionID: "test2"})
|
||||
|
||||
prints1 := harness1.Context().GetPrints()
|
||||
prints2 := harness2.Context().GetPrints()
|
||||
|
||||
if len(prints1) != 1 || prints1[0] != "extension 1" {
|
||||
t.Errorf("ext1 prints: expected ['extension 1'], got %v", prints1)
|
||||
}
|
||||
|
||||
if len(prints2) != 1 || prints2[0] != "extension 2" {
|
||||
t.Errorf("ext2 prints: expected ['extension 2'], got %v", prints2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHarness_InputHandling(t *testing.T) {
|
||||
src := `package main
|
||||
|
||||
import (
|
||||
"kit/ext"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Init(api ext.API) {
|
||||
api.OnInput(func(ie ext.InputEvent, ctx ext.Context) *ext.InputResult {
|
||||
if strings.Contains(ie.Text, "secret") {
|
||||
return &ext.InputResult{Action: "handled"}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
`
|
||||
|
||||
harness := New(t)
|
||||
harness.LoadString(src, "input-ext.go")
|
||||
|
||||
// Test handled input
|
||||
result, err := harness.Emit(extensions.InputEvent{Text: "my secret password", Source: "cli"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
AssertInputHandled(t, result, "handled")
|
||||
|
||||
// Test unhandled input
|
||||
result2, err := harness.Emit(extensions.InputEvent{Text: "normal input", Source: "cli"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result2 != nil {
|
||||
t.Errorf("expected nil result for unhandled input, got %v", result2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHarness_StatusSetting(t *testing.T) {
|
||||
src := `package main
|
||||
|
||||
import "kit/ext"
|
||||
|
||||
func Init(api ext.API) {
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
ctx.SetStatus("myext:status", "Ready", 50)
|
||||
})
|
||||
}
|
||||
`
|
||||
|
||||
harness := New(t)
|
||||
harness.LoadString(src, "status-ext.go")
|
||||
|
||||
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
AssertStatusSet(t, harness, "myext:status")
|
||||
AssertStatusText(t, harness, "myext:status", "Ready")
|
||||
}
|
||||
|
||||
func TestHarness_LoadFile_NotFound(t *testing.T) {
|
||||
// Test that loading a nonexistent file fails the test
|
||||
// We create a mock testing.T to capture the failure
|
||||
mockT := &testing.T{}
|
||||
harness := New(mockT)
|
||||
|
||||
// Just verify the harness was created successfully
|
||||
_ = harness.Context().GetPrints()
|
||||
|
||||
// The actual behavior (Fatalf on missing file) is tested implicitly
|
||||
// whenever LoadFile is used in other tests
|
||||
}
|
||||
|
||||
// MockContext tests
|
||||
func TestMockContext_Prompts(t *testing.T) {
|
||||
ctx := NewMockContext()
|
||||
|
||||
// Configure results
|
||||
ctx.SetPromptSelectResult(extensions.PromptSelectResult{Value: "option1", Index: 0, Cancelled: false})
|
||||
ctx.SetPromptConfirmResult(extensions.PromptConfirmResult{Value: true, Cancelled: false})
|
||||
ctx.SetPromptInputResult(extensions.PromptInputResult{Value: "input text", Cancelled: false})
|
||||
|
||||
extCtx := ctx.ToContext()
|
||||
|
||||
// Test prompts return configured results
|
||||
selectResult := extCtx.PromptSelect(extensions.PromptSelectConfig{Message: "test", Options: []string{"a", "b"}})
|
||||
if selectResult.Value != "option1" {
|
||||
t.Errorf("expected 'option1', got %q", selectResult.Value)
|
||||
}
|
||||
|
||||
confirmResult := extCtx.PromptConfirm(extensions.PromptConfirmConfig{Message: "test"})
|
||||
if !confirmResult.Value {
|
||||
t.Error("expected true")
|
||||
}
|
||||
|
||||
inputResult := extCtx.PromptInput(extensions.PromptInputConfig{Message: "test"})
|
||||
if inputResult.Value != "input text" {
|
||||
t.Errorf("expected 'input text', got %q", inputResult.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMockContext_Options(t *testing.T) {
|
||||
ctx := NewMockContext()
|
||||
extCtx := ctx.ToContext()
|
||||
|
||||
// Initially empty
|
||||
if extCtx.GetOption("key") != "" {
|
||||
t.Error("expected empty option")
|
||||
}
|
||||
|
||||
// Set option
|
||||
extCtx.SetOption("key", "value")
|
||||
if extCtx.GetOption("key") != "value" {
|
||||
t.Errorf("expected 'value', got %q", extCtx.GetOption("key"))
|
||||
}
|
||||
}
|
||||
|
||||
// Assertion helper tests
|
||||
func TestAssertPrintedContains(t *testing.T) {
|
||||
src := `package main
|
||||
|
||||
import "kit/ext"
|
||||
|
||||
func Init(api ext.API) {
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
ctx.Print("This is a long message with some content")
|
||||
})
|
||||
}
|
||||
`
|
||||
|
||||
harness := New(t)
|
||||
harness.LoadString(src, "print-ext.go")
|
||||
_, _ = harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
|
||||
|
||||
AssertPrintedContains(t, harness, "long message")
|
||||
AssertPrintedContains(t, harness, "some content")
|
||||
}
|
||||
|
||||
func TestAssertWidgetTextContains(t *testing.T) {
|
||||
src := `package main
|
||||
|
||||
import "kit/ext"
|
||||
|
||||
func Init(api ext.API) {
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
ctx.SetWidget(ext.WidgetConfig{
|
||||
ID: "status",
|
||||
Content: ext.WidgetContent{Text: "Build: passing, Tests: 42/42"},
|
||||
})
|
||||
})
|
||||
}
|
||||
`
|
||||
|
||||
harness := New(t)
|
||||
harness.LoadString(src, "widget-ext.go")
|
||||
_, _ = harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
|
||||
|
||||
AssertWidgetTextContains(t, harness, "status", "Build: passing")
|
||||
AssertWidgetTextContains(t, harness, "status", "42/42")
|
||||
}
|
||||
|
||||
// Test that shows how to test a realistic extension pattern
|
||||
func TestExample_RealisticExtension(t *testing.T) {
|
||||
// This is an example of a realistic extension that:
|
||||
// 1. Blocks dangerous tools
|
||||
// 2. Shows a status widget
|
||||
// 3. Logs tool calls
|
||||
src := `package main
|
||||
|
||||
import "kit/ext"
|
||||
|
||||
var blockedTools = []string{"rm", "del", "remove"}
|
||||
|
||||
func Init(api ext.API) {
|
||||
api.OnToolCall(func(tc ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
|
||||
// Check if tool is blocked
|
||||
for _, blocked := range blockedTools {
|
||||
if tc.ToolName == blocked {
|
||||
ctx.PrintError("Tool " + tc.ToolName + " is blocked for safety")
|
||||
return &ext.ToolCallResult{Block: true, Reason: "safety block"}
|
||||
}
|
||||
}
|
||||
|
||||
// Log the tool call
|
||||
ctx.SetStatus("tool-logger:last", tc.ToolName, 10)
|
||||
return nil
|
||||
})
|
||||
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
ctx.SetWidget(ext.WidgetConfig{
|
||||
ID: "safety-status",
|
||||
Content: ext.WidgetContent{Text: "Safety: Active"},
|
||||
Style: ext.WidgetStyle{BorderColor: "#00ff00"},
|
||||
})
|
||||
})
|
||||
}
|
||||
`
|
||||
|
||||
harness := New(t)
|
||||
harness.LoadString(src, "safety-ext.go")
|
||||
|
||||
// Verify handlers are registered
|
||||
AssertHasHandlers(t, harness, extensions.ToolCall)
|
||||
AssertHasHandlers(t, harness, extensions.SessionStart)
|
||||
|
||||
// Test session start
|
||||
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify widget was set
|
||||
AssertWidgetSet(t, harness, "safety-status")
|
||||
AssertWidgetText(t, harness, "safety-status", "Safety: Active")
|
||||
|
||||
// Test allowed tool
|
||||
result, _ := harness.Emit(extensions.ToolCallEvent{ToolName: "read", Input: "{}"})
|
||||
AssertNotBlocked(t, result)
|
||||
|
||||
// Verify status was updated
|
||||
AssertStatusSet(t, harness, "tool-logger:last")
|
||||
AssertStatusText(t, harness, "tool-logger:last", "read")
|
||||
|
||||
// Test blocked tool
|
||||
result2, _ := harness.Emit(extensions.ToolCallEvent{ToolName: "rm", Input: `{"file": "test.txt"}`})
|
||||
AssertBlocked(t, result2, "safety block")
|
||||
AssertPrintError(t, harness, "Tool rm is blocked for safety")
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
)
|
||||
|
||||
// MockContext records all interactions with the extension context.
|
||||
// It provides a Context object that captures Print calls, widget settings,
|
||||
// and other context operations for verification in tests.
|
||||
type MockContext struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
// Recorded calls
|
||||
Prints []string
|
||||
PrintInfos []string
|
||||
PrintErrors []string
|
||||
PrintBlocks []extensions.PrintBlockOpts
|
||||
Messages []string
|
||||
CancelSends []string
|
||||
|
||||
// Widget state
|
||||
Widgets map[string]extensions.WidgetConfig
|
||||
RemovedIDs []string
|
||||
Header *extensions.HeaderFooterConfig
|
||||
Footer *extensions.HeaderFooterConfig
|
||||
HeaderRemoved bool
|
||||
FooterRemoved bool
|
||||
|
||||
// Context properties
|
||||
SessionID string
|
||||
CWD string
|
||||
Model string
|
||||
Interactive bool
|
||||
|
||||
// UI visibility
|
||||
UIVisibility *extensions.UIVisibility
|
||||
|
||||
// Status entries
|
||||
StatusEntries map[string]extensions.StatusBarEntry
|
||||
RemovedStatus []string
|
||||
|
||||
// Editor
|
||||
EditorConfig *extensions.EditorConfig
|
||||
EditorReset bool
|
||||
EditorTexts []string
|
||||
|
||||
// Options
|
||||
Options map[string]string
|
||||
|
||||
// Prompt results (configurable for testing)
|
||||
PromptSelectResult extensions.PromptSelectResult
|
||||
PromptConfirmResult extensions.PromptConfirmResult
|
||||
PromptInputResult extensions.PromptInputResult
|
||||
PromptMultiSelectResult extensions.PromptMultiSelectResult
|
||||
|
||||
// Overlay
|
||||
Overlays []extensions.OverlayConfig
|
||||
}
|
||||
|
||||
// StatusBarEntry represents a recorded status bar entry
|
||||
type StatusBarEntry struct {
|
||||
Key string
|
||||
Text string
|
||||
Priority int
|
||||
}
|
||||
|
||||
// NewMockContext creates a new mock context with default values.
|
||||
func NewMockContext() *MockContext {
|
||||
return &MockContext{
|
||||
Prints: make([]string, 0),
|
||||
PrintInfos: make([]string, 0),
|
||||
PrintErrors: make([]string, 0),
|
||||
PrintBlocks: make([]extensions.PrintBlockOpts, 0),
|
||||
Messages: make([]string, 0),
|
||||
CancelSends: make([]string, 0),
|
||||
Widgets: make(map[string]extensions.WidgetConfig),
|
||||
RemovedIDs: make([]string, 0),
|
||||
StatusEntries: make(map[string]extensions.StatusBarEntry),
|
||||
RemovedStatus: make([]string, 0),
|
||||
EditorTexts: make([]string, 0),
|
||||
Options: make(map[string]string),
|
||||
Overlays: make([]extensions.OverlayConfig, 0),
|
||||
Interactive: true,
|
||||
SessionID: "test-session",
|
||||
CWD: "/test",
|
||||
Model: "test-model",
|
||||
}
|
||||
}
|
||||
|
||||
// ToContext returns a extensions.Context wired to record all interactions.
|
||||
func (m *MockContext) ToContext() extensions.Context {
|
||||
return extensions.Context{
|
||||
SessionID: m.SessionID,
|
||||
CWD: m.CWD,
|
||||
Model: m.Model,
|
||||
Interactive: m.Interactive,
|
||||
Print: m.recordPrint,
|
||||
PrintInfo: m.recordPrintInfo,
|
||||
PrintError: m.recordPrintError,
|
||||
PrintBlock: m.recordPrintBlock,
|
||||
SendMessage: m.recordSendMessage,
|
||||
CancelAndSend: m.recordCancelAndSend,
|
||||
SetWidget: m.recordSetWidget,
|
||||
RemoveWidget: m.recordRemoveWidget,
|
||||
SetHeader: m.recordSetHeader,
|
||||
RemoveHeader: m.recordRemoveHeader,
|
||||
SetFooter: m.recordSetFooter,
|
||||
RemoveFooter: m.recordRemoveFooter,
|
||||
PromptSelect: m.recordPromptSelect,
|
||||
PromptConfirm: m.recordPromptConfirm,
|
||||
PromptInput: m.recordPromptInput,
|
||||
PromptMultiSelect: m.recordPromptMultiSelect,
|
||||
SetEditor: m.recordSetEditor,
|
||||
ResetEditor: m.recordResetEditor,
|
||||
SetEditorText: m.recordSetEditorText,
|
||||
SetUIVisibility: m.recordUIVisibility,
|
||||
GetContextStats: m.getContextStats,
|
||||
GetMessages: m.getMessages,
|
||||
GetSessionPath: m.getSessionPath,
|
||||
AppendEntry: m.appendEntry,
|
||||
GetEntries: m.getEntries,
|
||||
SetStatus: m.recordSetStatus,
|
||||
RemoveStatus: m.recordRemoveStatus,
|
||||
GetOption: m.getOption,
|
||||
SetOption: m.setOption,
|
||||
SetModel: m.setModel,
|
||||
GetAllTools: m.getAllTools,
|
||||
SetActiveTools: m.setActiveTools,
|
||||
Exit: m.exit,
|
||||
Complete: m.complete,
|
||||
SuspendTUI: m.suspendTUI,
|
||||
RenderMessage: m.renderMessage,
|
||||
RegisterTheme: m.registerTheme,
|
||||
SetTheme: m.setTheme,
|
||||
ListThemes: m.listThemes,
|
||||
ReloadExtensions: m.reloadExtensions,
|
||||
SpawnSubagent: m.spawnSubagent,
|
||||
ShowOverlay: m.showOverlay,
|
||||
}
|
||||
}
|
||||
|
||||
// Record methods
|
||||
|
||||
func (m *MockContext) recordPrint(text string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.Prints = append(m.Prints, text)
|
||||
}
|
||||
|
||||
func (m *MockContext) recordPrintInfo(text string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.PrintInfos = append(m.PrintInfos, text)
|
||||
}
|
||||
|
||||
func (m *MockContext) recordPrintError(text string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.PrintErrors = append(m.PrintErrors, text)
|
||||
}
|
||||
|
||||
func (m *MockContext) recordPrintBlock(opts extensions.PrintBlockOpts) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.PrintBlocks = append(m.PrintBlocks, opts)
|
||||
}
|
||||
|
||||
func (m *MockContext) recordSendMessage(text string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.Messages = append(m.Messages, text)
|
||||
}
|
||||
|
||||
func (m *MockContext) recordCancelAndSend(text string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.CancelSends = append(m.CancelSends, text)
|
||||
}
|
||||
|
||||
func (m *MockContext) recordSetWidget(config extensions.WidgetConfig) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.Widgets[config.ID] = config
|
||||
}
|
||||
|
||||
func (m *MockContext) recordRemoveWidget(id string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.Widgets, id)
|
||||
m.RemovedIDs = append(m.RemovedIDs, id)
|
||||
}
|
||||
|
||||
func (m *MockContext) recordSetHeader(config extensions.HeaderFooterConfig) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.Header = &config
|
||||
}
|
||||
|
||||
func (m *MockContext) recordRemoveHeader() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.Header = nil
|
||||
m.HeaderRemoved = true
|
||||
}
|
||||
|
||||
func (m *MockContext) recordSetFooter(config extensions.HeaderFooterConfig) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.Footer = &config
|
||||
}
|
||||
|
||||
func (m *MockContext) recordRemoveFooter() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.Footer = nil
|
||||
m.FooterRemoved = true
|
||||
}
|
||||
|
||||
func (m *MockContext) recordSetStatus(key string, text string, priority int) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.StatusEntries[key] = extensions.StatusBarEntry{
|
||||
Key: key,
|
||||
Text: text,
|
||||
Priority: priority,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockContext) recordRemoveStatus(key string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.StatusEntries, key)
|
||||
m.RemovedStatus = append(m.RemovedStatus, key)
|
||||
}
|
||||
|
||||
func (m *MockContext) recordSetEditor(config extensions.EditorConfig) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.EditorConfig = &config
|
||||
}
|
||||
|
||||
func (m *MockContext) recordResetEditor() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.EditorReset = true
|
||||
m.EditorConfig = nil
|
||||
}
|
||||
|
||||
func (m *MockContext) recordSetEditorText(text string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.EditorTexts = append(m.EditorTexts, text)
|
||||
}
|
||||
|
||||
func (m *MockContext) recordUIVisibility(vis extensions.UIVisibility) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.UIVisibility = &vis
|
||||
}
|
||||
|
||||
func (m *MockContext) recordPromptSelect(config extensions.PromptSelectConfig) extensions.PromptSelectResult {
|
||||
// Return the configured result (tests can set this)
|
||||
return m.PromptSelectResult
|
||||
}
|
||||
|
||||
func (m *MockContext) recordPromptConfirm(config extensions.PromptConfirmConfig) extensions.PromptConfirmResult {
|
||||
return m.PromptConfirmResult
|
||||
}
|
||||
|
||||
func (m *MockContext) recordPromptInput(config extensions.PromptInputConfig) extensions.PromptInputResult {
|
||||
return m.PromptInputResult
|
||||
}
|
||||
|
||||
func (m *MockContext) recordPromptMultiSelect(config extensions.PromptMultiSelectConfig) extensions.PromptMultiSelectResult {
|
||||
return m.PromptMultiSelectResult
|
||||
}
|
||||
|
||||
func (m *MockContext) showOverlay(config extensions.OverlayConfig) extensions.OverlayResult {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.Overlays = append(m.Overlays, config)
|
||||
return extensions.OverlayResult{Cancelled: true} // Default to cancelled for tests
|
||||
}
|
||||
|
||||
// Stub methods that do nothing or return defaults
|
||||
|
||||
func (m *MockContext) getContextStats() extensions.ContextStats {
|
||||
return extensions.ContextStats{
|
||||
EstimatedTokens: 1000,
|
||||
ContextLimit: 200000,
|
||||
UsagePercent: 0.5,
|
||||
MessageCount: 10,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockContext) getMessages() []extensions.SessionMessage {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockContext) getSessionPath() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *MockContext) appendEntry(entryType string, data string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (m *MockContext) getEntries(entryType string) []extensions.ExtensionEntry {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockContext) getOption(name string) string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.Options[name]
|
||||
}
|
||||
|
||||
func (m *MockContext) setOption(name string, value string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.Options[name] = value
|
||||
}
|
||||
|
||||
func (m *MockContext) setModel(modelString string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockContext) getAllTools() []extensions.ToolInfo {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockContext) setActiveTools(names []string) {}
|
||||
|
||||
func (m *MockContext) exit() {}
|
||||
|
||||
func (m *MockContext) complete(req extensions.CompleteRequest) (extensions.CompleteResponse, error) {
|
||||
return extensions.CompleteResponse{}, nil
|
||||
}
|
||||
|
||||
func (m *MockContext) suspendTUI(callback func()) error {
|
||||
callback()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockContext) renderMessage(rendererName string, content string) {}
|
||||
|
||||
func (m *MockContext) registerTheme(name string, config extensions.ThemeColorConfig) {}
|
||||
|
||||
func (m *MockContext) setTheme(name string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockContext) listThemes() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockContext) reloadExtensions() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockContext) spawnSubagent(config extensions.SubagentConfig) (*extensions.SubagentHandle, *extensions.SubagentResult, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
// Accessor methods for verification
|
||||
|
||||
// GetPrints returns all recorded Print calls.
|
||||
func (m *MockContext) GetPrints() []string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
result := make([]string, len(m.Prints))
|
||||
copy(result, m.Prints)
|
||||
return result
|
||||
}
|
||||
|
||||
// GetPrintInfos returns all recorded PrintInfo calls.
|
||||
func (m *MockContext) GetPrintInfos() []string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
result := make([]string, len(m.PrintInfos))
|
||||
copy(result, m.PrintInfos)
|
||||
return result
|
||||
}
|
||||
|
||||
// GetPrintErrors returns all recorded PrintError calls.
|
||||
func (m *MockContext) GetPrintErrors() []string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
result := make([]string, len(m.PrintErrors))
|
||||
copy(result, m.PrintErrors)
|
||||
return result
|
||||
}
|
||||
|
||||
// GetWidget returns a recorded widget by ID.
|
||||
func (m *MockContext) GetWidget(id string) (extensions.WidgetConfig, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
w, ok := m.Widgets[id]
|
||||
return w, ok
|
||||
}
|
||||
|
||||
// HasWidget reports whether a widget with the given ID was set.
|
||||
func (m *MockContext) HasWidget(id string) bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
_, ok := m.Widgets[id]
|
||||
return ok
|
||||
}
|
||||
|
||||
// GetHeader returns the recorded header configuration.
|
||||
func (m *MockContext) GetHeader() *extensions.HeaderFooterConfig {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.Header
|
||||
}
|
||||
|
||||
// GetFooter returns the recorded footer configuration.
|
||||
func (m *MockContext) GetFooter() *extensions.HeaderFooterConfig {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.Footer
|
||||
}
|
||||
|
||||
// GetStatus returns a recorded status entry by key.
|
||||
func (m *MockContext) GetStatus(key string) (extensions.StatusBarEntry, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
s, ok := m.StatusEntries[key]
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// SetPromptSelectResult configures the result returned by PromptSelect.
|
||||
func (m *MockContext) SetPromptSelectResult(result extensions.PromptSelectResult) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.PromptSelectResult = result
|
||||
}
|
||||
|
||||
// SetPromptConfirmResult configures the result returned by PromptConfirm.
|
||||
func (m *MockContext) SetPromptConfirmResult(result extensions.PromptConfirmResult) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.PromptConfirmResult = result
|
||||
}
|
||||
|
||||
// SetPromptInputResult configures the result returned by PromptInput.
|
||||
func (m *MockContext) SetPromptInputResult(result extensions.PromptInputResult) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.PromptInputResult = result
|
||||
}
|
||||
|
||||
// SetPromptMultiSelectResult configures the result returned by PromptMultiSelect.
|
||||
func (m *MockContext) SetPromptMultiSelectResult(result extensions.PromptMultiSelectResult) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.PromptMultiSelectResult = result
|
||||
}
|
||||
+104
-14
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"charm.land/fantasy"
|
||||
|
||||
"github.com/mark3labs/kit/internal/compaction"
|
||||
)
|
||||
|
||||
@@ -83,8 +85,10 @@ func (m *Kit) GetContextStats() ContextStats {
|
||||
// customInstructions is optional text appended to the summary prompt (e.g.
|
||||
// "Focus on the API design decisions"). Pass "" for the default prompt.
|
||||
//
|
||||
// After compaction, the tree session is cleared and replaced with the
|
||||
// compacted messages (summary + preserved recent messages).
|
||||
// Compaction is non-destructive: a CompactionEntry is appended to the session
|
||||
// tree recording the summary and the first kept entry ID. Old messages remain
|
||||
// on disk but are skipped when building the LLM context — the summary is
|
||||
// injected in their place.
|
||||
func (m *Kit) Compact(ctx context.Context, opts *CompactionOptions, customInstructions string) (*CompactionResult, error) {
|
||||
return m.compactInternal(ctx, opts, customInstructions, false)
|
||||
}
|
||||
@@ -112,7 +116,7 @@ func (m *Kit) compactInternal(ctx context.Context, opts *CompactionOptions, cust
|
||||
return nil, fmt.Errorf("cannot compact: need at least 2 messages")
|
||||
}
|
||||
|
||||
// Run before-compact hook — extensions can cancel compaction.
|
||||
// Run before-compact hook — extensions can cancel or provide a custom summary.
|
||||
if m.beforeCompact.hasHooks() {
|
||||
stats := m.GetContextStats()
|
||||
if hookResult := m.beforeCompact.run(BeforeCompactHook{
|
||||
@@ -121,17 +125,32 @@ func (m *Kit) compactInternal(ctx context.Context, opts *CompactionOptions, cust
|
||||
UsagePercent: stats.UsagePercent,
|
||||
MessageCount: stats.MessageCount,
|
||||
IsAutomatic: isAutomatic,
|
||||
}); hookResult != nil && hookResult.Cancel {
|
||||
reason := hookResult.Reason
|
||||
if reason == "" {
|
||||
reason = "compaction cancelled by extension"
|
||||
}); hookResult != nil {
|
||||
if hookResult.Cancel {
|
||||
reason := hookResult.Reason
|
||||
if reason == "" {
|
||||
reason = "compaction cancelled by extension"
|
||||
}
|
||||
return nil, fmt.Errorf("%s", reason)
|
||||
}
|
||||
return nil, fmt.Errorf("%s", reason)
|
||||
// Extension provided a custom summary — use it directly.
|
||||
if hookResult.Summary != "" {
|
||||
return m.applyCustomCompaction(hookResult.Summary, messages, opts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Carry forward file tracking from previous compaction.
|
||||
var prev *compaction.PreviousCompaction
|
||||
if lastCompaction := m.treeSession.GetLastCompaction(); lastCompaction != nil {
|
||||
prev = &compaction.PreviousCompaction{
|
||||
ReadFiles: lastCompaction.ReadFiles,
|
||||
ModifiedFiles: lastCompaction.ModifiedFiles,
|
||||
}
|
||||
}
|
||||
|
||||
model := m.agent.GetModel()
|
||||
result, newMessages, err := compaction.Compact(ctx, model, messages, *opts, customInstructions)
|
||||
result, _, err := compaction.Compact(ctx, model, messages, *opts, customInstructions, prev)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -139,11 +158,82 @@ func (m *Kit) compactInternal(ctx context.Context, opts *CompactionOptions, cust
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Replace the session contents with the compacted messages.
|
||||
// Reset the tree leaf and re-add the compacted messages.
|
||||
m.treeSession.ResetLeaf()
|
||||
if err := m.treeSession.AddFantasyMessages(newMessages); err != nil {
|
||||
return nil, fmt.Errorf("failed to persist compacted messages: %w", err)
|
||||
// Non-destructive: append a CompactionEntry to the session tree instead
|
||||
// of clearing and rewriting messages.
|
||||
entryIDs := m.treeSession.GetContextEntryIDs()
|
||||
firstKeptEntryID := ""
|
||||
if result.CutPoint >= 0 && result.CutPoint < len(entryIDs) {
|
||||
firstKeptEntryID = entryIDs[result.CutPoint]
|
||||
}
|
||||
|
||||
if _, err := m.treeSession.AppendCompaction(
|
||||
result.Summary,
|
||||
firstKeptEntryID,
|
||||
result.OriginalTokens,
|
||||
result.CompactedTokens,
|
||||
result.MessagesRemoved,
|
||||
result.ReadFiles,
|
||||
result.ModifiedFiles,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("failed to persist compaction entry: %w", err)
|
||||
}
|
||||
|
||||
m.events.emit(CompactionEvent{
|
||||
Summary: result.Summary,
|
||||
OriginalTokens: result.OriginalTokens,
|
||||
CompactedTokens: result.CompactedTokens,
|
||||
MessagesRemoved: result.MessagesRemoved,
|
||||
ReadFiles: result.ReadFiles,
|
||||
ModifiedFiles: result.ModifiedFiles,
|
||||
})
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// applyCustomCompaction handles compaction when an extension provides a
|
||||
// custom summary. It still determines the cut point and persists a
|
||||
// CompactionEntry.
|
||||
func (m *Kit) applyCustomCompaction(summary string, messages []fantasy.Message, opts *CompactionOptions) (*CompactionResult, error) {
|
||||
originalTokens := compaction.EstimateMessageTokens(messages)
|
||||
|
||||
cutPoint := compaction.FindCutPoint(messages, opts.KeepRecentTokens)
|
||||
if cutPoint == 0 {
|
||||
cutPoint = len(messages) - 1
|
||||
if cutPoint < 1 {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
entryIDs := m.treeSession.GetContextEntryIDs()
|
||||
firstKeptEntryID := ""
|
||||
if cutPoint >= 0 && cutPoint < len(entryIDs) {
|
||||
firstKeptEntryID = entryIDs[cutPoint]
|
||||
}
|
||||
|
||||
// Estimate new token count.
|
||||
summaryTokens := compaction.EstimateMessageTokens([]fantasy.Message{{
|
||||
Role: "system",
|
||||
Content: []fantasy.MessagePart{fantasy.TextPart{Text: summary}},
|
||||
}})
|
||||
recentTokens := compaction.EstimateMessageTokens(messages[cutPoint:])
|
||||
compactedTokens := summaryTokens + recentTokens
|
||||
|
||||
if _, err := m.treeSession.AppendCompaction(
|
||||
summary,
|
||||
firstKeptEntryID,
|
||||
originalTokens,
|
||||
compactedTokens,
|
||||
cutPoint,
|
||||
nil, nil, // no file tracking for custom summaries
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("failed to persist compaction entry: %w", err)
|
||||
}
|
||||
|
||||
result := &CompactionResult{
|
||||
Summary: summary,
|
||||
OriginalTokens: originalTokens,
|
||||
CompactedTokens: compactedTokens,
|
||||
MessagesRemoved: cutPoint,
|
||||
}
|
||||
|
||||
m.events.emit(CompactionEvent{
|
||||
|
||||
@@ -39,6 +39,9 @@ const (
|
||||
EventCompaction EventType = "compaction"
|
||||
// EventReasoningDelta fires for each streaming reasoning/thinking chunk.
|
||||
EventReasoningDelta EventType = "reasoning_delta"
|
||||
// EventToolOutput fires when a tool produces streaming output chunks.
|
||||
EventToolOutput EventType = "tool_output"
|
||||
EventStepUsage EventType = "step_usage"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -143,6 +146,17 @@ type ReasoningDeltaEvent struct {
|
||||
// EventType implements Event.
|
||||
func (e ReasoningDeltaEvent) EventType() EventType { return EventReasoningDelta }
|
||||
|
||||
// ToolOutputEvent fires when a tool produces streaming output chunks (e.g., bash output).
|
||||
type ToolOutputEvent struct {
|
||||
ToolCallID string
|
||||
ToolName string
|
||||
Chunk string
|
||||
IsStderr bool
|
||||
}
|
||||
|
||||
// EventType implements Event.
|
||||
func (e ToolOutputEvent) EventType() EventType { return EventToolOutput }
|
||||
|
||||
// MessageEndEvent fires when the assistant message is complete.
|
||||
type MessageEndEvent struct {
|
||||
Content string
|
||||
@@ -236,12 +250,27 @@ type ResponseEvent struct {
|
||||
// EventType implements Event.
|
||||
func (e ResponseEvent) EventType() EventType { return EventResponse }
|
||||
|
||||
// StepUsageEvent fires after each complete step in a multi-step agent turn,
|
||||
// carrying the token usage for that specific step. This enables real-time
|
||||
// cost tracking during long-running tool-calling conversations.
|
||||
type StepUsageEvent struct {
|
||||
InputTokens uint64
|
||||
OutputTokens uint64
|
||||
CacheReadTokens uint64
|
||||
CacheWriteTokens uint64
|
||||
}
|
||||
|
||||
// EventType implements Event.
|
||||
func (e StepUsageEvent) EventType() EventType { return EventStepUsage }
|
||||
|
||||
// CompactionEvent fires after a successful compaction.
|
||||
type CompactionEvent struct {
|
||||
Summary string
|
||||
OriginalTokens int
|
||||
CompactedTokens int
|
||||
MessagesRemoved int
|
||||
ReadFiles []string
|
||||
ModifiedFiles []string
|
||||
}
|
||||
|
||||
// EventType implements Event.
|
||||
@@ -320,6 +349,16 @@ func (m *Kit) OnToolResult(handler func(ToolResultEvent)) func() {
|
||||
})
|
||||
}
|
||||
|
||||
// OnToolOutput registers a handler that fires only for ToolOutputEvent
|
||||
// (streaming tool output chunks, e.g., from bash). Returns an unsubscribe function.
|
||||
func (m *Kit) OnToolOutput(handler func(ToolOutputEvent)) func() {
|
||||
return m.Subscribe(func(e Event) {
|
||||
if to, ok := e.(ToolOutputEvent); ok {
|
||||
handler(to)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// OnStreaming registers a handler that fires only for MessageUpdateEvent
|
||||
// (streaming text chunks). Returns an unsubscribe function.
|
||||
func (m *Kit) OnStreaming(handler func(MessageUpdateEvent)) func() {
|
||||
@@ -359,3 +398,78 @@ func (m *Kit) OnTurnEnd(handler func(TurnEndEvent)) func() {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subagent event subscriptions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// subagentListenerSet holds per-tool-call listeners for subagent events.
|
||||
type subagentListenerSet struct {
|
||||
mu sync.RWMutex
|
||||
listeners map[int]EventListener
|
||||
nextID int
|
||||
}
|
||||
|
||||
func newSubagentListenerSet() *subagentListenerSet {
|
||||
return &subagentListenerSet{listeners: make(map[int]EventListener)}
|
||||
}
|
||||
|
||||
func (s *subagentListenerSet) add(listener EventListener) func() {
|
||||
s.mu.Lock()
|
||||
id := s.nextID
|
||||
s.nextID++
|
||||
s.listeners[id] = listener
|
||||
s.mu.Unlock()
|
||||
return func() {
|
||||
s.mu.Lock()
|
||||
delete(s.listeners, id)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *subagentListenerSet) emit(event Event) {
|
||||
s.mu.RLock()
|
||||
snapshot := make([]EventListener, 0, len(s.listeners))
|
||||
for _, l := range s.listeners {
|
||||
snapshot = append(snapshot, l)
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
for _, l := range snapshot {
|
||||
l(event)
|
||||
}
|
||||
}
|
||||
|
||||
// SubscribeSubagent registers a listener for real-time events from a subagent
|
||||
// identified by its tool call ID. Returns an unsubscribe function.
|
||||
//
|
||||
// The listener receives the same event types as Subscribe() (ToolCallEvent,
|
||||
// MessageUpdateEvent, etc.) but scoped to the child agent's activity. If the
|
||||
// tool call ID doesn't correspond to an active or future spawn_subagent call,
|
||||
// the listener simply never fires.
|
||||
//
|
||||
// Typical usage — register inside an OnToolCall handler:
|
||||
//
|
||||
// kit.OnToolCall(func(e kit.ToolCallEvent) {
|
||||
// if e.ToolName == "spawn_subagent" {
|
||||
// kit.SubscribeSubagent(e.ToolCallID, func(child kit.Event) {
|
||||
// // real-time subagent events
|
||||
// })
|
||||
// }
|
||||
// })
|
||||
func (m *Kit) SubscribeSubagent(toolCallID string, listener EventListener) func() {
|
||||
actual, _ := m.subagentListeners.LoadOrStore(toolCallID, newSubagentListenerSet())
|
||||
return actual.(*subagentListenerSet).add(listener)
|
||||
}
|
||||
|
||||
// getSubagentListenerSet returns the listener set for a tool call, or nil.
|
||||
func (m *Kit) getSubagentListenerSet(toolCallID string) *subagentListenerSet {
|
||||
if v, ok := m.subagentListeners.Load(toolCallID); ok {
|
||||
return v.(*subagentListenerSet)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanupSubagentListeners removes the listener set for a completed tool call.
|
||||
func (m *Kit) cleanupSubagentListeners(toolCallID string) {
|
||||
m.subagentListeners.Delete(toolCallID)
|
||||
}
|
||||
|
||||
@@ -86,6 +86,20 @@ func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
|
||||
})
|
||||
}
|
||||
|
||||
// Tool output streaming events (observation only).
|
||||
if runner.HasHandlers(extensions.ToolOutput) {
|
||||
m.Subscribe(func(e Event) {
|
||||
if ev, ok := e.(ToolOutputEvent); ok {
|
||||
_, _ = runner.Emit(extensions.ToolOutputEvent{
|
||||
ToolCallID: ev.ToolCallID,
|
||||
ToolName: ev.ToolName,
|
||||
Chunk: ev.Chunk,
|
||||
IsStderr: ev.IsStderr,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if runner.HasHandlers(extensions.AgentEnd) {
|
||||
m.Subscribe(func(e Event) {
|
||||
if ev, ok := e.(TurnEndEvent); ok {
|
||||
@@ -173,10 +187,17 @@ func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
|
||||
MessageCount: h.MessageCount,
|
||||
IsAutomatic: h.IsAutomatic,
|
||||
})
|
||||
if r, ok := result.(extensions.BeforeCompactResult); ok && r.Cancel {
|
||||
return &BeforeCompactResult{
|
||||
Cancel: true,
|
||||
Reason: r.Reason,
|
||||
if r, ok := result.(extensions.BeforeCompactResult); ok {
|
||||
if r.Cancel {
|
||||
return &BeforeCompactResult{
|
||||
Cancel: true,
|
||||
Reason: r.Reason,
|
||||
}
|
||||
}
|
||||
if r.Summary != "" {
|
||||
return &BeforeCompactResult{
|
||||
Summary: r.Summary,
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
+7
-1
@@ -107,12 +107,18 @@ type BeforeCompactHook struct {
|
||||
IsAutomatic bool
|
||||
}
|
||||
|
||||
// BeforeCompactResult controls whether compaction proceeds.
|
||||
// BeforeCompactResult controls whether compaction proceeds. Extensions can
|
||||
// cancel compaction or provide a custom summary that replaces the default
|
||||
// LLM-generated one.
|
||||
type BeforeCompactResult struct {
|
||||
// Cancel, when true, prevents compaction from proceeding.
|
||||
Cancel bool
|
||||
// Reason is a human-readable explanation when Cancel is true.
|
||||
Reason string
|
||||
// Summary, when non-empty, replaces the default LLM-generated summary.
|
||||
// The extension is responsible for generating a useful summary.
|
||||
// Ignored when Cancel is true.
|
||||
Summary string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+74
-6
@@ -62,6 +62,10 @@ type Kit struct {
|
||||
// tool definitions, etc.
|
||||
lastInputTokensMu sync.RWMutex
|
||||
lastInputTokens int
|
||||
|
||||
// subagentListeners holds per-tool-call event listeners registered via
|
||||
// SubscribeSubagent(). Keyed by toolCallID → *subagentListenerSet.
|
||||
subagentListeners sync.Map
|
||||
}
|
||||
|
||||
// Subscribe registers an EventListener that will be called for every lifecycle
|
||||
@@ -913,8 +917,12 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
|
||||
setSDKDefaults()
|
||||
|
||||
// Initialize config (loads config files and env vars).
|
||||
if err := InitConfig(opts.ConfigFile, false); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize config: %w", err)
|
||||
// Only initialize if not already done (e.g., by CLI's cobra.OnInitialize).
|
||||
// Check if model is already set, which indicates config was loaded.
|
||||
if viper.GetString("model") == "" {
|
||||
if err := InitConfig(opts.ConfigFile, false); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle CLI debug mode.
|
||||
@@ -1401,17 +1409,23 @@ func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent.
|
||||
// spawn_subagent core tool can create child Kit instances without
|
||||
// importing pkg/kit (which would create an import cycle).
|
||||
ctx = core.WithSubagentSpawner(ctx, func(
|
||||
spawnCtx context.Context, prompt, model, systemPrompt string, timeout time.Duration,
|
||||
spawnCtx context.Context, toolCallID, prompt, model, systemPrompt string, timeout time.Duration,
|
||||
) (*core.SubagentSpawnResult, error) {
|
||||
// Build OnEvent: dispatch to per-tool-call listeners if any are
|
||||
// registered via SubscribeSubagent(). Listeners are cleaned up
|
||||
// after the subagent completes.
|
||||
var onEvent func(Event)
|
||||
if listeners := m.getSubagentListenerSet(toolCallID); listeners != nil {
|
||||
onEvent = listeners.emit
|
||||
}
|
||||
result, err := m.Subagent(spawnCtx, SubagentConfig{
|
||||
Prompt: prompt,
|
||||
Model: model,
|
||||
SystemPrompt: systemPrompt,
|
||||
Timeout: timeout,
|
||||
OnEvent: func(e Event) {
|
||||
m.events.emit(e)
|
||||
},
|
||||
OnEvent: onEvent,
|
||||
})
|
||||
m.cleanupSubagentListeners(toolCallID)
|
||||
if result == nil {
|
||||
return &core.SubagentSpawnResult{Error: err}, err
|
||||
}
|
||||
@@ -1468,6 +1482,24 @@ func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent.
|
||||
func(delta string) {
|
||||
m.events.emit(ReasoningDeltaEvent{Delta: delta})
|
||||
},
|
||||
func(toolCallID, toolName, chunk string, isStderr bool) {
|
||||
// Emit tool output chunk event for streaming bash output
|
||||
m.events.emit(ToolOutputEvent{
|
||||
ToolCallID: toolCallID,
|
||||
ToolName: toolName,
|
||||
Chunk: chunk,
|
||||
IsStderr: isStderr,
|
||||
})
|
||||
},
|
||||
func(inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens int64) {
|
||||
// Emit step usage event for real-time cost tracking
|
||||
m.events.emit(StepUsageEvent{
|
||||
InputTokens: uint64(inputTokens),
|
||||
OutputTokens: uint64(outputTokens),
|
||||
CacheReadTokens: uint64(cacheReadTokens),
|
||||
CacheWriteTokens: uint64(cacheCreationTokens),
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1554,6 +1586,18 @@ func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, pr
|
||||
|
||||
result, err := m.generate(ctx, messages)
|
||||
if err != nil {
|
||||
// Persist any messages from completed steps (tool call/result
|
||||
// pairs) so partial progress is not lost. The agent layer only
|
||||
// includes fully-paired tool_use + tool_result messages in
|
||||
// completedStepMessages, so there are no orphaned entries that
|
||||
// would break subsequent API requests. The user message and any
|
||||
// completed work remain in the session; only the in-progress
|
||||
// (pending) message or tool call is discarded.
|
||||
if result != nil && len(result.ConversationMessages) > sentCount {
|
||||
for _, msg := range result.ConversationMessages[sentCount:] {
|
||||
_, _ = m.treeSession.AppendFantasyMessage(msg)
|
||||
}
|
||||
}
|
||||
m.events.emit(TurnEndEvent{Error: err})
|
||||
// Run AfterTurn hooks even on error.
|
||||
if m.afterTurn.hasHooks() {
|
||||
@@ -1752,6 +1796,30 @@ func (m *Kit) PromptResultWithFiles(ctx context.Context, message string, files [
|
||||
})
|
||||
}
|
||||
|
||||
// PromptResultWithMessages submits multiple user messages in a single turn.
|
||||
// All messages are persisted to the session and sent to the agent together.
|
||||
// The agent will respond once to the combined context of all messages.
|
||||
// Returns the full turn result including usage statistics and conversation messages.
|
||||
func (m *Kit) PromptResultWithMessages(ctx context.Context, messages []string) (*TurnResult, error) {
|
||||
if len(messages) == 0 {
|
||||
return nil, fmt.Errorf("no messages provided")
|
||||
}
|
||||
|
||||
// Build prompt label from all messages
|
||||
promptLabel := strings.Join(messages, " | ")
|
||||
if len(promptLabel) > 100 {
|
||||
promptLabel = promptLabel[:100] + "..."
|
||||
}
|
||||
|
||||
// Build fantasy messages from all strings
|
||||
var preMessages []fantasy.Message
|
||||
for _, msg := range messages {
|
||||
preMessages = append(preMessages, fantasy.NewUserMessage(msg))
|
||||
}
|
||||
|
||||
return m.runTurn(ctx, promptLabel, messages[len(messages)-1], preMessages)
|
||||
}
|
||||
|
||||
// ClearSession resets the tree session's leaf pointer to the root, starting
|
||||
// a fresh conversation branch.
|
||||
func (m *Kit) ClearSession() {
|
||||
|
||||
+4
-1
@@ -16,7 +16,10 @@ func TestNew(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Test default initialization
|
||||
host, err := kit.New(ctx, nil)
|
||||
opts := &kit.Options{
|
||||
Model: "anthropic/claude-sonnet-4-5-20250929",
|
||||
}
|
||||
host, err := kit.New(ctx, opts)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create Kit with defaults: %v", err)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,12 @@ func DeleteSession(path string) error {
|
||||
return session.DeleteSession(path)
|
||||
}
|
||||
|
||||
// OpenTreeSession opens an existing JSONL session file. This is a package-level
|
||||
// function (no Kit instance required) used by the CLI for session switching.
|
||||
func OpenTreeSession(path string) (*TreeManager, error) {
|
||||
return session.OpenTreeSession(path)
|
||||
}
|
||||
|
||||
// --- Instance methods on Kit ---
|
||||
|
||||
// GetTreeSession returns the tree session manager, or nil if not configured.
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"btca-cli": {
|
||||
"source": "davis7dotsh/better-context",
|
||||
"sourceType": "github",
|
||||
"computedHash": "99bc5301f4f839a6f3be99d98955f32f1cd576c218731fa05fa54a003bd20e9b"
|
||||
},
|
||||
"kit-extensions": {
|
||||
"source": "mark3labs/kit",
|
||||
"sourceType": "github",
|
||||
"computedHash": "9347a88bec46dd52727a672b6c8d058955f9f50dfe98708e0c63b85e0779ba96"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,9 @@ description: Guide for creating Kit extensions. Use when the user asks to build,
|
||||
|
||||
# Kit Extensions Development Guide
|
||||
|
||||
Kit extensions are single-file Go programs interpreted at runtime by Yaegi. They hook into Kit's lifecycle, register custom tools and slash commands, display widgets, intercept editor input, render tool output, and more.
|
||||
Kit extensions are single-file Go programs interpreted at runtime by Yaegi. They hook into Kit's lifecycle, register custom tools and slash commands, display widgets, intercept editor input, render tool output, register and switch color themes, and more.
|
||||
|
||||
Extensions can be distributed via git repositories using `kit install`. Repos can contain single extensions or collections of multiple extensions.
|
||||
|
||||
## Extension Structure
|
||||
|
||||
@@ -414,6 +416,17 @@ result := ctx.PromptInput(ext.PromptInputConfig{
|
||||
if !result.Cancelled {
|
||||
// result.Value string
|
||||
}
|
||||
|
||||
// Multi-select (toggle with spacebar, confirm with enter)
|
||||
result := ctx.PromptMultiSelect(ext.PromptMultiSelectConfig{
|
||||
Message: "Select extensions to install:",
|
||||
Options: []string{"git", "todo", "weather"},
|
||||
DefaultSelected: []int{0, 1, 2}, // pre-selected indices; nil = all selected
|
||||
})
|
||||
if !result.Cancelled {
|
||||
// result.Values []string — selected option texts
|
||||
// result.Indices []int — selected option indices
|
||||
}
|
||||
```
|
||||
|
||||
### Overlay Dialogs
|
||||
@@ -529,6 +542,64 @@ ctx.SuspendTUI(func() {
|
||||
})
|
||||
```
|
||||
|
||||
### Themes
|
||||
|
||||
Register, switch, and list color themes at runtime:
|
||||
|
||||
```go
|
||||
// Register a custom theme (empty fields inherit from default).
|
||||
ctx.RegisterTheme("neon", ext.ThemeColorConfig{
|
||||
Primary: ext.ThemeColor{Light: "#CC00FF", Dark: "#FF00FF"},
|
||||
Secondary: ext.ThemeColor{Light: "#0088CC", Dark: "#00FFFF"},
|
||||
Success: ext.ThemeColor{Light: "#00CC44", Dark: "#00FF66"},
|
||||
Warning: ext.ThemeColor{Light: "#CCAA00", Dark: "#FFFF00"},
|
||||
Error: ext.ThemeColor{Light: "#CC0033", Dark: "#FF0055"},
|
||||
Info: ext.ThemeColor{Light: "#0088CC", Dark: "#00CCFF"},
|
||||
Text: ext.ThemeColor{Light: "#111111", Dark: "#F0F0F0"},
|
||||
Background: ext.ThemeColor{Light: "#F0F0F0", Dark: "#0A0A14"},
|
||||
MdKeyword: ext.ThemeColor{Light: "#CC00FF", Dark: "#FF00FF"},
|
||||
MdString: ext.ThemeColor{Light: "#00CC44", Dark: "#00FF66"},
|
||||
MdComment: ext.ThemeColor{Light: "#888888", Dark: "#555555"},
|
||||
})
|
||||
|
||||
// Switch to a theme by name (built-in, file-based, or extension-registered).
|
||||
err := ctx.SetTheme("neon")
|
||||
|
||||
// List all available theme names.
|
||||
names := ctx.ListThemes() // []string
|
||||
```
|
||||
|
||||
**ThemeColorConfig fields:**
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `Primary` | Main brand/accent color |
|
||||
| `Secondary` | Secondary accent |
|
||||
| `Success` | Success states |
|
||||
| `Warning` | Warning states |
|
||||
| `Error` | Error/critical states |
|
||||
| `Info` | Informational states |
|
||||
| `Text` | Primary text |
|
||||
| `Muted` | Dimmed text |
|
||||
| `VeryMuted` | Very dimmed text |
|
||||
| `Background` | Base background |
|
||||
| `Border` | Panel borders |
|
||||
| `MutedBorder` | Subtle dividers |
|
||||
| `System` | System messages |
|
||||
| `Tool` | Tool-related elements |
|
||||
| `Accent` | Secondary highlight |
|
||||
| `Highlight` | Highlighted regions |
|
||||
| `MdHeading` | Markdown headings |
|
||||
| `MdLink` | Markdown links |
|
||||
| `MdKeyword` | Syntax: keywords |
|
||||
| `MdString` | Syntax: strings |
|
||||
| `MdNumber` | Syntax: numbers |
|
||||
| `MdComment` | Syntax: comments |
|
||||
|
||||
Each field is an `ext.ThemeColor` with `Light` and `Dark` hex strings. Kit ships 22 built-in themes: `kitt`, `catppuccin`, `dracula`, `tokyonight`, `nord`, `gruvbox`, `monokai`, `solarized`, `github`, `one-dark`, `rose-pine`, `ayu`, `material`, `everforest`, `kanagawa`, `amoled`, `synthwave`, `vesper`, `flexoki`, `matrix`, `vercel`, `zenburn`.
|
||||
|
||||
Users can also drop `.yml`/`.yaml`/`.json` theme files in `~/.config/kit/themes/` (global) or `.kit/themes/` (project-local). Extension-registered themes take highest precedence.
|
||||
|
||||
### Application Control
|
||||
|
||||
```go
|
||||
@@ -736,20 +807,164 @@ api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern: Custom Theme with Slash Command
|
||||
|
||||
Register a theme and provide a slash command shortcut to activate it:
|
||||
|
||||
```go
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
ctx.RegisterTheme("neon", ext.ThemeColorConfig{
|
||||
Primary: ext.ThemeColor{Light: "#CC00FF", Dark: "#FF00FF"},
|
||||
Secondary: ext.ThemeColor{Light: "#0088CC", Dark: "#00FFFF"},
|
||||
Success: ext.ThemeColor{Light: "#00CC44", Dark: "#00FF66"},
|
||||
Warning: ext.ThemeColor{Light: "#CCAA00", Dark: "#FFFF00"},
|
||||
Error: ext.ThemeColor{Light: "#CC0033", Dark: "#FF0055"},
|
||||
Info: ext.ThemeColor{Light: "#0088CC", Dark: "#00CCFF"},
|
||||
Text: ext.ThemeColor{Light: "#111111", Dark: "#F0F0F0"},
|
||||
Background: ext.ThemeColor{Light: "#F0F0F0", Dark: "#0A0A14"},
|
||||
})
|
||||
})
|
||||
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "neon",
|
||||
Description: "Switch to the neon cyberpunk theme",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
if err := ctx.SetTheme("neon"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "Neon theme activated!", nil
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern: Spawning Kit as a Sub-Agent
|
||||
|
||||
Extensions can spawn Kit as a subprocess for delegation:
|
||||
Use `ctx.SpawnSubagent` to spawn an isolated child Kit instance. The subagent runs as a subprocess with `--json --no-extensions` flags, ensuring isolation.
|
||||
|
||||
**Blocking mode** — waits for completion:
|
||||
|
||||
```go
|
||||
_, result, err := ctx.SpawnSubagent(ext.SubagentConfig{
|
||||
Prompt: "Analyze the test files and summarize coverage",
|
||||
Model: "anthropic/claude-haiku-3-5-20241022", // empty = parent's model
|
||||
SystemPrompt: "You are a test analysis expert.",
|
||||
Timeout: 2 * time.Minute, // 0 = 5 minute default
|
||||
Blocking: true,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.PrintError("spawn failed: " + err.Error())
|
||||
return
|
||||
}
|
||||
if result.Error != nil {
|
||||
ctx.PrintError("subagent failed: " + result.Error.Error())
|
||||
return
|
||||
}
|
||||
ctx.PrintInfo("Result:\n" + result.Response)
|
||||
// result.Elapsed, result.ExitCode, result.SessionID
|
||||
// result.Usage.InputTokens, result.Usage.OutputTokens (if available)
|
||||
```
|
||||
|
||||
**Background mode** — returns immediately with a handle:
|
||||
|
||||
```go
|
||||
handle, _, err := ctx.SpawnSubagent(ext.SubagentConfig{
|
||||
Prompt: "Write unit tests for UserService",
|
||||
OnOutput: func(chunk string) {
|
||||
// Live stderr streaming (progress, tool calls, etc.)
|
||||
},
|
||||
OnEvent: func(event ext.SubagentEvent) {
|
||||
// Real-time events: "text", "reasoning", "tool_call",
|
||||
// "tool_result", "tool_execution_start", "tool_execution_end",
|
||||
// "turn_start", "turn_end"
|
||||
// event.Type, event.Content, event.ToolName, event.ToolArgs, etc.
|
||||
},
|
||||
OnComplete: func(result ext.SubagentResult) {
|
||||
ctx.SendMessage("Subagent finished:\n" + result.Response)
|
||||
},
|
||||
})
|
||||
// handle.Kill() — terminate the subagent
|
||||
// handle.Wait() — block until completion, returns SubagentResult
|
||||
// <-handle.Done() — channel that closes on completion
|
||||
```
|
||||
|
||||
**SubagentConfig fields:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `Prompt` | string | Task instruction (required) |
|
||||
| `Model` | string | Override model ("provider/model"), empty = parent's |
|
||||
| `SystemPrompt` | string | Custom system prompt, empty = default |
|
||||
| `Timeout` | time.Duration | Execution limit, 0 = 5 minutes |
|
||||
| `Blocking` | bool | Wait for completion vs return handle |
|
||||
| `NoSession` | bool | Don't persist subagent session file |
|
||||
| `ParentSessionID` | string | Link to parent session (optional) |
|
||||
| `OnOutput` | func(string) | Stderr streaming callback |
|
||||
| `OnEvent` | func(SubagentEvent) | Real-time event callback |
|
||||
| `OnComplete` | func(SubagentResult) | Completion callback |
|
||||
|
||||
**SubagentResult fields:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `Response` | string | Final text response |
|
||||
| `Error` | error | Non-nil on failure |
|
||||
| `ExitCode` | int | Process exit code (0 = success) |
|
||||
| `Elapsed` | time.Duration | Total execution time |
|
||||
| `Usage` | *SubagentUsage | Token usage (InputTokens, OutputTokens) |
|
||||
| `SessionID` | string | Subagent's session ID (if persisted) |
|
||||
|
||||
You can also spawn Kit as a raw subprocess for simpler cases:
|
||||
|
||||
```bash
|
||||
kit --quiet --no-session --no-extensions --system-prompt "You are a reviewer" --model anthropic/claude-sonnet-4-20250514 "Review this code"
|
||||
```
|
||||
|
||||
Key flags: `--quiet` (stdout only, no TUI), `--no-session` (ephemeral), `--no-extensions` (prevent recursion), `--system-prompt` (string or file path).
|
||||
|
||||
---
|
||||
|
||||
## Testing Extensions
|
||||
|
||||
Kit provides a testing package to help you write unit tests for your extensions:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"github.com/mark3labs/kit/pkg/extensions/test"
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
)
|
||||
|
||||
func TestMyExtension(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("my-ext.go")
|
||||
|
||||
// Test event handlers
|
||||
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify behavior with assertions
|
||||
test.AssertPrinted(t, harness, "session started")
|
||||
test.AssertWidgetSet(t, harness, "my-widget")
|
||||
|
||||
// Test tool blocking
|
||||
result, _ := harness.Emit(extensions.ToolCallEvent{ToolName: "dangerous"})
|
||||
test.AssertBlocked(t, result, "not allowed")
|
||||
}
|
||||
```
|
||||
|
||||
**Key testing patterns:**
|
||||
- Load extensions with `LoadFile()` or `LoadString()` for inline code
|
||||
- Emit events with `Emit()` to trigger handlers
|
||||
- Verify with 25+ assertion helpers: `AssertWidgetSet()`, `AssertToolRegistered()`, `AssertPrintInfo()`, etc.
|
||||
- Mock prompts by setting results on `harness.Context().SetPromptSelectResult()`
|
||||
- Test multiple scenarios per extension with isolated harness instances
|
||||
|
||||
See `examples/extensions/tool-logger_test.go` for a complete example with 14 test cases.
|
||||
|
||||
### CLI Testing Commands
|
||||
|
||||
```bash
|
||||
# Validate syntax of all discovered extensions
|
||||
kit extensions validate
|
||||
@@ -772,6 +987,157 @@ kit extensions init
|
||||
|
||||
---
|
||||
|
||||
## Distributing Extensions via Git Repositories
|
||||
|
||||
Extensions can be distributed and installed from git repositories using `kit install`. This enables sharing extensions with others and maintaining versioned collections.
|
||||
|
||||
### Repository Structure
|
||||
|
||||
Extensions support two organization patterns within a repo:
|
||||
|
||||
**Single-file extensions** (simple, standalone):
|
||||
```
|
||||
my-extension-repo/
|
||||
├── weather.go # Single extension file
|
||||
├── todo.go # Another extension
|
||||
└── README.md # Installation and usage docs
|
||||
```
|
||||
|
||||
**Multi-file extensions** (with `main.go` entry point):
|
||||
```
|
||||
my-extension-repo/
|
||||
├── git-tools/
|
||||
│ ├── main.go # Entry point
|
||||
│ ├── helpers.go # Supporting code
|
||||
│ └── config.go # Configuration
|
||||
├── todo/
|
||||
│ ├── main.go # Entry point
|
||||
│ └── storage.go # Storage logic
|
||||
└── README.md
|
||||
```
|
||||
|
||||
**Hybrid approach** (single files + subdirectories with main.go):
|
||||
```
|
||||
my-extensions/
|
||||
├── weather.go # Single file extension
|
||||
├── calculator.go # Single file extension
|
||||
├── git-tools/
|
||||
│ ├── main.go # Multi-file extension
|
||||
│ └── utils.go
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### Installing from Git
|
||||
|
||||
Users install extensions using the `kit install` command:
|
||||
|
||||
```bash
|
||||
# Install from GitHub (latest)
|
||||
kit install github.com/user/repo
|
||||
|
||||
# Pin to a specific version/tag
|
||||
kit install github.com/user/repo@v1.0.0
|
||||
kit install github.com/user/repo@main
|
||||
kit install github.com/user/repo@abc1234
|
||||
|
||||
# Install locally in project (./.kit/git/)
|
||||
kit install github.com/user/repo --local
|
||||
|
||||
# Interactive selection for repos with multiple extensions
|
||||
kit install github.com/user/collection --select
|
||||
```
|
||||
|
||||
Supported URL formats:
|
||||
- `github.com/user/repo` — Shorthand (defaults to HTTPS)
|
||||
- `git:github.com/user/repo` — Git prefix format
|
||||
- `https://github.com/user/repo` — HTTPS URL
|
||||
- `ssh://git@github.com/user/repo` — SSH URL
|
||||
- `git@github.com:user/repo` — SSH shorthand
|
||||
|
||||
### Managing Installed Extensions
|
||||
|
||||
```bash
|
||||
# Update an installed extension (skips pinned versions)
|
||||
kit install github.com/user/repo --update
|
||||
|
||||
# Remove an installed extension
|
||||
kit install github.com/user/repo --uninstall
|
||||
|
||||
# List all loaded extensions
|
||||
kit extensions list
|
||||
|
||||
# Validate all extensions
|
||||
kit extensions validate
|
||||
```
|
||||
|
||||
### Extension Selection
|
||||
|
||||
For repos containing multiple extensions, users can select which to install:
|
||||
|
||||
```bash
|
||||
# Interactive selection
|
||||
kit install github.com/user/collection --select
|
||||
```
|
||||
|
||||
This prompts the user to choose which extensions to install. Selected extensions are recorded in the manifest, and only those are loaded at runtime (others in the repo are ignored).
|
||||
|
||||
### README Template for Extension Repos
|
||||
|
||||
Include this in your extension repo's README.md:
|
||||
|
||||
```markdown
|
||||
# My Kit Extensions
|
||||
|
||||
A collection of extensions for [Kit](https://github.com/mark3labs/kit).
|
||||
|
||||
## Installation
|
||||
|
||||
### Install all extensions
|
||||
\`\`\`bash
|
||||
kit install github.com/username/repo
|
||||
\`\`\`
|
||||
|
||||
### Install specific extensions
|
||||
\`\`\`bash
|
||||
kit install github.com/username/repo --select
|
||||
\`\`\`
|
||||
|
||||
### Install locally in a project
|
||||
\`\`\`bash
|
||||
kit install github.com/username/repo --local
|
||||
\`\`\`
|
||||
|
||||
## Extensions
|
||||
|
||||
### Extension Name
|
||||
Description of what it does.
|
||||
|
||||
- **Path**: `./ext-name/main.go` or `./ext-name.go`
|
||||
- **Commands**: `/command-name`
|
||||
- **Tools**: `tool_name`
|
||||
|
||||
## Requirements
|
||||
|
||||
- Kit vX.Y.Z+
|
||||
- Any other dependencies
|
||||
|
||||
## Update
|
||||
|
||||
\`\`\`bash
|
||||
kit install github.com/username/repo --update
|
||||
\`\`\`
|
||||
```
|
||||
|
||||
### Storage Locations
|
||||
|
||||
Installed extensions are stored at:
|
||||
|
||||
- **Global**: `~/.local/share/kit/git/<host>/<owner>/<repo>/`
|
||||
- **Project-local**: `./.kit/git/<host>/<owner>/<repo>/`
|
||||
- **Manifest**: `packages.json` in respective directories
|
||||
|
||||
---
|
||||
|
||||
## Complete Example: Plan Mode
|
||||
|
||||
A full extension that restricts the agent to read-only tools, with a slash command, keyboard shortcut, option, status bar indicator, and system prompt injection:
|
||||
@@ -850,4 +1216,6 @@ func applyMode(ctx ext.Context, active bool, tools []string) {
|
||||
- [`internal/extensions/runner.go`](https://github.com/mark3labs/kit/blob/main/internal/extensions/runner.go) — Event dispatch and state management
|
||||
- [`internal/extensions/loader.go`](https://github.com/mark3labs/kit/blob/main/internal/extensions/loader.go) — Yaegi interpreter setup
|
||||
- [`internal/extensions/symbols.go`](https://github.com/mark3labs/kit/blob/main/internal/extensions/symbols.go) — All types exported to extensions
|
||||
- [`pkg/extensions/test/`](https://github.com/mark3labs/kit/tree/main/pkg/extensions/test) — Testing package with harness, mocks, and assertions
|
||||
- [`examples/extensions/tool-logger_test.go`](https://github.com/mark3labs/kit/blob/main/examples/extensions/tool-logger_test.go) — Complete test example
|
||||
- [`examples/extensions/`](https://github.com/mark3labs/kit/tree/main/examples/extensions) — 25+ working example extensions
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user