mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-20 06:16:12 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 307dcd1734 | |||
| 81240b075e | |||
| 9a662d440c | |||
| 4ba9d6fab3 | |||
| aec0e7cc01 | |||
| bac04636bf | |||
| 5f851fd08e | |||
| f8371836d8 | |||
| 74f00244be | |||
| b5d7fd4f3e | |||
| 5857d40978 | |||
| 3ff701054a | |||
| c1dee3ceba | |||
| 2d9783a44d | |||
| 88dd216e15 | |||
| 9e5806ade8 | |||
| 50f586ec8f |
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"permission": {
|
||||
"external_directory": {
|
||||
"~/go/**": "deny"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
+64
-4
@@ -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>✓ Authentication Successful</h1>
|
||||
<p>You can close this window and return to the terminal.</p>
|
||||
</body>
|
||||
</html>`)
|
||||
|
||||
+1
-1
@@ -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)")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+48
-2
@@ -87,6 +87,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
|
||||
@@ -411,7 +424,7 @@ func (a *Agent) GenerateWithLoop(ctx context.Context, messages []fantasy.Message
|
||||
onResponse ResponseHandler, onToolCallContent ToolCallContentHandler,
|
||||
) (*GenerateWithLoopResult, error) {
|
||||
return a.GenerateWithLoopAndStreaming(ctx, messages, onToolCall, onToolExecution, onToolResult,
|
||||
onResponse, onToolCallContent, nil, nil, nil, nil, nil, nil, nil)
|
||||
onResponse, onToolCallContent, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
}
|
||||
|
||||
// GenerateWithLoopAndStreaming processes messages using the agent with streaming and callbacks.
|
||||
@@ -427,6 +440,9 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
onStepMessages StepMessagesHandler,
|
||||
onStepUsage StepUsageHandler,
|
||||
onPasswordPrompt PasswordPromptHandler,
|
||||
onToolCallStart ToolCallStartHandler,
|
||||
onToolCallDelta ToolCallDeltaHandler,
|
||||
onToolCallEnd ToolCallEndHandler,
|
||||
) (*GenerateWithLoopResult, error) {
|
||||
|
||||
// Wait for background MCP tool loading to complete and rebuild the
|
||||
@@ -462,7 +478,8 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
// Stream is required to observe tool execution in real time. The non-streaming
|
||||
// Generate path is reserved for the simple case with no callbacks at all.
|
||||
hasCallbacks := onToolCall != nil || onToolExecution != nil || onToolResult != nil ||
|
||||
onToolCallContent != nil || onStreamingResponse != nil || onReasoningDelta != nil
|
||||
onToolCallContent != nil || onStreamingResponse != nil || onReasoningDelta != nil ||
|
||||
onToolCallStart != nil || onToolCallDelta != nil || onToolCallEnd != nil
|
||||
|
||||
if a.streamingEnabled || hasCallbacks {
|
||||
// Track completed step messages so we can return partial results
|
||||
@@ -481,6 +498,35 @@ 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 onToolCallStart != nil {
|
||||
onToolCallStart(id, toolName)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
OnToolInputDelta: func(id, delta string) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if onToolCallDelta != nil {
|
||||
onToolCallDelta(id, delta)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
OnToolInputEnd: func(id string) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if onToolCallEnd != nil {
|
||||
onToolCallEnd(id)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
// Reasoning/thinking streaming callback
|
||||
OnReasoningDelta: func(id, delta string) error {
|
||||
if ctx.Err() != nil {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ Example use cases:
|
||||
},
|
||||
"model": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional model override (e.g. 'anthropic/claude-haiku-3-5-20241022' for faster/cheaper tasks)",
|
||||
"description": "Optional model override. Empty string uses the current model.",
|
||||
},
|
||||
"system_prompt": map[string]any{
|
||||
"type": "string",
|
||||
@@ -94,7 +94,7 @@ Example use cases:
|
||||
},
|
||||
"timeout_seconds": map[string]any{
|
||||
"type": "number",
|
||||
"description": "Maximum execution time in seconds (default: 300, max: 1800)",
|
||||
"description": "Maximum execution time in seconds (default: 300, max: 1800, minimum recommended: 240)",
|
||||
},
|
||||
},
|
||||
Required: []string{"task"},
|
||||
|
||||
@@ -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))
|
||||
@@ -1099,6 +1102,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)
|
||||
@@ -1890,6 +1913,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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -88,7 +101,8 @@ const (
|
||||
// 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,
|
||||
|
||||
@@ -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) != 24 {
|
||||
t.Fatalf("expected 24 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},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)),
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -365,6 +365,9 @@ 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 {
|
||||
@@ -410,6 +413,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
|
||||
@@ -518,6 +527,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.
|
||||
@@ -1213,12 +1229,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 +1262,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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -156,7 +156,7 @@ 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, durationMs, width, ty, style.GetTheme())
|
||||
} else {
|
||||
// Render as assistant message
|
||||
rendered = render.AssistantBlock(s.content, width, style.GetTheme())
|
||||
|
||||
+35
-4
@@ -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")
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,15 +36,15 @@ 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 {
|
||||
func HighlightFileTokens(text string, theme style.Theme) string {
|
||||
accentStyle := lipgloss.NewStyle().Foreground(theme.Accent).Bold(true)
|
||||
return fileTokenPattern.ReplaceAllStringFunc(text, func(token string) string {
|
||||
return accentStyle.Render(token)
|
||||
@@ -63,14 +63,18 @@ 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")
|
||||
if width > 4 {
|
||||
contentStr = wrapText(contentStr, width-4)
|
||||
}
|
||||
mutedStyle := lipgloss.NewStyle().Foreground(theme.Muted)
|
||||
contentRendered := mutedStyle.Render(ty.Italic(contentStr))
|
||||
|
||||
@@ -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)
|
||||
@@ -153,3 +196,9 @@ func ToolBlock(displayName, params, body string, isError bool, width int, ty *he
|
||||
func styleMarginBottom(theme style.Theme, content string) string {
|
||||
return lipgloss.NewStyle().MarginBottom(1).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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -472,6 +472,10 @@ 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")
|
||||
// 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)
|
||||
}
|
||||
theme := GetTheme()
|
||||
mutedStyle := lipgloss.NewStyle().Foreground(theme.Muted)
|
||||
parts = append(parts, mutedStyle.Render(s.ty.Italic(content)))
|
||||
|
||||
@@ -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.
|
||||
@@ -216,6 +224,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
|
||||
@@ -420,6 +462,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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+40
-4
@@ -543,6 +543,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 +883,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.0–2.0).
|
||||
@@ -2003,6 +2020,25 @@ func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent.
|
||||
resp := <-responseCh
|
||||
return resp.Password, resp.Cancelled
|
||||
},
|
||||
// Tool call argument streaming — fire as the LLM generates tool arguments
|
||||
func(toolCallID, toolName string) {
|
||||
m.events.emit(ToolCallStartEvent{
|
||||
ToolCallID: toolCallID,
|
||||
ToolName: toolName,
|
||||
ToolKind: toolKindFor(toolName),
|
||||
})
|
||||
},
|
||||
func(toolCallID, delta string) {
|
||||
m.events.emit(ToolCallDeltaEvent{
|
||||
ToolCallID: toolCallID,
|
||||
Delta: delta,
|
||||
})
|
||||
},
|
||||
func(toolCallID string) {
|
||||
m.events.emit(ToolCallEndEvent{
|
||||
ToolCallID: toolCallID,
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+184
-34
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+26
-2
@@ -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)
|
||||
@@ -303,6 +322,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` |
|
||||
@@ -316,6 +338,8 @@ unsub := host.Subscribe(func(e kit.Event) {
|
||||
| `steer_consumed` | `SteerConsumedEvent` | `Count` |
|
||||
| `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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.0–2.0) |
|
||||
| `--presence-penalty` | — | `0.0` | Penalize present tokens (0.0–2.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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
@@ -104,7 +130,10 @@ Lower values run first. First non-nil result wins.
|
||||
|
||||
| Event | Description |
|
||||
|-------|-------------|
|
||||
| `ToolCallEvent` | Tool call parsed and about to execute |
|
||||
| `ToolCallStartEvent` | LLM began generating tool call arguments (tool name known, args streaming) |
|
||||
| `ToolCallDeltaEvent` | Streamed JSON fragment of tool call arguments |
|
||||
| `ToolCallEndEvent` | Tool argument streaming complete, before execution begins |
|
||||
| `ToolCallEvent` | Tool call fully 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 |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -115,7 +115,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",
|
||||
|
||||
Reference in New Issue
Block a user