Compare commits

...

4 Commits

Author SHA1 Message Date
Ed Zynda 51c70b63a7 feat: add @file autocomplete and context attachment
Type @ in the input to trigger a fuzzy file picker popup. Files are
discovered via git ls-files (with os.ReadDir fallback), scored by
fuzzy match, and displayed in the existing autocomplete popup.

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

Run hooks in dedicated goroutines and deliver results via SendEvent,
matching the pattern already used by extension slash commands.
2026-03-05 10:34:17 +03:00
Ed Zynda 3ecedcbc2d docs: add comprehensive README with CLI reference, extensions, SDK, and configuration guide 2026-03-03 18:33:42 +03:00
Ed Zynda dbfa410fc1 fix: use strings.Builder instead of string += in loops 2026-03-02 20:25:07 +03:00
8 changed files with 1293 additions and 52 deletions
+492 -1
View File
@@ -13,4 +13,495 @@
# 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 `--prompt` mode 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 --prompt "List files in src/"
# 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 --prompt "Explain main.go" --json
# Quiet mode (final response only)
kit --quiet --prompt "Run tests"
# Ephemeral mode (no session file)
kit --prompt "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
--prompt, -p Run in non-interactive mode with given prompt
--quiet Suppress all output (only with --prompt)
--json Output response as JSON (only with --prompt)
--no-exit Continue to interactive mode after --prompt
--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 --prompt "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)
+2
View File
@@ -1065,11 +1065,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,
+129
View File
@@ -0,0 +1,129 @@
package ui
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
// fileTokenPattern matches @file references in user text. Supports:
// - @"path with spaces.txt" (quoted)
// - @path/to/file.txt (unquoted, no spaces)
var fileTokenPattern = regexp.MustCompile(`@"[^"]+"|@[^\s]+`)
// ProcessFileAttachments scans the user's input text for @file references,
// reads each referenced file, and returns the text with @tokens replaced by
// XML-wrapped file content. Non-file @ tokens (like email addresses) are left
// unchanged.
//
// Returns the original text unchanged if no valid @file references are found.
func ProcessFileAttachments(text string, cwd string) string {
tokens := fileTokenPattern.FindAllString(text, -1)
if len(tokens) == 0 {
return text
}
result := text
for _, token := range tokens {
path := tokenToPath(token)
if path == "" {
continue
}
absPath, err := resolvePath(path, cwd)
if err != nil {
// Not a valid file reference — leave the token as-is.
// This handles cases like email addresses (@user) gracefully.
continue
}
info, err := os.Stat(absPath)
if err != nil {
continue
}
// Skip directories — we only attach file content.
if info.IsDir() {
continue
}
// Skip empty files.
if info.Size() == 0 {
continue
}
content, err := os.ReadFile(absPath)
if err != nil {
continue
}
// Build the XML-wrapped replacement.
wrapped := wrapFileContent(absPath, content)
result = strings.Replace(result, token, wrapped, 1)
}
return result
}
// tokenToPath strips the @ prefix and optional quotes from a token,
// returning the raw file path. Returns "" for invalid tokens.
func tokenToPath(token string) string {
if !strings.HasPrefix(token, "@") {
return ""
}
path := token[1:]
// Strip quotes.
if strings.HasPrefix(path, `"`) && strings.HasSuffix(path, `"`) {
path = path[1 : len(path)-1]
}
// Reject obviously non-file tokens (e.g. bare @ or @-flags).
if path == "" || strings.HasPrefix(path, "-") {
return ""
}
return path
}
// resolvePath resolves a potentially relative file path to an absolute path.
// Supports ~/ expansion and relative paths. No CWD restriction — the user
// can reference any file they have read access to.
func resolvePath(path string, cwd string) (string, error) {
// Expand ~/
if strings.HasPrefix(path, "~/") {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("cannot expand ~: %w", err)
}
path = filepath.Join(home, path[2:])
}
// Resolve relative to cwd.
if !filepath.IsAbs(path) {
path = filepath.Join(cwd, path)
}
// Clean and resolve symlinks for consistent paths.
absPath, err := filepath.Abs(path)
if err != nil {
return "", fmt.Errorf("invalid path: %w", err)
}
// Resolve symlinks so the displayed path is canonical.
resolved, err := filepath.EvalSymlinks(absPath)
if err != nil {
// EvalSymlinks fails if the file doesn't exist — fall back to
// the cleaned absolute path and let the caller's Stat handle it.
return absPath, nil
}
return resolved, nil
}
// wrapFileContent wraps file content in XML tags for LLM consumption.
func wrapFileContent(absPath string, content []byte) string {
return fmt.Sprintf("<file path=\"%s\">\n%s\n</file>", absPath, string(content))
}
+389
View File
@@ -0,0 +1,389 @@
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 strings.HasPrefix(raw, `"`) {
raw = strings.TrimPrefix(raw, `"`)
raw = strings.TrimSuffix(raw, `"`)
}
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.Split(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.Split(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
}
+140 -12
View File
@@ -43,6 +43,18 @@ type InputComponent struct {
argCommand string // command prefix for arg mode (e.g. "/bookmark")
argSynthCmds []SlashCommand // backing storage for synthetic arg entries
// File completion state. When the user types @ followed by a partial
// file path, the popup shows file/directory suggestions from the cwd.
fileMode bool // true when showing @file completions
filePrefix string // current text after @ being matched
fileAtStartIdx int // byte offset of @ in the textarea value
fileSuggestions []FileSuggestion // backing storage for file entries
fileSynthCmds []SlashCommand // synthetic SlashCommands wrapping file entries
// cwd is the working directory used for @file path resolution and
// autocomplete suggestions. Set by the parent via SetCwd.
cwd string
// appCtrl is used for slash commands that mutate app state.
// May be nil in tests; nil-safe.
appCtrl AppController
@@ -90,6 +102,12 @@ func NewInputComponent(width int, title string, appCtrl AppController) *InputCom
}
}
// SetCwd sets the working directory used for @file autocomplete suggestions
// and path resolution. Should be called by the parent after construction.
func (s *InputComponent) SetCwd(cwd string) {
s.cwd = cwd
}
// Init implements tea.Model. Starts the cursor blink animation.
func (s *InputComponent) Init() tea.Cmd {
return textarea.Blink
@@ -148,19 +166,29 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))):
if s.selected < len(s.filtered) {
if s.argMode {
if s.fileMode {
s.applyFileCompletion(s.selected)
} else if s.argMode {
s.textarea.SetValue(s.argCommand + " " + s.filtered[s.selected].Command.Name)
s.showPopup = false
s.selected = 0
} else {
s.textarea.SetValue(s.filtered[s.selected].Command.Name)
s.showPopup = false
s.selected = 0
}
s.showPopup = false
s.selected = 0
s.textarea.CursorEnd()
}
return s, nil
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
if s.selected < len(s.filtered) {
if s.fileMode {
// Apply file completion but don't submit.
s.applyFileCompletion(s.selected)
s.textarea.CursorEnd()
return s, nil
}
// Populate textarea with selected item and submit on next tick.
if s.argMode {
s.textarea.SetValue(s.argCommand + " " + s.filtered[s.selected].Command.Name)
@@ -190,7 +218,37 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if value != s.lastValue {
s.lastValue = value
lines := strings.Split(value, "\n")
if len(lines) == 1 && strings.HasPrefix(lines[0], "/") {
line := lines[len(lines)-1] // current line (last line for multi-line)
// Check for @file trigger first.
cursorCol := len(line) // approximate: cursor is at end after typing
if hasAt, prefix, atIdx := ExtractAtPrefix(line, cursorCol); hasAt && s.cwd != "" {
suggestions := GetFileSuggestions(prefix, s.cwd)
if len(suggestions) > 0 {
s.showPopup = true
s.fileMode = true
s.argMode = false
s.filePrefix = prefix
s.fileAtStartIdx = atIdx
s.fileSuggestions = suggestions
s.fileSynthCmds = make([]SlashCommand, len(suggestions))
s.filtered = make([]FuzzyMatch, len(suggestions))
for i, fs := range suggestions {
name := fs.RelPath
desc := ""
if fs.IsDir {
desc = "directory"
}
s.fileSynthCmds[i] = SlashCommand{Name: name, Description: desc}
s.filtered[i] = FuzzyMatch{Command: &s.fileSynthCmds[i], Score: fs.Score}
}
s.selected = 0
} else {
s.showPopup = false
s.fileMode = false
}
} else if len(lines) == 1 && strings.HasPrefix(lines[0], "/") {
s.fileMode = false
if !strings.Contains(lines[0], " ") {
// Command name completion.
s.showPopup = true
@@ -210,6 +268,7 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else {
s.showPopup = false
s.argMode = false
s.fileMode = false
}
}
return s, cmd
@@ -335,16 +394,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 +479,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
}
}
+130 -30
View File
@@ -201,6 +201,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
@@ -449,6 +453,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 +533,7 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
serverNames: opts.ServerNames,
toolNames: opts.ToolNames,
usageTracker: opts.UsageTracker,
cwd: opts.Cwd,
width: width,
height: height,
}
@@ -552,6 +560,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 +718,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
@@ -901,12 +911,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).
@@ -1163,6 +1181,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 {
@@ -2004,13 +2040,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 +2078,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 +2180,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.
+5 -3
View File
@@ -1,6 +1,8 @@
package kit
import (
"strings"
"charm.land/fantasy"
"github.com/mark3labs/kit/internal/extensions"
)
@@ -109,16 +111,16 @@ func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
extMsgs := make([]extensions.ContextMessage, len(h.Messages))
for i, msg := range h.Messages {
// Extract text from content parts.
var text string
var text strings.Builder
for _, part := range msg.Content {
if tp, ok := part.(fantasy.TextPart); ok {
text += tp.Text
text.WriteString(tp.Text)
}
}
extMsgs[i] = extensions.ContextMessage{
Index: i,
Role: string(msg.Role),
Content: text,
Content: text.String(),
}
}
+6 -6
View File
@@ -321,24 +321,24 @@ func (m *Kit) GetSessionMessages() []extensions.SessionMessage {
continue
}
// Flatten content parts into a single text string.
var content string
var content strings.Builder
for _, p := range msg.Parts {
switch pt := p.(type) {
case message.TextContent:
content += pt.Text
content.WriteString(pt.Text)
case message.ReasoningContent:
content += pt.Thinking
content.WriteString(pt.Thinking)
case message.ToolCall:
content += fmt.Sprintf("[tool_call: %s(%s)]", pt.Name, pt.Input)
fmt.Fprintf(&content, "[tool_call: %s(%s)]", pt.Name, pt.Input)
case message.ToolResult:
content += fmt.Sprintf("[tool_result: %s]", pt.Content)
fmt.Fprintf(&content, "[tool_result: %s]", pt.Content)
}
}
msgs = append(msgs, extensions.SessionMessage{
ID: me.ID,
ParentID: me.ParentID,
Role: string(msg.Role),
Content: content,
Content: content.String(),
Model: msg.Model,
Provider: msg.Provider,
Timestamp: me.Timestamp.Format("2006-01-02T15:04:05Z07:00"),