From 78570d4188f9963f0b62964104b503ec141eb21f Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Thu, 7 May 2026 12:20:08 +0300 Subject: [PATCH 1/8] remove dead code identified by audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes ~600 lines of unreferenced code surfaced by deadcode + manual audit (none of it reachable from production code paths or test setup): - internal/models/pool.go: ProviderPool was never wired into kitsetup or the agent; the global pool singleton had zero callers. - internal/ui/debug_logger.go: CLIDebugLogger was unreachable; debug routing goes through internal/tools/buffered_logger.go instead. - internal/ui/tool_approval_input.go: tea.Model never instantiated; approvals are handled inline in model.go. - internal/ui/cli.go: DisplayAssistantMessage / DisplayCancellation / GetDebugLogger had zero callers (the *WithModel variant is what event_handler.go uses). - internal/ui/style/enhanced.go: Style{Card,Header,Subheader,Muted, Success,Error,Warning,Info} + Create{Separator,ProgressBar} — none used. CreateBadge stays (used by model.go). - internal/ui/style/themes.go: RefreshThemeRegistry — never called. - internal/ui/block_renderer.go: With{FullWidth,MarginTop,Padding{Left, Right},Background,Foreground,Width} — option helpers nobody calls. - internal/ui/render/blocks.go: UserBlock, ToolBlock — replaced by inline rendering elsewhere; the test for UserBlock was rewritten to directly exercise HighlightFileTokens (which is what the test really cared about). - internal/ui/commands/commands.go: GetAllCommandNames — no callers. - internal/ui/message_items.go: NewTextMessageItem, NewSystemMessageItem + the entire SystemMessageItem type — model.go uses NewStyledMessageItem instead. - internal/prompts/loader.go: Deduplicate — the loader does dedup internally; standalone helper was unused. - internal/models/cache_options.go: mergeProviderOptions + its test-only consumer. - internal/extensions/installer.go: Installer.GetInstalledPackages — intended for a 'kit ext list' command that was never built. - internal/extensions/manifest.go: saveManifestToScope, saveManifestToPath, GetGlobalManifest, GetProjectManifest, addEntryToManifest, removeEntryFromManifest — package-level duplicates of *Installer methods. Tests rewritten to exercise the live Installer methods instead, which fixes a latent path-resolution inconsistency between manifestPathForScope and Installer.manifestPath (the former hard-coded paths, the latter respects projectGitRoot). - internal/extensions/subagent.go: SpawnSubagent + helpers (generateSubagentID, findKitBinary, subagentJSONOutput). The subprocess-spawn implementation is unreachable; production code routes through kit.Kit.Subagent (in-process). Types (SubagentConfig/Result/Handle/etc.) and the SubagentHandle methods remain because they are exposed to extensions via Yaegi symbols and the Context.SpawnSubagent field. - cmd/root.go: LoadConfigWithEnvSubstitution — one-line wrapper around kit.LoadConfigWithEnvSubstitution with zero callers. go test -race ./... passes. --- cmd/root.go | 6 - internal/extensions/installer.go | 19 --- internal/extensions/installer_test.go | 72 ++++----- internal/extensions/manifest.go | 73 --------- internal/extensions/subagent.go | 224 -------------------------- internal/models/cache_options.go | 16 -- internal/models/cache_options_test.go | 55 ------- internal/models/pool.go | 168 ------------------- internal/prompts/loader.go | 25 --- internal/ui/block_renderer.go | 63 -------- internal/ui/cli.go | 19 --- internal/ui/commands/commands.go | 12 -- internal/ui/debug_logger.go | 79 --------- internal/ui/message_items.go | 62 ------- internal/ui/render/blocks.go | 61 +------ internal/ui/render/blocks_test.go | 14 +- internal/ui/style/enhanced.go | 95 ----------- internal/ui/style/themes.go | 6 - internal/ui/tool_approval_input.go | 140 ---------------- 19 files changed, 41 insertions(+), 1168 deletions(-) delete mode 100644 internal/models/pool.go delete mode 100644 internal/ui/debug_logger.go delete mode 100644 internal/ui/tool_approval_input.go diff --git a/cmd/root.go b/cmd/root.go index cbab5c69..91ee6b1e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -169,12 +169,6 @@ func InitConfig() { models.ReloadGlobalRegistry() } -// LoadConfigWithEnvSubstitution loads a config file with environment variable -// substitution. Delegates to the SDK implementation. -func LoadConfigWithEnvSubstitution(configPath string) error { - return kit.LoadConfigWithEnvSubstitution(configPath) -} - // adaptiveOrDefault converts a config.AdaptiveColor to a resolved color.Color, // falling back to fallback when both Light and Dark are empty. func adaptiveOrDefault(ac config.AdaptiveColor, fallback color.Color) color.Color { diff --git a/internal/extensions/installer.go b/internal/extensions/installer.go index d65c11b9..2ece8045 100644 --- a/internal/extensions/installer.go +++ b/internal/extensions/installer.go @@ -450,25 +450,6 @@ func globalGitInstallRoot() string { return filepath.Join(base, "kit", "git") } -// GetInstalledPackages returns all installed packages from both scopes. -func (i *Installer) GetInstalledPackages() ([]ManifestEntry, error) { - var all []ManifestEntry - - global, err := i.loadManifest(ScopeGlobal) - if err != nil { - return nil, fmt.Errorf("loading global manifest: %w", err) - } - all = append(all, global.Packages...) - - project, err := i.loadManifest(ScopeProject) - if err != nil { - return nil, fmt.Errorf("loading project manifest: %w", err) - } - all = append(all, project.Packages...) - - return all, nil -} - // IsInstalled checks if a package is installed in either scope. // Returns (scope, true) if installed, ("", false) otherwise. func (i *Installer) IsInstalled(source *GitSource) (InstallScope, bool) { diff --git a/internal/extensions/installer_test.go b/internal/extensions/installer_test.go index eae01c9c..c10149fa 100644 --- a/internal/extensions/installer_test.go +++ b/internal/extensions/installer_test.go @@ -247,12 +247,16 @@ func TestManifestEntryIdentity(t *testing.T) { func TestLoadAndSaveManifest(t *testing.T) { tempDir := t.TempDir() + installer := &Installer{ + projectGitRoot: tempDir, + globalGitRoot: tempDir, + } manifestPath := filepath.Join(tempDir, "packages.json") // Test loading non-existent manifest - manifest, err := loadManifestFromPath(manifestPath) + manifest, err := installer.loadManifest(ScopeGlobal) if err != nil { - t.Fatalf("loadManifestFromPath() error = %v", err) + t.Fatalf("loadManifest() error = %v", err) } if len(manifest.Packages) != 0 { t.Errorf("Expected empty packages, got %d", len(manifest.Packages)) @@ -273,15 +277,20 @@ func TestLoadAndSaveManifest(t *testing.T) { } // Save it - err = saveManifestToPath(manifest, manifestPath) + err = installer.saveManifest(manifest, ScopeGlobal) if err != nil { - t.Fatalf("saveManifestToPath() error = %v", err) + t.Fatalf("saveManifest() error = %v", err) + } + + // Verify it was written to expected path + if _, err := os.Stat(manifestPath); err != nil { + t.Fatalf("manifest file not created: %v", err) } // Load it back - loaded, err := loadManifestFromPath(manifestPath) + loaded, err := installer.loadManifest(ScopeGlobal) if err != nil { - t.Fatalf("loadManifestFromPath() error = %v", err) + t.Fatalf("loadManifest() error = %v", err) } if len(loaded.Packages) != 1 { t.Errorf("Expected 1 package, got %d", len(loaded.Packages)) @@ -293,19 +302,10 @@ func TestLoadAndSaveManifest(t *testing.T) { func TestAddAndRemoveFromManifest(t *testing.T) { tempDir := t.TempDir() - - // Set up environment for manifest path - if err := os.Setenv("XDG_DATA_HOME", tempDir); err != nil { - t.Fatalf("Setenv() error = %v", err) + installer := &Installer{ + projectGitRoot: tempDir, + globalGitRoot: tempDir, } - defer func() { - if err := os.Unsetenv("XDG_DATA_HOME"); err != nil { - t.Logf("Unsetenv() error = %v", err) - } - }() - - // The manifest path when XDG_DATA_HOME is set - manifestPath := filepath.Join(tempDir, "kit", "git", "packages.json") // Add an entry entry := ManifestEntry{ @@ -315,30 +315,28 @@ func TestAddAndRemoveFromManifest(t *testing.T) { Scope: ScopeGlobal, } - err := addEntryToManifest(entry, ScopeGlobal) - if err != nil { - t.Fatalf("addEntryToManifest() error = %v", err) + if err := installer.addToManifest(entry, ScopeGlobal); err != nil { + t.Fatalf("addToManifest() error = %v", err) } // Verify it was added - manifest, err := loadManifestFromPath(manifestPath) + manifest, err := installer.loadManifest(ScopeGlobal) if err != nil { - t.Fatalf("loadManifestFromPath() error = %v", err) + t.Fatalf("loadManifest() error = %v", err) } if len(manifest.Packages) != 1 { t.Errorf("Expected 1 package, got %d", len(manifest.Packages)) } // Remove it - err = removeEntryFromManifest("github.com/user/repo", ScopeGlobal) - if err != nil { - t.Fatalf("removeEntryFromManifest() error = %v", err) + if err := installer.removeFromManifest("github.com/user/repo", ScopeGlobal); err != nil { + t.Fatalf("removeFromManifest() error = %v", err) } // Verify it was removed - manifest, err = loadManifestFromPath(manifestPath) + manifest, err = installer.loadManifest(ScopeGlobal) if err != nil { - t.Fatalf("loadManifestFromPath() error = %v", err) + t.Fatalf("loadManifest() error = %v", err) } if len(manifest.Packages) != 0 { t.Errorf("Expected 0 packages, got %d", len(manifest.Packages)) @@ -356,17 +354,15 @@ func TestFindInManifest(t *testing.T) { } }() - // Add an entry to global manifest - entry := ManifestEntry{ - Source: "git:github.com/user/repo", - Host: "github.com", - Path: "user/repo", - Scope: ScopeGlobal, + // Write a manifest entry directly via the package-level path resolver + // so FindInManifest (which uses manifestPathForScope) can read it back. + manifestPath := manifestPathForScope(ScopeGlobal) + if err := os.MkdirAll(filepath.Dir(manifestPath), 0755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) } - - err := addEntryToManifest(entry, ScopeGlobal) - if err != nil { - t.Fatalf("addEntryToManifest() error = %v", err) + data := []byte(`{"packages":[{"source":"git:github.com/user/repo","repo":"","host":"github.com","path":"user/repo","pinned":false,"scope":"global","installed":"0001-01-01T00:00:00Z"}]}`) + if err := os.WriteFile(manifestPath, data, 0644); err != nil { + t.Fatalf("WriteFile() error = %v", err) } // Find it diff --git a/internal/extensions/manifest.go b/internal/extensions/manifest.go index 5e300bc2..b5b07462 100644 --- a/internal/extensions/manifest.go +++ b/internal/extensions/manifest.go @@ -72,30 +72,6 @@ func loadManifestFromPath(path string) (*Manifest, error) { return &manifest, nil } -// saveManifestToScope saves the manifest to the given scope. -func saveManifestToScope(manifest *Manifest, scope InstallScope) error { - path := manifestPathForScope(scope) - return saveManifestToPath(manifest, path) -} - -// saveManifestToPath saves a manifest to a specific file path. -func saveManifestToPath(manifest *Manifest, path string) error { - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return fmt.Errorf("creating manifest directory: %w", err) - } - - data, err := json.MarshalIndent(manifest, "", " ") - if err != nil { - return fmt.Errorf("encoding manifest: %w", err) - } - - if err := os.WriteFile(path, data, 0644); err != nil { - return fmt.Errorf("writing manifest: %w", err) - } - - return nil -} - // manifestPathForScope returns the manifest file path for a scope. func manifestPathForScope(scope InstallScope) string { if scope == ScopeProject { @@ -113,55 +89,6 @@ func manifestPathForScope(scope InstallScope) string { return filepath.Join(base, "kit", "git", "packages.json") } -// GetGlobalManifest returns the global manifest. -func GetGlobalManifest() (*Manifest, error) { - return loadManifestFromScope(ScopeGlobal) -} - -// GetProjectManifest returns the project manifest. -func GetProjectManifest() (*Manifest, error) { - return loadManifestFromScope(ScopeProject) -} - -// addEntryToManifest adds or replaces an entry in the manifest for a scope. -func addEntryToManifest(entry ManifestEntry, scope InstallScope) error { - manifest, err := loadManifestFromScope(scope) - if err != nil { - return err - } - - // Remove any existing entry with same identity - identity := entry.Identity() - filtered := make([]ManifestEntry, 0, len(manifest.Packages)) - for _, p := range manifest.Packages { - if p.Identity() != identity { - filtered = append(filtered, p) - } - } - filtered = append(filtered, entry) - manifest.Packages = filtered - - return saveManifestToScope(manifest, scope) -} - -// removeEntryFromManifest removes an entry by identity from the manifest for a scope. -func removeEntryFromManifest(identity string, scope InstallScope) error { - manifest, err := loadManifestFromScope(scope) - if err != nil { - return err - } - - filtered := make([]ManifestEntry, 0, len(manifest.Packages)) - for _, p := range manifest.Packages { - if p.Identity() != identity { - filtered = append(filtered, p) - } - } - manifest.Packages = filtered - - return saveManifestToScope(manifest, scope) -} - // FindInManifest finds an entry by identity in either global or project manifest. // Returns the entry and its scope, or nil if not found. func FindInManifest(identity string) (*ManifestEntry, InstallScope, error) { diff --git a/internal/extensions/subagent.go b/internal/extensions/subagent.go index 19bba70f..15b6688e 100644 --- a/internal/extensions/subagent.go +++ b/internal/extensions/subagent.go @@ -2,22 +2,15 @@ package extensions import ( - "bufio" - "context" - "encoding/json" "fmt" "os" - "os/exec" - "strings" "sync" - "sync/atomic" "time" ) // --------------------------------------------------------------------------- // Subagent types // --------------------------------------------------------------------------- - // SubagentConfig configures a subagent spawn. type SubagentConfig struct { // Prompt is the task/instruction for the subagent (required). @@ -158,220 +151,3 @@ func (h *SubagentHandle) Done() <-chan struct{} { return h.done } -// --------------------------------------------------------------------------- -// Internal helpers -// --------------------------------------------------------------------------- - -// subagentJSONOutput matches the JSON envelope produced by `kit --json`. -type subagentJSONOutput struct { - Response string `json:"response"` - StopReason string `json:"stop_reason,omitempty"` - SessionID string `json:"session_id,omitempty"` - Usage *struct { - InputTokens int64 `json:"input_tokens"` - OutputTokens int64 `json:"output_tokens"` - } `json:"usage,omitempty"` -} - -var subagentCounter atomic.Uint64 - -func generateSubagentID() string { - n := subagentCounter.Add(1) - return fmt.Sprintf("sub-%d-%d", time.Now().UnixNano(), n) -} - -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" -} - -// --------------------------------------------------------------------------- -// SpawnSubagent implementation -// --------------------------------------------------------------------------- - -// SpawnSubagent spawns a child Kit instance to perform a task. -// -// When config.Blocking is true, blocks until completion and returns the result -// directly (handle is nil). When false, returns immediately with a handle for -// monitoring/cancellation. -// -// The subagent runs with --json --no-session --no-extensions flags by default, -// ensuring isolation from the parent's extensions and session state. -func SpawnSubagent(cfg SubagentConfig) (*SubagentHandle, *SubagentResult, error) { - if cfg.Prompt == "" { - return nil, nil, fmt.Errorf("prompt is required") - } - - timeout := cfg.Timeout - if timeout == 0 { - timeout = 5 * time.Minute - } - - kitBinary := findKitBinary() - - // Build subprocess arguments. - args := []string{ - "--json", - "--no-extensions", - } - if cfg.NoSession { - args = append(args, "--no-session") - } - if cfg.Model != "" { - args = append(args, "--model", cfg.Model) - } - - // Handle system prompt - write to temp file if provided. - var tmpFile *os.File - if cfg.SystemPrompt != "" { - var err error - tmpFile, err = os.CreateTemp("", "kit-subagent-*.txt") - if err != nil { - return nil, nil, fmt.Errorf("create temp file: %w", err) - } - if _, err := tmpFile.WriteString(cfg.SystemPrompt); err != nil { - _ = tmpFile.Close() - _ = os.Remove(tmpFile.Name()) - return nil, nil, fmt.Errorf("write system prompt: %w", err) - } - _ = tmpFile.Close() - args = append(args, "--system-prompt", tmpFile.Name()) - } - - // Add the prompt as a positional argument. - args = append(args, cfg.Prompt) - - // Create command with timeout context. - ctx, cancel := context.WithTimeout(context.Background(), timeout) - - cmd := exec.CommandContext(ctx, kitBinary, args...) - cmd.Env = os.Environ() - - stdout, err := cmd.StdoutPipe() - if err != nil { - cancel() - if tmpFile != nil { - _ = os.Remove(tmpFile.Name()) - } - return nil, nil, fmt.Errorf("stdout pipe: %w", err) - } - stderr, err := cmd.StderrPipe() - if err != nil { - cancel() - if tmpFile != nil { - _ = os.Remove(tmpFile.Name()) - } - return nil, nil, fmt.Errorf("stderr pipe: %w", err) - } - - handle := &SubagentHandle{ - ID: generateSubagentID(), - done: make(chan struct{}), - } - - // Start the subprocess. - start := time.Now() - if err := cmd.Start(); err != nil { - cancel() - if tmpFile != nil { - _ = os.Remove(tmpFile.Name()) - } - return nil, nil, fmt.Errorf("start subprocess: %w", err) - } - - handle.mu.Lock() - handle.proc = cmd.Process - handle.mu.Unlock() - - // Run the subprocess monitoring in a goroutine. - go func() { - defer close(handle.done) - defer cancel() - if tmpFile != nil { - defer func() { _ = os.Remove(tmpFile.Name()) }() - } - - var wg sync.WaitGroup - var stdoutBuf strings.Builder - - // Read stderr (live output). - wg.Go(func() { - scanner := bufio.NewScanner(stderr) - scanner.Buffer(make([]byte, 256*1024), 256*1024) - for scanner.Scan() { - line := scanner.Text() - if cfg.OnOutput != nil && strings.TrimSpace(line) != "" { - cfg.OnOutput(line + "\n") - } - } - }) - - // Read stdout (JSON output). - scanner := bufio.NewScanner(stdout) - scanner.Buffer(make([]byte, 256*1024), 256*1024) - for scanner.Scan() { - stdoutBuf.WriteString(scanner.Text() + "\n") - } - - wg.Wait() - waitErr := cmd.Wait() - elapsed := time.Since(start) - - // Build result. - result := SubagentResult{Elapsed: elapsed} - if waitErr != nil { - result.Error = waitErr - if exitErr, ok := waitErr.(*exec.ExitError); ok { - result.ExitCode = exitErr.ExitCode() - } else { - result.ExitCode = 1 - } - } - - // Parse JSON output. - raw := strings.TrimSpace(stdoutBuf.String()) - var parsed subagentJSONOutput - if raw != "" && json.Unmarshal([]byte(raw), &parsed) == nil { - result.Response = parsed.Response - result.SessionID = parsed.SessionID - if parsed.Usage != nil { - result.Usage = &SubagentUsage{ - InputTokens: parsed.Usage.InputTokens, - OutputTokens: parsed.Usage.OutputTokens, - } - } - } else { - // Fallback: use raw stdout. - result.Response = raw - } - - handle.mu.Lock() - handle.result = &result - handle.proc = nil - handle.mu.Unlock() - - if cfg.OnComplete != nil { - cfg.OnComplete(result) - } - }() - - if cfg.Blocking { - // Wait for completion and return result directly. - <-handle.done - handle.mu.Lock() - r := handle.result - handle.mu.Unlock() - return nil, r, nil - } - - return handle, nil, nil -} diff --git a/internal/models/cache_options.go b/internal/models/cache_options.go index 9ca7fc11..385d4d38 100644 --- a/internal/models/cache_options.go +++ b/internal/models/cache_options.go @@ -3,7 +3,6 @@ package models import ( "crypto/sha256" "encoding/hex" - "maps" "os" "charm.land/fantasy" @@ -70,18 +69,3 @@ func generateCacheKey(systemPrompt, modelID string) string { return "kit-" + hex.EncodeToString(h.Sum(nil))[:24] } -// mergeProviderOptions merges multiple ProviderOptions maps. -// Later maps take precedence over earlier ones. -func mergeProviderOptions(opts ...fantasy.ProviderOptions) fantasy.ProviderOptions { - result := make(fantasy.ProviderOptions) - - for _, opt := range opts { - maps.Copy(result, opt) - } - - if len(result) == 0 { - return nil - } - - return result -} diff --git a/internal/models/cache_options_test.go b/internal/models/cache_options_test.go index 060a5fa0..8d8e80d3 100644 --- a/internal/models/cache_options_test.go +++ b/internal/models/cache_options_test.go @@ -3,8 +3,6 @@ package models import ( "os" "testing" - - "charm.land/fantasy" ) func TestModelInfo_SupportsCaching(t *testing.T) { @@ -193,56 +191,3 @@ func TestCachingPriorityOverThinking(t *testing.T) { } } -func TestMergeProviderOptions(t *testing.T) { - opts1 := fantasy.ProviderOptions{ - "provider1": &testProviderData{value: "value1"}, - } - opts2 := fantasy.ProviderOptions{ - "provider2": &testProviderData{value: "value2"}, - } - - merged := mergeProviderOptions(opts1, opts2) - - if len(merged) != 2 { - t.Errorf("mergeProviderOptions should combine options from multiple maps, got %d items", len(merged)) - } - - if _, ok := merged["provider1"]; !ok { - t.Errorf("merged options should contain 'provider1' key") - } - - if _, ok := merged["provider2"]; !ok { - t.Errorf("merged options should contain 'provider2' key") - } - - // Later options should override earlier ones - opts3 := fantasy.ProviderOptions{ - "provider1": &testProviderData{value: "overridden"}, - } - merged2 := mergeProviderOptions(opts1, opts3) - - if data, ok := merged2["provider1"].(*testProviderData); ok { - if data.value != "overridden" { - t.Errorf("later options should override earlier ones, got %q", data.value) - } - } - - if mergeProviderOptions() != nil { - t.Errorf("mergeProviderOptions with no args should return nil") - } -} - -// testProviderData is a simple implementation of ProviderOptionsData for testing -type testProviderData struct { - value string -} - -func (t *testProviderData) Options() {} - -func (t *testProviderData) MarshalJSON() ([]byte, error) { - return []byte(`"` + t.value + `"`), nil -} - -func (t *testProviderData) UnmarshalJSON(data []byte) error { - return nil -} diff --git a/internal/models/pool.go b/internal/models/pool.go deleted file mode 100644 index 012ce510..00000000 --- a/internal/models/pool.go +++ /dev/null @@ -1,168 +0,0 @@ -package models - -import ( - "context" - "sync" - "time" - - "charm.land/fantasy" -) - -// ProviderPool manages reusable LLM provider instances to reduce overhead -// when spawning multiple subagents or making repeated completion calls. -type ProviderPool struct { - mu sync.RWMutex - providers map[string]*pooledProvider - ttl time.Duration - closed bool - closeCh chan struct{} -} - -type pooledProvider struct { - model fantasy.LanguageModel - closer func() error - providerOpts fantasy.ProviderOptions - created time.Time - lastUsed time.Time - refs int32 -} - -// DefaultPoolTTL is the default time-to-live for idle pooled providers. -const DefaultPoolTTL = 5 * time.Minute - -// globalPool is the singleton provider pool instance. -var globalPool *ProviderPool -var poolOnce sync.Once - -// GetGlobalPool returns the singleton provider pool instance. -func GetGlobalPool() *ProviderPool { - poolOnce.Do(func() { - globalPool = NewProviderPool(DefaultPoolTTL) - }) - return globalPool -} - -// NewProviderPool creates a provider pool with the given TTL for idle providers. -func NewProviderPool(ttl time.Duration) *ProviderPool { - p := &ProviderPool{ - providers: make(map[string]*pooledProvider), - ttl: ttl, - closeCh: make(chan struct{}), - } - go p.cleanupLoop() - return p -} - -// Get returns a provider for the model string, creating one if needed. -// The returned release function must be called when the provider is no longer -// needed. The provider may be reused by subsequent Get calls. -func (p *ProviderPool) Get(ctx context.Context, modelString string) (fantasy.LanguageModel, fantasy.ProviderOptions, func(), error) { - p.mu.Lock() - - // Check if we have an existing provider. - if pp, ok := p.providers[modelString]; ok { - pp.refs++ - pp.lastUsed = time.Now() - p.mu.Unlock() - return pp.model, pp.providerOpts, func() { p.release(modelString) }, nil - } - - p.mu.Unlock() - - // Create a new provider outside the lock. - config := &ProviderConfig{ModelString: modelString} - result, err := CreateProvider(ctx, config) - if err != nil { - return nil, nil, nil, err - } - - p.mu.Lock() - defer p.mu.Unlock() - - // Double-check: another goroutine may have created one while we were unlocked. - if pp, ok := p.providers[modelString]; ok { - // Close the one we just created and use the existing one. - if result.Closer != nil { - _ = result.Closer.Close() - } - pp.refs++ - pp.lastUsed = time.Now() - return pp.model, pp.providerOpts, func() { p.release(modelString) }, nil - } - - var closerFn func() error - if result.Closer != nil { - closerFn = result.Closer.Close - } - - pp := &pooledProvider{ - model: result.Model, - closer: closerFn, - providerOpts: result.ProviderOptions, - created: time.Now(), - lastUsed: time.Now(), - refs: 1, - } - p.providers[modelString] = pp - - return pp.model, pp.providerOpts, func() { p.release(modelString) }, nil -} - -func (p *ProviderPool) release(modelString string) { - p.mu.Lock() - defer p.mu.Unlock() - - if pp, ok := p.providers[modelString]; ok { - pp.refs-- - pp.lastUsed = time.Now() - } -} - -func (p *ProviderPool) cleanupLoop() { - ticker := time.NewTicker(p.ttl / 2) - defer ticker.Stop() - - for { - select { - case <-p.closeCh: - return - case <-ticker.C: - p.cleanup() - } - } -} - -func (p *ProviderPool) cleanup() { - p.mu.Lock() - defer p.mu.Unlock() - - now := time.Now() - for key, pp := range p.providers { - // Only clean up providers with no active references and past TTL. - if pp.refs <= 0 && now.Sub(pp.lastUsed) > p.ttl { - if pp.closer != nil { - _ = pp.closer() - } - delete(p.providers, key) - } - } -} - -// Close shuts down the pool and releases all providers. -func (p *ProviderPool) Close() { - p.mu.Lock() - if p.closed { - p.mu.Unlock() - return - } - p.closed = true - close(p.closeCh) - - for key, pp := range p.providers { - if pp.closer != nil { - _ = pp.closer() - } - delete(p.providers, key) - } - p.mu.Unlock() -} diff --git a/internal/prompts/loader.go b/internal/prompts/loader.go index 5a629ae8..582ae8b7 100644 --- a/internal/prompts/loader.go +++ b/internal/prompts/loader.go @@ -179,31 +179,6 @@ func LoadFromDir(dir string) ([]*PromptTemplate, error) { return templates, nil } -// Deduplicate removes duplicate templates by name, keeping the first occurrence. -// It returns the deduplicated list and diagnostics for any collisions. -// This is a standalone function for when you need to deduplicate an existing list. -func Deduplicate(templates []*PromptTemplate) ([]*PromptTemplate, []Diagnostic) { - seen := make(map[string]*PromptTemplate) - var result []*PromptTemplate - var diagnostics []Diagnostic - - for _, tpl := range templates { - if existing, ok := seen[tpl.Name]; ok { - diagnostics = append(diagnostics, Diagnostic{ - Name: tpl.Name, - KeptPath: existing.FilePath, - DroppedPath: tpl.FilePath, - Reason: "duplicate template name (first-match-wins)", - }) - } else { - seen[tpl.Name] = tpl - result = append(result, tpl) - } - } - - return result, diagnostics -} - // loadDefaultTemplates returns the built-in default templates. // These are embedded templates that ship with Kit. func loadDefaultTemplates() []*PromptTemplate { diff --git a/internal/ui/block_renderer.go b/internal/ui/block_renderer.go index 0b1a06f4..db40b562 100644 --- a/internal/ui/block_renderer.go +++ b/internal/ui/block_renderer.go @@ -28,15 +28,6 @@ type blockRenderer struct { // renderingOption configures block rendering type renderingOption func(*blockRenderer) -// WithFullWidth returns a renderingOption that configures the block renderer -// to expand to the full available width of its container. When enabled, the -// block will fill the entire horizontal space rather than sizing to its content. -func WithFullWidth() renderingOption { - return func(c *blockRenderer) { - c.fullWidth = true - } -} - // WithNoBorder returns a renderingOption that disables all borders on the // block, rendering content with only padding. func WithNoBorder() renderingOption { @@ -63,15 +54,6 @@ func WithBorderColor(c color.Color) renderingOption { } } -// WithMarginTop returns a renderingOption that sets the top margin -// for the block. The margin is specified in number of lines and adds -// vertical space above the block. -func WithMarginTop(margin int) renderingOption { - return func(c *blockRenderer) { - c.marginTop = margin - } -} - // WithMarginBottom returns a renderingOption that sets the bottom margin // for the block. The margin is specified in number of lines and adds // vertical space below the block. @@ -81,24 +63,6 @@ func WithMarginBottom(margin int) renderingOption { } } -// WithPaddingLeft returns a renderingOption that sets the left padding -// for the block content. The padding is specified in number of characters -// and adds horizontal space between the left border and the content. -func WithPaddingLeft(padding int) renderingOption { - return func(c *blockRenderer) { - c.paddingLeft = padding - } -} - -// WithPaddingRight returns a renderingOption that sets the right padding -// for the block content. The padding is specified in number of characters -// and adds horizontal space between the content and the right border. -func WithPaddingRight(padding int) renderingOption { - return func(c *blockRenderer) { - c.paddingRight = padding - } -} - // WithPaddingTop returns a renderingOption that sets the top padding // for the block content. The padding is specified in number of lines // and adds vertical space between the top border and the content. @@ -117,33 +81,6 @@ func WithPaddingBottom(padding int) renderingOption { } } -// WithBackground returns a renderingOption that sets the background color -// for the entire block. The color parameter accepts any color.Color value, -// typically a lipgloss hex color (e.g. lipgloss.Color("#1e1e2e")). -func WithBackground(c color.Color) renderingOption { - return func(br *blockRenderer) { - br.background = &c - } -} - -// WithForeground returns a renderingOption that overrides the default text -// foreground color (theme.Text) for the block. Useful for muted or -// de-emphasized content blocks. -func WithForeground(c color.Color) renderingOption { - return func(br *blockRenderer) { - br.foreground = &c - } -} - -// WithWidth returns a renderingOption that sets a specific width for the block -// in characters. This overrides the default container width and allows precise -// control over the block's horizontal dimensions. -func WithWidth(width int) renderingOption { - return func(c *blockRenderer) { - c.width = width - } -} - // renderContentBlock renders content with configurable styling options func renderContentBlock(content string, containerWidth int, options ...renderingOption) string { renderer := &blockRenderer{ diff --git a/internal/ui/cli.go b/internal/ui/cli.go index ae55ebf3..5f3d2b51 100644 --- a/internal/ui/cli.go +++ b/internal/ui/cli.go @@ -54,12 +54,6 @@ func (c *CLI) GetUsageTracker() *UsageTracker { return c.usageTracker } -// GetDebugLogger returns a CLIDebugLogger instance that routes debug output -// through the CLI's rendering system for consistent message formatting and display. -func (c *CLI) GetDebugLogger() *CLIDebugLogger { - return NewCLIDebugLogger(c) -} - // SetModelName updates the current AI model name being used in the conversation. // This name is displayed in message headers to indicate which model is responding. func (c *CLI) SetModelName(modelName string) { @@ -87,13 +81,6 @@ func (c *CLI) DisplayUserMessage(message string) { fmt.Println(c.renderer.RenderUserMessage(message, time.Now()).Content) } -// DisplayAssistantMessage renders and displays an AI assistant's response message -// with appropriate formatting. This method delegates to DisplayAssistantMessageWithModel -// with an empty model name for backward compatibility. -func (c *CLI) DisplayAssistantMessage(message string) error { - return c.DisplayAssistantMessageWithModel(message, "") -} - // DisplayAssistantMessageWithModel renders and displays an AI assistant's response // with the specified model name shown in the message header. The message is // formatted according to the current display mode and includes timestamp information. @@ -149,12 +136,6 @@ func (c *CLI) DisplayExtensionBlock(text, borderColor, subtitle string) { fmt.Println(rendered) } -// DisplayCancellation displays a system message indicating that the current -// AI generation has been cancelled by the user (typically via ESC key). -func (c *CLI) DisplayCancellation() { - fmt.Println(c.renderer.RenderSystemMessage("Generation cancelled by user (ESC pressed)", time.Now()).Content) -} - // DisplayDebugMessage renders and displays a debug message if debug mode is enabled. // Debug messages are formatted distinctively and only shown when the CLI is // initialized with debug=true. diff --git a/internal/ui/commands/commands.go b/internal/ui/commands/commands.go index a7ba8b40..c68ef8c3 100644 --- a/internal/ui/commands/commands.go +++ b/internal/ui/commands/commands.go @@ -199,18 +199,6 @@ func GetCommandByName(name string) *SlashCommand { return nil } -// GetAllCommandNames returns a complete list of all command names and their aliases. -// This is useful for command completion, validation, and help display. The returned -// slice contains both primary command names and all alternative aliases. -func GetAllCommandNames() []string { - var names []string - for _, cmd := range SlashCommands { - names = append(names, cmd.Name) - names = append(names, cmd.Aliases...) - } - return names -} - // ExtensionCommand is a slash command registered by an extension. Unlike // built-in SlashCommands whose execution is hardcoded in handleSlashCommand, // extension commands carry their own Execute callback. diff --git a/internal/ui/debug_logger.go b/internal/ui/debug_logger.go deleted file mode 100644 index 8866430c..00000000 --- a/internal/ui/debug_logger.go +++ /dev/null @@ -1,79 +0,0 @@ -package ui - -import ( - "fmt" - "strings" - "time" -) - -// CLIDebugLogger implements the tools.DebugLogger interface using CLI rendering. -// It provides debug logging functionality that integrates with the CLI's display -// system, ensuring debug messages are properly formatted and displayed alongside -// other conversation content. -type CLIDebugLogger struct { - cli *CLI -} - -// NewCLIDebugLogger creates and returns a new CLIDebugLogger instance that routes -// debug output through the provided CLI instance. The logger will respect the CLI's -// debug mode setting and display format preferences. -func NewCLIDebugLogger(cli *CLI) *CLIDebugLogger { - return &CLIDebugLogger{cli: cli} -} - -// LogDebug processes and displays a debug message through the CLI's rendering system. -// Messages are formatted with appropriate emojis and tags based on their content type -// (DEBUG, POOL, etc.) and only displayed when debug mode is enabled. The method handles -// multi-line debug output and connection pool status messages with context-aware formatting. -func (l *CLIDebugLogger) LogDebug(message string) { - if l.cli == nil || !l.cli.debug { - return - } - - // Format the message to include all the debug info in a structured way - var formattedMessage string - - // Check if this is a multi-line debug output (like connection info) - if strings.Contains(message, "[DEBUG]") || strings.Contains(message, "[POOL]") { - // Extract the tag and content - if after, ok := strings.CutPrefix(message, "[DEBUG]"); ok { - content := after - content = strings.TrimSpace(content) - formattedMessage = fmt.Sprintf("🔍 DEBUG: %s", content) - } else if after, ok := strings.CutPrefix(message, "[POOL]"); ok { - content := after - content = strings.TrimSpace(content) - - // Add appropriate emoji based on the message content - if strings.Contains(content, "Creating new connection") { - formattedMessage = fmt.Sprintf("🆕 POOL: %s", content) - } else if strings.Contains(content, "Created connection") || strings.Contains(content, "Initialized") { - formattedMessage = fmt.Sprintf("✅ POOL: %s", content) - } else if strings.Contains(content, "Reusing") { - formattedMessage = fmt.Sprintf("🔄 POOL: %s", content) - } else if strings.Contains(content, "unhealthy") || strings.Contains(content, "failed") { - formattedMessage = fmt.Sprintf("❌ POOL: %s", content) - } else if strings.Contains(content, "closed") { - formattedMessage = fmt.Sprintf("🛑 POOL: %s", content) - } else if strings.Contains(content, "Failed to close") { - formattedMessage = fmt.Sprintf("⚠️ POOL: %s", content) - } else { - formattedMessage = fmt.Sprintf("🔍 POOL: %s", content) - } - } else { - formattedMessage = message - } - } else { - formattedMessage = message - } - - // Use the CLI's debug message rendering - fmt.Println(l.cli.renderer.RenderDebugMessage(formattedMessage, time.Now()).Content) -} - -// IsDebugEnabled checks whether debug logging is currently active. Returns true -// if the CLI instance exists and has debug mode enabled, allowing callers to -// conditionally perform expensive debug operations only when necessary. -func (l *CLIDebugLogger) IsDebugEnabled() bool { - return l.cli != nil && l.cli.debug -} diff --git a/internal/ui/message_items.go b/internal/ui/message_items.go index c7726439..1ab3861a 100644 --- a/internal/ui/message_items.go +++ b/internal/ui/message_items.go @@ -25,17 +25,6 @@ type TextMessageItem struct { timestamp time.Time } -// NewTextMessageItem creates a new text message for the scrollback. -// The content should be pre-rendered using MessageRenderer for proper styling. -func NewTextMessageItem(id string, role string, content string) *TextMessageItem { - return &TextMessageItem{ - id: id, - role: role, - content: content, - timestamp: time.Now(), - } -} - // NewStyledMessageItem creates a message item with pre-rendered styled content. // This is the preferred way to create messages when you have styled content from MessageRenderer. func NewStyledMessageItem(id string, role string, rawContent string, preRendered string) *TextMessageItem { @@ -316,57 +305,6 @@ func (m *StreamingBashOutputItem) MarkComplete() { } // -------------------------------------------------------------------------- -// SystemMessageItem - System messages (commands, info, errors) -// -------------------------------------------------------------------------- - -// SystemMessageItem represents a system message (commands, info, errors). -type SystemMessageItem struct { - id string - content string - timestamp time.Time - cachedRender string - cachedWidth int -} - -// NewSystemMessageItem creates a new system message for the scrollback. -func NewSystemMessageItem(id, content string) *SystemMessageItem { - return &SystemMessageItem{ - id: id, - content: content, - timestamp: time.Now(), - } -} - -func (m *SystemMessageItem) ID() string { - return m.id -} - -func (m *SystemMessageItem) Render(width int) string { - // Return cached render if width matches - if m.cachedWidth == width && m.cachedRender != "" { - return m.cachedRender - } - - // Simple system message formatting - rendered := "│ " + strings.ReplaceAll(m.content, "\n", "\n│ ") - - // Cache and return - m.cachedRender = rendered - m.cachedWidth = width - return rendered -} - -func (m *SystemMessageItem) Height() int { - if m.cachedRender != "" { - return strings.Count(m.cachedRender, "\n") + 1 - } - // Estimate - if m.cachedWidth > 0 { - return (len(m.content) / max(m.cachedWidth-10, 40)) + 3 - } - return 3 -} - // -------------------------------------------------------------------------- // Helper: generateMessageID // -------------------------------------------------------------------------- diff --git a/internal/ui/render/blocks.go b/internal/ui/render/blocks.go index e874a44c..6da706c6 100644 --- a/internal/ui/render/blocks.go +++ b/internal/ui/render/blocks.go @@ -19,28 +19,7 @@ import ( // - @path/to/file.txt (unquoted, no spaces) var fileTokenPattern = regexp.MustCompile(`@"[^"]+"|@[^\s]+`) -// UserBlock renders a user message with herald Tip styling. -// The width parameter controls line wrapping so long messages don't overflow. -// Any @file tokens in the content are highlighted with the theme accent color. -func UserBlock(content string, width int, ty *herald.Typography, theme style.Theme) string { - if strings.TrimSpace(content) == "" { - content = "(empty message)" - } - - // Wrap content before passing to herald Alert so long lines break - // inside the alert box. Subtract 4 to account for the alert bar - // prefix ("│ ") and a small margin. - if width > 4 { - content = lipgloss.Wrap(content, width-4, "") - } - - // Highlight @file tokens with accent color so file references are - // visually distinct from surrounding prompt text. - content = HighlightFileTokens(content, theme) - - rendered := ty.Tip(content) - return styleMarginBottom(theme, rendered) -} +// UserBlock-related rendering helpers and herald typography. // HighlightFileTokens wraps @file tokens in the given text with the theme // accent color so they stand out visually in rendered user messages. @@ -154,44 +133,6 @@ func ErrorBlock(errorMsg string, ty *herald.Typography, theme style.Theme) strin return styleMarginBottom(theme, rendered) } -// ToolBlock renders a tool execution result with header and body. -func ToolBlock(displayName, params, body string, isError bool, width int, ty *herald.Typography, theme style.Theme) string { - var icon string - iconColor := theme.Success - if isError { - icon = "×" - iconColor = theme.Error - } else { - icon = "✓" - } - - // Style the tool name with color - nameColor := theme.Info - if isError { - nameColor = theme.Error - } - styledName := lipgloss.NewStyle().Foreground(nameColor).Bold(true).Render(displayName) - styledIcon := lipgloss.NewStyle().Foreground(iconColor).Render(icon) - - // Build the content: icon + name + params on first line, then body - headerLine := styledIcon + " " + styledName - if params != "" { - headerLine += " " + lipgloss.NewStyle().Foreground(theme.Muted).Render(params) - } - - if strings.TrimSpace(body) == "" { - body = ty.Italic("(no output)") - } - - // Compose: icon + name + params, then body - fullContent := ty.Compose( - headerLine, - "", - body, - ) - return styleMarginBottom(theme, fullContent) -} - // styleMarginBottom applies a 1-line margin bottom using the theme. func styleMarginBottom(theme style.Theme, content string) string { return style.GetCachedStyles().MarginBottom1.Render(content) diff --git a/internal/ui/render/blocks_test.go b/internal/ui/render/blocks_test.go index 9e70a78b..60b9de9c 100644 --- a/internal/ui/render/blocks_test.go +++ b/internal/ui/render/blocks_test.go @@ -88,24 +88,22 @@ func TestHighlightFileTokens(t *testing.T) { } } -func TestUserBlockHighlightsFileTokens(t *testing.T) { +func TestHighlightFileTokensInjectsANSI(t *testing.T) { theme := style.DefaultTheme() - ty := testTypography(theme) - // A user message with @file tokens should contain ANSI escapes around the token. content := "refactor @main.go and @utils.go" - result := UserBlock(content, 80, ty, theme) + result := HighlightFileTokens(content, theme) - // The rendered output should contain both file references. + // The output should still contain both file references. if !strings.Contains(result, "@main.go") { - t.Errorf("UserBlock output should contain @main.go, got:\n%s", result) + t.Errorf("HighlightFileTokens output should contain @main.go, got:\n%s", result) } if !strings.Contains(result, "@utils.go") { - t.Errorf("UserBlock output should contain @utils.go, got:\n%s", result) + t.Errorf("HighlightFileTokens output should contain @utils.go, got:\n%s", result) } // Verify ANSI codes are present (the tokens are styled). if !strings.Contains(result, "\x1b[") { - t.Errorf("UserBlock output should contain ANSI escape codes for styled @file tokens") + t.Errorf("HighlightFileTokens output should contain ANSI escape codes for styled @file tokens") } } diff --git a/internal/ui/style/enhanced.go b/internal/ui/style/enhanced.go index 8dd1aa0c..15a1050c 100644 --- a/internal/ui/style/enhanced.go +++ b/internal/ui/style/enhanced.go @@ -211,106 +211,11 @@ func DefaultTheme() Theme { } } -// StyleCard creates a lipgloss style for card-like containers with rounded borders, -// padding, and appropriate width. Used for grouping related content in a visually -// distinct box. -func StyleCard(width int, theme Theme) lipgloss.Style { - return lipgloss.NewStyle(). - Width(width). - Border(lipgloss.RoundedBorder()). - BorderForeground(theme.Border). - Padding(1, 2). - MarginBottom(1) -} - // IsDarkBackground returns the cached terminal background detection result. func IsDarkBackground() bool { return isDarkBg } -// StyleHeader creates a lipgloss style for primary headers using the theme's -// primary color with bold text for emphasis and hierarchy. -func StyleHeader(theme Theme) lipgloss.Style { - return lipgloss.NewStyle(). - Foreground(theme.Primary). - Bold(true) -} - -// StyleSubheader creates a lipgloss style for secondary headers using the theme's -// secondary color with bold text, providing visual hierarchy below primary headers. -func StyleSubheader(theme Theme) lipgloss.Style { - return lipgloss.NewStyle(). - Foreground(theme.Secondary). - Bold(true) -} - -// StyleMuted creates a lipgloss style for de-emphasized text using muted colors -// and italic formatting, suitable for supplementary or less important information. -func StyleMuted(theme Theme) lipgloss.Style { - return lipgloss.NewStyle(). - Foreground(theme.Muted). - Italic(true) -} - -// StyleSuccess creates a lipgloss style for success messages using green colors -// with bold text to indicate successful operations or positive outcomes. -func StyleSuccess(theme Theme) lipgloss.Style { - return lipgloss.NewStyle(). - Foreground(theme.Success). - Bold(true) -} - -// StyleError creates a lipgloss style for error messages using red colors -// with bold text to ensure visibility of problems or failures. -func StyleError(theme Theme) lipgloss.Style { - return lipgloss.NewStyle(). - Foreground(theme.Error). - Bold(true) -} - -// StyleWarning creates a lipgloss style for warning messages using yellow/amber -// colors with bold text to draw attention to potential issues or cautions. -func StyleWarning(theme Theme) lipgloss.Style { - return lipgloss.NewStyle(). - Foreground(theme.Warning). - Bold(true) -} - -// StyleInfo creates a lipgloss style for informational messages using blue colors -// with bold text for general notifications and status updates. -func StyleInfo(theme Theme) lipgloss.Style { - return lipgloss.NewStyle(). - Foreground(theme.Info). - Bold(true) -} - -// CreateSeparator generates a horizontal separator line with the specified width, -// character, and color. Useful for visually dividing sections of content in the UI. -func CreateSeparator(width int, char string, c color.Color) string { - return lipgloss.NewStyle(). - Foreground(c). - Width(width). - Render(lipgloss.PlaceHorizontal(width, lipgloss.Center, char)) -} - -// CreateProgressBar generates a visual progress bar with filled and empty segments -// based on the percentage complete. The bar uses Unicode block characters for smooth -// appearance and theme colors to indicate progress. -func CreateProgressBar(width int, percentage float64, theme Theme) string { - filled := int(float64(width) * percentage / 100) - empty := width - filled - - filledBar := lipgloss.NewStyle(). - Foreground(theme.Success). - Render(lipgloss.PlaceHorizontal(filled, lipgloss.Left, "█")) - - emptyBar := lipgloss.NewStyle(). - Foreground(theme.Muted). - Render(lipgloss.PlaceHorizontal(empty, lipgloss.Left, "░")) - - return filledBar + emptyBar -} - // CreateBadge generates a styled badge or label with inverted colors (text on // colored background) for highlighting important tags, statuses, or categories. func CreateBadge(text string, c color.Color) string { diff --git a/internal/ui/style/themes.go b/internal/ui/style/themes.go index 5ff0cb33..a5fa8446 100644 --- a/internal/ui/style/themes.go +++ b/internal/ui/style/themes.go @@ -543,12 +543,6 @@ func ApplyThemeWithoutSave(name string) error { return nil } -// RefreshThemeRegistry re-scans the themes directory. Call after the user -// drops a new file into ~/.config/kit/themes/. -func RefreshThemeRegistry() { - initThemeRegistry() -} - // RegisterThemeFromConfig adds a theme to the runtime registry from an // extension's ThemeColorConfig (string hex pairs). Replaces any existing // entry with the same name. The theme is immediately available via diff --git a/internal/ui/tool_approval_input.go b/internal/ui/tool_approval_input.go deleted file mode 100644 index b310cd5e..00000000 --- a/internal/ui/tool_approval_input.go +++ /dev/null @@ -1,140 +0,0 @@ -package ui - -import ( - "fmt" - "strings" - - "charm.land/bubbles/v2/textarea" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" -) - -type ToolApprovalInput struct { - textarea textarea.Model - toolName string - toolArgs string - width int - selected bool // true when "yes" is highlighted and false when "no" is - approved bool - done bool -} - -func NewToolApprovalInput(toolName, toolArgs string, width int) *ToolApprovalInput { - ta := textarea.New() - ta.Placeholder = "" - ta.ShowLineNumbers = false - ta.CharLimit = 0 - ta.SetWidth(width - 8) // Account for container padding, border and internal padding - ta.SetHeight(4) // Default to 3 lines like huh - ta.Focus() - - // Style the textarea using theme colors. - theme := GetTheme() - styles := ta.Styles() - styles.Focused.Base = lipgloss.NewStyle() - styles.Focused.Placeholder = lipgloss.NewStyle().Foreground(theme.VeryMuted) - styles.Focused.Text = lipgloss.NewStyle().Foreground(theme.Text) - styles.Focused.Prompt = lipgloss.NewStyle() - styles.Focused.CursorLine = lipgloss.NewStyle() - ta.SetStyles(styles) - - return &ToolApprovalInput{ - textarea: ta, - toolName: toolName, - toolArgs: toolArgs, - width: width, - selected: true, - } -} - -func (t *ToolApprovalInput) Init() tea.Cmd { - return textarea.Blink -} - -func (t *ToolApprovalInput) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyPressMsg: - switch msg.String() { - case "y", "Y": - t.approved = true - t.done = true - return t, tea.Quit - case "n", "N": - t.approved = false - t.done = true - return t, tea.Quit - case "left": - t.selected = true - return t, nil - case "right": - t.selected = false - return t, nil - case "enter": - t.approved = t.selected - t.done = true - return t, tea.Quit - case "esc", "ctrl+c": - t.approved = false - t.done = true - return t, tea.Quit - } - } - return t, nil -} - -func (t *ToolApprovalInput) View() tea.View { - if t.done { - return tea.NewView("we are done") - } - - containerStyle := lipgloss.NewStyle() - - theme := GetTheme() - - // PaddingLeft(3) aligns with message content: border(1) + paddingLeft(2). - titleStyle := lipgloss.NewStyle(). - Foreground(theme.Text). - MarginBottom(1). - PaddingLeft(3) - - // Input box with huh-like styling - inputBoxStyle := lipgloss.NewStyle(). - Border(lipgloss.ThickBorder()). - BorderLeft(true). - BorderRight(false). - BorderTop(false). - BorderBottom(false). - BorderForeground(theme.Primary). - PaddingLeft(2). // match message block paddingLeft - Width(t.width - 1) // full width minus left border - - // Style for the currently selected/highlighted option - selectedStyle := lipgloss.NewStyle(). - Foreground(theme.Success). - Bold(true). - Underline(true) - - // Style for the unselected/unhighlighted option - unselectedStyle := lipgloss.NewStyle(). - Foreground(theme.VeryMuted) - - // Build the view - var view strings.Builder - view.WriteString(titleStyle.Render("Allow tool execution")) - view.WriteString("\n") - details := fmt.Sprintf("Tool: %s\nArguments: %s\n\n", t.toolName, t.toolArgs) - view.WriteString(details) - view.WriteString("Allow tool execution: ") - - var yesText, noText string - if t.selected { - yesText = selectedStyle.Render("[y]es") - noText = unselectedStyle.Render("[n]o") - } else { - yesText = unselectedStyle.Render("[y]es") - noText = selectedStyle.Render("[n]o") - } - view.WriteString(yesText + "/" + noText + "\n") - - return tea.NewView(containerStyle.Render(inputBoxStyle.Render(view.String()))) -} From 45689cb30d8aa0275957ca8fe89f617bf871107d Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Thu, 7 May 2026 12:23:15 +0300 Subject: [PATCH 2/8] extract duplicated subagent + event conversion to internal/extbridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same ~40-line block — building a kit.SubagentConfig, wrapping OnEvent through sdkEventToSubagentEvent, calling kitInstance.Subagent, and translating the SDK result into extensions.SubagentResult — was copy-pasted three times: * cmd/root.go (interactive TUI Context, line 1148) * cmd/root.go (post-SessionStart runtime Context, line 1446) * internal/acpserver/session.go (ACP server Context, line 154) A separate sdkEventToSubagentEvent function was duplicated byte-for-byte between cmd/root.go and internal/acpserver/session.go. Both are now consolidated in a new internal/extbridge package which is the only module-internal home that can legitimately import both pkg/kit/ (the public SDK) and internal/extensions/. cmd/ and internal/acpserver/ both import it, so SDK-event-to-extension-event schema changes only have one site to update. Also fixes pkg/kit/events.go godoc comment that named the underlying LLM library, per AGENTS.md 'No Dependency Name Leakage' rule for exported SDK symbols. go test -race ./... passes. --- .kit/prompts/code-audit.md | 146 ++++++++++++++++++++++++++++++++ cmd/root.go | 118 +------------------------- internal/acpserver/session.go | 71 +--------------- internal/extbridge/extbridge.go | 97 +++++++++++++++++++++ pkg/kit/events.go | 6 +- 5 files changed, 251 insertions(+), 187 deletions(-) create mode 100644 .kit/prompts/code-audit.md create mode 100644 internal/extbridge/extbridge.go diff --git a/.kit/prompts/code-audit.md b/.kit/prompts/code-audit.md new file mode 100644 index 00000000..c228e57d --- /dev/null +++ b/.kit/prompts/code-audit.md @@ -0,0 +1,146 @@ +--- +description: Read-only audit for dead code, duplication, boundary violations, and refactor opportunities +--- + +Perform a comprehensive **read-only** audit of this repository and report +findings. **Do not edit, rename, or delete any files.** Optional focus / scope +hints from the user: $@ + +## Scope + +If the user supplied focus hints above (a package path, a subsystem name, a +concern like "TUI" or "extensions"), scope the audit accordingly. Otherwise +audit the whole repo, prioritising the highest-traffic packages first +(`cmd/`, `internal/`, `pkg/kit/` for this repo). + +## Steps + +1. **Map the repo first**: + - `ls` / `find` the top-level layout and list every Go package + - Read `AGENTS.md`, `README.md`, and any `pkg/*/doc.go` to understand the + intended architectural boundaries (SDK vs internal vs TUI vs cmd vs + extension surface) + - Note the public SDK surface (`pkg/kit/`) and any documented invariants + (e.g. "no dependency name leakage", "UI never imports extensions + directly") — these define what counts as a violation + +2. **Hunt for dead code**: + - Run `go vet ./...` and capture warnings + - Use `grep` to find exported symbols (`^func [A-Z]`, `^type [A-Z]`, + `^var [A-Z]`, `^const [A-Z]`) and cross-reference call sites. Symbols + with zero non-test references inside the module are suspects + - Check for unreferenced files, `// TODO: remove` markers, commented-out + blocks, and `_ = x` discard patterns + - If `staticcheck`, `deadcode`, or `unused` are available on PATH, run + them and include their output verbatim + - **Do not delete anything** — list candidates with file:line and a + confidence level (high / medium / low) + +3. **Find unnecessary duplication**: + - Look for near-identical function bodies, struct shapes, or switch + statements across packages — `grep` for repeated function signatures + and copy-pasted string literals / error messages is a fast first pass + - Distinguish *coincidental* duplication (two things that happen to look + alike but evolve independently) from *unnecessary* duplication (same + intent, drifting in lockstep) — only flag the latter + - For each cluster, propose where the extracted helper should live + (which package, which file) and whether it crosses a boundary + +4. **Check concerns / boundary violations**: + - **SDK leakage**: grep `pkg/kit/` for imports of `internal/...` types + in exported signatures, and for dependency-name leakage in exported + names / godoc (e.g. library jargon appearing in `LLM*` types) + - **UI ↔ extensions**: grep `internal/ui/` for any import of + `internal/extensions/` — per AGENTS.md the UI must not import + extensions directly; converters in `cmd/root.go` should bridge them + - **cmd vs internal**: business logic living in `cmd/` that should be + in `internal/` (and vice versa) + - **Cyclic risk**: packages that import each other transitively or that + reach across sibling boundaries unexpectedly + - For each violation, cite the offending import / signature with + file:line + +5. **Spot refactor opportunities**: + - Long functions (>80 lines) doing multiple unrelated things + - Deeply nested conditionals that flatten well with early returns + - Repeated `if err != nil { return fmt.Errorf("...: %w", err) }` chains + that could become helpers — but only where the wrapping context is + genuinely uniform + - Structs with too many fields that hint at split responsibilities + - Exported APIs that would be cleaner with options structs / functional + options + - Tests that share setup boilerplate ripe for a helper + - Flag each with: location, current shape (1-2 lines), proposed shape + (1-2 lines), and estimated risk (low / medium / high) + +6. **Cross-check against project rules**: + - Re-read `AGENTS.md` "Key Patterns" section and verify nothing in your + findings contradicts the documented gotchas (Yaegi interface ban, + `prog.Send()` from `Update()`, function-field bug, etc.) — if a + "refactor" would reintroduce a known pitfall, drop it from the report + and note why + +7. **Write the report** as your final message (do not write it to disk) + structured as: + + ``` + # Code Audit Report + + ## Summary + - N dead-code candidates + - N duplication clusters + - N boundary violations + - N refactor opportunities + + ## Dead Code + ### High confidence + - path/to/file.go:LINE — symbol — reason + + ### Medium confidence + ... + + ## Duplication + ### Cluster: + - Sites: file:line, file:line, … + - Suggested home: package/path + - Notes: … + + ## Boundary Violations + - Rule: + - Offender: file:line + - Fix sketch: … + + ## Refactor Opportunities + - Location: file:line + - Current: … + - Proposed: … + - Risk: low/medium/high + - Why it's worth it: … + + ## Suggested Next Steps + 1. … + 2. … + ``` + +8. **End the report with an explicit reminder** that no files were modified, + and recommend the user pick the highest-leverage items to act on + manually (or via a follow-up `/fix-issue` style prompt) rather than + running a sweeping refactor. + +## Guidelines + +- **Read-only, always**: no `edit`, no `write`, no `git commit`, no `go mod + tidy`. Use only `read`, `grep`, `find`, `ls`, and read-only `bash` + commands (`go vet`, `go build -o /tmp/...`, `staticcheck`, etc.) +- **Cite every finding** with `path/to/file.go:LINE` so the user can jump + straight to it +- **Be honest about confidence**: false positives in a code audit are + expensive — prefer "medium confidence, worth a look" over confidently + wrong claims +- **Quantity isn't quality**: 10 sharp findings beat 100 nitpicks. Cut + anything that's purely stylistic unless it directly causes one of the + four issue categories above +- **Skip generated code** (`*.pb.go`, `*_gen.go`, anything under + `vendor/`) and obvious third-party copies +- **Don't propose architectural rewrites** — stay within the existing + shape of the repo and recommend incremental, reviewable changes diff --git a/cmd/root.go b/cmd/root.go index 91ee6b1e..93155dbe 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -14,6 +14,7 @@ import ( "github.com/mark3labs/kit/internal/app" "github.com/mark3labs/kit/internal/auth" "github.com/mark3labs/kit/internal/config" + "github.com/mark3labs/kit/internal/extbridge" "github.com/mark3labs/kit/internal/extensions" "github.com/mark3labs/kit/internal/models" "github.com/mark3labs/kit/internal/prompts" @@ -1140,45 +1141,8 @@ func runNormalMode(ctx context.Context) error { } }, SpawnSubagent: func(config extensions.SubagentConfig) (*extensions.SubagentHandle, *extensions.SubagentResult, error) { - // In-process subagent via SDK. - sdkCfg := kit.SubagentConfig{ - Prompt: config.Prompt, - Model: config.Model, - SystemPrompt: config.SystemPrompt, - Timeout: config.Timeout, - NoSession: config.NoSession, - } - // Bridge SDK events to extension SubagentEvents. - if config.OnEvent != nil { - sdkCfg.OnEvent = func(e kit.Event) { - se := sdkEventToSubagentEvent(e) - if se.Type != "" { - config.OnEvent(se) - } - } - } - result, err := kitInstance.Subagent(ctx, sdkCfg) - if result == nil { - return nil, &extensions.SubagentResult{Error: err}, err - } - extResult := &extensions.SubagentResult{ - Response: result.Response, - Error: err, - SessionID: result.SessionID, - Elapsed: result.Elapsed, - } - if result.Usage != nil { - extResult.Usage = &extensions.SubagentUsage{ - InputTokens: result.Usage.InputTokens, - OutputTokens: result.Usage.OutputTokens, - } - } - return nil, extResult, err + return extbridge.SpawnSubagent(ctx, kitInstance, config) }, - - // ------------------------------------------------------------------------- - // Tree Navigation API (Phase 1 Bridge) - // ------------------------------------------------------------------------- GetTreeNode: func(entryID string) *extensions.TreeNode { node := kitInstance.GetTreeNode(entryID) if node == nil { @@ -1438,45 +1402,8 @@ func runNormalMode(ctx context.Context) error { } }, SpawnSubagent: func(config extensions.SubagentConfig) (*extensions.SubagentHandle, *extensions.SubagentResult, error) { - // In-process subagent via SDK. - sdkCfg := kit.SubagentConfig{ - Prompt: config.Prompt, - Model: config.Model, - SystemPrompt: config.SystemPrompt, - Timeout: config.Timeout, - NoSession: config.NoSession, - } - // Bridge SDK events to extension SubagentEvents. - if config.OnEvent != nil { - sdkCfg.OnEvent = func(e kit.Event) { - se := sdkEventToSubagentEvent(e) - if se.Type != "" { - config.OnEvent(se) - } - } - } - result, err := kitInstance.Subagent(ctx, sdkCfg) - if result == nil { - return nil, &extensions.SubagentResult{Error: err}, err - } - extResult := &extensions.SubagentResult{ - Response: result.Response, - Error: err, - SessionID: result.SessionID, - Elapsed: result.Elapsed, - } - if result.Usage != nil { - extResult.Usage = &extensions.SubagentUsage{ - InputTokens: result.Usage.InputTokens, - OutputTokens: result.Usage.OutputTokens, - } - } - return nil, extResult, err + return extbridge.SpawnSubagent(ctx, kitInstance, config) }, - - // ------------------------------------------------------------------------- - // Tree Navigation API (Phase 1 Bridge) - Second Context - // ------------------------------------------------------------------------- GetTreeNode: func(entryID string) *extensions.TreeNode { node := kitInstance.GetTreeNode(entryID) if node == nil { @@ -2178,42 +2105,3 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN _, runErr := program.Run() return runErr } - -// sdkEventToSubagentEvent converts an SDK event to an extension-facing -// SubagentEvent. Returns a zero-value event (Type=="") for events that -// don't map to anything useful. -func sdkEventToSubagentEvent(e kit.Event) extensions.SubagentEvent { - switch ev := e.(type) { - case kit.MessageUpdateEvent: - return extensions.SubagentEvent{Type: "text", Content: ev.Chunk} - case kit.ReasoningDeltaEvent: - return extensions.SubagentEvent{Type: "reasoning", Content: ev.Delta} - case kit.ToolCallEvent: - return extensions.SubagentEvent{ - Type: "tool_call", ToolCallID: ev.ToolCallID, - ToolName: ev.ToolName, ToolKind: ev.ToolKind, ToolArgs: ev.ToolArgs, - } - case kit.ToolExecutionStartEvent: - return extensions.SubagentEvent{ - Type: "tool_execution_start", ToolCallID: ev.ToolCallID, - ToolName: ev.ToolName, ToolKind: ev.ToolKind, - } - case kit.ToolExecutionEndEvent: - return extensions.SubagentEvent{ - Type: "tool_execution_end", ToolCallID: ev.ToolCallID, - ToolName: ev.ToolName, ToolKind: ev.ToolKind, - } - case kit.ToolResultEvent: - return extensions.SubagentEvent{ - Type: "tool_result", ToolCallID: ev.ToolCallID, - ToolName: ev.ToolName, ToolKind: ev.ToolKind, - ToolResult: ev.Result, IsError: ev.IsError, - } - case kit.TurnStartEvent: - return extensions.SubagentEvent{Type: "turn_start"} - case kit.TurnEndEvent: - return extensions.SubagentEvent{Type: "turn_end"} - default: - return extensions.SubagentEvent{} - } -} diff --git a/internal/acpserver/session.go b/internal/acpserver/session.go index a98b71ee..af18a862 100644 --- a/internal/acpserver/session.go +++ b/internal/acpserver/session.go @@ -8,6 +8,7 @@ import ( "github.com/charmbracelet/log" + "github.com/mark3labs/kit/internal/extbridge" "github.com/mark3labs/kit/internal/extensions" kit "github.com/mark3labs/kit/pkg/kit" ) @@ -152,38 +153,7 @@ func (r *sessionRegistry) create(ctx context.Context, cwd string) (*acpSession, return kitInstance.ExecuteCompletion(context.Background(), req) }, SpawnSubagent: func(config extensions.SubagentConfig) (*extensions.SubagentHandle, *extensions.SubagentResult, error) { - sdkCfg := kit.SubagentConfig{ - Prompt: config.Prompt, - Model: config.Model, - SystemPrompt: config.SystemPrompt, - Timeout: config.Timeout, - NoSession: config.NoSession, - } - if config.OnEvent != nil { - sdkCfg.OnEvent = func(e kit.Event) { - se := sdkEventToSubagentEvent(e) - if se.Type != "" { - config.OnEvent(se) - } - } - } - result, err := kitInstance.Subagent(context.Background(), sdkCfg) - if result == nil { - return nil, &extensions.SubagentResult{Error: err}, err - } - extResult := &extensions.SubagentResult{ - Response: result.Response, - Error: err, - SessionID: result.SessionID, - Elapsed: result.Elapsed, - } - if result.Usage != nil { - extResult.Usage = &extensions.SubagentUsage{ - InputTokens: result.Usage.InputTokens, - OutputTokens: result.Usage.OutputTokens, - } - } - return nil, extResult, err + return extbridge.SpawnSubagent(context.Background(), kitInstance, config) }, // Render — fall back to logging. @@ -269,40 +239,3 @@ func (s *acpSession) clearCancel() { defer s.cancelMu.Unlock() s.cancelFn = nil } - -// sdkEventToSubagentEvent converts an SDK event to an extension SubagentEvent. -func sdkEventToSubagentEvent(e kit.Event) extensions.SubagentEvent { - switch ev := e.(type) { - case kit.MessageUpdateEvent: - return extensions.SubagentEvent{Type: "text", Content: ev.Chunk} - case kit.ReasoningDeltaEvent: - return extensions.SubagentEvent{Type: "reasoning", Content: ev.Delta} - case kit.ToolCallEvent: - return extensions.SubagentEvent{ - Type: "tool_call", ToolCallID: ev.ToolCallID, - ToolName: ev.ToolName, ToolKind: ev.ToolKind, ToolArgs: ev.ToolArgs, - } - case kit.ToolExecutionStartEvent: - return extensions.SubagentEvent{ - Type: "tool_execution_start", ToolCallID: ev.ToolCallID, - ToolName: ev.ToolName, ToolKind: ev.ToolKind, - } - case kit.ToolExecutionEndEvent: - return extensions.SubagentEvent{ - Type: "tool_execution_end", ToolCallID: ev.ToolCallID, - ToolName: ev.ToolName, ToolKind: ev.ToolKind, - } - case kit.ToolResultEvent: - return extensions.SubagentEvent{ - Type: "tool_result", ToolCallID: ev.ToolCallID, - ToolName: ev.ToolName, ToolKind: ev.ToolKind, - ToolResult: ev.Result, IsError: ev.IsError, - } - case kit.TurnStartEvent: - return extensions.SubagentEvent{Type: "turn_start"} - case kit.TurnEndEvent: - return extensions.SubagentEvent{Type: "turn_end"} - default: - return extensions.SubagentEvent{} - } -} diff --git a/internal/extbridge/extbridge.go b/internal/extbridge/extbridge.go new file mode 100644 index 00000000..4d8d7aa5 --- /dev/null +++ b/internal/extbridge/extbridge.go @@ -0,0 +1,97 @@ +// Package extbridge wires the public Kit SDK to the internal extensions +// package. It exists so that cmd/ and internal/acpserver/ don't both +// reimplement the same SDK→extension event/subagent conversions. +package extbridge + +import ( + "context" + + "github.com/mark3labs/kit/internal/extensions" + kit "github.com/mark3labs/kit/pkg/kit" +) + +// SDKEventToSubagentEvent converts an SDK [kit.Event] into the +// extension-facing [extensions.SubagentEvent]. Returns a zero-value event +// (Type=="") for events that don't map to anything useful — callers should +// drop those. +func SDKEventToSubagentEvent(e kit.Event) extensions.SubagentEvent { + switch ev := e.(type) { + case kit.MessageUpdateEvent: + return extensions.SubagentEvent{Type: "text", Content: ev.Chunk} + case kit.ReasoningDeltaEvent: + return extensions.SubagentEvent{Type: "reasoning", Content: ev.Delta} + case kit.ToolCallEvent: + return extensions.SubagentEvent{ + Type: "tool_call", ToolCallID: ev.ToolCallID, + ToolName: ev.ToolName, ToolKind: ev.ToolKind, ToolArgs: ev.ToolArgs, + } + case kit.ToolExecutionStartEvent: + return extensions.SubagentEvent{ + Type: "tool_execution_start", ToolCallID: ev.ToolCallID, + ToolName: ev.ToolName, ToolKind: ev.ToolKind, + } + case kit.ToolExecutionEndEvent: + return extensions.SubagentEvent{ + Type: "tool_execution_end", ToolCallID: ev.ToolCallID, + ToolName: ev.ToolName, ToolKind: ev.ToolKind, + } + case kit.ToolResultEvent: + return extensions.SubagentEvent{ + Type: "tool_result", ToolCallID: ev.ToolCallID, + ToolName: ev.ToolName, ToolKind: ev.ToolKind, + ToolResult: ev.Result, IsError: ev.IsError, + } + case kit.TurnStartEvent: + return extensions.SubagentEvent{Type: "turn_start"} + case kit.TurnEndEvent: + return extensions.SubagentEvent{Type: "turn_end"} + default: + return extensions.SubagentEvent{} + } +} + +// SpawnSubagent runs a subagent in-process via the Kit SDK and translates +// the result/events back into the extension-facing types. The returned +// handle is always nil — the SDK path runs synchronously and does not +// expose a separate process handle. Callers that need non-blocking +// behaviour should run this in their own goroutine. +// +// This function consolidates the previously-duplicated wiring in +// cmd/root.go (interactive + runtime contexts) and +// internal/acpserver/session.go. +func SpawnSubagent(ctx context.Context, k *kit.Kit, cfg extensions.SubagentConfig) (*extensions.SubagentHandle, *extensions.SubagentResult, error) { + sdkCfg := kit.SubagentConfig{ + Prompt: cfg.Prompt, + Model: cfg.Model, + SystemPrompt: cfg.SystemPrompt, + Timeout: cfg.Timeout, + NoSession: cfg.NoSession, + } + if cfg.OnEvent != nil { + sdkCfg.OnEvent = func(e kit.Event) { + se := SDKEventToSubagentEvent(e) + if se.Type != "" { + cfg.OnEvent(se) + } + } + } + + result, err := k.Subagent(ctx, sdkCfg) + if result == nil { + return nil, &extensions.SubagentResult{Error: err}, err + } + + extResult := &extensions.SubagentResult{ + Response: result.Response, + Error: err, + SessionID: result.SessionID, + Elapsed: result.Elapsed, + } + if result.Usage != nil { + extResult.Usage = &extensions.SubagentUsage{ + InputTokens: result.Usage.InputTokens, + OutputTokens: result.Usage.OutputTokens, + } + } + return nil, extResult, err +} diff --git a/pkg/kit/events.go b/pkg/kit/events.go index 2129f322..7f82cebf 100644 --- a/pkg/kit/events.go +++ b/pkg/kit/events.go @@ -148,9 +148,9 @@ func parseToolArgs(toolArgs string) map[string]any { // --------------------------------------------------------------------------- // Finish reasons reported by the LLM provider on a completed turn. These -// mirror fantasy.FinishReason string values so comparisons against -// TurnEndEvent.StopReason / TurnResult.StopReason are stable across -// providers. +// mirror the underlying provider's finish reason string values so +// comparisons against TurnEndEvent.StopReason / TurnResult.StopReason are +// stable across providers. const ( // FinishReasonStop: the model produced a natural stop (e.g. stop sequence // or end-of-turn signal). From 6755597c9b87cd3f45d25ff616d49df88787b4dd Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Thu, 7 May 2026 12:28:18 +0300 Subject: [PATCH 3/8] extract buildInteractiveExtensionContext helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous runNormalMode contained two nearly-identical 400-line extensions.Context literal expressions: * the startup-time literal (cmd/root.go:853-1307) that buffered Print* calls into startupExtensionMessages * the runtime literal (cmd/root.go:1311-1605) that routed Print* through appInstance.PrintFromExtension Every other field — Compact, SendMultimodalMessage, the four prompt factories, all 25+ data-access fields, all four bridge phases — was duplicated byte-for-byte. Maintainers had to remember to update both copies whenever an extension Context field was added. cmd/root.go is now 1463 lines (was 2225). The new helper lives in cmd/extension_context.go (455 lines, mostly the closures verbatim) and returns an extensions.Context with every field populated except Print/PrintInfo/PrintError, which each call site sets afterwards to match its phase. This preserves AGENTS.md's 'function field bug' guarantee — all assignments remain anonymous closure literals. Output of 'kit --version' / 'kit --help' unchanged. Full test suite passes. --- cmd/extension_context.go | 455 +++++++++++++++++++++++++ cmd/root.go | 706 ++------------------------------------- 2 files changed, 486 insertions(+), 675 deletions(-) create mode 100644 cmd/extension_context.go diff --git a/cmd/extension_context.go b/cmd/extension_context.go new file mode 100644 index 00000000..623363e8 --- /dev/null +++ b/cmd/extension_context.go @@ -0,0 +1,455 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/spf13/viper" + "golang.org/x/term" + + "github.com/mark3labs/kit/internal/app" + "github.com/mark3labs/kit/internal/auth" + "github.com/mark3labs/kit/internal/extbridge" + "github.com/mark3labs/kit/internal/extensions" + "github.com/mark3labs/kit/internal/models" + "github.com/mark3labs/kit/internal/ui" + kit "github.com/mark3labs/kit/pkg/kit" +) + +// extensionContextDeps groups the runtime dependencies needed to wire up +// an extensions.Context for the interactive TUI mode. +type extensionContextDeps struct { + ctx context.Context + cwd string + modelName string + interactive bool + kitInstance *kit.Kit + appInstance *app.App + usageTracker *ui.UsageTracker +} + +// buildInteractiveExtensionContext returns an extensions.Context with every +// field except Print / PrintInfo / PrintError populated. Callers must set +// the three print routes appropriately for their phase (startup buffering +// vs. live runtime routing). +// +// This consolidates two near-identical 400-line literal expressions that +// previously appeared inline in runNormalMode. +func buildInteractiveExtensionContext(deps extensionContextDeps) extensions.Context { + kitInstance := deps.kitInstance + appInstance := deps.appInstance + usageTracker := deps.usageTracker + ctx := deps.ctx + + return extensions.Context{ + CWD: deps.cwd, + Model: deps.modelName, + Interactive: deps.interactive, + PrintBlock: appInstance.PrintBlockFromExtension, + SendMessage: func(text string) { appInstance.Run(text) }, + CancelAndSend: func(text string) { appInstance.InterruptAndSend(text) }, + Abort: func() { appInstance.Abort() }, + IsIdle: func() bool { return !appInstance.IsBusy() }, + Compact: func(cfg extensions.CompactConfig) error { + return appInstance.CompactAsync(cfg.CustomInstructions, cfg.OnComplete, cfg.OnError) + }, + SendMultimodalMessage: func(text string, files []extensions.FilePart) { + parts := make([]kit.LLMFilePart, len(files)) + for i, f := range files { + parts[i] = kit.LLMFilePart{ + Filename: f.Filename, + Data: f.Data, + MediaType: f.MediaType, + } + } + appInstance.RunWithFiles(text, parts) + }, + GetSessionUsage: func() extensions.SessionUsage { + if usageTracker == nil { + return extensions.SessionUsage{} + } + stats := usageTracker.GetSessionStats() + return extensions.SessionUsage{ + TotalInputTokens: stats.TotalInputTokens, + TotalOutputTokens: stats.TotalOutputTokens, + TotalCacheReadTokens: stats.TotalCacheReadTokens, + TotalCacheWriteTokens: stats.TotalCacheWriteTokens, + TotalCost: stats.TotalCost, + RequestCount: stats.RequestCount, + } + }, + Exit: func() { appInstance.QuitFromExtension() }, + SetWidget: func(config extensions.WidgetConfig) { + kitInstance.Extensions().SetWidget(config) + go appInstance.NotifyWidgetUpdate() + }, + RemoveWidget: func(id string) { + kitInstance.Extensions().RemoveWidget(id) + go appInstance.NotifyWidgetUpdate() + }, + SetHeader: func(config extensions.HeaderFooterConfig) { + kitInstance.Extensions().SetHeader(config) + go appInstance.NotifyWidgetUpdate() + }, + RemoveHeader: func() { + kitInstance.Extensions().RemoveHeader() + go appInstance.NotifyWidgetUpdate() + }, + SetFooter: func(config extensions.HeaderFooterConfig) { + kitInstance.Extensions().SetFooter(config) + go appInstance.NotifyWidgetUpdate() + }, + RemoveFooter: func() { + kitInstance.Extensions().RemoveFooter() + go appInstance.NotifyWidgetUpdate() + }, + PromptSelect: func(config extensions.PromptSelectConfig) extensions.PromptSelectResult { + ch := make(chan app.PromptResponse, 1) + appInstance.SendPromptRequest(app.PromptRequestEvent{ + PromptType: "select", + Message: config.Message, + Options: config.Options, + ResponseCh: ch, + }) + resp := <-ch + if resp.Cancelled { + return extensions.PromptSelectResult{Cancelled: true} + } + return extensions.PromptSelectResult{Value: resp.Value, Index: resp.Index} + }, + PromptConfirm: func(config extensions.PromptConfirmConfig) extensions.PromptConfirmResult { + ch := make(chan app.PromptResponse, 1) + def := "false" + if config.DefaultValue { + def = "true" + } + appInstance.SendPromptRequest(app.PromptRequestEvent{ + PromptType: "confirm", + Message: config.Message, + Default: def, + ResponseCh: ch, + }) + resp := <-ch + if resp.Cancelled { + return extensions.PromptConfirmResult{Cancelled: true} + } + return extensions.PromptConfirmResult{Value: resp.Confirmed} + }, + PromptInput: func(config extensions.PromptInputConfig) extensions.PromptInputResult { + ch := make(chan app.PromptResponse, 1) + appInstance.SendPromptRequest(app.PromptRequestEvent{ + PromptType: "input", + Message: config.Message, + Placeholder: config.Placeholder, + Default: config.Default, + ResponseCh: ch, + }) + resp := <-ch + if resp.Cancelled { + return extensions.PromptInputResult{Cancelled: true} + } + return extensions.PromptInputResult{Value: resp.Value} + }, + SetUIVisibility: func(v extensions.UIVisibility) { + kitInstance.Extensions().SetUIVisibility(v) + go appInstance.NotifyWidgetUpdate() + }, + GetContextStats: func() extensions.ContextStats { + s := kitInstance.GetContextStats() + return extensions.ContextStats{ + EstimatedTokens: s.EstimatedTokens, + ContextLimit: s.ContextLimit, + UsagePercent: s.UsagePercent, + MessageCount: s.MessageCount, + } + }, + SetEditor: func(config extensions.EditorConfig) { + kitInstance.Extensions().SetEditor(config) + // Always use a goroutine for NotifyWidgetUpdate: prog.Send() + // deadlocks if called synchronously from inside BubbleTea's + // Update() handler. All call sites use go-routines uniformly. + go appInstance.NotifyWidgetUpdate() + }, + ResetEditor: func() { + kitInstance.Extensions().ResetEditor() + go appInstance.NotifyWidgetUpdate() + }, + GetMessages: func() []extensions.SessionMessage { + return kitInstance.Extensions().GetSessionMessages() + }, + GetSessionPath: func() string { + return kitInstance.GetSessionPath() + }, + AppendEntry: func(entryType string, data string) (string, error) { + return kitInstance.Extensions().AppendEntry(entryType, data) + }, + GetEntries: func(entryType string) []extensions.ExtensionEntry { + return kitInstance.Extensions().GetEntries(entryType) + }, + SetEditorText: func(text string) { + appInstance.SetEditorTextFromExtension(text) + }, + SetStatus: func(key string, text string, priority int) { + kitInstance.Extensions().SetStatus(extensions.StatusBarEntry{ + Key: key, + Text: text, + Priority: priority, + }) + go appInstance.NotifyWidgetUpdate() + }, + RemoveStatus: func(key string) { + kitInstance.Extensions().RemoveStatus(key) + go appInstance.NotifyWidgetUpdate() + }, + GetOption: func(name string) string { + return kitInstance.Extensions().GetOption(name) + }, + SetOption: func(name string, value string) { + kitInstance.Extensions().SetOption(name, value) + }, + SetModel: func(modelString string) error { + // Capture previous model for the ModelChange event. + previousModel := kitInstance.Extensions().GetContext().Model + err := kitInstance.SetModel(context.Background(), modelString) + if err != nil { + return err + } + // Notify TUI so it updates model in status bar. + p, m, _ := models.ParseModelString(modelString) + appInstance.NotifyModelChanged(p, m) + // Update the context's Model field so handlers see it. + kitInstance.Extensions().UpdateContextModel(modelString) + // Fire OnModelChange event to extensions. + kitInstance.Extensions().EmitModelChange(modelString, previousModel, "extension") + // Update usage tracker with new model info for correct token counting. + if usageTracker != nil { + newProvider, newModel, _ := models.ParseModelString(modelString) + if newProvider != "unknown" && newModel != "unknown" && newProvider != "ollama" { + registry := models.GetGlobalRegistry() + if modelInfo := registry.LookupModel(newProvider, newModel); modelInfo != nil { + // Check OAuth status for Anthropic models + isOAuth := false + if newProvider == "anthropic" { + _, source, err := auth.GetAnthropicAPIKey(viper.GetString("provider-api-key")) + if err == nil && strings.HasPrefix(source, "stored OAuth") { + isOAuth = true + } + } + usageTracker.UpdateModelInfo(modelInfo, newProvider, isOAuth) + } + } + } + return nil + }, + GetAvailableModels: func() []extensions.ModelInfoEntry { + return kitInstance.GetAvailableModels() + }, + EmitCustomEvent: func(name string, data string) { + kitInstance.Extensions().EmitCustomEvent(name, data) + }, + Complete: func(req extensions.CompleteRequest) (extensions.CompleteResponse, error) { + return kitInstance.ExecuteCompletion(context.Background(), req) + }, + SuspendTUI: func(callback func()) error { + return appInstance.SuspendTUI(callback) + }, + RenderMessage: func(rendererName, content string) { + renderer := kitInstance.Extensions().GetMessageRenderer(rendererName) + if renderer == nil || renderer.Render == nil { + appInstance.PrintFromExtension("", content) + return + } + w, _, _ := term.GetSize(int(os.Stdout.Fd())) + if w == 0 { + w = 80 + } + rendered := renderer.Render(content, w) + appInstance.PrintFromExtension("", rendered) + }, + ReloadExtensions: func() error { + err := kitInstance.Extensions().Reload() + if err != nil { + return err + } + // Notify TUI that widgets/status/commands may have changed. + go appInstance.NotifyWidgetUpdate() + return nil + }, + GetAllTools: func() []extensions.ToolInfo { + return kitInstance.Extensions().GetToolInfos() + }, + SetActiveTools: func(names []string) { + kitInstance.Extensions().SetActiveTools(names) + }, + RegisterTheme: func(name string, config extensions.ThemeColorConfig) { + tc := func(c extensions.ThemeColor) [2]string { return [2]string{c.Light, c.Dark} } + ui.RegisterThemeFromConfig(name, + tc(config.Primary), tc(config.Secondary), + tc(config.Success), tc(config.Warning), + tc(config.Error), tc(config.Info), + tc(config.Text), tc(config.Muted), + tc(config.VeryMuted), tc(config.Background), + tc(config.Border), tc(config.MutedBorder), + tc(config.System), tc(config.Tool), + tc(config.Accent), tc(config.Highlight), + tc(config.MdHeading), tc(config.MdLink), + tc(config.MdKeyword), tc(config.MdString), + tc(config.MdNumber), tc(config.MdComment), + ) + }, + SetTheme: func(name string) error { + return ui.ApplyTheme(name) + }, + ListThemes: func() []string { + return ui.ListThemes() + }, + ShowOverlay: func(config extensions.OverlayConfig) extensions.OverlayResult { + ch := make(chan app.OverlayResponse, 1) + appInstance.SendOverlayRequest(app.OverlayRequestEvent{ + Title: config.Title, + Content: config.Content.Text, + Markdown: config.Content.Markdown, + BorderColor: config.Style.BorderColor, + Background: config.Style.Background, + Width: config.Width, + MaxHeight: config.MaxHeight, + Anchor: string(config.Anchor), + Actions: config.Actions, + ResponseCh: ch, + }) + resp := <-ch + if resp.Cancelled { + return extensions.OverlayResult{Cancelled: true, Index: -1} + } + return extensions.OverlayResult{ + Action: resp.Action, + Index: resp.Index, + } + }, + SpawnSubagent: func(config extensions.SubagentConfig) (*extensions.SubagentHandle, *extensions.SubagentResult, error) { + return extbridge.SpawnSubagent(ctx, kitInstance, config) + }, + // ------------------------------------------------------------------- + // Tree Navigation API + // ------------------------------------------------------------------- + GetTreeNode: func(entryID string) *extensions.TreeNode { + node := kitInstance.GetTreeNode(entryID) + if node == nil { + return nil + } + return &extensions.TreeNode{ + ID: node.ID, + ParentID: node.ParentID, + Type: node.Type, + Role: node.Role, + Content: node.Content, + Model: node.Model, + Provider: node.Provider, + Timestamp: node.Timestamp, + Children: node.Children, + } + }, + GetCurrentBranch: func() []extensions.TreeNode { + nodes := kitInstance.GetCurrentBranch() + result := make([]extensions.TreeNode, len(nodes)) + for i, n := range nodes { + result[i] = extensions.TreeNode{ + ID: n.ID, + ParentID: n.ParentID, + Type: n.Type, + Role: n.Role, + Content: n.Content, + Model: n.Model, + Provider: n.Provider, + Timestamp: n.Timestamp, + Children: n.Children, + } + } + return result + }, + GetChildren: kitInstance.GetChildren, + NavigateTo: func(entryID string) extensions.TreeNavigationResult { + err := kitInstance.NavigateTo(entryID) + if err != nil { + return extensions.TreeNavigationResult{Success: false, Error: err.Error()} + } + return extensions.TreeNavigationResult{Success: true} + }, + SummarizeBranch: func(fromID, toID string) string { + summary, _ := kitInstance.SummarizeBranch(fromID, toID) + return summary + }, + CollapseBranch: func(fromID, toID, summary string) extensions.TreeNavigationResult { + err := kitInstance.CollapseBranch(fromID, toID, summary) + if err != nil { + return extensions.TreeNavigationResult{Success: false, Error: err.Error()} + } + return extensions.TreeNavigationResult{Success: true} + }, + + // ------------------------------------------------------------------- + // Skill Loading API + // ------------------------------------------------------------------- + LoadSkill: func(path string) (*extensions.Skill, string) { + s, err := kitInstance.LoadSkillForExtension(path) + return s, err + }, + LoadSkillsFromDir: func(dir string) extensions.SkillLoadResult { + return kitInstance.LoadSkillsFromDirForExtension(dir) + }, + DiscoverSkills: func() extensions.SkillLoadResult { + skills := kitInstance.DiscoverSkillsForExtension() + return extensions.SkillLoadResult{Skills: skills} + }, + InjectSkillAsContext: func(skillName string) string { + skills := kitInstance.DiscoverSkillsForExtension() + for _, s := range skills { + if s.Name == skillName { + appInstance.Run(fmt.Sprintf("\n%s\n", s.Name, s.Content)) + return "" + } + } + return fmt.Sprintf("skill not found: %s", skillName) + }, + InjectRawSkillAsContext: func(path string) string { + s, err := kitInstance.LoadSkillForExtension(path) + if err != "" { + return err + } + appInstance.Run(fmt.Sprintf("\n%s\n", s.Name, s.Content)) + return "" + }, + GetAvailableSkills: kitInstance.DiscoverSkillsForExtension, + + // ------------------------------------------------------------------- + // Template Parsing API + // ------------------------------------------------------------------- + ParseTemplate: kit.ParseTemplate, + RenderTemplate: kit.RenderTemplate, + ParseArguments: kit.ParseArguments, + SimpleParseArguments: kit.SimpleParseArguments, + EvaluateModelConditional: func(condition string) bool { + return kit.EvaluateModelConditional(kitInstance.Extensions().GetContext().Model, condition) + }, + RenderWithModelConditionals: func(content string) string { + return kit.RenderWithModelConditionals(content, kitInstance.Extensions().GetContext().Model) + }, + + // ------------------------------------------------------------------- + // Model Resolution API + // ------------------------------------------------------------------- + ResolveModelChain: kit.ResolveModelChain, + GetModelCapabilities: func(model string) (extensions.ModelCapabilities, string) { + return kit.GetModelCapabilities(model) + }, + CheckModelAvailable: kit.CheckModelAvailable, + GetCurrentProvider: func() string { + return kit.GetCurrentProvider(kitInstance.Extensions().GetContext().Model) + }, + GetCurrentModelID: func() string { + return kit.GetCurrentModelID(kitInstance.Extensions().GetContext().Model) + }, + } +} diff --git a/cmd/root.go b/cmd/root.go index 93155dbe..4b9e90ab 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -14,7 +14,6 @@ import ( "github.com/mark3labs/kit/internal/app" "github.com/mark3labs/kit/internal/auth" "github.com/mark3labs/kit/internal/config" - "github.com/mark3labs/kit/internal/extbridge" "github.com/mark3labs/kit/internal/extensions" "github.com/mark3labs/kit/internal/models" "github.com/mark3labs/kit/internal/prompts" @@ -845,685 +844,42 @@ func runNormalMode(ctx context.Context) error { // Set up extension context and emit SessionStart. if kitInstance.Extensions().HasExtensions() { cwd, _ := os.Getwd() - kitInstance.Extensions().SetContext(extensions.Context{ - CWD: cwd, - Model: modelName, - Interactive: positionalPrompt == "", - Print: func(text string) { - // Capture messages during startup, print after startup banner. - startupExtensionMessages = append(startupExtensionMessages, text) - }, - PrintInfo: func(text string) { - startupExtensionMessages = append(startupExtensionMessages, text) - }, - PrintError: func(text string) { - startupExtensionMessages = append(startupExtensionMessages, text) - }, - PrintBlock: appInstance.PrintBlockFromExtension, - SendMessage: func(text string) { appInstance.Run(text) }, - CancelAndSend: func(text string) { appInstance.InterruptAndSend(text) }, - Abort: func() { appInstance.Abort() }, - IsIdle: func() bool { return !appInstance.IsBusy() }, - Compact: func(cfg extensions.CompactConfig) error { - return appInstance.CompactAsync(cfg.CustomInstructions, cfg.OnComplete, cfg.OnError) - }, - SendMultimodalMessage: func(text string, files []extensions.FilePart) { - parts := make([]kit.LLMFilePart, len(files)) - for i, f := range files { - parts[i] = kit.LLMFilePart{ - Filename: f.Filename, - Data: f.Data, - MediaType: f.MediaType, - } - } - appInstance.RunWithFiles(text, parts) - }, - GetSessionUsage: func() extensions.SessionUsage { - if usageTracker == nil { - return extensions.SessionUsage{} - } - stats := usageTracker.GetSessionStats() - return extensions.SessionUsage{ - TotalInputTokens: stats.TotalInputTokens, - TotalOutputTokens: stats.TotalOutputTokens, - TotalCacheReadTokens: stats.TotalCacheReadTokens, - TotalCacheWriteTokens: stats.TotalCacheWriteTokens, - TotalCost: stats.TotalCost, - RequestCount: stats.RequestCount, - } - }, - Exit: func() { appInstance.QuitFromExtension() }, - SetWidget: func(config extensions.WidgetConfig) { - kitInstance.Extensions().SetWidget(config) - go appInstance.NotifyWidgetUpdate() - }, - RemoveWidget: func(id string) { - kitInstance.Extensions().RemoveWidget(id) - go appInstance.NotifyWidgetUpdate() - }, - SetHeader: func(config extensions.HeaderFooterConfig) { - kitInstance.Extensions().SetHeader(config) - go appInstance.NotifyWidgetUpdate() - }, - RemoveHeader: func() { - kitInstance.Extensions().RemoveHeader() - go appInstance.NotifyWidgetUpdate() - }, - SetFooter: func(config extensions.HeaderFooterConfig) { - kitInstance.Extensions().SetFooter(config) - go appInstance.NotifyWidgetUpdate() - }, - RemoveFooter: func() { - kitInstance.Extensions().RemoveFooter() - go appInstance.NotifyWidgetUpdate() - }, - PromptSelect: func(config extensions.PromptSelectConfig) extensions.PromptSelectResult { - ch := make(chan app.PromptResponse, 1) - appInstance.SendPromptRequest(app.PromptRequestEvent{ - PromptType: "select", - Message: config.Message, - Options: config.Options, - ResponseCh: ch, - }) - resp := <-ch - if resp.Cancelled { - return extensions.PromptSelectResult{Cancelled: true} - } - return extensions.PromptSelectResult{Value: resp.Value, Index: resp.Index} - }, - PromptConfirm: func(config extensions.PromptConfirmConfig) extensions.PromptConfirmResult { - ch := make(chan app.PromptResponse, 1) - def := "false" - if config.DefaultValue { - def = "true" - } - appInstance.SendPromptRequest(app.PromptRequestEvent{ - PromptType: "confirm", - Message: config.Message, - Default: def, - ResponseCh: ch, - }) - resp := <-ch - if resp.Cancelled { - return extensions.PromptConfirmResult{Cancelled: true} - } - return extensions.PromptConfirmResult{Value: resp.Confirmed} - }, - PromptInput: func(config extensions.PromptInputConfig) extensions.PromptInputResult { - ch := make(chan app.PromptResponse, 1) - appInstance.SendPromptRequest(app.PromptRequestEvent{ - PromptType: "input", - Message: config.Message, - Placeholder: config.Placeholder, - Default: config.Default, - ResponseCh: ch, - }) - resp := <-ch - if resp.Cancelled { - return extensions.PromptInputResult{Cancelled: true} - } - return extensions.PromptInputResult{Value: resp.Value} - }, - SetUIVisibility: func(v extensions.UIVisibility) { - kitInstance.Extensions().SetUIVisibility(v) - go appInstance.NotifyWidgetUpdate() - }, - GetContextStats: func() extensions.ContextStats { - s := kitInstance.GetContextStats() - return extensions.ContextStats{ - EstimatedTokens: s.EstimatedTokens, - ContextLimit: s.ContextLimit, - UsagePercent: s.UsagePercent, - MessageCount: s.MessageCount, - } - }, - SetEditor: func(config extensions.EditorConfig) { - kitInstance.Extensions().SetEditor(config) - // Always use a goroutine for NotifyWidgetUpdate: prog.Send() - // deadlocks if called synchronously from inside BubbleTea's - // Update() handler. All call sites use go-routines uniformly. - go appInstance.NotifyWidgetUpdate() - }, - ResetEditor: func() { - kitInstance.Extensions().ResetEditor() - go appInstance.NotifyWidgetUpdate() - }, - GetMessages: func() []extensions.SessionMessage { - return kitInstance.Extensions().GetSessionMessages() - }, - GetSessionPath: func() string { - return kitInstance.GetSessionPath() - }, - AppendEntry: func(entryType string, data string) (string, error) { - return kitInstance.Extensions().AppendEntry(entryType, data) - }, - GetEntries: func(entryType string) []extensions.ExtensionEntry { - return kitInstance.Extensions().GetEntries(entryType) - }, - SetEditorText: func(text string) { - appInstance.SetEditorTextFromExtension(text) - }, - SetStatus: func(key string, text string, priority int) { - kitInstance.Extensions().SetStatus(extensions.StatusBarEntry{ - Key: key, - Text: text, - Priority: priority, - }) - go appInstance.NotifyWidgetUpdate() - }, - RemoveStatus: func(key string) { - kitInstance.Extensions().RemoveStatus(key) - go appInstance.NotifyWidgetUpdate() - }, - GetOption: func(name string) string { - return kitInstance.Extensions().GetOption(name) - }, - SetOption: func(name string, value string) { - kitInstance.Extensions().SetOption(name, value) - }, - SetModel: func(modelString string) error { - // Capture previous model for the ModelChange event. - previousModel := kitInstance.Extensions().GetContext().Model - err := kitInstance.SetModel(context.Background(), modelString) - if err != nil { - return err - } - // Notify TUI so it updates model in status bar. - p, m, _ := models.ParseModelString(modelString) - appInstance.NotifyModelChanged(p, m) - // Update the context's Model field so handlers see it. - kitInstance.Extensions().UpdateContextModel(modelString) - // Fire OnModelChange event to extensions. - kitInstance.Extensions().EmitModelChange(modelString, previousModel, "extension") - // Update usage tracker with new model info for correct token counting. - if usageTracker != nil { - newProvider, newModel, _ := models.ParseModelString(modelString) - if newProvider != "unknown" && newModel != "unknown" && newProvider != "ollama" { - registry := models.GetGlobalRegistry() - if modelInfo := registry.LookupModel(newProvider, newModel); modelInfo != nil { - // Check OAuth status for Anthropic models - isOAuth := false - if newProvider == "anthropic" { - _, source, err := auth.GetAnthropicAPIKey(viper.GetString("provider-api-key")) - if err == nil && strings.HasPrefix(source, "stored OAuth") { - isOAuth = true - } - } - usageTracker.UpdateModelInfo(modelInfo, newProvider, isOAuth) - } - } - } - return nil - }, - GetAvailableModels: func() []extensions.ModelInfoEntry { - return kitInstance.GetAvailableModels() - }, - EmitCustomEvent: func(name string, data string) { - kitInstance.Extensions().EmitCustomEvent(name, data) - }, - Complete: func(req extensions.CompleteRequest) (extensions.CompleteResponse, error) { - return kitInstance.ExecuteCompletion(context.Background(), req) - }, - SuspendTUI: func(callback func()) error { - return appInstance.SuspendTUI(callback) - }, - RenderMessage: func(rendererName, content string) { - renderer := kitInstance.Extensions().GetMessageRenderer(rendererName) - if renderer == nil || renderer.Render == nil { - appInstance.PrintFromExtension("", content) - return - } - w, _, _ := term.GetSize(int(os.Stdout.Fd())) - if w == 0 { - w = 80 - } - rendered := renderer.Render(content, w) - appInstance.PrintFromExtension("", rendered) - }, - ReloadExtensions: func() error { - err := kitInstance.Extensions().Reload() - if err != nil { - return err - } - // Notify TUI that widgets/status/commands may have changed. - go appInstance.NotifyWidgetUpdate() - return nil - }, - GetAllTools: func() []extensions.ToolInfo { - return kitInstance.Extensions().GetToolInfos() - }, - SetActiveTools: func(names []string) { - kitInstance.Extensions().SetActiveTools(names) - }, - RegisterTheme: func(name string, config extensions.ThemeColorConfig) { - tc := func(c extensions.ThemeColor) [2]string { return [2]string{c.Light, c.Dark} } - ui.RegisterThemeFromConfig(name, - tc(config.Primary), tc(config.Secondary), - tc(config.Success), tc(config.Warning), - tc(config.Error), tc(config.Info), - tc(config.Text), tc(config.Muted), - tc(config.VeryMuted), tc(config.Background), - tc(config.Border), tc(config.MutedBorder), - tc(config.System), tc(config.Tool), - tc(config.Accent), tc(config.Highlight), - tc(config.MdHeading), tc(config.MdLink), - tc(config.MdKeyword), tc(config.MdString), - tc(config.MdNumber), tc(config.MdComment), - ) - }, - SetTheme: func(name string) error { - return ui.ApplyTheme(name) - }, - ListThemes: func() []string { - return ui.ListThemes() - }, - ShowOverlay: func(config extensions.OverlayConfig) extensions.OverlayResult { - ch := make(chan app.OverlayResponse, 1) - appInstance.SendOverlayRequest(app.OverlayRequestEvent{ - Title: config.Title, - Content: config.Content.Text, - Markdown: config.Content.Markdown, - BorderColor: config.Style.BorderColor, - Background: config.Style.Background, - Width: config.Width, - MaxHeight: config.MaxHeight, - Anchor: string(config.Anchor), - Actions: config.Actions, - ResponseCh: ch, - }) - resp := <-ch - if resp.Cancelled { - return extensions.OverlayResult{Cancelled: true, Index: -1} - } - return extensions.OverlayResult{ - Action: resp.Action, - Index: resp.Index, - } - }, - SpawnSubagent: func(config extensions.SubagentConfig) (*extensions.SubagentHandle, *extensions.SubagentResult, error) { - return extbridge.SpawnSubagent(ctx, kitInstance, config) - }, - GetTreeNode: func(entryID string) *extensions.TreeNode { - node := kitInstance.GetTreeNode(entryID) - if node == nil { - return nil - } - return &extensions.TreeNode{ - ID: node.ID, - ParentID: node.ParentID, - Type: node.Type, - Role: node.Role, - Content: node.Content, - Model: node.Model, - Provider: node.Provider, - Timestamp: node.Timestamp, - Children: node.Children, - } - }, - GetCurrentBranch: func() []extensions.TreeNode { - nodes := kitInstance.GetCurrentBranch() - result := make([]extensions.TreeNode, len(nodes)) - for i, n := range nodes { - result[i] = extensions.TreeNode{ - ID: n.ID, - ParentID: n.ParentID, - Type: n.Type, - Role: n.Role, - Content: n.Content, - Model: n.Model, - Provider: n.Provider, - Timestamp: n.Timestamp, - Children: n.Children, - } - } - return result - }, - GetChildren: kitInstance.GetChildren, - NavigateTo: func(entryID string) extensions.TreeNavigationResult { - err := kitInstance.NavigateTo(entryID) - if err != nil { - return extensions.TreeNavigationResult{Success: false, Error: err.Error()} - } - return extensions.TreeNavigationResult{Success: true} - }, - SummarizeBranch: func(fromID, toID string) string { - summary, _ := kitInstance.SummarizeBranch(fromID, toID) - return summary - }, - CollapseBranch: func(fromID, toID, summary string) extensions.TreeNavigationResult { - err := kitInstance.CollapseBranch(fromID, toID, summary) - if err != nil { - return extensions.TreeNavigationResult{Success: false, Error: err.Error()} - } - return extensions.TreeNavigationResult{Success: true} - }, - - // ------------------------------------------------------------------------- - // Skill Loading API (Phase 2 Bridge) - // ------------------------------------------------------------------------- - LoadSkill: func(path string) (*extensions.Skill, string) { - s, err := kitInstance.LoadSkillForExtension(path) - return s, err - }, - LoadSkillsFromDir: func(dir string) extensions.SkillLoadResult { - return kitInstance.LoadSkillsFromDirForExtension(dir) - }, - DiscoverSkills: func() extensions.SkillLoadResult { - skills := kitInstance.DiscoverSkillsForExtension() - return extensions.SkillLoadResult{Skills: skills} - }, - InjectSkillAsContext: func(skillName string) string { - // Find skill by name - skills := kitInstance.DiscoverSkillsForExtension() - for _, s := range skills { - if s.Name == skillName { - // Inject via SendMessage as a system context message - appInstance.Run(fmt.Sprintf("\n%s\n", s.Name, s.Content)) - return "" - } - } - return fmt.Sprintf("skill not found: %s", skillName) - }, - InjectRawSkillAsContext: func(path string) string { - s, err := kitInstance.LoadSkillForExtension(path) - if err != "" { - return err - } - appInstance.Run(fmt.Sprintf("\n%s\n", s.Name, s.Content)) - return "" - }, - GetAvailableSkills: kitInstance.DiscoverSkillsForExtension, - - // ------------------------------------------------------------------------- - // Template Parsing API (Phase 3 Bridge) - // ------------------------------------------------------------------------- - ParseTemplate: kit.ParseTemplate, - RenderTemplate: kit.RenderTemplate, - ParseArguments: kit.ParseArguments, - SimpleParseArguments: kit.SimpleParseArguments, - EvaluateModelConditional: func(condition string) bool { - return kit.EvaluateModelConditional(kitInstance.Extensions().GetContext().Model, condition) - }, - RenderWithModelConditionals: func(content string) string { - return kit.RenderWithModelConditionals(content, kitInstance.Extensions().GetContext().Model) - }, - - // ------------------------------------------------------------------------- - // Model Resolution API (Phase 4 Bridge) - // ------------------------------------------------------------------------- - ResolveModelChain: kit.ResolveModelChain, - GetModelCapabilities: func(model string) (extensions.ModelCapabilities, string) { - return kit.GetModelCapabilities(model) - }, - CheckModelAvailable: kit.CheckModelAvailable, - GetCurrentProvider: func() string { - return kit.GetCurrentProvider(kitInstance.Extensions().GetContext().Model) - }, - GetCurrentModelID: func() string { - return kit.GetCurrentModelID(kitInstance.Extensions().GetContext().Model) - }, + extCtx := buildInteractiveExtensionContext(extensionContextDeps{ + ctx: ctx, + cwd: cwd, + modelName: modelName, + interactive: positionalPrompt == "", + kitInstance: kitInstance, + appInstance: appInstance, + usageTracker: usageTracker, }) + extCtx.Print = func(text string) { + // Capture messages during startup, print after startup banner. + startupExtensionMessages = append(startupExtensionMessages, text) + } + extCtx.PrintInfo = func(text string) { + startupExtensionMessages = append(startupExtensionMessages, text) + } + extCtx.PrintError = func(text string) { + startupExtensionMessages = append(startupExtensionMessages, text) + } + kitInstance.Extensions().SetContext(extCtx) kitInstance.Extensions().EmitSessionStart() // Restore normal print functions for runtime use. - kitInstance.Extensions().SetContext(extensions.Context{ - CWD: cwd, - Model: modelName, - Interactive: positionalPrompt == "", - Print: func(text string) { appInstance.PrintFromExtension("", text) }, - 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) }, - CancelAndSend: func(text string) { appInstance.InterruptAndSend(text) }, - Abort: func() { appInstance.Abort() }, - IsIdle: func() bool { return !appInstance.IsBusy() }, - Compact: func(cfg extensions.CompactConfig) error { - return appInstance.CompactAsync(cfg.CustomInstructions, cfg.OnComplete, cfg.OnError) - }, - SendMultimodalMessage: func(text string, files []extensions.FilePart) { - parts := make([]kit.LLMFilePart, len(files)) - for i, f := range files { - parts[i] = kit.LLMFilePart{ - Filename: f.Filename, - Data: f.Data, - MediaType: f.MediaType, - } - } - appInstance.RunWithFiles(text, parts) - }, - GetSessionUsage: func() extensions.SessionUsage { - if usageTracker == nil { - return extensions.SessionUsage{} - } - stats := usageTracker.GetSessionStats() - return extensions.SessionUsage{ - TotalInputTokens: stats.TotalInputTokens, - TotalOutputTokens: stats.TotalOutputTokens, - TotalCacheReadTokens: stats.TotalCacheReadTokens, - TotalCacheWriteTokens: stats.TotalCacheWriteTokens, - TotalCost: stats.TotalCost, - RequestCount: stats.RequestCount, - } - }, - Exit: func() { appInstance.QuitFromExtension() }, - SetWidget: func(config extensions.WidgetConfig) { - kitInstance.Extensions().SetWidget(config) - go appInstance.NotifyWidgetUpdate() - }, - RemoveWidget: func(id string) { - kitInstance.Extensions().RemoveWidget(id) - go appInstance.NotifyWidgetUpdate() - }, - SetHeader: func(config extensions.HeaderFooterConfig) { - kitInstance.Extensions().SetHeader(config) - go appInstance.NotifyWidgetUpdate() - }, - RemoveHeader: func() { - kitInstance.Extensions().RemoveHeader() - go appInstance.NotifyWidgetUpdate() - }, - SetFooter: func(config extensions.HeaderFooterConfig) { - kitInstance.Extensions().SetFooter(config) - go appInstance.NotifyWidgetUpdate() - }, - RemoveFooter: func() { - kitInstance.Extensions().RemoveFooter() - go appInstance.NotifyWidgetUpdate() - }, - PromptSelect: func(config extensions.PromptSelectConfig) extensions.PromptSelectResult { - ch := make(chan app.PromptResponse, 1) - appInstance.SendPromptRequest(app.PromptRequestEvent{ - PromptType: "select", - Message: config.Message, - Options: config.Options, - ResponseCh: ch, - }) - resp := <-ch - if resp.Cancelled { - return extensions.PromptSelectResult{Cancelled: true} - } - return extensions.PromptSelectResult{Value: resp.Value, Index: resp.Index} - }, - PromptConfirm: func(config extensions.PromptConfirmConfig) extensions.PromptConfirmResult { - ch := make(chan app.PromptResponse, 1) - def := "false" - if config.DefaultValue { - def = "true" - } - appInstance.SendPromptRequest(app.PromptRequestEvent{ - PromptType: "confirm", - Message: config.Message, - Default: def, - ResponseCh: ch, - }) - resp := <-ch - if resp.Cancelled { - return extensions.PromptConfirmResult{Cancelled: true} - } - return extensions.PromptConfirmResult{Value: resp.Confirmed} - }, - PromptInput: func(config extensions.PromptInputConfig) extensions.PromptInputResult { - ch := make(chan app.PromptResponse, 1) - appInstance.SendPromptRequest(app.PromptRequestEvent{ - PromptType: "input", - Message: config.Message, - Placeholder: config.Placeholder, - Default: config.Default, - ResponseCh: ch, - }) - resp := <-ch - if resp.Cancelled { - return extensions.PromptInputResult{Cancelled: true} - } - return extensions.PromptInputResult{Value: resp.Value} - }, - ShowOverlay: func(config extensions.OverlayConfig) extensions.OverlayResult { - ch := make(chan app.OverlayResponse, 1) - appInstance.SendOverlayRequest(app.OverlayRequestEvent{ - Title: config.Title, - Content: config.Content.Text, - Markdown: config.Content.Markdown, - BorderColor: config.Style.BorderColor, - Background: config.Style.Background, - Width: config.Width, - MaxHeight: config.MaxHeight, - Anchor: string(config.Anchor), - Actions: config.Actions, - ResponseCh: ch, - }) - resp := <-ch - if resp.Cancelled { - return extensions.OverlayResult{Cancelled: true, Index: -1} - } - return extensions.OverlayResult{ - Action: resp.Action, - Index: resp.Index, - } - }, - SpawnSubagent: func(config extensions.SubagentConfig) (*extensions.SubagentHandle, *extensions.SubagentResult, error) { - return extbridge.SpawnSubagent(ctx, kitInstance, config) - }, - GetTreeNode: func(entryID string) *extensions.TreeNode { - node := kitInstance.GetTreeNode(entryID) - if node == nil { - return nil - } - return &extensions.TreeNode{ - ID: node.ID, - ParentID: node.ParentID, - Type: node.Type, - Role: node.Role, - Content: node.Content, - Model: node.Model, - Provider: node.Provider, - Timestamp: node.Timestamp, - Children: node.Children, - } - }, - GetCurrentBranch: func() []extensions.TreeNode { - nodes := kitInstance.GetCurrentBranch() - result := make([]extensions.TreeNode, len(nodes)) - for i, n := range nodes { - result[i] = extensions.TreeNode{ - ID: n.ID, - ParentID: n.ParentID, - Type: n.Type, - Role: n.Role, - Content: n.Content, - Model: n.Model, - Provider: n.Provider, - Timestamp: n.Timestamp, - Children: n.Children, - } - } - return result - }, - GetChildren: kitInstance.GetChildren, - NavigateTo: func(entryID string) extensions.TreeNavigationResult { - err := kitInstance.NavigateTo(entryID) - if err != nil { - return extensions.TreeNavigationResult{Success: false, Error: err.Error()} - } - return extensions.TreeNavigationResult{Success: true} - }, - SummarizeBranch: func(fromID, toID string) string { - summary, _ := kitInstance.SummarizeBranch(fromID, toID) - return summary - }, - CollapseBranch: func(fromID, toID, summary string) extensions.TreeNavigationResult { - err := kitInstance.CollapseBranch(fromID, toID, summary) - if err != nil { - return extensions.TreeNavigationResult{Success: false, Error: err.Error()} - } - return extensions.TreeNavigationResult{Success: true} - }, - - // ------------------------------------------------------------------------- - // Skill Loading API (Phase 2 Bridge) - Second Context - // ------------------------------------------------------------------------- - LoadSkill: func(path string) (*extensions.Skill, string) { - s, err := kitInstance.LoadSkillForExtension(path) - return s, err - }, - LoadSkillsFromDir: func(dir string) extensions.SkillLoadResult { - return kitInstance.LoadSkillsFromDirForExtension(dir) - }, - DiscoverSkills: func() extensions.SkillLoadResult { - skills := kitInstance.DiscoverSkillsForExtension() - return extensions.SkillLoadResult{Skills: skills} - }, - InjectSkillAsContext: func(skillName string) string { - skills := kitInstance.DiscoverSkillsForExtension() - for _, s := range skills { - if s.Name == skillName { - appInstance.Run(fmt.Sprintf("\n%s\n", s.Name, s.Content)) - return "" - } - } - return fmt.Sprintf("skill not found: %s", skillName) - }, - InjectRawSkillAsContext: func(path string) string { - s, err := kitInstance.LoadSkillForExtension(path) - if err != "" { - return err - } - appInstance.Run(fmt.Sprintf("\n%s\n", s.Name, s.Content)) - return "" - }, - GetAvailableSkills: func() []extensions.Skill { - return kitInstance.DiscoverSkillsForExtension() - }, - - // ------------------------------------------------------------------------- - // Template Parsing API (Phase 3 Bridge) - Second Context - // ------------------------------------------------------------------------- - ParseTemplate: kit.ParseTemplate, - RenderTemplate: kit.RenderTemplate, - ParseArguments: kit.ParseArguments, - SimpleParseArguments: kit.SimpleParseArguments, - EvaluateModelConditional: func(condition string) bool { - return kit.EvaluateModelConditional(kitInstance.Extensions().GetContext().Model, condition) - }, - RenderWithModelConditionals: func(content string) string { - return kit.RenderWithModelConditionals(content, kitInstance.Extensions().GetContext().Model) - }, - - // ------------------------------------------------------------------------- - // Model Resolution API (Phase 4 Bridge) - Second Context - // ------------------------------------------------------------------------- - ResolveModelChain: kit.ResolveModelChain, - GetModelCapabilities: func(model string) (extensions.ModelCapabilities, string) { - return kit.GetModelCapabilities(model) - }, - CheckModelAvailable: kit.CheckModelAvailable, - GetCurrentProvider: func() string { - return kit.GetCurrentProvider(kitInstance.Extensions().GetContext().Model) - }, - GetCurrentModelID: func() string { - return kit.GetCurrentModelID(kitInstance.Extensions().GetContext().Model) - }, + extCtx = buildInteractiveExtensionContext(extensionContextDeps{ + ctx: ctx, + cwd: cwd, + modelName: modelName, + interactive: positionalPrompt == "", + kitInstance: kitInstance, + appInstance: appInstance, + usageTracker: usageTracker, }) + extCtx.Print = func(text string) { appInstance.PrintFromExtension("", text) } + extCtx.PrintInfo = func(text string) { appInstance.PrintFromExtension("info", text) } + extCtx.PrintError = func(text string) { appInstance.PrintFromExtension("error", text) } + kitInstance.Extensions().SetContext(extCtx) } // Convert extension commands to UI-layer type for the interactive TUI. From 1e12505741d529171d2453c7bcb523828718d3c4 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Thu, 7 May 2026 12:29:59 +0300 Subject: [PATCH 4/8] remove unused style.BaseStyle helper --- internal/ui/style/styles.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/internal/ui/style/styles.go b/internal/ui/style/styles.go index 31233ab8..637f6d5d 100644 --- a/internal/ui/style/styles.go +++ b/internal/ui/style/styles.go @@ -6,13 +6,6 @@ import ( heraldmd "github.com/indaco/herald-md" ) -// BaseStyle returns a new, empty lipgloss style that can be customized with -// additional styling methods. This serves as the foundation for building more -// complex styled components. -func BaseStyle() lipgloss.Style { - return lipgloss.NewStyle() -} - // markdownTypographyCache holds the last-created Typography instance for // herald-md rendering. It is cached to avoid re-initialization on every // streaming flush tick. The cache is invalidated by SetTheme when the From 97d2246375d7e9c27377f77cc632eedca9cf9c76 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Thu, 7 May 2026 12:31:55 +0300 Subject: [PATCH 5/8] drop orphan testTypography helper from render tests The TestUserBlockHighlightsFileTokens test was rewritten to call HighlightFileTokens directly (UserBlock was deleted in the dead-code sweep). That left testTypography with no callers, so staticcheck U1000 flagged it. --- internal/ui/render/blocks_test.go | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/internal/ui/render/blocks_test.go b/internal/ui/render/blocks_test.go index 60b9de9c..b2560343 100644 --- a/internal/ui/render/blocks_test.go +++ b/internal/ui/render/blocks_test.go @@ -4,30 +4,9 @@ import ( "strings" "testing" - "github.com/indaco/herald" - "github.com/mark3labs/kit/internal/ui/style" ) -// testTypography creates a herald Typography for tests. -func testTypography(theme style.Theme) *herald.Typography { - return herald.New( - herald.WithPalette(herald.ColorPalette{ - Primary: theme.Primary, - Secondary: theme.Secondary, - Tertiary: theme.Info, - Accent: theme.Accent, - Highlight: theme.Highlight, - Muted: theme.Muted, - Text: theme.Text, - Surface: theme.Background, - Base: theme.CodeBg, - }), - herald.WithAlertLabel(herald.AlertTip, ""), - herald.WithAlertIcon(herald.AlertTip, ""), - ) -} - func TestHighlightFileTokens(t *testing.T) { theme := style.DefaultTheme() From 65054fe3db524f42c03d8a2c6e22f7f96a99aa89 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Thu, 7 May 2026 12:34:29 +0300 Subject: [PATCH 6/8] gofmt trailing-blank-line cleanup after dead-code removal --- internal/extensions/subagent.go | 1 - internal/models/cache_options.go | 1 - internal/models/cache_options_test.go | 1 - 3 files changed, 3 deletions(-) diff --git a/internal/extensions/subagent.go b/internal/extensions/subagent.go index 15b6688e..748af7bb 100644 --- a/internal/extensions/subagent.go +++ b/internal/extensions/subagent.go @@ -150,4 +150,3 @@ func (h *SubagentHandle) Wait() SubagentResult { func (h *SubagentHandle) Done() <-chan struct{} { return h.done } - diff --git a/internal/models/cache_options.go b/internal/models/cache_options.go index 385d4d38..161a0604 100644 --- a/internal/models/cache_options.go +++ b/internal/models/cache_options.go @@ -68,4 +68,3 @@ func generateCacheKey(systemPrompt, modelID string) string { // Prefix with "kit-" to identify KIT-generated cache keys return "kit-" + hex.EncodeToString(h.Sum(nil))[:24] } - diff --git a/internal/models/cache_options_test.go b/internal/models/cache_options_test.go index 8d8e80d3..be641181 100644 --- a/internal/models/cache_options_test.go +++ b/internal/models/cache_options_test.go @@ -190,4 +190,3 @@ func TestCachingPriorityOverThinking(t *testing.T) { t.Errorf("OpenAI caching should work when thinking is OFF") } } - From d557f4b870473cf6eb17db5571659a64fb006d34 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Thu, 7 May 2026 13:00:06 +0300 Subject: [PATCH 7/8] fix(cmd): wrap bare fn refs in extensions.Context as closures Per AGENTS.md 'Yaegi function field bug', named function/method references assigned to extensions.Context fields return zero values across the interpreter boundary. The two SetContext literals in runNormalMode (now consolidated in buildInteractiveExtensionContext) inherited 9 bare references that need to be anonymous closure literals: PrintBlock, GetChildren, GetAvailableSkills, ParseTemplate, RenderTemplate, ParseArguments, SimpleParseArguments, ResolveModelChain, CheckModelAvailable Each is now wrapped as 'func(args) ret { return (args) }'. Behaviour unchanged in regular Go; Yaegi extensions that consume these fields will now see callable closures instead of zero values. Verified with go test -race ./... --- cmd/extension_context.go | 42 ++++++++++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/cmd/extension_context.go b/cmd/extension_context.go index 623363e8..8fd75268 100644 --- a/cmd/extension_context.go +++ b/cmd/extension_context.go @@ -44,10 +44,12 @@ func buildInteractiveExtensionContext(deps extensionContextDeps) extensions.Cont ctx := deps.ctx return extensions.Context{ - CWD: deps.cwd, - Model: deps.modelName, - Interactive: deps.interactive, - PrintBlock: appInstance.PrintBlockFromExtension, + CWD: deps.cwd, + Model: deps.modelName, + Interactive: deps.interactive, + PrintBlock: func(opts extensions.PrintBlockOpts) { + appInstance.PrintBlockFromExtension(opts) + }, SendMessage: func(text string) { appInstance.Run(text) }, CancelAndSend: func(text string) { appInstance.InterruptAndSend(text) }, Abort: func() { appInstance.Abort() }, @@ -369,7 +371,9 @@ func buildInteractiveExtensionContext(deps extensionContextDeps) extensions.Cont } return result }, - GetChildren: kitInstance.GetChildren, + GetChildren: func(parentID string) []string { + return kitInstance.GetChildren(parentID) + }, NavigateTo: func(entryID string) extensions.TreeNavigationResult { err := kitInstance.NavigateTo(entryID) if err != nil { @@ -421,15 +425,25 @@ func buildInteractiveExtensionContext(deps extensionContextDeps) extensions.Cont appInstance.Run(fmt.Sprintf("\n%s\n", s.Name, s.Content)) return "" }, - GetAvailableSkills: kitInstance.DiscoverSkillsForExtension, + GetAvailableSkills: func() []extensions.Skill { + return kitInstance.DiscoverSkillsForExtension() + }, // ------------------------------------------------------------------- // Template Parsing API // ------------------------------------------------------------------- - ParseTemplate: kit.ParseTemplate, - RenderTemplate: kit.RenderTemplate, - ParseArguments: kit.ParseArguments, - SimpleParseArguments: kit.SimpleParseArguments, + ParseTemplate: func(name, content string) extensions.PromptTemplate { + return kit.ParseTemplate(name, content) + }, + RenderTemplate: func(tpl extensions.PromptTemplate, vars map[string]string) string { + return kit.RenderTemplate(tpl, vars) + }, + ParseArguments: func(input string, pattern extensions.ArgumentPattern) extensions.ParseResult { + return kit.ParseArguments(input, pattern) + }, + SimpleParseArguments: func(input string, count int) []string { + return kit.SimpleParseArguments(input, count) + }, EvaluateModelConditional: func(condition string) bool { return kit.EvaluateModelConditional(kitInstance.Extensions().GetContext().Model, condition) }, @@ -440,11 +454,15 @@ func buildInteractiveExtensionContext(deps extensionContextDeps) extensions.Cont // ------------------------------------------------------------------- // Model Resolution API // ------------------------------------------------------------------- - ResolveModelChain: kit.ResolveModelChain, + ResolveModelChain: func(preferences []string) extensions.ModelResolutionResult { + return kit.ResolveModelChain(preferences) + }, GetModelCapabilities: func(model string) (extensions.ModelCapabilities, string) { return kit.GetModelCapabilities(model) }, - CheckModelAvailable: kit.CheckModelAvailable, + CheckModelAvailable: func(model string) bool { + return kit.CheckModelAvailable(model) + }, GetCurrentProvider: func() string { return kit.GetCurrentProvider(kitInstance.Extensions().GetContext().Model) }, From 2016570e2d475d0eef003b773e5e80559884792f Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Thu, 7 May 2026 13:16:03 +0300 Subject: [PATCH 8/8] test: add docstrings to rewritten tests and use t.Setenv MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses two CodeRabbit feedback items on PR #24: * Docstring coverage warning (was 57.14%, threshold 80%): adds godoc comments to the four test functions added or substantially rewritten in this PR — TestLoadAndSaveManifest, TestAddAndRemoveFromManifest, TestFindInManifest, TestHighlightFileTokensInjectsANSI. * Quick-win nitpick: replaces the manual os.Setenv/os.Unsetenv + defer pattern in TestFindInManifest with t.Setenv, which restores the env var automatically on cleanup even on panic or t.Fatal. go test -race ./... still passes. --- internal/extensions/installer_test.go | 19 +++++++++++-------- internal/ui/render/blocks_test.go | 3 +++ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/internal/extensions/installer_test.go b/internal/extensions/installer_test.go index c10149fa..8008b427 100644 --- a/internal/extensions/installer_test.go +++ b/internal/extensions/installer_test.go @@ -245,6 +245,9 @@ func TestManifestEntryIdentity(t *testing.T) { } } +// TestLoadAndSaveManifest exercises the live *Installer.loadManifest / +// saveManifest round-trip against a temp directory, ensuring an absent +// manifest loads as empty and a saved manifest reads back identically. func TestLoadAndSaveManifest(t *testing.T) { tempDir := t.TempDir() installer := &Installer{ @@ -300,6 +303,9 @@ func TestLoadAndSaveManifest(t *testing.T) { } } +// TestAddAndRemoveFromManifest verifies that *Installer.addToManifest +// followed by removeFromManifest leaves the manifest in its original +// (empty) state, using a temp-directory installer scope. func TestAddAndRemoveFromManifest(t *testing.T) { tempDir := t.TempDir() installer := &Installer{ @@ -343,16 +349,13 @@ func TestAddAndRemoveFromManifest(t *testing.T) { } } +// TestFindInManifest writes a manifest file directly to the path +// resolved by the package-level manifestPathForScope helper and then +// confirms FindInManifest locates the entry by identity (and returns +// nil for a non-existent identity). func TestFindInManifest(t *testing.T) { tempDir := t.TempDir() - if err := os.Setenv("XDG_DATA_HOME", tempDir); err != nil { - t.Fatalf("Setenv() error = %v", err) - } - defer func() { - if err := os.Unsetenv("XDG_DATA_HOME"); err != nil { - t.Logf("Unsetenv() error = %v", err) - } - }() + t.Setenv("XDG_DATA_HOME", tempDir) // Write a manifest entry directly via the package-level path resolver // so FindInManifest (which uses manifestPathForScope) can read it back. diff --git a/internal/ui/render/blocks_test.go b/internal/ui/render/blocks_test.go index b2560343..9ba43adc 100644 --- a/internal/ui/render/blocks_test.go +++ b/internal/ui/render/blocks_test.go @@ -67,6 +67,9 @@ func TestHighlightFileTokens(t *testing.T) { } } +// TestHighlightFileTokensInjectsANSI verifies that HighlightFileTokens +// preserves the original @file references in the output and wraps each +// token with ANSI escape codes for the theme accent color. func TestHighlightFileTokensInjectsANSI(t *testing.T) { theme := style.DefaultTheme()