From 78570d4188f9963f0b62964104b503ec141eb21f Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Thu, 7 May 2026 12:20:08 +0300 Subject: [PATCH] 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()))) -}