mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-13 19:20:06 +00:00
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:
+2
-2
@@ -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
@@ -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
@@ -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{})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" +
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user