Files
kit/cmd/root.go
T
Ed Zynda fd960921ca refactor: address code audit findings across SDK, cmd, and internals
- Remove deprecated GenerateWithLoopAndStreaming and TreeManager
  AppendFantasyMessage / AddFantasyMessages / GetFantasyMessages to
  close the SDK leakage caused by the kit.TreeManager type alias
- Switch extensionAPI method signatures to local Extension* aliases so
  pkg.go.dev signatures no longer expose internal package names
- Bundle runNormalMode dependencies into a runModeDeps struct, shrinking
  the runNonInteractive and runInteractive call sites from 40+ positional
  args to (ctx, deps)
- Add generic subscribeTyped[E Event] helper and collapse ~30 typed OnXxx
  wrappers in pkg/kit/events.go onto it (public signatures unchanged)
- Extract setupBashPipes / interpretBashExit in internal/core/bash.go to
  deduplicate the buffered and streaming execution paths
- Extract resolveAutoRouteAPIKey and wrapProviderErr helpers in
  internal/models/providers.go and uniformly apply them across every
  createXxxProvider site
- Reimplement internal/extensions/watcher.go as a thin wrapper over the
  general-purpose internal/watcher.ContentWatcher, eliminating ~130 LOC
  of duplicated fsnotify logic while preserving the existing test API
- Add ctx.Err() pre-flight checks in executeRead / Write / Edit / Ls so
  cancellation actually short-circuits pure file-IO tools
2026-06-06 19:22:05 +03:00

1633 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 that lacks a provider
// prefix (no "/"), auto-prefix with "custom/" for OpenAI-compatible endpoints.
if viper.GetString("provider-url") != "" && modelFlagChanged {
model := viper.GetString("model")
if model != "" && !strings.Contains(model, "/") {
viper.Set("model", "custom/"+model)
}
}
// 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)
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
}