From 6ac8d3983ae4d51eb389ac0d35a4ca6bf4a1ac2c Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Fri, 27 Feb 2026 00:41:48 +0300 Subject: [PATCH] add SendMessage to extension Context and fix all golangci-lint issues SendMessage lets extensions inject messages into the conversation and trigger new agent turns, enabling async patterns like background subagent execution. It delegates to app.Run() which handles queueing. CommandDef.Execute now receives Context so commands can access SendMessage, Print*, and session metadata. The UI layer wraps the call via runner.GetContext() at the boundary. Also fixes all 20+ golangci-lint issues across the codebase: errcheck, modernize (min/max/slices.Contains/SplitSeq/range-over-int), staticcheck (error string casing), and unused code removal. --- btca.config.jsonc | 4 +-- cmd/extensions.go | 26 +++++++++++++++- cmd/root.go | 5 ++- internal/core/bash.go | 4 +-- internal/core/edit.go | 5 +-- internal/extensions/api.go | 14 ++++++++- internal/extensions/events.go | 9 ++---- internal/extensions/loader.go | 2 +- internal/extensions/loader_test.go | 38 ++++++----------------- internal/extensions/runner.go | 7 +++++ internal/session/store.go | 2 +- internal/session/tree_manager.go | 2 +- internal/ui/compact_renderer.go | 30 ++---------------- internal/ui/enhanced_styles.go | 10 ++---- internal/ui/messages.go | 5 +-- internal/ui/model.go | 8 +++-- internal/ui/tool_renderers.go | 49 ++++++------------------------ internal/ui/tree_selector.go | 10 ++---- 18 files changed, 89 insertions(+), 141 deletions(-) diff --git a/btca.config.jsonc b/btca.config.jsonc index 73cf32cc..e63f35ab 100644 --- a/btca.config.jsonc +++ b/btca.config.jsonc @@ -63,9 +63,9 @@ "type": "git", "name": "yaegi", "url": "https://github.com/traefik/yaegi", - "branch": "main" + "branch": "master" } ], "model": "claude-haiku-4-5", "provider": "opencode" -} \ No newline at end of file +} diff --git a/cmd/extensions.go b/cmd/extensions.go index 9eab83a2..ec31e2b1 100644 --- a/cmd/extensions.go +++ b/cmd/extensions.go @@ -103,6 +103,7 @@ import ( "encoding/json" "fmt" "os" + "os/exec" "strings" "time" @@ -157,7 +158,7 @@ func Init(api ext.API) { api.RegisterCommand(ext.CommandDef{ Name: "echo", Description: "Echo back the provided text", - Execute: func(args string) (string, error) { + Execute: func(args string, ctx ext.Context) (string, error) { if args == "" { return "Usage: /echo ", nil } @@ -165,6 +166,29 @@ func Init(api ext.API) { }, }) + // ── Background work with SendMessage ───────────────────────────── + // ctx.SendMessage injects a message into the conversation and + // triggers a new agent turn. Safe to call from goroutines. + + api.RegisterCommand(ext.CommandDef{ + Name: "run", + Description: "Run a shell command in the background and feed the result to the agent", + Execute: func(args string, ctx ext.Context) (string, error) { + if args == "" { + return "Usage: /run ", nil + } + go func() { + out, err := exec.Command("sh", "-c", args).CombinedOutput() + if err != nil { + ctx.SendMessage(fmt.Sprintf("Background command %q failed: %v\n%s", args, err, out)) + return + } + ctx.SendMessage(fmt.Sprintf("Background command %q finished:\n%s", args, out)) + }() + return fmt.Sprintf("Running %q in background...", args), nil + }, + }) + // ── Custom tools ────────────────────────────────────────────────── // Custom tools are added to the agent's tool list and can be // called by the LLM. Parameters is a JSON Schema string. diff --git a/cmd/root.go b/cmd/root.go index d772e79c..f17a1972 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -379,7 +379,9 @@ func extensionCommandsForUI(runner *extensions.Runner) []ui.ExtensionCommand { cmds = append(cmds, ui.ExtensionCommand{ Name: name, Description: d.Description, - Execute: d.Execute, + Execute: func(args string) (string, error) { + return d.Execute(args, runner.GetContext()) + }, }) } return cmds @@ -610,6 +612,7 @@ func runNormalMode(ctx context.Context) error { 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) }, }) if agentResult.ExtRunner.HasHandlers(extensions.SessionStart) { _, _ = agentResult.ExtRunner.Emit(extensions.SessionStartEvent{}) diff --git a/internal/core/bash.go b/internal/core/bash.go index e9237728..4f9776d3 100644 --- a/internal/core/bash.go +++ b/internal/core/bash.go @@ -76,9 +76,7 @@ func executeBash(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolRespon timeout := defaultBashTimeout if args.Timeout > 0 { timeout = time.Duration(args.Timeout) * time.Second - if timeout > maxBashTimeout { - timeout = maxBashTimeout - } + timeout = min(timeout, maxBashTimeout) } cmdCtx, cancel := context.WithTimeout(ctx, timeout) diff --git a/internal/core/edit.go b/internal/core/edit.go index fa1d16c3..f36bade1 100644 --- a/internal/core/edit.go +++ b/internal/core/edit.go @@ -173,10 +173,7 @@ func generateDiff(path, old, new string, changeIdx int) string { // Show context around the change contextLines := 3 - start := lineNum - contextLines - 1 - if start < 0 { - start = 0 - } + start := max(lineNum-contextLines-1, 0) var diff strings.Builder diff.WriteString(fmt.Sprintf("--- %s\n+++ %s\n", path, path)) diff --git a/internal/extensions/api.go b/internal/extensions/api.go index fb6683cf..ac0d2708 100644 --- a/internal/extensions/api.go +++ b/internal/extensions/api.go @@ -51,6 +51,18 @@ type Context struct { // Subtitle: "my-extension", // }) PrintBlock func(PrintBlockOpts) + + // SendMessage injects a message into the conversation and triggers a + // new agent turn. If the agent is currently busy the message is queued + // and processed after the current turn completes. + // + // This is safe to call from goroutines. Common pattern: + // + // go func() { + // out, _ := exec.Command("kit", "-p", task).Output() + // ctx.SendMessage("Subagent result:\n" + string(out)) + // }() + SendMessage func(string) } // PrintBlockOpts configures a custom styled block for PrintBlock. @@ -189,7 +201,7 @@ type ToolDef struct { type CommandDef struct { Name string Description string - Execute func(args string) (string, error) + Execute func(args string, ctx Context) (string, error) } // --------------------------------------------------------------------------- diff --git a/internal/extensions/events.go b/internal/extensions/events.go index 5eac0b02..75190812 100644 --- a/internal/extensions/events.go +++ b/internal/extensions/events.go @@ -4,6 +4,8 @@ // input transformation, and lifecycle observation — all without recompilation. package extensions +import "slices" + // EventType identifies a point in KIT's lifecycle where extensions can hook in. type EventType string @@ -60,10 +62,5 @@ func AllEventTypes() []EventType { // IsValid returns true if the event type is a recognised lifecycle event. func (e EventType) IsValid() bool { - for _, valid := range AllEventTypes() { - if e == valid { - return true - } - } - return false + return slices.Contains(AllEventTypes(), e) } diff --git a/internal/extensions/loader.go b/internal/extensions/loader.go index 46ade83f..ed9a702c 100644 --- a/internal/extensions/loader.go +++ b/internal/extensions/loader.go @@ -176,7 +176,7 @@ func loadSingleExtension(path string) (*LoadedExtension, error) { initFn, ok := initVal.Interface().(func(API)) if !ok { - return nil, fmt.Errorf("Init has wrong signature (want func(ext.API), got %T)", initVal.Interface()) + return nil, fmt.Errorf("init has wrong signature (want func(ext.API), got %T)", initVal.Interface()) } // Build the API object that wires typed registration methods back to diff --git a/internal/extensions/loader_test.go b/internal/extensions/loader_test.go index e20dacfd..7cd3f081 100644 --- a/internal/extensions/loader_test.go +++ b/internal/extensions/loader_test.go @@ -3,6 +3,7 @@ package extensions import ( "os" "path/filepath" + "slices" "testing" ) @@ -20,14 +21,7 @@ func TestDiscoverExtensionPaths_ExplicitFile(t *testing.T) { } abs, _ := filepath.Abs(f) - found := false - for _, p := range paths { - if p == abs { - found = true - break - } - } - if !found { + if !slices.Contains(paths, abs) { t.Errorf("expected %q in discovered paths %v", abs, paths) } } @@ -41,14 +35,7 @@ func TestDiscoverExtensionPaths_ExplicitDir(t *testing.T) { paths := discoverExtensionPaths([]string{dir}) abs, _ := filepath.Abs(f) - found := false - for _, p := range paths { - if p == abs { - found = true - break - } - } - if !found { + if !slices.Contains(paths, abs) { t.Errorf("expected %q in discovered paths %v", abs, paths) } } @@ -66,14 +53,7 @@ func TestDiscoverExtensionPaths_SubdirMainGo(t *testing.T) { paths := discoverExtensionPaths([]string{dir}) abs, _ := filepath.Abs(main) - found := false - for _, p := range paths { - if p == abs { - found = true - break - } - } - if !found { + if !slices.Contains(paths, abs) { t.Errorf("expected %q in discovered paths %v", abs, paths) } } @@ -298,7 +278,7 @@ func Init(api ext.API) { api.RegisterCommand(ext.CommandDef{ Name: "hello", Description: "says hello", - Execute: func(args string) (string, error) { + Execute: func(args string, ctx ext.Context) (string, error) { return "hello " + args, nil }, }) @@ -405,9 +385,9 @@ func Init(api ext.API) { func TestGlobalExtensionsDir_XDG(t *testing.T) { // Save and restore XDG_CONFIG_HOME. orig := os.Getenv("XDG_CONFIG_HOME") - defer os.Setenv("XDG_CONFIG_HOME", orig) + defer func() { _ = os.Setenv("XDG_CONFIG_HOME", orig) }() - os.Setenv("XDG_CONFIG_HOME", "/custom/config") + _ = os.Setenv("XDG_CONFIG_HOME", "/custom/config") dir := globalExtensionsDir() expected := "/custom/config/kit/extensions" if dir != expected { @@ -417,9 +397,9 @@ func TestGlobalExtensionsDir_XDG(t *testing.T) { func TestGlobalExtensionsDir_Default(t *testing.T) { orig := os.Getenv("XDG_CONFIG_HOME") - defer os.Setenv("XDG_CONFIG_HOME", orig) + defer func() { _ = os.Setenv("XDG_CONFIG_HOME", orig) }() - os.Setenv("XDG_CONFIG_HOME", "") + _ = os.Setenv("XDG_CONFIG_HOME", "") dir := globalExtensionsDir() home, _ := os.UserHomeDir() expected := filepath.Join(home, ".config", "kit", "extensions") diff --git a/internal/extensions/runner.go b/internal/extensions/runner.go index 8336d356..6541b892 100644 --- a/internal/extensions/runner.go +++ b/internal/extensions/runner.go @@ -115,6 +115,13 @@ func (r *Runner) RegisteredCommands() []CommandDef { return cmds } +// GetContext returns the current runtime context. Thread-safe. +func (r *Runner) GetContext() Context { + r.mu.RLock() + defer r.mu.RUnlock() + return r.ctx +} + // Extensions returns the loaded extensions for inspection (e.g. CLI list). func (r *Runner) Extensions() []LoadedExtension { return r.extensions diff --git a/internal/session/store.go b/internal/session/store.go index 310a8f40..8468a3a3 100644 --- a/internal/session/store.go +++ b/internal/session/store.go @@ -129,7 +129,7 @@ func extractSessionInfo(path string) (*SessionInfo, error) { if err != nil { return nil, err } - defer f.Close() + defer func() { _ = f.Close() }() info := &SessionInfo{ Path: path, diff --git a/internal/session/tree_manager.go b/internal/session/tree_manager.go index d07c751b..b40b3511 100644 --- a/internal/session/tree_manager.go +++ b/internal/session/tree_manager.go @@ -106,7 +106,7 @@ func CreateTreeSession(cwd string) (*TreeManager, error) { tm.file = f if err := tm.writeEntry(&header); err != nil { - f.Close() + _ = f.Close() return nil, fmt.Errorf("failed to write session header: %w", err) } diff --git a/internal/ui/compact_renderer.go b/internal/ui/compact_renderer.go index 15f3f119..580462a6 100644 --- a/internal/ui/compact_renderer.go +++ b/internal/ui/compact_renderer.go @@ -156,10 +156,7 @@ func (r *CompactRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin nameStr := lipgloss.NewStyle().Foreground(theme.Tool).Bold(true).Render(displayName) // Format params - paramBudget := r.width - 10 - len(displayName) - if paramBudget < 20 { - paramBudget = 20 - } + paramBudget := max(r.width-10-len(displayName), 20) params := formatToolParams(toolArgs, paramBudget) // Build header line @@ -188,8 +185,7 @@ func (r *CompactRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin var lines []string lines = append(lines, header) if body != "" { - bodyLines := strings.Split(body, "\n") - for _, line := range bodyLines { + for line := range strings.SplitSeq(body, "\n") { lines = append(lines, " "+line) } } @@ -514,25 +510,3 @@ func (r *CompactRenderer) formatBashOutput(result string) string { // Trim any leading/trailing whitespace from the final result return strings.TrimSpace(formattedResult.String()) } - -// determineResultType determines the display type for tool results -func (r *CompactRenderer) determineResultType(toolName, result string) string { - toolName = strings.ToLower(toolName) - - switch { - case strings.Contains(toolName, "read"): - return "Text" - case strings.Contains(toolName, "write"): - return "Write" - case strings.Contains(toolName, "bash") || strings.Contains(toolName, "command") || strings.Contains(toolName, "shell") || toolName == "run_shell_cmd": - return "Bash" - case strings.Contains(toolName, "list") || strings.Contains(toolName, "ls"): - return "List" - case strings.Contains(toolName, "search") || strings.Contains(toolName, "grep"): - return "Search" - case strings.Contains(toolName, "fetch") || strings.Contains(toolName, "http"): - return "Fetch" - default: - return "Result" - } -} diff --git a/internal/ui/enhanced_styles.go b/internal/ui/enhanced_styles.go index 525e0602..a78670dc 100644 --- a/internal/ui/enhanced_styles.go +++ b/internal/ui/enhanced_styles.go @@ -247,17 +247,11 @@ func ApplyGradient(text string, colorA, colorB color.Color) string { } const maxStops = 8 - segmentSize := len(runes) / maxStops - if segmentSize < 1 { - segmentSize = 1 - } + segmentSize := max(len(runes)/maxStops, 1) var result strings.Builder for i := 0; i < len(runes); i += segmentSize { - end := i + segmentSize - if end > len(runes) { - end = len(runes) - } + end := min(i+segmentSize, len(runes)) pos := float64(i) / float64(len(runes)) c := interpolateColor(colorA, colorB, pos) diff --git a/internal/ui/messages.go b/internal/ui/messages.go index 3c3e0443..e6e4822b 100644 --- a/internal/ui/messages.go +++ b/internal/ui/messages.go @@ -544,10 +544,7 @@ func (r *MessageRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin nameStr := lipgloss.NewStyle().Foreground(theme.Tool).Bold(true).Render(displayName) // Format params with width budget for the header line - paramBudget := r.width - 10 - len(displayName) - if paramBudget < 20 { - paramBudget = 20 - } + paramBudget := max(r.width-10-len(displayName), 20) params := formatToolParams(toolArgs, paramBudget) header := iconStr + " " + nameStr diff --git a/internal/ui/model.go b/internal/ui/model.go index 154f91e8..bb766839 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -916,11 +916,13 @@ func (m *AppModel) printHelpMessage() tea.Cmd { "- `/quit`: Exit the application\n\n" if len(m.extensionCommands) > 0 { - help += "**Extensions:**\n" + var extHelp strings.Builder + extHelp.WriteString("**Extensions:**\n") for _, ec := range m.extensionCommands { - help += fmt.Sprintf("- `%s`: %s\n", ec.Name, ec.Description) + fmt.Fprintf(&extHelp, "- `%s`: %s\n", ec.Name, ec.Description) } - help += "\n" + extHelp.WriteString("\n") + help += extHelp.String() } help += "**Keys:**\n" + diff --git a/internal/ui/tool_renderers.go b/internal/ui/tool_renderers.go index c40fa815..fd9e2601 100644 --- a/internal/ui/tool_renderers.go +++ b/internal/ui/tool_renderers.go @@ -4,7 +4,6 @@ import ( "bytes" "encoding/json" "fmt" - "path/filepath" "regexp" "strconv" "strings" @@ -154,11 +153,8 @@ func renderDiffBlock(before, after string, startLine int, width int) string { inserts = append(inserts, h.Lines[i]) i++ } - maxPairs := len(deletes) - if len(inserts) > maxPairs { - maxPairs = len(inserts) - } - for j := 0; j < maxPairs; j++ { + maxPairs := max(len(deletes), len(inserts)) + for j := range maxPairs { sl := splitLine{} if j < len(deletes) { sl.beforeNum = beforeLine @@ -200,10 +196,7 @@ func renderDiffBlock(before, after string, startLine int, width int) string { // Layout calculations const indent = " " availableWidth := width - len(indent) - panelWidth := (availableWidth - 3) / 2 // " │ " divider - if panelWidth < 20 { - panelWidth = 20 - } + panelWidth := max((availableWidth-3)/2, 20) // " │ " divider // Gutter width from max line number maxLineNum := 1 @@ -215,14 +208,8 @@ func renderDiffBlock(before, after string, startLine int, width int) string { maxLineNum = l.afterNum } } - gutterWidth := len(fmt.Sprintf("%d", maxLineNum)) - if gutterWidth < 3 { - gutterWidth = 3 - } - contentWidth := panelWidth - gutterWidth - 4 // gutter + " - " or " + " - if contentWidth < 10 { - contentWidth = 10 - } + gutterWidth := max(len(fmt.Sprintf("%d", maxLineNum)), 3) + contentWidth := max(panelWidth-gutterWidth-4, 10) // gutter + " - " or " + " theme := getTheme() @@ -395,14 +382,8 @@ func renderCodeBlock(content, fileName string, width int) string { // Layout const codeIndent = " " - gutterWidth := maxNumWidth + 2 - if gutterWidth < 5 { - gutterWidth = 5 - } - codeWidth := width - gutterWidth - len(codeIndent) - if codeWidth < 20 { - codeWidth = 20 - } + gutterWidth := max(maxNumWidth+2, 5) + codeWidth := max(width-gutterWidth-len(codeIndent), 20) theme := getTheme() gutterStyle := lipgloss.NewStyle().Foreground(theme.Muted).Background(theme.GutterBg).PaddingRight(1) @@ -483,10 +464,7 @@ func renderWriteBlock(content, fileName string, width int) string { } // Line number width - numDigits := len(fmt.Sprintf("%d", totalLines)) - if numDigits < 3 { - numDigits = 3 - } + numDigits := max(len(fmt.Sprintf("%d", totalLines)), 3) // Syntax highlight displayContent := strings.Join(lines, "\n") @@ -496,10 +474,7 @@ func renderWriteBlock(content, fileName string, width int) string { // Layout const codeIndent = " " gutterWidth := numDigits + 2 - codeWidth := width - gutterWidth - len(codeIndent) - if codeWidth < 20 { - codeWidth = 20 - } + codeWidth := max(width-gutterWidth-len(codeIndent), 20) theme := getTheme() gutterStyle := lipgloss.NewStyle().Foreground(theme.Muted).Background(theme.GutterBg).PaddingRight(1) @@ -666,12 +641,6 @@ func syntaxHighlight(source, fileName string) string { return strings.TrimRight(result, "\n") } -// fileExtension returns the file extension (with dot) for a path, used to -// help chroma pick the right lexer. -func fileExtension(path string) string { - return filepath.Ext(path) -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/internal/ui/tree_selector.go b/internal/ui/tree_selector.go index b905d399..9d84cde4 100644 --- a/internal/ui/tree_selector.go +++ b/internal/ui/tree_selector.go @@ -241,10 +241,7 @@ func (ts *TreeSelectorComponent) View() tea.View { if ts.cursor >= visH { startIdx = ts.cursor - visH + 1 } - endIdx := startIdx + visH - if endIdx > len(ts.flatNodes) { - endIdx = len(ts.flatNodes) - } + endIdx := min(startIdx+visH, len(ts.flatNodes)) for i := startIdx; i < endIdx; i++ { node := ts.flatNodes[i] @@ -274,10 +271,7 @@ func (ts *TreeSelectorComponent) IsActive() bool { func (ts *TreeSelectorComponent) visibleHeight() int { // Reserve lines for header(3) + search(1) + separator(1) + footer(2). - h := ts.height/2 - 7 - if h < 5 { - h = 5 - } + h := max(ts.height/2-7, 5) return h }