diff --git a/AGENTS.md b/AGENTS.md index d0082b41..9b1f8911 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,53 @@ Keep this managed block so 'openspec update' can refresh the instructions. - Multi-provider LLM support via `llm.Provider` interface - MCP client-server for tool integration - Builtin servers: bash, fetch, todo, fs +- **Extension system** (`internal/extensions/`): Yaegi-interpreted Go, 13 lifecycle events, custom tools/commands/widgets/overlays/editor interceptors +- **TUI** (`internal/ui/`): Bubble Tea v2 parent-child model (`AppModel` → `InputComponent`, `StreamComponent`, etc.) +- **Decoupling pattern**: `cmd/root.go` has converter functions (e.g. `widgetProviderForUI()`) that bridge `internal/extensions/` types to `internal/ui/` types — the UI never imports extensions directly + +## Key Patterns + +### Yaegi (Extension Interpreter) Gotchas +- **No interfaces across boundary**: All extension-facing API types must be concrete structs, never interfaces. Yaegi crashes on interface wrapper generation. +- **Function field bug**: Named function references assigned to struct fields return zero values across the interpreter boundary. Always use anonymous closure literals: + ```go + // WRONG: ctx.SetEditor(ext.EditorConfig{HandleKey: myHandler}) + // RIGHT: ctx.SetEditor(ext.EditorConfig{HandleKey: func(k, t string) ext.EditorKeyAction { return myHandler(k, t) }}) + ``` +- **Symbol exports**: Every new type exposed to extensions must be added to `internal/extensions/symbols.go` + +### BubbleTea Integration +- **No `prog.Send()` from inside `Update()`**: Calling `prog.Send()` synchronously within a BubbleTea `Update()` handler deadlocks the event loop. Use `go appInstance.NotifyWidgetUpdate()` (async goroutine) instead. +- **Height measurement**: `distributeHeight()` in `model.go` must measure using the same render path as `View()`. If an interceptor wraps rendering, measure with the wrapper too, or layout will mismatch. +- **Channel-based prompts**: Extension prompt calls (PromptSelect, etc.) block on a `chan PromptResponse`. Extension slash commands run in dedicated goroutines (not `tea.Cmd`) to avoid stalling BubbleTea's Cmd scheduler. + +### Extension State Management +- **Thread-safe maps on Runner**: Widget/header/footer/editor state lives on the Runner with `sync.RWMutex`, queried by UI via callbacks +- **Context function fields**: The `Context` struct uses function fields (`Print func(string)`, `SetWidget func(WidgetConfig)`) wired by closures in `cmd/root.go` +- **Package-level vars in extensions**: Yaegi supports package-level variables captured in closures — this is how extensions maintain state across event callbacks + +### Unicode in Widget Text +- Widget content renders through `lipgloss.Style.Render()` which preserves ANSI escape codes +- Use rune-based width calculations (`len([]rune(s))`) not byte length (`len(s)`) when aligning box-drawing characters or multi-byte symbols + +## Testing + +### Interactive TUI Testing with tmux +Use tmux to test Kit interactively without blocking the agent: +```bash +tmux new-session -d -s kittest -x 120 -y 40 "output/kit -e examples/extensions/my-ext.go --no-session 2>kit_stderr.log" +sleep 3 +tmux capture-pane -t kittest -p # read screen +tmux send-keys -t kittest '/command' Enter # send input +tmux kill-session -t kittest # cleanup +``` + +### Non-Interactive Kit (Subprocess Spawning) +Extensions can spawn Kit as a subprocess for sub-agent patterns: +```bash +kit --prompt "question" --quiet --no-session --no-extensions --system-prompt /path/to/prompt.txt --model provider/model +``` +Key flags: `--quiet` (stdout only, no TUI), `--no-session` (ephemeral), `--no-extensions` (prevent recursive loading), `--system-prompt` (string or file path). ## External Repo Research - **ALWAYS use `btca`** to search external repos (e.g. iteratr, other reference codebases) diff --git a/cmd/root.go b/cmd/root.go index e780716a..49208519 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -285,9 +285,6 @@ func extensionCommandsForUI(k *kit.Kit) []ui.ExtensionCommand { if len(defs) == 0 { return nil } - // We still need the raw runner for GetContext() in the Execute closure. - // This is the last remaining use of GetExtRunner() in cmd/. - runner := k.GetExtRunner() cmds := make([]ui.ExtensionCommand, 0, len(defs)) for _, d := range defs { name := d.Name @@ -298,7 +295,7 @@ func extensionCommandsForUI(k *kit.Kit) []ui.ExtensionCommand { Name: name, Description: d.Description, Execute: func(args string) (string, error) { - return d.Execute(args, runner.GetContext()) + return d.Execute(args, k.GetExtensionContext()) }, }) } diff --git a/examples/extensions/kit-kit-agents/ext-expert.md b/examples/extensions/kit-kit-agents/ext-expert.md new file mode 100644 index 00000000..a2d2a52e --- /dev/null +++ b/examples/extensions/kit-kit-agents/ext-expert.md @@ -0,0 +1,36 @@ +--- +name: ext-expert +description: Kit extensions — tools, events, commands, widgets, editor interceptors +tools: read,grep,glob +--- +You are an expert on Kit's extension system. Your job is to research and answer questions about how Kit extensions work. + +## Key Files + +- `internal/extensions/api.go` — Extension API surface, Context struct, all types +- `internal/extensions/runner.go` — Event dispatch, extension registry, widget/header/footer storage +- `internal/extensions/loader.go` — Yaegi interpreter setup, extension loading +- `internal/extensions/symbols.go` — Yaegi symbol exports +- `internal/extensions/events.go` — Event type definitions +- `examples/extensions/` — Example extensions demonstrating all features + +## Architecture + +Kit extensions are Go files interpreted at runtime by Yaegi. Each extension exports `func Init(api ext.API)` and uses the API to register: + +- **Event handlers**: OnSessionStart, OnToolCall, OnToolResult, OnInput, OnAgentEnd, etc. +- **Custom tools**: ToolDef with name, description, JSON Schema parameters, Execute function +- **Slash commands**: CommandDef with name, description, Execute function (receives Context) +- **Tool renderers**: ToolRenderConfig with custom RenderHeader/RenderBody +- **Widgets**: ctx.SetWidget/RemoveWidget for persistent UI elements +- **Headers/Footers**: ctx.SetHeader/SetFooter for chrome customization +- **Editor interceptors**: ctx.SetEditor for key interception and render wrapping +- **Prompts/Overlays**: ctx.PromptSelect/PromptConfirm/PromptInput/ShowOverlay + +## Critical Yaegi Limitations + +- All function fields in structs must be anonymous closures, NOT named function references +- No interfaces exported to extensions — only concrete structs +- Extensions run in isolated interpreters with stdlib + os/exec access + +When answering, cite specific file paths and line numbers. Provide concrete code examples. diff --git a/examples/extensions/kit-kit-agents/llm-expert.md b/examples/extensions/kit-kit-agents/llm-expert.md new file mode 100644 index 00000000..39614cdc --- /dev/null +++ b/examples/extensions/kit-kit-agents/llm-expert.md @@ -0,0 +1,34 @@ +--- +name: llm-expert +description: Kit LLM system — providers, streaming, agent loop, tool execution +tools: read,grep,glob +--- +You are an expert on Kit's LLM integration and agent system. Your job is to research and answer questions about how Kit communicates with language models and runs the agent loop. + +## Key Files + +- `internal/llm/provider.go` — Provider interface definition +- `internal/llm/anthropic/` — Anthropic Claude provider +- `internal/llm/openai/` — OpenAI-compatible provider (also used for Ollama) +- `internal/llm/google/` — Google Gemini provider +- `internal/agent/agent.go` — Agent loop: prompt -> LLM -> tool calls -> repeat +- `internal/agent/tools.go` — Tool registry, built-in tool definitions +- `internal/app/app.go` — App layer: RunOnce, RunOnceWithDisplay, event routing +- `pkg/kit/kit.go` — SDK: New(), configuration, extension management + +## Architecture + +Kit supports multiple LLM providers through the `llm.Provider` interface. The model flag format is `provider/model-name` (e.g., `anthropic/claude-sonnet-4-5`). + +The agent loop in `internal/agent/` follows a standard ReAct pattern: +1. Send conversation history + system prompt to LLM +2. LLM responds with text and/or tool calls +3. Execute tool calls (MCP servers + extension tools) +4. Append tool results to conversation +5. Repeat until LLM produces a final text response (no tool calls) + +Tool execution goes through MCP (Model Context Protocol) client-server architecture. Built-in MCP servers provide bash, file system, fetch, and todo tools. + +The App layer (`internal/app/`) manages the lifecycle: creating the agent, routing events to the UI or CLI renderer, handling cancellation, and coordinating with extensions. + +When answering, cite specific file paths and line numbers. Provide concrete code examples. diff --git a/examples/extensions/kit-kit-agents/orchestrator.md b/examples/extensions/kit-kit-agents/orchestrator.md new file mode 100644 index 00000000..6b4e0b3a --- /dev/null +++ b/examples/extensions/kit-kit-agents/orchestrator.md @@ -0,0 +1,27 @@ +--- +name: orchestrator +description: Kit Kit orchestrator system prompt template +--- +You are Kit Kit, an orchestrator agent with {{EXPERT_COUNT}} domain experts: {{EXPERT_NAMES}}. + +Your role is to coordinate these experts to research Kit's codebase and then synthesize their findings into working implementations. + +## Available Experts + +{{EXPERT_CATALOG}} + +## Workflow + +1. **Analyze** the user's request to identify which domains are relevant. +2. **Query** the relevant experts IN PARALLEL using the `query_experts` tool. Ask specific, targeted questions. +3. **Synthesize** the expert findings into a coherent understanding. +4. **Implement** — you are the ONLY agent that writes files. Experts are read-only researchers. + +## Rules + +- ALWAYS query experts before implementing. Never guess about Kit internals. +- Ask SPECIFIC questions: "How does SetWidget update the UI?" beats "Tell me about widgets." +- Query MULTIPLE experts in a single tool call when the task spans domains (they run in parallel). +- If an expert's answer is insufficient, query again with a more targeted question. +- Cite the file paths and patterns from expert responses in your implementation. +- When writing Kit extensions, remember the Yaegi closure wrapper pattern for all function fields. diff --git a/examples/extensions/kit-kit-agents/tui-expert.md b/examples/extensions/kit-kit-agents/tui-expert.md new file mode 100644 index 00000000..5c009845 --- /dev/null +++ b/examples/extensions/kit-kit-agents/tui-expert.md @@ -0,0 +1,38 @@ +--- +name: tui-expert +description: Kit TUI — Bubble Tea v2 components, rendering, theming, layout +tools: read,grep,glob +--- +You are an expert on Kit's terminal user interface. Your job is to research and answer questions about how Kit's TUI works. + +## Key Files + +- `internal/ui/model.go` — AppModel root component, View(), Update(), key handling, layout +- `internal/ui/input.go` — InputComponent wrapping textarea + autocomplete +- `internal/ui/overlay.go` — Modal overlay dialogs +- `internal/ui/prompt.go` — Interactive prompt overlays (select, confirm, input) +- `internal/ui/messages.go` — MessageRenderer for streaming messages +- `internal/ui/compact_renderer.go` — CompactRenderer for compact mode +- `internal/ui/block_renderer.go` — renderContentBlock() with functional options +- `internal/ui/theme.go` — Catppuccin-based theming (GetTheme) +- `internal/ui/commands.go` — ExtensionCommand type, slash command registry +- `internal/ui/model_test.go` — Tests with stubAppController mock + +## Architecture + +Kit uses Bubble Tea v2 for the TUI. The component hierarchy: + +- **AppModel** — root component managing layout, key routing, and child components + - **InputComponent** — text area with autocomplete popup + - **StreamComponent** — streaming message display + - **TreeSelectorComponent** — session/model picker + - **promptOverlay** — interactive prompts (select, confirm, input) + - **overlayDialog** — modal overlay dialogs + +Layout (top to bottom): header, stream, separator, widgets-above, input, widgets-below, footer, status bar. + +Rendering uses lipgloss for styling with the Catppuccin Mocha color palette. Content blocks use `renderContentBlock()` with functional options for border, padding, background, and alignment. + +Extension widgets integrate via callback functions (getWidgets, getHeader, getFooter) that query the extension runner through the SDK layer, keeping the UI decoupled from extensions. + +When answering, cite specific file paths and line numbers. Provide concrete code examples. diff --git a/examples/extensions/kit-kit.go b/examples/extensions/kit-kit.go new file mode 100644 index 00000000..61f8eba0 --- /dev/null +++ b/examples/extensions/kit-kit.go @@ -0,0 +1,845 @@ +//go:build ignore + +// Kit Kit — Meta-agent that builds Kit agents +// +// A team of domain-specific research experts operate IN PARALLEL to gather +// documentation and patterns. The primary agent synthesizes their findings +// and WRITES the actual files. +// +// Each expert runs as a separate `kit` subprocess with a domain-specific +// system prompt. Experts are read-only researchers; the primary agent is +// the only writer. +// +// Commands: +// +// /experts — list available experts and their status +// /experts-grid N — set dashboard column count (default 3) +// +// Usage: kit -e examples/extensions/kit-kit.go +package main + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "kit/ext" +) + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type expertDef struct { + Name string + Description string + Tools string + System string // system prompt body + File string +} + +type expertState struct { + Def expertDef + Status string // "idle", "researching", "done", "error" + Question string + Elapsed time.Duration + LastLine string + QueryCount int + mu sync.Mutex +} + +func (s *expertState) set(status, question, lastLine string, elapsed time.Duration) { + s.mu.Lock() + defer s.mu.Unlock() + if status != "" { + s.Status = status + } + if question != "" { + s.Question = question + } + if lastLine != "" { + s.LastLine = lastLine + } + if elapsed > 0 { + s.Elapsed = elapsed + } +} + +func (s *expertState) snapshot() (string, string, string, time.Duration, int) { + s.mu.Lock() + defer s.mu.Unlock() + return s.Status, s.Question, s.LastLine, s.Elapsed, s.QueryCount +} + +// --------------------------------------------------------------------------- +// Package-level state +// --------------------------------------------------------------------------- + +var ( + mu sync.Mutex + experts = map[string]*expertState{} + gridCols = 3 + latestCtx ext.Context + hasCtx bool + kitBinary string // resolved path to kit executable +) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func displayName(name string) string { + parts := strings.Split(name, "-") + for i, w := range parts { + if len(w) > 0 { + parts[i] = strings.ToUpper(w[:1]) + w[1:] + } + } + return strings.Join(parts, " ") +} + +func runeWidth(s string) int { + return len([]rune(s)) +} + +func truncate(s string, max int) string { + runes := []rune(s) + if len(runes) <= max { + return s + } + if max < 4 { + return string(runes[:max]) + } + return string(runes[:max-3]) + "..." +} + +func pad(s string, width int) string { + w := runeWidth(s) + if w >= width { + return string([]rune(s)[:width]) + } + return s + strings.Repeat(" ", width-w) +} + +// parseAgentFile reads a .md file with YAML-like frontmatter. +// +// --- +// name: ext-expert +// description: Extensions documentation +// tools: read,grep,glob +// --- +// System prompt body here ... +func parseAgentFile(path string) *expertDef { + raw, err := os.ReadFile(path) + if err != nil { + return nil + } + text := string(raw) + + // Must start with "---\n" + if !strings.HasPrefix(text, "---\n") { + return nil + } + rest := text[4:] + idx := strings.Index(rest, "\n---\n") + if idx < 0 { + return nil + } + frontmatter := rest[:idx] + body := strings.TrimSpace(rest[idx+5:]) + + fm := map[string]string{} + for _, line := range strings.Split(frontmatter, "\n") { + i := strings.Index(line, ":") + if i > 0 { + fm[strings.TrimSpace(line[:i])] = strings.TrimSpace(line[i+1:]) + } + } + if fm["name"] == "" { + return nil + } + return &expertDef{ + Name: fm["name"], + Description: fm["description"], + Tools: fm["tools"], + System: body, + File: path, + } +} + +func loadExperts(cwd string) { + mu.Lock() + defer mu.Unlock() + + experts = map[string]*expertState{} + dir := filepath.Join(cwd, ".kit", "agents", "kit-kit") + + entries, err := os.ReadDir(dir) + if err != nil { + return + } + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { + continue + } + if e.Name() == "orchestrator.md" { + continue + } + def := parseAgentFile(filepath.Join(dir, e.Name())) + if def == nil { + continue + } + key := strings.ToLower(def.Name) + experts[key] = &expertState{ + Def: *def, + Status: "idle", + } + } +} + +func expertList() []*expertState { + mu.Lock() + defer mu.Unlock() + list := make([]*expertState, 0, len(experts)) + for _, s := range experts { + list = append(list, s) + } + return list +} + +func expertNames() string { + list := expertList() + names := make([]string, len(list)) + for i, s := range list { + names[i] = displayName(s.Def.Name) + } + return strings.Join(names, ", ") +} + +// --------------------------------------------------------------------------- +// Widget grid rendering +// --------------------------------------------------------------------------- + +func renderCard(s *expertState, w int) []string { + status, question, lastLine, elapsed, queryCount := s.snapshot() + inner := w - 2 // inside the box-drawing borders + + // Name line + name := truncate(displayName(s.Def.Name), inner-1) + + // Status line + var icon string + switch status { + case "idle": + icon = "○" + case "researching": + icon = "◉" + case "done": + icon = "✓" + default: + icon = "✗" + } + statusText := icon + " " + status + if status != "idle" { + statusText += fmt.Sprintf(" %ds", int(elapsed.Seconds())) + } + if queryCount > 0 { + statusText += fmt.Sprintf(" (%d)", queryCount) + } + statusText = truncate(statusText, inner-1) + + // Work line (question or description) + work := question + if work == "" { + work = s.Def.Description + } + work = truncate(work, inner-1) + + // Last output line + last := lastLine + if last == "" { + last = "—" + } + last = truncate(last, inner-1) + + // Build card (use rune width for box-drawing alignment) + topBar := "─ " + name + " " + if runeWidth(topBar) < inner { + topBar += strings.Repeat("─", inner-runeWidth(topBar)) + } + + return []string{ + "┌" + truncate(topBar, inner) + "┐", + "│ " + pad(statusText, inner-1) + "│", + "│ " + pad(work, inner-1) + "│", + "│ " + pad(last, inner-1) + "│", + "└" + strings.Repeat("─", inner) + "┘", + } +} + +func buildGrid() string { + list := expertList() + if len(list) == 0 { + return "No experts found. Add agent .md files to .kit/agents/kit-kit/" + } + + cols := gridCols + if cols > len(list) { + cols = len(list) + } + + // Card width: aim for ~28 chars per card + cardWidth := 28 + gap := 1 + + var lines []string + for i := 0; i < len(list); i += cols { + end := i + cols + if end > len(list) { + end = len(list) + } + row := list[i:end] + + // Render each card in this row + cards := make([][]string, len(row)) + maxHeight := 0 + for j, s := range row { + cards[j] = renderCard(s, cardWidth) + if len(cards[j]) > maxHeight { + maxHeight = len(cards[j]) + } + } + + // Merge columns line by line + for line := 0; line < maxHeight; line++ { + var parts []string + for _, card := range cards { + if line < len(card) { + parts = append(parts, card[line]) + } else { + parts = append(parts, strings.Repeat(" ", cardWidth)) + } + } + lines = append(lines, strings.Join(parts, strings.Repeat(" ", gap))) + } + } + return strings.Join(lines, "\n") +} + +func updateWidget() { + mu.Lock() + ctx := latestCtx + ok := hasCtx + mu.Unlock() + if !ok { + return + } + ctx.SetWidget(ext.WidgetConfig{ + ID: "kit-kit:grid", + Placement: ext.WidgetAbove, + Content: ext.WidgetContent{ + Text: buildGrid(), + }, + Style: ext.WidgetStyle{ + NoBorder: true, + BorderColor: "", + }, + Priority: 10, + }) +} + +func updateFooter() { + mu.Lock() + ctx := latestCtx + ok := hasCtx + mu.Unlock() + if !ok { + return + } + + list := expertList() + active := 0 + done := 0 + for _, s := range list { + st, _, _, _, _ := s.snapshot() + switch st { + case "researching": + active++ + case "done": + done++ + } + } + + var mid string + if active > 0 { + mid = fmt.Sprintf(" ◉ %d researching", active) + } else if done > 0 { + mid = fmt.Sprintf(" ✓ %d done", done) + } + + text := fmt.Sprintf("%s | Kit Kit%s", ctx.Model, mid) + + ctx.SetFooter(ext.HeaderFooterConfig{ + Content: ext.WidgetContent{Text: text}, + Style: ext.WidgetStyle{BorderColor: "#89b4fa"}, + }) +} + +// --------------------------------------------------------------------------- +// Kit binary resolution +// --------------------------------------------------------------------------- + +func findKitBinary() string { + // Try the current process executable first. + if exe, err := os.Executable(); err == nil { + if _, err := os.Stat(exe); err == nil { + return exe + } + } + // Fall back to PATH lookup. + if p, err := exec.LookPath("kit"); err == nil { + return p + } + return "kit" +} + +// --------------------------------------------------------------------------- +// Expert query (subprocess) +// --------------------------------------------------------------------------- + +func queryExpert(name, question string) (output string, exitCode int, elapsed time.Duration) { + mu.Lock() + state, ok := experts[strings.ToLower(name)] + mu.Unlock() + if !ok { + return fmt.Sprintf("Expert %q not found.", name), 1, 0 + } + + // Mark as researching. + state.mu.Lock() + if state.Status == "researching" { + state.mu.Unlock() + return fmt.Sprintf("Expert %q is already researching.", displayName(name)), 1, 0 + } + state.Status = "researching" + state.Question = question + state.Elapsed = 0 + state.LastLine = "" + state.QueryCount++ + state.mu.Unlock() + updateWidget() + + start := time.Now() + + // Timer goroutine: update widget every second while researching. + done := make(chan struct{}) + go func() { + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + for { + select { + case <-done: + return + case <-ticker.C: + state.set("", "", "", time.Since(start)) + updateWidget() + updateFooter() + } + } + }() + + // Write system prompt to temp file. + tmpFile, err := os.CreateTemp("", "kit-kit-*.txt") + if err != nil { + close(done) + state.set("error", "", "temp file error: "+err.Error(), time.Since(start)) + updateWidget() + updateFooter() + return "Error creating temp file: " + err.Error(), 1, time.Since(start) + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.WriteString(state.Def.System); err != nil { + tmpFile.Close() + close(done) + state.set("error", "", "write error: "+err.Error(), time.Since(start)) + updateWidget() + updateFooter() + return "Error writing system prompt: " + err.Error(), 1, time.Since(start) + } + tmpFile.Close() + + // Build subprocess arguments. Don't pass --model; the subprocess + // inherits the same config/env and will use the same default. + args := []string{ + "--prompt", question, + "--quiet", + "--no-session", + "--no-extensions", + "--system-prompt", tmpFile.Name(), + } + + cmd := exec.Command(kitBinary, args...) + cmd.Env = os.Environ() + + outBytes, err := cmd.CombinedOutput() + close(done) + elapsed = time.Since(start) + result := strings.TrimSpace(string(outBytes)) + + if err != nil { + // Extract a single-line summary for the card (no newlines). + errLine := result + if idx := strings.Index(errLine, "\n"); idx >= 0 { + errLine = errLine[:idx] + } + state.set("error", "", truncate(strings.TrimSpace(errLine), 80), elapsed) + updateWidget() + updateFooter() + code := 1 + if exitErr, ok := err.(*exec.ExitError); ok { + code = exitErr.ExitCode() + } + return result, code, elapsed + } + + // Success — extract last non-empty line for the card. + lines := strings.Split(result, "\n") + var lastLine string + for i := len(lines) - 1; i >= 0; i-- { + if strings.TrimSpace(lines[i]) != "" { + lastLine = lines[i] + break + } + } + state.set("done", "", truncate(lastLine, 60), elapsed) + updateWidget() + updateFooter() + + return result, 0, elapsed +} + +// --------------------------------------------------------------------------- +// Orchestrator system prompt +// --------------------------------------------------------------------------- + +func buildOrchestratorPrompt(cwd string) string { + orchPath := filepath.Join(cwd, ".kit", "agents", "kit-kit", "orchestrator.md") + raw, err := os.ReadFile(orchPath) + if err != nil { + // Fallback: generate a basic orchestrator prompt. + return buildDefaultOrchestratorPrompt() + } + + text := string(raw) + // Strip frontmatter if present. + if strings.HasPrefix(text, "---\n") { + if idx := strings.Index(text[4:], "\n---\n"); idx >= 0 { + text = strings.TrimSpace(text[4+idx+5:]) + } + } + + list := expertList() + catalog := buildExpertCatalog(list) + names := make([]string, len(list)) + for i, s := range list { + names[i] = displayName(s.Def.Name) + } + + text = strings.ReplaceAll(text, "{{EXPERT_COUNT}}", fmt.Sprintf("%d", len(list))) + text = strings.ReplaceAll(text, "{{EXPERT_NAMES}}", strings.Join(names, ", ")) + text = strings.ReplaceAll(text, "{{EXPERT_CATALOG}}", catalog) + return text +} + +func buildExpertCatalog(list []*expertState) string { + var sb strings.Builder + for _, s := range list { + fmt.Fprintf(&sb, "### %s\n", displayName(s.Def.Name)) + fmt.Fprintf(&sb, "**Query as:** `%s`\n", s.Def.Name) + fmt.Fprintf(&sb, "%s\n\n", s.Def.Description) + } + return sb.String() +} + +func buildDefaultOrchestratorPrompt() string { + list := expertList() + names := make([]string, len(list)) + for i, s := range list { + names[i] = displayName(s.Def.Name) + } + catalog := buildExpertCatalog(list) + + return fmt.Sprintf(`You are Kit Kit, an orchestrator agent with %d domain experts: %s. + +Use the query_experts tool to consult experts IN PARALLEL before writing code. +Always query multiple experts at once when the task spans multiple domains. + +## Available Experts + +%s + +## Workflow + +1. Analyze the user's request to identify which domains are relevant. +2. Use query_experts to ask specific questions of the relevant experts. +3. Synthesize the expert findings into a coherent implementation. +4. Write the actual code/files — you are the only agent that writes. + +## Rules + +- ALWAYS query experts before implementing. Never guess. +- Ask SPECIFIC questions. "How does X work?" is better than "Tell me about X". +- Query multiple experts in a single call when possible (they run in parallel). +- If an expert returns insufficient info, query again with a more specific question. +`, len(list), strings.Join(names, ", "), catalog) +} + +// --------------------------------------------------------------------------- +// Init +// --------------------------------------------------------------------------- + +func Init(api ext.API) { + kitBinary = findKitBinary() + + // ── Session Start: load experts, show grid ── + api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) { + mu.Lock() + latestCtx = ctx + hasCtx = true + mu.Unlock() + + loadExperts(ctx.CWD) + updateWidget() + updateFooter() + + names := expertNames() + n := len(expertList()) + if n > 0 { + ctx.PrintInfo(fmt.Sprintf( + "Kit Kit loaded — %d experts: %s\n\n"+ + "/experts List experts and status\n"+ + "/experts-grid N Set grid columns (1-5)\n\n"+ + "Ask me to build any Kit component!", + n, names)) + } else { + ctx.PrintInfo( + "Kit Kit loaded — no experts found.\n\n" + + "Add agent .md files to .kit/agents/kit-kit/ to get started.\n" + + "See examples/extensions/kit-kit-agents/ for samples.") + } + }) + + // ── Before Agent Start: inject orchestrator system prompt ── + api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult { + mu.Lock() + latestCtx = ctx + mu.Unlock() + + prompt := buildOrchestratorPrompt(ctx.CWD) + return &ext.BeforeAgentStartResult{SystemPrompt: &prompt} + }) + + // ── Agent End: update footer ── + api.OnAgentEnd(func(_ ext.AgentEndEvent, ctx ext.Context) { + mu.Lock() + latestCtx = ctx + mu.Unlock() + updateFooter() + }) + + // ── Session Shutdown: cleanup ── + api.OnSessionShutdown(func(_ ext.SessionShutdownEvent, ctx ext.Context) { + ctx.RemoveWidget("kit-kit:grid") + ctx.RemoveFooter() + }) + + // ── Tool: query_experts ── + api.RegisterTool(ext.ToolDef{ + Name: "query_experts", + Description: `Query one or more Kit domain experts IN PARALLEL. All experts run simultaneously as concurrent subprocesses. + +Pass an array of queries — each with an expert name and a specific question. All experts start at the same time and their results are returned together. + +Available experts are loaded from .kit/agents/kit-kit/*.md at session start. The default set includes: +- ext-expert: Kit extensions — tools, events, commands, widgets, editor interceptors +- tui-expert: Kit TUI — Bubble Tea v2 components, rendering, theming, layout +- llm-expert: Kit LLM system — providers, streaming, agent loop, tool execution + +Ask specific questions about what you need to BUILD. Each expert will return documentation excerpts, code patterns, and implementation guidance.`, + Parameters: `{ + "type": "object", + "properties": { + "queries": { + "type": "array", + "description": "Array of expert queries to run in parallel", + "items": { + "type": "object", + "properties": { + "expert": { + "type": "string", + "description": "Expert name (e.g. ext-expert, tui-expert, llm-expert)" + }, + "question": { + "type": "string", + "description": "Specific question about what you need to build" + } + }, + "required": ["expert", "question"] + } + } + }, + "required": ["queries"] +}`, + Execute: func(input string) (string, error) { + var params struct { + Queries []struct { + Expert string `json:"expert"` + Question string `json:"question"` + } `json:"queries"` + } + if err := json.Unmarshal([]byte(input), ¶ms); err != nil { + return "", fmt.Errorf("invalid parameters: %w", err) + } + if len(params.Queries) == 0 { + return "No queries provided.", nil + } + + // Launch all experts in parallel. + type result struct { + Expert string + Question string + Output string + ExitCode int + Elapsed time.Duration + } + results := make([]result, len(params.Queries)) + var wg sync.WaitGroup + + for i, q := range params.Queries { + wg.Add(1) + go func(idx int, expert, question string) { + defer wg.Done() + out, code, elapsed := queryExpert(expert, question) + results[idx] = result{ + Expert: expert, + Question: question, + Output: out, + ExitCode: code, + Elapsed: elapsed, + } + }(i, q.Expert, q.Question) + } + wg.Wait() + + // Build combined response. + var sb strings.Builder + for _, r := range results { + icon := "✓" + if r.ExitCode != 0 { + icon = "✗" + } + fmt.Fprintf(&sb, "## [%s] %s (%ds)\n\n", + icon, displayName(r.Expert), int(r.Elapsed.Seconds())) + + out := r.Output + if len(out) > 12000 { + out = out[:12000] + "\n\n... [truncated — ask follow-up for more]" + } + sb.WriteString(out) + sb.WriteString("\n\n---\n\n") + } + return sb.String(), nil + }, + }) + + // ── Tool Renderer: query_experts ── + api.RegisterToolRenderer(ext.ToolRenderConfig{ + ToolName: "query_experts", + DisplayName: "Query Experts", + BorderColor: "#89b4fa", + RenderHeader: func(toolArgs string, width int) string { + var args struct { + Queries []struct { + Expert string `json:"expert"` + } `json:"queries"` + } + if err := json.Unmarshal([]byte(toolArgs), &args); err != nil { + return "" + } + names := make([]string, len(args.Queries)) + for i, q := range args.Queries { + names[i] = displayName(q.Expert) + } + header := fmt.Sprintf("%d experts in parallel: %s", + len(args.Queries), strings.Join(names, ", ")) + return truncate(header, width) + }, + RenderBody: func(toolResult string, isError bool, width int) string { + if isError { + return "" // fall back to default + } + // Show compact summary: extract ## headers with status + var lines []string + for _, line := range strings.Split(toolResult, "\n") { + if strings.HasPrefix(line, "## [") { + lines = append(lines, line[3:]) // strip "## " + } + } + if len(lines) == 0 { + return "" + } + return strings.Join(lines, " · ") + }, + }) + + // ── Command: /experts ── + api.RegisterCommand(ext.CommandDef{ + Name: "experts", + Description: "List available Kit Kit experts and their status", + Execute: func(args string, ctx ext.Context) (string, error) { + mu.Lock() + latestCtx = ctx + mu.Unlock() + + list := expertList() + if len(list) == 0 { + return "No experts loaded. Add agent .md files to .kit/agents/kit-kit/", nil + } + var sb strings.Builder + for _, s := range list { + status, _, _, _, qc := s.snapshot() + fmt.Fprintf(&sb, "%s (%s, queries: %d): %s\n", + displayName(s.Def.Name), status, qc, s.Def.Description) + } + return sb.String(), nil + }, + }) + + // ── Command: /experts-grid ── + api.RegisterCommand(ext.CommandDef{ + Name: "experts-grid", + Description: "Set expert grid columns: /experts-grid <1-5>", + Execute: func(args string, ctx ext.Context) (string, error) { + mu.Lock() + latestCtx = ctx + mu.Unlock() + + args = strings.TrimSpace(args) + n := 0 + if _, err := fmt.Sscanf(args, "%d", &n); err != nil || n < 1 || n > 5 { + return "Usage: /experts-grid <1-5>", nil + } + mu.Lock() + gridCols = n + mu.Unlock() + updateWidget() + return fmt.Sprintf("Grid set to %d columns.", n), nil + }, + }) +} diff --git a/internal/ui/overlay.go b/internal/ui/overlay.go index cff53822..994bb055 100644 --- a/internal/ui/overlay.go +++ b/internal/ui/overlay.go @@ -159,10 +159,7 @@ func (o *overlayDialog) Render() string { } // Inner width accounts for border (2) + horizontal padding (2 left + 1 right). - innerWidth := dw - 5 - if innerWidth < 10 { - innerWidth = 10 - } + innerWidth := max(dw-5, 10) // Render body text (potentially as markdown). bodyText := o.content @@ -184,10 +181,7 @@ func (o *overlayDialog) Render() string { chromeLines += 2 // separator line + action bar } - maxBodyLines := mh - chromeLines - if maxBodyLines < 1 { - maxBodyLines = 1 - } + maxBodyLines := max(mh-chromeLines, 1) scrollable := len(bodyLines) > maxBodyLines if scrollable { diff --git a/pkg/kit/kit.go b/pkg/kit/kit.go index b600a45c..6d697dd3 100644 --- a/pkg/kit/kit.go +++ b/pkg/kit/kit.go @@ -127,6 +127,15 @@ func (m *Kit) SetExtensionContext(ctx extensions.Context) { } } +// GetExtensionContext returns the current extension runtime context. +// Returns a zero Context if extensions are disabled. +func (m *Kit) GetExtensionContext() extensions.Context { + if m.extRunner != nil { + return m.extRunner.GetContext() + } + return extensions.Context{} +} + // EmitSessionStart fires the SessionStart event for extensions. // No-op if extensions are disabled or no handlers are registered. func (m *Kit) EmitSessionStart() {