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.
This commit is contained in:
Ed Zynda
2026-02-27 00:41:48 +03:00
parent f12950c0b6
commit 6ac8d3983a
18 changed files with 89 additions and 141 deletions
+2 -2
View File
@@ -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"
}
}
+25 -1
View File
@@ -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 <text>", 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 <command>", 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.
+4 -1
View File
@@ -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{})
+1 -3
View File
@@ -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)
+1 -4
View File
@@ -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))
+13 -1
View File
@@ -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)
}
// ---------------------------------------------------------------------------
+3 -6
View File
@@ -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)
}
+1 -1
View File
@@ -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
+9 -29
View File
@@ -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")
+7
View File
@@ -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
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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)
}
+2 -28
View File
@@ -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"
}
}
+2 -8
View File
@@ -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)
+1 -4
View File
@@ -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
+5 -3
View File
@@ -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" +
+9 -40
View File
@@ -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
// ---------------------------------------------------------------------------
+2 -8
View File
@@ -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
}