mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 66f2ba543b | |||
| 6dd052b990 | |||
| ef8628eecc | |||
| 3167222b72 | |||
| e3b37191b1 | |||
| 41d5f5e0fb | |||
| 3ad0b3616d | |||
| 8831b49b51 | |||
| c94edc929b | |||
| e49194a0d4 | |||
| 46b1acf444 | |||
| 6a6d201a50 | |||
| 930cbcb4f2 | |||
| 12e1ef2036 | |||
| a05da5f3ab | |||
| fefbf19b42 | |||
| 93905d4d77 | |||
| 7268ccdf4d | |||
| 9f59fa42dc | |||
| 8af7ca8455 | |||
| 424847f0db | |||
| 4c126ca41b | |||
| 4bdc4f75cc | |||
| bbd8975ca0 | |||
| e613a07773 | |||
| 1d3b4f8d56 | |||
| 118af2e152 | |||
| c46687fc44 | |||
| aeaa5368af | |||
| 4966c0ca2a | |||
| f3ea18ae3a | |||
| 24ea2c94e3 |
@@ -0,0 +1,853 @@
|
||||
---
|
||||
name: kit-extensions
|
||||
description: Guide for creating Kit extensions. Use when the user asks to build, create, or modify a Kit extension, add a custom tool, slash command, widget, keyboard shortcut, editor interceptor, tool renderer, or hook into any Kit lifecycle event.
|
||||
---
|
||||
|
||||
# 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.
|
||||
|
||||
## Extension Structure
|
||||
|
||||
Every extension must export a `package main` with an `Init(api ext.API)` function:
|
||||
|
||||
```go
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import "kit/ext"
|
||||
|
||||
func Init(api ext.API) {
|
||||
// Register event handlers, tools, commands, etc.
|
||||
}
|
||||
```
|
||||
|
||||
The `//go:build ignore` tag prevents `go build` from compiling the file directly.
|
||||
|
||||
## Extension Locations
|
||||
|
||||
Extensions are auto-loaded from these directories:
|
||||
|
||||
- `~/.config/kit/extensions/*.go` (global, single files)
|
||||
- `~/.config/kit/extensions/*/main.go` (global, subdirectories)
|
||||
- `.kit/extensions/*.go` (project-local, single files)
|
||||
- `.kit/extensions/*/main.go` (project-local, subdirectories)
|
||||
|
||||
Or loaded explicitly:
|
||||
|
||||
```bash
|
||||
kit -e path/to/extension.go
|
||||
kit --extension path/to/extension.go
|
||||
```
|
||||
|
||||
## Import Path
|
||||
|
||||
Extensions import the Kit API as `"kit/ext"`. The full standard library is available plus `os/exec` for subprocess spawning.
|
||||
|
||||
## API Overview
|
||||
|
||||
The `Init` function receives an `ext.API` object for registering handlers, and event handlers receive an `ext.Context` with runtime capabilities.
|
||||
|
||||
---
|
||||
|
||||
## Lifecycle Events
|
||||
|
||||
Kit provides 18 lifecycle events. Each handler receives an event struct and a `Context`.
|
||||
|
||||
### Session Events
|
||||
|
||||
```go
|
||||
// Fired when session is loaded/created.
|
||||
api.OnSessionStart(func(e ext.SessionStartEvent, ctx ext.Context) {
|
||||
// e.SessionID string
|
||||
})
|
||||
|
||||
// Fired when Kit is shutting down. Use for cleanup.
|
||||
api.OnSessionShutdown(func(e ext.SessionShutdownEvent, ctx ext.Context) {
|
||||
// No fields.
|
||||
})
|
||||
```
|
||||
|
||||
### Agent Turn Events
|
||||
|
||||
```go
|
||||
// Before agent starts processing. Can inject system prompt or text.
|
||||
api.OnBeforeAgentStart(func(e ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
|
||||
// e.Prompt string
|
||||
// Return nil to pass through.
|
||||
// Return &ext.BeforeAgentStartResult{SystemPrompt: &s} to augment system prompt.
|
||||
// Return &ext.BeforeAgentStartResult{InjectText: &s} to inject text before prompt.
|
||||
return nil
|
||||
})
|
||||
|
||||
// Agent loop has started.
|
||||
api.OnAgentStart(func(e ext.AgentStartEvent, ctx ext.Context) {
|
||||
// e.Prompt string
|
||||
})
|
||||
|
||||
// Agent finished responding.
|
||||
api.OnAgentEnd(func(e ext.AgentEndEvent, ctx ext.Context) {
|
||||
// e.Response string
|
||||
// e.StopReason string — "completed", "cancelled", "error"
|
||||
})
|
||||
```
|
||||
|
||||
### Tool Events
|
||||
|
||||
```go
|
||||
// Before a tool executes. Can block the call.
|
||||
api.OnToolCall(func(e ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
|
||||
// e.ToolName string
|
||||
// e.ToolCallID string
|
||||
// e.Input string — JSON-encoded parameters
|
||||
// e.Source string — "llm" or "user"
|
||||
// Return nil to allow.
|
||||
// Return &ext.ToolCallResult{Block: true, Reason: "..."} to block.
|
||||
return nil
|
||||
})
|
||||
|
||||
// Tool execution started (informational only).
|
||||
api.OnToolExecutionStart(func(e ext.ToolExecutionStartEvent, ctx ext.Context) {
|
||||
// e.ToolName string
|
||||
})
|
||||
|
||||
// Tool execution ended (informational only).
|
||||
api.OnToolExecutionEnd(func(e ext.ToolExecutionEndEvent, ctx ext.Context) {
|
||||
// e.ToolName string
|
||||
})
|
||||
|
||||
// After a tool returns. Can modify the result.
|
||||
api.OnToolResult(func(e ext.ToolResultEvent, ctx ext.Context) *ext.ToolResultResult {
|
||||
// e.ToolName string
|
||||
// e.Input string
|
||||
// e.Content string
|
||||
// e.IsError bool
|
||||
// Return nil to pass through.
|
||||
// Return &ext.ToolResultResult{Content: &s} to replace content.
|
||||
// Return &ext.ToolResultResult{IsError: &b} to change error status.
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
### Input Events
|
||||
|
||||
```go
|
||||
// User submitted input. Can handle or transform it.
|
||||
api.OnInput(func(e ext.InputEvent, ctx ext.Context) *ext.InputResult {
|
||||
// e.Text string
|
||||
// e.Source string — "interactive", "cli", "script", "queue"
|
||||
// Return nil to pass through to agent.
|
||||
// Return &ext.InputResult{Action: "handled"} to consume without sending to agent.
|
||||
// Return &ext.InputResult{Action: "transform", Text: "new text"} to rewrite.
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
### Streaming Events
|
||||
|
||||
```go
|
||||
api.OnMessageStart(func(e ext.MessageStartEvent, ctx ext.Context) {})
|
||||
api.OnMessageUpdate(func(e ext.MessageUpdateEvent, ctx ext.Context) {
|
||||
// e.Chunk string — streaming text chunk
|
||||
})
|
||||
api.OnMessageEnd(func(e ext.MessageEndEvent, ctx ext.Context) {
|
||||
// e.Content string — full message content
|
||||
})
|
||||
```
|
||||
|
||||
### Model Events
|
||||
|
||||
```go
|
||||
api.OnModelChange(func(e ext.ModelChangeEvent, ctx ext.Context) {
|
||||
// e.NewModel string
|
||||
// e.PreviousModel string
|
||||
// e.Source string — "extension" or "user"
|
||||
})
|
||||
```
|
||||
|
||||
### Context Filtering
|
||||
|
||||
```go
|
||||
// Before messages are sent to the LLM. Can filter, reorder, or inject messages.
|
||||
api.OnContextPrepare(func(e ext.ContextPrepareEvent, ctx ext.Context) *ext.ContextPrepareResult {
|
||||
// e.Messages []ext.ContextMessage
|
||||
// Each ContextMessage has: Index int, Role string, Content string
|
||||
// Index -1 means a new injected message (not from session).
|
||||
// Return nil to pass through.
|
||||
// Return &ext.ContextPrepareResult{Messages: msgs} to replace the context window.
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
### Session Control Events
|
||||
|
||||
```go
|
||||
// Before forking the session tree. Can cancel.
|
||||
api.OnBeforeFork(func(e ext.BeforeForkEvent, ctx ext.Context) *ext.BeforeForkResult {
|
||||
// e.TargetID string, e.IsUserMessage bool, e.UserText string
|
||||
return nil // or &ext.BeforeForkResult{Cancel: true, Reason: "..."}
|
||||
})
|
||||
|
||||
// Before switching/clearing session. Can cancel.
|
||||
api.OnBeforeSessionSwitch(func(e ext.BeforeSessionSwitchEvent, ctx ext.Context) *ext.BeforeSessionSwitchResult {
|
||||
// e.Reason string — "new" or "clear"
|
||||
return nil // or &ext.BeforeSessionSwitchResult{Cancel: true, Reason: "..."}
|
||||
})
|
||||
|
||||
// Before context compaction. Can cancel.
|
||||
api.OnBeforeCompact(func(e ext.BeforeCompactEvent, ctx ext.Context) *ext.BeforeCompactResult {
|
||||
// e.EstimatedTokens, e.ContextLimit int
|
||||
// e.UsagePercent float64, e.MessageCount int, e.IsAutomatic bool
|
||||
return nil // or &ext.BeforeCompactResult{Cancel: true, Reason: "..."}
|
||||
})
|
||||
```
|
||||
|
||||
### Custom Events
|
||||
|
||||
```go
|
||||
// Subscribe to custom events emitted by other extensions.
|
||||
api.OnCustomEvent("event-name", func(data string) {
|
||||
// data is arbitrary string payload
|
||||
})
|
||||
|
||||
// Emit from Context:
|
||||
ctx.EmitCustomEvent("event-name", "payload")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Registering Tools
|
||||
|
||||
Tools are functions the LLM can invoke:
|
||||
|
||||
```go
|
||||
api.RegisterTool(ext.ToolDef{
|
||||
Name: "current_time",
|
||||
Description: "Get the current date and time",
|
||||
Parameters: `{"type":"object","properties":{}}`,
|
||||
Execute: func(input string) (string, error) {
|
||||
return time.Now().Format(time.RFC3339), nil
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
For long-running tools with cancellation and progress:
|
||||
|
||||
```go
|
||||
api.RegisterTool(ext.ToolDef{
|
||||
Name: "slow_task",
|
||||
Description: "A long-running task with progress reporting",
|
||||
Parameters: `{"type":"object","properties":{"query":{"type":"string"}}}`,
|
||||
ExecuteWithContext: func(input string, tc ext.ToolContext) (string, error) {
|
||||
for i := 0; i < 10; i++ {
|
||||
if tc.IsCancelled() {
|
||||
return "cancelled", nil
|
||||
}
|
||||
tc.OnProgress(fmt.Sprintf("Step %d/10...", i+1))
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
return "done", nil
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Parameters must be a JSON Schema string. The `input` argument is the JSON-encoded parameters from the LLM.
|
||||
|
||||
---
|
||||
|
||||
## Registering Slash Commands
|
||||
|
||||
Commands are user-facing actions invoked with `/name` in the input:
|
||||
|
||||
```go
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "echo",
|
||||
Description: "Echo back the provided text",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
ctx.PrintInfo("You said: " + args)
|
||||
return "", nil
|
||||
},
|
||||
// Optional tab-completion:
|
||||
Complete: func(prefix string, ctx ext.Context) []string {
|
||||
return []string{"hello", "world"}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Slash commands run in a dedicated goroutine (not a `tea.Cmd`), so they can safely block on prompts, I/O, etc.
|
||||
|
||||
---
|
||||
|
||||
## Registering Keyboard Shortcuts
|
||||
|
||||
```go
|
||||
api.RegisterShortcut(ext.ShortcutDef{
|
||||
Key: "ctrl+alt+p",
|
||||
Description: "Toggle plan mode",
|
||||
}, func(ctx ext.Context) {
|
||||
// handler runs when shortcut is pressed
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Registering Options
|
||||
|
||||
Options are configurable values resolved from env vars, config, or defaults:
|
||||
|
||||
```go
|
||||
api.RegisterOption(ext.OptionDef{
|
||||
Name: "my-setting",
|
||||
Description: "Controls something",
|
||||
Default: "false",
|
||||
})
|
||||
|
||||
// Read at runtime (resolution: env KIT_OPT_MY_SETTING > config options.my-setting > default):
|
||||
val := ctx.GetOption("my-setting")
|
||||
|
||||
// Set at runtime:
|
||||
ctx.SetOption("my-setting", "true")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Context API Reference
|
||||
|
||||
The `ext.Context` struct provides runtime capabilities via function fields.
|
||||
|
||||
### Output
|
||||
|
||||
```go
|
||||
ctx.Print("plain text") // plain output
|
||||
ctx.PrintInfo("styled info block") // bordered info block
|
||||
ctx.PrintError("styled error block") // red error block
|
||||
ctx.PrintBlock(ext.PrintBlockOpts{ // custom styled block
|
||||
Text: "content",
|
||||
BorderColor: "#a6e3a1",
|
||||
Subtitle: "my-ext",
|
||||
})
|
||||
ctx.RenderMessage("renderer-name", "content") // use a registered message renderer
|
||||
```
|
||||
|
||||
### Message Injection
|
||||
|
||||
```go
|
||||
ctx.SendMessage("prompt text") // inject message and trigger agent turn (queued)
|
||||
ctx.CancelAndSend("new prompt") // cancel current turn, clear queue, send new message
|
||||
```
|
||||
|
||||
### Widgets
|
||||
|
||||
Persistent UI elements displayed above or below the input area:
|
||||
|
||||
```go
|
||||
ctx.SetWidget(ext.WidgetConfig{
|
||||
ID: "my-widget",
|
||||
Placement: ext.WidgetAbove, // or ext.WidgetBelow
|
||||
Content: ext.WidgetContent{
|
||||
Text: "Status: Active",
|
||||
Markdown: false, // set true for markdown rendering
|
||||
},
|
||||
Style: ext.WidgetStyle{
|
||||
BorderColor: "#a6e3a1", // hex color
|
||||
NoBorder: false,
|
||||
},
|
||||
Priority: 0, // lower values render first
|
||||
})
|
||||
|
||||
ctx.RemoveWidget("my-widget")
|
||||
```
|
||||
|
||||
### Header and Footer
|
||||
|
||||
```go
|
||||
ctx.SetHeader(ext.HeaderFooterConfig{
|
||||
Content: ext.WidgetContent{Text: "My Header"},
|
||||
Style: ext.WidgetStyle{BorderColor: "#89b4fa"},
|
||||
})
|
||||
ctx.RemoveHeader()
|
||||
|
||||
ctx.SetFooter(ext.HeaderFooterConfig{
|
||||
Content: ext.WidgetContent{Text: "My Footer"},
|
||||
Style: ext.WidgetStyle{BorderColor: "#585b70"},
|
||||
})
|
||||
ctx.RemoveFooter()
|
||||
```
|
||||
|
||||
### Status Bar
|
||||
|
||||
```go
|
||||
ctx.SetStatus("key", "PLAN MODE", 10) // key, text, priority (lower = further left)
|
||||
ctx.RemoveStatus("key")
|
||||
```
|
||||
|
||||
### Interactive Prompts
|
||||
|
||||
These block until the user responds (safe in slash commands and goroutines):
|
||||
|
||||
```go
|
||||
// Selection list
|
||||
result := ctx.PromptSelect(ext.PromptSelectConfig{
|
||||
Message: "Pick one:",
|
||||
Options: []string{"Option A", "Option B", "Option C"},
|
||||
})
|
||||
if !result.Cancelled {
|
||||
// result.Value string, result.Index int
|
||||
}
|
||||
|
||||
// Yes/No confirmation
|
||||
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
|
||||
Message: "Are you sure?",
|
||||
DefaultValue: false,
|
||||
})
|
||||
if !result.Cancelled {
|
||||
// result.Value bool
|
||||
}
|
||||
|
||||
// Text input
|
||||
result := ctx.PromptInput(ext.PromptInputConfig{
|
||||
Message: "Enter name:",
|
||||
Placeholder: "my-project",
|
||||
Default: "",
|
||||
})
|
||||
if !result.Cancelled {
|
||||
// result.Value string
|
||||
}
|
||||
```
|
||||
|
||||
### Overlay Dialogs
|
||||
|
||||
Modal dialogs with optional action buttons:
|
||||
|
||||
```go
|
||||
result := ctx.ShowOverlay(ext.OverlayConfig{
|
||||
Title: "Confirmation",
|
||||
Content: ext.WidgetContent{Text: "Are you sure you want to proceed?", Markdown: true},
|
||||
Style: ext.OverlayStyle{BorderColor: "#f38ba8"},
|
||||
Width: 60, // 0 = 60% of terminal width
|
||||
MaxHeight: 20, // 0 = 80% of terminal height
|
||||
Anchor: ext.OverlayCenter, // or ext.OverlayTopCenter, ext.OverlayBottomCenter
|
||||
Actions: []string{"Confirm", "Cancel"},
|
||||
})
|
||||
if !result.Cancelled {
|
||||
// result.Action string, result.Index int
|
||||
}
|
||||
```
|
||||
|
||||
### Editor Interceptor
|
||||
|
||||
Wrap the built-in text input with custom key handling and rendering:
|
||||
|
||||
```go
|
||||
ctx.SetEditor(ext.EditorConfig{
|
||||
HandleKey: func(key string, currentText string) ext.EditorKeyAction {
|
||||
if key == "ctrl+s" {
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeySubmit, SubmitText: currentText}
|
||||
}
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
|
||||
},
|
||||
Render: func(width int, defaultContent string) string {
|
||||
return "[custom] " + defaultContent
|
||||
},
|
||||
})
|
||||
|
||||
ctx.ResetEditor() // remove interceptor
|
||||
ctx.SetEditorText("prefilled") // set editor text content
|
||||
```
|
||||
|
||||
**EditorKeyAction types:**
|
||||
- `ext.EditorKeyPassthrough` — let the default editor handle the key
|
||||
- `ext.EditorKeyConsumed` — swallow the key, do nothing
|
||||
- `ext.EditorKeyRemap` — remap to a different key: `EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "up"}`
|
||||
- `ext.EditorKeySubmit` — submit text: `EditorKeyAction{Type: ext.EditorKeySubmit, SubmitText: "text"}`
|
||||
|
||||
### UI Visibility
|
||||
|
||||
```go
|
||||
ctx.SetUIVisibility(ext.UIVisibility{
|
||||
HideStartupMessage: true,
|
||||
HideStatusBar: true,
|
||||
HideSeparator: true,
|
||||
HideInputHint: true,
|
||||
})
|
||||
```
|
||||
|
||||
### Session Data
|
||||
|
||||
```go
|
||||
stats := ctx.GetContextStats() // .EstimatedTokens, .ContextLimit, .UsagePercent, .MessageCount
|
||||
msgs := ctx.GetMessages() // []ext.SessionMessage on current branch
|
||||
path := ctx.GetSessionPath() // file path of session JSONL
|
||||
|
||||
// Persist custom data in the session tree:
|
||||
id, err := ctx.AppendEntry("my-type", "data string")
|
||||
entries := ctx.GetEntries("my-type") // []ext.ExtensionEntry{ID, EntryType, Data, Timestamp}
|
||||
```
|
||||
|
||||
### Model Management
|
||||
|
||||
```go
|
||||
err := ctx.SetModel("anthropic/claude-sonnet-4-20250514")
|
||||
models := ctx.GetAvailableModels() // []ext.ModelInfoEntry
|
||||
```
|
||||
|
||||
### Tool Management
|
||||
|
||||
```go
|
||||
tools := ctx.GetAllTools() // []ext.ToolInfo{Name, Description, Source, Enabled}
|
||||
ctx.SetActiveTools([]string{"read", "grep"}) // restrict to these tools only
|
||||
ctx.SetActiveTools(nil) // re-enable all tools
|
||||
```
|
||||
|
||||
### LLM Completions
|
||||
|
||||
Make standalone LLM calls (bypasses the agent tool loop):
|
||||
|
||||
```go
|
||||
resp, err := ctx.Complete(ext.CompleteRequest{
|
||||
Model: "", // empty = current model
|
||||
System: "You are ...", // optional system prompt
|
||||
Prompt: "Summarize...", // the prompt
|
||||
MaxTokens: 1000, // 0 = provider default
|
||||
OnChunk: func(chunk string) { /* streaming */ },
|
||||
})
|
||||
// resp.Text, resp.InputTokens, resp.OutputTokens, resp.Model
|
||||
```
|
||||
|
||||
### TUI Suspension
|
||||
|
||||
Temporarily release the terminal for interactive subprocesses:
|
||||
|
||||
```go
|
||||
ctx.SuspendTUI(func() {
|
||||
cmd := exec.Command("vim", "file.go")
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Run()
|
||||
})
|
||||
```
|
||||
|
||||
### Application Control
|
||||
|
||||
```go
|
||||
ctx.Exit() // graceful shutdown
|
||||
err := ctx.ReloadExtensions() // hot-reload all extensions from disk
|
||||
```
|
||||
|
||||
### Context Fields
|
||||
|
||||
```go
|
||||
ctx.SessionID // string
|
||||
ctx.CWD // string — current working directory
|
||||
ctx.Model // string — active model name
|
||||
ctx.Interactive // bool — true if running in TUI mode
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tool Renderers
|
||||
|
||||
Customize how tool calls are displayed in the TUI:
|
||||
|
||||
```go
|
||||
api.RegisterToolRenderer(ext.ToolRenderConfig{
|
||||
ToolName: "bash",
|
||||
DisplayName: "Shell", // replaces auto-capitalized name
|
||||
BorderColor: "#89b4fa",
|
||||
Background: "",
|
||||
BodyMarkdown: true, // render body through markdown
|
||||
RenderHeader: func(toolArgs string, width int) string {
|
||||
var args struct{ Command string `json:"command"` }
|
||||
json.Unmarshal([]byte(toolArgs), &args)
|
||||
return "$ " + args.Command
|
||||
},
|
||||
RenderBody: func(toolResult string, isError bool, width int) string {
|
||||
if isError {
|
||||
return "ERROR: " + toolResult
|
||||
}
|
||||
return toolResult
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Message Renderers
|
||||
|
||||
Define named output styles for `ctx.RenderMessage()`:
|
||||
|
||||
```go
|
||||
api.RegisterMessageRenderer(ext.MessageRendererConfig{
|
||||
Name: "success",
|
||||
Render: func(content string, width int) string {
|
||||
return " " + content // green checkmark prefix
|
||||
},
|
||||
})
|
||||
|
||||
// Usage in handlers:
|
||||
ctx.RenderMessage("success", "All tests passed")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Critical Yaegi Constraints
|
||||
|
||||
### No Named Function References in Struct Fields
|
||||
|
||||
Yaegi has a bug where named function references assigned to struct fields return zero values across the interpreter boundary. Always use anonymous closure literals:
|
||||
|
||||
```go
|
||||
// WRONG - will silently return zero values:
|
||||
func myHandler(key, text string) ext.EditorKeyAction {
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
|
||||
}
|
||||
ctx.SetEditor(ext.EditorConfig{HandleKey: myHandler})
|
||||
|
||||
// CORRECT - use anonymous closure:
|
||||
ctx.SetEditor(ext.EditorConfig{
|
||||
HandleKey: func(key, text string) ext.EditorKeyAction {
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
This applies to ALL struct fields that take function values: `ToolDef.Execute`, `CommandDef.Execute`, `EditorConfig.HandleKey`, `EditorConfig.Render`, `ToolRenderConfig.RenderHeader`, `ToolRenderConfig.RenderBody`, etc.
|
||||
|
||||
### No Interfaces Across the Boundary
|
||||
|
||||
All extension-facing API types are concrete structs, never interfaces. Yaegi crashes on interface wrapper generation.
|
||||
|
||||
### Package-Level Variables for State
|
||||
|
||||
Yaegi supports package-level variables captured in closures. This is the standard way to maintain state across event callbacks:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import "kit/ext"
|
||||
|
||||
var callCount int
|
||||
var lastTool string
|
||||
|
||||
func Init(api ext.API) {
|
||||
api.OnToolResult(func(e ext.ToolResultEvent, ctx ext.Context) *ext.ToolResultResult {
|
||||
callCount++
|
||||
lastTool = e.ToolName
|
||||
return nil
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern: Tool Call Blocking
|
||||
|
||||
Block dangerous operations by intercepting tool calls:
|
||||
|
||||
```go
|
||||
api.OnToolCall(func(tc ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
|
||||
if tc.ToolName == "bash" {
|
||||
var input struct{ Command string `json:"command"` }
|
||||
json.Unmarshal([]byte(tc.Input), &input)
|
||||
if strings.Contains(input.Command, "rm -rf") {
|
||||
return &ext.ToolCallResult{
|
||||
Block: true,
|
||||
Reason: "Dangerous command blocked",
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern: System Prompt Injection
|
||||
|
||||
Augment the agent's behavior by injecting instructions:
|
||||
|
||||
```go
|
||||
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
|
||||
prompt := "Always respond with bullet points."
|
||||
return &ext.BeforeAgentStartResult{SystemPrompt: &prompt}
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern: Background Processing with SendMessage
|
||||
|
||||
Run work in a goroutine and inject results back:
|
||||
|
||||
```go
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "run",
|
||||
Description: "Run a command in the background",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
go func() {
|
||||
out, err := exec.Command("sh", "-c", args).CombinedOutput()
|
||||
if err != nil {
|
||||
ctx.SendMessage(fmt.Sprintf("Command failed: %s\n%s", err, out))
|
||||
return
|
||||
}
|
||||
ctx.SendMessage(fmt.Sprintf("Command output:\n```\n%s\n```", out))
|
||||
}()
|
||||
return "Running in background...", nil
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern: Ephemeral Context Injection
|
||||
|
||||
Inject information into every LLM turn without persisting in session history:
|
||||
|
||||
```go
|
||||
api.OnContextPrepare(func(e ext.ContextPrepareEvent, ctx ext.Context) *ext.ContextPrepareResult {
|
||||
data, err := os.ReadFile(".kit/context.md")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
injected := ext.ContextMessage{
|
||||
Index: -1, // -1 = new message, not from session
|
||||
Role: "system",
|
||||
Content: string(data),
|
||||
}
|
||||
msgs := append([]ext.ContextMessage{injected}, e.Messages...)
|
||||
return &ext.ContextPrepareResult{Messages: msgs}
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern: Live Widget Updates
|
||||
|
||||
Update a widget periodically from a goroutine:
|
||||
|
||||
```go
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
ctx.SetWidget(ext.WidgetConfig{
|
||||
ID: "clock",
|
||||
Placement: ext.WidgetAbove,
|
||||
Content: ext.WidgetContent{Text: time.Now().Format("15:04:05")},
|
||||
Style: ext.WidgetStyle{BorderColor: "#89b4fa"},
|
||||
})
|
||||
}
|
||||
}()
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern: Spawning Kit as a Sub-Agent
|
||||
|
||||
Extensions can spawn Kit as a subprocess for delegation:
|
||||
|
||||
```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
|
||||
|
||||
```bash
|
||||
# Validate syntax of all discovered extensions
|
||||
kit extensions validate
|
||||
|
||||
# List loaded extensions
|
||||
kit extensions list
|
||||
|
||||
# Run with a specific extension
|
||||
kit -e path/to/extension.go
|
||||
|
||||
# Run with multiple extensions
|
||||
kit -e ext1.go -e ext2.go
|
||||
|
||||
# Disable all extensions
|
||||
kit --no-extensions
|
||||
|
||||
# Generate an example extension scaffold
|
||||
kit extensions init
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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:
|
||||
|
||||
```go
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
func Init(api ext.API) {
|
||||
readOnlyTools := []string{"read", "grep", "find", "ls"}
|
||||
var planActive bool
|
||||
|
||||
api.RegisterOption(ext.OptionDef{
|
||||
Name: "plan",
|
||||
Description: "Start in plan mode (read-only tools)",
|
||||
Default: "false",
|
||||
})
|
||||
|
||||
api.RegisterShortcut(ext.ShortcutDef{
|
||||
Key: "ctrl+alt+p",
|
||||
Description: "Toggle plan/explore mode",
|
||||
}, func(ctx ext.Context) {
|
||||
planActive = !planActive
|
||||
applyMode(ctx, planActive, readOnlyTools)
|
||||
})
|
||||
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "plan",
|
||||
Description: "Toggle plan/explore mode",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
planActive = !planActive
|
||||
applyMode(ctx, planActive, readOnlyTools)
|
||||
return "", nil
|
||||
},
|
||||
})
|
||||
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
if strings.ToLower(ctx.GetOption("plan")) == "true" {
|
||||
planActive = true
|
||||
applyMode(ctx, true, readOnlyTools)
|
||||
}
|
||||
})
|
||||
|
||||
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
|
||||
if !planActive {
|
||||
return nil
|
||||
}
|
||||
prompt := `You are in PLAN MODE (read-only). You can ONLY read and search.
|
||||
Focus on understanding, analysis, and generating plans.`
|
||||
return &ext.BeforeAgentStartResult{SystemPrompt: &prompt}
|
||||
})
|
||||
}
|
||||
|
||||
func applyMode(ctx ext.Context, active bool, tools []string) {
|
||||
if active {
|
||||
ctx.SetActiveTools(tools)
|
||||
ctx.SetStatus("plan-mode", "PLAN MODE (read-only)", 10)
|
||||
ctx.PrintInfo("Plan mode ON")
|
||||
} else {
|
||||
ctx.SetActiveTools(nil)
|
||||
ctx.RemoveStatus("plan-mode")
|
||||
ctx.PrintInfo("Plan mode OFF")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Key Files for Reference
|
||||
|
||||
- [`internal/extensions/api.go`](https://github.com/mark3labs/kit/blob/main/internal/extensions/api.go) — Complete API type definitions
|
||||
- [`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
|
||||
- [`examples/extensions/`](https://github.com/mark3labs/kit/tree/main/examples/extensions) — 25+ working example extensions
|
||||
@@ -24,6 +24,7 @@ A powerful, extensible AI coding agent CLI with multi-provider support, built-in
|
||||
- **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
|
||||
- **ACP Server**: Run Kit as an [Agent Client Protocol](https://agentclientprotocol.com) agent over stdio
|
||||
- **Go SDK**: Embed Kit in your own applications
|
||||
|
||||
## Installation
|
||||
@@ -82,6 +83,20 @@ kit "Run tests" --quiet
|
||||
kit "Quick question" --no-session
|
||||
```
|
||||
|
||||
### ACP Server Mode
|
||||
|
||||
Kit can run as an [ACP (Agent Client Protocol)](https://agentclientprotocol.com) agent server, enabling ACP-compatible clients (such as [OpenCode](https://github.com/sst/opencode)) to drive Kit as a remote coding agent over stdio.
|
||||
|
||||
```bash
|
||||
# Start Kit as an ACP server (communicates via JSON-RPC 2.0 on stdin/stdout)
|
||||
kit acp
|
||||
|
||||
# With debug logging to stderr
|
||||
kit acp --debug
|
||||
```
|
||||
|
||||
The ACP server exposes Kit's full capabilities — LLM execution, tool calls (bash, read, write, edit, grep, etc.), and session persistence — over the standard ACP protocol. Sessions are persisted to Kit's normal JSONL session files, so they can be resumed later.
|
||||
|
||||
## Configuration
|
||||
|
||||
Kit looks for configuration in the following locations (in order of priority):
|
||||
@@ -188,6 +203,10 @@ kit update-models # Update local model database from models.dev
|
||||
kit extensions list # List discovered extensions
|
||||
kit extensions validate # Validate extension files
|
||||
kit extensions init # Generate example extension template
|
||||
|
||||
# ACP server
|
||||
kit acp # Start as ACP agent (stdio JSON-RPC)
|
||||
kit acp --debug # With debug logging to stderr
|
||||
```
|
||||
|
||||
## Extension System
|
||||
@@ -458,6 +477,7 @@ 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
|
||||
examples/extensions/ - Example extension files
|
||||
|
||||
+13
-1
@@ -64,8 +64,20 @@
|
||||
"name": "yaegi",
|
||||
"url": "https://github.com/traefik/yaegi",
|
||||
"branch": "master"
|
||||
},
|
||||
{
|
||||
"type": "git",
|
||||
"name": "acp-go-sdk",
|
||||
"url": "https://github.com/coder/acp-go-sdk",
|
||||
"branch": "main"
|
||||
},
|
||||
{
|
||||
"type": "git",
|
||||
"name": "opencode",
|
||||
"url": "https://github.com/anomalyco/opencode",
|
||||
"branch": "dev"
|
||||
}
|
||||
],
|
||||
"model": "claude-haiku-4-5",
|
||||
"provider": "opencode"
|
||||
}
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
acp "github.com/coder/acp-go-sdk"
|
||||
|
||||
"github.com/mark3labs/kit/internal/acpserver"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var acpCmd = &cobra.Command{
|
||||
Use: "acp",
|
||||
Short: "Start Kit as an ACP agent server",
|
||||
Long: `Start Kit as an ACP (Agent Client Protocol) agent server.
|
||||
|
||||
Communicates over stdio (stdin/stdout) using JSON-RPC 2.0 with
|
||||
newline-delimited JSON, compatible with OpenCode and other ACP clients.
|
||||
|
||||
The server exposes Kit's LLM execution, tool system, and session
|
||||
management via the Agent Client Protocol. Sessions are persisted
|
||||
to Kit's standard JSONL session files.`,
|
||||
RunE: runACP,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(acpCmd)
|
||||
}
|
||||
|
||||
func runACP(cmd *cobra.Command, _ []string) error {
|
||||
// Create the ACP agent implementation.
|
||||
agent := acpserver.NewAgent()
|
||||
defer agent.Close()
|
||||
|
||||
// Create the stdio connection. The SDK reads JSON-RPC from stdin and
|
||||
// writes responses to stdout. We wrap stdin with a normalizer that
|
||||
// fills in optional fields the SDK's generated validation requires
|
||||
// (e.g. mcpServers) so clients that omit them still work.
|
||||
conn := acp.NewAgentSideConnection(agent, os.Stdout, newACPNormalizer(os.Stdin))
|
||||
|
||||
// Wire the connection back to the agent so it can send session updates.
|
||||
agent.SetAgentConnection(conn)
|
||||
|
||||
// Enable debug logging to stderr if requested.
|
||||
if debugMode {
|
||||
conn.SetLogger(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||
Level: slog.LevelDebug,
|
||||
})))
|
||||
}
|
||||
|
||||
// Wait for either the client to disconnect or a signal.
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
select {
|
||||
case <-conn.Done():
|
||||
fmt.Fprintln(os.Stderr, "kit: ACP client disconnected")
|
||||
case sig := <-sigCh:
|
||||
fmt.Fprintf(os.Stderr, "kit: received %s, shutting down\n", sig)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// acpNormalizer wraps an io.Reader carrying newline-delimited JSON-RPC and
|
||||
// patches incoming messages so that fields the SDK validates as required —
|
||||
// but that some clients (e.g. Zed) omit — are defaulted. This avoids
|
||||
// InvalidParams errors without forking the SDK.
|
||||
type acpNormalizer struct {
|
||||
scanner *bufio.Scanner
|
||||
buf bytes.Buffer // leftover bytes from the last normalized line
|
||||
}
|
||||
|
||||
func newACPNormalizer(r io.Reader) *acpNormalizer {
|
||||
const maxMsg = 10 * 1024 * 1024 // 10 MB, matches SDK buffer
|
||||
s := bufio.NewScanner(r)
|
||||
s.Buffer(make([]byte, 0, 1024*1024), maxMsg)
|
||||
return &acpNormalizer{scanner: s}
|
||||
}
|
||||
|
||||
// Read satisfies io.Reader. It feeds one normalized JSON line (plus newline)
|
||||
// per underlying scan, buffering across short caller reads.
|
||||
func (n *acpNormalizer) Read(p []byte) (int, error) {
|
||||
// Drain any leftover bytes from the previous line first.
|
||||
if n.buf.Len() > 0 {
|
||||
return n.buf.Read(p)
|
||||
}
|
||||
|
||||
if !n.scanner.Scan() {
|
||||
if err := n.scanner.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
line := n.scanner.Bytes()
|
||||
normalized := normalizeACPLine(line)
|
||||
n.buf.Write(normalized)
|
||||
n.buf.WriteByte('\n')
|
||||
return n.buf.Read(p)
|
||||
}
|
||||
|
||||
// normalizeACPLine ensures session/new and session/load params contain an
|
||||
// mcpServers array. Returns the original line unchanged for all other methods.
|
||||
func normalizeACPLine(line []byte) []byte {
|
||||
// Quick check: if it already contains mcpServers, nothing to do.
|
||||
if bytes.Contains(line, []byte(`"mcpServers"`)) {
|
||||
return line
|
||||
}
|
||||
|
||||
// Only bother parsing if the method could be session/new or session/load.
|
||||
if !bytes.Contains(line, []byte(`"session/new"`)) &&
|
||||
!bytes.Contains(line, []byte(`"session/load"`)) {
|
||||
return line
|
||||
}
|
||||
|
||||
var msg struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id,omitempty"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(line, &msg); err != nil {
|
||||
return line
|
||||
}
|
||||
if msg.Method != "session/new" && msg.Method != "session/load" {
|
||||
return line
|
||||
}
|
||||
|
||||
// Patch params to include mcpServers: [].
|
||||
var params map[string]json.RawMessage
|
||||
if err := json.Unmarshal(msg.Params, ¶ms); err != nil {
|
||||
return line
|
||||
}
|
||||
if _, ok := params["mcpServers"]; ok {
|
||||
return line
|
||||
}
|
||||
params["mcpServers"] = json.RawMessage(`[]`)
|
||||
|
||||
patched, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return line
|
||||
}
|
||||
msg.Params = patched
|
||||
|
||||
out, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return line
|
||||
}
|
||||
return out
|
||||
}
|
||||
+101
-11
@@ -52,6 +52,7 @@ var (
|
||||
topP float32
|
||||
topK int32
|
||||
stopSequences []string
|
||||
thinkingLevel string
|
||||
|
||||
// Ollama-specific parameters
|
||||
numGPU int32
|
||||
@@ -247,6 +248,7 @@ func init() {
|
||||
flags.Float32Var(&topP, "top-p", 0.95, "controls diversity via nucleus sampling (0.0-1.0)")
|
||||
flags.Int32Var(&topK, "top-k", 40, "controls diversity by limiting top K tokens to sample from")
|
||||
flags.StringSliceVar(&stopSequences, "stop-sequences", nil, "custom stop sequences (comma-separated)")
|
||||
flags.StringVar(&thinkingLevel, "thinking-level", "off", "extended thinking level: off, minimal, low, medium, high")
|
||||
|
||||
// Ollama-specific parameters
|
||||
flags.Int32Var(&numGPU, "num-gpu-layers", -1, "number of model layers to offload to GPU for Ollama models (-1 for auto-detect)")
|
||||
@@ -269,6 +271,7 @@ func init() {
|
||||
_ = viper.BindPFlag("top-p", rootCmd.PersistentFlags().Lookup("top-p"))
|
||||
_ = viper.BindPFlag("top-k", rootCmd.PersistentFlags().Lookup("top-k"))
|
||||
_ = viper.BindPFlag("stop-sequences", rootCmd.PersistentFlags().Lookup("stop-sequences"))
|
||||
_ = viper.BindPFlag("thinking-level", rootCmd.PersistentFlags().Lookup("thinking-level"))
|
||||
_ = viper.BindPFlag("num-gpu-layers", rootCmd.PersistentFlags().Lookup("num-gpu-layers"))
|
||||
_ = viper.BindPFlag("main-gpu", rootCmd.PersistentFlags().Lookup("main-gpu"))
|
||||
_ = viper.BindPFlag("tls-skip-verify", rootCmd.PersistentFlags().Lookup("tls-skip-verify"))
|
||||
@@ -921,6 +924,42 @@ func runNormalMode(ctx context.Context) error {
|
||||
Index: resp.Index,
|
||||
}
|
||||
},
|
||||
SpawnSubagent: func(config extensions.SubagentConfig) (*extensions.SubagentHandle, *extensions.SubagentResult, error) {
|
||||
// In-process subagent via SDK.
|
||||
sdkCfg := kit.SubagentConfig{
|
||||
Prompt: config.Prompt,
|
||||
Model: config.Model,
|
||||
SystemPrompt: config.SystemPrompt,
|
||||
Timeout: config.Timeout,
|
||||
NoSession: config.NoSession,
|
||||
}
|
||||
// Bridge SDK events to extension SubagentEvents.
|
||||
if config.OnEvent != nil {
|
||||
sdkCfg.OnEvent = func(e kit.Event) {
|
||||
se := sdkEventToSubagentEvent(e)
|
||||
if se.Type != "" {
|
||||
config.OnEvent(se)
|
||||
}
|
||||
}
|
||||
}
|
||||
result, err := kitInstance.Subagent(ctx, sdkCfg)
|
||||
if result == nil {
|
||||
return nil, &extensions.SubagentResult{Error: err}, err
|
||||
}
|
||||
extResult := &extensions.SubagentResult{
|
||||
Response: result.Response,
|
||||
Error: result.Error,
|
||||
SessionID: result.SessionID,
|
||||
Elapsed: result.Elapsed,
|
||||
}
|
||||
if result.Usage != nil {
|
||||
extResult.Usage = &extensions.SubagentUsage{
|
||||
InputTokens: result.Usage.InputTokens,
|
||||
OutputTokens: result.Usage.OutputTokens,
|
||||
}
|
||||
}
|
||||
return nil, extResult, err
|
||||
},
|
||||
})
|
||||
kitInstance.EmitSessionStart()
|
||||
}
|
||||
@@ -980,9 +1019,14 @@ func runNormalMode(ctx context.Context) error {
|
||||
kitInstance.EmitModelChange(newModel, previousModel, source)
|
||||
}
|
||||
|
||||
// Build thinking level callback.
|
||||
setThinkingLevelForUI := func(level string) error {
|
||||
return kitInstance.SetThinkingLevel(context.Background(), level)
|
||||
}
|
||||
|
||||
// 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)
|
||||
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)
|
||||
}
|
||||
|
||||
// Quiet mode is not allowed in interactive mode
|
||||
@@ -990,7 +1034,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)
|
||||
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)
|
||||
}
|
||||
|
||||
// runNonInteractiveModeApp executes a single prompt via the app layer and exits,
|
||||
@@ -1003,7 +1047,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)) 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, 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 {
|
||||
// Expand @file references in the prompt before sending to the agent.
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
prompt = ui.ProcessFileAttachments(prompt, cwd)
|
||||
@@ -1046,7 +1090,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)
|
||||
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 nil
|
||||
@@ -1075,15 +1119,19 @@ func buildJSONOutput(result *kit.TurnResult, model string) ([]byte, error) {
|
||||
CacheCreationTokens int64 `json:"cache_creation_tokens"`
|
||||
}
|
||||
type jsonEnvelope struct {
|
||||
Response string `json:"response"`
|
||||
Model string `json:"model"`
|
||||
Usage *jsonUsage `json:"usage,omitempty"`
|
||||
Messages []jsonMessage `json:"messages"`
|
||||
Response string `json:"response"`
|
||||
Model string `json:"model"`
|
||||
StopReason string `json:"stop_reason,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Usage *jsonUsage `json:"usage,omitempty"`
|
||||
Messages []jsonMessage `json:"messages"`
|
||||
}
|
||||
|
||||
out := jsonEnvelope{
|
||||
Response: result.Response,
|
||||
Model: model,
|
||||
Response: result.Response,
|
||||
Model: model,
|
||||
StopReason: result.StopReason,
|
||||
SessionID: result.SessionID,
|
||||
}
|
||||
|
||||
if result.TotalUsage != nil {
|
||||
@@ -1140,7 +1188,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)) error {
|
||||
func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []ui.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error) error {
|
||||
// Determine terminal size; fall back gracefully.
|
||||
termWidth, termHeight, err := term.GetSize(int(os.Stdout.Fd()))
|
||||
if err != nil || termWidth == 0 {
|
||||
@@ -1178,6 +1226,9 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN
|
||||
GetExtensionCommands: getExtensionCommands,
|
||||
SetModel: setModel,
|
||||
EmitModelChange: emitModelChange,
|
||||
ThinkingLevel: thinkingLevel,
|
||||
IsReasoningModel: isReasoningModel,
|
||||
SetThinkingLevel: setThinkingLevel,
|
||||
})
|
||||
|
||||
// Print startup info to stdout before Bubble Tea takes over the screen.
|
||||
@@ -1191,3 +1242,42 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN
|
||||
_, runErr := program.Run()
|
||||
return runErr
|
||||
}
|
||||
|
||||
// sdkEventToSubagentEvent converts an SDK event to an extension-facing
|
||||
// SubagentEvent. Returns a zero-value event (Type=="") for events that
|
||||
// don't map to anything useful.
|
||||
func sdkEventToSubagentEvent(e kit.Event) extensions.SubagentEvent {
|
||||
switch ev := e.(type) {
|
||||
case kit.MessageUpdateEvent:
|
||||
return extensions.SubagentEvent{Type: "text", Content: ev.Chunk}
|
||||
case kit.ReasoningDeltaEvent:
|
||||
return extensions.SubagentEvent{Type: "reasoning", Content: ev.Delta}
|
||||
case kit.ToolCallEvent:
|
||||
return extensions.SubagentEvent{
|
||||
Type: "tool_call", ToolCallID: ev.ToolCallID,
|
||||
ToolName: ev.ToolName, ToolKind: ev.ToolKind, ToolArgs: ev.ToolArgs,
|
||||
}
|
||||
case kit.ToolExecutionStartEvent:
|
||||
return extensions.SubagentEvent{
|
||||
Type: "tool_execution_start", ToolCallID: ev.ToolCallID,
|
||||
ToolName: ev.ToolName, ToolKind: ev.ToolKind,
|
||||
}
|
||||
case kit.ToolExecutionEndEvent:
|
||||
return extensions.SubagentEvent{
|
||||
Type: "tool_execution_end", ToolCallID: ev.ToolCallID,
|
||||
ToolName: ev.ToolName, ToolKind: ev.ToolKind,
|
||||
}
|
||||
case kit.ToolResultEvent:
|
||||
return extensions.SubagentEvent{
|
||||
Type: "tool_result", ToolCallID: ev.ToolCallID,
|
||||
ToolName: ev.ToolName, ToolKind: ev.ToolKind,
|
||||
ToolResult: ev.Result, IsError: ev.IsError,
|
||||
}
|
||||
case kit.TurnStartEvent:
|
||||
return extensions.SubagentEvent{Type: "turn_start"}
|
||||
case kit.TurnEndEvent:
|
||||
return extensions.SubagentEvent{Type: "turn_end"}
|
||||
default:
|
||||
return extensions.SubagentEvent{}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
Example:
|
||||
kit skill`,
|
||||
RunE: runSkill,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(skillCmd)
|
||||
}
|
||||
|
||||
func runSkill(_ *cobra.Command, _ []string) error {
|
||||
npx, err := exec.LookPath("npx")
|
||||
if err != nil {
|
||||
return fmt.Errorf("npx not found in PATH — install Node.js to use this command: %w", err)
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"skills",
|
||||
"add",
|
||||
"mark3labs/kit",
|
||||
"--skill",
|
||||
"kit-extensions",
|
||||
}
|
||||
|
||||
cmd := exec.Command(npx, args...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("skills install failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,164 @@
|
||||
//go:build ignore
|
||||
|
||||
// Subagent Test Extension — Tests the new first-class subagent API
|
||||
//
|
||||
// Commands:
|
||||
//
|
||||
// /subtest <task> — spawn a blocking subagent and print result
|
||||
// /subbg <task> — spawn a background subagent with live output
|
||||
//
|
||||
// Usage: kit -e examples/extensions/subagent-test.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
latestCtx ext.Context
|
||||
hasCtx bool
|
||||
)
|
||||
|
||||
func Init(api ext.API) {
|
||||
// Keep context fresh
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
mu.Lock()
|
||||
latestCtx = ctx
|
||||
hasCtx = true
|
||||
mu.Unlock()
|
||||
|
||||
ctx.PrintInfo(
|
||||
"Subagent Test Extension loaded\n\n" +
|
||||
"/subtest <task> Spawn blocking subagent\n" +
|
||||
"/subbg <task> Spawn background subagent\n\n" +
|
||||
"The LLM can also use the spawn_subagent tool.")
|
||||
})
|
||||
|
||||
api.OnAgentEnd(func(_ ext.AgentEndEvent, ctx ext.Context) {
|
||||
mu.Lock()
|
||||
latestCtx = ctx
|
||||
mu.Unlock()
|
||||
})
|
||||
|
||||
// Command: /subtest <task> — blocking subagent
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "subtest",
|
||||
Description: "Spawn a blocking subagent: /subtest <task>",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
mu.Lock()
|
||||
latestCtx = ctx
|
||||
hasCtx = true
|
||||
mu.Unlock()
|
||||
|
||||
task := strings.TrimSpace(args)
|
||||
if task == "" {
|
||||
return "Usage: /subtest <task>", nil
|
||||
}
|
||||
|
||||
ctx.PrintInfo(fmt.Sprintf("Spawning blocking subagent for: %s", task))
|
||||
|
||||
start := time.Now()
|
||||
_, result, err := ctx.SpawnSubagent(ext.SubagentConfig{
|
||||
Prompt: task,
|
||||
Timeout: 2 * time.Minute,
|
||||
Blocking: true,
|
||||
})
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Sprintf("Spawn error: %v", err), nil
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
return "No result returned", nil
|
||||
}
|
||||
|
||||
if result.Error != nil {
|
||||
return fmt.Sprintf("Subagent failed (exit %d) after %ds: %v\n\nPartial output:\n%s",
|
||||
result.ExitCode, int(elapsed.Seconds()), result.Error, truncate(result.Response, 2000)), nil
|
||||
}
|
||||
|
||||
response := fmt.Sprintf("Subagent completed in %ds", int(elapsed.Seconds()))
|
||||
if result.Usage != nil {
|
||||
response += fmt.Sprintf(" (tokens: %d in / %d out)", result.Usage.InputTokens, result.Usage.OutputTokens)
|
||||
}
|
||||
response += fmt.Sprintf("\n\nResult:\n%s", truncate(result.Response, 4000))
|
||||
|
||||
return response, nil
|
||||
},
|
||||
})
|
||||
|
||||
// Command: /subbg <task> — background subagent with callbacks
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "subbg",
|
||||
Description: "Spawn a background subagent: /subbg <task>",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
mu.Lock()
|
||||
latestCtx = ctx
|
||||
hasCtx = true
|
||||
mu.Unlock()
|
||||
|
||||
task := strings.TrimSpace(args)
|
||||
if task == "" {
|
||||
return "Usage: /subbg <task>", nil
|
||||
}
|
||||
|
||||
ctx.PrintInfo(fmt.Sprintf("Spawning background subagent for: %s", task))
|
||||
|
||||
start := time.Now()
|
||||
handle, _, err := ctx.SpawnSubagent(ext.SubagentConfig{
|
||||
Prompt: task,
|
||||
Timeout: 2 * time.Minute,
|
||||
OnOutput: func(chunk string) {
|
||||
// Live output - could update a widget here
|
||||
fmt.Print(chunk)
|
||||
},
|
||||
OnComplete: func(result ext.SubagentResult) {
|
||||
elapsed := time.Since(start)
|
||||
|
||||
mu.Lock()
|
||||
c := latestCtx
|
||||
ok := hasCtx
|
||||
mu.Unlock()
|
||||
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if result.Error != nil {
|
||||
c.SendMessage(fmt.Sprintf("Background subagent failed after %ds: %v",
|
||||
int(elapsed.Seconds()), result.Error))
|
||||
return
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("Background subagent completed in %ds", int(elapsed.Seconds()))
|
||||
if result.Usage != nil {
|
||||
msg += fmt.Sprintf(" (tokens: %d in / %d out)", result.Usage.InputTokens, result.Usage.OutputTokens)
|
||||
}
|
||||
msg += fmt.Sprintf("\n\nResult:\n%s", truncate(result.Response, 4000))
|
||||
|
||||
c.SendMessage(msg)
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Sprintf("Spawn error: %v", err), nil
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Background subagent spawned (ID: %s). Results will be delivered when complete.", handle.ID), nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max] + "\n\n... [truncated]"
|
||||
}
|
||||
@@ -59,6 +59,7 @@ require (
|
||||
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/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
|
||||
@@ -114,6 +114,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
|
||||
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/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=
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
// Package acpserver implements a Kit-backed ACP (Agent Client Protocol) agent.
|
||||
//
|
||||
// It bridges Kit's LLM execution, tool system, and session management to the
|
||||
// ACP protocol over stdio, allowing ACP clients (such as OpenCode) to drive
|
||||
// Kit as a remote coding agent.
|
||||
package acpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
acp "github.com/coder/acp-go-sdk"
|
||||
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
)
|
||||
|
||||
// Version is injected at build time; fallback to "dev".
|
||||
var Version = "dev"
|
||||
|
||||
// Agent implements the acp.Agent interface, delegating to Kit for LLM
|
||||
// execution, tool calls, and session management.
|
||||
type Agent struct {
|
||||
conn *acp.AgentSideConnection
|
||||
registry *sessionRegistry
|
||||
|
||||
// toolCallCounter provides unique IDs for tool calls within a turn.
|
||||
toolCallCounter atomic.Int64
|
||||
}
|
||||
|
||||
// NewAgent creates a new ACP agent backed by Kit.
|
||||
func NewAgent() *Agent {
|
||||
return &Agent{
|
||||
registry: newSessionRegistry(),
|
||||
}
|
||||
}
|
||||
|
||||
// SetAgentConnection stores the connection so the agent can send session
|
||||
// updates (streaming, tool calls, etc.) back to the ACP client. This follows
|
||||
// the AgentConnAware duck-typing pattern from the SDK.
|
||||
func (a *Agent) SetAgentConnection(conn *acp.AgentSideConnection) {
|
||||
a.conn = conn
|
||||
}
|
||||
|
||||
// Close shuts down all active sessions.
|
||||
func (a *Agent) Close() {
|
||||
a.registry.closeAll()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// acp.Agent interface implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Authenticate handles authentication requests. Kit doesn't require auth for
|
||||
// local stdio usage, so this is a no-op.
|
||||
func (a *Agent) Authenticate(_ context.Context, _ acp.AuthenticateRequest) (acp.AuthenticateResponse, error) {
|
||||
return acp.AuthenticateResponse{}, nil
|
||||
}
|
||||
|
||||
// Initialize negotiates capabilities with the ACP client.
|
||||
func (a *Agent) Initialize(_ context.Context, params acp.InitializeRequest) (acp.InitializeResponse, error) {
|
||||
log.Debug("acp: initialize", "protocol_version", params.ProtocolVersion)
|
||||
|
||||
return acp.InitializeResponse{
|
||||
ProtocolVersion: acp.ProtocolVersion(1),
|
||||
AgentCapabilities: acp.AgentCapabilities{
|
||||
LoadSession: true,
|
||||
PromptCapabilities: acp.PromptCapabilities{
|
||||
EmbeddedContext: true,
|
||||
Image: true,
|
||||
},
|
||||
},
|
||||
AgentInfo: &acp.Implementation{
|
||||
Name: "Kit",
|
||||
Version: Version,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewSession creates a new Kit session for the given working directory.
|
||||
func (a *Agent) NewSession(ctx context.Context, params acp.NewSessionRequest) (acp.NewSessionResponse, error) {
|
||||
cwd := params.Cwd
|
||||
if cwd == "" {
|
||||
return acp.NewSessionResponse{}, acp.NewInvalidParams("cwd is required")
|
||||
}
|
||||
|
||||
log.Debug("acp: new_session", "cwd", cwd)
|
||||
|
||||
sess, err := a.registry.create(ctx, cwd)
|
||||
if err != nil {
|
||||
log.Error("acp: session creation failed", "cwd", cwd, "error", err)
|
||||
return acp.NewSessionResponse{}, fmt.Errorf("create session: %w", err)
|
||||
}
|
||||
|
||||
return acp.NewSessionResponse{
|
||||
SessionId: acp.SessionId(sess.sessionID),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Prompt handles the main agent execution. It subscribes to Kit's event bus,
|
||||
// converts events to ACP session updates, and runs the prompt through Kit's
|
||||
// full turn lifecycle (hooks, LLM, tool calls, persistence).
|
||||
func (a *Agent) Prompt(ctx context.Context, params acp.PromptRequest) (acp.PromptResponse, error) {
|
||||
sessionID := string(params.SessionId)
|
||||
sess, ok := a.registry.get(sessionID)
|
||||
if !ok {
|
||||
return acp.PromptResponse{}, acp.NewInvalidParams(
|
||||
fmt.Sprintf("session not found: %s", sessionID),
|
||||
)
|
||||
}
|
||||
|
||||
// Extract text from prompt content blocks.
|
||||
promptText := extractPromptText(params.Prompt)
|
||||
if promptText == "" {
|
||||
return acp.PromptResponse{}, acp.NewInvalidParams("empty prompt")
|
||||
}
|
||||
|
||||
log.Debug("acp: prompt", "session", sessionID, "prompt_len", len(promptText))
|
||||
|
||||
// Create a cancellable context for this prompt turn.
|
||||
promptCtx, cancel := context.WithCancel(ctx)
|
||||
sess.setCancel(cancel)
|
||||
defer sess.clearCancel()
|
||||
|
||||
// Subscribe to Kit events and stream them as ACP session updates.
|
||||
unsub := a.subscribeEvents(promptCtx, sess.kit, params.SessionId)
|
||||
defer unsub()
|
||||
|
||||
// Run the prompt through Kit's full turn lifecycle.
|
||||
_, err := sess.kit.PromptResult(promptCtx, promptText)
|
||||
if err != nil {
|
||||
if promptCtx.Err() != nil {
|
||||
return acp.PromptResponse{
|
||||
StopReason: acp.StopReasonCancelled,
|
||||
}, nil
|
||||
}
|
||||
return acp.PromptResponse{}, fmt.Errorf("prompt failed: %w", err)
|
||||
}
|
||||
|
||||
return acp.PromptResponse{
|
||||
StopReason: acp.StopReasonEndTurn,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Cancel cancels the ongoing prompt for a session.
|
||||
func (a *Agent) Cancel(_ context.Context, params acp.CancelNotification) error {
|
||||
sessionID := string(params.SessionId)
|
||||
sess, ok := a.registry.get(sessionID)
|
||||
if !ok {
|
||||
return nil // No-op if session doesn't exist.
|
||||
}
|
||||
|
||||
log.Debug("acp: cancel", "session", sessionID)
|
||||
sess.cancelPrompt()
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSessionMode is a no-op for now — Kit doesn't have built-in session modes.
|
||||
func (a *Agent) SetSessionMode(_ context.Context, _ acp.SetSessionModeRequest) (acp.SetSessionModeResponse, error) {
|
||||
return acp.SetSessionModeResponse{}, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event streaming: Kit events → ACP SessionUpdate notifications
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// subscribeEvents subscribes to Kit's event bus and forwards events as ACP
|
||||
// session update notifications to the client.
|
||||
func (a *Agent) subscribeEvents(ctx context.Context, k *kit.Kit, sessionID acp.SessionId) func() {
|
||||
return k.Subscribe(func(e kit.Event) {
|
||||
// Don't send updates after the context is cancelled.
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var update *acp.SessionUpdate
|
||||
switch ev := e.(type) {
|
||||
case kit.MessageUpdateEvent:
|
||||
u := acp.UpdateAgentMessageText(ev.Chunk)
|
||||
update = &u
|
||||
|
||||
case kit.ReasoningDeltaEvent:
|
||||
u := acp.UpdateAgentThoughtText(ev.Delta)
|
||||
update = &u
|
||||
|
||||
case kit.ToolCallEvent:
|
||||
tcID := acp.ToolCallId(ev.ToolCallID)
|
||||
if tcID == "" {
|
||||
tcID = acp.ToolCallId(fmt.Sprintf("tc_%d", a.toolCallCounter.Add(1)))
|
||||
}
|
||||
u := acp.StartToolCall(tcID, ev.ToolName,
|
||||
acp.WithStartStatus(acp.ToolCallStatusInProgress),
|
||||
acp.WithStartRawInput(parseToolArgs(ev.ToolArgs)),
|
||||
)
|
||||
update = &u
|
||||
|
||||
case kit.ToolResultEvent:
|
||||
tcID := acp.ToolCallId(ev.ToolCallID)
|
||||
if tcID == "" {
|
||||
tcID = acp.ToolCallId(fmt.Sprintf("tc_%d", a.toolCallCounter.Load()))
|
||||
}
|
||||
status := acp.ToolCallStatusCompleted
|
||||
if ev.IsError {
|
||||
status = acp.ToolCallStatusFailed
|
||||
}
|
||||
u := acp.UpdateToolCall(tcID,
|
||||
acp.WithUpdateStatus(status),
|
||||
acp.WithUpdateContent([]acp.ToolCallContent{
|
||||
acp.ToolContent(acp.TextBlock(ev.Result)),
|
||||
}),
|
||||
)
|
||||
update = &u
|
||||
|
||||
case kit.ToolCallContentEvent:
|
||||
u := acp.UpdateAgentMessageText(ev.Content)
|
||||
update = &u
|
||||
}
|
||||
|
||||
if update != nil {
|
||||
_ = a.conn.SessionUpdate(ctx, acp.SessionNotification{
|
||||
SessionId: sessionID,
|
||||
Update: *update,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// extractPromptText extracts the concatenated text content from ACP content
|
||||
// blocks. Non-text blocks are ignored for now.
|
||||
func extractPromptText(blocks []acp.ContentBlock) string {
|
||||
var text string
|
||||
for _, block := range blocks {
|
||||
if block.Text != nil {
|
||||
if text != "" {
|
||||
text += "\n"
|
||||
}
|
||||
text += block.Text.Text
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// parseToolArgs attempts to parse a JSON tool args string into a map for
|
||||
// structured display. Falls back to a simple string wrapper.
|
||||
func parseToolArgs(args string) any {
|
||||
if args == "" {
|
||||
return nil
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(args), &m); err == nil {
|
||||
return m
|
||||
}
|
||||
return map[string]any{"input": args}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package acpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
)
|
||||
|
||||
// acpSession maps an ACP session to a Kit instance with its own tree session.
|
||||
type acpSession struct {
|
||||
kit *kit.Kit
|
||||
cancelFn context.CancelFunc // cancels the current prompt
|
||||
cancelMu sync.Mutex
|
||||
cwd string
|
||||
sessionID string // Kit-generated session ID (from JSONL header)
|
||||
}
|
||||
|
||||
// sessionRegistry is a thread-safe registry of ACP session ID → Kit sessions.
|
||||
type sessionRegistry struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*acpSession // ACP session ID → session
|
||||
}
|
||||
|
||||
func newSessionRegistry() *sessionRegistry {
|
||||
return &sessionRegistry{
|
||||
sessions: make(map[string]*acpSession),
|
||||
}
|
||||
}
|
||||
|
||||
// create creates a new Kit instance with a persisted tree session for the
|
||||
// given working directory. The Kit-generated session ID is used as the ACP
|
||||
// session ID so the mapping is 1:1.
|
||||
func (r *sessionRegistry) create(ctx context.Context, cwd string) (*acpSession, error) {
|
||||
kitInstance, err := kit.New(ctx, &kit.Options{
|
||||
SessionDir: cwd,
|
||||
Quiet: true,
|
||||
Streaming: true,
|
||||
})
|
||||
if err != nil {
|
||||
// Provide actionable guidance for provider auth errors, which are
|
||||
// the most common failure mode when running via ACP.
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, "API key") || strings.Contains(msg, "credentials") || strings.Contains(msg, "OAuth") {
|
||||
return nil, fmt.Errorf("provider authentication failed: %w — run 'kit auth login <provider>' or set the appropriate environment variable before starting 'kit acp'", err)
|
||||
}
|
||||
return nil, fmt.Errorf("create kit instance: %w", err)
|
||||
}
|
||||
|
||||
sessionID := kitInstance.GetSessionID()
|
||||
if sessionID == "" {
|
||||
_ = kitInstance.Close()
|
||||
return nil, fmt.Errorf("kit instance has no session ID")
|
||||
}
|
||||
|
||||
// Wire extension context with headless implementations so extensions
|
||||
// work in ACP mode. TUI-dependent features (widgets, prompts, editor)
|
||||
// become no-ops or return cancelled; all data/model/tool APIs work
|
||||
// identically to interactive mode.
|
||||
if kitInstance.HasExtensions() {
|
||||
kitInstance.SetExtensionContext(extensions.Context{
|
||||
SessionID: sessionID,
|
||||
CWD: cwd,
|
||||
Model: kitInstance.GetModelString(),
|
||||
Interactive: false,
|
||||
|
||||
// Output — route through structured logger.
|
||||
Print: func(text string) { log.Debug("extension: print", "text", text) },
|
||||
PrintInfo: func(text string) { log.Info("extension: info", "text", text) },
|
||||
PrintError: func(text string) { log.Error("extension: error", "text", text) },
|
||||
PrintBlock: func(opts extensions.PrintBlockOpts) {
|
||||
log.Info("extension: block", "subtitle", opts.Subtitle, "text", opts.Text)
|
||||
},
|
||||
|
||||
// Message injection — no-ops for now; ACP clients drive prompts.
|
||||
SendMessage: func(string) {},
|
||||
CancelAndSend: func(string) {},
|
||||
Exit: func() {},
|
||||
|
||||
// TUI widgets/chrome — silent no-ops (no TUI in ACP).
|
||||
SetWidget: func(extensions.WidgetConfig) {},
|
||||
RemoveWidget: func(string) {},
|
||||
SetHeader: func(extensions.HeaderFooterConfig) {},
|
||||
RemoveHeader: func() {},
|
||||
SetFooter: func(extensions.HeaderFooterConfig) {},
|
||||
RemoveFooter: func() {},
|
||||
SetEditor: func(extensions.EditorConfig) {},
|
||||
ResetEditor: func() {},
|
||||
SetEditorText: func(string) {},
|
||||
SetUIVisibility: func(extensions.UIVisibility) {},
|
||||
SetStatus: func(string, string, int) {},
|
||||
RemoveStatus: func(string) {},
|
||||
|
||||
// Interactive prompts — return cancelled (no user to prompt).
|
||||
PromptSelect: func(extensions.PromptSelectConfig) extensions.PromptSelectResult {
|
||||
return extensions.PromptSelectResult{Cancelled: true}
|
||||
},
|
||||
PromptConfirm: func(extensions.PromptConfirmConfig) extensions.PromptConfirmResult {
|
||||
return extensions.PromptConfirmResult{Cancelled: true}
|
||||
},
|
||||
PromptInput: func(extensions.PromptInputConfig) extensions.PromptInputResult {
|
||||
return extensions.PromptInputResult{Cancelled: true}
|
||||
},
|
||||
ShowOverlay: func(extensions.OverlayConfig) extensions.OverlayResult {
|
||||
return extensions.OverlayResult{Cancelled: true, Index: -1}
|
||||
},
|
||||
SuspendTUI: func(callback func()) error { callback(); return nil },
|
||||
|
||||
// Data access — delegate to Kit instance.
|
||||
GetContextStats: func() extensions.ContextStats {
|
||||
s := kitInstance.GetContextStats()
|
||||
return extensions.ContextStats{
|
||||
EstimatedTokens: s.EstimatedTokens,
|
||||
ContextLimit: s.ContextLimit,
|
||||
UsagePercent: s.UsagePercent,
|
||||
MessageCount: s.MessageCount,
|
||||
}
|
||||
},
|
||||
GetMessages: func() []extensions.SessionMessage { return kitInstance.GetSessionMessages() },
|
||||
GetSessionPath: func() string { return kitInstance.GetSessionFilePath() },
|
||||
AppendEntry: func(entryType, data string) (string, error) {
|
||||
return kitInstance.AppendExtensionEntry(entryType, data)
|
||||
},
|
||||
GetEntries: func(entryType string) []extensions.ExtensionEntry {
|
||||
return kitInstance.GetExtensionEntries(entryType)
|
||||
},
|
||||
|
||||
// Options, model, and tool management.
|
||||
GetOption: func(name string) string { return kitInstance.GetExtensionOption(name) },
|
||||
SetOption: func(name, value string) { kitInstance.SetExtensionOption(name, value) },
|
||||
SetModel: func(modelString string) error {
|
||||
previousModel := kitInstance.GetExtensionContext().Model
|
||||
if err := kitInstance.SetModel(context.Background(), modelString); err != nil {
|
||||
return err
|
||||
}
|
||||
kitInstance.UpdateExtensionContextModel(modelString)
|
||||
kitInstance.EmitModelChange(modelString, previousModel, "extension")
|
||||
return nil
|
||||
},
|
||||
GetAvailableModels: func() []extensions.ModelInfoEntry { return kitInstance.GetAvailableModels() },
|
||||
EmitCustomEvent: func(name, data string) { kitInstance.EmitExtensionCustomEvent(name, data) },
|
||||
GetAllTools: func() []extensions.ToolInfo { return kitInstance.GetExtensionToolInfos() },
|
||||
SetActiveTools: func(names []string) { kitInstance.SetExtensionActiveTools(names) },
|
||||
|
||||
// LLM completions and subagents.
|
||||
Complete: func(req extensions.CompleteRequest) (extensions.CompleteResponse, error) {
|
||||
return kitInstance.ExecuteCompletion(context.Background(), req)
|
||||
},
|
||||
SpawnSubagent: func(config extensions.SubagentConfig) (*extensions.SubagentHandle, *extensions.SubagentResult, error) {
|
||||
sdkCfg := kit.SubagentConfig{
|
||||
Prompt: config.Prompt,
|
||||
Model: config.Model,
|
||||
SystemPrompt: config.SystemPrompt,
|
||||
Timeout: config.Timeout,
|
||||
NoSession: config.NoSession,
|
||||
}
|
||||
if config.OnEvent != nil {
|
||||
sdkCfg.OnEvent = func(e kit.Event) {
|
||||
se := sdkEventToSubagentEvent(e)
|
||||
if se.Type != "" {
|
||||
config.OnEvent(se)
|
||||
}
|
||||
}
|
||||
}
|
||||
result, err := kitInstance.Subagent(context.Background(), sdkCfg)
|
||||
if result == nil {
|
||||
return nil, &extensions.SubagentResult{Error: err}, err
|
||||
}
|
||||
extResult := &extensions.SubagentResult{
|
||||
Response: result.Response,
|
||||
Error: result.Error,
|
||||
SessionID: result.SessionID,
|
||||
Elapsed: result.Elapsed,
|
||||
}
|
||||
if result.Usage != nil {
|
||||
extResult.Usage = &extensions.SubagentUsage{
|
||||
InputTokens: result.Usage.InputTokens,
|
||||
OutputTokens: result.Usage.OutputTokens,
|
||||
}
|
||||
}
|
||||
return nil, extResult, err
|
||||
},
|
||||
|
||||
// Render — fall back to logging.
|
||||
RenderMessage: func(name, content string) {
|
||||
renderer := kitInstance.GetExtensionMessageRenderer(name)
|
||||
if renderer != nil && renderer.Render != nil {
|
||||
content = renderer.Render(content, 80)
|
||||
}
|
||||
log.Info("extension: message", "renderer", name, "content", content)
|
||||
},
|
||||
ReloadExtensions: func() error { return kitInstance.ReloadExtensions() },
|
||||
})
|
||||
kitInstance.EmitSessionStart()
|
||||
}
|
||||
|
||||
sess := &acpSession{
|
||||
kit: kitInstance,
|
||||
cwd: cwd,
|
||||
sessionID: sessionID,
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
r.sessions[sessionID] = sess
|
||||
r.mu.Unlock()
|
||||
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
// get retrieves a session by ACP session ID.
|
||||
func (r *sessionRegistry) get(sessionID string) (*acpSession, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
s, ok := r.sessions[sessionID]
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// closeAll closes all sessions.
|
||||
func (r *sessionRegistry) closeAll() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for id, sess := range r.sessions {
|
||||
if sess.kit != nil {
|
||||
_ = sess.kit.Close()
|
||||
}
|
||||
delete(r.sessions, id)
|
||||
}
|
||||
}
|
||||
|
||||
// cancelPrompt cancels the current prompt for a session, if any.
|
||||
func (s *acpSession) cancelPrompt() {
|
||||
s.cancelMu.Lock()
|
||||
defer s.cancelMu.Unlock()
|
||||
if s.cancelFn != nil {
|
||||
s.cancelFn()
|
||||
s.cancelFn = nil
|
||||
}
|
||||
}
|
||||
|
||||
// setCancel stores a cancel function for the current prompt.
|
||||
func (s *acpSession) setCancel(cancel context.CancelFunc) {
|
||||
s.cancelMu.Lock()
|
||||
defer s.cancelMu.Unlock()
|
||||
s.cancelFn = cancel
|
||||
}
|
||||
|
||||
// clearCancel clears the stored cancel function (called when prompt completes).
|
||||
func (s *acpSession) clearCancel() {
|
||||
s.cancelMu.Lock()
|
||||
defer s.cancelMu.Unlock()
|
||||
s.cancelFn = nil
|
||||
}
|
||||
|
||||
// sdkEventToSubagentEvent converts an SDK event to an extension SubagentEvent.
|
||||
func sdkEventToSubagentEvent(e kit.Event) extensions.SubagentEvent {
|
||||
switch ev := e.(type) {
|
||||
case kit.MessageUpdateEvent:
|
||||
return extensions.SubagentEvent{Type: "text", Content: ev.Chunk}
|
||||
case kit.ReasoningDeltaEvent:
|
||||
return extensions.SubagentEvent{Type: "reasoning", Content: ev.Delta}
|
||||
case kit.ToolCallEvent:
|
||||
return extensions.SubagentEvent{
|
||||
Type: "tool_call", ToolCallID: ev.ToolCallID,
|
||||
ToolName: ev.ToolName, ToolKind: ev.ToolKind, ToolArgs: ev.ToolArgs,
|
||||
}
|
||||
case kit.ToolExecutionStartEvent:
|
||||
return extensions.SubagentEvent{
|
||||
Type: "tool_execution_start", ToolCallID: ev.ToolCallID,
|
||||
ToolName: ev.ToolName, ToolKind: ev.ToolKind,
|
||||
}
|
||||
case kit.ToolExecutionEndEvent:
|
||||
return extensions.SubagentEvent{
|
||||
Type: "tool_execution_end", ToolCallID: ev.ToolCallID,
|
||||
ToolName: ev.ToolName, ToolKind: ev.ToolKind,
|
||||
}
|
||||
case kit.ToolResultEvent:
|
||||
return extensions.SubagentEvent{
|
||||
Type: "tool_result", ToolCallID: ev.ToolCallID,
|
||||
ToolName: ev.ToolName, ToolKind: ev.ToolKind,
|
||||
ToolResult: ev.Result, IsError: ev.IsError,
|
||||
}
|
||||
case kit.TurnStartEvent:
|
||||
return extensions.SubagentEvent{Type: "turn_start"}
|
||||
case kit.TurnEndEvent:
|
||||
return extensions.SubagentEvent{Type: "turn_end"}
|
||||
default:
|
||||
return extensions.SubagentEvent{}
|
||||
}
|
||||
}
|
||||
+89
-20
@@ -41,13 +41,15 @@ type AgentConfig struct {
|
||||
}
|
||||
|
||||
// ToolCallHandler is a function type for handling tool calls as they happen.
|
||||
type ToolCallHandler func(toolName, toolArgs string)
|
||||
type ToolCallHandler func(toolCallID, toolName, toolArgs string)
|
||||
|
||||
// ToolExecutionHandler is a function type for handling tool execution start/end events.
|
||||
type ToolExecutionHandler func(toolName string, isStarting bool)
|
||||
type ToolExecutionHandler func(toolCallID, toolName, toolArgs string, isStarting bool)
|
||||
|
||||
// ToolResultHandler is a function type for handling tool results.
|
||||
type ToolResultHandler func(toolName, toolArgs, result string, isError bool)
|
||||
// The metadata parameter carries optional structured data (e.g. file diff
|
||||
// info) from the tool execution, JSON-encoded. It may be empty.
|
||||
type ToolResultHandler func(toolCallID, toolName, toolArgs, result, metadata string, isError bool)
|
||||
|
||||
// ResponseHandler is a function type for handling LLM responses.
|
||||
type ResponseHandler func(content string)
|
||||
@@ -58,6 +60,9 @@ type StreamingResponseHandler func(content string)
|
||||
// ToolCallContentHandler is a function type for handling content that accompanies tool calls.
|
||||
type ToolCallContentHandler func(content string)
|
||||
|
||||
// ReasoningDeltaHandler is a function type for handling streaming reasoning/thinking deltas.
|
||||
type ReasoningDeltaHandler func(delta string)
|
||||
|
||||
// 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.
|
||||
@@ -87,6 +92,8 @@ type GenerateWithLoopResult struct {
|
||||
Messages []message.Message
|
||||
// TotalUsage contains aggregate token usage across all steps
|
||||
TotalUsage fantasy.Usage
|
||||
// StopReason is the LLM provider's finish reason for the final response.
|
||||
StopReason string
|
||||
}
|
||||
|
||||
// NewAgent creates a new Agent with core tools and optional MCP tool integration.
|
||||
@@ -157,6 +164,27 @@ func NewAgent(ctx context.Context, agentConfig *AgentConfig) (*Agent, error) {
|
||||
))
|
||||
}
|
||||
|
||||
// Pass provider-specific options (e.g. OpenAI Responses API reasoning settings).
|
||||
if providerResult.ProviderOptions != nil {
|
||||
agentOpts = append(agentOpts, fantasy.WithProviderOptions(providerResult.ProviderOptions))
|
||||
}
|
||||
|
||||
// Pass generation parameters when available.
|
||||
if agentConfig.ModelConfig != nil {
|
||||
if agentConfig.ModelConfig.MaxTokens > 0 {
|
||||
agentOpts = append(agentOpts, fantasy.WithMaxOutputTokens(int64(agentConfig.ModelConfig.MaxTokens)))
|
||||
}
|
||||
if agentConfig.ModelConfig.Temperature != nil {
|
||||
agentOpts = append(agentOpts, fantasy.WithTemperature(float64(*agentConfig.ModelConfig.Temperature)))
|
||||
}
|
||||
if agentConfig.ModelConfig.TopP != nil {
|
||||
agentOpts = append(agentOpts, fantasy.WithTopP(float64(*agentConfig.ModelConfig.TopP)))
|
||||
}
|
||||
if agentConfig.ModelConfig.TopK != nil {
|
||||
agentOpts = append(agentOpts, fantasy.WithTopK(int64(*agentConfig.ModelConfig.TopK)))
|
||||
}
|
||||
}
|
||||
|
||||
// Create the fantasy agent
|
||||
fantasyAgent := fantasy.NewAgent(providerResult.Model, agentOpts...)
|
||||
|
||||
@@ -190,7 +218,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)
|
||||
onResponse, onToolCallContent, nil, nil)
|
||||
}
|
||||
|
||||
// GenerateWithLoopAndStreaming processes messages using the fantasy agent with streaming and callbacks.
|
||||
@@ -200,11 +228,14 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
onToolCall ToolCallHandler, onToolExecution ToolExecutionHandler, onToolResult ToolResultHandler,
|
||||
onResponse ResponseHandler, onToolCallContent ToolCallContentHandler,
|
||||
onStreamingResponse StreamingResponseHandler,
|
||||
onReasoningDelta ReasoningDeltaHandler,
|
||||
) (*GenerateWithLoopResult, error) {
|
||||
|
||||
// Fantasy requires the current user input as Prompt, with prior messages as history.
|
||||
// Extract the last user message text as the prompt, and pass everything before it as Messages.
|
||||
prompt, history := splitPromptAndHistory(messages)
|
||||
// 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
|
||||
// field so Fantasy includes them in the API request.
|
||||
prompt, files, history := splitPromptAndHistory(messages)
|
||||
|
||||
// Track current tool call info for callbacks
|
||||
var currentToolName string
|
||||
@@ -215,14 +246,26 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
// Stream is required to observe tool execution in real time. The non-streaming
|
||||
// Generate path is reserved for the simple case with no callbacks at all.
|
||||
hasCallbacks := onToolCall != nil || onToolExecution != nil || onToolResult != nil ||
|
||||
onToolCallContent != nil || onStreamingResponse != nil
|
||||
onToolCallContent != nil || onStreamingResponse != nil || onReasoningDelta != nil
|
||||
|
||||
if a.streamingEnabled || hasCallbacks {
|
||||
// Use fantasy's streaming agent
|
||||
result, err := a.fantasyAgent.Stream(ctx, fantasy.AgentStreamCall{
|
||||
Prompt: prompt,
|
||||
Files: files,
|
||||
Messages: history,
|
||||
|
||||
// Reasoning/thinking streaming callback
|
||||
OnReasoningDelta: func(id, delta string) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if onReasoningDelta != nil {
|
||||
onReasoningDelta(delta)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
// Text streaming callback
|
||||
OnTextDelta: func(id, text string) error {
|
||||
if ctx.Err() != nil {
|
||||
@@ -244,12 +287,12 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
|
||||
// Notify about the tool call
|
||||
if onToolCall != nil {
|
||||
onToolCall(tc.ToolName, tc.Input)
|
||||
onToolCall(tc.ToolCallID, tc.ToolName, tc.Input)
|
||||
}
|
||||
|
||||
// Notify tool execution starting
|
||||
if onToolExecution != nil {
|
||||
onToolExecution(tc.ToolName, true)
|
||||
onToolExecution(tc.ToolCallID, tc.ToolName, tc.Input, true)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -262,13 +305,13 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
}
|
||||
// Notify tool execution finished
|
||||
if onToolExecution != nil {
|
||||
onToolExecution(tr.ToolName, false)
|
||||
onToolExecution(tr.ToolCallID, tr.ToolName, currentToolArgs, false)
|
||||
}
|
||||
|
||||
if onToolResult != nil {
|
||||
// Extract result text and error status
|
||||
resultText, isError := extractToolResultText(tr)
|
||||
onToolResult(tr.ToolName, currentToolArgs, resultText, isError)
|
||||
onToolResult(tr.ToolCallID, tr.ToolName, currentToolArgs, resultText, tr.ClientMetadata, isError)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -304,6 +347,7 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
// Non-streaming path with no callbacks — use the simpler Generate call.
|
||||
result, err := a.fantasyAgent.Generate(ctx, fantasy.AgentCall{
|
||||
Prompt: prompt,
|
||||
Files: files,
|
||||
Messages: history,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -324,27 +368,32 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
// and returns everything before it as conversation history. Fantasy's agent
|
||||
// requires the current turn's input as Prompt (string), with prior messages
|
||||
// passed separately as Messages (history).
|
||||
func splitPromptAndHistory(messages []fantasy.Message) (string, []fantasy.Message) {
|
||||
func splitPromptAndHistory(messages []fantasy.Message) (string, []fantasy.FilePart, []fantasy.Message) {
|
||||
if len(messages) == 0 {
|
||||
return "", nil
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
// Walk backwards to find the last user message
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
if messages[i].Role == fantasy.MessageRoleUser {
|
||||
// Extract text from the user message parts
|
||||
// Extract text and file parts from the user message
|
||||
var prompt string
|
||||
var files []fantasy.FilePart
|
||||
for _, part := range messages[i].Content {
|
||||
if tp, ok := part.(fantasy.TextPart); ok {
|
||||
prompt = tp.Text
|
||||
break
|
||||
switch p := part.(type) {
|
||||
case fantasy.TextPart:
|
||||
if prompt == "" {
|
||||
prompt = p.Text
|
||||
}
|
||||
case fantasy.FilePart:
|
||||
files = append(files, p)
|
||||
}
|
||||
}
|
||||
// History is everything except this last user message
|
||||
history := make([]fantasy.Message, 0, len(messages)-1)
|
||||
history = append(history, messages[:i]...)
|
||||
history = append(history, messages[i+1:]...)
|
||||
return prompt, history
|
||||
return prompt, files, history
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,11 +401,11 @@ func splitPromptAndHistory(messages []fantasy.Message) (string, []fantasy.Messag
|
||||
last := messages[len(messages)-1]
|
||||
for _, part := range last.Content {
|
||||
if tp, ok := part.(fantasy.TextPart); ok {
|
||||
return tp.Text, messages[:len(messages)-1]
|
||||
return tp.Text, nil, messages[:len(messages)-1]
|
||||
}
|
||||
}
|
||||
|
||||
return "", messages
|
||||
return "", nil, messages
|
||||
}
|
||||
|
||||
// convertAgentResult converts a fantasy AgentResult to our GenerateWithLoopResult.
|
||||
@@ -381,6 +430,7 @@ func convertAgentResult(result *fantasy.AgentResult, originalMessages []fantasy.
|
||||
ConversationMessages: allFantasyMessages,
|
||||
Messages: allMessages,
|
||||
TotalUsage: result.TotalUsage,
|
||||
StopReason: string(result.Response.FinishReason),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -524,6 +574,25 @@ func (a *Agent) SetModel(ctx context.Context, config *models.ProviderConfig) err
|
||||
))
|
||||
}
|
||||
|
||||
// Pass provider-specific options (e.g. OpenAI Responses API reasoning settings).
|
||||
if providerResult.ProviderOptions != nil {
|
||||
agentOpts = append(agentOpts, fantasy.WithProviderOptions(providerResult.ProviderOptions))
|
||||
}
|
||||
|
||||
// Pass generation parameters when available.
|
||||
if config.MaxTokens > 0 {
|
||||
agentOpts = append(agentOpts, fantasy.WithMaxOutputTokens(int64(config.MaxTokens)))
|
||||
}
|
||||
if config.Temperature != nil {
|
||||
agentOpts = append(agentOpts, fantasy.WithTemperature(float64(*config.Temperature)))
|
||||
}
|
||||
if config.TopP != nil {
|
||||
agentOpts = append(agentOpts, fantasy.WithTopP(float64(*config.TopP)))
|
||||
}
|
||||
if config.TopK != nil {
|
||||
agentOpts = append(agentOpts, fantasy.WithTopK(int64(*config.TopK)))
|
||||
}
|
||||
|
||||
newFantasyAgent := fantasy.NewAgent(providerResult.Model, agentOpts...)
|
||||
|
||||
// Close old provider.
|
||||
|
||||
+52
-24
@@ -13,6 +13,12 @@ import (
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
)
|
||||
|
||||
// queueItem holds a prompt and optional image attachments for the execution queue.
|
||||
type queueItem struct {
|
||||
Prompt string
|
||||
Files []fantasy.FilePart
|
||||
}
|
||||
|
||||
// App is the application-layer orchestrator. It owns the agentic loop,
|
||||
// conversation history (via MessageStore), and queue management. It is
|
||||
// designed to be created once per session and reused across multiple prompts.
|
||||
@@ -47,7 +53,7 @@ type App struct {
|
||||
// mu protects busy, queue, and cancelStep.
|
||||
mu sync.Mutex
|
||||
busy bool
|
||||
queue []string
|
||||
queue []queueItem
|
||||
|
||||
// wg tracks in-flight goroutines; Close() waits on it.
|
||||
wg sync.WaitGroup
|
||||
@@ -100,6 +106,16 @@ func (a *App) SetProgram(p *tea.Program) {
|
||||
//
|
||||
// Satisfies ui.AppController.
|
||||
func (a *App) Run(prompt string) int {
|
||||
return a.RunWithFiles(prompt, nil)
|
||||
}
|
||||
|
||||
// RunWithFiles queues a multimodal prompt (text + image files) for execution.
|
||||
// If the app is idle the prompt executes immediately; otherwise it is queued.
|
||||
// Returns the current queue depth (0 = started immediately, >0 = queued).
|
||||
//
|
||||
// Satisfies ui.AppController (via RunWithImages which converts ImageAttachment
|
||||
// to fantasy.FilePart).
|
||||
func (a *App) RunWithFiles(prompt string, files []fantasy.FilePart) int {
|
||||
a.mu.Lock()
|
||||
|
||||
if a.closed {
|
||||
@@ -107,8 +123,10 @@ func (a *App) Run(prompt string) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
item := queueItem{Prompt: prompt, Files: files}
|
||||
|
||||
if a.busy {
|
||||
a.queue = append(a.queue, prompt)
|
||||
a.queue = append(a.queue, item)
|
||||
qLen := len(a.queue)
|
||||
a.mu.Unlock()
|
||||
return qLen
|
||||
@@ -117,7 +135,7 @@ func (a *App) Run(prompt string) int {
|
||||
a.busy = true
|
||||
a.wg.Add(1)
|
||||
a.mu.Unlock()
|
||||
go a.drainQueue(prompt)
|
||||
go a.drainQueue(item)
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -153,17 +171,19 @@ func (a *App) Steer(prompt string) {
|
||||
return
|
||||
}
|
||||
|
||||
item := queueItem{Prompt: prompt}
|
||||
|
||||
if !a.busy {
|
||||
// Not busy — start immediately, same as Run().
|
||||
a.busy = true
|
||||
a.wg.Add(1)
|
||||
a.mu.Unlock()
|
||||
go a.drainQueue(prompt)
|
||||
go a.drainQueue(item)
|
||||
return
|
||||
}
|
||||
|
||||
// Agent is busy: clear queue, insert steer message, then cancel.
|
||||
a.queue = []string{prompt}
|
||||
a.queue = []queueItem{item}
|
||||
cancel := a.cancelStep
|
||||
a.mu.Unlock()
|
||||
cancel()
|
||||
@@ -287,7 +307,7 @@ func (a *App) RunOnce(ctx context.Context, prompt string) error {
|
||||
a.cancelStep = cancel
|
||||
a.mu.Unlock()
|
||||
|
||||
result, err := a.executeStep(stepCtx, prompt, nil)
|
||||
result, err := a.executeStep(stepCtx, prompt, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -309,7 +329,7 @@ func (a *App) RunOnceResult(ctx context.Context, prompt string) (*kit.TurnResult
|
||||
a.cancelStep = cancel
|
||||
a.mu.Unlock()
|
||||
|
||||
return a.executeStep(stepCtx, prompt, nil)
|
||||
return a.executeStep(stepCtx, prompt, nil, nil)
|
||||
}
|
||||
|
||||
// RunOnceWithDisplay executes a single agent step synchronously, sending
|
||||
@@ -330,7 +350,7 @@ func (a *App) RunOnceWithDisplay(ctx context.Context, prompt string, eventFn fun
|
||||
a.cancelStep = cancel
|
||||
a.mu.Unlock()
|
||||
|
||||
result, err := a.executeStep(stepCtx, prompt, eventFn)
|
||||
result, err := a.executeStep(stepCtx, prompt, eventFn, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -371,15 +391,15 @@ func (a *App) Close() {
|
||||
// Internal: queue drain loop
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// drainQueue runs in a goroutine. It executes the given prompt and then
|
||||
// drainQueue runs in a goroutine. It executes the given item and then
|
||||
// continues draining the queue until it is empty.
|
||||
// Must be called with a.busy == true and a.wg incremented.
|
||||
func (a *App) drainQueue(firstPrompt string) {
|
||||
func (a *App) drainQueue(first queueItem) {
|
||||
defer a.wg.Done()
|
||||
|
||||
prompt := firstPrompt
|
||||
item := first
|
||||
for {
|
||||
a.runPrompt(prompt)
|
||||
a.runQueueItem(item)
|
||||
|
||||
a.mu.Lock()
|
||||
// Stop draining if the app is shutting down.
|
||||
@@ -394,7 +414,7 @@ func (a *App) drainQueue(firstPrompt string) {
|
||||
a.mu.Unlock()
|
||||
return
|
||||
}
|
||||
prompt = a.queue[0]
|
||||
item = a.queue[0]
|
||||
a.queue = a.queue[1:]
|
||||
qLen := len(a.queue)
|
||||
a.mu.Unlock()
|
||||
@@ -403,9 +423,9 @@ func (a *App) drainQueue(firstPrompt string) {
|
||||
}
|
||||
}
|
||||
|
||||
// runPrompt executes a single prompt: adds the user message to the store,
|
||||
// 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) runPrompt(prompt string) {
|
||||
func (a *App) runQueueItem(item queueItem) {
|
||||
// Create a per-step cancellable context.
|
||||
stepCtx, cancel := context.WithCancel(a.rootCtx)
|
||||
a.mu.Lock()
|
||||
@@ -424,7 +444,7 @@ func (a *App) runPrompt(prompt string) {
|
||||
}
|
||||
}
|
||||
|
||||
result, err := a.executeStep(stepCtx, prompt, eventFn)
|
||||
result, err := a.executeStep(stepCtx, item.Prompt, eventFn, item.Files)
|
||||
if err != nil {
|
||||
if stepCtx.Err() != nil {
|
||||
// Step was cancelled by the user (e.g. double-ESC). Send a
|
||||
@@ -445,9 +465,9 @@ func (a *App) runPrompt(prompt string) {
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// executeStep runs a single agentic step by delegating to the SDK's
|
||||
// PromptResult(), which handles session persistence, hooks, extension
|
||||
// events, and the generation loop.
|
||||
func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(tea.Msg)) (*kit.TurnResult, error) {
|
||||
// PromptResult() (or PromptResultWithFiles for multimodal), which handles
|
||||
// session persistence, hooks, extension events, and the generation loop.
|
||||
func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(tea.Msg), files []fantasy.FilePart) (*kit.TurnResult, error) {
|
||||
// Test hook: bypass SDK entirely.
|
||||
if a.opts.PromptFunc != nil {
|
||||
return a.opts.PromptFunc(ctx, prompt)
|
||||
@@ -467,7 +487,13 @@ func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(tea.M
|
||||
// Show spinner while the agent works.
|
||||
sendFn(SpinnerEvent{Show: true})
|
||||
|
||||
result, err := a.opts.Kit.PromptResult(ctx, prompt)
|
||||
var result *kit.TurnResult
|
||||
var err error
|
||||
if len(files) > 0 {
|
||||
result, err = a.opts.Kit.PromptResultWithFiles(ctx, prompt, files)
|
||||
} else {
|
||||
result, err = a.opts.Kit.PromptResult(ctx, prompt)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -506,14 +532,14 @@ func (a *App) subscribeSDKEvents(sendFn func(tea.Msg)) func() {
|
||||
unsubs = append(unsubs, k.Subscribe(func(e kit.Event) {
|
||||
switch ev := e.(type) {
|
||||
case kit.ToolCallEvent:
|
||||
sendFn(ToolCallStartedEvent{ToolName: ev.ToolName, ToolArgs: ev.ToolArgs})
|
||||
sendFn(ToolCallStartedEvent{ToolCallID: ev.ToolCallID, ToolName: ev.ToolName, ToolArgs: ev.ToolArgs})
|
||||
case kit.ToolExecutionStartEvent:
|
||||
sendFn(ToolExecutionEvent{ToolName: ev.ToolName, IsStarting: true})
|
||||
sendFn(ToolExecutionEvent{ToolCallID: ev.ToolCallID, ToolName: ev.ToolName, ToolArgs: ev.ToolArgs, IsStarting: true})
|
||||
case kit.ToolExecutionEndEvent:
|
||||
sendFn(ToolExecutionEvent{ToolName: ev.ToolName, IsStarting: false})
|
||||
sendFn(ToolExecutionEvent{ToolCallID: ev.ToolCallID, ToolName: ev.ToolName, IsStarting: false})
|
||||
case kit.ToolResultEvent:
|
||||
sendFn(ToolResultEvent{
|
||||
ToolName: ev.ToolName, ToolArgs: ev.ToolArgs,
|
||||
ToolCallID: ev.ToolCallID, ToolName: ev.ToolName, ToolArgs: ev.ToolArgs,
|
||||
Result: ev.Result, IsError: ev.IsError,
|
||||
})
|
||||
case kit.ToolCallContentEvent:
|
||||
@@ -522,6 +548,8 @@ func (a *App) subscribeSDKEvents(sendFn func(tea.Msg)) func() {
|
||||
sendFn(ResponseCompleteEvent{Content: ev.Content})
|
||||
case kit.MessageUpdateEvent:
|
||||
sendFn(StreamChunkEvent{Content: ev.Chunk})
|
||||
case kit.ReasoningDeltaEvent:
|
||||
sendFn(ReasoningChunkEvent{Delta: ev.Delta})
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
@@ -494,7 +494,11 @@ func TestQueueLength_reflects(t *testing.T) {
|
||||
}
|
||||
|
||||
app.mu.Lock()
|
||||
app.queue = append(app.queue, "a", "b", "c")
|
||||
app.queue = append(app.queue,
|
||||
queueItem{Prompt: "a"},
|
||||
queueItem{Prompt: "b"},
|
||||
queueItem{Prompt: "c"},
|
||||
)
|
||||
app.mu.Unlock()
|
||||
|
||||
if got := app.QueueLength(); got != 3 {
|
||||
|
||||
@@ -9,9 +9,18 @@ type StreamChunkEvent struct {
|
||||
Content string
|
||||
}
|
||||
|
||||
// ReasoningChunkEvent is sent when a streaming reasoning/thinking delta arrives
|
||||
// from the LLM. Thinking content is rendered separately from regular text.
|
||||
type ReasoningChunkEvent struct {
|
||||
// Delta is the incremental reasoning text from the streaming response.
|
||||
Delta string
|
||||
}
|
||||
|
||||
// ToolCallStartedEvent is sent when a tool call has been parsed and is about to execute.
|
||||
// It carries the tool name and its arguments for display purposes.
|
||||
type ToolCallStartedEvent struct {
|
||||
// ToolCallID is the stable identifier for correlating tool lifecycle events.
|
||||
ToolCallID string
|
||||
// ToolName is the name of the tool being called.
|
||||
ToolName string
|
||||
// ToolArgs is the JSON-encoded arguments for the tool call.
|
||||
@@ -21,14 +30,20 @@ type ToolCallStartedEvent struct {
|
||||
// ToolExecutionEvent is sent when a tool starts or finishes executing.
|
||||
// The IsStarting flag distinguishes between the start and end of execution.
|
||||
type ToolExecutionEvent struct {
|
||||
// ToolCallID is the stable identifier for correlating tool lifecycle events.
|
||||
ToolCallID string
|
||||
// ToolName is the name of the tool being executed.
|
||||
ToolName string
|
||||
// ToolArgs is the JSON-encoded arguments for the tool call (only set when IsStarting is true).
|
||||
ToolArgs string
|
||||
// IsStarting is true when execution is beginning, false when it is complete.
|
||||
IsStarting bool
|
||||
}
|
||||
|
||||
// ToolResultEvent is sent after a tool execution completes with its result.
|
||||
type ToolResultEvent struct {
|
||||
// ToolCallID is the stable identifier for correlating tool lifecycle events.
|
||||
ToolCallID string
|
||||
// ToolName is the name of the tool that was executed.
|
||||
ToolName string
|
||||
// ToolArgs is the JSON-encoded arguments that were passed to the tool.
|
||||
|
||||
@@ -51,6 +51,7 @@ func TestCredentialManager(t *testing.T) {
|
||||
}
|
||||
if creds == nil {
|
||||
t.Fatal("Expected credentials to be returned")
|
||||
return
|
||||
}
|
||||
if creds.APIKey != testAPIKey {
|
||||
t.Errorf("Expected API key %s, got %s", testAPIKey, creds.APIKey)
|
||||
@@ -236,6 +237,7 @@ func TestCredentialStorePersistence(t *testing.T) {
|
||||
}
|
||||
if creds == nil {
|
||||
t.Fatal("Expected credentials to persist")
|
||||
return
|
||||
}
|
||||
if creds.APIKey != testAPIKey {
|
||||
t.Errorf("Expected API key %s, got %s", testAPIKey, creds.APIKey)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// Package clipboard provides cross-platform clipboard image reading for Kit.
|
||||
//
|
||||
// Terminals cannot paste binary image data via bracketed paste — only text is
|
||||
// supported. To read images we shell out to platform-specific clipboard tools:
|
||||
//
|
||||
// - Linux X11: xclip -selection clipboard -t image/png -o
|
||||
// - Linux Wayland: wl-paste --type image/png
|
||||
// - macOS: osascript + pbpaste (via a helper that reads NSPasteboard)
|
||||
// - Windows/WSL: powershell Get-Clipboard -Format Image (not yet supported)
|
||||
//
|
||||
// The ReadImage function returns the raw image bytes and detected MIME type,
|
||||
// or an error if no image is available on the clipboard.
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ImageData holds the result of a clipboard image read.
|
||||
type ImageData struct {
|
||||
// Data is the raw image bytes (PNG, JPEG, etc.).
|
||||
Data []byte
|
||||
// MediaType is the MIME type (e.g. "image/png", "image/jpeg").
|
||||
MediaType string
|
||||
}
|
||||
|
||||
// DetectMediaType inspects the magic bytes of data to determine the image
|
||||
// MIME type. Returns empty string if the format is not recognized.
|
||||
func DetectMediaType(data []byte) string {
|
||||
if len(data) < 8 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
||||
if data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 &&
|
||||
data[4] == 0x0D && data[5] == 0x0A && data[6] == 0x1A && data[7] == 0x0A {
|
||||
return "image/png"
|
||||
}
|
||||
|
||||
// JPEG: FF D8 FF
|
||||
if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
|
||||
return "image/jpeg"
|
||||
}
|
||||
|
||||
// GIF: 47 49 46 38
|
||||
if data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x38 {
|
||||
return "image/gif"
|
||||
}
|
||||
|
||||
// WebP: RIFF....WEBP
|
||||
if len(data) >= 12 &&
|
||||
data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 &&
|
||||
data[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50 {
|
||||
return "image/webp"
|
||||
}
|
||||
|
||||
// BMP: 42 4D
|
||||
if data[0] == 0x42 && data[1] == 0x4D {
|
||||
return "image/bmp"
|
||||
}
|
||||
|
||||
// TIFF: 49 49 2A 00 (little-endian) or 4D 4D 00 2A (big-endian)
|
||||
if (data[0] == 0x49 && data[1] == 0x49 && data[2] == 0x2A && data[3] == 0x00) ||
|
||||
(data[0] == 0x4D && data[1] == 0x4D && data[2] == 0x00 && data[3] == 0x2A) {
|
||||
return "image/tiff"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// 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)")
|
||||
@@ -0,0 +1,45 @@
|
||||
//go:build darwin
|
||||
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// 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.
|
||||
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.
|
||||
script := `use framework "AppKit"
|
||||
set pb to current application's NSPasteboard's generalPasteboard()
|
||||
set imgData to pb's dataForType:(current application's NSPasteboardTypePNG)
|
||||
if imgData is missing value then
|
||||
set tiffData to pb's dataForType:(current application's NSPasteboardTypeTIFF)
|
||||
if tiffData is missing value then
|
||||
error "No image on clipboard"
|
||||
end if
|
||||
set bitmapRep to current application's NSBitmapImageRep's imageRepWithData:tiffData
|
||||
set imgData to bitmapRep's representationUsingType:(current application's NSPNGFileType) |properties|:(missing value)
|
||||
end if
|
||||
imgData's writeToFile:"/dev/stdout" atomically:false`
|
||||
|
||||
cmd := exec.Command("osascript", "-l", "AppleScript", "-e", script)
|
||||
data, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, ErrNoImage
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, ErrNoImage
|
||||
}
|
||||
|
||||
mediaType := DetectMediaType(data)
|
||||
if mediaType == "" {
|
||||
mediaType = "image/png" // osascript converts to PNG
|
||||
}
|
||||
|
||||
return &ImageData{Data: data, MediaType: mediaType}, nil
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//go:build integration
|
||||
|
||||
package clipboard_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/mark3labs/kit/internal/clipboard"
|
||||
)
|
||||
|
||||
// TestReadImageIntegration tests reading an image from the system clipboard.
|
||||
// Run with: WAYLAND_DISPLAY=wayland-1 go test -tags integration -v -run TestReadImageIntegration ./internal/clipboard/
|
||||
//
|
||||
// Prerequisites: copy an image to the clipboard first, e.g.:
|
||||
//
|
||||
// WAYLAND_DISPLAY=wayland-1 wl-copy --type image/png < ~/Pictures/Screenshots/some_screenshot.png
|
||||
func TestReadImageIntegration(t *testing.T) {
|
||||
if os.Getenv("WAYLAND_DISPLAY") == "" && os.Getenv("DISPLAY") == "" {
|
||||
t.Skip("no display server available (set WAYLAND_DISPLAY or DISPLAY)")
|
||||
}
|
||||
|
||||
img, err := clipboard.ReadImage()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadImage() error: %v", err)
|
||||
}
|
||||
|
||||
if img == nil {
|
||||
t.Fatal("ReadImage() returned nil without error")
|
||||
}
|
||||
|
||||
t.Logf("Image data: %d bytes", len(img.Data))
|
||||
t.Logf("Media type: %s", img.MediaType)
|
||||
|
||||
if len(img.Data) == 0 {
|
||||
t.Fatal("image data is empty")
|
||||
}
|
||||
|
||||
if img.MediaType == "" {
|
||||
t.Fatal("media type is empty")
|
||||
}
|
||||
|
||||
// Verify magic bytes match the declared media type.
|
||||
detected := clipboard.DetectMediaType(img.Data)
|
||||
if detected == "" {
|
||||
t.Fatal("could not detect image format from magic bytes")
|
||||
}
|
||||
t.Logf("Detected format: %s", detected)
|
||||
|
||||
if detected != img.MediaType {
|
||||
t.Errorf("media type mismatch: declared=%s detected=%s", img.MediaType, detected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectMediaType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data []byte
|
||||
expected string
|
||||
}{
|
||||
{"PNG", []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00}, "image/png"},
|
||||
{"JPEG", []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49}, "image/jpeg"},
|
||||
{"GIF", []byte{0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x00, 0x00, 0x00}, "image/gif"},
|
||||
{"BMP", []byte{0x42, 0x4D, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, "image/bmp"},
|
||||
{"WebP", []byte{0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50}, "image/webp"},
|
||||
{"TIFF-LE", []byte{0x49, 0x49, 0x2A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, "image/tiff"},
|
||||
{"TIFF-BE", []byte{0x4D, 0x4D, 0x00, 0x2A, 0x00, 0x00, 0x00, 0x00, 0x00}, "image/tiff"},
|
||||
{"unknown", []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}, ""},
|
||||
{"too short", []byte{0x89, 0x50}, ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := clipboard.DetectMediaType(tt.data)
|
||||
if got != tt.expected {
|
||||
t.Errorf("DetectMediaType() = %q, want %q", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//go:build linux
|
||||
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// ReadImage reads image data from the system clipboard on Linux.
|
||||
// It tries xclip first (X11), then falls back to wl-paste (Wayland).
|
||||
func ReadImage() (*ImageData, error) {
|
||||
// Try xclip first (X11).
|
||||
if path, err := exec.LookPath("xclip"); err == nil {
|
||||
data, err := readWithXclip(path)
|
||||
if err == nil && len(data) > 0 {
|
||||
mediaType := DetectMediaType(data)
|
||||
if mediaType == "" {
|
||||
mediaType = "image/png" // xclip was asked for image/png
|
||||
}
|
||||
return &ImageData{Data: data, MediaType: mediaType}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to wl-paste (Wayland).
|
||||
if path, err := exec.LookPath("wl-paste"); err == nil {
|
||||
data, err := readWithWlPaste(path)
|
||||
if err == nil && len(data) > 0 {
|
||||
mediaType := DetectMediaType(data)
|
||||
if mediaType == "" {
|
||||
mediaType = "image/png"
|
||||
}
|
||||
return &ImageData{Data: data, MediaType: mediaType}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Check if either tool exists but just had no image.
|
||||
if _, err := exec.LookPath("xclip"); err == nil {
|
||||
return nil, ErrNoImage
|
||||
}
|
||||
if _, err := exec.LookPath("wl-paste"); err == nil {
|
||||
return nil, ErrNoImage
|
||||
}
|
||||
|
||||
return nil, ErrNoClipboardTool
|
||||
}
|
||||
|
||||
// readWithXclip reads image data using xclip.
|
||||
func readWithXclip(xclipPath string) ([]byte, error) {
|
||||
cmd := exec.Command(xclipPath, "-selection", "clipboard", "-t", "image/png", "-o")
|
||||
return cmd.Output()
|
||||
}
|
||||
|
||||
// readWithWlPaste reads image data using wl-paste.
|
||||
func readWithWlPaste(wlPastePath string) ([]byte, error) {
|
||||
cmd := exec.Command(wlPastePath, "--type", "image/png")
|
||||
return cmd.Output()
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build windows
|
||||
|
||||
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
|
||||
}
|
||||
@@ -165,6 +165,9 @@ type Config struct {
|
||||
TopK *int32 `json:"top-k,omitempty" yaml:"top-k,omitempty"`
|
||||
StopSequences []string `json:"stop-sequences,omitempty" yaml:"stop-sequences,omitempty"`
|
||||
|
||||
// Thinking / extended reasoning
|
||||
ThinkingLevel string `json:"thinking-level,omitempty" yaml:"thinking-level,omitempty"`
|
||||
|
||||
// TLS configuration
|
||||
TLSSkipVerify bool `json:"tls-skip-verify,omitempty" yaml:"tls-skip-verify,omitempty"`
|
||||
}
|
||||
|
||||
+21
-3
@@ -76,13 +76,15 @@ func executeEdit(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
|
||||
// If no exact match, try fuzzy matching
|
||||
if count == 0 {
|
||||
if idx, matchLen := fuzzyMatch(normalized, normalizedOld); idx >= 0 {
|
||||
// Apply fuzzy match
|
||||
// Apply fuzzy match — the matched text is the original content slice
|
||||
matchedText := normalized[idx : idx+matchLen]
|
||||
newContent := normalized[:idx] + args.NewText + normalized[idx+matchLen:]
|
||||
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)
|
||||
return fantasy.NewTextResponse(fmt.Sprintf("Applied edit (fuzzy match) to %s\n%s", args.Path, diff)), nil
|
||||
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
|
||||
}
|
||||
return fantasy.NewTextErrorResponse(fmt.Sprintf("old_text not found in %s", args.Path)), nil
|
||||
}
|
||||
@@ -100,7 +102,23 @@ func executeEdit(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
|
||||
|
||||
idx := strings.Index(normalized, normalizedOld)
|
||||
diff := generateDiff(absPath, normalized, newContent, idx)
|
||||
return fantasy.NewTextResponse(fmt.Sprintf("Applied edit to %s\n%s", args.Path, diff)), nil
|
||||
resp := fantasy.NewTextResponse(fmt.Sprintf("Applied edit to %s\n%s", args.Path, diff))
|
||||
return fantasy.WithResponseMetadata(resp, editDiffMeta(absPath, normalizedOld, args.NewText)), nil
|
||||
}
|
||||
|
||||
// editDiffMeta builds the structured metadata attached to edit tool responses.
|
||||
func editDiffMeta(path, oldText, newText string) map[string]any {
|
||||
return map[string]any{
|
||||
"file_diffs": []map[string]any{{
|
||||
"path": path,
|
||||
"additions": strings.Count(newText, "\n") + 1,
|
||||
"deletions": strings.Count(oldText, "\n") + 1,
|
||||
"diff_blocks": []map[string]any{{
|
||||
"old_text": oldText,
|
||||
"new_text": newText,
|
||||
}},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// fuzzyMatch tries to find old_text with relaxed matching:
|
||||
|
||||
@@ -39,6 +39,7 @@ func NewFindTool(opts ...ToolOption) fantasy.AgentTool {
|
||||
},
|
||||
},
|
||||
Required: []string{"pattern"},
|
||||
Parallel: true,
|
||||
},
|
||||
handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
return executeFind(ctx, call, cfg.WorkDir)
|
||||
|
||||
@@ -59,6 +59,7 @@ func NewGrepTool(opts ...ToolOption) fantasy.AgentTool {
|
||||
},
|
||||
},
|
||||
Required: []string{"pattern"},
|
||||
Parallel: true,
|
||||
},
|
||||
handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
return executeGrep(ctx, call, cfg.WorkDir)
|
||||
|
||||
@@ -33,6 +33,7 @@ func NewLsTool(opts ...ToolOption) fantasy.AgentTool {
|
||||
},
|
||||
},
|
||||
Required: []string{},
|
||||
Parallel: true,
|
||||
},
|
||||
handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
return executeLs(ctx, call, cfg.WorkDir)
|
||||
|
||||
@@ -38,6 +38,7 @@ func NewReadTool(opts ...ToolOption) fantasy.AgentTool {
|
||||
},
|
||||
},
|
||||
Required: []string{"path"},
|
||||
Parallel: true,
|
||||
},
|
||||
handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
return executeRead(ctx, call, cfg.WorkDir)
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
)
|
||||
|
||||
const defaultSubagentTimeout = 5 * time.Minute
|
||||
const maxSubagentTimeout = 30 * time.Minute
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context-based subagent spawner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SubagentSpawnResult carries the outcome of an in-process subagent spawn.
|
||||
type SubagentSpawnResult struct {
|
||||
Response string
|
||||
Error error
|
||||
SessionID string
|
||||
InputTokens int64
|
||||
OutputTokens int64
|
||||
Elapsed time.Duration
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
type subagentCtxKey struct{}
|
||||
|
||||
// WithSubagentSpawner stores a spawn function in the context so that the
|
||||
// spawn_subagent core tool can create in-process subagents.
|
||||
func WithSubagentSpawner(ctx context.Context, fn SubagentSpawnFunc) context.Context {
|
||||
return context.WithValue(ctx, subagentCtxKey{}, fn)
|
||||
}
|
||||
|
||||
// getSubagentSpawner retrieves the spawn function from the context.
|
||||
func getSubagentSpawner(ctx context.Context) SubagentSpawnFunc {
|
||||
if fn, ok := ctx.Value(subagentCtxKey{}).(SubagentSpawnFunc); ok {
|
||||
return fn
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// spawn_subagent tool
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type subagentArgs struct {
|
||||
Task string `json:"task"`
|
||||
Model string `json:"model,omitempty"`
|
||||
SystemPrompt string `json:"system_prompt,omitempty"`
|
||||
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
|
||||
}
|
||||
|
||||
// NewSubagentTool creates the spawn_subagent core tool.
|
||||
func NewSubagentTool(opts ...ToolOption) fantasy.AgentTool {
|
||||
return &coreTool{
|
||||
info: fantasy.ToolInfo{
|
||||
Name: "spawn_subagent",
|
||||
Description: `Spawn a subagent to perform a task autonomously.
|
||||
|
||||
The subagent runs as a separate in-process Kit instance with full tool access
|
||||
(except spawning further subagents). Use this to:
|
||||
- Delegate independent subtasks that can run in parallel
|
||||
- Perform research or analysis without blocking your main work
|
||||
- Execute tasks that benefit from a fresh context window
|
||||
|
||||
The subagent result is returned when it completes. For long-running tasks,
|
||||
consider breaking them into smaller focused subtasks.
|
||||
|
||||
Example use cases:
|
||||
- "Research the authentication patterns in this codebase"
|
||||
- "Write unit tests for the UserService class"
|
||||
- "Analyze the performance bottlenecks in the database queries"`,
|
||||
Parameters: map[string]any{
|
||||
"task": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The complete task description for the subagent to perform",
|
||||
},
|
||||
"model": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional model override (e.g. 'anthropic/claude-haiku-3-5-20241022' for faster/cheaper tasks)",
|
||||
},
|
||||
"system_prompt": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional system prompt for domain-specific guidance",
|
||||
},
|
||||
"timeout_seconds": map[string]any{
|
||||
"type": "number",
|
||||
"description": "Maximum execution time in seconds (default: 300, max: 1800)",
|
||||
},
|
||||
},
|
||||
Required: []string{"task"},
|
||||
Parallel: true,
|
||||
},
|
||||
handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
return executeSubagent(ctx, call)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func executeSubagent(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
var args subagentArgs
|
||||
if err := parseArgs(call.Input, &args); err != nil {
|
||||
return fantasy.NewTextErrorResponse("task parameter is required"), nil
|
||||
}
|
||||
if args.Task == "" {
|
||||
return fantasy.NewTextErrorResponse("task parameter is required"), nil
|
||||
}
|
||||
|
||||
// Determine timeout.
|
||||
timeout := defaultSubagentTimeout
|
||||
if args.TimeoutSeconds > 0 {
|
||||
timeout = min(time.Duration(args.TimeoutSeconds)*time.Second, maxSubagentTimeout)
|
||||
}
|
||||
|
||||
// Retrieve in-process spawner from context.
|
||||
spawner := getSubagentSpawner(ctx)
|
||||
if spawner == nil {
|
||||
return fantasy.NewTextErrorResponse(
|
||||
"Error: subagent spawner not available. " +
|
||||
"Ensure Kit is initialized with subagent support.",
|
||||
), fmt.Errorf("no subagent spawner in context")
|
||||
}
|
||||
|
||||
// Spawn in-process subagent.
|
||||
result, err := spawner(ctx, args.Task, args.Model, args.SystemPrompt, timeout)
|
||||
if err != nil || result.Error != nil {
|
||||
spawnErr := err
|
||||
if spawnErr == nil {
|
||||
spawnErr = result.Error
|
||||
}
|
||||
response := fmt.Sprintf("Subagent failed after %ds.\n\nError: %v",
|
||||
int(result.Elapsed.Seconds()), spawnErr)
|
||||
if result.Response != "" {
|
||||
response += fmt.Sprintf("\n\nPartial output:\n%s", truncateResponse(result.Response, 8000))
|
||||
}
|
||||
return fantasy.NewTextErrorResponse(response), nil
|
||||
}
|
||||
|
||||
// Build successful response.
|
||||
response := fmt.Sprintf("Subagent completed successfully in %ds.", int(result.Elapsed.Seconds()))
|
||||
if result.InputTokens > 0 || result.OutputTokens > 0 {
|
||||
response += fmt.Sprintf(" (tokens: %d in / %d out)", result.InputTokens, result.OutputTokens)
|
||||
}
|
||||
response += fmt.Sprintf("\n\nResult:\n%s", truncateResponse(result.Response, 12000))
|
||||
|
||||
resp := fantasy.NewTextResponse(response)
|
||||
|
||||
// Attach subagent session ID as metadata when available.
|
||||
if result.SessionID != "" {
|
||||
resp = fantasy.WithResponseMetadata(resp, map[string]any{
|
||||
"subagent_session_id": result.SessionID,
|
||||
})
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// truncateResponse limits the response length to avoid overwhelming context windows.
|
||||
func truncateResponse(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen] + "\n\n... [truncated — " + fmt.Sprintf("%d", len(s)-maxLen) + " bytes omitted]"
|
||||
}
|
||||
@@ -86,8 +86,9 @@ func ReadOnlyTools(opts ...ToolOption) []fantasy.AgentTool {
|
||||
}
|
||||
}
|
||||
|
||||
// AllTools returns all available core tools.
|
||||
func AllTools(opts ...ToolOption) []fantasy.AgentTool {
|
||||
// SubagentTools returns all core tools except spawn_subagent. This prevents
|
||||
// infinite recursion when a subagent is itself a Kit instance.
|
||||
func SubagentTools(opts ...ToolOption) []fantasy.AgentTool {
|
||||
return []fantasy.AgentTool{
|
||||
NewBashTool(opts...),
|
||||
NewReadTool(opts...),
|
||||
@@ -98,3 +99,8 @@ func AllTools(opts ...ToolOption) []fantasy.AgentTool {
|
||||
NewLsTool(opts...),
|
||||
}
|
||||
}
|
||||
|
||||
// AllTools returns all available core tools.
|
||||
func AllTools(opts ...ToolOption) []fantasy.AgentTool {
|
||||
return append(SubagentTools(opts...), NewSubagentTool(opts...))
|
||||
}
|
||||
|
||||
+32
-1
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"charm.land/fantasy"
|
||||
)
|
||||
@@ -53,6 +54,14 @@ func executeWrite(ctx context.Context, call fantasy.ToolCall, workDir string) (f
|
||||
return fantasy.NewTextErrorResponse(fmt.Sprintf("invalid path: %v", err)), nil
|
||||
}
|
||||
|
||||
// Read existing content before writing (for diff metadata).
|
||||
var beforeContent string
|
||||
isNew := true
|
||||
if existing, readErr := os.ReadFile(absPath); readErr == nil {
|
||||
beforeContent = string(existing)
|
||||
isNew = false
|
||||
}
|
||||
|
||||
// Create parent directories
|
||||
dir := filepath.Dir(absPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
@@ -63,5 +72,27 @@ func executeWrite(ctx context.Context, call fantasy.ToolCall, workDir string) (f
|
||||
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to write file: %v", err)), nil
|
||||
}
|
||||
|
||||
return fantasy.NewTextResponse(fmt.Sprintf("Wrote %d bytes to %s", len(args.Content), args.Path)), nil
|
||||
resp := fantasy.NewTextResponse(fmt.Sprintf("Wrote %d bytes to %s", len(args.Content), args.Path))
|
||||
return fantasy.WithResponseMetadata(resp, writeDiffMeta(absPath, beforeContent, args.Content, isNew)), nil
|
||||
}
|
||||
|
||||
// writeDiffMeta builds the structured metadata attached to write tool responses.
|
||||
func writeDiffMeta(path, beforeContent, afterContent string, isNew bool) map[string]any {
|
||||
additions := strings.Count(afterContent, "\n") + 1
|
||||
deletions := 0
|
||||
if !isNew {
|
||||
deletions = strings.Count(beforeContent, "\n") + 1
|
||||
}
|
||||
return map[string]any{
|
||||
"file_diffs": []map[string]any{{
|
||||
"path": path,
|
||||
"additions": additions,
|
||||
"deletions": deletions,
|
||||
"is_new": isNew,
|
||||
"diff_blocks": []map[string]any{{
|
||||
"old_text": beforeContent,
|
||||
"new_text": afterContent,
|
||||
}},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,6 +491,41 @@ type Context struct {
|
||||
// },
|
||||
// })
|
||||
ReloadExtensions func() error
|
||||
|
||||
// SpawnSubagent spawns a child Kit instance to perform a task autonomously.
|
||||
// The subagent runs as a separate subprocess with full tool access but
|
||||
// isolated session and extensions (--no-session --no-extensions).
|
||||
//
|
||||
// When config.Blocking is true, blocks until completion and returns the
|
||||
// result directly (handle is nil). When false, returns immediately with
|
||||
// a handle for monitoring/cancellation.
|
||||
//
|
||||
// Example — blocking call:
|
||||
//
|
||||
// _, result, err := ctx.SpawnSubagent(ext.SubagentConfig{
|
||||
// Prompt: "Research authentication patterns in this codebase",
|
||||
// Blocking: true,
|
||||
// Timeout: 2 * time.Minute,
|
||||
// })
|
||||
// if err != nil {
|
||||
// ctx.PrintError("spawn failed: " + err.Error())
|
||||
// return
|
||||
// }
|
||||
// ctx.PrintInfo("Subagent result:\n" + result.Response)
|
||||
//
|
||||
// Example — background spawn with callbacks:
|
||||
//
|
||||
// handle, _, _ := ctx.SpawnSubagent(ext.SubagentConfig{
|
||||
// Prompt: "Write unit tests for UserService",
|
||||
// OnOutput: func(chunk string) {
|
||||
// // Live output streaming
|
||||
// },
|
||||
// OnComplete: func(result ext.SubagentResult) {
|
||||
// ctx.SendMessage("Subagent finished:\n" + result.Response)
|
||||
// },
|
||||
// })
|
||||
// // handle.Kill() to cancel, handle.Wait() to block
|
||||
SpawnSubagent func(SubagentConfig) (*SubagentHandle, *SubagentResult, error)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1397,7 +1432,9 @@ type EditorConfig struct {
|
||||
type ToolCallEvent struct {
|
||||
ToolName string
|
||||
ToolCallID string
|
||||
Input string // JSON-encoded tool parameters
|
||||
ToolKind string // Tool classification: "execute", "edit", "read", "search", "agent"
|
||||
Input string // JSON-encoded tool parameters
|
||||
ParsedArgs map[string]any // Pre-parsed arguments for convenience (nil on parse failure)
|
||||
// Source indicates who initiated the tool call.
|
||||
// Currently always "llm" (all tool calls originate from the LLM agent loop).
|
||||
// Future user-initiated tool features may set this to "user".
|
||||
@@ -1416,24 +1453,31 @@ func (ToolCallResult) isResult() {}
|
||||
|
||||
// ToolExecutionStartEvent fires when a tool begins executing.
|
||||
type ToolExecutionStartEvent struct {
|
||||
ToolName string
|
||||
ToolCallID string
|
||||
ToolName string
|
||||
ToolKind string
|
||||
}
|
||||
|
||||
func (e ToolExecutionStartEvent) Type() EventType { return ToolExecutionStart }
|
||||
|
||||
// ToolExecutionEndEvent fires when a tool finishes executing.
|
||||
type ToolExecutionEndEvent struct {
|
||||
ToolName string
|
||||
ToolCallID string
|
||||
ToolName string
|
||||
ToolKind string
|
||||
}
|
||||
|
||||
func (e ToolExecutionEndEvent) Type() EventType { return ToolExecutionEnd }
|
||||
|
||||
// ToolResultEvent fires after tool execution with the output.
|
||||
type ToolResultEvent struct {
|
||||
ToolName string
|
||||
Input string
|
||||
Content string
|
||||
IsError bool
|
||||
ToolCallID string
|
||||
ToolName string
|
||||
ToolKind string
|
||||
Input string
|
||||
Content string
|
||||
IsError bool
|
||||
Metadata string // Optional JSON-encoded structured metadata (e.g. file diffs)
|
||||
}
|
||||
|
||||
func (e ToolResultEvent) Type() EventType { return ToolResult }
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
// Package extensions provides subagent spawning capabilities for Kit extensions.
|
||||
package extensions
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subagent types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SubagentConfig configures a subagent spawn.
|
||||
type SubagentConfig struct {
|
||||
// Prompt is the task/instruction for the subagent (required).
|
||||
Prompt string
|
||||
|
||||
// Model overrides the parent's model (e.g. "anthropic/claude-haiku-3-5-20241022").
|
||||
// Empty string uses the parent's current model.
|
||||
Model string
|
||||
|
||||
// SystemPrompt provides domain-specific instructions.
|
||||
// Empty string uses the default system prompt.
|
||||
SystemPrompt string
|
||||
|
||||
// Timeout limits execution time. Zero means 5 minute default.
|
||||
Timeout time.Duration
|
||||
|
||||
// OnOutput streams stderr output chunks as the subagent runs.
|
||||
// Called from a goroutine; must be safe for concurrent use.
|
||||
OnOutput func(chunk string)
|
||||
|
||||
// OnEvent receives real-time events from the subagent's execution:
|
||||
// text chunks, tool calls, tool results, reasoning deltas, etc.
|
||||
// Called synchronously from the subagent's event loop.
|
||||
OnEvent func(SubagentEvent)
|
||||
|
||||
// OnComplete is called when the subagent finishes (success or error).
|
||||
// Called from a goroutine; must be safe for concurrent use.
|
||||
OnComplete func(result SubagentResult)
|
||||
|
||||
// Blocking, when true, makes SpawnSubagent wait for completion and
|
||||
// return the result directly. When false (default), spawns in background
|
||||
// and returns immediately with a handle.
|
||||
Blocking bool
|
||||
|
||||
// NoSession, when true, runs the subagent without persisting a session
|
||||
// file. By default (false), subagent sessions are persisted so they can
|
||||
// be loaded for replay/inspection. Set to true for ephemeral tasks
|
||||
// where session history is not needed.
|
||||
NoSession bool
|
||||
|
||||
// ParentSessionID links the subagent's session to the parent (optional).
|
||||
// When set, the subagent's session header includes a parent reference
|
||||
// so viewers can navigate the session tree.
|
||||
ParentSessionID string
|
||||
}
|
||||
|
||||
// SubagentEvent carries a real-time event from a running subagent. Extensions
|
||||
// use the Type field to determine what happened and read the relevant fields.
|
||||
// This is a concrete struct (not an interface) for Yaegi compatibility.
|
||||
type SubagentEvent struct {
|
||||
// Type identifies the event: "text", "reasoning", "tool_call",
|
||||
// "tool_result", "tool_execution_start", "tool_execution_end",
|
||||
// "turn_start", "turn_end".
|
||||
Type string
|
||||
|
||||
// Content carries text for "text" and "reasoning" events.
|
||||
Content string
|
||||
|
||||
// ToolCallID is set on tool_call, tool_result, tool_execution_start,
|
||||
// and tool_execution_end events.
|
||||
ToolCallID string
|
||||
// ToolName is set on tool-related events.
|
||||
ToolName string
|
||||
// ToolKind is set on tool-related events.
|
||||
ToolKind string
|
||||
// ToolArgs is set on tool_call events (JSON-encoded).
|
||||
ToolArgs string
|
||||
// ToolResult is set on tool_result events.
|
||||
ToolResult string
|
||||
// IsError is set on tool_result events.
|
||||
IsError bool
|
||||
}
|
||||
|
||||
// SubagentResult contains the outcome of a subagent execution.
|
||||
type SubagentResult struct {
|
||||
// Response is the subagent's final text response.
|
||||
Response string
|
||||
|
||||
// Error is set if the subagent failed (nil on success).
|
||||
Error error
|
||||
|
||||
// ExitCode is the subprocess exit code (0 = success).
|
||||
ExitCode int
|
||||
|
||||
// Elapsed is the total execution time.
|
||||
Elapsed time.Duration
|
||||
|
||||
// Usage contains token usage if available.
|
||||
Usage *SubagentUsage
|
||||
|
||||
// SessionID is the subagent's session identifier, if available.
|
||||
// Populated when the subagent persists its session (requires running
|
||||
// without --no-session). Empty for ephemeral sessions.
|
||||
SessionID string
|
||||
}
|
||||
|
||||
// SubagentUsage contains token usage from the subagent's run.
|
||||
type SubagentUsage struct {
|
||||
InputTokens int64
|
||||
OutputTokens int64
|
||||
}
|
||||
|
||||
// SubagentHandle provides control over a running subagent.
|
||||
type SubagentHandle struct {
|
||||
// ID is a unique identifier for this subagent instance.
|
||||
ID string
|
||||
|
||||
proc *os.Process
|
||||
done chan struct{}
|
||||
result *SubagentResult
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// Kill terminates the subagent process.
|
||||
func (h *SubagentHandle) Kill() error {
|
||||
h.mu.Lock()
|
||||
proc := h.proc
|
||||
h.mu.Unlock()
|
||||
if proc != nil {
|
||||
return proc.Kill()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Wait blocks until the subagent completes and returns the result.
|
||||
func (h *SubagentHandle) Wait() SubagentResult {
|
||||
<-h.done
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.result != nil {
|
||||
return *h.result
|
||||
}
|
||||
return SubagentResult{Error: fmt.Errorf("subagent completed without result")}
|
||||
}
|
||||
|
||||
// Done returns a channel that closes when the subagent completes.
|
||||
func (h *SubagentHandle) Done() <-chan struct{} {
|
||||
return h.done
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// subagentJSONOutput matches the JSON envelope produced by `kit --json`.
|
||||
type subagentJSONOutput struct {
|
||||
Response string `json:"response"`
|
||||
StopReason string `json:"stop_reason,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Usage *struct {
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
} `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
var subagentCounter uint64
|
||||
|
||||
func generateSubagentID() string {
|
||||
n := atomic.AddUint64(&subagentCounter, 1)
|
||||
return fmt.Sprintf("sub-%d-%d", time.Now().UnixNano(), n)
|
||||
}
|
||||
|
||||
func findKitBinary() string {
|
||||
// Try the current process executable first.
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
if _, err := os.Stat(exe); err == nil {
|
||||
return exe
|
||||
}
|
||||
}
|
||||
// Fall back to PATH lookup.
|
||||
if p, err := exec.LookPath("kit"); err == nil {
|
||||
return p
|
||||
}
|
||||
return "kit"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SpawnSubagent implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SpawnSubagent spawns a child Kit instance to perform a task.
|
||||
//
|
||||
// When config.Blocking is true, blocks until completion and returns the result
|
||||
// directly (handle is nil). When false, returns immediately with a handle for
|
||||
// monitoring/cancellation.
|
||||
//
|
||||
// The subagent runs with --json --no-session --no-extensions flags by default,
|
||||
// ensuring isolation from the parent's extensions and session state.
|
||||
func SpawnSubagent(cfg SubagentConfig) (*SubagentHandle, *SubagentResult, error) {
|
||||
if cfg.Prompt == "" {
|
||||
return nil, nil, fmt.Errorf("prompt is required")
|
||||
}
|
||||
|
||||
timeout := cfg.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 5 * time.Minute
|
||||
}
|
||||
|
||||
kitBinary := findKitBinary()
|
||||
|
||||
// Build subprocess arguments.
|
||||
args := []string{
|
||||
"--json",
|
||||
"--no-extensions",
|
||||
}
|
||||
if cfg.NoSession {
|
||||
args = append(args, "--no-session")
|
||||
}
|
||||
if cfg.Model != "" {
|
||||
args = append(args, "--model", cfg.Model)
|
||||
}
|
||||
|
||||
// Handle system prompt - write to temp file if provided.
|
||||
var tmpFile *os.File
|
||||
if cfg.SystemPrompt != "" {
|
||||
var err error
|
||||
tmpFile, err = os.CreateTemp("", "kit-subagent-*.txt")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
if _, err := tmpFile.WriteString(cfg.SystemPrompt); err != nil {
|
||||
_ = tmpFile.Close()
|
||||
_ = os.Remove(tmpFile.Name())
|
||||
return nil, nil, fmt.Errorf("write system prompt: %w", err)
|
||||
}
|
||||
_ = tmpFile.Close()
|
||||
args = append(args, "--system-prompt", tmpFile.Name())
|
||||
}
|
||||
|
||||
// Add the prompt as a positional argument.
|
||||
args = append(args, cfg.Prompt)
|
||||
|
||||
// Create command with timeout context.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
|
||||
cmd := exec.CommandContext(ctx, kitBinary, args...)
|
||||
cmd.Env = os.Environ()
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
if tmpFile != nil {
|
||||
_ = os.Remove(tmpFile.Name())
|
||||
}
|
||||
return nil, nil, fmt.Errorf("stdout pipe: %w", err)
|
||||
}
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
if tmpFile != nil {
|
||||
_ = os.Remove(tmpFile.Name())
|
||||
}
|
||||
return nil, nil, fmt.Errorf("stderr pipe: %w", err)
|
||||
}
|
||||
|
||||
handle := &SubagentHandle{
|
||||
ID: generateSubagentID(),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
// Start the subprocess.
|
||||
start := time.Now()
|
||||
if err := cmd.Start(); err != nil {
|
||||
cancel()
|
||||
if tmpFile != nil {
|
||||
_ = os.Remove(tmpFile.Name())
|
||||
}
|
||||
return nil, nil, fmt.Errorf("start subprocess: %w", err)
|
||||
}
|
||||
|
||||
handle.mu.Lock()
|
||||
handle.proc = cmd.Process
|
||||
handle.mu.Unlock()
|
||||
|
||||
// Run the subprocess monitoring in a goroutine.
|
||||
go func() {
|
||||
defer close(handle.done)
|
||||
defer cancel()
|
||||
if tmpFile != nil {
|
||||
defer func() { _ = os.Remove(tmpFile.Name()) }()
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var stdoutBuf strings.Builder
|
||||
|
||||
// Read stderr (live output).
|
||||
wg.Go(func() {
|
||||
scanner := bufio.NewScanner(stderr)
|
||||
scanner.Buffer(make([]byte, 256*1024), 256*1024)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if cfg.OnOutput != nil && strings.TrimSpace(line) != "" {
|
||||
cfg.OnOutput(line + "\n")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Read stdout (JSON output).
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 256*1024), 256*1024)
|
||||
for scanner.Scan() {
|
||||
stdoutBuf.WriteString(scanner.Text() + "\n")
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
waitErr := cmd.Wait()
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// Build result.
|
||||
result := SubagentResult{Elapsed: elapsed}
|
||||
if waitErr != nil {
|
||||
result.Error = waitErr
|
||||
if exitErr, ok := waitErr.(*exec.ExitError); ok {
|
||||
result.ExitCode = exitErr.ExitCode()
|
||||
} else {
|
||||
result.ExitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
// Parse JSON output.
|
||||
raw := strings.TrimSpace(stdoutBuf.String())
|
||||
var parsed subagentJSONOutput
|
||||
if raw != "" && json.Unmarshal([]byte(raw), &parsed) == nil {
|
||||
result.Response = parsed.Response
|
||||
result.SessionID = parsed.SessionID
|
||||
if parsed.Usage != nil {
|
||||
result.Usage = &SubagentUsage{
|
||||
InputTokens: parsed.Usage.InputTokens,
|
||||
OutputTokens: parsed.Usage.OutputTokens,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: use raw stdout.
|
||||
result.Response = raw
|
||||
}
|
||||
|
||||
handle.mu.Lock()
|
||||
handle.result = &result
|
||||
handle.proc = nil
|
||||
handle.mu.Unlock()
|
||||
|
||||
if cfg.OnComplete != nil {
|
||||
cfg.OnComplete(result)
|
||||
}
|
||||
}()
|
||||
|
||||
if cfg.Blocking {
|
||||
// Wait for completion and return result directly.
|
||||
<-handle.done
|
||||
handle.mu.Lock()
|
||||
r := handle.result
|
||||
handle.mu.Unlock()
|
||||
return nil, r, nil
|
||||
}
|
||||
|
||||
return handle, nil, nil
|
||||
}
|
||||
@@ -110,6 +110,13 @@ func Symbols() interp.Exports {
|
||||
"BeforeCompactEvent": reflect.ValueOf((*BeforeCompactEvent)(nil)),
|
||||
"BeforeCompactResult": reflect.ValueOf((*BeforeCompactResult)(nil)),
|
||||
|
||||
// Subagent types
|
||||
"SubagentConfig": reflect.ValueOf((*SubagentConfig)(nil)),
|
||||
"SubagentResult": reflect.ValueOf((*SubagentResult)(nil)),
|
||||
"SubagentUsage": reflect.ValueOf((*SubagentUsage)(nil)),
|
||||
"SubagentHandle": reflect.ValueOf((*SubagentHandle)(nil)),
|
||||
"SubagentEvent": reflect.ValueOf((*SubagentEvent)(nil)),
|
||||
|
||||
// Event structs
|
||||
"ToolCallEvent": reflect.ValueOf((*ToolCallEvent)(nil)),
|
||||
"ToolCallResult": reflect.ValueOf((*ToolCallResult)(nil)),
|
||||
|
||||
@@ -2,6 +2,7 @@ package extensions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"charm.land/fantasy"
|
||||
@@ -39,6 +40,37 @@ func ExtensionToolsAsFantasy(defs []ToolDef, runner *Runner) []fantasy.AgentTool
|
||||
return tools
|
||||
}
|
||||
|
||||
// coreToolKinds maps built-in tool names to their kind classification.
|
||||
var coreToolKinds = map[string]string{
|
||||
"bash": "execute",
|
||||
"edit": "edit",
|
||||
"write": "edit",
|
||||
"read": "read",
|
||||
"ls": "read",
|
||||
"grep": "search",
|
||||
"find": "search",
|
||||
"spawn_subagent": "agent",
|
||||
}
|
||||
|
||||
// toolKindFor returns the ToolKind for a given tool name, defaulting to
|
||||
// "execute" for unknown tools (including MCP tools).
|
||||
func toolKindFor(toolName string) string {
|
||||
if kind, ok := coreToolKinds[toolName]; ok {
|
||||
return kind
|
||||
}
|
||||
return "execute"
|
||||
}
|
||||
|
||||
// parseToolArgsJSON attempts to parse JSON-encoded tool args into a map.
|
||||
// Returns nil on failure (non-fatal convenience parsing).
|
||||
func parseToolArgsJSON(input string) map[string]any {
|
||||
var parsed map[string]any
|
||||
if json.Unmarshal([]byte(input), &parsed) == nil {
|
||||
return parsed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// wrappedTool — intercepts tool calls through the extension runner
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -62,12 +94,16 @@ func (w *wrappedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.T
|
||||
fmt.Errorf("tool %q disabled by extension", toolName)
|
||||
}
|
||||
|
||||
kind := toolKindFor(toolName)
|
||||
|
||||
// 1. Emit ToolCall — extensions can block execution.
|
||||
if w.runner.HasHandlers(ToolCall) {
|
||||
result, _ := w.runner.Emit(ToolCallEvent{
|
||||
ToolName: toolName,
|
||||
ToolCallID: call.ID,
|
||||
ToolKind: kind,
|
||||
Input: call.Input,
|
||||
ParsedArgs: parseToolArgsJSON(call.Input),
|
||||
Source: "llm",
|
||||
})
|
||||
if r, ok := result.(ToolCallResult); ok && r.Block {
|
||||
@@ -82,7 +118,7 @@ func (w *wrappedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.T
|
||||
|
||||
// 2. Emit ToolExecutionStart.
|
||||
if w.runner.HasHandlers(ToolExecutionStart) {
|
||||
_, _ = w.runner.Emit(ToolExecutionStartEvent{ToolName: toolName})
|
||||
_, _ = w.runner.Emit(ToolExecutionStartEvent{ToolCallID: call.ID, ToolName: toolName, ToolKind: kind})
|
||||
}
|
||||
|
||||
// 3. Execute the actual tool.
|
||||
@@ -90,16 +126,19 @@ func (w *wrappedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.T
|
||||
|
||||
// 4. Emit ToolExecutionEnd.
|
||||
if w.runner.HasHandlers(ToolExecutionEnd) {
|
||||
_, _ = w.runner.Emit(ToolExecutionEndEvent{ToolName: toolName})
|
||||
_, _ = w.runner.Emit(ToolExecutionEndEvent{ToolCallID: call.ID, ToolName: toolName, ToolKind: kind})
|
||||
}
|
||||
|
||||
// 5. Emit ToolResult — extensions can modify output.
|
||||
if w.runner.HasHandlers(ToolResult) {
|
||||
result, _ := w.runner.Emit(ToolResultEvent{
|
||||
ToolName: toolName,
|
||||
Input: call.Input,
|
||||
Content: resp.Content,
|
||||
IsError: err != nil || resp.IsError,
|
||||
ToolCallID: call.ID,
|
||||
ToolName: toolName,
|
||||
ToolKind: kind,
|
||||
Input: call.Input,
|
||||
Content: resp.Content,
|
||||
IsError: err != nil || resp.IsError,
|
||||
Metadata: resp.Metadata,
|
||||
})
|
||||
if r, ok := result.(ToolResultResult); ok {
|
||||
if r.Content != nil {
|
||||
@@ -125,10 +164,49 @@ type extensionTool struct {
|
||||
}
|
||||
|
||||
func (t *extensionTool) Info() fantasy.ToolInfo {
|
||||
return fantasy.ToolInfo{
|
||||
info := fantasy.ToolInfo{
|
||||
Name: t.def.Name,
|
||||
Description: t.def.Description,
|
||||
}
|
||||
|
||||
// Parse the extension's JSON Schema and extract the properties map.
|
||||
// Fantasy expects Parameters to contain property definitions directly
|
||||
// (e.g. {"command": {"type":"string"}}) and wraps them into a full
|
||||
// JSON Schema object internally. If the extension provides a full
|
||||
// schema with "type":"object" and "properties", we extract just the
|
||||
// properties. Required fields are also extracted if present.
|
||||
if t.def.Parameters != "" {
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal([]byte(t.def.Parameters), &schema); err == nil {
|
||||
if props, ok := schema["properties"].(map[string]any); ok {
|
||||
info.Parameters = props
|
||||
} else {
|
||||
// Schema doesn't have "properties" — use as-is (may be
|
||||
// a flat property map already matching fantasy's format).
|
||||
info.Parameters = schema
|
||||
}
|
||||
// Extract required fields if present.
|
||||
if req, ok := schema["required"].([]any); ok {
|
||||
for _, r := range req {
|
||||
if s, ok := r.(string); ok {
|
||||
info.Required = append(info.Required, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure Parameters and Required are never nil — the OpenAI Responses API
|
||||
// rejects tools where these fields serialize to JSON null instead of
|
||||
// empty object/array.
|
||||
if info.Parameters == nil {
|
||||
info.Parameters = map[string]any{}
|
||||
}
|
||||
if info.Required == nil {
|
||||
info.Required = []string{}
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
func (t *extensionTool) ProviderOptions() fantasy.ProviderOptions { return t.providerOptions }
|
||||
|
||||
@@ -79,6 +79,7 @@ func BuildProviderConfig() (*models.ProviderConfig, string, error) {
|
||||
NumGPU: &numGPU,
|
||||
MainGPU: &mainGPU,
|
||||
TLSSkipVerify: viper.GetBool("tls-skip-verify"),
|
||||
ThinkingLevel: models.ParseThinkingLevel(viper.GetString("thinking-level")),
|
||||
}
|
||||
|
||||
return cfg, systemPrompt, nil
|
||||
|
||||
@@ -58,6 +58,16 @@ type ToolResult struct {
|
||||
|
||||
func (ToolResult) isPart() {}
|
||||
|
||||
// ImageContent holds image data within a message. The data is stored as raw
|
||||
// bytes (not base64-encoded); serialization handles encoding. MediaType is a
|
||||
// MIME type such as "image/png" or "image/jpeg".
|
||||
type ImageContent struct {
|
||||
Data []byte `json:"data"`
|
||||
MediaType string `json:"media_type"`
|
||||
}
|
||||
|
||||
func (ImageContent) isPart() {}
|
||||
|
||||
// Finish marks the end of an assistant turn, carrying the stop reason.
|
||||
type Finish struct {
|
||||
Reason string `json:"reason"` // "end_turn", "tool_use", "max_tokens", etc.
|
||||
@@ -129,6 +139,17 @@ func (m *Message) ToolResults() []ToolResult {
|
||||
return results
|
||||
}
|
||||
|
||||
// Images returns all ImageContent parts from this message.
|
||||
func (m *Message) Images() []ImageContent {
|
||||
var images []ImageContent
|
||||
for _, part := range m.Parts {
|
||||
if ic, ok := part.(ImageContent); ok {
|
||||
images = append(images, ic)
|
||||
}
|
||||
}
|
||||
return images
|
||||
}
|
||||
|
||||
// Reasoning returns the ReasoningContent if present, or a zero value.
|
||||
func (m *Message) Reasoning() ReasoningContent {
|
||||
for _, part := range m.Parts {
|
||||
@@ -170,6 +191,7 @@ const (
|
||||
toolCallType partType = "tool_call"
|
||||
toolResultType partType = "tool_result"
|
||||
finishType partType = "finish"
|
||||
imageType partType = "image"
|
||||
)
|
||||
|
||||
type partWrapper struct {
|
||||
@@ -194,6 +216,8 @@ func MarshalParts(parts []ContentPart) ([]byte, error) {
|
||||
pt = toolResultType
|
||||
case Finish:
|
||||
pt = finishType
|
||||
case ImageContent:
|
||||
pt = imageType
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown content part type: %T", part)
|
||||
}
|
||||
@@ -247,6 +271,12 @@ func UnmarshalParts(data []byte) ([]ContentPart, error) {
|
||||
return nil, fmt.Errorf("failed to unmarshal finish part: %w", err)
|
||||
}
|
||||
part = p
|
||||
case imageType:
|
||||
var p ImageContent
|
||||
if err := json.Unmarshal(w.Data, &p); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal image part: %w", err)
|
||||
}
|
||||
part = p
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown part type: %s", w.Type)
|
||||
}
|
||||
@@ -323,13 +353,25 @@ func (m *Message) ToFantasyMessages() []fantasy.Message {
|
||||
}}
|
||||
|
||||
case RoleUser:
|
||||
var parts []fantasy.MessagePart
|
||||
text := m.Content()
|
||||
if text == "" {
|
||||
if text != "" {
|
||||
parts = append(parts, fantasy.TextPart{Text: text})
|
||||
}
|
||||
for _, part := range m.Parts {
|
||||
if ic, ok := part.(ImageContent); ok {
|
||||
parts = append(parts, fantasy.FilePart{
|
||||
Data: ic.Data,
|
||||
MediaType: ic.MediaType,
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return nil
|
||||
}
|
||||
return []fantasy.Message{{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{fantasy.TextPart{Text: text}},
|
||||
Content: parts,
|
||||
}}
|
||||
|
||||
case RoleSystem:
|
||||
@@ -388,6 +430,13 @@ func FromFantasyMessage(msg fantasy.Message) Message {
|
||||
Thinking: p.Text,
|
||||
})
|
||||
}
|
||||
case fantasy.FilePart:
|
||||
if len(p.Data) > 0 {
|
||||
m.Parts = append(m.Parts, ImageContent{
|
||||
Data: p.Data,
|
||||
MediaType: p.MediaType,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
)
|
||||
|
||||
// ProviderPool manages reusable LLM provider instances to reduce overhead
|
||||
// when spawning multiple subagents or making repeated completion calls.
|
||||
type ProviderPool struct {
|
||||
mu sync.RWMutex
|
||||
providers map[string]*pooledProvider
|
||||
ttl time.Duration
|
||||
closed bool
|
||||
closeCh chan struct{}
|
||||
}
|
||||
|
||||
type pooledProvider struct {
|
||||
model fantasy.LanguageModel
|
||||
closer func() error
|
||||
providerOpts fantasy.ProviderOptions
|
||||
created time.Time
|
||||
lastUsed time.Time
|
||||
refs int32
|
||||
}
|
||||
|
||||
// DefaultPoolTTL is the default time-to-live for idle pooled providers.
|
||||
const DefaultPoolTTL = 5 * time.Minute
|
||||
|
||||
// globalPool is the singleton provider pool instance.
|
||||
var globalPool *ProviderPool
|
||||
var poolOnce sync.Once
|
||||
|
||||
// GetGlobalPool returns the singleton provider pool instance.
|
||||
func GetGlobalPool() *ProviderPool {
|
||||
poolOnce.Do(func() {
|
||||
globalPool = NewProviderPool(DefaultPoolTTL)
|
||||
})
|
||||
return globalPool
|
||||
}
|
||||
|
||||
// NewProviderPool creates a provider pool with the given TTL for idle providers.
|
||||
func NewProviderPool(ttl time.Duration) *ProviderPool {
|
||||
p := &ProviderPool{
|
||||
providers: make(map[string]*pooledProvider),
|
||||
ttl: ttl,
|
||||
closeCh: make(chan struct{}),
|
||||
}
|
||||
go p.cleanupLoop()
|
||||
return p
|
||||
}
|
||||
|
||||
// Get returns a provider for the model string, creating one if needed.
|
||||
// The returned release function must be called when the provider is no longer
|
||||
// needed. The provider may be reused by subsequent Get calls.
|
||||
func (p *ProviderPool) Get(ctx context.Context, modelString string) (fantasy.LanguageModel, fantasy.ProviderOptions, func(), error) {
|
||||
p.mu.Lock()
|
||||
|
||||
// Check if we have an existing provider.
|
||||
if pp, ok := p.providers[modelString]; ok {
|
||||
pp.refs++
|
||||
pp.lastUsed = time.Now()
|
||||
p.mu.Unlock()
|
||||
return pp.model, pp.providerOpts, func() { p.release(modelString) }, nil
|
||||
}
|
||||
|
||||
p.mu.Unlock()
|
||||
|
||||
// Create a new provider outside the lock.
|
||||
config := &ProviderConfig{ModelString: modelString}
|
||||
result, err := CreateProvider(ctx, config)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
// Double-check: another goroutine may have created one while we were unlocked.
|
||||
if pp, ok := p.providers[modelString]; ok {
|
||||
// Close the one we just created and use the existing one.
|
||||
if result.Closer != nil {
|
||||
_ = result.Closer.Close()
|
||||
}
|
||||
pp.refs++
|
||||
pp.lastUsed = time.Now()
|
||||
return pp.model, pp.providerOpts, func() { p.release(modelString) }, nil
|
||||
}
|
||||
|
||||
var closerFn func() error
|
||||
if result.Closer != nil {
|
||||
closerFn = result.Closer.Close
|
||||
}
|
||||
|
||||
pp := &pooledProvider{
|
||||
model: result.Model,
|
||||
closer: closerFn,
|
||||
providerOpts: result.ProviderOptions,
|
||||
created: time.Now(),
|
||||
lastUsed: time.Now(),
|
||||
refs: 1,
|
||||
}
|
||||
p.providers[modelString] = pp
|
||||
|
||||
return pp.model, pp.providerOpts, func() { p.release(modelString) }, nil
|
||||
}
|
||||
|
||||
func (p *ProviderPool) release(modelString string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if pp, ok := p.providers[modelString]; ok {
|
||||
pp.refs--
|
||||
pp.lastUsed = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ProviderPool) cleanupLoop() {
|
||||
ticker := time.NewTicker(p.ttl / 2)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-p.closeCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
p.cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ProviderPool) cleanup() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for key, pp := range p.providers {
|
||||
// Only clean up providers with no active references and past TTL.
|
||||
if pp.refs <= 0 && now.Sub(pp.lastUsed) > p.ttl {
|
||||
if pp.closer != nil {
|
||||
_ = pp.closer()
|
||||
}
|
||||
delete(p.providers, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close shuts down the pool and releases all providers.
|
||||
func (p *ProviderPool) Close() {
|
||||
p.mu.Lock()
|
||||
if p.closed {
|
||||
p.mu.Unlock()
|
||||
return
|
||||
}
|
||||
p.closed = true
|
||||
close(p.closeCh)
|
||||
|
||||
for key, pp := range p.providers {
|
||||
if pp.closer != nil {
|
||||
_ = pp.closer()
|
||||
}
|
||||
delete(p.providers, key)
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -57,6 +57,66 @@ func resolveModelAlias(provider, modelName string) string {
|
||||
return modelName
|
||||
}
|
||||
|
||||
// ThinkingLevel controls extended thinking / reasoning budget for supported models.
|
||||
type ThinkingLevel string
|
||||
|
||||
const (
|
||||
ThinkingOff ThinkingLevel = "off"
|
||||
ThinkingMinimal ThinkingLevel = "minimal"
|
||||
ThinkingLow ThinkingLevel = "low"
|
||||
ThinkingMedium ThinkingLevel = "medium"
|
||||
ThinkingHigh ThinkingLevel = "high"
|
||||
)
|
||||
|
||||
// ThinkingLevels returns the ordered list of available thinking levels for cycling.
|
||||
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 {
|
||||
switch level {
|
||||
case ThinkingMinimal:
|
||||
return 1024
|
||||
case ThinkingLow:
|
||||
return 4096
|
||||
case ThinkingMedium:
|
||||
return 10240
|
||||
case ThinkingHigh:
|
||||
return 20480
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// ThinkingLevelDescription returns a human-readable description of a thinking level.
|
||||
func ThinkingLevelDescription(level ThinkingLevel) string {
|
||||
switch level {
|
||||
case ThinkingOff:
|
||||
return "No reasoning"
|
||||
case ThinkingMinimal:
|
||||
return "Very brief reasoning (~1k tokens)"
|
||||
case ThinkingLow:
|
||||
return "Light reasoning (~4k tokens)"
|
||||
case ThinkingMedium:
|
||||
return "Moderate reasoning (~10k tokens)"
|
||||
case ThinkingHigh:
|
||||
return "Deep reasoning (~20k tokens)"
|
||||
default:
|
||||
return "No reasoning"
|
||||
}
|
||||
}
|
||||
|
||||
// ParseThinkingLevel converts a string to a ThinkingLevel, defaulting to ThinkingOff.
|
||||
func ParseThinkingLevel(s string) ThinkingLevel {
|
||||
switch ThinkingLevel(s) {
|
||||
case ThinkingMinimal, ThinkingLow, ThinkingMedium, ThinkingHigh:
|
||||
return ThinkingLevel(s)
|
||||
default:
|
||||
return ThinkingOff
|
||||
}
|
||||
}
|
||||
|
||||
// ProviderConfig holds configuration for creating LLM providers.
|
||||
type ProviderConfig struct {
|
||||
ModelString string
|
||||
@@ -71,6 +131,7 @@ type ProviderConfig struct {
|
||||
NumGPU *int32
|
||||
MainGPU *int32
|
||||
TLSSkipVerify bool
|
||||
ThinkingLevel ThinkingLevel
|
||||
}
|
||||
|
||||
// ProviderResult contains the result of provider creation.
|
||||
@@ -82,6 +143,9 @@ type ProviderResult struct {
|
||||
// Closer is an optional cleanup function for providers that hold
|
||||
// resources (e.g. kronk's loaded models). May be nil.
|
||||
Closer io.Closer
|
||||
// ProviderOptions contains provider-specific options to be passed to the
|
||||
// fantasy agent (e.g. OpenAI Responses API reasoning options).
|
||||
ProviderOptions fantasy.ProviderOptions
|
||||
}
|
||||
|
||||
// ParseModelString parses a model string in "provider/model" format (e.g. "anthropic/claude-sonnet-4-5").
|
||||
@@ -146,10 +210,11 @@ func CreateProvider(ctx context.Context, config *ProviderConfig) (*ProviderResul
|
||||
}
|
||||
}
|
||||
|
||||
// Validate environment variables
|
||||
if err := registry.ValidateEnvironment(provider, config.ProviderAPIKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// NOTE: We intentionally skip registry.ValidateEnvironment() here.
|
||||
// Each create*Provider function handles its own auth resolution and
|
||||
// produces provider-specific error messages. The early env-var check
|
||||
// was too narrow — it didn't account for stored credentials (e.g.
|
||||
// OAuth tokens from 'kit auth login') and blocked valid auth paths.
|
||||
|
||||
// Validate config against known model limits when metadata is available
|
||||
if modelInfo != nil {
|
||||
@@ -256,6 +321,8 @@ func createAutoRoutedOpenAICompatProvider(ctx context.Context, config *ProviderC
|
||||
// createAutoRoutedAnthropicProvider creates an anthropic provider for
|
||||
// third-party providers with anthropic-compatible APIs (e.g. minimax).
|
||||
func createAutoRoutedAnthropicProvider(ctx context.Context, config *ProviderConfig, modelName string, info *ProviderInfo) (*ProviderResult, error) {
|
||||
clearConflictingAnthropicSamplingParams(config)
|
||||
|
||||
apiKey := resolveAPIKey(config.ProviderAPIKey, info.Env)
|
||||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("%s API key not provided. Use --provider-api-key or set %s",
|
||||
@@ -297,6 +364,7 @@ func createAutoRoutedOpenAIProvider(ctx context.Context, config *ProviderConfig,
|
||||
|
||||
var opts []openai.Option
|
||||
opts = append(opts, openai.WithAPIKey(apiKey))
|
||||
opts = append(opts, openai.WithUseResponsesAPI())
|
||||
|
||||
if config.ProviderURL != "" {
|
||||
opts = append(opts, openai.WithBaseURL(config.ProviderURL))
|
||||
@@ -316,7 +384,9 @@ func createAutoRoutedOpenAIProvider(ctx context.Context, config *ProviderConfig,
|
||||
return nil, fmt.Errorf("failed to create %s model: %w", info.Name, err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
providerOpts := buildOpenAIProviderOptions(config, modelName)
|
||||
|
||||
return &ProviderResult{Model: model, ProviderOptions: providerOpts}, nil
|
||||
}
|
||||
|
||||
// resolveAPIKey returns the first non-empty API key from the explicit key
|
||||
@@ -347,7 +417,102 @@ func validateModelConfig(config *ProviderConfig, modelInfo *ModelInfo) {
|
||||
}
|
||||
}
|
||||
|
||||
// clearConflictingAnthropicSamplingParams ensures that temperature and top_p are
|
||||
// not both sent to the Anthropic API, which rejects requests containing both.
|
||||
// When both are set (typically from defaults), top_p is cleared so that
|
||||
// temperature takes precedence.
|
||||
func clearConflictingAnthropicSamplingParams(config *ProviderConfig) {
|
||||
if config.Temperature != nil && config.TopP != nil {
|
||||
config.TopP = nil
|
||||
}
|
||||
}
|
||||
|
||||
// buildOpenAIProviderOptions returns fantasy.ProviderOptions configured for
|
||||
// OpenAI Responses API models. For reasoning models it sets reasoning_summary
|
||||
// to "auto", includes encrypted reasoning content, and maps the ThinkingLevel
|
||||
// to an OpenAI ReasoningEffort. For non-responses or non-reasoning models the
|
||||
// returned map is nil (no extra options needed).
|
||||
func buildOpenAIProviderOptions(config *ProviderConfig, modelName string) fantasy.ProviderOptions {
|
||||
if !openai.IsResponsesModel(modelName) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if openai.IsResponsesReasoningModel(modelName) {
|
||||
reasoningSummary := "auto"
|
||||
opts := &openai.ResponsesProviderOptions{
|
||||
ReasoningSummary: &reasoningSummary,
|
||||
Include: []openai.IncludeType{
|
||||
openai.IncludeReasoningEncryptedContent,
|
||||
},
|
||||
}
|
||||
|
||||
// Map ThinkingLevel to OpenAI ReasoningEffort.
|
||||
if effort := thinkingLevelToReasoningEffort(config.ThinkingLevel); effort != nil {
|
||||
opts.ReasoningEffort = effort
|
||||
}
|
||||
|
||||
return fantasy.ProviderOptions{
|
||||
openai.Name: opts,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// thinkingLevelToReasoningEffort maps a ThinkingLevel to an OpenAI ReasoningEffort.
|
||||
// Returns nil for ThinkingOff (use the model's default).
|
||||
func thinkingLevelToReasoningEffort(level ThinkingLevel) *openai.ReasoningEffort {
|
||||
switch level {
|
||||
case ThinkingMinimal:
|
||||
return openai.ReasoningEffortOption(openai.ReasoningEffortMinimal)
|
||||
case ThinkingLow:
|
||||
return openai.ReasoningEffortOption(openai.ReasoningEffortLow)
|
||||
case ThinkingMedium:
|
||||
return openai.ReasoningEffortOption(openai.ReasoningEffortMedium)
|
||||
case ThinkingHigh:
|
||||
return openai.ReasoningEffortOption(openai.ReasoningEffortHigh)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// buildAnthropicProviderOptions returns fantasy.ProviderOptions configured for
|
||||
// Anthropic models with extended thinking. When thinking is enabled, it sets
|
||||
// SendReasoning to true and configures the thinking budget. For thinking-off
|
||||
// or non-reasoning models the returned map is nil.
|
||||
//
|
||||
// Anthropic requires max_tokens > thinking.budget_tokens. If the configured
|
||||
// MaxTokens is too low, it is bumped to budget + 4096 to leave room for the
|
||||
// actual response.
|
||||
func buildAnthropicProviderOptions(config *ProviderConfig, modelName string) fantasy.ProviderOptions {
|
||||
if config.ThinkingLevel == "" || config.ThinkingLevel == ThinkingOff {
|
||||
return nil
|
||||
}
|
||||
|
||||
budget := ThinkingBudgetTokens(config.ThinkingLevel)
|
||||
if budget == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ensure MaxTokens exceeds the thinking budget (Anthropic requirement).
|
||||
minRequired := int(budget) + 4096
|
||||
if config.MaxTokens < minRequired {
|
||||
config.MaxTokens = minRequired
|
||||
}
|
||||
|
||||
sendReasoning := true
|
||||
opts := &anthropic.ProviderOptions{
|
||||
SendReasoning: &sendReasoning,
|
||||
Thinking: &anthropic.ThinkingProviderOption{
|
||||
BudgetTokens: budget,
|
||||
},
|
||||
}
|
||||
return anthropic.NewProviderOptions(opts)
|
||||
}
|
||||
|
||||
func createAnthropicProvider(ctx context.Context, config *ProviderConfig, modelName string) (*ProviderResult, error) {
|
||||
clearConflictingAnthropicSamplingParams(config)
|
||||
|
||||
apiKey, source, err := auth.GetAnthropicAPIKey(config.ProviderAPIKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -383,10 +548,15 @@ func createAnthropicProvider(ctx context.Context, config *ProviderConfig, modelN
|
||||
return nil, fmt.Errorf("failed to create Anthropic model: %w", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
// Build provider options for extended thinking (reasoning budget).
|
||||
providerOpts := buildAnthropicProviderOptions(config, modelName)
|
||||
|
||||
return &ProviderResult{Model: model, ProviderOptions: providerOpts}, nil
|
||||
}
|
||||
|
||||
func createVertexAnthropicProvider(ctx context.Context, config *ProviderConfig, modelName string) (*ProviderResult, error) {
|
||||
clearConflictingAnthropicSamplingParams(config)
|
||||
|
||||
projectID := firstNonEmpty(
|
||||
os.Getenv("GOOGLE_VERTEX_PROJECT"),
|
||||
os.Getenv("ANTHROPIC_VERTEX_PROJECT_ID"),
|
||||
@@ -434,6 +604,7 @@ func createOpenAIProvider(ctx context.Context, config *ProviderConfig, modelName
|
||||
|
||||
var opts []openai.Option
|
||||
opts = append(opts, openai.WithAPIKey(apiKey))
|
||||
opts = append(opts, openai.WithUseResponsesAPI())
|
||||
|
||||
if config.ProviderURL != "" {
|
||||
opts = append(opts, openai.WithBaseURL(config.ProviderURL))
|
||||
@@ -453,7 +624,10 @@ func createOpenAIProvider(ctx context.Context, config *ProviderConfig, modelName
|
||||
return nil, fmt.Errorf("failed to create OpenAI model: %w", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
// Build provider options for OpenAI Responses API reasoning models.
|
||||
providerOpts := buildOpenAIProviderOptions(config, modelName)
|
||||
|
||||
return &ProviderResult{Model: model, ProviderOptions: providerOpts}, nil
|
||||
}
|
||||
|
||||
func createGoogleProvider(ctx context.Context, config *ProviderConfig, modelName string) (*ProviderResult, error) {
|
||||
@@ -869,9 +1043,21 @@ type oauthTransport struct {
|
||||
}
|
||||
|
||||
func (t *oauthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
// Resolve the freshest available token. The credential manager
|
||||
// automatically refreshes tokens nearing expiry (5-minute buffer).
|
||||
// This keeps long-lived sessions (e.g. ACP) working across token
|
||||
// renewals. Falls back to the originally-provided token if the
|
||||
// credential manager is unavailable.
|
||||
token := t.accessToken
|
||||
if cm, err := auth.NewCredentialManager(); err == nil {
|
||||
if fresh, err := cm.GetValidAccessToken(); err == nil && fresh != "" {
|
||||
token = fresh
|
||||
}
|
||||
}
|
||||
|
||||
newReq := req.Clone(req.Context())
|
||||
newReq.Header.Del("x-api-key")
|
||||
newReq.Header.Set("Authorization", "Bearer "+t.accessToken)
|
||||
newReq.Header.Set("Authorization", "Bearer "+token)
|
||||
newReq.Header.Set("anthropic-beta", "oauth-2025-04-20")
|
||||
newReq.Header.Set("anthropic-version", "2023-06-01")
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ func TestCreateOAuthHTTPClient(t *testing.T) {
|
||||
|
||||
if client == nil {
|
||||
t.Fatal("expected non-nil client")
|
||||
return
|
||||
}
|
||||
|
||||
// Check that the transport is an oauthTransport
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/mark3labs/kit/internal/auth"
|
||||
)
|
||||
|
||||
//go:embed embedded_models.json
|
||||
@@ -171,14 +173,27 @@ func (r *ModelsRegistry) GetRequiredEnvVars(provider string) ([]string, error) {
|
||||
return providerInfo.Env, nil
|
||||
}
|
||||
|
||||
// ValidateEnvironment checks if required environment variables are set.
|
||||
// Returns nil for providers not in the registry (unknown providers are
|
||||
// assumed to handle auth themselves or via --provider-api-key).
|
||||
// ValidateEnvironment checks if required credentials are available for a
|
||||
// provider. It checks the explicit API key, stored credentials (for
|
||||
// providers that support them, such as Anthropic OAuth), and environment
|
||||
// variables. Returns nil for providers not in the registry (unknown
|
||||
// providers are assumed to handle auth themselves or via --provider-api-key).
|
||||
func (r *ModelsRegistry) ValidateEnvironment(provider string, apiKey string) error {
|
||||
if apiKey != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// For anthropic, also check stored credentials (OAuth / API key)
|
||||
// since auth resolution goes through the credential manager, not
|
||||
// just environment variables.
|
||||
if provider == "anthropic" {
|
||||
if cm, err := auth.NewCredentialManager(); err == nil {
|
||||
if has, _ := cm.HasAnthropicCredentials(); has {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
envVars, err := r.GetRequiredEnvVars(provider)
|
||||
if err != nil {
|
||||
// Unknown provider — nothing to validate
|
||||
|
||||
@@ -38,6 +38,10 @@ type SessionHeader struct {
|
||||
Timestamp time.Time `json:"timestamp"` // creation time
|
||||
Cwd string `json:"cwd"` // working directory
|
||||
ParentSession string `json:"parent_session,omitempty"` // path to parent if forked
|
||||
|
||||
// Subagent fields (set when session is created by a subagent)
|
||||
ParentSessionID string `json:"parent_session_id,omitempty"` // UUID of parent session
|
||||
SubagentTask string `json:"subagent_task,omitempty"` // original task prompt
|
||||
}
|
||||
|
||||
// Entry is the common structure shared by all tree entries (everything except
|
||||
|
||||
@@ -29,6 +29,12 @@ type SessionInfo struct {
|
||||
// ParentSessionPath is the parent session path if this session was forked.
|
||||
ParentSessionPath string
|
||||
|
||||
// ParentSessionID is the UUID of the parent session (for subagent sessions).
|
||||
ParentSessionID string
|
||||
|
||||
// SubagentTask is the original task prompt (for subagent sessions).
|
||||
SubagentTask string
|
||||
|
||||
// Created is when the session was first created.
|
||||
Created time.Time
|
||||
|
||||
@@ -162,6 +168,8 @@ func extractSessionInfo(path string) (*SessionInfo, error) {
|
||||
info.Created = h.Timestamp
|
||||
info.Modified = h.Timestamp
|
||||
info.ParentSessionPath = h.ParentSession
|
||||
info.ParentSessionID = h.ParentSessionID
|
||||
info.SubagentTask = h.SubagentTask
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -245,3 +253,27 @@ 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
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ type blockRenderer struct {
|
||||
align *lipgloss.Position
|
||||
borderColor *color.Color
|
||||
background *color.Color
|
||||
foreground *color.Color
|
||||
fullWidth bool
|
||||
noBorder bool
|
||||
paddingTop int
|
||||
@@ -123,6 +124,15 @@ func WithBackground(c color.Color) renderingOption {
|
||||
}
|
||||
}
|
||||
|
||||
// WithForeground returns a renderingOption that overrides the default text
|
||||
// foreground color (theme.Text) for the block. Useful for muted or
|
||||
// de-emphasized content blocks.
|
||||
func WithForeground(c color.Color) renderingOption {
|
||||
return func(br *blockRenderer) {
|
||||
br.foreground = &c
|
||||
}
|
||||
}
|
||||
|
||||
// WithWidth returns a renderingOption that sets a specific width for the block
|
||||
// in characters. This overrides the default container width and allows precise
|
||||
// control over the block's horizontal dimensions.
|
||||
@@ -167,13 +177,19 @@ func renderContentBlock(content string, containerWidth int, options ...rendering
|
||||
|
||||
theme := GetTheme()
|
||||
|
||||
// Resolve foreground color: caller override or theme default.
|
||||
fgColor := theme.Text
|
||||
if renderer.foreground != nil {
|
||||
fgColor = *renderer.foreground
|
||||
}
|
||||
|
||||
// Single-pass render: padding, border, and foreground in one style.
|
||||
style := lipgloss.NewStyle().
|
||||
PaddingLeft(renderer.paddingLeft).
|
||||
PaddingRight(renderer.paddingRight).
|
||||
PaddingTop(renderer.paddingTop).
|
||||
PaddingBottom(renderer.paddingBottom).
|
||||
Foreground(theme.Text)
|
||||
Foreground(fgColor)
|
||||
|
||||
if hasBorder {
|
||||
style = style.BorderStyle(lipgloss.ThickBorder())
|
||||
|
||||
@@ -348,6 +348,9 @@ func TestStreamComponent_SpinnerKeepsRunningDuringStreaming(t *testing.T) {
|
||||
// Receive first chunk — spinner should keep running.
|
||||
c = sendStreamMsg(c, app.StreamChunkEvent{Content: "hello"})
|
||||
|
||||
// Flush pending chunks (simulates the 16ms tick firing).
|
||||
c = sendStreamMsg(c, streamFlushTickMsg{})
|
||||
|
||||
if !c.spinning {
|
||||
t.Fatal("expected spinning=true after first chunk")
|
||||
}
|
||||
@@ -372,6 +375,9 @@ func TestStreamComponent_ChunkAccumulation(t *testing.T) {
|
||||
c = sendStreamMsg(c, app.StreamChunkEvent{Content: chunk})
|
||||
}
|
||||
|
||||
// Flush pending chunks (simulates the 16ms tick firing).
|
||||
c = sendStreamMsg(c, streamFlushTickMsg{})
|
||||
|
||||
got := c.streamContent.String()
|
||||
want := "Hello, world!"
|
||||
if got != want {
|
||||
@@ -397,8 +403,8 @@ func TestStreamComponent_ToolExecution_IsStarting_ShowsSpinner(t *testing.T) {
|
||||
if !c.spinning {
|
||||
t.Fatal("expected spinning=true during tool execution")
|
||||
}
|
||||
if !strings.Contains(c.spinnerMsg, "exec_tool") {
|
||||
t.Fatalf("expected spinnerMsg to contain tool name, got %q", c.spinnerMsg)
|
||||
if len(c.activeTools) != 1 || !strings.Contains(c.activeTools[0], "exec_tool") {
|
||||
t.Fatalf("expected activeTools to contain tool name, got %v", c.activeTools)
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("expected tick cmd from ToolExecutionEvent{IsStarting:true}")
|
||||
@@ -410,7 +416,11 @@ func TestStreamComponent_ToolExecution_NotStarting_KeepsSpinning(t *testing.T) {
|
||||
c := newTestStream()
|
||||
// Start spinning first (simulating execution in progress).
|
||||
c = sendStreamMsg(c, app.SpinnerEvent{Show: true})
|
||||
c.spinnerMsg = "Executing some_tool…"
|
||||
// Simulate a tool starting
|
||||
c = sendStreamMsg(c, app.ToolExecutionEvent{
|
||||
ToolName: "some_tool",
|
||||
IsStarting: true,
|
||||
})
|
||||
|
||||
c = sendStreamMsg(c, app.ToolExecutionEvent{
|
||||
ToolName: "some_tool",
|
||||
@@ -420,8 +430,41 @@ func TestStreamComponent_ToolExecution_NotStarting_KeepsSpinning(t *testing.T) {
|
||||
if !c.spinning {
|
||||
t.Fatal("expected spinning=true after tool execution finished (spinner keeps running)")
|
||||
}
|
||||
if c.spinnerMsg != "" {
|
||||
t.Fatalf("expected spinnerMsg cleared after tool finished, got %q", c.spinnerMsg)
|
||||
if len(c.activeTools) != 0 {
|
||||
t.Fatalf("expected activeTools cleared after tool finished, got %v", c.activeTools)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamComponent_ParallelToolExecution verifies multiple tools can run concurrently.
|
||||
func TestStreamComponent_ParallelToolExecution(t *testing.T) {
|
||||
c := newTestStream()
|
||||
|
||||
// Start three tools in parallel
|
||||
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "read", IsStarting: true})
|
||||
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "grep", IsStarting: true})
|
||||
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "find", IsStarting: true})
|
||||
|
||||
if len(c.activeTools) != 3 {
|
||||
t.Fatalf("expected 3 active tools, got %d: %v", len(c.activeTools), c.activeTools)
|
||||
}
|
||||
|
||||
// Check SpinnerView shows all tools
|
||||
view := c.SpinnerView()
|
||||
if !strings.Contains(view, "Running:") {
|
||||
t.Fatalf("expected spinner view to contain 'Running:' for multiple tools, got %q", view)
|
||||
}
|
||||
|
||||
// Finish one tool
|
||||
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "grep", IsStarting: false})
|
||||
if len(c.activeTools) != 2 {
|
||||
t.Fatalf("expected 2 active tools after one finished, got %d: %v", len(c.activeTools), c.activeTools)
|
||||
}
|
||||
|
||||
// Finish remaining tools
|
||||
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "read", IsStarting: false})
|
||||
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "find", IsStarting: false})
|
||||
if len(c.activeTools) != 0 {
|
||||
t.Fatalf("expected 0 active tools after all finished, got %d: %v", len(c.activeTools), c.activeTools)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,8 +523,8 @@ func TestStreamComponent_Reset(t *testing.T) {
|
||||
if !c.timestamp.IsZero() {
|
||||
t.Fatal("expected zero timestamp after Reset()")
|
||||
}
|
||||
if c.spinnerMsg != "" {
|
||||
t.Fatalf("expected spinnerMsg empty after Reset(), got %q", c.spinnerMsg)
|
||||
if len(c.activeTools) != 0 {
|
||||
t.Fatalf("expected activeTools empty after Reset(), got %v", c.activeTools)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
-1
@@ -1,6 +1,11 @@
|
||||
package ui
|
||||
|
||||
import "slices"
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/mark3labs/kit/internal/models"
|
||||
)
|
||||
|
||||
// SlashCommand represents a user-invokable slash command with its metadata.
|
||||
// Commands can have multiple aliases and are organized by category for better
|
||||
@@ -72,6 +77,23 @@ var SlashCommands = []SlashCommand{
|
||||
Category: "System",
|
||||
Aliases: []string{"/m"},
|
||||
},
|
||||
{
|
||||
Name: "/thinking",
|
||||
Description: "Set thinking/reasoning level (off, minimal, low, medium, high)",
|
||||
Category: "System",
|
||||
Aliases: []string{"/think"},
|
||||
Complete: func(prefix string) []string {
|
||||
levels := models.ThinkingLevels()
|
||||
var matches []string
|
||||
for _, l := range levels {
|
||||
s := string(l)
|
||||
if prefix == "" || strings.HasPrefix(s, strings.ToLower(prefix)) {
|
||||
matches = append(matches, s)
|
||||
}
|
||||
}
|
||||
return matches
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "/quit",
|
||||
Description: "Exit the application",
|
||||
|
||||
@@ -44,15 +44,20 @@ func (r *CompactRenderer) SetWidth(width int) {
|
||||
// and metadata.
|
||||
func (r *CompactRenderer) RenderUserMessage(content string, timestamp time.Time) UIMessage {
|
||||
theme := getTheme()
|
||||
symbol := lipgloss.NewStyle().Foreground(theme.Secondary).Render(">")
|
||||
label := lipgloss.NewStyle().Foreground(theme.Secondary).Bold(true).Render("User")
|
||||
symbol := lipgloss.NewStyle().Foreground(theme.Info).Render(">")
|
||||
label := lipgloss.NewStyle().Foreground(theme.Info).Bold(true).Render("User")
|
||||
|
||||
// Convert single newlines to paragraph breaks so they survive glamour's
|
||||
// markdown rendering (glamour treats single \n as a soft break).
|
||||
content = strings.ReplaceAll(content, "\n", "\n\n")
|
||||
|
||||
// Format content for user messages (preserve formatting, no truncation)
|
||||
compactContent := r.formatUserAssistantContent(content)
|
||||
// Only run markdown rendering when the message contains code spans or
|
||||
// fenced code blocks. Plain text is rendered directly so that newlines
|
||||
// are preserved without the extra paragraph spacing glamour adds.
|
||||
var compactContent string
|
||||
if strings.Contains(content, "`") {
|
||||
mdContent := strings.ReplaceAll(content, "\n", "\n\n")
|
||||
compactContent = r.formatUserAssistantContent(mdContent)
|
||||
compactContent = removeBlankLines(compactContent)
|
||||
} else {
|
||||
compactContent = content
|
||||
}
|
||||
|
||||
// Handle multi-line content
|
||||
lines := strings.Split(compactContent, "\n")
|
||||
@@ -170,7 +175,7 @@ func (r *CompactRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
|
||||
if extRd != nil && extRd.DisplayName != "" {
|
||||
displayName = extRd.DisplayName
|
||||
}
|
||||
nameStr := lipgloss.NewStyle().Foreground(theme.Tool).Bold(true).Render(displayName)
|
||||
nameStr := lipgloss.NewStyle().Foreground(theme.Info).Bold(true).Render(displayName)
|
||||
|
||||
// Format params — check extension renderer first.
|
||||
paramBudget := max(r.width-10-len(displayName), 20)
|
||||
@@ -235,8 +240,8 @@ func (r *CompactRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
|
||||
// formatted to fit on a single line for minimal space usage.
|
||||
func (r *CompactRenderer) RenderSystemMessage(content string, timestamp time.Time) UIMessage {
|
||||
theme := getTheme()
|
||||
symbol := lipgloss.NewStyle().Foreground(theme.System).Render("*")
|
||||
label := lipgloss.NewStyle().Foreground(theme.System).Bold(true).Render("System")
|
||||
symbol := lipgloss.NewStyle().Foreground(theme.Muted).Render("◇")
|
||||
label := lipgloss.NewStyle().Foreground(theme.Muted).Bold(true).Render("System")
|
||||
|
||||
compactContent := r.formatCompactContent(content)
|
||||
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
package ui
|
||||
|
||||
// ImageAttachment holds a clipboard image that will be sent alongside the
|
||||
// user's text prompt to the LLM. The data is raw image bytes; MediaType is
|
||||
// a MIME type like "image/png".
|
||||
type ImageAttachment struct {
|
||||
// Data is the raw image bytes (PNG, JPEG, etc.).
|
||||
Data []byte
|
||||
// MediaType is the MIME type (e.g. "image/png", "image/jpeg").
|
||||
MediaType string
|
||||
}
|
||||
|
||||
// submitMsg is sent by the InputComponent when the user submits a text prompt.
|
||||
// The parent model receives this and calls app.Run(Text) to start agent processing.
|
||||
type submitMsg struct {
|
||||
// Text is the user's input text to send to the agent.
|
||||
Text string
|
||||
// Images holds clipboard image attachments to send alongside the text.
|
||||
// Empty when no images are attached.
|
||||
Images []ImageAttachment
|
||||
}
|
||||
|
||||
// cancelTimerExpiredMsg is sent by the tea.Tick command that starts when the user
|
||||
|
||||
+82
-5
@@ -1,12 +1,15 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
"charm.land/bubbles/v2/textarea"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/mark3labs/kit/internal/clipboard"
|
||||
)
|
||||
|
||||
// InputComponent is the interactive text input field for the parent AppModel.
|
||||
@@ -61,6 +64,16 @@ type InputComponent struct {
|
||||
|
||||
// hideHint suppresses the "enter submit · ctrl+j..." hint text.
|
||||
hideHint bool
|
||||
|
||||
// pendingImages holds clipboard images attached to the next submission.
|
||||
// Images are added via Ctrl+V and cleared on submit or Ctrl+U.
|
||||
pendingImages []ImageAttachment
|
||||
}
|
||||
|
||||
// clipboardImageMsg is the result of an async clipboard image read.
|
||||
type clipboardImageMsg struct {
|
||||
image *ImageAttachment
|
||||
err error
|
||||
}
|
||||
|
||||
// NewInputComponent creates a new InputComponent with the given width, title,
|
||||
@@ -76,10 +89,10 @@ func NewInputComponent(width int, title string, appCtrl AppController) *InputCom
|
||||
ta.SetHeight(3) // Default to 3 lines like huh
|
||||
ta.Focus()
|
||||
|
||||
// Override InsertNewline so only ctrl+j and alt+enter insert newlines.
|
||||
// Override InsertNewline so only ctrl+j and shift+enter insert newlines.
|
||||
// Enter always submits the input.
|
||||
ta.KeyMap.InsertNewline = key.NewBinding(
|
||||
key.WithKeys("ctrl+j", "alt+enter"),
|
||||
key.WithKeys("ctrl+j", "shift+enter"),
|
||||
key.WithHelp("ctrl+j", "insert newline"),
|
||||
)
|
||||
|
||||
@@ -137,6 +150,16 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
s.textarea.SetWidth(msg.Width - 8)
|
||||
return s, nil
|
||||
|
||||
case clipboardImageMsg:
|
||||
if msg.err != nil {
|
||||
// Silently ignore — no image on clipboard or tool unavailable.
|
||||
return s, nil
|
||||
}
|
||||
if msg.image != nil {
|
||||
s.pendingImages = append(s.pendingImages, *msg.image)
|
||||
}
|
||||
return s, nil
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
if !s.showPopup {
|
||||
switch msg.String() {
|
||||
@@ -146,6 +169,15 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
s.textarea.CursorEnd()
|
||||
s.lastValue = ""
|
||||
return s, s.handleSubmit(value)
|
||||
case "ctrl+v":
|
||||
// Try to read an image from the clipboard asynchronously.
|
||||
return s, readClipboardImageCmd()
|
||||
case "ctrl+u":
|
||||
// Clear all pending image attachments.
|
||||
if len(s.pendingImages) > 0 {
|
||||
s.pendingImages = nil
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,9 +362,12 @@ func (s *InputComponent) handleSubmit(value string) tea.Cmd {
|
||||
}
|
||||
|
||||
// For all other input (including unrecognised slash commands and regular
|
||||
// prompts) hand off to the parent via submitMsg.
|
||||
// prompts) hand off to the parent via submitMsg. Attach any pending
|
||||
// images and clear them.
|
||||
images := s.pendingImages
|
||||
s.pendingImages = nil
|
||||
return func() tea.Msg {
|
||||
return submitMsg{Text: trimmed}
|
||||
return submitMsg{Text: trimmed, Images: images}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,14 +402,26 @@ func (s *InputComponent) View() tea.View {
|
||||
view.WriteString(s.renderPopup())
|
||||
}
|
||||
|
||||
// Show image attachment indicator when images are pending.
|
||||
if len(s.pendingImages) > 0 {
|
||||
imgStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("39")).
|
||||
PaddingLeft(3)
|
||||
|
||||
label := fmt.Sprintf("[%d image(s) attached] ctrl+u to clear", len(s.pendingImages))
|
||||
view.WriteString("\n")
|
||||
view.WriteString(imgStyle.Render(label))
|
||||
}
|
||||
|
||||
if !s.hideHint {
|
||||
helpStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("240")).
|
||||
MarginTop(1).
|
||||
PaddingLeft(3)
|
||||
|
||||
hint := "enter submit • ctrl+j / shift+enter new line • ctrl+v paste image"
|
||||
view.WriteString("\n")
|
||||
view.WriteString(helpStyle.Render("enter submit • ctrl+j / alt+enter new line"))
|
||||
view.WriteString(helpStyle.Render(hint))
|
||||
}
|
||||
|
||||
return tea.NewView(containerStyle.Render(view.String()))
|
||||
@@ -502,6 +549,36 @@ func (s *InputComponent) findCommandWithComplete(name string) *SlashCommand {
|
||||
return nil
|
||||
}
|
||||
|
||||
// readClipboardImageCmd returns a tea.Cmd that reads an image from the system
|
||||
// clipboard. The result is delivered as a clipboardImageMsg.
|
||||
func readClipboardImageCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
img, err := clipboard.ReadImage()
|
||||
if err != nil {
|
||||
return clipboardImageMsg{err: err}
|
||||
}
|
||||
return clipboardImageMsg{
|
||||
image: &ImageAttachment{
|
||||
Data: img.Data,
|
||||
MediaType: img.MediaType,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ClearPendingImages removes all pending image attachments and returns them.
|
||||
// Used by the parent model when consuming images for submission.
|
||||
func (s *InputComponent) ClearPendingImages() []ImageAttachment {
|
||||
images := s.pendingImages
|
||||
s.pendingImages = nil
|
||||
return images
|
||||
}
|
||||
|
||||
// PendingImageCount returns the number of images currently attached.
|
||||
func (s *InputComponent) PendingImageCount() int {
|
||||
return len(s.pendingImages)
|
||||
}
|
||||
|
||||
// applyFileCompletion replaces the @prefix in the textarea with the selected
|
||||
// file suggestion. For directories, it keeps the popup open for further
|
||||
// drilling. For files, it closes the popup and adds a trailing space.
|
||||
|
||||
+55
-115
@@ -3,8 +3,7 @@ package ui
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -12,6 +11,9 @@ import (
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// ansiEscapeRe matches ANSI escape sequences used for terminal styling.
|
||||
var ansiEscapeRe = regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
||||
|
||||
// MessageType represents different categories of messages displayed in the UI,
|
||||
// each with distinct visual styling and formatting rules.
|
||||
type MessageType int
|
||||
@@ -154,21 +156,6 @@ type MessageRenderer struct {
|
||||
getToolRenderer func(toolName string) *ToolRendererData
|
||||
}
|
||||
|
||||
// getSystemUsername returns the current system username, fallback to "User"
|
||||
func getSystemUsername() string {
|
||||
if currentUser, err := user.Current(); err == nil && currentUser.Username != "" {
|
||||
return currentUser.Username
|
||||
}
|
||||
// Fallback to environment variable
|
||||
if username := os.Getenv("USER"); username != "" {
|
||||
return username
|
||||
}
|
||||
if username := os.Getenv("USERNAME"); username != "" {
|
||||
return username
|
||||
}
|
||||
return "User"
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -189,31 +176,30 @@ func (r *MessageRenderer) SetWidth(width int) {
|
||||
// formatting, including the system username, timestamp, and markdown-rendered content.
|
||||
// The message is displayed with a colored right border for visual distinction.
|
||||
func (r *MessageRenderer) RenderUserMessage(content string, timestamp time.Time) UIMessage {
|
||||
// Format timestamp and username
|
||||
timeStr := timestamp.Local().Format("15:04")
|
||||
username := getSystemUsername()
|
||||
|
||||
// Convert single newlines to paragraph breaks so they survive glamour's
|
||||
// markdown rendering (glamour treats single \n as a soft break).
|
||||
content = strings.ReplaceAll(content, "\n", "\n\n")
|
||||
|
||||
theme := getTheme()
|
||||
|
||||
messageContent := r.renderMarkdown(content, r.width-8) // Account for padding and borders
|
||||
// Only run markdown rendering when the message contains code spans or
|
||||
// fenced code blocks. Plain text is rendered directly so that newlines
|
||||
// are preserved without the extra paragraph spacing glamour adds.
|
||||
var messageContent string
|
||||
if strings.Contains(content, "`") {
|
||||
// Glamour treats single \n as a soft break, so convert to paragraph
|
||||
// breaks and collapse the resulting blank lines after rendering.
|
||||
mdContent := strings.ReplaceAll(content, "\n", "\n\n")
|
||||
messageContent = r.renderMarkdown(mdContent, r.width-8)
|
||||
messageContent = removeBlankLines(messageContent)
|
||||
} else {
|
||||
messageContent = content
|
||||
}
|
||||
|
||||
// Create info line
|
||||
info := fmt.Sprintf(" %s (%s)", username, timeStr)
|
||||
fullContent := strings.TrimSuffix(messageContent, "\n")
|
||||
|
||||
// Combine content and info
|
||||
fullContent := strings.TrimSuffix(messageContent, "\n") + "\n" +
|
||||
lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(info)
|
||||
|
||||
// Use the block renderer — left border with Primary color, no background.
|
||||
// Left border with Blue color for user messages.
|
||||
rendered := renderContentBlock(
|
||||
fullContent,
|
||||
r.width,
|
||||
WithAlign(lipgloss.Left),
|
||||
WithBorderColor(theme.Primary),
|
||||
WithBorderColor(theme.Info),
|
||||
WithMarginBottom(1),
|
||||
)
|
||||
|
||||
@@ -230,14 +216,8 @@ func (r *MessageRenderer) RenderUserMessage(content string, timestamp time.Time)
|
||||
// are displayed with a special "Finished without output" message. The message features
|
||||
// a colored left border for visual distinction.
|
||||
func (r *MessageRenderer) RenderAssistantMessage(content string, timestamp time.Time, modelName string) UIMessage {
|
||||
// Format timestamp and model info with better defaults
|
||||
timeStr := timestamp.Local().Format("15:04")
|
||||
if modelName == "" {
|
||||
modelName = "Assistant"
|
||||
}
|
||||
|
||||
// Handle empty content with better styling
|
||||
theme := getTheme()
|
||||
|
||||
var messageContent string
|
||||
if strings.TrimSpace(content) == "" {
|
||||
messageContent = lipgloss.NewStyle().
|
||||
@@ -246,21 +226,16 @@ func (r *MessageRenderer) RenderAssistantMessage(content string, timestamp time.
|
||||
Align(lipgloss.Center).
|
||||
Render("Finished without output")
|
||||
} else {
|
||||
messageContent = r.renderMarkdown(content, r.width-8) // Account for padding and borders
|
||||
messageContent = r.renderMarkdown(content, r.width-8)
|
||||
}
|
||||
|
||||
// Create info line
|
||||
info := fmt.Sprintf(" %s (%s)", modelName, timeStr)
|
||||
fullContent := strings.TrimSuffix(messageContent, "\n")
|
||||
|
||||
// Combine content and info
|
||||
fullContent := strings.TrimSuffix(messageContent, "\n") + "\n" +
|
||||
lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(info)
|
||||
|
||||
// Use the new block renderer — no borders for agent messages.
|
||||
// Left border with Primary (Mauve) color for assistant messages.
|
||||
rendered := renderContentBlock(
|
||||
fullContent,
|
||||
r.width,
|
||||
WithNoBorder(),
|
||||
WithBorderColor(theme.Primary),
|
||||
WithMarginBottom(1),
|
||||
)
|
||||
|
||||
@@ -276,35 +251,24 @@ func (r *MessageRenderer) RenderAssistantMessage(content string, timestamp time.
|
||||
// and informational notifications. These messages are displayed with a distinctive system
|
||||
// color border and "KIT System" label to differentiate them from user and AI content.
|
||||
func (r *MessageRenderer) RenderSystemMessage(content string, timestamp time.Time) UIMessage {
|
||||
// Format timestamp
|
||||
timeStr := timestamp.Local().Format("15:04")
|
||||
|
||||
// Handle empty content with better styling
|
||||
theme := getTheme()
|
||||
|
||||
var messageContent string
|
||||
if strings.TrimSpace(content) == "" {
|
||||
messageContent = lipgloss.NewStyle().
|
||||
Italic(true).
|
||||
Foreground(theme.Muted).
|
||||
Align(lipgloss.Center).
|
||||
Render("No content available")
|
||||
messageContent = "No content available"
|
||||
} else if strings.Contains(content, "`") {
|
||||
messageContent = r.renderMarkdown(content, r.width-8)
|
||||
} else {
|
||||
messageContent = r.renderMarkdown(content, r.width-8) // Account for padding and borders
|
||||
messageContent = content
|
||||
}
|
||||
|
||||
// Create info line
|
||||
info := fmt.Sprintf(" KIT System (%s)", timeStr)
|
||||
fullContent := "◇ " + strings.TrimSuffix(messageContent, "\n")
|
||||
|
||||
// Combine content and info
|
||||
fullContent := strings.TrimSuffix(messageContent, "\n") + "\n" +
|
||||
lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(info)
|
||||
|
||||
// Use the new block renderer
|
||||
rendered := renderContentBlock(
|
||||
fullContent,
|
||||
r.width,
|
||||
WithAlign(lipgloss.Left),
|
||||
WithBorderColor(theme.System),
|
||||
WithNoBorder(),
|
||||
WithForeground(theme.Muted),
|
||||
WithMarginBottom(1),
|
||||
)
|
||||
|
||||
@@ -322,29 +286,22 @@ func (r *MessageRenderer) RenderSystemMessage(content string, timestamp time.Tim
|
||||
func (r *MessageRenderer) RenderDebugMessage(message string, timestamp time.Time) UIMessage {
|
||||
baseStyle := lipgloss.NewStyle()
|
||||
|
||||
// Create the main message style with border using tool color
|
||||
theme := getTheme()
|
||||
style := baseStyle.
|
||||
Width(r.width - 3). // Account for left margin
|
||||
Width(r.width - 3).
|
||||
BorderLeft(true).
|
||||
Foreground(theme.Muted).
|
||||
BorderForeground(theme.Tool).
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
PaddingLeft(1).
|
||||
MarginLeft(2). // Add left margin like other messages
|
||||
MarginBottom(1) // Add bottom margin
|
||||
MarginLeft(2).
|
||||
MarginBottom(1)
|
||||
|
||||
// Format timestamp
|
||||
timeStr := timestamp.Local().Format("02 Jan 2006 03:04 PM")
|
||||
|
||||
// Create header with debug icon
|
||||
header := baseStyle.
|
||||
Foreground(theme.Tool).
|
||||
Bold(true).
|
||||
Render("🔍 Debug Output")
|
||||
|
||||
// Process and format the message content
|
||||
// Split into lines and format each one
|
||||
lines := strings.Split(message, "\n")
|
||||
var formattedLines []string
|
||||
for _, line := range lines {
|
||||
@@ -357,17 +314,9 @@ func (r *MessageRenderer) RenderDebugMessage(message string, timestamp time.Time
|
||||
Foreground(theme.Muted).
|
||||
Render(strings.Join(formattedLines, "\n"))
|
||||
|
||||
// Create info line
|
||||
info := baseStyle.
|
||||
Width(r.width - 5). // Account for margins and padding
|
||||
Foreground(theme.Muted).
|
||||
Render(fmt.Sprintf(" KIT (%s)", timeStr))
|
||||
|
||||
// Combine all parts
|
||||
fullContent := lipgloss.JoinVertical(lipgloss.Left,
|
||||
header,
|
||||
content,
|
||||
info,
|
||||
)
|
||||
|
||||
return UIMessage{
|
||||
@@ -382,7 +331,6 @@ func (r *MessageRenderer) RenderDebugMessage(message string, timestamp time.Time
|
||||
func (r *MessageRenderer) RenderDebugConfigMessage(config map[string]any, timestamp time.Time) UIMessage {
|
||||
baseStyle := lipgloss.NewStyle()
|
||||
|
||||
// Create the main message style with border using tool color
|
||||
theme := getTheme()
|
||||
style := baseStyle.
|
||||
Width(r.width - 1).
|
||||
@@ -392,16 +340,11 @@ func (r *MessageRenderer) RenderDebugConfigMessage(config map[string]any, timest
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
PaddingLeft(1)
|
||||
|
||||
// Format timestamp
|
||||
timeStr := timestamp.Local().Format("02 Jan 2006 03:04 PM")
|
||||
|
||||
// Create header with debug icon
|
||||
header := baseStyle.
|
||||
Foreground(theme.Tool).
|
||||
Bold(true).
|
||||
Render("🔧 Debug Configuration")
|
||||
|
||||
// Format configuration settings
|
||||
var configLines []string
|
||||
for key, value := range config {
|
||||
if value != nil {
|
||||
@@ -413,18 +356,10 @@ func (r *MessageRenderer) RenderDebugConfigMessage(config map[string]any, timest
|
||||
Foreground(theme.Muted).
|
||||
Render(strings.Join(configLines, "\n"))
|
||||
|
||||
// Create info line
|
||||
info := baseStyle.
|
||||
Width(r.width - 1).
|
||||
Foreground(theme.Muted).
|
||||
Render(fmt.Sprintf(" KIT (%s)", timeStr))
|
||||
|
||||
// Combine parts
|
||||
parts := []string{header}
|
||||
if len(configLines) > 0 {
|
||||
parts = append(parts, configContent)
|
||||
}
|
||||
parts = append(parts, info)
|
||||
|
||||
rendered := style.Render(
|
||||
lipgloss.JoinVertical(lipgloss.Left, parts...),
|
||||
@@ -442,26 +377,15 @@ func (r *MessageRenderer) RenderDebugConfigMessage(config map[string]any, timest
|
||||
// bold text to ensure visibility. Error messages include timestamp information and
|
||||
// are displayed with an error-colored border for immediate recognition.
|
||||
func (r *MessageRenderer) RenderErrorMessage(errorMsg string, timestamp time.Time) UIMessage {
|
||||
// Format timestamp
|
||||
timeStr := timestamp.Local().Format("15:04")
|
||||
|
||||
// Format error content
|
||||
theme := getTheme()
|
||||
|
||||
errorContent := lipgloss.NewStyle().
|
||||
Foreground(theme.Error).
|
||||
Bold(true).
|
||||
Render(errorMsg)
|
||||
|
||||
// Create info line
|
||||
info := fmt.Sprintf(" Error (%s)", timeStr)
|
||||
|
||||
// Combine content and info
|
||||
fullContent := errorContent + "\n" +
|
||||
lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(info)
|
||||
|
||||
// Use the new block renderer
|
||||
rendered := renderContentBlock(
|
||||
fullContent,
|
||||
errorContent,
|
||||
r.width,
|
||||
WithAlign(lipgloss.Left),
|
||||
WithBorderColor(theme.Error),
|
||||
@@ -559,7 +483,7 @@ func (r *MessageRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
|
||||
if extRd != nil && extRd.DisplayName != "" {
|
||||
displayName = extRd.DisplayName
|
||||
}
|
||||
nameStr := lipgloss.NewStyle().Foreground(theme.Tool).Bold(true).Render(displayName)
|
||||
nameStr := lipgloss.NewStyle().Foreground(theme.Info).Bold(true).Render(displayName)
|
||||
|
||||
// Format params with width budget for the header line.
|
||||
// Check extension renderer for custom header params first.
|
||||
@@ -710,3 +634,19 @@ func (r *MessageRenderer) renderMarkdown(content string, width int) string {
|
||||
rendered := toMarkdown(content, width)
|
||||
return strings.TrimSuffix(rendered, "\n")
|
||||
}
|
||||
|
||||
// removeBlankLines removes lines that are visually blank from rendered output.
|
||||
// Glamour wraps every character (including padding spaces) with ANSI color
|
||||
// codes, so we must strip escape sequences before checking whether a line is
|
||||
// empty. This collapses paragraph spacing so user messages render without
|
||||
// extra vertical gaps.
|
||||
func removeBlankLines(s string) string {
|
||||
lines := strings.Split(s, "\n")
|
||||
filtered := lines[:0]
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(ansiEscapeRe.ReplaceAllString(line, "")) != "" {
|
||||
filtered = append(filtered, line)
|
||||
}
|
||||
}
|
||||
return strings.Join(filtered, "\n")
|
||||
}
|
||||
|
||||
+234
-23
@@ -10,9 +10,12 @@ import (
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/fantasy"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/mark3labs/kit/internal/app"
|
||||
"github.com/mark3labs/kit/internal/core"
|
||||
"github.com/mark3labs/kit/internal/models"
|
||||
"github.com/mark3labs/kit/internal/session"
|
||||
)
|
||||
|
||||
@@ -84,6 +87,11 @@ type AppController interface {
|
||||
// to inject command output into context so the LLM can reference it in
|
||||
// subsequent turns.
|
||||
AddContextMessage(text string)
|
||||
// RunWithFiles queues a multimodal prompt (text + images) for execution.
|
||||
// Behaves like Run but includes file parts (e.g. clipboard images)
|
||||
// alongside the text. Returns the current queue depth (0 = started
|
||||
// immediately, >0 = queued).
|
||||
RunWithFiles(prompt string, files []fantasy.FilePart) int
|
||||
}
|
||||
|
||||
// SkillItem holds display metadata about a loaded skill for the startup
|
||||
@@ -321,6 +329,13 @@ type AppModelOptions struct {
|
||||
// successful model switch. Parameters are (newModel, previousModel, source).
|
||||
// May be nil if extensions are not loaded.
|
||||
EmitModelChange func(newModel, previousModel, source string)
|
||||
|
||||
// ThinkingLevel is the initial thinking level (e.g. "off", "medium").
|
||||
ThinkingLevel string
|
||||
// IsReasoningModel is true when the current model supports reasoning.
|
||||
IsReasoningModel bool
|
||||
// SetThinkingLevel changes the thinking level on the agent/provider.
|
||||
SetThinkingLevel func(level string) error
|
||||
}
|
||||
|
||||
// AppModel is the root Bubble Tea model for the interactive TUI. It owns the
|
||||
@@ -381,6 +396,12 @@ type AppModel struct {
|
||||
// the input and move to scrollback when the agent picks them up.
|
||||
queuedMessages []string
|
||||
|
||||
// pendingUserPrints holds user messages that have been consumed from the
|
||||
// queue but not yet printed to scrollback. They are deferred until
|
||||
// SpinnerEvent{Show: true} so the previous assistant response can be
|
||||
// flushed first, preserving chronological order.
|
||||
pendingUserPrints []string
|
||||
|
||||
// canceling tracks whether the user has pressed ESC once during stateWorking.
|
||||
// A second ESC within 2 seconds will cancel the current step.
|
||||
canceling bool
|
||||
@@ -442,6 +463,16 @@ type AppModel struct {
|
||||
// Returns (cancelled, reason). May be nil if no extensions are loaded.
|
||||
emitBeforeSessionSwitch func(reason string) (bool, string)
|
||||
|
||||
// thinkingLevel is the current extended thinking level.
|
||||
thinkingLevel string
|
||||
// thinkingVisible controls whether reasoning blocks are shown or collapsed.
|
||||
thinkingVisible bool
|
||||
// isReasoningModel is true when the current model supports reasoning.
|
||||
isReasoningModel bool
|
||||
// setThinkingLevel is a callback to change the thinking level on the agent.
|
||||
// It takes the new level string and returns an error if the change fails.
|
||||
setThinkingLevel func(level string) error
|
||||
|
||||
// getGlobalShortcuts returns extension-registered keyboard shortcuts.
|
||||
// May be nil if no extensions are loaded.
|
||||
getGlobalShortcuts func() map[string]func()
|
||||
@@ -519,6 +550,10 @@ type streamComponentIface interface {
|
||||
// Returns "" when the spinner is not active. The parent renders this in the
|
||||
// status bar so the spinner never changes the view height.
|
||||
SpinnerView() string
|
||||
// SetThinkingVisible sets whether reasoning blocks are shown or collapsed.
|
||||
SetThinkingVisible(visible bool)
|
||||
// HasReasoning returns true if any reasoning content has been accumulated.
|
||||
HasReasoning() bool
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -585,6 +620,10 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
|
||||
m.getExtensionCommands = opts.GetExtensionCommands
|
||||
m.setModel = opts.SetModel
|
||||
m.emitModelChange = opts.EmitModelChange
|
||||
m.thinkingLevel = opts.ThinkingLevel
|
||||
m.thinkingVisible = true // default to showing thinking blocks
|
||||
m.isReasoningModel = opts.IsReasoningModel
|
||||
m.setThinkingLevel = opts.SetThinkingLevel
|
||||
|
||||
// Store context/skills metadata and tool counts for startup display.
|
||||
m.contextPaths = opts.ContextPaths
|
||||
@@ -613,6 +652,7 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
|
||||
}
|
||||
|
||||
m.stream = NewStreamComponent(opts.CompactMode, width, opts.ModelName)
|
||||
m.stream.SetThinkingVisible(m.thinkingVisible)
|
||||
|
||||
// Propagate initial height distribution to children.
|
||||
m.distributeHeight()
|
||||
@@ -871,6 +911,23 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
|
||||
// Thinking keybindings — only when the model supports reasoning.
|
||||
if m.isReasoningModel {
|
||||
switch msg.String() {
|
||||
case "ctrl+t":
|
||||
// Toggle thinking block visibility.
|
||||
m.thinkingVisible = !m.thinkingVisible
|
||||
if m.stream != nil {
|
||||
m.stream.SetThinkingVisible(m.thinkingVisible)
|
||||
}
|
||||
return m, tea.Batch(cmds...)
|
||||
case "shift+tab":
|
||||
// Cycle thinking level.
|
||||
m.cycleThinkingLevel()
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
}
|
||||
|
||||
// Route to tree selector when active.
|
||||
if m.state == stateTreeSelector && m.treeSelector != nil {
|
||||
updated, cmd := m.treeSelector.Update(msg)
|
||||
@@ -930,16 +987,18 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// If remap target is unrecognized, fall through to normal handling.
|
||||
case EditorKeySubmit:
|
||||
text := action.SubmitText
|
||||
var images []ImageAttachment
|
||||
if text == "" {
|
||||
if ic, ok := m.input.(*InputComponent); ok {
|
||||
text = strings.TrimSpace(ic.textarea.Value())
|
||||
images = ic.ClearPendingImages()
|
||||
ic.textarea.SetValue("")
|
||||
ic.textarea.CursorEnd()
|
||||
}
|
||||
}
|
||||
if text != "" {
|
||||
cmds = append(cmds, func() tea.Msg {
|
||||
return submitMsg{Text: text}
|
||||
return submitMsg{Text: text, Images: images}
|
||||
})
|
||||
}
|
||||
intercepted = true
|
||||
@@ -984,6 +1043,11 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
return m, tea.Batch(cmds...)
|
||||
case "/thinking":
|
||||
if cmd := m.handleThinkingCommand(strings.TrimSpace(args)); cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1004,23 +1068,48 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
processedText = ProcessFileAttachments(msg.Text, m.cwd)
|
||||
}
|
||||
|
||||
// Convert image attachments to fantasy.FilePart for the app layer.
|
||||
var fileParts []fantasy.FilePart
|
||||
for _, img := range msg.Images {
|
||||
fileParts = append(fileParts, fantasy.FilePart{
|
||||
Data: img.Data,
|
||||
MediaType: img.MediaType,
|
||||
})
|
||||
}
|
||||
|
||||
// Build display text for scrollback (include image count if any).
|
||||
displayText := msg.Text
|
||||
if len(msg.Images) > 0 {
|
||||
displayText = fmt.Sprintf("%s\n[%d image(s) attached]", msg.Text, len(msg.Images))
|
||||
}
|
||||
|
||||
if m.appCtrl != nil {
|
||||
// Run returns the queue depth: >0 means the prompt was queued
|
||||
// (agent is busy). We update queuedMessages directly here
|
||||
// instead of relying on an event from prog.Send(), which would
|
||||
// deadlock when called synchronously from within Update().
|
||||
if qLen := m.appCtrl.Run(processedText); qLen > 0 {
|
||||
var qLen int
|
||||
if len(fileParts) > 0 {
|
||||
qLen = m.appCtrl.RunWithFiles(processedText, fileParts)
|
||||
} else {
|
||||
qLen = m.appCtrl.Run(processedText)
|
||||
}
|
||||
if qLen > 0 {
|
||||
// Queued: anchor the message text above the input with a
|
||||
// "queued" badge. It will be printed to scrollback when
|
||||
// the agent picks it up (on QueueUpdatedEvent).
|
||||
m.queuedMessages = append(m.queuedMessages, msg.Text)
|
||||
// the agent picks it up (via SpinnerEvent).
|
||||
m.queuedMessages = append(m.queuedMessages, displayText)
|
||||
m.distributeHeight()
|
||||
} else {
|
||||
// Started immediately: print to scrollback now.
|
||||
cmds = append(cmds, m.printUserMessage(msg.Text))
|
||||
// Started immediately. Flush any leftover stream content
|
||||
// from the previous step first, then print the user
|
||||
// message — combined in a single tea.Println so
|
||||
// scrollback stays in chronological order.
|
||||
m.pendingUserPrints = append(m.pendingUserPrints, displayText)
|
||||
cmds = append(cmds, m.flushStreamAndPendingUserMessages())
|
||||
}
|
||||
} else {
|
||||
cmds = append(cmds, m.printUserMessage(msg.Text))
|
||||
cmds = append(cmds, m.printUserMessage(displayText))
|
||||
}
|
||||
if m.state != stateWorking {
|
||||
m.state = stateWorking
|
||||
@@ -1040,10 +1129,11 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// SpinnerEvent{Show: true} means a new agent step has started (either
|
||||
// freshly or from the queue after a previous step completed). Flush
|
||||
// any leftover stream content from the previous step to scrollback
|
||||
// before starting the new one. This deferred flush avoids shrinking
|
||||
// the view at step-completion time (which leaves blank lines).
|
||||
// before starting the new one, followed by any pending user messages
|
||||
// from the queue. Everything is emitted in a single tea.Println to
|
||||
// guarantee chronological ordering in scrollback.
|
||||
if msg.Show {
|
||||
cmds = append(cmds, m.flushStreamContent())
|
||||
cmds = append(cmds, m.flushStreamAndPendingUserMessages())
|
||||
m.state = stateWorking
|
||||
m.distributeHeight()
|
||||
}
|
||||
@@ -1052,6 +1142,12 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
case app.ReasoningChunkEvent:
|
||||
if m.stream != nil {
|
||||
_, cmd := m.stream.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
case app.StreamChunkEvent:
|
||||
if m.stream != nil {
|
||||
_, cmd := m.stream.Update(msg)
|
||||
@@ -1087,26 +1183,31 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// double-printing.
|
||||
|
||||
case app.ResponseCompleteEvent:
|
||||
// Non-streaming mode: this carries the full response text (StreamChunkEvents
|
||||
// never fire). Print it immediately.
|
||||
if msg.Content != "" {
|
||||
// This event fires for both streaming and non-streaming paths.
|
||||
// In streaming mode, the content was already delivered via StreamChunkEvents
|
||||
// and is sitting in the stream component (possibly with reasoning). Don't
|
||||
// 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 != "" {
|
||||
cmds = append(cmds, m.printAssistantMessage(msg.Content))
|
||||
}
|
||||
if m.stream != nil {
|
||||
m.stream.Reset() // stop spinner
|
||||
if m.stream != nil {
|
||||
m.stream.Reset()
|
||||
}
|
||||
}
|
||||
|
||||
case app.MessageCreatedEvent:
|
||||
// Informational — no action needed by parent.
|
||||
|
||||
case app.QueueUpdatedEvent:
|
||||
// drainQueue popped item(s) from the queue. Move consumed messages
|
||||
// from the anchored display to scrollback (they are now being processed
|
||||
// or about to be).
|
||||
// drainQueue popped item(s) from the queue. Move consumed
|
||||
// messages to pendingUserPrints — they will be printed to
|
||||
// scrollback in the next SpinnerEvent{Show: true} after the
|
||||
// previous assistant response is flushed.
|
||||
for len(m.queuedMessages) > msg.Length {
|
||||
text := m.queuedMessages[0]
|
||||
m.queuedMessages = m.queuedMessages[1:]
|
||||
cmds = append(cmds, m.printUserMessage(text))
|
||||
m.pendingUserPrints = append(m.pendingUserPrints, text)
|
||||
}
|
||||
m.distributeHeight()
|
||||
|
||||
@@ -1448,8 +1549,14 @@ func (m *AppModel) renderStatusBar() string {
|
||||
leftSide = m.stream.SpinnerView()
|
||||
}
|
||||
|
||||
// Middle: extension status bar entries (sorted by priority).
|
||||
// Middle: thinking level (when reasoning model) + extension status bar entries.
|
||||
var middleParts []string
|
||||
if m.isReasoningModel && m.thinkingLevel != "" && m.thinkingLevel != "off" {
|
||||
thinkingLabel := "Thinking: " + m.thinkingLevel
|
||||
middleParts = append(middleParts, lipgloss.NewStyle().
|
||||
Foreground(theme.Secondary).
|
||||
Render(thinkingLabel))
|
||||
}
|
||||
if m.getStatusBarEntries != nil {
|
||||
entries := m.getStatusBarEntries()
|
||||
for _, e := range entries {
|
||||
@@ -1493,6 +1600,35 @@ func (m *AppModel) renderStatusBar() string {
|
||||
return leftSide + middleSide + strings.Repeat(" ", gap) + rightSide
|
||||
}
|
||||
|
||||
// cycleThinkingLevel advances to the next thinking level and applies it.
|
||||
func (m *AppModel) cycleThinkingLevel() {
|
||||
levels := []string{"off", "minimal", "low", "medium", "high"}
|
||||
current := m.thinkingLevel
|
||||
if current == "" {
|
||||
current = "off"
|
||||
}
|
||||
|
||||
// Find current index and advance to next.
|
||||
idx := 0
|
||||
for i, l := range levels {
|
||||
if l == current {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
next := levels[(idx+1)%len(levels)]
|
||||
m.thinkingLevel = next
|
||||
|
||||
// Apply the change to the agent/provider.
|
||||
if m.setThinkingLevel != nil {
|
||||
// Run in goroutine to avoid blocking the event loop (provider
|
||||
// recreation may take time).
|
||||
go func() {
|
||||
_ = m.setThinkingLevel(next)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// renderSeparator renders the separator line with an optional queue count badge.
|
||||
func (m *AppModel) renderSeparator() string {
|
||||
theme := GetTheme()
|
||||
@@ -1678,6 +1814,8 @@ func (m *AppModel) handleSlashCommand(sc *SlashCommand) tea.Cmd {
|
||||
return m.printResetUsage()
|
||||
case "/model":
|
||||
return m.handleModelCommand("")
|
||||
case "/thinking":
|
||||
return m.handleThinkingCommand("")
|
||||
case "/compact":
|
||||
return m.handleCompactCommand("")
|
||||
case "/clear":
|
||||
@@ -1954,8 +2092,7 @@ func (m *AppModel) printCompactResult(evt app.CompactCompleteEvent) tea.Cmd {
|
||||
// and on step completion.
|
||||
//
|
||||
// After flushing, a ClearScreen is issued to force a full terminal redraw.
|
||||
// When
|
||||
// the stream content is moved to scrollback the view height shrinks, and
|
||||
// When the stream content is moved to scrollback the view height shrinks, and
|
||||
// bubbletea's inline renderer doesn't clear the orphaned terminal rows
|
||||
// below the managed region. ClearScreen ensures a clean redraw.
|
||||
func (m *AppModel) flushStreamContent() tea.Cmd {
|
||||
@@ -1973,6 +2110,37 @@ func (m *AppModel) flushStreamContent() tea.Cmd {
|
||||
)
|
||||
}
|
||||
|
||||
// flushStreamAndPendingUserMessages flushes the previous assistant response
|
||||
// and any pending queued user messages to scrollback in a single tea.Println
|
||||
// call, guaranteeing chronological order. Called from SpinnerEvent{Show: true}
|
||||
// where all previous stream chunks are guaranteed to have been processed.
|
||||
func (m *AppModel) flushStreamAndPendingUserMessages() tea.Cmd {
|
||||
var parts []string
|
||||
|
||||
// 1. Flush previous stream content (assistant response).
|
||||
if m.stream != nil {
|
||||
if content := m.stream.GetRenderedContent(); content != "" {
|
||||
m.stream.Reset()
|
||||
parts = append(parts, content)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Render pending user messages from the queue.
|
||||
for _, text := range m.pendingUserPrints {
|
||||
rendered := m.renderer.RenderUserMessage(text, time.Now()).Content
|
||||
parts = append(parts, rendered)
|
||||
}
|
||||
m.pendingUserPrints = nil
|
||||
|
||||
if len(parts) == 0 {
|
||||
return nil
|
||||
}
|
||||
return tea.Sequence(
|
||||
tea.Println(strings.Join(parts, "\n")),
|
||||
func() tea.Msg { return tea.ClearScreen() },
|
||||
)
|
||||
}
|
||||
|
||||
// distributeHeight recalculates child component heights after a window resize,
|
||||
// queue change, widget update, or state transition, and propagates the computed
|
||||
// stream height to the StreamComponent.
|
||||
@@ -2150,6 +2318,49 @@ func (m *AppModel) handleModelCommand(args string) tea.Cmd {
|
||||
return m.printSystemMessage(fmt.Sprintf("Switched to %s", args))
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Thinking command handler
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// handleThinkingCommand changes or displays the current thinking/reasoning level.
|
||||
// With no arguments, it shows the current level. With a level argument (off,
|
||||
// minimal, low, medium, high) it switches to that level.
|
||||
func (m *AppModel) handleThinkingCommand(args string) tea.Cmd {
|
||||
if !m.isReasoningModel {
|
||||
return m.printSystemMessage("Current model does not support thinking/reasoning.")
|
||||
}
|
||||
|
||||
if args == "" {
|
||||
// Show current level with descriptions.
|
||||
var lines []string
|
||||
levels := models.ThinkingLevels()
|
||||
for _, l := range levels {
|
||||
marker := " "
|
||||
if string(l) == m.thinkingLevel {
|
||||
marker = "▸ "
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%s%s — %s", marker, l, models.ThinkingLevelDescription(l)))
|
||||
}
|
||||
header := fmt.Sprintf("Current thinking level: %s\n\nAvailable levels:", m.thinkingLevel)
|
||||
return m.printSystemMessage(header + "\n" + strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
// Parse and validate the level.
|
||||
level := models.ParseThinkingLevel(args)
|
||||
if string(level) != strings.ToLower(args) {
|
||||
return m.printSystemMessage(fmt.Sprintf("Unknown thinking level: %q. Use: off, minimal, low, medium, high", args))
|
||||
}
|
||||
|
||||
// Apply the change.
|
||||
m.thinkingLevel = string(level)
|
||||
if m.setThinkingLevel != nil {
|
||||
go func() {
|
||||
_ = m.setThinkingLevel(string(level))
|
||||
}()
|
||||
}
|
||||
return m.printSystemMessage(fmt.Sprintf("Thinking level set to: %s — %s", level, models.ThinkingLevelDescription(level)))
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Tree session command handlers
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/fantasy"
|
||||
"github.com/mark3labs/kit/internal/app"
|
||||
"github.com/mark3labs/kit/internal/session"
|
||||
)
|
||||
@@ -61,6 +62,11 @@ func (s *stubAppController) AddContextMessage(_ string) {
|
||||
// no-op in tests
|
||||
}
|
||||
|
||||
func (s *stubAppController) RunWithFiles(prompt string, _ []fantasy.FilePart) int {
|
||||
s.runCalls = append(s.runCalls, prompt)
|
||||
return s.queueLen
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Stub child components
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -83,6 +89,8 @@ func (s *stubStreamComponent) Reset() { s.resetCalled++; s.r
|
||||
func (s *stubStreamComponent) SetHeight(h int) { s.height = h }
|
||||
func (s *stubStreamComponent) GetRenderedContent() string { return s.renderedContent }
|
||||
func (s *stubStreamComponent) SpinnerView() string { return "" }
|
||||
func (s *stubStreamComponent) SetThinkingVisible(bool) {}
|
||||
func (s *stubStreamComponent) HasReasoning() bool { return false }
|
||||
|
||||
// stubInputComponent satisfies inputComponentIface without rendering anything.
|
||||
type stubInputComponent struct {
|
||||
@@ -397,14 +405,16 @@ func TestQueuedMessages_storedOnQueuedSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestQueuedMessages_poppedOnQueueUpdated verifies that QueueUpdatedEvent pops
|
||||
// consumed messages from queuedMessages and prints them to scrollback.
|
||||
// consumed messages from queuedMessages and moves them to pendingUserPrints.
|
||||
// The actual printing is deferred to SpinnerEvent{Show: true} to preserve
|
||||
// chronological order with the preceding assistant response.
|
||||
func TestQueuedMessages_poppedOnQueueUpdated(t *testing.T) {
|
||||
ctrl := &stubAppController{}
|
||||
m, _, _ := newTestAppModel(ctrl)
|
||||
m.queuedMessages = []string{"first", "second", "third"}
|
||||
|
||||
// Simulate drainQueue popping one item (length goes from 3 to 2).
|
||||
_, cmd := m.Update(app.QueueUpdatedEvent{Length: 2})
|
||||
m = sendMsg(m, app.QueueUpdatedEvent{Length: 2})
|
||||
|
||||
if len(m.queuedMessages) != 2 {
|
||||
t.Fatalf("expected 2 queued messages after pop, got %d", len(m.queuedMessages))
|
||||
@@ -412,14 +422,17 @@ func TestQueuedMessages_poppedOnQueueUpdated(t *testing.T) {
|
||||
if m.queuedMessages[0] != "second" {
|
||||
t.Fatalf("expected first remaining message 'second', got %q", m.queuedMessages[0])
|
||||
}
|
||||
// Should produce a cmd (tea.Println for the popped user message).
|
||||
if cmd == nil {
|
||||
t.Fatal("expected non-nil cmd (tea.Println) for popped message")
|
||||
// Popped message should be deferred to pendingUserPrints.
|
||||
if len(m.pendingUserPrints) != 1 {
|
||||
t.Fatalf("expected 1 pending user print, got %d", len(m.pendingUserPrints))
|
||||
}
|
||||
if m.pendingUserPrints[0] != "first" {
|
||||
t.Fatalf("expected pending message 'first', got %q", m.pendingUserPrints[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueuedMessages_allPoppedOnDrain verifies that QueueUpdatedEvent with
|
||||
// Length=0 pops all remaining queued messages.
|
||||
// Length=0 pops all remaining queued messages into pendingUserPrints.
|
||||
func TestQueuedMessages_allPoppedOnDrain(t *testing.T) {
|
||||
ctrl := &stubAppController{}
|
||||
m, _, _ := newTestAppModel(ctrl)
|
||||
@@ -430,6 +443,9 @@ func TestQueuedMessages_allPoppedOnDrain(t *testing.T) {
|
||||
if len(m.queuedMessages) != 0 {
|
||||
t.Fatalf("expected 0 queued messages after drain, got %d", len(m.queuedMessages))
|
||||
}
|
||||
if len(m.pendingUserPrints) != 2 {
|
||||
t.Fatalf("expected 2 pending user prints, got %d", len(m.pendingUserPrints))
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -83,7 +83,7 @@ func newInputPrompt(message, placeholder, defaultValue string, width, height int
|
||||
|
||||
// Prevent Enter from inserting a newline — we intercept it for submit.
|
||||
ta.KeyMap.InsertNewline = key.NewBinding(
|
||||
key.WithKeys("ctrl+j", "alt+enter"),
|
||||
key.WithKeys("ctrl+j", "shift+enter"),
|
||||
)
|
||||
|
||||
if defaultValue != "" {
|
||||
|
||||
@@ -42,10 +42,10 @@ func NewSlashCommandInput(width int, title string) *SlashCommandInput {
|
||||
ta.SetHeight(3) // Default to 3 lines like huh
|
||||
ta.Focus()
|
||||
|
||||
// Override InsertNewline so only ctrl+j and alt+enter insert newlines.
|
||||
// Override InsertNewline so only ctrl+j and shift+enter insert newlines.
|
||||
// Enter always submits the input.
|
||||
ta.KeyMap.InsertNewline = key.NewBinding(
|
||||
key.WithKeys("ctrl+j", "alt+enter"),
|
||||
key.WithKeys("ctrl+j", "shift+enter"),
|
||||
key.WithHelp("ctrl+j", "insert newline"),
|
||||
)
|
||||
|
||||
@@ -227,7 +227,7 @@ func (s *SlashCommandInput) View() tea.View {
|
||||
MarginTop(1).
|
||||
PaddingLeft(3)
|
||||
|
||||
helpText := "enter submit • ctrl+j / alt+enter new line"
|
||||
helpText := "enter submit • ctrl+j / shift+enter new line"
|
||||
|
||||
view.WriteString("\n")
|
||||
view.WriteString(helpStyle.Render(helpText))
|
||||
|
||||
+277
-18
@@ -1,6 +1,7 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -69,6 +70,25 @@ func streamSpinnerTickCmd() tea.Cmd {
|
||||
})
|
||||
}
|
||||
|
||||
// streamFlushTickMsg fires when it's time to commit pending chunks to the
|
||||
// main content builders and trigger a re-render. This coalesces rapid
|
||||
// streaming chunks into fewer expensive markdown re-renders.
|
||||
type streamFlushTickMsg struct{}
|
||||
|
||||
// streamFlushInterval is the coalescing window for stream chunks. Chunks
|
||||
// arriving within this window are batched into a single render pass.
|
||||
// 16ms ≈ 60 fps — fast enough to appear smooth, slow enough to coalesce
|
||||
// bursts from the LLM provider.
|
||||
const streamFlushInterval = 16 * time.Millisecond
|
||||
|
||||
// streamFlushTickCmd returns a tea.Cmd that fires streamFlushTickMsg after
|
||||
// the coalescing interval.
|
||||
func streamFlushTickCmd() tea.Cmd {
|
||||
return tea.Tick(streamFlushInterval, func(_ time.Time) tea.Msg {
|
||||
return streamFlushTickMsg{}
|
||||
})
|
||||
}
|
||||
|
||||
// streamPhase tracks what the StreamComponent is currently displaying.
|
||||
type streamPhase int
|
||||
|
||||
@@ -114,13 +134,47 @@ type StreamComponent struct {
|
||||
// spinnerFrame is the current frame index.
|
||||
spinnerFrame int
|
||||
|
||||
// spinnerMsg is the label shown next to the KITT animation (e.g.
|
||||
// "Executing tool_name…"). Empty string means no label.
|
||||
spinnerMsg string
|
||||
// activeTools tracks the names of tools currently executing in parallel.
|
||||
// When multiple tools run concurrently, all are displayed in the spinner.
|
||||
activeTools []string
|
||||
|
||||
// streamContent accumulates all streaming text chunks.
|
||||
// streamContent holds committed streaming text (flushed from pending).
|
||||
streamContent strings.Builder
|
||||
|
||||
// reasoningContent holds committed reasoning text (flushed from pending).
|
||||
reasoningContent strings.Builder
|
||||
|
||||
// pendingStream accumulates streaming text chunks between flush ticks.
|
||||
// Chunks are written here immediately on arrival, then moved to
|
||||
// streamContent when the flush tick fires.
|
||||
pendingStream strings.Builder
|
||||
|
||||
// pendingReasoning accumulates reasoning chunks between flush ticks.
|
||||
pendingReasoning strings.Builder
|
||||
|
||||
// flushPending is true while a flush tick is in-flight. Prevents
|
||||
// scheduling duplicate ticks when multiple chunks arrive within
|
||||
// the same coalescing window.
|
||||
flushPending bool
|
||||
|
||||
// renderCache holds the last rendered output string. Reused by View()
|
||||
// between flush ticks to avoid redundant markdown re-parsing.
|
||||
renderCache string
|
||||
|
||||
// renderDirty is true when committed content has changed since the
|
||||
// last render. Set on flush tick; cleared after render() rebuilds
|
||||
// the cache.
|
||||
renderDirty bool
|
||||
|
||||
// thinkingVisible controls whether reasoning blocks are expanded or collapsed.
|
||||
thinkingVisible bool
|
||||
|
||||
// reasoningStartTime records when the first reasoning chunk was received.
|
||||
reasoningStartTime time.Time
|
||||
|
||||
// reasoningDuration holds the total reasoning time, frozen when streaming text begins.
|
||||
reasoningDuration time.Duration
|
||||
|
||||
// messageRenderer renders assistant messages in standard mode.
|
||||
messageRenderer *MessageRenderer
|
||||
|
||||
@@ -166,7 +220,12 @@ func (s *StreamComponent) SetHeight(h int) {
|
||||
if h < 0 {
|
||||
h = 0
|
||||
}
|
||||
s.height = h
|
||||
if s.height != h {
|
||||
s.height = h
|
||||
// Invalidate cache — height clamp affects output.
|
||||
s.renderCache = ""
|
||||
s.renderDirty = true
|
||||
}
|
||||
}
|
||||
|
||||
// Reset clears all accumulated state so the component is ready for the next
|
||||
@@ -175,20 +234,60 @@ func (s *StreamComponent) Reset() {
|
||||
s.phase = streamPhaseIdle
|
||||
s.spinning = false
|
||||
s.spinnerFrame = 0
|
||||
s.spinnerMsg = ""
|
||||
s.activeTools = nil
|
||||
s.streamContent.Reset()
|
||||
s.reasoningContent.Reset()
|
||||
s.pendingStream.Reset()
|
||||
s.pendingReasoning.Reset()
|
||||
s.flushPending = false
|
||||
s.renderCache = ""
|
||||
s.renderDirty = false
|
||||
s.timestamp = time.Time{}
|
||||
s.reasoningStartTime = time.Time{}
|
||||
s.reasoningDuration = 0
|
||||
}
|
||||
|
||||
// GetRenderedContent returns the rendered assistant message from the accumulated
|
||||
// streaming text. Returns empty string if no text has been accumulated. Used by
|
||||
// the parent AppModel to flush content via tea.Println() before resetting.
|
||||
//
|
||||
// This commits any pending chunks first so the output includes all received
|
||||
// content, not just what has been flushed by the tick.
|
||||
func (s *StreamComponent) GetRenderedContent() string {
|
||||
// Commit any pending chunks so the final output is complete.
|
||||
s.commitPending()
|
||||
|
||||
var sections []string
|
||||
|
||||
// Include rendered reasoning block if present.
|
||||
if reasoning := s.reasoningContent.String(); reasoning != "" {
|
||||
sections = append(sections, s.renderReasoningBlock(reasoning))
|
||||
}
|
||||
|
||||
text := s.streamContent.String()
|
||||
if text == "" {
|
||||
if text != "" {
|
||||
sections = append(sections, s.renderStreamingText(text))
|
||||
}
|
||||
|
||||
if len(sections) == 0 {
|
||||
return ""
|
||||
}
|
||||
return s.renderStreamingText(text)
|
||||
return strings.Join(sections, "\n")
|
||||
}
|
||||
|
||||
// commitPending moves any pending chunks to the committed content builders.
|
||||
// Called before reading content for scrollback output or on flush tick.
|
||||
func (s *StreamComponent) commitPending() {
|
||||
if s.pendingStream.Len() > 0 {
|
||||
s.streamContent.WriteString(s.pendingStream.String())
|
||||
s.pendingStream.Reset()
|
||||
s.renderDirty = true
|
||||
}
|
||||
if s.pendingReasoning.Len() > 0 {
|
||||
s.reasoningContent.WriteString(s.pendingReasoning.String())
|
||||
s.pendingReasoning.Reset()
|
||||
s.renderDirty = true
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -209,6 +308,9 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
s.width = msg.Width
|
||||
s.messageRenderer.SetWidth(s.width)
|
||||
s.compactRenderer.SetWidth(s.width)
|
||||
// Invalidate render cache — width change affects wrapping/styling.
|
||||
s.renderCache = ""
|
||||
s.renderDirty = true
|
||||
|
||||
case streamSpinnerTickMsg:
|
||||
if s.spinning {
|
||||
@@ -228,6 +330,26 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
s.timestamp = time.Now()
|
||||
}
|
||||
return s, streamSpinnerTickCmd()
|
||||
} else if !msg.Show && s.spinning {
|
||||
s.spinning = false
|
||||
}
|
||||
|
||||
case streamFlushTickMsg:
|
||||
s.flushPending = false
|
||||
s.commitPending()
|
||||
|
||||
case app.ReasoningChunkEvent:
|
||||
s.phase = streamPhaseActive
|
||||
if s.timestamp.IsZero() {
|
||||
s.timestamp = time.Now()
|
||||
}
|
||||
if s.reasoningStartTime.IsZero() {
|
||||
s.reasoningStartTime = time.Now()
|
||||
}
|
||||
s.pendingReasoning.WriteString(msg.Delta)
|
||||
if !s.flushPending {
|
||||
s.flushPending = true
|
||||
return s, streamFlushTickCmd()
|
||||
}
|
||||
|
||||
case app.StreamChunkEvent:
|
||||
@@ -235,12 +357,21 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if s.timestamp.IsZero() {
|
||||
s.timestamp = time.Now()
|
||||
}
|
||||
s.streamContent.WriteString(msg.Content)
|
||||
// Freeze reasoning duration on transition from reasoning to streaming.
|
||||
if s.reasoningDuration == 0 && !s.reasoningStartTime.IsZero() {
|
||||
s.reasoningDuration = time.Since(s.reasoningStartTime)
|
||||
}
|
||||
s.pendingStream.WriteString(msg.Content)
|
||||
if !s.flushPending {
|
||||
s.flushPending = true
|
||||
return s, streamFlushTickCmd()
|
||||
}
|
||||
|
||||
case app.ToolExecutionEvent:
|
||||
if msg.IsStarting {
|
||||
// Show the tool name on the spinner while the tool executes.
|
||||
s.spinnerMsg = "Executing " + msg.ToolName + "…"
|
||||
// Add tool to active list for parallel execution display.
|
||||
toolDisplay := formatToolExecutionMessage(msg.ToolName, msg.ToolArgs)
|
||||
s.activeTools = append(s.activeTools, toolDisplay)
|
||||
s.spinnerFrame = 0
|
||||
if !s.spinning {
|
||||
s.phase = streamPhaseActive
|
||||
@@ -248,8 +379,9 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return s, streamSpinnerTickCmd()
|
||||
}
|
||||
} else {
|
||||
// Tool finished — clear execution label but keep spinning.
|
||||
s.spinnerMsg = ""
|
||||
// Tool finished — remove from active list but keep spinning if others remain.
|
||||
toolDisplay := formatToolExecutionMessage(msg.ToolName, msg.ToolArgs)
|
||||
s.activeTools = removeFromSlice(s.activeTools, toolDisplay)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,20 +397,41 @@ func (s *StreamComponent) View() tea.View {
|
||||
// Internal rendering
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// render builds the full content string for the stream region.
|
||||
// render builds the full content string for the stream region. Uses a render
|
||||
// cache to avoid redundant markdown re-parsing between flush ticks. The cache
|
||||
// is invalidated when committed content changes (flush tick), terminal width
|
||||
// changes, or height/thinking visibility changes.
|
||||
func (s *StreamComponent) render() string {
|
||||
if s.phase == streamPhaseIdle {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Return cached render if committed content hasn't changed.
|
||||
if !s.renderDirty {
|
||||
return s.renderCache
|
||||
}
|
||||
|
||||
var sections []string
|
||||
|
||||
// Render reasoning/thinking block above the main text if present.
|
||||
if reasoning := s.reasoningContent.String(); reasoning != "" {
|
||||
sections = append(sections, s.renderReasoningBlock(reasoning))
|
||||
}
|
||||
|
||||
// Render streaming text only. The spinner is rendered in the status bar
|
||||
// by the parent so it never changes the stream region height.
|
||||
text := s.streamContent.String()
|
||||
if text == "" {
|
||||
if text != "" {
|
||||
sections = append(sections, s.renderStreamingText(text))
|
||||
}
|
||||
|
||||
if len(sections) == 0 {
|
||||
s.renderCache = ""
|
||||
s.renderDirty = false
|
||||
return ""
|
||||
}
|
||||
|
||||
content := s.renderStreamingText(text)
|
||||
content := strings.Join(sections, "\n")
|
||||
|
||||
// Clamp to height if constrained: keep the last h lines so the most
|
||||
// recent output is always visible.
|
||||
@@ -290,9 +443,88 @@ func (s *StreamComponent) render() string {
|
||||
}
|
||||
}
|
||||
|
||||
s.renderCache = content
|
||||
s.renderDirty = false
|
||||
return content
|
||||
}
|
||||
|
||||
// renderReasoningBlock renders the reasoning/thinking content in a surface-tinted
|
||||
// box. When collapsed, shows the last 10 lines with a truncation hint. When
|
||||
// expanded, shows all lines. Includes a "Thought for Xs" duration footer.
|
||||
func (s *StreamComponent) renderReasoningBlock(reasoning string) string {
|
||||
theme := GetTheme()
|
||||
maxWidth := max(s.width-4, 20)
|
||||
|
||||
lines := strings.Split(strings.TrimRight(reasoning, "\n"), "\n")
|
||||
|
||||
contentStyle := lipgloss.NewStyle().
|
||||
Foreground(theme.Muted).
|
||||
Italic(true)
|
||||
|
||||
var parts []string
|
||||
|
||||
// When collapsed and content exceeds 10 lines, show only the last 10
|
||||
// with a truncation hint (matching iteratr's thinking block pattern).
|
||||
const maxCollapsedLines = 10
|
||||
if !s.thinkingVisible && len(lines) > maxCollapsedLines {
|
||||
hidden := len(lines) - maxCollapsedLines
|
||||
hintStyle := lipgloss.NewStyle().
|
||||
Foreground(theme.VeryMuted).
|
||||
Italic(true)
|
||||
parts = append(parts, hintStyle.Render(fmt.Sprintf("... (%d lines hidden)", hidden)))
|
||||
lines = lines[len(lines)-maxCollapsedLines:]
|
||||
}
|
||||
|
||||
// Render reasoning text.
|
||||
parts = append(parts, contentStyle.Width(maxWidth).Render(strings.Join(lines, "\n")))
|
||||
|
||||
// Duration footer.
|
||||
var duration time.Duration
|
||||
if s.reasoningDuration > 0 {
|
||||
duration = s.reasoningDuration
|
||||
} else if !s.reasoningStartTime.IsZero() {
|
||||
duration = time.Since(s.reasoningStartTime)
|
||||
}
|
||||
if duration > 0 {
|
||||
var durationStr string
|
||||
if duration < time.Second {
|
||||
durationStr = fmt.Sprintf("%dms", duration.Milliseconds())
|
||||
} else {
|
||||
durationStr = fmt.Sprintf("%.1fs", duration.Seconds())
|
||||
}
|
||||
footer := lipgloss.NewStyle().Foreground(theme.VeryMuted).Render("Thought for ") +
|
||||
lipgloss.NewStyle().Foreground(theme.Info).Render(durationStr)
|
||||
parts = append(parts, footer)
|
||||
}
|
||||
|
||||
innerContent := strings.Join(parts, "\n")
|
||||
|
||||
// Wrap in box with surface background for visual distinction.
|
||||
boxStyle := lipgloss.NewStyle().
|
||||
Background(theme.MutedBorder). // Surface0 (#313244)
|
||||
PaddingLeft(1).
|
||||
Width(maxWidth + 2).
|
||||
MarginBottom(1)
|
||||
|
||||
return boxStyle.Render(innerContent)
|
||||
}
|
||||
|
||||
// SetThinkingVisible sets whether reasoning blocks are shown or collapsed.
|
||||
func (s *StreamComponent) SetThinkingVisible(visible bool) {
|
||||
if s.thinkingVisible != visible {
|
||||
s.thinkingVisible = visible
|
||||
// Invalidate cache — thinking visibility affects rendered output.
|
||||
s.renderCache = ""
|
||||
s.renderDirty = true
|
||||
}
|
||||
}
|
||||
|
||||
// HasReasoning returns true if any reasoning content has been accumulated
|
||||
// (committed or pending).
|
||||
func (s *StreamComponent) HasReasoning() bool {
|
||||
return s.reasoningContent.Len() > 0 || s.pendingReasoning.Len() > 0
|
||||
}
|
||||
|
||||
// SpinnerView returns the rendered spinner line for the parent to embed in the
|
||||
// status bar. Returns "" when the spinner is not active.
|
||||
func (s *StreamComponent) SpinnerView() string {
|
||||
@@ -300,14 +532,22 @@ func (s *StreamComponent) SpinnerView() string {
|
||||
return ""
|
||||
}
|
||||
frame := s.spinnerFrames[s.spinnerFrame%len(s.spinnerFrames)]
|
||||
if s.spinnerMsg == "" {
|
||||
if len(s.activeTools) == 0 {
|
||||
return " " + frame
|
||||
}
|
||||
theme := GetTheme()
|
||||
msgStyle := lipgloss.NewStyle().
|
||||
Foreground(theme.Text).
|
||||
Italic(true)
|
||||
return " " + frame + " " + msgStyle.Render(s.spinnerMsg)
|
||||
|
||||
// Format active tools list
|
||||
var toolsMsg string
|
||||
if len(s.activeTools) == 1 {
|
||||
toolsMsg = s.activeTools[0]
|
||||
} else {
|
||||
toolsMsg = "Running: " + strings.Join(s.activeTools, ", ")
|
||||
}
|
||||
return " " + frame + " " + msgStyle.Render(toolsMsg)
|
||||
}
|
||||
|
||||
// renderStreamingText renders the accumulated streaming text as a live assistant
|
||||
@@ -325,3 +565,22 @@ func (s *StreamComponent) renderStreamingText(text string) string {
|
||||
msg := s.messageRenderer.RenderAssistantMessage(text, ts, s.modelName)
|
||||
return msg.Content
|
||||
}
|
||||
|
||||
// removeFromSlice removes the first occurrence of a string from a slice.
|
||||
func removeFromSlice(slice []string, s string) []string {
|
||||
for i, v := range slice {
|
||||
if v == s {
|
||||
return append(slice[:i], slice[i+1:]...)
|
||||
}
|
||||
}
|
||||
return slice
|
||||
}
|
||||
|
||||
// formatToolExecutionMessage creates a descriptive spinner message for tool execution.
|
||||
// For spawn_subagent, it shows simply as "Subagent" with optional task preview.
|
||||
func formatToolExecutionMessage(toolName, toolArgs string) string {
|
||||
if toolName == "spawn_subagent" {
|
||||
return "Subagent"
|
||||
}
|
||||
return toolName
|
||||
}
|
||||
|
||||
@@ -49,6 +49,10 @@ func renderToolBody(toolName, toolArgs, toolResult string, width int) string {
|
||||
if body := renderBashBody(toolResult, width); body != "" {
|
||||
return body
|
||||
}
|
||||
case toolName == "spawn_subagent":
|
||||
if body := renderSubagentBody(toolResult, width); body != "" {
|
||||
return body
|
||||
}
|
||||
}
|
||||
return "" // fall back to default
|
||||
}
|
||||
@@ -716,6 +720,8 @@ func renderToolBodyCompact(toolName, toolArgs, toolResult string, width int) str
|
||||
case toolName == "bash" || toolName == "run_shell_cmd" ||
|
||||
strings.Contains(toolName, "shell") || strings.Contains(toolName, "command"):
|
||||
return renderBashCompact(toolResult, width)
|
||||
case toolName == "spawn_subagent":
|
||||
return renderSubagentCompact(toolResult)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -870,3 +876,121 @@ func renderBashCompact(toolResult string, width int) string {
|
||||
|
||||
return lipgloss.NewStyle().Foreground(theme.Muted).Render(summary)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subagent tool renderers — show only summary, not full output
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// renderSubagentBody renders a clean summary of subagent results.
|
||||
// Extracts timing/token info and shows only a brief summary instead of raw output.
|
||||
func renderSubagentBody(toolResult string, width int) string {
|
||||
theme := getTheme()
|
||||
result := strings.TrimSpace(toolResult)
|
||||
if result == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Parse the subagent result format:
|
||||
// "Subagent completed successfully in Xs. (tokens: N in / M out)\n\nResult:\n..."
|
||||
// or "Subagent failed (exit code X) after Ys.\n\nError: ...\n\nPartial output:\n..."
|
||||
|
||||
lines := strings.Split(result, "\n")
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// First line is always the status summary
|
||||
statusLine := lines[0]
|
||||
|
||||
// Build a clean summary
|
||||
var summary strings.Builder
|
||||
summary.WriteString(lipgloss.NewStyle().Foreground(theme.Muted).Render(statusLine))
|
||||
|
||||
// For successful results, extract a brief preview of the actual result
|
||||
if strings.Contains(statusLine, "successfully") {
|
||||
// Find where "Result:" starts and extract a preview
|
||||
if _, resultContent, found := strings.Cut(result, "Result:\n"); found {
|
||||
resultContent = strings.TrimSpace(resultContent)
|
||||
if resultContent != "" {
|
||||
// Show first 3 meaningful lines as preview
|
||||
preview := extractSubagentPreview(resultContent, 3, width-4)
|
||||
if preview != "" {
|
||||
summary.WriteString("\n\n")
|
||||
summary.WriteString(lipgloss.NewStyle().
|
||||
Foreground(theme.Muted).
|
||||
Italic(true).
|
||||
Render(preview))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return summary.String()
|
||||
}
|
||||
|
||||
// extractSubagentPreview extracts the first N non-empty lines from content,
|
||||
// truncating each line to maxWidth.
|
||||
func extractSubagentPreview(content string, maxLines, maxWidth int) string {
|
||||
lines := strings.Split(content, "\n")
|
||||
var preview []string
|
||||
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Truncate long lines
|
||||
if len(trimmed) > maxWidth {
|
||||
trimmed = trimmed[:maxWidth-3] + "..."
|
||||
}
|
||||
preview = append(preview, trimmed)
|
||||
|
||||
if len(preview) >= maxLines {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(preview) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
result := strings.Join(preview, "\n")
|
||||
|
||||
// Count remaining lines for "more" indicator
|
||||
totalLines := 0
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) != "" {
|
||||
totalLines++
|
||||
}
|
||||
}
|
||||
if totalLines > maxLines {
|
||||
result += fmt.Sprintf("\n...(%d more lines)", totalLines-maxLines)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// renderSubagentCompact returns a brief one-line summary for subagent results.
|
||||
func renderSubagentCompact(toolResult string) string {
|
||||
result := strings.TrimSpace(toolResult)
|
||||
if result == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
theme := getTheme()
|
||||
|
||||
// Extract just the first line which contains the status
|
||||
lines := strings.Split(result, "\n")
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
statusLine := lines[0]
|
||||
|
||||
// Make it more compact by removing redundant words
|
||||
statusLine = strings.Replace(statusLine, "Subagent completed successfully in ", "Completed in ", 1)
|
||||
statusLine = strings.Replace(statusLine, "Subagent failed", "Failed", 1)
|
||||
|
||||
return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(statusLine)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ func TestUsageTracker_OAuthCosts(t *testing.T) {
|
||||
stats := regularTracker.GetLastRequestStats()
|
||||
if stats == nil {
|
||||
t.Fatal("Expected stats to be non-nil")
|
||||
return
|
||||
}
|
||||
|
||||
// Check that costs are calculated for regular API key
|
||||
@@ -48,6 +49,7 @@ func TestUsageTracker_OAuthCosts(t *testing.T) {
|
||||
oauthStats := oauthTracker.GetLastRequestStats()
|
||||
if oauthStats == nil {
|
||||
t.Fatal("Expected OAuth stats to be non-nil")
|
||||
return
|
||||
}
|
||||
|
||||
// Check that all costs are $0 for OAuth
|
||||
|
||||
@@ -45,6 +45,7 @@ func setSDKDefaults() {
|
||||
viper.SetDefault("top-p", 0.95)
|
||||
viper.SetDefault("top-k", 40)
|
||||
viper.SetDefault("stream", true)
|
||||
viper.SetDefault("thinking-level", "off")
|
||||
viper.SetDefault("num-gpu-layers", -1)
|
||||
viper.SetDefault("main-gpu", 0)
|
||||
}
|
||||
|
||||
+106
-11
@@ -1,6 +1,9 @@
|
||||
package kit
|
||||
|
||||
import "sync"
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event types
|
||||
@@ -34,6 +37,8 @@ const (
|
||||
EventResponse EventType = "response"
|
||||
// EventCompaction fires after a successful compaction.
|
||||
EventCompaction EventType = "compaction"
|
||||
// EventReasoningDelta fires for each streaming reasoning/thinking chunk.
|
||||
EventReasoningDelta EventType = "reasoning_delta"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -46,6 +51,54 @@ type Event interface {
|
||||
EventType() EventType
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool kind constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ToolKind constants classify what a tool does, enabling UIs to render
|
||||
// appropriate visualizations (e.g. diff view for edit tools, command+output
|
||||
// for execute tools) and file trackers to identify which results contain
|
||||
// modifications.
|
||||
const (
|
||||
ToolKindExecute = "execute" // Shell execution (bash)
|
||||
ToolKindEdit = "edit" // File modification (edit, write)
|
||||
ToolKindRead = "read" // File reading (read, ls)
|
||||
ToolKindSearch = "search" // Content/file search (grep, find)
|
||||
ToolKindSubagent = "agent" // Subagent spawning (spawn_subagent)
|
||||
)
|
||||
|
||||
// coreToolKinds maps built-in tool names to their kind. MCP and extension
|
||||
// tools without an entry default to ToolKindExecute.
|
||||
var coreToolKinds = map[string]string{
|
||||
"bash": ToolKindExecute,
|
||||
"edit": ToolKindEdit,
|
||||
"write": ToolKindEdit,
|
||||
"read": ToolKindRead,
|
||||
"ls": ToolKindRead,
|
||||
"grep": ToolKindSearch,
|
||||
"find": ToolKindSearch,
|
||||
"spawn_subagent": ToolKindSubagent,
|
||||
}
|
||||
|
||||
// toolKindFor returns the ToolKind for a given tool name, defaulting to
|
||||
// ToolKindExecute for unknown tools.
|
||||
func toolKindFor(toolName string) string {
|
||||
if kind, ok := coreToolKinds[toolName]; ok {
|
||||
return kind
|
||||
}
|
||||
return ToolKindExecute
|
||||
}
|
||||
|
||||
// parseToolArgs attempts to parse a JSON-encoded tool args string into a map.
|
||||
// Returns nil on failure (non-fatal convenience parsing).
|
||||
func parseToolArgs(toolArgs string) map[string]any {
|
||||
var parsed map[string]any
|
||||
if json.Unmarshal([]byte(toolArgs), &parsed) == nil {
|
||||
return parsed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Concrete event structs
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -60,8 +113,9 @@ func (e TurnStartEvent) EventType() EventType { return EventTurnStart }
|
||||
|
||||
// TurnEndEvent fires after the agent finishes processing.
|
||||
type TurnEndEvent struct {
|
||||
Response string
|
||||
Error error
|
||||
Response string
|
||||
Error error
|
||||
StopReason string // "end_turn", "max_tokens", "tool_use", "error", etc.
|
||||
}
|
||||
|
||||
// EventType implements Event.
|
||||
@@ -81,6 +135,14 @@ type MessageUpdateEvent struct {
|
||||
// EventType implements Event.
|
||||
func (e MessageUpdateEvent) EventType() EventType { return EventMessageUpdate }
|
||||
|
||||
// ReasoningDeltaEvent fires for each streaming reasoning/thinking chunk.
|
||||
type ReasoningDeltaEvent struct {
|
||||
Delta string
|
||||
}
|
||||
|
||||
// EventType implements Event.
|
||||
func (e ReasoningDeltaEvent) EventType() EventType { return EventReasoningDelta }
|
||||
|
||||
// MessageEndEvent fires when the assistant message is complete.
|
||||
type MessageEndEvent struct {
|
||||
Content string
|
||||
@@ -91,8 +153,11 @@ func (e MessageEndEvent) EventType() EventType { return EventMessageEnd }
|
||||
|
||||
// ToolCallEvent fires when a tool call has been parsed.
|
||||
type ToolCallEvent struct {
|
||||
ToolName string
|
||||
ToolArgs string
|
||||
ToolCallID string // Stable ID for correlating tool lifecycle events
|
||||
ToolName string
|
||||
ToolKind string // Tool classification: "execute", "edit", "read", "search", "agent"
|
||||
ToolArgs string // JSON-encoded arguments
|
||||
ParsedArgs map[string]any // Pre-parsed arguments for convenience (nil on parse failure)
|
||||
}
|
||||
|
||||
// EventType implements Event.
|
||||
@@ -100,7 +165,10 @@ func (e ToolCallEvent) EventType() EventType { return EventToolCall }
|
||||
|
||||
// ToolExecutionStartEvent fires when a tool begins executing.
|
||||
type ToolExecutionStartEvent struct {
|
||||
ToolName string
|
||||
ToolCallID string
|
||||
ToolName string
|
||||
ToolKind string
|
||||
ToolArgs string
|
||||
}
|
||||
|
||||
// EventType implements Event.
|
||||
@@ -108,7 +176,9 @@ func (e ToolExecutionStartEvent) EventType() EventType { return EventToolExecuti
|
||||
|
||||
// ToolExecutionEndEvent fires when a tool finishes executing.
|
||||
type ToolExecutionEndEvent struct {
|
||||
ToolName string
|
||||
ToolCallID string
|
||||
ToolName string
|
||||
ToolKind string
|
||||
}
|
||||
|
||||
// EventType implements Event.
|
||||
@@ -116,10 +186,35 @@ func (e ToolExecutionEndEvent) EventType() EventType { return EventToolExecution
|
||||
|
||||
// ToolResultEvent fires after a tool execution completes with its result.
|
||||
type ToolResultEvent struct {
|
||||
ToolName string
|
||||
ToolArgs string
|
||||
Result string
|
||||
IsError bool
|
||||
ToolCallID string
|
||||
ToolName string
|
||||
ToolKind string
|
||||
ToolArgs string
|
||||
ParsedArgs map[string]any // Pre-parsed arguments for convenience
|
||||
Result string
|
||||
IsError bool
|
||||
Metadata *ToolResultMetadata // Optional structured metadata from tool execution
|
||||
}
|
||||
|
||||
// ToolResultMetadata carries structured data from tool executions.
|
||||
type ToolResultMetadata struct {
|
||||
FileDiffs []FileDiffInfo `json:"file_diffs,omitempty"` // Present for edit/write tools
|
||||
SubagentSessionID string `json:"subagent_session_id,omitempty"` // Present for spawn_subagent tool
|
||||
}
|
||||
|
||||
// FileDiffInfo describes a file modification from an edit or write tool.
|
||||
type FileDiffInfo struct {
|
||||
Path string `json:"path"` // Absolute file path
|
||||
Additions int `json:"additions"` // Lines added
|
||||
Deletions int `json:"deletions"` // Lines removed
|
||||
IsNew bool `json:"is_new,omitempty"` // True if file was created (write only)
|
||||
DiffBlocks []DiffBlock `json:"diff_blocks,omitempty"`
|
||||
}
|
||||
|
||||
// DiffBlock represents a single old→new text replacement within a file.
|
||||
type DiffBlock struct {
|
||||
OldText string `json:"old_text"`
|
||||
NewText string `json:"new_text"`
|
||||
}
|
||||
|
||||
// EventType implements Event.
|
||||
|
||||
@@ -89,11 +89,13 @@ func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
|
||||
if runner.HasHandlers(extensions.AgentEnd) {
|
||||
m.Subscribe(func(e Event) {
|
||||
if ev, ok := e.(TurnEndEvent); ok {
|
||||
stopReason := "completed"
|
||||
stopReason := ev.StopReason
|
||||
response := ev.Response
|
||||
if ev.Error != nil {
|
||||
stopReason = "error"
|
||||
response = ""
|
||||
} else if stopReason == "" {
|
||||
stopReason = "completed"
|
||||
}
|
||||
_, _ = runner.Emit(extensions.AgentEndEvent{
|
||||
Response: response,
|
||||
|
||||
+16
-12
@@ -31,8 +31,9 @@ const (
|
||||
|
||||
// BeforeToolCallHook is the input for hooks that fire before a tool executes.
|
||||
type BeforeToolCallHook struct {
|
||||
ToolName string
|
||||
ToolArgs string
|
||||
ToolCallID string
|
||||
ToolName string
|
||||
ToolArgs string
|
||||
}
|
||||
|
||||
// BeforeToolCallResult controls whether the tool call proceeds.
|
||||
@@ -43,10 +44,11 @@ type BeforeToolCallResult struct {
|
||||
|
||||
// AfterToolResultHook is the input for hooks that fire after a tool executes.
|
||||
type AfterToolResultHook struct {
|
||||
ToolName string
|
||||
ToolArgs string
|
||||
Result string
|
||||
IsError bool
|
||||
ToolCallID string
|
||||
ToolName string
|
||||
ToolArgs string
|
||||
Result string
|
||||
IsError bool
|
||||
}
|
||||
|
||||
// AfterToolResultResult can modify the tool's output before it reaches the LLM.
|
||||
@@ -258,8 +260,9 @@ func (h *hookedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.To
|
||||
// 1. BeforeToolCall — can block execution.
|
||||
if h.beforeToolCall.hasHooks() {
|
||||
if result := h.beforeToolCall.run(BeforeToolCallHook{
|
||||
ToolName: toolName,
|
||||
ToolArgs: call.Input,
|
||||
ToolCallID: call.ID,
|
||||
ToolName: toolName,
|
||||
ToolArgs: call.Input,
|
||||
}); result != nil && result.Block {
|
||||
reason := result.Reason
|
||||
if reason == "" {
|
||||
@@ -276,10 +279,11 @@ func (h *hookedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.To
|
||||
// 3. AfterToolResult — can modify output.
|
||||
if h.afterToolResult.hasHooks() {
|
||||
if result := h.afterToolResult.run(AfterToolResultHook{
|
||||
ToolName: toolName,
|
||||
ToolArgs: call.Input,
|
||||
Result: resp.Content,
|
||||
IsError: err != nil || resp.IsError,
|
||||
ToolCallID: call.ID,
|
||||
ToolName: toolName,
|
||||
ToolArgs: call.Input,
|
||||
Result: resp.Content,
|
||||
IsError: err != nil || resp.IsError,
|
||||
}); result != nil {
|
||||
if result.Result != nil {
|
||||
resp.Content = *result.Result
|
||||
|
||||
@@ -24,6 +24,7 @@ func TestHookRegistry_RegisterAndRun(t *testing.T) {
|
||||
got := hr.run("hello")
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
return
|
||||
}
|
||||
if *got != "handled: hello" {
|
||||
t.Errorf("expected 'handled: hello', got %q", *got)
|
||||
@@ -51,6 +52,7 @@ func TestHookRegistry_FirstNonNilWins(t *testing.T) {
|
||||
got := hr.run("test")
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
return
|
||||
}
|
||||
if *got != "second: test" {
|
||||
t.Errorf("expected 'second: test', got %q", *got)
|
||||
@@ -77,6 +79,7 @@ func TestHookRegistry_PriorityOrdering(t *testing.T) {
|
||||
got := hr.run("x")
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
return
|
||||
}
|
||||
if *got != "high" {
|
||||
t.Errorf("expected 'high' (priority 0 runs first), got %q", *got)
|
||||
@@ -441,6 +444,7 @@ func TestBeforeTurnHook_PromptOverride(t *testing.T) {
|
||||
result := hr.run(BeforeTurnHook{Prompt: "original"})
|
||||
if result == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
return
|
||||
}
|
||||
if result.Prompt == nil || *result.Prompt != "modified prompt" {
|
||||
t.Errorf("expected prompt override, got %v", result.Prompt)
|
||||
@@ -462,6 +466,7 @@ func TestBeforeTurnHook_InjectSystemAndContext(t *testing.T) {
|
||||
result := hr.run(BeforeTurnHook{Prompt: "hello"})
|
||||
if result == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
return
|
||||
}
|
||||
if result.SystemPrompt == nil || *result.SystemPrompt != "be concise" {
|
||||
t.Errorf("expected system prompt injection")
|
||||
|
||||
+344
-19
@@ -2,6 +2,7 @@ package kit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
|
||||
"github.com/mark3labs/kit/internal/agent"
|
||||
"github.com/mark3labs/kit/internal/config"
|
||||
"github.com/mark3labs/kit/internal/core"
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
"github.com/mark3labs/kit/internal/kitsetup"
|
||||
"github.com/mark3labs/kit/internal/message"
|
||||
@@ -347,6 +349,50 @@ func (m *Kit) GetSessionMessages() []extensions.SessionMessage {
|
||||
return msgs
|
||||
}
|
||||
|
||||
// StructuredMessage represents a conversation message with typed content parts
|
||||
// (tool calls, reasoning, finish markers, etc.) instead of flattened text.
|
||||
type StructuredMessage struct {
|
||||
ID string
|
||||
ParentID string
|
||||
Role MessageRole
|
||||
Parts []ContentPart
|
||||
Model string
|
||||
Provider string
|
||||
Timestamp string // RFC3339 format
|
||||
}
|
||||
|
||||
// GetStructuredMessages returns the conversation messages on the current
|
||||
// branch with full typed content parts. Unlike GetSessionMessages() which
|
||||
// flattens all content to a single text string, this preserves tool calls,
|
||||
// tool results, reasoning blocks, and finish markers as distinct typed parts.
|
||||
func (m *Kit) GetStructuredMessages() []StructuredMessage {
|
||||
if m.treeSession == nil {
|
||||
return nil
|
||||
}
|
||||
branch := m.treeSession.GetBranch("")
|
||||
var msgs []StructuredMessage
|
||||
for _, entry := range branch {
|
||||
me, ok := entry.(*session.MessageEntry)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
msg, err := me.ToMessage()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
msgs = append(msgs, StructuredMessage{
|
||||
ID: me.ID,
|
||||
ParentID: me.ParentID,
|
||||
Role: msg.Role,
|
||||
Parts: msg.Parts,
|
||||
Model: msg.Model,
|
||||
Provider: msg.Provider,
|
||||
Timestamp: me.Timestamp.Format("2006-01-02T15:04:05Z07:00"),
|
||||
})
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
|
||||
// GetSessionFilePath returns the JSONL file path of the current session.
|
||||
func (m *Kit) GetSessionFilePath() string {
|
||||
if m.treeSession == nil {
|
||||
@@ -485,6 +531,7 @@ func (m *Kit) SetModel(ctx context.Context, modelString string) error {
|
||||
ProviderURL: viper.GetString("provider-url"),
|
||||
MaxTokens: viper.GetInt("max-tokens"),
|
||||
TLSSkipVerify: viper.GetBool("tls-skip-verify"),
|
||||
ThinkingLevel: models.ParseThinkingLevel(viper.GetString("thinking-level")),
|
||||
}
|
||||
temperature := float32(viper.GetFloat64("temperature"))
|
||||
config.Temperature = &temperature
|
||||
@@ -618,9 +665,10 @@ func (m *Kit) ReloadExtensions() error {
|
||||
// used, and closed.
|
||||
func (m *Kit) ExecuteCompletion(ctx context.Context, req extensions.CompleteRequest) (extensions.CompleteResponse, error) {
|
||||
var (
|
||||
llmModel fantasy.LanguageModel
|
||||
closer func()
|
||||
usedModel string
|
||||
llmModel fantasy.LanguageModel
|
||||
closer func()
|
||||
usedModel string
|
||||
providerOps fantasy.ProviderOptions
|
||||
)
|
||||
|
||||
if req.Model == "" {
|
||||
@@ -643,6 +691,7 @@ func (m *Kit) ExecuteCompletion(ctx context.Context, req extensions.CompleteRequ
|
||||
}
|
||||
llmModel = providerResult.Model
|
||||
usedModel = req.Model
|
||||
providerOps = providerResult.ProviderOptions
|
||||
closer = func() {
|
||||
if providerResult.Closer != nil {
|
||||
_ = providerResult.Closer.Close()
|
||||
@@ -659,6 +708,9 @@ func (m *Kit) ExecuteCompletion(ctx context.Context, req extensions.CompleteRequ
|
||||
if req.MaxTokens > 0 {
|
||||
agentOpts = append(agentOpts, fantasy.WithMaxOutputTokens(int64(req.MaxTokens)))
|
||||
}
|
||||
if providerOps != nil {
|
||||
agentOpts = append(agentOpts, fantasy.WithProviderOptions(providerOps))
|
||||
}
|
||||
|
||||
completionAgent := fantasy.NewAgent(llmModel, agentOpts...)
|
||||
|
||||
@@ -843,11 +895,19 @@ func InitTreeSession(opts *Options) (*session.TreeManager, error) {
|
||||
// New creates a Kit instance using the same initialization as the CLI.
|
||||
// It loads configuration, initializes MCP servers, creates the LLM model, and
|
||||
// sets up the agent for interaction. Returns an error if initialization fails.
|
||||
// viperInitMu serializes viper writes during kit.New(). Viper's global state
|
||||
// is not thread-safe, so concurrent calls (e.g. parallel subagent spawns)
|
||||
// must not overlap the Set()/Get() window.
|
||||
var viperInitMu sync.Mutex
|
||||
|
||||
func New(ctx context.Context, opts *Options) (*Kit, error) {
|
||||
if opts == nil {
|
||||
opts = &Options{}
|
||||
}
|
||||
|
||||
viperInitMu.Lock()
|
||||
defer viperInitMu.Unlock()
|
||||
|
||||
// Set CLI-equivalent defaults for viper. When used as an SDK (without
|
||||
// cobra), these defaults are not registered via flag bindings.
|
||||
setSDKDefaults()
|
||||
@@ -1144,6 +1204,14 @@ type TurnResult struct {
|
||||
// Response is the assistant's final text response.
|
||||
Response string
|
||||
|
||||
// StopReason indicates why the turn ended. Derived from the LLM
|
||||
// provider's finish reason: "stop", "length" (max tokens), "tool-calls",
|
||||
// "content-filter", "error", "other", "unknown".
|
||||
StopReason string
|
||||
|
||||
// SessionID is the UUID of the session this turn belongs to.
|
||||
SessionID string
|
||||
|
||||
// TotalUsage is the aggregate token usage across all steps in the turn
|
||||
// (includes tool-calling loop iterations). Nil if the provider didn't
|
||||
// report usage.
|
||||
@@ -1159,6 +1227,168 @@ type TurnResult struct {
|
||||
Messages []FantasyMessage
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-process subagent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SubagentConfig configures an in-process subagent spawned via Kit.Subagent().
|
||||
type SubagentConfig struct {
|
||||
// Prompt is the task/instruction for the subagent (required).
|
||||
Prompt string
|
||||
|
||||
// Model overrides the parent's model (e.g. "anthropic/claude-haiku-3-5-20241022").
|
||||
// Empty string uses the parent's current model.
|
||||
Model string
|
||||
|
||||
// SystemPrompt provides domain-specific instructions for the subagent.
|
||||
// Empty string uses a minimal default prompt.
|
||||
SystemPrompt string
|
||||
|
||||
// Tools overrides the tool set. If nil, SubagentTools() is used (all
|
||||
// core tools except spawn_subagent, preventing infinite recursion).
|
||||
Tools []Tool
|
||||
|
||||
// NoSession, when true, uses an in-memory ephemeral session. When false
|
||||
// (default), the subagent's session is persisted and can be loaded for
|
||||
// replay/inspection.
|
||||
NoSession bool
|
||||
|
||||
// Timeout limits execution time. Zero means 5 minute default.
|
||||
Timeout time.Duration
|
||||
|
||||
// OnEvent, when set, receives all events from the subagent's event bus.
|
||||
// This enables the parent to stream subagent tool calls, text chunks,
|
||||
// etc. in real time.
|
||||
OnEvent func(Event)
|
||||
}
|
||||
|
||||
// SubagentResult contains the outcome of an in-process subagent execution.
|
||||
type SubagentResult struct {
|
||||
// Response is the subagent's final text response.
|
||||
Response string
|
||||
// Error is set if the subagent failed (nil on success).
|
||||
Error error
|
||||
// SessionID is the subagent's session identifier (for replay).
|
||||
SessionID string
|
||||
// StopReason is the LLM's finish reason for the subagent's final turn.
|
||||
StopReason string
|
||||
// Usage contains token usage from the subagent's run.
|
||||
Usage *FantasyUsage
|
||||
// Elapsed is the total execution time.
|
||||
Elapsed time.Duration
|
||||
}
|
||||
|
||||
// Subagent spawns an in-process child Kit instance to perform a task. The
|
||||
// child gets its own session, event bus, and agent loop but shares the
|
||||
// parent's config (API keys, provider settings) and defaults to the parent's
|
||||
// model when SubagentConfig.Model is empty.
|
||||
//
|
||||
// This is the recommended way to run subagents in the SDK — no subprocess,
|
||||
// no kit binary dependency, native Go types for results.
|
||||
func (m *Kit) Subagent(ctx context.Context, cfg SubagentConfig) (*SubagentResult, error) {
|
||||
if cfg.Prompt == "" {
|
||||
return nil, fmt.Errorf("subagent prompt is required")
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
|
||||
// Default timeout.
|
||||
timeout := cfg.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 5 * time.Minute
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
// Resolve model: fall back to parent's model, and inherit the parent's
|
||||
// provider when only a bare model name is given (e.g. "claude-haiku"
|
||||
// instead of "anthropic/claude-haiku"). This avoids provider guessing.
|
||||
model := cfg.Model
|
||||
if model == "" {
|
||||
model = m.modelString
|
||||
} else if !strings.Contains(model, "/") {
|
||||
// Bare model name — prepend parent's provider.
|
||||
if parts := strings.SplitN(m.modelString, "/", 2); len(parts) == 2 {
|
||||
model = parts[0] + "/" + model
|
||||
}
|
||||
}
|
||||
|
||||
// Default system prompt.
|
||||
systemPrompt := cfg.SystemPrompt
|
||||
if systemPrompt == "" {
|
||||
systemPrompt = "You are a helpful coding assistant. Complete the task efficiently and thoroughly."
|
||||
}
|
||||
|
||||
// Default tools: everything except spawn_subagent.
|
||||
tools := cfg.Tools
|
||||
if tools == nil {
|
||||
tools = SubagentTools()
|
||||
}
|
||||
|
||||
// Create child Kit instance. If the requested model fails (bad name,
|
||||
// unsupported provider, etc.), fall back to the parent's model so the
|
||||
// agent gets a useful error message instead of a hard failure.
|
||||
childOpts := &Options{
|
||||
Model: model,
|
||||
SystemPrompt: systemPrompt,
|
||||
Tools: tools,
|
||||
NoSession: cfg.NoSession,
|
||||
Quiet: true,
|
||||
}
|
||||
child, err := New(ctx, childOpts)
|
||||
if err != nil && model != m.modelString {
|
||||
// Model-specific failure — retry with parent's model.
|
||||
childOpts.Model = m.modelString
|
||||
child, err = New(ctx, childOpts)
|
||||
if err != nil {
|
||||
return &SubagentResult{
|
||||
Error: fmt.Errorf("failed to create subagent: %w", err),
|
||||
Elapsed: time.Since(start),
|
||||
}, err
|
||||
}
|
||||
// Prepend a note so the agent knows which model is actually running.
|
||||
cfg.Prompt = fmt.Sprintf(
|
||||
"[Note: requested model %q was not available, using %s instead.]\n\n%s",
|
||||
model, m.modelString, cfg.Prompt,
|
||||
)
|
||||
} else if err != nil {
|
||||
return &SubagentResult{
|
||||
Error: fmt.Errorf("failed to create subagent: %w", err),
|
||||
Elapsed: time.Since(start),
|
||||
}, err
|
||||
}
|
||||
defer func() { _ = child.Close() }()
|
||||
|
||||
// Forward events to parent if requested.
|
||||
if cfg.OnEvent != nil {
|
||||
child.Subscribe(cfg.OnEvent)
|
||||
}
|
||||
|
||||
// Run the prompt.
|
||||
result, err := child.PromptResult(ctx, cfg.Prompt)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
return &SubagentResult{
|
||||
Error: err,
|
||||
SessionID: child.GetSessionID(),
|
||||
Elapsed: elapsed,
|
||||
}, err
|
||||
}
|
||||
|
||||
subResult := &SubagentResult{
|
||||
Response: result.Response,
|
||||
SessionID: child.GetSessionID(),
|
||||
StopReason: result.StopReason,
|
||||
Elapsed: elapsed,
|
||||
}
|
||||
if result.TotalUsage != nil {
|
||||
subResult.Usage = result.TotalUsage
|
||||
}
|
||||
|
||||
return subResult, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared generation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1167,22 +1397,64 @@ type TurnResult struct {
|
||||
// All prompt modes (Prompt, Steer, FollowUp, PromptWithOptions) share this
|
||||
// single code path so callback wiring is never duplicated.
|
||||
func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent.GenerateWithLoopResult, error) {
|
||||
// Inject the in-process subagent spawner into the context so the
|
||||
// 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,
|
||||
) (*core.SubagentSpawnResult, error) {
|
||||
result, err := m.Subagent(spawnCtx, SubagentConfig{
|
||||
Prompt: prompt,
|
||||
Model: model,
|
||||
SystemPrompt: systemPrompt,
|
||||
Timeout: timeout,
|
||||
OnEvent: func(e Event) {
|
||||
m.events.emit(e)
|
||||
},
|
||||
})
|
||||
if result == nil {
|
||||
return &core.SubagentSpawnResult{Error: err}, err
|
||||
}
|
||||
sr := &core.SubagentSpawnResult{
|
||||
Response: result.Response,
|
||||
Error: result.Error,
|
||||
SessionID: result.SessionID,
|
||||
Elapsed: result.Elapsed,
|
||||
}
|
||||
if result.Usage != nil {
|
||||
sr.InputTokens = result.Usage.InputTokens
|
||||
sr.OutputTokens = result.Usage.OutputTokens
|
||||
}
|
||||
return sr, err
|
||||
})
|
||||
|
||||
return m.agent.GenerateWithLoopAndStreaming(ctx, messages,
|
||||
func(toolName, toolArgs string) {
|
||||
m.events.emit(ToolCallEvent{ToolName: toolName, ToolArgs: toolArgs})
|
||||
func(toolCallID, toolName, toolArgs string) {
|
||||
m.events.emit(ToolCallEvent{
|
||||
ToolCallID: toolCallID, ToolName: toolName, ToolKind: toolKindFor(toolName),
|
||||
ToolArgs: toolArgs, ParsedArgs: parseToolArgs(toolArgs),
|
||||
})
|
||||
},
|
||||
func(toolName string, isStarting bool) {
|
||||
func(toolCallID, toolName, toolArgs string, isStarting bool) {
|
||||
if isStarting {
|
||||
m.events.emit(ToolExecutionStartEvent{ToolName: toolName})
|
||||
m.events.emit(ToolExecutionStartEvent{ToolCallID: toolCallID, ToolName: toolName, ToolKind: toolKindFor(toolName), ToolArgs: toolArgs})
|
||||
} else {
|
||||
m.events.emit(ToolExecutionEndEvent{ToolName: toolName})
|
||||
m.events.emit(ToolExecutionEndEvent{ToolCallID: toolCallID, ToolName: toolName, ToolKind: toolKindFor(toolName)})
|
||||
}
|
||||
},
|
||||
func(toolName, toolArgs, resultText string, isError bool) {
|
||||
m.events.emit(ToolResultEvent{
|
||||
ToolName: toolName, ToolArgs: toolArgs,
|
||||
func(toolCallID, toolName, toolArgs, resultText, metadata string, isError bool) {
|
||||
evt := ToolResultEvent{
|
||||
ToolCallID: toolCallID, ToolName: toolName, ToolKind: toolKindFor(toolName),
|
||||
ToolArgs: toolArgs, ParsedArgs: parseToolArgs(toolArgs),
|
||||
Result: resultText, IsError: isError,
|
||||
})
|
||||
}
|
||||
if metadata != "" {
|
||||
var meta ToolResultMetadata
|
||||
if err := json.Unmarshal([]byte(metadata), &meta); err == nil {
|
||||
evt.Metadata = &meta
|
||||
}
|
||||
}
|
||||
m.events.emit(evt)
|
||||
},
|
||||
func(content string) {
|
||||
m.events.emit(ResponseEvent{Content: content})
|
||||
@@ -1193,6 +1465,9 @@ func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent.
|
||||
func(chunk string) {
|
||||
m.events.emit(MessageUpdateEvent{Chunk: chunk})
|
||||
},
|
||||
func(delta string) {
|
||||
m.events.emit(ReasoningDeltaEvent{Delta: delta})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1213,10 +1488,12 @@ func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, pr
|
||||
// <skill> block, and appends any trailing user args.
|
||||
if expanded := m.expandSkillCommand(prompt); expanded != prompt {
|
||||
prompt = expanded
|
||||
// Replace the last user message in preMessages with the expanded text.
|
||||
// Replace the last user message in preMessages with the expanded text,
|
||||
// preserving any file parts (e.g. clipboard images).
|
||||
for i := len(preMessages) - 1; i >= 0; i-- {
|
||||
if preMessages[i].Role == fantasy.MessageRoleUser {
|
||||
preMessages[i] = fantasy.NewUserMessage(expanded)
|
||||
files := extractFileParts(preMessages[i])
|
||||
preMessages[i] = fantasy.NewUserMessage(expanded, files...)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -1225,11 +1502,13 @@ func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, pr
|
||||
// Run BeforeTurn hooks — can modify the prompt, inject system/context messages.
|
||||
if m.beforeTurn.hasHooks() {
|
||||
if hookResult := m.beforeTurn.run(BeforeTurnHook{Prompt: prompt}); hookResult != nil {
|
||||
// Override prompt text in the last user message.
|
||||
// Override prompt text in the last user message, preserving
|
||||
// any file parts (e.g. clipboard images).
|
||||
if hookResult.Prompt != nil {
|
||||
for i := len(preMessages) - 1; i >= 0; i-- {
|
||||
if preMessages[i].Role == fantasy.MessageRoleUser {
|
||||
preMessages[i] = fantasy.NewUserMessage(*hookResult.Prompt)
|
||||
files := extractFileParts(preMessages[i])
|
||||
preMessages[i] = fantasy.NewUserMessage(*hookResult.Prompt, files...)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -1304,8 +1583,10 @@ func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, pr
|
||||
m.lastInputTokensMu.Unlock()
|
||||
}
|
||||
|
||||
stopReason := result.StopReason
|
||||
|
||||
m.events.emit(MessageEndEvent{Content: responseText})
|
||||
m.events.emit(TurnEndEvent{Response: responseText})
|
||||
m.events.emit(TurnEndEvent{Response: responseText, StopReason: stopReason})
|
||||
|
||||
// Run AfterTurn hooks.
|
||||
if m.afterTurn.hasHooks() {
|
||||
@@ -1314,8 +1595,10 @@ func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, pr
|
||||
|
||||
// Build TurnResult with usage stats.
|
||||
turnResult := &TurnResult{
|
||||
Response: responseText,
|
||||
Messages: result.ConversationMessages,
|
||||
Response: responseText,
|
||||
StopReason: stopReason,
|
||||
SessionID: m.GetSessionID(),
|
||||
Messages: result.ConversationMessages,
|
||||
}
|
||||
totalUsage := result.TotalUsage
|
||||
turnResult.TotalUsage = &totalUsage
|
||||
@@ -1460,6 +1743,15 @@ func (m *Kit) PromptResult(ctx context.Context, message string) (*TurnResult, er
|
||||
})
|
||||
}
|
||||
|
||||
// PromptResultWithFiles sends a multimodal message (text + images) and returns
|
||||
// the full turn result. The files parameter carries binary file data (e.g.
|
||||
// clipboard images) that are included alongside the text in the user message.
|
||||
func (m *Kit) PromptResultWithFiles(ctx context.Context, message string, files []fantasy.FilePart) (*TurnResult, error) {
|
||||
return m.runTurn(ctx, message, message, []fantasy.Message{
|
||||
fantasy.NewUserMessage(message, files...),
|
||||
})
|
||||
}
|
||||
|
||||
// ClearSession resets the tree session's leaf pointer to the root, starting
|
||||
// a fresh conversation branch.
|
||||
func (m *Kit) ClearSession() {
|
||||
@@ -1483,11 +1775,44 @@ func (m *Kit) GetModelInfo() *ModelInfo {
|
||||
return LookupModel(provider, modelID)
|
||||
}
|
||||
|
||||
// IsReasoningModel returns true if the current model supports extended thinking / reasoning.
|
||||
func (m *Kit) IsReasoningModel() bool {
|
||||
info := m.GetModelInfo()
|
||||
return info != nil && info.Reasoning
|
||||
}
|
||||
|
||||
// GetThinkingLevel returns the current thinking level.
|
||||
func (m *Kit) GetThinkingLevel() string {
|
||||
return viper.GetString("thinking-level")
|
||||
}
|
||||
|
||||
// SetThinkingLevel changes the thinking level and recreates the agent with
|
||||
// the new thinking budget. Returns an error if provider recreation fails.
|
||||
func (m *Kit) SetThinkingLevel(ctx context.Context, level string) error {
|
||||
viper.Set("thinking-level", level)
|
||||
// Recreate agent with new thinking config by re-running SetModel
|
||||
// with the same model string. SetModel rebuilds the provider and
|
||||
// passes the updated viper config (including thinking-level).
|
||||
return m.SetModel(ctx, m.modelString)
|
||||
}
|
||||
|
||||
// GetTools returns all tools available to the agent (core + MCP + extensions).
|
||||
func (m *Kit) GetTools() []Tool {
|
||||
return m.agent.GetTools()
|
||||
}
|
||||
|
||||
// extractFileParts returns all FilePart entries from a message's Content.
|
||||
// Used to preserve image attachments when replacing user message text.
|
||||
func extractFileParts(msg fantasy.Message) []fantasy.FilePart {
|
||||
var files []fantasy.FilePart
|
||||
for _, part := range msg.Content {
|
||||
if fp, ok := part.(fantasy.FilePart); ok {
|
||||
files = append(files, fp)
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
// Close cleans up resources including MCP server connections, model resources,
|
||||
// and the tree session file handle. Should be called when the Kit instance is
|
||||
// no longer needed. Returns an error if cleanup fails.
|
||||
|
||||
@@ -51,3 +51,8 @@ func CodingTools(opts ...ToolOption) []Tool { return core.CodingTools(opts...) }
|
||||
// ReadOnlyTools returns tools for read-only exploration:
|
||||
// read, grep, find, ls.
|
||||
func ReadOnlyTools(opts ...ToolOption) []Tool { return core.ReadOnlyTools(opts...) }
|
||||
|
||||
// SubagentTools returns all core tools except spawn_subagent. Use this when
|
||||
// creating child Kit instances (in-process subagents) to prevent infinite
|
||||
// recursion.
|
||||
func SubagentTools(opts ...ToolOption) []Tool { return core.SubagentTools(opts...) }
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
"source": "davis7dotsh/better-context",
|
||||
"sourceType": "github",
|
||||
"computedHash": "99bc5301f4f839a6f3be99d98955f32f1cd576c218731fa05fa54a003bd20e9b"
|
||||
},
|
||||
"kit-extensions": {
|
||||
"source": "mark3labs/kit",
|
||||
"sourceType": "github",
|
||||
"computedHash": "9347a88bec46dd52727a672b6c8d058955f9f50dfe98708e0c63b85e0779ba96"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,853 @@
|
||||
---
|
||||
name: kit-extensions
|
||||
description: Guide for creating Kit extensions. Use when the user asks to build, create, or modify a Kit extension, add a custom tool, slash command, widget, keyboard shortcut, editor interceptor, tool renderer, or hook into any Kit lifecycle event.
|
||||
---
|
||||
|
||||
# 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.
|
||||
|
||||
## Extension Structure
|
||||
|
||||
Every extension must export a `package main` with an `Init(api ext.API)` function:
|
||||
|
||||
```go
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import "kit/ext"
|
||||
|
||||
func Init(api ext.API) {
|
||||
// Register event handlers, tools, commands, etc.
|
||||
}
|
||||
```
|
||||
|
||||
The `//go:build ignore` tag prevents `go build` from compiling the file directly.
|
||||
|
||||
## Extension Locations
|
||||
|
||||
Extensions are auto-loaded from these directories:
|
||||
|
||||
- `~/.config/kit/extensions/*.go` (global, single files)
|
||||
- `~/.config/kit/extensions/*/main.go` (global, subdirectories)
|
||||
- `.kit/extensions/*.go` (project-local, single files)
|
||||
- `.kit/extensions/*/main.go` (project-local, subdirectories)
|
||||
|
||||
Or loaded explicitly:
|
||||
|
||||
```bash
|
||||
kit -e path/to/extension.go
|
||||
kit --extension path/to/extension.go
|
||||
```
|
||||
|
||||
## Import Path
|
||||
|
||||
Extensions import the Kit API as `"kit/ext"`. The full standard library is available plus `os/exec` for subprocess spawning.
|
||||
|
||||
## API Overview
|
||||
|
||||
The `Init` function receives an `ext.API` object for registering handlers, and event handlers receive an `ext.Context` with runtime capabilities.
|
||||
|
||||
---
|
||||
|
||||
## Lifecycle Events
|
||||
|
||||
Kit provides 18 lifecycle events. Each handler receives an event struct and a `Context`.
|
||||
|
||||
### Session Events
|
||||
|
||||
```go
|
||||
// Fired when session is loaded/created.
|
||||
api.OnSessionStart(func(e ext.SessionStartEvent, ctx ext.Context) {
|
||||
// e.SessionID string
|
||||
})
|
||||
|
||||
// Fired when Kit is shutting down. Use for cleanup.
|
||||
api.OnSessionShutdown(func(e ext.SessionShutdownEvent, ctx ext.Context) {
|
||||
// No fields.
|
||||
})
|
||||
```
|
||||
|
||||
### Agent Turn Events
|
||||
|
||||
```go
|
||||
// Before agent starts processing. Can inject system prompt or text.
|
||||
api.OnBeforeAgentStart(func(e ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
|
||||
// e.Prompt string
|
||||
// Return nil to pass through.
|
||||
// Return &ext.BeforeAgentStartResult{SystemPrompt: &s} to augment system prompt.
|
||||
// Return &ext.BeforeAgentStartResult{InjectText: &s} to inject text before prompt.
|
||||
return nil
|
||||
})
|
||||
|
||||
// Agent loop has started.
|
||||
api.OnAgentStart(func(e ext.AgentStartEvent, ctx ext.Context) {
|
||||
// e.Prompt string
|
||||
})
|
||||
|
||||
// Agent finished responding.
|
||||
api.OnAgentEnd(func(e ext.AgentEndEvent, ctx ext.Context) {
|
||||
// e.Response string
|
||||
// e.StopReason string — "completed", "cancelled", "error"
|
||||
})
|
||||
```
|
||||
|
||||
### Tool Events
|
||||
|
||||
```go
|
||||
// Before a tool executes. Can block the call.
|
||||
api.OnToolCall(func(e ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
|
||||
// e.ToolName string
|
||||
// e.ToolCallID string
|
||||
// e.Input string — JSON-encoded parameters
|
||||
// e.Source string — "llm" or "user"
|
||||
// Return nil to allow.
|
||||
// Return &ext.ToolCallResult{Block: true, Reason: "..."} to block.
|
||||
return nil
|
||||
})
|
||||
|
||||
// Tool execution started (informational only).
|
||||
api.OnToolExecutionStart(func(e ext.ToolExecutionStartEvent, ctx ext.Context) {
|
||||
// e.ToolName string
|
||||
})
|
||||
|
||||
// Tool execution ended (informational only).
|
||||
api.OnToolExecutionEnd(func(e ext.ToolExecutionEndEvent, ctx ext.Context) {
|
||||
// e.ToolName string
|
||||
})
|
||||
|
||||
// After a tool returns. Can modify the result.
|
||||
api.OnToolResult(func(e ext.ToolResultEvent, ctx ext.Context) *ext.ToolResultResult {
|
||||
// e.ToolName string
|
||||
// e.Input string
|
||||
// e.Content string
|
||||
// e.IsError bool
|
||||
// Return nil to pass through.
|
||||
// Return &ext.ToolResultResult{Content: &s} to replace content.
|
||||
// Return &ext.ToolResultResult{IsError: &b} to change error status.
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
### Input Events
|
||||
|
||||
```go
|
||||
// User submitted input. Can handle or transform it.
|
||||
api.OnInput(func(e ext.InputEvent, ctx ext.Context) *ext.InputResult {
|
||||
// e.Text string
|
||||
// e.Source string — "interactive", "cli", "script", "queue"
|
||||
// Return nil to pass through to agent.
|
||||
// Return &ext.InputResult{Action: "handled"} to consume without sending to agent.
|
||||
// Return &ext.InputResult{Action: "transform", Text: "new text"} to rewrite.
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
### Streaming Events
|
||||
|
||||
```go
|
||||
api.OnMessageStart(func(e ext.MessageStartEvent, ctx ext.Context) {})
|
||||
api.OnMessageUpdate(func(e ext.MessageUpdateEvent, ctx ext.Context) {
|
||||
// e.Chunk string — streaming text chunk
|
||||
})
|
||||
api.OnMessageEnd(func(e ext.MessageEndEvent, ctx ext.Context) {
|
||||
// e.Content string — full message content
|
||||
})
|
||||
```
|
||||
|
||||
### Model Events
|
||||
|
||||
```go
|
||||
api.OnModelChange(func(e ext.ModelChangeEvent, ctx ext.Context) {
|
||||
// e.NewModel string
|
||||
// e.PreviousModel string
|
||||
// e.Source string — "extension" or "user"
|
||||
})
|
||||
```
|
||||
|
||||
### Context Filtering
|
||||
|
||||
```go
|
||||
// Before messages are sent to the LLM. Can filter, reorder, or inject messages.
|
||||
api.OnContextPrepare(func(e ext.ContextPrepareEvent, ctx ext.Context) *ext.ContextPrepareResult {
|
||||
// e.Messages []ext.ContextMessage
|
||||
// Each ContextMessage has: Index int, Role string, Content string
|
||||
// Index -1 means a new injected message (not from session).
|
||||
// Return nil to pass through.
|
||||
// Return &ext.ContextPrepareResult{Messages: msgs} to replace the context window.
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
### Session Control Events
|
||||
|
||||
```go
|
||||
// Before forking the session tree. Can cancel.
|
||||
api.OnBeforeFork(func(e ext.BeforeForkEvent, ctx ext.Context) *ext.BeforeForkResult {
|
||||
// e.TargetID string, e.IsUserMessage bool, e.UserText string
|
||||
return nil // or &ext.BeforeForkResult{Cancel: true, Reason: "..."}
|
||||
})
|
||||
|
||||
// Before switching/clearing session. Can cancel.
|
||||
api.OnBeforeSessionSwitch(func(e ext.BeforeSessionSwitchEvent, ctx ext.Context) *ext.BeforeSessionSwitchResult {
|
||||
// e.Reason string — "new" or "clear"
|
||||
return nil // or &ext.BeforeSessionSwitchResult{Cancel: true, Reason: "..."}
|
||||
})
|
||||
|
||||
// Before context compaction. Can cancel.
|
||||
api.OnBeforeCompact(func(e ext.BeforeCompactEvent, ctx ext.Context) *ext.BeforeCompactResult {
|
||||
// e.EstimatedTokens, e.ContextLimit int
|
||||
// e.UsagePercent float64, e.MessageCount int, e.IsAutomatic bool
|
||||
return nil // or &ext.BeforeCompactResult{Cancel: true, Reason: "..."}
|
||||
})
|
||||
```
|
||||
|
||||
### Custom Events
|
||||
|
||||
```go
|
||||
// Subscribe to custom events emitted by other extensions.
|
||||
api.OnCustomEvent("event-name", func(data string) {
|
||||
// data is arbitrary string payload
|
||||
})
|
||||
|
||||
// Emit from Context:
|
||||
ctx.EmitCustomEvent("event-name", "payload")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Registering Tools
|
||||
|
||||
Tools are functions the LLM can invoke:
|
||||
|
||||
```go
|
||||
api.RegisterTool(ext.ToolDef{
|
||||
Name: "current_time",
|
||||
Description: "Get the current date and time",
|
||||
Parameters: `{"type":"object","properties":{}}`,
|
||||
Execute: func(input string) (string, error) {
|
||||
return time.Now().Format(time.RFC3339), nil
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
For long-running tools with cancellation and progress:
|
||||
|
||||
```go
|
||||
api.RegisterTool(ext.ToolDef{
|
||||
Name: "slow_task",
|
||||
Description: "A long-running task with progress reporting",
|
||||
Parameters: `{"type":"object","properties":{"query":{"type":"string"}}}`,
|
||||
ExecuteWithContext: func(input string, tc ext.ToolContext) (string, error) {
|
||||
for i := 0; i < 10; i++ {
|
||||
if tc.IsCancelled() {
|
||||
return "cancelled", nil
|
||||
}
|
||||
tc.OnProgress(fmt.Sprintf("Step %d/10...", i+1))
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
return "done", nil
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Parameters must be a JSON Schema string. The `input` argument is the JSON-encoded parameters from the LLM.
|
||||
|
||||
---
|
||||
|
||||
## Registering Slash Commands
|
||||
|
||||
Commands are user-facing actions invoked with `/name` in the input:
|
||||
|
||||
```go
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "echo",
|
||||
Description: "Echo back the provided text",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
ctx.PrintInfo("You said: " + args)
|
||||
return "", nil
|
||||
},
|
||||
// Optional tab-completion:
|
||||
Complete: func(prefix string, ctx ext.Context) []string {
|
||||
return []string{"hello", "world"}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Slash commands run in a dedicated goroutine (not a `tea.Cmd`), so they can safely block on prompts, I/O, etc.
|
||||
|
||||
---
|
||||
|
||||
## Registering Keyboard Shortcuts
|
||||
|
||||
```go
|
||||
api.RegisterShortcut(ext.ShortcutDef{
|
||||
Key: "ctrl+alt+p",
|
||||
Description: "Toggle plan mode",
|
||||
}, func(ctx ext.Context) {
|
||||
// handler runs when shortcut is pressed
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Registering Options
|
||||
|
||||
Options are configurable values resolved from env vars, config, or defaults:
|
||||
|
||||
```go
|
||||
api.RegisterOption(ext.OptionDef{
|
||||
Name: "my-setting",
|
||||
Description: "Controls something",
|
||||
Default: "false",
|
||||
})
|
||||
|
||||
// Read at runtime (resolution: env KIT_OPT_MY_SETTING > config options.my-setting > default):
|
||||
val := ctx.GetOption("my-setting")
|
||||
|
||||
// Set at runtime:
|
||||
ctx.SetOption("my-setting", "true")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Context API Reference
|
||||
|
||||
The `ext.Context` struct provides runtime capabilities via function fields.
|
||||
|
||||
### Output
|
||||
|
||||
```go
|
||||
ctx.Print("plain text") // plain output
|
||||
ctx.PrintInfo("styled info block") // bordered info block
|
||||
ctx.PrintError("styled error block") // red error block
|
||||
ctx.PrintBlock(ext.PrintBlockOpts{ // custom styled block
|
||||
Text: "content",
|
||||
BorderColor: "#a6e3a1",
|
||||
Subtitle: "my-ext",
|
||||
})
|
||||
ctx.RenderMessage("renderer-name", "content") // use a registered message renderer
|
||||
```
|
||||
|
||||
### Message Injection
|
||||
|
||||
```go
|
||||
ctx.SendMessage("prompt text") // inject message and trigger agent turn (queued)
|
||||
ctx.CancelAndSend("new prompt") // cancel current turn, clear queue, send new message
|
||||
```
|
||||
|
||||
### Widgets
|
||||
|
||||
Persistent UI elements displayed above or below the input area:
|
||||
|
||||
```go
|
||||
ctx.SetWidget(ext.WidgetConfig{
|
||||
ID: "my-widget",
|
||||
Placement: ext.WidgetAbove, // or ext.WidgetBelow
|
||||
Content: ext.WidgetContent{
|
||||
Text: "Status: Active",
|
||||
Markdown: false, // set true for markdown rendering
|
||||
},
|
||||
Style: ext.WidgetStyle{
|
||||
BorderColor: "#a6e3a1", // hex color
|
||||
NoBorder: false,
|
||||
},
|
||||
Priority: 0, // lower values render first
|
||||
})
|
||||
|
||||
ctx.RemoveWidget("my-widget")
|
||||
```
|
||||
|
||||
### Header and Footer
|
||||
|
||||
```go
|
||||
ctx.SetHeader(ext.HeaderFooterConfig{
|
||||
Content: ext.WidgetContent{Text: "My Header"},
|
||||
Style: ext.WidgetStyle{BorderColor: "#89b4fa"},
|
||||
})
|
||||
ctx.RemoveHeader()
|
||||
|
||||
ctx.SetFooter(ext.HeaderFooterConfig{
|
||||
Content: ext.WidgetContent{Text: "My Footer"},
|
||||
Style: ext.WidgetStyle{BorderColor: "#585b70"},
|
||||
})
|
||||
ctx.RemoveFooter()
|
||||
```
|
||||
|
||||
### Status Bar
|
||||
|
||||
```go
|
||||
ctx.SetStatus("key", "PLAN MODE", 10) // key, text, priority (lower = further left)
|
||||
ctx.RemoveStatus("key")
|
||||
```
|
||||
|
||||
### Interactive Prompts
|
||||
|
||||
These block until the user responds (safe in slash commands and goroutines):
|
||||
|
||||
```go
|
||||
// Selection list
|
||||
result := ctx.PromptSelect(ext.PromptSelectConfig{
|
||||
Message: "Pick one:",
|
||||
Options: []string{"Option A", "Option B", "Option C"},
|
||||
})
|
||||
if !result.Cancelled {
|
||||
// result.Value string, result.Index int
|
||||
}
|
||||
|
||||
// Yes/No confirmation
|
||||
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
|
||||
Message: "Are you sure?",
|
||||
DefaultValue: false,
|
||||
})
|
||||
if !result.Cancelled {
|
||||
// result.Value bool
|
||||
}
|
||||
|
||||
// Text input
|
||||
result := ctx.PromptInput(ext.PromptInputConfig{
|
||||
Message: "Enter name:",
|
||||
Placeholder: "my-project",
|
||||
Default: "",
|
||||
})
|
||||
if !result.Cancelled {
|
||||
// result.Value string
|
||||
}
|
||||
```
|
||||
|
||||
### Overlay Dialogs
|
||||
|
||||
Modal dialogs with optional action buttons:
|
||||
|
||||
```go
|
||||
result := ctx.ShowOverlay(ext.OverlayConfig{
|
||||
Title: "Confirmation",
|
||||
Content: ext.WidgetContent{Text: "Are you sure you want to proceed?", Markdown: true},
|
||||
Style: ext.OverlayStyle{BorderColor: "#f38ba8"},
|
||||
Width: 60, // 0 = 60% of terminal width
|
||||
MaxHeight: 20, // 0 = 80% of terminal height
|
||||
Anchor: ext.OverlayCenter, // or ext.OverlayTopCenter, ext.OverlayBottomCenter
|
||||
Actions: []string{"Confirm", "Cancel"},
|
||||
})
|
||||
if !result.Cancelled {
|
||||
// result.Action string, result.Index int
|
||||
}
|
||||
```
|
||||
|
||||
### Editor Interceptor
|
||||
|
||||
Wrap the built-in text input with custom key handling and rendering:
|
||||
|
||||
```go
|
||||
ctx.SetEditor(ext.EditorConfig{
|
||||
HandleKey: func(key string, currentText string) ext.EditorKeyAction {
|
||||
if key == "ctrl+s" {
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeySubmit, SubmitText: currentText}
|
||||
}
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
|
||||
},
|
||||
Render: func(width int, defaultContent string) string {
|
||||
return "[custom] " + defaultContent
|
||||
},
|
||||
})
|
||||
|
||||
ctx.ResetEditor() // remove interceptor
|
||||
ctx.SetEditorText("prefilled") // set editor text content
|
||||
```
|
||||
|
||||
**EditorKeyAction types:**
|
||||
- `ext.EditorKeyPassthrough` — let the default editor handle the key
|
||||
- `ext.EditorKeyConsumed` — swallow the key, do nothing
|
||||
- `ext.EditorKeyRemap` — remap to a different key: `EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "up"}`
|
||||
- `ext.EditorKeySubmit` — submit text: `EditorKeyAction{Type: ext.EditorKeySubmit, SubmitText: "text"}`
|
||||
|
||||
### UI Visibility
|
||||
|
||||
```go
|
||||
ctx.SetUIVisibility(ext.UIVisibility{
|
||||
HideStartupMessage: true,
|
||||
HideStatusBar: true,
|
||||
HideSeparator: true,
|
||||
HideInputHint: true,
|
||||
})
|
||||
```
|
||||
|
||||
### Session Data
|
||||
|
||||
```go
|
||||
stats := ctx.GetContextStats() // .EstimatedTokens, .ContextLimit, .UsagePercent, .MessageCount
|
||||
msgs := ctx.GetMessages() // []ext.SessionMessage on current branch
|
||||
path := ctx.GetSessionPath() // file path of session JSONL
|
||||
|
||||
// Persist custom data in the session tree:
|
||||
id, err := ctx.AppendEntry("my-type", "data string")
|
||||
entries := ctx.GetEntries("my-type") // []ext.ExtensionEntry{ID, EntryType, Data, Timestamp}
|
||||
```
|
||||
|
||||
### Model Management
|
||||
|
||||
```go
|
||||
err := ctx.SetModel("anthropic/claude-sonnet-4-20250514")
|
||||
models := ctx.GetAvailableModels() // []ext.ModelInfoEntry
|
||||
```
|
||||
|
||||
### Tool Management
|
||||
|
||||
```go
|
||||
tools := ctx.GetAllTools() // []ext.ToolInfo{Name, Description, Source, Enabled}
|
||||
ctx.SetActiveTools([]string{"read", "grep"}) // restrict to these tools only
|
||||
ctx.SetActiveTools(nil) // re-enable all tools
|
||||
```
|
||||
|
||||
### LLM Completions
|
||||
|
||||
Make standalone LLM calls (bypasses the agent tool loop):
|
||||
|
||||
```go
|
||||
resp, err := ctx.Complete(ext.CompleteRequest{
|
||||
Model: "", // empty = current model
|
||||
System: "You are ...", // optional system prompt
|
||||
Prompt: "Summarize...", // the prompt
|
||||
MaxTokens: 1000, // 0 = provider default
|
||||
OnChunk: func(chunk string) { /* streaming */ },
|
||||
})
|
||||
// resp.Text, resp.InputTokens, resp.OutputTokens, resp.Model
|
||||
```
|
||||
|
||||
### TUI Suspension
|
||||
|
||||
Temporarily release the terminal for interactive subprocesses:
|
||||
|
||||
```go
|
||||
ctx.SuspendTUI(func() {
|
||||
cmd := exec.Command("vim", "file.go")
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Run()
|
||||
})
|
||||
```
|
||||
|
||||
### Application Control
|
||||
|
||||
```go
|
||||
ctx.Exit() // graceful shutdown
|
||||
err := ctx.ReloadExtensions() // hot-reload all extensions from disk
|
||||
```
|
||||
|
||||
### Context Fields
|
||||
|
||||
```go
|
||||
ctx.SessionID // string
|
||||
ctx.CWD // string — current working directory
|
||||
ctx.Model // string — active model name
|
||||
ctx.Interactive // bool — true if running in TUI mode
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tool Renderers
|
||||
|
||||
Customize how tool calls are displayed in the TUI:
|
||||
|
||||
```go
|
||||
api.RegisterToolRenderer(ext.ToolRenderConfig{
|
||||
ToolName: "bash",
|
||||
DisplayName: "Shell", // replaces auto-capitalized name
|
||||
BorderColor: "#89b4fa",
|
||||
Background: "",
|
||||
BodyMarkdown: true, // render body through markdown
|
||||
RenderHeader: func(toolArgs string, width int) string {
|
||||
var args struct{ Command string `json:"command"` }
|
||||
json.Unmarshal([]byte(toolArgs), &args)
|
||||
return "$ " + args.Command
|
||||
},
|
||||
RenderBody: func(toolResult string, isError bool, width int) string {
|
||||
if isError {
|
||||
return "ERROR: " + toolResult
|
||||
}
|
||||
return toolResult
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Message Renderers
|
||||
|
||||
Define named output styles for `ctx.RenderMessage()`:
|
||||
|
||||
```go
|
||||
api.RegisterMessageRenderer(ext.MessageRendererConfig{
|
||||
Name: "success",
|
||||
Render: func(content string, width int) string {
|
||||
return " " + content // green checkmark prefix
|
||||
},
|
||||
})
|
||||
|
||||
// Usage in handlers:
|
||||
ctx.RenderMessage("success", "All tests passed")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Critical Yaegi Constraints
|
||||
|
||||
### No Named Function References in Struct Fields
|
||||
|
||||
Yaegi has a bug where named function references assigned to struct fields return zero values across the interpreter boundary. Always use anonymous closure literals:
|
||||
|
||||
```go
|
||||
// WRONG - will silently return zero values:
|
||||
func myHandler(key, text string) ext.EditorKeyAction {
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
|
||||
}
|
||||
ctx.SetEditor(ext.EditorConfig{HandleKey: myHandler})
|
||||
|
||||
// CORRECT - use anonymous closure:
|
||||
ctx.SetEditor(ext.EditorConfig{
|
||||
HandleKey: func(key, text string) ext.EditorKeyAction {
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
This applies to ALL struct fields that take function values: `ToolDef.Execute`, `CommandDef.Execute`, `EditorConfig.HandleKey`, `EditorConfig.Render`, `ToolRenderConfig.RenderHeader`, `ToolRenderConfig.RenderBody`, etc.
|
||||
|
||||
### No Interfaces Across the Boundary
|
||||
|
||||
All extension-facing API types are concrete structs, never interfaces. Yaegi crashes on interface wrapper generation.
|
||||
|
||||
### Package-Level Variables for State
|
||||
|
||||
Yaegi supports package-level variables captured in closures. This is the standard way to maintain state across event callbacks:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import "kit/ext"
|
||||
|
||||
var callCount int
|
||||
var lastTool string
|
||||
|
||||
func Init(api ext.API) {
|
||||
api.OnToolResult(func(e ext.ToolResultEvent, ctx ext.Context) *ext.ToolResultResult {
|
||||
callCount++
|
||||
lastTool = e.ToolName
|
||||
return nil
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern: Tool Call Blocking
|
||||
|
||||
Block dangerous operations by intercepting tool calls:
|
||||
|
||||
```go
|
||||
api.OnToolCall(func(tc ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
|
||||
if tc.ToolName == "bash" {
|
||||
var input struct{ Command string `json:"command"` }
|
||||
json.Unmarshal([]byte(tc.Input), &input)
|
||||
if strings.Contains(input.Command, "rm -rf") {
|
||||
return &ext.ToolCallResult{
|
||||
Block: true,
|
||||
Reason: "Dangerous command blocked",
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern: System Prompt Injection
|
||||
|
||||
Augment the agent's behavior by injecting instructions:
|
||||
|
||||
```go
|
||||
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
|
||||
prompt := "Always respond with bullet points."
|
||||
return &ext.BeforeAgentStartResult{SystemPrompt: &prompt}
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern: Background Processing with SendMessage
|
||||
|
||||
Run work in a goroutine and inject results back:
|
||||
|
||||
```go
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "run",
|
||||
Description: "Run a command in the background",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
go func() {
|
||||
out, err := exec.Command("sh", "-c", args).CombinedOutput()
|
||||
if err != nil {
|
||||
ctx.SendMessage(fmt.Sprintf("Command failed: %s\n%s", err, out))
|
||||
return
|
||||
}
|
||||
ctx.SendMessage(fmt.Sprintf("Command output:\n```\n%s\n```", out))
|
||||
}()
|
||||
return "Running in background...", nil
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern: Ephemeral Context Injection
|
||||
|
||||
Inject information into every LLM turn without persisting in session history:
|
||||
|
||||
```go
|
||||
api.OnContextPrepare(func(e ext.ContextPrepareEvent, ctx ext.Context) *ext.ContextPrepareResult {
|
||||
data, err := os.ReadFile(".kit/context.md")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
injected := ext.ContextMessage{
|
||||
Index: -1, // -1 = new message, not from session
|
||||
Role: "system",
|
||||
Content: string(data),
|
||||
}
|
||||
msgs := append([]ext.ContextMessage{injected}, e.Messages...)
|
||||
return &ext.ContextPrepareResult{Messages: msgs}
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern: Live Widget Updates
|
||||
|
||||
Update a widget periodically from a goroutine:
|
||||
|
||||
```go
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
ctx.SetWidget(ext.WidgetConfig{
|
||||
ID: "clock",
|
||||
Placement: ext.WidgetAbove,
|
||||
Content: ext.WidgetContent{Text: time.Now().Format("15:04:05")},
|
||||
Style: ext.WidgetStyle{BorderColor: "#89b4fa"},
|
||||
})
|
||||
}
|
||||
}()
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern: Spawning Kit as a Sub-Agent
|
||||
|
||||
Extensions can spawn Kit as a subprocess for delegation:
|
||||
|
||||
```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
|
||||
|
||||
```bash
|
||||
# Validate syntax of all discovered extensions
|
||||
kit extensions validate
|
||||
|
||||
# List loaded extensions
|
||||
kit extensions list
|
||||
|
||||
# Run with a specific extension
|
||||
kit -e path/to/extension.go
|
||||
|
||||
# Run with multiple extensions
|
||||
kit -e ext1.go -e ext2.go
|
||||
|
||||
# Disable all extensions
|
||||
kit --no-extensions
|
||||
|
||||
# Generate an example extension scaffold
|
||||
kit extensions init
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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:
|
||||
|
||||
```go
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
func Init(api ext.API) {
|
||||
readOnlyTools := []string{"read", "grep", "find", "ls"}
|
||||
var planActive bool
|
||||
|
||||
api.RegisterOption(ext.OptionDef{
|
||||
Name: "plan",
|
||||
Description: "Start in plan mode (read-only tools)",
|
||||
Default: "false",
|
||||
})
|
||||
|
||||
api.RegisterShortcut(ext.ShortcutDef{
|
||||
Key: "ctrl+alt+p",
|
||||
Description: "Toggle plan/explore mode",
|
||||
}, func(ctx ext.Context) {
|
||||
planActive = !planActive
|
||||
applyMode(ctx, planActive, readOnlyTools)
|
||||
})
|
||||
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "plan",
|
||||
Description: "Toggle plan/explore mode",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
planActive = !planActive
|
||||
applyMode(ctx, planActive, readOnlyTools)
|
||||
return "", nil
|
||||
},
|
||||
})
|
||||
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
if strings.ToLower(ctx.GetOption("plan")) == "true" {
|
||||
planActive = true
|
||||
applyMode(ctx, true, readOnlyTools)
|
||||
}
|
||||
})
|
||||
|
||||
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
|
||||
if !planActive {
|
||||
return nil
|
||||
}
|
||||
prompt := `You are in PLAN MODE (read-only). You can ONLY read and search.
|
||||
Focus on understanding, analysis, and generating plans.`
|
||||
return &ext.BeforeAgentStartResult{SystemPrompt: &prompt}
|
||||
})
|
||||
}
|
||||
|
||||
func applyMode(ctx ext.Context, active bool, tools []string) {
|
||||
if active {
|
||||
ctx.SetActiveTools(tools)
|
||||
ctx.SetStatus("plan-mode", "PLAN MODE (read-only)", 10)
|
||||
ctx.PrintInfo("Plan mode ON")
|
||||
} else {
|
||||
ctx.SetActiveTools(nil)
|
||||
ctx.RemoveStatus("plan-mode")
|
||||
ctx.PrintInfo("Plan mode OFF")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Key Files for Reference
|
||||
|
||||
- [`internal/extensions/api.go`](https://github.com/mark3labs/kit/blob/main/internal/extensions/api.go) — Complete API type definitions
|
||||
- [`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
|
||||
- [`examples/extensions/`](https://github.com/mark3labs/kit/tree/main/examples/extensions) — 25+ working example extensions
|
||||
Reference in New Issue
Block a user