Compare commits

...

20 Commits

Author SHA1 Message Date
Ed Zynda 879723fe90 feat: add ! and !! shell command prefixes (matching pi behavior)
! runs a shell command with output included in LLM context.
!! runs a shell command with output excluded from LLM context.
Adds AddContextMessage to AppController for injecting messages
without triggering an LLM turn.
2026-03-05 19:17:41 +03:00
Ed Zynda 57250a3a3d refactor: remove --prompt flag, positional args are the only way
Drop the --prompt/-p flag entirely. Non-interactive mode is now
triggered by passing positional arguments:

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

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

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

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

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

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

Run hooks in dedicated goroutines and deliver results via SendEvent,
matching the pattern already used by extension slash commands.
2026-03-05 10:34:17 +03:00
Ed Zynda 3ecedcbc2d docs: add comprehensive README with CLI reference, extensions, SDK, and configuration guide 2026-03-03 18:33:42 +03:00
Ed Zynda dbfa410fc1 fix: use strings.Builder instead of string += in loops 2026-03-02 20:25:07 +03:00
Ed Zynda 512ecb92dc cleanup 2026-03-02 20:05:37 +03:00
Ed Zynda aede76d807 feat: add TUI suspend, custom message rendering, and extension hot-reload
- ctx.SuspendTUI(callback): releases terminal for interactive subprocesses
  (vim, shell, htop), automatically restores TUI when callback returns.
  Uses BubbleTea v2 ReleaseTerminal/RestoreTerminal.

- api.RegisterMessageRenderer(config) + ctx.RenderMessage(name, content):
  named render functions for branded/styled extension output. Renderers
  receive content and terminal width, return ANSI-styled strings.

- ctx.ReloadExtensions(): hot-reloads all extensions from disk. Emits
  SessionShutdown to old extensions, reloads source, emits SessionStart
  to new. Event handlers, commands, renderers, shortcuts update immediately.
  TUI command list refreshes via WidgetUpdateEvent. Extension tools are
  NOT updated (baked into agent at creation, documented limitation).

New example extensions: interactive-shell.go, branded-output.go, dev-reload.go
2026-03-02 19:32:19 +03:00
Ed Zynda 9e1df38836 feat: add keyboard shortcuts, tool context, and ToolCallEvent source field
- RegisterShortcut(ShortcutDef, handler) for global keyboard shortcuts
  that fire across all non-modal app states (after ctrl+c, before
  component dispatch). Handlers run in goroutines for safe blocking calls.
- ToolContext with IsCancelled/OnProgress for rich tool execution;
  ExecuteWithContext on ToolDef takes priority over simple Execute.
- Source field on ToolCallEvent (currently "llm", forward-compatible
  with future user-initiated tool calls).
- Fix missing //go:build ignore on context-inject.go.
- Update plan-mode.go to register ctrl+alt+p shortcut.
2026-03-02 19:04:37 +03:00
Ed Zynda 8f5efee837 feat: add session before-hooks (OnBeforeFork, OnBeforeSessionSwitch) and compaction event (OnBeforeCompact)
Add three new extension events that allow extensions to gate destructive
session operations and compaction:

- OnBeforeFork: fires before branching in the tree selector; handler can
  cancel with reason (e.g. dirty-repo guard)
- OnBeforeSessionSwitch: fires before /new resets the session branch;
  handler can cancel with reason
- OnBeforeCompact: fires before context compaction (auto or manual);
  handler receives token stats and IsAutomatic flag, can cancel

Includes SDK hook registry (beforeCompact), extension bridge, UI
callbacks threaded through AppModelOptions, and two example extensions:
- confirm-destructive.go: git dirty check + fork confirmation
- compact-notify.go: compaction notification + auto-compact gating
2026-03-02 16:35:00 +03:00
Ed Zynda a392d3e572 feat: add OnContextPrepare event for context window filtering and injection
Extensions can now register an OnContextPrepare handler that fires after
the context window is built from the session tree and before messages are
sent to the LLM. Handlers receive ContextMessage entries with positional
indices and can filter, reorder, or inject messages. Original messages
referenced by index preserve tool calls, reasoning, and other complex
parts. New context-inject example extension demonstrates injecting a
local .kit/context.md file as an ephemeral system message every turn.
2026-03-02 15:56:08 +03:00
Ed Zynda c40dc2f4fb feat: add argument tab-completion for extension slash commands
Extensions can now provide a Complete function on CommandDef that supplies
argument suggestions. When the user types a command name followed by a space,
the input popup switches to argument-completion mode, calling Complete with
the partial text and displaying matching suggestions.
2026-03-02 15:37:52 +03:00
Ed Zynda 37e82781b1 feat: add OnModelChange event and ctx.Exit(); remove Gap/Pi references from comments 2026-03-02 14:49:51 +03:00
Ed Zynda 23c16bb197 feat: add tool mgmt, model mgmt, options, event bus, LLM completion, steer mode, and 10 example extensions
Phase 2+3 extension API additions:
- Tool management: GetAllTools, SetActiveTools (plan-mode support)
- Model management: SetModel, GetAvailableModels, ModelChangedEvent
- Extension options: RegisterOption, GetOption, SetOption (env/config/default)
- Inter-extension event bus: OnCustomEvent, EmitCustomEvent
- Direct LLM completion: ctx.Complete with streaming/blocking modes
- Steer delivery mode: CancelAndSend for interrupt-and-redirect

New example extensions (10):
- plan-mode.go: read-only exploration with /plan toggle
- summarize.go: conversation summarization via ctx.Complete
- bookmark.go: persistent bookmarks via AppendEntry/GetEntries
- auto-commit.go: auto-commit on exit using last assistant message
- permission-gate.go: confirm dangerous bash commands
- protected-paths.go: block writes to .env, .git/, secrets/
- notify.go: desktop notifications on agent completion
- inline-bash.go: !{cmd} expansion in prompts
- pirate.go: system prompt persona injection
- project-rules.go: load .kit/rules/*.md into system prompt

Always-wrap tools through runner for SetActiveTools disabled-tool checking.
Removed phase1/phase2 test extensions from examples.
2026-03-02 14:31:35 +03:00
Ed Zynda 9449f1fcdf feat: add session management, persistence, editor text, and status bar APIs for extensions
Implement Phase 1 extension API gaps identified in the pi-mono gap analysis:

- Gap 1: Session Management API (GetMessages, GetSessionPath) — read-only
  access to conversation history from extensions
- Gap 2: Session Persistence (AppendEntry, GetEntries) — custom extension
  data survives across session restarts via new ExtensionDataEntry type
- Gap 10: SetEditorText — extensions can pre-fill the input editor
- Gap M3: Keyed Status Bar (SetStatus, RemoveStatus) — multiple extensions
  can place independent entries in the TUI status bar, ordered by priority
2026-03-02 01:33:56 +03:00
Ed Zynda dc59cfc81e feat: add --json output mode for --prompt and update subagent extensions
Add a --json flag that outputs structured JSON (response, model, usage,
messages with typed parts) when used with --prompt. Update kit-kit and
subagent-widget extensions to use --json for cleaner subprocess output
parsing instead of raw text heuristics.
2026-03-01 21:16:34 +03:00
Ed Zynda 8407d924b9 feat: add UIVisibility, GetContextStats APIs and compact tool renderers
- Add ctx.SetUIVisibility() to toggle built-in TUI chrome (startup
  message, status bar, separator, input hint) from extensions
- Add ctx.GetContextStats() returning accurate API-reported token counts
  instead of text-based heuristic; fix event ordering so extension
  handlers see up-to-date conversation state
- Add compact tool body renderers for compact mode: Read/Edit/Write/Ls
  show one-line summaries, Bash shows first 3 lines instead of full
  20-line syntax-highlighted output
- Add minimal.go example extension using UIVisibility + GetContextStats
2026-03-01 15:24:48 +03:00
57 changed files with 6158 additions and 237 deletions
+2 -2
View File
@@ -83,9 +83,9 @@ tmux kill-session -t kittest # cleanup
### Non-Interactive Kit (Subprocess Spawning)
Extensions can spawn Kit as a subprocess for sub-agent patterns:
```bash
kit --prompt "question" --quiet --no-session --no-extensions --system-prompt /path/to/prompt.txt --model provider/model
kit --quiet --no-session --no-extensions --system-prompt /path/to/prompt.txt --model provider/model "question"
```
Key flags: `--quiet` (stdout only, no TUI), `--no-session` (ephemeral), `--no-extensions` (prevent recursive loading), `--system-prompt` (string or file path).
Positional args are the prompt. `@file` args attach file content. Key flags: `--quiet` (stdout only, no TUI), `--no-session` (ephemeral), `--no-extensions` (prevent recursive loading), `--system-prompt` (string or file path).
## External Repo Research
- **ALWAYS use `btca`** to search external repos (e.g. iteratr, other reference codebases)
+494 -1
View File
@@ -13,4 +13,497 @@
# KIT (Knowledge Inference Tool)
TBD
A powerful, extensible AI coding agent CLI with multi-provider support, built-in tools, and a rich extension system.
## Features
- **Multi-Provider LLM Support**: Anthropic, OpenAI, Google Gemini, Ollama, Azure OpenAI, AWS Bedrock, OpenRouter, and more
- **Built-in Core Tools**: bash, read, write, edit, grep, find, ls - no MCP overhead
- **MCP Integration**: Connect external MCP servers for expanded capabilities
- **Extension System**: Write custom tools, commands, widgets, and UI modifications in Go
- **Interactive TUI**: Rich terminal interface powered by Bubble Tea with streaming, syntax highlighting, and custom rendering
- **Session Management**: Tree-based conversation history with branching support
- **Non-Interactive Mode**: Script-friendly positional args with JSON output
- **Go SDK**: Embed Kit in your own applications
## Installation
### Using npm (recommended)
```bash
npm install -g @mark3labs/kit
```
### Using Go
```bash
go install github.com/mark3labs/kit/cmd/kit@latest
```
### Building from source
```bash
git clone https://github.com/mark3labs/kit.git
cd kit
go build -o kit ./cmd/kit
```
## Quick Start
### Basic Usage
```bash
# Start interactive session
kit
# Run a one-off prompt
kit "List files in src/"
# Attach files as context
kit @main.go @test.go "Review these files"
# Continue the most recent session
kit --continue
# Use specific model
kit --model anthropic/claude-sonnet-4-5-20250929
```
### Non-Interactive Mode
```bash
# Get JSON output for scripting
kit "Explain main.go" --json
# Quiet mode (final response only)
kit "Run tests" --quiet
# Ephemeral mode (no session file)
kit "Quick question" --no-session
```
## Configuration
Kit looks for configuration in the following locations (in order of priority):
1. CLI flags
2. Environment variables (with `KIT_` prefix)
3. `./.kit.yml` (project-local)
4. `~/.kit.yml` (global)
### Basic Configuration
Create `~/.kit.yml`:
```yaml
model: anthropic/claude-sonnet-4-5-20250929
max-tokens: 4096
temperature: 0.7
stream: true
```
### Environment Variables
```bash
export ANTHROPIC_API_KEY="sk-..."
export OPENAI_API_KEY="sk-..."
export KIT_MODEL="openai/gpt-4o"
```
### MCP Server Configuration
Add external MCP servers to `.kit.yml`:
```yaml
mcpServers:
filesystem:
type: local
command: ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed"]
environment:
LOG_LEVEL: "info"
allowedTools: ["read_file", "write_file"]
search:
type: remote
url: "https://mcp.example.com/search"
```
## CLI Reference
### Global Flags
```bash
# Model and provider
--model, -m Model to use (provider/model format)
--provider-api-key API key for the provider
--provider-url Base URL for provider API
--tls-skip-verify Skip TLS certificate verification
# Session management
--session, -s Open specific JSONL session file
--continue, -c Resume most recent session for current directory
--resume, -r Interactive session picker
--no-session Ephemeral mode, no persistence
# Behavior (non-interactive: pass prompt as positional arg)
--quiet Suppress all output (non-interactive only)
--json Output response as JSON (non-interactive only)
--no-exit Enter interactive mode after prompt completes
--max-steps Maximum agent steps (0 for unlimited)
--stream Enable streaming output (default: true)
--compact Enable compact output mode
--auto-compact Auto-compact conversation near context limit
# Extensions
--extension, -e Load additional extension file(s) (repeatable)
--no-extensions Disable all extensions
# Generation parameters
--max-tokens Maximum tokens in response (default: 4096)
--temperature Randomness 0.0-1.0 (default: 0.7)
--top-p Nucleus sampling 0.0-1.0 (default: 0.95)
--top-k Limit top K tokens (default: 40)
--stop-sequences Custom stop sequences (comma-separated)
# System
--config Config file path (default: ~/.kit.yml)
--system-prompt System prompt text or file path
--debug Enable debug logging
```
### Commands
```bash
# Authentication (for OAuth-enabled providers)
kit auth login # Start OAuth flow
kit auth logout # Remove credentials
kit auth status # Check authentication status
# Model database
kit models # List available models
kit models --all # Show all providers (not just Fantasy-compatible)
kit update-models # Update local model database from models.dev
# Extension management
kit extensions list # List discovered extensions
kit extensions validate # Validate extension files
kit extensions init # Generate example extension template
```
## Extension System
Extensions are Go source files that run via Yaegi interpreter. They can add custom tools, slash commands, widgets, keyboard shortcuts, and intercept lifecycle events.
### Minimal Extension
```go
//go:build ignore
package main
import "kit/ext"
func Init(api ext.API) {
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
ctx.SetFooter(ext.HeaderFooterConfig{
Content: ext.WidgetContent{Text: "Custom Footer"},
})
})
}
```
**Usage:**
```bash
kit -e examples/extensions/minimal.go
```
### Extension Capabilities
**Lifecycle Events**: OnSessionStart, OnSessionShutdown, OnAgentStart, OnAgentEnd, OnToolCall, OnToolResult, OnInput, OnMessageStart, OnMessageUpdate, OnMessageEnd, OnModelChange, OnContextPrepare, OnBeforeFork, OnBeforeSessionSwitch, OnBeforeCompact
**Custom Components**:
- **Tools**: Add new tools the LLM can invoke
- **Commands**: Register slash commands (e.g., `/mycommand`)
- **Widgets**: Persistent status displays above/below input
- **Shortcuts**: Global keyboard shortcuts
- **Overlays**: Modal dialogs with markdown content
- **Tool Renderers**: Customize how tool calls display
- **Editor Interceptors**: Handle key events and wrap rendering
### Extension Examples
See the `examples/extensions/` directory:
- `minimal.go` - Clean UI with custom footer
- `notify.go` - Desktop notifications
- `widget-status.go` - Persistent status widgets
- `custom-editor-demo.go` - Vim-like modal editor
- `prompt-demo.go` - Interactive prompts (select/confirm/input)
- `tool-logger.go` - Log all tool calls
- `overlay-demo.go` - Modal dialogs
- `plan-mode.go` - Read-only planning mode
- `subagent-widget.go` - Multi-agent orchestration
- `auto-commit.go` - Auto-commit on shutdown
### Loading Extensions
**Auto-discovery** (loads automatically):
- `./.kit/extensions/*.go` (project-local)
- `~/.config/kit/extensions/*.go` (global)
**Explicit loading**:
```bash
kit -e path/to/extension.go
kit -e ext1.go -e ext2.go # Multiple extensions
```
**Disable auto-load**:
```bash
kit --no-extensions
```
## Session Management
Kit uses a tree-based session model that supports branching and forking conversations.
### Session Locations
- Default: `~/.local/share/kit/sessions/<cwd-hash>/<uuid>.jsonl`
- Each line is a session entry (messages, tool calls, extension data)
- Supports branching from any message to explore alternate paths
### Session Commands
```bash
# Resume most recent session for current directory
kit --continue
kit -c
# Interactive session picker
kit --resume
kit -r
# Open specific session file
kit --session path/to/session.jsonl
kit -s path/to/session.jsonl
# Ephemeral mode (no file persistence)
kit --no-session
```
## Go SDK
Embed Kit in your Go applications:
```go
package main
import (
"context"
"log"
kit "github.com/mark3labs/kit/pkg/kit"
)
func main() {
ctx := context.Background()
// Create Kit instance with default configuration
host, err := kit.New(ctx, nil)
if err != nil {
log.Fatal(err)
}
defer host.Close()
// Send a prompt
response, err := host.Prompt(ctx, "What is 2+2?")
if err != nil {
log.Fatal(err)
}
println(response)
}
```
### With Options
```go
host, err := kit.New(ctx, &kit.Options{
Model: "ollama/llama3",
SystemPrompt: "You are a helpful bot",
ConfigFile: "/path/to/config.yml",
MaxSteps: 10,
Streaming: true,
Quiet: true,
})
```
### With Callbacks
```go
response, err := host.PromptWithCallbacks(
ctx,
"List files in current directory",
func(name, args string) {
// Tool call started
println("Calling tool:", name)
},
func(name, args, result string, isError bool) {
// Tool call completed
if isError {
println("Tool failed:", name)
}
},
func(chunk string) {
// Streaming text chunk
print(chunk)
},
)
```
### Session Management
```go
host.Prompt(ctx, "My name is Alice")
response, _ := host.Prompt(ctx, "What's my name?")
host.SaveSession("./session.json")
host.LoadSession("./session.json")
host.ClearSession()
```
## Advanced Usage
### Subagent Pattern
Spawn Kit as a subprocess for multi-agent orchestration:
```bash
kit "Analyze codebase" \
--json \
--no-session \
--no-extensions \
--quiet \
--model anthropic/claude-haiku-3-5-20241022
```
Parse the JSON output:
```json
{
"response": "Final assistant response text",
"model": "anthropic/claude-haiku-3-5-20241022",
"usage": {
"input_tokens": 1024,
"output_tokens": 512,
"total_tokens": 1536
},
"messages": [...]
}
```
### Testing with tmux
Test the TUI non-interactively:
```bash
# Start Kit in detached tmux session
tmux new-session -d -s kittest -x 120 -y 40 \
"kit -e ext.go --no-session 2>kit.log"
# Wait for startup
sleep 3
# Capture screen
tmux capture-pane -t kittest -p
# Send input
tmux send-keys -t kittest '/command' Enter
# Cleanup
tmux kill-session -t kittest
```
## Development
### Build and Test
```bash
# Build
go build -o output/kit ./cmd/kit
# Run tests
go test -race ./...
# Run specific test
go test -race ./cmd -run TestScriptExecution
# Lint
go vet ./...
# Format
go fmt ./...
```
### Project Structure
```
cmd/kit/ - CLI entry point
cmd/ - CLI command implementations
pkg/kit/ - Go SDK
internal/agent/ - Agent loop and tool execution
internal/ui/ - Bubble Tea TUI components
internal/extensions/ - Yaegi extension system
internal/core/ - Built-in tools
internal/tools/ - MCP tool integration
internal/config/ - Configuration management
internal/session/ - Session persistence
internal/models/ - Provider and model management
examples/extensions/ - Example extension files
```
## Supported Providers
- **Anthropic** - Claude models (native, prompt caching, OAuth)
- **OpenAI** - GPT models
- **Google** - Gemini models
- **Ollama** - Local models
- **Azure OpenAI** - Azure-hosted OpenAI
- **AWS Bedrock** - Bedrock models
- **Google Vertex** - Claude on Vertex AI
- **OpenRouter** - Multi-provider router
- **Vercel AI** - Vercel AI SDK models
- **Auto-routed** - Any provider from models.dev database
### Model String Format
```bash
provider/model # Standard format
anthropic/claude-sonnet-4-5-20250929
openai/gpt-4o
ollama/llama3
google/gemini-2.0-flash-exp
```
### Model Aliases
```bash
claude-opus-latest → claude-opus-4-20250514
claude-sonnet-latest → claude-sonnet-4-5-20250929
claude-3-5-haiku-latest → claude-3-5-haiku-20241022
```
## Contributing
Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
## License
[Apache 2.0](LICENSE)
## Community
- [Discord](https://discord.gg/RqSS2NQVsY)
- [GitHub Issues](https://github.com/mark3labs/kit/issues)
- [Documentation](https://github.com/mark3labs/kit/wiki)
+415 -48
View File
@@ -2,6 +2,7 @@ package cmd
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
@@ -13,6 +14,7 @@ import (
"github.com/mark3labs/kit/internal/app"
"github.com/mark3labs/kit/internal/config"
"github.com/mark3labs/kit/internal/extensions"
"github.com/mark3labs/kit/internal/models"
"github.com/mark3labs/kit/internal/ui"
kit "github.com/mark3labs/kit/pkg/kit"
"github.com/spf13/cobra"
@@ -27,8 +29,9 @@ var (
providerURL string
providerAPIKey string
debugMode bool
promptFlag string
positionalPrompt string // set by processPositionalArgs from CLI positional args
quietFlag bool
jsonFlag bool
noExitFlag bool
maxSteps int
streamFlag bool // Enable streaming output
@@ -98,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())
},
}
@@ -199,12 +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(&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().
@@ -247,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"))
@@ -272,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)
}
@@ -291,13 +356,19 @@ func extensionCommandsForUI(k *kit.Kit) []ui.ExtensionCommand {
if len(name) > 0 && name[0] != '/' {
name = "/" + name
}
cmds = append(cmds, ui.ExtensionCommand{
ec := ui.ExtensionCommand{
Name: name,
Description: d.Description,
Execute: func(args string) (string, error) {
return d.Execute(args, k.GetExtensionContext())
},
})
}
if d.Complete != nil {
ec.Complete = func(prefix string) []string {
return d.Complete(prefix, k.GetExtensionContext())
}
}
cmds = append(cmds, ec)
}
return cmds
}
@@ -411,6 +482,27 @@ func editorInterceptorProviderForUI(k *kit.Kit) func() *ui.EditorInterceptor {
}
}
// uiVisibilityProviderForUI returns a function that converts extension UI
// visibility overrides to a *ui.UIVisibility for the TUI. Returns nil if
// extensions are disabled — the UI treats nil as "show everything".
func uiVisibilityProviderForUI(k *kit.Kit) func() *ui.UIVisibility {
if !k.HasExtensions() {
return nil
}
return func() *ui.UIVisibility {
v := k.GetExtensionUIVisibility()
if v == nil {
return nil
}
return &ui.UIVisibility{
HideStartupMessage: v.HideStartupMessage,
HideStatusBar: v.HideStatusBar,
HideSeparator: v.HideSeparator,
HideInputHint: v.HideInputHint,
}
}
}
// footerProviderForUI returns a function that converts the extension footer
// to a *ui.WidgetData for the TUI. Returns nil if extensions are disabled,
// which is safe — the UI treats a nil GetFooter as "no footer".
@@ -432,13 +524,74 @@ func footerProviderForUI(k *kit.Kit) func() *ui.WidgetData {
}
}
// statusBarProviderForUI returns a function that fetches extension status bar
// entries and converts them to ui.StatusBarEntryData for the TUI. Returns nil
// if extensions are disabled, which is safe — the TUI treats a nil
// GetStatusBarEntries as "no extension entries".
func statusBarProviderForUI(k *kit.Kit) func() []ui.StatusBarEntryData {
if !k.HasExtensions() {
return nil
}
return func() []ui.StatusBarEntryData {
entries := k.GetExtensionStatusEntries()
if len(entries) == 0 {
return nil
}
result := make([]ui.StatusBarEntryData, len(entries))
for i, e := range entries {
result[i] = ui.StatusBarEntryData{
Key: e.Key,
Text: e.Text,
Priority: e.Priority,
}
}
return result
}
}
// beforeForkProviderForUI returns a callback that emits a BeforeFork event
// and returns (cancelled, reason). Returns nil if extensions are disabled —
// the UI treats nil as "no hook".
func beforeForkProviderForUI(k *kit.Kit) func(string, bool, string) (bool, string) {
if !k.HasExtensions() {
return nil
}
return k.EmitBeforeFork
}
// beforeSessionSwitchProviderForUI returns a callback that emits a
// BeforeSessionSwitch event and returns (cancelled, reason). Returns nil
// if extensions are disabled — the UI treats nil as "no hook".
func beforeSessionSwitchProviderForUI(k *kit.Kit) func(string) (bool, string) {
if !k.HasExtensions() {
return nil
}
return k.EmitBeforeSessionSwitch
}
// globalShortcutsProviderForUI returns a callback that queries the extension
// runner for registered keyboard shortcuts. Returns nil if extensions are
// disabled — the UI treats nil as "no shortcuts".
func globalShortcutsProviderForUI(k *kit.Kit) func() map[string]func() {
if !k.HasExtensions() {
return nil
}
return k.GetExtensionShortcuts
}
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 noExitFlag && promptFlag == "" {
return fmt.Errorf("--no-exit 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 && positionalPrompt == "" {
return fmt.Errorf("--no-exit requires a prompt (e.g. kit \"your question\" --no-exit)")
}
// Set up logging
@@ -505,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)
@@ -550,14 +703,16 @@ func runNormalMode(ctx context.Context) error {
if kitInstance.HasExtensions() {
cwd, _ := os.Getwd()
kitInstance.SetExtensionContext(extensions.Context{
CWD: cwd,
Model: modelName,
Interactive: promptFlag == "",
Print: func(text string) { appInstance.PrintFromExtension("", text) },
PrintInfo: func(text string) { appInstance.PrintFromExtension("info", text) },
PrintError: func(text string) { appInstance.PrintFromExtension("error", text) },
PrintBlock: appInstance.PrintBlockFromExtension,
SendMessage: func(text string) { appInstance.Run(text) },
CWD: cwd,
Model: modelName,
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) },
PrintBlock: appInstance.PrintBlockFromExtension,
SendMessage: func(text string) { appInstance.Run(text) },
CancelAndSend: func(text string) { appInstance.Steer(text) },
Exit: func() { appInstance.QuitFromExtension() },
SetWidget: func(config extensions.WidgetConfig) {
kitInstance.SetExtensionWidget(config)
appInstance.NotifyWidgetUpdate()
@@ -629,6 +784,19 @@ func runNormalMode(ctx context.Context) error {
}
return extensions.PromptInputResult{Value: resp.Value}
},
SetUIVisibility: func(v extensions.UIVisibility) {
kitInstance.SetExtensionUIVisibility(v)
appInstance.NotifyWidgetUpdate()
},
GetContextStats: func() extensions.ContextStats {
s := kitInstance.GetContextStats()
return extensions.ContextStats{
EstimatedTokens: s.EstimatedTokens,
ContextLimit: s.ContextLimit,
UsagePercent: s.UsagePercent,
MessageCount: s.MessageCount,
}
},
SetEditor: func(config extensions.EditorConfig) {
kitInstance.SetExtensionEditor(config)
// Use a goroutine for NotifyWidgetUpdate because this may be
@@ -641,6 +809,95 @@ func runNormalMode(ctx context.Context) error {
kitInstance.ResetExtensionEditor()
go appInstance.NotifyWidgetUpdate()
},
GetMessages: func() []extensions.SessionMessage {
return kitInstance.GetSessionMessages()
},
GetSessionPath: func() string {
return kitInstance.GetSessionFilePath()
},
AppendEntry: func(entryType string, data string) (string, error) {
return kitInstance.AppendExtensionEntry(entryType, data)
},
GetEntries: func(entryType string) []extensions.ExtensionEntry {
return kitInstance.GetExtensionEntries(entryType)
},
SetEditorText: func(text string) {
appInstance.SetEditorTextFromExtension(text)
},
SetStatus: func(key string, text string, priority int) {
kitInstance.SetExtensionStatus(extensions.StatusBarEntry{
Key: key,
Text: text,
Priority: priority,
})
appInstance.NotifyWidgetUpdate()
},
RemoveStatus: func(key string) {
kitInstance.RemoveExtensionStatus(key)
appInstance.NotifyWidgetUpdate()
},
GetOption: func(name string) string {
return kitInstance.GetExtensionOption(name)
},
SetOption: func(name string, value string) {
kitInstance.SetExtensionOption(name, value)
},
SetModel: func(modelString string) error {
// Capture previous model for the ModelChange event.
previousModel := kitInstance.GetExtensionContext().Model
err := kitInstance.SetModel(context.Background(), modelString)
if err != nil {
return err
}
// Notify TUI so it updates model in status bar.
p, m, _ := models.ParseModelString(modelString)
appInstance.NotifyModelChanged(p, m)
// Update the context's Model field so handlers see it.
kitInstance.UpdateExtensionContextModel(modelString)
// Fire OnModelChange event to extensions.
kitInstance.EmitModelChange(modelString, previousModel, "extension")
return nil
},
GetAvailableModels: func() []extensions.ModelInfoEntry {
return kitInstance.GetAvailableModels()
},
EmitCustomEvent: func(name string, data string) {
kitInstance.EmitExtensionCustomEvent(name, data)
},
Complete: func(req extensions.CompleteRequest) (extensions.CompleteResponse, error) {
return kitInstance.ExecuteCompletion(context.Background(), req)
},
SuspendTUI: func(callback func()) error {
return appInstance.SuspendTUI(callback)
},
RenderMessage: func(rendererName, content string) {
renderer := kitInstance.GetExtensionMessageRenderer(rendererName)
if renderer == nil || renderer.Render == nil {
appInstance.PrintFromExtension("", content)
return
}
w, _, _ := term.GetSize(int(os.Stdout.Fd()))
if w == 0 {
w = 80
}
rendered := renderer.Render(content, w)
appInstance.PrintFromExtension("", rendered)
},
ReloadExtensions: func() error {
err := kitInstance.ReloadExtensions()
if err != nil {
return err
}
// Notify TUI that widgets/status/commands may have changed.
appInstance.NotifyWidgetUpdate()
return nil
},
GetAllTools: func() []extensions.ToolInfo {
return kitInstance.GetExtensionToolInfos()
},
SetActiveTools: func(names []string) {
kitInstance.SetExtensionActiveTools(names)
},
ShowOverlay: func(config extensions.OverlayConfig) extensions.OverlayResult {
ch := make(chan app.OverlayResponse, 1)
appInstance.SendOverlayRequest(app.OverlayRequestEvent{
@@ -696,18 +953,26 @@ func runNormalMode(ctx context.Context) error {
getFooter := footerProviderForUI(kitInstance)
getToolRenderer := toolRendererProviderForUI(kitInstance)
getEditorInterceptor := editorInterceptorProviderForUI(kitInstance)
getUIVisibility := uiVisibilityProviderForUI(kitInstance)
getStatusBarEntries := statusBarProviderForUI(kitInstance)
emitBeforeFork := beforeForkProviderForUI(kitInstance)
emitBeforeSessionSwitch := beforeSessionSwitchProviderForUI(kitInstance)
getGlobalShortcuts := globalShortcutsProviderForUI(kitInstance)
getExtensionCommands := func() []ui.ExtensionCommand {
return extensionCommandsForUI(kitInstance)
}
// Check if running in non-interactive mode
if promptFlag != "" {
return runNonInteractiveModeApp(ctx, appInstance, cli, promptFlag, quietFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor)
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)
}
// 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)
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)
}
// runNonInteractiveModeApp executes a single prompt via the app layer and exits,
@@ -720,8 +985,25 @@ 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, 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) error {
if quiet {
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 {
// 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)
if err != nil {
writeJSONError(err)
return err
}
data, err := buildJSONOutput(result, modelName)
if err != nil {
return fmt.Errorf("failed to marshal JSON output: %w", err)
}
fmt.Println(string(data))
} else if quiet {
// Quiet mode: no intermediate display, just print final response.
if err := appInstance.RunOnce(ctx, prompt); err != nil {
return err
@@ -746,12 +1028,89 @@ 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)
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 nil
}
// ---------------------------------------------------------------------------
// JSON output helpers (--json mode)
// ---------------------------------------------------------------------------
// buildJSONOutput converts a TurnResult into a structured JSON byte slice
// suitable for machine consumption.
func buildJSONOutput(result *kit.TurnResult, model string) ([]byte, error) {
type jsonPart struct {
Type string `json:"type"`
Data any `json:"data"`
}
type jsonMessage struct {
Role string `json:"role"`
Parts []jsonPart `json:"parts"`
}
type jsonUsage struct {
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
TotalTokens int64 `json:"total_tokens"`
CacheReadTokens int64 `json:"cache_read_tokens"`
CacheCreationTokens int64 `json:"cache_creation_tokens"`
}
type jsonEnvelope struct {
Response string `json:"response"`
Model string `json:"model"`
Usage *jsonUsage `json:"usage,omitempty"`
Messages []jsonMessage `json:"messages"`
}
out := jsonEnvelope{
Response: result.Response,
Model: model,
}
if result.TotalUsage != nil {
out.Usage = &jsonUsage{
InputTokens: result.TotalUsage.InputTokens,
OutputTokens: result.TotalUsage.OutputTokens,
TotalTokens: result.TotalUsage.TotalTokens,
CacheReadTokens: result.TotalUsage.CacheReadTokens,
CacheCreationTokens: result.TotalUsage.CacheCreationTokens,
}
}
for _, fmsg := range result.Messages {
converted := kit.ConvertFromFantasyMessage(fmsg)
m := jsonMessage{Role: string(converted.Role)}
for _, p := range converted.Parts {
switch c := p.(type) {
case kit.TextContent:
m.Parts = append(m.Parts, jsonPart{Type: "text", Data: c})
case kit.ToolCall:
m.Parts = append(m.Parts, jsonPart{Type: "tool_call", Data: c})
case kit.ToolResult:
m.Parts = append(m.Parts, jsonPart{Type: "tool_result", Data: c})
case kit.ReasoningContent:
m.Parts = append(m.Parts, jsonPart{Type: "reasoning", Data: c})
case kit.Finish:
m.Parts = append(m.Parts, jsonPart{Type: "finish", Data: c})
}
}
out.Messages = append(out.Messages, m)
}
return json.MarshalIndent(out, "", " ")
}
// writeJSONError writes a JSON-formatted error object to stdout so that
// callers using --json always receive parseable output.
func writeJSONError(err error) {
type jsonError struct {
Error string `json:"error"`
}
data, _ := json.MarshalIndent(jsonError{Error: err.Error()}, "", " ")
fmt.Fprintln(os.Stderr, string(data))
}
// runInteractiveModeBubbleTea starts the new unified Bubble Tea interactive TUI.
//
// It:
@@ -763,7 +1122,7 @@ func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui
// 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) 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) error {
// Determine terminal size; fall back gracefully.
termWidth, termHeight, err := term.GetSize(int(os.Stdout.Fd()))
if err != nil || termWidth == 0 {
@@ -771,26 +1130,34 @@ 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,
Width: termWidth,
Height: termHeight,
ServerNames: serverNames,
ToolNames: toolNames,
MCPToolCount: mcpToolCount,
ExtensionToolCount: extensionToolCount,
UsageTracker: usageTracker,
ExtensionCommands: extCommands,
ContextPaths: contextPaths,
SkillItems: skillItems,
GetWidgets: getWidgets,
GetHeader: getHeader,
GetFooter: getFooter,
GetToolRenderer: getToolRenderer,
GetEditorInterceptor: getEditorInterceptor,
CompactMode: viper.GetBool("compact"),
ModelName: modelName,
ProviderName: providerName,
LoadingMessage: loadingMessage,
Cwd: cwd,
Width: termWidth,
Height: termHeight,
ServerNames: serverNames,
ToolNames: toolNames,
MCPToolCount: mcpToolCount,
ExtensionToolCount: extensionToolCount,
UsageTracker: usageTracker,
ExtensionCommands: extCommands,
ContextPaths: contextPaths,
SkillItems: skillItems,
GetWidgets: getWidgets,
GetHeader: getHeader,
GetFooter: getFooter,
GetToolRenderer: getToolRenderer,
GetEditorInterceptor: getEditorInterceptor,
GetUIVisibility: getUIVisibility,
GetStatusBarEntries: getStatusBarEntries,
EmitBeforeFork: emitBeforeFork,
EmitBeforeSessionSwitch: emitBeforeSessionSwitch,
GetGlobalShortcuts: getGlobalShortcuts,
GetExtensionCommands: getExtensionCommands,
})
// Print startup info to stdout before Bubble Tea takes over the screen.
+71
View File
@@ -0,0 +1,71 @@
//go:build ignore
package main
import (
"os/exec"
"strings"
"kit/ext"
)
// Init automatically commits staged changes when the session shuts down,
// using the last assistant message as the commit message.
//
// Only commits if:
// - There are staged changes (git diff --cached is non-empty)
// - There is at least one assistant message to use as commit message
//
// The commit message is derived from the last assistant response, trimmed
// to the first paragraph (max 72 chars for the subject line).
//
// Usage: kit -e examples/extensions/auto-commit.go
func Init(api ext.API) {
api.OnSessionShutdown(func(_ ext.SessionShutdownEvent, ctx ext.Context) {
// Check for staged changes.
diff, err := exec.Command("git", "diff", "--cached", "--quiet").CombinedOutput()
_ = diff
if err == nil {
return // exit code 0 means no staged changes
}
// Get the last assistant message.
msgs := ctx.GetMessages()
var lastAssistant string
for i := len(msgs) - 1; i >= 0; i-- {
if msgs[i].Role == "assistant" {
lastAssistant = msgs[i].Content
break
}
}
if lastAssistant == "" {
return
}
// Build commit message: first paragraph, subject line max 72 chars.
subject := firstParagraph(lastAssistant)
if len(subject) > 72 {
subject = subject[:69] + "..."
}
// Commit.
cmd := exec.Command("git", "commit", "-m", subject)
output, err := cmd.CombinedOutput()
if err != nil {
ctx.PrintError("Auto-commit failed: " + string(output))
return
}
ctx.PrintInfo("Auto-committed: " + subject)
})
}
// firstParagraph returns the first non-empty paragraph of text.
func firstParagraph(text string) string {
text = strings.TrimSpace(text)
// Split on double newlines (paragraph breaks).
parts := strings.SplitN(text, "\n\n", 2)
line := strings.TrimSpace(parts[0])
// Collapse to single line.
line = strings.ReplaceAll(line, "\n", " ")
return line
}
+101
View File
@@ -0,0 +1,101 @@
//go:build ignore
package main
import (
"encoding/json"
"fmt"
"strings"
"time"
"kit/ext"
)
// Init adds bookmark commands for marking and recalling important points in
// a conversation. Bookmarks are persisted in the session tree and survive
// restarts.
//
// Commands:
//
// /bookmark <label> — bookmark the current point with a label
// /bookmarks — list all bookmarks in this session
//
// Usage: kit -e examples/extensions/bookmark.go
func Init(api ext.API) {
api.RegisterCommand(ext.CommandDef{
Name: "bookmark",
Description: "Bookmark the current point in the conversation",
Execute: func(args string, ctx ext.Context) (string, error) {
label := strings.TrimSpace(args)
if label == "" {
label = time.Now().Format("15:04:05")
}
// Count existing messages to record position.
msgs := ctx.GetMessages()
data, _ := json.Marshal(map[string]any{
"label": label,
"messages": len(msgs),
})
_, err := ctx.AppendEntry("bookmark", string(data))
if err != nil {
ctx.PrintError("Failed to save bookmark: " + err.Error())
return "", nil
}
ctx.PrintInfo(fmt.Sprintf("Bookmarked: %s (at message %d)", label, len(msgs)))
return "", nil
},
Complete: func(prefix string, ctx ext.Context) []string {
// Suggest existing bookmark labels so the user can quickly
// re-bookmark at the same label.
entries := ctx.GetEntries("bookmark")
var labels []string
seen := map[string]bool{}
for _, e := range entries {
var data map[string]any
if err := json.Unmarshal([]byte(e.Data), &data); err != nil {
continue
}
label, _ := data["label"].(string)
if label == "" || seen[label] {
continue
}
if prefix == "" || strings.HasPrefix(strings.ToLower(label), strings.ToLower(prefix)) {
labels = append(labels, label)
seen[label] = true
}
}
return labels
},
})
api.RegisterCommand(ext.CommandDef{
Name: "bookmarks",
Description: "List all bookmarks in this session",
Execute: func(args string, ctx ext.Context) (string, error) {
entries := ctx.GetEntries("bookmark")
if len(entries) == 0 {
ctx.PrintInfo("No bookmarks yet. Use /bookmark <label> to create one.")
return "", nil
}
var lines []string
for i, e := range entries {
var data map[string]any
if err := json.Unmarshal([]byte(e.Data), &data); err != nil {
continue
}
label, _ := data["label"].(string)
msgCount, _ := data["messages"].(float64)
lines = append(lines, fmt.Sprintf(" %d. %s (msg %d, %s)",
i+1, label, int(msgCount), e.Timestamp[:19]))
}
ctx.PrintInfo("Bookmarks:\n" + strings.Join(lines, "\n"))
return "", nil
},
})
}
+76
View File
@@ -0,0 +1,76 @@
//go:build ignore
// branded-output.go — Custom Message Rendering example extension for Kit.
//
// Demonstrates api.RegisterMessageRenderer() and ctx.RenderMessage() which
// let extensions define reusable visual styles for output. Each renderer has
// a name and a render function that receives content and terminal width.
//
// This extension registers three renderers:
// "success" — green-bordered block for success messages
// "warning" — yellow-bordered block for warnings
// "metric" — compact key=value display for metrics
//
// Commands:
// /demo-render — shows all three renderers in action
package main
import (
"fmt"
"strings"
"time"
ext "kit/ext"
)
func Init(api ext.API) {
// Register a "success" renderer — green-accented block.
api.RegisterMessageRenderer(ext.MessageRendererConfig{
Name: "success",
Render: func(content string, width int) string {
maxW := width - 6
if maxW < 20 {
maxW = 20
}
bar := strings.Repeat("─", maxW)
return fmt.Sprintf(" \033[32m┌%s┐\033[0m\n \033[32m│\033[0m \033[1;32m%s\033[0m\n \033[32m└%s┘\033[0m",
bar, content, bar)
},
})
// Register a "warning" renderer — yellow-accented block.
api.RegisterMessageRenderer(ext.MessageRendererConfig{
Name: "warning",
Render: func(content string, width int) string {
maxW := width - 6
if maxW < 20 {
maxW = 20
}
bar := strings.Repeat("─", maxW)
return fmt.Sprintf(" \033[33m┌%s┐\033[0m\n \033[33m│\033[0m \033[1;33m%s\033[0m\n \033[33m└%s┘\033[0m",
bar, content, bar)
},
})
// Register a "metric" renderer — compact label: value format.
api.RegisterMessageRenderer(ext.MessageRendererConfig{
Name: "metric",
Render: func(content string, width int) string {
return fmt.Sprintf(" \033[36m▸\033[0m %s", content)
},
})
api.RegisterCommand(ext.CommandDef{
Name: "demo-render",
Description: "Demonstrate custom message renderers",
Execute: func(args string, ctx ext.Context) (string, error) {
ctx.RenderMessage("success", "All 42 tests passed in 3.2s")
ctx.RenderMessage("warning", "3 deprecation warnings detected")
ctx.RenderMessage("metric", fmt.Sprintf("build_time=%.1fs tests=42 coverage=87%% timestamp=%s",
3.2, time.Now().Format("15:04:05")))
return "Rendered three message styles.", nil
},
})
}
+56
View File
@@ -0,0 +1,56 @@
//go:build ignore
package main
import (
"fmt"
"kit/ext"
)
// Init registers a before-compact hook that notifies the user when
// compaction is about to happen and optionally blocks automatic compaction.
//
// When automatic compaction is triggered (via --auto-compact), the extension
// asks for user confirmation. Manual /compact commands are always allowed.
//
// This demonstrates the OnBeforeCompact event which allows extensions to
// inspect context usage stats and gate the compaction process.
//
// Usage: kit -e examples/extensions/compact-notify.go --auto-compact
func Init(api ext.API) {
api.OnBeforeCompact(func(e ext.BeforeCompactEvent, ctx ext.Context) *ext.BeforeCompactResult {
pct := int(e.UsagePercent * 100)
summary := fmt.Sprintf("Context: %dk/%dk tokens (%d%%), %d messages",
e.EstimatedTokens/1000, e.ContextLimit/1000, pct, e.MessageCount)
if e.IsAutomatic {
// Auto-compaction: ask user first.
ctx.PrintBlock(ext.PrintBlockOpts{
Text: "Auto-compaction triggered.\n" + summary,
BorderColor: "#f9e2af",
Subtitle: "compact-notify",
})
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
Message: "Allow automatic compaction?",
DefaultValue: true,
})
if result.Cancelled || !result.Value {
return &ext.BeforeCompactResult{
Cancel: true,
Reason: "Auto-compaction skipped by user.",
}
}
} else {
// Manual /compact: just notify.
ctx.PrintBlock(ext.PrintBlockOpts{
Text: "Compacting conversation...\n" + summary,
BorderColor: "#89b4fa",
Subtitle: "compact-notify",
})
}
return nil // allow compaction
})
}
@@ -0,0 +1,72 @@
//go:build ignore
package main
import (
"os/exec"
"strings"
"kit/ext"
)
// Init registers before-hooks for destructive session operations:
// - Forks: Asks for confirmation before branching to a different tree node.
// - New sessions: Checks for uncommitted git changes and warns before
// starting a new branch if the working tree is dirty.
//
// This demonstrates the OnBeforeFork and OnBeforeSessionSwitch events
// which allow extensions to cancel session lifecycle operations.
//
// Usage: kit -e examples/extensions/confirm-destructive.go --continue
func Init(api ext.API) {
// Gate /new command: warn if there are uncommitted git changes.
api.OnBeforeSessionSwitch(func(e ext.BeforeSessionSwitchEvent, ctx ext.Context) *ext.BeforeSessionSwitchResult {
if !isGitDirty() {
return nil // clean repo, allow switch
}
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
Message: "Working tree has uncommitted changes. Start new session anyway?",
})
if result.Cancelled || !result.Value {
return &ext.BeforeSessionSwitchResult{
Cancel: true,
Reason: "Session switch cancelled: uncommitted git changes.",
}
}
return nil // user approved
})
// Gate fork: ask for confirmation before branching.
api.OnBeforeFork(func(e ext.BeforeForkEvent, ctx ext.Context) *ext.BeforeForkResult {
msg := "Branch to this point in the conversation?"
if e.IsUserMessage && e.UserText != "" {
// Show a preview of the user message being forked to.
preview := e.UserText
if len(preview) > 80 {
preview = preview[:77] + "..."
}
msg = "Fork and edit: " + preview + "\n\nContinue?"
}
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
Message: msg,
})
if result.Cancelled || !result.Value {
return &ext.BeforeForkResult{
Cancel: true,
Reason: "Fork cancelled by user.",
}
}
return nil // user approved
})
}
// isGitDirty returns true if the git working tree has uncommitted changes.
func isGitDirty() bool {
out, err := exec.Command("git", "status", "--porcelain").Output()
if err != nil {
return false // not a git repo or git not available
}
return len(strings.TrimSpace(string(out))) > 0
}
+89
View File
@@ -0,0 +1,89 @@
//go:build ignore
// context-inject.go — Injects context from a local file into every LLM turn.
//
// Reads a context file (default: .kit/context.md) and prepends it as a system
// message to every LLM context window via OnContextPrepare. This is useful for
// injecting project-specific knowledge, coding standards, or RAG results that
// should always be visible to the model — without cluttering the session history.
//
// The injected message does NOT persist in the session tree (it's ephemeral,
// added at query time only). This means:
// - Changing the context file immediately affects future turns
// - No session bloat from repeated context injection
// - The model always sees the latest version of the context
//
// Configuration:
//
// KIT_OPT_CONTEXT_FILE — path to context file (default: .kit/context.md)
//
// Usage:
//
// kit -e examples/extensions/context-inject.go
// echo "Always use error wrapping with fmt.Errorf" > .kit/context.md
package main
import (
"fmt"
"os"
"strings"
ext "kit/ext"
)
func Init(api ext.API) {
api.RegisterOption(ext.OptionDef{
Name: "context-file",
Description: "Path to the context file to inject into every turn",
Default: ".kit/context.md",
})
api.OnContextPrepare(func(e ext.ContextPrepareEvent, ctx ext.Context) *ext.ContextPrepareResult {
path := ctx.GetOption("context-file")
if path == "" {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
// File doesn't exist or can't be read — skip silently.
return nil
}
content := strings.TrimSpace(string(data))
if content == "" {
return nil
}
// Prepend a system message with the context file contents.
injected := ext.ContextMessage{
Index: -1,
Role: "system",
Content: fmt.Sprintf("[Project Context from %s]\n\n%s", path, content),
}
msgs := make([]ext.ContextMessage, 0, len(e.Messages)+1)
msgs = append(msgs, injected)
msgs = append(msgs, e.Messages...)
return &ext.ContextPrepareResult{Messages: msgs}
})
api.RegisterCommand(ext.CommandDef{
Name: "context",
Description: "Show or edit the injected context file path",
Execute: func(args string, ctx ext.Context) (string, error) {
path := ctx.GetOption("context-file")
data, err := os.ReadFile(path)
if err != nil {
return fmt.Sprintf("Context file: %s (not found or unreadable)", path), nil
}
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
preview := strings.Join(lines, "\n")
if len(lines) > 10 {
preview = strings.Join(lines[:10], "\n") + "\n..."
}
return fmt.Sprintf("Context file: %s (%d lines)\n\n%s", path, len(lines), preview), nil
},
})
}
+56
View File
@@ -0,0 +1,56 @@
//go:build ignore
// dev-reload.go — Extension Hot-Reload example extension for Kit.
//
// Demonstrates ctx.ReloadExtensions() which hot-reloads all extensions
// from disk without restarting Kit. This is invaluable during extension
// development: edit your extension source, then type /reload to pick up
// changes immediately.
//
// Event handlers, slash commands, tool renderers, message renderers, and
// keyboard shortcuts update immediately. Extension-defined tools are NOT
// updated (they are baked into the agent at creation time and require a
// restart).
//
// Commands:
// /reload — hot-reload all extensions from disk
package main
import (
"fmt"
"time"
ext "kit/ext"
)
var loadedAt string
func Init(api ext.API) {
loadedAt = time.Now().Format("15:04:05")
api.RegisterCommand(ext.CommandDef{
Name: "reload",
Description: "Hot-reload all extensions from disk",
Execute: func(args string, ctx ext.Context) (string, error) {
ctx.Print("Reloading extensions...")
err := ctx.ReloadExtensions()
if err != nil {
return "", fmt.Errorf("reload failed: %w", err)
}
return "Extensions reloaded successfully.", nil
},
})
api.RegisterCommand(ext.CommandDef{
Name: "load-time",
Description: "Show when this extension was loaded",
Execute: func(args string, ctx ext.Context) (string, error) {
return fmt.Sprintf("This extension was loaded at %s", loadedAt), nil
},
})
api.OnSessionStart(func(e ext.SessionStartEvent, ctx ext.Context) {
ctx.Print(fmt.Sprintf("[dev-reload] Extension loaded at %s", loadedAt))
})
}
+52
View File
@@ -0,0 +1,52 @@
//go:build ignore
package main
import (
"os/exec"
"regexp"
"strings"
"kit/ext"
)
// Init expands inline bash expressions in user prompts before they reach the
// LLM. Text like !{git branch --show-current} is replaced with the command's
// stdout.
//
// Examples:
//
// "Fix the tests on !{git branch --show-current}"
// → "Fix the tests on main"
//
// "The current directory is !{pwd}"
// → "The current directory is /home/user/project"
//
// Usage: kit -e examples/extensions/inline-bash.go
func Init(api ext.API) {
// Matches !{...} with non-greedy content.
re := regexp.MustCompile(`!\{([^}]+)\}`)
api.OnInput(func(ev ext.InputEvent, ctx ext.Context) *ext.InputResult {
if !re.MatchString(ev.Text) {
return nil
}
expanded := re.ReplaceAllStringFunc(ev.Text, func(match string) string {
// Extract the command between !{ and }.
cmd := re.FindStringSubmatch(match)[1]
cmd = strings.TrimSpace(cmd)
out, err := exec.Command("bash", "-c", cmd).Output()
if err != nil {
return match // keep original on error
}
return strings.TrimSpace(string(out))
})
return &ext.InputResult{
Action: "transform",
Text: expanded,
}
})
}
+123
View File
@@ -0,0 +1,123 @@
//go:build ignore
// interactive-shell.go — TUI Suspend example extension for Kit.
//
// Demonstrates ctx.SuspendTUI() which temporarily releases the terminal
// from the TUI so interactive subprocesses can run with full terminal
// control. The TUI is automatically restored when the callback returns.
//
// Commands:
// /edit <file> — opens $EDITOR (or vi) to edit a file
// /shell — drops into an interactive shell session
// /run <cmd> — runs a command with full terminal I/O (no TUI capture)
package main
import (
"fmt"
"os"
"os/exec"
"strings"
ext "kit/ext"
)
func Init(api ext.API) {
api.RegisterCommand(ext.CommandDef{
Name: "edit",
Description: "Open $EDITOR to edit a file (TUI suspends)",
Execute: func(args string, ctx ext.Context) (string, error) {
file := strings.TrimSpace(args)
if file == "" {
return "", fmt.Errorf("usage: /edit <file>")
}
editor := os.Getenv("EDITOR")
if editor == "" {
editor = "vi"
}
ctx.Print(fmt.Sprintf("Opening %s in %s...", file, editor))
err := ctx.SuspendTUI(func() {
cmd := exec.Command(editor, file)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Run()
})
if err != nil {
return "", fmt.Errorf("editor session failed: %w", err)
}
return fmt.Sprintf("Finished editing %s", file), nil
},
Complete: func(prefix string, ctx ext.Context) []string {
// Suggest files in the current directory.
entries, err := os.ReadDir(".")
if err != nil {
return nil
}
var results []string
for _, e := range entries {
name := e.Name()
if strings.HasPrefix(name, prefix) {
results = append(results, name)
}
}
return results
},
})
api.RegisterCommand(ext.CommandDef{
Name: "shell",
Description: "Drop into an interactive shell (TUI suspends)",
Execute: func(args string, ctx ext.Context) (string, error) {
shell := os.Getenv("SHELL")
if shell == "" {
shell = "/bin/sh"
}
ctx.Print(fmt.Sprintf("Starting %s... (type 'exit' to return to Kit)", shell))
err := ctx.SuspendTUI(func() {
cmd := exec.Command(shell)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Run()
})
if err != nil {
return "", fmt.Errorf("shell session failed: %w", err)
}
return "Shell session ended, TUI restored.", nil
},
})
api.RegisterCommand(ext.CommandDef{
Name: "run",
Description: "Run a command with full terminal I/O (TUI suspends)",
Execute: func(args string, ctx ext.Context) (string, error) {
cmdStr := strings.TrimSpace(args)
if cmdStr == "" {
return "", fmt.Errorf("usage: /run <command>")
}
ctx.Print(fmt.Sprintf("Running: %s", cmdStr))
err := ctx.SuspendTUI(func() {
cmd := exec.Command("sh", "-c", cmdStr)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Run()
})
if err != nil {
return "", fmt.Errorf("command failed: %w", err)
}
return "Command finished, TUI restored.", nil
},
})
}
+35 -10
View File
@@ -19,6 +19,7 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"os"
@@ -31,6 +32,16 @@ import (
"kit/ext"
)
// kitJSONOutput matches the JSON envelope produced by `kit --json`.
type kitJSONOutput struct {
Response string `json:"response"`
Model string `json:"model"`
Usage *struct {
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
} `json:"usage,omitempty"`
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
@@ -474,27 +485,33 @@ func queryExpert(name, question string) (output string, exitCode int, elapsed ti
}
tmpFile.Close()
// Build subprocess arguments. Don't pass --model; the subprocess
// inherits the same config/env and will use the same default.
// 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,
"--quiet",
"--json",
"--no-session",
"--no-extensions",
"--system-prompt", tmpFile.Name(),
question,
}
var stdoutBuf, stderrBuf bytes.Buffer
cmd := exec.Command(kitBinary, args...)
cmd.Env = os.Environ()
cmd.Stdout = &stdoutBuf
cmd.Stderr = &stderrBuf
outBytes, err := cmd.CombinedOutput()
err = cmd.Run()
close(done)
elapsed = time.Since(start)
result := strings.TrimSpace(string(outBytes))
if err != nil {
// Extract a single-line summary for the card (no newlines).
errLine := result
// On error, prefer stderr for the error message; fall back to stdout.
errText := strings.TrimSpace(stderrBuf.String())
if errText == "" {
errText = strings.TrimSpace(stdoutBuf.String())
}
errLine := errText
if idx := strings.Index(errLine, "\n"); idx >= 0 {
errLine = errLine[:idx]
}
@@ -505,10 +522,18 @@ func queryExpert(name, question string) (output string, exitCode int, elapsed ti
if exitErr, ok := err.(*exec.ExitError); ok {
code = exitErr.ExitCode()
}
return result, code, elapsed
return errText, code, elapsed
}
// Success — extract last non-empty line for the card.
// Parse JSON output from subprocess.
var parsed kitJSONOutput
result := strings.TrimSpace(stdoutBuf.String())
if err := json.Unmarshal([]byte(result), &parsed); err == nil {
result = parsed.Response
}
// else: fall back to raw stdout (e.g. older kit binary without --json)
// Extract last non-empty line for the card.
lines := strings.Split(result, "\n")
var lastLine string
for i := len(lines) - 1; i >= 0; i-- {
+71
View File
@@ -0,0 +1,71 @@
//go:build ignore
package main
import (
"fmt"
"math"
"strings"
"kit/ext"
)
// Init demonstrates a minimal-chrome extension.
// Hides the startup banner, status bar, separator, and input hint, replacing
// them with a compact footer showing model name and a context usage bar:
//
// claude-sonnet-4-5-20250929 [###-------] 30% (3.9K/200K tokens)
//
// Usage: kit -e examples/extensions/minimal.go
func Init(api ext.API) {
// updateFooter builds the footer text from current context stats.
updateFooter := func(ctx ext.Context) {
stats := ctx.GetContextStats()
pct := stats.UsagePercent * 100
if pct > 100 {
pct = 100
}
filled := int(math.Round(pct)) / 10
bar := strings.Repeat("#", filled) + strings.Repeat("-", 10-filled)
// Format token counts like the built-in status bar (e.g. "3.9K/200K").
fmtTokens := func(n int) string {
if n >= 1000 {
return fmt.Sprintf("%.1fK", float64(n)/1000)
}
return fmt.Sprintf("%d", n)
}
text := fmt.Sprintf("%s [%s] %d%%", ctx.Model, bar, int(math.Round(pct)))
if stats.ContextLimit > 0 {
text += fmt.Sprintf(" (%s/%s tokens)",
fmtTokens(stats.EstimatedTokens), fmtTokens(stats.ContextLimit))
}
ctx.SetFooter(ext.HeaderFooterConfig{
Content: ext.WidgetContent{Text: text},
Style: ext.WidgetStyle{BorderColor: "#585b70"},
})
}
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
// Strip built-in chrome for a minimal look.
ctx.SetUIVisibility(ext.UIVisibility{
HideStartupMessage: true,
HideStatusBar: true,
HideSeparator: true,
HideInputHint: true,
})
updateFooter(ctx)
})
// Refresh after each agent turn — context usage changes here.
api.OnAgentEnd(func(_ ext.AgentEndEvent, ctx ext.Context) {
updateFooter(ctx)
})
api.OnSessionShutdown(func(_ ext.SessionShutdownEvent, ctx ext.Context) {
ctx.RemoveFooter()
})
}
+34
View File
@@ -0,0 +1,34 @@
//go:build ignore
package main
import (
"os/exec"
"runtime"
"kit/ext"
)
// Init sends a desktop notification when the agent finishes responding.
// Useful for long-running tasks — get notified without watching the terminal.
// Supports: Linux (notify-send), macOS (osascript).
//
// Usage: kit -e examples/extensions/notify.go
func Init(api ext.API) {
api.OnAgentEnd(func(_ ext.AgentEndEvent, ctx ext.Context) {
sendNotification("Kit", "Agent finished responding")
})
}
func sendNotification(title, body string) {
switch runtime.GOOS {
case "linux":
// Uses notify-send (libnotify) — available on most Linux desktops.
_ = exec.Command("notify-send", "-a", "Kit", title, body).Start()
case "darwin":
// Uses macOS built-in osascript for native notifications.
script := `display notification "` + body + `" with title "` + title + `"`
_ = exec.Command("osascript", "-e", script).Start()
}
}
+64
View File
@@ -0,0 +1,64 @@
//go:build ignore
package main
import (
"encoding/json"
"strings"
"kit/ext"
)
// Init intercepts potentially dangerous bash commands and asks the user for
// confirmation before allowing execution.
//
// Dangerous patterns: rm -rf, sudo, chmod 777, mkfs, dd, > /dev/
//
// Usage: kit -e examples/extensions/permission-gate.go
func Init(api ext.API) {
// Patterns that require user confirmation.
dangerousPatterns := []string{
"rm -rf",
"rm -r /",
"sudo ",
"chmod 777",
"chmod -R 777",
"mkfs",
"dd if=",
"> /dev/",
":(){ :|:& };:",
}
api.OnToolCall(func(tc ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
if tc.ToolName != "Bash" {
return nil
}
// Extract the command from the tool input JSON.
var input struct {
Command string `json:"command"`
}
if err := json.Unmarshal([]byte(tc.Input), &input); err != nil {
return nil
}
cmd := strings.ToLower(input.Command)
// Check for dangerous patterns.
for _, pattern := range dangerousPatterns {
if strings.Contains(cmd, strings.ToLower(pattern)) {
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
Message: "Dangerous command detected: " + input.Command + "\n\nAllow execution?",
})
if result.Cancelled || !result.Value {
return &ext.ToolCallResult{
Block: true,
Reason: "User denied execution of dangerous command: " + input.Command,
}
}
return nil // user approved
}
}
return nil
})
}
+28
View File
@@ -0,0 +1,28 @@
//go:build ignore
package main
import "kit/ext"
// Init injects a pirate persona into the system prompt, causing the LLM to
// respond in pirate-speak. Demonstrates OnBeforeAgentStart system prompt
// injection.
//
// Usage: kit -e examples/extensions/pirate.go
func Init(api ext.API) {
piratePrompt := `
You are a pirate! You must:
- Start every response with "Ahoy!"
- Use pirate slang (ye, matey, arr, landlubber, etc.)
- Refer to files as "scrolls" and directories as "treasure chests"
- Call errors "cursed mishaps" and bugs "sea monsters"
- End responses with a pirate saying
Despite the pirate persona, your technical advice must remain accurate and helpful.`
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
return &ext.BeforeAgentStartResult{
SystemPrompt: &piratePrompt,
}
})
}
+96
View File
@@ -0,0 +1,96 @@
//go:build ignore
package main
import (
"strings"
"kit/ext"
)
// Init implements a plan/explore mode that restricts the agent to read-only
// tools. Toggle with /plan (or start in plan mode via KIT_OPT_PLAN=true).
// In plan mode the agent can only use read, grep, find, and ls — it cannot
// write files, run bash, or make edits. This is useful for exploring a
// codebase, reviewing architecture, or generating plans before executing.
//
// The status bar shows the current mode and the system prompt is augmented
// with planning instructions when active.
//
// Usage: kit -e examples/extensions/plan-mode.go
//
// Start in plan mode: KIT_OPT_PLAN=true kit -e examples/extensions/plan-mode.go
func Init(api ext.API) {
// Read-only tool set (matches core.ReadOnlyTools).
readOnlyTools := []string{"read", "grep", "find", "ls"}
var planActive bool
// Register "plan" option so users can start in plan mode via env/config.
api.RegisterOption(ext.OptionDef{
Name: "plan",
Description: "Start in plan mode (read-only tools)",
Default: "false",
})
// ctrl+alt+p — global shortcut to toggle plan mode.
api.RegisterShortcut(ext.ShortcutDef{
Key: "ctrl+alt+p",
Description: "Toggle plan/explore mode",
}, func(ctx ext.Context) {
planActive = !planActive
applyMode(ctx, planActive, readOnlyTools)
})
// /plan — toggle plan mode on or off.
api.RegisterCommand(ext.CommandDef{
Name: "plan",
Description: "Toggle plan/explore mode (ctrl+alt+p)",
Execute: func(args string, ctx ext.Context) (string, error) {
planActive = !planActive
applyMode(ctx, planActive, readOnlyTools)
return "", nil
},
})
// Check option at session start to enable plan mode from env/config.
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
opt := strings.ToLower(ctx.GetOption("plan"))
if opt == "true" || opt == "1" || opt == "yes" {
planActive = true
applyMode(ctx, true, readOnlyTools)
}
})
// Inject planning instructions into the system prompt when active.
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
if !planActive {
return nil
}
prompt := `You are in PLAN MODE (read-only exploration).
You can ONLY read, search, and explore the codebase. You CANNOT write files,
run commands, or make edits. Focus on:
- Understanding the codebase structure and architecture
- Identifying relevant files and patterns
- Generating detailed plans and recommendations
- Answering questions about how the code works
When the user is ready to execute, they will exit plan mode with /plan.`
return &ext.BeforeAgentStartResult{
SystemPrompt: &prompt,
}
})
}
func applyMode(ctx ext.Context, active bool, readOnlyTools []string) {
if active {
ctx.SetActiveTools(readOnlyTools)
ctx.SetStatus("plan-mode", "PLAN MODE (read-only)", 10)
ctx.PrintInfo("Plan mode ON — agent restricted to read-only tools (read, grep, find, ls).\nUse /plan to toggle off.")
} else {
ctx.SetActiveTools(nil) // re-enable all tools
ctx.RemoveStatus("plan-mode")
ctx.PrintInfo("Plan mode OFF — all tools re-enabled.")
}
}
+71
View File
@@ -0,0 +1,71 @@
//go:build ignore
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"kit/ext"
)
// Init loads project-specific rules from .kit/rules/ into the system prompt.
// Each .md file in the rules directory is injected as additional context,
// giving projects a way to customise LLM behaviour without editing the
// main system prompt.
//
// Place rule files in:
//
// .kit/rules/code-style.md
// .kit/rules/testing.md
// .kit/rules/security.md
//
// Usage: kit -e examples/extensions/project-rules.go
func Init(api ext.API) {
var rules string
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
rulesDir := filepath.Join(ctx.CWD, ".kit", "rules")
entries, err := os.ReadDir(rulesDir)
if err != nil {
return // no rules directory, nothing to do
}
var parts []string
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasSuffix(name, ".md") && !strings.HasSuffix(name, ".txt") {
continue
}
data, err := os.ReadFile(filepath.Join(rulesDir, name))
if err != nil {
continue
}
content := strings.TrimSpace(string(data))
if content != "" {
parts = append(parts, "## "+strings.TrimSuffix(name, filepath.Ext(name))+"\n\n"+content)
}
}
if len(parts) == 0 {
return
}
rules = "# Project Rules\n\n" + strings.Join(parts, "\n\n---\n\n")
ctx.PrintInfo(fmt.Sprintf("[project-rules] Loaded %d rule file(s) from .kit/rules/", len(parts)))
})
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
if rules == "" {
return nil
}
return &ext.BeforeAgentStartResult{
SystemPrompt: &rules,
}
})
}
+114
View File
@@ -0,0 +1,114 @@
//go:build ignore
package main
import (
"encoding/json"
"strings"
"kit/ext"
)
// Init blocks tool calls that attempt to write, edit, or delete files in
// protected paths.
//
// Protected: .env*, .git/, secrets/, credentials*, *.pem, *.key
//
// Usage: kit -e examples/extensions/protected-paths.go
func Init(api ext.API) {
// Tools that modify files.
writeTools := map[string]bool{
"Write": true,
"Edit": true,
"Bash": true,
}
// Path patterns to protect (checked against the file_path / filePath field).
protectedPatterns := []string{
".env",
".git/",
"secrets/",
"credentials",
".pem",
".key",
"id_rsa",
"id_ed25519",
}
// Bash commands that could modify protected files.
bashWritePatterns := []string{
"rm ", "mv ", "cp ", "> ",
"cat >", "echo >", "tee ",
"chmod ", "chown ",
}
isProtected := func(path string) bool {
lower := strings.ToLower(path)
for _, p := range protectedPatterns {
if strings.Contains(lower, p) {
return true
}
}
return false
}
api.OnToolCall(func(tc ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
if !writeTools[tc.ToolName] {
return nil
}
// For Write/Edit: check the file_path / filePath field.
if tc.ToolName == "Write" || tc.ToolName == "Edit" {
var input map[string]any
if err := json.Unmarshal([]byte(tc.Input), &input); err != nil {
return nil
}
// Try both naming conventions.
filePath, _ := input["file_path"].(string)
if filePath == "" {
filePath, _ = input["filePath"].(string)
}
if isProtected(filePath) {
return &ext.ToolCallResult{
Block: true,
Reason: "Blocked: writing to protected path: " + filePath,
}
}
return nil
}
// For Bash: check if the command references protected paths.
if tc.ToolName == "Bash" {
var input struct {
Command string `json:"command"`
}
if err := json.Unmarshal([]byte(tc.Input), &input); err != nil {
return nil
}
// Only check bash commands that look like file mutations.
isMutation := false
for _, pat := range bashWritePatterns {
if strings.Contains(input.Command, pat) {
isMutation = true
break
}
}
if !isMutation {
return nil
}
// Check if any protected pattern appears in the command.
for _, p := range protectedPatterns {
if strings.Contains(input.Command, p) {
return &ext.ToolCallResult{
Block: true,
Reason: "Blocked: bash command references protected path (" + p + "): " + input.Command,
}
}
}
}
return nil
})
}
+22 -7
View File
@@ -35,6 +35,11 @@ import (
"kit/ext"
)
// subJSONOutput matches the JSON envelope produced by `kit --json`.
type subJSONOutput struct {
Response string `json:"response"`
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
@@ -204,10 +209,10 @@ func spawnAgent(state *subState) {
}
args := []string{
"--prompt", prompt,
"--quiet",
"--json",
"--no-session",
"--no-extensions",
prompt,
}
cmd := exec.Command(kitBinary, args...)
@@ -261,7 +266,7 @@ func spawnAgent(state *subState) {
}
}()
// Read stderr in background goroutine.
// Read stderr in background goroutine (live widget updates).
var readWg sync.WaitGroup
readWg.Add(1)
go func() {
@@ -277,12 +282,12 @@ func spawnAgent(state *subState) {
}
}()
// Read stdout in foreground.
// Read stdout into a separate buffer (JSON output from --json mode).
var stdoutBuf strings.Builder
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 256*1024), 256*1024)
for scanner.Scan() {
state.appendChunk(scanner.Text() + "\n")
updateWidgets()
stdoutBuf.WriteString(scanner.Text() + "\n")
}
// Wait for all pipe readers, then the process.
@@ -290,6 +295,17 @@ func spawnAgent(state *subState) {
waitErr := cmd.Wait()
close(doneCh) // stop timer
// Parse JSON output from --json mode to extract the response.
var result string
rawStdout := strings.TrimSpace(stdoutBuf.String())
var parsed subJSONOutput
if rawStdout != "" && json.Unmarshal([]byte(rawStdout), &parsed) == nil && parsed.Response != "" {
result = parsed.Response
} else {
// Fallback: use raw stdout (e.g. older kit binary without --json).
result = rawStdout
}
state.mu.Lock()
state.Elapsed = time.Since(start)
state.Proc = nil
@@ -298,7 +314,6 @@ func spawnAgent(state *subState) {
} else {
state.Status = "done"
}
result := strings.Join(state.Chunks, "")
// Save history for /subcont continuations (cap at 16 KB).
state.History += fmt.Sprintf("\n--- Turn %d ---\nTask: %s\nResult:\n%s\n",
+93
View File
@@ -0,0 +1,93 @@
//go:build ignore
package main
import (
"fmt"
"strings"
"kit/ext"
)
// Init adds a /summarize command that generates a concise summary of the
// current conversation using a direct LLM completion. Demonstrates the
// ctx.Complete API.
//
// The summary is displayed in a styled block and can optionally be saved
// to the session via AppendEntry for later retrieval.
//
// Usage: kit -e examples/extensions/summarize.go
func Init(api ext.API) {
api.RegisterCommand(ext.CommandDef{
Name: "summarize",
Description: "Summarize the current conversation",
Execute: func(args string, ctx ext.Context) (string, error) {
msgs := ctx.GetMessages()
if len(msgs) == 0 {
ctx.PrintInfo("Nothing to summarize — no messages yet.")
return "", nil
}
// Build a text representation of the conversation.
var parts []string
for _, m := range msgs {
content := m.Content
if len(content) > 2000 {
content = content[:1997] + "..."
}
parts = append(parts, fmt.Sprintf("[%s]: %s", m.Role, content))
}
conversation := strings.Join(parts, "\n\n")
ctx.PrintInfo("Generating summary...")
resp, err := ctx.Complete(ext.CompleteRequest{
System: `You are a concise summarization assistant. Summarize the conversation below in 3-5 bullet points. Focus on:
- What was discussed or requested
- Key decisions or outcomes
- Any pending action items
Be concise. Use plain text, no markdown headers.`,
Prompt: conversation,
})
if err != nil {
ctx.PrintError("Summary failed: " + err.Error())
return "", nil
}
summary := strings.TrimSpace(resp.Text)
ctx.PrintBlock(ext.PrintBlockOpts{
Text: summary,
BorderColor: "#89b4fa",
Subtitle: fmt.Sprintf("Summary (%d messages, %d tokens used)", len(msgs), resp.InputTokens+resp.OutputTokens),
})
// Persist the summary in the session for later retrieval.
ctx.AppendEntry("summary", summary)
return "", nil
},
})
// /summaries — list all saved summaries.
api.RegisterCommand(ext.CommandDef{
Name: "summaries",
Description: "List saved conversation summaries",
Execute: func(args string, ctx ext.Context) (string, error) {
entries := ctx.GetEntries("summary")
if len(entries) == 0 {
ctx.PrintInfo("No summaries saved yet. Use /summarize to create one.")
return "", nil
}
for i, e := range entries {
ctx.PrintBlock(ext.PrintBlockOpts{
Text: e.Data,
BorderColor: "#89b4fa",
Subtitle: fmt.Sprintf("Summary #%d (%s)", i+1, e.Timestamp[:19]),
})
}
return "", nil
},
})
}
+70
View File
@@ -74,6 +74,7 @@ type Agent struct {
streamingEnabled bool
coreTools []fantasy.AgentTool
extraTools []fantasy.AgentTool
toolWrapper func([]fantasy.AgentTool) []fantasy.AgentTool // stored for SetModel rebuild
}
// GenerateWithLoopResult contains the result and conversation history from an agent interaction.
@@ -179,6 +180,7 @@ func NewAgent(ctx context.Context, agentConfig *AgentConfig) (*Agent, error) {
streamingEnabled: agentConfig.StreamingEnabled,
coreTools: coreTools,
extraTools: agentConfig.ExtraTools,
toolWrapper: agentConfig.ToolWrapper,
}, nil
}
@@ -455,6 +457,11 @@ func (a *Agent) GetTools() []fantasy.AgentTool {
return allTools
}
// GetCoreToolCount returns the number of core tools.
func (a *Agent) GetCoreToolCount() int {
return len(a.coreTools)
}
// GetMCPToolCount returns the number of tools loaded from external MCP servers.
func (a *Agent) GetMCPToolCount() int {
if a.toolManager == nil {
@@ -481,6 +488,69 @@ func (a *Agent) GetLoadedServerNames() []string {
return a.toolManager.GetLoadedServerNames()
}
// SetModel swaps the agent's LLM provider to a new model. The existing tools,
// system prompt, and configuration are preserved. The old provider is closed
// if it has a closer. Returns the previous model string for notification.
func (a *Agent) SetModel(ctx context.Context, config *models.ProviderConfig) error {
providerResult, err := models.CreateProvider(ctx, config)
if err != nil {
return fmt.Errorf("failed to create model provider: %v", err)
}
// Rebuild tool list (same as NewAgent).
allTools := make([]fantasy.AgentTool, len(a.coreTools))
copy(allTools, a.coreTools)
if a.toolManager != nil {
allTools = append(allTools, a.toolManager.GetTools()...)
}
if len(a.extraTools) > 0 {
allTools = append(allTools, a.extraTools...)
}
if a.toolWrapper != nil {
allTools = a.toolWrapper(allTools)
}
// Rebuild fantasy agent options.
var agentOpts []fantasy.AgentOption
if a.systemPrompt != "" {
agentOpts = append(agentOpts, fantasy.WithSystemPrompt(a.systemPrompt))
}
if len(allTools) > 0 {
agentOpts = append(agentOpts, fantasy.WithTools(allTools...))
}
if a.maxSteps > 0 {
agentOpts = append(agentOpts, fantasy.WithStopConditions(
fantasy.StepCountIs(a.maxSteps),
))
}
newFantasyAgent := fantasy.NewAgent(providerResult.Model, agentOpts...)
// Close old provider.
if a.providerCloser != nil {
_ = a.providerCloser.Close()
}
// Update model info on MCP tool manager.
if a.toolManager != nil {
a.toolManager.SetModel(providerResult.Model)
}
// Swap fields.
a.fantasyAgent = newFantasyAgent
a.model = providerResult.Model
a.providerCloser = providerResult.Closer
// Update provider type.
if config.ModelString != "" {
if p, _, err := models.ParseModelString(config.ModelString); err == nil {
a.providerType = p
}
}
return nil
}
// GetModel returns the underlying fantasy LanguageModel.
func (a *Agent) GetModel() fantasy.LanguageModel {
return a.model
+122
View File
@@ -141,6 +141,34 @@ func (a *App) QueueLength() int {
return len(a.queue)
}
// Steer cancels the current agent step (if running), clears the queue, and
// sends a new message that will execute as soon as the current step finishes
// cancelling. If the agent is idle, the message executes immediately.
// This is the "steer" delivery mode for SendMessage.
func (a *App) Steer(prompt string) {
a.mu.Lock()
if a.closed {
a.mu.Unlock()
return
}
if !a.busy {
// Not busy — start immediately, same as Run().
a.busy = true
a.wg.Add(1)
a.mu.Unlock()
go a.drainQueue(prompt)
return
}
// Agent is busy: clear queue, insert steer message, then cancel.
a.queue = []string{prompt}
cancel := a.cancelStep
a.mu.Unlock()
cancel()
}
// ClearQueue discards all queued prompts. The caller is responsible for
// updating any UI state (e.g. queue badge) — ClearQueue does NOT send
// events to the program, because it may be called synchronously from
@@ -169,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
@@ -254,6 +298,20 @@ func (a *App) RunOnce(ctx context.Context, prompt string) error {
return nil
}
// RunOnceResult executes a single agent step synchronously and returns the
// full TurnResult without printing anything. This is used by --json mode to
// capture structured output for serialization.
func (a *App) RunOnceResult(ctx context.Context, prompt string) (*kit.TurnResult, error) {
stepCtx, cancel := context.WithCancel(ctx)
defer cancel()
a.mu.Lock()
a.cancelStep = cancel
a.mu.Unlock()
return a.executeStep(stepCtx, prompt, nil)
}
// RunOnceWithDisplay executes a single agent step synchronously, sending
// intermediate display events (spinner, tool calls, streaming chunks, etc.)
// to eventFn. This is the non-TUI equivalent of the interactive Run() path —
@@ -474,6 +532,22 @@ func (a *App) subscribeSDKEvents(sendFn func(tea.Msg)) func() {
}
}
// QuitFromExtension triggers a graceful shutdown. In interactive mode it
// sends a tea.QuitMsg to the program so the TUI exits cleanly. In
// non-interactive mode it cancels the root context, stopping any in-flight
// step. Safe to call from any goroutine; idempotent.
func (a *App) QuitFromExtension() {
a.mu.Lock()
prog := a.program
a.mu.Unlock()
if prog != nil {
prog.Send(tea.QuitMsg{})
return
}
// Non-interactive: cancel the root context.
a.rootCancel()
}
// PrintFromExtension outputs text from an extension to the user. The level
// controls styling: "" for plain text, "info" for a system message block,
// "error" for an error block. In interactive mode it sends an
@@ -491,6 +565,28 @@ func (a *App) PrintFromExtension(level, text string) {
fmt.Println(text)
}
// SetEditorTextFromExtension sends an EditorTextSetEvent to the TUI to
// pre-fill the input editor. In non-interactive mode this is a no-op.
func (a *App) SetEditorTextFromExtension(text string) {
a.mu.Lock()
prog := a.program
a.mu.Unlock()
if prog != nil {
prog.Send(EditorTextSetEvent{Text: text})
}
}
// NotifyModelChanged sends a ModelChangedEvent to the TUI so it updates
// the model name in the status bar and message attribution.
func (a *App) NotifyModelChanged(provider, model string) {
a.mu.Lock()
prog := a.program
a.mu.Unlock()
if prog != nil {
prog.Send(ModelChangedEvent{ProviderName: provider, ModelName: model})
}
}
// NotifyWidgetUpdate sends a WidgetUpdateEvent to the TUI so it re-renders
// extension widgets. Called from the extension context's SetWidget/RemoveWidget
// closures. In non-interactive mode this is a no-op (widgets are TUI-only).
@@ -547,6 +643,32 @@ func (a *App) SendOverlayRequest(evt OverlayRequestEvent) {
}
}
// SuspendTUI temporarily releases the terminal from the TUI, runs the
// callback (which may spawn interactive subprocesses), and then restores
// the TUI. In non-interactive mode (no program registered) the callback
// runs directly with no terminal state changes.
//
// Safe to call from any goroutine (extension command handlers run in
// goroutines). Blocks until the callback returns.
func (a *App) SuspendTUI(callback func()) error {
a.mu.Lock()
prog := a.program
a.mu.Unlock()
if prog == nil {
// Non-interactive: just run the callback directly.
callback()
return nil
}
if err := prog.ReleaseTerminal(); err != nil {
return fmt.Errorf("release terminal: %w", err)
}
callback()
if err := prog.RestoreTerminal(); err != nil {
return fmt.Errorf("restore terminal: %w", err)
}
return nil
}
// PrintBlockFromExtension outputs a custom styled block from an extension.
func (a *App) PrintBlockFromExtension(opts extensions.PrintBlockOpts) {
a.mu.Lock()
+17
View File
@@ -113,11 +113,28 @@ type CompactErrorEvent struct {
Err error
}
// ModelChangedEvent is sent when an extension changes the active model via
// ctx.SetModel. The TUI updates the model name shown in the status bar and
// message attribution.
type ModelChangedEvent struct {
// ProviderName is the new provider (e.g. "anthropic").
ProviderName string
// ModelName is the new model ID (e.g. "claude-3-5-haiku-20241022").
ModelName string
}
// WidgetUpdateEvent is sent when an extension adds, updates, or removes a
// widget via ctx.SetWidget or ctx.RemoveWidget. The TUI re-reads widget state
// from its WidgetProvider on the next render cycle.
type WidgetUpdateEvent struct{}
// EditorTextSetEvent is sent when an extension calls ctx.SetEditorText to
// pre-fill the input editor with text. The TUI handles this by setting the
// textarea content and moving the cursor to the end.
type EditorTextSetEvent struct {
Text string
}
// ExtensionPrintEvent is sent when an extension calls ctx.Print, ctx.PrintInfo,
// ctx.PrintError, or ctx.PrintBlock. The TUI renders it via the appropriate
// renderer and tea.Println (scrollback); the CLI handler uses
+15 -15
View File
@@ -1,7 +1,7 @@
// Package compaction provides context window management with token estimation,
// compaction triggers, and LLM-based conversation summarization.
//
// The algorithm mirrors Pi's approach: preserve a token budget of recent
// The algorithm preserves a token budget of recent
// messages (KeepRecentTokens, default 20 000) rather than a fixed message
// count. Auto-compaction fires when estimated context usage exceeds
// contextWindow ReserveTokens.
@@ -50,8 +50,8 @@ func estimateSingleMessageTokens(msg fantasy.Message) int {
// Auto-compact trigger
// ---------------------------------------------------------------------------
// ShouldCompact reports whether auto-compaction should fire. It uses Pi's
// formula: contextTokens > contextWindow reserveTokens.
// ShouldCompact reports whether auto-compaction should fire.
// Formula: contextTokens > contextWindow reserveTokens.
func ShouldCompact(messages []fantasy.Message, contextWindow int, reserveTokens int) bool {
if contextWindow <= 0 || reserveTokens <= 0 {
return false
@@ -72,8 +72,8 @@ type CompactionResult struct {
MessagesRemoved int // Number of messages replaced by the summary
}
// CompactionOptions configures compaction behaviour. Pi-style token-based
// defaults are applied for zero-value fields.
// CompactionOptions configures compaction behaviour. Token-based defaults
// are applied for zero-value fields.
type CompactionOptions struct {
ContextWindow int // Model's context window size (tokens)
ReserveTokens int // Tokens to reserve for LLM response, default 16384
@@ -81,7 +81,7 @@ type CompactionOptions struct {
SummaryPrompt string // Custom summary prompt (empty = use default)
}
// defaults fills zero-value fields with sensible Pi-style defaults.
// defaults fills zero-value fields with sensible defaults.
func (o *CompactionOptions) defaults() {
if o.ReserveTokens <= 0 {
o.ReserveTokens = 16384
@@ -92,13 +92,13 @@ func (o *CompactionOptions) defaults() {
}
// defaultSystemPrompt is the system prompt sent to the summarisation LLM.
// Matches Pi's compaction system prompt.
const defaultSystemPrompt = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified.
Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`
// defaultSummaryPrompt is the user prompt appended after the serialised
// conversation. Matches Pi's initial-compaction format.
// conversation.
const defaultSummaryPrompt = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.
Use this EXACT format:
@@ -133,7 +133,7 @@ Use this EXACT format:
Keep each section concise. Preserve exact file paths, function names, and error messages.`
// ---------------------------------------------------------------------------
// Cut point (token-based, Pi-style)
// Cut point (token-based)
// ---------------------------------------------------------------------------
// isValidCutPoint returns true if the message at index i is a valid place to
@@ -208,11 +208,11 @@ func forceCutPoint(messages []fantasy.Message) int {
}
// ---------------------------------------------------------------------------
// Message serialisation (Pi-style)
// Message serialisation
// ---------------------------------------------------------------------------
// roleLabel returns a human-readable label for a fantasy message role,
// matching Pi's serialisation format.
func roleLabel(role fantasy.MessageRole) string {
switch role {
case fantasy.MessageRoleUser:
@@ -230,7 +230,7 @@ func roleLabel(role fantasy.MessageRole) string {
// serializeMessages converts a slice of fantasy messages into a plain-text
// representation suitable for sending to the summarisation LLM. The format
// mirrors Pi's compaction serialisation.
func serializeMessages(messages []fantasy.Message) string {
var sb strings.Builder
for _, msg := range messages {
@@ -277,8 +277,8 @@ func Compact(
cutPoint := FindCutPoint(messages, opts.KeepRecentTokens)
if cutPoint == 0 {
// All messages fit within the keep budget. Force a cut that
// keeps only the last non-tool message — matching Pi, which
// always compacts when the user explicitly requests it.
// keeps only the last non-tool message — always compact when
// the user explicitly requests it.
cutPoint = forceCutPoint(messages)
if cutPoint == 0 {
return nil, messages, nil
@@ -289,7 +289,7 @@ func Compact(
recentMessages := messages[cutPoint:]
originalTokens := EstimateMessageTokens(messages)
// Serialise old messages to text, matching Pi's format.
// Serialise old messages to text.
conversationText := serializeMessages(oldMessages)
// Build the user-facing prompt: conversation text + summary instructions.
+2 -2
View File
@@ -63,7 +63,7 @@ func TestEstimateMessageTokens_Empty(t *testing.T) {
}
// ---------------------------------------------------------------------------
// ShouldCompact (Pi-style: contextTokens > contextWindow - reserveTokens)
// ShouldCompact (contextTokens > contextWindow - reserveTokens)
// ---------------------------------------------------------------------------
func TestShouldCompact(t *testing.T) {
@@ -94,7 +94,7 @@ func TestShouldCompact(t *testing.T) {
}
// ---------------------------------------------------------------------------
// FindCutPoint (token-based, Pi-style)
// FindCutPoint (token-based)
// ---------------------------------------------------------------------------
func TestFindCutPoint_TokenBased(t *testing.T) {
+4 -4
View File
@@ -1,7 +1,7 @@
// Package core provides the built-in core tools for KIT's coding agent.
// These tools are direct fantasy.AgentTool implementations — no MCP layer,
// no JSON-RPC, no serialization overhead. They match the pi coding agent's
// core tool set: bash, read, write, edit, grep, find, ls.
// no JSON-RPC, no serialization overhead. Core tool set: bash, read, write,
// edit, grep, find, ls.
package core
import (
@@ -65,7 +65,7 @@ func parseArgs(input string, target any) error {
}
// CodingTools returns the default set of core tools for a coding agent:
// bash, read, write, edit. This matches pi's codingTools collection.
// bash, read, write, edit.
func CodingTools(opts ...ToolOption) []fantasy.AgentTool {
return []fantasy.AgentTool{
NewBashTool(opts...),
@@ -76,7 +76,7 @@ func CodingTools(opts ...ToolOption) []fantasy.AgentTool {
}
// ReadOnlyTools returns tools for read-only exploration:
// read, grep, find, ls. This matches pi's readOnlyTools collection.
// read, grep, find, ls.
func ReadOnlyTools(opts ...ToolOption) []fantasy.AgentTool {
return []fantasy.AgentTool{
NewReadTool(opts...),
+802 -19
View File
@@ -64,6 +64,19 @@ type Context struct {
// }()
SendMessage func(string)
// CancelAndSend cancels the current agent turn (if running), clears
// the message queue, and sends a new message that executes as soon as
// cancellation completes. If the agent is idle, the message executes
// immediately. This is the "steer" delivery mode.
//
// Use this for directive changes that should interrupt the current
// operation, e.g. switching modes or redirecting the agent.
//
// Example:
//
// ctx.CancelAndSend("Stop what you're doing and focus on the tests")
CancelAndSend func(string)
// SetWidget places or updates a persistent widget in the TUI. Widgets
// remain visible across agent turns until explicitly removed. The
// widget is identified by WidgetConfig.ID; calling SetWidget with the
@@ -206,6 +219,403 @@ type Context struct {
// ResetEditor removes the active editor interceptor and restores the
// default built-in editor behavior. No-op if no interceptor is set.
ResetEditor func()
// SetUIVisibility controls which built-in TUI chrome elements are
// visible. By default all elements are shown (zero value = show all).
// Call this during OnSessionStart to configure the initial layout.
//
// Example — minimal chrome:
//
// ctx.SetUIVisibility(ext.UIVisibility{
// HideStartupMessage: true,
// HideStatusBar: true,
// HideSeparator: true,
// HideInputHint: true,
// })
SetUIVisibility func(UIVisibility)
// GetContextStats returns current context-window usage information
// (estimated tokens, context limit, usage percentage, message count).
// Useful for building context meters, auto-compaction triggers, etc.
//
// Example:
//
// stats := ctx.GetContextStats()
// pct := int(stats.UsagePercent * 100)
// fmt.Sprintf("[%s%s] %d%%", strings.Repeat("#", pct/10), strings.Repeat("-", 10-pct/10), pct)
GetContextStats func() ContextStats
// GetMessages returns the conversation messages on the current branch,
// ordered from root to leaf. This is a read-only view; extensions
// cannot modify messages directly.
//
// Example:
//
// msgs := ctx.GetMessages()
// for _, m := range msgs {
// if m.Role == "assistant" {
// lastResponse = m.Content
// }
// }
GetMessages func() []SessionMessage
// GetSessionPath returns the file path of the current session's JSONL
// file. Returns empty string for in-memory (ephemeral) sessions.
GetSessionPath func() string
// AppendEntry persists custom extension data in the session tree.
// The data survives across session restarts and can be retrieved via
// GetEntries. Use entryType to namespace your data (e.g. "myext:state").
//
// Example:
//
// data, _ := json.Marshal(myState)
// ctx.AppendEntry("myext:state", string(data))
AppendEntry func(entryType string, data string) (string, error)
// GetEntries retrieves all persisted extension data entries matching
// the given type on the current branch, ordered root to leaf. Pass
// empty string to retrieve all extension data entries.
//
// Example — restore state on session resume:
//
// entries := ctx.GetEntries("myext:state")
// if len(entries) > 0 {
// last := entries[len(entries)-1]
// json.Unmarshal([]byte(last.Data), &myState)
// }
GetEntries func(entryType string) []ExtensionEntry
// SetEditorText sets the text content of the input editor. This can
// be used to pre-fill the editor with suggested text (e.g. extracted
// questions, handoff prompts). The cursor is moved to the end.
//
// Example:
//
// ctx.SetEditorText("Please review the changes in src/main.go")
SetEditorText func(text string)
// SetStatus places or updates a keyed entry in the TUI status bar.
// Multiple entries from different extensions coexist; each is identified
// by a unique key. Lower priority values render further left.
//
// Example:
//
// ctx.SetStatus("myext:branch", "main", 50)
SetStatus func(key string, text string, priority int)
// RemoveStatus removes a keyed status bar entry. No-op if the key
// does not exist.
RemoveStatus func(key string)
// GetOption returns the value of a named extension option. Options are
// resolved in priority order:
// 1. Runtime override (via SetOption)
// 2. Environment variable: KIT_OPT_<NAME> (uppercase, dashes → underscores)
// 3. Config file: options.<name> in .kit.yml
// 4. Default value registered by the extension
//
// Returns empty string if the option was not registered.
//
// Example:
//
// preset := ctx.GetOption("preset")
// if preset == "fast" {
// ctx.SetModel("anthropic/claude-haiku-3-5-20241022")
// }
GetOption func(name string) string
// SetOption sets a runtime override for a named extension option. This
// takes highest priority over env vars, config, and defaults. Useful for
// persisting user choices during a session.
SetOption func(name string, value string)
// SetModel changes the active LLM model at runtime. The model string
// should be in "provider/model" format (e.g. "anthropic/claude-sonnet-4-5-20250929").
// Existing tools, system prompt, and session are preserved. Returns an
// error if the model string is invalid or the provider cannot be created.
//
// Example:
//
// err := ctx.SetModel("openai/gpt-4o")
// if err != nil {
// ctx.PrintError("Failed to switch model: " + err.Error())
// }
SetModel func(modelString string) error
// GetAvailableModels returns a list of known models from the registry.
// This is an advisory list — models not in the registry can still be
// used by specifying their provider/model string directly.
//
// Example:
//
// models := ctx.GetAvailableModels()
// for _, m := range models {
// fmt.Printf("%s/%s (ctx: %dk)\n", m.Provider, m.ModelID, m.ContextLimit/1000)
// }
GetAvailableModels func() []ModelInfoEntry
// EmitCustomEvent publishes a named event that other extensions can
// subscribe to via api.OnCustomEvent(). Data is an arbitrary string
// (JSON-encode complex payloads). Handlers run synchronously in
// registration order.
//
// Example:
//
// ctx.EmitCustomEvent("plan-mode:toggled", `{"active":true}`)
EmitCustomEvent func(name string, data string)
// GetAllTools returns information about all tools available to the agent,
// including core tools (bash, read, write, etc.), MCP server tools, and
// extension-registered tools. Each entry includes the tool's enabled status.
//
// Example — list read-only tools:
//
// for _, t := range ctx.GetAllTools() {
// if t.Source == "core" && t.Enabled {
// fmt.Println(t.Name, "-", t.Description)
// }
// }
GetAllTools func() []ToolInfo
// SetActiveTools restricts the agent to only the named tools. Tools not
// in the list are blocked from execution (the LLM receives an error if
// it tries to call them). Pass nil or an empty slice to re-enable all
// tools. Tool names are case-sensitive.
//
// Example — plan mode (read-only):
//
// ctx.SetActiveTools([]string{"Read", "Glob", "Grep", "LS"})
SetActiveTools func(names []string)
// Exit triggers a graceful application shutdown. In interactive mode
// this sends a quit signal to the TUI; in non-interactive mode it
// cancels the current operation. Safe to call from any goroutine.
//
// Example:
//
// api.RegisterCommand(ext.CommandDef{
// Name: "quit",
// Description: "Exit the application",
// Execute: func(args string, ctx ext.Context) (string, error) {
// ctx.Exit()
// return "", nil
// },
// })
Exit func()
// Complete makes a standalone LLM completion call, bypassing the agent
// tool loop. Use this for summarisation, question extraction, or any
// sub-task that needs an LLM response without tool access.
//
// If Model is empty the current session model is reused (no extra
// provider creation overhead). Specify a different model string to
// use a cheaper/faster model for the sub-task.
//
// Example — summarise with a fast model:
//
// resp, err := ctx.Complete(ext.CompleteRequest{
// Model: "anthropic/claude-haiku-3-5-20241022",
// System: "You are a concise summarisation assistant.",
// Prompt: "Summarise this conversation:\n" + text,
// })
// if err != nil {
// ctx.PrintError("completion failed: " + err.Error())
// return
// }
// ctx.PrintInfo(resp.Text)
//
// Example — streaming completion:
//
// resp, err := ctx.Complete(ext.CompleteRequest{
// Prompt: "Explain quantum computing",
// OnChunk: func(chunk string) {
// fmt.Print(chunk) // stream to stdout
// },
// })
Complete func(CompleteRequest) (CompleteResponse, error)
// SuspendTUI temporarily releases the terminal from the TUI, runs the
// provided callback (which may spawn interactive processes like vim or
// htop), and then restores the TUI. In non-interactive mode the
// callback runs directly with no terminal changes.
//
// The callback has full access to stdin/stdout/stderr while the TUI is
// suspended. Return from the callback to restore the TUI.
//
// Example — launch $EDITOR:
//
// err := ctx.SuspendTUI(func() {
// editor := os.Getenv("EDITOR")
// if editor == "" { editor = "vim" }
// cmd := exec.Command(editor, "file.go")
// cmd.Stdin = os.Stdin
// cmd.Stdout = os.Stdout
// cmd.Stderr = os.Stderr
// cmd.Run()
// })
SuspendTUI func(callback func()) error
// RenderMessage outputs text using a named message renderer registered
// by an extension via api.RegisterMessageRenderer(). If no renderer
// with the given name exists, the content is printed as plain text.
//
// This allows extensions to define reusable visual styles (borders,
// colors, formatting) for specific message categories and invoke them
// by name at runtime.
//
// Example:
//
// ctx.RenderMessage("build-status", "All 42 tests passed.")
RenderMessage func(rendererName string, content string)
// ReloadExtensions hot-reloads all extensions from disk. Existing
// extensions receive a SessionShutdown event, then new code is loaded
// and receives a SessionStart event. Event handlers, commands,
// renderers, and shortcuts update immediately; extension-defined tools
// are NOT updated (they are baked into the agent at creation time).
//
// After calling ReloadExtensions the calling extension's code has been
// replaced; the caller should return promptly.
//
// Example:
//
// api.RegisterCommand(ext.CommandDef{
// Name: "reload",
// Description: "Hot-reload all extensions",
// Execute: func(args string, ctx ext.Context) (string, error) {
// if err := ctx.ReloadExtensions(); err != nil {
// return "", err
// }
// return "Extensions reloaded", nil
// },
// })
ReloadExtensions func() error
}
// ---------------------------------------------------------------------------
// Session types (exposed to Yaegi — concrete structs for session access)
// ---------------------------------------------------------------------------
// SessionMessage represents a conversation message exposed to extensions.
// This is a simplified, read-only view of the internal message structures.
type SessionMessage struct {
// ID is the unique entry identifier in the session tree.
ID string
// ParentID links this entry to its parent in the tree.
ParentID string
// Role is the message role: "user", "assistant", "tool", or "system".
Role string
// Content is the text content of the message (tool calls and results
// are serialized as text summaries).
Content string
// Model is the model that generated this message (empty for user messages).
Model string
// Provider is the provider used (empty for user messages).
Provider string
// Timestamp is the RFC3339-formatted creation time.
Timestamp string
}
// ExtensionEntry represents persisted extension data stored in the session.
// Extensions use AppendEntry to save custom state and GetEntries to retrieve
// it on session resume.
type ExtensionEntry struct {
// ID is the unique entry identifier.
ID string
// EntryType is the extension-defined type string (e.g. "plan-mode:state").
EntryType string
// Data is the extension-defined payload (JSON or plain text).
Data string
// Timestamp is the RFC3339-formatted creation time.
Timestamp string
}
// ---------------------------------------------------------------------------
// Context filtering types (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// ContextMessage represents a single message in the LLM context window.
// Used by OnContextPrepare to let extensions inspect and modify the messages
// that will be sent to the LLM.
type ContextMessage struct {
// Index is the position of this message in the original context array
// (0-based). When returning messages from a ContextPrepareResult,
// messages with Index >= 0 reuse the original fantasy.Message at that
// position (preserving tool calls, reasoning, and other complex parts).
// Set Index to -1 for newly injected messages (created from Role + Content).
Index int
// Role is the message role: "user", "assistant", "system", or "tool".
Role string
// Content is the text content of the message. For assistant messages
// with tool calls, this includes a text summary of the calls.
Content string
}
// ---------------------------------------------------------------------------
// LLM completion types (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// CompleteRequest configures a standalone LLM completion call. Extensions use
// this with ctx.Complete() to make direct LLM calls without the agent tool loop.
type CompleteRequest struct {
// Model is the model to use in "provider/model" format (e.g.
// "anthropic/claude-haiku-3-5-20241022"). Empty string uses the current
// session model, avoiding extra provider creation overhead.
Model string
// Prompt is the user input text sent to the model.
Prompt string
// System is an optional system prompt. Empty uses no system prompt.
System string
// Messages is optional conversation history. If provided, Prompt is
// appended as the final user message.
Messages []SessionMessage
// MaxTokens limits the response length (0 = provider default).
MaxTokens int
// OnChunk is called for each streaming text delta. When set, the
// completion is performed in streaming mode. When nil, the call blocks
// until the full response is available.
OnChunk func(chunk string)
}
// CompleteResponse contains the LLM response and usage metadata from a
// standalone completion call.
type CompleteResponse struct {
// Text is the complete response text.
Text string
// InputTokens is the number of tokens in the request.
InputTokens int
// OutputTokens is the number of tokens in the response.
OutputTokens int
// Model is the actual model used (useful when CompleteRequest.Model was empty).
Model string
}
// ---------------------------------------------------------------------------
// Status bar types (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// StatusBarEntry represents a keyed entry in the TUI status bar. Extensions
// can set multiple independent entries that render alongside the built-in
// model name and token usage display.
type StatusBarEntry struct {
// Key uniquely identifies this entry (e.g. "myext:git-branch").
Key string
// Text is the rendered content shown in the status bar.
Text string
// Priority controls ordering. Lower values render further left.
// Built-in entries (model, usage) have implicit priority 100-110.
Priority int
}
// PrintBlockOpts configures a custom styled block for PrintBlock.
@@ -233,22 +643,31 @@ type PrintBlockOpts struct {
// register typed event handlers, custom tools, and slash commands.
type API struct {
// Event-specific registration functions (wired by the loader).
onToolCall func(func(ToolCallEvent, Context) *ToolCallResult)
onToolExecStart func(func(ToolExecutionStartEvent, Context))
onToolExecEnd func(func(ToolExecutionEndEvent, Context))
onToolResult func(func(ToolResultEvent, Context) *ToolResultResult)
onInput func(func(InputEvent, Context) *InputResult)
onBeforeAgentStart func(func(BeforeAgentStartEvent, Context) *BeforeAgentStartResult)
onAgentStart func(func(AgentStartEvent, Context))
onAgentEnd func(func(AgentEndEvent, Context))
onMessageStart func(func(MessageStartEvent, Context))
onMessageUpdate func(func(MessageUpdateEvent, Context))
onMessageEnd func(func(MessageEndEvent, Context))
onSessionStart func(func(SessionStartEvent, Context))
onSessionShutdown func(func(SessionShutdownEvent, Context))
registerToolFn func(ToolDef)
registerCmdFn func(CommandDef)
registerToolRendererFn func(ToolRenderConfig)
onToolCall func(func(ToolCallEvent, Context) *ToolCallResult)
onToolExecStart func(func(ToolExecutionStartEvent, Context))
onToolExecEnd func(func(ToolExecutionEndEvent, Context))
onToolResult func(func(ToolResultEvent, Context) *ToolResultResult)
onInput func(func(InputEvent, Context) *InputResult)
onBeforeAgentStart func(func(BeforeAgentStartEvent, Context) *BeforeAgentStartResult)
onAgentStart func(func(AgentStartEvent, Context))
onAgentEnd func(func(AgentEndEvent, Context))
onMessageStart func(func(MessageStartEvent, Context))
onMessageUpdate func(func(MessageUpdateEvent, Context))
onMessageEnd func(func(MessageEndEvent, Context))
onSessionStart func(func(SessionStartEvent, Context))
onSessionShutdown func(func(SessionShutdownEvent, Context))
registerToolFn func(ToolDef)
registerCmdFn func(CommandDef)
registerToolRendererFn func(ToolRenderConfig)
onModelChange func(func(ModelChangeEvent, Context))
onContextPrepare func(func(ContextPrepareEvent, Context) *ContextPrepareResult)
onBeforeFork func(func(BeforeForkEvent, Context) *BeforeForkResult)
onBeforeSessionSwitch func(func(BeforeSessionSwitchEvent, Context) *BeforeSessionSwitchResult)
onBeforeCompact func(func(BeforeCompactEvent, Context) *BeforeCompactResult)
onCustomEvent func(name string, handler func(string))
registerOption func(OptionDef)
registerShortcutFn func(ShortcutDef, func(Context))
registerMessageRendererFn func(MessageRendererConfig)
}
// OnToolCall registers a handler that fires before a tool executes.
@@ -319,6 +738,36 @@ func (a *API) OnSessionShutdown(handler func(SessionShutdownEvent, Context)) {
a.onSessionShutdown(handler)
}
// OnModelChange registers a handler that fires after the active model is
// changed via ctx.SetModel(). The handler receives the new and previous model
// strings plus the source of the change.
func (a *API) OnModelChange(handler func(ModelChangeEvent, Context)) {
a.onModelChange(handler)
}
// OnContextPrepare registers a handler that fires after the context window is
// built from the session tree (including compaction) and before the messages
// are sent to the LLM. The handler can inspect the context and return a
// modified message set to filter, reorder, or inject messages.
//
// Return nil to leave the context unchanged. Return a non-nil result with
// a Messages slice to replace the context window entirely. Messages with a
// non-negative Index reuse the original message at that position (preserving
// tool calls, reasoning parts, etc.); messages with Index < 0 are created
// fresh from Role + Content.
//
// Example — inject a RAG context message:
//
// api.OnContextPrepare(func(e ext.ContextPrepareEvent, ctx ext.Context) *ext.ContextPrepareResult {
// ragContext := fetchRelevantDocs(e.Messages[len(e.Messages)-1].Content)
// injected := ext.ContextMessage{Index: -1, Role: "system", Content: ragContext}
// msgs := append([]ext.ContextMessage{injected}, e.Messages...)
// return &ext.ContextPrepareResult{Messages: msgs}
// })
func (a *API) OnContextPrepare(handler func(ContextPrepareEvent, Context) *ContextPrepareResult) {
a.onContextPrepare(handler)
}
// RegisterTool adds a custom tool that the LLM can invoke.
func (a *API) RegisterTool(tool ToolDef) {
a.registerToolFn(tool)
@@ -329,6 +778,55 @@ func (a *API) RegisterCommand(cmd CommandDef) {
a.registerCmdFn(cmd)
}
// RegisterOption declares a named configuration option. The option can be set
// via environment variables (KIT_OPT_<NAME>) or config file (options.<name>).
// Multiple extensions can register options with the same name; the last default
// wins.
func (a *API) RegisterOption(opt OptionDef) {
a.registerOption(opt)
}
// RegisterShortcut registers a global keyboard shortcut that fires across
// all app states except modal prompts/overlays. Use modifier combinations
// like "ctrl+p", "alt+t", or "f1" — avoid bare characters that conflict
// with text input. If multiple extensions register the same key, the last
// registration wins. The handler runs in a goroutine so it can call blocking
// APIs like PromptSelect without stalling the TUI event loop.
func (a *API) RegisterShortcut(def ShortcutDef, handler func(Context)) {
if a.registerShortcutFn != nil {
a.registerShortcutFn(def, handler)
}
}
// OnCustomEvent registers a handler for a custom inter-extension event.
// The handler receives the data string published by EmitCustomEvent.
// Multiple handlers can subscribe to the same event name; they execute
// in registration order.
func (a *API) OnCustomEvent(name string, handler func(string)) {
a.onCustomEvent(name, handler)
}
// OnBeforeFork registers a handler that fires before the session tree is
// branched to a different entry point. Return a non-nil BeforeForkResult
// with Cancel=true to prevent the fork.
func (a *API) OnBeforeFork(handler func(BeforeForkEvent, Context) *BeforeForkResult) {
a.onBeforeFork(handler)
}
// OnBeforeSessionSwitch registers a handler that fires before the session
// is switched to a new branch (e.g. /new command). Return a non-nil
// BeforeSessionSwitchResult with Cancel=true to prevent the switch.
func (a *API) OnBeforeSessionSwitch(handler func(BeforeSessionSwitchEvent, Context) *BeforeSessionSwitchResult) {
a.onBeforeSessionSwitch(handler)
}
// OnBeforeCompact registers a handler that fires before context compaction
// runs. Return a non-nil BeforeCompactResult with Cancel=true to prevent
// compaction from proceeding.
func (a *API) OnBeforeCompact(handler func(BeforeCompactEvent, Context) *BeforeCompactResult) {
a.onBeforeCompact(handler)
}
// RegisterToolRenderer registers a custom renderer for a specific tool's
// display in the TUI. The renderer controls the header (parameter summary)
// and/or body (result display) of the tool's output block. If multiple
@@ -337,6 +835,17 @@ func (a *API) RegisterToolRenderer(config ToolRenderConfig) {
a.registerToolRendererFn(config)
}
// RegisterMessageRenderer registers a named message renderer that extensions
// can invoke via ctx.RenderMessage(name, content). Use this to define
// reusable visual styles for branded output, progress reports, or custom
// notification formats. If multiple extensions register the same name, the
// last one wins.
func (a *API) RegisterMessageRenderer(config MessageRendererConfig) {
if a.registerMessageRendererFn != nil {
a.registerMessageRendererFn(config)
}
}
// ---------------------------------------------------------------------------
// Widget types (exposed to Yaegi — concrete structs, no interfaces)
// ---------------------------------------------------------------------------
@@ -472,6 +981,36 @@ type HeaderFooterConfig struct {
Style WidgetStyle
}
// ---------------------------------------------------------------------------
// UI visibility (exposed to Yaegi — concrete struct)
// ---------------------------------------------------------------------------
// UIVisibility controls which built-in TUI chrome elements are visible.
// The zero value shows everything (backward compatible). Extensions call
// ctx.SetUIVisibility to customise the layout — for example, a "minimal"
// theme can hide the startup banner, status bar, and input hint and replace
// them with a single custom footer.
type UIVisibility struct {
HideStartupMessage bool // Hide the "Model loaded..." startup block
HideStatusBar bool // Hide the "provider · model Tokens: ..." line
HideSeparator bool // Hide the "────────" divider between stream and input
HideInputHint bool // Hide the "enter submit · ctrl+j..." hint below input
}
// ---------------------------------------------------------------------------
// Context stats (exposed to Yaegi — concrete struct)
// ---------------------------------------------------------------------------
// ContextStats contains current context-window usage information.
// Extensions can poll this via ctx.GetContextStats() to build usage
// meters, auto-compaction triggers, etc.
type ContextStats struct {
EstimatedTokens int // Estimated token count of the current conversation
ContextLimit int // Model's context window size (tokens), 0 if unknown
UsagePercent float64 // Fraction of context used (0.01.0), 0 if limit unknown
MessageCount int // Number of messages in the conversation
}
// ---------------------------------------------------------------------------
// Overlay types (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
@@ -563,16 +1102,73 @@ type OverlayResult struct {
Cancelled bool
}
// ---------------------------------------------------------------------------
// Model info types (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// ModelInfoEntry represents a known model from the registry. Used by
// GetAvailableModels to let extensions discover which models are available.
type ModelInfoEntry struct {
// Provider is the provider ID (e.g. "anthropic", "openai").
Provider string
// ModelID is the model identifier (e.g. "claude-sonnet-4-5-20250929").
ModelID string
// Name is the human-readable model name.
Name string
// ContextLimit is the maximum context window in tokens (0 if unknown).
ContextLimit int
// OutputLimit is the maximum output tokens (0 if unknown).
OutputLimit int
// Reasoning is true if the model supports extended thinking.
Reasoning bool
}
// ---------------------------------------------------------------------------
// Tool info types (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// ToolInfo provides read-only information about a tool available to the agent.
// Used by GetAllTools to let extensions inspect and filter the tool set.
type ToolInfo struct {
// Name is the tool's unique identifier.
Name string
// Description is the tool's human-readable description.
Description string
// Source indicates where the tool came from: "core", "mcp", or "extension".
Source string
// Enabled is true if the tool is currently active.
Enabled bool
}
// ---------------------------------------------------------------------------
// ToolDef / CommandDef
// ---------------------------------------------------------------------------
// ToolContext provides runtime context to a tool's ExecuteWithContext handler.
// It allows tools to check for cancellation and report progress while running.
type ToolContext struct {
// IsCancelled returns true when the tool's execution has been cancelled
// (e.g. the user interrupted the agent or the request timed out).
// Long-running tools should poll this periodically and return early.
IsCancelled func() bool
// OnProgress sends a progress message that is displayed in the TUI
// while the tool is executing. Useful for long-running operations
// that want to show incremental status.
OnProgress func(text string)
}
// ToolDef describes a custom tool registered by an extension.
type ToolDef struct {
Name string
Description string
Parameters string // JSON Schema string
Execute func(input string) (string, error)
// Execute is the simple handler — receives JSON input, returns text result.
// Use this for tools that don't need cancellation or progress reporting.
Execute func(input string) (string, error)
// ExecuteWithContext is the rich handler — receives JSON input plus a
// ToolContext that provides cancellation checking and progress reporting.
// If both Execute and ExecuteWithContext are set, ExecuteWithContext wins.
ExecuteWithContext func(input string, tc ToolContext) (string, error)
}
// CommandDef describes a slash command registered by an extension.
@@ -580,6 +1176,74 @@ type CommandDef struct {
Name string
Description string
Execute func(args string, ctx Context) (string, error)
// Complete provides argument tab-completion for this command.
// Called with the partial argument text typed so far; returns
// candidate completions. Nil means no argument completion.
Complete func(prefix string, ctx Context) []string
}
// ---------------------------------------------------------------------------
// Keyboard shortcuts (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// ShortcutDef describes a global keyboard shortcut registered by an extension.
// Shortcuts fire across all app states except modal prompts/overlays.
// Use modifier combinations (e.g., "ctrl+p", "alt+t", "f1") — avoid bare
// characters like "a" or "x" which conflict with text input.
type ShortcutDef struct {
// Key is the key binding (e.g., "ctrl+p", "alt+t", "f1", "ctrl+shift+s").
Key string
// Description explains what the shortcut does (shown in /shortcuts help).
Description string
}
// ---------------------------------------------------------------------------
// Custom message rendering (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// MessageRendererConfig provides a named rendering function that extensions
// can invoke via ctx.RenderMessage(name, content). Unlike tool renderers
// (which hook into the automatic tool result display), message renderers are
// invoked explicitly by extension code for branded status updates, progress
// reports, or any custom visual output.
//
// Example:
//
// api.RegisterMessageRenderer(ext.MessageRendererConfig{
// Name: "build-status",
// Render: func(content string, width int) string {
// border := strings.Repeat("─", width-4)
// return "╭" + border + "╮\n│ " + content + "\n╰" + border + "╯"
// },
// })
type MessageRendererConfig struct {
// Name uniquely identifies this renderer. Used by ctx.RenderMessage
// to look it up at call time. Should be namespaced to avoid collisions
// (e.g. "myext:build-status").
Name string
// Render produces the styled output string from raw content. Receives
// the content and the terminal width in columns. Return the final
// ANSI-styled string to print; it will be emitted via tea.Println
// (or plain stdout in non-interactive mode).
Render func(content string, width int) string
}
// ---------------------------------------------------------------------------
// Extension options (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// OptionDef describes a configuration option that an extension can register.
// Options are resolved from env vars, config file, or default value.
type OptionDef struct {
// Name is the option identifier. Used as:
// - Env var: KIT_OPT_<NAME> (uppercased, dashes → underscores)
// - Config key: options.<name> in .kit.yml
Name string
// Description explains what the option controls.
Description string
// Default is the fallback value if not set via env or config.
Default string
}
// ---------------------------------------------------------------------------
@@ -693,8 +1357,7 @@ type EditorKeyAction struct {
// submit) and/or modify the rendered output (add mode indicators, apply visual
// effects).
//
// This follows Pi's extension editor pattern (modal editor, rainbow editor)
// but uses concrete function fields instead of interfaces for Yaegi safety.
// Uses concrete function fields instead of interfaces for Yaegi safety.
//
// IMPORTANT (Yaegi limitation): Function fields MUST be set using anonymous
// function literals (closures), NOT bare function references. Yaegi does not
@@ -735,6 +1398,10 @@ type ToolCallEvent struct {
ToolName string
ToolCallID string
Input string // JSON-encoded tool parameters
// Source indicates who initiated the tool call.
// Currently always "llm" (all tool calls originate from the LLM agent loop).
// Future user-initiated tool features may set this to "user".
Source string
}
func (e ToolCallEvent) Type() EventType { return ToolCall }
@@ -856,4 +1523,120 @@ func (e SessionStartEvent) Type() EventType { return SessionStart }
// SessionShutdownEvent fires when the application is closing.
type SessionShutdownEvent struct{}
// ModelChangeEvent fires after the active model is changed via ctx.SetModel().
type ModelChangeEvent struct {
// NewModel is the model string that was set (e.g. "anthropic/claude-sonnet-4-5-20250929").
NewModel string
// PreviousModel is the model string before the change.
PreviousModel string
// Source indicates what triggered the change: "extension" for ctx.SetModel(),
// "user" for interactive model selection.
Source string
}
func (e SessionShutdownEvent) Type() EventType { return SessionShutdown }
func (e ModelChangeEvent) Type() EventType { return ModelChange }
// ContextPrepareEvent fires after the context window is built from the session
// tree and before the messages are sent to the LLM. Handlers can inspect the
// messages and return a modified set to filter, reorder, or inject context.
type ContextPrepareEvent struct {
// Messages is the current context window that will be sent to the LLM.
// Each ContextMessage includes an Index field that maps back to the
// position in the original message array (for identity-preserving edits).
Messages []ContextMessage
}
func (e ContextPrepareEvent) Type() EventType { return ContextPrepare }
// ContextPrepareResult allows extensions to replace the context window.
// Return nil to leave the context unchanged.
type ContextPrepareResult struct {
// Messages replaces the entire context window. Each entry with a
// non-negative Index reuses the original message at that position
// (preserving tool calls, reasoning, etc.); entries with Index < 0
// are created fresh from Role + Content.
Messages []ContextMessage
}
func (ContextPrepareResult) isResult() {}
// BeforeForkEvent fires before the session tree is branched to a different
// entry point (via the tree selector or /fork command).
type BeforeForkEvent struct {
// TargetID is the session entry ID being branched to.
TargetID string
// IsUserMessage is true if the selected entry is a user message
// (which causes the fork to target the parent entry).
IsUserMessage bool
// UserText is the user message text (non-empty only when IsUserMessage is true).
UserText string
}
func (e BeforeForkEvent) Type() EventType { return BeforeFork }
// BeforeForkResult controls whether the fork proceeds. Return Cancel=true
// with an optional Reason to block the fork.
type BeforeForkResult struct {
// Cancel, when true, prevents the fork from proceeding.
Cancel bool
// Reason is a human-readable explanation shown to the user when
// Cancel is true. Empty string uses a default message.
Reason string
}
func (BeforeForkResult) isResult() {}
// BeforeSessionSwitchEvent fires before the session is switched to a new
// branch (e.g. /new or /clear commands).
type BeforeSessionSwitchEvent struct {
// Reason describes why the switch is happening: "new" for /new command,
// "clear" for /clear command.
Reason string
}
func (e BeforeSessionSwitchEvent) Type() EventType { return BeforeSessionSwitch }
// BeforeSessionSwitchResult controls whether the session switch proceeds.
// Return Cancel=true with an optional Reason to block the switch.
type BeforeSessionSwitchResult struct {
// Cancel, when true, prevents the session switch from proceeding.
Cancel bool
// Reason is a human-readable explanation shown to the user when
// Cancel is true. Empty string uses a default message.
Reason string
}
func (BeforeSessionSwitchResult) isResult() {}
// BeforeCompactEvent fires before context compaction runs. Provides
// information about the current context state to help extensions decide
// whether to allow or block compaction.
type BeforeCompactEvent struct {
// EstimatedTokens is the estimated token count of the conversation.
EstimatedTokens int
// ContextLimit is the model's context window size in tokens.
ContextLimit int
// UsagePercent is the fraction of context used (0.01.0).
UsagePercent float64
// MessageCount is the number of messages in the conversation.
MessageCount int
// IsAutomatic is true when compaction was triggered automatically
// (as opposed to manual /compact command).
IsAutomatic bool
}
func (e BeforeCompactEvent) Type() EventType { return BeforeCompact }
// BeforeCompactResult controls whether compaction proceeds. Return
// Cancel=true with an optional Reason to block compaction.
type BeforeCompactResult struct {
// Cancel, when true, prevents compaction from proceeding.
Cancel bool
// Reason is a human-readable explanation shown to the user when
// Cancel is true. Empty string uses a default message.
Reason string
}
func (BeforeCompactResult) isResult() {}
+23 -1
View File
@@ -1,4 +1,4 @@
// Package extensions implements a Pi-style in-process extension system for KIT.
// Package extensions implements an in-process extension system for KIT.
// Extensions are plain Go files loaded at runtime via Yaegi (a Go interpreter).
// They register event handlers using an API object, enabling tool interception,
// input transformation, and lifecycle observation — all without recompilation.
@@ -48,6 +48,26 @@ const (
// SessionShutdown fires when the application is closing.
SessionShutdown EventType = "session_shutdown"
// ModelChange fires after the active model is changed via ctx.SetModel().
ModelChange EventType = "model_change"
// ContextPrepare fires after context is built from the session tree and
// before the messages are sent to the LLM. Handlers can filter, reorder,
// or inject messages into the context window.
ContextPrepare EventType = "context_prepare"
// BeforeFork fires before the session tree is branched to a different
// entry point. Handlers can cancel the fork by returning Cancel=true.
BeforeFork EventType = "before_fork"
// BeforeSessionSwitch fires before the session is switched to a new
// branch (e.g. /new command). Handlers can cancel by returning Cancel=true.
BeforeSessionSwitch EventType = "before_session_switch"
// BeforeCompact fires before context compaction runs. Handlers can
// cancel compaction by returning Cancel=true.
BeforeCompact EventType = "before_compact"
)
// AllEventTypes returns every supported event type.
@@ -57,6 +77,8 @@ func AllEventTypes() []EventType {
Input, BeforeAgentStart, AgentStart, AgentEnd,
MessageStart, MessageUpdate, MessageEnd,
SessionStart, SessionShutdown,
ModelChange, ContextPrepare,
BeforeFork, BeforeSessionSwitch, BeforeCompact,
}
}
+7 -2
View File
@@ -4,8 +4,8 @@ import "testing"
func TestAllEventTypes_Count(t *testing.T) {
all := AllEventTypes()
if len(all) != 13 {
t.Fatalf("expected 13 event types, got %d", len(all))
if len(all) != 18 {
t.Fatalf("expected 18 event types, got %d", len(all))
}
}
@@ -50,6 +50,11 @@ func TestEventType_TypeMethod(t *testing.T) {
{MessageEndEvent{Content: "done"}, MessageEnd},
{SessionStartEvent{SessionID: "abc"}, SessionStart},
{SessionShutdownEvent{}, SessionShutdown},
{ModelChangeEvent{NewModel: "a/b"}, ModelChange},
{ContextPrepareEvent{Messages: []ContextMessage{{Index: 0, Role: "user", Content: "hi"}}}, ContextPrepare},
{BeforeForkEvent{TargetID: "abc"}, BeforeFork},
{BeforeSessionSwitchEvent{Reason: "new"}, BeforeSessionSwitch},
{BeforeCompactEvent{EstimatedTokens: 1000}, BeforeCompact},
}
for _, tt := range tests {
+57
View File
@@ -283,6 +283,48 @@ func loadSingleExtension(path string) (*LoadedExtension, error) {
return nil
})
},
onModelChange: func(h func(ModelChangeEvent, Context)) {
reg(ModelChange, func(e Event, c Context) Result {
h(e.(ModelChangeEvent), c)
return nil
})
},
onContextPrepare: func(h func(ContextPrepareEvent, Context) *ContextPrepareResult) {
reg(ContextPrepare, func(e Event, c Context) Result {
r := h(e.(ContextPrepareEvent), c)
if r == nil {
return nil
}
return *r
})
},
onBeforeFork: func(h func(BeforeForkEvent, Context) *BeforeForkResult) {
reg(BeforeFork, func(e Event, c Context) Result {
r := h(e.(BeforeForkEvent), c)
if r == nil {
return nil
}
return *r
})
},
onBeforeSessionSwitch: func(h func(BeforeSessionSwitchEvent, Context) *BeforeSessionSwitchResult) {
reg(BeforeSessionSwitch, func(e Event, c Context) Result {
r := h(e.(BeforeSessionSwitchEvent), c)
if r == nil {
return nil
}
return *r
})
},
onBeforeCompact: func(h func(BeforeCompactEvent, Context) *BeforeCompactResult) {
reg(BeforeCompact, func(e Event, c Context) Result {
r := h(e.(BeforeCompactEvent), c)
if r == nil {
return nil
}
return *r
})
},
registerToolFn: func(tool ToolDef) {
ext.Tools = append(ext.Tools, tool)
},
@@ -292,6 +334,21 @@ func loadSingleExtension(path string) (*LoadedExtension, error) {
registerToolRendererFn: func(config ToolRenderConfig) {
ext.ToolRenderers = append(ext.ToolRenderers, config)
},
registerMessageRendererFn: func(config MessageRendererConfig) {
ext.MessageRenderers = append(ext.MessageRenderers, config)
},
onCustomEvent: func(name string, handler func(string)) {
if ext.CustomEventHandlers == nil {
ext.CustomEventHandlers = make(map[string][]func(string))
}
ext.CustomEventHandlers[name] = append(ext.CustomEventHandlers[name], handler)
},
registerOption: func(opt OptionDef) {
ext.Options = append(ext.Options, opt)
},
registerShortcutFn: func(def ShortcutDef, handler func(Context)) {
ext.Shortcuts = append(ext.Shortcuts, ShortcutEntry{Def: def, Handler: handler})
},
}
// Call Init — the extension registers its handlers, tools, commands.
+333 -20
View File
@@ -2,34 +2,52 @@ package extensions
import (
"fmt"
"os"
"sort"
"strings"
"sync"
"github.com/charmbracelet/log"
"github.com/spf13/viper"
)
// Runner manages loaded extensions and dispatches events to their handlers
// sequentially, mirroring Pi's ExtensionRunner. Handlers execute in extension
// sequentially. Handlers execute in extension
// load order; for cancellable events the first blocking result wins.
type Runner struct {
extensions []LoadedExtension
ctx Context
widgets map[string]WidgetConfig // keyed by widget ID
header *HeaderFooterConfig // nil = no custom header
footer *HeaderFooterConfig // nil = no custom footer
customEditor *EditorConfig // nil = no custom editor interceptor
mu sync.RWMutex
extensions []LoadedExtension
ctx Context
widgets map[string]WidgetConfig // keyed by widget ID
statusEntries map[string]StatusBarEntry // keyed by status key
header *HeaderFooterConfig // nil = no custom header
footer *HeaderFooterConfig // nil = no custom footer
customEditor *EditorConfig // nil = no custom editor interceptor
uiVisibility *UIVisibility // nil = show everything (default)
disabledTools map[string]bool // nil = all tools enabled
customEventSubs map[string][]func(string) // inter-extension event bus
optionOverrides map[string]string // runtime option overrides
mu sync.RWMutex
}
// ShortcutEntry pairs a shortcut definition with its handler.
type ShortcutEntry struct {
Def ShortcutDef
Handler func(Context)
}
// LoadedExtension represents a single extension that has been discovered,
// loaded, and initialised. It holds the registered handlers and any custom
// tools, commands, or tool renderers the extension provided.
type LoadedExtension struct {
Path string
Handlers map[EventType][]HandlerFunc
Tools []ToolDef
Commands []CommandDef
ToolRenderers []ToolRenderConfig
Path string
Handlers map[EventType][]HandlerFunc
Tools []ToolDef
Commands []CommandDef
ToolRenderers []ToolRenderConfig
MessageRenderers []MessageRendererConfig // named message renderers
CustomEventHandlers map[string][]func(string) // inter-extension event bus
Options []OptionDef // registered configuration options
Shortcuts []ShortcutEntry // global keyboard shortcuts
}
// NewRunner creates a Runner from a set of loaded extensions.
@@ -45,6 +63,13 @@ func (r *Runner) SetContext(ctx Context) {
r.ctx = ctx
}
// GetContext returns a snapshot of the current runtime context. Thread-safe.
func (r *Runner) GetContext() Context {
r.mu.RLock()
defer r.mu.RUnlock()
return r.ctx
}
// HasHandlers returns true if any loaded extension has at least one handler
// registered for the given event type.
func (r *Runner) HasHandlers(event EventType) bool {
@@ -121,13 +146,6 @@ func (r *Runner) RegisteredCommands() []CommandDef {
return cmds
}
// GetContext returns the current runtime context. Thread-safe.
func (r *Runner) GetContext() Context {
r.mu.RLock()
defer r.mu.RUnlock()
return r.ctx
}
// Extensions returns the loaded extensions for inspection (e.g. CLI list).
func (r *Runner) Extensions() []LoadedExtension {
return r.extensions
@@ -177,6 +195,45 @@ func (r *Runner) GetWidgets(placement WidgetPlacement) []WidgetConfig {
return result
}
// ---------------------------------------------------------------------------
// Status bar management
// ---------------------------------------------------------------------------
// SetStatusEntry places or updates a keyed status bar entry. Thread-safe.
func (r *Runner) SetStatusEntry(entry StatusBarEntry) {
r.mu.Lock()
defer r.mu.Unlock()
if r.statusEntries == nil {
r.statusEntries = make(map[string]StatusBarEntry)
}
r.statusEntries[entry.Key] = entry
}
// RemoveStatusEntry removes a status bar entry by key. Thread-safe.
func (r *Runner) RemoveStatusEntry(key string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.statusEntries, key)
}
// GetStatusEntries returns all status bar entries, sorted by priority
// (ascending). Thread-safe.
func (r *Runner) GetStatusEntries() []StatusBarEntry {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]StatusBarEntry, 0, len(r.statusEntries))
for _, e := range r.statusEntries {
result = append(result, e)
}
sort.Slice(result, func(i, j int) bool {
if result[i].Priority != result[j].Priority {
return result[i].Priority < result[j].Priority
}
return result[i].Key < result[j].Key
})
return result
}
// ---------------------------------------------------------------------------
// Header/Footer management
// ---------------------------------------------------------------------------
@@ -269,6 +326,29 @@ func (r *Runner) GetEditor() *EditorConfig {
return &e
}
// ---------------------------------------------------------------------------
// UI visibility management
// ---------------------------------------------------------------------------
// SetUIVisibility updates the UI visibility overrides. Thread-safe.
func (r *Runner) SetUIVisibility(v UIVisibility) {
r.mu.Lock()
defer r.mu.Unlock()
r.uiVisibility = &v
}
// GetUIVisibility returns the current UI visibility overrides, or nil if
// none have been set (meaning show everything). Thread-safe.
func (r *Runner) GetUIVisibility() *UIVisibility {
r.mu.RLock()
defer r.mu.RUnlock()
if r.uiVisibility == nil {
return nil
}
v := *r.uiVisibility
return &v
}
// ---------------------------------------------------------------------------
// Tool renderer management
// ---------------------------------------------------------------------------
@@ -290,6 +370,233 @@ func (r *Runner) GetToolRenderer(toolName string) *ToolRenderConfig {
return nil
}
// ---------------------------------------------------------------------------
// Message renderer management
// ---------------------------------------------------------------------------
// GetMessageRenderer returns the named message renderer, or nil if no
// extension registered a renderer with that name. If multiple extensions
// register the same name, the last one (by load order) wins.
func (r *Runner) GetMessageRenderer(name string) *MessageRendererConfig {
for i := len(r.extensions) - 1; i >= 0; i-- {
for j := len(r.extensions[i].MessageRenderers) - 1; j >= 0; j-- {
if r.extensions[i].MessageRenderers[j].Name == name {
config := r.extensions[i].MessageRenderers[j]
return &config
}
}
}
return nil
}
// ---------------------------------------------------------------------------
// Hot-reload
// ---------------------------------------------------------------------------
// Reload replaces the loaded extensions with a fresh set and clears all
// dynamic state (widgets, status, header/footer, editor, visibility,
// disabled tools, custom event subscriptions). Option overrides are
// preserved across reloads since they represent user intent.
//
// The caller is responsible for emitting SessionShutdown before calling
// Reload and SessionStart after.
func (r *Runner) Reload(exts []LoadedExtension) {
r.mu.Lock()
defer r.mu.Unlock()
r.extensions = exts
r.widgets = nil
r.statusEntries = nil
r.header = nil
r.footer = nil
r.customEditor = nil
r.uiVisibility = nil
r.disabledTools = nil
r.customEventSubs = nil
// optionOverrides are intentionally preserved.
}
// ---------------------------------------------------------------------------
// Inter-extension event bus
// ---------------------------------------------------------------------------
// SubscribeCustomEvent registers a handler for a named custom event. Handlers
// execute in registration order when EmitCustomEvent is called. Thread-safe.
func (r *Runner) SubscribeCustomEvent(name string, handler func(string)) {
r.mu.Lock()
defer r.mu.Unlock()
if r.customEventSubs == nil {
r.customEventSubs = make(map[string][]func(string))
}
r.customEventSubs[name] = append(r.customEventSubs[name], handler)
}
// EmitCustomEvent dispatches a named event to all subscribed handlers.
// Handlers run synchronously in extension load order. Panics are recovered
// and logged. Thread-safe.
func (r *Runner) EmitCustomEvent(name, data string) {
// Collect handlers: extension-registered (Init-time) + dynamic subs.
r.mu.RLock()
dynamicHandlers := r.customEventSubs[name]
r.mu.RUnlock()
safeInvoke := func(h func(string)) {
defer func() {
if rec := recover(); rec != nil {
log.Warn("custom event handler panicked",
"event", name,
"err", fmt.Sprintf("%v", rec))
}
}()
h(data)
}
// Extension-registered handlers first (in load order).
for i := range r.extensions {
for _, h := range r.extensions[i].CustomEventHandlers[name] {
safeInvoke(h)
}
}
// Then dynamic subscriptions.
for _, h := range dynamicHandlers {
safeInvoke(h)
}
}
// ---------------------------------------------------------------------------
// Tool management
// ---------------------------------------------------------------------------
// SetActiveTools restricts the tool set to the named tools. All tools not in
// the list are disabled. Passing nil or an empty slice re-enables all tools.
// Thread-safe.
func (r *Runner) SetActiveTools(names []string) {
r.mu.Lock()
defer r.mu.Unlock()
if len(names) == 0 {
r.disabledTools = nil
return
}
active := make(map[string]bool, len(names))
for _, n := range names {
active[n] = true
}
r.disabledTools = active // non-nil = only these tools are allowed
}
// IsToolDisabled returns true if the tool has been disabled via SetActiveTools.
// Thread-safe.
func (r *Runner) IsToolDisabled(toolName string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
if r.disabledTools == nil {
return false // no filter = all enabled
}
return !r.disabledTools[toolName]
}
// ---------------------------------------------------------------------------
// Extension options
// ---------------------------------------------------------------------------
// GetOption resolves a named option value in priority order:
// 1. Runtime override (via SetOption)
// 2. Environment variable: KIT_OPT_<NAME> (uppercased, dashes → underscores)
// 3. Viper config: options.<name>
// 4. Default value from RegisterOption
//
// Returns empty string if the option was never registered.
// Thread-safe.
func (r *Runner) GetOption(name string) string {
// 1. Runtime override.
r.mu.RLock()
if v, ok := r.optionOverrides[name]; ok {
r.mu.RUnlock()
return v
}
r.mu.RUnlock()
// 2. Environment variable: KIT_OPT_<NAME>
envKey := "KIT_OPT_" + strings.ToUpper(strings.ReplaceAll(name, "-", "_"))
if v := os.Getenv(envKey); v != "" {
return v
}
// 3. Viper config: options.<name>
configKey := "options." + name
if v := viper.GetString(configKey); v != "" {
return v
}
// 4. Default from registered option defs.
for i := range r.extensions {
for _, opt := range r.extensions[i].Options {
if opt.Name == name {
return opt.Default
}
}
}
return ""
}
// SetOption stores a runtime override for a named option. This takes highest
// priority over env vars, config, and defaults. Thread-safe.
func (r *Runner) SetOption(name, value string) {
r.mu.Lock()
defer r.mu.Unlock()
if r.optionOverrides == nil {
r.optionOverrides = make(map[string]string)
}
r.optionOverrides[name] = value
}
// RegisteredOptions returns all option definitions from all loaded extensions.
func (r *Runner) RegisteredOptions() []OptionDef {
var opts []OptionDef
for i := range r.extensions {
opts = append(opts, r.extensions[i].Options...)
}
return opts
}
// ---------------------------------------------------------------------------
// Keyboard shortcuts
// ---------------------------------------------------------------------------
// GetShortcuts returns all registered keyboard shortcuts as a map of
// key binding → handler. If multiple extensions register the same key,
// the last registration wins. Thread-safe (reads extension list which is
// immutable after loading).
func (r *Runner) GetShortcuts() map[string]ShortcutEntry {
result := make(map[string]ShortcutEntry)
for i := range r.extensions {
for _, sc := range r.extensions[i].Shortcuts {
result[sc.Def.Key] = sc
}
}
if len(result) == 0 {
return nil
}
return result
}
// RegisteredShortcuts returns all shortcut definitions from all loaded
// extensions. Used for help/listing commands.
func (r *Runner) RegisteredShortcuts() []ShortcutDef {
var defs []ShortcutDef
seen := make(map[string]bool)
// Iterate in reverse so last registration for a key wins.
for i := len(r.extensions) - 1; i >= 0; i-- {
for _, sc := range r.extensions[i].Shortcuts {
if !seen[sc.Def.Key] {
seen[sc.Def.Key] = true
defs = append(defs, sc.Def)
}
}
}
return defs
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -311,6 +618,12 @@ func isBlocking(result Result) bool {
return r.Block
case InputResult:
return r.Action == "handled"
case BeforeForkResult:
return r.Cancel
case BeforeSessionSwitchResult:
return r.Cancel
case BeforeCompactResult:
return r.Cancel
}
return false
}
+45
View File
@@ -23,9 +23,31 @@ func Symbols() interp.Exports {
"API": reflect.ValueOf((*API)(nil)),
"Context": reflect.ValueOf((*Context)(nil)),
"ToolDef": reflect.ValueOf((*ToolDef)(nil)),
"ToolContext": reflect.ValueOf((*ToolContext)(nil)),
"ShortcutDef": reflect.ValueOf((*ShortcutDef)(nil)),
"CommandDef": reflect.ValueOf((*CommandDef)(nil)),
"PrintBlockOpts": reflect.ValueOf((*PrintBlockOpts)(nil)),
// Session types
"SessionMessage": reflect.ValueOf((*SessionMessage)(nil)),
"ExtensionEntry": reflect.ValueOf((*ExtensionEntry)(nil)),
// Option types
"OptionDef": reflect.ValueOf((*OptionDef)(nil)),
// Model info types
"ModelInfoEntry": reflect.ValueOf((*ModelInfoEntry)(nil)),
// Tool info types
"ToolInfo": reflect.ValueOf((*ToolInfo)(nil)),
// LLM completion types
"CompleteRequest": reflect.ValueOf((*CompleteRequest)(nil)),
"CompleteResponse": reflect.ValueOf((*CompleteResponse)(nil)),
// Status bar types
"StatusBarEntry": reflect.ValueOf((*StatusBarEntry)(nil)),
// Widget types
"WidgetConfig": reflect.ValueOf((*WidgetConfig)(nil)),
"WidgetContent": reflect.ValueOf((*WidgetContent)(nil)),
@@ -37,6 +59,12 @@ func Symbols() interp.Exports {
// Header/Footer types
"HeaderFooterConfig": reflect.ValueOf((*HeaderFooterConfig)(nil)),
// UI visibility
"UIVisibility": reflect.ValueOf((*UIVisibility)(nil)),
// Context stats
"ContextStats": reflect.ValueOf((*ContextStats)(nil)),
// Overlay types
"OverlayAnchor": reflect.ValueOf((*OverlayAnchor)(nil)),
"OverlayCenter": reflect.ValueOf(OverlayCenter),
@@ -49,6 +77,9 @@ func Symbols() interp.Exports {
// Tool renderer types
"ToolRenderConfig": reflect.ValueOf((*ToolRenderConfig)(nil)),
// Message renderer types
"MessageRendererConfig": reflect.ValueOf((*MessageRendererConfig)(nil)),
// Editor interceptor types
"EditorKeyActionType": reflect.ValueOf((*EditorKeyActionType)(nil)),
"EditorKeyPassthrough": reflect.ValueOf(EditorKeyPassthrough),
@@ -66,6 +97,19 @@ func Symbols() interp.Exports {
"PromptInputConfig": reflect.ValueOf((*PromptInputConfig)(nil)),
"PromptInputResult": reflect.ValueOf((*PromptInputResult)(nil)),
// Context filtering types
"ContextMessage": reflect.ValueOf((*ContextMessage)(nil)),
"ContextPrepareEvent": reflect.ValueOf((*ContextPrepareEvent)(nil)),
"ContextPrepareResult": reflect.ValueOf((*ContextPrepareResult)(nil)),
// Session lifecycle types
"BeforeForkEvent": reflect.ValueOf((*BeforeForkEvent)(nil)),
"BeforeForkResult": reflect.ValueOf((*BeforeForkResult)(nil)),
"BeforeSessionSwitchEvent": reflect.ValueOf((*BeforeSessionSwitchEvent)(nil)),
"BeforeSessionSwitchResult": reflect.ValueOf((*BeforeSessionSwitchResult)(nil)),
"BeforeCompactEvent": reflect.ValueOf((*BeforeCompactEvent)(nil)),
"BeforeCompactResult": reflect.ValueOf((*BeforeCompactResult)(nil)),
// Event structs
"ToolCallEvent": reflect.ValueOf((*ToolCallEvent)(nil)),
"ToolCallResult": reflect.ValueOf((*ToolCallResult)(nil)),
@@ -84,6 +128,7 @@ func Symbols() interp.Exports {
"MessageEndEvent": reflect.ValueOf((*MessageEndEvent)(nil)),
"SessionStartEvent": reflect.ValueOf((*SessionStartEvent)(nil)),
"SessionShutdownEvent": reflect.ValueOf((*SessionShutdownEvent)(nil)),
"ModelChangeEvent": reflect.ValueOf((*ModelChangeEvent)(nil)),
},
}
}
+43 -11
View File
@@ -9,19 +9,17 @@ import (
// WrapToolsWithExtensions wraps each tool so that ToolCall and ToolResult
// events are emitted through the extension runner before and after execution.
// This is the Go equivalent of Pi's wrapper.ts pattern.
//
// If the runner has no relevant handlers the original tools are returned
// unchanged (zero overhead).
func WrapToolsWithExtensions(tools []fantasy.AgentTool, runner *Runner) []fantasy.AgentTool {
if runner == nil {
return tools
}
if !runner.HasHandlers(ToolCall) && !runner.HasHandlers(ToolResult) &&
!runner.HasHandlers(ToolExecutionStart) && !runner.HasHandlers(ToolExecutionEnd) {
return tools
}
// Always wrap tools through the runner so that SetActiveTools
// (disabled-tool checking) and event handlers both work. The
// overhead for disabled-tool checking is a single map lookup
// per tool call, which is negligible.
wrapped := make([]fantasy.AgentTool, len(tools))
for i, tool := range tools {
wrapped[i] = &wrappedTool{inner: tool, runner: runner}
@@ -31,10 +29,12 @@ func WrapToolsWithExtensions(tools []fantasy.AgentTool, runner *Runner) []fantas
// ExtensionToolsAsFantasy converts ToolDef values registered by extensions
// into fantasy.AgentTool implementations so the LLM can invoke them.
func ExtensionToolsAsFantasy(defs []ToolDef) []fantasy.AgentTool {
// The runner is optional; if provided, ToolContext.OnProgress routes
// progress messages through the runner's Print function.
func ExtensionToolsAsFantasy(defs []ToolDef, runner *Runner) []fantasy.AgentTool {
tools := make([]fantasy.AgentTool, 0, len(defs))
for _, def := range defs {
tools = append(tools, &extensionTool{def: def})
tools = append(tools, &extensionTool{def: def, runner: runner})
}
return tools
}
@@ -55,12 +55,20 @@ func (w *wrappedTool) SetProviderOptions(o fantasy.ProviderOptions) { w.inner.Se
func (w *wrappedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
toolName := w.inner.Info().Name
// 0. Check if tool is disabled via SetActiveTools.
if w.runner.IsToolDisabled(toolName) {
return fantasy.NewTextErrorResponse(
fmt.Sprintf("Error: tool %q is currently disabled", toolName)),
fmt.Errorf("tool %q disabled by extension", toolName)
}
// 1. Emit ToolCall — extensions can block execution.
if w.runner.HasHandlers(ToolCall) {
result, _ := w.runner.Emit(ToolCallEvent{
ToolName: toolName,
ToolCallID: call.ID,
Input: call.Input,
Source: "llm",
})
if r, ok := result.(ToolCallResult); ok && r.Block {
reason := r.Reason
@@ -112,6 +120,7 @@ func (w *wrappedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.T
type extensionTool struct {
def ToolDef
runner *Runner // optional; enables ToolContext.OnProgress
providerOptions fantasy.ProviderOptions
}
@@ -125,8 +134,31 @@ func (t *extensionTool) Info() fantasy.ToolInfo {
func (t *extensionTool) ProviderOptions() fantasy.ProviderOptions { return t.providerOptions }
func (t *extensionTool) SetProviderOptions(o fantasy.ProviderOptions) { t.providerOptions = o }
func (t *extensionTool) Run(_ context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
result, err := t.def.Execute(call.Input)
func (t *extensionTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
var result string
var err error
if t.def.ExecuteWithContext != nil {
tc := ToolContext{
IsCancelled: func() bool {
return ctx.Err() != nil
},
OnProgress: func(text string) {
if t.runner != nil {
t.runner.mu.RLock()
printFn := t.runner.ctx.Print
t.runner.mu.RUnlock()
if printFn != nil {
printFn(text)
}
}
},
}
result, err = t.def.ExecuteWithContext(call.Input, tc)
} else {
result, err = t.def.Execute(call.Input)
}
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), err
}
+121 -5
View File
@@ -48,8 +48,13 @@ func TestWrapToolsWithExtensions_NoRelevantHandlers(t *testing.T) {
}))
tools := []fantasy.AgentTool{newMockTool("test")}
result := WrapToolsWithExtensions(tools, r)
if result[0] != tools[0] {
t.Error("expected original tool when no tool handlers exist")
// Tools are always wrapped now (for SetActiveTools support),
// but Info() should pass through correctly.
if result[0] == tools[0] {
t.Error("expected wrapped tool (always wraps for SetActiveTools)")
}
if result[0].Info().Name != "test" {
t.Errorf("expected name 'test', got %q", result[0].Info().Name)
}
}
@@ -102,6 +107,22 @@ func TestWrappedTool_NormalExecution(t *testing.T) {
}
}
func TestWrappedTool_SourceField(t *testing.T) {
var gotSource string
r := makeRunner(makeHandlerExt("source.go", map[EventType][]HandlerFunc{
ToolCall: {func(e Event, c Context) Result {
gotSource = e.(ToolCallEvent).Source
return nil
}},
}))
tools := WrapToolsWithExtensions([]fantasy.AgentTool{newMockTool("bash")}, r)
_, _ = tools[0].Run(context.Background(), fantasy.ToolCall{ID: "1", Input: "{}"})
if gotSource != "llm" {
t.Errorf("expected Source='llm', got %q", gotSource)
}
}
func TestWrappedTool_BlockExecution(t *testing.T) {
var toolRan bool
r := makeRunner(makeHandlerExt("blocker.go", map[EventType][]HandlerFunc{
@@ -181,7 +202,7 @@ func TestExtensionToolsAsFantasy(t *testing.T) {
},
}
tools := ExtensionToolsAsFantasy(defs)
tools := ExtensionToolsAsFantasy(defs, nil)
if len(tools) != 1 {
t.Fatalf("expected 1 tool, got %d", len(tools))
}
@@ -211,7 +232,7 @@ func TestExtensionTool_Error(t *testing.T) {
},
}
tools := ExtensionToolsAsFantasy(defs)
tools := ExtensionToolsAsFantasy(defs, nil)
resp, err := tools[0].Run(context.Background(), fantasy.ToolCall{Input: "x"})
if err == nil {
t.Error("expected error")
@@ -221,9 +242,104 @@ func TestExtensionTool_Error(t *testing.T) {
}
}
func TestExtensionTool_ExecuteWithContext(t *testing.T) {
var gotCancelled bool
var gotProgress []string
defs := []ToolDef{
{
Name: "rich",
ExecuteWithContext: func(input string, tc ToolContext) (string, error) {
gotCancelled = tc.IsCancelled()
tc.OnProgress("step 1")
tc.OnProgress("step 2")
return "done: " + input, nil
},
},
}
// Without runner, OnProgress is a no-op.
tools := ExtensionToolsAsFantasy(defs, nil)
resp, err := tools[0].Run(context.Background(), fantasy.ToolCall{Input: "test"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Content != "done: test" {
t.Errorf("expected 'done: test', got %q", resp.Content)
}
if gotCancelled {
t.Error("expected IsCancelled=false for non-cancelled context")
}
// With runner, OnProgress routes through Print.
runner := NewRunner(nil)
runner.SetContext(Context{
Print: func(text string) { gotProgress = append(gotProgress, text) },
})
defs2 := []ToolDef{
{
Name: "rich2",
ExecuteWithContext: func(input string, tc ToolContext) (string, error) {
tc.OnProgress("hello")
return "ok", nil
},
},
}
tools2 := ExtensionToolsAsFantasy(defs2, runner)
_, err = tools2[0].Run(context.Background(), fantasy.ToolCall{Input: ""})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(gotProgress) != 1 || gotProgress[0] != "hello" {
t.Errorf("expected [hello], got %v", gotProgress)
}
}
func TestExtensionTool_ExecuteWithContextPriority(t *testing.T) {
// When both Execute and ExecuteWithContext are set, ExecuteWithContext wins.
defs := []ToolDef{
{
Name: "both",
Execute: func(input string) (string, error) { return "simple", nil },
ExecuteWithContext: func(input string, tc ToolContext) (string, error) {
return "rich", nil
},
},
}
tools := ExtensionToolsAsFantasy(defs, nil)
resp, err := tools[0].Run(context.Background(), fantasy.ToolCall{Input: ""})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Content != "rich" {
t.Errorf("expected 'rich' (ExecuteWithContext), got %q", resp.Content)
}
}
func TestExtensionTool_CancelledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately
var sawCancelled bool
defs := []ToolDef{
{
Name: "checkcancel",
ExecuteWithContext: func(input string, tc ToolContext) (string, error) {
sawCancelled = tc.IsCancelled()
return "ok", nil
},
},
}
tools := ExtensionToolsAsFantasy(defs, nil)
_, _ = tools[0].Run(ctx, fantasy.ToolCall{Input: ""})
if !sawCancelled {
t.Error("expected IsCancelled=true for cancelled context")
}
}
func TestExtensionTool_ProviderOptions(t *testing.T) {
defs := []ToolDef{{Name: "test", Execute: func(string) (string, error) { return "", nil }}}
tools := ExtensionToolsAsFantasy(defs)
tools := ExtensionToolsAsFantasy(defs, nil)
// Initially nil.
opts := tools[0].ProviderOptions()
+1 -1
View File
@@ -186,7 +186,7 @@ func loadExtensions() (*extensions.Runner, extensionCreationOpts, error) {
return extensions.WrapToolsWithExtensions(tools, runner)
}
extTools := extensions.ExtensionToolsAsFantasy(runner.RegisteredTools())
extTools := extensions.ExtensionToolsAsFantasy(runner.RegisteredTools(), runner)
return runner, extensionCreationOpts{
toolWrapper: wrapper,
+27 -2
View File
@@ -11,8 +11,8 @@ import (
)
// EntryType identifies the kind of entry stored in a JSONL session file.
// Following pi's design, sessions are append-only JSONL files where each line
// is a typed entry linked by id/parent_id to form a tree structure.
// Sessions are append-only JSONL files where each line is a typed entry
// linked by id/parent_id to form a tree structure.
type EntryType string
const (
@@ -22,6 +22,7 @@ const (
EntryTypeBranchSummary EntryType = "branch_summary"
EntryTypeLabel EntryType = "label"
EntryTypeSessionInfo EntryType = "session_info"
EntryTypeExtensionData EntryType = "extension_data"
)
// CurrentVersion is the session format version for JSONL tree sessions.
@@ -89,6 +90,14 @@ type SessionInfoEntry struct {
Name string `json:"name"`
}
// ExtensionDataEntry stores custom extension data in the session tree.
// Extensions use this to persist state that survives across session restarts.
type ExtensionDataEntry struct {
Entry
ExtType string `json:"ext_type"` // Extension-defined type string (e.g. "plan-mode:state")
Data string `json:"data"` // Extension-defined data (JSON or plain text)
}
// GenerateEntryID creates a unique entry identifier (16 hex chars).
func GenerateEntryID() string {
bytes := make([]byte, 8)
@@ -177,6 +186,15 @@ func NewSessionInfoEntry(parentID, name string) *SessionInfoEntry {
}
}
// NewExtensionDataEntry creates an ExtensionDataEntry.
func NewExtensionDataEntry(parentID, extType, data string) *ExtensionDataEntry {
return &ExtensionDataEntry{
Entry: NewEntry(EntryTypeExtensionData, parentID),
ExtType: extType,
Data: data,
}
}
// --- JSONL marshaling helpers ---
// MarshalEntry serializes any entry to a JSON line (no trailing newline).
@@ -241,6 +259,13 @@ func UnmarshalEntry(data []byte) (any, error) {
}
return &e, nil
case EntryTypeExtensionData:
var e ExtensionDataEntry
if err := json.Unmarshal(data, &e); err != nil {
return nil, fmt.Errorf("failed to unmarshal extension_data entry: %w", err)
}
return &e, nil
default:
return nil, fmt.Errorf("unknown entry type: %q", env.Type)
}
+1 -1
View File
@@ -12,7 +12,7 @@ import (
)
// SessionInfo contains metadata about a discovered session, used for listing
// and session picker display. Follows pi's SessionInfo design.
// and session picker display.
type SessionInfo struct {
// Path is the absolute path to the JSONL session file.
Path string
+45 -3
View File
@@ -16,7 +16,7 @@ import (
)
// TreeNode represents a node in the session tree for display purposes.
// It mirrors pi's SessionTreeNode design.
type TreeNode struct {
Entry any // the underlying entry (*MessageEntry, *ModelChangeEntry, etc.)
ID string // entry ID
@@ -25,7 +25,7 @@ type TreeNode struct {
}
// TreeManager manages a tree-structured JSONL session. It is the replacement
// for the linear session.Manager, following pi's design decisions:
// for the linear session.Manager:
//
// - JSONL append-only format (one JSON object per line)
// - Tree structure via id/parent_id on every entry
@@ -283,6 +283,44 @@ func (tm *TreeManager) AppendSessionInfo(name string) (string, error) {
return entry.ID, nil
}
// AppendExtensionData adds an extension data entry to the tree and persists it.
// Extensions use this to store custom state that survives across session restarts.
func (tm *TreeManager) AppendExtensionData(extType, data string) (string, error) {
tm.mu.Lock()
defer tm.mu.Unlock()
entry := NewExtensionDataEntry(tm.leafID, extType, data)
if err := tm.appendAndPersist(entry); err != nil {
return "", err
}
tm.leafID = entry.ID
return entry.ID, nil
}
// GetExtensionData returns all extension data entries matching the given type,
// walking the current branch from root to leaf. If extType is empty, all
// extension data entries on the branch are returned.
func (tm *TreeManager) GetExtensionData(extType string) []*ExtensionDataEntry {
tm.mu.RLock()
defer tm.mu.RUnlock()
if tm.leafID == "" {
return nil
}
branch := tm.getBranchLocked(tm.leafID)
var results []*ExtensionDataEntry
for _, entry := range branch {
if e, ok := entry.(*ExtensionDataEntry); ok {
if extType == "" || e.ExtType == extType {
results = append(results, e)
}
}
}
return results
}
// --- Tree navigation ---
// Branch moves the leaf pointer to the given entry ID, creating a branch
@@ -601,6 +639,8 @@ func (tm *TreeManager) entryID(entry any) string {
return e.ID
case *SessionInfoEntry:
return e.ID
case *ExtensionDataEntry:
return e.ID
default:
return ""
}
@@ -619,6 +659,8 @@ func (tm *TreeManager) entryParentID(entry any) string {
return e.ParentID
case *SessionInfoEntry:
return e.ParentID
case *ExtensionDataEntry:
return e.ParentID
default:
return ""
}
@@ -675,7 +717,7 @@ func (tm *TreeManager) buildTreeNode(id string) *TreeNode {
// --- Path conventions ---
// DefaultSessionDir returns the default session storage directory for a cwd.
// Following pi's convention: ~/.kit/sessions/--<cwd-path>--/
// Convention: ~/.kit/sessions/--<cwd-path>--/
func DefaultSessionDir(cwd string) string {
home, err := os.UserHomeDir()
if err != nil {
+1 -1
View File
@@ -202,7 +202,7 @@ func LoadSkills(cwd string) ([]*Skill, error) {
// FormatForPrompt formats skills as metadata-only XML for inclusion in a
// system prompt. Only the name, description, and file location are included;
// the agent reads the full skill file on demand using the read tool. This
// matches the Pi SDK's formatSkillsForPrompt convention.
func FormatForPrompt(skills []*Skill) string {
if len(skills) == 0 {
return ""
+3 -1
View File
@@ -9,7 +9,8 @@ type SlashCommand struct {
Name string
Description string
Aliases []string
Category string // e.g., "Navigation", "System", "Info"
Category string // e.g., "Navigation", "System", "Info"
Complete func(prefix string) []string // optional argument tab-completion
}
// SlashCommands provides the global registry of all available slash commands
@@ -136,6 +137,7 @@ type ExtensionCommand struct {
Name string
Description string
Execute func(args string) (string, error)
Complete func(prefix string) []string // optional argument tab-completion
}
// FindExtensionCommand looks up an extension command by name from the given
+3 -2
View File
@@ -188,7 +188,7 @@ func (r *CompactRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
header += " " + lipgloss.NewStyle().Foreground(theme.Muted).Render(params)
}
// Format body: check extension renderer first, then builtin, then default.
// Format body: check extension renderer first, then compact builtin, then default.
var body string
if extRd != nil && extRd.RenderBody != nil {
body = extRd.RenderBody(toolResult, isError, r.width-4)
@@ -201,7 +201,8 @@ func (r *CompactRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
if isError {
body = lipgloss.NewStyle().Foreground(theme.Error).Render(r.formatToolResult(toolResult))
} else {
body = renderToolBody(toolName, toolArgs, toolResult, r.width-4)
// Use compact summary renderers instead of full tool body renderers.
body = renderToolBodyCompact(toolName, toolArgs, toolResult, r.width-4)
if body == "" {
formatted := r.formatToolResult(toolResult)
if formatted == "" {
+30
View File
@@ -28,3 +28,33 @@ type TreeNodeSelectedMsg struct {
// TreeCancelledMsg is sent when the user cancels the tree selector (ESC).
type TreeCancelledMsg struct{}
// shellCommandMsg is sent by the InputComponent when the user submits a
// ! or !! prefixed command. The parent model intercepts this to execute
// the shell command directly instead of forwarding to the LLM.
//
// Matching pi's behavior:
// - !cmd → run shell command, output INCLUDED in LLM context
// - !!cmd → run shell command, output EXCLUDED from LLM context
type shellCommandMsg struct {
// Command is the shell command to execute (prefix stripped).
Command string
// ExcludeFromContext is true for !! (output excluded from LLM context),
// false for ! (output included in LLM context).
ExcludeFromContext bool
}
// shellCommandResultMsg carries the result of a shell command execution
// back to the parent model for display.
type shellCommandResultMsg struct {
// Command is the original shell command that was executed.
Command string
// Output is the combined stdout/stderr output.
Output string
// ExitCode is the process exit code (0 = success).
ExitCode int
// Err is non-nil if the command failed to start or timed out.
Err error
// ExcludeFromContext mirrors the flag from shellCommandMsg.
ExcludeFromContext bool
}
+129
View File
@@ -0,0 +1,129 @@
package ui
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
// fileTokenPattern matches @file references in user text. Supports:
// - @"path with spaces.txt" (quoted)
// - @path/to/file.txt (unquoted, no spaces)
var fileTokenPattern = regexp.MustCompile(`@"[^"]+"|@[^\s]+`)
// ProcessFileAttachments scans the user's input text for @file references,
// reads each referenced file, and returns the text with @tokens replaced by
// XML-wrapped file content. Non-file @ tokens (like email addresses) are left
// unchanged.
//
// Returns the original text unchanged if no valid @file references are found.
func ProcessFileAttachments(text string, cwd string) string {
tokens := fileTokenPattern.FindAllString(text, -1)
if len(tokens) == 0 {
return text
}
result := text
for _, token := range tokens {
path := tokenToPath(token)
if path == "" {
continue
}
absPath, err := resolvePath(path, cwd)
if err != nil {
// Not a valid file reference — leave the token as-is.
// This handles cases like email addresses (@user) gracefully.
continue
}
info, err := os.Stat(absPath)
if err != nil {
continue
}
// Skip directories — we only attach file content.
if info.IsDir() {
continue
}
// Skip empty files.
if info.Size() == 0 {
continue
}
content, err := os.ReadFile(absPath)
if err != nil {
continue
}
// Build the XML-wrapped replacement.
wrapped := wrapFileContent(absPath, content)
result = strings.Replace(result, token, wrapped, 1)
}
return result
}
// tokenToPath strips the @ prefix and optional quotes from a token,
// returning the raw file path. Returns "" for invalid tokens.
func tokenToPath(token string) string {
if !strings.HasPrefix(token, "@") {
return ""
}
path := token[1:]
// Strip quotes.
if strings.HasPrefix(path, `"`) && strings.HasSuffix(path, `"`) {
path = path[1 : len(path)-1]
}
// Reject obviously non-file tokens (e.g. bare @ or @-flags).
if path == "" || strings.HasPrefix(path, "-") {
return ""
}
return path
}
// resolvePath resolves a potentially relative file path to an absolute path.
// Supports ~/ expansion and relative paths. No CWD restriction — the user
// can reference any file they have read access to.
func resolvePath(path string, cwd string) (string, error) {
// Expand ~/
if strings.HasPrefix(path, "~/") {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("cannot expand ~: %w", err)
}
path = filepath.Join(home, path[2:])
}
// Resolve relative to cwd.
if !filepath.IsAbs(path) {
path = filepath.Join(cwd, path)
}
// Clean and resolve symlinks for consistent paths.
absPath, err := filepath.Abs(path)
if err != nil {
return "", fmt.Errorf("invalid path: %w", err)
}
// Resolve symlinks so the displayed path is canonical.
resolved, err := filepath.EvalSymlinks(absPath)
if err != nil {
// EvalSymlinks fails if the file doesn't exist — fall back to
// the cleaned absolute path and let the caller's Stat handle it.
return absPath, nil
}
return resolved, nil
}
// wrapFileContent wraps file content in XML tags for LLM consumption.
func wrapFileContent(absPath string, content []byte) string {
return fmt.Sprintf("<file path=\"%s\">\n%s\n</file>", absPath, string(content))
}
+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
}
+251 -23
View File
@@ -36,9 +36,31 @@ type InputComponent struct {
title string
submitNext bool // defer submit one tick so popup dismisses cleanly
// Argument completion state. When the user types "/cmd " followed by
// a partial argument and the command has a Complete function, the popup
// switches to argument-completion mode showing suggestions from Complete.
argMode bool // true when showing arg completions
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
// hideHint suppresses the "enter submit · ctrl+j..." hint text.
hideHint bool
}
// NewInputComponent creates a new InputComponent with the given width, title,
@@ -80,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
@@ -138,17 +166,35 @@ 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) {
s.textarea.SetValue(s.filtered[s.selected].Command.Name)
s.showPopup = false
s.selected = 0
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.textarea.CursorEnd()
}
return s, nil
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
if s.selected < len(s.filtered) {
// Populate textarea with selected command and submit on next tick.
s.textarea.SetValue(s.filtered[s.selected].Command.Name)
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)
} else {
s.textarea.SetValue(s.filtered[s.selected].Command.Name)
}
s.textarea.CursorEnd()
s.showPopup = false
s.selected = 0
@@ -172,12 +218,57 @@ 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], "/") && !strings.Contains(lines[0], " ") {
s.showPopup = true
s.filtered = FuzzyMatchCommands(lines[0], s.commands)
s.selected = 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
s.argMode = false
s.filtered = FuzzyMatchCommands(lines[0], s.commands)
s.selected = 0
} else if suggestions := s.completeArgs(lines[0]); len(suggestions) > 0 {
// Argument completion for a command with a Complete function.
s.showPopup = true
// s.argMode, s.argCommand, s.argSynthCmds, s.filtered
// are set by completeArgs.
s.selected = 0
} else {
s.showPopup = false
s.argMode = false
}
} else {
s.showPopup = false
s.argMode = false
s.fileMode = false
}
}
return s, cmd
@@ -191,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
@@ -254,13 +367,15 @@ func (s *InputComponent) View() tea.View {
view.WriteString(s.renderPopup())
}
helpStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("240")).
MarginTop(1).
PaddingLeft(3)
if !s.hideHint {
helpStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("240")).
MarginTop(1).
PaddingLeft(3)
view.WriteString("\n")
view.WriteString(helpStyle.Render("enter submit • ctrl+j / alt+enter new line"))
view.WriteString("\n")
view.WriteString(helpStyle.Render("enter submit • ctrl+j / alt+enter new line"))
}
return tea.NewView(containerStyle.Render(view.String()))
}
@@ -301,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 {
@@ -326,3 +457,100 @@ func (s *InputComponent) renderPopup() string {
return popupStyle.Render(content + "\n\n" + footer)
}
// completeArgs checks whether the input line matches a command with a Complete
// function, calls it, and populates the arg-mode state on success. Returns the
// list of suggestions (empty means no completions available).
func (s *InputComponent) completeArgs(line string) []FuzzyMatch {
parts := strings.SplitN(line, " ", 2)
cmdName := parts[0]
argPrefix := ""
if len(parts) > 1 {
argPrefix = parts[1]
}
cmd := s.findCommandWithComplete(cmdName)
if cmd == nil {
return nil
}
suggestions := cmd.Complete(argPrefix)
if len(suggestions) == 0 {
s.argMode = false
return nil
}
s.argMode = true
s.argCommand = cmdName
s.argSynthCmds = make([]SlashCommand, len(suggestions))
s.filtered = make([]FuzzyMatch, len(suggestions))
for i, sug := range suggestions {
s.argSynthCmds[i] = SlashCommand{Name: sug}
s.filtered[i] = FuzzyMatch{Command: &s.argSynthCmds[i]}
}
return s.filtered
}
// findCommandWithComplete looks up a command by name that has a non-nil
// Complete function.
func (s *InputComponent) findCommandWithComplete(name string) *SlashCommand {
for i := range s.commands {
if s.commands[i].Name == name && s.commands[i].Complete != nil {
return &s.commands[i]
}
}
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
}
}
+491 -32
View File
@@ -1,8 +1,11 @@
package ui
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"strings"
"time"
@@ -72,6 +75,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
@@ -167,6 +175,24 @@ type WidgetData struct {
NoBorder bool
}
// StatusBarEntryData represents a keyed extension entry in the TUI status bar.
// Multiple entries from different extensions coexist, ordered by Priority
// (lower values render further left).
type StatusBarEntryData struct {
Key string // unique identifier (e.g. "myext:git-branch")
Text string // rendered content shown in the status bar
Priority int // lower = further left; built-in entries use 100-110
}
// UIVisibility controls which built-in TUI chrome elements are visible.
// The zero value shows everything (backward compatible).
type UIVisibility struct {
HideStartupMessage bool // Hide the "Model loaded..." startup block
HideStatusBar bool // Hide the "provider · model Tokens: ..." line
HideSeparator bool // Hide the "────────" divider between stream and input
HideInputHint bool // Hide the "enter submit · ctrl+j..." hint below input
}
// AppModelOptions holds configuration passed to NewAppModel.
type AppModelOptions struct {
// CompactMode selects the compact renderer for message formatting.
@@ -183,6 +209,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
@@ -242,6 +272,40 @@ type AppModelOptions struct {
// intercept key events and during View() to wrap input rendering.
// May be nil if no extensions are loaded.
GetEditorInterceptor func() *EditorInterceptor
// GetUIVisibility returns the current UI visibility overrides set by
// an extension, or nil if none have been set (show everything).
// Called during View() and PrintStartupInfo() to conditionally hide
// built-in chrome elements. May be nil if no extensions are loaded.
GetUIVisibility func() *UIVisibility
// GetStatusBarEntries returns extension-provided status bar entries,
// sorted by priority. Called during renderStatusBar() to inject
// extension entries alongside the built-in model/usage display.
// May be nil if no extensions are loaded.
GetStatusBarEntries func() []StatusBarEntryData
// EmitBeforeFork, if non-nil, is called before branching to a
// different session tree entry. Returns (cancelled, reason) where
// cancelled=true means the fork should be aborted. May be nil if
// no extensions are loaded.
EmitBeforeFork func(targetID string, isUserMsg bool, userText string) (bool, string)
// EmitBeforeSessionSwitch, if non-nil, is called before switching
// to a new session branch (e.g. /new, /clear). Returns (cancelled,
// reason). May be nil if no extensions are loaded.
EmitBeforeSessionSwitch func(reason string) (bool, string)
// GetGlobalShortcuts, if non-nil, returns extension-registered global
// keyboard shortcuts. Keys are binding strings (e.g., "ctrl+p").
// Handlers are called in a goroutine to avoid blocking the TUI event
// loop. May be nil if no extensions are loaded.
GetGlobalShortcuts func() map[string]func()
// GetExtensionCommands, if non-nil, returns the current extension
// commands. Called on WidgetUpdateEvent to refresh the command list
// after an extension hot-reload. May be nil if no extensions loaded.
GetExtensionCommands func() []ExtensionCommand
}
// AppModel is the root Bubble Tea model for the interactive TUI. It owns the
@@ -349,6 +413,28 @@ type AppModel struct {
// getEditorInterceptor returns the current editor interceptor. May be nil.
getEditorInterceptor func() *EditorInterceptor
// getUIVisibility returns extension-provided UI visibility overrides. May be nil.
getUIVisibility func() *UIVisibility
// getStatusBarEntries returns extension-provided status bar entries. May be nil.
getStatusBarEntries func() []StatusBarEntryData
// emitBeforeFork emits a before-fork event to extensions. Returns
// (cancelled, reason). May be nil if no extensions are loaded.
emitBeforeFork func(targetID string, isUserMsg bool, userText string) (bool, string)
// emitBeforeSessionSwitch emits a before-session-switch event to extensions.
// Returns (cancelled, reason). May be nil if no extensions are loaded.
emitBeforeSessionSwitch func(reason string) (bool, string)
// getGlobalShortcuts returns extension-registered keyboard shortcuts.
// May be nil if no extensions are loaded.
getGlobalShortcuts func() map[string]func()
// getExtensionCommands returns the current extension commands. Used
// to refresh the command list after an extension hot-reload. May be nil.
getExtensionCommands func() []ExtensionCommand
// prompt holds the state of an active interactive prompt overlay. Nil
// when no prompt is active. Managed by updatePromptState().
prompt *promptOverlay
@@ -375,6 +461,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
@@ -452,6 +541,7 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
serverNames: opts.ServerNames,
toolNames: opts.ToolNames,
usageTracker: opts.UsageTracker,
cwd: opts.Cwd,
width: width,
height: height,
}
@@ -462,6 +552,12 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
m.getHeader = opts.GetHeader
m.getFooter = opts.GetFooter
m.getEditorInterceptor = opts.GetEditorInterceptor
m.getUIVisibility = opts.GetUIVisibility
m.getStatusBarEntries = opts.GetStatusBarEntries
m.emitBeforeFork = opts.EmitBeforeFork
m.emitBeforeSessionSwitch = opts.EmitBeforeSessionSwitch
m.getGlobalShortcuts = opts.GetGlobalShortcuts
m.getExtensionCommands = opts.GetExtensionCommands
// Store context/skills metadata and tool counts for startup display.
m.contextPaths = opts.ContextPaths
@@ -472,6 +568,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 {
@@ -479,6 +580,7 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
Name: ec.Name,
Description: ec.Description,
Category: "Extensions",
Complete: ec.Complete,
})
}
}
@@ -510,12 +612,27 @@ func (m *AppModel) Init() tea.Cmd {
return tea.Batch(cmds...)
}
// uiVis returns the current UIVisibility, defaulting to zero value (show all)
// if no extension has set visibility overrides.
func (m *AppModel) uiVis() UIVisibility {
if m.getUIVisibility != nil {
if v := m.getUIVisibility(); v != nil {
return *v
}
}
return UIVisibility{}
}
// PrintStartupInfo prints the startup banner (model name, context, skills,
// tool counts) to stdout. Call this before program.Run() so the messages are
// visible above the Bubble Tea managed region.
//
// All startup information is rendered inside a single system message block.
func (m *AppModel) PrintStartupInfo() {
if m.uiVis().HideStartupMessage {
return
}
render := func(text string) string {
return m.renderer.RenderSystemMessage(text, time.Now()).Content
}
@@ -608,25 +725,32 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
}
_ = 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()
}
// 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 {
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 }
}
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
@@ -672,6 +796,21 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Quit
}
// Check extension-registered global keyboard shortcuts. These fire
// in all app states except modal prompts/overlays (which return early
// above). Matched shortcuts are consumed — the key does not propagate
// to child components.
if m.getGlobalShortcuts != nil {
if shortcuts := m.getGlobalShortcuts(); shortcuts != nil {
if handler, ok := shortcuts[msg.String()]; ok {
// Run in goroutine so blocking extension calls
// (PromptSelect, etc.) don't stall the event loop.
go handler()
return m, tea.Batch(cmds...)
}
}
}
// Route to tree selector when active.
if m.state == stateTreeSelector && m.treeSelector != nil {
updated, cmd := m.treeSelector.Update(msg)
@@ -780,12 +919,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).
@@ -802,6 +949,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:
@@ -930,12 +1085,50 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.state = stateInput
cmds = append(cmds, m.printSystemMessage(fmt.Sprintf("Compaction failed: %v", msg.Err)))
case app.ModelChangedEvent:
// Extension changed the model — update display name in status bar
// and message attribution.
m.providerName = msg.ProviderName
m.modelName = msg.ModelName
case app.WidgetUpdateEvent:
// Extension widget changed — recalculate height distribution so the
// stream region accounts for widget space. View() will read the
// latest widget state on the next render.
m.distributeHeight()
// Refresh extension commands (e.g. after hot-reload). The callback
// returns the current set from the runner which may have changed.
if m.getExtensionCommands != nil {
newCmds := m.getExtensionCommands()
m.extensionCommands = newCmds
if ic, ok := m.input.(*InputComponent); ok {
// Remove old extension commands and add fresh ones.
var builtins []SlashCommand
for _, sc := range ic.commands {
if sc.Category != "Extensions" {
builtins = append(builtins, sc)
}
}
for _, ec := range newCmds {
builtins = append(builtins, SlashCommand{
Name: ec.Name,
Description: ec.Description,
Category: "Extensions",
Complete: ec.Complete,
})
}
ic.commands = builtins
}
}
case app.EditorTextSetEvent:
// Extension wants to pre-fill the input editor with text.
if ic, ok := m.input.(*InputComponent); ok {
ic.textarea.SetValue(msg.Text)
ic.textarea.CursorEnd()
}
case app.PromptRequestEvent:
// Extension wants to show an interactive prompt. Enter prompt state.
// If already in prompt state (concurrent prompt from another
@@ -1004,6 +1197,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 {
@@ -1049,8 +1260,14 @@ func (m *AppModel) View() tea.View {
return tea.NewView(m.overlay.Render())
}
vis := m.uiVis()
streamView := m.renderStream()
separator := m.renderSeparator()
// Propagate hint visibility to the input component before rendering.
if ic, ok := m.input.(*InputComponent); ok {
ic.hideHint = vis.HideInputHint
}
// When a prompt is active, it replaces the input area for consistency
// (appears below the separator, in the same position as the input).
@@ -1060,7 +1277,6 @@ func (m *AppModel) View() tea.View {
} else {
inputView = m.renderInput()
}
statusBar := m.renderStatusBar()
// Build the stacked layout. Optional header/footer wrap the core layout.
var parts []string
@@ -1076,7 +1292,10 @@ func (m *AppModel) View() tea.View {
if streamView != "" {
parts = append(parts, streamView)
}
parts = append(parts, separator)
if !vis.HideSeparator {
parts = append(parts, m.renderSeparator())
}
// Render "above" widgets between separator and queued messages.
if aboveView := m.renderWidgetSlot("above"); aboveView != "" {
@@ -1094,7 +1313,9 @@ func (m *AppModel) View() tea.View {
parts = append(parts, belowView)
}
parts = append(parts, statusBar)
if !vis.HideStatusBar {
parts = append(parts, m.renderStatusBar())
}
// Custom footer (if set by extension) — below everything.
if footerView := m.renderHeaderFooter(m.getFooter); footerView != "" {
@@ -1132,7 +1353,8 @@ func (m *AppModel) renderStream() string {
}
// renderStatusBar renders a persistent single-line status bar below the input.
// Left side: spinner (when active). Right side: provider · model + usage stats.
// Left side: spinner (when active). Middle: extension status entries (sorted by
// priority). Right side: provider · model + usage stats.
// This bar is always present so its height is constant, eliminating layout
// shifts from spinner or usage info appearing/disappearing.
func (m *AppModel) renderStatusBar() string {
@@ -1143,7 +1365,21 @@ func (m *AppModel) renderStatusBar() string {
if m.stream != nil {
leftSide = m.stream.SpinnerView()
}
leftWidth := lipgloss.Width(leftSide)
// Middle: extension status bar entries (sorted by priority).
var middleParts []string
if m.getStatusBarEntries != nil {
entries := m.getStatusBarEntries()
for _, e := range entries {
middleParts = append(middleParts, lipgloss.NewStyle().
Foreground(theme.Muted).
Render(e.Text))
}
}
middleSide := strings.Join(middleParts, " ")
if middleSide != "" && leftSide != "" {
middleSide = " " + middleSide
}
// Right side: provider · model + usage stats.
var rightParts []string
@@ -1167,12 +1403,12 @@ func (m *AppModel) renderStatusBar() string {
}
rightSide := strings.Join(rightParts, " ")
rightWidth := lipgloss.Width(rightSide)
// Fill the gap between left and right with spaces.
gap := max(m.width-leftWidth-rightWidth, 1)
// Fill the gap between left+middle and right with spaces.
usedWidth := lipgloss.Width(leftSide) + lipgloss.Width(middleSide) + lipgloss.Width(rightSide)
gap := max(m.width-usedWidth, 1)
return leftSide + strings.Repeat(" ", gap) + rightSide
return leftSide + middleSide + strings.Repeat(" ", gap) + rightSide
}
// renderSeparator renders the separator line with an optional queue count badge.
@@ -1513,7 +1749,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."
@@ -1631,7 +1870,7 @@ func (m *AppModel) printCompactResult(evt app.CompactCompleteEvent) tea.Cmd {
// and on step completion.
//
// After flushing, a ClearScreen is issued to force a full terminal redraw.
// This is the bubbletea equivalent of pi's "clearOnShrink" mechanism: when
// When
// the stream content is moved to scrollback the view height shrinks, and
// bubbletea's inline renderer doesn't clear the orphaned terminal rows
// below the managed region. ClearScreen ensures a clean redraw.
@@ -1666,11 +1905,24 @@ func (m *AppModel) flushStreamContent() tea.Cmd {
// status bar = 1 line (always present)
// footer = measured dynamically (0 if not set)
func (m *AppModel) distributeHeight() {
const separatorLines = 1
const statusBarLines = 1 // always-present status bar
vis := m.uiVis()
separatorLines := 1
if vis.HideSeparator {
separatorLines = 0
}
statusBarLines := 1
if vis.HideStatusBar {
statusBarLines = 0
}
const linesPerQueuedMsg = 5
queuedLines := len(m.queuedMessages) * linesPerQueuedMsg
// Propagate hint visibility before measuring input height.
if ic, ok := m.input.(*InputComponent); ok {
ic.hideHint = vis.HideInputHint
}
// Measure the actual rendered input (or prompt overlay) height so we
// don't rely on a fragile constant that drifts when styling changes.
// Use renderInput() which includes the editor interceptor's Render
@@ -1807,6 +2059,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 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 {
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.
@@ -1823,6 +2097,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()
@@ -1831,7 +2134,7 @@ func (m *AppModel) handleNameCommand() tea.Cmd {
}
// For now, prompt user to provide name via input. We print instructions
// and the next non-command input starting with "name:" will be captured.
// TODO: inline input dialog like pi's implementation.
// TODO: inline input dialog.
currentName := ts.GetSessionName()
if currentName != "" {
return m.printSystemMessage(fmt.Sprintf("Current session name: %q\nTo rename, type: `/name <new name>` (not yet implemented — use the session file directly).", currentName))
@@ -1896,6 +2199,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.
@@ -2027,3 +2350,139 @@ 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 output strings.Builder
if stdout.Len() > 0 {
output.WriteString(stdout.String())
}
if stderr.Len() > 0 {
if output.Len() > 0 {
output.WriteString("\n")
}
output.WriteString(stderr.String())
}
return shellCommandResultMsg{
Command: command,
Output: output.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)
if msg.Err != nil {
content.WriteString(fmt.Sprintf("\n\nError: %v", msg.Err))
} else if msg.Output != "" {
content.WriteString("\n\n")
content.WriteString(msg.Output)
} else {
content.WriteString("\n\n(no output)")
}
if msg.ExitCode != 0 {
content.WriteString(fmt.Sprintf("\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 {
var output string
if msg.Output != "" {
output = msg.Output
} else {
output = "(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, output, msg.ExitCode)
m.appCtrl.AddContextMessage(contextMsg)
}
return tea.Batch(cmds...)
}
+4
View File
@@ -57,6 +57,10 @@ func (s *stubAppController) SendEvent(_ tea.Msg) {
// no-op in tests
}
func (s *stubAppController) AddContextMessage(_ string) {
// no-op in tests
}
// --------------------------------------------------------------------------
// Stub child components
// --------------------------------------------------------------------------
+12 -9
View File
@@ -26,6 +26,7 @@ type SlashCommandInput struct {
value string
submitNext bool // Flag to submit on next update
renderedLines int // Track how many lines were rendered
hideHint bool // Suppress the "enter submit · ctrl+j..." hint
}
// NewSlashCommandInput creates and initializes a new slash command input field with
@@ -219,17 +220,19 @@ func (s *SlashCommandInput) View() tea.View {
s.renderedLines += 1 + popupLines // newline + popup
}
// Add help text at bottom
helpStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("240")).
MarginTop(1).
PaddingLeft(3)
// Add help text at bottom (unless hidden by extension).
if !s.hideHint {
helpStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("240")).
MarginTop(1).
PaddingLeft(3)
helpText := "enter submit • ctrl+j / alt+enter new line"
helpText := "enter submit • ctrl+j / alt+enter new line"
view.WriteString("\n")
view.WriteString(helpStyle.Render(helpText))
s.renderedLines += 2 // newline + help text
view.WriteString("\n")
view.WriteString(helpStyle.Render(helpText))
s.renderedLines += 2 // newline + help text
}
// Apply container padding to entire view
return tea.NewView(containerStyle.Render(view.String()))
+174
View File
@@ -696,3 +696,177 @@ func truncateLine(s string, maxWidth int) string {
}
return s[:maxWidth-1] + "…"
}
// ---------------------------------------------------------------------------
// Compact tool body renderers — one-line summaries for compact mode
// ---------------------------------------------------------------------------
// renderToolBodyCompact returns a brief summary string for tool results in
// compact display mode. Returns empty string to fall back to default.
func renderToolBodyCompact(toolName, toolArgs, toolResult string, width int) string {
switch {
case toolName == "edit":
return renderEditCompact(toolArgs, toolResult)
case toolName == "ls":
return renderLsCompact(toolResult)
case toolName == "read":
return renderReadCompact(toolResult)
case toolName == "write":
return renderWriteCompact(toolArgs)
case toolName == "bash" || toolName == "run_shell_cmd" ||
strings.Contains(toolName, "shell") || strings.Contains(toolName, "command"):
return renderBashCompact(toolResult, width)
}
return ""
}
// renderReadCompact returns a line-count summary for Read tool output.
func renderReadCompact(toolResult string) string {
content := strings.TrimSpace(toolResult)
if content == "" {
return ""
}
lines := strings.Split(content, "\n")
// Count actual code lines (those with "N: " line-number prefix)
codeLines := 0
for _, line := range lines {
if idx := strings.Index(line, ": "); idx > 0 && idx <= 7 {
numPart := line[:idx]
if _, err := strconv.Atoi(strings.TrimSpace(numPart)); err == nil {
codeLines++
}
}
}
if codeLines == 0 {
return ""
}
theme := getTheme()
summary := fmt.Sprintf("%d lines", codeLines)
return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(summary)
}
// renderEditCompact returns a change-count summary for Edit tool output.
func renderEditCompact(toolArgs, toolResult string) string {
var args map[string]any
if err := json.Unmarshal([]byte(toolArgs), &args); err != nil {
return ""
}
oldText, _ := args["old_text"].(string)
newText, _ := args["new_text"].(string)
if oldText == "" && newText == "" {
return ""
}
oldCount := len(strings.Split(oldText, "\n"))
newCount := len(strings.Split(newText, "\n"))
theme := getTheme()
var summary string
if oldCount == newCount {
summary = fmt.Sprintf("%d lines modified", oldCount)
} else {
summary = fmt.Sprintf("-%d/+%d lines", oldCount, newCount)
}
return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(summary)
}
// renderWriteCompact returns a line-count summary for Write tool output.
func renderWriteCompact(toolArgs string) string {
var args map[string]any
if err := json.Unmarshal([]byte(toolArgs), &args); err != nil {
return ""
}
content, _ := args["content"].(string)
if content == "" {
return ""
}
count := len(strings.Split(content, "\n"))
theme := getTheme()
summary := fmt.Sprintf("%d lines written", count)
return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(summary)
}
// renderLsCompact returns an entry-count summary for Ls tool output.
func renderLsCompact(toolResult string) string {
content := strings.TrimSpace(toolResult)
if content == "" {
return ""
}
entries := strings.Split(content, "\n")
theme := getTheme()
summary := fmt.Sprintf("%d entries", len(entries))
return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(summary)
}
// renderBashCompact returns the first few lines of bash output as a compact
// summary. Shows up to 3 meaningful output lines.
func renderBashCompact(toolResult string, width int) string {
result := strings.TrimSpace(toolResult)
if result == "" {
return ""
}
lines := strings.Split(result, "\n")
// Filter to meaningful output lines (skip STDERR: label, keep exit codes separate)
var outputLines []string
var exitCode string
inStderr := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "STDERR:" {
inStderr = true
continue
}
if strings.HasPrefix(trimmed, "Exit code:") {
exitCode = trimmed
continue
}
if trimmed == "" {
continue
}
outputLines = append(outputLines, line)
_ = inStderr // stderr lines are included in output
}
if len(outputLines) == 0 {
if exitCode != "" {
theme := getTheme()
return lipgloss.NewStyle().Foreground(theme.Error).Render(exitCode)
}
return ""
}
const maxLines = 3
theme := getTheme()
display := outputLines
if len(display) > maxLines {
display = display[:maxLines]
}
// Truncate each line to available width
lineMax := max(width-4, 20)
for i, line := range display {
if len(line) > lineMax {
display[i] = line[:lineMax-3] + "..."
}
}
summary := strings.Join(display, "\n")
if len(outputLines) > maxLines {
summary += fmt.Sprintf("\n...(%d more lines)", len(outputLines)-maxLines)
}
if exitCode != "" {
summary += "\n" + lipgloss.NewStyle().Foreground(theme.Error).Render(exitCode)
}
return lipgloss.NewStyle().Foreground(theme.Muted).Render(summary)
}
+1 -2
View File
@@ -52,8 +52,7 @@ type FlatNode struct {
}
// TreeSelectorComponent is a Bubble Tea component that renders the session
// tree as an ASCII art list with navigation and selection. It follows pi's
// tree selector design.
// tree as an ASCII art list with navigation and selection.
type TreeSelectorComponent struct {
tm *session.TreeManager
flatNodes []FlatNode
+41 -3
View File
@@ -23,8 +23,8 @@ func (m *Kit) EstimateContextTokens() int {
}
// ShouldCompact reports whether the conversation is near the model's context
// limit and should be compacted. Uses Pi's formula:
// contextTokens > contextWindow reserveTokens.
// limit and should be compacted.
// Formula: contextTokens > contextWindow reserveTokens.
// Returns false if the model's context limit is unknown.
func (m *Kit) ShouldCompact() bool {
info := m.GetModelInfo()
@@ -43,9 +43,23 @@ func (m *Kit) ShouldCompact() bool {
// GetContextStats returns current context usage statistics including
// estimated token count, context limit, usage percentage, and message count.
//
// When API-reported token counts are available (after at least one turn),
// EstimatedTokens uses the real input token count from the most recent API
// response. This is significantly more accurate than the text-based heuristic
// because it includes system prompts, tool definitions, and other overhead
// that the heuristic cannot account for.
func (m *Kit) GetContextStats() ContextStats {
messages := m.treeSession.GetFantasyMessages()
estimated := compaction.EstimateMessageTokens(messages)
// Prefer the real API-reported input token count when available.
m.lastInputTokensMu.RLock()
estimated := m.lastInputTokens
m.lastInputTokensMu.RUnlock()
if estimated == 0 {
// Fall back to heuristic before first turn completes.
estimated = compaction.EstimateMessageTokens(messages)
}
stats := ContextStats{
EstimatedTokens: estimated,
@@ -72,6 +86,12 @@ func (m *Kit) GetContextStats() ContextStats {
// After compaction, the tree session is cleared and replaced with the
// compacted messages (summary + preserved recent messages).
func (m *Kit) Compact(ctx context.Context, opts *CompactionOptions, customInstructions string) (*CompactionResult, error) {
return m.compactInternal(ctx, opts, customInstructions, false)
}
// compactInternal is the shared compaction implementation. The isAutomatic
// flag distinguishes auto-triggered compaction from manual /compact.
func (m *Kit) compactInternal(ctx context.Context, opts *CompactionOptions, customInstructions string, isAutomatic bool) (*CompactionResult, error) {
if opts == nil {
if m.compactionOpts != nil {
opts = m.compactionOpts
@@ -92,6 +112,24 @@ func (m *Kit) Compact(ctx context.Context, opts *CompactionOptions, customInstru
return nil, fmt.Errorf("cannot compact: need at least 2 messages")
}
// Run before-compact hook — extensions can cancel compaction.
if m.beforeCompact.hasHooks() {
stats := m.GetContextStats()
if hookResult := m.beforeCompact.run(BeforeCompactHook{
EstimatedTokens: stats.EstimatedTokens,
ContextLimit: stats.ContextLimit,
UsagePercent: stats.UsagePercent,
MessageCount: stats.MessageCount,
IsAutomatic: isAutomatic,
}); hookResult != nil && hookResult.Cancel {
reason := hookResult.Reason
if reason == "" {
reason = "compaction cancelled by extension"
}
return nil, fmt.Errorf("%s", reason)
}
}
model := m.agent.GetModel()
result, newMessages, err := compaction.Compact(ctx, model, messages, *opts, customInstructions)
if err != nil {
+1 -1
View File
@@ -11,7 +11,7 @@ import (
// defaultSystemPrompt is the built-in system prompt used when no custom
// prompt is configured. It describes the available core tools and provides
// usage guidelines, matching the Pi SDK's default prompt style.
// usage guidelines.
const defaultSystemPrompt = `You are an expert coding assistant operating inside kit, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.
Available tools:
+84 -1
View File
@@ -1,6 +1,11 @@
package kit
import "github.com/mark3labs/kit/internal/extensions"
import (
"strings"
"charm.land/fantasy"
"github.com/mark3labs/kit/internal/extensions"
)
// bridgeExtensions registers extension event handlers as SDK hooks and
// subscribes to SDK observation events to forward them to the extension runner.
@@ -97,4 +102,82 @@ func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
}
})
}
// --- Context filtering hook ---
// Extension ContextPrepare → SDK ContextPrepare hook.
if runner.HasHandlers(extensions.ContextPrepare) {
m.OnContextPrepare(HookPriorityNormal, func(h ContextPrepareHook) *ContextPrepareResult {
// Convert fantasy.Message slice to extension ContextMessage slice.
extMsgs := make([]extensions.ContextMessage, len(h.Messages))
for i, msg := range h.Messages {
// Extract text from content parts.
var text strings.Builder
for _, part := range msg.Content {
if tp, ok := part.(fantasy.TextPart); ok {
text.WriteString(tp.Text)
}
}
extMsgs[i] = extensions.ContextMessage{
Index: i,
Role: string(msg.Role),
Content: text.String(),
}
}
result, _ := runner.Emit(extensions.ContextPrepareEvent{Messages: extMsgs})
r, ok := result.(extensions.ContextPrepareResult)
if !ok || r.Messages == nil {
return nil
}
// Rebuild fantasy.Message slice from extension result.
rebuilt := make([]fantasy.Message, 0, len(r.Messages))
for _, cm := range r.Messages {
if cm.Index >= 0 && cm.Index < len(h.Messages) {
// Reuse original message (preserves tool calls, reasoning, etc.)
rebuilt = append(rebuilt, h.Messages[cm.Index])
} else {
// New message injected by extension.
role := fantasy.MessageRoleUser
switch cm.Role {
case "assistant":
role = fantasy.MessageRoleAssistant
case "system":
role = fantasy.MessageRoleSystem
case "tool":
role = fantasy.MessageRoleTool
}
rebuilt = append(rebuilt, fantasy.Message{
Role: role,
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: cm.Content},
},
})
}
}
return &ContextPrepareResult{Messages: rebuilt}
})
}
// --- Compaction hook ---
// Extension BeforeCompact → SDK BeforeCompact hook.
if runner.HasHandlers(extensions.BeforeCompact) {
m.OnBeforeCompact(HookPriorityNormal, func(h BeforeCompactHook) *BeforeCompactResult {
result, _ := runner.Emit(extensions.BeforeCompactEvent{
EstimatedTokens: h.EstimatedTokens,
ContextLimit: h.ContextLimit,
UsagePercent: h.UsagePercent,
MessageCount: h.MessageCount,
IsAutomatic: h.IsAutomatic,
})
if r, ok := result.(extensions.BeforeCompactResult); ok && r.Cancel {
return &BeforeCompactResult{
Cancel: true,
Reason: r.Reason,
}
}
return nil
})
}
}
+54
View File
@@ -76,6 +76,43 @@ type AfterTurnHook struct {
// AfterTurnResult is a placeholder — after-turn hooks are observation-only.
type AfterTurnResult struct{}
// ContextPrepareHook is the input for hooks that fire after the context window
// is assembled from the session tree (including compaction) and before the
// messages are sent to the LLM. Hooks can filter, reorder, or inject messages.
type ContextPrepareHook struct {
// Messages is the current context as fantasy.Message objects.
Messages []fantasy.Message
}
// ContextPrepareResult can replace the context window.
type ContextPrepareResult struct {
// Messages replaces the entire context window. If nil, the original
// messages are used.
Messages []fantasy.Message
}
// BeforeCompactHook is the input for hooks that fire before compaction runs.
type BeforeCompactHook struct {
// EstimatedTokens is the estimated token count of the conversation.
EstimatedTokens int
// ContextLimit is the model's context window size in tokens.
ContextLimit int
// UsagePercent is the fraction of context used (0.01.0).
UsagePercent float64
// MessageCount is the number of messages in the conversation.
MessageCount int
// IsAutomatic is true when compaction was triggered automatically.
IsAutomatic bool
}
// BeforeCompactResult controls whether compaction proceeds.
type BeforeCompactResult struct {
// Cancel, when true, prevents compaction from proceeding.
Cancel bool
// Reason is a human-readable explanation when Cancel is true.
Reason string
}
// ---------------------------------------------------------------------------
// Generic hook registry with priority ordering
// ---------------------------------------------------------------------------
@@ -181,6 +218,23 @@ func (m *Kit) OnAfterTurn(p HookPriority, h func(AfterTurnHook)) func() {
})
}
// OnContextPrepare registers a hook that fires after the context window is
// built from the session tree and before messages are sent to the LLM. Return
// a non-nil ContextPrepareResult with Messages to replace the entire context.
// Hooks execute in priority order; the first non-nil result wins.
// Returns an unregister function.
func (m *Kit) OnContextPrepare(p HookPriority, h func(ContextPrepareHook) *ContextPrepareResult) func() {
return m.contextPrepare.register(p, h)
}
// OnBeforeCompact registers a hook that fires before context compaction runs.
// Return a non-nil BeforeCompactResult with Cancel=true to prevent compaction.
// Hooks execute in priority order; the first non-nil result wins.
// Returns an unregister function.
func (m *Kit) OnBeforeCompact(p HookPriority, h func(BeforeCompactHook) *BeforeCompactResult) func() {
return m.beforeCompact.register(p, h)
}
// ---------------------------------------------------------------------------
// Tool wrapping via hooks
// ---------------------------------------------------------------------------
+520 -8
View File
@@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"time"
"charm.land/fantasy"
@@ -14,6 +15,8 @@ import (
"github.com/mark3labs/kit/internal/config"
"github.com/mark3labs/kit/internal/extensions"
"github.com/mark3labs/kit/internal/kitsetup"
"github.com/mark3labs/kit/internal/message"
"github.com/mark3labs/kit/internal/models"
"github.com/mark3labs/kit/internal/session"
"github.com/mark3labs/kit/internal/skills"
"github.com/mark3labs/kit/internal/tools"
@@ -48,6 +51,15 @@ type Kit struct {
afterToolResult *hookRegistry[AfterToolResultHook, AfterToolResultResult]
beforeTurn *hookRegistry[BeforeTurnHook, BeforeTurnResult]
afterTurn *hookRegistry[AfterTurnHook, AfterTurnResult]
contextPrepare *hookRegistry[ContextPrepareHook, ContextPrepareResult]
beforeCompact *hookRegistry[BeforeCompactHook, BeforeCompactResult]
// lastInputTokens stores the API-reported input token count from the
// most recent turn. Used by GetContextStats() to return accurate usage
// instead of the text-based heuristic which misses system prompts,
// tool definitions, etc.
lastInputTokensMu sync.RWMutex
lastInputTokens int
}
// Subscribe registers an EventListener that will be called for every lifecycle
@@ -136,6 +148,17 @@ func (m *Kit) GetExtensionContext() extensions.Context {
return extensions.Context{}
}
// UpdateExtensionContextModel updates the Model field on the extension
// context so subsequent event handlers see the new model. This is a
// targeted update that avoids replacing the entire Context struct.
func (m *Kit) UpdateExtensionContextModel(model string) {
if m.extRunner != nil {
ctx := m.extRunner.GetContext()
ctx.Model = model
m.extRunner.SetContext(ctx)
}
}
// EmitSessionStart fires the SessionStart event for extensions.
// No-op if extensions are disabled or no handlers are registered.
func (m *Kit) EmitSessionStart() {
@@ -263,6 +286,472 @@ func (m *Kit) GetExtensionEditor() *extensions.EditorConfig {
return m.extRunner.GetEditor()
}
// SetExtensionUIVisibility stores extension-provided UI visibility overrides.
// No-op if extensions are disabled.
func (m *Kit) SetExtensionUIVisibility(v extensions.UIVisibility) {
if m.extRunner != nil {
m.extRunner.SetUIVisibility(v)
}
}
// GetExtensionUIVisibility returns extension-provided UI visibility overrides,
// or nil if none have been set. Returns nil if extensions are disabled.
func (m *Kit) GetExtensionUIVisibility() *extensions.UIVisibility {
if m.extRunner == nil {
return nil
}
return m.extRunner.GetUIVisibility()
}
// GetSessionMessages returns the conversation messages on the current branch
// as extension-facing SessionMessage structs, ordered root to leaf.
func (m *Kit) GetSessionMessages() []extensions.SessionMessage {
if m.treeSession == nil {
return nil
}
branch := m.treeSession.GetBranch("")
var msgs []extensions.SessionMessage
for _, entry := range branch {
me, ok := entry.(*session.MessageEntry)
if !ok {
continue
}
msg, err := me.ToMessage()
if err != nil {
continue
}
// Flatten content parts into a single text string.
var content strings.Builder
for _, p := range msg.Parts {
switch pt := p.(type) {
case message.TextContent:
content.WriteString(pt.Text)
case message.ReasoningContent:
content.WriteString(pt.Thinking)
case message.ToolCall:
fmt.Fprintf(&content, "[tool_call: %s(%s)]", pt.Name, pt.Input)
case message.ToolResult:
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.String(),
Model: msg.Model,
Provider: msg.Provider,
Timestamp: me.Timestamp.Format("2006-01-02T15:04:05Z07:00"),
})
}
return msgs
}
// GetSessionFilePath returns the JSONL file path of the current session.
func (m *Kit) GetSessionFilePath() string {
if m.treeSession == nil {
return ""
}
return m.treeSession.GetFilePath()
}
// AppendExtensionEntry persists custom extension data in the session tree.
func (m *Kit) AppendExtensionEntry(extType, data string) (string, error) {
if m.treeSession == nil {
return "", fmt.Errorf("no session available")
}
return m.treeSession.AppendExtensionData(extType, data)
}
// GetExtensionEntries retrieves persisted extension data entries for a type.
func (m *Kit) GetExtensionEntries(extType string) []extensions.ExtensionEntry {
if m.treeSession == nil {
return nil
}
entries := m.treeSession.GetExtensionData(extType)
result := make([]extensions.ExtensionEntry, 0, len(entries))
for _, e := range entries {
result = append(result, extensions.ExtensionEntry{
ID: e.ID,
EntryType: e.ExtType,
Data: e.Data,
Timestamp: e.Timestamp.Format("2006-01-02T15:04:05Z07:00"),
})
}
return result
}
// SetExtensionStatus places or updates a keyed status bar entry.
func (m *Kit) SetExtensionStatus(entry extensions.StatusBarEntry) {
if m.extRunner != nil {
m.extRunner.SetStatusEntry(entry)
}
}
// RemoveExtensionStatus removes a keyed status bar entry.
func (m *Kit) RemoveExtensionStatus(key string) {
if m.extRunner != nil {
m.extRunner.RemoveStatusEntry(key)
}
}
// GetExtensionStatusEntries returns all extension status bar entries sorted by priority.
func (m *Kit) GetExtensionStatusEntries() []extensions.StatusBarEntry {
if m.extRunner == nil {
return nil
}
return m.extRunner.GetStatusEntries()
}
// GetExtensionShortcuts returns a map of key bindings to handler functions
// from all loaded extensions. Returns nil if no shortcuts are registered or
// extensions are disabled. Handlers are closures that capture the runner's
// current context, so they can call Print/SetStatus/etc.
func (m *Kit) GetExtensionShortcuts() map[string]func() {
if m.extRunner == nil {
return nil
}
entries := m.extRunner.GetShortcuts()
if entries == nil {
return nil
}
result := make(map[string]func(), len(entries))
for key, entry := range entries {
h := entry.Handler
r := m.extRunner
result[key] = func() {
ctx := r.GetContext()
h(ctx)
}
}
return result
}
// GetExtensionToolInfos returns information about all tools available to the
// agent, including enabled/disabled status from SetActiveTools. Each tool is
// categorized by source: "core", "mcp", or "extension".
func (m *Kit) GetExtensionToolInfos() []extensions.ToolInfo {
agentTools := m.agent.GetTools()
coreCount := m.agent.GetCoreToolCount()
mcpCount := m.agent.GetMCPToolCount()
result := make([]extensions.ToolInfo, 0, len(agentTools))
for i, t := range agentTools {
info := t.Info()
source := "core"
if i >= coreCount && i < coreCount+mcpCount {
source = "mcp"
} else if i >= coreCount+mcpCount {
source = "extension"
}
enabled := true
if m.extRunner != nil && m.extRunner.IsToolDisabled(info.Name) {
enabled = false
}
result = append(result, extensions.ToolInfo{
Name: info.Name,
Description: info.Description,
Source: source,
Enabled: enabled,
})
}
return result
}
// SetExtensionActiveTools restricts the tool set to the named tools. All
// other tools are blocked from execution. Pass nil to re-enable all tools.
// No-op if extensions are disabled.
func (m *Kit) SetExtensionActiveTools(names []string) {
if m.extRunner != nil {
m.extRunner.SetActiveTools(names)
}
}
// SetModel changes the active model at runtime. The existing tools, system
// prompt, and session are preserved. The model string should be in
// "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.
func (m *Kit) SetModel(ctx context.Context, modelString string) error {
// Validate the model string first.
if _, _, err := ParseModelString(modelString); err != nil {
return err
}
// Build a provider config from current settings, overriding the model.
config := &models.ProviderConfig{
ModelString: modelString,
ProviderAPIKey: viper.GetString("provider-api-key"),
ProviderURL: viper.GetString("provider-url"),
MaxTokens: viper.GetInt("max-tokens"),
TLSSkipVerify: viper.GetBool("tls-skip-verify"),
}
temperature := float32(viper.GetFloat64("temperature"))
config.Temperature = &temperature
topP := float32(viper.GetFloat64("top-p"))
config.TopP = &topP
topK := int32(viper.GetInt("top-k"))
config.TopK = &topK
if err := m.agent.SetModel(ctx, config); err != nil {
return err
}
m.modelString = modelString
// Update extension context's Model field.
if m.extRunner != nil {
extCtx := m.extRunner.GetContext()
extCtx.Model = modelString
m.extRunner.SetContext(extCtx)
}
return nil
}
// GetAvailableModels returns a list of known models from the registry. Each
// entry includes provider, model ID, context limit, and whether the model
// supports reasoning. This is an advisory list — models not in the registry
// can still be used by specifying their provider/model string.
func (m *Kit) GetAvailableModels() []extensions.ModelInfoEntry {
registry := models.GetGlobalRegistry()
var result []extensions.ModelInfoEntry
for _, providerID := range registry.GetFantasyProviders() {
modelsMap, err := registry.GetModelsForProvider(providerID)
if err != nil {
continue
}
for modelID, info := range modelsMap {
result = append(result, extensions.ModelInfoEntry{
Provider: providerID,
ModelID: modelID,
Name: info.Name,
ContextLimit: info.Limit.Context,
OutputLimit: info.Limit.Output,
Reasoning: info.Reasoning,
})
}
}
return result
}
// GetExtensionOption resolves a named extension option value.
func (m *Kit) GetExtensionOption(name string) string {
if m.extRunner == nil {
return ""
}
return m.extRunner.GetOption(name)
}
// SetExtensionOption stores a runtime override for a named extension option.
func (m *Kit) SetExtensionOption(name, value string) {
if m.extRunner != nil {
m.extRunner.SetOption(name, value)
}
}
// EmitModelChange fires the ModelChange event for extensions.
// No-op if extensions are disabled or no handlers are registered.
func (m *Kit) EmitModelChange(newModel, previousModel, source string) {
if m.extRunner != nil && m.extRunner.HasHandlers(extensions.ModelChange) {
_, _ = m.extRunner.Emit(extensions.ModelChangeEvent{
NewModel: newModel,
PreviousModel: previousModel,
Source: source,
})
}
}
// EmitExtensionCustomEvent dispatches a named event to all extension handlers.
// No-op if extensions are disabled.
func (m *Kit) EmitExtensionCustomEvent(name, data string) {
if m.extRunner != nil {
m.extRunner.EmitCustomEvent(name, data)
}
}
// GetExtensionMessageRenderer returns the named message renderer, or nil
// if no extension registered a renderer with that name.
func (m *Kit) GetExtensionMessageRenderer(name string) *extensions.MessageRendererConfig {
if m.extRunner == nil {
return nil
}
return m.extRunner.GetMessageRenderer(name)
}
// ReloadExtensions hot-reloads all extensions from disk. Event handlers,
// commands, renderers, and shortcuts update immediately. Extension-defined
// tools are NOT updated (they are baked into the agent at creation time).
func (m *Kit) ReloadExtensions() error {
if m.extRunner == nil {
return fmt.Errorf("no extensions loaded")
}
// Emit shutdown to old extensions.
if m.extRunner.HasHandlers(extensions.SessionShutdown) {
_, _ = m.extRunner.Emit(extensions.SessionShutdownEvent{})
}
// Re-load from disk.
extraPaths := viper.GetStringSlice("extension")
loaded, err := extensions.LoadExtensions(extraPaths)
if err != nil {
return fmt.Errorf("reloading extensions: %w", err)
}
// Swap extensions on the runner (clears dynamic state).
m.extRunner.Reload(loaded)
// Re-set context and emit SessionStart.
ctx := m.extRunner.GetContext()
m.extRunner.SetContext(ctx)
if m.extRunner.HasHandlers(extensions.SessionStart) {
_, _ = m.extRunner.Emit(extensions.SessionStartEvent{SessionID: ctx.SessionID})
}
return nil
}
// ExecuteCompletion makes a standalone LLM completion call for extensions.
// When req.Model is empty the current agent model is reused (no provider
// creation overhead). When req.Model is set a temporary provider is created,
// used, and closed.
func (m *Kit) ExecuteCompletion(ctx context.Context, req extensions.CompleteRequest) (extensions.CompleteResponse, error) {
var (
llmModel fantasy.LanguageModel
closer func()
usedModel string
)
if req.Model == "" {
// Reuse the active agent's model.
llmModel = m.agent.GetModel()
usedModel = m.modelString
closer = func() {} // nothing to clean up
} else {
// Create a temporary provider for the requested model.
config := &models.ProviderConfig{
ModelString: req.Model,
TLSSkipVerify: viper.GetBool("tls-skip-verify"),
}
if req.MaxTokens > 0 {
config.MaxTokens = req.MaxTokens
}
providerResult, err := models.CreateProvider(ctx, config)
if err != nil {
return extensions.CompleteResponse{}, fmt.Errorf("create provider for %q: %w", req.Model, err)
}
llmModel = providerResult.Model
usedModel = req.Model
closer = func() {
if providerResult.Closer != nil {
_ = providerResult.Closer.Close()
}
}
}
defer closer()
// Build fantasy agent options (no tools — just a simple completion).
var agentOpts []fantasy.AgentOption
if req.System != "" {
agentOpts = append(agentOpts, fantasy.WithSystemPrompt(req.System))
}
if req.MaxTokens > 0 {
agentOpts = append(agentOpts, fantasy.WithMaxOutputTokens(int64(req.MaxTokens)))
}
completionAgent := fantasy.NewAgent(llmModel, agentOpts...)
// Convert extension SessionMessage history to fantasy.Message slice.
var messages []fantasy.Message
for _, sm := range req.Messages {
messages = append(messages, fantasy.Message{
Role: fantasy.MessageRole(sm.Role),
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: sm.Content},
},
})
}
// Streaming path.
if req.OnChunk != nil {
result, err := completionAgent.Stream(ctx, fantasy.AgentStreamCall{
Prompt: req.Prompt,
Messages: messages,
OnTextDelta: func(_, text string) error {
req.OnChunk(text)
return nil
},
})
if err != nil {
return extensions.CompleteResponse{}, fmt.Errorf("streaming completion: %w", err)
}
return extensions.CompleteResponse{
Text: result.Response.Content.Text(),
InputTokens: int(result.Response.Usage.InputTokens),
OutputTokens: int(result.Response.Usage.OutputTokens),
Model: usedModel,
}, nil
}
// Non-streaming path.
result, err := completionAgent.Generate(ctx, fantasy.AgentCall{
Prompt: req.Prompt,
Messages: messages,
})
if err != nil {
return extensions.CompleteResponse{}, fmt.Errorf("completion: %w", err)
}
return extensions.CompleteResponse{
Text: result.Response.Content.Text(),
InputTokens: int(result.Response.Usage.InputTokens),
OutputTokens: int(result.Response.Usage.OutputTokens),
Model: usedModel,
}, nil
}
// EmitBeforeFork emits a BeforeFork event to extensions and returns
// whether the fork was cancelled and the reason. No-op if extensions are
// disabled (returns false, "").
func (m *Kit) EmitBeforeFork(targetID string, isUserMsg bool, userText string) (cancelled bool, reason string) {
if m.extRunner == nil || !m.extRunner.HasHandlers(extensions.BeforeFork) {
return false, ""
}
result, _ := m.extRunner.Emit(extensions.BeforeForkEvent{
TargetID: targetID,
IsUserMessage: isUserMsg,
UserText: userText,
})
if r, ok := result.(extensions.BeforeForkResult); ok && r.Cancel {
reason := r.Reason
if reason == "" {
reason = "Fork cancelled by extension."
}
return true, reason
}
return false, ""
}
// EmitBeforeSessionSwitch emits a BeforeSessionSwitch event to extensions
// and returns whether the switch was cancelled and the reason. No-op if
// extensions are disabled (returns false, "").
func (m *Kit) EmitBeforeSessionSwitch(switchReason string) (cancelled bool, reason string) {
if m.extRunner == nil || !m.extRunner.HasHandlers(extensions.BeforeSessionSwitch) {
return false, ""
}
result, _ := m.extRunner.Emit(extensions.BeforeSessionSwitchEvent{
Reason: switchReason,
})
if r, ok := result.(extensions.BeforeSessionSwitchResult); ok && r.Cancel {
reason := r.Reason
if reason == "" {
reason = "Session switch cancelled by extension."
}
return true, reason
}
return false, ""
}
// HasExtensions returns true if the extension runner is configured and active.
func (m *Kit) HasExtensions() bool {
return m.extRunner != nil
@@ -401,8 +890,7 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
}
// Always compose the system prompt with runtime context: base prompt +
// AGENTS.md context + skills metadata + date/cwd. This matches Pi's
// buildSystemPrompt() convention.
// AGENTS.md context + skills metadata + date/cwd.
{
basePrompt := viper.GetString("system-prompt")
pb := skills.NewPromptBuilder(basePrompt)
@@ -445,6 +933,8 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
afterToolResult := newHookRegistry[AfterToolResultHook, AfterToolResultResult]()
beforeTurn := newHookRegistry[BeforeTurnHook, BeforeTurnResult]()
afterTurn := newHookRegistry[AfterTurnHook, AfterTurnResult]()
contextPrepare := newHookRegistry[ContextPrepareHook, ContextPrepareResult]()
beforeCompact := newHookRegistry[BeforeCompactHook, BeforeCompactResult]()
// Build agent setup options, pulling CLI-specific fields when available.
setupOpts := kitsetup.AgentSetupOptions{
@@ -488,6 +978,8 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
afterToolResult: afterToolResult,
beforeTurn: beforeTurn,
afterTurn: afterTurn,
contextPrepare: contextPrepare,
beforeCompact: beforeCompact,
}
// Bridge extension events to SDK hooks.
@@ -535,7 +1027,7 @@ func loadContextFiles(cwd string) []*ContextFile {
// so, re-reads the skill file, strips its YAML frontmatter, wraps the body in
// a <skill> block with baseDir metadata, and appends any trailing user args.
// Returns the original text unchanged when the prefix is absent or the skill is
// not found. This matches Pi's _expandSkillCommand() convention.
// not found.
func (m *Kit) expandSkillCommand(prompt string) string {
if !strings.HasPrefix(prompt, "/skill:") {
return prompt
@@ -763,11 +1255,19 @@ func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, pr
// Auto-compact if enabled and conversation is near the context limit.
if m.autoCompact && m.ShouldCompact() {
_, _ = m.Compact(ctx, m.compactionOpts, "") // best-effort
_, _ = m.compactInternal(ctx, m.compactionOpts, "", true) // best-effort, automatic
}
// Build context from the tree so only the current branch is sent.
messages := m.treeSession.GetFantasyMessages()
// Run ContextPrepare hooks — extensions can filter, reorder, or inject messages.
if m.contextPrepare.hasHooks() {
if hookResult := m.contextPrepare.run(ContextPrepareHook{Messages: messages}); hookResult != nil && hookResult.Messages != nil {
messages = hookResult.Messages
}
}
sentCount := len(messages)
m.events.emit(TurnStartEvent{Prompt: promptLabel})
@@ -785,16 +1285,28 @@ func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, pr
responseText := result.FinalResponse.Content.Text()
m.events.emit(MessageEndEvent{Content: responseText})
m.events.emit(TurnEndEvent{Response: responseText})
// Persist new messages (tool calls, tool results, assistant response).
// Persist new messages (tool calls, tool results, assistant response)
// BEFORE emitting events so that extension handlers calling
// GetContextStats() see up-to-date token counts.
if len(result.ConversationMessages) > sentCount {
for _, msg := range result.ConversationMessages[sentCount:] {
_, _ = m.treeSession.AppendFantasyMessage(msg)
}
}
// Store the API-reported token count so GetContextStats() matches the
// built-in status bar (which uses input + output tokens). The
// text-based heuristic misses system prompts, tool definitions, etc.
if result.FinalResponse != nil {
u := result.FinalResponse.Usage
m.lastInputTokensMu.Lock()
m.lastInputTokens = int(u.InputTokens) + int(u.OutputTokens)
m.lastInputTokensMu.Unlock()
}
m.events.emit(MessageEndEvent{Content: responseText})
m.events.emit(TurnEndEvent{Response: responseText})
// Run AfterTurn hooks.
if m.afterTurn.hasHooks() {
m.afterTurn.run(AfterTurnHook{Response: responseText})