From 6755597c9b87cd3f45d25ff616d49df88787b4dd Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Thu, 7 May 2026 12:28:18 +0300 Subject: [PATCH] extract buildInteractiveExtensionContext helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous runNormalMode contained two nearly-identical 400-line extensions.Context literal expressions: * the startup-time literal (cmd/root.go:853-1307) that buffered Print* calls into startupExtensionMessages * the runtime literal (cmd/root.go:1311-1605) that routed Print* through appInstance.PrintFromExtension Every other field — Compact, SendMultimodalMessage, the four prompt factories, all 25+ data-access fields, all four bridge phases — was duplicated byte-for-byte. Maintainers had to remember to update both copies whenever an extension Context field was added. cmd/root.go is now 1463 lines (was 2225). The new helper lives in cmd/extension_context.go (455 lines, mostly the closures verbatim) and returns an extensions.Context with every field populated except Print/PrintInfo/PrintError, which each call site sets afterwards to match its phase. This preserves AGENTS.md's 'function field bug' guarantee — all assignments remain anonymous closure literals. Output of 'kit --version' / 'kit --help' unchanged. Full test suite passes. --- cmd/extension_context.go | 455 +++++++++++++++++++++++++ cmd/root.go | 706 ++------------------------------------- 2 files changed, 486 insertions(+), 675 deletions(-) create mode 100644 cmd/extension_context.go diff --git a/cmd/extension_context.go b/cmd/extension_context.go new file mode 100644 index 00000000..623363e8 --- /dev/null +++ b/cmd/extension_context.go @@ -0,0 +1,455 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/spf13/viper" + "golang.org/x/term" + + "github.com/mark3labs/kit/internal/app" + "github.com/mark3labs/kit/internal/auth" + "github.com/mark3labs/kit/internal/extbridge" + "github.com/mark3labs/kit/internal/extensions" + "github.com/mark3labs/kit/internal/models" + "github.com/mark3labs/kit/internal/ui" + kit "github.com/mark3labs/kit/pkg/kit" +) + +// extensionContextDeps groups the runtime dependencies needed to wire up +// an extensions.Context for the interactive TUI mode. +type extensionContextDeps struct { + ctx context.Context + cwd string + modelName string + interactive bool + kitInstance *kit.Kit + appInstance *app.App + usageTracker *ui.UsageTracker +} + +// buildInteractiveExtensionContext returns an extensions.Context with every +// field except Print / PrintInfo / PrintError populated. Callers must set +// the three print routes appropriately for their phase (startup buffering +// vs. live runtime routing). +// +// This consolidates two near-identical 400-line literal expressions that +// previously appeared inline in runNormalMode. +func buildInteractiveExtensionContext(deps extensionContextDeps) extensions.Context { + kitInstance := deps.kitInstance + appInstance := deps.appInstance + usageTracker := deps.usageTracker + ctx := deps.ctx + + return extensions.Context{ + CWD: deps.cwd, + Model: deps.modelName, + Interactive: deps.interactive, + PrintBlock: appInstance.PrintBlockFromExtension, + SendMessage: func(text string) { appInstance.Run(text) }, + CancelAndSend: func(text string) { appInstance.InterruptAndSend(text) }, + Abort: func() { appInstance.Abort() }, + IsIdle: func() bool { return !appInstance.IsBusy() }, + Compact: func(cfg extensions.CompactConfig) error { + return appInstance.CompactAsync(cfg.CustomInstructions, cfg.OnComplete, cfg.OnError) + }, + SendMultimodalMessage: func(text string, files []extensions.FilePart) { + parts := make([]kit.LLMFilePart, len(files)) + for i, f := range files { + parts[i] = kit.LLMFilePart{ + Filename: f.Filename, + Data: f.Data, + MediaType: f.MediaType, + } + } + appInstance.RunWithFiles(text, parts) + }, + GetSessionUsage: func() extensions.SessionUsage { + if usageTracker == nil { + return extensions.SessionUsage{} + } + stats := usageTracker.GetSessionStats() + return extensions.SessionUsage{ + TotalInputTokens: stats.TotalInputTokens, + TotalOutputTokens: stats.TotalOutputTokens, + TotalCacheReadTokens: stats.TotalCacheReadTokens, + TotalCacheWriteTokens: stats.TotalCacheWriteTokens, + TotalCost: stats.TotalCost, + RequestCount: stats.RequestCount, + } + }, + Exit: func() { appInstance.QuitFromExtension() }, + SetWidget: func(config extensions.WidgetConfig) { + kitInstance.Extensions().SetWidget(config) + go appInstance.NotifyWidgetUpdate() + }, + RemoveWidget: func(id string) { + kitInstance.Extensions().RemoveWidget(id) + go appInstance.NotifyWidgetUpdate() + }, + SetHeader: func(config extensions.HeaderFooterConfig) { + kitInstance.Extensions().SetHeader(config) + go appInstance.NotifyWidgetUpdate() + }, + RemoveHeader: func() { + kitInstance.Extensions().RemoveHeader() + go appInstance.NotifyWidgetUpdate() + }, + SetFooter: func(config extensions.HeaderFooterConfig) { + kitInstance.Extensions().SetFooter(config) + go appInstance.NotifyWidgetUpdate() + }, + RemoveFooter: func() { + kitInstance.Extensions().RemoveFooter() + go appInstance.NotifyWidgetUpdate() + }, + PromptSelect: func(config extensions.PromptSelectConfig) extensions.PromptSelectResult { + ch := make(chan app.PromptResponse, 1) + appInstance.SendPromptRequest(app.PromptRequestEvent{ + PromptType: "select", + Message: config.Message, + Options: config.Options, + ResponseCh: ch, + }) + resp := <-ch + if resp.Cancelled { + return extensions.PromptSelectResult{Cancelled: true} + } + return extensions.PromptSelectResult{Value: resp.Value, Index: resp.Index} + }, + PromptConfirm: func(config extensions.PromptConfirmConfig) extensions.PromptConfirmResult { + ch := make(chan app.PromptResponse, 1) + def := "false" + if config.DefaultValue { + def = "true" + } + appInstance.SendPromptRequest(app.PromptRequestEvent{ + PromptType: "confirm", + Message: config.Message, + Default: def, + ResponseCh: ch, + }) + resp := <-ch + if resp.Cancelled { + return extensions.PromptConfirmResult{Cancelled: true} + } + return extensions.PromptConfirmResult{Value: resp.Confirmed} + }, + PromptInput: func(config extensions.PromptInputConfig) extensions.PromptInputResult { + ch := make(chan app.PromptResponse, 1) + appInstance.SendPromptRequest(app.PromptRequestEvent{ + PromptType: "input", + Message: config.Message, + Placeholder: config.Placeholder, + Default: config.Default, + ResponseCh: ch, + }) + resp := <-ch + if resp.Cancelled { + return extensions.PromptInputResult{Cancelled: true} + } + return extensions.PromptInputResult{Value: resp.Value} + }, + SetUIVisibility: func(v extensions.UIVisibility) { + kitInstance.Extensions().SetUIVisibility(v) + go appInstance.NotifyWidgetUpdate() + }, + GetContextStats: func() extensions.ContextStats { + s := kitInstance.GetContextStats() + return extensions.ContextStats{ + EstimatedTokens: s.EstimatedTokens, + ContextLimit: s.ContextLimit, + UsagePercent: s.UsagePercent, + MessageCount: s.MessageCount, + } + }, + SetEditor: func(config extensions.EditorConfig) { + kitInstance.Extensions().SetEditor(config) + // Always use a goroutine for NotifyWidgetUpdate: prog.Send() + // deadlocks if called synchronously from inside BubbleTea's + // Update() handler. All call sites use go-routines uniformly. + go appInstance.NotifyWidgetUpdate() + }, + ResetEditor: func() { + kitInstance.Extensions().ResetEditor() + go appInstance.NotifyWidgetUpdate() + }, + GetMessages: func() []extensions.SessionMessage { + return kitInstance.Extensions().GetSessionMessages() + }, + GetSessionPath: func() string { + return kitInstance.GetSessionPath() + }, + AppendEntry: func(entryType string, data string) (string, error) { + return kitInstance.Extensions().AppendEntry(entryType, data) + }, + GetEntries: func(entryType string) []extensions.ExtensionEntry { + return kitInstance.Extensions().GetEntries(entryType) + }, + SetEditorText: func(text string) { + appInstance.SetEditorTextFromExtension(text) + }, + SetStatus: func(key string, text string, priority int) { + kitInstance.Extensions().SetStatus(extensions.StatusBarEntry{ + Key: key, + Text: text, + Priority: priority, + }) + go appInstance.NotifyWidgetUpdate() + }, + RemoveStatus: func(key string) { + kitInstance.Extensions().RemoveStatus(key) + go appInstance.NotifyWidgetUpdate() + }, + GetOption: func(name string) string { + return kitInstance.Extensions().GetOption(name) + }, + SetOption: func(name string, value string) { + kitInstance.Extensions().SetOption(name, value) + }, + SetModel: func(modelString string) error { + // Capture previous model for the ModelChange event. + previousModel := kitInstance.Extensions().GetContext().Model + err := kitInstance.SetModel(context.Background(), modelString) + if err != nil { + return err + } + // Notify TUI so it updates model in status bar. + p, m, _ := models.ParseModelString(modelString) + appInstance.NotifyModelChanged(p, m) + // Update the context's Model field so handlers see it. + kitInstance.Extensions().UpdateContextModel(modelString) + // Fire OnModelChange event to extensions. + kitInstance.Extensions().EmitModelChange(modelString, previousModel, "extension") + // 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 + }, + GetAvailableModels: func() []extensions.ModelInfoEntry { + return kitInstance.GetAvailableModels() + }, + EmitCustomEvent: func(name string, data string) { + kitInstance.Extensions().EmitCustomEvent(name, data) + }, + Complete: func(req extensions.CompleteRequest) (extensions.CompleteResponse, error) { + return kitInstance.ExecuteCompletion(context.Background(), req) + }, + SuspendTUI: func(callback func()) error { + return appInstance.SuspendTUI(callback) + }, + RenderMessage: func(rendererName, content string) { + renderer := kitInstance.Extensions().GetMessageRenderer(rendererName) + if renderer == nil || renderer.Render == nil { + appInstance.PrintFromExtension("", content) + return + } + w, _, _ := term.GetSize(int(os.Stdout.Fd())) + if w == 0 { + w = 80 + } + rendered := renderer.Render(content, w) + appInstance.PrintFromExtension("", rendered) + }, + ReloadExtensions: func() error { + err := kitInstance.Extensions().Reload() + if err != nil { + return err + } + // Notify TUI that widgets/status/commands may have changed. + go appInstance.NotifyWidgetUpdate() + return nil + }, + GetAllTools: func() []extensions.ToolInfo { + return kitInstance.Extensions().GetToolInfos() + }, + SetActiveTools: func(names []string) { + kitInstance.Extensions().SetActiveTools(names) + }, + RegisterTheme: func(name string, config extensions.ThemeColorConfig) { + tc := func(c extensions.ThemeColor) [2]string { return [2]string{c.Light, c.Dark} } + ui.RegisterThemeFromConfig(name, + tc(config.Primary), tc(config.Secondary), + tc(config.Success), tc(config.Warning), + tc(config.Error), tc(config.Info), + tc(config.Text), tc(config.Muted), + tc(config.VeryMuted), tc(config.Background), + tc(config.Border), tc(config.MutedBorder), + tc(config.System), tc(config.Tool), + tc(config.Accent), tc(config.Highlight), + tc(config.MdHeading), tc(config.MdLink), + tc(config.MdKeyword), tc(config.MdString), + tc(config.MdNumber), tc(config.MdComment), + ) + }, + SetTheme: func(name string) error { + return ui.ApplyTheme(name) + }, + ListThemes: func() []string { + return ui.ListThemes() + }, + ShowOverlay: func(config extensions.OverlayConfig) extensions.OverlayResult { + ch := make(chan app.OverlayResponse, 1) + appInstance.SendOverlayRequest(app.OverlayRequestEvent{ + Title: config.Title, + Content: config.Content.Text, + Markdown: config.Content.Markdown, + BorderColor: config.Style.BorderColor, + Background: config.Style.Background, + Width: config.Width, + MaxHeight: config.MaxHeight, + Anchor: string(config.Anchor), + Actions: config.Actions, + ResponseCh: ch, + }) + resp := <-ch + if resp.Cancelled { + return extensions.OverlayResult{Cancelled: true, Index: -1} + } + return extensions.OverlayResult{ + Action: resp.Action, + Index: resp.Index, + } + }, + SpawnSubagent: func(config extensions.SubagentConfig) (*extensions.SubagentHandle, *extensions.SubagentResult, error) { + return extbridge.SpawnSubagent(ctx, kitInstance, config) + }, + // ------------------------------------------------------------------- + // Tree Navigation API + // ------------------------------------------------------------------- + GetTreeNode: func(entryID string) *extensions.TreeNode { + node := kitInstance.GetTreeNode(entryID) + if node == nil { + return nil + } + return &extensions.TreeNode{ + ID: node.ID, + ParentID: node.ParentID, + Type: node.Type, + Role: node.Role, + Content: node.Content, + Model: node.Model, + Provider: node.Provider, + Timestamp: node.Timestamp, + Children: node.Children, + } + }, + GetCurrentBranch: func() []extensions.TreeNode { + nodes := kitInstance.GetCurrentBranch() + result := make([]extensions.TreeNode, len(nodes)) + for i, n := range nodes { + result[i] = extensions.TreeNode{ + ID: n.ID, + ParentID: n.ParentID, + Type: n.Type, + Role: n.Role, + Content: n.Content, + Model: n.Model, + Provider: n.Provider, + Timestamp: n.Timestamp, + Children: n.Children, + } + } + return result + }, + GetChildren: kitInstance.GetChildren, + NavigateTo: func(entryID string) extensions.TreeNavigationResult { + err := kitInstance.NavigateTo(entryID) + if err != nil { + return extensions.TreeNavigationResult{Success: false, Error: err.Error()} + } + return extensions.TreeNavigationResult{Success: true} + }, + SummarizeBranch: func(fromID, toID string) string { + summary, _ := kitInstance.SummarizeBranch(fromID, toID) + return summary + }, + CollapseBranch: func(fromID, toID, summary string) extensions.TreeNavigationResult { + err := kitInstance.CollapseBranch(fromID, toID, summary) + if err != nil { + return extensions.TreeNavigationResult{Success: false, Error: err.Error()} + } + return extensions.TreeNavigationResult{Success: true} + }, + + // ------------------------------------------------------------------- + // Skill Loading API + // ------------------------------------------------------------------- + LoadSkill: func(path string) (*extensions.Skill, string) { + s, err := kitInstance.LoadSkillForExtension(path) + return s, err + }, + LoadSkillsFromDir: func(dir string) extensions.SkillLoadResult { + return kitInstance.LoadSkillsFromDirForExtension(dir) + }, + DiscoverSkills: func() extensions.SkillLoadResult { + skills := kitInstance.DiscoverSkillsForExtension() + return extensions.SkillLoadResult{Skills: skills} + }, + InjectSkillAsContext: func(skillName string) string { + skills := kitInstance.DiscoverSkillsForExtension() + for _, s := range skills { + if s.Name == skillName { + appInstance.Run(fmt.Sprintf("\n%s\n", s.Name, s.Content)) + return "" + } + } + return fmt.Sprintf("skill not found: %s", skillName) + }, + InjectRawSkillAsContext: func(path string) string { + s, err := kitInstance.LoadSkillForExtension(path) + if err != "" { + return err + } + appInstance.Run(fmt.Sprintf("\n%s\n", s.Name, s.Content)) + return "" + }, + GetAvailableSkills: kitInstance.DiscoverSkillsForExtension, + + // ------------------------------------------------------------------- + // Template Parsing API + // ------------------------------------------------------------------- + ParseTemplate: kit.ParseTemplate, + RenderTemplate: kit.RenderTemplate, + ParseArguments: kit.ParseArguments, + SimpleParseArguments: kit.SimpleParseArguments, + EvaluateModelConditional: func(condition string) bool { + return kit.EvaluateModelConditional(kitInstance.Extensions().GetContext().Model, condition) + }, + RenderWithModelConditionals: func(content string) string { + return kit.RenderWithModelConditionals(content, kitInstance.Extensions().GetContext().Model) + }, + + // ------------------------------------------------------------------- + // Model Resolution API + // ------------------------------------------------------------------- + ResolveModelChain: kit.ResolveModelChain, + GetModelCapabilities: func(model string) (extensions.ModelCapabilities, string) { + return kit.GetModelCapabilities(model) + }, + CheckModelAvailable: kit.CheckModelAvailable, + GetCurrentProvider: func() string { + return kit.GetCurrentProvider(kitInstance.Extensions().GetContext().Model) + }, + GetCurrentModelID: func() string { + return kit.GetCurrentModelID(kitInstance.Extensions().GetContext().Model) + }, + } +} diff --git a/cmd/root.go b/cmd/root.go index 93155dbe..4b9e90ab 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -14,7 +14,6 @@ import ( "github.com/mark3labs/kit/internal/app" "github.com/mark3labs/kit/internal/auth" "github.com/mark3labs/kit/internal/config" - "github.com/mark3labs/kit/internal/extbridge" "github.com/mark3labs/kit/internal/extensions" "github.com/mark3labs/kit/internal/models" "github.com/mark3labs/kit/internal/prompts" @@ -845,685 +844,42 @@ func runNormalMode(ctx context.Context) error { // Set up extension context and emit SessionStart. if kitInstance.Extensions().HasExtensions() { cwd, _ := os.Getwd() - kitInstance.Extensions().SetContext(extensions.Context{ - CWD: cwd, - Model: modelName, - Interactive: positionalPrompt == "", - Print: func(text string) { - // Capture messages during startup, print after startup banner. - startupExtensionMessages = append(startupExtensionMessages, text) - }, - PrintInfo: func(text string) { - startupExtensionMessages = append(startupExtensionMessages, text) - }, - PrintError: func(text string) { - startupExtensionMessages = append(startupExtensionMessages, text) - }, - PrintBlock: appInstance.PrintBlockFromExtension, - SendMessage: func(text string) { appInstance.Run(text) }, - CancelAndSend: func(text string) { appInstance.InterruptAndSend(text) }, - Abort: func() { appInstance.Abort() }, - IsIdle: func() bool { return !appInstance.IsBusy() }, - Compact: func(cfg extensions.CompactConfig) error { - return appInstance.CompactAsync(cfg.CustomInstructions, cfg.OnComplete, cfg.OnError) - }, - SendMultimodalMessage: func(text string, files []extensions.FilePart) { - parts := make([]kit.LLMFilePart, len(files)) - for i, f := range files { - parts[i] = kit.LLMFilePart{ - Filename: f.Filename, - Data: f.Data, - MediaType: f.MediaType, - } - } - appInstance.RunWithFiles(text, parts) - }, - GetSessionUsage: func() extensions.SessionUsage { - if usageTracker == nil { - return extensions.SessionUsage{} - } - stats := usageTracker.GetSessionStats() - return extensions.SessionUsage{ - TotalInputTokens: stats.TotalInputTokens, - TotalOutputTokens: stats.TotalOutputTokens, - TotalCacheReadTokens: stats.TotalCacheReadTokens, - TotalCacheWriteTokens: stats.TotalCacheWriteTokens, - TotalCost: stats.TotalCost, - RequestCount: stats.RequestCount, - } - }, - Exit: func() { appInstance.QuitFromExtension() }, - SetWidget: func(config extensions.WidgetConfig) { - kitInstance.Extensions().SetWidget(config) - go appInstance.NotifyWidgetUpdate() - }, - RemoveWidget: func(id string) { - kitInstance.Extensions().RemoveWidget(id) - go appInstance.NotifyWidgetUpdate() - }, - SetHeader: func(config extensions.HeaderFooterConfig) { - kitInstance.Extensions().SetHeader(config) - go appInstance.NotifyWidgetUpdate() - }, - RemoveHeader: func() { - kitInstance.Extensions().RemoveHeader() - go appInstance.NotifyWidgetUpdate() - }, - SetFooter: func(config extensions.HeaderFooterConfig) { - kitInstance.Extensions().SetFooter(config) - go appInstance.NotifyWidgetUpdate() - }, - RemoveFooter: func() { - kitInstance.Extensions().RemoveFooter() - go appInstance.NotifyWidgetUpdate() - }, - PromptSelect: func(config extensions.PromptSelectConfig) extensions.PromptSelectResult { - ch := make(chan app.PromptResponse, 1) - appInstance.SendPromptRequest(app.PromptRequestEvent{ - PromptType: "select", - Message: config.Message, - Options: config.Options, - ResponseCh: ch, - }) - resp := <-ch - if resp.Cancelled { - return extensions.PromptSelectResult{Cancelled: true} - } - return extensions.PromptSelectResult{Value: resp.Value, Index: resp.Index} - }, - PromptConfirm: func(config extensions.PromptConfirmConfig) extensions.PromptConfirmResult { - ch := make(chan app.PromptResponse, 1) - def := "false" - if config.DefaultValue { - def = "true" - } - appInstance.SendPromptRequest(app.PromptRequestEvent{ - PromptType: "confirm", - Message: config.Message, - Default: def, - ResponseCh: ch, - }) - resp := <-ch - if resp.Cancelled { - return extensions.PromptConfirmResult{Cancelled: true} - } - return extensions.PromptConfirmResult{Value: resp.Confirmed} - }, - PromptInput: func(config extensions.PromptInputConfig) extensions.PromptInputResult { - ch := make(chan app.PromptResponse, 1) - appInstance.SendPromptRequest(app.PromptRequestEvent{ - PromptType: "input", - Message: config.Message, - Placeholder: config.Placeholder, - Default: config.Default, - ResponseCh: ch, - }) - resp := <-ch - if resp.Cancelled { - return extensions.PromptInputResult{Cancelled: true} - } - return extensions.PromptInputResult{Value: resp.Value} - }, - SetUIVisibility: func(v extensions.UIVisibility) { - kitInstance.Extensions().SetUIVisibility(v) - go appInstance.NotifyWidgetUpdate() - }, - GetContextStats: func() extensions.ContextStats { - s := kitInstance.GetContextStats() - return extensions.ContextStats{ - EstimatedTokens: s.EstimatedTokens, - ContextLimit: s.ContextLimit, - UsagePercent: s.UsagePercent, - MessageCount: s.MessageCount, - } - }, - SetEditor: func(config extensions.EditorConfig) { - kitInstance.Extensions().SetEditor(config) - // Always use a goroutine for NotifyWidgetUpdate: prog.Send() - // deadlocks if called synchronously from inside BubbleTea's - // Update() handler. All call sites use go-routines uniformly. - go appInstance.NotifyWidgetUpdate() - }, - ResetEditor: func() { - kitInstance.Extensions().ResetEditor() - go appInstance.NotifyWidgetUpdate() - }, - GetMessages: func() []extensions.SessionMessage { - return kitInstance.Extensions().GetSessionMessages() - }, - GetSessionPath: func() string { - return kitInstance.GetSessionPath() - }, - AppendEntry: func(entryType string, data string) (string, error) { - return kitInstance.Extensions().AppendEntry(entryType, data) - }, - GetEntries: func(entryType string) []extensions.ExtensionEntry { - return kitInstance.Extensions().GetEntries(entryType) - }, - SetEditorText: func(text string) { - appInstance.SetEditorTextFromExtension(text) - }, - SetStatus: func(key string, text string, priority int) { - kitInstance.Extensions().SetStatus(extensions.StatusBarEntry{ - Key: key, - Text: text, - Priority: priority, - }) - go appInstance.NotifyWidgetUpdate() - }, - RemoveStatus: func(key string) { - kitInstance.Extensions().RemoveStatus(key) - go appInstance.NotifyWidgetUpdate() - }, - GetOption: func(name string) string { - return kitInstance.Extensions().GetOption(name) - }, - SetOption: func(name string, value string) { - kitInstance.Extensions().SetOption(name, value) - }, - SetModel: func(modelString string) error { - // Capture previous model for the ModelChange event. - previousModel := kitInstance.Extensions().GetContext().Model - err := kitInstance.SetModel(context.Background(), modelString) - if err != nil { - return err - } - // Notify TUI so it updates model in status bar. - p, m, _ := models.ParseModelString(modelString) - appInstance.NotifyModelChanged(p, m) - // Update the context's Model field so handlers see it. - kitInstance.Extensions().UpdateContextModel(modelString) - // Fire OnModelChange event to extensions. - kitInstance.Extensions().EmitModelChange(modelString, previousModel, "extension") - // 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 - }, - GetAvailableModels: func() []extensions.ModelInfoEntry { - return kitInstance.GetAvailableModels() - }, - EmitCustomEvent: func(name string, data string) { - kitInstance.Extensions().EmitCustomEvent(name, data) - }, - Complete: func(req extensions.CompleteRequest) (extensions.CompleteResponse, error) { - return kitInstance.ExecuteCompletion(context.Background(), req) - }, - SuspendTUI: func(callback func()) error { - return appInstance.SuspendTUI(callback) - }, - RenderMessage: func(rendererName, content string) { - renderer := kitInstance.Extensions().GetMessageRenderer(rendererName) - if renderer == nil || renderer.Render == nil { - appInstance.PrintFromExtension("", content) - return - } - w, _, _ := term.GetSize(int(os.Stdout.Fd())) - if w == 0 { - w = 80 - } - rendered := renderer.Render(content, w) - appInstance.PrintFromExtension("", rendered) - }, - ReloadExtensions: func() error { - err := kitInstance.Extensions().Reload() - if err != nil { - return err - } - // Notify TUI that widgets/status/commands may have changed. - go appInstance.NotifyWidgetUpdate() - return nil - }, - GetAllTools: func() []extensions.ToolInfo { - return kitInstance.Extensions().GetToolInfos() - }, - SetActiveTools: func(names []string) { - kitInstance.Extensions().SetActiveTools(names) - }, - RegisterTheme: func(name string, config extensions.ThemeColorConfig) { - tc := func(c extensions.ThemeColor) [2]string { return [2]string{c.Light, c.Dark} } - ui.RegisterThemeFromConfig(name, - tc(config.Primary), tc(config.Secondary), - tc(config.Success), tc(config.Warning), - tc(config.Error), tc(config.Info), - tc(config.Text), tc(config.Muted), - tc(config.VeryMuted), tc(config.Background), - tc(config.Border), tc(config.MutedBorder), - tc(config.System), tc(config.Tool), - tc(config.Accent), tc(config.Highlight), - tc(config.MdHeading), tc(config.MdLink), - tc(config.MdKeyword), tc(config.MdString), - tc(config.MdNumber), tc(config.MdComment), - ) - }, - SetTheme: func(name string) error { - return ui.ApplyTheme(name) - }, - ListThemes: func() []string { - return ui.ListThemes() - }, - ShowOverlay: func(config extensions.OverlayConfig) extensions.OverlayResult { - ch := make(chan app.OverlayResponse, 1) - appInstance.SendOverlayRequest(app.OverlayRequestEvent{ - Title: config.Title, - Content: config.Content.Text, - Markdown: config.Content.Markdown, - BorderColor: config.Style.BorderColor, - Background: config.Style.Background, - Width: config.Width, - MaxHeight: config.MaxHeight, - Anchor: string(config.Anchor), - Actions: config.Actions, - ResponseCh: ch, - }) - resp := <-ch - if resp.Cancelled { - return extensions.OverlayResult{Cancelled: true, Index: -1} - } - return extensions.OverlayResult{ - Action: resp.Action, - Index: resp.Index, - } - }, - SpawnSubagent: func(config extensions.SubagentConfig) (*extensions.SubagentHandle, *extensions.SubagentResult, error) { - return extbridge.SpawnSubagent(ctx, kitInstance, config) - }, - GetTreeNode: func(entryID string) *extensions.TreeNode { - node := kitInstance.GetTreeNode(entryID) - if node == nil { - return nil - } - return &extensions.TreeNode{ - ID: node.ID, - ParentID: node.ParentID, - Type: node.Type, - Role: node.Role, - Content: node.Content, - Model: node.Model, - Provider: node.Provider, - Timestamp: node.Timestamp, - Children: node.Children, - } - }, - GetCurrentBranch: func() []extensions.TreeNode { - nodes := kitInstance.GetCurrentBranch() - result := make([]extensions.TreeNode, len(nodes)) - for i, n := range nodes { - result[i] = extensions.TreeNode{ - ID: n.ID, - ParentID: n.ParentID, - Type: n.Type, - Role: n.Role, - Content: n.Content, - Model: n.Model, - Provider: n.Provider, - Timestamp: n.Timestamp, - Children: n.Children, - } - } - return result - }, - GetChildren: kitInstance.GetChildren, - NavigateTo: func(entryID string) extensions.TreeNavigationResult { - err := kitInstance.NavigateTo(entryID) - if err != nil { - return extensions.TreeNavigationResult{Success: false, Error: err.Error()} - } - return extensions.TreeNavigationResult{Success: true} - }, - SummarizeBranch: func(fromID, toID string) string { - summary, _ := kitInstance.SummarizeBranch(fromID, toID) - return summary - }, - CollapseBranch: func(fromID, toID, summary string) extensions.TreeNavigationResult { - err := kitInstance.CollapseBranch(fromID, toID, summary) - if err != nil { - return extensions.TreeNavigationResult{Success: false, Error: err.Error()} - } - return extensions.TreeNavigationResult{Success: true} - }, - - // ------------------------------------------------------------------------- - // Skill Loading API (Phase 2 Bridge) - // ------------------------------------------------------------------------- - LoadSkill: func(path string) (*extensions.Skill, string) { - s, err := kitInstance.LoadSkillForExtension(path) - return s, err - }, - LoadSkillsFromDir: func(dir string) extensions.SkillLoadResult { - return kitInstance.LoadSkillsFromDirForExtension(dir) - }, - DiscoverSkills: func() extensions.SkillLoadResult { - skills := kitInstance.DiscoverSkillsForExtension() - return extensions.SkillLoadResult{Skills: skills} - }, - InjectSkillAsContext: func(skillName string) string { - // Find skill by name - skills := kitInstance.DiscoverSkillsForExtension() - for _, s := range skills { - if s.Name == skillName { - // Inject via SendMessage as a system context message - appInstance.Run(fmt.Sprintf("\n%s\n", s.Name, s.Content)) - return "" - } - } - return fmt.Sprintf("skill not found: %s", skillName) - }, - InjectRawSkillAsContext: func(path string) string { - s, err := kitInstance.LoadSkillForExtension(path) - if err != "" { - return err - } - appInstance.Run(fmt.Sprintf("\n%s\n", s.Name, s.Content)) - return "" - }, - GetAvailableSkills: kitInstance.DiscoverSkillsForExtension, - - // ------------------------------------------------------------------------- - // Template Parsing API (Phase 3 Bridge) - // ------------------------------------------------------------------------- - ParseTemplate: kit.ParseTemplate, - RenderTemplate: kit.RenderTemplate, - ParseArguments: kit.ParseArguments, - SimpleParseArguments: kit.SimpleParseArguments, - EvaluateModelConditional: func(condition string) bool { - return kit.EvaluateModelConditional(kitInstance.Extensions().GetContext().Model, condition) - }, - RenderWithModelConditionals: func(content string) string { - return kit.RenderWithModelConditionals(content, kitInstance.Extensions().GetContext().Model) - }, - - // ------------------------------------------------------------------------- - // Model Resolution API (Phase 4 Bridge) - // ------------------------------------------------------------------------- - ResolveModelChain: kit.ResolveModelChain, - GetModelCapabilities: func(model string) (extensions.ModelCapabilities, string) { - return kit.GetModelCapabilities(model) - }, - CheckModelAvailable: kit.CheckModelAvailable, - GetCurrentProvider: func() string { - return kit.GetCurrentProvider(kitInstance.Extensions().GetContext().Model) - }, - GetCurrentModelID: func() string { - return kit.GetCurrentModelID(kitInstance.Extensions().GetContext().Model) - }, + extCtx := buildInteractiveExtensionContext(extensionContextDeps{ + ctx: ctx, + cwd: cwd, + modelName: modelName, + interactive: positionalPrompt == "", + kitInstance: kitInstance, + appInstance: appInstance, + usageTracker: usageTracker, }) + extCtx.Print = func(text string) { + // Capture messages during startup, print after startup banner. + 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. - kitInstance.Extensions().SetContext(extensions.Context{ - CWD: cwd, - Model: modelName, - Interactive: positionalPrompt == "", - Print: func(text string) { appInstance.PrintFromExtension("", text) }, - PrintInfo: func(text string) { appInstance.PrintFromExtension("info", text) }, - PrintError: func(text string) { appInstance.PrintFromExtension("error", text) }, - PrintBlock: appInstance.PrintBlockFromExtension, - SendMessage: func(text string) { appInstance.Run(text) }, - CancelAndSend: func(text string) { appInstance.InterruptAndSend(text) }, - Abort: func() { appInstance.Abort() }, - IsIdle: func() bool { return !appInstance.IsBusy() }, - Compact: func(cfg extensions.CompactConfig) error { - return appInstance.CompactAsync(cfg.CustomInstructions, cfg.OnComplete, cfg.OnError) - }, - SendMultimodalMessage: func(text string, files []extensions.FilePart) { - parts := make([]kit.LLMFilePart, len(files)) - for i, f := range files { - parts[i] = kit.LLMFilePart{ - Filename: f.Filename, - Data: f.Data, - MediaType: f.MediaType, - } - } - appInstance.RunWithFiles(text, parts) - }, - GetSessionUsage: func() extensions.SessionUsage { - if usageTracker == nil { - return extensions.SessionUsage{} - } - stats := usageTracker.GetSessionStats() - return extensions.SessionUsage{ - TotalInputTokens: stats.TotalInputTokens, - TotalOutputTokens: stats.TotalOutputTokens, - TotalCacheReadTokens: stats.TotalCacheReadTokens, - TotalCacheWriteTokens: stats.TotalCacheWriteTokens, - TotalCost: stats.TotalCost, - RequestCount: stats.RequestCount, - } - }, - Exit: func() { appInstance.QuitFromExtension() }, - SetWidget: func(config extensions.WidgetConfig) { - kitInstance.Extensions().SetWidget(config) - go appInstance.NotifyWidgetUpdate() - }, - RemoveWidget: func(id string) { - kitInstance.Extensions().RemoveWidget(id) - go appInstance.NotifyWidgetUpdate() - }, - SetHeader: func(config extensions.HeaderFooterConfig) { - kitInstance.Extensions().SetHeader(config) - go appInstance.NotifyWidgetUpdate() - }, - RemoveHeader: func() { - kitInstance.Extensions().RemoveHeader() - go appInstance.NotifyWidgetUpdate() - }, - SetFooter: func(config extensions.HeaderFooterConfig) { - kitInstance.Extensions().SetFooter(config) - go appInstance.NotifyWidgetUpdate() - }, - RemoveFooter: func() { - kitInstance.Extensions().RemoveFooter() - go appInstance.NotifyWidgetUpdate() - }, - PromptSelect: func(config extensions.PromptSelectConfig) extensions.PromptSelectResult { - ch := make(chan app.PromptResponse, 1) - appInstance.SendPromptRequest(app.PromptRequestEvent{ - PromptType: "select", - Message: config.Message, - Options: config.Options, - ResponseCh: ch, - }) - resp := <-ch - if resp.Cancelled { - return extensions.PromptSelectResult{Cancelled: true} - } - return extensions.PromptSelectResult{Value: resp.Value, Index: resp.Index} - }, - PromptConfirm: func(config extensions.PromptConfirmConfig) extensions.PromptConfirmResult { - ch := make(chan app.PromptResponse, 1) - def := "false" - if config.DefaultValue { - def = "true" - } - appInstance.SendPromptRequest(app.PromptRequestEvent{ - PromptType: "confirm", - Message: config.Message, - Default: def, - ResponseCh: ch, - }) - resp := <-ch - if resp.Cancelled { - return extensions.PromptConfirmResult{Cancelled: true} - } - return extensions.PromptConfirmResult{Value: resp.Confirmed} - }, - PromptInput: func(config extensions.PromptInputConfig) extensions.PromptInputResult { - ch := make(chan app.PromptResponse, 1) - appInstance.SendPromptRequest(app.PromptRequestEvent{ - PromptType: "input", - Message: config.Message, - Placeholder: config.Placeholder, - Default: config.Default, - ResponseCh: ch, - }) - resp := <-ch - if resp.Cancelled { - return extensions.PromptInputResult{Cancelled: true} - } - return extensions.PromptInputResult{Value: resp.Value} - }, - ShowOverlay: func(config extensions.OverlayConfig) extensions.OverlayResult { - ch := make(chan app.OverlayResponse, 1) - appInstance.SendOverlayRequest(app.OverlayRequestEvent{ - Title: config.Title, - Content: config.Content.Text, - Markdown: config.Content.Markdown, - BorderColor: config.Style.BorderColor, - Background: config.Style.Background, - Width: config.Width, - MaxHeight: config.MaxHeight, - Anchor: string(config.Anchor), - Actions: config.Actions, - ResponseCh: ch, - }) - resp := <-ch - if resp.Cancelled { - return extensions.OverlayResult{Cancelled: true, Index: -1} - } - return extensions.OverlayResult{ - Action: resp.Action, - Index: resp.Index, - } - }, - SpawnSubagent: func(config extensions.SubagentConfig) (*extensions.SubagentHandle, *extensions.SubagentResult, error) { - return extbridge.SpawnSubagent(ctx, kitInstance, config) - }, - GetTreeNode: func(entryID string) *extensions.TreeNode { - node := kitInstance.GetTreeNode(entryID) - if node == nil { - return nil - } - return &extensions.TreeNode{ - ID: node.ID, - ParentID: node.ParentID, - Type: node.Type, - Role: node.Role, - Content: node.Content, - Model: node.Model, - Provider: node.Provider, - Timestamp: node.Timestamp, - Children: node.Children, - } - }, - GetCurrentBranch: func() []extensions.TreeNode { - nodes := kitInstance.GetCurrentBranch() - result := make([]extensions.TreeNode, len(nodes)) - for i, n := range nodes { - result[i] = extensions.TreeNode{ - ID: n.ID, - ParentID: n.ParentID, - Type: n.Type, - Role: n.Role, - Content: n.Content, - Model: n.Model, - Provider: n.Provider, - Timestamp: n.Timestamp, - Children: n.Children, - } - } - return result - }, - GetChildren: kitInstance.GetChildren, - NavigateTo: func(entryID string) extensions.TreeNavigationResult { - err := kitInstance.NavigateTo(entryID) - if err != nil { - return extensions.TreeNavigationResult{Success: false, Error: err.Error()} - } - return extensions.TreeNavigationResult{Success: true} - }, - SummarizeBranch: func(fromID, toID string) string { - summary, _ := kitInstance.SummarizeBranch(fromID, toID) - return summary - }, - CollapseBranch: func(fromID, toID, summary string) extensions.TreeNavigationResult { - err := kitInstance.CollapseBranch(fromID, toID, summary) - if err != nil { - return extensions.TreeNavigationResult{Success: false, Error: err.Error()} - } - return extensions.TreeNavigationResult{Success: true} - }, - - // ------------------------------------------------------------------------- - // Skill Loading API (Phase 2 Bridge) - Second Context - // ------------------------------------------------------------------------- - LoadSkill: func(path string) (*extensions.Skill, string) { - s, err := kitInstance.LoadSkillForExtension(path) - return s, err - }, - LoadSkillsFromDir: func(dir string) extensions.SkillLoadResult { - return kitInstance.LoadSkillsFromDirForExtension(dir) - }, - DiscoverSkills: func() extensions.SkillLoadResult { - skills := kitInstance.DiscoverSkillsForExtension() - return extensions.SkillLoadResult{Skills: skills} - }, - InjectSkillAsContext: func(skillName string) string { - skills := kitInstance.DiscoverSkillsForExtension() - for _, s := range skills { - if s.Name == skillName { - appInstance.Run(fmt.Sprintf("\n%s\n", s.Name, s.Content)) - return "" - } - } - return fmt.Sprintf("skill not found: %s", skillName) - }, - InjectRawSkillAsContext: func(path string) string { - s, err := kitInstance.LoadSkillForExtension(path) - if err != "" { - return err - } - appInstance.Run(fmt.Sprintf("\n%s\n", s.Name, s.Content)) - return "" - }, - GetAvailableSkills: func() []extensions.Skill { - return kitInstance.DiscoverSkillsForExtension() - }, - - // ------------------------------------------------------------------------- - // Template Parsing API (Phase 3 Bridge) - Second Context - // ------------------------------------------------------------------------- - ParseTemplate: kit.ParseTemplate, - RenderTemplate: kit.RenderTemplate, - ParseArguments: kit.ParseArguments, - SimpleParseArguments: kit.SimpleParseArguments, - EvaluateModelConditional: func(condition string) bool { - return kit.EvaluateModelConditional(kitInstance.Extensions().GetContext().Model, condition) - }, - RenderWithModelConditionals: func(content string) string { - return kit.RenderWithModelConditionals(content, kitInstance.Extensions().GetContext().Model) - }, - - // ------------------------------------------------------------------------- - // Model Resolution API (Phase 4 Bridge) - Second Context - // ------------------------------------------------------------------------- - ResolveModelChain: kit.ResolveModelChain, - GetModelCapabilities: func(model string) (extensions.ModelCapabilities, string) { - return kit.GetModelCapabilities(model) - }, - CheckModelAvailable: kit.CheckModelAvailable, - GetCurrentProvider: func() string { - return kit.GetCurrentProvider(kitInstance.Extensions().GetContext().Model) - }, - GetCurrentModelID: func() string { - return kit.GetCurrentModelID(kitInstance.Extensions().GetContext().Model) - }, + extCtx = buildInteractiveExtensionContext(extensionContextDeps{ + ctx: ctx, + cwd: cwd, + modelName: modelName, + interactive: positionalPrompt == "", + kitInstance: kitInstance, + appInstance: appInstance, + usageTracker: usageTracker, }) + 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.