mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
49f8b485be
* feat(extensions): add OnLLMUsage, SetState, enriched AgentEndEvent (#53) Three additive primitives to the extension API: - OnLLMUsage event: per-LLM-call token + cost deltas attributed to the specific model/provider used for each round-trip. Derived from the SDK StepFinishEvent in the extension bridge. Enables accurate budget enforcement between calls instead of only at turn boundaries. - ctx.SetState / GetState / DeleteState / ListState: session-scoped, last-write-wins key-value store backed by a sidecar file (<session>.ext-state.json) outside the conversation tree. Reads are O(1), writes don't grow the JSONL, and the store is not duplicated on fork. State is preserved across hot-reloads. - Enriched AgentEndEvent: ToolCallCount, ToolNames, LLMCallCount, token deltas (input/output/cache-read/cache-write), CostDelta, and DurationMs populated by a per-turn aggregator. Existing handlers reading only Response/StopReason are unaffected. Includes unit tests for the state store, LLMUsage registration, enriched AgentEndEvent, turn aggregator, llmUsageMeta, and sidecar path derivation. Adds examples/extensions/usage-budget.go demoing all three primitives together. Documents the additions in README, the docs site (extensions overview, capabilities, examples), and the kit-extensions and kit-sdk skill guides. Fixes #53 * fix(extensions): address review feedback on state store and llmUsageMeta - Serialize SetState/DeleteState saver invocations through a new saverMu so overlapping atomic-rename writes can no longer race on the shared .tmp file and persist an older snapshot after a newer one. - LoadStateFromFile now clears the in-memory store when the sidecar is missing or empty, matching the documented "replace … with its contents" contract. This makes session-switching safe by preventing keys from a prior session leaking into a new one. Tests updated to cover both the missing-file and empty-file cases. - llmUsageMeta now detects Anthropic OAuth credentials and returns Cost=0, matching the comment and the existing usage_tracker behavior for OAuth users. Mirrors the OAuth detection already used in cmd/extension_context.go. - Document the single-in-flight-turn assumption baked into the per-turn aggregator with a clear migration path (per-turn ID) for if concurrent turns ever become a supported use case. * fix(extensions): release saverMu on panic in state store Extract a runSaver helper that locks saverMu and defers Unlock before invoking the persistence callback. Without the deferred Unlock, a panic inside the saver (e.g. disk full mid-write) would leave saverMu held forever and deadlock the next SetState/DeleteState. Both SetState and DeleteState now route through the helper. New TestRunner_State_Saver PanicReleasesSaverMu reproduces the deadlock window with a 2s deadline and proves the mutex is released after a panic.
1651 lines
60 KiB
Go
1651 lines
60 KiB
Go
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"image/color"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
"github.com/mark3labs/kit/internal/app"
|
|
"github.com/mark3labs/kit/internal/auth"
|
|
"github.com/mark3labs/kit/internal/config"
|
|
"github.com/mark3labs/kit/internal/extensions"
|
|
"github.com/mark3labs/kit/internal/models"
|
|
"github.com/mark3labs/kit/internal/prompts"
|
|
"github.com/mark3labs/kit/internal/ui"
|
|
"github.com/mark3labs/kit/internal/ui/commands"
|
|
"github.com/mark3labs/kit/internal/ui/progress"
|
|
"github.com/mark3labs/kit/internal/watcher"
|
|
kit "github.com/mark3labs/kit/pkg/kit"
|
|
"github.com/spf13/cobra"
|
|
"github.com/spf13/viper"
|
|
"golang.org/x/term"
|
|
)
|
|
|
|
var (
|
|
configFile string
|
|
systemPromptFile string
|
|
modelFlag string
|
|
providerURL string
|
|
providerAPIKey string
|
|
debugMode bool
|
|
positionalPrompt string // set by processPositionalArgs from CLI positional args
|
|
positionalFiles []ui.FilePart // binary @file parts from processPositionalArgs
|
|
|
|
// MCP resource callbacks, set in runNormalMode, consumed by runInteractiveModeBubbleTea.
|
|
mcpGetResources func() []ui.FileSuggestion
|
|
mcpResourceReader ui.MCPResourceReader
|
|
quietFlag bool
|
|
jsonFlag bool
|
|
noExitFlag bool
|
|
maxSteps int
|
|
streamFlag bool // Enable streaming output
|
|
autoCompactFlag bool // Enable auto-compaction near context limit
|
|
|
|
// Session management
|
|
sessionPath string
|
|
|
|
// Tree session management (pi-style)
|
|
continueFlag bool // --continue / -c: resume most recent session for cwd
|
|
resumeFlag bool // --resume / -r: interactive session picker
|
|
noSessionFlag bool // --no-session: ephemeral mode, no persistence
|
|
|
|
// Model generation parameters
|
|
maxTokens int
|
|
temperature float32
|
|
topP float32
|
|
topK int32
|
|
frequencyPenalty float32
|
|
presencePenalty float32
|
|
stopSequences []string
|
|
thinkingLevel string
|
|
|
|
// Ollama-specific parameters
|
|
numGPU int32
|
|
mainGPU int32
|
|
|
|
// Extensions control
|
|
noExtensionsFlag bool
|
|
noCoreToolsFlag bool
|
|
extensionPaths []string
|
|
|
|
// TLS configuration
|
|
tlsSkipVerify bool
|
|
|
|
// Prompt templates
|
|
promptTemplatePaths []string
|
|
noPromptTemplates bool
|
|
|
|
// Preference restoration flags — set in RunE after cobra parses, used
|
|
// in runNormalMode to decide whether to apply saved preferences.
|
|
modelFlagChanged bool
|
|
thinkingFlagChanged bool
|
|
)
|
|
|
|
// kitUIAdapter adapts *kit.Kit to ui.AgentInterface so the CLI setup layer
|
|
// can display tool/server metadata without importing internal types.
|
|
type kitUIAdapter struct {
|
|
kit *kit.Kit
|
|
}
|
|
|
|
func (a *kitUIAdapter) GetLoadingMessage() string {
|
|
return a.kit.GetLoadingMessage()
|
|
}
|
|
|
|
func (a *kitUIAdapter) GetTools() []any {
|
|
names := a.kit.GetToolNames()
|
|
result := make([]any, len(names))
|
|
for i, name := range names {
|
|
result[i] = name
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (a *kitUIAdapter) GetLoadedServerNames() []string {
|
|
return a.kit.GetLoadedServerNames()
|
|
}
|
|
|
|
func (a *kitUIAdapter) GetMCPToolCount() int {
|
|
return a.kit.GetMCPToolCount()
|
|
}
|
|
|
|
func (a *kitUIAdapter) GetExtensionToolCount() int {
|
|
return a.kit.GetExtensionToolCount()
|
|
}
|
|
|
|
// rootCmd represents the base command when called without any subcommands.
|
|
// This is the main entry point for the KIT CLI application, providing
|
|
// 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 [@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)
|
|
}
|
|
// Record whether --model / --thinking-level were explicitly set by the
|
|
// user so that runNormalMode can fall back to saved preferences when
|
|
// they weren't. Must be captured here (after cobra parses) and before
|
|
// runKit because rootCmd can't be referenced inside runNormalMode
|
|
// without creating an initialization cycle.
|
|
if f := cmd.PersistentFlags().Lookup("model"); f != nil {
|
|
modelFlagChanged = f.Changed
|
|
}
|
|
if f := cmd.PersistentFlags().Lookup("thinking-level"); f != nil {
|
|
thinkingFlagChanged = f.Changed
|
|
}
|
|
return runKit(context.Background())
|
|
},
|
|
}
|
|
|
|
// GetRootCommand returns the root command with the version set.
|
|
// This function is the main entry point for the KIT CLI and should be
|
|
// called from main.go with the appropriate version string.
|
|
func GetRootCommand(v string) *cobra.Command {
|
|
rootCmd.Version = v
|
|
return rootCmd
|
|
}
|
|
|
|
// InitConfig initializes the configuration for KIT by loading config files,
|
|
// environment variables. It delegates to the SDK's
|
|
// InitConfig, injecting the CLI-specific configFile flag and debug mode.
|
|
// This function is automatically called by cobra before command execution.
|
|
func InitConfig() {
|
|
if err := kit.InitConfig(configFile, debugMode); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
// Rebuild the model registry now that viper has the config loaded,
|
|
// so customModels defined in the config file are picked up.
|
|
models.ReloadGlobalRegistry()
|
|
}
|
|
|
|
// adaptiveOrDefault converts a config.AdaptiveColor to a resolved color.Color,
|
|
// falling back to fallback when both Light and Dark are empty.
|
|
func adaptiveOrDefault(ac config.AdaptiveColor, fallback color.Color) color.Color {
|
|
if ac.Light == "" && ac.Dark == "" {
|
|
return fallback
|
|
}
|
|
return ui.AdaptiveColor(ac.Light, ac.Dark)
|
|
}
|
|
|
|
func configToUiTheme(cfg config.Theme) ui.Theme {
|
|
def := ui.DefaultTheme()
|
|
return ui.Theme{
|
|
Primary: adaptiveOrDefault(cfg.Primary, def.Primary),
|
|
Secondary: adaptiveOrDefault(cfg.Secondary, def.Secondary),
|
|
Success: adaptiveOrDefault(cfg.Success, def.Success),
|
|
Warning: adaptiveOrDefault(cfg.Warning, def.Warning),
|
|
Error: adaptiveOrDefault(cfg.Error, def.Error),
|
|
Info: adaptiveOrDefault(cfg.Info, def.Info),
|
|
Text: adaptiveOrDefault(cfg.Text, def.Text),
|
|
Muted: adaptiveOrDefault(cfg.Muted, def.Muted),
|
|
VeryMuted: adaptiveOrDefault(cfg.VeryMuted, def.VeryMuted),
|
|
Background: adaptiveOrDefault(cfg.Background, def.Background),
|
|
Border: adaptiveOrDefault(cfg.Border, def.Border),
|
|
MutedBorder: adaptiveOrDefault(cfg.MutedBorder, def.MutedBorder),
|
|
System: adaptiveOrDefault(cfg.System, def.System),
|
|
Tool: adaptiveOrDefault(cfg.Tool, def.Tool),
|
|
Accent: adaptiveOrDefault(cfg.Accent, def.Accent),
|
|
Highlight: adaptiveOrDefault(cfg.Highlight, def.Highlight),
|
|
|
|
DiffInsertBg: adaptiveOrDefault(cfg.DiffInsertBg, def.DiffInsertBg),
|
|
DiffDeleteBg: adaptiveOrDefault(cfg.DiffDeleteBg, def.DiffDeleteBg),
|
|
DiffEqualBg: adaptiveOrDefault(cfg.DiffEqualBg, def.DiffEqualBg),
|
|
DiffMissingBg: adaptiveOrDefault(cfg.DiffMissingBg, def.DiffMissingBg),
|
|
|
|
CodeBg: adaptiveOrDefault(cfg.CodeBg, def.CodeBg),
|
|
GutterBg: adaptiveOrDefault(cfg.GutterBg, def.GutterBg),
|
|
WriteBg: adaptiveOrDefault(cfg.WriteBg, def.WriteBg),
|
|
|
|
Markdown: ui.MarkdownThemeColors{
|
|
Text: adaptiveOrDefault(cfg.Markdown.Text, def.Markdown.Text),
|
|
Muted: adaptiveOrDefault(cfg.Markdown.Muted, def.Markdown.Muted),
|
|
Heading: adaptiveOrDefault(cfg.Markdown.Heading, def.Markdown.Heading),
|
|
Emph: adaptiveOrDefault(cfg.Markdown.Emph, def.Markdown.Emph),
|
|
Strong: adaptiveOrDefault(cfg.Markdown.Strong, def.Markdown.Strong),
|
|
Link: adaptiveOrDefault(cfg.Markdown.Link, def.Markdown.Link),
|
|
Code: adaptiveOrDefault(cfg.Markdown.Code, def.Markdown.Code),
|
|
Error: adaptiveOrDefault(cfg.Markdown.Error, def.Markdown.Error),
|
|
Keyword: adaptiveOrDefault(cfg.Markdown.Keyword, def.Markdown.Keyword),
|
|
String: adaptiveOrDefault(cfg.Markdown.String, def.Markdown.String),
|
|
Number: adaptiveOrDefault(cfg.Markdown.Number, def.Markdown.Number),
|
|
Comment: adaptiveOrDefault(cfg.Markdown.Comment, def.Markdown.Comment),
|
|
},
|
|
}
|
|
}
|
|
|
|
// kitBanner returns the KIT ASCII art title with KITT scanner lights.
|
|
// Delegates to ui.KitBanner() which owns the logo rendering.
|
|
func kitBanner() string {
|
|
return ui.KitBanner()
|
|
}
|
|
|
|
func init() {
|
|
cobra.OnInitialize(InitConfig)
|
|
|
|
rootCmd.Long = kitBanner() + "\n\n" + rootCmd.Long
|
|
|
|
var theme config.Theme
|
|
err := config.FilepathOr("theme", &theme)
|
|
if err == nil && viper.InConfig("theme") {
|
|
uiTheme := configToUiTheme(theme)
|
|
ui.SetTheme(uiTheme)
|
|
} else if pref := ui.LoadThemePreference(); pref != "" {
|
|
// No explicit theme in config — fall back to persisted preference.
|
|
_ = ui.ApplyThemeWithoutSave(pref)
|
|
}
|
|
|
|
rootCmd.PersistentFlags().
|
|
StringVar(&configFile, "config", "", "config file (default is $HOME/.kit.yml)")
|
|
rootCmd.PersistentFlags().
|
|
StringVar(&systemPromptFile, "system-prompt", "", "system prompt text or path to text file")
|
|
|
|
rootCmd.PersistentFlags().
|
|
StringVarP(&modelFlag, "model", "m", "anthropic/claude-sonnet-4-5-20250929",
|
|
"model to use (format: provider/model)")
|
|
rootCmd.PersistentFlags().
|
|
BoolVar(&debugMode, "debug", false, "enable debug logging")
|
|
|
|
rootCmd.PersistentFlags().
|
|
BoolVar(&quietFlag, "quiet", false, "suppress all output (non-interactive mode only)")
|
|
rootCmd.PersistentFlags().
|
|
BoolVar(&jsonFlag, "json", false, "output response as JSON (non-interactive mode only)")
|
|
rootCmd.PersistentFlags().
|
|
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().
|
|
BoolVar(&streamFlag, "stream", true, "enable streaming output for faster response display")
|
|
rootCmd.PersistentFlags().
|
|
BoolVar(&autoCompactFlag, "auto-compact", false, "auto-compact conversation when near context limit")
|
|
rootCmd.PersistentFlags().
|
|
StringVarP(&sessionPath, "session", "s", "", "open a specific JSONL session file")
|
|
rootCmd.PersistentFlags().
|
|
BoolVarP(&continueFlag, "continue", "c", false, "continue the most recent session for the current directory")
|
|
rootCmd.PersistentFlags().
|
|
BoolVarP(&resumeFlag, "resume", "r", false, "interactive session picker")
|
|
rootCmd.PersistentFlags().
|
|
BoolVar(&noSessionFlag, "no-session", false, "ephemeral mode — no session persistence")
|
|
rootCmd.PersistentFlags().
|
|
BoolVar(&noExtensionsFlag, "no-extensions", false, "disable all extensions")
|
|
rootCmd.PersistentFlags().
|
|
BoolVar(&noCoreToolsFlag, "no-core-tools", false, "disable all built-in core tools (bash, read, write, edit, grep, find, ls, subagent)")
|
|
rootCmd.PersistentFlags().
|
|
StringSliceVarP(&extensionPaths, "extension", "e", nil, "load additional extension file(s)")
|
|
|
|
flags := rootCmd.PersistentFlags()
|
|
flags.StringVar(&providerURL, "provider-url", "", "base URL for the provider API (applies to OpenAI, Anthropic, Ollama, and Google)")
|
|
flags.StringVar(&providerAPIKey, "provider-api-key", "", "API key for the provider (applies to OpenAI, Anthropic, and Google)")
|
|
flags.BoolVar(&tlsSkipVerify, "tls-skip-verify", false, "skip TLS certificate verification (WARNING: insecure, use only for self-signed certificates)")
|
|
|
|
// Prompt template flags
|
|
flags.StringArrayVar(&promptTemplatePaths, "prompt-template", nil, "load prompt template file or directory (repeatable)")
|
|
flags.BoolVar(&noPromptTemplates, "no-prompt-templates", false, "disable prompt template discovery")
|
|
|
|
// Model generation parameters
|
|
flags.IntVar(&maxTokens, "max-tokens", 8192, "maximum number of output tokens per response (auto-raised up to 32768 for models with higher known output limits; see internal/models/embedded_models.json)")
|
|
flags.Float32Var(&temperature, "temperature", 0.7, "controls randomness in responses (0.0-1.0)")
|
|
flags.Float32Var(&topP, "top-p", 0.95, "controls diversity via nucleus sampling (0.0-1.0)")
|
|
flags.Int32Var(&topK, "top-k", 40, "controls diversity by limiting top K tokens to sample from")
|
|
flags.Float32Var(&frequencyPenalty, "frequency-penalty", 0.0, "penalizes tokens based on frequency of appearance (0.0-2.0)")
|
|
flags.Float32Var(&presencePenalty, "presence-penalty", 0.0, "penalizes tokens based on whether they have appeared (0.0-2.0)")
|
|
flags.StringSliceVar(&stopSequences, "stop-sequences", nil, "custom stop sequences (comma-separated)")
|
|
flags.StringVar(&thinkingLevel, "thinking-level", "off", "extended thinking level: off, none, minimal, low, medium, high")
|
|
|
|
// Ollama-specific parameters
|
|
flags.Int32Var(&numGPU, "num-gpu-layers", -1, "number of model layers to offload to GPU for Ollama models (-1 for auto-detect)")
|
|
_ = flags.MarkHidden("num-gpu-layers") // Advanced option, hidden from help
|
|
flags.Int32Var(&mainGPU, "main-gpu", 0, "main GPU device to use for Ollama models")
|
|
|
|
// Bind flags to viper for config file support
|
|
_ = 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("max-steps", rootCmd.PersistentFlags().Lookup("max-steps"))
|
|
_ = viper.BindPFlag("stream", rootCmd.PersistentFlags().Lookup("stream"))
|
|
_ = viper.BindPFlag("auto-compact", rootCmd.PersistentFlags().Lookup("auto-compact"))
|
|
|
|
_ = viper.BindPFlag("provider-url", rootCmd.PersistentFlags().Lookup("provider-url"))
|
|
_ = viper.BindPFlag("provider-api-key", rootCmd.PersistentFlags().Lookup("provider-api-key"))
|
|
_ = viper.BindPFlag("max-tokens", rootCmd.PersistentFlags().Lookup("max-tokens"))
|
|
_ = viper.BindPFlag("temperature", rootCmd.PersistentFlags().Lookup("temperature"))
|
|
_ = viper.BindPFlag("top-p", rootCmd.PersistentFlags().Lookup("top-p"))
|
|
_ = viper.BindPFlag("top-k", rootCmd.PersistentFlags().Lookup("top-k"))
|
|
_ = viper.BindPFlag("frequency-penalty", rootCmd.PersistentFlags().Lookup("frequency-penalty"))
|
|
_ = viper.BindPFlag("presence-penalty", rootCmd.PersistentFlags().Lookup("presence-penalty"))
|
|
_ = viper.BindPFlag("stop-sequences", rootCmd.PersistentFlags().Lookup("stop-sequences"))
|
|
_ = viper.BindPFlag("thinking-level", rootCmd.PersistentFlags().Lookup("thinking-level"))
|
|
_ = viper.BindPFlag("num-gpu-layers", rootCmd.PersistentFlags().Lookup("num-gpu-layers"))
|
|
_ = viper.BindPFlag("main-gpu", rootCmd.PersistentFlags().Lookup("main-gpu"))
|
|
_ = viper.BindPFlag("tls-skip-verify", rootCmd.PersistentFlags().Lookup("tls-skip-verify"))
|
|
_ = viper.BindPFlag("no-extensions", rootCmd.PersistentFlags().Lookup("no-extensions"))
|
|
_ = viper.BindPFlag("no-core-tools", rootCmd.PersistentFlags().Lookup("no-core-tools"))
|
|
_ = viper.BindPFlag("extension", rootCmd.PersistentFlags().Lookup("extension"))
|
|
_ = viper.BindPFlag("prompt-template", rootCmd.PersistentFlags().Lookup("prompt-template"))
|
|
_ = viper.BindPFlag("no-prompt-templates", rootCmd.PersistentFlags().Lookup("no-prompt-templates"))
|
|
|
|
// Defaults are already set in flag definitions, no need to duplicate in viper
|
|
|
|
// Add subcommands
|
|
rootCmd.AddCommand(authCmd)
|
|
}
|
|
|
|
// processPositionalArgs separates positional CLI arguments into @file
|
|
// attachments and prompt text. Text file content is read and prepended to
|
|
// positionalPrompt; binary files (images, audio) are stored in positionalFiles
|
|
// for multimodal submission. Positional args are the primary way to run
|
|
// non-interactive mode:
|
|
//
|
|
// kit "Explain this codebase"
|
|
// kit @code.ts @test.ts "Review these files"
|
|
// kit @screenshot.png "What's in this image?"
|
|
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.
|
|
// Text files are XML-wrapped inline; binary files become multimodal parts.
|
|
var fileContent strings.Builder
|
|
for _, token := range fileTokens {
|
|
result := ui.ProcessFileAttachments(token, cwd)
|
|
if result.ProcessedText != token {
|
|
// Text file was resolved — add it.
|
|
fileContent.WriteString(result.ProcessedText)
|
|
fileContent.WriteString("\n\n")
|
|
}
|
|
// Collect binary file parts for multimodal submission.
|
|
positionalFiles = append(positionalFiles, result.FileParts...)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// extensionCommandsForUI converts extension-registered CommandDefs into the
|
|
// commands.ExtensionCommand type used by the interactive TUI. Command names are
|
|
// normalised to start with "/" so they integrate with the slash-command
|
|
// autocomplete and dispatch pipeline.
|
|
func extensionCommandsForUI(k *kit.Kit) []commands.ExtensionCommand {
|
|
defs := k.Extensions().Commands()
|
|
if len(defs) == 0 {
|
|
return nil
|
|
}
|
|
cmds := make([]commands.ExtensionCommand, 0, len(defs))
|
|
for _, d := range defs {
|
|
name := d.Name
|
|
if len(name) > 0 && name[0] != '/' {
|
|
name = "/" + name
|
|
}
|
|
ec := commands.ExtensionCommand{
|
|
Name: name,
|
|
Description: d.Description,
|
|
Execute: func(args string) (string, error) {
|
|
return d.Execute(args, k.Extensions().GetContext())
|
|
},
|
|
}
|
|
if d.Complete != nil {
|
|
ec.Complete = func(prefix string) []string {
|
|
return d.Complete(prefix, k.Extensions().GetContext())
|
|
}
|
|
}
|
|
cmds = append(cmds, ec)
|
|
}
|
|
return cmds
|
|
}
|
|
|
|
// buildExtensionItems converts the loaded extensions into ui.ExtensionItem
|
|
// values for the startup [Extensions] section. The display name is the file
|
|
// basename without the .go suffix; for subdirectory extensions the parent
|
|
// directory name is used (matching `kit extensions list`).
|
|
func buildExtensionItems(k *kit.Kit, cwd string) []ui.ExtensionItem {
|
|
infos := k.Extensions().Loaded()
|
|
if len(infos) == 0 {
|
|
return nil
|
|
}
|
|
items := make([]ui.ExtensionItem, 0, len(infos))
|
|
for _, info := range infos {
|
|
name := filepath.Base(info.Path)
|
|
if name == "main.go" {
|
|
// Subdirectory extension: use the parent directory's name.
|
|
name = filepath.Base(filepath.Dir(info.Path))
|
|
}
|
|
name = strings.TrimSuffix(name, ".go")
|
|
source := "user"
|
|
if cwd != "" && strings.HasPrefix(info.Path, cwd) {
|
|
source = "project"
|
|
}
|
|
items = append(items, ui.ExtensionItem{
|
|
Name: name,
|
|
Path: info.Path,
|
|
Source: source,
|
|
})
|
|
}
|
|
return items
|
|
}
|
|
|
|
// widgetProviderForUI returns a function that converts extension widgets to
|
|
// ui.WidgetData for the given placement. Returns nil if extensions are
|
|
// disabled, which is safe — the UI treats a nil GetWidgets as "no widgets".
|
|
func widgetProviderForUI(k *kit.Kit) func(string) []ui.WidgetData {
|
|
if !k.Extensions().HasExtensions() {
|
|
return nil
|
|
}
|
|
return func(placement string) []ui.WidgetData {
|
|
configs := k.Extensions().GetWidgets(extensions.WidgetPlacement(placement))
|
|
if len(configs) == 0 {
|
|
return nil
|
|
}
|
|
widgets := make([]ui.WidgetData, len(configs))
|
|
for i, c := range configs {
|
|
widgets[i] = ui.WidgetData{
|
|
Text: c.Content.Text,
|
|
Markdown: c.Content.Markdown,
|
|
BorderColor: c.Style.BorderColor,
|
|
NoBorder: c.Style.NoBorder,
|
|
}
|
|
}
|
|
return widgets
|
|
}
|
|
}
|
|
|
|
// headerFooterProviderForUI returns a provider func that maps an
|
|
// extensions.HeaderFooterConfig getter into the ui.WidgetData shape
|
|
// expected by AppModel. The getter argument selects header vs footer.
|
|
func headerFooterProviderForUI(k *kit.Kit, getter func() *extensions.HeaderFooterConfig) func() *ui.WidgetData {
|
|
if !k.Extensions().HasExtensions() {
|
|
return nil
|
|
}
|
|
return func() *ui.WidgetData {
|
|
cfg := getter()
|
|
if cfg == nil {
|
|
return nil
|
|
}
|
|
return &ui.WidgetData{
|
|
Text: cfg.Content.Text,
|
|
Markdown: cfg.Content.Markdown,
|
|
BorderColor: cfg.Style.BorderColor,
|
|
NoBorder: cfg.Style.NoBorder,
|
|
}
|
|
}
|
|
}
|
|
|
|
// headerProviderForUI returns a function that converts the extension header
|
|
// to a *ui.WidgetData for the TUI. Returns nil if extensions are disabled,
|
|
// which is safe — the UI treats a nil GetHeader as "no header".
|
|
func headerProviderForUI(k *kit.Kit) func() *ui.WidgetData {
|
|
return headerFooterProviderForUI(k, func() *extensions.HeaderFooterConfig {
|
|
return k.Extensions().GetHeader()
|
|
})
|
|
}
|
|
|
|
// toolRendererProviderForUI returns a function that converts extension tool
|
|
// renderers to ui.ToolRendererData for the TUI. Returns nil if extensions are
|
|
// disabled, which is safe — the UI treats a nil GetToolRenderer as "no
|
|
// custom renderers".
|
|
func toolRendererProviderForUI(k *kit.Kit) func(string) *ui.ToolRendererData {
|
|
if !k.Extensions().HasExtensions() {
|
|
return nil
|
|
}
|
|
return func(toolName string) *ui.ToolRendererData {
|
|
config := k.Extensions().GetToolRenderer(toolName)
|
|
if config == nil {
|
|
return nil
|
|
}
|
|
return &ui.ToolRendererData{
|
|
DisplayName: config.DisplayName,
|
|
BorderColor: config.BorderColor,
|
|
Background: config.Background,
|
|
BodyMarkdown: config.BodyMarkdown,
|
|
RenderHeader: config.RenderHeader,
|
|
RenderBody: config.RenderBody,
|
|
}
|
|
}
|
|
}
|
|
|
|
// editorInterceptorProviderForUI returns a function that converts the
|
|
// extension editor interceptor to a *ui.EditorInterceptor for the TUI.
|
|
// Returns nil if extensions are disabled, which is safe — the UI treats a
|
|
// nil GetEditorInterceptor as "no interceptor".
|
|
func editorInterceptorProviderForUI(k *kit.Kit) func() *ui.EditorInterceptor {
|
|
if !k.Extensions().HasExtensions() {
|
|
return nil
|
|
}
|
|
return func() *ui.EditorInterceptor {
|
|
config := k.Extensions().GetEditor()
|
|
if config == nil {
|
|
return nil
|
|
}
|
|
var handleKey func(string, string) ui.EditorKeyAction
|
|
if config.HandleKey != nil {
|
|
extHandleKey := config.HandleKey
|
|
handleKey = func(key, text string) ui.EditorKeyAction {
|
|
r := extHandleKey(key, text)
|
|
return ui.EditorKeyAction{
|
|
Type: ui.EditorKeyActionType(r.Type),
|
|
RemappedKey: r.RemappedKey,
|
|
SubmitText: r.SubmitText,
|
|
}
|
|
}
|
|
}
|
|
var render func(int, string) string
|
|
if config.Render != nil {
|
|
extRender := config.Render
|
|
render = func(width int, defaultContent string) string {
|
|
return extRender(width, defaultContent)
|
|
}
|
|
}
|
|
return &ui.EditorInterceptor{
|
|
HandleKey: handleKey,
|
|
Render: render,
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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.Extensions().HasExtensions() {
|
|
return nil
|
|
}
|
|
return func() *ui.UIVisibility {
|
|
v := k.Extensions().GetUIVisibility()
|
|
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".
|
|
func footerProviderForUI(k *kit.Kit) func() *ui.WidgetData {
|
|
return headerFooterProviderForUI(k, func() *extensions.HeaderFooterConfig {
|
|
return k.Extensions().GetFooter()
|
|
})
|
|
}
|
|
|
|
// 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.Extensions().HasExtensions() {
|
|
return nil
|
|
}
|
|
return func() []ui.StatusBarEntryData {
|
|
entries := k.Extensions().GetStatusEntries()
|
|
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.Extensions().HasExtensions() {
|
|
return nil
|
|
}
|
|
return func(targetID string, isUserMsg bool, userText string) (bool, string) {
|
|
return k.Extensions().EmitBeforeFork(targetID, isUserMsg, userText)
|
|
}
|
|
}
|
|
|
|
// 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.Extensions().HasExtensions() {
|
|
return nil
|
|
}
|
|
return func(switchReason string) (bool, string) {
|
|
return k.Extensions().EmitBeforeSessionSwitch(switchReason)
|
|
}
|
|
}
|
|
|
|
// 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.Extensions().HasExtensions() {
|
|
return nil
|
|
}
|
|
return func() map[string]func() {
|
|
return k.Extensions().GetShortcuts()
|
|
}
|
|
}
|
|
|
|
func runNormalMode(ctx context.Context) error {
|
|
// Validate flag combinations
|
|
if quietFlag && positionalPrompt == "" {
|
|
return fmt.Errorf("--quiet requires a prompt (e.g. kit \"your question\" --quiet)")
|
|
}
|
|
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
|
|
if debugMode {
|
|
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
|
}
|
|
|
|
// Update debug mode from viper
|
|
if viper.GetBool("debug") && !debugMode {
|
|
debugMode = viper.GetBool("debug")
|
|
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
|
}
|
|
|
|
// Restore persisted model preference when no explicit --model flag or
|
|
// config file model is set. Precedence: CLI flag > config file > saved
|
|
// preference > built-in default. This mirrors how themes are persisted.
|
|
// Skip custom/* models unless --provider-url is also provided, since the
|
|
// custom provider requires a URL that was only valid for the previous session.
|
|
if !modelFlagChanged && !viper.InConfig("model") {
|
|
if pref := ui.LoadModelPreference(); pref != "" {
|
|
if strings.HasPrefix(pref, "custom/") && viper.GetString("provider-url") == "" {
|
|
// Don't restore custom models without a provider URL
|
|
} else {
|
|
viper.Set("model", pref)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Restore persisted thinking level preference (same precedence chain).
|
|
if !thinkingFlagChanged && !viper.InConfig("thinking-level") {
|
|
if pref := ui.LoadThinkingLevelPreference(); pref != "" {
|
|
viper.Set("thinking-level", pref)
|
|
}
|
|
}
|
|
|
|
// When --provider-url is set but no explicit --model was provided,
|
|
// default to "custom/custom" so the user doesn't need to remember a
|
|
// provider/model pair for custom OpenAI-compatible endpoints.
|
|
// This intentionally overrides saved preferences but respects config-file
|
|
// models — if you specify a model in ~/.kit.yml, it will be used with
|
|
// custom/custom's provider routing.
|
|
if viper.GetString("provider-url") != "" && !modelFlagChanged && !viper.InConfig("model") {
|
|
viper.Set("model", "custom/custom")
|
|
}
|
|
|
|
// When --provider-url is set with an explicit --model, route through the
|
|
// "custom" provider (OpenAI-compatible wire). This honors the user's
|
|
// intent: passing a custom URL means "use THIS endpoint", not "speak
|
|
// the Google/Anthropic/etc. wire protocol against this endpoint".
|
|
//
|
|
// Any provider prefix on the model is stripped so a model name that
|
|
// happens to collide with a known provider (e.g. `google/gemma-4-12b`
|
|
// served by LM Studio) still resolves correctly. If you genuinely need
|
|
// to point a non-OpenAI wire (Anthropic, Google, ...) at a proxy URL,
|
|
// use the explicit `custom/<name>` form to opt out of the rewrite by
|
|
// configuring the proxy as that provider in your config file instead.
|
|
if viper.GetString("provider-url") != "" && modelFlagChanged {
|
|
model := viper.GetString("model")
|
|
if model != "" {
|
|
name := model
|
|
if _, after, ok := strings.Cut(model, "/"); ok {
|
|
name = after
|
|
}
|
|
if !strings.HasPrefix(model, "custom/") {
|
|
viper.Set("model", "custom/"+name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load MCP configuration.
|
|
mcpConfig, err := config.LoadAndValidateConfig()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load MCP config: %v", err)
|
|
}
|
|
|
|
// Create spinner function for agent creation.
|
|
var spinnerFunc kit.SpinnerFunc
|
|
if !quietFlag {
|
|
spinnerFunc = func(fn func() error) error {
|
|
tempCli, tempErr := ui.NewCLI(viper.GetBool("debug"))
|
|
if tempErr == nil {
|
|
return tempCli.ShowSpinner(fn)
|
|
}
|
|
return fn()
|
|
}
|
|
}
|
|
|
|
// Build Kit options from CLI flags and create the SDK instance.
|
|
// kit.New() handles: config → skills → agent → session → extension bridge.
|
|
authHandler, authErr := kit.NewCLIMCPAuthHandler()
|
|
if authErr != nil {
|
|
// Non-fatal: OAuth just won't be available for remote MCP servers.
|
|
fmt.Fprintf(os.Stderr, "Warning: Failed to create OAuth handler: %v\n", authErr)
|
|
}
|
|
|
|
// appInstancePtr is used to break the circular dependency between
|
|
// kit.New (which needs the OnMCPServerLoaded callback) and app.New
|
|
// (which is needed by the callback to send events to the TUI).
|
|
var appInstancePtr *app.App
|
|
|
|
kitOpts := &kit.Options{
|
|
Quiet: quietFlag,
|
|
Debug: debugMode,
|
|
NoSession: noSessionFlag,
|
|
Continue: continueFlag,
|
|
SessionPath: sessionPath,
|
|
AutoCompact: autoCompactFlag,
|
|
MCPAuthHandler: authHandler,
|
|
DisableCoreTools: viper.GetBool("no-core-tools"),
|
|
// This callback is called when each MCP server finishes loading.
|
|
// We use a closure that captures appInstancePtr which is set after
|
|
// app.New() is called below.
|
|
OnMCPServerLoaded: func(serverName string, toolCount int, err error) {
|
|
if appInstancePtr != nil {
|
|
appInstancePtr.NotifyMCPServerLoaded(serverName, toolCount, err)
|
|
}
|
|
},
|
|
CLI: &kit.CLIOptions{
|
|
MCPConfig: mcpConfig,
|
|
ShowSpinner: true,
|
|
SpinnerFunc: spinnerFunc,
|
|
UseBufferedLogger: true,
|
|
ProgressReaderFunc: progress.NewProgressReadCloser,
|
|
},
|
|
}
|
|
if resumeFlag {
|
|
// When --resume is combined with interactive mode, the TUI session
|
|
// picker will be shown at startup. For non-interactive mode, fall
|
|
// back to auto-selecting the most recent session.
|
|
if positionalPrompt != "" {
|
|
sessions, _ := kit.ListSessions("")
|
|
if len(sessions) > 0 {
|
|
kitOpts.SessionPath = sessions[0].Path
|
|
}
|
|
}
|
|
// Interactive mode: ShowSessionPicker is set below on AppModelOptions.
|
|
}
|
|
|
|
kitInstance, err := kit.New(ctx, kitOpts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = kitInstance.Close() }()
|
|
|
|
// Build the "System Prompt loaded" notice shown at startup, paralleling the
|
|
// per-server "MCP server loaded" notifications so users can confirm that a
|
|
// configured prompt file was found and applied.
|
|
var systemPromptLoadedMsg string
|
|
if kitInstance.HasCustomSystemPrompt() {
|
|
if src := kitInstance.GetSystemPromptSource(); src != "" {
|
|
systemPromptLoadedMsg = "System Prompt loaded: " + src
|
|
}
|
|
}
|
|
|
|
// Extract metadata for display and app options.
|
|
parsedProvider, modelName, serverNames, toolNames, mcpToolCount, extensionToolCount := CollectAgentMetadata(kitInstance, mcpConfig)
|
|
|
|
// Create CLI for non-interactive mode only.
|
|
var cli *ui.CLI
|
|
if positionalPrompt != "" {
|
|
cli, err = SetupCLIForNonInteractive(kitInstance)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to setup CLI: %v", err)
|
|
}
|
|
|
|
// Display buffered debug messages if any (non-interactive path only).
|
|
if msgs := kitInstance.GetBufferedDebugMessages(); len(msgs) > 0 && cli != nil {
|
|
cli.DisplayDebugMessage(strings.Join(msgs, "\n "))
|
|
}
|
|
|
|
DisplayDebugConfig(cli, kitInstance, mcpConfig, parsedProvider)
|
|
if systemPromptLoadedMsg != "" && cli != nil {
|
|
cli.DisplayInfo(systemPromptLoadedMsg)
|
|
}
|
|
}
|
|
|
|
// Load existing messages from resumed/continued sessions.
|
|
treeSession := kitInstance.GetTreeSession()
|
|
var messages []kit.LLMMessage
|
|
if treeSession != nil {
|
|
messages = treeSession.GetLLMMessages()
|
|
}
|
|
|
|
// Create the app.App instance.
|
|
appOpts := BuildAppOptions(mcpConfig, modelName, serverNames, toolNames)
|
|
appOpts.Kit = kitInstance
|
|
appOpts.TreeSession = treeSession
|
|
|
|
// Create a usage tracker that is shared between the app layer (for recording
|
|
// usage after each step) and the TUI (for /usage display).
|
|
var usageTracker *ui.UsageTracker
|
|
if cli != nil {
|
|
usageTracker = cli.GetUsageTracker()
|
|
} else {
|
|
usageTracker = ui.CreateUsageTracker(viper.GetString("model"), viper.GetString("provider-api-key"))
|
|
}
|
|
if usageTracker != nil {
|
|
appOpts.UsageTracker = usageTracker
|
|
}
|
|
|
|
appInstance := app.New(appOpts, messages)
|
|
appInstancePtr = appInstance // Wire up the MCP server loaded callback.
|
|
defer appInstance.Close()
|
|
|
|
// Wire OAuth handler to route messages through the TUI once it's running.
|
|
if authHandler != nil {
|
|
authHandler.NotifyFunc = func(serverName, message string) {
|
|
appInstance.PrintFromExtension("info", message)
|
|
}
|
|
}
|
|
|
|
// Buffer for extension messages during startup (printed after startup banner).
|
|
var startupExtensionMessages []string
|
|
if systemPromptLoadedMsg != "" {
|
|
startupExtensionMessages = append(startupExtensionMessages, systemPromptLoadedMsg)
|
|
}
|
|
|
|
// Set up extension context and emit SessionStart.
|
|
if kitInstance.Extensions().HasExtensions() {
|
|
cwd, _ := os.Getwd()
|
|
extCtx := buildInteractiveExtensionContext(extensionContextDeps{
|
|
ctx: ctx,
|
|
cwd: cwd,
|
|
modelName: modelName,
|
|
interactive: positionalPrompt == "",
|
|
kitInstance: kitInstance,
|
|
appInstance: appInstance,
|
|
usageTracker: usageTracker,
|
|
})
|
|
|
|
// During startup, buffer extension messages so they appear after the banner.
|
|
extCtx.Print = func(text string) {
|
|
startupExtensionMessages = append(startupExtensionMessages, text)
|
|
}
|
|
extCtx.PrintInfo = func(text string) {
|
|
startupExtensionMessages = append(startupExtensionMessages, text)
|
|
}
|
|
extCtx.PrintError = func(text string) {
|
|
startupExtensionMessages = append(startupExtensionMessages, text)
|
|
}
|
|
kitInstance.Extensions().SetContext(extCtx)
|
|
if err := kitInstance.Extensions().InitStatePersistence(); err != nil {
|
|
log.Printf("WARN extension state init failed: %v", err)
|
|
}
|
|
kitInstance.Extensions().EmitSessionStart()
|
|
|
|
// Restore normal print functions for runtime use.
|
|
extCtx.Print = func(text string) { appInstance.PrintFromExtension("", text) }
|
|
extCtx.PrintInfo = func(text string) { appInstance.PrintFromExtension("info", text) }
|
|
extCtx.PrintError = func(text string) { appInstance.PrintFromExtension("error", text) }
|
|
kitInstance.Extensions().SetContext(extCtx)
|
|
}
|
|
|
|
// Convert extension commands to UI-layer type for the interactive TUI.
|
|
extCommands := extensionCommandsForUI(kitInstance)
|
|
|
|
// Load prompt templates from standard locations and explicit paths.
|
|
var promptTemplates []*prompts.PromptTemplate
|
|
if !noPromptTemplates {
|
|
homeDir, _ := os.UserHomeDir()
|
|
cwd, _ := os.Getwd()
|
|
tpls, diags, err := prompts.LoadAll(prompts.LoadOptions{
|
|
Cwd: cwd,
|
|
HomeDir: homeDir,
|
|
ExtraPaths: promptTemplatePaths,
|
|
ConfigPaths: viper.GetStringSlice("prompts"),
|
|
IncludeDefaults: true,
|
|
})
|
|
if err != nil {
|
|
log.Printf("Warning: failed to load some prompt templates: %v", err)
|
|
}
|
|
promptTemplates = tpls
|
|
for _, d := range diags {
|
|
log.Printf("Prompt template collision: /%s kept from %s, dropped from %s", d.Name, d.KeptPath, d.DroppedPath)
|
|
}
|
|
}
|
|
|
|
// Build context/skills display metadata for the startup banner.
|
|
var contextPaths []string
|
|
for _, cf := range kitInstance.GetContextFiles() {
|
|
contextPaths = append(contextPaths, cf.Path)
|
|
}
|
|
cwd, _ := os.Getwd()
|
|
var skillItems []ui.SkillItem
|
|
for _, s := range kitInstance.GetSkills() {
|
|
source := "user"
|
|
if strings.HasPrefix(s.Path, cwd) {
|
|
source = "project"
|
|
}
|
|
skillItems = append(skillItems, ui.SkillItem{
|
|
Name: s.Name,
|
|
Path: s.Path,
|
|
Source: source,
|
|
Description: s.Description,
|
|
})
|
|
}
|
|
|
|
// Build extension items from the loaded extensions for the [Extensions]
|
|
// startup section. Each entry is a single .go file (or a subdir's main.go).
|
|
extensionItems := buildExtensionItems(kitInstance, cwd)
|
|
|
|
// Build prompt template and skill item provider callbacks for hot-reload.
|
|
// These are called by the TUI when ContentReloadEvent fires.
|
|
getPromptTemplates := func() []*prompts.PromptTemplate {
|
|
if noPromptTemplates {
|
|
return nil
|
|
}
|
|
homeDir, _ := os.UserHomeDir()
|
|
cwd, _ := os.Getwd()
|
|
tpls, _, err := prompts.LoadAll(prompts.LoadOptions{
|
|
Cwd: cwd,
|
|
HomeDir: homeDir,
|
|
ExtraPaths: promptTemplatePaths,
|
|
ConfigPaths: viper.GetStringSlice("prompts"),
|
|
IncludeDefaults: true,
|
|
})
|
|
if err != nil {
|
|
log.Printf("Warning: failed to reload prompt templates: %v", err)
|
|
}
|
|
return tpls
|
|
}
|
|
|
|
getSkillItems := func() []ui.SkillItem {
|
|
// Re-discover skills from disk.
|
|
if err := kitInstance.ReloadSkills(); err != nil {
|
|
log.Printf("Warning: failed to reload skills: %v", err)
|
|
return nil
|
|
}
|
|
cwd, _ := os.Getwd()
|
|
var items []ui.SkillItem
|
|
for _, s := range kitInstance.GetSkills() {
|
|
source := "user"
|
|
if strings.HasPrefix(s.Path, cwd) {
|
|
source = "project"
|
|
}
|
|
items = append(items, ui.SkillItem{
|
|
Name: s.Name,
|
|
Path: s.Path,
|
|
Source: source,
|
|
Description: s.Description,
|
|
})
|
|
}
|
|
return items
|
|
}
|
|
|
|
// getExtensionItems re-collects the loaded extension list, used by the
|
|
// TUI after an extension hot-reload to refresh the [Extensions] row.
|
|
getExtensionItems := func() []ui.ExtensionItem {
|
|
cwd, _ := os.Getwd()
|
|
return buildExtensionItems(kitInstance, cwd)
|
|
}
|
|
|
|
// Build extension UI providers once (shared between both modes).
|
|
getWidgets := widgetProviderForUI(kitInstance)
|
|
getHeader := headerProviderForUI(kitInstance)
|
|
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() []commands.ExtensionCommand {
|
|
return extensionCommandsForUI(kitInstance)
|
|
}
|
|
|
|
// Build dynamic tool name and MCP tool count providers. These are called
|
|
// by the TUI when MCPToolsReadyEvent fires to refresh the /tools list
|
|
// and startup info bar after background MCP tool loading completes.
|
|
getToolNames := func() []string {
|
|
return kitInstance.GetToolNames()
|
|
}
|
|
getMCPToolCount := func() int {
|
|
return kitInstance.GetMCPToolCount()
|
|
}
|
|
|
|
// Build MCP prompt provider callbacks for the TUI.
|
|
// Convert kit.MCPPrompt → ui.MCPPromptInfo for the UI layer.
|
|
convertMCPPromptsForUI := func() []ui.MCPPromptInfo {
|
|
prompts := kitInstance.ListMCPPrompts()
|
|
if len(prompts) == 0 {
|
|
return nil
|
|
}
|
|
result := make([]ui.MCPPromptInfo, len(prompts))
|
|
for i, p := range prompts {
|
|
args := make([]ui.MCPPromptArgInfo, len(p.Arguments))
|
|
for j, a := range p.Arguments {
|
|
args[j] = ui.MCPPromptArgInfo{
|
|
Name: a.Name,
|
|
Description: a.Description,
|
|
Required: a.Required,
|
|
}
|
|
}
|
|
result[i] = ui.MCPPromptInfo{
|
|
Name: p.Name,
|
|
Description: p.Description,
|
|
Arguments: args,
|
|
ServerName: p.ServerName,
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
mcpPrompts := convertMCPPromptsForUI()
|
|
getMCPPrompts := func() []ui.MCPPromptInfo {
|
|
return convertMCPPromptsForUI()
|
|
}
|
|
expandMCPPrompt := func(serverName, promptName string, args map[string]string) (*ui.MCPPromptExpandResult, error) {
|
|
result, err := kitInstance.GetMCPPrompt(context.Background(), serverName, promptName, args)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
msgs := make([]ui.MCPPromptMessageInfo, len(result.Messages))
|
|
for i, m := range result.Messages {
|
|
msgs[i] = ui.MCPPromptMessageInfo{
|
|
Role: m.Role,
|
|
Content: m.Content,
|
|
FileParts: m.FileParts,
|
|
}
|
|
}
|
|
return &ui.MCPPromptExpandResult{Messages: msgs}, nil
|
|
}
|
|
|
|
// MCP resource callbacks for @ autocomplete and submit-time resolution.
|
|
getMCPResources := func() []ui.FileSuggestion {
|
|
resources := kitInstance.ListMCPResources()
|
|
suggestions := make([]ui.FileSuggestion, len(resources))
|
|
for i, r := range resources {
|
|
suggestions[i] = ui.FileSuggestion{
|
|
RelPath: r.Name,
|
|
IsMCPResource: true,
|
|
MCPServerName: r.ServerName,
|
|
MCPResourceURI: r.URI,
|
|
MCPMIMEType: r.MIMEType,
|
|
Score: 100, // default score, filtered later
|
|
}
|
|
}
|
|
return suggestions
|
|
}
|
|
mcpResourceReaderFn := func(serverName, uri string) (string, []byte, string, bool, error) {
|
|
content, err := kitInstance.ReadMCPResource(context.Background(), serverName, uri)
|
|
if err != nil {
|
|
return "", nil, "", false, err
|
|
}
|
|
return content.Text, content.BlobData, content.MIMEType, content.IsBlob, nil
|
|
}
|
|
|
|
// Store MCP resource callbacks at package level for consumption by
|
|
// runInteractiveModeBubbleTea and runNonInteractiveModeApp.
|
|
mcpGetResources = getMCPResources
|
|
mcpResourceReader = mcpResourceReaderFn
|
|
|
|
// Start a goroutine that waits for background MCP tool loading to
|
|
// complete and notifies the TUI so it can refresh tool names and counts.
|
|
if len(mcpConfig.MCPServers) > 0 {
|
|
go func() {
|
|
_ = kitInstance.WaitForMCPTools()
|
|
appInstance.NotifyMCPToolsReady()
|
|
}()
|
|
}
|
|
|
|
// Build model switching callbacks for the /model command.
|
|
setModelForUI := func(modelString string) error {
|
|
err := kitInstance.SetModel(context.Background(), modelString)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Update the extension context's Model field so handlers see it.
|
|
kitInstance.Extensions().UpdateContextModel(modelString)
|
|
// NOTE: We do NOT call appInstance.NotifyModelChanged() here because
|
|
// this callback runs synchronously inside BubbleTea's Update(), and
|
|
// NotifyModelChanged calls prog.Send() which deadlocks. The UI layer
|
|
// updates m.providerName and m.modelName directly after setModel returns.
|
|
// Update usage tracker with new model info for correct token counting.
|
|
if usageTracker != nil {
|
|
newProvider, newModel, _ := models.ParseModelString(modelString)
|
|
if newProvider != "unknown" && newModel != "unknown" && newProvider != "ollama" {
|
|
registry := models.GetGlobalRegistry()
|
|
if modelInfo := registry.LookupModel(newProvider, newModel); modelInfo != nil {
|
|
// Check OAuth status for Anthropic models
|
|
isOAuth := false
|
|
if newProvider == "anthropic" {
|
|
_, source, err := auth.GetAnthropicAPIKey(viper.GetString("provider-api-key"))
|
|
if err == nil && strings.HasPrefix(source, "stored OAuth") {
|
|
isOAuth = true
|
|
}
|
|
}
|
|
usageTracker.UpdateModelInfo(modelInfo, newProvider, isOAuth)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
emitModelChangeForUI := func(newModel, previousModel, source string) {
|
|
kitInstance.Extensions().EmitModelChange(newModel, previousModel, source)
|
|
}
|
|
|
|
// Build thinking level callback.
|
|
setThinkingLevelForUI := func(level string) error {
|
|
return kitInstance.SetThinkingLevel(context.Background(), level)
|
|
}
|
|
|
|
// Build session-switching callback. Opens a JSONL session file and
|
|
// replaces the active tree session on both the Kit SDK and App layer.
|
|
switchSessionForUI := func(path string) error {
|
|
ts, err := kit.OpenTreeSession(path)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open session: %w", err)
|
|
}
|
|
kitInstance.SetTreeSession(ts)
|
|
appInstance.SwitchTreeSession(ts)
|
|
return nil
|
|
}
|
|
|
|
// Build extension reload callback for the /reload-ext command.
|
|
reloadExtensionsForUI := func() error {
|
|
err := kitInstance.Extensions().Reload()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
go appInstance.NotifyWidgetUpdate()
|
|
return nil
|
|
}
|
|
|
|
// Start file watcher for automatic extension hot-reload.
|
|
extraPaths := viper.GetStringSlice("extension")
|
|
watchDirs := extensions.WatchedDirs(extraPaths)
|
|
if len(watchDirs) > 0 {
|
|
extWatcher, watchErr := extensions.NewWatcher(watchDirs, func() {
|
|
if err := reloadExtensionsForUI(); err != nil {
|
|
log.Printf("auto-reload extensions failed: %v", err)
|
|
appInstance.PrintFromExtension("error", fmt.Sprintf("Extension auto-reload failed: %v", err))
|
|
return
|
|
}
|
|
appInstance.PrintFromExtension("info", "Extensions reloaded.")
|
|
})
|
|
if watchErr != nil {
|
|
log.Printf("extension file watcher not started: %v", watchErr)
|
|
} else {
|
|
go extWatcher.Start(ctx)
|
|
defer func() { _ = extWatcher.Close() }()
|
|
}
|
|
}
|
|
|
|
// Start file watchers for automatic prompt and skill hot-reload.
|
|
{
|
|
homeDir, _ := os.UserHomeDir()
|
|
cwd, _ := os.Getwd()
|
|
|
|
// Collect prompt template directories.
|
|
promptDirs := watcher.CollectDirs(
|
|
[]string{
|
|
filepath.Join(homeDir, ".kit", "prompts"),
|
|
prompts.GlobalDir(),
|
|
filepath.Join(cwd, ".kit", "prompts"),
|
|
},
|
|
append(promptTemplatePaths, viper.GetStringSlice("prompts")...),
|
|
)
|
|
|
|
// Collect skill directories.
|
|
skillDirs := watcher.CollectDirs(
|
|
[]string{
|
|
filepath.Join(homeDir, ".config", "kit", "skills"),
|
|
filepath.Join(cwd, ".agents", "skills"),
|
|
filepath.Join(cwd, ".kit", "skills"),
|
|
},
|
|
nil,
|
|
)
|
|
|
|
// Combine all content directories and start a single watcher.
|
|
allContentDirs := append(promptDirs, skillDirs...)
|
|
if len(allContentDirs) > 0 {
|
|
contentWatcher, watchErr := watcher.New(watcher.Options{
|
|
Dirs: allContentDirs,
|
|
Extensions: []string{".md", ".txt"},
|
|
Label: "prompts/skills",
|
|
OnReload: func() {
|
|
log.Printf("auto-reloading prompts and skills")
|
|
appInstance.NotifyContentReload()
|
|
},
|
|
})
|
|
if watchErr != nil {
|
|
log.Printf("content file watcher not started: %v", watchErr)
|
|
} else {
|
|
go contentWatcher.Start(ctx)
|
|
defer func() { _ = contentWatcher.Close() }()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Bundle all the shared dependencies into a single struct that both
|
|
// run-mode entry points consume. This keeps the dispatch site and the
|
|
// function signatures readable.
|
|
deps := runModeDeps{
|
|
appInstance: appInstance,
|
|
cli: cli,
|
|
modelName: modelName,
|
|
providerName: parsedProvider,
|
|
loadingMessage: kitInstance.GetLoadingMessage(),
|
|
serverNames: serverNames,
|
|
toolNames: toolNames,
|
|
mcpToolCount: mcpToolCount,
|
|
extensionToolCount: extensionToolCount,
|
|
usageTracker: usageTracker,
|
|
extCommands: extCommands,
|
|
promptTemplates: promptTemplates,
|
|
contextPaths: contextPaths,
|
|
skillItems: skillItems,
|
|
extensionItems: extensionItems,
|
|
getPromptTemplates: getPromptTemplates,
|
|
getSkillItems: getSkillItems,
|
|
getExtensionItems: getExtensionItems,
|
|
getToolNames: getToolNames,
|
|
getMCPToolCount: getMCPToolCount,
|
|
mcpPrompts: mcpPrompts,
|
|
getMCPPrompts: getMCPPrompts,
|
|
expandMCPPrompt: expandMCPPrompt,
|
|
getWidgets: getWidgets,
|
|
getHeader: getHeader,
|
|
getFooter: getFooter,
|
|
getToolRenderer: getToolRenderer,
|
|
getEditorInterceptor: getEditorInterceptor,
|
|
getUIVisibility: getUIVisibility,
|
|
getStatusBarEntries: getStatusBarEntries,
|
|
emitBeforeFork: emitBeforeFork,
|
|
emitBeforeSessionSwitch: emitBeforeSessionSwitch,
|
|
getGlobalShortcuts: getGlobalShortcuts,
|
|
getExtensionCommands: getExtensionCommands,
|
|
setModel: setModelForUI,
|
|
emitModelChange: emitModelChangeForUI,
|
|
isReasoningModel: kitInstance.IsReasoningModel(),
|
|
thinkingLevel: kitInstance.GetThinkingLevel(),
|
|
setThinkingLevel: setThinkingLevelForUI,
|
|
switchSession: switchSessionForUI,
|
|
reloadExtensions: reloadExtensionsForUI,
|
|
startupExtensionMessages: startupExtensionMessages,
|
|
}
|
|
|
|
// Check if running in non-interactive mode
|
|
if positionalPrompt != "" {
|
|
return runNonInteractiveModeApp(ctx, deps, positionalPrompt, quietFlag, jsonFlag, noExitFlag)
|
|
}
|
|
|
|
// Quiet mode is not allowed in interactive mode
|
|
if quietFlag {
|
|
return fmt.Errorf("--quiet requires a prompt")
|
|
}
|
|
|
|
return runInteractiveModeBubbleTea(ctx, deps)
|
|
}
|
|
|
|
// runNonInteractiveModeApp executes a single prompt via the app layer and exits,
|
|
// or transitions to the interactive BubbleTea TUI when --no-exit is set.
|
|
//
|
|
// In quiet mode, RunOnce is used (no intermediate output, final response only).
|
|
// Otherwise, RunOnceWithDisplay streams tool calls and responses through the
|
|
// shared CLIEventHandler — giving --prompt mode the same rich output as
|
|
// interactive mode.
|
|
//
|
|
// 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, deps runModeDeps, prompt string, quiet, jsonOutput, noExit bool) error {
|
|
appInstance := deps.appInstance
|
|
cli := deps.cli
|
|
modelName := deps.modelName
|
|
// Expand @file references in the prompt before sending to the agent.
|
|
// Text files are XML-inlined; binary files are extracted as multimodal parts.
|
|
var fileParts []kit.LLMFilePart
|
|
if cwd, err := os.Getwd(); err == nil {
|
|
result := ui.ProcessFileAttachments(prompt, cwd, mcpResourceReader)
|
|
prompt = result.ProcessedText
|
|
for _, fp := range result.FileParts {
|
|
fileParts = append(fileParts, kit.LLMFilePart{
|
|
Filename: fp.Filename,
|
|
Data: fp.Data,
|
|
MediaType: fp.MediaType,
|
|
})
|
|
}
|
|
}
|
|
// Also include binary files from processPositionalArgs (CLI @file args).
|
|
for _, fp := range positionalFiles {
|
|
fileParts = append(fileParts, kit.LLMFilePart{
|
|
Filename: fp.Filename,
|
|
Data: fp.Data,
|
|
MediaType: fp.MediaType,
|
|
})
|
|
}
|
|
|
|
if jsonOutput {
|
|
// JSON mode: no intermediate display, structured JSON output.
|
|
result, err := appInstance.RunOnceResultWithFiles(ctx, prompt, fileParts)
|
|
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.RunOnceWithFiles(ctx, prompt, fileParts); err != nil {
|
|
return err
|
|
}
|
|
} else if cli != nil {
|
|
// Display user message before running the agent.
|
|
cli.DisplayUserMessage(prompt)
|
|
|
|
// Route events through the shared CLI event handler.
|
|
eventHandler := ui.NewCLIEventHandler(cli, modelName)
|
|
err := appInstance.RunOnceWithDisplayAndFiles(ctx, prompt, eventHandler.Handle, fileParts)
|
|
eventHandler.Cleanup()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
// No CLI available (shouldn't happen in non-quiet mode, but be safe).
|
|
if err := appInstance.RunOnceWithFiles(ctx, prompt, fileParts); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// If --no-exit was requested, hand off to the interactive TUI.
|
|
if noExit {
|
|
// Drop the cli (interactive mode doesn't use it) and clear the
|
|
// interactive-only fields explicitly; deps carries everything else.
|
|
interactive := deps
|
|
interactive.cli = nil
|
|
interactive.startupExtensionMessages = nil
|
|
return runInteractiveModeBubbleTea(ctx, interactive)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// runModeDeps bundles the shared dependencies that runNormalMode wires up
|
|
// once and threads to both runNonInteractiveModeApp and
|
|
// runInteractiveModeBubbleTea. Grouping them into a single struct keeps the
|
|
// call sites and signatures readable and makes it trivial to add a new
|
|
// provider callback without touching every call chain.
|
|
type runModeDeps struct {
|
|
appInstance *app.App
|
|
cli *ui.CLI // non-interactive only
|
|
modelName string
|
|
providerName string
|
|
loadingMessage string
|
|
serverNames []string
|
|
toolNames []string
|
|
mcpToolCount int
|
|
extensionToolCount int
|
|
usageTracker *ui.UsageTracker
|
|
extCommands []commands.ExtensionCommand
|
|
promptTemplates []*prompts.PromptTemplate
|
|
contextPaths []string
|
|
skillItems []ui.SkillItem
|
|
extensionItems []ui.ExtensionItem
|
|
getPromptTemplates func() []*prompts.PromptTemplate
|
|
getSkillItems func() []ui.SkillItem
|
|
getExtensionItems func() []ui.ExtensionItem
|
|
getToolNames func() []string
|
|
getMCPToolCount func() int
|
|
mcpPrompts []ui.MCPPromptInfo
|
|
getMCPPrompts func() []ui.MCPPromptInfo
|
|
expandMCPPrompt func(string, string, map[string]string) (*ui.MCPPromptExpandResult, error)
|
|
getWidgets func(string) []ui.WidgetData
|
|
getHeader func() *ui.WidgetData
|
|
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() []commands.ExtensionCommand
|
|
setModel func(string) error
|
|
emitModelChange func(string, string, string)
|
|
isReasoningModel bool
|
|
thinkingLevel string
|
|
setThinkingLevel func(string) error
|
|
switchSession func(string) error
|
|
reloadExtensions func() error
|
|
startupExtensionMessages []string // interactive only
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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"`
|
|
StopReason string `json:"stop_reason,omitempty"`
|
|
SessionID string `json:"session_id,omitempty"`
|
|
Usage *jsonUsage `json:"usage,omitempty"`
|
|
Messages []jsonMessage `json:"messages"`
|
|
}
|
|
|
|
out := jsonEnvelope{
|
|
Response: result.Response,
|
|
Model: model,
|
|
StopReason: result.StopReason,
|
|
SessionID: result.SessionID,
|
|
}
|
|
|
|
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.ConvertFromLLMMessage(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:
|
|
// 1. Gets the terminal dimensions (falls back to 80x24 if unavailable).
|
|
// 2. Creates a ui.AppModel (parent model) with the appInstance as the controller,
|
|
// wiring up all child components (InputComponent, StreamComponent).
|
|
// 3. Creates a single tea.NewProgram and registers it with appInstance via SetProgram
|
|
// so that agent events are routed to the TUI.
|
|
// 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, deps runModeDeps) error {
|
|
appInstance := deps.appInstance
|
|
// Redirect all log output (stdlib and charm) to a file so that log
|
|
// messages don't write to stderr and corrupt the TUI. Bubble Tea
|
|
// captures stdout for rendering; any stray stderr output from
|
|
// background goroutines (watchers, extension handlers, SDK internals)
|
|
// will visually corrupt the terminal.
|
|
logDir := filepath.Join(os.TempDir(), "kit")
|
|
_ = os.MkdirAll(logDir, 0o700)
|
|
logFile, logErr := tea.LogToFile(filepath.Join(logDir, "kit.log"), "kit")
|
|
if logErr == nil {
|
|
defer func() { _ = logFile.Close() }()
|
|
}
|
|
|
|
// Determine terminal size; fall back gracefully.
|
|
termWidth, termHeight, err := term.GetSize(int(os.Stdout.Fd()))
|
|
if err != nil || termWidth == 0 {
|
|
termWidth = 80
|
|
termHeight = 24
|
|
}
|
|
|
|
cwd, _ := os.Getwd()
|
|
|
|
appModel := ui.NewAppModel(appInstance, ui.AppModelOptions{
|
|
ModelName: deps.modelName,
|
|
ProviderName: deps.providerName,
|
|
LoadingMessage: deps.loadingMessage,
|
|
Cwd: cwd,
|
|
Width: termWidth,
|
|
Height: termHeight,
|
|
ServerNames: deps.serverNames,
|
|
ToolNames: deps.toolNames,
|
|
GetToolNames: deps.getToolNames,
|
|
GetMCPToolCount: deps.getMCPToolCount,
|
|
MCPToolCount: deps.mcpToolCount,
|
|
ExtensionToolCount: deps.extensionToolCount,
|
|
UsageTracker: deps.usageTracker,
|
|
ExtensionCommands: deps.extCommands,
|
|
PromptTemplates: deps.promptTemplates,
|
|
GetPromptTemplates: deps.getPromptTemplates,
|
|
MCPPrompts: deps.mcpPrompts,
|
|
GetMCPPrompts: deps.getMCPPrompts,
|
|
ExpandMCPPrompt: deps.expandMCPPrompt,
|
|
ContextPaths: deps.contextPaths,
|
|
SkillItems: deps.skillItems,
|
|
GetSkillItems: deps.getSkillItems,
|
|
ExtensionItems: deps.extensionItems,
|
|
GetExtensionItems: deps.getExtensionItems,
|
|
StartupExtensionMessages: deps.startupExtensionMessages,
|
|
GetWidgets: deps.getWidgets,
|
|
GetHeader: deps.getHeader,
|
|
GetFooter: deps.getFooter,
|
|
GetToolRenderer: deps.getToolRenderer,
|
|
GetEditorInterceptor: deps.getEditorInterceptor,
|
|
GetUIVisibility: deps.getUIVisibility,
|
|
GetStatusBarEntries: deps.getStatusBarEntries,
|
|
EmitBeforeFork: deps.emitBeforeFork,
|
|
EmitBeforeSessionSwitch: deps.emitBeforeSessionSwitch,
|
|
GetGlobalShortcuts: deps.getGlobalShortcuts,
|
|
GetExtensionCommands: deps.getExtensionCommands,
|
|
SetModel: deps.setModel,
|
|
EmitModelChange: deps.emitModelChange,
|
|
ThinkingLevel: deps.thinkingLevel,
|
|
IsReasoningModel: deps.isReasoningModel,
|
|
SetThinkingLevel: deps.setThinkingLevel,
|
|
SwitchSession: deps.switchSession,
|
|
ReloadExtensions: deps.reloadExtensions,
|
|
ShowSessionPicker: resumeFlag,
|
|
GetMCPResources: mcpGetResources,
|
|
MCPResourceReader: mcpResourceReader,
|
|
})
|
|
|
|
program := tea.NewProgram(appModel)
|
|
|
|
// Register the program with the app layer so agent events are sent to the TUI.
|
|
appInstance.SetProgram(program)
|
|
|
|
_, runErr := program.Run()
|
|
return runErr
|
|
}
|