mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-19 05:45:40 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4577d218d3 | |||
| bd48457b27 | |||
| 84298a0743 | |||
| 393074447b | |||
| 879723fe90 | |||
| 57250a3a3d | |||
| 7e1686e572 | |||
| 4a8b10cde7 | |||
| cc5611eff7 | |||
| 51c70b63a7 | |||
| c9ee80d98a | |||
| 3ecedcbc2d | |||
| dbfa410fc1 |
@@ -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)
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -209,10 +209,10 @@ func spawnAgent(state *subState) {
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"--prompt", prompt,
|
||||
"--json",
|
||||
"--no-session",
|
||||
"--no-extensions",
|
||||
prompt,
|
||||
}
|
||||
|
||||
cmd := exec.Command(kitBinary, args...)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user