Compare commits

..

13 Commits

Author SHA1 Message Date
Ed Zynda 4577d218d3 feat: add /model slash command with interactive fuzzy-finding selector
Add /model command that allows switching LLM models mid-session.
When invoked without arguments, opens a full-screen selector overlay
showing only models with configured API keys, with inline fuzzy search,
cursor navigation, and current model indicator. When invoked with an
argument (e.g. /model anthropic/claude-haiku-4-5), switches directly.

Also upgrades all Go dependencies to latest versions.
2026-03-06 18:50:32 +03:00
Ed Zynda bd48457b27 fix: resolve golangci-lint modernize and staticcheck warnings 2026-03-06 15:40:29 +03:00
Ed Zynda 84298a0743 fix: add 20-line display truncation for shell command output
Match the tool result renderer behavior — show first 20 lines
with a '...(N more lines)' hint. Full output still goes to
context (with TruncateTail limits) for ! commands.
2026-03-05 19:31:22 +03:00
Ed Zynda 393074447b fix: truncate shell command output in TUI using same limits as core bash tool 2026-03-05 19:24:49 +03:00
Ed Zynda 879723fe90 feat: add ! and !! shell command prefixes (matching pi behavior)
! runs a shell command with output included in LLM context.
!! runs a shell command with output excluded from LLM context.
Adds AddContextMessage to AppController for injecting messages
without triggering an LLM turn.
2026-03-05 19:17:41 +03:00
Ed Zynda 57250a3a3d refactor: remove --prompt flag, positional args are the only way
Drop the --prompt/-p flag entirely. Non-interactive mode is now
triggered by passing positional arguments:

  kit "Explain this"
  kit @file.go "Review this" --json
  kit @a.go @b.go --quiet

Updated extension examples (kit-kit.go, subagent-widget.go) to pass
the prompt as a positional arg. Updated AGENTS.md and README.md.
2026-03-05 19:03:47 +03:00
Ed Zynda 7e1686e572 feat: positional args as primary non-interactive mode, hide --prompt
Positional args are now the main way to run non-interactive mode:

  kit "Explain this codebase"
  kit @code.ts @test.ts "Review these files"
  kit @go.mod "What module?" --quiet

--prompt is hidden but still works for subprocess compat (extensions
spawn kit with --prompt internally). Updated --quiet/--json/--no-exit
error messages to reference the new positional arg pattern.
2026-03-05 19:00:51 +03:00
Ed Zynda 4a8b10cde7 feat: support Pi-style positional @file args
Enables: kit @code.ts @test.ts "Review these files"

Positional args starting with @ are treated as file attachments —
their content is read and prepended to the prompt. Remaining
positional args are joined as the prompt text. Works alongside
--prompt flag (files prepended, extra text appended).
2026-03-05 18:57:00 +03:00
Ed Zynda cc5611eff7 feat: support @file references in non-interactive mode (--prompt) 2026-03-05 18:54:17 +03:00
Ed Zynda 51c70b63a7 feat: add @file autocomplete and context attachment
Type @ in the input to trigger a fuzzy file picker popup. Files are
discovered via git ls-files (with os.ReadDir fallback), scored by
fuzzy match, and displayed in the existing autocomplete popup.

Tab/Enter inserts the selected path; directories keep the popup open
for drilling. On submit, @file tokens are expanded into XML-wrapped
file content before being sent to the agent. No CWD restriction —
supports ~/, ../, and absolute paths.
2026-03-05 18:46:25 +03:00
Ed Zynda c9ee80d98a fix: run before-hook callbacks in goroutines to prevent TUI deadlock
Before-hook callbacks (OnBeforeSessionSwitch, OnBeforeFork) were called
synchronously inside BubbleTea's Update(), so extensions that used
blocking prompts (ctx.PromptConfirm) would deadlock — the channel read
waited for Update() to process the PromptRequestEvent, but Update()
was blocked on that same channel read.

Run hooks in dedicated goroutines and deliver results via SendEvent,
matching the pattern already used by extension slash commands.
2026-03-05 10:34:17 +03:00
Ed Zynda 3ecedcbc2d docs: add comprehensive README with CLI reference, extensions, SDK, and configuration guide 2026-03-03 18:33:42 +03:00
Ed Zynda dbfa410fc1 fix: use strings.Builder instead of string += in loops 2026-03-02 20:25:07 +03:00
20 changed files with 2326 additions and 201 deletions
+2 -2
View File
@@ -83,9 +83,9 @@ tmux kill-session -t kittest # cleanup
### Non-Interactive Kit (Subprocess Spawning)
Extensions can spawn Kit as a subprocess for sub-agent patterns:
```bash
kit --prompt "question" --quiet --no-session --no-extensions --system-prompt /path/to/prompt.txt --model provider/model
kit --quiet --no-session --no-extensions --system-prompt /path/to/prompt.txt --model provider/model "question"
```
Key flags: `--quiet` (stdout only, no TUI), `--no-session` (ephemeral), `--no-extensions` (prevent recursive loading), `--system-prompt` (string or file path).
Positional args are the prompt. `@file` args attach file content. Key flags: `--quiet` (stdout only, no TUI), `--no-session` (ephemeral), `--no-extensions` (prevent recursive loading), `--system-prompt` (string or file path).
## External Repo Research
- **ALWAYS use `btca`** to search external repos (e.g. iteratr, other reference codebases)
+494 -1
View File
@@ -13,4 +13,497 @@
# KIT (Knowledge Inference Tool)
TBD
A powerful, extensible AI coding agent CLI with multi-provider support, built-in tools, and a rich extension system.
## Features
- **Multi-Provider LLM Support**: Anthropic, OpenAI, Google Gemini, Ollama, Azure OpenAI, AWS Bedrock, OpenRouter, and more
- **Built-in Core Tools**: bash, read, write, edit, grep, find, ls - no MCP overhead
- **MCP Integration**: Connect external MCP servers for expanded capabilities
- **Extension System**: Write custom tools, commands, widgets, and UI modifications in Go
- **Interactive TUI**: Rich terminal interface powered by Bubble Tea with streaming, syntax highlighting, and custom rendering
- **Session Management**: Tree-based conversation history with branching support
- **Non-Interactive Mode**: Script-friendly positional args with JSON output
- **Go SDK**: Embed Kit in your own applications
## Installation
### Using npm (recommended)
```bash
npm install -g @mark3labs/kit
```
### Using Go
```bash
go install github.com/mark3labs/kit/cmd/kit@latest
```
### Building from source
```bash
git clone https://github.com/mark3labs/kit.git
cd kit
go build -o kit ./cmd/kit
```
## Quick Start
### Basic Usage
```bash
# Start interactive session
kit
# Run a one-off prompt
kit "List files in src/"
# Attach files as context
kit @main.go @test.go "Review these files"
# Continue the most recent session
kit --continue
# Use specific model
kit --model anthropic/claude-sonnet-4-5-20250929
```
### Non-Interactive Mode
```bash
# Get JSON output for scripting
kit "Explain main.go" --json
# Quiet mode (final response only)
kit "Run tests" --quiet
# Ephemeral mode (no session file)
kit "Quick question" --no-session
```
## Configuration
Kit looks for configuration in the following locations (in order of priority):
1. CLI flags
2. Environment variables (with `KIT_` prefix)
3. `./.kit.yml` (project-local)
4. `~/.kit.yml` (global)
### Basic Configuration
Create `~/.kit.yml`:
```yaml
model: anthropic/claude-sonnet-4-5-20250929
max-tokens: 4096
temperature: 0.7
stream: true
```
### Environment Variables
```bash
export ANTHROPIC_API_KEY="sk-..."
export OPENAI_API_KEY="sk-..."
export KIT_MODEL="openai/gpt-4o"
```
### MCP Server Configuration
Add external MCP servers to `.kit.yml`:
```yaml
mcpServers:
filesystem:
type: local
command: ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed"]
environment:
LOG_LEVEL: "info"
allowedTools: ["read_file", "write_file"]
search:
type: remote
url: "https://mcp.example.com/search"
```
## CLI Reference
### Global Flags
```bash
# Model and provider
--model, -m Model to use (provider/model format)
--provider-api-key API key for the provider
--provider-url Base URL for provider API
--tls-skip-verify Skip TLS certificate verification
# Session management
--session, -s Open specific JSONL session file
--continue, -c Resume most recent session for current directory
--resume, -r Interactive session picker
--no-session Ephemeral mode, no persistence
# Behavior (non-interactive: pass prompt as positional arg)
--quiet Suppress all output (non-interactive only)
--json Output response as JSON (non-interactive only)
--no-exit Enter interactive mode after prompt completes
--max-steps Maximum agent steps (0 for unlimited)
--stream Enable streaming output (default: true)
--compact Enable compact output mode
--auto-compact Auto-compact conversation near context limit
# Extensions
--extension, -e Load additional extension file(s) (repeatable)
--no-extensions Disable all extensions
# Generation parameters
--max-tokens Maximum tokens in response (default: 4096)
--temperature Randomness 0.0-1.0 (default: 0.7)
--top-p Nucleus sampling 0.0-1.0 (default: 0.95)
--top-k Limit top K tokens (default: 40)
--stop-sequences Custom stop sequences (comma-separated)
# System
--config Config file path (default: ~/.kit.yml)
--system-prompt System prompt text or file path
--debug Enable debug logging
```
### Commands
```bash
# Authentication (for OAuth-enabled providers)
kit auth login # Start OAuth flow
kit auth logout # Remove credentials
kit auth status # Check authentication status
# Model database
kit models # List available models
kit models --all # Show all providers (not just Fantasy-compatible)
kit update-models # Update local model database from models.dev
# Extension management
kit extensions list # List discovered extensions
kit extensions validate # Validate extension files
kit extensions init # Generate example extension template
```
## Extension System
Extensions are Go source files that run via Yaegi interpreter. They can add custom tools, slash commands, widgets, keyboard shortcuts, and intercept lifecycle events.
### Minimal Extension
```go
//go:build ignore
package main
import "kit/ext"
func Init(api ext.API) {
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
ctx.SetFooter(ext.HeaderFooterConfig{
Content: ext.WidgetContent{Text: "Custom Footer"},
})
})
}
```
**Usage:**
```bash
kit -e examples/extensions/minimal.go
```
### Extension Capabilities
**Lifecycle Events**: OnSessionStart, OnSessionShutdown, OnAgentStart, OnAgentEnd, OnToolCall, OnToolResult, OnInput, OnMessageStart, OnMessageUpdate, OnMessageEnd, OnModelChange, OnContextPrepare, OnBeforeFork, OnBeforeSessionSwitch, OnBeforeCompact
**Custom Components**:
- **Tools**: Add new tools the LLM can invoke
- **Commands**: Register slash commands (e.g., `/mycommand`)
- **Widgets**: Persistent status displays above/below input
- **Shortcuts**: Global keyboard shortcuts
- **Overlays**: Modal dialogs with markdown content
- **Tool Renderers**: Customize how tool calls display
- **Editor Interceptors**: Handle key events and wrap rendering
### Extension Examples
See the `examples/extensions/` directory:
- `minimal.go` - Clean UI with custom footer
- `notify.go` - Desktop notifications
- `widget-status.go` - Persistent status widgets
- `custom-editor-demo.go` - Vim-like modal editor
- `prompt-demo.go` - Interactive prompts (select/confirm/input)
- `tool-logger.go` - Log all tool calls
- `overlay-demo.go` - Modal dialogs
- `plan-mode.go` - Read-only planning mode
- `subagent-widget.go` - Multi-agent orchestration
- `auto-commit.go` - Auto-commit on shutdown
### Loading Extensions
**Auto-discovery** (loads automatically):
- `./.kit/extensions/*.go` (project-local)
- `~/.config/kit/extensions/*.go` (global)
**Explicit loading**:
```bash
kit -e path/to/extension.go
kit -e ext1.go -e ext2.go # Multiple extensions
```
**Disable auto-load**:
```bash
kit --no-extensions
```
## Session Management
Kit uses a tree-based session model that supports branching and forking conversations.
### Session Locations
- Default: `~/.local/share/kit/sessions/<cwd-hash>/<uuid>.jsonl`
- Each line is a session entry (messages, tool calls, extension data)
- Supports branching from any message to explore alternate paths
### Session Commands
```bash
# Resume most recent session for current directory
kit --continue
kit -c
# Interactive session picker
kit --resume
kit -r
# Open specific session file
kit --session path/to/session.jsonl
kit -s path/to/session.jsonl
# Ephemeral mode (no file persistence)
kit --no-session
```
## Go SDK
Embed Kit in your Go applications:
```go
package main
import (
"context"
"log"
kit "github.com/mark3labs/kit/pkg/kit"
)
func main() {
ctx := context.Background()
// Create Kit instance with default configuration
host, err := kit.New(ctx, nil)
if err != nil {
log.Fatal(err)
}
defer host.Close()
// Send a prompt
response, err := host.Prompt(ctx, "What is 2+2?")
if err != nil {
log.Fatal(err)
}
println(response)
}
```
### With Options
```go
host, err := kit.New(ctx, &kit.Options{
Model: "ollama/llama3",
SystemPrompt: "You are a helpful bot",
ConfigFile: "/path/to/config.yml",
MaxSteps: 10,
Streaming: true,
Quiet: true,
})
```
### With Callbacks
```go
response, err := host.PromptWithCallbacks(
ctx,
"List files in current directory",
func(name, args string) {
// Tool call started
println("Calling tool:", name)
},
func(name, args, result string, isError bool) {
// Tool call completed
if isError {
println("Tool failed:", name)
}
},
func(chunk string) {
// Streaming text chunk
print(chunk)
},
)
```
### Session Management
```go
host.Prompt(ctx, "My name is Alice")
response, _ := host.Prompt(ctx, "What's my name?")
host.SaveSession("./session.json")
host.LoadSession("./session.json")
host.ClearSession()
```
## Advanced Usage
### Subagent Pattern
Spawn Kit as a subprocess for multi-agent orchestration:
```bash
kit "Analyze codebase" \
--json \
--no-session \
--no-extensions \
--quiet \
--model anthropic/claude-haiku-3-5-20241022
```
Parse the JSON output:
```json
{
"response": "Final assistant response text",
"model": "anthropic/claude-haiku-3-5-20241022",
"usage": {
"input_tokens": 1024,
"output_tokens": 512,
"total_tokens": 1536
},
"messages": [...]
}
```
### Testing with tmux
Test the TUI non-interactively:
```bash
# Start Kit in detached tmux session
tmux new-session -d -s kittest -x 120 -y 40 \
"kit -e ext.go --no-session 2>kit.log"
# Wait for startup
sleep 3
# Capture screen
tmux capture-pane -t kittest -p
# Send input
tmux send-keys -t kittest '/command' Enter
# Cleanup
tmux kill-session -t kittest
```
## Development
### Build and Test
```bash
# Build
go build -o output/kit ./cmd/kit
# Run tests
go test -race ./...
# Run specific test
go test -race ./cmd -run TestScriptExecution
# Lint
go vet ./...
# Format
go fmt ./...
```
### Project Structure
```
cmd/kit/ - CLI entry point
cmd/ - CLI command implementations
pkg/kit/ - Go SDK
internal/agent/ - Agent loop and tool execution
internal/ui/ - Bubble Tea TUI components
internal/extensions/ - Yaegi extension system
internal/core/ - Built-in tools
internal/tools/ - MCP tool integration
internal/config/ - Configuration management
internal/session/ - Session persistence
internal/models/ - Provider and model management
examples/extensions/ - Example extension files
```
## Supported Providers
- **Anthropic** - Claude models (native, prompt caching, OAuth)
- **OpenAI** - GPT models
- **Google** - Gemini models
- **Ollama** - Local models
- **Azure OpenAI** - Azure-hosted OpenAI
- **AWS Bedrock** - Bedrock models
- **Google Vertex** - Claude on Vertex AI
- **OpenRouter** - Multi-provider router
- **Vercel AI** - Vercel AI SDK models
- **Auto-routed** - Any provider from models.dev database
### Model String Format
```bash
provider/model # Standard format
anthropic/claude-sonnet-4-5-20250929
openai/gpt-4o
ollama/llama3
google/gemini-2.0-flash-exp
```
### Model Aliases
```bash
claude-opus-latest → claude-opus-4-20250514
claude-sonnet-latest → claude-sonnet-4-5-20250929
claude-3-5-haiku-latest → claude-3-5-haiku-20241022
```
## Contributing
Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
## License
[Apache 2.0](LICENSE)
## Community
- [Discord](https://discord.gg/RqSS2NQVsY)
- [GitHub Issues](https://github.com/mark3labs/kit/issues)
- [Documentation](https://github.com/mark3labs/kit/wiki)
+110 -23
View File
@@ -29,7 +29,7 @@ var (
providerURL string
providerAPIKey string
debugMode bool
promptFlag string
positionalPrompt string // set by processPositionalArgs from CLI positional args
quietFlag bool
jsonFlag bool
noExitFlag bool
@@ -101,10 +101,16 @@ func (a *kitUIAdapter) GetExtensionToolCount() int {
// an interface to interact with various AI models through a unified interface
// with support for MCP servers and tool integration.
var rootCmd = &cobra.Command{
Use: "kit",
Use: "kit [@file...] [prompt]",
Short: "Chat with AI models through a unified interface",
Long: `KIT (Knowledge Inference Tool) — A lightweight AI agent for coding`,
Args: cobra.ArbitraryArgs,
RunE: func(cmd *cobra.Command, args []string) error {
// Parse positional args: @-prefixed args are file attachments,
// remaining args form the prompt (like Pi: kit @code.ts "Review this").
if len(args) > 0 {
processPositionalArgs(args)
}
return runKit(context.Background())
},
}
@@ -202,14 +208,13 @@ func init() {
"model to use (format: provider/model)")
rootCmd.PersistentFlags().
BoolVar(&debugMode, "debug", false, "enable debug logging")
rootCmd.PersistentFlags().
StringVarP(&promptFlag, "prompt", "p", "", "run in non-interactive mode with the given prompt")
BoolVar(&quietFlag, "quiet", false, "suppress all output (non-interactive mode only)")
rootCmd.PersistentFlags().
BoolVar(&quietFlag, "quiet", false, "suppress all output (only works with --prompt)")
BoolVar(&jsonFlag, "json", false, "output response as JSON (non-interactive mode only)")
rootCmd.PersistentFlags().
BoolVar(&jsonFlag, "json", false, "output response as JSON (only works with --prompt)")
rootCmd.PersistentFlags().
BoolVar(&noExitFlag, "no-exit", false, "prevent non-interactive mode from exiting, show input prompt instead")
BoolVar(&noExitFlag, "no-exit", false, "enter interactive mode after non-interactive prompt completes")
rootCmd.PersistentFlags().
IntVar(&maxSteps, "max-steps", 0, "maximum number of agent steps (0 for unlimited)")
rootCmd.PersistentFlags().
@@ -252,7 +257,6 @@ func init() {
_ = viper.BindPFlag("system-prompt", rootCmd.PersistentFlags().Lookup("system-prompt"))
_ = viper.BindPFlag("model", rootCmd.PersistentFlags().Lookup("model"))
_ = viper.BindPFlag("debug", rootCmd.PersistentFlags().Lookup("debug"))
_ = viper.BindPFlag("prompt", rootCmd.PersistentFlags().Lookup("prompt"))
_ = viper.BindPFlag("max-steps", rootCmd.PersistentFlags().Lookup("max-steps"))
_ = viper.BindPFlag("stream", rootCmd.PersistentFlags().Lookup("stream"))
_ = viper.BindPFlag("compact", rootCmd.PersistentFlags().Lookup("compact"))
@@ -277,6 +281,62 @@ func init() {
rootCmd.AddCommand(authCmd)
}
// processPositionalArgs separates positional CLI arguments into @file
// attachments and prompt text. File content is read and prepended to
// positionalPrompt so the agent receives it. Positional args are the primary
// way to run non-interactive mode:
//
// kit "Explain this codebase"
// kit @code.ts @test.ts "Review these files"
func processPositionalArgs(args []string) {
cwd, err := os.Getwd()
if err != nil {
cwd = "."
}
var fileTokens []string
var promptParts []string
for _, arg := range args {
if strings.HasPrefix(arg, "@") && len(arg) > 1 {
fileTokens = append(fileTokens, arg)
} else {
promptParts = append(promptParts, arg)
}
}
// Build file content prefix from @file arguments.
var fileContent strings.Builder
for _, token := range fileTokens {
expanded := ui.ProcessFileAttachments(token, cwd)
if expanded != token {
// File was resolved — add it.
fileContent.WriteString(expanded)
fileContent.WriteString("\n\n")
}
}
// Combine: positional prompt text is appended to any existing --prompt
// value (for backward compat with subprocess invocations).
if len(promptParts) > 0 {
extra := strings.Join(promptParts, " ")
if positionalPrompt != "" {
positionalPrompt = positionalPrompt + " " + extra
} else {
positionalPrompt = extra
}
}
// Prepend file content to the prompt.
if fileContent.Len() > 0 {
if positionalPrompt == "" {
positionalPrompt = strings.TrimSpace(fileContent.String())
} else {
positionalPrompt = strings.TrimSpace(fileContent.String()) + "\n\n" + positionalPrompt
}
}
}
func runKit(ctx context.Context) error {
return runNormalMode(ctx)
}
@@ -521,17 +581,17 @@ func globalShortcutsProviderForUI(k *kit.Kit) func() map[string]func() {
func runNormalMode(ctx context.Context) error {
// Validate flag combinations
if quietFlag && promptFlag == "" {
return fmt.Errorf("--quiet flag can only be used with --prompt/-p")
if quietFlag && positionalPrompt == "" {
return fmt.Errorf("--quiet requires a prompt (e.g. kit \"your question\" --quiet)")
}
if jsonFlag && promptFlag == "" {
return fmt.Errorf("--json flag can only be used with --prompt/-p")
if jsonFlag && positionalPrompt == "" {
return fmt.Errorf("--json requires a prompt (e.g. kit \"your question\" --json)")
}
if jsonFlag && noExitFlag {
return fmt.Errorf("--json and --no-exit flags cannot be used together")
}
if noExitFlag && promptFlag == "" {
return fmt.Errorf("--no-exit flag can only be used with --prompt/-p")
if noExitFlag && positionalPrompt == "" {
return fmt.Errorf("--no-exit requires a prompt (e.g. kit \"your question\" --no-exit)")
}
// Set up logging
@@ -598,7 +658,7 @@ func runNormalMode(ctx context.Context) error {
// Create CLI for non-interactive mode only.
var cli *ui.CLI
if promptFlag != "" {
if positionalPrompt != "" {
cli, err = SetupCLIForNonInteractive(kitInstance)
if err != nil {
return fmt.Errorf("failed to setup CLI: %v", err)
@@ -645,7 +705,7 @@ func runNormalMode(ctx context.Context) error {
kitInstance.SetExtensionContext(extensions.Context{
CWD: cwd,
Model: modelName,
Interactive: promptFlag == "",
Interactive: positionalPrompt == "",
Print: func(text string) { appInstance.PrintFromExtension("", text) },
PrintInfo: func(text string) { appInstance.PrintFromExtension("info", text) },
PrintError: func(text string) { appInstance.PrintFromExtension("error", text) },
@@ -902,17 +962,35 @@ func runNormalMode(ctx context.Context) error {
return extensionCommandsForUI(kitInstance)
}
// Build model switching callbacks for the /model command.
setModelForUI := func(modelString string) error {
err := kitInstance.SetModel(context.Background(), modelString)
if err != nil {
return err
}
// Update the extension context's Model field so handlers see it.
kitInstance.UpdateExtensionContextModel(modelString)
// NOTE: We do NOT call appInstance.NotifyModelChanged() here because
// this callback runs synchronously inside BubbleTea's Update(), and
// NotifyModelChanged calls prog.Send() which deadlocks. The UI layer
// updates m.providerName and m.modelName directly after setModel returns.
return nil
}
emitModelChangeForUI := func(newModel, previousModel, source string) {
kitInstance.EmitModelChange(newModel, previousModel, source)
}
// Check if running in non-interactive mode
if promptFlag != "" {
return runNonInteractiveModeApp(ctx, appInstance, cli, promptFlag, quietFlag, jsonFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands)
if positionalPrompt != "" {
return runNonInteractiveModeApp(ctx, appInstance, cli, positionalPrompt, quietFlag, jsonFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI)
}
// Quiet mode is not allowed in interactive mode
if quietFlag {
return fmt.Errorf("--quiet flag can only be used with --prompt/-p")
return fmt.Errorf("--quiet requires a prompt")
}
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands)
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI)
}
// runNonInteractiveModeApp executes a single prompt via the app layer and exits,
@@ -925,7 +1003,12 @@ func runNormalMode(ctx context.Context) error {
//
// When --no-exit is set, after the prompt completes the interactive BubbleTea
// TUI is started so the user can continue the conversation.
func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui.CLI, prompt string, quiet, jsonOutput, noExit bool, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []ui.ExtensionCommand) error {
func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui.CLI, prompt string, quiet, jsonOutput, noExit bool, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []ui.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string)) error {
// Expand @file references in the prompt before sending to the agent.
if cwd, err := os.Getwd(); err == nil {
prompt = ui.ProcessFileAttachments(prompt, cwd)
}
if jsonOutput {
// JSON mode: no intermediate display, structured JSON output.
result, err := appInstance.RunOnceResult(ctx, prompt)
@@ -963,7 +1046,7 @@ func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui
// If --no-exit was requested, hand off to the interactive TUI.
if noExit {
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands)
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModel, emitModelChange)
}
return nil
@@ -1057,7 +1140,7 @@ func writeJSONError(err error) {
// 4. Calls program.Run() which blocks until the user quits (Ctrl+C or /quit).
//
// SetupCLI is not used for interactive mode; the TUI (AppModel) handles its own rendering.
func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []ui.ExtensionCommand) error {
func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []ui.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string)) error {
// Determine terminal size; fall back gracefully.
termWidth, termHeight, err := term.GetSize(int(os.Stdout.Fd()))
if err != nil || termWidth == 0 {
@@ -1065,11 +1148,13 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN
termHeight = 24
}
cwd, _ := os.Getwd()
appModel := ui.NewAppModel(appInstance, ui.AppModelOptions{
CompactMode: viper.GetBool("compact"),
ModelName: modelName,
ProviderName: providerName,
LoadingMessage: loadingMessage,
Cwd: cwd,
Width: termWidth,
Height: termHeight,
ServerNames: serverNames,
@@ -1091,6 +1176,8 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN
EmitBeforeSessionSwitch: emitBeforeSessionSwitch,
GetGlobalShortcuts: getGlobalShortcuts,
GetExtensionCommands: getExtensionCommands,
SetModel: setModel,
EmitModelChange: emitModelChange,
})
// Print startup info to stdout before Bubble Tea takes over the screen.
+1 -1
View File
@@ -488,11 +488,11 @@ func queryExpert(name, question string) (output string, exitCode int, elapsed ti
// Build subprocess arguments. Use --json for structured output parsing.
// Don't pass --model; the subprocess inherits the same config/env default.
args := []string{
"--prompt", question,
"--json",
"--no-session",
"--no-extensions",
"--system-prompt", tmpFile.Name(),
question,
}
var stdoutBuf, stderrBuf bytes.Buffer
+1 -1
View File
@@ -209,10 +209,10 @@ func spawnAgent(state *subState) {
}
args := []string{
"--prompt", prompt,
"--json",
"--no-session",
"--no-extensions",
prompt,
}
cmd := exec.Command(kitBinary, args...)
+39 -39
View File
@@ -4,13 +4,17 @@ go 1.26.0
require (
charm.land/bubbles/v2 v2.0.0
charm.land/bubbletea/v2 v2.0.0
charm.land/fantasy v0.10.0
charm.land/bubbletea/v2 v2.0.1
charm.land/fantasy v0.11.1
charm.land/lipgloss/v2 v2.0.0
github.com/alecthomas/chroma/v2 v2.23.1
github.com/aymanbagabas/go-udiff v0.4.0
github.com/charmbracelet/fang v0.4.4
github.com/mark3labs/mcp-go v0.44.0
github.com/charmbracelet/log v0.4.2
github.com/mark3labs/mcp-go v0.44.1
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/traefik/yaegi v0.16.1
golang.org/x/term v0.40.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -22,24 +26,22 @@ require (
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/internal v1.11.2 // indirect
github.com/alecthomas/chroma/v2 v2.23.1 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.2 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.10 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.10 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 // indirect
github.com/aws/smithy-go v1.24.1 // indirect
github.com/aymanbagabas/go-udiff v0.4.0 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.3 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.11 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.11 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
@@ -48,11 +50,10 @@ require (
github.com/charmbracelet/colorprofile v0.4.2 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
github.com/charmbracelet/log v0.4.2 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260223171050-89c142e4aa73 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260223200540-d6a276319c45 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20260223200540-d6a276319c45 // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260305213658-fe36e8c10185 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20260305213658-fe36e8c10185 // 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
@@ -62,7 +63,7 @@ require (
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/go-logfmt/logfmt v0.6.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
@@ -71,14 +72,14 @@ require (
github.com/google/go-cmp v0.7.0 // 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.12 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/kaptinlin/go-i18n v0.2.11 // indirect
github.com/kaptinlin/jsonpointer v0.4.16 // indirect
github.com/kaptinlin/jsonschema v0.7.3 // indirect
github.com/kaptinlin/go-i18n v0.2.12 // indirect
github.com/kaptinlin/jsonpointer v0.4.17 // indirect
github.com/kaptinlin/jsonschema v0.7.5 // indirect
github.com/kaptinlin/messageformat-go v0.4.18 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
@@ -97,28 +98,27 @@ require (
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/traefik/yaegi v0.16.1 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/yuin/goldmark v1.7.16 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect
go.opentelemetry.io/otel v1.40.0 // indirect
go.opentelemetry.io/otel/metric v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.66.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0 // indirect
go.opentelemetry.io/otel v1.41.0 // indirect
go.opentelemetry.io/otel/metric v1.41.0 // indirect
go.opentelemetry.io/otel/trace v1.41.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/api v0.269.0 // indirect
google.golang.org/genai v1.47.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect
google.golang.org/grpc v1.79.1 // indirect
google.golang.org/genai v1.49.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/grpc v1.79.2 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)
+74 -74
View File
@@ -1,9 +1,9 @@
charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
charm.land/bubbletea/v2 v2.0.0 h1:p0d6CtWyJXJ9GfzMpUUqbP/XUUhhlk06+vCKWmox1wQ=
charm.land/bubbletea/v2 v2.0.0/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
charm.land/fantasy v0.10.0 h1:6PD+1rrsCgLIG1n+PAZp/gHiC0dltU0cvb7c8zUKyu8=
charm.land/fantasy v0.10.0/go.mod h1:KIeNQUpJTswwpY0P6HJsr3LBFgfTDb8FDpOdVQMsKqY=
charm.land/bubbletea/v2 v2.0.1 h1:B8e9zzK7x9JJ+XvHGF4xnYu9Xa0E0y0MyggY6dbaCfQ=
charm.land/bubbletea/v2 v2.0.1/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
charm.land/fantasy v0.11.1 h1:G1dRqkzEQ0RJN1Ls5mte8HOi0wFKxYd5bfnRAmeYvDk=
charm.land/fantasy v0.11.1/go.mod h1:C8wNxWlw+b2z54zsTor9r1tG2GE2C4QotvAlgXh9KF8=
charm.land/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo=
charm.land/lipgloss/v2 v2.0.0/go.mod h1:w6SnmsBFBmEFBodiEDurGS/sdUY/u1+v72DqUzc6J14=
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
@@ -32,36 +32,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.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls=
github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5/go.mod h1:nVUlMLVV8ycXSb7mSkcNu9e3v/1TJq2RTlrPwhYWr5c=
github.com/aws/aws-sdk-go-v2/config v1.32.10 h1:9DMthfO6XWZYLfzZglAgW5Fyou2nRI5CuV44sTedKBI=
github.com/aws/aws-sdk-go-v2/config v1.32.10/go.mod h1:2rUIOnA2JaiqYmSKYmRJlcMWy6qTj1vuRFscppSBMcw=
github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.10/go.mod h1:RnnlFCAlxQCkN2Q379B67USkBMu1PipEEiibzYN5UTE=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 h1:Ii4s+Sq3yDfaMLpjrJsqD6SmG/Wq/P5L/hw2qa78UAY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18/go.mod h1:6x81qnY++ovptLE6nWQeWrpXxbnlIex+4H4eYYGcqfc=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 h1:F43zk1vemYIqPAwhjTjYIz0irU2EY7sOb/F5eJ3HuyM=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18/go.mod h1:w1jdlZXrGKaJcNoL+Nnrj+k5wlpGXqnNrKoP22HvAug=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 h1:xCeWVjj0ki0l3nruoyP2slHsGArMxeiiaoPN5QZH6YQ=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18/go.mod h1:r/eLGuGCBw6l36ZRWiw6PaZwPXb6YOj+i/7MizNl5/k=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 h1:CeY9LUdur+Dxoeldqoun6y4WtJ3RQtzk0JMP2gfUay0=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5/go.mod h1:AZLZf2fMaahW5s/wMRciu1sYbdsikT/UHwbUjOdEVTc=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 h1:LTRCYFlnnKFlKsyIQxKhJuDuA3ZkrDQMRYm6rXiHlLY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18/go.mod h1:XhwkgGG6bHSd00nO/mexWTcTjgd6PjuvWQMqSn2UaEk=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 h1:MzORe+J94I+hYu2a6XmV5yC9huoTv8NRcCrUNedDypQ=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.6/go.mod h1:hXzcHLARD7GeWnifd8j9RWqtfIgxj4/cAtIVIK7hg8g=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 h1:7oGD8KPfBOJGXiCoRKrrrQkbvCp8N++u36hrLMPey6o=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.11/go.mod h1:0DO9B5EUJQlIDif+XJRWCljZRKsAFKh3gpFz7UnDtOo=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 h1:edCcNp9eGIUDUCrzoCu1jWAXLGFIizeqkdkKgRlJwWc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15/go.mod h1:lyRQKED9xWfgkYC/wmmYfv7iVIM68Z5OQ88ZdcV1QbU=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 h1:NITQpgo9A5NrDZ57uOWj+abvXSb83BbyggcUBVksN7c=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7/go.mod h1:sks5UWBhEuWYDPdwlnRFn1w7xWdH29Jcpe+/PJQefEs=
github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0=
github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/aws/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA=
github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 h1:N4lRUXZpZ1KVEUn6hxtco/1d2lgYhNn1fHkkl8WhlyQ=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2/config v1.32.11 h1:ftxI5sgz8jZkckuUHXfC/wMUc8u3fG1vQS0plr2F2Zs=
github.com/aws/aws-sdk-go-v2/config v1.32.11/go.mod h1:twF11+6ps9aNRKEDimksp923o44w/Thk9+8YIlzWMmo=
github.com/aws/aws-sdk-go-v2/credentials v1.19.11 h1:NdV8cwCcAXrCWyxArt58BrvZJ9pZ9Fhf9w6Uh5W3Uyc=
github.com/aws/aws-sdk-go-v2/credentials v1.19.11/go.mod h1:30yY2zqkMPdrvxBqzI9xQCM+WrlrZKSOpSJEsylVU+8=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 h1:INUvJxmhdEbVulJYHI061k4TVuS3jzzthNvjqvVvTKM=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19/go.mod h1:FpZN2QISLdEBWkayloda+sZjVJL+e9Gl0k1SyTgcswU=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 h1:/sECfyq2JTifMI2JPyZ4bdRN77zJmr6SrS1eL3augIA=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19/go.mod h1:dMf8A5oAqr9/oxOfLkC/c2LU/uMcALP0Rgn2BD5LWn0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 h1:AWeJMk33GTBf6J20XJe6qZoRSJo0WfUhsMdUKhoODXE=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19/go.mod h1:+GWrYoaAsV7/4pNHpwh1kiNLXkKaSoppxQq9lbH8Ejw=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 h1:clHU5fm//kWS1C2HgtgWxfQbFbx4b6rx+5jzhgX9HrI=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 h1:XAq62tBTJP/85lFD5oqOOe7YYgWxY9LvWq8plyDvDVg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 h1:X1Tow7suZk9UCJHE1Iw9GMZJJl0dAnKXXP1NaSDHwmw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19/go.mod h1:/rARO8psX+4sfjUQXp5LLifjUt8DuATZ31WptNJTyQA=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 h1:Y2cAXlClHsXkkOvWZFXATr34b0hxxloeQu/pAZz2row=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7/go.mod h1:idzZ7gmDeqeNrSPkdbtMp9qWMgcBwykA7P7Rzh5DXVU=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 h1:iSsvB9EtQ09YrsmIc44Heqlx5ByGErqhPK1ZQLppias=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12/go.mod h1:fEWYKTRGoZNl8tZ77i61/ccwOMJdGxwOhWCkp6TXAr0=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 h1:EnUdUqRP1CNzt2DkV67tJx6XDN4xlfBFm+bzeNOQVb0=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16/go.mod h1:Jic/xv0Rq/pFNCh3WwpH4BEqdbSAl+IyHro8LbibHD8=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 h1:XQTQTF75vnug2TXS8m7CVJfC2nniYPZnO1D4Np761Oo=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8/go.mod h1:Xgx+PR1NUOjNmQY+tRMnouRp83JRM8pRMw/vCaVhPkI=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.4.0 h1:TKnLPh7IbnizJIBKFWa9mKayRUBQ9Kh1BPCk6w2PnYM=
@@ -88,18 +88,18 @@ github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0r
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
github.com/charmbracelet/ultraviolet v0.0.0-20260223171050-89c142e4aa73 h1:Af/L28Xh+pddhouT/6lJ7IAIYfu5tWJOB0iqt+mXsYM=
github.com/charmbracelet/ultraviolet v0.0.0-20260223171050-89c142e4aa73/go.mod h1:E6/0abq9uG2SnM8IbLB9Y5SW09uIgfaFETk8aRzgXUQ=
github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff h1:uY7A6hTokHPJBHfq7rj9Y/wm+IAjOghZTxKfVW6QLvw=
github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff/go.mod h1:E6/0abq9uG2SnM8IbLB9Y5SW09uIgfaFETk8aRzgXUQ=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260223200540-d6a276319c45 h1:t/EWU3ZOrVxmr2d19f+1wnWr92p1O82oOTm7ASxodsA=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260223200540-d6a276319c45/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260305213658-fe36e8c10185 h1:/192monmpmRICpSPrFRzkIO+xfhioV6/nwrQdkDTj10=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260305213658-fe36e8c10185/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY=
github.com/charmbracelet/x/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/slice v0.0.0-20260223200540-d6a276319c45 h1:jgQlAnMmwbjtvd91AzjWWFtwpIZ2P/Nspx5zyrhmPec=
github.com/charmbracelet/x/exp/slice v0.0.0-20260223200540-d6a276319c45/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
github.com/charmbracelet/x/exp/slice v0.0.0-20260305213658-fe36e8c10185 h1:bloHJLweYZeIkBVgi8AF94DrTdx3eoEB57VOpFuFi3U=
github.com/charmbracelet/x/exp/slice v0.0.0-20260305213658-fe36e8c10185/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
@@ -134,8 +134,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -155,8 +155,8 @@ 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.12 h1:Fg+zsqzYEs1ZnvmcztTYxhgCBsx3eEhEwQ1W/lHq/sQ=
github.com/googleapis/enterprise-certificate-proxy v0.3.12/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=
github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
@@ -169,12 +169,12 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/kaptinlin/go-i18n v0.2.11 h1:OayNt8mWt8nDaqAOp09/C1VG9Y5u8LpQnnxbyGARDV4=
github.com/kaptinlin/go-i18n v0.2.11/go.mod h1:pVcu9qsW5pOIOoZFJXesRYmLos1vMQrby70JPAoWmJU=
github.com/kaptinlin/jsonpointer v0.4.16 h1:Ux4w4FY+uLv+K+TxaCJtM/TpPv+1+eS6gH4Z9/uhOuA=
github.com/kaptinlin/jsonpointer v0.4.16/go.mod h1:SsfsjqnHG5zuKo1DTBzk1VknaHlL4osHw+X9kZKukpU=
github.com/kaptinlin/jsonschema v0.7.3 h1:kyIydij76ORiSxmfy0xFYy0cOx8MwG6pyyaSoQshsK4=
github.com/kaptinlin/jsonschema v0.7.3/go.mod h1:Ys6zr+W6/1330FzZEouFrAYImK+AmYt5HQVTHQQXQo8=
github.com/kaptinlin/go-i18n v0.2.12 h1:ywDsvb4KDFddMC2dpI/rrIzGU2mWUSvHmWUm9BMsdl4=
github.com/kaptinlin/go-i18n v0.2.12/go.mod h1:pVcu9qsW5pOIOoZFJXesRYmLos1vMQrby70JPAoWmJU=
github.com/kaptinlin/jsonpointer v0.4.17 h1:mY9k8ciWncxbsECyaxKnR0MdmxamNdp2tLQkAKVrtSk=
github.com/kaptinlin/jsonpointer v0.4.17/go.mod h1:SsfsjqnHG5zuKo1DTBzk1VknaHlL4osHw+X9kZKukpU=
github.com/kaptinlin/jsonschema v0.7.5 h1:jkK4a3NyzNoGlvu12CsL3IcqNMVa5sL51HPVa0nWcPY=
github.com/kaptinlin/jsonschema v0.7.5/go.mod h1:3gIWnptl+SWMyfMR2r4TXXd0xsQZ1m50AKrwmcUONSg=
github.com/kaptinlin/messageformat-go v0.4.18 h1:RBlHVWgZyoxTcUgGWBsl2AcyScq/urqbLZvzgryTmSI=
github.com/kaptinlin/messageformat-go v0.4.18/go.mod h1:ntI3154RnqJgr7GaC+vZBnIExl2V3sv9selvRNNEM24=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -187,8 +187,8 @@ github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQ
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mark3labs/mcp-go v0.44.0 h1:OlYfcVviAnwNN40QZUrrzU0QZjq3En7rCU5X09a/B7I=
github.com/mark3labs/mcp-go v0.44.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
github.com/mark3labs/mcp-go v0.44.1 h1:2PKppYlT9X2fXnE8SNYQLAX4hNjfPB0oNLqQVcN6mE8=
github.com/mark3labs/mcp-go v0.44.1/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
@@ -269,28 +269,28 @@ github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 h1:XmiuHzgJt067+a6kwyAzkhXooYVv3/TOw9cM2VfJgUM=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0/go.mod h1:KDgtbWKTQs4bM+VPUr6WlL9m/WXcmkCcBlIzqxPGzmI=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.66.0 h1:w/o339tDd6Qtu3+ytwt+/jon2yjAs3Ot8Xq8pelfhSo=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.66.0/go.mod h1:pdhNtM9C4H5fRdrnwO7NjxzQWhKSSxCHk/KluVqDVC0=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0 h1:PnV4kVnw0zOmwwFkAzCN5O07fw1YOIQor120zrh0AVo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0/go.mod h1:ofAwF4uinaf8SXdVzzbL4OsxJ3VfeEg3f/F6CeF49/Y=
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8=
go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90=
go.opentelemetry.io/otel/sdk/metric v1.41.0 h1:siZQIYBAUd1rlIWQT2uCxWJxcCO7q3TriaMlf08rXw8=
go.opentelemetry.io/otel/sdk/metric v1.41.0/go.mod h1:HNBuSvT7ROaGtGI50ArdRLUnvRTRGniSUZbxiWxSO8Y=
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
@@ -308,12 +308,12 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.269.0 h1:qDrTOxKUQ/P0MveH6a7vZ+DNHxJQjtGm/uvdbdGXCQg=
google.golang.org/api v0.269.0/go.mod h1:N8Wpcu23Tlccl0zSHEkcAZQKDLdquxK+l9r2LkwAauE=
google.golang.org/genai v1.47.0 h1:iWCS7gEdO6rctOqfCYLOrZGKu2D+N42aTnCEcBvB1jo=
google.golang.org/genai v1.47.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/genai v1.49.0 h1:Se+QJaH2GYK1aaR1o5S38mlU2GD5FnVvP76nfkV7LH0=
google.golang.org/genai v1.49.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+16
View File
@@ -197,6 +197,22 @@ func (a *App) GetTreeSession() *session.TreeManager {
return a.opts.TreeSession
}
// AddContextMessage adds a user-role message to the conversation history
// without triggering an LLM response. Used by the ! shell command prefix
// to inject command output into context so the LLM can reference it in
// subsequent turns.
//
// Satisfies ui.AppController.
func (a *App) AddContextMessage(text string) {
msg := fantasy.NewUserMessage(text)
a.store.Add(msg)
// Persist to tree session if active.
if ts := a.opts.TreeSession; ts != nil {
_, _ = ts.AppendFantasyMessage(msg)
}
}
// CompactConversation summarises older messages to free context space. It
// returns an error synchronously if compaction cannot start (agent busy or
// app closed). The actual compaction runs in a background goroutine and
+1 -1
View File
@@ -130,7 +130,7 @@ func executeBash(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
}
// Truncate from tail (keep last N lines, most relevant for bash)
tr := truncateTail(output, defaultMaxLines, defaultMaxBytes)
tr := TruncateTail(output, defaultMaxLines, defaultMaxBytes)
if exitCode != 0 {
return fantasy.NewTextErrorResponse(tr.Content), nil
+7 -2
View File
@@ -9,6 +9,11 @@ const (
defaultMaxLines = 2000
defaultMaxBytes = 50 * 1024 // 50KB
grepMaxLineLen = 500
// DefaultMaxLines is the exported default line limit for truncation.
DefaultMaxLines = defaultMaxLines
// DefaultMaxBytes is the exported default byte limit for truncation.
DefaultMaxBytes = defaultMaxBytes
)
// TruncationResult describes how output was truncated.
@@ -20,9 +25,9 @@ type TruncationResult struct {
Kept int // lines kept after truncation
}
// truncateTail keeps the last maxLines lines and at most maxBytes bytes.
// TruncateTail keeps the last maxLines lines and at most maxBytes bytes.
// Used for bash output where the tail is most relevant.
func truncateTail(content string, maxLines, maxBytes int) TruncationResult {
func TruncateTail(content string, maxLines, maxBytes int) TruncationResult {
if maxLines <= 0 {
maxLines = defaultMaxLines
}
+6
View File
@@ -66,6 +66,12 @@ var SlashCommands = []SlashCommand{
Category: "System",
Aliases: []string{"/co"},
},
{
Name: "/model",
Description: "Switch to a different model",
Category: "System",
Aliases: []string{"/m"},
},
{
Name: "/quit",
Description: "Exit the application",
+30
View File
@@ -28,3 +28,33 @@ type TreeNodeSelectedMsg struct {
// TreeCancelledMsg is sent when the user cancels the tree selector (ESC).
type TreeCancelledMsg struct{}
// shellCommandMsg is sent by the InputComponent when the user submits a
// ! or !! prefixed command. The parent model intercepts this to execute
// the shell command directly instead of forwarding to the LLM.
//
// Matching pi's behavior:
// - !cmd → run shell command, output INCLUDED in LLM context
// - !!cmd → run shell command, output EXCLUDED from LLM context
type shellCommandMsg struct {
// Command is the shell command to execute (prefix stripped).
Command string
// ExcludeFromContext is true for !! (output excluded from LLM context),
// false for ! (output included in LLM context).
ExcludeFromContext bool
}
// shellCommandResultMsg carries the result of a shell command execution
// back to the parent model for display.
type shellCommandResultMsg struct {
// Command is the original shell command that was executed.
Command string
// Output is the combined stdout/stderr output.
Output string
// ExitCode is the process exit code (0 = success).
ExitCode int
// Err is non-nil if the command failed to start or timed out.
Err error
// ExcludeFromContext mirrors the flag from shellCommandMsg.
ExcludeFromContext bool
}
+129
View File
@@ -0,0 +1,129 @@
package ui
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
// fileTokenPattern matches @file references in user text. Supports:
// - @"path with spaces.txt" (quoted)
// - @path/to/file.txt (unquoted, no spaces)
var fileTokenPattern = regexp.MustCompile(`@"[^"]+"|@[^\s]+`)
// ProcessFileAttachments scans the user's input text for @file references,
// reads each referenced file, and returns the text with @tokens replaced by
// XML-wrapped file content. Non-file @ tokens (like email addresses) are left
// unchanged.
//
// Returns the original text unchanged if no valid @file references are found.
func ProcessFileAttachments(text string, cwd string) string {
tokens := fileTokenPattern.FindAllString(text, -1)
if len(tokens) == 0 {
return text
}
result := text
for _, token := range tokens {
path := tokenToPath(token)
if path == "" {
continue
}
absPath, err := resolvePath(path, cwd)
if err != nil {
// Not a valid file reference — leave the token as-is.
// This handles cases like email addresses (@user) gracefully.
continue
}
info, err := os.Stat(absPath)
if err != nil {
continue
}
// Skip directories — we only attach file content.
if info.IsDir() {
continue
}
// Skip empty files.
if info.Size() == 0 {
continue
}
content, err := os.ReadFile(absPath)
if err != nil {
continue
}
// Build the XML-wrapped replacement.
wrapped := wrapFileContent(absPath, content)
result = strings.Replace(result, token, wrapped, 1)
}
return result
}
// tokenToPath strips the @ prefix and optional quotes from a token,
// returning the raw file path. Returns "" for invalid tokens.
func tokenToPath(token string) string {
if !strings.HasPrefix(token, "@") {
return ""
}
path := token[1:]
// Strip quotes.
if strings.HasPrefix(path, `"`) && strings.HasSuffix(path, `"`) {
path = path[1 : len(path)-1]
}
// Reject obviously non-file tokens (e.g. bare @ or @-flags).
if path == "" || strings.HasPrefix(path, "-") {
return ""
}
return path
}
// resolvePath resolves a potentially relative file path to an absolute path.
// Supports ~/ expansion and relative paths. No CWD restriction — the user
// can reference any file they have read access to.
func resolvePath(path string, cwd string) (string, error) {
// Expand ~/
if strings.HasPrefix(path, "~/") {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("cannot expand ~: %w", err)
}
path = filepath.Join(home, path[2:])
}
// Resolve relative to cwd.
if !filepath.IsAbs(path) {
path = filepath.Join(cwd, path)
}
// Clean and resolve symlinks for consistent paths.
absPath, err := filepath.Abs(path)
if err != nil {
return "", fmt.Errorf("invalid path: %w", err)
}
// Resolve symlinks so the displayed path is canonical.
resolved, err := filepath.EvalSymlinks(absPath)
if err != nil {
// EvalSymlinks fails if the file doesn't exist — fall back to
// the cleaned absolute path and let the caller's Stat handle it.
return absPath, nil
}
return resolved, nil
}
// wrapFileContent wraps file content in XML tags for LLM consumption.
func wrapFileContent(absPath string, content []byte) string {
return fmt.Sprintf("<file path=\"%s\">\n%s\n</file>", absPath, string(content))
}
+388
View File
@@ -0,0 +1,388 @@
package ui
import (
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"unicode/utf8"
)
// FileSuggestion represents a single file or directory suggestion for the @
// autocomplete popup.
type FileSuggestion struct {
// RelPath is the path relative to the search base (e.g. "cmd/kit/main.go").
RelPath string
// IsDir is true when the entry is a directory.
IsDir bool
// Score is the fuzzy match score (higher is better).
Score int
}
// maxFileSuggestions is the maximum number of file suggestions returned.
const maxFileSuggestions = 20
// ExtractAtPrefix checks the current line for an @-file trigger at cursorCol.
// It returns:
// - hasAt: true if a valid @ trigger was found
// - prefix: the text after @ (possibly empty) that the user has typed so far
// - startIdx: byte offset of the @ character in the line
//
// The @ must appear at the start of the line or after whitespace. Quoted paths
// are supported: @"path with spaces" — the returned prefix strips quotes.
func ExtractAtPrefix(line string, cursorCol int) (hasAt bool, prefix string, startIdx int) {
if cursorCol > len(line) {
cursorCol = len(line)
}
// Walk backwards from cursorCol to find the @ character.
text := line[:cursorCol]
// Find the last @ that is preceded by whitespace or is at position 0.
atIdx := -1
for i := len(text) - 1; i >= 0; i-- {
if text[i] == '@' {
// Must be at start of line or preceded by whitespace.
if i == 0 || text[i-1] == ' ' || text[i-1] == '\t' {
atIdx = i
break
}
}
// Stop scanning if we hit a space — the @ we want must be in the
// current "word".
if text[i] == ' ' || text[i] == '\t' {
break
}
}
if atIdx < 0 {
return false, "", 0
}
raw := text[atIdx+1:]
// Handle quoted paths: @"some path" — strip leading quote.
if after, found := strings.CutPrefix(raw, `"`); found {
raw = strings.TrimSuffix(after, `"`)
}
return true, raw, atIdx
}
// GetFileSuggestions returns file/directory suggestions matching the given
// prefix. It tries `git ls-files` first (fast, respects .gitignore), then
// falls back to a simple directory walk.
//
// If prefix contains a path separator the search is scoped to that
// subdirectory. For example, prefix "cmd/k" searches inside "cmd/" for
// entries matching "k".
func GetFileSuggestions(prefix string, cwd string) []FileSuggestion {
// Resolve the base directory and filter query from the prefix.
baseDir, query := splitPrefixPath(prefix)
searchDir := cwd
if baseDir != "" {
candidate := resolveSearchDir(baseDir, cwd)
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
searchDir = candidate
} else {
return nil // invalid base directory
}
}
files := listFiles(searchDir, cwd)
if len(files) == 0 {
return nil
}
// Prepend baseDir so results display as "cmd/main.go" not just "main.go".
if baseDir != "" {
for i := range files {
files[i].RelPath = baseDir + files[i].RelPath
}
}
return fuzzyFilterFiles(files, prefix, query)
}
// splitPrefixPath separates a prefix like "cmd/kit/m" into
// baseDir="cmd/kit/" and query="m". If there is no separator the
// baseDir is empty and query is the full prefix.
func splitPrefixPath(prefix string) (baseDir, query string) {
// Handle ~ expansion display (we keep it in the prefix for display
// but resolve it when actually searching).
idx := strings.LastIndex(prefix, "/")
if idx < 0 {
return "", prefix
}
return prefix[:idx+1], prefix[idx+1:]
}
// resolveSearchDir converts a baseDir from the prefix into an absolute path.
// Supports ~/, ../, and absolute paths.
func resolveSearchDir(baseDir, cwd string) string {
// Expand ~/
if strings.HasPrefix(baseDir, "~/") {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, baseDir[2:])
}
}
// Absolute paths
if filepath.IsAbs(baseDir) {
return filepath.Clean(baseDir)
}
// Relative to cwd
return filepath.Join(cwd, baseDir)
}
// listFiles returns files and directories within searchDir, relative to that
// directory. Uses `git ls-files` when inside a git repo for speed and
// .gitignore awareness, otherwise falls back to os.ReadDir.
func listFiles(searchDir, cwd string) []FileSuggestion {
// Try git ls-files first (fast, respects .gitignore).
if files := listFilesGit(searchDir, cwd); files != nil {
return files
}
return listFilesReadDir(searchDir)
}
// listFilesGit uses `git ls-files` and `git ls-files --others --exclude-standard`
// to list tracked and untracked-but-not-ignored files.
func listFilesGit(searchDir, cwd string) []FileSuggestion {
// Check if we're in a git repo.
check := exec.Command("git", "rev-parse", "--show-toplevel")
check.Dir = cwd
if err := check.Run(); err != nil {
return nil
}
seen := make(map[string]bool)
var results []FileSuggestion
// Tracked files.
cmd := exec.Command("git", "ls-files")
cmd.Dir = searchDir
out, err := cmd.Output()
if err == nil {
for line := range strings.SplitSeq(strings.TrimSpace(string(out)), "\n") {
if line == "" {
continue
}
// Normalize separators.
line = filepath.ToSlash(line)
addFileEntries(&results, seen, line, searchDir)
}
}
// Untracked, non-ignored files.
cmd2 := exec.Command("git", "ls-files", "--others", "--exclude-standard")
cmd2.Dir = searchDir
out2, err := cmd2.Output()
if err == nil {
for line := range strings.SplitSeq(strings.TrimSpace(string(out2)), "\n") {
if line == "" {
continue
}
line = filepath.ToSlash(line)
addFileEntries(&results, seen, line, searchDir)
}
}
if len(results) == 0 {
return nil
}
return results
}
// addFileEntries adds the file and any intermediate directory entries to
// results if not already seen. Paths are stored with forward slashes.
func addFileEntries(results *[]FileSuggestion, seen map[string]bool, relPath string, searchDir string) {
// Add intermediate directories as suggestions (first component only).
parts := strings.SplitN(relPath, "/", 2)
if len(parts) > 1 {
dir := parts[0] + "/"
if !seen[dir] {
seen[dir] = true
*results = append(*results, FileSuggestion{RelPath: dir, IsDir: true})
}
}
// Add the file itself.
if !seen[relPath] {
seen[relPath] = true
*results = append(*results, FileSuggestion{RelPath: relPath, IsDir: false})
}
}
// listFilesReadDir is the fallback when git is not available. Lists immediate
// children of dir via os.ReadDir, skipping hidden dirs and common noise.
func listFilesReadDir(dir string) []FileSuggestion {
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
skip := map[string]bool{
".git": true, "node_modules": true, ".kit": true,
"__pycache__": true, ".venv": true, "vendor": true,
}
var results []FileSuggestion
for _, e := range entries {
name := e.Name()
if skip[name] {
continue
}
// Skip hidden files/dirs (except common config files).
if strings.HasPrefix(name, ".") && name != ".env" && name != ".gitignore" {
continue
}
if e.IsDir() {
results = append(results, FileSuggestion{RelPath: name + "/", IsDir: true})
} else {
results = append(results, FileSuggestion{RelPath: name, IsDir: false})
}
}
return results
}
// fuzzyFilterFiles scores and filters file suggestions against the query,
// returning the top maxFileSuggestions results sorted by score descending.
// Directories are boosted slightly so they appear near the top.
func fuzzyFilterFiles(files []FileSuggestion, fullPrefix, query string) []FileSuggestion {
if query == "" && fullPrefix == "" {
// No filter — return all (capped).
if len(files) > maxFileSuggestions {
files = files[:maxFileSuggestions]
}
return files
}
// When there's a base dir but no query (e.g. "cmd/"), show everything
// in that directory.
if query == "" {
var filtered []FileSuggestion
for i := range files {
if strings.HasPrefix(files[i].RelPath, fullPrefix) {
// Only show direct children of the base directory.
rest := files[i].RelPath[len(fullPrefix):]
if rest == "" {
continue
}
filtered = append(filtered, files[i])
}
}
if len(filtered) > maxFileSuggestions {
filtered = filtered[:maxFileSuggestions]
}
return filtered
}
var scored []FileSuggestion
queryLower := strings.ToLower(query)
for i := range files {
path := files[i].RelPath
// When we have a fullPrefix with a dir component, only consider
// files under that directory.
if fullPrefix != query && !strings.HasPrefix(path, fullPrefix[:len(fullPrefix)-len(query)]) {
continue
}
score := scoreFilePath(queryLower, path)
if score <= 0 {
continue
}
// Boost directories so they appear near the top for navigation.
if files[i].IsDir {
score += 10
}
files[i].Score = score
scored = append(scored, files[i])
}
// Sort by score descending.
sort.Slice(scored, func(i, j int) bool {
return scored[i].Score > scored[j].Score
})
if len(scored) > maxFileSuggestions {
scored = scored[:maxFileSuggestions]
}
return scored
}
// scoreFilePath scores a file path against a fuzzy query. Higher is better.
// Returns 0 if there is no match.
func scoreFilePath(query, path string) int {
pathLower := strings.ToLower(path)
baseName := filepath.Base(strings.TrimSuffix(path, "/"))
baseNameLower := strings.ToLower(baseName)
// Exact basename match.
if baseNameLower == query {
return 1000
}
// Basename starts with query.
if strings.HasPrefix(baseNameLower, query) {
return 800 - len(baseName) + len(query)
}
// Basename contains query as substring.
if strings.Contains(baseNameLower, query) {
return 500 - len(baseName) + len(query)
}
// Full path contains query as substring.
if strings.Contains(pathLower, query) {
return 300 - len(path) + len(query)
}
// Fuzzy character match on basename.
if score := fuzzyCharMatch(query, baseNameLower); score > 0 {
return score
}
// Fuzzy character match on full path.
if score := fuzzyCharMatch(query, pathLower); score > 0 {
return score - 50
}
return 0
}
// fuzzyCharMatch performs character-by-character fuzzy matching. Returns a
// positive score if all query characters appear in order in the target.
func fuzzyCharMatch(query, target string) int {
if utf8.RuneCountInString(query) > utf8.RuneCountInString(target) {
return 0
}
qRunes := []rune(query)
tRunes := []rune(target)
qi := 0
score := 100
consecutive := 0
for ti := 0; ti < len(tRunes) && qi < len(qRunes); ti++ {
if tRunes[ti] == qRunes[qi] {
qi++
consecutive++
score += consecutive * 5
} else {
consecutive = 0
score -= 2
}
}
if qi < len(qRunes) {
return 0
}
return score
}
+162 -12
View File
@@ -43,6 +43,18 @@ type InputComponent struct {
argCommand string // command prefix for arg mode (e.g. "/bookmark")
argSynthCmds []SlashCommand // backing storage for synthetic arg entries
// File completion state. When the user types @ followed by a partial
// file path, the popup shows file/directory suggestions from the cwd.
fileMode bool // true when showing @file completions
filePrefix string // current text after @ being matched
fileAtStartIdx int // byte offset of @ in the textarea value
fileSuggestions []FileSuggestion // backing storage for file entries
fileSynthCmds []SlashCommand // synthetic SlashCommands wrapping file entries
// cwd is the working directory used for @file path resolution and
// autocomplete suggestions. Set by the parent via SetCwd.
cwd string
// appCtrl is used for slash commands that mutate app state.
// May be nil in tests; nil-safe.
appCtrl AppController
@@ -90,6 +102,12 @@ func NewInputComponent(width int, title string, appCtrl AppController) *InputCom
}
}
// SetCwd sets the working directory used for @file autocomplete suggestions
// and path resolution. Should be called by the parent after construction.
func (s *InputComponent) SetCwd(cwd string) {
s.cwd = cwd
}
// Init implements tea.Model. Starts the cursor blink animation.
func (s *InputComponent) Init() tea.Cmd {
return textarea.Blink
@@ -148,19 +166,29 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))):
if s.selected < len(s.filtered) {
if s.argMode {
if s.fileMode {
s.applyFileCompletion(s.selected)
} else if s.argMode {
s.textarea.SetValue(s.argCommand + " " + s.filtered[s.selected].Command.Name)
s.showPopup = false
s.selected = 0
} else {
s.textarea.SetValue(s.filtered[s.selected].Command.Name)
s.showPopup = false
s.selected = 0
}
s.showPopup = false
s.selected = 0
s.textarea.CursorEnd()
}
return s, nil
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
if s.selected < len(s.filtered) {
if s.fileMode {
// Apply file completion but don't submit.
s.applyFileCompletion(s.selected)
s.textarea.CursorEnd()
return s, nil
}
// Populate textarea with selected item and submit on next tick.
if s.argMode {
s.textarea.SetValue(s.argCommand + " " + s.filtered[s.selected].Command.Name)
@@ -190,7 +218,37 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if value != s.lastValue {
s.lastValue = value
lines := strings.Split(value, "\n")
if len(lines) == 1 && strings.HasPrefix(lines[0], "/") {
line := lines[len(lines)-1] // current line (last line for multi-line)
// Check for @file trigger first.
cursorCol := len(line) // approximate: cursor is at end after typing
if hasAt, prefix, atIdx := ExtractAtPrefix(line, cursorCol); hasAt && s.cwd != "" {
suggestions := GetFileSuggestions(prefix, s.cwd)
if len(suggestions) > 0 {
s.showPopup = true
s.fileMode = true
s.argMode = false
s.filePrefix = prefix
s.fileAtStartIdx = atIdx
s.fileSuggestions = suggestions
s.fileSynthCmds = make([]SlashCommand, len(suggestions))
s.filtered = make([]FuzzyMatch, len(suggestions))
for i, fs := range suggestions {
name := fs.RelPath
desc := ""
if fs.IsDir {
desc = "directory"
}
s.fileSynthCmds[i] = SlashCommand{Name: name, Description: desc}
s.filtered[i] = FuzzyMatch{Command: &s.fileSynthCmds[i], Score: fs.Score}
}
s.selected = 0
} else {
s.showPopup = false
s.fileMode = false
}
} else if len(lines) == 1 && strings.HasPrefix(lines[0], "/") {
s.fileMode = false
if !strings.Contains(lines[0], " ") {
// Command name completion.
s.showPopup = true
@@ -210,6 +268,7 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else {
s.showPopup = false
s.argMode = false
s.fileMode = false
}
}
return s, cmd
@@ -223,12 +282,34 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// handleSubmit processes the submitted text. Slash commands that affect app
// state are executed here; /quit returns tea.Quit; everything else returns a
// submitMsg tea.Cmd for the parent to forward to app.Run().
//
// Shell command prefixes (matching pi's behavior):
// - !cmd → execute shell command, output INCLUDED in LLM context
// - !!cmd → execute shell command, output EXCLUDED from LLM context
func (s *InputComponent) handleSubmit(value string) tea.Cmd {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil
}
// Check for shell command prefixes before slash commands. Test !! first
// (more specific) to avoid matching the single-! case for double-bang.
if strings.HasPrefix(trimmed, "!!") {
cmd := strings.TrimSpace(trimmed[2:])
if cmd != "" {
return func() tea.Msg {
return shellCommandMsg{Command: cmd, ExcludeFromContext: true}
}
}
} else if strings.HasPrefix(trimmed, "!") {
cmd := strings.TrimSpace(trimmed[1:])
if cmd != "" {
return func() tea.Msg {
return shellCommandMsg{Command: cmd, ExcludeFromContext: false}
}
}
}
// Resolve via canonical command lookup so aliases are handled uniformly.
// Only /quit and /clear are handled locally — /clear-queue must go
// through the parent model so it can update queueCount directly
@@ -335,16 +416,32 @@ func (s *InputComponent) renderPopup() string {
descStyle = descStyle.Foreground(lipgloss.Color("250"))
}
nameWidth := 15
name := nameStyle.Width(nameWidth - 2).Render(sc.Name)
if s.fileMode {
// File mode: use full width for the path, show description
// (e.g. "directory") inline after a gap.
maxNameLen := s.width - 24
displayName := sc.Name
if len(displayName) > maxNameLen && maxNameLen > 3 {
displayName = displayName[:maxNameLen-3] + "..."
}
name := nameStyle.Render(displayName)
if sc.Description != "" {
items = append(items, indicator+name+" "+descStyle.Render(sc.Description))
} else {
items = append(items, indicator+name)
}
} else {
nameWidth := 15
name := nameStyle.Width(nameWidth - 2).Render(sc.Name)
desc := sc.Description
maxDescLen := s.width - nameWidth - 14
if len(desc) > maxDescLen && maxDescLen > 3 {
desc = desc[:maxDescLen-3] + "..."
desc := sc.Description
maxDescLen := s.width - nameWidth - 14
if len(desc) > maxDescLen && maxDescLen > 3 {
desc = desc[:maxDescLen-3] + "..."
}
items = append(items, indicator+name+descStyle.Render(desc))
}
items = append(items, indicator+name+descStyle.Render(desc))
}
if startIdx > 0 {
@@ -404,3 +501,56 @@ func (s *InputComponent) findCommandWithComplete(name string) *SlashCommand {
}
return nil
}
// applyFileCompletion replaces the @prefix in the textarea with the selected
// file suggestion. For directories, it keeps the popup open for further
// drilling. For files, it closes the popup and adds a trailing space.
func (s *InputComponent) applyFileCompletion(idx int) {
if idx >= len(s.fileSuggestions) {
return
}
suggestion := s.fileSuggestions[idx]
value := s.textarea.Value()
// Build the replacement text. The @ and everything after it up to the
// cursor should be replaced with @<selected path>.
// Find the current line's contribution.
lines := strings.Split(value, "\n")
lastLine := lines[len(lines)-1]
// Reconstruct: everything before the @ on the last line + @<path>
beforeAt := lastLine[:s.fileAtStartIdx]
needsQuote := strings.Contains(suggestion.RelPath, " ")
var replacement string
if needsQuote {
replacement = `@"` + suggestion.RelPath + `"`
} else {
replacement = "@" + suggestion.RelPath
}
// For files, add a trailing space. For directories, don't — allow
// continued drilling into the directory.
if !suggestion.IsDir {
replacement += " "
}
newLastLine := beforeAt + replacement
// Reconstruct the full value with the updated last line.
lines[len(lines)-1] = newLastLine
newValue := strings.Join(lines, "\n")
s.textarea.SetValue(newValue)
s.textarea.CursorEnd()
if suggestion.IsDir {
// Keep popup open — trigger a refresh for the new directory.
s.lastValue = "" // force re-evaluation on next update tick
} else {
s.showPopup = false
s.fileMode = false
s.selected = 0
}
}
+438 -36
View File
@@ -1,14 +1,18 @@
package ui
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/mark3labs/kit/internal/app"
"github.com/mark3labs/kit/internal/core"
"github.com/mark3labs/kit/internal/session"
)
@@ -34,6 +38,9 @@ const (
// stateOverlay means an extension-triggered modal overlay dialog is active.
// The overlay takes over the full view until the user completes or cancels.
stateOverlay
// stateModelSelector means the /model selector overlay is active.
stateModelSelector
)
// AppController is the interface the parent TUI model uses to interact with the
@@ -72,6 +79,11 @@ type AppController interface {
// results back to the TUI without going through tea.Cmd (which can stall
// when the goroutine blocks on interactive prompts).
SendEvent(tea.Msg)
// AddContextMessage adds a user-role message to the conversation history
// without triggering an LLM response. Used by the ! shell command prefix
// to inject command output into context so the LLM can reference it in
// subsequent turns.
AddContextMessage(text string)
}
// SkillItem holds display metadata about a loaded skill for the startup
@@ -201,6 +213,10 @@ type AppModelOptions struct {
// (e.g. GPU fallback info). Displayed at startup when non-empty.
LoadingMessage string
// Cwd is the working directory for @file autocomplete and path resolution.
// If empty, @file features are disabled.
Cwd string
// Width is the initial terminal width in columns.
Width int
@@ -294,6 +310,17 @@ type AppModelOptions struct {
// commands. Called on WidgetUpdateEvent to refresh the command list
// after an extension hot-reload. May be nil if no extensions loaded.
GetExtensionCommands func() []ExtensionCommand
// SetModel changes the active model at runtime. The model string uses
// "provider/model" format (e.g. "anthropic/claude-sonnet-4-5-20250929").
// Returns an error if the model string is invalid or the provider cannot
// be created. May be nil if model switching is not supported.
SetModel func(modelString string) error
// EmitModelChange fires the OnModelChange extension event after a
// successful model switch. Parameters are (newModel, previousModel, source).
// May be nil if extensions are not loaded.
EmitModelChange func(newModel, previousModel, source string)
}
// AppModel is the root Bubble Tea model for the interactive TUI. It owns the
@@ -423,6 +450,16 @@ type AppModel struct {
// to refresh the command list after an extension hot-reload. May be nil.
getExtensionCommands func() []ExtensionCommand
// setModel changes the active model at runtime. Wired from cmd/root.go.
// May be nil if model switching is not supported.
setModel func(modelString string) error
// emitModelChange fires the OnModelChange extension event. May be nil.
emitModelChange func(newModel, previousModel, source string)
// modelSelector is the model selection overlay, active in stateModelSelector.
modelSelector *ModelSelectorComponent
// prompt holds the state of an active interactive prompt overlay. Nil
// when no prompt is active. Managed by updatePromptState().
prompt *promptOverlay
@@ -449,6 +486,9 @@ type AppModel struct {
// so the model can return to it when the overlay completes.
preOverlayState appState
// cwd is the working directory for @file path resolution.
cwd string
// width and height track the terminal dimensions.
width int
height int
@@ -526,6 +566,7 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
serverNames: opts.ServerNames,
toolNames: opts.ToolNames,
usageTracker: opts.UsageTracker,
cwd: opts.Cwd,
width: width,
height: height,
}
@@ -542,6 +583,8 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
m.emitBeforeSessionSwitch = opts.EmitBeforeSessionSwitch
m.getGlobalShortcuts = opts.GetGlobalShortcuts
m.getExtensionCommands = opts.GetExtensionCommands
m.setModel = opts.SetModel
m.emitModelChange = opts.EmitModelChange
// Store context/skills metadata and tool counts for startup display.
m.contextPaths = opts.ContextPaths
@@ -552,6 +595,11 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
// Wire up child components now that we have the concrete implementations.
m.input = NewInputComponent(width, "Enter your prompt (Type /help for commands, Ctrl+C to quit)", appCtrl)
// Wire up cwd for @file autocomplete.
if ic, ok := m.input.(*InputComponent); ok && opts.Cwd != "" {
ic.SetCwd(opts.Cwd)
}
// Merge extension commands into the InputComponent's autocomplete source.
if ic, ok := m.input.(*InputComponent); ok && len(opts.ExtensionCommands) > 0 {
for _, ec := range opts.ExtensionCommands {
@@ -705,34 +753,31 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
// Emit before-fork event — extensions can cancel the operation.
// Emit before-fork event in a goroutine so that extension handlers
// can call blocking operations (e.g. ctx.PromptConfirm) without
// deadlocking the BubbleTea event loop.
if m.emitBeforeFork != nil {
if cancelled, reason := m.emitBeforeFork(targetID, msg.IsUser, msg.UserText); cancelled {
m.treeSelector = nil
m.state = stateInput
return m, m.printSystemMessage(reason)
}
emit := m.emitBeforeFork
ctrl := m.appCtrl
forkTargetID := targetID
forkIsUser := msg.IsUser
forkUserText := msg.UserText
go func() {
cancelled, reason := emit(forkTargetID, forkIsUser, forkUserText)
ctrl.SendEvent(beforeForkResultMsg{
cancelled: cancelled,
reason: reason,
targetID: forkTargetID,
isUser: forkIsUser,
userText: forkUserText,
})
}()
m.treeSelector = nil
m.state = stateInput
return m, func() tea.Msg { return nil }
}
_ = ts.Branch(targetID)
m.appCtrl.ClearMessages()
// If it was a user message, populate the input with the text.
if msg.IsUser && msg.UserText != "" {
if ic, ok := m.input.(*InputComponent); ok {
ic.textarea.SetValue(msg.UserText)
ic.textarea.CursorEnd()
}
}
cmds = append(cmds, m.printSystemMessage(
fmt.Sprintf("Navigated to branch point. %s",
func() string {
if msg.IsUser {
return "Edit and resubmit to create a new branch."
}
return "Continue from this point."
}())))
cmds = append(cmds, m.performFork(targetID, msg.IsUser, msg.UserText))
}
m.treeSelector = nil
m.state = stateInput
@@ -743,6 +788,39 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.state = stateInput
return m, nil
// ── Model selector events ────────────────────────────────────────────────
case ModelSelectedMsg:
m.modelSelector = nil
m.state = stateInput
if m.setModel != nil {
previousModel := m.providerName + "/" + m.modelName
if err := m.setModel(msg.ModelString); err != nil {
cmds = append(cmds, m.printSystemMessage(fmt.Sprintf("Failed to switch model: %v", err)))
} else {
// Update display state directly — we cannot use
// NotifyModelChanged (prog.Send) from inside Update()
// without deadlocking BubbleTea.
parts := strings.SplitN(msg.ModelString, "/", 2)
if len(parts) == 2 {
m.providerName = parts[0]
m.modelName = parts[1]
}
cmds = append(cmds, m.printSystemMessage(fmt.Sprintf("Switched to %s", msg.ModelString)))
if m.emitModelChange != nil {
emit := m.emitModelChange
newModel := msg.ModelString
prev := previousModel
go emit(newModel, prev, "user")
}
}
}
return m, tea.Batch(cmds...)
case ModelSelectorCancelledMsg:
m.modelSelector = nil
m.state = stateInput
return m, nil
// ── Window resize ────────────────────────────────────────────────────────
case tea.WindowSizeMsg:
m.width = msg.Width
@@ -801,6 +879,14 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
}
// Route to model selector when active.
if m.state == stateModelSelector && m.modelSelector != nil {
updated, cmd := m.modelSelector.Update(msg)
m.modelSelector = updated.(*ModelSelectorComponent)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
switch msg.String() {
case "esc":
if m.state == stateWorking {
@@ -882,14 +968,23 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
}
// /compact supports optional args: "/compact Focus on API decisions".
// /compact and /model support optional args (e.g. "/compact Focus on API",
// "/model anthropic/claude-haiku-3-5-20241022").
// GetCommandByName won't match the full text, so check the prefix.
if name, args, ok := strings.Cut(msg.Text, " "); ok {
if sc := GetCommandByName(name); sc != nil && sc.Name == "/compact" {
if cmd := m.handleCompactCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
if sc := GetCommandByName(name); sc != nil {
switch sc.Name {
case "/compact":
if cmd := m.handleCompactCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
return m, tea.Batch(cmds...)
case "/model":
if cmd := m.handleModelCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
return m, tea.Batch(cmds...)
}
return m, tea.Batch(cmds...)
}
}
@@ -901,12 +996,20 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
// Regular prompt — forward to the app layer.
// Preprocess @file references: expand them into XML-wrapped file
// content before sending to the agent. The display text (shown in
// scrollback) uses the original user text so the UI stays clean.
processedText := msg.Text
if m.cwd != "" {
processedText = ProcessFileAttachments(msg.Text, m.cwd)
}
if m.appCtrl != nil {
// Run returns the queue depth: >0 means the prompt was queued
// (agent is busy). We update queuedMessages directly here
// instead of relying on an event from prog.Send(), which would
// deadlock when called synchronously from within Update().
if qLen := m.appCtrl.Run(msg.Text); qLen > 0 {
if qLen := m.appCtrl.Run(processedText); qLen > 0 {
// Queued: anchor the message text above the input with a
// "queued" badge. It will be printed to scrollback when
// the agent picks it up (on QueueUpdatedEvent).
@@ -923,6 +1026,14 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.state = stateWorking
}
// ── Shell command (! / !!) ───────────────────────────────────────────────
case shellCommandMsg:
// Execute the shell command asynchronously so the TUI stays responsive.
cmds = append(cmds, m.executeShellCommand(msg))
case shellCommandResultMsg:
cmds = append(cmds, m.handleShellCommandResult(msg))
// ── App layer events ─────────────────────────────────────────────────────
case app.SpinnerEvent:
@@ -1163,6 +1274,24 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, m.printSystemMessage(msg.output))
}
case beforeSessionSwitchResultMsg:
// Async before-session-switch hook completed. Proceed with the
// session reset if the hook did not cancel.
if msg.cancelled {
cmds = append(cmds, m.printSystemMessage(msg.reason))
} else {
cmds = append(cmds, m.performNewSession())
}
case beforeForkResultMsg:
// Async before-fork hook completed. Proceed with the fork if the
// hook did not cancel.
if msg.cancelled {
cmds = append(cmds, m.printSystemMessage(msg.reason))
} else {
cmds = append(cmds, m.performFork(msg.targetID, msg.isUser, msg.userText))
}
case app.ExtensionPrintEvent:
// Extension output — route through styled renderers when a level is set.
switch msg.Level {
@@ -1203,6 +1332,11 @@ func (m *AppModel) View() tea.View {
return m.treeSelector.View()
}
// Model selector overlay replaces the normal layout.
if m.state == stateModelSelector && m.modelSelector != nil {
return m.modelSelector.View()
}
// Overlay dialog replaces the normal layout.
if m.state == stateOverlay && m.overlay != nil {
return tea.NewView(m.overlay.Render())
@@ -1542,6 +1676,8 @@ func (m *AppModel) handleSlashCommand(sc *SlashCommand) tea.Cmd {
return m.printUsageMessage()
case "/reset-usage":
return m.printResetUsage()
case "/model":
return m.handleModelCommand("")
case "/compact":
return m.handleCompactCommand("")
case "/clear":
@@ -1697,7 +1833,10 @@ func (m *AppModel) printHelpMessage() tea.Cmd {
help += skillHelp.String()
}
help += "**Keys:**\n" +
help += "**Shell Commands:**\n" +
"- `!command`: Run shell command, output included in LLM context\n" +
"- `!!command`: Run shell command, output excluded from LLM context\n\n" +
"**Keys:**\n" +
"- `Ctrl+C`: Exit at any time\n" +
"- `ESC` (x2): Cancel ongoing LLM generation\n\n" +
"You can also just type your message to chat with the AI assistant."
@@ -1967,6 +2106,50 @@ func remapKey(name string) (tea.KeyPressMsg, bool) {
}
}
// --------------------------------------------------------------------------
// Model command handler
// --------------------------------------------------------------------------
// handleModelCommand handles the /model slash command. With no arguments, it
// opens an interactive model selector overlay with fuzzy finding. With an
// argument (e.g. "/model anthropic/claude-haiku-3-5-20241022"), it switches
// to that model directly.
func (m *AppModel) handleModelCommand(args string) tea.Cmd {
if m.setModel == nil {
return m.printSystemMessage("Model switching is not available.")
}
if args == "" {
// Open the interactive model selector.
currentModel := m.providerName + "/" + m.modelName
m.modelSelector = NewModelSelector(currentModel, m.width, m.height)
m.state = stateModelSelector
return nil
}
// Direct model switch with the provided model string.
previousModel := m.providerName + "/" + m.modelName
if err := m.setModel(args); err != nil {
return m.printSystemMessage(fmt.Sprintf("Failed to switch model: %v", err))
}
// Update display state directly (cannot use prog.Send from Update).
parts := strings.SplitN(args, "/", 2)
if len(parts) == 2 {
m.providerName = parts[0]
m.modelName = parts[1]
}
if m.emitModelChange != nil {
emit := m.emitModelChange
prev := previousModel
newModel := args
go emit(newModel, prev, "user")
}
return m.printSystemMessage(fmt.Sprintf("Switched to %s", args))
}
// --------------------------------------------------------------------------
// Tree session command handlers
// --------------------------------------------------------------------------
@@ -2004,13 +2187,28 @@ func (m *AppModel) handleForkCommand() tea.Cmd {
// handleNewCommand starts a fresh session by resetting the tree leaf.
func (m *AppModel) handleNewCommand() tea.Cmd {
// Emit before-session-switch event — extensions can cancel.
// Emit before-session-switch event in a goroutine so that extension
// handlers can call blocking operations (e.g. ctx.PromptConfirm) without
// deadlocking the BubbleTea event loop.
if m.emitBeforeSessionSwitch != nil {
if cancelled, reason := m.emitBeforeSessionSwitch("new"); cancelled {
return m.printSystemMessage(reason)
}
emit := m.emitBeforeSessionSwitch
ctrl := m.appCtrl
go func() {
cancelled, reason := emit("new")
ctrl.SendEvent(beforeSessionSwitchResultMsg{
cancelled: cancelled,
reason: reason,
})
}()
return func() tea.Msg { return nil }
}
return m.performNewSession()
}
// performNewSession performs the actual session reset. Called either directly
// (when no before-hook exists) or after the async hook completes.
func (m *AppModel) performNewSession() tea.Cmd {
ts := m.appCtrl.GetTreeSession()
if ts == nil {
// No tree session — just clear messages.
@@ -2027,6 +2225,35 @@ func (m *AppModel) handleNewCommand() tea.Cmd {
return m.printSystemMessage("New branch started. Previous conversation is preserved in the tree.")
}
// performFork performs the actual tree branch. Called either directly (when no
// before-hook exists) or after the async before-fork hook completes.
func (m *AppModel) performFork(targetID string, isUser bool, userText string) tea.Cmd {
ts := m.appCtrl.GetTreeSession()
if ts == nil {
return m.printSystemMessage("No tree session active.")
}
_ = ts.Branch(targetID)
m.appCtrl.ClearMessages()
// If it was a user message, populate the input with the text.
if isUser && userText != "" {
if ic, ok := m.input.(*InputComponent); ok {
ic.textarea.SetValue(userText)
ic.textarea.CursorEnd()
}
}
return m.printSystemMessage(
fmt.Sprintf("Navigated to branch point. %s",
func() string {
if isUser {
return "Edit and resubmit to create a new branch."
}
return "Continue from this point."
}()))
}
// handleNameCommand sets a display name for the current session.
func (m *AppModel) handleNameCommand() tea.Cmd {
ts := m.appCtrl.GetTreeSession()
@@ -2100,6 +2327,26 @@ type extensionCmdResultMsg struct {
err error
}
// beforeSessionSwitchResultMsg carries the result of an asynchronously
// executed before-session-switch hook. The hook runs in a goroutine so that
// blocking operations like ctx.PromptConfirm() do not deadlock the TUI.
type beforeSessionSwitchResultMsg struct {
cancelled bool
reason string
}
// beforeForkResultMsg carries the result of an asynchronously executed
// before-fork hook along with the fork context needed to complete the
// operation if the hook allows it.
type beforeForkResultMsg struct {
cancelled bool
reason string
// Fork context — preserved so the operation can proceed after the hook.
targetID string
isUser bool
userText string
}
// updatePromptState handles all messages while the prompt overlay is active.
// It routes keys to the prompt overlay, detects completion/cancellation, and
// restores the previous state when done.
@@ -2231,3 +2478,158 @@ func (m *AppModel) resolveOverlay(resp app.OverlayResponse) {
m.overlay = nil
m.state = m.preOverlayState
}
// --------------------------------------------------------------------------
// Shell command execution (! and !!)
// --------------------------------------------------------------------------
// shellCommandTimeout is the maximum duration for a user shell command.
const shellCommandTimeout = 120 * time.Second
// executeShellCommand runs a shell command asynchronously and returns the
// result as a shellCommandResultMsg. This is launched from Update() as a
// tea.Cmd so the TUI stays responsive during execution.
func (m *AppModel) executeShellCommand(msg shellCommandMsg) tea.Cmd {
command := msg.Command
excludeFromContext := msg.ExcludeFromContext
cwd := m.cwd
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), shellCommandTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "bash", "-c", command)
if cwd != "" {
cmd.Dir = cwd
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
exitCode := 0
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
// Non-zero exit is reported via exitCode, not as an error.
err = nil
} else if ctx.Err() == context.DeadlineExceeded {
return shellCommandResultMsg{
Command: command,
Output: fmt.Sprintf("command timed out after %v", shellCommandTimeout),
ExitCode: -1,
Err: fmt.Errorf("command timed out after %v", shellCommandTimeout),
ExcludeFromContext: excludeFromContext,
}
}
}
// Combine stdout + stderr.
var combined strings.Builder
if stdout.Len() > 0 {
combined.WriteString(stdout.String())
}
if stderr.Len() > 0 {
if combined.Len() > 0 {
combined.WriteString("\n")
}
combined.WriteString(stderr.String())
}
return shellCommandResultMsg{
Command: command,
Output: combined.String(),
ExitCode: exitCode,
Err: err,
ExcludeFromContext: excludeFromContext,
}
}
}
// handleShellCommandResult processes the result of a shell command execution.
// It prints the output to scrollback and optionally injects it into the
// conversation context (for ! commands) so the LLM can see it.
func (m *AppModel) handleShellCommandResult(msg shellCommandResultMsg) tea.Cmd {
theme := GetTheme()
// Build the display header.
var header string
if msg.ExcludeFromContext {
header = fmt.Sprintf("$ %s (excluded from context)", msg.Command)
} else {
header = fmt.Sprintf("$ %s", msg.Command)
}
// Build the output content.
var content strings.Builder
content.WriteString(header)
// Display-level truncation: show first maxShellDisplayLines lines with a
// "...(N more lines)" hint, matching the tool result renderer behavior.
const maxShellDisplayLines = 20
displayOutput := msg.Output
var displayHiddenCount int
if displayOutput != "" {
lines := strings.Split(displayOutput, "\n")
if len(lines) > maxShellDisplayLines {
displayHiddenCount = len(lines) - maxShellDisplayLines
displayOutput = strings.Join(lines[:maxShellDisplayLines], "\n")
}
}
if msg.Err != nil {
fmt.Fprintf(&content, "\n\nError: %v", msg.Err)
} else if displayOutput != "" {
content.WriteString("\n\n")
content.WriteString(displayOutput)
if displayHiddenCount > 0 {
fmt.Fprintf(&content, "\n\n...(%d more lines)", displayHiddenCount)
}
} else {
content.WriteString("\n\n(no output)")
}
if msg.ExitCode != 0 {
fmt.Fprintf(&content, "\n\nExit code: %d", msg.ExitCode)
}
// Choose border color: dim for excluded, accent for included.
borderClr := theme.Accent
if msg.ExcludeFromContext {
borderClr = theme.Muted
}
rendered := renderContentBlock(
content.String(),
m.width,
WithAlign(lipgloss.Left),
WithBorderColor(borderClr),
WithMarginBottom(1),
)
var cmds []tea.Cmd
cmds = append(cmds, tea.Println(rendered))
// For ! (included in context): inject the command output into the
// conversation as a user message so the LLM can reference it on the
// next turn. This does NOT trigger an LLM response — it only adds
// to the conversation history.
if !msg.ExcludeFromContext && m.appCtrl != nil {
// Truncate context output with the same limits as display.
contextOutput := msg.Output
if contextOutput != "" {
tr := core.TruncateTail(contextOutput, core.DefaultMaxLines, core.DefaultMaxBytes)
contextOutput = tr.Content
} else {
contextOutput = "(no output)"
}
contextMsg := fmt.Sprintf("<shell_command>\n<command>%s</command>\n<output>\n%s</output>\n<exit_code>%d</exit_code>\n</shell_command>",
msg.Command, contextOutput, msg.ExitCode)
m.appCtrl.AddContextMessage(contextMsg)
}
return tea.Batch(cmds...)
}
+413
View File
@@ -0,0 +1,413 @@
package ui
import (
"fmt"
"sort"
"strings"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/mark3labs/kit/internal/models"
)
// ModelEntry holds display metadata for a single model in the selector.
type ModelEntry struct {
Provider string
ModelID string
Name string // human-friendly name (e.g. "Claude Haiku 4.5")
ContextLimit int
Reasoning bool
}
// ModelSelectedMsg is sent when the user selects a model from the selector.
type ModelSelectedMsg struct {
ModelString string // "provider/model-id"
}
// ModelSelectorCancelledMsg is sent when the user cancels the selector.
type ModelSelectorCancelledMsg struct{}
// ModelSelectorComponent is a full-screen Bubble Tea component that displays
// a filterable list of available models. It follows the same pattern as
// TreeSelectorComponent: inline text search, scrolling list, and custom
// messages for result delivery.
type ModelSelectorComponent struct {
allModels []ModelEntry // all available models (pre-sorted)
filtered []ModelEntry // subset matching the current search
cursor int
search string
currentModel string // "provider/model" of the active model (for checkmark)
width int
height int
active bool
}
// NewModelSelector creates a model selector populated from the global registry,
// filtered to only providers with configured API keys.
func NewModelSelector(currentModel string, width, height int) *ModelSelectorComponent {
registry := models.GetGlobalRegistry()
var allModels []ModelEntry
for _, providerID := range registry.GetFantasyProviders() {
// Only include providers with valid API keys configured.
if err := registry.ValidateEnvironment(providerID, ""); err != nil {
continue
}
modelsMap, err := registry.GetModelsForProvider(providerID)
if err != nil {
continue
}
for modelID, info := range modelsMap {
allModels = append(allModels, ModelEntry{
Provider: providerID,
ModelID: modelID,
Name: info.Name,
ContextLimit: info.Limit.Context,
Reasoning: info.Reasoning,
})
}
}
// Sort: alphabetically by model ID, grouped by provider.
sort.Slice(allModels, func(i, j int) bool {
if allModels[i].Provider != allModels[j].Provider {
return allModels[i].Provider < allModels[j].Provider
}
return allModels[i].ModelID < allModels[j].ModelID
})
ms := &ModelSelectorComponent{
allModels: allModels,
filtered: allModels,
currentModel: currentModel,
width: width,
height: height,
active: true,
}
// Position cursor on the current model if found.
for i, m := range ms.filtered {
if m.Provider+"/"+m.ModelID == currentModel {
ms.cursor = i
break
}
}
return ms
}
// Init implements tea.Model.
func (ms *ModelSelectorComponent) Init() tea.Cmd {
return nil
}
// Update implements tea.Model.
func (ms *ModelSelectorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
ms.width = msg.Width
ms.height = msg.Height
return ms, nil
case tea.KeyPressMsg:
switch {
case key.Matches(msg, key.NewBinding(key.WithKeys("up", "k"))):
if ms.cursor > 0 {
ms.cursor--
}
case key.Matches(msg, key.NewBinding(key.WithKeys("down", "j"))):
if ms.cursor < len(ms.filtered)-1 {
ms.cursor++
}
case key.Matches(msg, key.NewBinding(key.WithKeys("pgup"))):
ms.cursor -= ms.visibleHeight()
if ms.cursor < 0 {
ms.cursor = 0
}
case key.Matches(msg, key.NewBinding(key.WithKeys("pgdown"))):
ms.cursor += ms.visibleHeight()
if ms.cursor >= len(ms.filtered) {
ms.cursor = len(ms.filtered) - 1
}
if ms.cursor < 0 {
ms.cursor = 0
}
case key.Matches(msg, key.NewBinding(key.WithKeys("home"))):
ms.cursor = 0
case key.Matches(msg, key.NewBinding(key.WithKeys("end"))):
ms.cursor = max(len(ms.filtered)-1, 0)
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
if ms.cursor < len(ms.filtered) {
entry := ms.filtered[ms.cursor]
ms.active = false
return ms, func() tea.Msg {
return ModelSelectedMsg{
ModelString: entry.Provider + "/" + entry.ModelID,
}
}
}
case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
if ms.search != "" {
ms.search = ""
ms.rebuildFiltered()
} else {
ms.active = false
return ms, func() tea.Msg {
return ModelSelectorCancelledMsg{}
}
}
default:
// Inline text search.
if msg.Text != "" && len(msg.Text) == 1 {
ch := msg.Text[0]
if ch >= 32 && ch < 127 {
ms.search += string(ch)
ms.rebuildFiltered()
}
}
if key.Matches(msg, key.NewBinding(key.WithKeys("backspace"))) && len(ms.search) > 0 {
ms.search = ms.search[:len(ms.search)-1]
ms.rebuildFiltered()
}
}
}
return ms, nil
}
// View implements tea.Model.
func (ms *ModelSelectorComponent) View() tea.View {
theme := GetTheme()
headerStyle := lipgloss.NewStyle().
Bold(true).
Foreground(theme.Accent).
PaddingLeft(2)
helpStyle := lipgloss.NewStyle().
Foreground(theme.Muted).
PaddingLeft(2)
infoStyle := lipgloss.NewStyle().
Foreground(theme.Warning).
PaddingLeft(2)
var b strings.Builder
// Header.
b.WriteString(headerStyle.Render("Model Selector"))
b.WriteString("\n")
b.WriteString(helpStyle.Render("↑/↓: move enter: select esc: cancel type to filter"))
b.WriteString("\n")
b.WriteString(infoStyle.Render("Only showing models with configured API keys"))
b.WriteString("\n")
// Search input.
searchStyle := lipgloss.NewStyle().Foreground(theme.Info).PaddingLeft(2)
if ms.search != "" {
b.WriteString(searchStyle.Render(fmt.Sprintf("> %s", ms.search)))
} else {
b.WriteString(searchStyle.Render("> "))
}
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(theme.Muted).Render(strings.Repeat("─", ms.width)))
b.WriteString("\n")
if len(ms.filtered) == 0 {
emptyStyle := lipgloss.NewStyle().Foreground(theme.Muted).PaddingLeft(2)
if ms.search != "" {
b.WriteString(emptyStyle.Render("No models matching \"" + ms.search + "\""))
} else {
b.WriteString(emptyStyle.Render("No models available (check API keys)"))
}
b.WriteString("\n")
} else {
// Visible window.
visH := ms.visibleHeight()
startIdx := 0
if ms.cursor >= visH {
startIdx = ms.cursor - visH + 1
}
endIdx := min(startIdx+visH, len(ms.filtered))
for i := startIdx; i < endIdx; i++ {
entry := ms.filtered[i]
line := ms.renderEntry(entry, i == ms.cursor)
b.WriteString(line)
b.WriteString("\n")
}
}
// Footer.
b.WriteString(lipgloss.NewStyle().Foreground(theme.Muted).Render(strings.Repeat("─", ms.width)))
b.WriteString("\n")
footerParts := []string{
fmt.Sprintf("(%d/%d)", ms.cursor+1, len(ms.filtered)),
}
if ms.cursor < len(ms.filtered) {
entry := ms.filtered[ms.cursor]
if entry.Name != "" {
footerParts = append(footerParts, fmt.Sprintf("Model Name: %s", entry.Name))
}
if entry.ContextLimit > 0 {
footerParts = append(footerParts, fmt.Sprintf("Context: %dK", entry.ContextLimit/1000))
}
}
footerStyle := lipgloss.NewStyle().Foreground(theme.Muted).PaddingLeft(2)
b.WriteString(footerStyle.Render(strings.Join(footerParts, " ")))
return tea.NewView(b.String())
}
// IsActive returns whether the selector is still accepting input.
func (ms *ModelSelectorComponent) IsActive() bool {
return ms.active
}
// --- Internal helpers ---
func (ms *ModelSelectorComponent) visibleHeight() int {
// Reserve: header(1) + help(1) + info(1) + search(1) + separator(1) + footer(2) = 7
h := max(ms.height-7, 5)
return h
}
func (ms *ModelSelectorComponent) rebuildFiltered() {
if ms.search == "" {
ms.filtered = ms.allModels
} else {
query := strings.ToLower(ms.search)
ms.filtered = ms.filtered[:0]
type scored struct {
entry ModelEntry
score int
}
var matches []scored
for _, entry := range ms.allModels {
s := ms.fuzzyScoreModel(query, entry)
if s > 0 {
matches = append(matches, scored{entry: entry, score: s})
}
}
// Sort by score descending, then alphabetically.
sort.Slice(matches, func(i, j int) bool {
if matches[i].score != matches[j].score {
return matches[i].score > matches[j].score
}
return matches[i].entry.ModelID < matches[j].entry.ModelID
})
ms.filtered = make([]ModelEntry, len(matches))
for i, m := range matches {
ms.filtered[i] = m.entry
}
}
// Clamp cursor.
if ms.cursor >= len(ms.filtered) {
ms.cursor = max(len(ms.filtered)-1, 0)
}
}
// fuzzyScoreModel scores a model entry against the search query.
func (ms *ModelSelectorComponent) fuzzyScoreModel(query string, entry ModelEntry) int {
modelID := strings.ToLower(entry.ModelID)
provider := strings.ToLower(entry.Provider)
name := strings.ToLower(entry.Name)
combined := provider + "/" + modelID
// Exact match on combined provider/model.
if combined == query {
return 1000
}
// Exact match on model ID.
if modelID == query {
return 950
}
// Prefix match on model ID.
if strings.HasPrefix(modelID, query) {
return 800 - len(modelID) + len(query)
}
// Prefix match on combined.
if strings.HasPrefix(combined, query) {
return 750 - len(combined) + len(query)
}
// Contains match on model ID.
if strings.Contains(modelID, query) {
return 600
}
// Contains match on combined.
if strings.Contains(combined, query) {
return 550
}
// Contains match on name.
if strings.Contains(name, query) {
return 400
}
// Character-by-character fuzzy match on model ID.
if s := fuzzyCharacterMatch(query, modelID); s > 0 {
return s
}
// Fuzzy match on combined.
if s := fuzzyCharacterMatch(query, combined); s > 0 {
return s - 20
}
return 0
}
func (ms *ModelSelectorComponent) renderEntry(entry ModelEntry, isCursor bool) string {
theme := GetTheme()
modelStr := entry.ModelID
providerStr := fmt.Sprintf("[%s]", entry.Provider)
// Cursor indicator.
var cursor string
if isCursor {
cursor = lipgloss.NewStyle().Foreground(theme.Accent).Render("-> ")
} else {
cursor = " "
}
// Active model checkmark.
var active string
if entry.Provider+"/"+entry.ModelID == ms.currentModel {
active = lipgloss.NewStyle().Foreground(theme.Success).Render(" \u2713")
}
// Style the model ID.
modelStyle := lipgloss.NewStyle().Foreground(theme.Text)
if isCursor {
modelStyle = modelStyle.Bold(true).Foreground(theme.Accent)
}
// Style the provider tag.
providerStyle := lipgloss.NewStyle().Foreground(theme.Muted)
return cursor + modelStyle.Render(modelStr) + " " + providerStyle.Render(providerStr) + active
}
+4
View File
@@ -57,6 +57,10 @@ func (s *stubAppController) SendEvent(_ tea.Msg) {
// no-op in tests
}
func (s *stubAppController) AddContextMessage(_ string) {
// no-op in tests
}
// --------------------------------------------------------------------------
// Stub child components
// --------------------------------------------------------------------------
+5 -3
View File
@@ -1,6 +1,8 @@
package kit
import (
"strings"
"charm.land/fantasy"
"github.com/mark3labs/kit/internal/extensions"
)
@@ -109,16 +111,16 @@ func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
extMsgs := make([]extensions.ContextMessage, len(h.Messages))
for i, msg := range h.Messages {
// Extract text from content parts.
var text string
var text strings.Builder
for _, part := range msg.Content {
if tp, ok := part.(fantasy.TextPart); ok {
text += tp.Text
text.WriteString(tp.Text)
}
}
extMsgs[i] = extensions.ContextMessage{
Index: i,
Role: string(msg.Role),
Content: text,
Content: text.String(),
}
}
+6 -6
View File
@@ -321,24 +321,24 @@ func (m *Kit) GetSessionMessages() []extensions.SessionMessage {
continue
}
// Flatten content parts into a single text string.
var content string
var content strings.Builder
for _, p := range msg.Parts {
switch pt := p.(type) {
case message.TextContent:
content += pt.Text
content.WriteString(pt.Text)
case message.ReasoningContent:
content += pt.Thinking
content.WriteString(pt.Thinking)
case message.ToolCall:
content += fmt.Sprintf("[tool_call: %s(%s)]", pt.Name, pt.Input)
fmt.Fprintf(&content, "[tool_call: %s(%s)]", pt.Name, pt.Input)
case message.ToolResult:
content += fmt.Sprintf("[tool_result: %s]", pt.Content)
fmt.Fprintf(&content, "[tool_result: %s]", pt.Content)
}
}
msgs = append(msgs, extensions.SessionMessage{
ID: me.ID,
ParentID: me.ParentID,
Role: string(msg.Role),
Content: content,
Content: content.String(),
Model: msg.Model,
Provider: msg.Provider,
Timestamp: me.Timestamp.Format("2006-01-02T15:04:05Z07:00"),