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 }