Compare commits

...

19 Commits

Author SHA1 Message Date
Ed Zynda 558fb5214f feat(sdk): expose remaining Fantasy lifecycle callbacks as events and hooks
Closes #19.

SDK events (pkg/kit):
- Add 10 new event types: StepStart, StepFinish, TextStart, TextEnd,
  ReasoningStart, Warnings, Source, StreamFinish, Error, Retry
- Add typed convenience subscribers for all 31 event types (20 previously
  required raw Subscribe + type assertion)
- Add OnPrepareStep hook for intercepting/replacing messages between
  steps within a multi-step turn (composes with existing steering)
- Rename OnStreaming to OnMessageUpdate (deprecated alias kept)

Agent internals (internal/agent):
- Add GenerateCallbacks struct replacing 16 positional callback params
- Add GenerateWithCallbacks method; deprecate GenerateWithLoopAndStreaming
- Wire all Fantasy stream callbacks: OnStepStart, OnTextStart/End,
  OnReasoningStart, OnWarnings, OnSource, OnStreamFinish, OnError,
  OnRetry, OnStepFinish (unified step event)
- Compose PrepareStep with steering channel + consumer hook

Extension system (internal/extensions):
- Add 8 new extension events: StepStart, StepFinish, ReasoningStart,
  Warnings, Source, Error, Retry, PrepareStep
- Bridge SDK events to extension runner with Yaegi-safe types (string
  errors, plain int64 token fields, ContextMessage for PrepareStep)

Docs: update README, SDK skill, www/sdk/callbacks, www/sdk/overview
2026-04-22 20:25:06 +03:00
Ed Zynda 61408ed490 fix(sdk): infer ToolResponse.Type for binary data in NewTool/NewParallelTool
- Infer Type="image" for image/* MIME types and Type="media" for all
  other binary content so the downstream framework creates a media
  content block instead of silently discarding Data bytes (#17)
- Extract shared toolOutputToResponse() helper to eliminate duplication
- Add ImageResult() and MediaResult() convenience constructors
- Add LLMToolCall and LLMToolResponse type aliases so SDK consumers
  can call Tool.Run() without importing the underlying framework
- Add 6 regression tests covering image, media, and text responses

Closes #17
2026-04-22 16:58:07 +03:00
Ed Zynda 3cfb6437f9 perf(session,ui): reduce syscalls, allocations, and subprocess spam
- Buffer session JSONL writes with bufio.Writer, flush at sync points;
  ForkToNewSession and AddLLMMessages now batch N entries into ~1 syscall
- Cache lipgloss styles in style.CachedStyles, lazily built and
  invalidated on SetTheme; eliminates ~15 NewStyle() calls per frame in
  hot render paths (reasoning blocks, spinner, tool headers, margins)
- Cache git ls-files results for @file suggestions with 3s TTL; typing
  @filename no longer spawns 3 subprocesses per keystroke
- Use strings.Builder for StreamingMessageItem.content; eliminates O(n²)
  string copying during LLM response streaming
2026-04-22 16:48:17 +03:00
Ed Zynda d33ad4028b fix(kit): enable streaming for subagent child instances
- Set Streaming: true in subagent childOpts to prevent
  viper.Set("stream", false) from polluting global state
- Without this, concurrent subagents and the parent could read
  stale stream=false from viper, causing provider-level issues
  (e.g. Anthropic non-streaming timeouts with extended thinking)
2026-04-22 13:06:37 +03:00
Ed Zynda 307dcd1734 cleanup 2026-04-22 11:56:06 +03:00
Ed Zynda 81240b075e chore: update all deps and fix acp-go-sdk v0.12.0 breaking changes
- Update all Go dependencies (bubbletea v2.0.6, fantasy v0.19.0,
  acp-go-sdk v0.12.0, mcp-go v0.49.0, and transitive deps)
- Replace SetSessionModel with SetSessionConfigOption to match new
  acp-go-sdk Agent interface (union type with ValueId/Boolean variants)
- Add ListSessions stub returning empty list (new required method)
- Refresh embedded_models.json from models.dev/api.json
- Update ACP smoke test: add initialize handshake, session/list,
  session/set_config_option, session/cancel, and fix update parsing
2026-04-22 11:55:40 +03:00
Ed Zynda 9a662d440c fix(ui): reduce TUI visual noise and improve layout
- remove "You" label and icon from user messages, use borderless content block
- remove input title bar ("Enter your prompt...") and hint line
- increase textarea from 3 to 4 rows with top/bottom margin
- hide input hints permanently for a cleaner UI
- match separator colors (use theme.Border for both startup and input dividers)
- make startup separator full terminal width instead of hardcoded 80
- add /help for help hint and pipe separators to status bar
- add printCustomMessage/RenderCustomMessage for custom alert labels
- render /help output as markdown with "Help" alert label
- add Ctrl+V (paste image) to help message keys section
- fix reasoning text wrapping using ANSI-aware lipgloss.Style.Width
- export HighlightFileTokens for cross-package use
2026-04-22 11:41:09 +03:00
Ed Zynda 4ba9d6fab3 feat(events): mirror Fantasy tool input streaming callbacks as Kit events
- Add ToolCallStartEvent, ToolCallDeltaEvent, ToolCallEndEvent to SDK
- Wire Fantasy OnToolInputStart/Delta/End through agent to EventBus
- Add typed convenience subscribers: OnToolCallStart/Delta/End on Kit
- Bridge new events to TUI via ToolCallInputStart/Delta/End app events
- Extend extension system with OnToolCallInputStart/Delta/End handlers
- Add extension event types, API methods, loader wiring, Yaegi symbols
- Update docs: README, SDK skill, extensions skill, www/sdk, www/extensions

Closes #16
2026-04-21 23:28:13 +03:00
Ed Zynda aec0e7cc01 docs: document noOAuth MCP server config field
- Add noOAuth to MCP server fields table in www/pages/configuration.md
- Add pubmed example with noOAuth in README and www config docs
2026-04-21 22:44:27 +03:00
Ed Zynda bac04636bf feat(config): add noOAuth flag to skip OAuth on public MCP servers
- Add NoOAuth field to MCPServerConfig with JSON/YAML support
- Guard OAuth error handling and transport setup with the new flag
- Prevents failed dynamic client registration on servers like PubMed
  that do not support OAuth
2026-04-21 22:24:10 +03:00
Ed Zynda 5f851fd08e fix(ui): require double ctrl+c to quit, matching double-esc pattern
- First ctrl+c clears input and arms quit flag with 3s timeout
- Second ctrl+c within timeout window actually quits
- Show '⚠ Press Ctrl+C again to quit' warning after first press
- Empty input no longer quits immediately on single ctrl+c
- Prompt/overlay states: ctrl+c cancels dialog, re-dispatches to
  main handler for double-press tracking instead of quitting
- Update placeholder, help text, and tests to match new behavior
2026-04-21 22:05:13 +03:00
Ed Zynda f8371836d8 fix(cmd): fix character encoding in OAuth success page
Add charset=utf-8 to Content-Type header and use HTML entity
✓ instead of raw Unicode checkmark to prevent garbled
text display in browsers.

Fixes #9
2026-04-21 21:19:51 +03:00
Ed Zynda 74f00244be fix(ui): wrap reasoning blocks to terminal width to prevent clipping
- wrap thinking text in StreamComponent and render.ReasoningBlock
- plumb width through renderer and streaming item paths
- keeps style consistent with user/assistant blocks and avoids cut-off lines
2026-04-21 20:42:53 +03:00
Ed Zynda b5d7fd4f3e update docs 2026-04-21 20:33:32 +03:00
Ed Zynda 5857d40978 cleanup 2026-04-21 20:27:32 +03:00
Ed Zynda 3ff701054a fix(models): add gpt-5.4 reasoning level support with auto-adjustment
Adds 'none' thinking level to support OpenAI gpt-5.4 models which use
'reasoning_effort: none' instead of 'minimal'. Includes validation and
auto-adjustment when switching models with incompatible levels.

- Add ThinkingNone constant mapping to ReasoningEffortNone
- Add IsValidThinkingLevelForModel() with gpt-5.4 detection
- Add SuggestThinkingLevelFallback() for level migration
- Auto-adjust thinking level on model switch with user notification
- Update all docs to include 'none' in valid levels

Fixes #11
2026-04-21 20:19:00 +03:00
Ed Zynda c1dee3ceba feat(cmd): add --set-default flag and improve auth error messages
Add --set-default flag to 'kit auth login' to automatically set the
provider's default model after successful authentication. When no Anthropic
credentials exist but OpenAI credentials are detected, error messages
now suggest using OpenAI with the correct --model flag.

Fixes #9
2026-04-21 19:52:06 +03:00
Ed Zynda 2d9783a44d fix(ui): make ctrl+c clear input before quitting
Change Ctrl+C behavior to match other terminal AI tools (claude, codex, pi):
- First Ctrl+C clears the current input when text is present
- Second Ctrl+C (within 3 seconds) quits the application
- Ctrl+C on empty input quits immediately
- 3-second auto-reset timer clears the 'pressed once' state
- Flag also resets after message submission

Updates placeholder text and help message to reflect new behavior.

Fixes #13
2026-04-21 19:32:48 +03:00
Ed Zynda 88dd216e15 fix(session): prevent circular parent references in tree session
Add defensive validation to detect and prevent cycles in the session tree
parent chain that could occur after compaction or file corruption.

- Add tree_validation.go with cycle detection and parent chain validation
- Validate parent chain before appending messages (AppendMessage)
- Validate firstKeptEntryID exists in AppendCompaction
- Add depth limit and cycle detection to buildTreeNode to prevent infinite recursion
- Log diagnostics on session open to detect existing cycles
- Add tests for cycle detection and graceful handling
2026-04-21 16:24:38 +03:00
61 changed files with 3559 additions and 512 deletions
-8
View File
@@ -1,8 +0,0 @@
{
"$schema": "https://opencode.ai/config.json",
"permission": {
"external_directory": {
"~/go/**": "deny"
}
}
}
-80
View File
@@ -1,80 +0,0 @@
# Autoscroll Fix - Final Summary
## Root Cause
The autoscroll was failing for streaming assistant messages due to a bug in how `GotoBottom()` calculated item heights.
### The Problem
1. **Reasoning blocks** (`StreamingMessageItem` with `role="reasoning"`) are **never cached** because they have live duration counters that update every render
2. The `Height()` method returns `0` when `cachedRender == ""`
3. `GotoBottom()` was calling:
```go
itemHeight := item.Height() // Returns 0 for reasoning
if itemHeight == 0 {
item.Render(s.width) // Renders but doesn't cache (reasoning)
itemHeight = item.Height() // Still returns 0!
}
```
4. This caused incorrect scroll position calculations, especially during reasoning → assistant transitions
## The Solution
Changed `GotoBottom()` and `AtBottom()` to calculate height **directly from the rendered string** instead of relying on the cached height:
```go
// OLD: item.Height() which checks cached render
itemHeight := item.Height()
if itemHeight == 0 {
item.Render(s.width)
itemHeight = item.Height() // Still might be 0!
}
// NEW: Calculate from rendered string directly
rendered := item.Render(s.width)
itemHeight := strings.Count(rendered, "\n") + 1
```
This works for **all** items regardless of whether they cache their render or not.
## Files Changed
### `internal/ui/scrolllist.go`
- **`GotoBottom()`**: Calculate height from rendered string (2 loops)
- **`AtBottom()`**: Calculate height from rendered string (1 loop)
### `internal/ui/model.go`
- **`appendStreamingChunk()`**: For existing messages, call `GotoBottom()` directly (iteratr pattern)
- **`refreshContent()`**: Simplified to only call `SetItems()` (removed redundant `GotoBottom()`)
- **Bash streaming handler**: Removed redundant `GotoBottom()` after `refreshContent()`
## Testing Results
✅ **Test prompt**: "explore this repo"
**Before fix**:
- Autoscroll stopped after reasoning block completed
- Viewport stuck showing end of reasoning ("Thought for 203ms")
- Assistant response streamed off-screen below
**After fix**:
- Autoscroll works throughout reasoning block
- Autoscroll continues during reasoning → assistant transition
- Viewport stays at bottom showing latest assistant content
- Final position shows end of response (build commands section)
## Behavior Verified
1. ✅ Streaming text auto-scrolls to bottom
2. ✅ Works across reasoning → assistant transition
3. ✅ Manual scroll up (PgUp) disables autoscroll
4. ✅ Scroll to bottom (Alt+End) re-enables autoscroll
5. ✅ Accurate positioning with no offset errors
## Performance Note
The fix calls `Render()` on all items during `GotoBottom()` calculations. This is acceptable because:
- `Render()` is already optimized with caching for non-reasoning items
- `GotoBottom()` is only called during content updates (not every frame)
- Reasoning blocks need to render anyway for live duration updates
- This matches iteratr's approach of ensuring items are rendered before height calculations
+37 -10
View File
@@ -29,7 +29,7 @@ A powerful, extensible AI coding agent CLI with multi-provider support, built-in
- **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
- **Go SDK**: Embed Kit in your own applications with full agent lifecycle events (30+ event types) and behavior-modifying hooks
## Installation
@@ -126,7 +126,7 @@ model: anthropic/claude-sonnet-latest
max-tokens: 4096
temperature: 0.7
stream: true
thinking-level: off # off, minimal, low, medium, high
thinking-level: off # off, none, minimal, low, medium, high
```
All of the above keys can also be set programmatically via the SDK
@@ -157,6 +157,11 @@ mcpServers:
search:
type: remote
url: "https://mcp.example.com/search"
pubmed:
type: remote
url: "https://pubmed.mcp.example.com"
noOAuth: true # skip OAuth for public servers that don't require auth
```
## CLI Reference
@@ -199,7 +204,7 @@ mcpServers:
--stop-sequences Custom stop sequences (comma-separated)
--frequency-penalty Penalize frequent tokens 0.0-2.0 (default: 0.0)
--presence-penalty Penalize present tokens 0.0-2.0 (default: 0.0)
--thinking-level Extended thinking level: off, minimal, low, medium, high (default: off)
--thinking-level Extended thinking level: off, none, minimal, low, medium, high (default: off)
# System
--config Config file path (default: ~/.kit.yml)
@@ -211,9 +216,10 @@ mcpServers:
```bash
# Authentication (for OAuth-enabled providers)
kit auth login [provider] # Start OAuth flow (e.g., anthropic)
kit auth logout [provider] # Remove credentials for provider
kit auth status # Check authentication status
kit auth login [provider] # Start OAuth flow (e.g., anthropic)
kit auth login [provider] --set-default # Set provider's default model as system default
kit auth logout [provider] # Remove credentials for provider
kit auth status # Check authentication status
# Model database
kit models [provider] # List available models (optionally filter by provider)
@@ -295,7 +301,7 @@ kit -e examples/extensions/minimal.go
### Extension Capabilities
**Lifecycle Events**: OnSessionStart, OnSessionShutdown, OnBeforeAgentStart, OnAgentStart, OnAgentEnd, OnToolCall, OnToolExecutionStart, OnToolOutput, OnToolExecutionEnd, OnToolResult, OnInput, OnMessageStart, OnMessageUpdate, OnMessageEnd, OnModelChange, OnContextPrepare, OnBeforeFork, OnBeforeSessionSwitch, OnBeforeCompact, OnCustomEvent, OnSubagentStart, OnSubagentChunk, OnSubagentEnd
**Lifecycle Events**: OnSessionStart, OnSessionShutdown, OnBeforeAgentStart, OnAgentStart, OnAgentEnd, OnToolCall, OnToolCallInputStart, OnToolCallInputDelta, OnToolCallInputEnd, OnToolExecutionStart, OnToolOutput, OnToolExecutionEnd, OnToolResult, OnInput, OnMessageStart, OnMessageUpdate, OnMessageEnd, OnModelChange, OnContextPrepare, OnBeforeFork, OnBeforeSessionSwitch, OnBeforeCompact, OnCustomEvent, OnSubagentStart, OnSubagentChunk, OnSubagentEnd
**Custom Components**:
- **Tools**: Add new tools the LLM can invoke
@@ -548,7 +554,7 @@ host, err := kit.New(ctx, &kit.Options{
// Generation parameters (override env/config/per-model defaults)
MaxTokens: 16384, // 0 = auto-resolve (env → config → per-model → 8192 floor)
ThinkingLevel: "medium", // "off", "low", "medium", "high"
ThinkingLevel: "medium", // "off", "none", "minimal", "low", "medium", "high"
Temperature: ptr(float32(0.2)), // pointer so 0.0 != unset; nil = provider default
TopP: nil, // nil = leave provider/per-model default
TopK: nil,
@@ -640,7 +646,28 @@ host, _ := kit.New(ctx, &kit.Options{
})
```
Use `kit.NewParallelTool` for tools safe to run concurrently. See the [SDK docs](/sdk/overview) for full details on struct tags, `ToolOutput` fields, and `ToolCallIDFromContext`.
Use `kit.NewParallelTool` for tools safe to run concurrently. Binary data (images, audio, etc.) in `ToolOutput.Data` is automatically forwarded to the LLM when `MediaType` is set. See the [SDK docs](/sdk/overview) for full details on struct tags, `ToolOutput` fields, and `ToolCallIDFromContext`.
#### Return Helpers
| Helper | Description |
| --- | --- |
| `kit.TextResult(content)` | Successful text result |
| `kit.ErrorResult(content)` | Error result (LLM sees it as a tool error) |
| `kit.ImageResult(content, data, mediaType)` | Image result with binary data (e.g. `"image/png"`) |
| `kit.MediaResult(content, data, mediaType)` | Non-image media result (e.g. `"audio/mpeg"`) |
#### ToolOutput Fields
```go
kit.ToolOutput{
Content: "result text", // text returned to the LLM
IsError: false, // true = LLM sees this as an error
Data: pngBytes, // optional binary data (images, audio)
MediaType: "image/png", // MIME type for binary Data
Metadata: map[string]any{}, // opaque metadata for hooks/UI (not sent to LLM)
}
```
### With Callbacks
@@ -657,7 +684,7 @@ unsub2 := host.OnToolResult(func(e kit.ToolResultEvent) {
})
defer unsub2()
unsub3 := host.OnStreaming(func(e kit.MessageUpdateEvent) {
unsub3 := host.OnMessageUpdate(func(e kit.MessageUpdateEvent) {
print(e.Chunk)
})
defer unsub3()
+64 -4
View File
@@ -11,6 +11,7 @@ import (
"charm.land/huh/v2"
"github.com/mark3labs/kit/internal/auth"
"github.com/mark3labs/kit/internal/ui"
kit "github.com/mark3labs/kit/pkg/kit"
"github.com/spf13/cobra"
)
@@ -54,9 +55,13 @@ Available providers:
- anthropic: Anthropic Claude API (OAuth)
- openai: OpenAI ChatGPT Plus/Pro (Codex OAuth)
Example:
Flags:
--set-default Set this provider's default model as the system default
Examples:
kit auth login anthropic
kit auth login openai`,
kit auth login openai
kit auth login openai --set-default`,
Args: cobra.ExactArgs(1),
RunE: runAuthLogin,
}
@@ -99,10 +104,43 @@ Example:
RunE: runAuthStatus,
}
var (
loginSetDefault bool
)
// defaultModels maps providers to their recommended default models.
// These are used when --set-default flag is passed to auth login.
var defaultModels = map[string]string{
"anthropic": "anthropic/claude-sonnet-4-5-20250929",
"openai": "openai/gpt-5.4",
}
// setDefaultModelIfRequested sets the default model for the given provider
// if the --set-default flag was provided.
func setDefaultModelIfRequested(provider string) error {
if !loginSetDefault {
return nil
}
model, ok := defaultModels[provider]
if !ok {
return fmt.Errorf("no default model configured for provider: %s", provider)
}
if err := ui.SaveModelPreference(model); err != nil {
return fmt.Errorf("failed to save model preference: %w", err)
}
fmt.Printf("\n✓ Set default model to: %s\n", model)
return nil
}
func init() {
authCmd.AddCommand(authLoginCmd)
authCmd.AddCommand(authLogoutCmd)
authCmd.AddCommand(authStatusCmd)
authLoginCmd.Flags().BoolVar(&loginSetDefault, "set-default", false, "Set this provider's default model as the system default after login")
}
func runAuthLogin(cmd *cobra.Command, args []string) error {
@@ -288,6 +326,17 @@ func loginAnthropic() error {
fmt.Println("\n🎉 Your OAuth credentials will now be used for Anthropic API calls.")
fmt.Println("💡 You can check your authentication status with: kit auth status")
// Set default model if requested
if err := setDefaultModelIfRequested("anthropic"); err != nil {
return err
}
// Remind users how to set this as default if they didn't use --set-default
if !loginSetDefault {
fmt.Println("\n💡 To set Anthropic as your default model, run:")
fmt.Println(" kit auth login anthropic --set-default")
}
return nil
}
@@ -454,6 +503,17 @@ func loginOpenAI() error {
fmt.Println("\n🎉 Your OAuth credentials will now be used for OpenAI API calls.")
fmt.Println("💡 You can check your authentication status with: kit auth status")
// Set default model if requested
if err := setDefaultModelIfRequested("openai"); err != nil {
return err
}
// Remind users how to set this as default if they didn't use --set-default
if !loginSetDefault {
fmt.Println("\n💡 To set OpenAI as your default model, run:")
fmt.Println(" kit auth login openai --set-default")
}
return nil
}
@@ -504,13 +564,13 @@ func startOpenAICallbackServer(expectedState string) (*callbackServer, error) {
}
// Return success page
w.Header().Set("Content-Type", "text/html")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprintf(w, `<!DOCTYPE html>
<html>
<head><title>Authentication Successful</title></head>
<body style="font-family: sans-serif; text-align: center; padding: 50px;">
<h1> Authentication Successful</h1>
<h1>&#10003; Authentication Successful</h1>
<p>You can close this window and return to the terminal.</p>
</body>
</html>`)
+1 -1
View File
@@ -304,7 +304,7 @@ func init() {
flags.Float32Var(&frequencyPenalty, "frequency-penalty", 0.0, "penalizes tokens based on frequency of appearance (0.0-2.0)")
flags.Float32Var(&presencePenalty, "presence-penalty", 0.0, "penalizes tokens based on whether they have appeared (0.0-2.0)")
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")
flags.StringVar(&thinkingLevel, "thinking-level", "off", "extended thinking level: off, none, 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)")
+1 -1
View File
@@ -62,7 +62,7 @@ func main() {
}
})
// Subscribe to streaming chunks.
host3.OnStreaming(func(e kit.MessageUpdateEvent) {
host3.OnMessageUpdate(func(e kit.MessageUpdateEvent) {
fmt.Print(e.Chunk)
})
+33 -33
View File
@@ -4,8 +4,8 @@ go 1.26.2
require (
charm.land/bubbles/v2 v2.1.0
charm.land/bubbletea/v2 v2.0.5
charm.land/fantasy v0.17.2
charm.land/bubbletea/v2 v2.0.6
charm.land/fantasy v0.19.0
charm.land/huh/v2 v2.0.3
charm.land/lipgloss/v2 v2.0.3
github.com/alecthomas/chroma/v2 v2.23.1
@@ -14,15 +14,15 @@ require (
github.com/charmbracelet/fang v1.0.0
github.com/charmbracelet/log v1.0.0
github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266
github.com/charmbracelet/ultraviolet v0.0.0-20260414011438-8c69ec811b1e
github.com/charmbracelet/ultraviolet v0.0.0-20260420095748-421e4a7fa8d7
github.com/charmbracelet/x/editor v0.2.0
github.com/clipperhouse/displaywidth v0.11.0
github.com/clipperhouse/uax29/v2 v2.7.0
github.com/coder/acp-go-sdk v0.6.3
github.com/coder/acp-go-sdk v0.12.0
github.com/fsnotify/fsnotify v1.9.0
github.com/indaco/herald v0.13.0
github.com/indaco/herald-md v0.3.0
github.com/mark3labs/mcp-go v0.48.0
github.com/mark3labs/mcp-go v0.49.0
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/traefik/yaegi v0.16.1
@@ -35,23 +35,23 @@ require (
cloud.google.com/go/auth v0.20.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.14 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect
github.com/aws/smithy-go v1.24.3 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.6 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.16 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.15 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 // indirect
github.com/aws/smithy-go v1.25.0 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab // indirect
@@ -59,14 +59,14 @@ require (
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260413165052-6921c759c913 // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260420102150-fe550f2efce5 // indirect
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20260413165052-6921c759c913 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20260420102150-fe550f2efce5 // indirect
github.com/charmbracelet/x/exp/strings v0.1.0 // indirect
github.com/charmbracelet/x/json v0.2.0 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dlclark/regexp2 v1.12.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
@@ -79,13 +79,13 @@ require (
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
github.com/googleapis/gax-go/v2 v2.21.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.15 // indirect
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/kaptinlin/go-i18n v0.4.0 // indirect
github.com/kaptinlin/jsonpointer v0.4.17 // indirect
github.com/kaptinlin/jsonschema v0.7.7 // indirect
github.com/kaptinlin/messageformat-go v0.4.20 // indirect
github.com/kaptinlin/go-i18n v0.4.2 // indirect
github.com/kaptinlin/jsonpointer v0.4.18 // indirect
github.com/kaptinlin/jsonschema v0.7.8 // indirect
github.com/kaptinlin/messageformat-go v0.5.2 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/mango v0.2.0 // indirect
github.com/muesli/mango-cobra v1.3.0 // indirect
@@ -115,9 +115,9 @@ require (
golang.org/x/net v0.53.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/api v0.275.0 // indirect
google.golang.org/api v0.276.0 // indirect
google.golang.org/genai v1.54.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect
google.golang.org/grpc v1.80.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
@@ -134,7 +134,7 @@ require (
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/pflag v1.0.10
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0
+64 -64
View File
@@ -1,9 +1,9 @@
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
charm.land/bubbletea/v2 v2.0.5 h1:TQlLFqxo39AAHSVuOhJ5D3nH7O9Nk8JGinsfWQ4y1U4=
charm.land/bubbletea/v2 v2.0.5/go.mod h1:dvbsYZD+MHkdIZl+Z67D212hEvB+GII2tfH8f9SnoDw=
charm.land/fantasy v0.17.2 h1:ojTMufMxY/PVH7TzYUxht2SVkvD90iCTJfmPR6c8BR8=
charm.land/fantasy v0.17.2/go.mod h1:V9cCIUMZB9g3Bq40aKEY8xBNzDd48EdfHp2OMS0uzWs=
charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo=
charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g=
charm.land/fantasy v0.19.0 h1:fnNXkIJ/xcIW3sdVtWxjtQGpWWe8pDGhBCWSHkgbrd0=
charm.land/fantasy v0.19.0/go.mod h1:V9cCIUMZB9g3Bq40aKEY8xBNzDd48EdfHp2OMS0uzWs=
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
@@ -16,8 +16,8 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
@@ -34,36 +34,36 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI=
github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo=
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw=
github.com/aws/smithy-go v1.24.3 h1:XgOAaUgx+HhVBoP4v8n6HCQoTRDhoMghKqw4LNHsDNg=
github.com/aws/smithy-go v1.24.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aws/aws-sdk-go-v2 v1.41.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg=
github.com/aws/aws-sdk-go-v2 v1.41.6/go.mod h1:dy0UzBIfwSeot4grGvY1AqFWN5zgziMmWGzysDnHFcQ=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 h1:adBsCIIpLbLmYnkQU+nAChU5yhVTvu5PerROm+/Kq2A=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9/go.mod h1:uOYhgfgThm/ZyAuJGNQ5YgNyOlYfqnGpTHXvk3cpykg=
github.com/aws/aws-sdk-go-v2/config v1.32.16 h1:Q0iQ7quUgJP0F/SCRTieScnaMdXr9h/2+wze1u3cNeM=
github.com/aws/aws-sdk-go-v2/config v1.32.16/go.mod h1:duCCnJEFqpt2RC6no1iK6q+8HpwOAkiUua0pY507dQc=
github.com/aws/aws-sdk-go-v2/credentials v1.19.15 h1:fyvgWTszojq8hEnMi8PPBTvZdTtEVmAVyo+NFLHBhH4=
github.com/aws/aws-sdk-go-v2/credentials v1.19.15/go.mod h1:gJiYyMOjNg8OEdRWOf3CrFQxM2a98qmrtjx1zuiQfB8=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 h1:IOGsJ1xVWhsi+ZO7/NW8OuZZBtMJLZbk4P5HDjJO0jQ=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22/go.mod h1:b+hYdbU+jGKfXE8kKM6g1+h+L/Go3vMvzlxBsiuGsxg=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 h1:GmLa5Kw1ESqtFpXsx5MmC84QWa/ZrLZvlJGa2y+4kcQ=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22/go.mod h1:6sW9iWm9DK9YRpRGga/qzrzNLgKpT2cIxb7Vo2eNOp0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 h1:dY4kWZiSaXIzxnKlj17nHnBcXXBfac6UlsAx2qL6XrU=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22/go.mod h1:KIpEUx0JuRZLO7U6cbV204cWAEco2iC3l061IxlwLtI=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 h1:FPXsW9+gMuIeKmz7j6ENWcWtBGTe1kH8r9thNt5Uxx4=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23/go.mod h1:7J8iGMdRKk6lw2C+cMIphgAnT8uTwBwNOsGkyOCm80U=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 h1:HtOTYcbVcGABLOVuPYaIihj6IlkqubBwFj10K5fxRek=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8/go.mod h1:VsK9abqQeGlzPgUr+isNWzPlK2vKe9INMLWnY65f5Xs=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 h1:PUmZeJU6Y1Lbvt9WFuJ0ugUK2xn6hIWUBBbKuOWF30s=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22/go.mod h1:nO6egFBoAaoXze24a2C0NjQCvdpk8OueRoYimvEB9jo=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 h1:a1Fq/KXn75wSzoJaPQTgZO0wHGqE9mjFnylnqEPTchA=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.10/go.mod h1:p6+MXNxW7IA6dMgHfTAzljuwSKD0NCm/4lbS4t6+7vI=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 h1:x6bKbmDhsgSZwv6q19wY/u3rLk/3FGjJWyqKcIRufpE=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.16/go.mod h1:CudnEVKRtLn0+3uMV0yEXZ+YZOKnAtUJ5DmDhilVnIw=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 h1:oK/njaL8GtyEihkWMD4k3VgHCT64RQKkZwh0DG5j8ak=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20/go.mod h1:JHs8/y1f3zY7U5WcuzoJ/yAYGYtNIVPKLIbp61euvmg=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 h1:ks8KBcZPh3PYISr5dAiXCM5/Thcuxk8l+PG4+A0exds=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.0/go.mod h1:pFw33T0WLvXU3rw1WBkpMlkgIn54eCB5FYLhjDc9Foo=
github.com/aws/smithy-go v1.25.0 h1:Sz/XJ64rwuiKtB6j98nDIPyYrV1nVNJ4YU74gttcl5U=
github.com/aws/smithy-go v1.25.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
@@ -86,8 +86,8 @@ github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdR
github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA=
github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266 h1:BW/sZtyd1JyYy0h5adMm3tzpNyL857LWjuTRET6OhpY=
github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266/go.mod h1:1DahUaExbUZx/jD+FNT2PKP4L9rLE5+ZBRuI8mZjd/E=
github.com/charmbracelet/ultraviolet v0.0.0-20260414011438-8c69ec811b1e h1:O5hZFj55wZQWxMiRtQLa3uLKhZGZGS/j8M3OXinQlrw=
github.com/charmbracelet/ultraviolet v0.0.0-20260414011438-8c69ec811b1e/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM=
github.com/charmbracelet/ultraviolet v0.0.0-20260420095748-421e4a7fa8d7 h1:PbFxahSfyADcQOp+7WxbeqN3wX37KA/Rk+EXOW1xS9Q=
github.com/charmbracelet/ultraviolet v0.0.0-20260420095748-421e4a7fa8d7/go.mod h1:3YdTxlnV/L0bQ3VN8WOSw8doF7LZV/xawUQ4MuAPDvo=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
@@ -98,14 +98,14 @@ github.com/charmbracelet/x/editor v0.2.0 h1:7XLUKtaRaB8jN7bWU2p2UChiySyaAuIfYiIR
github.com/charmbracelet/x/editor v0.2.0/go.mod h1:p3oQ28TSL3YPd+GKJ1fHWcp+7bVGpedHpXmo0D6t1dY=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260413165052-6921c759c913 h1:6F/6bu5nBLjodsvaU5xAszTaxtHrDU5UiJarpMPZj48=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260413165052-6921c759c913/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260420102150-fe550f2efce5 h1:3ElWZRQqSRqML2P/r2TmuSkdXPMDI+Jg3f0bGA6Ekg4=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260420102150-fe550f2efce5/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
github.com/charmbracelet/x/exp/slice v0.0.0-20260413165052-6921c759c913 h1:RiZFY92Ug9iz1CenzxSSQla2Z3WflsR7bIuXq40JlpU=
github.com/charmbracelet/x/exp/slice v0.0.0-20260413165052-6921c759c913/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
github.com/charmbracelet/x/exp/slice v0.0.0-20260420102150-fe550f2efce5 h1:QqpW1CPNAnOpM3Nj0X7IT2IFlR90bLdAkO5+A3Hwbi4=
github.com/charmbracelet/x/exp/slice v0.0.0-20260420102150-fe550f2efce5/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA=
github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
@@ -124,15 +124,15 @@ 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/coder/acp-go-sdk v0.12.0 h1:GoIC6RrkPMBIVQ3ckSkl+bO/ERV/IRK6clBdZmx4Uf4=
github.com/coder/acp-go-sdk v0.12.0/go.mod h1:yKzM/3R9uELp4+nBAwwtkS0aN1FOFjo11CNPy37yFko=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -173,10 +173,10 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=
github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI=
github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4=
github.com/googleapis/enterprise-certificate-proxy v0.3.15 h1:xolVQTEXusUcAA5UgtyRLjelpFFHWlPQ4XfWGc7MBas=
github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4=
github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
@@ -187,14 +187,14 @@ github.com/indaco/herald v0.13.0 h1:+xVG9Fx5NpuWhwku/9IlRL6I009NnX4VUGKvlZHTRxU=
github.com/indaco/herald v0.13.0/go.mod h1:T5g1+XLYvpjouhzAGHnAHDCKizhESkoV6+QPZ3DhgWA=
github.com/indaco/herald-md v0.3.0 h1:hN1cKyrexPPM9PeHBsKuaWvIizSi/iYvM9yzRgtdb8M=
github.com/indaco/herald-md v0.3.0/go.mod h1:RUHVaDSG45ymJjKyxpDwBocLXrZo93FB4OeYMsw9B9s=
github.com/kaptinlin/go-i18n v0.4.0 h1:i7L3U2yurg+xhokITtJ0k+mjHnXqkoyz8ju5Wb7W8Oc=
github.com/kaptinlin/go-i18n v0.4.0/go.mod h1:njA6x0+4MWGcLWT0KLrwekhRPmze1Hnstf2+VJFzwpM=
github.com/kaptinlin/jsonpointer v0.4.17 h1:mY9k8ciWncxbsECyaxKnR0MdmxamNdp2tLQkAKVrtSk=
github.com/kaptinlin/jsonpointer v0.4.17/go.mod h1:SsfsjqnHG5zuKo1DTBzk1VknaHlL4osHw+X9kZKukpU=
github.com/kaptinlin/jsonschema v0.7.7 h1:41BlQJ9dskH0oE5DSzBUrl/w4JQYIr6N6L0B5GNyDoM=
github.com/kaptinlin/jsonschema v0.7.7/go.mod h1:rKjWfyySHSxAD7Li2ctYkPlOu960igoKBvZ2ADRtd5Q=
github.com/kaptinlin/messageformat-go v0.4.20 h1:a0ufTd5liiUubIGeGxpSTnNS8ZSrN4DV01/wGFmfzMs=
github.com/kaptinlin/messageformat-go v0.4.20/go.mod h1:FqdEPfQLkqVBX7OBRMPgYwUPvKYJohFD9Ok1BMzCfIo=
github.com/kaptinlin/go-i18n v0.4.2 h1:52gGOx4ZwbLEiOyDMNA1ax2WktKlrKsmV6Ydf9Tw3/I=
github.com/kaptinlin/go-i18n v0.4.2/go.mod h1:IACLIi+sHn3pGyryFMiqr2N1CJry4OKFD0MAEneEVQk=
github.com/kaptinlin/jsonpointer v0.4.18 h1:EDUXT4WKpOKguU7oaFv6VaNatN7uHFe6dEYHX0+OFxs=
github.com/kaptinlin/jsonpointer v0.4.18/go.mod h1:ndmfvrqrEDSbV3F7yGaOuDvr29WrxYU1aqkvef9L2do=
github.com/kaptinlin/jsonschema v0.7.8 h1:aHv28bYtfLfUXYI/10Phb1nvVyLXNz1lmu73vtKmlOY=
github.com/kaptinlin/jsonschema v0.7.8/go.mod h1:cz7SK0jTHdabKdQp+SwBKKmOeZ55txuNo72Jx9Sbb2w=
github.com/kaptinlin/messageformat-go v0.5.2 h1:E+D5oQVRepHgyMiLWRHnPXYFbqBDI4Sek7/CTIAByj4=
github.com/kaptinlin/messageformat-go v0.5.2/go.mod h1:NKjwS6e9u7DRhAK+vydjDDwJ7UbdHhYjk/yk2WPuZPs=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -203,8 +203,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mark3labs/mcp-go v0.48.0 h1:o+MXuGW/HCeR2ny5LcAcZQn2bo6I2xaZMEHnpRG+dtw=
github.com/mark3labs/mcp-go v0.48.0/go.mod h1:JKTC7R2LLVagkEWK7Kwu7DbmA6iIvnNAod6yrHiQMag=
github.com/mark3labs/mcp-go v0.49.0 h1:7Ssx4d7/T86qnWoJIdye7wEEvUzv39UIbnZb/FqUZMY=
github.com/mark3labs/mcp-go v0.49.0/go.mod h1:BflTAZAzXlrTpiO44gmjMu89n2FO56rJ9m31fp4zd5k=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
@@ -310,16 +310,16 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/api v0.275.0 h1:vfY5d9vFVJeWEZT65QDd9hbndr7FyZ2+6mIzGAh71NI=
google.golang.org/api v0.275.0/go.mod h1:Fnag/EWUPIcJXuIkP1pjoTgS5vdxlk3eeemL7Do6bvw=
google.golang.org/api v0.276.0 h1:nVArUtfLEihtW+b0DdcqRGK1xoEm2+ltAihyztq7MKY=
google.golang.org/api v0.276.0/go.mod h1:Fnag/EWUPIcJXuIkP1pjoTgS5vdxlk3eeemL7Do6bvw=
google.golang.org/genai v1.54.0 h1:ZQCa70WMTJDI11FdqWCzGvZ5PanpcpfoO6jl/lrSnGU=
google.golang.org/genai v1.54.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
google.golang.org/genproto v0.0.0-20260406210006-6f92a3bedf2d h1:N1Ec54vZnIPd7MnxRiYLW+oY4fDR4BOS/LrssdD9+ek=
google.golang.org/genproto v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:c2hJ1grtnH0xUiEKGDGkjGNTJ1Hy2LrblyKOHF0sqRM=
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d h1:/aDRtSZJjyLQzm75d+a1wOJaqyKBMvIAfeQmoa3ORiI=
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:etfGUgejTiadZAUaEP14NP97xi1RGeawqkjDARA/UOs=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+42 -9
View File
@@ -177,22 +177,55 @@ func (a *Agent) SetSessionMode(_ context.Context, _ acp.SetSessionModeRequest) (
return acp.SetSessionModeResponse{}, nil
}
// SetSessionModel changes the active model for a session.
func (a *Agent) SetSessionModel(ctx context.Context, params acp.SetSessionModelRequest) (acp.SetSessionModelResponse, error) {
sessionID := string(params.SessionId)
// ListSessions returns an empty session list. Kit doesn't persist sessions
// across restarts in ACP mode, so this is effectively a no-op.
func (a *Agent) ListSessions(_ context.Context, _ acp.ListSessionsRequest) (acp.ListSessionsResponse, error) {
return acp.ListSessionsResponse{
Sessions: []acp.SessionInfo{},
}, nil
}
// SetSessionConfigOption handles session configuration changes. Currently
// supports the "model" config option to change the active model for a session.
func (a *Agent) SetSessionConfigOption(ctx context.Context, params acp.SetSessionConfigOptionRequest) (acp.SetSessionConfigOptionResponse, error) {
// Extract session ID and config ID from whichever variant is present.
var sessionID string
var configID string
var value string
switch {
case params.ValueId != nil:
sessionID = string(params.ValueId.SessionId)
configID = string(params.ValueId.ConfigId)
value = string(params.ValueId.Value)
case params.Boolean != nil:
sessionID = string(params.Boolean.SessionId)
configID = string(params.Boolean.ConfigId)
// Boolean config options are not used for model selection.
log.Debug("acp: set_session_config_option (boolean)", "session", sessionID, "config", configID, "value", params.Boolean.Value)
return acp.SetSessionConfigOptionResponse{}, nil
default:
return acp.SetSessionConfigOptionResponse{}, acp.NewInvalidParams("unsupported config option variant")
}
sess, ok := a.registry.get(sessionID)
if !ok {
return acp.SetSessionModelResponse{}, acp.NewInvalidParams(fmt.Sprintf("session not found: %s", sessionID))
return acp.SetSessionConfigOptionResponse{}, acp.NewInvalidParams(fmt.Sprintf("session not found: %s", sessionID))
}
modelID := string(params.ModelId)
log.Debug("acp: set_session_model", "session", sessionID, "model", modelID)
log.Debug("acp: set_session_config_option", "session", sessionID, "config", configID, "value", value)
if err := sess.kit.SetModel(ctx, modelID); err != nil {
return acp.SetSessionModelResponse{}, fmt.Errorf("set model: %w", err)
// Handle known config options.
switch configID {
case "model":
if err := sess.kit.SetModel(ctx, value); err != nil {
return acp.SetSessionConfigOptionResponse{}, fmt.Errorf("set model: %w", err)
}
default:
log.Debug("acp: unknown config option", "config", configID)
}
return acp.SetSessionModelResponse{}, nil
return acp.SetSessionConfigOptionResponse{}, nil
}
// ---------------------------------------------------------------------------
+328 -63
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"strings"
"time"
"charm.land/fantasy"
@@ -87,6 +88,19 @@ type ReasoningDeltaHandler func(delta string)
// Called when the last reasoning token has been processed, before text streaming starts.
type ReasoningCompleteHandler func()
// ToolCallStartHandler is a function type for handling the moment when the LLM
// begins generating tool call arguments. The tool name is known but the full
// argument JSON is still streaming.
type ToolCallStartHandler func(toolCallID, toolName string)
// ToolCallDeltaHandler is a function type for handling streamed fragments of
// tool call arguments as they arrive from the LLM.
type ToolCallDeltaHandler func(toolCallID, delta string)
// ToolCallEndHandler is a function type for handling the end of tool argument
// streaming, before the tool call is parsed and execution begins.
type ToolCallEndHandler func(toolCallID string)
// ToolOutputHandler is a function type for handling streaming tool output chunks.
// Used by tools like bash to stream output as it arrives rather than waiting
// for the command to complete. The isStderr flag indicates if the chunk
@@ -113,6 +127,76 @@ type StepMessagesHandler func(stepMessages []fantasy.Message)
// tracking during long-running tool-calling conversations.
type StepUsageHandler func(inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens int64)
// StepStartHandler is called when a new LLM step begins within a turn.
type StepStartHandler func(stepNumber int)
// StepFinishHandler is called when a step completes with full context.
type StepFinishHandler func(stepNumber int, hasToolCalls bool, finishReason string, usage fantasy.Usage)
// TextStartHandler is called when the LLM begins generating text content.
type TextStartHandler func(id string)
// TextEndHandler is called when the LLM finishes generating text content.
type TextEndHandler func(id string)
// ReasoningStartHandler is called when the LLM begins reasoning/thinking.
type ReasoningStartHandler func(id string)
// WarningsHandler is called when the LLM provider returns warnings.
type WarningsHandler func(warnings []string)
// SourceHandler is called when the LLM references a source.
type SourceHandler func(sourceType, id, url, title string)
// StreamFinishHandler is called when a per-step LLM stream completes.
type StreamFinishHandler func(usage fantasy.Usage, finishReason string)
// ErrorHandler is called when an agent-level error occurs.
type ErrorHandler func(err error)
// RetryHandler is called when the LLM request is retried.
type RetryHandler func(attempt int, err error)
// PrepareStepHandler is called between steps to allow message modification.
// It receives the step number and current messages, and returns replacement
// messages (or nil to keep unchanged).
type PrepareStepHandler func(stepNumber int, messages []fantasy.Message) []fantasy.Message
// GenerateCallbacks consolidates all callback functions for
// GenerateWithLoopAndStreaming into a single struct. This replaces the previous
// 16+ positional callback parameters, making it easier to add new callbacks
// without breaking existing callers (new fields default to nil).
type GenerateCallbacks struct {
OnToolCall ToolCallHandler
OnToolExecution ToolExecutionHandler
OnToolResult ToolResultHandler
OnResponse ResponseHandler
OnToolCallContent ToolCallContentHandler
OnStreamingResponse StreamingResponseHandler
OnReasoningDelta ReasoningDeltaHandler
OnReasoningComplete ReasoningCompleteHandler
OnToolOutput ToolOutputHandler
OnStepMessages StepMessagesHandler
OnStepUsage StepUsageHandler
OnPasswordPrompt PasswordPromptHandler
OnToolCallStart ToolCallStartHandler
OnToolCallDelta ToolCallDeltaHandler
OnToolCallEnd ToolCallEndHandler
// New callbacks for previously unwired Fantasy lifecycle events.
OnStepStart StepStartHandler
OnStepFinish StepFinishHandler
OnTextStart TextStartHandler
OnTextEnd TextEndHandler
OnReasoningStart ReasoningStartHandler
OnWarnings WarningsHandler
OnSource SourceHandler
OnStreamFinish StreamFinishHandler
OnError ErrorHandler
OnRetry RetryHandler
OnPrepareStep PrepareStepHandler
}
// Agent represents an AI agent with core tool integration using the LLM library.
// Core tools (bash, read, write, edit, grep, find, ls) are registered as direct
// AgentTool implementations — no MCP layer, no serialization overhead.
@@ -410,13 +494,20 @@ func (a *Agent) GenerateWithLoop(ctx context.Context, messages []fantasy.Message
onToolCall ToolCallHandler, onToolExecution ToolExecutionHandler, onToolResult ToolResultHandler,
onResponse ResponseHandler, onToolCallContent ToolCallContentHandler,
) (*GenerateWithLoopResult, error) {
return a.GenerateWithLoopAndStreaming(ctx, messages, onToolCall, onToolExecution, onToolResult,
onResponse, onToolCallContent, nil, nil, nil, nil, nil, nil, nil)
return a.GenerateWithCallbacks(ctx, messages, GenerateCallbacks{
OnToolCall: onToolCall,
OnToolExecution: onToolExecution,
OnToolResult: onToolResult,
OnResponse: onResponse,
OnToolCallContent: onToolCallContent,
})
}
// GenerateWithLoopAndStreaming processes messages using the agent with streaming and callbacks.
// The agent handles the tool call loop internally. We map the rich callback system
// to kit's existing callback interface for UI integration.
// The agent handles the tool call loop internally.
//
// Deprecated: Use GenerateWithCallbacks instead, which takes a GenerateCallbacks
// struct and is easier to extend with new callbacks.
func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fantasy.Message,
onToolCall ToolCallHandler, onToolExecution ToolExecutionHandler, onToolResult ToolResultHandler,
onResponse ResponseHandler, onToolCallContent ToolCallContentHandler,
@@ -427,6 +518,34 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
onStepMessages StepMessagesHandler,
onStepUsage StepUsageHandler,
onPasswordPrompt PasswordPromptHandler,
onToolCallStart ToolCallStartHandler,
onToolCallDelta ToolCallDeltaHandler,
onToolCallEnd ToolCallEndHandler,
) (*GenerateWithLoopResult, error) {
return a.GenerateWithCallbacks(ctx, messages, GenerateCallbacks{
OnToolCall: onToolCall,
OnToolExecution: onToolExecution,
OnToolResult: onToolResult,
OnResponse: onResponse,
OnToolCallContent: onToolCallContent,
OnStreamingResponse: onStreamingResponse,
OnReasoningDelta: onReasoningDelta,
OnReasoningComplete: onReasoningComplete,
OnToolOutput: onToolOutput,
OnStepMessages: onStepMessages,
OnStepUsage: onStepUsage,
OnPasswordPrompt: onPasswordPrompt,
OnToolCallStart: onToolCallStart,
OnToolCallDelta: onToolCallDelta,
OnToolCallEnd: onToolCallEnd,
})
}
// GenerateWithCallbacks processes messages using the agent with streaming and callbacks.
// The agent handles the tool call loop internally. We map the rich callback system
// to kit's existing callback interface for UI integration.
func (a *Agent) GenerateWithCallbacks(ctx context.Context, messages []fantasy.Message,
cb GenerateCallbacks,
) (*GenerateWithLoopResult, error) {
// Wait for background MCP tool loading to complete and rebuild the
@@ -435,13 +554,13 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
a.ensureMCPTools()
// Inject tool output handler into context for use by core tools (e.g., bash).
if onToolOutput != nil {
ctx = core.ContextWithToolOutputCallback(ctx, onToolOutput)
if cb.OnToolOutput != nil {
ctx = core.ContextWithToolOutputCallback(ctx, cb.OnToolOutput)
}
// Inject password prompt handler into context for use by bash tool.
if onPasswordPrompt != nil {
ctx = core.ContextWithPasswordPrompt(ctx, onPasswordPrompt)
if cb.OnPasswordPrompt != nil {
ctx = core.ContextWithPasswordPrompt(ctx, cb.OnPasswordPrompt)
}
// The agent requires the current user input as Prompt, with prior messages as history.
@@ -461,8 +580,13 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
// provided. The agent only exposes tool/step callbacks on AgentStreamCall, so
// 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 || onReasoningDelta != nil
hasCallbacks := cb.OnToolCall != nil || cb.OnToolExecution != nil || cb.OnToolResult != nil ||
cb.OnToolCallContent != nil || cb.OnStreamingResponse != nil || cb.OnReasoningDelta != nil ||
cb.OnToolCallStart != nil || cb.OnToolCallDelta != nil || cb.OnToolCallEnd != nil ||
cb.OnStepStart != nil || cb.OnStepFinish != nil || cb.OnTextStart != nil ||
cb.OnTextEnd != nil || cb.OnReasoningStart != nil || cb.OnWarnings != nil ||
cb.OnSource != nil || cb.OnStreamFinish != nil || cb.OnError != nil ||
cb.OnRetry != nil || cb.OnPrepareStep != nil
if a.streamingEnabled || hasCallbacks {
// Track completed step messages so we can return partial results
@@ -471,9 +595,11 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
// for every step that completed before the error occurred.
var completedStepMessages []fantasy.Message
// persistedCount tracks how many new messages (beyond the original
// input) were persisted incrementally via onStepMessages, so the
// input) were persisted incrementally via cb.OnStepMessages, so the
// caller can skip them during post-generation persistence.
var persistedCount int
// stepCounter tracks the current step number for StepStart/StepFinish events.
var stepCounter int
// Use the streaming agent
streamCall := fantasy.AgentStreamCall{
@@ -481,13 +607,73 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
Files: files,
Messages: history,
// Tool input streaming callbacks — fire during tool argument generation
OnToolInputStart: func(id, toolName string) error {
if ctx.Err() != nil {
return ctx.Err()
}
if cb.OnToolCallStart != nil {
cb.OnToolCallStart(id, toolName)
}
return nil
},
OnToolInputDelta: func(id, delta string) error {
if ctx.Err() != nil {
return ctx.Err()
}
if cb.OnToolCallDelta != nil {
cb.OnToolCallDelta(id, delta)
}
return nil
},
OnToolInputEnd: func(id string) error {
if ctx.Err() != nil {
return ctx.Err()
}
if cb.OnToolCallEnd != nil {
cb.OnToolCallEnd(id)
}
return nil
},
// Text start/end callbacks
OnTextStart: func(id string) error {
if ctx.Err() != nil {
return ctx.Err()
}
if cb.OnTextStart != nil {
cb.OnTextStart(id)
}
return nil
},
OnTextEnd: func(id string) error {
if ctx.Err() != nil {
return ctx.Err()
}
if cb.OnTextEnd != nil {
cb.OnTextEnd(id)
}
return nil
},
// Reasoning start callback
OnReasoningStart: func(id string, _ fantasy.ReasoningContent) error {
if ctx.Err() != nil {
return ctx.Err()
}
if cb.OnReasoningStart != nil {
cb.OnReasoningStart(id)
}
return nil
},
// Reasoning/thinking streaming callback
OnReasoningDelta: func(id, delta string) error {
if ctx.Err() != nil {
return ctx.Err()
}
if onReasoningDelta != nil {
onReasoningDelta(delta)
if cb.OnReasoningDelta != nil {
cb.OnReasoningDelta(delta)
}
return nil
},
@@ -497,8 +683,8 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
if ctx.Err() != nil {
return ctx.Err()
}
if onReasoningComplete != nil {
onReasoningComplete()
if cb.OnReasoningComplete != nil {
cb.OnReasoningComplete()
}
return nil
},
@@ -508,8 +694,64 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
if ctx.Err() != nil {
return ctx.Err()
}
if onStreamingResponse != nil {
onStreamingResponse(text)
if cb.OnStreamingResponse != nil {
cb.OnStreamingResponse(text)
}
return nil
},
// Warnings callback
OnWarnings: func(warnings []fantasy.CallWarning) error {
if ctx.Err() != nil {
return ctx.Err()
}
if cb.OnWarnings != nil {
strs := make([]string, len(warnings))
for i, w := range warnings {
strs[i] = w.Message
}
cb.OnWarnings(strs)
}
return nil
},
// Source callback
OnSource: func(source fantasy.SourceContent) error {
if ctx.Err() != nil {
return ctx.Err()
}
if cb.OnSource != nil {
cb.OnSource(string(source.SourceType), source.ID, source.URL, source.Title)
}
return nil
},
// Stream finish callback (per-step stream completion)
OnStreamFinish: func(usage fantasy.Usage, finishReason fantasy.FinishReason, _ fantasy.ProviderMetadata) error {
if ctx.Err() != nil {
return ctx.Err()
}
if cb.OnStreamFinish != nil {
cb.OnStreamFinish(usage, string(finishReason))
}
return nil
},
// Error callback
OnError: func(err error) {
if cb.OnError != nil {
cb.OnError(err)
}
},
// Step start callback
OnStepStart: func(stepNumber int) error {
if ctx.Err() != nil {
return ctx.Err()
}
stepCounter = stepNumber
if cb.OnStepStart != nil {
cb.OnStepStart(stepNumber)
}
return nil
},
@@ -522,13 +764,13 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
currentToolArgs = tc.Input
// Notify about the tool call
if onToolCall != nil {
onToolCall(tc.ToolCallID, tc.ToolName, tc.Input)
if cb.OnToolCall != nil {
cb.OnToolCall(tc.ToolCallID, tc.ToolName, tc.Input)
}
// Notify tool execution starting
if onToolExecution != nil {
onToolExecution(tc.ToolCallID, tc.ToolName, tc.Input, true)
if cb.OnToolExecution != nil {
cb.OnToolExecution(tc.ToolCallID, tc.ToolName, tc.Input, true)
}
return nil
@@ -540,14 +782,14 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
return ctx.Err()
}
// Notify tool execution finished
if onToolExecution != nil {
onToolExecution(tr.ToolCallID, tr.ToolName, currentToolArgs, false)
if cb.OnToolExecution != nil {
cb.OnToolExecution(tr.ToolCallID, tr.ToolName, currentToolArgs, false)
}
if onToolResult != nil {
if cb.OnToolResult != nil {
// Extract result text and error status
resultText, isError := extractToolResultText(tr)
onToolResult(tr.ToolCallID, tr.ToolName, currentToolArgs, resultText, tr.ClientMetadata, isError)
cb.OnToolResult(tr.ToolCallID, tr.ToolName, currentToolArgs, resultText, tr.ClientMetadata, isError)
}
return nil
@@ -561,8 +803,8 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
// Persist step messages incrementally so progress is saved
// as it happens rather than only at the end of the turn.
if onStepMessages != nil && len(step.Messages) > 0 {
onStepMessages(step.Messages)
if cb.OnStepMessages != nil && len(step.Messages) > 0 {
cb.OnStepMessages(step.Messages)
persistedCount += len(step.Messages)
}
@@ -572,65 +814,88 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
// Check if step has text content alongside tool calls
text := step.Content.Text()
toolCalls := step.Content.ToolCalls()
if text != "" && len(toolCalls) > 0 && onToolCallContent != nil {
onToolCallContent(text)
if text != "" && len(toolCalls) > 0 && cb.OnToolCallContent != nil {
cb.OnToolCallContent(text)
}
// Emit step usage for real-time cost tracking
if onStepUsage != nil {
onStepUsage(step.Usage.InputTokens, step.Usage.OutputTokens,
if cb.OnStepUsage != nil {
cb.OnStepUsage(step.Usage.InputTokens, step.Usage.OutputTokens,
step.Usage.CacheReadTokens, step.Usage.CacheCreationTokens)
}
// Emit unified step finish event
if cb.OnStepFinish != nil {
cb.OnStepFinish(stepCounter, len(toolCalls) > 0, string(step.FinishReason), step.Usage)
}
return nil
},
}
// If a steer channel is attached to the context, wire up a
// PrepareStep function that drains the channel between steps
// and injects pending steer messages as user messages before
// the next LLM call. This enables graceful mid-turn steering
// without cancelling in-progress tool execution.
if steerCh := steerChFromContext(ctx); steerCh != nil {
onConsumed := steerConsumedFromContext(ctx)
// Always wire up PrepareStep to handle both steering and the
// OnPrepareStep hook. Steering drains its channel first, then
// OnPrepareStep hooks run against the (possibly already steered)
// messages.
steerCh := steerChFromContext(ctx)
onConsumed := steerConsumedFromContext(ctx)
hasSteering := steerCh != nil
hasPrepareStepHook := cb.OnPrepareStep != nil
if hasSteering || hasPrepareStepHook {
streamCall.PrepareStep = func(
stepCtx context.Context,
opts fantasy.PrepareStepFunctionOptions,
) (context.Context, fantasy.PrepareStepResult, error) {
// Drain all pending steer messages (non-blocking).
var steered []SteerMessage
for {
select {
case msg := <-steerCh:
steered = append(steered, msg)
default:
goto done
}
}
done:
result := fantasy.PrepareStepResult{
Model: opts.Model,
Messages: opts.Messages,
}
if len(steered) > 0 {
// Inject each steer message as a user message so the
// LLM sees the redirection on the next step.
for _, sm := range steered {
result.Messages = append(result.Messages,
fantasy.NewUserMessage(sm.Text, sm.Files...))
// Phase 1: Drain steering channel (if present).
if hasSteering {
var steered []SteerMessage
for {
select {
case msg := <-steerCh:
steered = append(steered, msg)
default:
goto done
}
}
// Notify that steer messages were consumed.
if onConsumed != nil {
onConsumed(len(steered))
done:
if len(steered) > 0 {
for _, sm := range steered {
result.Messages = append(result.Messages,
fantasy.NewUserMessage(sm.Text, sm.Files...))
}
if onConsumed != nil {
onConsumed(len(steered))
}
}
}
// Phase 2: Run OnPrepareStep hook (if registered).
if hasPrepareStepHook {
if replacement := cb.OnPrepareStep(opts.StepNumber, result.Messages); replacement != nil {
result.Messages = replacement
}
}
// Apply message-level cache control for Anthropic models.
// This avoids type conflicts with provider-level options.
result.Messages = applyCacheControlToMessages(result.Messages)
return stepCtx, result, nil
}
}
// Wire OnRetry callback if provided.
if cb.OnRetry != nil {
streamCall.OnRetry = func(err *fantasy.ProviderError, _ time.Duration) {
// Use the retry number from the error if available; Fantasy
// doesn't pass a counter directly, so we approximate with a
// counter incremented on each call.
cb.OnRetry(0, err)
}
}
result, err := a.fantasyAgent.Stream(ctx, streamCall)
if err != nil {
// On cancellation (or any error), return a partial result
@@ -656,8 +921,8 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
// empty (e.g. reasoning-only responses) so the UI properly resets
// the stream component and avoids duplicate content on the next
// flush.
if onResponse != nil {
onResponse(result.Response.Content.Text())
if cb.OnResponse != nil {
cb.OnResponse(result.Response.Content.Text())
}
r := convertAgentResult(result, messages)
@@ -677,8 +942,8 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
// For non-streaming, fire the response callback so callers can reset
// streaming state (see streaming path comment above).
if onResponse != nil {
onResponse(result.Response.Content.Text())
if cb.OnResponse != nil {
cb.OnResponse(result.Response.Content.Text())
}
return convertAgentResult(result, messages), nil
+6
View File
@@ -888,6 +888,12 @@ func (a *App) subscribeSDKEvents(sendFn func(tea.Msg), stepUsageSeen *atomic.Boo
switch ev := e.(type) {
case kit.ToolCallEvent:
sendFn(ToolCallStartedEvent{ToolCallID: ev.ToolCallID, ToolName: ev.ToolName, ToolArgs: ev.ToolArgs})
case kit.ToolCallStartEvent:
sendFn(ToolCallInputStartEvent{ToolCallID: ev.ToolCallID, ToolName: ev.ToolName, ToolKind: ev.ToolKind})
case kit.ToolCallDeltaEvent:
sendFn(ToolCallInputDeltaEvent{ToolCallID: ev.ToolCallID, Delta: ev.Delta})
case kit.ToolCallEndEvent:
sendFn(ToolCallInputEndEvent{ToolCallID: ev.ToolCallID})
case kit.ToolExecutionStartEvent:
sendFn(ToolExecutionEvent{ToolCallID: ev.ToolCallID, ToolName: ev.ToolName, ToolArgs: ev.ToolArgs, IsStarting: true})
case kit.ToolExecutionEndEvent:
+30
View File
@@ -32,6 +32,36 @@ type ToolCallStartedEvent struct {
ToolArgs string
}
// ToolCallInputStartEvent is sent when the LLM begins generating tool call
// arguments. The tool name is known but the full argument JSON is still being
// streamed. UIs can use this to show a "running" indicator immediately instead
// of waiting for the full argument JSON to finish streaming.
type ToolCallInputStartEvent struct {
// ToolCallID is the stable identifier for correlating tool lifecycle events.
ToolCallID string
// ToolName is the name of the tool being called.
ToolName string
// ToolKind classifies the tool: "execute", "edit", "read", "search", "agent".
ToolKind string
}
// ToolCallInputDeltaEvent is sent for each streamed fragment of tool call
// arguments as they arrive from the LLM. Useful for live-previewing content
// or showing a progress indicator with byte count.
type ToolCallInputDeltaEvent struct {
// ToolCallID is the stable identifier for correlating tool lifecycle events.
ToolCallID string
// Delta is a JSON fragment of tool call arguments.
Delta string
}
// ToolCallInputEndEvent is sent when tool argument streaming is complete,
// before the tool call is parsed and execution begins.
type ToolCallInputEndEvent struct {
// ToolCallID is the stable identifier for correlating tool lifecycle events.
ToolCallID string
}
// ToolExecutionEvent is sent when a tool starts or finishes executing.
// The IsStarting flag distinguishes between the start and end of execution.
type ToolExecutionEvent struct {
+8
View File
@@ -471,5 +471,13 @@ func GetAnthropicAPIKey(flagValue string) (string, string, error) {
return envKey, "ANTHROPIC_API_KEY environment variable", nil
}
// Check if OpenAI credentials exist to provide a helpful suggestion
if cm != nil {
hasOpenAI, _ := cm.HasOpenAICredentials()
if hasOpenAI {
return "", "", fmt.Errorf("no Anthropic API key found. Use 'kit auth login anthropic', set ANTHROPIC_API_KEY environment variable, or use --provider-api-key flag\n\nNote: OpenAI credentials were detected. To use OpenAI, run with --model openai/gpt-5.4 or set it as default:\n kit auth login openai --set-default")
}
}
return "", "", fmt.Errorf("no Anthropic API key found. Use 'kit auth login anthropic', set ANTHROPIC_API_KEY environment variable, or use --provider-api-key flag")
}
+10
View File
@@ -30,6 +30,14 @@ type MCPServerConfig struct {
OAuthClientSecret string `json:"oauthClientSecret,omitempty" yaml:"oauthClientSecret,omitempty"`
OAuthScopes []string `json:"oauthScopes,omitempty" yaml:"oauthScopes,omitempty"`
// NoOAuth disables OAuth transport configuration for this server, even
// when the connection pool has an auth handler. Use this for public MCP
// servers (e.g. PubMed) that don't require authentication. Without this
// flag, the pool would attach OAuth transport to every remote server,
// causing proactive dynamic-client-registration attempts that fail on
// servers that don't support it.
NoOAuth bool `json:"noOAuth,omitempty" yaml:"noOAuth,omitempty"`
// InProcessServer holds a live *server.MCPServer for in-process transport.
// When set (and Type is "inprocess"), the connection pool creates an
// in-process client instead of spawning a subprocess or making HTTP calls.
@@ -59,6 +67,7 @@ func (s *MCPServerConfig) UnmarshalJSON(data []byte) error {
OAuthClientID string `json:"oauthClientId,omitempty" yaml:"oauthClientId,omitempty"`
OAuthClientSecret string `json:"oauthClientSecret,omitempty" yaml:"oauthClientSecret,omitempty"`
OAuthScopes []string `json:"oauthScopes,omitempty" yaml:"oauthScopes,omitempty"`
NoOAuth bool `json:"noOAuth,omitempty" yaml:"noOAuth,omitempty"`
}
// Also try legacy format
@@ -86,6 +95,7 @@ func (s *MCPServerConfig) UnmarshalJSON(data []byte) error {
s.OAuthClientID = newConfig.OAuthClientID
s.OAuthClientSecret = newConfig.OAuthClientSecret
s.OAuthScopes = newConfig.OAuthScopes
s.NoOAuth = newConfig.NoOAuth
return nil
}
+201
View File
@@ -1063,6 +1063,9 @@ type PrintBlockOpts struct {
type API struct {
// Event-specific registration functions (wired by the loader).
onToolCall func(func(ToolCallEvent, Context) *ToolCallResult)
onToolCallInputStart func(func(ToolCallInputStartEvent, Context))
onToolCallInputDelta func(func(ToolCallInputDeltaEvent, Context))
onToolCallInputEnd func(func(ToolCallInputEndEvent, Context))
onToolExecStart func(func(ToolExecutionStartEvent, Context))
onToolExecEnd func(func(ToolExecutionEndEvent, Context))
onToolOutput func(func(ToolOutputEvent, Context))
@@ -1091,6 +1094,14 @@ type API struct {
onSubagentStart func(func(SubagentStartEvent, Context))
onSubagentChunk func(func(SubagentChunkEvent, Context))
onSubagentEnd func(func(SubagentEndEvent, Context))
onStepStart func(func(StepStartEvent, Context))
onStepFinish func(func(StepFinishEvent, Context))
onReasoningStart func(func(ReasoningStartEvent, Context))
onWarnings func(func(WarningsEvent, Context))
onSource func(func(SourceEvent, Context))
onError func(func(ErrorEvent, Context))
onRetry func(func(RetryEvent, Context))
onPrepareStep func(func(PrepareStepEvent, Context) *PrepareStepResult)
}
// OnToolCall registers a handler that fires before a tool executes.
@@ -1099,6 +1110,26 @@ func (a *API) OnToolCall(handler func(ToolCallEvent, Context) *ToolCallResult) {
a.onToolCall(handler)
}
// OnToolCallInputStart registers a handler that fires when the LLM begins
// generating tool call arguments. The tool name is known but the full
// argument JSON is still being streamed. Useful for showing a "running"
// indicator immediately without waiting for the full arguments.
func (a *API) OnToolCallInputStart(handler func(ToolCallInputStartEvent, Context)) {
a.onToolCallInputStart(handler)
}
// OnToolCallInputDelta registers a handler that fires for each streamed
// fragment of tool call arguments as they arrive from the LLM.
func (a *API) OnToolCallInputDelta(handler func(ToolCallInputDeltaEvent, Context)) {
a.onToolCallInputDelta(handler)
}
// OnToolCallInputEnd registers a handler that fires when tool argument
// streaming is complete, before the tool call is parsed and execution begins.
func (a *API) OnToolCallInputEnd(handler func(ToolCallInputEndEvent, Context)) {
a.onToolCallInputEnd(handler)
}
// OnToolExecutionStart registers a handler for tool execution start.
func (a *API) OnToolExecutionStart(handler func(ToolExecutionStartEvent, Context)) {
a.onToolExecStart(handler)
@@ -1278,6 +1309,56 @@ func (a *API) OnBeforeCompact(handler func(BeforeCompactEvent, Context) *BeforeC
a.onBeforeCompact(handler)
}
// OnStepStart registers a handler that fires when a new LLM call begins
// within a multi-step agent turn.
func (a *API) OnStepStart(handler func(StepStartEvent, Context)) {
a.onStepStart(handler)
}
// OnStepFinish registers a handler that fires when a step completes,
// providing step number, finish reason, and decomposed token usage.
func (a *API) OnStepFinish(handler func(StepFinishEvent, Context)) {
a.onStepFinish(handler)
}
// OnReasoningStart registers a handler that fires when the LLM begins
// reasoning/thinking.
func (a *API) OnReasoningStart(handler func(ReasoningStartEvent, Context)) {
a.onReasoningStart(handler)
}
// OnWarnings registers a handler that fires when the LLM provider returns
// warnings about the request.
func (a *API) OnWarnings(handler func(WarningsEvent, Context)) {
a.onWarnings(handler)
}
// OnSource registers a handler that fires when the LLM references a source
// (e.g. from web search tools).
func (a *API) OnSource(handler func(SourceEvent, Context)) {
a.onSource(handler)
}
// OnError registers a handler that fires when an agent-level error occurs
// during streaming.
func (a *API) OnError(handler func(ErrorEvent, Context)) {
a.onError(handler)
}
// OnRetry registers a handler that fires when the LLM provider request is
// retried after a transient error.
func (a *API) OnRetry(handler func(RetryEvent, Context)) {
a.onRetry(handler)
}
// OnPrepareStep registers a handler that fires between steps within a
// multi-step agent turn, after steering messages are injected and before
// messages are sent to the LLM. Return a non-nil PrepareStepResult with
// Messages to replace the context window for this step.
func (a *API) OnPrepareStep(handler func(PrepareStepEvent, Context) *PrepareStepResult) {
a.onPrepareStep(handler)
}
// RegisterToolRenderer registers a custom renderer for a specific tool's
// display in the TUI. The renderer controls the header (parameter summary)
// and/or body (result display) of the tool's output block. If multiple
@@ -1890,6 +1971,34 @@ type ToolCallResult struct {
func (ToolCallResult) isResult() {}
// ToolCallInputStartEvent fires when the LLM begins generating tool call
// arguments. The tool name is known but the full argument JSON is still
// being streamed.
type ToolCallInputStartEvent struct {
ToolCallID string
ToolName string
ToolKind string // Tool classification: "execute", "edit", "read", "search", "agent"
}
func (e ToolCallInputStartEvent) Type() EventType { return ToolCallInputStart }
// ToolCallInputDeltaEvent fires for each streamed fragment of tool call
// arguments as they arrive from the LLM.
type ToolCallInputDeltaEvent struct {
ToolCallID string
Delta string // JSON fragment of tool arguments
}
func (e ToolCallInputDeltaEvent) Type() EventType { return ToolCallInputDelta }
// ToolCallInputEndEvent fires when tool argument streaming is complete,
// before the tool call is parsed and execution begins.
type ToolCallInputEndEvent struct {
ToolCallID string
}
func (e ToolCallInputEndEvent) Type() EventType { return ToolCallInputEnd }
// ToolExecutionStartEvent fires when a tool begins executing.
type ToolExecutionStartEvent struct {
ToolCallID string
@@ -2202,6 +2311,98 @@ type SubagentEndEvent struct {
func (e SubagentEndEvent) Type() EventType { return SubagentEnd }
// ---------------------------------------------------------------------------
// Step lifecycle events (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// StepStartEvent fires when a new LLM call begins within a multi-step agent turn.
type StepStartEvent struct {
StepNumber int
}
func (e StepStartEvent) Type() EventType { return StepStart }
// StepFinishEvent fires when a step completes, providing step metadata and
// token usage. Usage fields are plain int64 (not LLMUsage) because Yaegi
// cannot handle fantasy types across the interpreter boundary.
type StepFinishEvent struct {
StepNumber int
HasToolCalls bool
FinishReason string
InputTokens int64
OutputTokens int64
CacheReadTokens int64
CacheWriteTokens int64
}
func (e StepFinishEvent) Type() EventType { return StepFinish }
// ReasoningStartEvent fires when the LLM begins reasoning/thinking.
type ReasoningStartEvent struct {
ID string
}
func (e ReasoningStartEvent) Type() EventType { return ReasoningStart }
// WarningsEvent fires when the LLM provider returns warnings about the request.
type WarningsEvent struct {
Warnings []string
}
func (e WarningsEvent) Type() EventType { return Warnings }
// SourceEvent fires when the LLM references a source (e.g. from web search).
type SourceEvent struct {
SourceType string
ID string
URL string
Title string
}
func (e SourceEvent) Type() EventType { return Source }
// ErrorEvent fires when an agent-level error occurs during streaming.
// Uses string instead of error because Yaegi cannot handle the error
// interface reliably across the interpreter boundary.
type ErrorEvent struct {
Error string
}
func (e ErrorEvent) Type() EventType { return Error }
// RetryEvent fires when the LLM provider request is retried after a
// transient error.
type RetryEvent struct {
Attempt int
Error string
}
func (e RetryEvent) Type() EventType { return Retry }
// PrepareStepEvent fires between steps within a multi-step agent turn,
// after steering messages are injected and before messages are sent to
// the LLM. Handlers can inspect and replace the context window.
type PrepareStepEvent struct {
// StepNumber is the zero-based step index within the current turn.
StepNumber int
// Messages is the current context window that will be sent to the LLM.
Messages []ContextMessage
}
func (e PrepareStepEvent) Type() EventType { return PrepareStep }
// PrepareStepResult allows extensions to replace the context window between
// steps. Return nil Messages to leave the context unchanged.
type PrepareStepResult struct {
// Messages replaces the entire context window for this step. If nil,
// the original messages are used unchanged. Messages with a non-negative
// Index reuse the original message at that position; messages with
// Index < 0 are created fresh from Role + Content.
Messages []ContextMessage
}
func (PrepareStepResult) isResult() {}
// ThemeColor is an adaptive color pair with light and dark hex values.
// Either field may be empty to inherit from the default theme.
type ThemeColor struct {
+46 -1
View File
@@ -13,6 +13,19 @@ const (
// ToolCall fires before a tool executes. Handlers can block execution.
ToolCall EventType = "tool_call"
// ToolCallInputStart fires when the LLM begins generating tool call
// arguments. The tool name is known but the full argument JSON is still
// being streamed.
ToolCallInputStart EventType = "tool_call_input_start"
// ToolCallInputDelta fires for each streamed fragment of tool call
// arguments as they arrive from the LLM.
ToolCallInputDelta EventType = "tool_call_input_delta"
// ToolCallInputEnd fires when tool argument streaming is complete,
// before the tool call is parsed and execution begins.
ToolCallInputEnd EventType = "tool_call_input_end"
// ToolExecutionStart fires when a tool begins executing.
ToolExecutionStart EventType = "tool_execution_start"
@@ -83,18 +96,50 @@ const (
// SubagentEnd fires when a subagent tool call completes (success
// or error). Carries the final response and any error message.
SubagentEnd EventType = "subagent_end"
// StepStart fires when a new LLM call begins within a multi-step
// agent turn.
StepStart EventType = "step_start"
// StepFinish fires when a step completes, providing step number,
// finish reason, and token usage.
StepFinish EventType = "step_finish"
// ReasoningStart fires when the LLM begins reasoning/thinking.
ReasoningStart EventType = "reasoning_start"
// Warnings fires when the LLM provider returns warnings.
Warnings EventType = "warnings"
// Source fires when the LLM references a source (e.g. web search).
Source EventType = "source"
// Error fires when an agent-level error occurs during streaming.
Error EventType = "error"
// Retry fires when the LLM provider request is retried after a
// transient error.
Retry EventType = "retry"
// PrepareStep fires between steps within a multi-step agent turn,
// after steering messages are injected and before messages are sent
// to the LLM. Handlers can replace the context window for this step.
PrepareStep EventType = "prepare_step"
)
// AllEventTypes returns every supported event type.
func AllEventTypes() []EventType {
return []EventType{
ToolCall, ToolExecutionStart, ToolExecutionEnd, ToolResult,
ToolCall, ToolCallInputStart, ToolCallInputDelta, ToolCallInputEnd,
ToolExecutionStart, ToolExecutionEnd, ToolResult,
Input, BeforeAgentStart, AgentStart, AgentEnd,
MessageStart, MessageUpdate, MessageEnd,
SessionStart, SessionShutdown,
ModelChange, ContextPrepare,
BeforeFork, BeforeSessionSwitch, BeforeCompact,
SubagentStart, SubagentChunk, SubagentEnd,
StepStart, StepFinish, ReasoningStart, Warnings, Source, Error, Retry,
PrepareStep,
}
}
+5 -2
View File
@@ -4,8 +4,8 @@ import "testing"
func TestAllEventTypes_Count(t *testing.T) {
all := AllEventTypes()
if len(all) != 21 {
t.Fatalf("expected 21 event types, got %d", len(all))
if len(all) != 32 {
t.Fatalf("expected 32 event types, got %d", len(all))
}
}
@@ -38,6 +38,9 @@ func TestEventType_TypeMethod(t *testing.T) {
want EventType
}{
{ToolCallEvent{ToolName: "test"}, ToolCall},
{ToolCallInputStartEvent{ToolCallID: "x", ToolName: "test"}, ToolCallInputStart},
{ToolCallInputDeltaEvent{ToolCallID: "x", Delta: "{"}, ToolCallInputDelta},
{ToolCallInputEndEvent{ToolCallID: "x"}, ToolCallInputEnd},
{ToolExecutionStartEvent{ToolName: "test"}, ToolExecutionStart},
{ToolExecutionEndEvent{ToolName: "test"}, ToolExecutionEnd},
{ToolResultEvent{ToolName: "test"}, ToolResult},
+69
View File
@@ -429,6 +429,24 @@ func loadSingleExtension(path string) (*LoadedExtension, error) {
return *r
})
},
onToolCallInputStart: func(h func(ToolCallInputStartEvent, Context)) {
reg(ToolCallInputStart, func(e Event, c Context) Result {
h(e.(ToolCallInputStartEvent), c)
return nil
})
},
onToolCallInputDelta: func(h func(ToolCallInputDeltaEvent, Context)) {
reg(ToolCallInputDelta, func(e Event, c Context) Result {
h(e.(ToolCallInputDeltaEvent), c)
return nil
})
},
onToolCallInputEnd: func(h func(ToolCallInputEndEvent, Context)) {
reg(ToolCallInputEnd, func(e Event, c Context) Result {
h(e.(ToolCallInputEndEvent), c)
return nil
})
},
onToolExecStart: func(h func(ToolExecutionStartEvent, Context)) {
reg(ToolExecutionStart, func(e Event, c Context) Result {
h(e.(ToolExecutionStartEvent), c)
@@ -600,6 +618,57 @@ func loadSingleExtension(path string) (*LoadedExtension, error) {
return nil
})
},
onStepStart: func(h func(StepStartEvent, Context)) {
reg(StepStart, func(e Event, c Context) Result {
h(e.(StepStartEvent), c)
return nil
})
},
onStepFinish: func(h func(StepFinishEvent, Context)) {
reg(StepFinish, func(e Event, c Context) Result {
h(e.(StepFinishEvent), c)
return nil
})
},
onReasoningStart: func(h func(ReasoningStartEvent, Context)) {
reg(ReasoningStart, func(e Event, c Context) Result {
h(e.(ReasoningStartEvent), c)
return nil
})
},
onWarnings: func(h func(WarningsEvent, Context)) {
reg(Warnings, func(e Event, c Context) Result {
h(e.(WarningsEvent), c)
return nil
})
},
onSource: func(h func(SourceEvent, Context)) {
reg(Source, func(e Event, c Context) Result {
h(e.(SourceEvent), c)
return nil
})
},
onError: func(h func(ErrorEvent, Context)) {
reg(Error, func(e Event, c Context) Result {
h(e.(ErrorEvent), c)
return nil
})
},
onRetry: func(h func(RetryEvent, Context)) {
reg(Retry, func(e Event, c Context) Result {
h(e.(RetryEvent), c)
return nil
})
},
onPrepareStep: func(h func(PrepareStepEvent, Context) *PrepareStepResult) {
reg(PrepareStep, func(e Event, c Context) Result {
r := h(e.(PrepareStepEvent), c)
if r == nil {
return nil
}
return *r
})
},
}
// Call Init — the extension registers its handlers, tools, commands.
+14
View File
@@ -152,6 +152,9 @@ func Symbols() interp.Exports {
// Event structs
"ToolCallEvent": reflect.ValueOf((*ToolCallEvent)(nil)),
"ToolCallResult": reflect.ValueOf((*ToolCallResult)(nil)),
"ToolCallInputStartEvent": reflect.ValueOf((*ToolCallInputStartEvent)(nil)),
"ToolCallInputDeltaEvent": reflect.ValueOf((*ToolCallInputDeltaEvent)(nil)),
"ToolCallInputEndEvent": reflect.ValueOf((*ToolCallInputEndEvent)(nil)),
"ToolExecutionStartEvent": reflect.ValueOf((*ToolExecutionStartEvent)(nil)),
"ToolExecutionEndEvent": reflect.ValueOf((*ToolExecutionEndEvent)(nil)),
"ToolOutputEvent": reflect.ValueOf((*ToolOutputEvent)(nil)),
@@ -169,6 +172,17 @@ func Symbols() interp.Exports {
"SessionStartEvent": reflect.ValueOf((*SessionStartEvent)(nil)),
"SessionShutdownEvent": reflect.ValueOf((*SessionShutdownEvent)(nil)),
"ModelChangeEvent": reflect.ValueOf((*ModelChangeEvent)(nil)),
// Step lifecycle events
"StepStartEvent": reflect.ValueOf((*StepStartEvent)(nil)),
"StepFinishEvent": reflect.ValueOf((*StepFinishEvent)(nil)),
"ReasoningStartEvent": reflect.ValueOf((*ReasoningStartEvent)(nil)),
"WarningsEvent": reflect.ValueOf((*WarningsEvent)(nil)),
"SourceEvent": reflect.ValueOf((*SourceEvent)(nil)),
"ErrorEvent": reflect.ValueOf((*ErrorEvent)(nil)),
"RetryEvent": reflect.ValueOf((*RetryEvent)(nil)),
"PrepareStepEvent": reflect.ValueOf((*PrepareStepEvent)(nil)),
"PrepareStepResult": reflect.ValueOf((*PrepareStepResult)(nil)),
},
}
}
File diff suppressed because one or more lines are too long
+72 -6
View File
@@ -85,6 +85,7 @@ type ThinkingLevel string
const (
ThinkingOff ThinkingLevel = "off"
ThinkingNone ThinkingLevel = "none"
ThinkingMinimal ThinkingLevel = "minimal"
ThinkingLow ThinkingLevel = "low"
ThinkingMedium ThinkingLevel = "medium"
@@ -93,12 +94,14 @@ const (
// ThinkingLevels returns the ordered list of available thinking levels for cycling.
func ThinkingLevels() []ThinkingLevel {
return []ThinkingLevel{ThinkingOff, ThinkingMinimal, ThinkingLow, ThinkingMedium, ThinkingHigh}
return []ThinkingLevel{ThinkingOff, ThinkingNone, ThinkingMinimal, ThinkingLow, ThinkingMedium, ThinkingHigh}
}
// thinkingBudgetTokens returns the token budget for a thinking level, or 0 for "off".
// thinkingBudgetTokens returns the token budget for a thinking level, or 0 for "off" or "none".
func thinkingBudgetTokens(level ThinkingLevel) int64 {
switch level {
case ThinkingNone:
return 1024
case ThinkingMinimal:
return 1024
case ThinkingLow:
@@ -117,6 +120,8 @@ func ThinkingLevelDescription(level ThinkingLevel) string {
switch level {
case ThinkingOff:
return "No reasoning"
case ThinkingNone:
return "Minimal reasoning (OpenAI 'none')"
case ThinkingMinimal:
return "Very brief reasoning (~1k tokens)"
case ThinkingLow:
@@ -133,7 +138,7 @@ func ThinkingLevelDescription(level ThinkingLevel) string {
// ParseThinkingLevel converts a string to a ThinkingLevel, defaulting to ThinkingOff.
func ParseThinkingLevel(s string) ThinkingLevel {
switch ThinkingLevel(s) {
case ThinkingMinimal, ThinkingLow, ThinkingMedium, ThinkingHigh:
case ThinkingNone, ThinkingMinimal, ThinkingLow, ThinkingMedium, ThinkingHigh:
return ThinkingLevel(s)
default:
return ThinkingOff
@@ -300,9 +305,18 @@ func CreateProvider(ctx context.Context, config *ProviderConfig) (*ProviderResul
// Only add cache options for providers that don't already have
// options set, to avoid type conflicts (e.g., Anthropic has
// different types for regular options vs cache control options).
for k, v := range cacheOpts {
if _, exists := result.ProviderOptions[k]; !exists {
result.ProviderOptions[k] = v
//
// For OpenAI Responses API models, we skip merging entirely because
// ResponsesProviderOptions and ProviderOptions are incompatible types.
skipMerge := false
if provider == "openai" && openai.IsResponsesModel(modelName) {
skipMerge = true
}
if !skipMerge {
for k, v := range cacheOpts {
if _, exists := result.ProviderOptions[k]; !exists {
result.ProviderOptions[k] = v
}
}
}
}
@@ -571,6 +585,8 @@ func buildOpenAIProviderOptions(config *ProviderConfig, modelName string) fantas
// Returns nil for ThinkingOff (use the model's default).
func thinkingLevelToReasoningEffort(level ThinkingLevel) *openai.ReasoningEffort {
switch level {
case ThinkingNone:
return new(openai.ReasoningEffortNone)
case ThinkingMinimal:
return new(openai.ReasoningEffortMinimal)
case ThinkingLow:
@@ -584,6 +600,56 @@ func thinkingLevelToReasoningEffort(level ThinkingLevel) *openai.ReasoningEffort
}
}
// IsValidThinkingLevelForModel checks if a thinking level is valid for the given
// model. Some OpenAI models like gpt-5.4 don't support "minimal" and require
// "none" instead.
func IsValidThinkingLevelForModel(level ThinkingLevel, modelName string) bool {
if level == ThinkingOff {
return true
}
// Check if this is an OpenAI model that doesn't support "minimal"
// gpt-5.4 and newer gpt-5.x models use "none" instead of "minimal"
if level == ThinkingMinimal {
if strings.Contains(modelName, "gpt-5.4") ||
strings.Contains(modelName, "gpt-5-pro") ||
strings.Contains(modelName, "gpt-5-chat") {
return false
}
}
// Check if this is an OpenAI model that doesn't support "none"
// Older gpt-5 models only support "minimal", not "none"
if level == ThinkingNone {
if strings.Contains(modelName, "gpt-5") &&
!strings.Contains(modelName, "gpt-5.4") &&
!strings.Contains(modelName, "gpt-5-pro") &&
!strings.Contains(modelName, "gpt-5-chat") {
// Older gpt-5 models might not support "none"
// They only added "none" support in newer versions
return false
}
}
// All other levels are generally valid for reasoning models
return true
}
// SuggestThinkingLevelFallback returns a recommended fallback level when the
// requested level is not valid for the model. Returns ThinkingOff if no
// suitable fallback exists.
func SuggestThinkingLevelFallback(level ThinkingLevel, modelName string) ThinkingLevel {
if level == ThinkingMinimal && !IsValidThinkingLevelForModel(level, modelName) {
// For models that don't support "minimal", suggest "none" (~same token budget)
return ThinkingNone
}
if level == ThinkingNone && !IsValidThinkingLevelForModel(level, modelName) {
// For models that don't support "none", suggest "minimal" (~same token budget)
return ThinkingMinimal
}
return ThinkingOff
}
// 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
+66
View File
@@ -0,0 +1,66 @@
package session
import (
"testing"
"github.com/mark3labs/kit/internal/message"
)
// TestCompactionParentCycleRegression tests that after multiple compactions,
// newly appended messages always have a valid parent chain and BuildContext
// returns the correct messages.
func TestCompactionParentCycleRegression(t *testing.T) {
tm := InMemoryTreeSession("/test")
// Simulate a long conversation with multiple compactions.
msg1, _ := tm.AppendMessage(message.Message{Role: message.RoleUser, Parts: []message.ContentPart{message.TextContent{Text: "msg1"}}})
msg2, _ := tm.AppendMessage(message.Message{Role: message.RoleAssistant, Parts: []message.ContentPart{message.TextContent{Text: "msg2"}}})
// First compaction
comp1, _ := tm.AppendCompaction("Summary 1", msg1, 1000, 500, 1, []string{}, []string{})
msg3, _ := tm.AppendMessage(message.Message{Role: message.RoleUser, Parts: []message.ContentPart{message.TextContent{Text: "msg3"}}})
msg4, _ := tm.AppendMessage(message.Message{Role: message.RoleAssistant, Parts: []message.ContentPart{message.TextContent{Text: "msg4"}}})
// Second compaction
comp2, _ := tm.AppendCompaction("Summary 2", msg3, 1000, 500, 1, []string{}, []string{})
msg5, _ := tm.AppendMessage(message.Message{Role: message.RoleUser, Parts: []message.ContentPart{message.TextContent{Text: "msg5"}}})
msg6, _ := tm.AppendMessage(message.Message{Role: message.RoleAssistant, Parts: []message.ContentPart{message.TextContent{Text: "msg6"}}})
// Verify parent chain integrity
for _, id := range []string{msg1, msg2, comp1, msg3, msg4, comp2, msg5, msg6} {
entry := tm.GetEntry(id)
if entry == nil {
t.Fatalf("entry %s not found in index", id)
}
}
// Walk parent chain from msg6 — must reach root without cycles
visited := make(map[string]bool)
current := msg6
for current != "" {
if visited[current] {
t.Fatalf("cycle detected at entry %s", current)
}
visited[current] = true
entry := tm.GetEntry(current)
if entry == nil {
t.Fatalf("entry %s missing from index during parent walk", current)
}
parent := ""
switch e := entry.(type) {
case *MessageEntry:
parent = e.ParentID
case *CompactionEntry:
parent = e.ParentID
}
current = parent
}
// BuildContext should return: Summary2 + msg6 + msg5 + msg3 + msg4 = 5 messages
msgs, _, _ := tm.BuildContext()
if len(msgs) != 5 {
t.Fatalf("expected 5 messages, got %d: %+v", len(msgs), msgs)
}
}
+109
View File
@@ -0,0 +1,109 @@
package session
import (
"testing"
"github.com/mark3labs/kit/internal/message"
)
// TestDetectCycleWithCorruptedParentChain tests that cycle detection works
// when a corrupted session has circular parent references.
func TestDetectCycleWithCorruptedParentChain(t *testing.T) {
tm := InMemoryTreeSession("/test")
// Create normal chain: msg1 -> msg2 -> msg3
id1, _ := tm.AppendMessage(message.Message{Role: message.RoleUser, Parts: []message.ContentPart{message.TextContent{Text: "msg1"}}})
_, _ = tm.AppendMessage(message.Message{Role: message.RoleAssistant, Parts: []message.ContentPart{message.TextContent{Text: "msg2"}}})
id3, _ := tm.AppendMessage(message.Message{Role: message.RoleUser, Parts: []message.ContentPart{message.TextContent{Text: "msg3"}}})
// Simulate corruption: manually set msg1's parent to msg3, creating cycle
// This simulates the condition seen in the user's session
for _, entry := range tm.entries {
if e, ok := entry.(*MessageEntry); ok && e.ID == id1 {
e.ParentID = id3 // Create cycle: msg1 -> msg3 -> ... -> msg1
break
}
}
// DetectCycle should find the cycle
// The cycle is: id1 -> id3 -> id2 -> id1
// So detecting from id3 should find id1 as the repeat
cycle, entry := tm.DetectCycle(id3)
if !cycle {
t.Fatal("expected to detect cycle, but none found")
}
// The cycle entry could be id1 or id3 depending on where we start
if entry != id1 && entry != id3 {
t.Fatalf("expected cycle at %s or %s, got %s", id1, id3, entry)
}
// BuildContext should still work (it has its own cycle detection)
// but will truncate at the cycle point
msgs, _, _ := tm.BuildContext()
if len(msgs) == 0 {
t.Fatal("BuildContext returned no messages")
}
}
// TestAppendMessageRejectsInvalidParent tests that AppendMessage rejects
// appending when the current leaf has a broken parent chain.
func TestAppendMessageRejectsInvalidParent(t *testing.T) {
tm := InMemoryTreeSession("/test")
// Create normal message
id1, err := tm.AppendMessage(message.Message{Role: message.RoleUser, Parts: []message.ContentPart{message.TextContent{Text: "msg1"}}})
if err != nil {
t.Fatalf("failed to append msg1: %v", err)
}
// Simulate corruption: set leafID to a non-existent ID
tm.leafID = "non-existent-id"
// Next append should fail validation
_, err = tm.AppendMessage(message.Message{Role: message.RoleAssistant, Parts: []message.ContentPart{message.TextContent{Text: "msg2"}}})
if err == nil {
t.Fatal("expected error when appending with invalid leafID, got nil")
}
// Restore valid leafID
tm.leafID = id1
// Append should succeed now
_, err = tm.AppendMessage(message.Message{Role: message.RoleAssistant, Parts: []message.ContentPart{message.TextContent{Text: "msg3"}}})
if err != nil {
t.Fatalf("failed to append msg3 after restoring leafID: %v", err)
}
}
// TestBuildContextHandlesCycleGracefully tests that BuildContext handles
// cycles gracefully by truncating the branch.
func TestBuildContextHandlesCycleGracefully(t *testing.T) {
tm := InMemoryTreeSession("/test")
// Create messages
id1, _ := tm.AppendMessage(message.Message{Role: message.RoleUser, Parts: []message.ContentPart{message.TextContent{Text: "msg1"}}})
_, _ = tm.AppendMessage(message.Message{Role: message.RoleAssistant, Parts: []message.ContentPart{message.TextContent{Text: "msg2"}}})
id3, _ := tm.AppendMessage(message.Message{Role: message.RoleUser, Parts: []message.ContentPart{message.TextContent{Text: "msg3"}}})
// Verify normal case works
msgs, _, _ := tm.BuildContext()
if len(msgs) != 3 {
t.Fatalf("expected 3 messages, got %d", len(msgs))
}
// Simulate cycle: set msg1's parent to msg3
for _, entry := range tm.entries {
if e, ok := entry.(*MessageEntry); ok && e.ID == id1 {
e.ParentID = id3
break
}
}
// BuildContext should handle cycle gracefully (getBranchLocked has cycle detection)
msgs, _, _ = tm.BuildContext()
// Should only include messages from the cycle: msg3, msg2, msg1
// (msg3 is leaf, walks to msg2 -> msg1 -> msg3 (cycle detected, stops))
if len(msgs) != 3 {
t.Fatalf("expected 3 messages in cycle case, got %d: %+v", len(msgs), msgs)
}
}
+117 -5
View File
@@ -63,6 +63,11 @@ type TreeManager struct {
// file is the open file handle for appending entries. Nil for in-memory.
file *os.File
// writer is a buffered writer wrapping file. Writes go through this
// buffer and are flushed to disk at explicit sync points (after each
// public Append* call, in Close, etc.) to reduce syscall overhead.
writer *bufio.Writer
}
// --- Constructors ---
@@ -105,11 +110,16 @@ func CreateTreeSession(cwd string) (*TreeManager, error) {
return nil, fmt.Errorf("failed to create session file: %w", err)
}
tm.file = f
tm.writer = bufio.NewWriter(f)
if err := tm.writeEntry(&header); err != nil {
_ = f.Close()
return nil, fmt.Errorf("failed to write session header: %w", err)
}
if err := tm.flushLocked(); err != nil {
_ = f.Close()
return nil, fmt.Errorf("failed to flush session header: %w", err)
}
return tm, nil
}
@@ -150,6 +160,7 @@ func (tm *TreeManager) ForkToNewSession(cwd string, targetID string) (*TreeManag
return nil, fmt.Errorf("failed to recreate session file: %w", err)
}
newTm.file = f
newTm.writer = bufio.NewWriter(f)
if err := newTm.writeEntry(&newTm.header); err != nil {
_ = f.Close()
@@ -289,6 +300,12 @@ func (tm *TreeManager) ForkToNewSession(cwd string, targetID string) (*TreeManag
}
}
// Flush all buffered writes from the fork in a single syscall.
if err := newTm.flushLocked(); err != nil {
_ = f.Close()
return nil, fmt.Errorf("failed to flush forked session: %w", err)
}
// Set the leaf to the last entry in the new session.
newTm.leafID = prevNewID
@@ -365,12 +382,16 @@ func OpenTreeSession(path string) (*TreeManager, error) {
tm.leafID = tm.EntryID(tm.entries[len(tm.entries)-1])
}
// Validate tree integrity and log diagnostics
tm.LogTreeDiagnostics()
// Open file for appending.
f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return nil, fmt.Errorf("failed to open session file for append: %w", err)
}
tm.file = f
tm.writer = bufio.NewWriter(f)
return tm, nil
}
@@ -410,6 +431,12 @@ func (tm *TreeManager) AppendMessage(msg message.Message) (string, error) {
tm.mu.Lock()
defer tm.mu.Unlock()
// Validate parent chain before appending to detect/prevent cycles
// that could be caused by external file corruption or race conditions.
if err := tm.validateParentChainLocked(tm.leafID, ""); err != nil {
return "", fmt.Errorf("parent chain validation failed: %w", err)
}
entry, err := NewMessageEntry(tm.leafID, msg)
if err != nil {
return "", err
@@ -418,6 +445,9 @@ func (tm *TreeManager) AppendMessage(msg message.Message) (string, error) {
if err := tm.appendAndPersist(entry); err != nil {
return "", err
}
if err := tm.flushLocked(); err != nil {
return "", fmt.Errorf("failed to flush message: %w", err)
}
tm.leafID = entry.ID
return entry.ID, nil
@@ -442,6 +472,9 @@ func (tm *TreeManager) AppendModelChange(provider, modelID string) (string, erro
if err := tm.appendAndPersist(entry); err != nil {
return "", err
}
if err := tm.flushLocked(); err != nil {
return "", fmt.Errorf("failed to flush model change: %w", err)
}
tm.leafID = entry.ID
return entry.ID, nil
@@ -456,6 +489,9 @@ func (tm *TreeManager) AppendBranchSummary(fromID, summary string) (string, erro
if err := tm.appendAndPersist(entry); err != nil {
return "", err
}
if err := tm.flushLocked(); err != nil {
return "", fmt.Errorf("failed to flush branch summary: %w", err)
}
tm.leafID = entry.ID
return entry.ID, nil
@@ -470,6 +506,9 @@ func (tm *TreeManager) AppendLabel(targetID, label string) (string, error) {
if err := tm.appendAndPersist(entry); err != nil {
return "", err
}
if err := tm.flushLocked(); err != nil {
return "", fmt.Errorf("failed to flush label: %w", err)
}
tm.labels[targetID] = label
tm.leafID = entry.ID
@@ -485,6 +524,9 @@ func (tm *TreeManager) AppendSessionInfo(name string) (string, error) {
if err := tm.appendAndPersist(entry); err != nil {
return "", err
}
if err := tm.flushLocked(); err != nil {
return "", fmt.Errorf("failed to flush session info: %w", err)
}
tm.sessionName = name
tm.leafID = entry.ID
@@ -501,6 +543,9 @@ func (tm *TreeManager) AppendExtensionData(extType, data string) (string, error)
if err := tm.appendAndPersist(entry); err != nil {
return "", err
}
if err := tm.flushLocked(); err != nil {
return "", fmt.Errorf("failed to flush extension data: %w", err)
}
tm.leafID = entry.ID
return entry.ID, nil
@@ -518,6 +563,13 @@ func (tm *TreeManager) AppendCompaction(summary, firstKeptEntryID string, tokens
tm.mu.Lock()
defer tm.mu.Unlock()
// Validate that firstKeptEntryID exists if provided
if firstKeptEntryID != "" {
if _, ok := tm.index[firstKeptEntryID]; !ok {
return "", fmt.Errorf("first kept entry %q does not exist", firstKeptEntryID)
}
}
// The compaction entry has no parent, making it a new "root" for the
// post-compaction branch. This ensures old compacted messages are not
// traversed when walking from the current leaf.
@@ -525,6 +577,9 @@ func (tm *TreeManager) AppendCompaction(summary, firstKeptEntryID string, tokens
if err := tm.appendAndPersist(entry); err != nil {
return "", err
}
if err := tm.flushLocked(); err != nil {
return "", fmt.Errorf("failed to flush compaction: %w", err)
}
tm.leafID = entry.ID
return entry.ID, nil
@@ -910,11 +965,31 @@ func (tm *TreeManager) IsEmpty() bool {
return tm.MessageCount() == 0
}
// Close closes the underlying file handle.
// Flush writes any buffered data to the underlying file.
func (tm *TreeManager) Flush() error {
tm.mu.Lock()
defer tm.mu.Unlock()
return tm.flushLocked()
}
// flushLocked writes buffered data to disk. Caller must hold the lock.
func (tm *TreeManager) flushLocked() error {
if tm.writer != nil {
return tm.writer.Flush()
}
return nil
}
// Close flushes any buffered writes and closes the underlying file handle.
func (tm *TreeManager) Close() error {
tm.mu.Lock()
defer tm.mu.Unlock()
if tm.file != nil {
// Flush buffered data before closing.
if tm.writer != nil {
_ = tm.writer.Flush()
tm.writer = nil
}
err := tm.file.Close()
tm.file = nil
return err
@@ -1074,13 +1149,22 @@ func (tm *TreeManager) GetLastCompaction() *CompactionEntry {
// AddLLMMessages appends multiple LLM messages as entries. This is
// used when syncing from the agent's ConversationMessages after a step.
// All entries are buffered and flushed to disk in a single batch.
func (tm *TreeManager) AddLLMMessages(msgs []fantasy.Message) error {
tm.mu.Lock()
defer tm.mu.Unlock()
for _, msg := range msgs {
if _, err := tm.AppendLLMMessage(msg); err != nil {
entry, err := NewMessageEntry(tm.leafID, message.FromLLMMessage(msg))
if err != nil {
return err
}
if err := tm.appendAndPersist(entry); err != nil {
return err
}
tm.leafID = entry.ID
}
return nil
return tm.flushLocked()
}
// Deprecated: Use AddLLMMessages instead.
@@ -1132,12 +1216,20 @@ func (tm *TreeManager) appendAndPersist(entry any) error {
return nil
}
// writeEntry serializes an entry and appends it as a line to the file.
// writeEntry serializes an entry and appends it to the buffered writer.
// The data is not flushed to disk until flushLocked is called.
func (tm *TreeManager) writeEntry(entry any) error {
data, err := json.Marshal(entry)
if err != nil {
return fmt.Errorf("failed to marshal entry: %w", err)
}
if tm.writer != nil {
if _, err := tm.writer.Write(data); err != nil {
return err
}
return tm.writer.WriteByte('\n')
}
// Fallback for direct file writes (shouldn't happen in normal flow).
data = append(data, '\n')
_, err = tm.file.Write(data)
return err
@@ -1213,12 +1305,32 @@ func (tm *TreeManager) getBranchLocked(fromID string) []any {
}
// buildTreeNode recursively builds a TreeNode from an entry ID.
// It includes a depth limit to prevent infinite recursion in case of
// corrupted parent-child relationships.
func (tm *TreeManager) buildTreeNode(id string) *TreeNode {
return tm.buildTreeNodeDepth(id, 0, make(map[string]bool))
}
// buildTreeNodeDepth is the internal implementation with depth tracking.
func (tm *TreeManager) buildTreeNodeDepth(id string, depth int, visited map[string]bool) *TreeNode {
const maxDepth = 1000
if depth > maxDepth {
// Cycle or extremely deep tree detected, stop recursing
return nil
}
if visited[id] {
// Cycle detected, stop recursing
return nil
}
entry, ok := tm.index[id]
if !ok {
return nil
}
visited[id] = true
defer delete(visited, id)
node := &TreeNode{
Entry: entry,
ID: id,
@@ -1226,7 +1338,7 @@ func (tm *TreeManager) buildTreeNode(id string) *TreeNode {
}
for _, childID := range tm.childIndex[id] {
child := tm.buildTreeNode(childID)
child := tm.buildTreeNodeDepth(childID, depth+1, visited)
if child != nil {
node.Children = append(node.Children, child)
}
+143
View File
@@ -0,0 +1,143 @@
package session
import (
"fmt"
"log"
)
// ValidateParentChain checks that the parent ID points to an existing entry
// and that appending this entry would not create a cycle. This should be called
// before appending any entry to the tree.
// Returns an error if the parent is invalid or would create a cycle.
func (tm *TreeManager) ValidateParentChain(parentID string, newEntryID string) error {
if parentID == "" {
// Empty parent is valid (root entry)
return nil
}
// Check that parent exists
if _, ok := tm.index[parentID]; !ok {
return fmt.Errorf("parent entry %q does not exist in index", parentID)
}
// Check that we're not creating a cycle by walking up the parent chain
// from parentID and ensuring we don't hit newEntryID (or any node that
// has newEntryID as an ancestor, but since newEntryID is new, just check
// that parentID isn't newEntryID, which it can't be since we check existence)
visited := make(map[string]bool)
current := parentID
for current != "" {
if visited[current] {
return fmt.Errorf("existing cycle detected at entry %q", current)
}
visited[current] = true
// Safety check: if somehow we reach the new entry ID, that's a cycle
if current == newEntryID {
return fmt.Errorf("would create cycle: entry %q cannot be its own ancestor", newEntryID)
}
entry, ok := tm.index[current]
if !ok {
return fmt.Errorf("broken parent chain: entry %q not found", current)
}
current = tm.entryParentID(entry)
}
return nil
}
// DetectCycle walks the parent chain from the given entry ID and returns true
// if a cycle is detected. This is used for diagnostics.
func (tm *TreeManager) DetectCycle(fromID string) (cycleDetected bool, cycleEntry string) {
visited := make(map[string]bool)
current := fromID
for current != "" {
if visited[current] {
return true, current
}
visited[current] = true
entry, ok := tm.index[current]
if !ok {
return false, ""
}
current = tm.entryParentID(entry)
}
return false, ""
}
// LogTreeDiagnostics logs information about the tree structure for debugging.
// Call this after OpenTreeSession or when anomalies are detected.
func (tm *TreeManager) LogTreeDiagnostics() {
tm.mu.RLock()
defer tm.mu.RUnlock()
log.Printf("[TreeManager] Entry count: %d, Leaf ID: %s", len(tm.entries), tm.leafID)
// Check for cycles from leaf
if tm.leafID != "" {
if cycle, entry := tm.detectCycleLocked(tm.leafID); cycle {
log.Printf("[TreeManager] WARNING: Cycle detected in tree at entry %s", entry)
}
}
// Count entries by type
counts := make(map[EntryType]int)
for _, entry := range tm.entries {
var et EntryType
switch e := entry.(type) {
case *MessageEntry:
et = e.Type
case *ModelChangeEntry:
et = e.Type
case *BranchSummaryEntry:
et = e.Type
case *LabelEntry:
et = e.Type
case *SessionInfoEntry:
et = e.Type
case *ExtensionDataEntry:
et = e.Type
case *CompactionEntry:
et = e.Type
default:
et = "unknown"
}
counts[et]++
}
log.Printf("[TreeManager] Entry types: %+v", counts)
}
// detectCycleLocked is the internal version of DetectCycle (must hold read lock)
func (tm *TreeManager) detectCycleLocked(fromID string) (bool, string) {
visited := make(map[string]bool)
current := fromID
for current != "" {
if visited[current] {
return true, current
}
visited[current] = true
entry, ok := tm.index[current]
if !ok {
return false, ""
}
current = tm.entryParentID(entry)
}
return false, ""
}
// validateParentChainLocked is the internal version used by append methods.
// Must be called with the write lock held.
func (tm *TreeManager) validateParentChainLocked(parentID string, newEntryID string) error {
if parentID == "" {
return nil
}
if _, ok := tm.index[parentID]; !ok {
return fmt.Errorf("parent entry %q does not exist", parentID)
}
// Check for existing cycles in the parent chain
if cycle, entry := tm.detectCycleLocked(parentID); cycle {
return fmt.Errorf("existing cycle detected at entry %q in parent chain", entry)
}
return nil
}
+12 -12
View File
@@ -243,10 +243,12 @@ func (p *MCPConnectionPool) performHealthCheck(ctx context.Context, conn *MCPCon
// createConnection creates a new connection
func (p *MCPConnectionPool) createConnection(ctx context.Context, serverName string, serverConfig config.MCPServerConfig) (*MCPConnection, error) {
oauthEnabled := p.oauthFlow != nil && !serverConfig.NoOAuth
mcpClient, err := p.createMCPClient(ctx, serverName, serverConfig)
if err != nil {
// SSE transport can return OAuth error during Start()
if p.oauthFlow != nil && IsOAuthError(err) {
if oauthEnabled && IsOAuthError(err) {
if flowErr := p.oauthFlow.RunAuthFlow(ctx, serverName, err); flowErr != nil {
return nil, fmt.Errorf("OAuth authorization failed: %w", flowErr)
}
@@ -262,7 +264,7 @@ func (p *MCPConnectionPool) createConnection(ctx context.Context, serverName str
if err := p.initializeClient(ctx, mcpClient); err != nil {
// Streamable HTTP transport returns OAuth error during Initialize()
if p.oauthFlow != nil && IsOAuthError(err) {
if oauthEnabled && IsOAuthError(err) {
if flowErr := p.oauthFlow.RunAuthFlow(ctx, serverName, err); flowErr != nil {
_ = mcpClient.Close()
return nil, fmt.Errorf("OAuth authorization failed: %w", flowErr)
@@ -363,11 +365,11 @@ func (p *MCPConnectionPool) createSSEClient(ctx context.Context, serverConfig co
}
}
// Enable OAuth for remote transports when an auth handler is configured.
// The OAuthConfig uses PKCE and the handler's redirect URI. If the server
// config provides a pre-registered ClientID (for servers that don't support
// dynamic client registration, e.g. GitHub), it is passed through directly.
if p.oauthFlow != nil {
// Enable OAuth for remote transports when an auth handler is configured
// and the server hasn't opted out via NoOAuth. Public MCP servers (e.g.
// PubMed) set NoOAuth to skip dynamic client registration and token
// exchange, which would otherwise fail with a 404.
if p.oauthFlow != nil && !serverConfig.NoOAuth {
tokenStore, tsErr := p.createTokenStore(serverConfig.URL)
if tsErr != nil {
return nil, fmt.Errorf("failed to create token store: %w", tsErr)
@@ -420,11 +422,9 @@ func (p *MCPConnectionPool) createStreamableClient(ctx context.Context, serverCo
}
}
// Enable OAuth for remote transports when an auth handler is configured.
// The OAuthConfig uses PKCE and the handler's redirect URI. If the server
// config provides a pre-registered ClientID (for servers that don't support
// dynamic client registration, e.g. GitHub), it is passed through directly.
if p.oauthFlow != nil {
// Enable OAuth for remote transports when an auth handler is configured
// and the server hasn't opted out via NoOAuth.
if p.oauthFlow != nil && !serverConfig.NoOAuth {
tokenStore, tsErr := p.createTokenStore(serverConfig.URL)
if tsErr != nil {
return nil, fmt.Errorf("failed to create token store: %w", tsErr)
+1 -1
View File
@@ -19,7 +19,7 @@ import (
// newTestInput creates an InputComponent with the given AppController (may be nil).
func newTestInput(ctrl AppController) *InputComponent {
return NewInputComponent(80, "test input", ctrl)
return NewInputComponent(80, ctrl)
}
// sendInputMsg calls component.Update with the given message, returns the
+1 -1
View File
@@ -84,7 +84,7 @@ var SlashCommands = []SlashCommand{
},
{
Name: "/thinking",
Description: "Set thinking/reasoning level (off, minimal, low, medium, high)",
Description: "Set thinking/reasoning level (off, none, minimal, low, medium, high)",
Category: "System",
Aliases: []string{"/think"},
Complete: func(prefix string) []string {
+5
View File
@@ -25,6 +25,11 @@ type SubmitMsg struct {
// presses ESC a second time, the canceling state is reset to false.
type CancelTimerExpiredMsg struct{}
// CtrlCResetMsg is sent after a short delay when the user presses Ctrl+C to
// clear input. If the user doesn't press Ctrl+C again within the timeout,
// the ctrlCPressedOnce flag is reset so the next Ctrl+C will clear again.
type CtrlCResetMsg struct{}
// --- Tree session events ---
// TreeNodeSelectedMsg is sent when the user selects a node in the tree selector.
+48 -1
View File
@@ -6,6 +6,8 @@ import (
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// FileSuggestion represents a single file, directory, or MCP resource
@@ -31,6 +33,51 @@ type FileSuggestion struct {
// maxFileSuggestions is the maximum number of file suggestions returned.
const maxFileSuggestions = 20
// fileListCache caches the result of listFiles() keyed by directory to avoid
// re-running git subprocesses on every keystroke during @file completion.
var fileListCache struct {
mu sync.Mutex
dir string // searchDir that produced the cached entries
cwd string // cwd used for the git query
entries []FileSuggestion // cached file list
expireAt time.Time // when the cache entry expires
}
// fileListCacheTTL controls how long a cached file list stays valid.
// During rapid typing the list is reused; after the TTL a fresh git
// ls-files is executed so newly created files become visible.
const fileListCacheTTL = 3 * time.Second
// getCachedFileList returns the file list for searchDir, using a short-lived
// cache to avoid repeated subprocess calls during @file autocompletion.
func getCachedFileList(searchDir, cwd string) []FileSuggestion {
fileListCache.mu.Lock()
defer fileListCache.mu.Unlock()
now := time.Now()
if fileListCache.dir == searchDir &&
fileListCache.cwd == cwd &&
now.Before(fileListCache.expireAt) {
// Return a copy so callers can mutate (e.g. prepend baseDir).
cp := make([]FileSuggestion, len(fileListCache.entries))
copy(cp, fileListCache.entries)
return cp
}
// Cache miss or expired — run the real (potentially expensive) lookup.
files := listFiles(searchDir, cwd)
fileListCache.dir = searchDir
fileListCache.cwd = cwd
fileListCache.entries = files
fileListCache.expireAt = now.Add(fileListCacheTTL)
// Return a copy.
cp := make([]FileSuggestion, len(files))
copy(cp, files)
return cp
}
// ExtractAtPrefix checks the current line for an @-file trigger at cursorCol.
// It returns:
// - hasAt: true if a valid @ trigger was found
@@ -99,7 +146,7 @@ func GetFileSuggestions(prefix string, cwd string) []FileSuggestion {
}
}
files := listFiles(searchDir, cwd)
files := getCachedFileList(searchDir, cwd)
if len(files) == 0 {
return nil
}
+1
View File
@@ -17,6 +17,7 @@ type Renderer interface {
RenderReasoningBlock(content string, timestamp time.Time) UIMessage
RenderToolMessage(toolName, toolArgs, toolResult string, isError bool) UIMessage
RenderSystemMessage(content string, timestamp time.Time) UIMessage
RenderCustomMessage(content, label string, timestamp time.Time) UIMessage
RenderErrorMessage(errorMsg string, timestamp time.Time) UIMessage
RenderDebugMessage(message string, timestamp time.Time) UIMessage
RenderDebugConfigMessage(config map[string]any, timestamp time.Time) UIMessage
+23 -15
View File
@@ -40,7 +40,6 @@ type InputComponent struct {
width int
lastValue string
popupHeight int
title string
submitNext bool // defer submit one tick so popup dismisses cleanly
// Argument completion state. When the user types "/cmd " followed by
@@ -106,17 +105,17 @@ type clipboardImageMsg struct {
err error
}
// NewInputComponent creates a new InputComponent with the given width, title,
// and optional AppController. If appCtrl is nil the component still works but
// NewInputComponent creates a new InputComponent with the given width and
// optional AppController. If appCtrl is nil the component still works but
// /clear and /clear-queue are no-ops.
func NewInputComponent(width int, title string, appCtrl AppController) *InputComponent {
func NewInputComponent(width int, appCtrl AppController) *InputComponent {
ta := textarea.New()
ta.Placeholder = "Type your message..."
ta.ShowLineNumbers = false
ta.Prompt = ""
ta.CharLimit = 0
ta.SetWidth(width - 8) // Account for container padding, border and internal padding
ta.SetHeight(3) // Default to 3 lines like huh
ta.SetHeight(4) // 4 lines for comfortable multi-line input
ta.Focus()
// Override InsertNewline so only ctrl+j and shift+enter insert newlines.
@@ -141,8 +140,8 @@ func NewInputComponent(width int, title string, appCtrl AppController) *InputCom
commands: commands.SlashCommands,
width: width,
popupHeight: 7,
title: title,
appCtrl: appCtrl,
hideHint: true,
}
}
@@ -520,19 +519,13 @@ func (s *InputComponent) resetHistoryBrowsing() {
s.savedInput = ""
}
// View implements tea.Model. Renders the title, textarea, autocomplete popup
// View implements tea.Model. Renders the textarea, autocomplete popup
// (if visible), and help text.
func (s *InputComponent) View() tea.View {
containerStyle := lipgloss.NewStyle()
theme := style.GetTheme()
// PaddingLeft(3) aligns with message content: border(1) + paddingLeft(2).
titleStyle := lipgloss.NewStyle().
Foreground(theme.Text).
MarginBottom(1).
PaddingLeft(3)
inputBoxStyle := lipgloss.NewStyle().
Border(lipgloss.ThickBorder()).
BorderLeft(true).
@@ -540,12 +533,12 @@ func (s *InputComponent) View() tea.View {
BorderTop(false).
BorderBottom(false).
BorderForeground(theme.Primary).
MarginTop(1).
MarginBottom(1).
PaddingLeft(2). // match message block paddingLeft
Width(s.width - 1) // full width minus left border
var view strings.Builder
view.WriteString(titleStyle.Render(s.title))
view.WriteString("\n")
view.WriteString(inputBoxStyle.Render(s.textarea.View()))
// Popup is now rendered as a centered overlay in AppModel.View()
@@ -859,6 +852,21 @@ func (s *InputComponent) PendingImageCount() int {
return len(s.pendingImages)
}
// Clear clears the textarea content and resets related state. Returns true if
// there was content to clear, false if the input was already empty.
func (s *InputComponent) Clear() bool {
hadContent := s.textarea.Value() != ""
s.textarea.SetValue("")
s.textarea.CursorEnd()
s.lastValue = ""
s.showPopup = false
s.argMode = false
s.fileMode = false
s.browsingHistory = false
s.savedInput = ""
return hadContent
}
// applyFileCompletion replaces the @prefix in the textarea with the selected
// file or MCP resource suggestion. For directories, it keeps the popup open
// for further drilling. For files and resources, it closes the popup and adds
+6 -8
View File
@@ -109,8 +109,8 @@ func (m *TextMessageItem) renderContent(width int) string {
// It accumulates content chunks and re-renders on each update for live display.
type StreamingMessageItem struct {
id string
role string // "assistant" or "reasoning"
content string // Accumulated streaming content
role string // "assistant" or "reasoning"
content strings.Builder // Accumulated streaming content
timestamp time.Time
startTime time.Time // When streaming started (for live duration counter)
modelName string
@@ -156,10 +156,10 @@ func (s *StreamingMessageItem) Render(width int) string {
durationMs = time.Since(s.startTime).Milliseconds()
}
ty := createTypography(style.GetTheme())
rendered = render.ReasoningBlock(s.content, durationMs, ty, style.GetTheme())
rendered = render.ReasoningBlock(s.content.String(), durationMs, width, ty, style.GetTheme())
} else {
// Render as assistant message
rendered = render.AssistantBlock(s.content, width, style.GetTheme())
rendered = render.AssistantBlock(s.content.String(), width, style.GetTheme())
}
// Cache and return (but reasoning is never cached due to live duration)
@@ -187,7 +187,7 @@ func (s *StreamingMessageItem) Height() int {
// AppendChunk adds a content chunk and invalidates the render cache.
func (s *StreamingMessageItem) AppendChunk(chunk string) {
s.content += chunk
s.content.WriteString(chunk)
s.cachedWidth = 0 // Invalidate cache
}
@@ -243,9 +243,7 @@ func (m *StreamingBashOutputItem) Render(width int) string {
// Header with command
if m.command != "" {
headerStyle := lipgloss.NewStyle().
Foreground(theme.Muted).
Italic(true)
headerStyle := style.GetCachedStyles().BashHeader
parts = append(parts, headerStyle.Render(fmt.Sprintf("▸ %s", m.command)))
}
+36 -5
View File
@@ -150,9 +150,26 @@ func (r *MessageRenderer) SetWidth(width int) {
r.width = width
}
// RenderUserMessage renders a user's input message using herald Tip alert
// RenderUserMessage renders a user's input message with a colored left border.
func (r *MessageRenderer) RenderUserMessage(content string, timestamp time.Time) UIMessage {
rendered := render.UserBlock(content, r.width, r.ty, style.GetTheme())
if strings.TrimSpace(content) == "" {
content = "(empty message)"
}
theme := style.GetTheme()
// Highlight @file tokens with accent color.
content = render.HighlightFileTokens(content, theme)
rendered := renderContentBlock(
content,
r.width,
WithAlign(lipgloss.Left),
WithBorderColor(theme.Success),
WithPaddingTop(0),
WithPaddingBottom(0),
WithMarginBottom(1),
)
return UIMessage{
Type: UserMessage,
@@ -178,7 +195,7 @@ func (r *MessageRenderer) RenderAssistantMessage(content string, timestamp time.
// as live streaming: muted italic text with margin. This is used when resuming
// sessions to display saved reasoning content.
func (r *MessageRenderer) RenderReasoningBlock(content string, timestamp time.Time) UIMessage {
rendered := render.ReasoningBlock(content, 0, r.ty, style.GetTheme())
rendered := render.ReasoningBlock(content, 0, r.width, r.ty, style.GetTheme())
return UIMessage{
Type: AssistantMessage,
@@ -200,6 +217,19 @@ func (r *MessageRenderer) RenderSystemMessage(content string, timestamp time.Tim
}
}
// RenderCustomMessage renders a message with a custom alert label (e.g. "Help").
// Content is rendered as markdown.
func (r *MessageRenderer) RenderCustomMessage(content, label string, timestamp time.Time) UIMessage {
rendered := render.CustomBlock(content, label, r.width, style.GetTheme())
return UIMessage{
Type: SystemMessage,
Content: rendered,
Height: lipgloss.Height(rendered),
Timestamp: timestamp,
}
}
// RenderDebugMessage renders diagnostic and debugging information
func (r *MessageRenderer) RenderDebugMessage(message string, timestamp time.Time) UIMessage {
header := r.ty.H6("🔍 Debug Output")
@@ -308,7 +338,7 @@ func (r *MessageRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
// Build the content: icon + name + params on first line, then body
headerLine := styledIcon + " " + styledName
if params != "" {
headerLine += " " + lipgloss.NewStyle().Foreground(theme.Muted).Render(params)
headerLine += " " + style.GetCachedStyles().ToolMuted.Render(params)
}
// Get body content
@@ -399,7 +429,8 @@ func createTypography(theme style.Theme) *herald.Typography {
herald.WithCodeLineNumbers(true),
// Customize alert labels
herald.WithAlertLabel(herald.AlertNote, "Info"),
herald.WithAlertLabel(herald.AlertTip, "You"),
herald.WithAlertLabel(herald.AlertTip, ""),
herald.WithAlertIcon(herald.AlertTip, ""),
herald.WithAlertLabel(herald.AlertWarning, "Working"),
herald.WithAlertLabel(herald.AlertCaution, "Error"),
)
+142 -24
View File
@@ -720,6 +720,10 @@ type AppModel struct {
// disables alt screen to restore the terminal properly.
quitting bool
// ctrlCPressedOnce tracks if Ctrl+C was pressed once to clear input.
// A second Ctrl+C (or Ctrl+C when input is empty) will quit the app.
ctrlCPressedOnce bool
// streamingBashOutput holds the current streaming bash output lines.
// Lines are accumulated as they arrive and displayed in the stream region.
streamingBashOutput []string
@@ -869,7 +873,7 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
m.messages = []MessageItem{}
// Wire up child components now that we have the concrete implementations.
m.input = NewInputComponent(width, "Enter your prompt (Type /help for commands, Ctrl+C to quit)", appCtrl)
m.input = NewInputComponent(width, appCtrl)
// Wire up cwd for @file autocomplete.
if ic, ok := m.input.(*InputComponent); ok && opts.Cwd != "" {
@@ -1040,7 +1044,7 @@ func (m *AppModel) AddStartupMessageToScrollList() {
// Add a visual separator after startup info: blank line + HR + blank line.
// Uses a single pre-rendered item so there are no left borders on the spacing.
theme := style.GetTheme()
separator := strings.Repeat("─", 80)
separator := strings.Repeat("─", m.width)
separatorStyled := lipgloss.NewStyle().
Foreground(theme.Border).
Render(separator)
@@ -1138,6 +1142,31 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.state = stateInput
if m.setModel != nil {
previousModel := m.providerName + "/" + m.modelName
// Check if thinking level needs adjustment for the new model.
// Some models (e.g., OpenAI gpt-5.4) don't support "minimal" and require "none".
if m.thinkingLevel != "" && m.thinkingLevel != "off" {
parts := strings.SplitN(msg.ModelString, "/", 2)
if len(parts) == 2 {
modelName := parts[1]
currentLevel := models.ParseThinkingLevel(m.thinkingLevel)
if !models.IsValidThinkingLevelForModel(currentLevel, modelName) {
fallback := models.SuggestThinkingLevelFallback(currentLevel, modelName)
if fallback != models.ThinkingOff {
m.printSystemMessage(fmt.Sprintf(
"Note: Model %s doesn't support '%s' thinking level. Adjusted to '%s'.",
modelName, currentLevel, fallback,
))
m.thinkingLevel = string(fallback)
if m.setThinkingLevel != nil {
_ = m.setThinkingLevel(string(fallback))
}
go func() { _ = prefs.SaveThinkingLevelPreference(string(fallback)) }()
}
}
}
}
if err := m.setModel(msg.ModelString); err != nil {
m.printSystemMessage(fmt.Sprintf("Failed to switch model: %v", err))
} else {
@@ -1283,10 +1312,22 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.overlayResponseCh = nil
m.overlay = nil
}
// Set quitting flag so View() disables alt screen for clean exit.
m.quitting = true
// Graceful quit: app.Close() is deferred in cmd/root.go.
return m, tea.Quit
// Second Ctrl+C within the timeout window — quit.
if m.ctrlCPressedOnce {
m.quitting = true
return m, tea.Quit
}
// First Ctrl+C — clear input if it has content, then arm the quit flag.
if m.state == stateInput {
if ic, ok := m.input.(*InputComponent); ok {
ic.Clear()
}
}
m.ctrlCPressedOnce = true
// Start reset timer so the flag clears after 3 seconds.
return m, ctrlCResetCmd()
}
// Check extension-registered global keyboard shortcuts. These fire
@@ -1564,10 +1605,16 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case uicore.CancelTimerExpiredMsg:
m.canceling = false
// ── Ctrl+C reset timer expired ────────────────────────────────────────────
case uicore.CtrlCResetMsg:
m.ctrlCPressedOnce = false
// ── Input submitted ──────────────────────────────────────────────────────
case uicore.SubmitMsg:
// Re-enable auto-scroll when user submits a new message.
m.scrollList.autoScroll = true
// Reset Ctrl+C flag so next Ctrl+C clears input instead of quitting.
m.ctrlCPressedOnce = false
// Handle slash commands locally — they should never reach app.Run().
// Parse once: split on the first space so argument-bearing commands
@@ -2393,8 +2440,10 @@ func (m *AppModel) View() tea.View {
scrollbackView := m.renderScrollback()
// Propagate hint visibility to the input component before rendering.
// Hints are hidden by default for a cleaner UI; extensions cannot
// override this.
if ic, ok := m.input.(*InputComponent); ok {
ic.hideHint = vis.HideInputHint
ic.hideHint = true
ic.agentBusy = m.state == stateWorking
}
@@ -2436,6 +2485,14 @@ func (m *AppModel) View() tea.View {
parts = append(parts, warning)
}
if m.ctrlCPressedOnce {
warning := lipgloss.NewStyle().
Foreground(theme.Warning).
Bold(true).
Render(" ⚠ Press Ctrl+C again to quit")
parts = append(parts, warning)
}
if !vis.HideSeparator {
parts = append(parts, m.renderSeparator())
}
@@ -2580,9 +2637,14 @@ func (m *AppModel) renderStatusBar() string {
middleSide = " " + middleSide
}
// Right side: provider · model + usage stats.
// Right side: help hint + provider · model + usage stats.
// Order matters for progressive truncation — least important first.
var rightParts []string
rightParts = append(rightParts, lipgloss.NewStyle().
Foreground(theme.VeryMuted).
Render("/help for help"))
var modelLabel string
if m.providerName != "" && m.modelName != "" {
modelLabel = m.providerName + " · " + m.modelName
@@ -2601,11 +2663,11 @@ func (m *AppModel) renderStatusBar() string {
}
}
rightSide := strings.Join(rightParts, " ")
rightSide := strings.Join(rightParts, " | ")
// Progressive truncation to keep the status bar on one line.
// When content exceeds terminal width, drop sections in order:
// middle (extensions/thinking) → usage stats → model label → right side.
// middle (extensions/thinking) → help hint → usage → model → all.
leftW := lipgloss.Width(leftSide)
middleW := lipgloss.Width(middleSide)
rightW := lipgloss.Width(rightSide)
@@ -2616,13 +2678,19 @@ func (m *AppModel) renderStatusBar() string {
middleSide = ""
middleW = 0
}
if leftW+rightW+1 > m.width && len(rightParts) > 2 {
// Drop help hint first.
rightParts = rightParts[1:]
rightSide = strings.Join(rightParts, " | ")
rightW = lipgloss.Width(rightSide)
}
if leftW+rightW+1 > m.width && len(rightParts) > 1 {
// Drop usage stats, keep model label.
rightSide = rightParts[0]
// Drop usage (last) next, keep model label.
rightParts = rightParts[:len(rightParts)-1]
rightSide = strings.Join(rightParts, " | ")
rightW = lipgloss.Width(rightSide)
}
if leftW+rightW+1 > m.width {
// Drop right side entirely.
rightSide = ""
rightW = 0
}
@@ -2633,7 +2701,7 @@ func (m *AppModel) renderStatusBar() string {
// cycleThinkingLevel advances to the next thinking level and applies it.
func (m *AppModel) cycleThinkingLevel() {
levels := []string{"off", "minimal", "low", "medium", "high"}
levels := []string{"off", "none", "minimal", "low", "medium", "high"}
current := m.thinkingLevel
if current == "" {
current = "off"
@@ -2666,7 +2734,7 @@ func (m *AppModel) cycleThinkingLevel() {
// renderSeparator renders the separator line with an optional queue/steer count badge.
func (m *AppModel) renderSeparator() string {
theme := style.GetTheme()
lineStyle := lipgloss.NewStyle().Foreground(theme.Muted)
lineStyle := lipgloss.NewStyle().Foreground(theme.Border)
queueLen := len(m.queuedMessages)
steerLen := len(m.steeringMessages)
@@ -3043,6 +3111,16 @@ func (m *AppModel) printSystemMessage(text string) {
m.refreshContent()
}
// printCustomMessage renders a message with a custom alert label into the ScrollList.
func (m *AppModel) printCustomMessage(text, label string) {
styledMsg := m.renderer.RenderCustomMessage(text, label, time.Now())
msg := NewStyledMessageItem(generateMessageID(), "system", styledMsg.Content, styledMsg.Content)
m.messages = append(m.messages, msg)
m.refreshContent()
}
// printExtensionBlock renders a custom styled block from an extension with
// caller-chosen border color and optional subtitle into the ScrollList.
func (m *AppModel) printExtensionBlock(evt app.ExtensionPrintEvent) {
@@ -3422,13 +3500,14 @@ func (m *AppModel) printHelpMessage() {
"- `!command`: Run shell command, output included in LLM context\n" +
"- `!!command`: Run shell command, output excluded from LLM context\n\n" +
"**Keys:**\n" +
"- `Ctrl+C`: Exit at any time\n" +
"- `Ctrl+C`: Clear input and arm quit (press again to exit)\n" +
"- `ESC` (x2): Cancel ongoing LLM generation\n" +
"- `Ctrl+X s`: Steer — redirect the agent mid-turn (injected between tool calls)\n" +
"- `Ctrl+X e`: Open `$EDITOR` to compose/edit your prompt\n" +
"- `Ctrl+V`: Paste image from clipboard\n" +
"- `Enter` (while working): Queue message for after the agent finishes\n\n" +
"You can also just type your message to chat with the AI assistant."
m.printSystemMessage(help)
m.printCustomMessage(help, "Help")
}
// printToolsMessage renders the list of available tools.
@@ -3681,15 +3760,16 @@ func (m *AppModel) distributeHeight() {
}
// Propagate hint visibility before measuring input height.
// Hints are always hidden for a cleaner UI.
if ic, ok := m.input.(*InputComponent); ok {
ic.hideHint = vis.HideInputHint
ic.hideHint = true
}
// Measure the actual rendered input (or prompt overlay) height so we
// don't rely on a fragile constant that drifts when styling changes.
// Use renderInput() which includes the editor interceptor's Render
// wrapper so the measured height matches what View() actually renders.
inputLines := 9 // fallback: title(1)+margin(1)+nl(1)+textarea(3)+nl(1)+margin(1)+help(1)
inputLines := 8 // fallback: marginTop(1)+textarea(4)+border-chrome(2)+marginBottom(1)
if m.state == statePrompt && m.prompt != nil {
if rendered := m.prompt.Render(); rendered != "" {
inputLines = lipgloss.Height(rendered)
@@ -3818,6 +3898,30 @@ func (m *AppModel) handleModelCommand(args string) tea.Cmd {
return nil
}
// Check if thinking level needs adjustment for the new model.
// Some models (e.g., OpenAI gpt-5.4) don't support "minimal" and require "none".
if m.thinkingLevel != "" && m.thinkingLevel != "off" {
parts := strings.SplitN(args, "/", 2)
if len(parts) == 2 {
modelName := parts[1]
currentLevel := models.ParseThinkingLevel(m.thinkingLevel)
if !models.IsValidThinkingLevelForModel(currentLevel, modelName) {
fallback := models.SuggestThinkingLevelFallback(currentLevel, modelName)
if fallback != models.ThinkingOff {
m.printSystemMessage(fmt.Sprintf(
"Note: Model %s doesn't support '%s' thinking level. Adjusted to '%s'.",
modelName, currentLevel, fallback,
))
m.thinkingLevel = string(fallback)
if m.setThinkingLevel != nil {
_ = m.setThinkingLevel(string(fallback))
}
go func() { _ = prefs.SaveThinkingLevelPreference(string(fallback)) }()
}
}
}
}
// Direct model switch with the provided model string.
previousModel := m.providerName + "/" + m.modelName
if err := m.setModel(args); err != nil {
@@ -3922,7 +4026,7 @@ func (m *AppModel) handleThinkingCommand(args string) tea.Cmd {
// Parse and validate the level.
level := models.ParseThinkingLevel(args)
if string(level) != strings.ToLower(args) {
m.printSystemMessage(fmt.Sprintf("Unknown thinking level: %q. Use: off, minimal, low, medium, high", args))
m.printSystemMessage(fmt.Sprintf("Unknown thinking level: %q. Use: off, none, minimal, low, medium, high", args))
return nil
}
@@ -4509,6 +4613,14 @@ func cancelTimerCmd() tea.Cmd {
})
}
// ctrlCResetCmd returns a tea.Cmd that fires CtrlCResetMsg after 3s.
// This resets the ctrlCPressedOnce flag so the next Ctrl+C will clear input again.
func ctrlCResetCmd() tea.Cmd {
return tea.Tick(3*time.Second, func(_ time.Time) tea.Msg {
return uicore.CtrlCResetMsg{}
})
}
// --------------------------------------------------------------------------
// Interactive prompt support
// --------------------------------------------------------------------------
@@ -4580,9 +4692,12 @@ func (m *AppModel) updatePromptState(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
if msg.String() == "ctrl+c" {
// Cancel prompt and quit the application.
// Cancel the prompt but don't quit — let the main handler's
// double-Ctrl+C logic handle quitting.
m.resolvePrompt(app.PromptResponse{Cancelled: true})
return m, tea.Quit
// Don't consume the keypress — re-dispatch so the main
// ctrl+c handler can track the double-press state.
return m.Update(msg)
}
result, cmd := m.prompt.Update(msg)
if cmd != nil {
@@ -4649,9 +4764,12 @@ func (m *AppModel) updateOverlayState(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
if msg.String() == "ctrl+c" {
// Cancel overlay and quit the application.
// Cancel the overlay but don't quit — let the main handler's
// double-Ctrl+C logic handle quitting.
m.resolveOverlay(app.OverlayResponse{Cancelled: true})
return m, tea.Quit
// Don't consume the keypress — re-dispatch so the main
// ctrl+c handler can track the double-press state.
return m.Update(msg)
}
result, cmd := m.overlay.Update(msg)
if cmd != nil {
+151 -9
View File
@@ -515,12 +515,12 @@ func TestWindowResize_distributeHeight(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
// With height=30, scroll height = 30 - 1 (separator) - 9 (input) - 1 (statusBar) = 19
// With height=30, scroll height = 30 - 1 (separator) - 8 (input) - 1 (statusBar) = 20
m = sendMsg(m, tea.WindowSizeMsg{Width: 80, Height: 30})
_ = m
if m.scrollList.height != 19 {
t.Fatalf("expected scroll list height=19, got %d", m.scrollList.height)
if m.scrollList.height != 20 {
t.Fatalf("expected scroll list height=20, got %d", m.scrollList.height)
}
}
@@ -853,23 +853,165 @@ func TestSpinnerEvent_hideDoesNotTransitionState(t *testing.T) {
}
// --------------------------------------------------------------------------
// ctrl+c produces tea.Quit
// ctrl+c double-press to quit
// --------------------------------------------------------------------------
// TestCtrlC_producesQuit verifies that ctrl+c always returns a tea.Quit cmd.
// TestCtrlC_producesQuit verifies that double ctrl+c returns a tea.Quit cmd.
func TestCtrlC_producesQuit(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
// First Ctrl+C arms the quit flag.
updated, cmd := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})
m = updated.(*AppModel)
if cmd == nil {
t.Fatal("expected a command after first ctrl+c, got nil")
}
// Should be a reset timer, not quit.
msg := cmd()
if _, ok := msg.(core.CtrlCResetMsg); !ok {
t.Fatalf("expected CtrlCResetMsg after first ctrl+c, got %T", msg)
}
// Second Ctrl+C should quit.
_, cmd = m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})
if cmd == nil {
t.Fatal("expected tea.Quit cmd on second ctrl+c, got nil")
}
msg = cmd()
if _, ok := msg.(tea.QuitMsg); !ok {
t.Fatalf("expected QuitMsg from second ctrl+c, got %T", msg)
}
}
// TestCtrlC_clearsInput_firstPress tests that Ctrl+C clears input on first
// press when there's content, and requires a second press to quit.
func TestCtrlC_clearsInput_firstPress(t *testing.T) {
// Create a real InputComponent to test the clear behavior
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
// Replace with real InputComponent that has content
input := NewInputComponent(80, ctrl)
input.textarea.SetValue("some text content")
m.input = input
// First Ctrl+C should clear input, not quit
_, cmd := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})
if cmd == nil {
t.Fatal("expected tea.Quit cmd on ctrl+c, got nil")
// Should have cleared the input
if input.textarea.Value() != "" {
t.Fatalf("expected input to be cleared, got %q", input.textarea.Value())
}
// Should have set ctrlCPressedOnce flag
if !m.ctrlCPressedOnce {
t.Fatal("expected ctrlCPressedOnce to be true after first Ctrl+C")
}
// The command should be a ctrlCResetCmd (not tea.Quit)
if cmd == nil {
t.Fatal("expected a command after first Ctrl+C, got nil")
}
// We verify it's a quit command by running it and checking the message type.
msg := cmd()
if _, ok := msg.(core.CtrlCResetMsg); !ok {
t.Fatalf("expected CtrlCResetMsg, got %T", msg)
}
// Second Ctrl+C should now quit
_, cmd = m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})
if cmd == nil {
t.Fatal("expected tea.Quit cmd on second Ctrl+C, got nil")
}
msg = cmd()
if _, ok := msg.(tea.QuitMsg); !ok {
t.Fatalf("expected QuitMsg from ctrl+c cmd, got %T", msg)
t.Fatalf("expected QuitMsg on second Ctrl+C, got %T", msg)
}
}
// TestCtrlC_resetAfterSubmit tests that the Ctrl+C flag is reset after
// submitting a message, so the next Ctrl+C clears input again.
func TestCtrlC_resetAfterSubmit(t *testing.T) {
// Use newTestAppModel but replace the input with a real InputComponent
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
// Replace with real InputComponent
input := NewInputComponent(80, ctrl)
input.textarea.SetValue("content")
m.input = input
// First Ctrl+C clears input
updated, _ := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})
m = updated.(*AppModel)
if input.textarea.Value() != "" {
t.Fatal("expected input to be cleared")
}
// Flag should be set
if !m.ctrlCPressedOnce {
t.Fatal("expected ctrlCPressedOnce to be true after first Ctrl+C")
}
// Simulate CtrlCResetMsg being processed (timer expired)
updated, _ = m.Update(core.CtrlCResetMsg{})
m = updated.(*AppModel)
// Flag should be reset
if m.ctrlCPressedOnce {
t.Fatal("expected ctrlCPressedOnce to be false after CtrlCResetMsg")
}
// Add new content to input
input.textarea.SetValue("new content")
// Next Ctrl+C should clear again (not quit) because flag was reset
_, cmd := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})
if input.textarea.Value() != "" {
t.Fatalf("expected input to be cleared again, got %q", input.textarea.Value())
}
if cmd == nil {
t.Fatal("expected a command after Ctrl+C, got nil")
}
msg := cmd()
if _, ok := msg.(core.CtrlCResetMsg); !ok {
t.Fatalf("expected CtrlCResetMsg, got %T", msg)
}
}
// TestCtrlC_emptyInput_armsQuit tests that Ctrl+C on empty input still
// requires a second press to quit (consistent double-press behavior).
func TestCtrlC_emptyInput_armsQuit(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
// Replace with real InputComponent (empty by default)
input := NewInputComponent(80, ctrl)
m.input = input
// First Ctrl+C on empty input should arm the flag, not quit.
updated, cmd := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})
m = updated.(*AppModel)
if !m.ctrlCPressedOnce {
t.Fatal("expected ctrlCPressedOnce to be true after first Ctrl+C")
}
if cmd == nil {
t.Fatal("expected a command (reset timer), got nil")
}
msg := cmd()
if _, ok := msg.(core.CtrlCResetMsg); !ok {
t.Fatalf("expected CtrlCResetMsg, got %T", msg)
}
// Second Ctrl+C should quit.
_, cmd = m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})
if cmd == nil {
t.Fatal("expected tea.Quit cmd on second Ctrl+C, got nil")
}
msg = cmd()
if _, ok := msg.(tea.QuitMsg); !ok {
t.Fatalf("expected QuitMsg on second Ctrl+C, got %T", msg)
}
}
+61 -12
View File
@@ -36,16 +36,16 @@ func UserBlock(content string, width int, ty *herald.Typography, theme style.The
// Highlight @file tokens with accent color so file references are
// visually distinct from surrounding prompt text.
content = highlightFileTokens(content, theme)
content = HighlightFileTokens(content, theme)
rendered := ty.Tip(content)
return styleMarginBottom(theme, rendered)
}
// highlightFileTokens wraps @file tokens in the given text with the theme
// HighlightFileTokens wraps @file tokens in the given text with the theme
// accent color so they stand out visually in rendered user messages.
func highlightFileTokens(text string, theme style.Theme) string {
accentStyle := lipgloss.NewStyle().Foreground(theme.Accent).Bold(true)
func HighlightFileTokens(text string, theme style.Theme) string {
accentStyle := style.GetCachedStyles().FileTokenAccent
return fileTokenPattern.ReplaceAllStringFunc(text, func(token string) string {
return accentStyle.Render(token)
})
@@ -63,16 +63,20 @@ func AssistantBlock(content string, width int, theme style.Theme) string {
// ReasoningBlock renders a reasoning/thinking block with muted italic text.
// If duration > 0, shows "Thought for Xs" label. Otherwise shows just "Thought".
func ReasoningBlock(content string, duration int64, ty *herald.Typography, theme style.Theme) string {
// The width parameter controls soft-wrapping so long reasoning lines don't get cut off.
func ReasoningBlock(content string, duration int64, width int, ty *herald.Typography, theme style.Theme) string {
if strings.TrimSpace(content) == "" {
return ""
}
// Match live streaming styling: muted italic text
// Match live streaming styling: muted italic text.
lines := strings.Split(strings.TrimRight(content, "\n"), "\n")
contentStr := strings.TrimLeft(strings.Join(lines, "\n"), " \t\n")
mutedStyle := lipgloss.NewStyle().Foreground(theme.Muted)
contentRendered := mutedStyle.Render(ty.Italic(contentStr))
if width > 4 {
contentStr = wrapText(contentStr, width-4)
}
cs := style.GetCachedStyles()
contentRendered := cs.Muted.Render(ty.Italic(contentStr))
// Build label based on duration
if duration > 0 {
@@ -82,14 +86,14 @@ func ReasoningBlock(content string, duration int64, ty *herald.Typography, theme
} else {
durationStr = fmt.Sprintf("%.1fs", float64(duration)/1000)
}
labelPart := lipgloss.NewStyle().Foreground(theme.VeryMuted).Render("Thought for ")
durationPart := lipgloss.NewStyle().Foreground(theme.Accent).Render(durationStr)
labelPart := cs.VeryMuted.Render("Thought for ")
durationPart := cs.Accent.Render(durationStr)
label := labelPart + durationPart
rendered := contentRendered + "\n" + label
return styleMarginBottom(theme, rendered)
}
label := lipgloss.NewStyle().Foreground(theme.VeryMuted).Render("Thought")
label := cs.VeryMuted.Render("Thought")
rendered := contentRendered + "\n" + label
return styleMarginBottom(theme, rendered)
@@ -105,6 +109,45 @@ func SystemBlock(content string, ty *herald.Typography, theme style.Theme) strin
return styleMarginBottom(theme, rendered)
}
// CustomBlock renders a message with herald Note styling and a custom label.
// Content is rendered as markdown before being wrapped in the alert. This
// creates a one-off Typography instance with the given label so callers
// can use any title (e.g. "Help", "Warning") without changing the shared
// typography's default "Info" label.
func CustomBlock(content, label string, width int, theme style.Theme) string {
if strings.TrimSpace(content) == "" {
content = "No content available"
}
// Render markdown first — subtract 4 for the alert bar prefix ("│ ").
mdWidth := max(width-4, 10)
rendered := style.ToMarkdown(content, mdWidth)
ty := herald.New(
herald.WithPalette(herald.ColorPalette{
Primary: theme.Primary,
Secondary: theme.Secondary,
Tertiary: theme.Info,
Accent: theme.Accent,
Highlight: theme.Highlight,
Muted: theme.Muted,
Text: theme.Text,
Surface: theme.Background,
Base: theme.CodeBg,
}),
herald.WithAlertPalette(herald.AlertPalette{
Note: theme.Info,
Tip: theme.Success,
Important: theme.Accent,
Warning: theme.Warning,
Caution: theme.Error,
}),
herald.WithAlertLabel(herald.AlertNote, label),
)
alertRendered := ty.Note(rendered)
return styleMarginBottom(theme, alertRendered)
}
// ErrorBlock renders an error message with herald Caution styling.
func ErrorBlock(errorMsg string, ty *herald.Typography, theme style.Theme) string {
rendered := ty.Caution(errorMsg)
@@ -151,5 +194,11 @@ func ToolBlock(displayName, params, body string, isError bool, width int, ty *he
// styleMarginBottom applies a 1-line margin bottom using the theme.
func styleMarginBottom(theme style.Theme, content string) string {
return lipgloss.NewStyle().MarginBottom(1).Render(content)
return style.GetCachedStyles().MarginBottom1.Render(content)
}
// wrapText soft-wraps a string to the given width using lipgloss, which is
// ANSI-aware and preserves escape sequences across line breaks.
func wrapText(s string, width int) string {
return lipgloss.NewStyle().Width(width).Render(s)
}
+5 -4
View File
@@ -23,7 +23,8 @@ func testTypography(theme style.Theme) *herald.Typography {
Surface: theme.Background,
Base: theme.CodeBg,
}),
herald.WithAlertLabel(herald.AlertTip, "You"),
herald.WithAlertLabel(herald.AlertTip, ""),
herald.WithAlertIcon(herald.AlertTip, ""),
)
}
@@ -70,18 +71,18 @@ func TestHighlightFileTokens(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := highlightFileTokens(tt.input, theme)
result := HighlightFileTokens(tt.input, theme)
for _, want := range tt.wantHas {
if !strings.Contains(result, want) {
t.Errorf("highlightFileTokens(%q) = %q, want substring %q", tt.input, result, want)
t.Errorf("HighlightFileTokens(%q) = %q, want substring %q", tt.input, result, want)
}
}
// If there were @tokens, the result should contain ANSI escape
// sequences (from lipgloss styling).
if fileTokenPattern.MatchString(tt.input) && !strings.Contains(result, "\x1b[") {
t.Errorf("highlightFileTokens(%q) should contain ANSI escapes for @tokens but got %q", tt.input, result)
t.Errorf("HighlightFileTokens(%q) should contain ANSI escapes for @tokens but got %q", tt.input, result)
}
})
}
+13 -11
View File
@@ -21,12 +21,11 @@ func knightRiderFrames() []string {
const numDots = 8
const dot = "▪"
theme := style.GetTheme()
bright := lipgloss.NewStyle().Foreground(theme.Primary)
med := lipgloss.NewStyle().Foreground(theme.Muted)
dim := lipgloss.NewStyle().Foreground(theme.VeryMuted)
off := lipgloss.NewStyle().Foreground(theme.MutedBorder)
cs := style.GetCachedStyles()
bright := cs.SpinnerBright
med := cs.SpinnerMed
dim := cs.SpinnerDim
off := cs.SpinnerOff
// Scanner bounces: 0→7→0
positions := make([]int, 0, 2*numDots-2)
@@ -472,9 +471,12 @@ func (s *StreamComponent) renderReasoningBlock(reasoning string) string {
// Main content using Italic with Muted color for visual distinction.
content := strings.TrimLeft(strings.Join(lines, "\n"), " \t\n")
theme := GetTheme()
mutedStyle := lipgloss.NewStyle().Foreground(theme.Muted)
parts = append(parts, mutedStyle.Render(s.ty.Italic(content)))
// Soft-wrap to the available width so long lines don't get cut off.
if s.width > 4 {
content = lipgloss.NewStyle().Width(s.width - 4).Render(content)
}
cs := style.GetCachedStyles()
parts = append(parts, cs.Muted.Render(s.ty.Italic(content)))
// Duration footer with VeryMuted label and Accent duration.
var duration time.Duration
@@ -490,8 +492,8 @@ func (s *StreamComponent) renderReasoningBlock(reasoning string) string {
} else {
durationStr = fmt.Sprintf("%.1fs", duration.Seconds())
}
label := lipgloss.NewStyle().Foreground(theme.VeryMuted).Render("Thought for ")
durationStyled := lipgloss.NewStyle().Foreground(theme.Accent).Render(durationStr)
label := cs.VeryMuted.Render("Thought for ")
durationStyled := cs.Accent.Render(durationStr)
parts = append(parts, label+durationStyled)
}
+64
View File
@@ -40,6 +40,70 @@ func GetTheme() Theme {
func SetTheme(theme Theme) {
currentTheme = theme
markdownTypographyCache = nil // invalidate cached renderer; colors may have changed
styleCache = nil // invalidate cached styles; colors may have changed
}
// CachedStyles holds pre-built lipgloss styles that are reused across
// render frames. Invalidated by SetTheme, lazily rebuilt on next access.
// Only accessed from BubbleTea's single-threaded Update/View cycle.
type CachedStyles struct {
// render/blocks.go
FileTokenAccent lipgloss.Style // Foreground(Accent).Bold(true)
Muted lipgloss.Style // Foreground(Muted)
VeryMuted lipgloss.Style // Foreground(VeryMuted)
Accent lipgloss.Style // Foreground(Accent)
MarginBottom1 lipgloss.Style // MarginBottom(1)
// stream.go - spinner phases
SpinnerBright lipgloss.Style // Foreground(Primary)
SpinnerMed lipgloss.Style // Foreground(Muted)
SpinnerDim lipgloss.Style // Foreground(VeryMuted)
SpinnerOff lipgloss.Style // Foreground(MutedBorder)
// message_items.go - bash output
BashHeader lipgloss.Style // Foreground(Muted).Italic(true)
BashStderr lipgloss.Style // Foreground(Error)
// render/blocks.go - tool block
ToolSuccess lipgloss.Style // Foreground(Success)
ToolError lipgloss.Style // Foreground(Error)
ToolInfo lipgloss.Style // Foreground(Info).Bold(true)
ToolMuted lipgloss.Style // Foreground(Muted)
// common
ErrorFg lipgloss.Style // Foreground(Error)
TextBold lipgloss.Style // Foreground(Text).Bold(true)
}
var styleCache *CachedStyles
// GetCachedStyles returns the pre-built style cache, creating it lazily
// from the current theme. Invalidated by SetTheme.
func GetCachedStyles() *CachedStyles {
if styleCache != nil {
return styleCache
}
theme := GetTheme()
styleCache = &CachedStyles{
FileTokenAccent: lipgloss.NewStyle().Foreground(theme.Accent).Bold(true),
Muted: lipgloss.NewStyle().Foreground(theme.Muted),
VeryMuted: lipgloss.NewStyle().Foreground(theme.VeryMuted),
Accent: lipgloss.NewStyle().Foreground(theme.Accent),
MarginBottom1: lipgloss.NewStyle().MarginBottom(1),
SpinnerBright: lipgloss.NewStyle().Foreground(theme.Primary),
SpinnerMed: lipgloss.NewStyle().Foreground(theme.Muted),
SpinnerDim: lipgloss.NewStyle().Foreground(theme.VeryMuted),
SpinnerOff: lipgloss.NewStyle().Foreground(theme.MutedBorder),
BashHeader: lipgloss.NewStyle().Foreground(theme.Muted).Italic(true),
BashStderr: lipgloss.NewStyle().Foreground(theme.Error),
ToolSuccess: lipgloss.NewStyle().Foreground(theme.Success),
ToolError: lipgloss.NewStyle().Foreground(theme.Error),
ToolInfo: lipgloss.NewStyle().Foreground(theme.Info).Bold(true),
ToolMuted: lipgloss.NewStyle().Foreground(theme.Muted),
ErrorFg: lipgloss.NewStyle().Foreground(theme.Error),
TextBold: lipgloss.NewStyle().Foreground(theme.Text).Bold(true),
}
return styleCache
}
// MarkdownThemeColors defines colors for markdown rendering and syntax highlighting.
+1 -1
View File
@@ -106,7 +106,7 @@ unsub2 := host.OnToolResult(func(e kit.ToolResultEvent) {
})
defer unsub2()
unsub3 := host.OnStreaming(func(e kit.MessageUpdateEvent) {
unsub3 := host.OnMessageUpdate(func(e kit.MessageUpdateEvent) {
fmt.Print(e.Chunk)
})
defer unsub3()
+411
View File
@@ -23,6 +23,14 @@ const (
EventMessageUpdate EventType = "message_update"
// EventMessageEnd fires when the assistant message is complete.
EventMessageEnd EventType = "message_end"
// EventToolCallStart fires when the LLM begins generating tool call arguments.
// The tool name is known but arguments are still streaming.
EventToolCallStart EventType = "tool_call_start"
// EventToolCallDelta fires for each streamed fragment of tool call arguments.
EventToolCallDelta EventType = "tool_call_delta"
// EventToolCallEnd fires when tool argument streaming is complete, before
// the tool call is parsed and execution begins.
EventToolCallEnd EventType = "tool_call_end"
// EventToolCall fires when a tool call has been parsed and is about to execute.
EventToolCall EventType = "tool_call"
// EventToolExecutionStart fires when a tool begins executing.
@@ -50,6 +58,31 @@ const (
// EventSteerConsumed fires when one or more steering messages have been
// injected into the agent turn via PrepareStep.
EventSteerConsumed EventType = "steer_consumed"
// EventStepStart fires when a new LLM call begins within a turn.
EventStepStart EventType = "step_start"
// EventStepFinish fires when a step completes, providing full step context
// including whether tool calls were made, the finish reason, and usage stats.
EventStepFinish EventType = "step_finish"
// EventTextStart fires when the LLM begins generating text content.
EventTextStart EventType = "text_start"
// EventTextEnd fires when the LLM finishes generating text content.
EventTextEnd EventType = "text_end"
// EventReasoningStart fires when the LLM begins reasoning/thinking.
EventReasoningStart EventType = "reasoning_start"
// EventWarnings fires when the LLM provider returns warnings.
EventWarnings EventType = "warnings"
// EventSource fires when the LLM references a source (e.g. from web search).
EventSource EventType = "source"
// EventStreamFinish fires when a per-step LLM stream completes with
// usage stats and a finish reason.
EventStreamFinish EventType = "stream_finish"
// EventError fires when an agent-level error occurs during streaming.
// This is distinct from TurnEndEvent.Error — it fires at the point of
// failure, before the turn ends.
EventError EventType = "error"
// EventRetry fires when the LLM provider request is retried after a
// transient error.
EventRetry EventType = "retry"
)
// ---------------------------------------------------------------------------
@@ -216,6 +249,40 @@ type MessageEndEvent struct {
// EventType implements Event.
func (e MessageEndEvent) EventType() EventType { return EventMessageEnd }
// ToolCallStartEvent fires when the LLM begins generating tool call arguments.
// The tool name is known at this point but the full arguments are still being
// streamed. UIs can use this to show a "running" indicator immediately instead
// of waiting for the full argument JSON to finish streaming.
type ToolCallStartEvent struct {
ToolCallID string // Stable ID for correlating tool lifecycle events
ToolName string
ToolKind string // Tool classification: "execute", "edit", "read", "search", "agent"
}
// EventType implements Event.
func (e ToolCallStartEvent) EventType() EventType { return EventToolCallStart }
// ToolCallDeltaEvent fires for each streamed fragment of tool call arguments.
// Useful for live-previewing artifact content as it's generated, or showing a
// progress indicator with byte count.
type ToolCallDeltaEvent struct {
ToolCallID string // Stable ID for correlating tool lifecycle events
Delta string // JSON fragment of tool arguments
}
// EventType implements Event.
func (e ToolCallDeltaEvent) EventType() EventType { return EventToolCallDelta }
// ToolCallEndEvent fires when tool argument streaming is complete, before
// the tool call is parsed and execution begins. UIs can use this to
// transition from an "generating args" state to an "executing" state.
type ToolCallEndEvent struct {
ToolCallID string // Stable ID for correlating tool lifecycle events
}
// EventType implements Event.
func (e ToolCallEndEvent) EventType() EventType { return EventToolCallEnd }
// ToolCallEvent fires when a tool call has been parsed.
type ToolCallEvent struct {
ToolCallID string // Stable ID for correlating tool lifecycle events
@@ -337,6 +404,100 @@ type SteerConsumedEvent struct {
// EventType implements Event.
func (e SteerConsumedEvent) EventType() EventType { return EventSteerConsumed }
// StepStartEvent fires when a new LLM call begins within a multi-step agent turn.
type StepStartEvent struct {
StepNumber int
}
// EventType implements Event.
func (e StepStartEvent) EventType() EventType { return EventStepStart }
// StepFinishEvent fires when a step completes, providing full step context.
// This is a unified event that carries the same data as the existing
// ToolCallContentEvent and StepUsageEvent, plus additional step metadata.
type StepFinishEvent struct {
StepNumber int
HasToolCalls bool
FinishReason string
Usage LLMUsage
}
// EventType implements Event.
func (e StepFinishEvent) EventType() EventType { return EventStepFinish }
// TextStartEvent fires when the LLM begins generating text content.
// Paired with MessageUpdateEvent (deltas) and TextEndEvent.
type TextStartEvent struct {
ID string
}
// EventType implements Event.
func (e TextStartEvent) EventType() EventType { return EventTextStart }
// TextEndEvent fires when the LLM finishes generating text content.
type TextEndEvent struct {
ID string
}
// EventType implements Event.
func (e TextEndEvent) EventType() EventType { return EventTextEnd }
// ReasoningStartEvent fires when the LLM begins reasoning/thinking.
// Paired with ReasoningDeltaEvent (deltas) and ReasoningCompleteEvent.
type ReasoningStartEvent struct {
ID string
}
// EventType implements Event.
func (e ReasoningStartEvent) EventType() EventType { return EventReasoningStart }
// WarningsEvent fires when the LLM provider returns warnings about the request.
type WarningsEvent struct {
Warnings []string
}
// EventType implements Event.
func (e WarningsEvent) EventType() EventType { return EventWarnings }
// SourceEvent fires when the LLM references a source (e.g. from web search tools).
type SourceEvent struct {
SourceType string
ID string
URL string
Title string
}
// EventType implements Event.
func (e SourceEvent) EventType() EventType { return EventSource }
// StreamFinishEvent fires when a per-step LLM stream completes.
// Provides per-stream usage stats and finish reason.
type StreamFinishEvent struct {
Usage LLMUsage
FinishReason string
}
// EventType implements Event.
func (e StreamFinishEvent) EventType() EventType { return EventStreamFinish }
// ErrorEvent fires when an agent-level error occurs during streaming.
// This is distinct from TurnEndEvent.Error — it fires at the point of failure.
type ErrorEvent struct {
Error error
}
// EventType implements Event.
func (e ErrorEvent) EventType() EventType { return EventError }
// RetryEvent fires when the LLM provider request is retried after a transient error.
type RetryEvent struct {
Attempt int
Error error
}
// EventType implements Event.
func (e RetryEvent) EventType() EventType { return EventRetry }
// PasswordPromptEvent fires when a sudo command needs a password.
// The TUI should display a password prompt and send the result back via ResponseCh.
type PasswordPromptEvent struct {
@@ -420,6 +581,39 @@ func (m *Kit) OnToolCall(handler func(ToolCallEvent)) func() {
})
}
// OnToolCallStart registers a handler that fires only for ToolCallStartEvent.
// This fires when the LLM begins generating tool call arguments — before the
// full argument JSON is available. Returns an unsubscribe function.
func (m *Kit) OnToolCallStart(handler func(ToolCallStartEvent)) func() {
return m.Subscribe(func(e Event) {
if tcs, ok := e.(ToolCallStartEvent); ok {
handler(tcs)
}
})
}
// OnToolCallDelta registers a handler that fires only for ToolCallDeltaEvent.
// Each delta contains a JSON fragment of tool call arguments as they stream in.
// Returns an unsubscribe function.
func (m *Kit) OnToolCallDelta(handler func(ToolCallDeltaEvent)) func() {
return m.Subscribe(func(e Event) {
if tcd, ok := e.(ToolCallDeltaEvent); ok {
handler(tcd)
}
})
}
// OnToolCallEnd registers a handler that fires only for ToolCallEndEvent.
// This fires when tool argument streaming is complete, before the tool call
// is parsed and execution begins. Returns an unsubscribe function.
func (m *Kit) OnToolCallEnd(handler func(ToolCallEndEvent)) func() {
return m.Subscribe(func(e Event) {
if tce, ok := e.(ToolCallEndEvent); ok {
handler(tce)
}
})
}
// OnToolResult registers a handler that fires only for ToolResultEvent.
// Returns an unsubscribe function.
func (m *Kit) OnToolResult(handler func(ToolResultEvent)) func() {
@@ -442,7 +636,16 @@ func (m *Kit) OnToolOutput(handler func(ToolOutputEvent)) func() {
// OnStreaming registers a handler that fires only for MessageUpdateEvent
// (streaming text chunks). Returns an unsubscribe function.
//
// Deprecated: Use OnMessageUpdate instead. OnStreaming will be removed in a
// future release.
func (m *Kit) OnStreaming(handler func(MessageUpdateEvent)) func() {
return m.OnMessageUpdate(handler)
}
// OnMessageUpdate registers a handler that fires only for MessageUpdateEvent
// (streaming text chunks). Returns an unsubscribe function.
func (m *Kit) OnMessageUpdate(handler func(MessageUpdateEvent)) func() {
return m.Subscribe(func(e Event) {
if mu, ok := e.(MessageUpdateEvent); ok {
handler(mu)
@@ -480,6 +683,214 @@ func (m *Kit) OnTurnEnd(handler func(TurnEndEvent)) func() {
})
}
// ---------------------------------------------------------------------------
// Typed subscribers for previously unsubscribed event types
// ---------------------------------------------------------------------------
// OnMessageStart registers a handler that fires only for MessageStartEvent.
// Returns an unsubscribe function.
func (m *Kit) OnMessageStart(handler func(MessageStartEvent)) func() {
return m.Subscribe(func(e Event) {
if ms, ok := e.(MessageStartEvent); ok {
handler(ms)
}
})
}
// OnMessageEnd registers a handler that fires only for MessageEndEvent.
// Returns an unsubscribe function.
func (m *Kit) OnMessageEnd(handler func(MessageEndEvent)) func() {
return m.Subscribe(func(e Event) {
if me, ok := e.(MessageEndEvent); ok {
handler(me)
}
})
}
// OnReasoningDelta registers a handler that fires only for ReasoningDeltaEvent.
// Returns an unsubscribe function.
func (m *Kit) OnReasoningDelta(handler func(ReasoningDeltaEvent)) func() {
return m.Subscribe(func(e Event) {
if rd, ok := e.(ReasoningDeltaEvent); ok {
handler(rd)
}
})
}
// OnReasoningComplete registers a handler that fires only for ReasoningCompleteEvent.
// Returns an unsubscribe function.
func (m *Kit) OnReasoningComplete(handler func(ReasoningCompleteEvent)) func() {
return m.Subscribe(func(e Event) {
if rc, ok := e.(ReasoningCompleteEvent); ok {
handler(rc)
}
})
}
// OnToolExecutionStart registers a handler that fires only for ToolExecutionStartEvent.
// Returns an unsubscribe function.
func (m *Kit) OnToolExecutionStart(handler func(ToolExecutionStartEvent)) func() {
return m.Subscribe(func(e Event) {
if tes, ok := e.(ToolExecutionStartEvent); ok {
handler(tes)
}
})
}
// OnToolExecutionEnd registers a handler that fires only for ToolExecutionEndEvent.
// Returns an unsubscribe function.
func (m *Kit) OnToolExecutionEnd(handler func(ToolExecutionEndEvent)) func() {
return m.Subscribe(func(e Event) {
if tee, ok := e.(ToolExecutionEndEvent); ok {
handler(tee)
}
})
}
// OnToolCallContent registers a handler that fires only for ToolCallContentEvent.
// Returns an unsubscribe function.
func (m *Kit) OnToolCallContent(handler func(ToolCallContentEvent)) func() {
return m.Subscribe(func(e Event) {
if tcc, ok := e.(ToolCallContentEvent); ok {
handler(tcc)
}
})
}
// OnStepUsage registers a handler that fires only for StepUsageEvent.
// Returns an unsubscribe function.
func (m *Kit) OnStepUsage(handler func(StepUsageEvent)) func() {
return m.Subscribe(func(e Event) {
if su, ok := e.(StepUsageEvent); ok {
handler(su)
}
})
}
// OnCompaction registers a handler that fires only for CompactionEvent.
// Returns an unsubscribe function.
func (m *Kit) OnCompaction(handler func(CompactionEvent)) func() {
return m.Subscribe(func(e Event) {
if ce, ok := e.(CompactionEvent); ok {
handler(ce)
}
})
}
// OnSteerConsumed registers a handler that fires only for SteerConsumedEvent.
// Returns an unsubscribe function.
func (m *Kit) OnSteerConsumed(handler func(SteerConsumedEvent)) func() {
return m.Subscribe(func(e Event) {
if sc, ok := e.(SteerConsumedEvent); ok {
handler(sc)
}
})
}
// ---------------------------------------------------------------------------
// Typed subscribers for new event types
// ---------------------------------------------------------------------------
// OnStepStart registers a handler that fires only for StepStartEvent.
// Returns an unsubscribe function.
func (m *Kit) OnStepStart(handler func(StepStartEvent)) func() {
return m.Subscribe(func(e Event) {
if ss, ok := e.(StepStartEvent); ok {
handler(ss)
}
})
}
// OnStepFinish registers a handler that fires only for StepFinishEvent.
// Returns an unsubscribe function.
func (m *Kit) OnStepFinish(handler func(StepFinishEvent)) func() {
return m.Subscribe(func(e Event) {
if sf, ok := e.(StepFinishEvent); ok {
handler(sf)
}
})
}
// OnTextStart registers a handler that fires only for TextStartEvent.
// Returns an unsubscribe function.
func (m *Kit) OnTextStart(handler func(TextStartEvent)) func() {
return m.Subscribe(func(e Event) {
if ts, ok := e.(TextStartEvent); ok {
handler(ts)
}
})
}
// OnTextEnd registers a handler that fires only for TextEndEvent.
// Returns an unsubscribe function.
func (m *Kit) OnTextEnd(handler func(TextEndEvent)) func() {
return m.Subscribe(func(e Event) {
if te, ok := e.(TextEndEvent); ok {
handler(te)
}
})
}
// OnReasoningStart registers a handler that fires only for ReasoningStartEvent.
// Returns an unsubscribe function.
func (m *Kit) OnReasoningStart(handler func(ReasoningStartEvent)) func() {
return m.Subscribe(func(e Event) {
if rs, ok := e.(ReasoningStartEvent); ok {
handler(rs)
}
})
}
// OnWarnings registers a handler that fires only for WarningsEvent.
// Returns an unsubscribe function.
func (m *Kit) OnWarnings(handler func(WarningsEvent)) func() {
return m.Subscribe(func(e Event) {
if w, ok := e.(WarningsEvent); ok {
handler(w)
}
})
}
// OnSource registers a handler that fires only for SourceEvent.
// Returns an unsubscribe function.
func (m *Kit) OnSource(handler func(SourceEvent)) func() {
return m.Subscribe(func(e Event) {
if s, ok := e.(SourceEvent); ok {
handler(s)
}
})
}
// OnStreamFinish registers a handler that fires only for StreamFinishEvent.
// Returns an unsubscribe function.
func (m *Kit) OnStreamFinish(handler func(StreamFinishEvent)) func() {
return m.Subscribe(func(e Event) {
if sf, ok := e.(StreamFinishEvent); ok {
handler(sf)
}
})
}
// OnError registers a handler that fires only for ErrorEvent.
// Returns an unsubscribe function.
func (m *Kit) OnError(handler func(ErrorEvent)) func() {
return m.Subscribe(func(e Event) {
if ee, ok := e.(ErrorEvent); ok {
handler(ee)
}
})
}
// OnRetry registers a handler that fires only for RetryEvent.
// Returns an unsubscribe function.
func (m *Kit) OnRetry(handler func(RetryEvent)) func() {
return m.Subscribe(func(e Event) {
if r, ok := e.(RetryEvent); ok {
handler(r)
}
})
}
// ---------------------------------------------------------------------------
// Subagent event subscriptions
// ---------------------------------------------------------------------------
+69
View File
@@ -1,6 +1,7 @@
package kit
import (
"fmt"
"sync"
"sync/atomic"
"testing"
@@ -190,6 +191,74 @@ func TestEventTypes(t *testing.T) {
}
}
// TestNewEventTypes verifies that each new event struct returns the correct EventType.
func TestNewEventTypes(t *testing.T) {
tests := []struct {
event Event
expected EventType
}{
{StepStartEvent{StepNumber: 0}, EventStepStart},
{StepFinishEvent{StepNumber: 1, HasToolCalls: true}, EventStepFinish},
{TextStartEvent{ID: "text-1"}, EventTextStart},
{TextEndEvent{ID: "text-1"}, EventTextEnd},
{ReasoningStartEvent{ID: "reason-1"}, EventReasoningStart},
{WarningsEvent{Warnings: []string{"test"}}, EventWarnings},
{SourceEvent{URL: "https://example.com", Title: "Example"}, EventSource},
{StreamFinishEvent{FinishReason: "stop"}, EventStreamFinish},
{ErrorEvent{Error: fmt.Errorf("test error")}, EventError},
{RetryEvent{Attempt: 1, Error: fmt.Errorf("retry error")}, EventRetry},
{ToolCallStartEvent{}, EventToolCallStart},
{ToolCallDeltaEvent{}, EventToolCallDelta},
{ToolCallEndEvent{}, EventToolCallEnd},
{PasswordPromptEvent{}, EventPasswordPrompt},
}
for _, tt := range tests {
if got := tt.event.EventType(); got != tt.expected {
t.Errorf("%T.EventType() = %q, want %q", tt.event, got, tt.expected)
}
}
}
// TestNewEventEmission verifies that new event types are properly emitted and received.
func TestNewEventEmission(t *testing.T) {
bus := newEventBus()
var received []Event
bus.subscribe(func(e Event) {
received = append(received, e)
})
bus.emit(StepStartEvent{StepNumber: 0})
bus.emit(TextStartEvent{ID: "text-1"})
bus.emit(TextEndEvent{ID: "text-1"})
bus.emit(ReasoningStartEvent{ID: "reason-1"})
bus.emit(WarningsEvent{Warnings: []string{"low confidence"}})
bus.emit(SourceEvent{URL: "https://example.com", Title: "Example"})
bus.emit(StreamFinishEvent{FinishReason: "stop"})
bus.emit(StepFinishEvent{StepNumber: 0, HasToolCalls: false, FinishReason: "stop"})
bus.emit(ErrorEvent{Error: fmt.Errorf("test error")})
bus.emit(RetryEvent{Attempt: 1, Error: fmt.Errorf("retry")})
if len(received) != 10 {
t.Fatalf("expected 10 events, got %d", len(received))
}
// Verify specific event fields
if ss, ok := received[0].(StepStartEvent); !ok || ss.StepNumber != 0 {
t.Errorf("event 0: expected StepStartEvent{StepNumber:0}, got %T %+v", received[0], received[0])
}
if ts, ok := received[1].(TextStartEvent); !ok || ts.ID != "text-1" {
t.Errorf("event 1: expected TextStartEvent{ID:text-1}, got %T %+v", received[1], received[1])
}
if w, ok := received[4].(WarningsEvent); !ok || len(w.Warnings) != 1 || w.Warnings[0] != "low confidence" {
t.Errorf("event 4: expected WarningsEvent with 1 warning, got %T %+v", received[4], received[4])
}
if sf, ok := received[7].(StepFinishEvent); !ok || sf.StepNumber != 0 || sf.HasToolCalls {
t.Errorf("event 7: expected StepFinishEvent{StepNumber:0, HasToolCalls:false}, got %T %+v", received[7], received[7])
}
}
// TestEventBusListenerCanUnsubscribeInCallback verifies that a listener can
// safely call its own unsubscribe function from within the callback.
func TestEventBusListenerCanUnsubscribeInCallback(t *testing.T) {
+162
View File
@@ -100,6 +100,38 @@ func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
})
}
// Tool call input streaming events — fire as the LLM generates tool arguments.
if runner.HasHandlers(extensions.ToolCallInputStart) {
m.Subscribe(func(e Event) {
if ev, ok := e.(ToolCallStartEvent); ok {
_, _ = runner.Emit(extensions.ToolCallInputStartEvent{
ToolCallID: ev.ToolCallID,
ToolName: ev.ToolName,
ToolKind: ev.ToolKind,
})
}
})
}
if runner.HasHandlers(extensions.ToolCallInputDelta) {
m.Subscribe(func(e Event) {
if ev, ok := e.(ToolCallDeltaEvent); ok {
_, _ = runner.Emit(extensions.ToolCallInputDeltaEvent{
ToolCallID: ev.ToolCallID,
Delta: ev.Delta,
})
}
})
}
if runner.HasHandlers(extensions.ToolCallInputEnd) {
m.Subscribe(func(e Event) {
if ev, ok := e.(ToolCallEndEvent); ok {
_, _ = runner.Emit(extensions.ToolCallInputEndEvent{
ToolCallID: ev.ToolCallID,
})
}
})
}
if runner.HasHandlers(extensions.AgentEnd) {
m.Subscribe(func(e Event) {
if ev, ok := e.(TurnEndEvent); ok {
@@ -324,4 +356,134 @@ func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
return nil
})
}
// --- Step lifecycle observation events ---
if runner.HasHandlers(extensions.StepStart) {
m.Subscribe(func(e Event) {
if ev, ok := e.(StepStartEvent); ok {
_, _ = runner.Emit(extensions.StepStartEvent{StepNumber: ev.StepNumber})
}
})
}
if runner.HasHandlers(extensions.StepFinish) {
m.Subscribe(func(e Event) {
if ev, ok := e.(StepFinishEvent); ok {
_, _ = runner.Emit(extensions.StepFinishEvent{
StepNumber: ev.StepNumber,
HasToolCalls: ev.HasToolCalls,
FinishReason: ev.FinishReason,
InputTokens: ev.Usage.InputTokens,
OutputTokens: ev.Usage.OutputTokens,
CacheReadTokens: ev.Usage.CacheReadTokens,
CacheWriteTokens: ev.Usage.CacheCreationTokens,
})
}
})
}
if runner.HasHandlers(extensions.ReasoningStart) {
m.Subscribe(func(e Event) {
if ev, ok := e.(ReasoningStartEvent); ok {
_, _ = runner.Emit(extensions.ReasoningStartEvent{ID: ev.ID})
}
})
}
if runner.HasHandlers(extensions.Warnings) {
m.Subscribe(func(e Event) {
if ev, ok := e.(WarningsEvent); ok {
_, _ = runner.Emit(extensions.WarningsEvent{Warnings: ev.Warnings})
}
})
}
if runner.HasHandlers(extensions.Source) {
m.Subscribe(func(e Event) {
if ev, ok := e.(SourceEvent); ok {
_, _ = runner.Emit(extensions.SourceEvent{
SourceType: ev.SourceType,
ID: ev.ID,
URL: ev.URL,
Title: ev.Title,
})
}
})
}
if runner.HasHandlers(extensions.Error) {
m.Subscribe(func(e Event) {
if ev, ok := e.(ErrorEvent); ok {
_, _ = runner.Emit(extensions.ErrorEvent{Error: ev.Error.Error()})
}
})
}
if runner.HasHandlers(extensions.Retry) {
m.Subscribe(func(e Event) {
if ev, ok := e.(RetryEvent); ok {
_, _ = runner.Emit(extensions.RetryEvent{
Attempt: ev.Attempt,
Error: ev.Error.Error(),
})
}
})
}
// --- PrepareStep hook ---
// Extension PrepareStep → SDK PrepareStep hook.
// Same pattern as ContextPrepare: convert LLMMessage ↔ ContextMessage.
if runner.HasHandlers(extensions.PrepareStep) {
m.OnPrepareStep(HookPriorityNormal, func(h PrepareStepHook) *PrepareStepResult {
// Convert LLM message slice to extension ContextMessage slice.
extMsgs := make([]extensions.ContextMessage, len(h.Messages))
for i, msg := range h.Messages {
var sb strings.Builder
for _, part := range msg.Content {
if tp, ok := part.(LLMTextPart); ok {
sb.WriteString(tp.Text)
}
}
extMsgs[i] = extensions.ContextMessage{
Index: i,
Role: string(msg.Role),
Content: sb.String(),
}
}
result, _ := runner.Emit(extensions.PrepareStepEvent{
StepNumber: h.StepNumber,
Messages: extMsgs,
})
r, ok := result.(extensions.PrepareStepResult)
if !ok || r.Messages == nil {
return nil
}
// Rebuild LLM message slice from extension result.
rebuilt := make([]LLMMessage, 0, len(r.Messages))
for _, cm := range r.Messages {
if cm.Index >= 0 && cm.Index < len(h.Messages) {
rebuilt = append(rebuilt, h.Messages[cm.Index])
} else {
role := LLMRoleUser
switch cm.Role {
case "assistant":
role = LLMRoleAssistant
case "system":
role = LLMRoleSystem
case "tool":
role = LLMRoleTool
}
rebuilt = append(rebuilt, LLMMessage{
Role: role,
Content: []LLMMessagePart{LLMTextPart{Text: cm.Content}},
})
}
}
return &PrepareStepResult{Messages: rebuilt}
})
}
}
+39
View File
@@ -121,6 +121,32 @@ type BeforeCompactResult struct {
Summary string
}
// PrepareStepHook is the input for hooks that fire between steps within a
// multi-step agent turn, with full message replacement capability. This is
// the most powerful interception point — it fires after the existing steering
// logic (if any) and before the messages are sent to the LLM.
//
// Use cases:
// - Transforming tool results (e.g. converting image tool results to FilePart
// user messages for vision models that don't support media in tool results)
// - Dynamic tool filtering per step
// - Mid-turn context injection beyond simple steering
// - Custom stop conditions that inspect message history
type PrepareStepHook struct {
// StepNumber is the zero-based step index within the current turn.
StepNumber int
// Messages is the current context window that will be sent to the LLM.
// This includes any steering messages already injected in this step.
Messages []LLMMessage
}
// PrepareStepResult can replace the context window between steps.
type PrepareStepResult struct {
// Messages replaces the entire context window for this step. If nil,
// the original messages (including any steering) are used unchanged.
Messages []LLMMessage
}
// ---------------------------------------------------------------------------
// Generic hook registry with priority ordering
// ---------------------------------------------------------------------------
@@ -248,6 +274,19 @@ func (m *Kit) OnBeforeCompact(p HookPriority, h func(BeforeCompactHook) *BeforeC
return m.beforeCompact.register(p, h)
}
// OnPrepareStep registers a hook that fires between steps within a multi-step
// agent turn, after steering messages are injected and before the messages are
// sent to the LLM. Return a non-nil PrepareStepResult with Messages to replace
// the entire context window for this step. Hooks execute in priority order;
// the first non-nil result wins. Returns an unregister function.
//
// This is the most powerful interception point in the agent lifecycle. It
// enables patterns like transforming tool results, dynamic tool filtering,
// and mid-turn context injection.
func (m *Kit) OnPrepareStep(p HookPriority, h func(PrepareStepHook) *PrepareStepResult) func() {
return m.prepareStep.register(p, h)
}
// ---------------------------------------------------------------------------
// Tool wrapping via hooks
// ---------------------------------------------------------------------------
+72
View File
@@ -538,3 +538,75 @@ func TestKit_HookMethodsExist(t *testing.T) {
u3()
u4()
}
// TestPrepareStepHookRegistry verifies registration and execution of PrepareStep hooks.
func TestPrepareStepHookRegistry(t *testing.T) {
hr := newHookRegistry[PrepareStepHook, PrepareStepResult]()
// Register a hook that appends a message.
hr.register(HookPriorityNormal, func(h PrepareStepHook) *PrepareStepResult {
if h.StepNumber == 0 {
// On step 0, prepend a system message.
newMsgs := make([]LLMMessage, 0, len(h.Messages)+1)
newMsgs = append(newMsgs, fantasy.NewSystemMessage("injected"))
newMsgs = append(newMsgs, h.Messages...)
return &PrepareStepResult{Messages: newMsgs}
}
return nil // No modification for other steps.
})
// Test step 0 — should modify messages.
input := PrepareStepHook{
StepNumber: 0,
Messages: []LLMMessage{fantasy.NewUserMessage("hello")},
}
result := hr.run(input)
if result == nil {
t.Fatal("expected non-nil result for step 0")
}
if len(result.Messages) != 2 {
t.Fatalf("expected 2 messages, got %d", len(result.Messages))
}
if result.Messages[0].Role != fantasy.MessageRoleSystem {
t.Errorf("expected system message first, got role %q", result.Messages[0].Role)
}
// Test step 1 — should return nil (no modification).
input.StepNumber = 1
result = hr.run(input)
if result != nil {
t.Errorf("expected nil result for step 1, got %+v", result)
}
}
// TestPrepareStepHookPriority verifies that PrepareStep hooks respect priority ordering.
func TestPrepareStepHookPriority(t *testing.T) {
hr := newHookRegistry[PrepareStepHook, PrepareStepResult]()
var order []string
// Low priority — should run second.
hr.register(HookPriorityLow, func(_ PrepareStepHook) *PrepareStepResult {
order = append(order, "low")
return nil
})
// High priority — should run first and win.
hr.register(HookPriorityHigh, func(h PrepareStepHook) *PrepareStepResult {
order = append(order, "high")
return &PrepareStepResult{Messages: h.Messages}
})
input := PrepareStepHook{
StepNumber: 0,
Messages: []LLMMessage{fantasy.NewUserMessage("test")},
}
result := hr.run(input)
if result == nil {
t.Fatal("expected non-nil result")
}
if len(order) != 1 || order[0] != "high" {
t.Errorf("expected [high] (first non-nil wins), got %v", order)
}
}
+127 -27
View File
@@ -66,6 +66,7 @@ type Kit struct {
afterTurn *hookRegistry[AfterTurnHook, AfterTurnResult]
contextPrepare *hookRegistry[ContextPrepareHook, ContextPrepareResult]
beforeCompact *hookRegistry[BeforeCompactHook, BeforeCompactResult]
prepareStep *hookRegistry[PrepareStepHook, PrepareStepResult]
// lastInputTokens stores the API-reported input token count from the
// most recent turn. Used by GetContextStats() to return accurate usage
@@ -543,6 +544,23 @@ func (m *Kit) SetModel(ctx context.Context, modelString string) error {
systemPrompt, _ := config.LoadSystemPrompt(viper.GetString("system-prompt"))
thinkingLevel := models.ParseThinkingLevel(viper.GetString("thinking-level"))
// Validate and adjust thinking level for the target model.
// Some models (e.g., OpenAI gpt-5.4) don't support "minimal" and require "none".
if thinkingLevel != models.ThinkingOff {
parts := strings.SplitN(modelString, "/", 2)
if len(parts) == 2 {
modelName := parts[1]
if !models.IsValidThinkingLevelForModel(thinkingLevel, modelName) {
fallback := models.SuggestThinkingLevelFallback(thinkingLevel, modelName)
if fallback != models.ThinkingOff {
// Adjust the thinking level in viper so the change persists.
viper.Set("thinking-level", string(fallback))
thinkingLevel = fallback
}
}
}
}
// With message-level caching, thinking and caching can work together.
// No need to disable caching when thinking is enabled.
cfg := &models.ProviderConfig{
@@ -866,10 +884,10 @@ type Options struct {
MaxTokens int
// ThinkingLevel sets the reasoning effort for models that support
// extended thinking. Valid values: "off", "low", "medium", "high".
// "" = let the precedence chain resolve a level (env → config →
// per-model → "off"). Use [Kit.SetThinkingLevel] to change at
// runtime.
// extended thinking. Valid values: "off", "none", "minimal", "low",
// "medium", "high". "" = let the precedence chain resolve a level
// (env → config → per-model → "off"). Use [Kit.SetThinkingLevel]
// to change at runtime.
ThinkingLevel string
// Temperature controls sampling randomness (typically 0.02.0).
@@ -1351,6 +1369,7 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
afterTurn := newHookRegistry[AfterTurnHook, AfterTurnResult]()
contextPrepare := newHookRegistry[ContextPrepareHook, ContextPrepareResult]()
beforeCompact := newHookRegistry[BeforeCompactHook, BeforeCompactResult]()
prepareStep := newHookRegistry[PrepareStepHook, PrepareStepResult]()
// Build agent setup options, pulling CLI-specific fields when available.
// Pass the pre-built ProviderConfig and scalar viper snapshots so
@@ -1444,6 +1463,7 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
afterTurn: afterTurn,
contextPrepare: contextPrepare,
beforeCompact: beforeCompact,
prepareStep: prepareStep,
}
// Bridge extension events to SDK hooks.
@@ -1764,12 +1784,19 @@ func (m *Kit) Subagent(ctx context.Context, cfg SubagentConfig) (*SubagentResult
// Create child Kit instance. Pass the parent's loaded MCP config to
// avoid re-reading viper (which races with concurrent subagent spawns).
// Streaming must be explicitly enabled — Options.Streaming defaults to
// false, and New() unconditionally writes viper.Set("stream", opts.Streaming).
// Without this, the subagent would (a) pollute viper global state for
// other concurrent callers and (b) potentially hit provider-level
// differences (e.g. Anthropic non-streaming timeouts with extended
// thinking).
childOpts := &Options{
Model: model,
SystemPrompt: systemPrompt,
Tools: tools,
NoSession: cfg.NoSession,
Quiet: true,
Streaming: true,
MCPConfig: m.mcpConfig,
}
child, err := New(ctx, childOpts)
@@ -1877,21 +1904,21 @@ func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent.
return sr, err
})
return m.agent.GenerateWithLoopAndStreaming(ctx, messages,
func(toolCallID, toolName, toolArgs string) {
return m.agent.GenerateWithCallbacks(ctx, messages, agent.GenerateCallbacks{
OnToolCall: func(toolCallID, toolName, toolArgs string) {
m.events.emit(ToolCallEvent{
ToolCallID: toolCallID, ToolName: toolName, ToolKind: toolKindFor(toolName),
ToolArgs: toolArgs, ParsedArgs: parseToolArgs(toolArgs),
})
},
func(toolCallID, toolName, toolArgs string, isStarting bool) {
OnToolExecution: func(toolCallID, toolName, toolArgs string, isStarting bool) {
if isStarting {
m.events.emit(ToolExecutionStartEvent{ToolCallID: toolCallID, ToolName: toolName, ToolKind: toolKindFor(toolName), ToolArgs: toolArgs})
} else {
m.events.emit(ToolExecutionEndEvent{ToolCallID: toolCallID, ToolName: toolName, ToolKind: toolKindFor(toolName)})
}
},
func(toolCallID, toolName, toolArgs, resultText, metadata string, isError bool) {
OnToolResult: func(toolCallID, toolName, toolArgs, resultText, metadata string, isError bool) {
evt := ToolResultEvent{
ToolCallID: toolCallID, ToolName: toolName, ToolKind: toolKindFor(toolName),
ToolArgs: toolArgs, ParsedArgs: parseToolArgs(toolArgs),
@@ -1905,17 +1932,17 @@ func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent.
}
m.events.emit(evt)
},
func(content string) {
OnResponse: func(content string) {
m.events.emit(ResponseEvent{Content: content})
},
func(content string) {
OnToolCallContent: func(content string) {
m.events.emit(ToolCallContentEvent{Content: content})
},
// <think> tag filtering: models like Qwen/DeepSeek wrap reasoning inside
// <think>...</think> tags in the regular text stream. We intercept those
// spans here and re-route them as ReasoningDeltaEvent/ReasoningCompleteEvent
// so callers always receive clean, tag-free text and structured reasoning.
func() func(chunk string) {
OnStreamingResponse: func() func(chunk string) {
const (
thinkOpen = "<think>"
thinkClose = "</think>"
@@ -1951,14 +1978,13 @@ func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent.
}
}
}(),
func(delta string) {
OnReasoningDelta: func(delta string) {
m.events.emit(ReasoningDeltaEvent{Delta: delta})
},
func() {
OnReasoningComplete: func() {
m.events.emit(ReasoningCompleteEvent{})
},
func(toolCallID, toolName, chunk string, isStderr bool) {
// Emit tool output chunk event for streaming bash output
OnToolOutput: func(toolCallID, toolName, chunk string, isStderr bool) {
m.events.emit(ToolOutputEvent{
ToolCallID: toolCallID,
ToolName: toolName,
@@ -1967,18 +1993,13 @@ func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent.
})
},
// Persist step messages incrementally so that progress survives
// crashes and long-running turns don't lose work. Each step's
// messages are persisted as a unit: for tool-calling steps this is
// the assistant message (with tool_use parts) + tool-role message
// (with tool_result parts) as a pair; for the final step it's the
// assistant text/reasoning message alone.
func(stepMessages []fantasy.Message) {
// crashes and long-running turns don't lose work.
OnStepMessages: func(stepMessages []fantasy.Message) {
for _, msg := range stepMessages {
_, _ = m.session.AppendMessage(msg)
}
},
func(inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens int64) {
// Emit step usage event for real-time cost tracking
OnStepUsage: func(inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens int64) {
if viper.GetBool("debug") {
log.Printf("DEBUG Kit.generate emitting StepUsageEvent: input=%d output=%d cacheRead=%d cacheCreate=%d",
inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens,
@@ -1992,18 +2013,97 @@ func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent.
})
},
// Password prompt handler for sudo commands
func(prompt string) (string, bool) {
// Emit event to TUI and wait for response via channel
OnPasswordPrompt: func(prompt string) (string, bool) {
responseCh := make(chan PasswordPromptResponse, 1)
m.events.emit(PasswordPromptEvent{
Prompt: prompt,
ResponseCh: responseCh,
})
// Wait for response (TUI will send password or cancel)
resp := <-responseCh
return resp.Password, resp.Cancelled
},
)
// Tool call argument streaming
OnToolCallStart: func(toolCallID, toolName string) {
m.events.emit(ToolCallStartEvent{
ToolCallID: toolCallID,
ToolName: toolName,
ToolKind: toolKindFor(toolName),
})
},
OnToolCallDelta: func(toolCallID, delta string) {
m.events.emit(ToolCallDeltaEvent{
ToolCallID: toolCallID,
Delta: delta,
})
},
OnToolCallEnd: func(toolCallID string) {
m.events.emit(ToolCallEndEvent{
ToolCallID: toolCallID,
})
},
// New callbacks for previously unwired Fantasy lifecycle events.
OnStepStart: func(stepNumber int) {
m.events.emit(StepStartEvent{StepNumber: stepNumber})
},
OnStepFinish: func(stepNumber int, hasToolCalls bool, finishReason string, usage fantasy.Usage) {
m.events.emit(StepFinishEvent{
StepNumber: stepNumber,
HasToolCalls: hasToolCalls,
FinishReason: finishReason,
Usage: usage,
})
},
OnTextStart: func(id string) {
m.events.emit(TextStartEvent{ID: id})
},
OnTextEnd: func(id string) {
m.events.emit(TextEndEvent{ID: id})
},
OnReasoningStart: func(id string) {
m.events.emit(ReasoningStartEvent{ID: id})
},
OnWarnings: func(warnings []string) {
m.events.emit(WarningsEvent{Warnings: warnings})
},
OnSource: func(sourceType, id, url, title string) {
m.events.emit(SourceEvent{
SourceType: sourceType,
ID: id,
URL: url,
Title: title,
})
},
OnStreamFinish: func(usage fantasy.Usage, finishReason string) {
m.events.emit(StreamFinishEvent{
Usage: usage,
FinishReason: finishReason,
})
},
OnError: func(err error) {
m.events.emit(ErrorEvent{Error: err})
},
OnRetry: func(attempt int, err error) {
m.events.emit(RetryEvent{Attempt: attempt, Error: err})
},
// PrepareStep hook — compose with steering (handled in agent layer)
// and then run SDK consumer hooks.
OnPrepareStep: func() agent.PrepareStepHandler {
if !m.prepareStep.hasHooks() {
return nil
}
return func(stepNumber int, messages []fantasy.Message) []fantasy.Message {
hookResult := m.prepareStep.run(PrepareStepHook{
StepNumber: stepNumber,
Messages: messages,
})
if hookResult != nil && hookResult.Messages != nil {
return hookResult.Messages
}
return nil
}
}(),
})
}
// runTurn is the shared lifecycle for every prompt mode:
+52 -22
View File
@@ -2,6 +2,7 @@ package kit
import (
"context"
"strings"
"charm.land/fantasy"
@@ -52,6 +53,22 @@ func ErrorResult(content string) ToolOutput {
return ToolOutput{Content: content, IsError: true}
}
// ImageResult creates a [ToolOutput] that returns an image to the LLM.
// The data is the raw image bytes and mediaType is the MIME type
// (e.g. "image/png", "image/jpeg"). The optional text content accompanies
// the image and is visible to the LLM alongside it.
func ImageResult(content string, data []byte, mediaType string) ToolOutput {
return ToolOutput{Content: content, Data: data, MediaType: mediaType}
}
// MediaResult creates a [ToolOutput] that returns non-image binary media
// (e.g. audio, video) to the LLM. The data is the raw bytes and mediaType
// is the MIME type (e.g. "audio/wav", "video/mp4"). The optional text
// content accompanies the media.
func MediaResult(content string, data []byte, mediaType string) ToolOutput {
return ToolOutput{Content: content, Data: data, MediaType: mediaType}
}
// toolCallIDKey is the context key for the tool call ID.
type toolCallIDKey struct{}
@@ -63,9 +80,35 @@ func ToolCallIDFromContext(ctx context.Context) string {
return s
}
// toolOutputToResponse converts a [ToolOutput] into the underlying
// framework's ToolResponse, inferring the response Type from Data/MediaType
// so that binary content (images, audio, etc.) is forwarded to the LLM
// instead of being silently dropped.
func toolOutputToResponse(result ToolOutput) fantasy.ToolResponse {
resp := fantasy.ToolResponse{
Content: result.Content,
IsError: result.IsError,
Data: result.Data,
MediaType: result.MediaType,
}
// Infer response type from binary data so the downstream framework
// creates a media content block instead of a plain-text one.
if len(result.Data) > 0 && result.MediaType != "" {
if strings.HasPrefix(result.MediaType, "image/") {
resp.Type = "image"
} else {
resp.Type = "media"
}
}
if result.Metadata != nil {
resp = fantasy.WithResponseMetadata(resp, result.Metadata)
}
return resp
}
// NewTool creates a custom [Tool] with automatic JSON schema generation from
// the TInput struct type. The handler receives a typed input (deserialized
// from the LLM's JSON arguments) and returns a [ToolResult].
// from the LLM's JSON arguments) and returns a [ToolOutput].
//
// Struct tags on TInput control the generated schema:
//
@@ -77,6 +120,11 @@ func ToolCallIDFromContext(ctx context.Context) string {
// The tool call ID is injected into the context and can be retrieved with
// [ToolCallIDFromContext].
//
// Binary results: When [ToolOutput.Data] and [ToolOutput.MediaType] are set,
// the response type is automatically inferred so the LLM receives the binary
// content (e.g. an image) instead of only the text. Use [ImageResult] or
// [MediaResult] for convenience.
//
// Example:
//
// type WeatherInput struct {
@@ -84,7 +132,7 @@ func ToolCallIDFromContext(ctx context.Context) string {
// }
//
// tool := kit.NewTool("get_weather", "Get weather for a city",
// func(ctx context.Context, input WeatherInput) (kit.ToolResult, error) {
// func(ctx context.Context, input WeatherInput) (kit.ToolOutput, error) {
// return kit.TextResult("72°F, sunny in " + input.City), nil
// },
// )
@@ -96,16 +144,7 @@ func NewTool[TInput any](name, description string, fn func(ctx context.Context,
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
resp := fantasy.ToolResponse{
Content: result.Content,
IsError: result.IsError,
Data: result.Data,
MediaType: result.MediaType,
}
if result.Metadata != nil {
resp = fantasy.WithResponseMetadata(resp, result.Metadata)
}
return resp, nil
return toolOutputToResponse(result), nil
},
)
}
@@ -121,16 +160,7 @@ func NewParallelTool[TInput any](name, description string, fn func(ctx context.C
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
resp := fantasy.ToolResponse{
Content: result.Content,
IsError: result.IsError,
Data: result.Data,
MediaType: result.MediaType,
}
if result.Metadata != nil {
resp = fantasy.WithResponseMetadata(resp, result.Metadata)
}
return resp, nil
return toolOutputToResponse(result), nil
},
)
}
+146
View File
@@ -117,3 +117,149 @@ func TestToolOutput_BinaryData(t *testing.T) {
t.Errorf("MediaType = %q, want %q", r.MediaType, "image/png")
}
}
// TestImageResult verifies the ImageResult convenience constructor.
func TestImageResult(t *testing.T) {
data := []byte{0x89, 0x50, 0x4E, 0x47}
r := kit.ImageResult("here is the image", data, "image/png")
if r.Content != "here is the image" {
t.Errorf("Content = %q, want %q", r.Content, "here is the image")
}
if len(r.Data) != 4 {
t.Errorf("Data len = %d, want 4", len(r.Data))
}
if r.MediaType != "image/png" {
t.Errorf("MediaType = %q, want %q", r.MediaType, "image/png")
}
if r.IsError {
t.Error("ImageResult should not set IsError")
}
}
// TestMediaResult verifies the MediaResult convenience constructor.
func TestMediaResult(t *testing.T) {
data := []byte{0xFF, 0xFB, 0x90, 0x00}
r := kit.MediaResult("audio clip", data, "audio/mpeg")
if r.Content != "audio clip" {
t.Errorf("Content = %q, want %q", r.Content, "audio clip")
}
if len(r.Data) != 4 {
t.Errorf("Data len = %d, want 4", len(r.Data))
}
if r.MediaType != "audio/mpeg" {
t.Errorf("MediaType = %q, want %q", r.MediaType, "audio/mpeg")
}
if r.IsError {
t.Error("MediaResult should not set IsError")
}
}
// TestNewTool_BinaryImageResponse verifies that NewTool correctly infers the
// response type for image data so binary content is forwarded to the LLM
// (issue #17).
func TestNewTool_BinaryImageResponse(t *testing.T) {
type Input struct {
Path string `json:"path"`
}
imgData := []byte{0x89, 0x50, 0x4E, 0x47} // PNG magic bytes
tool := kit.NewTool("read_image", "Read an image file",
func(ctx context.Context, input Input) (kit.ToolOutput, error) {
return kit.ImageResult("Here is the image", imgData, "image/png"), nil
},
)
// Run the tool and inspect the raw ToolResponse via the AgentTool interface.
resp, err := tool.Run(context.Background(), kit.LLMToolCall{
ID: "call_1",
Name: "read_image",
Input: `{"path": "test.png"}`,
})
if err != nil {
t.Fatalf("Run() error: %v", err)
}
// The Type field must be "image" so the downstream framework creates a
// media content block instead of discarding the binary data.
if resp.Type != "image" {
t.Errorf("ToolResponse.Type = %q, want %q", resp.Type, "image")
}
if len(resp.Data) != 4 {
t.Errorf("ToolResponse.Data len = %d, want 4", len(resp.Data))
}
if resp.MediaType != "image/png" {
t.Errorf("ToolResponse.MediaType = %q, want %q", resp.MediaType, "image/png")
}
if resp.Content != "Here is the image" {
t.Errorf("ToolResponse.Content = %q, want %q", resp.Content, "Here is the image")
}
}
// TestNewTool_BinaryMediaResponse verifies type inference for non-image media.
func TestNewTool_BinaryMediaResponse(t *testing.T) {
type Input struct{}
tool := kit.NewTool("get_audio", "Get audio",
func(ctx context.Context, input Input) (kit.ToolOutput, error) {
return kit.MediaResult("audio clip", []byte{0xFF, 0xFB}, "audio/mpeg"), nil
},
)
resp, err := tool.Run(context.Background(), kit.LLMToolCall{
ID: "call_2",
Name: "get_audio",
Input: `{}`,
})
if err != nil {
t.Fatalf("Run() error: %v", err)
}
if resp.Type != "media" {
t.Errorf("ToolResponse.Type = %q, want %q", resp.Type, "media")
}
}
// TestNewTool_TextResponseTypeNotSet verifies that text-only responses do NOT
// get an inferred type (preserving existing behavior).
func TestNewTool_TextResponseTypeNotSet(t *testing.T) {
type Input struct{}
tool := kit.NewTool("echo", "Echo",
func(ctx context.Context, input Input) (kit.ToolOutput, error) {
return kit.TextResult("hello"), nil
},
)
resp, err := tool.Run(context.Background(), kit.LLMToolCall{
ID: "call_3", Name: "echo", Input: `{}`,
})
if err != nil {
t.Fatalf("Run() error: %v", err)
}
// Text responses should not have Type set (the framework treats "" as text).
if resp.Type != "" {
t.Errorf("ToolResponse.Type = %q, want empty string for text responses", resp.Type)
}
}
// TestNewParallelTool_BinaryImageResponse mirrors the NewTool binary test for
// NewParallelTool.
func TestNewParallelTool_BinaryImageResponse(t *testing.T) {
type Input struct{}
tool := kit.NewParallelTool("snap", "Take a snapshot",
func(ctx context.Context, input Input) (kit.ToolOutput, error) {
return kit.ImageResult("snapshot", []byte{0xFF, 0xD8}, "image/jpeg"), nil
},
)
resp, err := tool.Run(context.Background(), kit.LLMToolCall{
ID: "call_4", Name: "snap", Input: `{}`,
})
if err != nil {
t.Fatalf("Run() error: %v", err)
}
if resp.Type != "image" {
t.Errorf("ToolResponse.Type = %q, want %q", resp.Type, "image")
}
}
+12
View File
@@ -157,6 +157,18 @@ type LLMTextPart = fantasy.TextPart
// LLMReasoningPart is a reasoning/chain-of-thought content part.
type LLMReasoningPart = fantasy.ReasoningPart
// LLMToolCall represents the raw tool invocation passed to a [Tool]'s Run
// method. It carries the call ID, tool name, and the JSON-encoded input
// arguments from the LLM. This is the execution-layer call object — distinct
// from [ToolCall] (a message content part).
type LLMToolCall = fantasy.ToolCall
// LLMToolResponse represents the raw response returned from a [Tool]'s Run
// method. Most SDK consumers should use [ToolOutput] with [NewTool] /
// [NewParallelTool] instead — this alias is provided for advanced use cases
// that need to call Tool.Run() directly (e.g. testing).
type LLMToolResponse = fantasy.ToolResponse
// LLMToolCallPart represents an LLM-initiated tool invocation within a message.
type LLMToolCallPart = fantasy.ToolCallPart
+184 -34
View File
@@ -3,10 +3,13 @@
ACP smoke test drives `kit acp` over JSON-RPC 2.0 stdio.
Protocol flow:
1. session/new get sessionId
2. session/set_model set opencode/kimi-k2.5
3. session/prompt "What is 2+2? Answer in one sentence."
4. Collect session updates until done
1. initialize negotiate capabilities
2. session/new get sessionId
3. session/list verify session listing works
4. session/set_config_option set model
5. session/prompt "What is 2+2? Answer in one sentence."
6. Collect session/update notifications until prompt response
7. session/cancel verify cancel is accepted (no-op since prompt is done)
"""
import json
@@ -21,9 +24,24 @@ MODEL = os.environ.get("MODEL", "opencode/kimi-k2.5")
CWD = os.path.expanduser("~")
TIMEOUT = 60 # seconds to wait for the prompt to complete
# Request ID counter — initialize=1, session/new=2, etc.
_next_id = 0
def rpc(method, params, req_id):
return json.dumps({"jsonrpc": "2.0", "id": req_id, "method": method, "params": params}) + "\n"
def next_id():
global _next_id
_next_id += 1
return _next_id
def rpc_request(method, params):
"""Build a JSON-RPC 2.0 request with auto-incrementing ID."""
return json.dumps({"jsonrpc": "2.0", "id": next_id(), "method": method, "params": params}) + "\n"
def rpc_notification(method, params):
"""Build a JSON-RPC 2.0 notification (no id)."""
return json.dumps({"jsonrpc": "2.0", "method": method, "params": params}) + "\n"
def send(proc, line):
@@ -32,7 +50,7 @@ def send(proc, line):
proc.stdin.flush()
def read_responses(proc, collected, done_event):
def read_responses(proc, collected, done_event, prompt_id):
"""Read newline-delimited JSON from stdout until process exits."""
for raw in proc.stdout:
raw = raw.strip()
@@ -50,32 +68,49 @@ def read_responses(proc, collected, done_event):
if "result" in msg:
result = msg["result"]
print(f"← RESP id={msg.get('id')} result={json.dumps(result)[:200]}", flush=True)
# Prompt complete when we get a stopReason on id=3
if msg.get("id") == 3 and "stopReason" in result:
# Prompt complete when we get a stopReason on the prompt request ID
if msg.get("id") == prompt_id and "stopReason" in result:
done_event.set()
elif "error" in msg:
print(f"← ERROR id={msg.get('id')} {json.dumps(msg['error'])}", flush=True)
# If it's the prompt call that errored, unblock
if msg.get("id") == 3:
if msg.get("id") == prompt_id:
done_event.set()
elif "method" in msg:
# Notification / session update
m = msg.get("method", "")
p = msg.get("params", {})
if m in ("session/update", "session/updated"):
if m == "session/update":
update = p.get("update", {})
stype = update.get("sessionUpdate") or update.get("type", "?")
stype = update.get("sessionUpdate", "?")
content = update.get("content", {})
text = content.get("text", "")
if stype == "agent_thought_chunk":
print(f" [thinking] {content.get('text','')}", end="", flush=True)
print(f" [thinking] {text}", end="", flush=True)
elif stype == "agent_message_chunk":
print(f" [response] {content.get('text','')}", end="", flush=True)
print(f" [response] {text}", end="", flush=True)
elif stype in ("tool_call", "tool_call_update"):
title = update.get("title", update.get("toolCallId", "?"))
status = update.get("status", "?")
print(f"\n [{stype}] {title} ({status})", flush=True)
else:
print(f"\n [update/{stype}] {json.dumps(update)[:200]}", flush=True)
else:
print(f"\n← NOTIF {m} {json.dumps(p)[:200]}", flush=True)
def wait_for_response(collected, req_id, timeout=5.0, label="response"):
"""Block until we have a response for the given request ID."""
deadline = time.time() + timeout
while time.time() < deadline:
for msg in collected:
if msg.get("id") == req_id and ("result" in msg or "error" in msg):
return msg
time.sleep(0.1)
print(f"\n✗ FAIL: timed out waiting for {label} (id={req_id})", flush=True)
return None
def main():
print(f"Starting: {KIT_BIN} acp -m {MODEL}", flush=True)
@@ -91,8 +126,13 @@ def main():
collected = []
done_event = threading.Event()
reader = threading.Thread(target=read_responses, args=(proc, collected, done_event), daemon=True)
reader.start()
# We'll set the prompt_id once we know it
prompt_id_holder = [None]
# Start reader thread — prompt_id will be set before prompt is sent
class ReaderThread(threading.Thread):
def run(self):
read_responses(proc, collected, done_event, prompt_id_holder[0])
stderr_lines = []
def read_stderr():
@@ -105,16 +145,55 @@ def main():
time.sleep(0.3) # let the process initialise
# 1. session/new
send(proc, rpc("session/new", {"cwd": CWD, "mcpServers": []}, 1))
# ── Step 1: initialize ──────────────────────────────────────────────
init_id = next_id()
send(proc, json.dumps({
"jsonrpc": "2.0",
"id": init_id,
"method": "initialize",
"params": {
"protocolVersion": 1,
"clientCapabilities": {
"fs": {"readTextFile": False, "writeTextFile": False},
},
"clientInfo": {"name": "acp-smoke-test", "version": "1.0.0"},
},
}) + "\n")
# Start a simple reader for the initialize response
reader = threading.Thread(target=read_responses, args=(proc, collected, done_event, None), daemon=True)
reader.start()
time.sleep(1.0)
session_id = None
for msg in collected:
if msg.get("id") == 1 and "result" in msg:
session_id = msg["result"].get("sessionId")
break
init_resp = wait_for_response(collected, init_id, timeout=5, label="initialize")
if not init_resp or "error" in init_resp:
print(f"\n✗ FAIL: initialize failed: {init_resp}", flush=True)
proc.terminate()
sys.exit(1)
result = init_resp["result"]
proto_ver = result.get("protocolVersion")
agent_info = result.get("agentInfo", {})
print(f"\n✓ Initialized: protocol_version={proto_ver} agent={agent_info.get('name', '?')} v{agent_info.get('version', '?')}", flush=True)
# ── Step 2: session/new ─────────────────────────────────────────────
new_session_id = next_id()
send(proc, json.dumps({
"jsonrpc": "2.0",
"id": new_session_id,
"method": "session/new",
"params": {"cwd": CWD, "mcpServers": []},
}) + "\n")
time.sleep(1.0)
session_resp = wait_for_response(collected, new_session_id, timeout=10, label="session/new")
if not session_resp or "error" in session_resp:
print(f"\n✗ FAIL: session/new failed: {session_resp}", flush=True)
proc.terminate()
sys.exit(1)
session_id = session_resp["result"].get("sessionId")
if not session_id:
print("\n✗ FAIL: did not get sessionId from session/new", flush=True)
proc.terminate()
@@ -122,31 +201,102 @@ def main():
print(f"\n✓ Got sessionId: {session_id}", flush=True)
# 2. session/set_model (model already set via -m flag, but exercise the RPC)
send(proc, rpc("session/set_model", {"sessionId": session_id, "modelId": MODEL}, 2))
# ── Step 3: session/list ────────────────────────────────────────────
list_id = next_id()
send(proc, json.dumps({
"jsonrpc": "2.0",
"id": list_id,
"method": "session/list",
"params": {},
}) + "\n")
time.sleep(0.5)
# 3. session/prompt
list_resp = wait_for_response(collected, list_id, timeout=5, label="session/list")
if not list_resp:
print("\n⚠ WARN: session/list timed out (non-fatal)", flush=True)
elif "error" in list_resp:
print(f"\n⚠ WARN: session/list returned error: {list_resp['error']} (non-fatal)", flush=True)
else:
sessions = list_resp["result"].get("sessions", [])
print(f"\n✓ session/list returned {len(sessions)} session(s)", flush=True)
# ── Step 4: session/set_config_option (model) ───────────────────────
# Uses the new session/set_config_option method (replaces the old session/set_model).
# The model is already set via -m flag, but we exercise the RPC to verify it works.
config_id = next_id()
send(proc, json.dumps({
"jsonrpc": "2.0",
"id": config_id,
"method": "session/set_config_option",
"params": {
"sessionId": session_id,
"configId": "model",
"value": MODEL,
},
}) + "\n")
time.sleep(0.5)
config_resp = wait_for_response(collected, config_id, timeout=5, label="session/set_config_option")
if not config_resp:
print("\n⚠ WARN: session/set_config_option timed out (non-fatal)", flush=True)
elif "error" in config_resp:
print(f"\n⚠ WARN: session/set_config_option returned error: {config_resp['error']} (non-fatal)", flush=True)
else:
print(f"\n✓ session/set_config_option accepted", flush=True)
# ── Step 5: session/prompt ──────────────────────────────────────────
prompt_id = next_id()
prompt_id_holder[0] = prompt_id
# Re-wire the reader to know the prompt ID (the existing thread is already running)
# Since we can't change it mid-flight easily, we check the collected list instead.
prompt_params = {
"sessionId": session_id,
"prompt": [{"type": "text", "text": "What is 2+2? Answer in one sentence."}],
}
send(proc, rpc("session/prompt", prompt_params, 3))
send(proc, json.dumps({
"jsonrpc": "2.0",
"id": prompt_id,
"method": "session/prompt",
"params": prompt_params,
}) + "\n")
# Wait for finished update or timeout
if not done_event.wait(timeout=TIMEOUT):
print(f"\n✗ FAIL: timed out after {TIMEOUT}s waiting for finished update", flush=True)
# Wait for finished update or timeout — poll collected list
deadline = time.time() + TIMEOUT
prompt_resp = None
while time.time() < deadline:
for msg in collected:
if msg.get("id") == prompt_id and ("result" in msg or "error" in msg):
prompt_resp = msg
break
if prompt_resp:
break
time.sleep(0.2)
if not prompt_resp:
print(f"\n✗ FAIL: timed out after {TIMEOUT}s waiting for prompt response", flush=True)
proc.terminate()
sys.exit(1)
# Check we got a successful prompt response
prompt_resp = next((m for m in collected if m.get("id") == 3), None)
if prompt_resp and "error" in prompt_resp:
if "error" in prompt_resp:
print(f"\n✗ FAIL: prompt returned error: {prompt_resp['error']}", flush=True)
proc.terminate()
sys.exit(1)
print("\n✓ SMOKE TEST PASSED", flush=True)
stop_reason = prompt_resp["result"].get("stopReason", "?")
print(f"\n✓ Prompt completed: stopReason={stop_reason}", flush=True)
# ── Step 6: session/cancel (no-op, prompt already done) ─────────────
# This is a notification (no id), so no response expected.
send(proc, rpc_notification("session/cancel", {"sessionId": session_id}))
time.sleep(0.3)
print("✓ session/cancel sent (no-op)", flush=True)
# ── Summary ─────────────────────────────────────────────────────────
# Count session updates received
update_count = sum(1 for m in collected if m.get("method") == "session/update")
print(f"\n✓ SMOKE TEST PASSED ({update_count} session updates received)", flush=True)
proc.terminate()
proc.wait(timeout=5)
+32 -1
View File
@@ -55,7 +55,7 @@ The `Init` function receives an `ext.API` object for registering handlers, and e
## Lifecycle Events
Kit provides 18 lifecycle events. Each handler receives an event struct and a `Context`.
Kit provides 21 lifecycle events. Each handler receives an event struct and a `Context`.
### Session Events
@@ -136,6 +136,37 @@ api.OnToolResult(func(e ext.ToolResultEvent, ctx ext.Context) *ext.ToolResultRes
})
```
### Tool Call Input Streaming Events
These events fire during the LLM's tool argument generation phase, **before** the tool call is fully parsed and before `OnToolCall` fires. They enable UIs to show tool activity immediately rather than waiting for the full argument JSON to finish streaming.
```go
// Fires when the LLM begins generating tool call arguments.
// The tool name is known but the full argument JSON is still streaming.
api.OnToolCallInputStart(func(e ext.ToolCallInputStartEvent, ctx ext.Context) {
// e.ToolCallID string — stable ID for correlating tool lifecycle events
// e.ToolName string — name of the tool being called
// e.ToolKind string — "execute", "edit", "read", "search", "agent"
ctx.PrintInfo("Tool starting: " + e.ToolName)
})
// Fires for each streamed fragment of tool call arguments.
// Useful for live-previewing artifact content or showing a progress indicator.
api.OnToolCallInputDelta(func(e ext.ToolCallInputDeltaEvent, ctx ext.Context) {
// e.ToolCallID string
// e.Delta string — JSON fragment of tool arguments
})
// Fires when tool argument streaming is complete, before the tool call
// is parsed and execution begins. Transition UI from "generating args"
// to "executing".
api.OnToolCallInputEnd(func(e ext.ToolCallInputEndEvent, ctx ext.Context) {
// e.ToolCallID string
})
```
**Full tool lifecycle order**: `OnToolCallInputStart``OnToolCallInputDelta` (repeated) → `OnToolCallInputEnd``OnToolCall``OnToolExecutionStart``OnToolOutput` (optional, repeated) → `OnToolExecutionEnd``OnToolResult`
### Input Events
```go
+112 -4
View File
@@ -85,7 +85,7 @@ host, err := kit.New(ctx, &kit.Options{
// resolve a value (KIT_* env → .kit.yml → modelSettings/customModels →
// 8192 floor for MaxTokens, provider defaults for samplers).
MaxTokens: 16384, // 0 = auto-resolve; non-zero suppresses right-sizing
ThinkingLevel: "medium", // "off", "low", "medium", "high" ("" = default)
ThinkingLevel: "medium", // "off", "none", "minimal", "low", "medium", "high" ("" = default)
Temperature: ptrFloat32(0.2), // pointer so explicit 0.0 != unset
TopP: nil, // nil = leave provider/per-model default
TopK: nil, // nil = leave provider/per-model default
@@ -154,7 +154,7 @@ func ptrFloat32(v float32) *float32 { return &v }
| Field | Type | Empty/nil means | Notes |
|-------|------|-----------------|-------|
| `MaxTokens` | `int` | Auto-resolve (env → config → per-model → 8192 floor) | Non-zero suppresses `rightSizeMaxTokens` |
| `ThinkingLevel` | `string` | Auto-resolve (→ `"off"`) | Valid: `"off"`, `"low"`, `"medium"`, `"high"` (and `"minimal"` for some providers) |
| `ThinkingLevel` | `string` | Auto-resolve (→ `"off"`) | Valid: `"off"`, `"none"`, `"minimal"`, `"low"`, `"medium"`, `"high"` |
| `Temperature` | `*float32` | Leave provider/per-model default | Pointer so explicit `0.0` ≠ unset |
| `TopP` | `*float32` | Leave provider/per-model default | |
| `TopK` | `*int32` | Leave provider/per-model default | |
@@ -252,6 +252,25 @@ unsub := host.OnToolCall(func(e kit.ToolCallEvent) {
})
defer unsub()
host.OnToolCallStart(func(e kit.ToolCallStartEvent) {
// Fires when the LLM begins generating tool call arguments.
// e.ToolCallID, e.ToolName, e.ToolKind
// Use this to show a "running" indicator immediately — before the
// full argument JSON finishes streaming (eliminates "dead air").
})
host.OnToolCallDelta(func(e kit.ToolCallDeltaEvent) {
// Fires for each streamed fragment of tool call arguments.
// e.ToolCallID, e.Delta (JSON fragment)
// Useful for live-previewing artifact content or progress indicators.
})
host.OnToolCallEnd(func(e kit.ToolCallEndEvent) {
// Fires when tool argument streaming is complete, before execution.
// e.ToolCallID
// Transition UI from "generating args" to "executing".
})
host.OnToolResult(func(e kit.ToolResultEvent) {
// e.ToolCallID, e.ToolName, e.ToolKind, e.ToolArgs, e.ParsedArgs
// e.Result, e.IsError, e.Metadata (*ToolResultMetadata)
@@ -262,7 +281,7 @@ host.OnToolOutput(func(e kit.ToolOutputEvent) {
// Streaming bash output chunks
})
host.OnStreaming(func(e kit.MessageUpdateEvent) {
host.OnMessageUpdate(func(e kit.MessageUpdateEvent) {
fmt.Print(e.Chunk) // real-time text streaming
})
@@ -277,8 +296,64 @@ host.OnTurnStart(func(e kit.TurnStartEvent) {
host.OnTurnEnd(func(e kit.TurnEndEvent) {
// e.Response, e.Error, e.StopReason
})
host.OnStepStart(func(e kit.StepStartEvent) {
// e.StepNumber — which LLM call step (1-based)
})
host.OnStepFinish(func(e kit.StepFinishEvent) {
// e.StepNumber, e.HasToolCalls, e.FinishReason, e.Usage (LLMUsage)
})
host.OnWarnings(func(e kit.WarningsEvent) {
for _, w := range e.Warnings {
log.Printf("warning: %s", w)
}
})
host.OnError(func(e kit.ErrorEvent) {
log.Printf("agent error: %v", e.Error)
})
host.OnRetry(func(e kit.RetryEvent) {
log.Printf("retrying (attempt %d): %v", e.Attempt, e.Error)
})
host.OnTextStart(func(e kit.TextStartEvent) {
// e.ID — content block ID
})
host.OnTextEnd(func(e kit.TextEndEvent) {
// e.ID — content block ID
})
host.OnReasoningStart(func(e kit.ReasoningStartEvent) {
// e.ID — reasoning block ID
})
host.OnSource(func(e kit.SourceEvent) {
// e.SourceType, e.ID, e.URL, e.Title
})
host.OnStreamFinish(func(e kit.StreamFinishEvent) {
// e.Usage (LLMUsage), e.FinishReason
})
// Additional typed subscribers for previously generic-only events:
host.OnMessageStart(func(e kit.MessageStartEvent) {})
host.OnMessageEnd(func(e kit.MessageEndEvent) { /* e.Content */ })
host.OnReasoningDelta(func(e kit.ReasoningDeltaEvent) { /* e.Delta */ })
host.OnReasoningComplete(func(e kit.ReasoningCompleteEvent) {})
host.OnToolExecutionStart(func(e kit.ToolExecutionStartEvent) { /* e.ToolCallID, e.ToolName, e.ToolKind, e.ToolArgs */ })
host.OnToolExecutionEnd(func(e kit.ToolExecutionEndEvent) { /* e.ToolCallID, e.ToolName, e.ToolKind */ })
host.OnToolCallContent(func(e kit.ToolCallContentEvent) { /* e.Content */ })
host.OnStepUsage(func(e kit.StepUsageEvent) { /* e.InputTokens, e.OutputTokens, e.CacheReadTokens, e.CacheWriteTokens */ })
host.OnCompaction(func(e kit.CompactionEvent) { /* e.Summary, e.OriginalTokens, e.CompactedTokens, ... */ })
host.OnSteerConsumed(func(e kit.SteerConsumedEvent) { /* e.Count */ })
```
> **Rename note:** `OnStreaming` has been renamed to `OnMessageUpdate`. The old `OnStreaming` name is kept as a deprecated alias for one release cycle.
### Generic subscriber (receives all events)
```go
@@ -303,6 +378,9 @@ unsub := host.Subscribe(func(e kit.Event) {
| `message_start` | `MessageStartEvent` | *(none)* |
| `message_update` | `MessageUpdateEvent` | `Chunk` |
| `message_end` | `MessageEndEvent` | `Content` |
| `tool_call_start` | `ToolCallStartEvent` | `ToolCallID`, `ToolName`, `ToolKind` |
| `tool_call_delta` | `ToolCallDeltaEvent` | `ToolCallID`, `Delta` |
| `tool_call_end` | `ToolCallEndEvent` | `ToolCallID` |
| `tool_call` | `ToolCallEvent` | `ToolCallID`, `ToolName`, `ToolKind`, `ToolArgs`, `ParsedArgs` |
| `tool_execution_start` | `ToolExecutionStartEvent` | `ToolCallID`, `ToolName`, `ToolKind`, `ToolArgs` |
| `tool_execution_end` | `ToolExecutionEndEvent` | `ToolCallID`, `ToolName`, `ToolKind` |
@@ -314,8 +392,20 @@ unsub := host.Subscribe(func(e kit.Event) {
| `reasoning_delta` | `ReasoningDeltaEvent` | `Delta` |
| `step_usage` | `StepUsageEvent` | `InputTokens`, `OutputTokens`, `CacheReadTokens`, `CacheWriteTokens` |
| `steer_consumed` | `SteerConsumedEvent` | `Count` |
| `step_start` | `StepStartEvent` | `StepNumber` |
| `step_finish` | `StepFinishEvent` | `StepNumber`, `HasToolCalls`, `FinishReason`, `Usage` |
| `text_start` | `TextStartEvent` | `ID` |
| `text_end` | `TextEndEvent` | `ID` |
| `reasoning_start` | `ReasoningStartEvent` | `ID` |
| `warnings` | `WarningsEvent` | `Warnings` |
| `source` | `SourceEvent` | `SourceType`, `ID`, `URL`, `Title` |
| `stream_finish` | `StreamFinishEvent` | `Usage`, `FinishReason` |
| `error` | `ErrorEvent` | `Error` |
| `retry` | `RetryEvent` | `Attempt`, `Error` |
| `password_prompt` | `PasswordPromptEvent` | `Prompt`, `ResponseCh` |
**Tool call streaming lifecycle**: `ToolCallStartEvent``ToolCallDeltaEvent` (repeated) → `ToolCallEndEvent``ToolCallEvent``ToolExecutionStartEvent``ToolOutputEvent` (optional, repeated) → `ToolExecutionEndEvent``ToolResultEvent`
**PasswordPromptEvent** (for sudo password handling):
```go
// PasswordPromptEvent fires when a sudo command needs a password.
@@ -397,6 +487,20 @@ host.OnAfterTurn(kit.HookPriorityNormal, func(h kit.AfterTurnHook) {
})
```
### PrepareStep — intercept/replace messages before each LLM call
```go
host.OnPrepareStep(kit.HookPriorityNormal, func(h kit.PrepareStepHook) *kit.PrepareStepResult {
// h.StepNumber — which step in the current turn (1-based)
// h.Messages — []kit.LLMMessage being sent to the LLM
// Return nil to pass through unchanged, or replace messages:
modified := filterSensitiveMessages(h.Messages)
return &kit.PrepareStepResult{Messages: modified}
})
```
`PrepareStep` fires before every LLM API call within a turn (including tool-call loop iterations). Unlike `ContextPrepare` (which operates on the full context window once per turn), `PrepareStep` runs per-step and sees the messages that include the latest tool results.
### ContextPrepare — filter/inject context window
```go
@@ -469,6 +573,8 @@ host, _ := kit.New(ctx, &kit.Options{
|----------|-------------|
| `kit.TextResult(content)` | Successful text result |
| `kit.ErrorResult(content)` | Error result (LLM sees it as a tool error) |
| `kit.ImageResult(content, data, mediaType)` | Image result with binary data (e.g. `"image/png"`) |
| `kit.MediaResult(content, data, mediaType)` | Non-image media result (e.g. `"audio/mpeg"`) |
**ToolOutput fields** (for advanced use):
@@ -1071,6 +1177,8 @@ kit.LLMUsage // {InputTokens, OutputTokens, TotalTokens, ReasoningTokens,
// CacheCreationTokens, CacheReadTokens}
kit.LLMResponse // {Content, FinishReason, Usage}
kit.LLMFilePart // {Filename, Data []byte, MediaType}
kit.LLMToolCall // {ID, Name, Input string} — execution-layer tool call (for Tool.Run)
kit.LLMToolResponse // {Type, Content, Data, MediaType, IsError, ...} — raw tool response
// Compaction types
kit.CompactionResult, kit.CompactionOptions
@@ -1144,7 +1252,7 @@ for {
### Pattern: Streaming output to terminal
```go
host.OnStreaming(func(e kit.MessageUpdateEvent) {
host.OnMessageUpdate(func(e kit.MessageUpdateEvent) {
fmt.Print(e.Chunk)
})
response, _ := host.Prompt(ctx, "Write a poem")
-9
View File
@@ -1,9 +0,0 @@
1. Hello, world!
2. Testing one, two, three.
3. This is a quick test message.
4. Sample text for verification.
5. All systems operational.
+5 -4
View File
@@ -10,9 +10,10 @@ description: Complete reference for all Kit CLI subcommands.
For OAuth-enabled providers like Anthropic.
```bash
kit auth login [provider] # Start OAuth flow (e.g., anthropic)
kit auth logout [provider] # Remove credentials for provider
kit auth status # Check authentication status
kit auth login [provider] # Start OAuth flow (e.g., anthropic)
kit auth login [provider] --set-default # Set provider's default model as system default
kit auth logout [provider] # Remove credentials for provider
kit auth status # Check authentication status
```
## Model database
@@ -66,7 +67,7 @@ These commands are available inside the Kit TUI during an interactive session:
| `/servers` | Show connected MCP servers |
| `/model [name]` | Switch model or open model selector |
| `/theme [name]` | Switch color theme or list available themes |
| `/thinking [level]` | Set thinking level (off, minimal, low, medium, high) |
| `/thinking [level]` | Set thinking level (off, none, minimal, low, medium, high) |
| `/compact [focus]` | Summarize older messages to free context |
| `/clear` | Clear conversation |
| `/clear-queue` | Clear queued messages |
+1 -1
View File
@@ -59,7 +59,7 @@ These flags control Kit's behavior. When a prompt is passed as a positional argu
| `--stop-sequences` | — | — | Custom stop sequences (comma-separated) |
| `--frequency-penalty` | — | `0.0` | Penalize frequent tokens (0.02.0) |
| `--presence-penalty` | — | `0.0` | Penalize present tokens (0.02.0) |
| `--thinking-level` | — | `off` | Extended thinking level: off, minimal, low, medium, high |
| `--thinking-level` | — | `off` | Extended thinking level: off, none, minimal, low, medium, high |
## System
+7 -1
View File
@@ -37,7 +37,7 @@ stream: true
| `compact` | bool | `false` | Enable compact output mode |
| `system-prompt` | string | — | System prompt text or file path |
| `max-steps` | int | `0` | Maximum agent steps (0 = unlimited) |
| `thinking-level` | string | `off` | Extended thinking: off, minimal, low, medium, high |
| `thinking-level` | string | `off` | Extended thinking: off, none, minimal, low, medium, high |
| `provider-api-key` | string | — | API key for the provider |
| `provider-url` | string | — | Base URL for provider API |
| `tls-skip-verify` | bool | `false` | Skip TLS certificate verification |
@@ -83,6 +83,11 @@ mcpServers:
search:
type: remote
url: "https://mcp.example.com/search"
pubmed:
type: remote
url: "https://pubmed.mcp.example.com"
noOAuth: true # skip OAuth for public servers
```
### MCP server fields
@@ -95,6 +100,7 @@ mcpServers:
| `url` | string | URL for remote servers |
| `allowedTools` | list | Whitelist of tool names to expose |
| `excludedTools` | list | Blacklist of tool names to hide |
| `noOAuth` | bool | Skip OAuth for this server (for public servers that don't require auth) |
A legacy format with `transport`, `args`, `env`, and `headers` fields is also supported.
+4 -1
View File
@@ -7,7 +7,7 @@ description: All extension capabilities — lifecycle events, tools, commands, w
## Lifecycle events
Extensions can hook into 23 lifecycle events:
Extensions can hook into 26 lifecycle events:
| Event | Description |
|-------|-------------|
@@ -17,6 +17,9 @@ Extensions can hook into 23 lifecycle events:
| `OnAgentStart` | Agent loop started |
| `OnAgentEnd` | Agent loop completed |
| `OnToolCall` | Tool call requested by the model |
| `OnToolCallInputStart` | LLM began generating tool call arguments (tool name known, args streaming) |
| `OnToolCallInputDelta` | Streamed JSON fragment of tool call arguments |
| `OnToolCallInputEnd` | Tool argument streaming complete, before execution begins |
| `OnToolExecutionStart` | Tool execution beginning |
| `OnToolOutput` | Streaming tool output chunk (for long-running tools) |
| `OnToolExecutionEnd` | Tool execution completed |
+80 -11
View File
@@ -20,7 +20,7 @@ unsub2 := host.OnToolResult(func(event kit.ToolResultEvent) {
})
defer unsub2()
unsub3 := host.OnStreaming(func(event kit.MessageUpdateEvent) {
unsub3 := host.OnMessageUpdate(func(event kit.MessageUpdateEvent) {
fmt.Print(event.Chunk)
})
defer unsub3()
@@ -41,6 +41,32 @@ unsub6 := host.OnTurnEnd(func(event kit.TurnEndEvent) {
defer unsub6()
```
## Tool call argument streaming
For tools with large arguments (e.g., `write` with a full file body), the `ToolCallEvent` only fires after the full argument JSON finishes streaming — which can take 5-10+ seconds of "dead air." These three events fire during argument generation so UIs can show activity immediately:
```go
host.OnToolCallStart(func(event kit.ToolCallStartEvent) {
// Fires as soon as the LLM begins generating tool arguments.
// event.ToolCallID, event.ToolName, event.ToolKind
fmt.Printf("⏳ %s generating arguments...\n", event.ToolName)
})
host.OnToolCallDelta(func(event kit.ToolCallDeltaEvent) {
// Each streamed JSON fragment of the tool arguments.
// event.ToolCallID, event.Delta
// Useful for live-previewing content or showing byte progress.
})
host.OnToolCallEnd(func(event kit.ToolCallEndEvent) {
// Tool argument streaming complete — execution about to begin.
// event.ToolCallID
fmt.Printf("✓ Arguments ready, executing...\n")
})
```
**Full tool lifecycle**: `ToolCallStartEvent``ToolCallDeltaEvent` (repeated) → `ToolCallEndEvent``ToolCallEvent``ToolExecutionStartEvent``ToolOutputEvent` (optional) → `ToolExecutionEndEvent``ToolResultEvent`
## Hook system
Hooks can **modify or cancel** operations. Unlike events (read-only), hooks are read-write interceptors.
@@ -90,6 +116,24 @@ host.OnAfterTurn(kit.HookPriorityNormal, func(h kit.AfterTurnHook) {
})
```
### PrepareStep — intercept messages between steps
The most powerful hook — fires between steps within a multi-step agent turn, after any steering messages are injected and before messages are sent to the LLM. Can replace the entire context window.
```go
host.OnPrepareStep(kit.HookPriorityNormal, func(h kit.PrepareStepHook) *kit.PrepareStepResult {
// h.StepNumber — zero-based step index within the turn
// h.Messages — current context window (includes any steering)
// Example: transform tool results with images into user messages
modified := transformImageToolResults(h.Messages)
return &kit.PrepareStepResult{Messages: modified}
// Return nil to pass through unchanged
})
```
Use cases: transforming tool results (e.g., image data for vision models), dynamic tool filtering per step, mid-turn context injection, custom stop conditions.
### Hook priorities
```go
@@ -102,16 +146,41 @@ Lower values run first. First non-nil result wins.
## All event types
| Event | Description |
|-------|-------------|
| `ToolCallEvent` | Tool call parsed and about to execute |
| `ToolResultEvent` | Tool execution completed with result |
| `ToolOutputEvent` | Streaming output chunk from tool (e.g., bash stdout/stderr) |
| `MessageUpdateEvent` | Streaming text chunk from LLM |
| `ResponseEvent` | Final response received |
| `TurnStartEvent` | Agent turn started |
| `TurnEndEvent` | Agent turn completed |
| `PasswordPromptEvent` | Sudo command needs password (respond via `ResponseCh`) |
| Event | Typed Subscriber | Description |
|-------|-----------------|-------------|
| `TurnStartEvent` | `OnTurnStart` | Agent turn started |
| `TurnEndEvent` | `OnTurnEnd` | Agent turn completed |
| `MessageStartEvent` | `OnMessageStart` | New assistant message begins |
| `MessageUpdateEvent` | `OnMessageUpdate` | Streaming text chunk from LLM |
| `MessageEndEvent` | `OnMessageEnd` | Assistant message complete |
| `ToolCallStartEvent` | `OnToolCallStart` | LLM began generating tool call arguments |
| `ToolCallDeltaEvent` | `OnToolCallDelta` | Streamed JSON fragment of tool call arguments |
| `ToolCallEndEvent` | `OnToolCallEnd` | Tool argument streaming complete |
| `ToolCallEvent` | `OnToolCall` | Tool call fully parsed, about to execute |
| `ToolExecutionStartEvent` | `OnToolExecutionStart` | Tool begins executing |
| `ToolExecutionEndEvent` | `OnToolExecutionEnd` | Tool finishes executing |
| `ToolResultEvent` | `OnToolResult` | Tool execution completed with result |
| `ToolCallContentEvent` | `OnToolCallContent` | Text content alongside tool calls |
| `ToolOutputEvent` | `OnToolOutput` | Streaming output chunk from tool (e.g., bash) |
| `ResponseEvent` | `OnResponse` | Final response received |
| `ReasoningStartEvent` | `OnReasoningStart` | LLM begins reasoning/thinking |
| `ReasoningDeltaEvent` | `OnReasoningDelta` | Streaming reasoning/thinking chunk |
| `ReasoningCompleteEvent` | `OnReasoningComplete` | Reasoning/thinking finished |
| `StepStartEvent` | `OnStepStart` | New LLM call begins within a turn |
| `StepFinishEvent` | `OnStepFinish` | Step completes (with usage, finish reason, tool call info) |
| `StepUsageEvent` | `OnStepUsage` | Per-step token usage |
| `StreamFinishEvent` | `OnStreamFinish` | Per-step stream completes (with usage + finish reason) |
| `TextStartEvent` | `OnTextStart` | LLM begins text content generation |
| `TextEndEvent` | `OnTextEnd` | LLM finishes text content generation |
| `WarningsEvent` | `OnWarnings` | LLM provider returned warnings |
| `SourceEvent` | `OnSource` | LLM referenced a source (e.g., web search) |
| `ErrorEvent` | `OnError` | Agent-level error during streaming |
| `RetryEvent` | `OnRetry` | LLM request retried after transient error |
| `CompactionEvent` | `OnCompaction` | Conversation compacted |
| `SteerConsumedEvent` | `OnSteerConsumed` | Steering messages injected into turn |
| `PasswordPromptEvent` | — | Sudo command needs password (respond via `ResponseCh`) |
> **Note:** `OnStreaming` is a deprecated alias for `OnMessageUpdate` and will be removed in a future release.
## Subagent event monitoring
+2 -2
View File
@@ -24,7 +24,7 @@ host, err := kit.New(ctx, &kit.Options{
// Generation parameters (override env/config/per-model defaults)
MaxTokens: 16384, // 0 = auto-resolve; non-zero suppresses right-sizing
ThinkingLevel: "medium", // "off", "low", "medium", "high"
ThinkingLevel: "medium", // "off", "none", "minimal", "low", "medium", "high"
Temperature: ptrFloat32(0.2), // pointer so explicit 0.0 != unset
TopP: nil, // nil = provider/per-model default
TopK: nil,
@@ -107,7 +107,7 @@ defaults for samplers).
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `MaxTokens` | `int` | auto-resolved | Max output tokens per response. `0` = auto-resolve; non-zero suppresses automatic right-sizing (same semantics as `--max-tokens`). |
| `ThinkingLevel` | `string` | auto-resolved | Reasoning effort: `"off"`, `"low"`, `"medium"`, `"high"` (some providers also accept `"minimal"`). `""` falls through to config/env/per-model/`"off"`. |
| `ThinkingLevel` | `string` | auto-resolved | Reasoning effort: `"off"`, `"none"`, `"minimal"`, `"low"`, `"medium"`, `"high"`. `""` falls through to config/env/per-model/`"off"`. |
| `Temperature` | `*float32` | — | Sampling randomness. Pointer type so explicit `0.0` is distinguishable from "unset". |
| `TopP` | `*float32` | — | Nucleus sampling cutoff. `nil` leaves provider/per-model default. |
| `TopK` | `*int32` | — | Top-K sampling limit. `nil` leaves provider/per-model default. |
+5 -3
View File
@@ -101,8 +101,10 @@ Return values:
|--------|-------------|
| `kit.TextResult(s)` | Successful text result |
| `kit.ErrorResult(s)` | Error result (LLM sees it as a tool error) |
| `kit.ImageResult(s, data, mediaType)` | Image result with binary data (e.g. `"image/png"`) |
| `kit.MediaResult(s, data, mediaType)` | Non-image media result (e.g. `"audio/mpeg"`) |
For advanced use, return a `kit.ToolOutput` struct directly with `Data`, `MediaType`, and `Metadata` fields.
Binary data (images, audio, etc.) in `ToolOutput.Data` is automatically forwarded to the LLM when `MediaType` is set. For advanced use, return a `kit.ToolOutput` struct directly with `Data`, `MediaType`, and `Metadata` fields.
Use `kit.NewParallelTool` for tools that are safe to run concurrently. Use `kit.ToolCallIDFromContext(ctx)` to retrieve the LLM-assigned call ID for logging or tracing.
@@ -115,7 +117,7 @@ entirely in-code via `Options`, without touching `.kit.yml` or `viper.Set()`:
host, _ := kit.New(ctx, &kit.Options{
Model: "anthropic/claude-sonnet-4-5-20250929",
MaxTokens: 16384, // 0 = auto-resolve (env → config → per-model → floor)
ThinkingLevel: "high", // "off" | "low" | "medium" | "high"
ThinkingLevel: "high", // "off" | "none" | "minimal" | "low" | "medium" | "high"
Temperature: ptrFloat32(0.2), // nil = provider/per-model default
ProviderAPIKey: os.Getenv("MY_SECRET"), // overrides pre-existing viper state
ProviderURL: "https://proxy.internal/v1",
@@ -141,7 +143,7 @@ host.OnToolResult(func(event kit.ToolResultEvent) {
fmt.Println("Tool result:", event.Name)
})
host.OnStreaming(func(event kit.MessageUpdateEvent) {
host.OnMessageUpdate(func(event kit.MessageUpdateEvent) {
fmt.Print(event.Chunk)
})
```