From c70df81455dcb2bf68e9309165e1af8e3fbfe16a Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 12:59:09 +0530 Subject: [PATCH 01/57] fix(agent): graceful error handling instead of panics --- internal/chat/app.go | 7 ++++++- internal/chat/model.go | 12 +++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/internal/chat/app.go b/internal/chat/app.go index bd1e925..23cf22b 100644 --- a/internal/chat/app.go +++ b/internal/chat/app.go @@ -6,8 +6,13 @@ var program *tea.Program // Run starts the WindMist Bubble Tea application. func Run() error { + model, err := New() + if err != nil { + return err + } + p := tea.NewProgram( - New(), + model, tea.WithAltScreen(), ) diff --git a/internal/chat/model.go b/internal/chat/model.go index e61322a..bb59caa 100644 --- a/internal/chat/model.go +++ b/internal/chat/model.go @@ -1,6 +1,8 @@ package chat import ( + "fmt" + "github.com/Nithwin/WindMist/internal/agent" "github.com/Nithwin/WindMist/internal/ai" "github.com/Nithwin/WindMist/internal/config" @@ -46,15 +48,15 @@ type Model struct { } // New creates a new Bubble Tea model. -func New() Model { +func New() (Model, error) { cfg, err := config.Load() if err != nil { - panic(err) + return Model{}, fmt.Errorf("failed to load configuration: %w", err) } provider, err := ai.New(cfg) if err != nil { - panic(err) + return Model{}, fmt.Errorf("failed to initialize AI provider: %w", err) } manager := tools.NewManager() @@ -73,7 +75,7 @@ func New() Model { renderer, err := ui.NewMarkdownRenderer() if err != nil { - panic(err) + return Model{}, fmt.Errorf("failed to initialize markdown renderer: %w", err) } ta := textarea.New() @@ -118,7 +120,7 @@ func New() Model { viewport: vp, markdown: renderer, - } + }, nil } // Init initializes the application. From 5601b770adacab4071514cc708c89a909ad8c4fa Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 13:01:22 +0530 Subject: [PATCH 02/57] fix(agent): resolve variable assignment compiler error --- internal/chat/app.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/chat/app.go b/internal/chat/app.go index 23cf22b..8d1faa0 100644 --- a/internal/chat/app.go +++ b/internal/chat/app.go @@ -18,6 +18,6 @@ func Run() error { program = p - _, err := p.Run() + _, err = p.Run() return err } From b9be689e399b36984a42aba7f022e14cdd6a7cd8 Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 13:08:21 +0530 Subject: [PATCH 03/57] fix(agent): implement exponential backoff retry in loop --- internal/agent/loop.go | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/internal/agent/loop.go b/internal/agent/loop.go index f17754c..131444f 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -3,6 +3,7 @@ package agent import ( "context" "os" + "time" "github.com/Nithwin/WindMist/internal/agent/prompt" "github.com/Nithwin/WindMist/internal/ai" @@ -35,9 +36,33 @@ func (a *Agent) runLoop(ctx context.Context, messages []ai.Message, userPrompt s var resp *ai.GenerateResponse var err error - // Use stream only for the first turn (to show the user something is happening) - // Or always stream. Since we patched providers to return GenerateResponse, we can always Stream! - resp, err = a.provider.Stream(ctx, req, onChunk) + maxRetries := 3 + backoff := 1 * time.Second + + for attempt := 0; attempt <= maxRetries; attempt++ { + resp, err = a.provider.Stream(ctx, req, onChunk) + if err == nil { + break + } + + // Don't retry if context is cancelled by user + if ctx.Err() != nil { + break + } + + if attempt == maxRetries { + break + } + + // Wait before retrying + select { + case <-ctx.Done(): + break + case <-time.After(backoff): + } + backoff *= 2 + } + if err != nil { return nil, err } From cb613bfc81961113dbc80eb65ec1573754ad8c10 Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 13:12:13 +0530 Subject: [PATCH 04/57] fix(chat): implement graceful context cancellation on ctrl+c --- internal/chat/chat.go | 4 ++-- internal/chat/model.go | 3 +++ internal/chat/update.go | 12 +++++++++++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/internal/chat/chat.go b/internal/chat/chat.go index 92d5d93..4434695 100644 --- a/internal/chat/chat.go +++ b/internal/chat/chat.go @@ -6,9 +6,9 @@ import ( ) // sendMessage starts running the agent request. -func (m Model) sendMessage(prompt string) { +func (m Model) sendMessage(ctx context.Context, prompt string) { go func() { - res, err := m.agent.Run(context.Background(), prompt, func(s string) { + res, err := m.agent.Run(ctx, prompt, func(s string) { program.Send(StreamingMsg{ Text: s, }) diff --git a/internal/chat/model.go b/internal/chat/model.go index bb59caa..4f9ab23 100644 --- a/internal/chat/model.go +++ b/internal/chat/model.go @@ -1,6 +1,7 @@ package chat import ( + "context" "fmt" "github.com/Nithwin/WindMist/internal/agent" @@ -45,6 +46,8 @@ type Model struct { width int height int + + cancel context.CancelFunc } // New creates a new Bubble Tea model. diff --git a/internal/chat/update.go b/internal/chat/update.go index 385ead1..4e19b90 100644 --- a/internal/chat/update.go +++ b/internal/chat/update.go @@ -1,6 +1,7 @@ package chat import ( + "context" "fmt" "strings" @@ -86,6 +87,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.String() { case "ctrl+c", "esc": + if m.loading && m.cancel != nil { + m.cancel() + m.loading = false + m.conversation.AddAssistant("\n\n*(Cancelled by user)*") + m.refreshViewport() + return m, nil + } return m, tea.Quit } @@ -185,7 +193,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.conversation.AddAssistant("") m.refreshViewport() - m.sendMessage(prompt) + ctx, cancel := context.WithCancel(context.Background()) + m.cancel = cancel + m.sendMessage(ctx, prompt) return m, nil } From 31851d6f0ae85ccdfc0e2af4e53ae6f727f5bcfd Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 13:14:03 +0530 Subject: [PATCH 05/57] fix(config): allow setting base URL for providers --- internal/config/config.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/internal/config/config.go b/internal/config/config.go index b5aeac9..178d77f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -104,7 +104,15 @@ func (c *Config) SetAPIKey(providerName, apiKey string) error { // SetBaseURL updates a provider base URL. func (c *Config) SetBaseURL(providerName, baseURL string) error { - return fmt.Errorf("base_url cannot be set or changed by the user for any provider") + provider, ok := c.Providers[providerName] + if !ok { + return fmt.Errorf("unsupported provider: %s", providerName) + } + + provider.BaseURL = baseURL + c.Providers[providerName] = provider + + return nil } // SetTheme updates the UI theme. From 96df1a7c1504e9fe8ba86b4e2a1611e4a744b7de Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 13:18:03 +0530 Subject: [PATCH 06/57] feat(chat): implement input history navigation with up/down arrows --- internal/chat/model.go | 6 +++++- internal/chat/update.go | 30 ++++++++++++++++++++++++++++-- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/internal/chat/model.go b/internal/chat/model.go index 4f9ab23..77bd8a6 100644 --- a/internal/chat/model.go +++ b/internal/chat/model.go @@ -25,7 +25,9 @@ type Model struct { conversation Conversation - input textarea.Model + input textarea.Model + inputHistory []string + historyIndex int showSplash bool @@ -108,6 +110,8 @@ func New() (Model, error) { agent: ag, conversation: Conversation{}, input: ta, + inputHistory: make([]string, 0), + historyIndex: 0, showSplash: true, diff --git a/internal/chat/update.go b/internal/chat/update.go index 4e19b90..b9e4f66 100644 --- a/internal/chat/update.go +++ b/internal/chat/update.go @@ -31,14 +31,34 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if !m.showCommands { switch msg.String() { - case "up": + case "ctrl+up", "shift+up": m.viewport.ScrollUp(1) return m, nil - case "down": + case "ctrl+down", "shift+down": m.viewport.ScrollDown(1) return m, nil + case "up": + if len(m.inputHistory) > 0 && m.historyIndex > 0 { + m.historyIndex-- + m.input.SetValue(m.inputHistory[m.historyIndex]) + m.input.CursorEnd() + } + return m, nil + + case "down": + if len(m.inputHistory) > 0 && m.historyIndex < len(m.inputHistory) { + m.historyIndex++ + if m.historyIndex == len(m.inputHistory) { + m.input.SetValue("") + } else { + m.input.SetValue(m.inputHistory[m.historyIndex]) + m.input.CursorEnd() + } + } + return m, nil + case "pgup": m.viewport.ScrollUp(m.viewport.Height / 2) return m, nil @@ -171,6 +191,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Execute typed slash command. if strings.HasPrefix(prompt, "/") { + m.inputHistory = append(m.inputHistory, prompt) + m.historyIndex = len(m.inputHistory) + if command, ok := FindCommand(prompt); ok { m.input.SetValue("") return m, command.Execute(&m) @@ -182,6 +205,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // Normal AI message. + m.inputHistory = append(m.inputHistory, prompt) + m.historyIndex = len(m.inputHistory) + m.conversation.AddUser(prompt) m.refreshViewport() m.loading = true From 6db2e2aebcbba76d27c195e5510355a66e561984 Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 13:19:23 +0530 Subject: [PATCH 07/57] feat(agent): stream progress indicators during tool execution --- internal/agent/executor.go | 10 +++++++++- internal/agent/loop.go | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/internal/agent/executor.go b/internal/agent/executor.go index ca38bcd..685d2d7 100644 --- a/internal/agent/executor.go +++ b/internal/agent/executor.go @@ -9,7 +9,7 @@ import ( ) // execute runs a slice of tool calls against the tool manager and returns their results. -func (a *Agent) execute(ctx context.Context, calls []ai.ToolCall) []ai.ToolResult { +func (a *Agent) execute(ctx context.Context, calls []ai.ToolCall, onChunk func(string)) []ai.ToolResult { results := make([]ai.ToolResult, 0, len(calls)) for _, call := range calls { @@ -24,12 +24,20 @@ func (a *Agent) execute(ctx context.Context, calls []ai.ToolCall) []ai.ToolResul continue } + if onChunk != nil { + onChunk(fmt.Sprintf("\n\n> ⏳ **Executing tool**: `%s`...", call.Name)) + } + // Execute the tool. res := tool.Run(ctx, tools.Call{ Name: call.Name, Args: call.Args, }) + if onChunk != nil { + onChunk(" ✅ Done.\n\n") + } + content := "" isError := false diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 131444f..0bbb07c 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -81,7 +81,7 @@ func (a *Agent) runLoop(ctx context.Context, messages []ai.Message, userPrompt s }, nil } - results := a.execute(ctx, resp.ToolCalls) + results := a.execute(ctx, resp.ToolCalls, onChunk) messages = appendToolResults(messages, results) } From 5f347d35228e3069f423a77ae1ec29fbc5bc59dd Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 13:29:39 +0530 Subject: [PATCH 08/57] feat(agent): implement concurrent tool execution --- internal/agent/executor.go | 86 +++++++++++++++++++++----------------- 1 file changed, 47 insertions(+), 39 deletions(-) diff --git a/internal/agent/executor.go b/internal/agent/executor.go index 685d2d7..72117d8 100644 --- a/internal/agent/executor.go +++ b/internal/agent/executor.go @@ -3,6 +3,7 @@ package agent import ( "context" "fmt" + "sync" "github.com/Nithwin/WindMist/internal/ai" "github.com/Nithwin/WindMist/internal/tools" @@ -10,54 +11,61 @@ import ( // execute runs a slice of tool calls against the tool manager and returns their results. func (a *Agent) execute(ctx context.Context, calls []ai.ToolCall, onChunk func(string)) []ai.ToolResult { - results := make([]ai.ToolResult, 0, len(calls)) + results := make([]ai.ToolResult, len(calls)) + var wg sync.WaitGroup - for _, call := range calls { - tool, ok := a.manager.Get(call.Name) - if !ok { - results = append(results, ai.ToolResult{ - ID: call.ID, - Name: call.Name, - Content: fmt.Sprintf("error: tool %q not found or not registered", call.Name), - IsError: true, - }) - continue - } + for i, call := range calls { + wg.Add(1) + go func(i int, call ai.ToolCall) { + defer wg.Done() - if onChunk != nil { - onChunk(fmt.Sprintf("\n\n> ⏳ **Executing tool**: `%s`...", call.Name)) - } + tool, ok := a.manager.Get(call.Name) + if !ok { + results[i] = ai.ToolResult{ + ID: call.ID, + Name: call.Name, + Content: fmt.Sprintf("error: tool %q not found or not registered", call.Name), + IsError: true, + } + return + } - // Execute the tool. - res := tool.Run(ctx, tools.Call{ - Name: call.Name, - Args: call.Args, - }) + if onChunk != nil { + onChunk(fmt.Sprintf("\n\n> ⏳ **Executing tool**: `%s`...", call.Name)) + } - if onChunk != nil { - onChunk(" ✅ Done.\n\n") - } + // Execute the tool. + res := tool.Run(ctx, tools.Call{ + Name: call.Name, + Args: call.Args, + }) - content := "" - isError := false + if onChunk != nil { + onChunk(fmt.Sprintf(" ✅ Done (`%s`).\n\n", call.Name)) + } - if res.Error != nil { - content = fmt.Sprintf("error executing tool %s: %v", call.Name, res.Error) - isError = true - } else if res.Output != nil { - content = fmt.Sprintf("%v", res.Output) - } else { - content = "success" - } + content := "" + isError := false - results = append(results, ai.ToolResult{ - ID: call.ID, - Name: call.Name, - Content: content, - IsError: isError, - }) + if res.Error != nil { + content = fmt.Sprintf("error executing tool %s: %v", call.Name, res.Error) + isError = true + } else if res.Output != nil { + content = fmt.Sprintf("%v", res.Output) + } else { + content = "success" + } + + results[i] = ai.ToolResult{ + ID: call.ID, + Name: call.Name, + Content: content, + IsError: isError, + } + }(i, call) } + wg.Wait() return results } From f16ff35b1723e2a611a2a658f1ceba85d17fd56c Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 13:35:18 +0530 Subject: [PATCH 09/57] feat(providers): add deepseek, mistral, and kimi (moonshot) models --- cmd/root.go | 3 +++ internal/config/config.go | 6 ++++++ internal/config/default.go | 9 +++++++++ internal/config/models.json | 15 +++++++++++++++ internal/providers/deepseek/deepseek.go | 22 ++++++++++++++++++++++ internal/providers/kimi/kimi.go | 22 ++++++++++++++++++++++ internal/providers/mistral/mistral.go | 22 ++++++++++++++++++++++ internal/providers/openai/provider.go | 5 ++++- 8 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 internal/providers/deepseek/deepseek.go create mode 100644 internal/providers/kimi/kimi.go create mode 100644 internal/providers/mistral/mistral.go diff --git a/cmd/root.go b/cmd/root.go index b7de833..4eb8b21 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,8 +2,11 @@ package cmd import ( _ "github.com/Nithwin/WindMist/internal/providers/anthropic" + _ "github.com/Nithwin/WindMist/internal/providers/deepseek" _ "github.com/Nithwin/WindMist/internal/providers/gemini" _ "github.com/Nithwin/WindMist/internal/providers/groq" + _ "github.com/Nithwin/WindMist/internal/providers/kimi" + _ "github.com/Nithwin/WindMist/internal/providers/mistral" _ "github.com/Nithwin/WindMist/internal/providers/ollama" _ "github.com/Nithwin/WindMist/internal/providers/openai" diff --git a/internal/config/config.go b/internal/config/config.go index 178d77f..8b9a12b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,6 +10,9 @@ const ( EnvGroqAPIKey = "GROQ_API_KEY" EnvOpenAIAPIKey = "OPENAI_API_KEY" EnvAnthropicAPIKey = "ANTHROPIC_API_KEY" + EnvDeepSeekAPIKey = "DEEPSEEK_API_KEY" + EnvMistralAPIKey = "MISTRAL_API_KEY" + EnvMoonshotAPIKey = "MOONSHOT_API_KEY" ) var envKeys = map[string]string{ @@ -17,6 +20,9 @@ var envKeys = map[string]string{ "groq": EnvGroqAPIKey, "openai": EnvOpenAIAPIKey, "anthropic": EnvAnthropicAPIKey, + "deepseek": EnvDeepSeekAPIKey, + "mistral": EnvMistralAPIKey, + "kimi": EnvMoonshotAPIKey, } // ActiveProvider returns the active provider configuration. diff --git a/internal/config/default.go b/internal/config/default.go index a898baa..1acc1b4 100644 --- a/internal/config/default.go +++ b/internal/config/default.go @@ -24,6 +24,15 @@ func DefaultConfig() *Config { "anthropic": { Model: "claude-3-5-sonnet-latest", }, + "deepseek": { + Model: "deepseek-chat", + }, + "mistral": { + Model: "mistral-large-latest", + }, + "kimi": { + Model: "kimi-k3", + }, }, UI: UIConfig{ diff --git a/internal/config/models.json b/internal/config/models.json index cb3ee64..1f79c77 100644 --- a/internal/config/models.json +++ b/internal/config/models.json @@ -27,5 +27,20 @@ { "label": "llama-3.1-8b-instant", "description": "Ultra-fast low latency 8B model", "value": "llama-3.1-8b-instant" }, { "label": "mixtral-8x7b-32768", "description": "Mixtral MoE fast model", "value": "mixtral-8x7b-32768" }, { "label": "gemma2-9b-it", "description": "Google Gemma 2 9B model on Groq", "value": "gemma2-9b-it" } + ], + "deepseek": [ + { "label": "deepseek-chat", "description": "DeepSeek V3 Chat Model (Default)", "value": "deepseek-chat" }, + { "label": "deepseek-reasoner", "description": "DeepSeek R1 Reasoning Model", "value": "deepseek-reasoner" } + ], + "mistral": [ + { "label": "mistral-large-latest", "description": "Mistral Large (Default)", "value": "mistral-large-latest" }, + { "label": "mistral-small-latest", "description": "Mistral Small", "value": "mistral-small-latest" }, + { "label": "codestral-latest", "description": "Codestral specialized code model", "value": "codestral-latest" } + ], + "kimi": [ + { "label": "kimi-k3", "description": "Latest flagship Kimi K3 model with 1M token context (Default)", "value": "kimi-k3" }, + { "label": "kimi-k2.7-code", "description": "Kimi specialized coding model", "value": "kimi-k2.7-code" }, + { "label": "moonshot-v1-8k", "description": "Legacy 8k context model", "value": "moonshot-v1-8k" }, + { "label": "moonshot-v1-32k", "description": "Legacy 32k context model", "value": "moonshot-v1-32k" } ] } diff --git a/internal/providers/deepseek/deepseek.go b/internal/providers/deepseek/deepseek.go new file mode 100644 index 0000000..c0d4281 --- /dev/null +++ b/internal/providers/deepseek/deepseek.go @@ -0,0 +1,22 @@ +package deepseek + +import ( + "github.com/Nithwin/WindMist/internal/ai" + "github.com/Nithwin/WindMist/internal/config" + "github.com/Nithwin/WindMist/internal/providers/openai" +) + +func init() { + ai.Register("deepseek", New) +} + +// New creates a new DeepSeek provider instance using the OpenAI-compatible client. +func New(cfg config.ProviderConfig) ai.Provider { + if cfg.BaseURL == "" { + cfg.BaseURL = "https://api.deepseek.com/v1" + } + if cfg.Model == "" { + cfg.Model = "deepseek-coder" + } + return openai.New(cfg) +} diff --git a/internal/providers/kimi/kimi.go b/internal/providers/kimi/kimi.go new file mode 100644 index 0000000..28746d8 --- /dev/null +++ b/internal/providers/kimi/kimi.go @@ -0,0 +1,22 @@ +package kimi + +import ( + "github.com/Nithwin/WindMist/internal/ai" + "github.com/Nithwin/WindMist/internal/config" + "github.com/Nithwin/WindMist/internal/providers/openai" +) + +func init() { + ai.Register("kimi", New) +} + +// New creates a new Kimi (Moonshot AI) provider instance using the OpenAI-compatible client. +func New(cfg config.ProviderConfig) ai.Provider { + if cfg.BaseURL == "" { + cfg.BaseURL = "https://api.moonshot.ai/v1" + } + if cfg.Model == "" { + cfg.Model = "moonshot-v1-8k" + } + return openai.New(cfg) +} diff --git a/internal/providers/mistral/mistral.go b/internal/providers/mistral/mistral.go new file mode 100644 index 0000000..47feb29 --- /dev/null +++ b/internal/providers/mistral/mistral.go @@ -0,0 +1,22 @@ +package mistral + +import ( + "github.com/Nithwin/WindMist/internal/ai" + "github.com/Nithwin/WindMist/internal/config" + "github.com/Nithwin/WindMist/internal/providers/openai" +) + +func init() { + ai.Register("mistral", New) +} + +// New creates a new Mistral provider instance using the OpenAI-compatible client. +func New(cfg config.ProviderConfig) ai.Provider { + if cfg.BaseURL == "" { + cfg.BaseURL = "https://api.mistral.ai/v1" + } + if cfg.Model == "" { + cfg.Model = "mistral-large-latest" + } + return openai.New(cfg) +} diff --git a/internal/providers/openai/provider.go b/internal/providers/openai/provider.go index 8b7af1d..a825446 100644 --- a/internal/providers/openai/provider.go +++ b/internal/providers/openai/provider.go @@ -19,7 +19,10 @@ type Provider struct { // New creates a new OpenAI provider instance. func New(cfg config.ProviderConfig) ai.Provider { - baseURL := "https://api.openai.com/v1" + baseURL := cfg.BaseURL + if baseURL == "" { + baseURL = "https://api.openai.com/v1" + } model := cfg.Model if model == "" { From edbb782074388255ec2476125b1353998c44da0a Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 13:36:49 +0530 Subject: [PATCH 10/57] feat(providers): add latest 2026 models and providers (perplexity, together) --- cmd/root.go | 2 ++ internal/config/config.go | 32 ++++++++++++--------- internal/config/default.go | 6 ++++ internal/config/models.json | 18 +++++++++--- internal/providers/perplexity/perplexity.go | 22 ++++++++++++++ internal/providers/together/together.go | 22 ++++++++++++++ 6 files changed, 84 insertions(+), 18 deletions(-) create mode 100644 internal/providers/perplexity/perplexity.go create mode 100644 internal/providers/together/together.go diff --git a/cmd/root.go b/cmd/root.go index 4eb8b21..3991cd5 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -9,6 +9,8 @@ import ( _ "github.com/Nithwin/WindMist/internal/providers/mistral" _ "github.com/Nithwin/WindMist/internal/providers/ollama" _ "github.com/Nithwin/WindMist/internal/providers/openai" + _ "github.com/Nithwin/WindMist/internal/providers/perplexity" + _ "github.com/Nithwin/WindMist/internal/providers/together" "github.com/Nithwin/WindMist/internal/chat" "github.com/spf13/cobra" diff --git a/internal/config/config.go b/internal/config/config.go index 8b9a12b..1c7d242 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,23 +6,27 @@ import ( ) const ( - EnvGeminiAPIKey = "GEMINI_API_KEY" - EnvGroqAPIKey = "GROQ_API_KEY" - EnvOpenAIAPIKey = "OPENAI_API_KEY" - EnvAnthropicAPIKey = "ANTHROPIC_API_KEY" - EnvDeepSeekAPIKey = "DEEPSEEK_API_KEY" - EnvMistralAPIKey = "MISTRAL_API_KEY" - EnvMoonshotAPIKey = "MOONSHOT_API_KEY" + EnvGeminiAPIKey = "GEMINI_API_KEY" + EnvGroqAPIKey = "GROQ_API_KEY" + EnvOpenAIAPIKey = "OPENAI_API_KEY" + EnvAnthropicAPIKey = "ANTHROPIC_API_KEY" + EnvDeepSeekAPIKey = "DEEPSEEK_API_KEY" + EnvMistralAPIKey = "MISTRAL_API_KEY" + EnvMoonshotAPIKey = "MOONSHOT_API_KEY" + EnvPerplexityAPIKey = "PERPLEXITY_API_KEY" + EnvTogetherAPIKey = "TOGETHER_API_KEY" ) var envKeys = map[string]string{ - "gemini": EnvGeminiAPIKey, - "groq": EnvGroqAPIKey, - "openai": EnvOpenAIAPIKey, - "anthropic": EnvAnthropicAPIKey, - "deepseek": EnvDeepSeekAPIKey, - "mistral": EnvMistralAPIKey, - "kimi": EnvMoonshotAPIKey, + "gemini": EnvGeminiAPIKey, + "groq": EnvGroqAPIKey, + "openai": EnvOpenAIAPIKey, + "anthropic": EnvAnthropicAPIKey, + "deepseek": EnvDeepSeekAPIKey, + "mistral": EnvMistralAPIKey, + "kimi": EnvMoonshotAPIKey, + "perplexity": EnvPerplexityAPIKey, + "together": EnvTogetherAPIKey, } // ActiveProvider returns the active provider configuration. diff --git a/internal/config/default.go b/internal/config/default.go index 1acc1b4..2da94d3 100644 --- a/internal/config/default.go +++ b/internal/config/default.go @@ -33,6 +33,12 @@ func DefaultConfig() *Config { "kimi": { Model: "kimi-k3", }, + "perplexity": { + Model: "llama-3.1-sonar-large-128k-online", + }, + "together": { + Model: "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo", + }, }, UI: UIConfig{ diff --git a/internal/config/models.json b/internal/config/models.json index 1f79c77..e614d32 100644 --- a/internal/config/models.json +++ b/internal/config/models.json @@ -1,9 +1,9 @@ { "gemini": [ - { "label": "gemini-3.5-flash", "description": "Latest high-speed Gemini 3.5 Flash model (Default)", "value": "gemini-3.5-flash" }, - { "label": "gemini-3.1-pro", "description": "Latest advanced reasoning Gemini 3.1 Pro model", "value": "gemini-3.1-pro" }, - { "label": "gemini-2.5-flash", "description": "High-speed multimodal model", "value": "gemini-2.5-flash" }, - { "label": "gemini-2.5-pro", "description": "Advanced reasoning model", "value": "gemini-2.5-pro" } + { "label": "gemini-3.6-flash", "description": "Latest ultra-high-speed Gemini 3.6 Flash model (Default)", "value": "gemini-3.6-flash" }, + { "label": "gemini-3.6-pro", "description": "Latest advanced reasoning Gemini 3.6 Pro model", "value": "gemini-3.6-pro" }, + { "label": "gemini-3.5-flash", "description": "High-speed multimodal model", "value": "gemini-3.5-flash" }, + { "label": "gemini-3.1-pro", "description": "Advanced reasoning model", "value": "gemini-3.1-pro" } ], "openai": [ { "label": "gpt-5.5-pro", "description": "Latest frontier GPT-5.5 Pro model for professional coding (Default)", "value": "gpt-5.5-pro" }, @@ -42,5 +42,15 @@ { "label": "kimi-k2.7-code", "description": "Kimi specialized coding model", "value": "kimi-k2.7-code" }, { "label": "moonshot-v1-8k", "description": "Legacy 8k context model", "value": "moonshot-v1-8k" }, { "label": "moonshot-v1-32k", "description": "Legacy 32k context model", "value": "moonshot-v1-32k" } + ], + "perplexity": [ + { "label": "llama-3.1-sonar-large-128k-online", "description": "Perplexity Sonar Large Online (Default)", "value": "llama-3.1-sonar-large-128k-online" }, + { "label": "llama-3.1-sonar-small-128k-online", "description": "Perplexity Sonar Small Online", "value": "llama-3.1-sonar-small-128k-online" }, + { "label": "llama-3.1-sonar-huge-128k-online", "description": "Perplexity Sonar Huge Online", "value": "llama-3.1-sonar-huge-128k-online" } + ], + "together": [ + { "label": "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo", "description": "Llama 3.1 70B Turbo (Default)", "value": "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo" }, + { "label": "meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo", "description": "Llama 3.1 405B Turbo", "value": "meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo" }, + { "label": "Qwen/Qwen2.5-72B-Instruct-Turbo", "description": "Qwen 2.5 72B Turbo", "value": "Qwen/Qwen2.5-72B-Instruct-Turbo" } ] } diff --git a/internal/providers/perplexity/perplexity.go b/internal/providers/perplexity/perplexity.go new file mode 100644 index 0000000..4461c7c --- /dev/null +++ b/internal/providers/perplexity/perplexity.go @@ -0,0 +1,22 @@ +package perplexity + +import ( + "github.com/Nithwin/WindMist/internal/ai" + "github.com/Nithwin/WindMist/internal/config" + "github.com/Nithwin/WindMist/internal/providers/openai" +) + +func init() { + ai.Register("perplexity", New) +} + +// New creates a new Perplexity provider instance using the OpenAI-compatible client. +func New(cfg config.ProviderConfig) ai.Provider { + if cfg.BaseURL == "" { + cfg.BaseURL = "https://api.perplexity.ai" + } + if cfg.Model == "" { + cfg.Model = "llama-3.1-sonar-large-128k-online" + } + return openai.New(cfg) +} diff --git a/internal/providers/together/together.go b/internal/providers/together/together.go new file mode 100644 index 0000000..3481a42 --- /dev/null +++ b/internal/providers/together/together.go @@ -0,0 +1,22 @@ +package together + +import ( + "github.com/Nithwin/WindMist/internal/ai" + "github.com/Nithwin/WindMist/internal/config" + "github.com/Nithwin/WindMist/internal/providers/openai" +) + +func init() { + ai.Register("together", New) +} + +// New creates a new Together AI provider instance using the OpenAI-compatible client. +func New(cfg config.ProviderConfig) ai.Provider { + if cfg.BaseURL == "" { + cfg.BaseURL = "https://api.together.xyz/v1" + } + if cfg.Model == "" { + cfg.Model = "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo" + } + return openai.New(cfg) +} From 5055b3dc7ef5f00936e1dca1383c862bdc74f55a Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 13:45:35 +0530 Subject: [PATCH 11/57] feat(config): support saving and displaying custom models persistently --- cmd/model.go | 3 ++- cmd/provider.go | 3 ++- cmd/set.go | 6 ++++-- internal/chat/commands.go | 12 ++++++++++-- internal/config/config.go | 14 ++++++++++++++ internal/config/options.go | 13 ++++++++++++- internal/config/types.go | 9 +++++---- 7 files changed, 49 insertions(+), 11 deletions(-) diff --git a/cmd/model.go b/cmd/model.go index 28f3ead..ae7557c 100644 --- a/cmd/model.go +++ b/cmd/model.go @@ -30,7 +30,7 @@ var modelCmd = &cobra.Command{ opt, err := selector.Run( fmt.Sprintf("Select Model for %s", cfg.AI.Provider), "Choose the active model to use:", - config.GetModelOptions(cfg.AI.Provider, ollamaBaseURL), + cfg.GetModelOptions(cfg.AI.Provider, ollamaBaseURL), ) if err != nil { log.Fatal(err) @@ -42,6 +42,7 @@ var modelCmd = &cobra.Command{ log.Fatal(err) } value = customVal + cfg.AddCustomModel(cfg.AI.Provider, value) } } diff --git a/cmd/provider.go b/cmd/provider.go index 3e07a22..f6f3e2b 100644 --- a/cmd/provider.go +++ b/cmd/provider.go @@ -45,7 +45,7 @@ var providerCmd = &cobra.Command{ modelOpt, err := selector.Run( fmt.Sprintf("Select Model for %s", value), "Choose the active model for this provider:", - config.GetModelOptions(value, ollamaBaseURL), + cfg.GetModelOptions(value, ollamaBaseURL), ) if err != nil { log.Fatal(err) @@ -57,6 +57,7 @@ var providerCmd = &cobra.Command{ log.Fatal(err) } modelValue = customVal + cfg.AddCustomModel(value, modelValue) } if err := cfg.SetModel(value, modelValue); err != nil { diff --git a/cmd/set.go b/cmd/set.go index 451fb11..133a67c 100644 --- a/cmd/set.go +++ b/cmd/set.go @@ -50,7 +50,7 @@ var setCmd = &cobra.Command{ modelOpt, err := selector.Run( fmt.Sprintf("Select Model for %s", value), "Choose the active model for this provider:", - config.GetModelOptions(value, ollamaBaseURL), + cfg.GetModelOptions(value, ollamaBaseURL), ) if err != nil { log.Fatal(err) @@ -62,6 +62,7 @@ var setCmd = &cobra.Command{ log.Fatal(err) } modelValue = customVal + cfg.AddCustomModel(value, modelValue) } err = cfg.SetModel(value, modelValue) if err != nil { @@ -84,7 +85,7 @@ var setCmd = &cobra.Command{ opt, err := selector.Run( fmt.Sprintf("Select Model for %s", cfg.AI.Provider), "Choose the active model to use:", - config.GetModelOptions(cfg.AI.Provider, ollamaBaseURL), + cfg.GetModelOptions(cfg.AI.Provider, ollamaBaseURL), ) if err != nil { log.Fatal(err) @@ -96,6 +97,7 @@ var setCmd = &cobra.Command{ log.Fatal(err) } value = customVal + cfg.AddCustomModel(cfg.AI.Provider, value) } } err = cfg.SetModel(cfg.AI.Provider, value) diff --git a/internal/chat/commands.go b/internal/chat/commands.go index 81b613a..fcbc102 100644 --- a/internal/chat/commands.go +++ b/internal/chat/commands.go @@ -112,7 +112,7 @@ func selectProviderCmd(m *Model) tea.Cmd { modelOpt, err := selector.Run( fmt.Sprintf("Select Model for %s", providerOpt.Value), "Choose the active model for this provider:", - config.GetModelOptions(providerOpt.Value, ollamaBaseURL), + m.cfg.GetModelOptions(providerOpt.Value, ollamaBaseURL), ) if err != nil { return switchCancelMsg{} @@ -125,6 +125,10 @@ func selectProviderCmd(m *Model) tea.Cmd { return switchCancelMsg{} } modelValue = customVal + + // Save the custom model so it shows up next time + m.cfg.AddCustomModel(providerOpt.Value, modelValue) + _ = config.Save(m.cfg) } return switchProviderSuccessMsg{ @@ -154,7 +158,7 @@ func selectModelCmd(m *Model) tea.Cmd { modelOpt, err := selector.Run( fmt.Sprintf("Select Model for %s", m.cfg.AI.Provider), "Choose the active model to use:", - config.GetModelOptions(m.cfg.AI.Provider, ollamaBaseURL), + m.cfg.GetModelOptions(m.cfg.AI.Provider, ollamaBaseURL), ) if err != nil { return switchCancelMsg{} @@ -167,6 +171,10 @@ func selectModelCmd(m *Model) tea.Cmd { return switchCancelMsg{} } modelValue = customVal + + // Save the custom model so it shows up next time + m.cfg.AddCustomModel(m.cfg.AI.Provider, modelValue) + _ = config.Save(m.cfg) } return switchModelSuccessMsg{ diff --git a/internal/config/config.go b/internal/config/config.go index 1c7d242..def43a5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -129,3 +129,17 @@ func (c *Config) SetBaseURL(providerName, baseURL string) error { func (c *Config) SetTheme(theme string) { c.UI.Theme = theme } + +// AddCustomModel adds a new custom model to a provider if it doesn't already exist. +func (c *Config) AddCustomModel(providerName, model string) { + if c.CustomModels == nil { + c.CustomModels = make(map[string][]string) + } + + for _, m := range c.CustomModels[providerName] { + if m == model { + return // already exists + } + } + c.CustomModels[providerName] = append(c.CustomModels[providerName], model) +} diff --git a/internal/config/options.go b/internal/config/options.go index 595f63c..ecf9a08 100644 --- a/internal/config/options.go +++ b/internal/config/options.go @@ -58,7 +58,7 @@ func GetProviderOptions() []selector.Option { // GetModelOptions returns model options for the specified provider. // Cloud providers fetch dynamically from remote/embedded models.json manifest. // Ollama intelligently checks daemon state, auto-starting or auto-pulling models upon user confirmation. -func GetModelOptions(providerName, ollamaBaseURL string) []selector.Option { +func (c *Config) GetModelOptions(providerName, ollamaBaseURL string) []selector.Option { var options []selector.Option if providerName == "ollama" { @@ -80,6 +80,17 @@ func GetModelOptions(providerName, ollamaBaseURL string) []selector.Option { } } + // Append custom models + if c.CustomModels != nil { + for _, m := range c.CustomModels[providerName] { + options = append(options, selector.Option{ + Label: fmt.Sprintf("%s (Custom)", m), + Description: "Saved custom model", + Value: m, + }) + } + } + // Always append custom model escape hatch options = append(options, selector.Option{ Label: "Custom model ID...", diff --git a/internal/config/types.go b/internal/config/types.go index 529bfaf..cbe459e 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -2,10 +2,11 @@ package config // Config represents the complete WindMist configuration. type Config struct { - AI AIConfig `yaml:"ai"` - Providers map[string]ProviderConfig `yaml:"providers"` - UI UIConfig `yaml:"ui"` - Cache CacheConfig `yaml:"cache"` + AI AIConfig `yaml:"ai"` + Providers map[string]ProviderConfig `yaml:"providers"` + UI UIConfig `yaml:"ui"` + Cache CacheConfig `yaml:"cache"` + CustomModels map[string][]string `yaml:"custom_models,omitempty"` } // AIConfig stores the active AI provider. From ed307a0491ad9b2dca0a0432cf2e89ed78f20dfc Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 13:49:32 +0530 Subject: [PATCH 12/57] chore: add bin/ to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 1c3b9bc..3e7f302 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Go binaries & build artifacts WindMist +bin/ *.exe *.exe~ *.dll From 097a580473c35076c820b2a2a9e854a43c517e6a Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 13:53:35 +0530 Subject: [PATCH 13/57] feat(tools): implement priority 1 tools (glob, grep, search, fetch, todo) --- go.mod | 1 + go.sum | 2 + internal/tools/agent/todo.go | 106 +++++++++++++++++++ internal/tools/agent/todo_test.go | 58 +++++++++++ internal/tools/defaults/defaults.go | 11 ++ internal/tools/filesystem/glob.go | 63 +++++++++++ internal/tools/filesystem/glob_test.go | 62 +++++++++++ internal/tools/filesystem/grep.go | 139 +++++++++++++++++++++++++ internal/tools/filesystem/grep_test.go | 63 +++++++++++ internal/tools/web/fetch.go | 86 +++++++++++++++ internal/tools/web/search.go | 115 ++++++++++++++++++++ 11 files changed, 706 insertions(+) create mode 100644 internal/tools/agent/todo.go create mode 100644 internal/tools/agent/todo_test.go create mode 100644 internal/tools/filesystem/glob.go create mode 100644 internal/tools/filesystem/glob_test.go create mode 100644 internal/tools/filesystem/grep.go create mode 100644 internal/tools/filesystem/grep_test.go create mode 100644 internal/tools/web/fetch.go create mode 100644 internal/tools/web/search.go diff --git a/go.mod b/go.mod index b1f3b09..6f210cd 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/Nithwin/WindMist go 1.26 require ( + github.com/bmatcuk/doublestar/v4 v4.10.0 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/glamour v1.0.0 diff --git a/go.sum b/go.sum index d409e05..9dedb35 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= +github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= diff --git a/internal/tools/agent/todo.go b/internal/tools/agent/todo.go new file mode 100644 index 0000000..054e1b7 --- /dev/null +++ b/internal/tools/agent/todo.go @@ -0,0 +1,106 @@ +package agent + +import ( + "context" + "fmt" + "strings" + + "github.com/Nithwin/WindMist/internal/tools" +) + +type TodoTool struct { + tasks []string +} + +func NewTodoTool() *TodoTool { + return &TodoTool{ + tasks: make([]string, 0), + } +} + +func (t *TodoTool) Definition() tools.Definition { + return tools.Definition{ + Name: "todo", + Description: "Maintains an in-memory checklist to keep track of multi-step tasks. You can add, complete, remove, or list tasks.", + Parameters: []tools.Parameter{ + { + Name: "action", + Type: "string", + Description: "Action to perform: 'add', 'complete', 'remove', or 'list'.", + Required: true, + }, + { + Name: "task", + Type: "string", + Description: "The task text. Required for 'add', 'complete', and 'remove'. For 'complete' or 'remove', it must match part of the task string.", + Required: false, + }, + }, + } +} + +func (t *TodoTool) Run(ctx context.Context, call tools.Call) tools.Result { + action, ok := call.Args["action"].(string) + if !ok || action == "" { + return tools.Result{Error: fmt.Errorf("action is required")} + } + + task := "" + if v, ok := call.Args["task"].(string); ok { + task = v + } + + switch action { + case "add": + if task == "" { + return tools.Result{Error: fmt.Errorf("task text is required for add")} + } + t.tasks = append(t.tasks, "[ ] "+task) + return tools.Result{Output: "Added task. Current list:\n" + t.list()} + case "complete": + if task == "" { + return tools.Result{Error: fmt.Errorf("task text is required for complete")} + } + found := false + for i, v := range t.tasks { + if strings.Contains(v, task) && strings.HasPrefix(v, "[ ]") { + t.tasks[i] = strings.Replace(v, "[ ]", "[x]", 1) + found = true + break + } + } + if !found { + return tools.Result{Error: fmt.Errorf("no incomplete task matching %q found", task)} + } + return tools.Result{Output: "Completed task. Current list:\n" + t.list()} + case "remove": + if task == "" { + return tools.Result{Error: fmt.Errorf("task text is required for remove")} + } + found := false + var newTasks []string + for _, v := range t.tasks { + if !found && strings.Contains(v, task) { + found = true + continue + } + newTasks = append(newTasks, v) + } + if !found { + return tools.Result{Error: fmt.Errorf("no task matching %q found", task)} + } + t.tasks = newTasks + return tools.Result{Output: "Removed task. Current list:\n" + t.list()} + case "list": + return tools.Result{Output: "Current tasks:\n" + t.list()} + default: + return tools.Result{Error: fmt.Errorf("invalid action: %s", action)} + } +} + +func (t *TodoTool) list() string { + if len(t.tasks) == 0 { + return "(Empty)" + } + return strings.Join(t.tasks, "\n") +} diff --git a/internal/tools/agent/todo_test.go b/internal/tools/agent/todo_test.go new file mode 100644 index 0000000..c41b6c1 --- /dev/null +++ b/internal/tools/agent/todo_test.go @@ -0,0 +1,58 @@ +package agent + +import ( + "context" + "strings" + "testing" + + "github.com/Nithwin/WindMist/internal/tools" +) + +func TestTodoTool(t *testing.T) { + tool := NewTodoTool() + + // Test Add + res := tool.Run(context.Background(), tools.Call{ + Args: map[string]interface{}{ + "action": "add", + "task": "fix tests", + }, + }) + if res.Error != nil { + t.Fatalf("unexpected error: %v", res.Error) + } + out := res.Output.(string) + if !strings.Contains(out, "[ ] fix tests") { + t.Fatalf("expected output to contain task, got: %s", out) + } + + // Test Complete + res = tool.Run(context.Background(), tools.Call{ + Args: map[string]interface{}{ + "action": "complete", + "task": "fix tests", + }, + }) + if res.Error != nil { + t.Fatalf("unexpected error: %v", res.Error) + } + out = res.Output.(string) + if !strings.Contains(out, "[x] fix tests") { + t.Fatalf("expected output to contain completed task, got: %s", out) + } + + // Test Remove + res = tool.Run(context.Background(), tools.Call{ + Args: map[string]interface{}{ + "action": "remove", + "task": "fix tests", + }, + }) + if res.Error != nil { + t.Fatalf("unexpected error: %v", res.Error) + } + out = res.Output.(string) + if strings.Contains(out, "fix tests") { + t.Fatalf("expected task to be removed, got: %s", out) + } +} diff --git a/internal/tools/defaults/defaults.go b/internal/tools/defaults/defaults.go index e25c081..2b49c52 100644 --- a/internal/tools/defaults/defaults.go +++ b/internal/tools/defaults/defaults.go @@ -2,9 +2,11 @@ package defaults import ( "github.com/Nithwin/WindMist/internal/tools" + "github.com/Nithwin/WindMist/internal/tools/agent" "github.com/Nithwin/WindMist/internal/tools/editing" "github.com/Nithwin/WindMist/internal/tools/filesystem" "github.com/Nithwin/WindMist/internal/tools/system" + "github.com/Nithwin/WindMist/internal/tools/web" ) // RegisterAll registers all built-in filesystem and editing tools onto the manager. @@ -23,6 +25,8 @@ func RegisterAll(m *tools.Manager, approvalCb system.ApprovalCallback) { m.Register(filesystem.NewCreateTool()) m.Register(filesystem.NewInfoTool()) m.Register(filesystem.NewExistsTool()) + m.Register(filesystem.NewGlobTool()) + m.Register(filesystem.NewGrepTool()) // Editing tools m.Register(editing.NewReplaceTextTool()) @@ -34,4 +38,11 @@ func RegisterAll(m *tools.Manager, approvalCb system.ApprovalCallback) { // System tools m.Register(system.NewCommandTool(approvalCb)) + + // Web tools + m.Register(web.NewWebSearchTool()) + m.Register(web.NewFetchTool()) + + // Agent tools + m.Register(agent.NewTodoTool()) } diff --git a/internal/tools/filesystem/glob.go b/internal/tools/filesystem/glob.go new file mode 100644 index 0000000..92b04ac --- /dev/null +++ b/internal/tools/filesystem/glob.go @@ -0,0 +1,63 @@ +package filesystem + +import ( + "context" + "fmt" + "os" + + "github.com/Nithwin/WindMist/internal/tools" + "github.com/bmatcuk/doublestar/v4" +) + +type GlobTool struct{} + +func NewGlobTool() *GlobTool { + return &GlobTool{} +} + +func (t *GlobTool) Definition() tools.Definition { + return tools.Definition{ + Name: "glob", + Description: "Finds files by matching a pattern (e.g., *.go, **/*.js) across the workspace.", + Parameters: []tools.Parameter{ + { + Name: "pattern", + Type: "string", + Description: "The glob pattern to search for (supports ** for recursive).", + Required: true, + }, + { + Name: "path", + Type: "string", + Description: "The base directory to start searching from. Defaults to current directory.", + Required: false, + }, + }, + } +} + +func (t *GlobTool) Run(ctx context.Context, call tools.Call) tools.Result { + pattern, ok := call.Args["pattern"].(string) + if !ok || pattern == "" { + return tools.Result{ + Error: fmt.Errorf("pattern is required"), + } + } + + basePath := "." + if p, ok := call.Args["path"].(string); ok && p != "" { + basePath = p + } + + fsys := os.DirFS(basePath) + matches, err := doublestar.Glob(fsys, pattern) + if err != nil { + return tools.Result{ + Error: fmt.Errorf("glob error: %w", err), + } + } + + return tools.Result{ + Output: matches, + } +} diff --git a/internal/tools/filesystem/glob_test.go b/internal/tools/filesystem/glob_test.go new file mode 100644 index 0000000..1cd8841 --- /dev/null +++ b/internal/tools/filesystem/glob_test.go @@ -0,0 +1,62 @@ +package filesystem + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/Nithwin/WindMist/internal/tools" +) + +func TestGlobTool(t *testing.T) { + // Setup test directory + tempDir, err := os.MkdirTemp("", "windmist_glob_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + os.WriteFile(filepath.Join(tempDir, "test1.go"), []byte("package main"), 0644) + os.WriteFile(filepath.Join(tempDir, "test2.txt"), []byte("hello"), 0644) + os.Mkdir(filepath.Join(tempDir, "sub"), 0755) + os.WriteFile(filepath.Join(tempDir, "sub", "test3.go"), []byte("package sub"), 0644) + + tool := NewGlobTool() + + // Test 1: *.go in root + res := tool.Run(context.Background(), tools.Call{ + Args: map[string]interface{}{ + "pattern": "*.go", + "path": tempDir, + }, + }) + + if res.Error != nil { + t.Fatalf("unexpected error: %v", res.Error) + } + + matches, ok := res.Output.([]string) + if !ok { + t.Fatalf("expected []string, got %T", res.Output) + } + if len(matches) != 1 || matches[0] != "test1.go" { + t.Fatalf("expected [test1.go], got %v", matches) + } + + // Test 2: **/*.go (recursive) + res = tool.Run(context.Background(), tools.Call{ + Args: map[string]interface{}{ + "pattern": "**/*.go", + "path": tempDir, + }, + }) + + if res.Error != nil { + t.Fatalf("unexpected error: %v", res.Error) + } + matches = res.Output.([]string) + if len(matches) != 2 { + t.Fatalf("expected 2 matches, got %d: %v", len(matches), matches) + } +} diff --git a/internal/tools/filesystem/grep.go b/internal/tools/filesystem/grep.go new file mode 100644 index 0000000..101d341 --- /dev/null +++ b/internal/tools/filesystem/grep.go @@ -0,0 +1,139 @@ +package filesystem + +import ( + "bufio" + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/Nithwin/WindMist/internal/tools" + "github.com/bmatcuk/doublestar/v4" +) + +type GrepTool struct{} + +func NewGrepTool() *GrepTool { + return &GrepTool{} +} + +func (t *GrepTool) Definition() tools.Definition { + return tools.Definition{ + Name: "grep", + Description: "Searches for a regex pattern inside files across the workspace. Returns file path, line number, and the matching line.", + Parameters: []tools.Parameter{ + { + Name: "pattern", + Type: "string", + Description: "The regular expression pattern to search for.", + Required: true, + }, + { + Name: "path", + Type: "string", + Description: "The directory to search in (defaults to current directory).", + Required: false, + }, + { + Name: "include", + Type: "string", + Description: "Optional glob pattern to filter files (e.g., *.go).", + Required: false, + }, + }, + } +} + +type GrepMatch struct { + File string `json:"file"` + LineNum int `json:"line"` + Content string `json:"content"` +} + +func (t *GrepTool) Run(ctx context.Context, call tools.Call) tools.Result { + pattern, ok := call.Args["pattern"].(string) + if !ok || pattern == "" { + return tools.Result{Error: fmt.Errorf("pattern is required")} + } + + re, err := regexp.Compile(pattern) + if err != nil { + return tools.Result{Error: fmt.Errorf("invalid regex pattern: %w", err)} + } + + basePath := "." + if p, ok := call.Args["path"].(string); ok && p != "" { + basePath = p + } + + includeGlob := "" + if inc, ok := call.Args["include"].(string); ok && inc != "" { + includeGlob = inc + } + + var matches []GrepMatch + maxMatches := 200 // Cap to prevent massive outputs + + err = filepath.WalkDir(basePath, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil // skip errors + } + if d.IsDir() { + // Skip .git and common vendor/binary folders + name := d.Name() + if name == ".git" || name == "node_modules" || name == "vendor" || name == ".windmist" { + return filepath.SkipDir + } + return nil + } + + if includeGlob != "" { + // Check if file matches include glob + rel, _ := filepath.Rel(basePath, path) + if rel == "" { + rel = path + } + matched, _ := doublestar.Match(includeGlob, rel) + if !matched { + return nil + } + } + + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + + scanner := bufio.NewScanner(f) + lineNum := 1 + for scanner.Scan() { + line := scanner.Text() + if re.MatchString(line) { + matches = append(matches, GrepMatch{ + File: path, + LineNum: lineNum, + Content: strings.TrimSpace(line), + }) + if len(matches) >= maxMatches { + return fmt.Errorf("max matches reached") + } + } + lineNum++ + } + return nil + }) + + if err != nil && err.Error() != "max matches reached" { + return tools.Result{Error: err} + } + + return tools.Result{ + Output: map[string]interface{}{ + "matches": matches, + "limit": len(matches) == maxMatches, + }, + } +} diff --git a/internal/tools/filesystem/grep_test.go b/internal/tools/filesystem/grep_test.go new file mode 100644 index 0000000..0750ede --- /dev/null +++ b/internal/tools/filesystem/grep_test.go @@ -0,0 +1,63 @@ +package filesystem + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/Nithwin/WindMist/internal/tools" +) + +func TestGrepTool(t *testing.T) { + tempDir, err := os.MkdirTemp("", "windmist_grep_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + os.WriteFile(filepath.Join(tempDir, "file1.txt"), []byte("hello world\nthis is a test\nend"), 0644) + os.WriteFile(filepath.Join(tempDir, "file2.go"), []byte("package main\nfunc hello() {}\n"), 0644) + + tool := NewGrepTool() + + res := tool.Run(context.Background(), tools.Call{ + Args: map[string]interface{}{ + "pattern": "hello", + "path": tempDir, + }, + }) + + if res.Error != nil { + t.Fatalf("unexpected error: %v", res.Error) + } + + output, ok := res.Output.(map[string]interface{}) + if !ok { + t.Fatalf("expected map[string]interface{}, got %T", res.Output) + } + + matches := output["matches"].([]GrepMatch) + if len(matches) != 2 { + t.Fatalf("expected 2 matches, got %d", len(matches)) + } + + // Test include glob + res = tool.Run(context.Background(), tools.Call{ + Args: map[string]interface{}{ + "pattern": "hello", + "path": tempDir, + "include": "*.go", + }, + }) + + if res.Error != nil { + t.Fatalf("unexpected error: %v", res.Error) + } + + output = res.Output.(map[string]interface{}) + matches = output["matches"].([]GrepMatch) + if len(matches) != 1 { + t.Fatalf("expected 1 match, got %d", len(matches)) + } +} diff --git a/internal/tools/web/fetch.go b/internal/tools/web/fetch.go new file mode 100644 index 0000000..67cd8e6 --- /dev/null +++ b/internal/tools/web/fetch.go @@ -0,0 +1,86 @@ +package web + +import ( + "context" + "fmt" + "io" + "net/http" + "regexp" + "strings" + + "github.com/Nithwin/WindMist/internal/tools" +) + +type FetchTool struct{} + +func NewFetchTool() *FetchTool { + return &FetchTool{} +} + +func (t *FetchTool) Definition() tools.Definition { + return tools.Definition{ + Name: "fetch", + Description: "Fetches the text content of a given URL. Useful for reading documentation pages or articles.", + Parameters: []tools.Parameter{ + { + Name: "url", + Type: "string", + Description: "The URL to fetch.", + Required: true, + }, + }, + } +} + +func (t *FetchTool) Run(ctx context.Context, call tools.Call) tools.Result { + targetURL, ok := call.Args["url"].(string) + if !ok || targetURL == "" { + return tools.Result{Error: fmt.Errorf("url is required")} + } + + req, err := http.NewRequestWithContext(ctx, "GET", targetURL, nil) + if err != nil { + return tools.Result{Error: err} + } + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return tools.Result{Error: err} + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return tools.Result{Error: fmt.Errorf("HTTP %d", resp.StatusCode)} + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return tools.Result{Error: err} + } + content := string(body) + + // Very naive HTML to text conversion to save tokens + // Remove script and style tags + scriptRe := regexp.MustCompile(`(?is).*?`) + styleRe := regexp.MustCompile(`(?is).*?`) + content = scriptRe.ReplaceAllString(content, "") + content = styleRe.ReplaceAllString(content, "") + + // Remove all HTML tags + tagRe := regexp.MustCompile(`(?is)<[^>]*>`) + content = tagRe.ReplaceAllString(content, " ") + + // Condense whitespace + wsRe := regexp.MustCompile(`\s+`) + content = wsRe.ReplaceAllString(content, " ") + content = strings.TrimSpace(content) + + // Truncate to reasonable length (e.g. 15000 chars) to not blow up context + if len(content) > 15000 { + content = content[:15000] + "\n... (truncated)" + } + + return tools.Result{Output: content} +} diff --git a/internal/tools/web/search.go b/internal/tools/web/search.go new file mode 100644 index 0000000..df1ed73 --- /dev/null +++ b/internal/tools/web/search.go @@ -0,0 +1,115 @@ +package web + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + + "github.com/Nithwin/WindMist/internal/tools" +) + +type WebSearchTool struct{} + +func NewWebSearchTool() *WebSearchTool { + return &WebSearchTool{} +} + +func (t *WebSearchTool) Definition() tools.Definition { + return tools.Definition{ + Name: "web_search", + Description: "Searches the internet for a given query and returns a summary of the results with URLs. Useful for looking up documentation, error codes, and tutorials.", + Parameters: []tools.Parameter{ + { + Name: "query", + Type: "string", + Description: "The search query.", + Required: true, + }, + }, + } +} + +type SearchResult struct { + Title string `json:"title"` + Snippet string `json:"snippet"` + URL string `json:"url"` +} + +func (t *WebSearchTool) Run(ctx context.Context, call tools.Call) tools.Result { + query, ok := call.Args["query"].(string) + if !ok || query == "" { + return tools.Result{Error: fmt.Errorf("query is required")} + } + + searchURL := fmt.Sprintf("https://html.duckduckgo.com/html/?q=%s", url.QueryEscape(query)) + req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) + if err != nil { + return tools.Result{Error: err} + } + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return tools.Result{Error: err} + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return tools.Result{Error: err} + } + content := string(body) + + // Naive HTML parsing for DuckDuckGo results + var results []SearchResult + + // Extract results using regex to avoid external HTML parser dependencies + titleRe := regexp.MustCompile(`(?s)(.*?)`) + snippetRe := regexp.MustCompile(`(?s)]*>`) + return strings.TrimSpace(re.ReplaceAllString(str, "")) +} From 04d89d89ba07c57942796e64f24a7623d1b506ac Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 13:57:32 +0530 Subject: [PATCH 14/57] feat(tools): implement GitTool and PatchTool --- internal/tools/defaults/defaults.go | 2 + internal/tools/editing/patch_tool_test.go | 59 ++++++++++++++++++ internal/tools/editing/tool_patch.go | 71 ++++++++++++++++++++++ internal/tools/system/git.go | 73 +++++++++++++++++++++++ internal/tools/system/git_test.go | 27 +++++++++ 5 files changed, 232 insertions(+) create mode 100644 internal/tools/editing/patch_tool_test.go create mode 100644 internal/tools/editing/tool_patch.go create mode 100644 internal/tools/system/git.go create mode 100644 internal/tools/system/git_test.go diff --git a/internal/tools/defaults/defaults.go b/internal/tools/defaults/defaults.go index 2b49c52..b3910b0 100644 --- a/internal/tools/defaults/defaults.go +++ b/internal/tools/defaults/defaults.go @@ -35,9 +35,11 @@ func RegisterAll(m *tools.Manager, approvalCb system.ApprovalCallback) { m.Register(editing.NewReadContextTool()) m.Register(editing.NewInsertTextTool()) m.Register(editing.NewSearchTool()) + m.Register(editing.NewPatchTool()) // System tools m.Register(system.NewCommandTool(approvalCb)) + m.Register(system.NewGitTool(approvalCb)) // Web tools m.Register(web.NewWebSearchTool()) diff --git a/internal/tools/editing/patch_tool_test.go b/internal/tools/editing/patch_tool_test.go new file mode 100644 index 0000000..6bc42c5 --- /dev/null +++ b/internal/tools/editing/patch_tool_test.go @@ -0,0 +1,59 @@ +package editing + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Nithwin/WindMist/internal/tools" +) + +func TestPatchTool(t *testing.T) { + tempDir, err := os.MkdirTemp("", "windmist_patch_tool_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + + filePath := filepath.Join(tempDir, "test.txt") + err = os.WriteFile(filePath, []byte("line 1\nline 2\nline 3\n"), 0644) + if err != nil { + t.Fatal(err) + } + + // Change working directory for patch to work correctly + oldCwd, _ := os.Getwd() + os.Chdir(tempDir) + defer os.Chdir(oldCwd) + + patchStr := `--- test.txt ++++ test.txt +@@ -1,3 +1,3 @@ + line 1 +-line 2 ++line 2 edited + line 3 +` + + tool := NewPatchTool() + res := tool.Run(context.Background(), tools.Call{ + Args: map[string]interface{}{ + "diff": patchStr, + }, + }) + + if res.Error != nil { + t.Fatalf("unexpected error: %v", res.Error) + } + + content, err := os.ReadFile("test.txt") + if err != nil { + t.Fatal(err) + } + + if !strings.Contains(string(content), "line 2 edited") { + t.Fatalf("patch was not applied correctly, got content: %s", string(content)) + } +} diff --git a/internal/tools/editing/tool_patch.go b/internal/tools/editing/tool_patch.go new file mode 100644 index 0000000..ca53107 --- /dev/null +++ b/internal/tools/editing/tool_patch.go @@ -0,0 +1,71 @@ +package editing + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" + + "github.com/Nithwin/WindMist/internal/tools" +) + +type PatchTool struct{} + +func NewPatchTool() *PatchTool { + return &PatchTool{} +} + +func (t *PatchTool) Definition() tools.Definition { + return tools.Definition{ + Name: "patch", + Description: "Applies a unified diff patch to the workspace. Useful for making complex modifications to files efficiently.", + Parameters: []tools.Parameter{ + { + Name: "diff", + Type: "string", + Description: "The unified diff string to apply.", + Required: true, + }, + }, + } +} + +func (t *PatchTool) Run(ctx context.Context, call tools.Call) tools.Result { + diff, ok := call.Args["diff"].(string) + if !ok || diff == "" { + return tools.Result{Error: fmt.Errorf("diff is required")} + } + + // Create temp file for the patch + tmpFile, err := os.CreateTemp("", "windmist-patch-*.diff") + if err != nil { + return tools.Result{Error: fmt.Errorf("failed to create temp file: %w", err)} + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.WriteString(diff); err != nil { + return tools.Result{Error: fmt.Errorf("failed to write patch: %w", err)} + } + tmpFile.Close() + + // Try patch command first (standard on linux/mac) + cmd := exec.CommandContext(ctx, "patch", "-p1", "-i", tmpFile.Name()) + out, err := cmd.CombinedOutput() + if err == nil { + return tools.Result{Output: "Patch applied successfully:\n" + string(out)} + } + + // If patch fails, try git apply + gitCmd := exec.CommandContext(ctx, "git", "apply", tmpFile.Name()) + gitOut, gitErr := gitCmd.CombinedOutput() + if gitErr == nil { + return tools.Result{Output: "Patch applied successfully via git apply:\n" + string(gitOut)} + } + + return tools.Result{ + Error: fmt.Errorf("failed to apply patch.\npatch error: %v, out: %s\ngit apply error: %v, out: %s", + err, strings.TrimSpace(string(out)), + gitErr, strings.TrimSpace(string(gitOut))), + } +} diff --git a/internal/tools/system/git.go b/internal/tools/system/git.go new file mode 100644 index 0000000..9bf7bcf --- /dev/null +++ b/internal/tools/system/git.go @@ -0,0 +1,73 @@ +package system + +import ( + "context" + "fmt" + "os/exec" + "strings" + + "github.com/Nithwin/WindMist/internal/tools" +) + +type GitTool struct { + approvalCb ApprovalCallback +} + +func NewGitTool(approvalCb ApprovalCallback) *GitTool { + return &GitTool{approvalCb: approvalCb} +} + +func (t *GitTool) Definition() tools.Definition { + return tools.Definition{ + Name: "git", + Description: "Execute git operations. Safe read-only commands (status, log, diff, branch) auto-run. Write commands (commit, checkout, stash, push) require user approval.", + Parameters: []tools.Parameter{ + { + Name: "command", + Type: "string", + Description: "The git subcommand to run (e.g. status, diff, log -n 5, commit -m 'msg').", + Required: true, + }, + }, + } +} + +func (t *GitTool) Run(ctx context.Context, call tools.Call) tools.Result { + cmdStr, ok := call.Args["command"].(string) + if !ok || cmdStr == "" { + return tools.Result{Error: fmt.Errorf("command is required")} + } + + args := strings.Fields(cmdStr) + if len(args) == 0 { + return tools.Result{Error: fmt.Errorf("empty command")} + } + + subcommand := args[0] + isReadOnly := false + switch subcommand { + case "status", "diff", "log", "show", "branch", "rev-parse", "ls-files": + isReadOnly = true + } + + if !isReadOnly && t.approvalCb != nil { + approved := t.approvalCb("git " + cmdStr) + if !approved { + return tools.Result{Error: fmt.Errorf("user denied execution of git %s", cmdStr)} + } + } + + cmd := exec.CommandContext(ctx, "git", args...) + out, err := cmd.CombinedOutput() + + if err != nil { + return tools.Result{Error: fmt.Errorf("git %s failed: %v\nOutput: %s", cmdStr, err, string(out))} + } + + output := strings.TrimSpace(string(out)) + if output == "" { + output = "(Success: no output)" + } + + return tools.Result{Output: output} +} diff --git a/internal/tools/system/git_test.go b/internal/tools/system/git_test.go new file mode 100644 index 0000000..8ab9895 --- /dev/null +++ b/internal/tools/system/git_test.go @@ -0,0 +1,27 @@ +package system + +import ( + "context" + "testing" + + "github.com/Nithwin/WindMist/internal/tools" +) + +func TestGitTool(t *testing.T) { + tool := NewGitTool(func(cmd string) bool { return true }) // Auto approve for tests + + res := tool.Run(context.Background(), tools.Call{ + Args: map[string]interface{}{ + "command": "version", + }, + }) + + if res.Error != nil { + t.Fatalf("unexpected error: %v", res.Error) + } + + out := res.Output.(string) + if out == "" { + t.Fatal("expected non-empty output from git version") + } +} From a9012af946e2382c131042b777823ca76d8eabc8 Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 14:02:24 +0530 Subject: [PATCH 15/57] feat(tools): implement tool categories and permission levels --- internal/tools/agent/todo.go | 2 ++ internal/tools/editing/tool_context.go | 2 ++ internal/tools/editing/tool_delete.go | 2 ++ internal/tools/editing/tool_insert.go | 2 ++ internal/tools/editing/tool_patch.go | 2 ++ internal/tools/editing/tool_range.go | 2 ++ internal/tools/editing/tool_replace.go | 2 ++ internal/tools/editing/tool_search.go | 2 ++ internal/tools/filesystem/append.go | 2 ++ internal/tools/filesystem/create.go | 2 ++ internal/tools/filesystem/delete.go | 2 ++ internal/tools/filesystem/exists.go | 2 ++ internal/tools/filesystem/glob.go | 2 ++ internal/tools/filesystem/grep.go | 2 ++ internal/tools/filesystem/info.go | 2 ++ internal/tools/filesystem/list.go | 2 ++ internal/tools/filesystem/read.go | 2 ++ internal/tools/filesystem/rename.go | 2 ++ internal/tools/filesystem/write.go | 2 ++ internal/tools/manager.go | 20 +++++++++++ internal/tools/system/command.go | 2 ++ internal/tools/system/git.go | 2 ++ internal/tools/types.go | 35 ++++++++++++++++-- internal/tools/web/fetch.go | 2 ++ internal/tools/web/search.go | 2 ++ refactor_tools.py | 50 ++++++++++++++++++++++++++ 26 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 refactor_tools.py diff --git a/internal/tools/agent/todo.go b/internal/tools/agent/todo.go index 054e1b7..43348db 100644 --- a/internal/tools/agent/todo.go +++ b/internal/tools/agent/todo.go @@ -22,6 +22,8 @@ func (t *TodoTool) Definition() tools.Definition { return tools.Definition{ Name: "todo", Description: "Maintains an in-memory checklist to keep track of multi-step tasks. You can add, complete, remove, or list tasks.", + Category: tools.CategoryAgent, + Permission: tools.PermWrite, Parameters: []tools.Parameter{ { Name: "action", diff --git a/internal/tools/editing/tool_context.go b/internal/tools/editing/tool_context.go index 8c80979..03f4df0 100644 --- a/internal/tools/editing/tool_context.go +++ b/internal/tools/editing/tool_context.go @@ -19,6 +19,8 @@ func (t *ReadContextTool) Definition() tools.Definition { return tools.Definition{ Name: "read_context", Description: "Reads a specific range of lines from a file with 1-indexed line numbers formatted for editing context.", + Category: tools.CategoryEditing, + Permission: tools.PermWrite, Parameters: []tools.Parameter{ { Name: "path", diff --git a/internal/tools/editing/tool_delete.go b/internal/tools/editing/tool_delete.go index afc677b..24a18ab 100644 --- a/internal/tools/editing/tool_delete.go +++ b/internal/tools/editing/tool_delete.go @@ -18,6 +18,8 @@ func (t *DeleteRangeTool) Definition() tools.Definition { return tools.Definition{ Name: "delete_range", Description: "Deletes exact 1-indexed line ranges from a file.", + Category: tools.CategoryEditing, + Permission: tools.PermWrite, Parameters: []tools.Parameter{ { Name: "file", diff --git a/internal/tools/editing/tool_insert.go b/internal/tools/editing/tool_insert.go index 40aa7a7..9ecab5f 100644 --- a/internal/tools/editing/tool_insert.go +++ b/internal/tools/editing/tool_insert.go @@ -19,6 +19,8 @@ func (t *InsertTextTool) Definition() tools.Definition { return tools.Definition{ Name: "insert_text", Description: "Inserts text at a specific 1-indexed line number.", + Category: tools.CategoryEditing, + Permission: tools.PermWrite, Parameters: []tools.Parameter{ { Name: "file", diff --git a/internal/tools/editing/tool_patch.go b/internal/tools/editing/tool_patch.go index ca53107..0421aff 100644 --- a/internal/tools/editing/tool_patch.go +++ b/internal/tools/editing/tool_patch.go @@ -20,6 +20,8 @@ func (t *PatchTool) Definition() tools.Definition { return tools.Definition{ Name: "patch", Description: "Applies a unified diff patch to the workspace. Useful for making complex modifications to files efficiently.", + Category: tools.CategoryEditing, + Permission: tools.PermWrite, Parameters: []tools.Parameter{ { Name: "diff", diff --git a/internal/tools/editing/tool_range.go b/internal/tools/editing/tool_range.go index 58cf763..7e04699 100644 --- a/internal/tools/editing/tool_range.go +++ b/internal/tools/editing/tool_range.go @@ -18,6 +18,8 @@ func (t *ReplaceRangeTool) Definition() tools.Definition { return tools.Definition{ Name: "replace_range", Description: "Replace a contiguous range of lines (1-indexed, inclusive) in an existing file with new text. Use this when you know the exact line numbers from reading context. Preferred over replace_text when the target string appears multiple times in the file.", + Category: tools.CategoryEditing, + Permission: tools.PermWrite, Parameters: []tools.Parameter{ { Name: "file", diff --git a/internal/tools/editing/tool_replace.go b/internal/tools/editing/tool_replace.go index 09ad111..742e5b5 100644 --- a/internal/tools/editing/tool_replace.go +++ b/internal/tools/editing/tool_replace.go @@ -18,6 +18,8 @@ func (t *ReplaceTextTool) Definition() tools.Definition { return tools.Definition{ Name: "replace_text", Description: "Replace a unique piece of text in an existing file. Use this when the target text is known exactly. Prefer range-based editing when exact line numbers are available.", + Category: tools.CategoryEditing, + Permission: tools.PermWrite, Parameters: []tools.Parameter{ { Name: "file", diff --git a/internal/tools/editing/tool_search.go b/internal/tools/editing/tool_search.go index 39ebe13..e407ae5 100644 --- a/internal/tools/editing/tool_search.go +++ b/internal/tools/editing/tool_search.go @@ -18,6 +18,8 @@ func (t *SearchTool) Definition() tools.Definition { return tools.Definition{ Name: "search_text", Description: "Searches for text or regex patterns across files in a directory.", + Category: tools.CategorySearch, + Permission: tools.PermReadOnly, Parameters: []tools.Parameter{ { Name: "query", diff --git a/internal/tools/filesystem/append.go b/internal/tools/filesystem/append.go index e39b370..bc77800 100644 --- a/internal/tools/filesystem/append.go +++ b/internal/tools/filesystem/append.go @@ -18,6 +18,8 @@ func (t *AppendTool) Definition() tools.Definition { return tools.Definition{ Name: "append", Description: "Appends content to an existing file.", + Category: tools.CategoryFilesystem, + Permission: tools.PermWrite, Parameters: []tools.Parameter{ { Name: "path", diff --git a/internal/tools/filesystem/create.go b/internal/tools/filesystem/create.go index af1a103..af14657 100644 --- a/internal/tools/filesystem/create.go +++ b/internal/tools/filesystem/create.go @@ -19,6 +19,8 @@ func (t *CreateTool) Definition() tools.Definition { return tools.Definition{ Name: "create", Description: "Creates a new file or directory.", + Category: tools.CategoryFilesystem, + Permission: tools.PermWrite, Parameters: []tools.Parameter{ { Name: "path", diff --git a/internal/tools/filesystem/delete.go b/internal/tools/filesystem/delete.go index 71b407c..53c063d 100644 --- a/internal/tools/filesystem/delete.go +++ b/internal/tools/filesystem/delete.go @@ -18,6 +18,8 @@ func (t *DeleteTool) Definition() tools.Definition { return tools.Definition{ Name: "delete", Description: "Deletes a file or directory.", + Category: tools.CategoryFilesystem, + Permission: tools.PermWrite, Parameters: []tools.Parameter{ { Name: "path", diff --git a/internal/tools/filesystem/exists.go b/internal/tools/filesystem/exists.go index 40822e1..affa4a2 100644 --- a/internal/tools/filesystem/exists.go +++ b/internal/tools/filesystem/exists.go @@ -17,6 +17,8 @@ func (t *ExistsTool) Definition() tools.Definition { return tools.Definition{ Name: "exists", Description: "Checks if a file or directory exists.", + Category: tools.CategoryFilesystem, + Permission: tools.PermReadOnly, Parameters: []tools.Parameter{ { Name: "path", diff --git a/internal/tools/filesystem/glob.go b/internal/tools/filesystem/glob.go index 92b04ac..7aaba41 100644 --- a/internal/tools/filesystem/glob.go +++ b/internal/tools/filesystem/glob.go @@ -19,6 +19,8 @@ func (t *GlobTool) Definition() tools.Definition { return tools.Definition{ Name: "glob", Description: "Finds files by matching a pattern (e.g., *.go, **/*.js) across the workspace.", + Category: tools.CategorySearch, + Permission: tools.PermReadOnly, Parameters: []tools.Parameter{ { Name: "pattern", diff --git a/internal/tools/filesystem/grep.go b/internal/tools/filesystem/grep.go index 101d341..59a48a1 100644 --- a/internal/tools/filesystem/grep.go +++ b/internal/tools/filesystem/grep.go @@ -23,6 +23,8 @@ func (t *GrepTool) Definition() tools.Definition { return tools.Definition{ Name: "grep", Description: "Searches for a regex pattern inside files across the workspace. Returns file path, line number, and the matching line.", + Category: tools.CategorySearch, + Permission: tools.PermReadOnly, Parameters: []tools.Parameter{ { Name: "pattern", diff --git a/internal/tools/filesystem/info.go b/internal/tools/filesystem/info.go index 1caabcf..e2c39cd 100644 --- a/internal/tools/filesystem/info.go +++ b/internal/tools/filesystem/info.go @@ -17,6 +17,8 @@ func (t *InfoTool) Definition() tools.Definition { return tools.Definition{ Name: "info", Description: "Retrieves metadata and information about a file or directory.", + Category: tools.CategoryFilesystem, + Permission: tools.PermReadOnly, Parameters: []tools.Parameter{ { Name: "path", diff --git a/internal/tools/filesystem/list.go b/internal/tools/filesystem/list.go index aadf424..b79649c 100644 --- a/internal/tools/filesystem/list.go +++ b/internal/tools/filesystem/list.go @@ -19,6 +19,8 @@ func (t *ListTool) Definition() tools.Definition { return tools.Definition{ Name: "list", Description: "Lists files and directories inside a specified directory.", + Category: tools.CategoryFilesystem, + Permission: tools.PermReadOnly, Parameters: []tools.Parameter{ { Name: "path", diff --git a/internal/tools/filesystem/read.go b/internal/tools/filesystem/read.go index 9eaf8a8..e9212ea 100644 --- a/internal/tools/filesystem/read.go +++ b/internal/tools/filesystem/read.go @@ -20,6 +20,8 @@ func (t *ReadTool) Definition() tools.Definition { return tools.Definition{ Name: "read", Description: "Reads the entire contents of a file from disk. Use this when you need to inspect or verify a small file or an entire file from start to finish. For large files when you only need a specific section around a line number, prefer read_context.", + Category: tools.CategoryFilesystem, + Permission: tools.PermReadOnly, Parameters: []tools.Parameter{ { Name: "path", diff --git a/internal/tools/filesystem/rename.go b/internal/tools/filesystem/rename.go index 75b48d8..e5fd6bd 100644 --- a/internal/tools/filesystem/rename.go +++ b/internal/tools/filesystem/rename.go @@ -18,6 +18,8 @@ func (t *RenameTool) Definition() tools.Definition { return tools.Definition{ Name: "rename", Description: "Renames or moves a file or directory.", + Category: tools.CategoryFilesystem, + Permission: tools.PermWrite, Parameters: []tools.Parameter{ { Name: "old_path", diff --git a/internal/tools/filesystem/write.go b/internal/tools/filesystem/write.go index b31e9fb..72c5179 100644 --- a/internal/tools/filesystem/write.go +++ b/internal/tools/filesystem/write.go @@ -18,6 +18,8 @@ func (t *WriteTool) Definition() tools.Definition { return tools.Definition{ Name: "write", Description: "Overwrites the entire contents of an existing file with new content. WARNING: This replaces all existing code in the file. Prefer using replace_text or replace_range when making targeted edits or modifying existing code.", + Category: tools.CategoryFilesystem, + Permission: tools.PermWrite, Parameters: []tools.Parameter{ { Name: "path", diff --git a/internal/tools/manager.go b/internal/tools/manager.go index a878a28..b91dcf2 100644 --- a/internal/tools/manager.go +++ b/internal/tools/manager.go @@ -29,3 +29,23 @@ func (m *Manager) List() []Tool { return list } + +func (m *Manager) ListByCategory(categories ...Category) []Tool { + if len(categories) == 0 { + return m.List() + } + + catMap := make(map[Category]bool) + for _, c := range categories { + catMap[c] = true + } + + list := make([]Tool, 0) + for _, tool := range m.tools { + if catMap[tool.Definition().Category] { + list = append(list, tool) + } + } + + return list +} diff --git a/internal/tools/system/command.go b/internal/tools/system/command.go index f630fb2..b4b9dbd 100644 --- a/internal/tools/system/command.go +++ b/internal/tools/system/command.go @@ -27,6 +27,8 @@ func (t *CommandTool) Definition() tools.Definition { return tools.Definition{ Name: "run_command", Description: "Execute a bash command in the terminal. Use this to run tests, compile code, execute git commands, or check system state.", + Category: tools.CategorySystem, + Permission: tools.PermDangerous, Parameters: []tools.Parameter{ { Name: "command", diff --git a/internal/tools/system/git.go b/internal/tools/system/git.go index 9bf7bcf..b3ef06c 100644 --- a/internal/tools/system/git.go +++ b/internal/tools/system/git.go @@ -21,6 +21,8 @@ func (t *GitTool) Definition() tools.Definition { return tools.Definition{ Name: "git", Description: "Execute git operations. Safe read-only commands (status, log, diff, branch) auto-run. Write commands (commit, checkout, stash, push) require user approval.", + Category: tools.CategoryGit, + Permission: tools.PermDangerous, Parameters: []tools.Parameter{ { Name: "command", diff --git a/internal/tools/types.go b/internal/tools/types.go index dd3684c..7c041a9 100644 --- a/internal/tools/types.go +++ b/internal/tools/types.go @@ -1,6 +1,29 @@ package tools -import "context" +import ( + "context" + "time" +) + +type Category string + +const ( + CategoryFilesystem Category = "filesystem" + CategoryEditing Category = "editing" + CategorySystem Category = "system" + CategorySearch Category = "search" + CategoryGit Category = "git" + CategoryWeb Category = "web" + CategoryAgent Category = "agent" +) + +type PermissionLevel int + +const ( + PermReadOnly PermissionLevel = iota // Auto-approved + PermWrite // Needs approval first time + PermDangerous // Always needs approval +) type Parameter struct { Name string @@ -13,6 +36,8 @@ type Parameter struct { type Definition struct { Name string Description string + Category Category + Permission PermissionLevel Parameters []Parameter } @@ -22,8 +47,12 @@ type Call struct { } type Result struct { - Output any - Error error + Output any + Error error + Duration time.Duration // How long the tool took + FilesRead []string // Files accessed + FilesChanged []string // Files modified + BytesChanged int64 // Total bytes changed } type Tool interface { diff --git a/internal/tools/web/fetch.go b/internal/tools/web/fetch.go index 67cd8e6..b8f49e2 100644 --- a/internal/tools/web/fetch.go +++ b/internal/tools/web/fetch.go @@ -21,6 +21,8 @@ func (t *FetchTool) Definition() tools.Definition { return tools.Definition{ Name: "fetch", Description: "Fetches the text content of a given URL. Useful for reading documentation pages or articles.", + Category: tools.CategoryWeb, + Permission: tools.PermReadOnly, Parameters: []tools.Parameter{ { Name: "url", diff --git a/internal/tools/web/search.go b/internal/tools/web/search.go index df1ed73..7895b00 100644 --- a/internal/tools/web/search.go +++ b/internal/tools/web/search.go @@ -22,6 +22,8 @@ func (t *WebSearchTool) Definition() tools.Definition { return tools.Definition{ Name: "web_search", Description: "Searches the internet for a given query and returns a summary of the results with URLs. Useful for looking up documentation, error codes, and tutorials.", + Category: tools.CategoryWeb, + Permission: tools.PermReadOnly, Parameters: []tools.Parameter{ { Name: "query", diff --git a/refactor_tools.py b/refactor_tools.py new file mode 100644 index 0000000..646c9d3 --- /dev/null +++ b/refactor_tools.py @@ -0,0 +1,50 @@ +import os +import glob +import re + +for file_path in glob.glob('/home/shadow/Desktop/windmist/internal/tools/**/*.go', recursive=True): + with open(file_path, 'r') as f: + content = f.read() + + if 'func (' not in content or 'Definition() tools.Definition' not in content: + continue + + category = "tools.CategoryFilesystem" + perm = "tools.PermReadOnly" + + if "/editing/" in file_path: + category = "tools.CategoryEditing" + if "search" in file_path: + category = "tools.CategorySearch" + perm = "tools.PermReadOnly" + else: + perm = "tools.PermWrite" + elif "/filesystem/" in file_path: + if "glob" in file_path or "grep" in file_path: + category = "tools.CategorySearch" + elif "delete" in file_path or "write" in file_path or "append" in file_path or "create" in file_path or "rename" in file_path: + perm = "tools.PermWrite" + elif "/system/" in file_path: + if "git" in file_path: + category = "tools.CategoryGit" + perm = "tools.PermDangerous" + else: + category = "tools.CategorySystem" + perm = "tools.PermDangerous" + elif "/web/" in file_path: + category = "tools.CategoryWeb" + elif "/agent/" in file_path: + category = "tools.CategoryAgent" + perm = "tools.PermWrite" + + # Match `tools.Definition{\n\t\tName: "...",\n\t\tDescription: "...",` + + def repl(m): + return f"{m.group(0)}\n\t\tCategory: {category},\n\t\tPermission: {perm}," + + new_content = re.sub(r'(tools\.Definition\{\s*Name:\s*".*?",\s*Description:\s*".*?",)', repl, content, count=1) + + if new_content != content: + with open(file_path, 'w') as f: + f.write(new_content) + print(f"Updated {file_path}") From f744ae2de9ad6389a9b4238e927f84f961fe8c1f Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 14:13:21 +0530 Subject: [PATCH 16/57] feat(persistence): implement sqlite session persistence --- cmd/chat.go | 2 +- go.mod | 2 + go.sum | 11 ++++ internal/agent/agent.go | 38 +++++++++++- internal/agent/loop.go | 3 + internal/chat/chat.go | 43 +++++++++++++- internal/chat/model.go | 30 +++++++++- internal/store/db.go | 109 ++++++++++++++++++++++++++++++++++ internal/store/db_test.go | 90 ++++++++++++++++++++++++++++ internal/store/models.go | 40 +++++++++++++ internal/store/queries.go | 120 ++++++++++++++++++++++++++++++++++++++ 11 files changed, 482 insertions(+), 6 deletions(-) create mode 100644 internal/store/db.go create mode 100644 internal/store/db_test.go create mode 100644 internal/store/models.go create mode 100644 internal/store/queries.go diff --git a/cmd/chat.go b/cmd/chat.go index 28134ee..e7454f9 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -56,7 +56,7 @@ var chatCmd = &cobra.Command{ ag := agent.New(provider, manager, agent.Config{}) - res, err := ag.Run(context.Background(), args[0], func(s string) { + res, err := ag.Run(context.Background(), nil, args[0], func(s string) { fmt.Print(s) }) if err != nil { diff --git a/go.mod b/go.mod index 6f210cd..66da1e8 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,8 @@ require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/glamour v1.0.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 + github.com/jmoiron/sqlx v1.4.0 + github.com/mattn/go-sqlite3 v1.14.48 github.com/spf13/cobra v1.10.2 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 9dedb35..435b1c7 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= @@ -47,12 +49,18 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -62,6 +70,9 @@ github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+Ei github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs= +github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 94713b9..83d8194 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -2,8 +2,10 @@ package agent import ( "context" + "encoding/json" "github.com/Nithwin/WindMist/internal/ai" + "github.com/Nithwin/WindMist/internal/store" "github.com/Nithwin/WindMist/internal/tools" ) @@ -15,6 +17,10 @@ type Config struct { // MaxContextTokens is the maximum number of tokens retained in the // sliding window context memory. MaxContextTokens int + // Store is the optional database connection for session persistence. + Store *store.Store + // SessionID is the unique identifier for the current session, if persistence is enabled. + SessionID string } // Result contains the final output produced by the agent. @@ -57,7 +63,33 @@ func New( } // Run executes a single user request. -func (a *Agent) Run(ctx context.Context, userPrompt string, onChunk func(string)) (*Result, error) { - messages := make([]ai.Message, 0, 8) - return a.runLoop(ctx, messages, userPrompt, onChunk) +func (a *Agent) Run(ctx context.Context, initialMessages []ai.Message, userPrompt string, onChunk func(string)) (*Result, error) { + if initialMessages == nil { + initialMessages = make([]ai.Message, 0, 8) + } + return a.runLoop(ctx, initialMessages, userPrompt, onChunk) +} + +func (a *Agent) saveMessage(msg ai.Message) { + if a.config.Store == nil || a.config.SessionID == "" { + return + } + + sMsg := &store.Message{ + SessionID: a.config.SessionID, + Role: string(msg.Role), + Content: msg.Content, + } + + if len(msg.ToolCalls) > 0 { + b, _ := json.Marshal(msg.ToolCalls) + sMsg.ToolCalls = string(b) + } + + if len(msg.ToolResults) > 0 { + b, _ := json.Marshal(msg.ToolResults) + sMsg.ToolResults = string(b) + } + + _ = a.config.Store.SaveMessage(sMsg) } diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 0bbb07c..5d378fa 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -13,6 +13,7 @@ import ( func (a *Agent) runLoop(ctx context.Context, messages []ai.Message, userPrompt string, onChunk func(string)) (*Result, error) { if len(messages) == 0 { messages = appendUser(messages, userPrompt) + a.saveMessage(messages[len(messages)-1]) } var totalUsage ai.Usage @@ -72,6 +73,7 @@ func (a *Agent) runLoop(ctx context.Context, messages []ai.Message, userPrompt s totalUsage.TotalTokens += resp.Usage.TotalTokens messages = appendAssistant(messages, resp.Text, resp.ToolCalls) + a.saveMessage(messages[len(messages)-1]) if len(resp.ToolCalls) == 0 { return &Result{ @@ -83,6 +85,7 @@ func (a *Agent) runLoop(ctx context.Context, messages []ai.Message, userPrompt s results := a.execute(ctx, resp.ToolCalls, onChunk) messages = appendToolResults(messages, results) + a.saveMessage(messages[len(messages)-1]) } return nil, ErrMaxTurnsExceeded diff --git a/internal/chat/chat.go b/internal/chat/chat.go index 4434695..6606d18 100644 --- a/internal/chat/chat.go +++ b/internal/chat/chat.go @@ -2,13 +2,17 @@ package chat import ( "context" + "encoding/json" "fmt" + + "github.com/Nithwin/WindMist/internal/ai" ) // sendMessage starts running the agent request. func (m Model) sendMessage(ctx context.Context, prompt string) { go func() { - res, err := m.agent.Run(ctx, prompt, func(s string) { + initialMessages := m.getInitialMessages() + res, err := m.agent.Run(ctx, initialMessages, prompt, func(s string) { program.Send(StreamingMsg{ Text: s, }) @@ -29,3 +33,40 @@ func (m Model) sendMessage(ctx context.Context, prompt string) { }) }() } + +func (m Model) getInitialMessages() []ai.Message { + if m.store == nil || m.session == nil { + return nil + } + + storeMsgs, err := m.store.GetMessagesBySession(m.session.ID) + if err != nil || len(storeMsgs) == 0 { + return nil + } + + var msgs []ai.Message + for _, sm := range storeMsgs { + msg := ai.Message{ + Role: ai.Role(sm.Role), + Content: sm.Content, + } + + if sm.ToolCalls != "" { + var calls []ai.ToolCall + if err := json.Unmarshal([]byte(sm.ToolCalls), &calls); err == nil { + msg.ToolCalls = calls + } + } + + if sm.ToolResults != "" { + var res []ai.ToolResult + if err := json.Unmarshal([]byte(sm.ToolResults), &res); err == nil { + msg.ToolResults = res + } + } + + msgs = append(msgs, msg) + } + + return msgs +} diff --git a/internal/chat/model.go b/internal/chat/model.go index 77bd8a6..488046b 100644 --- a/internal/chat/model.go +++ b/internal/chat/model.go @@ -3,10 +3,12 @@ package chat import ( "context" "fmt" + "time" "github.com/Nithwin/WindMist/internal/agent" "github.com/Nithwin/WindMist/internal/ai" "github.com/Nithwin/WindMist/internal/config" + "github.com/Nithwin/WindMist/internal/store" "github.com/Nithwin/WindMist/internal/tools" "github.com/Nithwin/WindMist/internal/tools/defaults" "github.com/Nithwin/WindMist/internal/ui" @@ -22,6 +24,8 @@ type Model struct { provider ai.Provider agent *agent.Agent + store *store.Store + session *store.Session conversation Conversation @@ -76,7 +80,29 @@ func New() (Model, error) { }) return <-ch }) - ag := agent.New(provider, manager, agent.Config{}) + dbStore, err := store.NewStore() + if err != nil { + return Model{}, fmt.Errorf("failed to initialize db store: %w", err) + } + + // For now, create a new session on startup + // Later we can implement logic to load an existing session + // using the /session commands + activeModel, _ := cfg.ActiveModel() + sess := &store.Session{ + ID: fmt.Sprintf("sess_%d", time.Now().Unix()), + Title: "New Session", + ProjectPath: ".", + Provider: cfg.AI.Provider, + Model: activeModel, + AgentMode: "build", + } + _ = dbStore.CreateSession(sess) + + ag := agent.New(provider, manager, agent.Config{ + Store: dbStore, + SessionID: sess.ID, + }) renderer, err := ui.NewMarkdownRenderer() if err != nil { @@ -108,6 +134,8 @@ func New() (Model, error) { cfg: cfg, provider: provider, agent: ag, + store: dbStore, + session: sess, conversation: Conversation{}, input: ta, inputHistory: make([]string, 0), diff --git a/internal/store/db.go b/internal/store/db.go new file mode 100644 index 0000000..5263981 --- /dev/null +++ b/internal/store/db.go @@ -0,0 +1,109 @@ +package store + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" +) + +var schema = ` +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + title TEXT, + project_path TEXT, + provider TEXT, + model TEXT, + agent_mode TEXT, + token_count INTEGER DEFAULT 0, + cost_estimate REAL DEFAULT 0.0, + created_at DATETIME, + updated_at DATETIME +); + +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT REFERENCES sessions(id) ON DELETE CASCADE, + role TEXT, + content TEXT, + tool_calls TEXT, + tool_results TEXT, + token_count INTEGER DEFAULT 0, + created_at DATETIME +); + +CREATE TABLE IF NOT EXISTS file_changes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT REFERENCES sessions(id) ON DELETE CASCADE, + message_id INTEGER REFERENCES messages(id) ON DELETE CASCADE, + file_path TEXT, + change_type TEXT, + before_content TEXT, + after_content TEXT, + created_at DATETIME +); + +-- Indexes for faster lookups +CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id); +CREATE INDEX IF NOT EXISTS idx_file_changes_session_id ON file_changes(session_id); +CREATE INDEX IF NOT EXISTS idx_file_changes_message_id ON file_changes(message_id); +` + +type Store struct { + db *sqlx.DB +} + +// NewStore initializes the SQLite database at ~/.windmist/sessions.db +func NewStore() (*Store, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("failed to get home dir: %w", err) + } + + windmistDir := filepath.Join(home, ".windmist") + if err := os.MkdirAll(windmistDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create config dir: %w", err) + } + + dbPath := filepath.Join(windmistDir, "sessions.db") + + // Enable foreign keys + db, err := sqlx.Connect("sqlite3", dbPath+"?_fk=1") + if err != nil { + return nil, fmt.Errorf("failed to connect to db: %w", err) + } + + // Apply schema + _, err = db.Exec(schema) + if err != nil { + return nil, fmt.Errorf("failed to apply schema: %w", err) + } + + return &Store{db: db}, nil +} + +func (s *Store) Close() error { + if s.db != nil { + return s.db.Close() + } + return nil +} + +// NewStoreForTest creates a new Store with a specific path for testing +func NewStoreForTest(dbPath string) (*Store, error) { + // Enable foreign keys + db, err := sqlx.Connect("sqlite3", dbPath+"?_fk=1") + if err != nil { + return nil, fmt.Errorf("failed to connect to db: %w", err) + } + + // Apply schema + _, err = db.Exec(schema) + if err != nil { + return nil, fmt.Errorf("failed to apply schema: %w", err) + } + + return &Store{db: db}, nil +} diff --git a/internal/store/db_test.go b/internal/store/db_test.go new file mode 100644 index 0000000..eefa110 --- /dev/null +++ b/internal/store/db_test.go @@ -0,0 +1,90 @@ +package store + +import ( + "os" + "path/filepath" + "testing" +) + +func TestStoreIntegration(t *testing.T) { + // Temporarily mock user home dir to a temp directory + tempHome, err := os.MkdirTemp("", "windmist_home_*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempHome) + + // Since NewStore relies on os.UserHomeDir(), we just mock the db path directly for testing + windmistDir := filepath.Join(tempHome, ".windmist") + os.MkdirAll(windmistDir, 0755) + + dbPath := filepath.Join(windmistDir, "sessions.db") + + store, err := NewStoreForTest(dbPath) + if err != nil { + t.Fatalf("failed to create store: %v", err) + } + defer store.Close() + + // Test CreateSession + session := &Session{ + ID: "sess_123", + Title: "Test Session", + ProjectPath: "/home/user/project", + Provider: "openai", + Model: "gpt-4", + AgentMode: "build", + } + + err = store.CreateSession(session) + if err != nil { + t.Fatalf("failed to create session: %v", err) + } + + // Test GetSession + retrieved, err := store.GetSession("sess_123") + if err != nil { + t.Fatalf("failed to get session: %v", err) + } + if retrieved.Title != "Test Session" { + t.Fatalf("expected title 'Test Session', got %s", retrieved.Title) + } + + // Test SaveMessage + msg := &Message{ + SessionID: "sess_123", + Role: "user", + Content: "Hello world", + } + err = store.SaveMessage(msg) + if err != nil { + t.Fatalf("failed to save message: %v", err) + } + if msg.ID == 0 { + t.Fatal("expected message ID to be set") + } + + // Test GetMessages + messages, err := store.GetMessagesBySession("sess_123") + if err != nil { + t.Fatalf("failed to get messages: %v", err) + } + if len(messages) != 1 { + t.Fatalf("expected 1 message, got %d", len(messages)) + } + if messages[0].Content != "Hello world" { + t.Fatalf("expected message content 'Hello world', got %s", messages[0].Content) + } + + // Test DeleteSession (should cascade and delete messages too, assuming SQLite foreign keys are enabled) + err = store.DeleteSession("sess_123") + if err != nil { + t.Fatalf("failed to delete session: %v", err) + } + + // Verify messages are deleted + messages, _ = store.GetMessagesBySession("sess_123") + if len(messages) != 0 { + t.Fatalf("expected 0 messages after cascade delete, got %d", len(messages)) + } +} diff --git a/internal/store/models.go b/internal/store/models.go new file mode 100644 index 0000000..3031046 --- /dev/null +++ b/internal/store/models.go @@ -0,0 +1,40 @@ +package store + +import ( + "time" +) + +type Session struct { + ID string `db:"id"` + Title string `db:"title"` + ProjectPath string `db:"project_path"` + Provider string `db:"provider"` + Model string `db:"model"` + AgentMode string `db:"agent_mode"` + TokenCount int `db:"token_count"` + CostEstimate float64 `db:"cost_estimate"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +type Message struct { + ID int `db:"id"` + SessionID string `db:"session_id"` + Role string `db:"role"` // user, assistant, tool, system + Content string `db:"content"` + ToolCalls string `db:"tool_calls"` // JSON encoded + ToolResults string `db:"tool_results"` // JSON encoded + TokenCount int `db:"token_count"` + CreatedAt time.Time `db:"created_at"` +} + +type FileChange struct { + ID int `db:"id"` + SessionID string `db:"session_id"` + MessageID int `db:"message_id"` + FilePath string `db:"file_path"` + ChangeType string `db:"change_type"` // create, edit, delete + BeforeContent string `db:"before_content"` + AfterContent string `db:"after_content"` + CreatedAt time.Time `db:"created_at"` +} diff --git a/internal/store/queries.go b/internal/store/queries.go new file mode 100644 index 0000000..e700b65 --- /dev/null +++ b/internal/store/queries.go @@ -0,0 +1,120 @@ +package store + +import ( + "fmt" + "time" +) + +// CreateSession creates a new session in the database +func (s *Store) CreateSession(session *Session) error { + session.CreatedAt = time.Now() + session.UpdatedAt = session.CreatedAt + + query := ` + INSERT INTO sessions (id, title, project_path, provider, model, agent_mode, token_count, cost_estimate, created_at, updated_at) + VALUES (:id, :title, :project_path, :provider, :model, :agent_mode, :token_count, :cost_estimate, :created_at, :updated_at) + ` + _, err := s.db.NamedExec(query, session) + return err +} + +// GetSession retrieves a session by ID +func (s *Store) GetSession(id string) (*Session, error) { + var session Session + err := s.db.Get(&session, "SELECT * FROM sessions WHERE id = ?", id) + if err != nil { + return nil, err + } + return &session, nil +} + +// ListSessionsByProject gets all sessions for a specific project +func (s *Store) ListSessionsByProject(projectPath string) ([]Session, error) { + var sessions []Session + err := s.db.Select(&sessions, "SELECT * FROM sessions WHERE project_path = ? ORDER BY updated_at DESC", projectPath) + return sessions, err +} + +// UpdateSession updates the metadata of a session +func (s *Store) UpdateSession(session *Session) error { + session.UpdatedAt = time.Now() + query := ` + UPDATE sessions + SET title = :title, provider = :provider, model = :model, agent_mode = :agent_mode, token_count = :token_count, cost_estimate = :cost_estimate, updated_at = :updated_at + WHERE id = :id + ` + _, err := s.db.NamedExec(query, session) + return err +} + +// SaveMessage stores a new message and returns its ID +func (s *Store) SaveMessage(msg *Message) error { + msg.CreatedAt = time.Now() + + query := ` + INSERT INTO messages (session_id, role, content, tool_calls, tool_results, token_count, created_at) + VALUES (:session_id, :role, :content, :tool_calls, :tool_results, :token_count, :created_at) + ` + res, err := s.db.NamedExec(query, msg) + if err != nil { + return err + } + + id, err := res.LastInsertId() + if err == nil { + msg.ID = int(id) + } + + // Update the session's updated_at timestamp + _, _ = s.db.Exec("UPDATE sessions SET updated_at = ? WHERE id = ?", msg.CreatedAt, msg.SessionID) + + return nil +} + +// GetMessagesBySession gets all messages for a session, ordered by creation time +func (s *Store) GetMessagesBySession(sessionID string) ([]Message, error) { + var messages []Message + err := s.db.Select(&messages, "SELECT * FROM messages WHERE session_id = ? ORDER BY id ASC", sessionID) + return messages, err +} + +// SaveFileChange logs a file change for undo/redo +func (s *Store) SaveFileChange(change *FileChange) error { + change.CreatedAt = time.Now() + + query := ` + INSERT INTO file_changes (session_id, message_id, file_path, change_type, before_content, after_content, created_at) + VALUES (:session_id, :message_id, :file_path, :change_type, :before_content, :after_content, :created_at) + ` + res, err := s.db.NamedExec(query, change) + if err != nil { + return err + } + + id, err := res.LastInsertId() + if err == nil { + change.ID = int(id) + } + + return nil +} + +// GetFileChangesBySession retrieves all file changes in a session +func (s *Store) GetFileChangesBySession(sessionID string) ([]FileChange, error) { + var changes []FileChange + err := s.db.Select(&changes, "SELECT * FROM file_changes WHERE session_id = ? ORDER BY id ASC", sessionID) + return changes, err +} + +// DeleteSession completely deletes a session and all cascading data +func (s *Store) DeleteSession(id string) error { + res, err := s.db.Exec("DELETE FROM sessions WHERE id = ?", id) + if err != nil { + return err + } + rows, _ := res.RowsAffected() + if rows == 0 { + return fmt.Errorf("session not found") + } + return nil +} From dfe53913984ff4434f9d745bcc72e3d1ce8452ba Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 14:16:10 +0530 Subject: [PATCH 17/57] docs: add Discord community link --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a6d0922..941a94f 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,13 @@ [![Version: v1.0.0](https://img.shields.io/badge/Version-v1.0.0-8B5CF6?style=for-the-badge)](CHANGELOG.md) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge)](LICENSE) [![Go Version](https://img.shields.io/badge/Go-1.25+-00ADD8?style=for-the-badge&logo=go)](https://golang.org) -[![Python Version](https://img.shields.io/badge/Python-3.13+-3776AB?style=for-the-badge&logo=python&logoColor=white)](https://python.org) +[![Discord](https://img.shields.io/badge/Discord-Join-7289DA?style=for-the-badge&logo=discord)](https://discord.gg/9hNxQdHYX) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-10B981?style=for-the-badge)](CONTRIBUTING.md) **A modern open-source AI coding assistant running right inside your terminal.** WindMist (`v1.0.0`) is built in high-performance Go to inspect code, edit files atomically across your workspace, and engage in multi-turn reasoning loops with local tools. -> **🌐 Official Website:** [windmist.vercel.app](https://windmist.vercel.app/)  |  **💻 Website Repo:** [`windmist-site`](https://github.com/Nithwin/windmist-site) +> **🌐 Official Website:** [windmist.vercel.app](https://windmist.vercel.app/)  |  **💻 Website Repo:** [`windmist-site`](https://github.com/Nithwin/windmist-site)  |  **💬 Community:** [Discord](https://discord.gg/9hNxQdHYX) [Demo](#-demo) • [Installation](#-installation) • [Quick Start](#-quick-start) • [Features](#-features--capabilities) • [Commands](#-core-commands) • [Architecture](docs/architecture.md) • [Contributing](CONTRIBUTING.md) From 1a59b3ea09428b365304a08fe50a0c70ee266cc3 Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 14:24:42 +0530 Subject: [PATCH 18/57] feat(ui): implement interactive session loader and searchable menus --- cmd/set.go | 4 +- go.mod | 1 + go.sum | 4 + internal/agent/agent.go | 5 ++ internal/chat/commands.go | 54 +++++++++++++ internal/chat/messages.go | 5 ++ internal/chat/update.go | 41 ++++++++++ internal/config/options.go | 30 +++---- internal/ui/selector/selector.go | 129 +++++++++++-------------------- 9 files changed, 171 insertions(+), 102 deletions(-) diff --git a/cmd/set.go b/cmd/set.go index 133a67c..45402d6 100644 --- a/cmd/set.go +++ b/cmd/set.go @@ -118,8 +118,8 @@ var setCmd = &cobra.Command{ case "theme": if value == "" { opt, err := selector.Run("Select UI Theme", "Choose visual theme:", []selector.Option{ - {Label: "dark", Description: "Dark theme with purple & cyan accents", Value: "dark"}, - {Label: "light", Description: "Light theme", Value: "light"}, + {Label: "dark", Desc: "Dark theme with purple & cyan accents", Value: "dark"}, + {Label: "light", Desc: "Light theme", Value: "light"}, }) if err != nil { log.Fatal(err) diff --git a/go.mod b/go.mod index 66da1e8..b372ee1 100644 --- a/go.mod +++ b/go.mod @@ -41,6 +41,7 @@ require ( github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.7.13 // indirect diff --git a/go.sum b/go.sum index 435b1c7..5a4663f 100644 --- a/go.sum +++ b/go.sum @@ -59,6 +59,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= @@ -88,6 +90,8 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 83d8194..6c1863a 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -62,6 +62,11 @@ func New( } } +// Manager returns the tools manager associated with the agent. +func (a *Agent) Manager() *tools.Manager { + return a.manager +} + // Run executes a single user request. func (a *Agent) Run(ctx context.Context, initialMessages []ai.Message, userPrompt string, onChunk func(string)) (*Result, error) { if initialMessages == nil { diff --git a/internal/chat/commands.go b/internal/chat/commands.go index fcbc102..5682236 100644 --- a/internal/chat/commands.go +++ b/internal/chat/commands.go @@ -2,6 +2,7 @@ package chat import ( "fmt" + "os" "strings" "github.com/Nithwin/WindMist/internal/config" @@ -26,6 +27,7 @@ var Registry = []Command{ /help Show available commands /new Start a new conversation +/sessions Load a previous session /model Change model /provider Change provider /clear Clear conversation @@ -43,6 +45,13 @@ var Registry = []Command{ return nil }, }, + { + Name: "/sessions", + Description: "Load a previous session", + Execute: func(m *Model) tea.Cmd { + return selectSessionCmd(m) + }, + }, { Name: "/clear", Description: "Clear conversation", @@ -82,6 +91,51 @@ var Registry = []Command{ }, } +func selectSessionCmd(m *Model) tea.Cmd { + return func() tea.Msg { + if program == nil { + return switchErrorMsg{Err: fmt.Errorf("program instance not initialized")} + } + if m.store == nil { + return switchErrorMsg{Err: fmt.Errorf("database not initialized")} + } + + cwd, _ := os.Getwd() + sessions, err := m.store.ListSessionsByProject(cwd) + if err != nil { + return switchErrorMsg{Err: fmt.Errorf("failed to fetch sessions: %w", err)} + } + + if len(sessions) == 0 { + return switchErrorMsg{Err: fmt.Errorf("no past sessions found in this project")} + } + + if err := program.ReleaseTerminal(); err != nil { + return switchErrorMsg{Err: fmt.Errorf("failed to release terminal: %w", err)} + } + defer program.RestoreTerminal() + + var options []selector.Option + for _, s := range sessions { + desc := fmt.Sprintf("%s | Tokens: %d | Cost: $%.3f", s.UpdatedAt.Format("Jan 02 15:04"), s.TokenCount, s.CostEstimate) + options = append(options, selector.Option{ + Label: s.Title, + Desc: desc, + Value: s.ID, + }) + } + + opt, err := selector.Run("Select Session", "Choose a previous session to resume:", options) + if err != nil { + return switchCancelMsg{} + } + + return switchSessionSuccessMsg{ + SessionID: opt.Value, + } + } +} + func selectProviderCmd(m *Model) tea.Cmd { return func() tea.Msg { if program == nil { diff --git a/internal/chat/messages.go b/internal/chat/messages.go index 613e89b..5fa5c27 100644 --- a/internal/chat/messages.go +++ b/internal/chat/messages.go @@ -22,6 +22,11 @@ type switchProviderSuccessMsg struct { Model string } +// switchSessionSuccessMsg represents a successful session change. +type switchSessionSuccessMsg struct { + SessionID string +} + // switchModelSuccessMsg represents a successful model change. type switchModelSuccessMsg struct { Model string diff --git a/internal/chat/update.go b/internal/chat/update.go index b9e4f66..bf7e771 100644 --- a/internal/chat/update.go +++ b/internal/chat/update.go @@ -263,6 +263,47 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil + case switchSessionSuccessMsg: + sess, err := m.store.GetSession(msg.SessionID) + if err != nil { + m.conversation.AddAssistant(fmt.Sprintf("❌ Error loading session: %v", err)) + m.refreshViewport() + return m, nil + } + + m.session = sess + m.agent = agent.New(m.provider, m.agent.Manager(), agent.Config{ + Store: m.store, + SessionID: sess.ID, + }) + + m.conversation.Clear() + initialMessages := m.getInitialMessages() + for _, msg := range initialMessages { + if msg.Role == ai.RoleUser { + m.conversation.AddUser(msg.Content) + } else if msg.Role == ai.RoleAssistant { + content := msg.Content + if len(msg.ToolCalls) > 0 { + for _, tc := range msg.ToolCalls { + content += fmt.Sprintf("\n*(Tool Call: %s)*", tc.Name) + } + } + m.conversation.AddAssistant(content) + } else if msg.Role == ai.RoleTool { + content := "" + for _, tr := range msg.ToolResults { + content += fmt.Sprintf("\n*(Tool Result: %s)*", tr.Name) + } + m.conversation.AddAssistant(content) + } + } + + m.conversation.AddAssistant(fmt.Sprintf("✨ Loaded session: **%s**", sess.Title)) + m.refreshViewport() + m.loading = false + return m, nil + case switchProviderSuccessMsg: m.cfg.SetProvider(msg.Provider) m.cfg.SetModel(msg.Provider, msg.Model) diff --git a/internal/config/options.go b/internal/config/options.go index ecf9a08..7cb2558 100644 --- a/internal/config/options.go +++ b/internal/config/options.go @@ -29,27 +29,27 @@ func GetProviderOptions() []selector.Option { return []selector.Option{ { Label: "gemini", - Description: "Google Gemini — Fast, highly capable multimodal AI (Default)", + Desc: "Google Gemini — Fast, highly capable multimodal AI (Default)", Value: "gemini", }, { Label: "openai", - Description: "OpenAI — Flagship models like GPT-4o, o1, o3-mini", + Desc: "OpenAI — Flagship models like GPT-4o, o1, o3-mini", Value: "openai", }, { Label: "anthropic", - Description: "Anthropic — Claude 3.5 Sonnet, Haiku, Opus models", + Desc: "Anthropic — Claude 3.5 Sonnet, Haiku, Opus models", Value: "anthropic", }, { Label: "groq", - Description: "Groq — Ultra-fast Llama 3 and Mixtral inference", + Desc: "Groq — Ultra-fast Llama 3 and Mixtral inference", Value: "groq", }, { Label: "ollama", - Description: "Ollama — Run open-source models locally on your system", + Desc: "Ollama — Run open-source models locally on your system", Value: "ollama", }, } @@ -73,7 +73,7 @@ func (c *Config) GetModelOptions(providerName, ollamaBaseURL string) []selector. for _, e := range entries { options = append(options, selector.Option{ Label: e.Label, - Description: e.Description, + Desc: e.Description, Value: e.Value, }) } @@ -85,7 +85,7 @@ func (c *Config) GetModelOptions(providerName, ollamaBaseURL string) []selector. for _, m := range c.CustomModels[providerName] { options = append(options, selector.Option{ Label: fmt.Sprintf("%s (Custom)", m), - Description: "Saved custom model", + Desc: "Saved custom model", Value: m, }) } @@ -94,7 +94,7 @@ func (c *Config) GetModelOptions(providerName, ollamaBaseURL string) []selector. // Always append custom model escape hatch options = append(options, selector.Option{ Label: "Custom model ID...", - Description: "Enter any model name or identifier manually", + Desc: "Enter any model name or identifier manually", Value: "__CUSTOM__", }) @@ -107,7 +107,7 @@ func ensureOllamaReadyAndGetModels(baseURL string) []selector.Option { return []selector.Option{ { Label: "❌ Ollama CLI not installed", - Description: "Please install Ollama from https://ollama.com first", + Desc: "Please install Ollama from https://ollama.com first", Value: "__CUSTOM__", }, } @@ -121,8 +121,8 @@ func ensureOllamaReadyAndGetModels(baseURL string) []selector.Option { "Ollama Daemon Not Running", fmt.Sprintf("Ollama server is offline at %s.\nWould you like WindMist to automatically start 'ollama serve' in the background?", baseURL), []selector.Option{ - {Label: "Yes (Start 'ollama serve' right now and retry)", Description: "Launch Ollama background service automatically", Value: "yes"}, - {Label: "No (Skip auto-start)", Description: "Enter model ID manually or start Ollama yourself later", Value: "no"}, + {Label: "Yes (Start 'ollama serve' right now and retry)", Desc: "Launch Ollama background service automatically", Value: "yes"}, + {Label: "No (Skip auto-start)", Desc: "Enter model ID manually or start Ollama yourself later", Value: "no"}, }, ) if runErr == nil && opt.Value == "yes" { @@ -149,8 +149,8 @@ func ensureOllamaReadyAndGetModels(baseURL string) []selector.Option { "No Local Models Downloaded", "Ollama is running, but you have 0 models pulled to your system.\nWould you like WindMist to automatically pull 'qwen2.5:8b' right now?", []selector.Option{ - {Label: "Yes (Run 'ollama pull qwen2.5:8b' right now)", Description: "Download recommended 8B local model (shows live progress)", Value: "yes"}, - {Label: "No (Skip and pull later)", Description: "Enter model ID manually or run 'ollama pull' yourself", Value: "no"}, + {Label: "Yes (Run 'ollama pull qwen2.5:8b' right now)", Desc: "Download recommended 8B local model (shows live progress)", Value: "yes"}, + {Label: "No (Skip and pull later)", Desc: "Enter model ID manually or run 'ollama pull' yourself", Value: "no"}, }, ) if runErr == nil && opt.Value == "yes" { @@ -175,7 +175,7 @@ func ensureOllamaReadyAndGetModels(baseURL string) []selector.Option { return []selector.Option{ { Label: "⚠️ Ollama offline or empty", - Description: fmt.Sprintf("Run 'ollama serve' and 'ollama pull ' at %s", baseURL), + Desc: fmt.Sprintf("Run 'ollama serve' and 'ollama pull ' at %s", baseURL), Value: "__CUSTOM__", }, } @@ -236,7 +236,7 @@ func fetchOllamaModels(baseURL string) ([]selector.Option, error) { } options = append(options, selector.Option{ Label: m.Name, - Description: desc, + Desc: desc, Value: m.Name, }) } diff --git a/internal/ui/selector/selector.go b/internal/ui/selector/selector.go index 18d2dcb..2b0e092 100644 --- a/internal/ui/selector/selector.go +++ b/internal/ui/selector/selector.go @@ -2,9 +2,9 @@ package selector import ( "fmt" - "strings" "github.com/Nithwin/WindMist/internal/ui" + "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) @@ -12,20 +12,21 @@ import ( // Option represents a selectable item in the selector list. type Option struct { Label string - Description string + Desc string Value string } +func (o Option) Title() string { return o.Label } +func (o Option) Description() string { return o.Desc } +func (o Option) FilterValue() string { return o.Label + " " + o.Value } + // ErrCancelled is returned when the user cancels the selector (e.g. via Esc or Ctrl+C). var ErrCancelled = fmt.Errorf("selection cancelled") type model struct { - title string - description string - options []Option - cursor int - selected *Option - cancelled bool + list list.Model + selected *Option + cancelled bool } func (m model) Init() tea.Cmd { @@ -35,88 +36,34 @@ func (m model) Init() tea.Cmd { func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: - switch msg.String() { - case "ctrl+c", "q", "esc": + switch keypress := msg.String(); keypress { + case "ctrl+c": m.cancelled = true return m, tea.Quit - - case "up", "k": - if m.cursor > 0 { - m.cursor-- - } else { - m.cursor = len(m.options) - 1 - } - - case "down", "j": - if m.cursor < len(m.options)-1 { - m.cursor++ - } else { - m.cursor = 0 - } - case "enter": - if len(m.options) > 0 { - m.selected = &m.options[m.cursor] + if i, ok := m.list.SelectedItem().(Option); ok { + m.selected = &i + return m, tea.Quit } - return m, tea.Quit - } - } - return m, nil -} - -func (m model) View() string { - var b strings.Builder - - // Title - titleStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(ui.Purple). - MarginBottom(1) - b.WriteString(titleStyle.Render(m.title) + "\n") - - // Optional Description - if m.description != "" { - descStyle := lipgloss.NewStyle(). - Foreground(ui.MutedLight). - MarginBottom(1) - b.WriteString(descStyle.Render(m.description) + "\n\n") - } else { - b.WriteString("\n") - } - - // Options - for i, opt := range m.options { - cursor := " " - if m.cursor == i { - cursor = lipgloss.NewStyle().Foreground(ui.Cyan).Bold(true).Render("❯ ") - } - - labelStyle := lipgloss.NewStyle().Foreground(ui.White) - if m.cursor == i { - labelStyle = lipgloss.NewStyle().Foreground(ui.Cyan).Bold(true) - } - - label := labelStyle.Render(opt.Label) - - var desc string - if opt.Description != "" { - descStyle := lipgloss.NewStyle().Foreground(ui.Muted) - if m.cursor == i { - descStyle = lipgloss.NewStyle().Foreground(ui.MutedLight) + case "esc": + if !m.list.SettingFilter() { + m.cancelled = true + return m, tea.Quit } - desc = " " + descStyle.Render(opt.Description) } - b.WriteString(fmt.Sprintf("%s%s%s\n", cursor, label, desc)) + case tea.WindowSizeMsg: + h, v := lipgloss.NewStyle().Margin(1, 2).GetFrameSize() + m.list.SetSize(msg.Width-h, msg.Height-v) } - // Footer instructions - footerStyle := lipgloss.NewStyle(). - Foreground(ui.Muted). - MarginTop(1) - b.WriteString("\n" + footerStyle.Render("↑/↓ navigate • enter select • esc/q cancel") + "\n") + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd +} - return b.String() +func (m model) View() string { + return "\n" + m.list.View() } // Run displays an interactive arrow-key list and returns the selected Option. @@ -125,11 +72,22 @@ func Run(title, description string, options []Option) (Option, error) { return Option{}, fmt.Errorf("no options provided") } - p := tea.NewProgram(model{ - title: title, - description: description, - options: options, - }) + items := make([]list.Item, len(options)) + for i, opt := range options { + items[i] = opt + } + + d := list.NewDefaultDelegate() + d.Styles.SelectedTitle = d.Styles.SelectedTitle.Foreground(ui.Cyan).BorderForeground(ui.Cyan) + d.Styles.SelectedDesc = d.Styles.SelectedDesc.Foreground(ui.Cyan).BorderForeground(ui.Cyan) + + l := list.New(items, d, 80, 20) + l.Title = title + l.SetShowStatusBar(false) + l.SetFilteringEnabled(true) + l.Styles.Title = lipgloss.NewStyle().Background(ui.Purple).Foreground(ui.White).Padding(0, 1) + + p := tea.NewProgram(model{list: l}, tea.WithAltScreen()) finalModel, err := p.Run() if err != nil { @@ -143,3 +101,4 @@ func Run(title, description string, options []Option) (Option, error) { return *m.selected, nil } + From 3d5ab2baaccf2d77c7913bb05de0a1a3f07cb1d8 Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 14:27:15 +0530 Subject: [PATCH 19/57] fix(ui): make /new command create a new database session --- internal/chat/commands.go | 6 +++--- internal/chat/messages.go | 3 +++ internal/chat/update.go | 27 +++++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/internal/chat/commands.go b/internal/chat/commands.go index 5682236..554a2c6 100644 --- a/internal/chat/commands.go +++ b/internal/chat/commands.go @@ -40,9 +40,9 @@ var Registry = []Command{ Name: "/new", Description: "Start a new conversation", Execute: func(m *Model) tea.Cmd { - m.conversation.Clear() - m.refreshViewport() - return nil + return func() tea.Msg { + return createNewSessionMsg{} + } }, }, { diff --git a/internal/chat/messages.go b/internal/chat/messages.go index 5fa5c27..8afc612 100644 --- a/internal/chat/messages.go +++ b/internal/chat/messages.go @@ -27,6 +27,9 @@ type switchSessionSuccessMsg struct { SessionID string } +// createNewSessionMsg signals to spin up a new session. +type createNewSessionMsg struct{} + // switchModelSuccessMsg represents a successful model change. type switchModelSuccessMsg struct { Model string diff --git a/internal/chat/update.go b/internal/chat/update.go index bf7e771..7d5b3ae 100644 --- a/internal/chat/update.go +++ b/internal/chat/update.go @@ -8,8 +8,10 @@ import ( "github.com/Nithwin/WindMist/internal/agent" "github.com/Nithwin/WindMist/internal/ai" "github.com/Nithwin/WindMist/internal/config" + "github.com/Nithwin/WindMist/internal/store" "github.com/Nithwin/WindMist/internal/tools" "github.com/Nithwin/WindMist/internal/tools/defaults" + "time" tea "github.com/charmbracelet/bubbletea" ) @@ -263,6 +265,31 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil + case createNewSessionMsg: + activeModel, _ := m.cfg.ActiveModel() + sess := &store.Session{ + ID: fmt.Sprintf("sess_%d", time.Now().Unix()), + Title: "New Session", + ProjectPath: ".", + Provider: m.cfg.AI.Provider, + Model: activeModel, + AgentMode: "build", + } + if m.store != nil { + _ = m.store.CreateSession(sess) + } + + m.session = sess + m.agent = agent.New(m.provider, m.agent.Manager(), agent.Config{ + Store: m.store, + SessionID: sess.ID, + }) + + m.conversation.Clear() + m.conversation.AddAssistant("✨ Started a new session.") + m.refreshViewport() + return m, nil + case switchSessionSuccessMsg: sess, err := m.store.GetSession(msg.SessionID) if err != nil { From 1aae9758f8cc97149188fb37f0f6952a61ac5a28 Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 14:34:24 +0530 Subject: [PATCH 20/57] feat(agent): implement atomic undo/redo file engine with SQLite integration --- internal/agent/executor.go | 13 +++++++ internal/chat/commands.go | 20 +++++++++++ internal/chat/messages.go | 6 ++++ internal/chat/update.go | 49 ++++++++++++++++++++++++++ internal/store/queries.go | 10 ++++++ internal/tools/editing/tool_delete.go | 18 +++++++++- internal/tools/editing/tool_insert.go | 18 +++++++++- internal/tools/editing/tool_range.go | 18 +++++++++- internal/tools/editing/tool_replace.go | 18 +++++++++- internal/tools/filesystem/append.go | 19 +++++++++- internal/tools/filesystem/create.go | 11 +++++- internal/tools/filesystem/delete.go | 22 ++++++++++-- internal/tools/filesystem/write.go | 18 ++++++++-- internal/tools/types.go | 8 +++++ 14 files changed, 238 insertions(+), 10 deletions(-) diff --git a/internal/agent/executor.go b/internal/agent/executor.go index 72117d8..896f0e0 100644 --- a/internal/agent/executor.go +++ b/internal/agent/executor.go @@ -6,6 +6,7 @@ import ( "sync" "github.com/Nithwin/WindMist/internal/ai" + "github.com/Nithwin/WindMist/internal/store" "github.com/Nithwin/WindMist/internal/tools" ) @@ -44,6 +45,18 @@ func (a *Agent) execute(ctx context.Context, calls []ai.ToolCall, onChunk func(s onChunk(fmt.Sprintf(" ✅ Done (`%s`).\n\n", call.Name)) } + if a.config.Store != nil && a.config.SessionID != "" && len(res.FileStates) > 0 { + for _, state := range res.FileStates { + _ = a.config.Store.SaveFileChange(&store.FileChange{ + SessionID: a.config.SessionID, + FilePath: state.Path, + ChangeType: state.ChangeType, + BeforeContent: state.BeforeContent, + AfterContent: state.AfterContent, + }) + } + } + content := "" isError := false diff --git a/internal/chat/commands.go b/internal/chat/commands.go index 554a2c6..e0275c3 100644 --- a/internal/chat/commands.go +++ b/internal/chat/commands.go @@ -28,6 +28,8 @@ var Registry = []Command{ /help Show available commands /new Start a new conversation /sessions Load a previous session +/undo Undo the last AI file edit +/redo Redo the last undone file edit /model Change model /provider Change provider /clear Clear conversation @@ -52,6 +54,24 @@ var Registry = []Command{ return selectSessionCmd(m) }, }, + { + Name: "/undo", + Description: "Undo the last AI file edit", + Execute: func(m *Model) tea.Cmd { + return func() tea.Msg { + return undoFileChangeMsg{} + } + }, + }, + { + Name: "/redo", + Description: "Redo the last undone file edit", + Execute: func(m *Model) tea.Cmd { + return func() tea.Msg { + return redoFileChangeMsg{} + } + }, + }, { Name: "/clear", Description: "Clear conversation", diff --git a/internal/chat/messages.go b/internal/chat/messages.go index 8afc612..c8fa246 100644 --- a/internal/chat/messages.go +++ b/internal/chat/messages.go @@ -30,6 +30,12 @@ type switchSessionSuccessMsg struct { // createNewSessionMsg signals to spin up a new session. type createNewSessionMsg struct{} +// undoFileChangeMsg signals to undo the last file edit. +type undoFileChangeMsg struct{} + +// redoFileChangeMsg signals to redo the last undone file edit. +type redoFileChangeMsg struct{} + // switchModelSuccessMsg represents a successful model change. type switchModelSuccessMsg struct { Model string diff --git a/internal/chat/update.go b/internal/chat/update.go index 7d5b3ae..fc09934 100644 --- a/internal/chat/update.go +++ b/internal/chat/update.go @@ -3,6 +3,7 @@ package chat import ( "context" "fmt" + "os" "strings" "github.com/Nithwin/WindMist/internal/agent" @@ -290,6 +291,54 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.refreshViewport() return m, nil + case undoFileChangeMsg: + if m.store == nil || m.session == nil { + m.conversation.AddAssistant("❌ Persistence not enabled.") + m.refreshViewport() + return m, nil + } + + change, err := m.store.GetLastFileChange(m.session.ID) + if err != nil { + m.conversation.AddAssistant("❌ No file changes found to undo.") + m.refreshViewport() + return m, nil + } + + if change.ChangeType == "create" { + _ = os.Remove(change.FilePath) + } else { + _ = os.WriteFile(change.FilePath, []byte(change.BeforeContent), 0644) + } + + m.conversation.AddAssistant(fmt.Sprintf("⏮️ **Undid edit** to `%s`", change.FilePath)) + m.refreshViewport() + return m, nil + + case redoFileChangeMsg: + if m.store == nil || m.session == nil { + m.conversation.AddAssistant("❌ Persistence not enabled.") + m.refreshViewport() + return m, nil + } + + change, err := m.store.GetLastFileChange(m.session.ID) + if err != nil { + m.conversation.AddAssistant("❌ No file changes found to redo.") + m.refreshViewport() + return m, nil + } + + if change.ChangeType == "delete" { + _ = os.Remove(change.FilePath) + } else { + _ = os.WriteFile(change.FilePath, []byte(change.AfterContent), 0644) + } + + m.conversation.AddAssistant(fmt.Sprintf("⏭️ **Redid edit** to `%s`", change.FilePath)) + m.refreshViewport() + return m, nil + case switchSessionSuccessMsg: sess, err := m.store.GetSession(msg.SessionID) if err != nil { diff --git a/internal/store/queries.go b/internal/store/queries.go index e700b65..4c7cc58 100644 --- a/internal/store/queries.go +++ b/internal/store/queries.go @@ -106,6 +106,16 @@ func (s *Store) GetFileChangesBySession(sessionID string) ([]FileChange, error) return changes, err } +// GetLastFileChange gets the most recent file change for a session +func (s *Store) GetLastFileChange(sessionID string) (*FileChange, error) { + var change FileChange + err := s.db.Get(&change, "SELECT * FROM file_changes WHERE session_id = ? ORDER BY id DESC LIMIT 1", sessionID) + if err != nil { + return nil, err + } + return &change, nil +} + // DeleteSession completely deletes a session and all cascading data func (s *Store) DeleteSession(id string) error { res, err := s.db.Exec("DELETE FROM sessions WHERE id = ?", id) diff --git a/internal/tools/editing/tool_delete.go b/internal/tools/editing/tool_delete.go index 24a18ab..408a81f 100644 --- a/internal/tools/editing/tool_delete.go +++ b/internal/tools/editing/tool_delete.go @@ -69,10 +69,26 @@ func (t *DeleteRangeTool) Run(ctx context.Context, call tools.Call) tools.Result EndLine: endLine, } + beforeBytes, _ := os.ReadFile(file) + result, err := DeleteRange(ctx, opts) if err != nil { return tools.Result{Error: err} } - return tools.Result{Output: result} + // Capture AfterContent + afterBytes, _ := os.ReadFile(file) + + return tools.Result{ + Output: result, + FilesChanged: []string{file}, + FileStates: []tools.FileState{ + { + Path: file, + BeforeContent: string(beforeBytes), + AfterContent: string(afterBytes), + ChangeType: "edit", + }, + }, + } } diff --git a/internal/tools/editing/tool_insert.go b/internal/tools/editing/tool_insert.go index 9ecab5f..5135f2c 100644 --- a/internal/tools/editing/tool_insert.go +++ b/internal/tools/editing/tool_insert.go @@ -71,10 +71,26 @@ func (t *InsertTextTool) Run(ctx context.Context, call tools.Call) tools.Result NewText: newText, } + beforeBytes, _ := os.ReadFile(file) + result, err := InsertText(ctx, opts) if err != nil { return tools.Result{Error: err} } - return tools.Result{Output: result} + // Capture AfterContent + afterBytes, _ := os.ReadFile(file) + + return tools.Result{ + Output: result, + FilesChanged: []string{file}, + FileStates: []tools.FileState{ + { + Path: file, + BeforeContent: string(beforeBytes), + AfterContent: string(afterBytes), + ChangeType: "edit", + }, + }, + } } diff --git a/internal/tools/editing/tool_range.go b/internal/tools/editing/tool_range.go index 7e04699..462b8c5 100644 --- a/internal/tools/editing/tool_range.go +++ b/internal/tools/editing/tool_range.go @@ -78,10 +78,26 @@ func (t *ReplaceRangeTool) Run(ctx context.Context, call tools.Call) tools.Resul NewText: newText, } + beforeBytes, _ := os.ReadFile(file) + result, err := ReplaceRange(ctx, opts) if err != nil { return tools.Result{Error: err} } - return tools.Result{Output: result} + // Capture AfterContent + afterBytes, _ := os.ReadFile(file) + + return tools.Result{ + Output: result, + FilesChanged: []string{file}, + FileStates: []tools.FileState{ + { + Path: file, + BeforeContent: string(beforeBytes), + AfterContent: string(afterBytes), + ChangeType: "edit", + }, + }, + } } diff --git a/internal/tools/editing/tool_replace.go b/internal/tools/editing/tool_replace.go index 742e5b5..12aae6b 100644 --- a/internal/tools/editing/tool_replace.go +++ b/internal/tools/editing/tool_replace.go @@ -90,10 +90,26 @@ func (t *ReplaceTextTool) Run(ctx context.Context, call tools.Call) tools.Result MaxReplacements: maxReplacements, } + beforeBytes, _ := os.ReadFile(file) + result, err := ReplaceText(ctx, opts) if err != nil { return tools.Result{Error: err} } - return tools.Result{Output: result} + // Capture AfterContent + afterBytes, _ := os.ReadFile(opts.File) + + return tools.Result{ + Output: result, + FilesChanged: []string{opts.File}, + FileStates: []tools.FileState{ + { + Path: opts.File, + BeforeContent: string(beforeBytes), + AfterContent: string(afterBytes), + ChangeType: "edit", + }, + }, + } } diff --git a/internal/tools/filesystem/append.go b/internal/tools/filesystem/append.go index bc77800..00bc4ed 100644 --- a/internal/tools/filesystem/append.go +++ b/internal/tools/filesystem/append.go @@ -48,6 +48,12 @@ func (t *AppendTool) Run(ctx context.Context, call tools.Call) tools.Result { return tools.Result{Error: os.ErrInvalid} } + beforeBytes, readErr := os.ReadFile(path) + beforeContent := "" + if readErr == nil { + beforeContent = string(beforeBytes) + } + file, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0) if err != nil { return tools.Result{Error: err} @@ -60,7 +66,18 @@ func (t *AppendTool) Run(ctx context.Context, call tools.Call) tools.Result { return tools.Result{Error: err} } + afterBytes, _ := os.ReadFile(path) + return tools.Result{ - Output: fmt.Sprintf("Appended %d bytes to %q", len(content), path), + Output: fmt.Sprintf("Appended %d bytes to %q", len(content), path), + FilesChanged: []string{path}, + FileStates: []tools.FileState{ + { + Path: path, + BeforeContent: beforeContent, + AfterContent: string(afterBytes), + ChangeType: "edit", + }, + }, } } diff --git a/internal/tools/filesystem/create.go b/internal/tools/filesystem/create.go index af14657..9ccec63 100644 --- a/internal/tools/filesystem/create.go +++ b/internal/tools/filesystem/create.go @@ -75,6 +75,15 @@ func (t *CreateTool) Run(ctx context.Context, call tools.Call) tools.Result { defer file.Close() return tools.Result{ - Output: "File created successfully.", + Output: "File created successfully.", + FilesChanged: []string{path}, + FileStates: []tools.FileState{ + { + Path: path, + BeforeContent: "", // It didn't exist + AfterContent: "", // It is empty initially + ChangeType: "create", + }, + }, } } diff --git a/internal/tools/filesystem/delete.go b/internal/tools/filesystem/delete.go index 53c063d..b2bd62b 100644 --- a/internal/tools/filesystem/delete.go +++ b/internal/tools/filesystem/delete.go @@ -37,15 +37,33 @@ func (t *DeleteTool) Run(ctx context.Context, call tools.Call) tools.Result { return tools.Result{Error: os.ErrInvalid} } - if _, err := os.Stat(path); err != nil { + info, err := os.Stat(path) + if err != nil { return tools.Result{Error: err} } + beforeContent := "" + if !info.IsDir() { + beforeBytes, err := os.ReadFile(path) + if err == nil { + beforeContent = string(beforeBytes) + } + } + if err := os.RemoveAll(path); err != nil { return tools.Result{Error: err} } return tools.Result{ - Output: fmt.Sprintf("Deleted %q", path), + Output: fmt.Sprintf("Deleted %q", path), + FilesChanged: []string{path}, + FileStates: []tools.FileState{ + { + Path: path, + BeforeContent: beforeContent, + AfterContent: "", + ChangeType: "delete", + }, + }, } } diff --git a/internal/tools/filesystem/write.go b/internal/tools/filesystem/write.go index 72c5179..3050179 100644 --- a/internal/tools/filesystem/write.go +++ b/internal/tools/filesystem/write.go @@ -52,13 +52,18 @@ func (t *WriteTool) Run(ctx context.Context, call tools.Call) tools.Result { } } + beforeBytes, readErr := os.ReadFile(path) + beforeContent := "" + if readErr == nil { + beforeContent = string(beforeBytes) + } + file, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0) if err != nil { return tools.Result{ Error: err, } } - defer file.Close() _, err = file.WriteString(content) @@ -69,6 +74,15 @@ func (t *WriteTool) Run(ctx context.Context, call tools.Call) tools.Result { } return tools.Result{ - Output: fmt.Sprintf("Wrote %d bytes to %q", len(content), path), + Output: fmt.Sprintf("Wrote %d bytes to %q", len(content), path), + FilesChanged: []string{path}, + FileStates: []tools.FileState{ + { + Path: path, + BeforeContent: beforeContent, + AfterContent: content, + ChangeType: "edit", + }, + }, } } diff --git a/internal/tools/types.go b/internal/tools/types.go index 7c041a9..370bd68 100644 --- a/internal/tools/types.go +++ b/internal/tools/types.go @@ -46,12 +46,20 @@ type Call struct { Args map[string]any } +type FileState struct { + Path string + BeforeContent string + AfterContent string + ChangeType string // create, edit, delete +} + type Result struct { Output any Error error Duration time.Duration // How long the tool took FilesRead []string // Files accessed FilesChanged []string // Files modified + FileStates []FileState // Exact before/after for Undo/Redo BytesChanged int64 // Total bytes changed } From 7f7cad2549518657b0691d03e015fe08817953cb Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 14:36:33 +0530 Subject: [PATCH 21/57] chore(ui): remove confusing /clear command in favor of /new --- internal/chat/commands.go | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/internal/chat/commands.go b/internal/chat/commands.go index e0275c3..490d7d9 100644 --- a/internal/chat/commands.go +++ b/internal/chat/commands.go @@ -32,7 +32,6 @@ var Registry = []Command{ /redo Redo the last undone file edit /model Change model /provider Change provider -/clear Clear conversation /exit Exit WindMist`, ) return nil @@ -72,15 +71,6 @@ var Registry = []Command{ } }, }, - { - Name: "/clear", - Description: "Clear conversation", - Execute: func(m *Model) tea.Cmd { - m.conversation.Clear() - m.refreshViewport() - return nil - }, - }, { Name: "/model", Description: "Change model", From b5079c66d02a2ffb0b73f69f6081eb92b67f96f8 Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 14:51:35 +0530 Subject: [PATCH 22/57] feat(agent): implement build and plan agent modes with auto-routing --- internal/agent/agent.go | 5 + internal/agent/executor.go | 9 +- internal/agent/loop.go | 49 +++- internal/agent/mode.go | 71 +++++ internal/agent/prompt/builder.go | 8 +- internal/chat/banner.go | 14 + internal/chat/commands.go | 34 +++ internal/chat/messages.go | 5 + internal/chat/model.go | 3 +- internal/chat/update.go | 436 +------------------------------ internal/chat/update_events.go | 223 ++++++++++++++++ internal/chat/update_keys.go | 213 +++++++++++++++ internal/chat/update_stream.go | 33 +++ 13 files changed, 670 insertions(+), 433 deletions(-) create mode 100644 internal/agent/mode.go create mode 100644 internal/chat/update_events.go create mode 100644 internal/chat/update_keys.go create mode 100644 internal/chat/update_stream.go diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 6c1863a..c55cf64 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -21,6 +21,8 @@ type Config struct { Store *store.Store // SessionID is the unique identifier for the current session, if persistence is enabled. SessionID string + // Mode is the operating mode of the agent (e.g., build, plan, auto). + Mode string } // Result contains the final output produced by the agent. @@ -54,6 +56,9 @@ func New( if config.MaxContextTokens <= 0 { config.MaxContextTokens = DefaultMaxContextTokens } + if config.Mode == "" { + config.Mode = string(ModeBuild) + } return &Agent{ provider: provider, diff --git a/internal/agent/executor.go b/internal/agent/executor.go index 896f0e0..af91eab 100644 --- a/internal/agent/executor.go +++ b/internal/agent/executor.go @@ -83,14 +83,15 @@ func (a *Agent) execute(ctx context.Context, calls []ai.ToolCall, onChunk func(s } // toolDefinitions converts the registered tool definitions from tools.Manager into ai.ToolDefinition format. -func (a *Agent) toolDefinitions() []ai.ToolDefinition { +func (a *Agent) toolDefinitions(modeConfig ModeConfig) []ai.ToolDefinition { if a.manager == nil { return nil } - toolsList := a.manager.List() + + toolsList := FilterTools(a.manager, modeConfig) + defs := make([]ai.ToolDefinition, 0, len(toolsList)) - for _, t := range toolsList { - def := t.Definition() + for _, def := range toolsList { params := make([]ai.ToolParameter, 0, len(def.Parameters)) for _, p := range def.Parameters { params = append(params, ai.ToolParameter{ diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 5d378fa..e6a0907 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -2,7 +2,9 @@ package agent import ( "context" + "fmt" "os" + "strings" "time" "github.com/Nithwin/WindMist/internal/agent/prompt" @@ -18,20 +20,30 @@ func (a *Agent) runLoop(ctx context.Context, messages []ai.Message, userPrompt s var totalUsage ai.Usage + effectiveMode := a.config.Mode + if effectiveMode == string(ModeAuto) { + resolvedMode := a.orchestrateMode(ctx, userPrompt) + effectiveMode = resolvedMode + if onChunk != nil { + onChunk(fmt.Sprintf("\n> 🤖 **Auto-Router**: Selected `%s` mode.\n\n", resolvedMode)) + } + } + for turn := 0; turn < a.config.MaxTurns; turn++ { if err := ctx.Err(); err != nil { return nil, err } prunedHistory := pruneMessages(messages, a.config.MaxContextTokens) - // Build dynamic system prompt + // Build dynamic system prompt based on mode cwd, _ := os.Getwd() - dynamicSystemPrompt := prompt.Build(cwd) + modeConfig := GetModeConfig(Mode(effectiveMode)) + dynamicSystemPrompt := prompt.Build(cwd, modeConfig.SystemPrompt) req := &ai.GenerateRequest{ System: dynamicSystemPrompt, Messages: prunedHistory, - Tools: a.toolDefinitions(), + Tools: a.toolDefinitions(modeConfig), } var resp *ai.GenerateResponse @@ -90,3 +102,34 @@ func (a *Agent) runLoop(ctx context.Context, messages []ai.Message, userPrompt s return nil, ErrMaxTurnsExceeded } + +// orchestrateMode sends a fast prompt to the LLM to classify the user's intent. +func (a *Agent) orchestrateMode(ctx context.Context, userPrompt string) string { + systemPrompt := `You are an AI orchestrator for a coding assistant. +Your ONLY job is to classify the user's prompt into one of two modes: +1. "build" - The user wants you to write code, fix a bug, create a file, or modify the codebase. +2. "plan" - The user just wants you to analyze, review, explain, search, or output a step-by-step plan WITHOUT modifying any files. + +Reply with EXACTLY ONE WORD: either "build" or "plan". Do not include any punctuation or extra text.` + + req := &ai.GenerateRequest{ + System: systemPrompt, + Messages: []ai.Message{ + {Role: ai.RoleUser, Content: userPrompt}, + }, + // No tools needed for classification + } + + // We use the same provider to classify, but ideally a smaller model. + // For now, we just use the active model. + resp, err := a.provider.Generate(ctx, req) + if err != nil { + return string(ModeBuild) // Default fallback + } + + res := strings.ToLower(strings.TrimSpace(resp.Text)) + if strings.Contains(res, "plan") { + return string(ModePlan) + } + return string(ModeBuild) +} diff --git a/internal/agent/mode.go b/internal/agent/mode.go new file mode 100644 index 0000000..e8ec33a --- /dev/null +++ b/internal/agent/mode.go @@ -0,0 +1,71 @@ +package agent + +import ( + "github.com/Nithwin/WindMist/internal/tools" +) + +// Mode represents an operating mode for the agent. +type Mode string + +const ( + // ModeAuto automatically decides between Plan and Build based on the prompt. + ModeAuto Mode = "auto" + // ModeBuild has full read/write access and autonomy. + ModeBuild Mode = "build" + // ModePlan is read-only. It can search and analyze, but cannot write files. + ModePlan Mode = "plan" +) + +// ModeConfig defines the behavior and permissions of a specific mode. +type ModeConfig struct { + Name Mode + Description string + SystemPrompt string + AllowFileEdits bool + AllowCommands bool +} + +// GetModeConfig returns the configuration for a given mode. +func GetModeConfig(mode Mode) ModeConfig { + switch mode { + case ModePlan: + return ModeConfig{ + Name: ModePlan, + Description: "Read-only architect mode. Analyzes and plans but cannot edit files.", + SystemPrompt: "You are an expert software architect in PLAN mode. Your job is to analyze the user's request, search the codebase, read files, and output a detailed, step-by-step implementation plan. YOU CANNOT MODIFY FILES OR WRITE CODE TO DISK. Do not attempt to use any write tools. If a command must be run, ask the user first. Focus on architectural decisions, edge cases, and producing a clear numbered plan.", + AllowFileEdits: false, + AllowCommands: false, + } + default: + // Default to build (even if auto, the actual execution mode resolves to build/plan) + return ModeConfig{ + Name: ModeBuild, + Description: "Full autonomy mode. Can read, write, and execute.", + SystemPrompt: "You are WindMist, an expert autonomous coding agent in BUILD mode. Your job is to implement features, fix bugs, and refactor code directly. You have full access to the filesystem. When asked to complete a task, you should read relevant files, make the necessary edits using your tools, and run commands to verify your work. Act surgically and efficiently.", + AllowFileEdits: true, + AllowCommands: true, + } + } +} + +// FilterTools returns only the tools allowed by the given ModeConfig. +func FilterTools(manager *tools.Manager, config ModeConfig) []tools.Definition { + var allowed []tools.Definition + + for _, tool := range manager.List() { + def := tool.Definition() + + // If edits are denied, filter out PermWrite and PermDangerous + if !config.AllowFileEdits && (def.Category == tools.CategoryEditing || def.Permission == tools.PermWrite || def.Permission == tools.PermDangerous) { + continue + } + + // Wait, if commands are denied, we could filter out system/command tools, + // but maybe we just require permission instead of completely filtering. + // For now, in plan mode, we completely disable editing tools. + + allowed = append(allowed, def) + } + + return allowed +} diff --git a/internal/agent/prompt/builder.go b/internal/agent/prompt/builder.go index 7e48c05..b880deb 100644 --- a/internal/agent/prompt/builder.go +++ b/internal/agent/prompt/builder.go @@ -4,9 +4,13 @@ import "strings" // Build constructs the complete system prompt for WindMist. // It dynamically generates a map of the workspace if cwd is provided. -func Build(cwd string) string { +func Build(cwd string, modeSystemPrompt string) string { + if modeSystemPrompt == "" { + modeSystemPrompt = System() + } + sections := []string{ - System(), + modeSystemPrompt, Developer(), Tools(), } diff --git a/internal/chat/banner.go b/internal/chat/banner.go index 098606e..eee4392 100644 --- a/internal/chat/banner.go +++ b/internal/chat/banner.go @@ -33,6 +33,20 @@ func renderBanner(m Model) string { if provider, err := m.cfg.ActiveProvider(); err == nil { b.WriteString(provider.Model) } + b.WriteString("\n") + + b.WriteString(ui.LabelStyle.Render("Mode : ")) + if m.session != nil { + modeColor := ui.SuccessStyle + if m.session.AgentMode == "plan" { + modeColor = lipgloss.NewStyle().Foreground(ui.Amber) + } else if m.session.AgentMode == "auto" { + modeColor = lipgloss.NewStyle().Foreground(ui.Purple) + } + b.WriteString(modeColor.Render(strings.ToUpper(m.session.AgentMode))) + } else { + b.WriteString("BUILD") + } b.WriteString("\n\n") diff --git a/internal/chat/commands.go b/internal/chat/commands.go index 490d7d9..f6fc7f1 100644 --- a/internal/chat/commands.go +++ b/internal/chat/commands.go @@ -31,6 +31,7 @@ var Registry = []Command{ /undo Undo the last AI file edit /redo Redo the last undone file edit /model Change model +/mode Change agent mode /provider Change provider /exit Exit WindMist`, ) @@ -78,6 +79,13 @@ var Registry = []Command{ return selectModelCmd(m) }, }, + { + Name: "/mode", + Description: "Change agent mode", + Execute: func(m *Model) tea.Cmd { + return selectModeCmd(m) + }, + }, { Name: "/provider", Description: "Change provider", @@ -247,6 +255,32 @@ func selectModelCmd(m *Model) tea.Cmd { } } +func selectModeCmd(m *Model) tea.Cmd { + return func() tea.Msg { + if program == nil { + return switchErrorMsg{Err: fmt.Errorf("program instance not initialized")} + } + + if err := program.ReleaseTerminal(); err != nil { + return switchErrorMsg{Err: fmt.Errorf("failed to release terminal: %w", err)} + } + defer program.RestoreTerminal() + + options := []selector.Option{ + {Label: "Auto", Desc: "Dynamically switches between Build and Plan based on prompt", Value: "auto"}, + {Label: "Build", Desc: "Full autonomy mode with read/write access", Value: "build"}, + {Label: "Plan", Desc: "Read-only mode for architecture and analysis", Value: "plan"}, + } + + opt, err := selector.Run("Select Agent Mode", "Choose how the AI should behave:", options) + if err != nil { + return switchCancelMsg{} + } + + return switchModeSuccessMsg{Mode: opt.Value} + } +} + func FilterCommands(input string) []Command { if input == "/" { return Registry diff --git a/internal/chat/messages.go b/internal/chat/messages.go index c8fa246..f2b1482 100644 --- a/internal/chat/messages.go +++ b/internal/chat/messages.go @@ -22,6 +22,11 @@ type switchProviderSuccessMsg struct { Model string } +// switchModeSuccessMsg represents a successful agent mode change. +type switchModeSuccessMsg struct { + Mode string +} + // switchSessionSuccessMsg represents a successful session change. type switchSessionSuccessMsg struct { SessionID string diff --git a/internal/chat/model.go b/internal/chat/model.go index 488046b..fb3c837 100644 --- a/internal/chat/model.go +++ b/internal/chat/model.go @@ -95,13 +95,14 @@ func New() (Model, error) { ProjectPath: ".", Provider: cfg.AI.Provider, Model: activeModel, - AgentMode: "build", + AgentMode: "auto", } _ = dbStore.CreateSession(sess) ag := agent.New(provider, manager, agent.Config{ Store: dbStore, SessionID: sess.ID, + Mode: sess.AgentMode, }) renderer, err := ui.NewMarkdownRenderer() diff --git a/internal/chat/update.go b/internal/chat/update.go index fc09934..d33f3c3 100644 --- a/internal/chat/update.go +++ b/internal/chat/update.go @@ -1,27 +1,14 @@ package chat import ( - "context" - "fmt" - "os" - "strings" - - "github.com/Nithwin/WindMist/internal/agent" - "github.com/Nithwin/WindMist/internal/ai" - "github.com/Nithwin/WindMist/internal/config" - "github.com/Nithwin/WindMist/internal/store" - "github.com/Nithwin/WindMist/internal/tools" - "github.com/Nithwin/WindMist/internal/tools/defaults" - "time" tea "github.com/charmbracelet/bubbletea" ) -// Update handles all user interactions. +// Update handles all user interactions and routes them to specific handlers. func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { - case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height @@ -30,419 +17,22 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyMsg: - // Scroll conversation when command palette is closed. - if !m.showCommands { - switch msg.String() { - - case "ctrl+up", "shift+up": - m.viewport.ScrollUp(1) - return m, nil - - case "ctrl+down", "shift+down": - m.viewport.ScrollDown(1) - return m, nil - - case "up": - if len(m.inputHistory) > 0 && m.historyIndex > 0 { - m.historyIndex-- - m.input.SetValue(m.inputHistory[m.historyIndex]) - m.input.CursorEnd() - } - return m, nil - - case "down": - if len(m.inputHistory) > 0 && m.historyIndex < len(m.inputHistory) { - m.historyIndex++ - if m.historyIndex == len(m.inputHistory) { - m.input.SetValue("") - } else { - m.input.SetValue(m.inputHistory[m.historyIndex]) - m.input.CursorEnd() - } - } - return m, nil - - case "pgup": - m.viewport.ScrollUp(m.viewport.Height / 2) - return m, nil - - case "pgdown": - m.viewport.ScrollDown(m.viewport.Height / 2) - return m, nil - - case "home": - m.viewport.GotoTop() - return m, nil - - case "end": - m.viewport.GotoBottom() - return m, nil - } - } - - // Handle approval keys - if m.waitingApproval { - switch msg.String() { - case "y", "Y": - if m.approvalChan != nil { - m.approvalChan <- true - } - m.waitingApproval = false - m.refreshViewport() - return m, nil - case "n", "N": - if m.approvalChan != nil { - m.approvalChan <- false - } - m.waitingApproval = false - m.refreshViewport() - return m, nil - case "ctrl+c", "esc": - if m.approvalChan != nil { - m.approvalChan <- false - } - m.waitingApproval = false - return m, tea.Quit - } - // Block other inputs - return m, nil - } - - switch msg.String() { - case "ctrl+c", "esc": - if m.loading && m.cancel != nil { - m.cancel() - m.loading = false - m.conversation.AddAssistant("\n\n*(Cancelled by user)*") - m.refreshViewport() - return m, nil - } - return m, tea.Quit - } - - // Hide splash on first key press. - if m.showSplash { - m.showSplash = false - - // Preserve the first typed character. - if len(msg.String()) == 1 { - m.input.SetValue(msg.String()) - m.input.CursorEnd() - } - - m.refreshViewport() - return m, nil - } - - // Update slash command suggestions (check first line only). - value := m.input.Value() - firstLine := strings.SplitN(value, "\n", 2)[0] - - if strings.HasPrefix(firstLine, "/") { - m.showCommands = true - m.filteredCommands = FilterCommands(firstLine) - } else { - m.showCommands = false - m.filteredCommands = nil - m.selectedCommand = 0 - } - m.updateViewportSize() - - // Navigate the command palette. - if m.showCommands { - switch msg.String() { - - case "up": - if m.selectedCommand > 0 { - m.selectedCommand-- - } - return m, nil - - case "down": - if m.selectedCommand < len(m.filteredCommands)-1 { - m.selectedCommand++ - } - return m, nil - - case "esc": - m.showCommands = false - m.filteredCommands = nil - m.selectedCommand = 0 - return m, nil - } - } - switch msg.String() { - - case "enter": - prompt := strings.TrimSpace(m.input.Value()) - - if prompt == "" { - return m, nil - } - - // Execute selected command from palette. - if m.showCommands && len(m.filteredCommands) > 0 { - cmd := m.filteredCommands[m.selectedCommand] - - m.showCommands = false - m.filteredCommands = nil - m.selectedCommand = 0 - m.input.SetValue("") - - return m, cmd.Execute(&m) - } - - // Execute typed slash command. - if strings.HasPrefix(prompt, "/") { - m.inputHistory = append(m.inputHistory, prompt) - m.historyIndex = len(m.inputHistory) - - if command, ok := FindCommand(prompt); ok { - m.input.SetValue("") - return m, command.Execute(&m) - } - - m.conversation.AddAssistant("Unknown command: " + prompt) - m.input.SetValue("") - return m, nil - } - - // Normal AI message. - m.inputHistory = append(m.inputHistory, prompt) - m.historyIndex = len(m.inputHistory) - - m.conversation.AddUser(prompt) - m.refreshViewport() - m.loading = true - - m.input.SetValue("") - - // Create an empty assistant message. - // Streaming chunks will be appended to this. - m.conversation.AddAssistant("") - m.refreshViewport() - - ctx, cancel := context.WithCancel(context.Background()) - m.cancel = cancel - m.sendMessage(ctx, prompt) - - return m, nil - } - - case ApprovalRequestMsg: - m.waitingApproval = true - m.approvalCommand = msg.Command - m.approvalChan = msg.ResponseChan - m.refreshViewport() - return m, nil + return m.handleKeyMsg(msg) case StreamingMsg: - - if msg.Err != nil { - m.loading = false - - if len(m.conversation.Messages) > 0 { - m.conversation.Messages[len(m.conversation.Messages)-1].Content = - "Error: " + msg.Err.Error() - - m.refreshViewport() - } - - return m, nil - } - - if len(m.conversation.Messages) > 0 { - last := &m.conversation.Messages[len(m.conversation.Messages)-1] - - if last.Role == "assistant" { - last.Content += msg.Text - m.refreshViewport() - } - } - - if msg.Done { - m.loading = false - } - - return m, nil - - case createNewSessionMsg: - activeModel, _ := m.cfg.ActiveModel() - sess := &store.Session{ - ID: fmt.Sprintf("sess_%d", time.Now().Unix()), - Title: "New Session", - ProjectPath: ".", - Provider: m.cfg.AI.Provider, - Model: activeModel, - AgentMode: "build", - } - if m.store != nil { - _ = m.store.CreateSession(sess) - } - - m.session = sess - m.agent = agent.New(m.provider, m.agent.Manager(), agent.Config{ - Store: m.store, - SessionID: sess.ID, - }) - - m.conversation.Clear() - m.conversation.AddAssistant("✨ Started a new session.") - m.refreshViewport() - return m, nil - - case undoFileChangeMsg: - if m.store == nil || m.session == nil { - m.conversation.AddAssistant("❌ Persistence not enabled.") - m.refreshViewport() - return m, nil - } - - change, err := m.store.GetLastFileChange(m.session.ID) - if err != nil { - m.conversation.AddAssistant("❌ No file changes found to undo.") - m.refreshViewport() - return m, nil - } - - if change.ChangeType == "create" { - _ = os.Remove(change.FilePath) - } else { - _ = os.WriteFile(change.FilePath, []byte(change.BeforeContent), 0644) - } - - m.conversation.AddAssistant(fmt.Sprintf("⏮️ **Undid edit** to `%s`", change.FilePath)) - m.refreshViewport() - return m, nil - - case redoFileChangeMsg: - if m.store == nil || m.session == nil { - m.conversation.AddAssistant("❌ Persistence not enabled.") - m.refreshViewport() - return m, nil - } - - change, err := m.store.GetLastFileChange(m.session.ID) - if err != nil { - m.conversation.AddAssistant("❌ No file changes found to redo.") - m.refreshViewport() - return m, nil - } - - if change.ChangeType == "delete" { - _ = os.Remove(change.FilePath) - } else { - _ = os.WriteFile(change.FilePath, []byte(change.AfterContent), 0644) - } - - m.conversation.AddAssistant(fmt.Sprintf("⏭️ **Redid edit** to `%s`", change.FilePath)) - m.refreshViewport() - return m, nil - - case switchSessionSuccessMsg: - sess, err := m.store.GetSession(msg.SessionID) - if err != nil { - m.conversation.AddAssistant(fmt.Sprintf("❌ Error loading session: %v", err)) - m.refreshViewport() - return m, nil - } - - m.session = sess - m.agent = agent.New(m.provider, m.agent.Manager(), agent.Config{ - Store: m.store, - SessionID: sess.ID, - }) - - m.conversation.Clear() - initialMessages := m.getInitialMessages() - for _, msg := range initialMessages { - if msg.Role == ai.RoleUser { - m.conversation.AddUser(msg.Content) - } else if msg.Role == ai.RoleAssistant { - content := msg.Content - if len(msg.ToolCalls) > 0 { - for _, tc := range msg.ToolCalls { - content += fmt.Sprintf("\n*(Tool Call: %s)*", tc.Name) - } - } - m.conversation.AddAssistant(content) - } else if msg.Role == ai.RoleTool { - content := "" - for _, tr := range msg.ToolResults { - content += fmt.Sprintf("\n*(Tool Result: %s)*", tr.Name) - } - m.conversation.AddAssistant(content) - } - } - - m.conversation.AddAssistant(fmt.Sprintf("✨ Loaded session: **%s**", sess.Title)) - m.refreshViewport() - m.loading = false - return m, nil - - case switchProviderSuccessMsg: - m.cfg.SetProvider(msg.Provider) - m.cfg.SetModel(msg.Provider, msg.Model) - _ = config.Save(m.cfg) - - provider, err := ai.New(m.cfg) - if err == nil { - m.provider = provider - manager := tools.NewManager() - defaults.RegisterAll(manager, func(cmd string) bool { - if program == nil { - return false - } - ch := make(chan bool) - program.Send(ApprovalRequestMsg{Command: cmd, ResponseChan: ch}) - return <-ch - }) - m.agent = agent.New(provider, manager, agent.Config{}) - } - - m.conversation.AddAssistant(fmt.Sprintf("✨ Provider switched to **%s** (model: `%s`)", msg.Provider, msg.Model)) - m.refreshViewport() - m.loading = false - return m, nil - - case switchModelSuccessMsg: - m.cfg.SetModel(m.cfg.AI.Provider, msg.Model) - _ = config.Save(m.cfg) - - provider, err := ai.New(m.cfg) - if err == nil { - m.provider = provider - manager := tools.NewManager() - defaults.RegisterAll(manager, func(cmd string) bool { - if program == nil { - return false - } - ch := make(chan bool) - program.Send(ApprovalRequestMsg{Command: cmd, ResponseChan: ch}) - return <-ch - }) - m.agent = agent.New(provider, manager, agent.Config{}) - } - - m.conversation.AddAssistant(fmt.Sprintf("✨ Model switched to `%s`", msg.Model)) - m.refreshViewport() - m.loading = false - return m, nil - - case switchCancelMsg: - m.conversation.AddAssistant("❌ Provider/model selection cancelled.") - m.refreshViewport() - m.loading = false - return m, nil - - case switchErrorMsg: - m.conversation.AddAssistant(fmt.Sprintf("❌ Error: %v", msg.Err)) - m.refreshViewport() - m.loading = false - return m, nil + return m.handleStreamMsg(msg) + + // All other custom events (Session, Agent Mode, Undo/Redo, Models) + case ApprovalRequestMsg, switchModeSuccessMsg, createNewSessionMsg, + undoFileChangeMsg, redoFileChangeMsg, switchSessionSuccessMsg, + switchProviderSuccessMsg, switchModelSuccessMsg, switchCancelMsg, switchErrorMsg: + + var evtCmd tea.Cmd + m, evtCmd = m.handleEventMsg(msg) + return m, evtCmd } + // Update text input for other key events that don't match the main handler m.input, cmd = m.input.Update(msg) - return m, cmd } diff --git a/internal/chat/update_events.go b/internal/chat/update_events.go new file mode 100644 index 0000000..b83dbeb --- /dev/null +++ b/internal/chat/update_events.go @@ -0,0 +1,223 @@ +package chat + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/Nithwin/WindMist/internal/agent" + "github.com/Nithwin/WindMist/internal/ai" + "github.com/Nithwin/WindMist/internal/config" + "github.com/Nithwin/WindMist/internal/store" + "github.com/Nithwin/WindMist/internal/tools" + "github.com/Nithwin/WindMist/internal/tools/defaults" + tea "github.com/charmbracelet/bubbletea" +) + +func (m Model) handleEventMsg(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case ApprovalRequestMsg: + m.waitingApproval = true + m.approvalCommand = msg.Command + m.approvalChan = msg.ResponseChan + m.refreshViewport() + return m, nil + + case switchModeSuccessMsg: + m.session.AgentMode = msg.Mode + if m.store != nil { + _ = m.store.UpdateSession(m.session) + } + + // Update Agent config mode + m.agent = agent.New(m.provider, m.agent.Manager(), agent.Config{ + Store: m.store, + SessionID: m.session.ID, + Mode: m.session.AgentMode, + }) + + m.conversation.AddAssistant(fmt.Sprintf("✨ Switched Agent Mode to **%s**", strings.ToUpper(msg.Mode))) + m.refreshViewport() + return m, nil + + case createNewSessionMsg: + activeModel, _ := m.cfg.ActiveModel() + sess := &store.Session{ + ID: fmt.Sprintf("sess_%d", time.Now().Unix()), + Title: "New Session", + ProjectPath: ".", + Provider: m.cfg.AI.Provider, + Model: activeModel, + AgentMode: "auto", + } + if m.store != nil { + _ = m.store.CreateSession(sess) + } + + m.session = sess + m.agent = agent.New(m.provider, m.agent.Manager(), agent.Config{ + Store: m.store, + SessionID: sess.ID, + Mode: sess.AgentMode, + }) + + m.conversation.Clear() + m.conversation.AddAssistant("✨ Started a new session.") + m.refreshViewport() + return m, nil + + case undoFileChangeMsg: + if m.store == nil || m.session == nil { + m.conversation.AddAssistant("❌ Persistence not enabled.") + m.refreshViewport() + return m, nil + } + + change, err := m.store.GetLastFileChange(m.session.ID) + if err != nil { + m.conversation.AddAssistant("❌ No file changes found to undo.") + m.refreshViewport() + return m, nil + } + + if change.ChangeType == "create" { + _ = os.Remove(change.FilePath) + } else { + _ = os.WriteFile(change.FilePath, []byte(change.BeforeContent), 0644) + } + + m.conversation.AddAssistant(fmt.Sprintf("⏮️ **Undid edit** to `%s`", change.FilePath)) + m.refreshViewport() + return m, nil + + case redoFileChangeMsg: + if m.store == nil || m.session == nil { + m.conversation.AddAssistant("❌ Persistence not enabled.") + m.refreshViewport() + return m, nil + } + + change, err := m.store.GetLastFileChange(m.session.ID) + if err != nil { + m.conversation.AddAssistant("❌ No file changes found to redo.") + m.refreshViewport() + return m, nil + } + + if change.ChangeType == "delete" { + _ = os.Remove(change.FilePath) + } else { + _ = os.WriteFile(change.FilePath, []byte(change.AfterContent), 0644) + } + + m.conversation.AddAssistant(fmt.Sprintf("⏭️ **Redid edit** to `%s`", change.FilePath)) + m.refreshViewport() + return m, nil + + case switchSessionSuccessMsg: + sess, err := m.store.GetSession(msg.SessionID) + if err != nil { + m.conversation.AddAssistant(fmt.Sprintf("❌ Error loading session: %v", err)) + m.refreshViewport() + return m, nil + } + + m.session = sess + m.agent = agent.New(m.provider, m.agent.Manager(), agent.Config{ + Store: m.store, + SessionID: sess.ID, + Mode: sess.AgentMode, + }) + + m.conversation.Clear() + initialMessages := m.getInitialMessages() + for _, msg := range initialMessages { + if msg.Role == ai.RoleUser { + m.conversation.AddUser(msg.Content) + } else if msg.Role == ai.RoleAssistant { + content := msg.Content + if len(msg.ToolCalls) > 0 { + for _, tc := range msg.ToolCalls { + content += fmt.Sprintf("\n*(Tool Call: %s)*", tc.Name) + } + } + m.conversation.AddAssistant(content) + } else if msg.Role == ai.RoleTool { + content := "" + for _, tr := range msg.ToolResults { + content += fmt.Sprintf("\n*(Tool Result: %s)*", tr.Name) + } + m.conversation.AddAssistant(content) + } + } + + m.conversation.AddAssistant(fmt.Sprintf("✨ Loaded session: **%s**", sess.Title)) + m.refreshViewport() + m.loading = false + return m, nil + + case switchProviderSuccessMsg: + m.cfg.SetProvider(msg.Provider) + m.cfg.SetModel(msg.Provider, msg.Model) + _ = config.Save(m.cfg) + + provider, err := ai.New(m.cfg) + if err == nil { + m.provider = provider + manager := tools.NewManager() + defaults.RegisterAll(manager, func(cmd string) bool { + if program == nil { + return false + } + ch := make(chan bool) + program.Send(ApprovalRequestMsg{Command: cmd, ResponseChan: ch}) + return <-ch + }) + m.agent = agent.New(provider, manager, agent.Config{}) + } + + m.conversation.AddAssistant(fmt.Sprintf("✨ Provider switched to **%s** (model: `%s`)", msg.Provider, msg.Model)) + m.refreshViewport() + m.loading = false + return m, nil + + case switchModelSuccessMsg: + m.cfg.SetModel(m.cfg.AI.Provider, msg.Model) + _ = config.Save(m.cfg) + + provider, err := ai.New(m.cfg) + if err == nil { + m.provider = provider + manager := tools.NewManager() + defaults.RegisterAll(manager, func(cmd string) bool { + if program == nil { + return false + } + ch := make(chan bool) + program.Send(ApprovalRequestMsg{Command: cmd, ResponseChan: ch}) + return <-ch + }) + m.agent = agent.New(provider, manager, agent.Config{}) + } + + m.conversation.AddAssistant(fmt.Sprintf("✨ Model switched to `%s`", msg.Model)) + m.refreshViewport() + m.loading = false + return m, nil + + case switchCancelMsg: + m.conversation.AddAssistant("❌ Provider/model selection cancelled.") + m.refreshViewport() + m.loading = false + return m, nil + + case switchErrorMsg: + m.conversation.AddAssistant(fmt.Sprintf("❌ Error: %v", msg.Err)) + m.refreshViewport() + m.loading = false + return m, nil + } + + return m, nil +} diff --git a/internal/chat/update_keys.go b/internal/chat/update_keys.go new file mode 100644 index 0000000..5b0e89e --- /dev/null +++ b/internal/chat/update_keys.go @@ -0,0 +1,213 @@ +package chat + +import ( + "context" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +func (m Model) handleKeyMsg(msg tea.KeyMsg) (Model, tea.Cmd) { + // Scroll conversation when command palette is closed. + if !m.showCommands { + switch msg.String() { + + case "ctrl+up", "shift+up": + m.viewport.ScrollUp(1) + return m, nil + + case "ctrl+down", "shift+down": + m.viewport.ScrollDown(1) + return m, nil + + case "up": + if len(m.inputHistory) > 0 && m.historyIndex > 0 { + m.historyIndex-- + m.input.SetValue(m.inputHistory[m.historyIndex]) + m.input.CursorEnd() + } + return m, nil + + case "down": + if len(m.inputHistory) > 0 && m.historyIndex < len(m.inputHistory) { + m.historyIndex++ + if m.historyIndex == len(m.inputHistory) { + m.input.SetValue("") + } else { + m.input.SetValue(m.inputHistory[m.historyIndex]) + m.input.CursorEnd() + } + } + return m, nil + + case "pgup": + m.viewport.ScrollUp(m.viewport.Height / 2) + return m, nil + + case "pgdown": + m.viewport.ScrollDown(m.viewport.Height / 2) + return m, nil + + case "home": + m.viewport.GotoTop() + return m, nil + + case "end": + m.viewport.GotoBottom() + return m, nil + } + } + + // Handle approval keys + if m.waitingApproval { + switch msg.String() { + case "y", "Y": + if m.approvalChan != nil { + m.approvalChan <- true + } + m.waitingApproval = false + m.refreshViewport() + return m, nil + case "n", "N": + if m.approvalChan != nil { + m.approvalChan <- false + } + m.waitingApproval = false + m.refreshViewport() + return m, nil + case "ctrl+c", "esc": + if m.approvalChan != nil { + m.approvalChan <- false + } + m.waitingApproval = false + return m, tea.Quit + } + // Block other inputs + return m, nil + } + + switch msg.String() { + case "ctrl+c", "esc": + if m.loading && m.cancel != nil { + m.cancel() + m.loading = false + m.conversation.AddAssistant("\n\n*(Cancelled by user)*") + m.refreshViewport() + return m, nil + } + return m, tea.Quit + } + + // Hide splash on first key press. + if m.showSplash { + m.showSplash = false + + // Preserve the first typed character. + if len(msg.String()) == 1 { + m.input.SetValue(msg.String()) + m.input.CursorEnd() + } + + m.refreshViewport() + return m, nil + } + + // Update slash command suggestions (check first line only). + value := m.input.Value() + firstLine := strings.SplitN(value, "\n", 2)[0] + + if strings.HasPrefix(firstLine, "/") { + m.showCommands = true + m.filteredCommands = FilterCommands(firstLine) + } else { + m.showCommands = false + m.filteredCommands = nil + m.selectedCommand = 0 + } + m.updateViewportSize() + + // Navigate the command palette. + if m.showCommands { + switch msg.String() { + + case "up": + if m.selectedCommand > 0 { + m.selectedCommand-- + } + return m, nil + + case "down": + if m.selectedCommand < len(m.filteredCommands)-1 { + m.selectedCommand++ + } + return m, nil + + case "esc": + m.showCommands = false + m.filteredCommands = nil + m.selectedCommand = 0 + return m, nil + } + } + switch msg.String() { + + case "enter": + prompt := strings.TrimSpace(m.input.Value()) + + if prompt == "" { + return m, nil + } + + // Execute selected command from palette. + if m.showCommands && len(m.filteredCommands) > 0 { + cmd := m.filteredCommands[m.selectedCommand] + + m.showCommands = false + m.filteredCommands = nil + m.selectedCommand = 0 + m.input.SetValue("") + + return m, cmd.Execute(&m) + } + + // Execute typed slash command. + if strings.HasPrefix(prompt, "/") { + m.inputHistory = append(m.inputHistory, prompt) + m.historyIndex = len(m.inputHistory) + + if command, ok := FindCommand(prompt); ok { + m.input.SetValue("") + return m, command.Execute(&m) + } + + m.conversation.AddAssistant("Unknown command: " + prompt) + m.input.SetValue("") + return m, nil + } + + // Normal AI message. + m.inputHistory = append(m.inputHistory, prompt) + m.historyIndex = len(m.inputHistory) + + m.conversation.AddUser(prompt) + m.refreshViewport() + m.loading = true + + m.input.SetValue("") + + // Create an empty assistant message. + // Streaming chunks will be appended to this. + m.conversation.AddAssistant("") + m.refreshViewport() + + ctx, cancel := context.WithCancel(context.Background()) + m.cancel = cancel + m.sendMessage(ctx, prompt) + + return m, nil + } + + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + return m, cmd +} diff --git a/internal/chat/update_stream.go b/internal/chat/update_stream.go new file mode 100644 index 0000000..4588df6 --- /dev/null +++ b/internal/chat/update_stream.go @@ -0,0 +1,33 @@ +package chat + +import tea "github.com/charmbracelet/bubbletea" + +func (m Model) handleStreamMsg(msg StreamingMsg) (Model, tea.Cmd) { + if msg.Err != nil { + m.loading = false + + if len(m.conversation.Messages) > 0 { + m.conversation.Messages[len(m.conversation.Messages)-1].Content = + "Error: " + msg.Err.Error() + + m.refreshViewport() + } + + return m, nil + } + + if len(m.conversation.Messages) > 0 { + last := &m.conversation.Messages[len(m.conversation.Messages)-1] + + if last.Role == "assistant" { + last.Content += msg.Text + m.refreshViewport() + } + } + + if msg.Done { + m.loading = false + } + + return m, nil +} From 8470a1067d0ed69f6ebd2fa0b20337c36ca7e4e2 Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 15:02:40 +0530 Subject: [PATCH 23/57] feat(agent): implement exact token-aware memory with tiktoken --- go.mod | 2 + go.sum | 10 ++++ internal/agent/agent.go | 5 ++ internal/agent/loop.go | 2 +- internal/agent/{messages.go => memory.go} | 49 ++++++++++++----- internal/agent/memory_test.go | 52 ++++++++++++++++++ internal/agent/messages_test.go | 67 ----------------------- 7 files changed, 106 insertions(+), 81 deletions(-) rename internal/agent/{messages.go => memory.go} (60%) create mode 100644 internal/agent/memory_test.go delete mode 100644 internal/agent/messages_test.go diff --git a/go.mod b/go.mod index b372ee1..1a413c6 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/jmoiron/sqlx v1.4.0 github.com/mattn/go-sqlite3 v1.14.48 + github.com/pkoukk/tiktoken-go v0.1.8 github.com/spf13/cobra v1.10.2 gopkg.in/yaml.v3 v3.0.1 ) @@ -29,6 +30,7 @@ require ( github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/google/uuid v1.3.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect diff --git a/go.sum b/go.sum index 5a4663f..cde0060 100644 --- a/go.sum +++ b/go.sum @@ -45,12 +45,16 @@ github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEX github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= @@ -85,6 +89,10 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pkoukk/tiktoken-go v0.1.8 h1:85ENo+3FpWgAACBaEUVp+lctuTcYUO7BtmfhlN/QTRo= +github.com/pkoukk/tiktoken-go v0.1.8/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= @@ -96,6 +104,8 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= diff --git a/internal/agent/agent.go b/internal/agent/agent.go index c55cf64..1dcb3c2 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -23,6 +23,8 @@ type Config struct { SessionID string // Mode is the operating mode of the agent (e.g., build, plan, auto). Mode string + // Memory defines the token pruning strategy. + Memory MemoryStrategy } // Result contains the final output produced by the agent. @@ -59,6 +61,9 @@ func New( if config.Mode == "" { config.Mode = string(ModeBuild) } + if config.Memory == nil { + config.Memory = SlidingWindowMemory{} + } return &Agent{ provider: provider, diff --git a/internal/agent/loop.go b/internal/agent/loop.go index e6a0907..e77ef2a 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -34,7 +34,7 @@ func (a *Agent) runLoop(ctx context.Context, messages []ai.Message, userPrompt s return nil, err } - prunedHistory := pruneMessages(messages, a.config.MaxContextTokens) + prunedHistory := a.config.Memory.Prune(messages, a.config.MaxContextTokens) // Build dynamic system prompt based on mode cwd, _ := os.Getwd() modeConfig := GetModeConfig(Mode(effectiveMode)) diff --git a/internal/agent/messages.go b/internal/agent/memory.go similarity index 60% rename from internal/agent/messages.go rename to internal/agent/memory.go index 8571988..59c55f0 100644 --- a/internal/agent/messages.go +++ b/internal/agent/memory.go @@ -4,8 +4,19 @@ import ( "fmt" "github.com/Nithwin/WindMist/internal/ai" + "github.com/pkoukk/tiktoken-go" ) +var tokenizer *tiktoken.Tiktoken + +func init() { + var err error + tokenizer, err = tiktoken.GetEncoding("cl100k_base") + if err != nil { + fmt.Printf("Warning: failed to load tiktoken: %v\n", err) + } +} + // appendUser appends a user message to the conversation history. func appendUser(messages []ai.Message, content string) []ai.Message { return append(messages, ai.Message{ @@ -34,35 +45,47 @@ func appendToolResults(messages []ai.Message, results []ai.ToolResult) []ai.Mess }) } -// estimateTokens roughly estimates the number of tokens in a string. -// A common heuristic is 1 token ≈ 4 characters. -func estimateTokens(s string) int { +// countTokens accurately counts the number of tokens in a string using tiktoken. +func countTokens(s string) int { + if tokenizer != nil { + return len(tokenizer.Encode(s, nil, nil)) + } + // Fallback heuristic if tokenizer failed to load return len(s) / 4 } -// estimateMessageTokens calculates the approximate token size of a message. -func estimateMessageTokens(m ai.Message) int { - tokens := estimateTokens(m.Content) +// countMessageTokens calculates the token size of a message. +func countMessageTokens(m ai.Message) int { + tokens := countTokens(m.Content) for _, call := range m.ToolCalls { - tokens += estimateTokens(call.Name) + estimateTokens(fmt.Sprintf("%v", call.Args)) + tokens += countTokens(call.Name) + countTokens(fmt.Sprintf("%v", call.Args)) } for _, res := range m.ToolResults { - tokens += estimateTokens(res.Name) + estimateTokens(res.Content) + tokens += countTokens(res.Name) + countTokens(res.Content) } - return tokens + // Add base padding per message + return tokens + 4 +} + +// MemoryStrategy defines an interface for context window management. +type MemoryStrategy interface { + Prune(messages []ai.Message, maxTokens int) []ai.Message } -// pruneMessages uses a sliding window approach based on token estimation. +// SlidingWindowMemory implements a basic sliding window token pruner. +type SlidingWindowMemory struct{} + +// Prune uses a sliding window approach based on exact token estimation. // It keeps the first message (original user instruction) and dynamically // retains as many recent messages as possible without exceeding maxTokens. -func pruneMessages(messages []ai.Message, maxTokens int) []ai.Message { +func (s SlidingWindowMemory) Prune(messages []ai.Message, maxTokens int) []ai.Message { if len(messages) <= 1 { return messages } // Always keep the first message firstMsg := messages[0] - firstTokens := estimateMessageTokens(firstMsg) + firstTokens := countMessageTokens(firstMsg) budget := maxTokens - firstTokens if budget < 0 { @@ -75,7 +98,7 @@ func pruneMessages(messages []ai.Message, maxTokens int) []ai.Message { // Iterate backwards from the last message to the second message for i := len(messages) - 1; i > 0; i-- { msg := messages[i] - tokens := estimateMessageTokens(msg) + tokens := countMessageTokens(msg) if currentTokens+tokens > budget { break diff --git a/internal/agent/memory_test.go b/internal/agent/memory_test.go new file mode 100644 index 0000000..44958a7 --- /dev/null +++ b/internal/agent/memory_test.go @@ -0,0 +1,52 @@ +package agent + +import ( + "testing" + + "github.com/Nithwin/WindMist/internal/ai" +) + +func TestPruneMessages(t *testing.T) { + shortHistory := []ai.Message{ + {Role: ai.RoleUser, Content: "Initial prompt"}, + {Role: ai.RoleAssistant, Content: "Step 1"}, + {Role: ai.RoleTool, Content: "Result 1"}, + } + mem := SlidingWindowMemory{} + pruned := mem.Prune(shortHistory, 1000) + if len(pruned) != 3 { + t.Errorf("expected length 3, got %d", len(pruned)) + } + + longHistory := []ai.Message{ + {Role: ai.RoleUser, Content: "Initial task goal"}, + {Role: ai.RoleAssistant, Content: "Turn 1 Assistant"}, + {Role: ai.RoleTool, Content: "Turn 1 Tool"}, + {Role: ai.RoleAssistant, Content: "Turn 2 Assistant"}, + {Role: ai.RoleTool, Content: "Turn 2 Tool"}, + {Role: ai.RoleAssistant, Content: "Turn 3 Assistant"}, + {Role: ai.RoleTool, Content: "Turn 3 Tool"}, + {Role: ai.RoleAssistant, Content: "Turn 4 Assistant"}, + {Role: ai.RoleTool, Content: "Turn 4 Tool"}, + } + + prunedLong := mem.Prune(longHistory, 50) + if len(prunedLong) < 2 { + t.Fatalf("expected at least 2 messages after pruning, got %d", len(prunedLong)) + } + + if prunedLong[0].Content != "Initial task goal" { + t.Errorf("expected first message to be preserved, got %q", prunedLong[0].Content) + } + + prunedDangling := mem.Prune(longHistory, 20) + if len(prunedDangling) > 0 { + for i := 1; i < len(prunedDangling); i++ { + if prunedDangling[i].Role == ai.RoleTool { + if prunedDangling[i-1].Role != ai.RoleAssistant { + t.Errorf("Dangling tool found! Tool result at index %d has no preceding Assistant msg", i) + } + } + } + } +} diff --git a/internal/agent/messages_test.go b/internal/agent/messages_test.go deleted file mode 100644 index 8ae0537..0000000 --- a/internal/agent/messages_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package agent - -import ( - "testing" - - "github.com/Nithwin/WindMist/internal/ai" -) - -func TestPruneMessages(t *testing.T) { - // Case 1: History is smaller or equal to budget -> Should not prune - shortHistory := []ai.Message{ - {Role: ai.RoleUser, Content: "Initial prompt"}, // 14/4 = 3 tokens - {Role: ai.RoleAssistant, Content: "Step 1"}, // 6/4 = 1 token - {Role: ai.RoleTool, Content: "Result 1"}, // 8/4 = 2 tokens - } - pruned := pruneMessages(shortHistory, 100) - if len(pruned) != 3 { - t.Errorf("expected length 3, got %d", len(pruned)) - } - - // Case 2: History is large -> Should keep index 0 + last fitting messages - longHistory := []ai.Message{ - {Role: ai.RoleUser, Content: "Initial task goal"}, // 17/4 = 4 tokens (always kept) - {Role: ai.RoleAssistant, Content: "Turn 1 Assistant"}, // 16/4 = 4 tokens - {Role: ai.RoleTool, Content: "Turn 1 Tool"}, // 11/4 = 2 tokens - {Role: ai.RoleAssistant, Content: "Turn 2 Assistant"}, // 16/4 = 4 tokens - {Role: ai.RoleTool, Content: "Turn 2 Tool"}, // 11/4 = 2 tokens - {Role: ai.RoleAssistant, Content: "Turn 3 Assistant"}, // 16/4 = 4 tokens - {Role: ai.RoleTool, Content: "Turn 3 Tool"}, // 11/4 = 2 tokens - {Role: ai.RoleAssistant, Content: "Turn 4 Assistant"}, // 16/4 = 4 tokens - {Role: ai.RoleTool, Content: "Turn 4 Tool"}, // 11/4 = 2 tokens - } - - // budget = 16 - 4 (first) = 12 tokens - // Turn 4 = 6 tokens, Turn 3 = 6 tokens. Both fit exactly (12 tokens). - prunedLong := pruneMessages(longHistory, 16) - if len(prunedLong) != 5 { // 1 initial + 4 recent = 5 total - t.Fatalf("expected 5 messages after pruning, got %d", len(prunedLong)) - } - - if prunedLong[0].Content != "Initial task goal" { - t.Errorf("expected first message to be preserved, got %q", prunedLong[0].Content) - } - - if prunedLong[1].Content != "Turn 3 Assistant" { - t.Errorf("expected second kept message to be 'Turn 3 Assistant', got %q", prunedLong[1].Content) - } - - if prunedLong[4].Content != "Turn 4 Tool" { - t.Errorf("expected last kept message to be 'Turn 4 Tool', got %q", prunedLong[4].Content) - } - - // Case 3: Dangling Tool Result - // budget = 14 - 4 (first) = 10 tokens - // Turn 4 = 6 tokens. Remaining budget = 4. - // Turn 3 Tool = 2 tokens. Remaining budget = 2. - // Turn 3 Assistant = 4 tokens. Exceeds budget (2 < 4)! Break. - // Keep list starts with "Turn 3 Tool", which is dangling. It should be stripped. - // Final expected: First message + Turn 4 = 3 messages. - prunedDangling := pruneMessages(longHistory, 14) - if len(prunedDangling) != 3 { - t.Fatalf("expected 3 messages after pruning dangling tool, got %d", len(prunedDangling)) - } - if prunedDangling[1].Content != "Turn 4 Assistant" { - t.Errorf("expected dangling tool to be removed and start with 'Turn 4 Assistant', got %q", prunedDangling[1].Content) - } -} From f0649d191ac0f3957e20a0269c59736c71e8374b Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 15:23:54 +0530 Subject: [PATCH 24/57] feat(agent): implement sub-agent system for robust background research --- cmd/chat.go | 2 +- internal/chat/model.go | 2 +- internal/chat/update_events.go | 4 +- internal/config/config.go | 41 +++++++++ internal/config/types.go | 7 ++ internal/tools/agent/subagent.go | 134 ++++++++++++++++++++++++++++ internal/tools/defaults/defaults.go | 6 +- 7 files changed, 191 insertions(+), 5 deletions(-) create mode 100644 internal/tools/agent/subagent.go diff --git a/cmd/chat.go b/cmd/chat.go index e7454f9..05d4420 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -52,7 +52,7 @@ var chatCmd = &cobra.Command{ var ans string fmt.Scanln(&ans) return ans == "y" || ans == "Y" - }) + }, cfg) ag := agent.New(provider, manager, agent.Config{}) diff --git a/internal/chat/model.go b/internal/chat/model.go index fb3c837..710a11e 100644 --- a/internal/chat/model.go +++ b/internal/chat/model.go @@ -79,7 +79,7 @@ func New() (Model, error) { ResponseChan: ch, }) return <-ch - }) + }, cfg) dbStore, err := store.NewStore() if err != nil { return Model{}, fmt.Errorf("failed to initialize db store: %w", err) diff --git a/internal/chat/update_events.go b/internal/chat/update_events.go index b83dbeb..674eaff 100644 --- a/internal/chat/update_events.go +++ b/internal/chat/update_events.go @@ -173,7 +173,7 @@ func (m Model) handleEventMsg(msg tea.Msg) (Model, tea.Cmd) { ch := make(chan bool) program.Send(ApprovalRequestMsg{Command: cmd, ResponseChan: ch}) return <-ch - }) + }, m.cfg) m.agent = agent.New(provider, manager, agent.Config{}) } @@ -197,7 +197,7 @@ func (m Model) handleEventMsg(msg tea.Msg) (Model, tea.Cmd) { ch := make(chan bool) program.Send(ApprovalRequestMsg{Command: cmd, ResponseChan: ch}) return <-ch - }) + }, m.cfg) m.agent = agent.New(provider, manager, agent.Config{}) } diff --git a/internal/config/config.go b/internal/config/config.go index def43a5..12f9731 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -143,3 +143,44 @@ func (c *Config) AddCustomModel(providerName, model string) { } c.CustomModels[providerName] = append(c.CustomModels[providerName], model) } + +// ActiveSubAgentProvider returns the provider to use for sub-agents. +// If the user hasn't explicitly set one, it falls back to the main AI provider. +func (c *Config) ActiveSubAgentProvider() string { + if c.SubAgent.Provider != "" { + return c.SubAgent.Provider + } + return c.AI.Provider +} + +// ActiveSubAgentModel returns the model to use for sub-agents. +// If the user hasn't explicitly set one, it attempts to use a fast default for the active provider. +// If no fast default exists, it falls back to the main AI model. +func (c *Config) ActiveSubAgentModel() string { + if c.SubAgent.Model != "" { + return c.SubAgent.Model + } + + provider := c.ActiveSubAgentProvider() + + // Hardcoded cheap/fast models for known providers + switch provider { + case "openai": + return "gpt-4o-mini" + case "anthropic": + return "claude-3-5-haiku-latest" + case "gemini": + return "gemini-2.5-flash" + case "groq": + return "llama-3.1-8b-instant" + } + + // Fallback to the main model if using the main provider + if provider == c.AI.Provider { + if m, err := c.ActiveModel(); err == nil { + return m + } + } + + return "" +} diff --git a/internal/config/types.go b/internal/config/types.go index cbe459e..10edceb 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -6,9 +6,16 @@ type Config struct { Providers map[string]ProviderConfig `yaml:"providers"` UI UIConfig `yaml:"ui"` Cache CacheConfig `yaml:"cache"` + SubAgent SubAgentConfig `yaml:"subagent,omitempty"` CustomModels map[string][]string `yaml:"custom_models,omitempty"` } +// SubAgentConfig stores the provider and model for sub-agents. +type SubAgentConfig struct { + Provider string `yaml:"provider,omitempty"` + Model string `yaml:"model,omitempty"` +} + // AIConfig stores the active AI provider. type AIConfig struct { Provider string `yaml:"provider"` diff --git a/internal/tools/agent/subagent.go b/internal/tools/agent/subagent.go new file mode 100644 index 0000000..0889cde --- /dev/null +++ b/internal/tools/agent/subagent.go @@ -0,0 +1,134 @@ +package agent + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/Nithwin/WindMist/internal/ai" + "github.com/Nithwin/WindMist/internal/config" + "github.com/Nithwin/WindMist/internal/tools" +) + +type subAgentArgs struct { + Task string `json:"task"` + Files []string `json:"files"` +} + +type subAgentTool struct { + cfg *config.Config +} + +// NewSubAgentTool creates a tool that delegates tasks to a smaller/faster LLM model. +func NewSubAgentTool(cfg *config.Config) tools.Tool { + return &subAgentTool{cfg: cfg} +} + +func (t *subAgentTool) Definition() tools.Definition { + return tools.Definition{ + Name: "spawn_subagent", + Description: "Spawns a sub-agent to read multiple files and summarize or analyze them based on a specific task. Use this to prevent cluttering the main context window when you need to research a large codebase.", + Parameters: []tools.Parameter{ + { + Name: "task", + Type: "string", + Description: "The specific research or analysis task for the sub-agent. E.g., 'Analyze how authentication is implemented and list the JWT secret name'.", + Required: true, + }, + { + Name: "files", + Type: "array", + Description: "List of exact file paths to read and analyze.", + Required: true, + }, + }, + } +} + +func (t *subAgentTool) Run(ctx context.Context, call tools.Call) tools.Result { + taskStr, ok := call.Args["task"].(string) + if !ok { + return tools.Result{Error: fmt.Errorf("task must be a string")} + } + + filesRaw, ok := call.Args["files"].([]any) + if !ok { + return tools.Result{Error: fmt.Errorf("files must be an array")} + } + + var files []string + for _, f := range filesRaw { + if fs, ok := f.(string); ok { + files = append(files, fs) + } + } + + if len(files) == 0 { + return tools.Result{Error: fmt.Errorf("no valid files provided")} + } + + // Read all files + var fileContents string + var filesRead []string + for _, file := range files { + cleanPath := filepath.Clean(file) + content, err := os.ReadFile(cleanPath) + if err != nil { + fileContents += fmt.Sprintf("File: %s\nError reading file: %v\n\n", cleanPath, err) + continue + } + fileContents += fmt.Sprintf("File: %s\n```\n%s\n```\n\n", cleanPath, string(content)) + filesRead = append(filesRead, cleanPath) + } + + // Prepare AI config using the fast model + providerName := t.cfg.ActiveSubAgentProvider() + modelName := t.cfg.ActiveSubAgentModel() + + // Create a temporary config for the sub-agent + subCfg := &config.Config{ + AI: config.AIConfig{Provider: providerName}, + Providers: map[string]config.ProviderConfig{ + providerName: { + Model: modelName, + }, + }, + } + + // Copy API key/base url from original provider if it exists + if origProvider, ok := t.cfg.Providers[providerName]; ok { + p := subCfg.Providers[providerName] + p.APIKey = origProvider.APIKey + p.BaseURL = origProvider.BaseURL + subCfg.Providers[providerName] = p + } + + provider, err := ai.New(subCfg) + if err != nil { + return tools.Result{Error: fmt.Errorf("failed to initialize sub-agent AI provider (%s/%s): %w", providerName, modelName, err)} + } + + systemPrompt := "You are a specialized sub-agent for an AI coding assistant. Your job is to read the provided files, analyze them, and fulfill the requested task concisely and accurately. Do not write full files, just provide the exact analysis requested." + + req := &ai.GenerateRequest{ + System: systemPrompt, + Messages: []ai.Message{ + { + Role: ai.RoleUser, + Content: fmt.Sprintf("Task: %s\n\nFiles Content:\n%s", taskStr, fileContents), + }, + }, + } + + resp, err := provider.Generate(ctx, req) + if err != nil { + return tools.Result{Error: fmt.Errorf("sub-agent generation failed: %w", err)} + } + + output := fmt.Sprintf("Sub-Agent Analysis (Model: %s/%s):\n\n%s", providerName, modelName, resp.Text) + return tools.Result{ + Output: output, + FilesRead: filesRead, + } +} diff --git a/internal/tools/defaults/defaults.go b/internal/tools/defaults/defaults.go index b3910b0..73c267e 100644 --- a/internal/tools/defaults/defaults.go +++ b/internal/tools/defaults/defaults.go @@ -1,6 +1,7 @@ package defaults import ( + "github.com/Nithwin/WindMist/internal/config" "github.com/Nithwin/WindMist/internal/tools" "github.com/Nithwin/WindMist/internal/tools/agent" "github.com/Nithwin/WindMist/internal/tools/editing" @@ -10,7 +11,7 @@ import ( ) // RegisterAll registers all built-in filesystem and editing tools onto the manager. -func RegisterAll(m *tools.Manager, approvalCb system.ApprovalCallback) { +func RegisterAll(m *tools.Manager, approvalCb system.ApprovalCallback, cfg *config.Config) { if m == nil { return } @@ -47,4 +48,7 @@ func RegisterAll(m *tools.Manager, approvalCb system.ApprovalCallback) { // Agent tools m.Register(agent.NewTodoTool()) + if cfg != nil { + m.Register(agent.NewSubAgentTool(cfg)) + } } From c009cc0ccf3e3e4610263757f356eaf5d9da3bfb Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 15:35:33 +0530 Subject: [PATCH 25/57] feat(agent): implement sub-agent fallback strategy and /subagent UI command --- internal/chat/commands.go | 68 ++++++++++++++++++++++++++++++++ internal/chat/messages.go | 6 +++ internal/chat/update.go | 2 +- internal/chat/update_events.go | 27 +++++++++++++ internal/tools/agent/subagent.go | 30 +++++++++++++- 5 files changed, 131 insertions(+), 2 deletions(-) diff --git a/internal/chat/commands.go b/internal/chat/commands.go index f6fc7f1..7229492 100644 --- a/internal/chat/commands.go +++ b/internal/chat/commands.go @@ -33,6 +33,7 @@ var Registry = []Command{ /model Change model /mode Change agent mode /provider Change provider +/subagent Configure sub-agent (cheaper background model) /exit Exit WindMist`, ) return nil @@ -93,6 +94,13 @@ var Registry = []Command{ return selectProviderCmd(m) }, }, + { + Name: "/subagent", + Description: "Configure sub-agent (cheaper background model)", + Execute: func(m *Model) tea.Cmd { + return selectSubagentCmd(m) + }, + }, { Name: "/exit", Description: "Exit WindMist", @@ -210,6 +218,66 @@ func selectProviderCmd(m *Model) tea.Cmd { } } +func selectSubagentCmd(m *Model) tea.Cmd { + return func() tea.Msg { + if program == nil { + return switchErrorMsg{Err: fmt.Errorf("program instance not initialized")} + } + + if err := program.ReleaseTerminal(); err != nil { + return switchErrorMsg{Err: fmt.Errorf("failed to release terminal: %w", err)} + } + defer program.RestoreTerminal() + + providerOpt, err := selector.Run( + "Select Sub-Agent Provider", + "Choose which AI provider the Sub-Agent should use (Auto uses main config):", + append([]selector.Option{{Label: "Auto (Use Main Config)", Value: "auto"}}, config.GetProviderOptions()...), + ) + if err != nil { + return switchCancelMsg{} + } + + if providerOpt.Value == "auto" { + return switchSubagentSuccessMsg{ + Provider: "", + Model: "", + } + } + + ollamaBaseURL := "" + if pConfig, ok := m.cfg.Providers[providerOpt.Value]; ok { + ollamaBaseURL = pConfig.BaseURL + } + modelOpt, err := selector.Run( + fmt.Sprintf("Select Sub-Agent Model for %s", providerOpt.Value), + "Choose the active model for this provider (Auto uses fast default):", + append([]selector.Option{{Label: "Auto (Fast Default)", Value: "auto"}}, m.cfg.GetModelOptions(providerOpt.Value, ollamaBaseURL)...), + ) + if err != nil { + return switchCancelMsg{} + } + + modelValue := modelOpt.Value + if modelValue == "auto" { + modelValue = "" + } else if modelValue == "__CUSTOM__" { + customVal, err := selector.RunInput("Custom Model ID", "Enter exact model ID (e.g. gpt-4o-mini)", "") + if err != nil { + return switchCancelMsg{} + } + modelValue = customVal + m.cfg.AddCustomModel(providerOpt.Value, modelValue) + _ = config.Save(m.cfg) + } + + return switchSubagentSuccessMsg{ + Provider: providerOpt.Value, + Model: modelValue, + } + } +} + func selectModelCmd(m *Model) tea.Cmd { return func() tea.Msg { if program == nil { diff --git a/internal/chat/messages.go b/internal/chat/messages.go index f2b1482..48c4435 100644 --- a/internal/chat/messages.go +++ b/internal/chat/messages.go @@ -22,6 +22,12 @@ type switchProviderSuccessMsg struct { Model string } +// switchSubagentSuccessMsg represents a successful subagent change. +type switchSubagentSuccessMsg struct { + Provider string + Model string +} + // switchModeSuccessMsg represents a successful agent mode change. type switchModeSuccessMsg struct { Mode string diff --git a/internal/chat/update.go b/internal/chat/update.go index d33f3c3..9c4aa46 100644 --- a/internal/chat/update.go +++ b/internal/chat/update.go @@ -25,7 +25,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // All other custom events (Session, Agent Mode, Undo/Redo, Models) case ApprovalRequestMsg, switchModeSuccessMsg, createNewSessionMsg, undoFileChangeMsg, redoFileChangeMsg, switchSessionSuccessMsg, - switchProviderSuccessMsg, switchModelSuccessMsg, switchCancelMsg, switchErrorMsg: + switchProviderSuccessMsg, switchModelSuccessMsg, switchSubagentSuccessMsg, switchCancelMsg, switchErrorMsg: var evtCmd tea.Cmd m, evtCmd = m.handleEventMsg(msg) diff --git a/internal/chat/update_events.go b/internal/chat/update_events.go index 674eaff..476d8e9 100644 --- a/internal/chat/update_events.go +++ b/internal/chat/update_events.go @@ -206,6 +206,33 @@ func (m Model) handleEventMsg(msg tea.Msg) (Model, tea.Cmd) { m.loading = false return m, nil + case switchSubagentSuccessMsg: + m.cfg.SubAgent.Provider = msg.Provider + m.cfg.SubAgent.Model = msg.Model + _ = config.Save(m.cfg) + + // Re-register tools with the new config so sub-agent picks it up + manager := tools.NewManager() + defaults.RegisterAll(manager, func(cmd string) bool { + if program == nil { + return false + } + ch := make(chan bool) + program.Send(ApprovalRequestMsg{Command: cmd, ResponseChan: ch}) + return <-ch + }, m.cfg) + m.agent = agent.New(m.provider, manager, agent.Config{}) + + if msg.Provider == "" { + m.conversation.AddAssistant("✨ Sub-Agent reset to Auto (will use fast fallback or main model).") + } else { + m.conversation.AddAssistant(fmt.Sprintf("✨ Sub-Agent switched to **%s** (model: `%s`)", msg.Provider, msg.Model)) + } + + m.refreshViewport() + m.loading = false + return m, nil + case switchCancelMsg: m.conversation.AddAssistant("❌ Provider/model selection cancelled.") m.refreshViewport() diff --git a/internal/tools/agent/subagent.go b/internal/tools/agent/subagent.go index 0889cde..e52c699 100644 --- a/internal/tools/agent/subagent.go +++ b/internal/tools/agent/subagent.go @@ -123,10 +123,38 @@ func (t *subAgentTool) Run(ctx context.Context, call tools.Call) tools.Result { resp, err := provider.Generate(ctx, req) if err != nil { - return tools.Result{Error: fmt.Errorf("sub-agent generation failed: %w", err)} + // AUTOMATIC SAFE FALLBACK + // If the cheap/sub-agent model fails, we automatically fallback to the user's main active model + mainProvider, mainErr := t.cfg.ActiveProvider() + if mainErr != nil { + return tools.Result{Error: fmt.Errorf("sub-agent failed (%w) and could not resolve main fallback: %v", err, mainErr)} + } + + fallbackProvider, fallbackErr := ai.New(t.cfg) // Use exactly the main config + if fallbackErr != nil { + return tools.Result{Error: fmt.Errorf("sub-agent failed (%w) and failed to init fallback: %v", err, fallbackErr)} + } + + resp, fallbackErr = fallbackProvider.Generate(ctx, req) + if fallbackErr != nil { + return tools.Result{Error: fmt.Errorf("sub-agent failed (%w) and main fallback also failed: %v", err, fallbackErr)} + } + + output := fmt.Sprintf("⚠️ Sub-agent (%s/%s) failed. Safely fell back to main model (%s/%s).\n\nSub-Agent Analysis:\n\n%s", providerName, modelName, t.cfg.AI.Provider, mainProvider.Model, resp.Text) + return tools.Result{ + Output: output, + FilesRead: filesRead, + } } output := fmt.Sprintf("Sub-Agent Analysis (Model: %s/%s):\n\n%s", providerName, modelName, resp.Text) + + // Add tip if we implicitly used the main model because no cheap one was configured + mainProvider, _ := t.cfg.ActiveProvider() + if t.cfg.SubAgent.Provider == "" && providerName == t.cfg.AI.Provider && modelName == mainProvider.Model { + output = "💡 Tip: Using main model for background research. Type `/subagent` to configure a cheaper model to save costs.\n\n" + output + } + return tools.Result{ Output: output, FilesRead: filesRead, From 4e5b6a6d3578af9e03ba878a76707b0602c52686 Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 15:43:41 +0530 Subject: [PATCH 26/57] feat(ui): implement configurable theme system --- internal/chat/commands.go | 40 +++++++ internal/chat/messages.go | 5 + internal/chat/model.go | 8 ++ internal/chat/update_events.go | 23 ++++ internal/ui/styles.go | 89 +++++++++----- internal/ui/theme.go | 209 +++++++++++++++++++++++++++++++++ 6 files changed, 343 insertions(+), 31 deletions(-) create mode 100644 internal/ui/theme.go diff --git a/internal/chat/commands.go b/internal/chat/commands.go index 7229492..06ce342 100644 --- a/internal/chat/commands.go +++ b/internal/chat/commands.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/Nithwin/WindMist/internal/config" + "github.com/Nithwin/WindMist/internal/ui" "github.com/Nithwin/WindMist/internal/ui/selector" tea "github.com/charmbracelet/bubbletea" ) @@ -34,6 +35,7 @@ var Registry = []Command{ /mode Change agent mode /provider Change provider /subagent Configure sub-agent (cheaper background model) +/theme Change UI theme /exit Exit WindMist`, ) return nil @@ -101,6 +103,13 @@ var Registry = []Command{ return selectSubagentCmd(m) }, }, + { + Name: "/theme", + Description: "Change UI theme", + Execute: func(m *Model) tea.Cmd { + return selectThemeCmd(m) + }, + }, { Name: "/exit", Description: "Exit WindMist", @@ -374,3 +383,34 @@ func FindCommand(name string) (Command, bool) { return Command{}, false } + +func selectThemeCmd(m *Model) tea.Cmd { + return func() tea.Msg { + if program == nil { + return switchErrorMsg{Err: fmt.Errorf("program instance not initialized")} + } + + if err := program.ReleaseTerminal(); err != nil { + return switchErrorMsg{Err: fmt.Errorf("failed to release terminal: %w", err)} + } + defer program.RestoreTerminal() + + themes := ui.AvailableThemes() + var options []selector.Option + for _, t := range themes { + options = append(options, selector.Option{ + Label: t, + Value: t, + }) + } + + opt, err := selector.Run("Select Theme", "Choose a UI theme:", options) + if err != nil { + return switchCancelMsg{} + } + + return switchThemeSuccessMsg{ + Theme: opt.Value, + } + } +} diff --git a/internal/chat/messages.go b/internal/chat/messages.go index 48c4435..f71aabb 100644 --- a/internal/chat/messages.go +++ b/internal/chat/messages.go @@ -52,6 +52,11 @@ type switchModelSuccessMsg struct { Model string } +// switchThemeSuccessMsg represents a successful theme change. +type switchThemeSuccessMsg struct { + Theme string +} + // switchCancelMsg represents a user cancellation of the menu. type switchCancelMsg struct{} diff --git a/internal/chat/model.go b/internal/chat/model.go index 710a11e..96ce9b7 100644 --- a/internal/chat/model.go +++ b/internal/chat/model.go @@ -3,6 +3,7 @@ package chat import ( "context" "fmt" + "path/filepath" "time" "github.com/Nithwin/WindMist/internal/agent" @@ -63,6 +64,13 @@ func New() (Model, error) { return Model{}, fmt.Errorf("failed to load configuration: %w", err) } + customDir := "" + cfgDir, err := config.ConfigDir() + if err == nil { + customDir = filepath.Join(cfgDir, "themes") + } + _ = ui.LoadTheme(cfg.UI.Theme, customDir) + provider, err := ai.New(cfg) if err != nil { return Model{}, fmt.Errorf("failed to initialize AI provider: %w", err) diff --git a/internal/chat/update_events.go b/internal/chat/update_events.go index 476d8e9..b48f4fd 100644 --- a/internal/chat/update_events.go +++ b/internal/chat/update_events.go @@ -3,6 +3,7 @@ package chat import ( "fmt" "os" + "path/filepath" "strings" "time" @@ -12,6 +13,7 @@ import ( "github.com/Nithwin/WindMist/internal/store" "github.com/Nithwin/WindMist/internal/tools" "github.com/Nithwin/WindMist/internal/tools/defaults" + "github.com/Nithwin/WindMist/internal/ui" tea "github.com/charmbracelet/bubbletea" ) @@ -233,6 +235,27 @@ func (m Model) handleEventMsg(msg tea.Msg) (Model, tea.Cmd) { m.loading = false return m, nil + case switchThemeSuccessMsg: + m.cfg.SetTheme(msg.Theme) + _ = config.Save(m.cfg) + + customDir := "" + cfgDir, err := config.ConfigDir() + if err == nil { + customDir = filepath.Join(cfgDir, "themes") + } + + err = ui.LoadTheme(msg.Theme, customDir) + if err != nil { + m.conversation.AddAssistant(fmt.Sprintf("❌ Failed to load theme: %v", err)) + } else { + m.conversation.AddAssistant(fmt.Sprintf("✨ Theme switched to **%s**", msg.Theme)) + } + + m.refreshViewport() + m.loading = false + return m, nil + case switchCancelMsg: m.conversation.AddAssistant("❌ Provider/model selection cancelled.") m.refreshViewport() diff --git a/internal/ui/styles.go b/internal/ui/styles.go index 0f2f564..288b4f0 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -15,65 +15,92 @@ var ( MutedLight = lipgloss.Color("#9CA3AF") Surface = lipgloss.Color("#1E1B2E") White = lipgloss.Color("#F8FAFC") + Border = lipgloss.Color("#3B3551") + Selection = lipgloss.Color("#3B3551") // ── Typography ────────────────────────────────────────────────── + TitleStyle lipgloss.Style + SubtitleStyle lipgloss.Style + LabelStyle lipgloss.Style + MutedStyle lipgloss.Style + MutedLightStyle lipgloss.Style + PromptStyle lipgloss.Style + SuccessStyle lipgloss.Style + ErrorStyle lipgloss.Style + DividerStyle lipgloss.Style + + // ── Chat bubbles ──────────────────────────────────────────────── + UserLabelStyle lipgloss.Style + UserBubbleStyle lipgloss.Style + AssistantLabelStyle lipgloss.Style + AssistantBubbleStyle lipgloss.Style + + // ── Input area ────────────────────────────────────────────────── + InputBoxStyle lipgloss.Style + InputBoxFocusStyle lipgloss.Style +) + +func init() { + UpdateStyles() +} + +// UpdateStyles re-evaluates all lipgloss styles based on the current color variables. +func UpdateStyles() { TitleStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(Purple) + Bold(true). + Foreground(Purple) SubtitleStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(Green) + Bold(true). + Foreground(Green) LabelStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(Cyan) + Bold(true). + Foreground(Cyan) MutedStyle = lipgloss.NewStyle(). - Foreground(Muted) + Foreground(Muted) MutedLightStyle = lipgloss.NewStyle(). - Foreground(MutedLight) + Foreground(MutedLight) PromptStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(Amber) + Bold(true). + Foreground(Amber) SuccessStyle = lipgloss.NewStyle(). - Foreground(Green) + Foreground(Green) ErrorStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(Red) + Bold(true). + Foreground(Red) DividerStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#3B3551")) + Foreground(Border) - // ── Chat bubbles ──────────────────────────────────────────────── UserLabelStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(Amber) + Bold(true). + Foreground(Amber) UserBubbleStyle = lipgloss.NewStyle(). - Foreground(White). - PaddingLeft(2) + Foreground(White). + PaddingLeft(2) AssistantLabelStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(Purple) + Bold(true). + Foreground(Purple) AssistantBubbleStyle = lipgloss.NewStyle(). - Foreground(MutedLight). - PaddingLeft(2) + Foreground(White). + PaddingLeft(2) - // ── Input area ────────────────────────────────────────────────── InputBoxStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#3B3551")). - Padding(0, 1) + Border(lipgloss.RoundedBorder()). + BorderForeground(Border). + Padding(0, 1) InputBoxFocusStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(Purple). - Padding(0, 1) -) + Border(lipgloss.RoundedBorder()). + BorderForeground(Purple). + Padding(0, 1) +} diff --git a/internal/ui/theme.go b/internal/ui/theme.go new file mode 100644 index 0000000..34cf283 --- /dev/null +++ b/internal/ui/theme.go @@ -0,0 +1,209 @@ +package ui + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/charmbracelet/lipgloss" + "gopkg.in/yaml.v3" +) + +type ThemeColors struct { + Background string `yaml:"background"` + Foreground string `yaml:"foreground"` + Accent string `yaml:"accent"` + Success string `yaml:"success"` + Error string `yaml:"error"` + Warning string `yaml:"warning"` + Info string `yaml:"info"` + Muted string `yaml:"muted"` + Border string `yaml:"border"` + Selection string `yaml:"selection"` +} + +type Theme struct { + Name string `yaml:"name"` + Colors ThemeColors `yaml:"colors"` +} + +var BuiltinThemes = map[string]Theme{ + "catppuccin": { + Name: "catppuccin", + Colors: ThemeColors{ + Background: "#1e1e2e", + Foreground: "#cdd6f4", + Accent: "#cba6f7", + Success: "#a6e3a1", + Error: "#f38ba8", + Warning: "#f9e2af", + Info: "#89b4fa", + Muted: "#6c7086", + Border: "#313244", + Selection: "#313244", + }, + }, + "dracula": { + Name: "dracula", + Colors: ThemeColors{ + Background: "#282a36", + Foreground: "#f8f8f2", + Accent: "#bd93f9", + Success: "#50fa7b", + Error: "#ff5555", + Warning: "#f1fa8c", + Info: "#8be9fd", + Muted: "#6272a4", + Border: "#44475a", + Selection: "#44475a", + }, + }, + "gruvbox": { + Name: "gruvbox", + Colors: ThemeColors{ + Background: "#282828", + Foreground: "#ebdbb2", + Accent: "#d3869b", + Success: "#b8bb26", + Error: "#fb4934", + Warning: "#fabd2f", + Info: "#83a598", + Muted: "#928374", + Border: "#504945", + Selection: "#504945", + }, + }, + "nord": { + Name: "nord", + Colors: ThemeColors{ + Background: "#2e3440", + Foreground: "#d8dee9", + Accent: "#b48ead", + Success: "#a3be8c", + Error: "#bf616a", + Warning: "#ebcb8b", + Info: "#81a1c1", + Muted: "#4c566a", + Border: "#3b4252", + Selection: "#3b4252", + }, + }, + "tokyo-night": { + Name: "tokyo-night", + Colors: ThemeColors{ + Background: "#1a1b26", + Foreground: "#c0caf5", + Accent: "#bb9af7", + Success: "#9ece6a", + Error: "#f7768e", + Warning: "#e0af68", + Info: "#7aa2f7", + Muted: "#565f89", + Border: "#292e42", + Selection: "#292e42", + }, + }, + "solarized": { + Name: "solarized", + Colors: ThemeColors{ + Background: "#002b36", + Foreground: "#839496", + Accent: "#6c71c4", + Success: "#859900", + Error: "#dc322f", + Warning: "#b58900", + Info: "#268bd2", + Muted: "#586e75", + Border: "#073642", + Selection: "#073642", + }, + }, + "monokai": { + Name: "monokai", + Colors: ThemeColors{ + Background: "#272822", + Foreground: "#f8f8f2", + Accent: "#ae81ff", + Success: "#a6e22e", + Error: "#f92672", + Warning: "#e6db74", + Info: "#66d9ef", + Muted: "#75715e", + Border: "#3e3d32", + Selection: "#3e3d32", + }, + }, + "windmist": { + Name: "windmist", + Colors: ThemeColors{ + Background: "#1E1B2E", + Foreground: "#F8FAFC", + Accent: "#8B5CF6", + Success: "#10B981", + Error: "#EF4444", + Warning: "#F59E0B", + Info: "#22D3EE", + Muted: "#6B7280", + Border: "#3B3551", + Selection: "#3B3551", + }, + }, +} + +var CurrentThemeName = "windmist" + +func ApplyTheme(t Theme) { + CurrentThemeName = t.Name + + Purple = lipgloss.Color(t.Colors.Accent) + PurpleDark = lipgloss.Color(t.Colors.Accent) + PurpleDim = lipgloss.Color(t.Colors.Accent) + Cyan = lipgloss.Color(t.Colors.Info) + Green = lipgloss.Color(t.Colors.Success) + Amber = lipgloss.Color(t.Colors.Warning) + Red = lipgloss.Color(t.Colors.Error) + Muted = lipgloss.Color(t.Colors.Muted) + MutedLight = lipgloss.Color(t.Colors.Foreground) + Surface = lipgloss.Color(t.Colors.Background) + White = lipgloss.Color(t.Colors.Foreground) + Border = lipgloss.Color(t.Colors.Border) + Selection = lipgloss.Color(t.Colors.Selection) + + UpdateStyles() +} + +func LoadTheme(name string, customDir string) error { + if name == "" { + name = "windmist" + } + + // First check built-in themes + if t, ok := BuiltinThemes[name]; ok { + ApplyTheme(t) + return nil + } + + // Try loading from customDir + themePath := filepath.Join(customDir, name+".yaml") + data, err := os.ReadFile(themePath) + if err != nil { + ApplyTheme(BuiltinThemes["windmist"]) + return fmt.Errorf("theme %s not found: %w", name, err) + } + + var t Theme + if err := yaml.Unmarshal(data, &t); err != nil { + return fmt.Errorf("failed to parse theme %s: %w", name, err) + } + + ApplyTheme(t) + return nil +} + +func AvailableThemes() []string { + var themes []string + for k := range BuiltinThemes { + themes = append(themes, k) + } + return themes +} From 1bfd9d69458d3ab2cfa265ba42e9fae5432ba499 Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 15:55:14 +0530 Subject: [PATCH 27/57] fix(ui): apply global background color and fix theme updating --- internal/chat/model.go | 35 ++++++++++++++++++++-------------- internal/chat/update.go | 2 +- internal/chat/update_events.go | 1 + internal/chat/view.go | 8 +++++++- internal/ui/theme.go | 20 +++++++++---------- 5 files changed, 40 insertions(+), 26 deletions(-) diff --git a/internal/chat/model.go b/internal/chat/model.go index 96ce9b7..aa13a58 100644 --- a/internal/chat/model.go +++ b/internal/chat/model.go @@ -126,20 +126,10 @@ func New() (Model, error) { ta.SetHeight(3) ta.ShowLineNumbers = false ta.Prompt = "" - - // Clean minimal style — no borders, transparent background - plain := lipgloss.NewStyle() - ta.FocusedStyle.Base = plain.Foreground(ui.White) - ta.FocusedStyle.CursorLine = plain.Foreground(ui.White) - ta.FocusedStyle.Placeholder = plain.Foreground(ui.Muted) - ta.FocusedStyle.EndOfBuffer = plain.Foreground(ui.Muted) - ta.BlurredStyle.Base = plain.Foreground(ui.MutedLight) - ta.BlurredStyle.Placeholder = plain.Foreground(ui.Muted) - ta.BlurredStyle.CursorLine = plain - + vp := viewport.New(0, 0) - - return Model{ + + model := Model{ cfg: cfg, provider: provider, agent: ag, @@ -164,7 +154,12 @@ func New() (Model, error) { viewport: vp, markdown: renderer, - }, nil + } + + model.UpdateInputStyles() + + return model, nil + } // Init initializes the application. @@ -184,3 +179,15 @@ func (m Model) MaxContentWidth() int { } return w } + +// UpdateInputStyles applies the current UI colors to the textarea input. +func (m *Model) UpdateInputStyles() { + plain := lipgloss.NewStyle() + m.input.FocusedStyle.Base = plain.Foreground(ui.White) + m.input.FocusedStyle.CursorLine = plain.Foreground(ui.White) + m.input.FocusedStyle.Placeholder = plain.Foreground(ui.Muted) + m.input.FocusedStyle.EndOfBuffer = plain.Foreground(ui.Muted) + m.input.BlurredStyle.Base = plain.Foreground(ui.MutedLight) + m.input.BlurredStyle.Placeholder = plain.Foreground(ui.Muted) + m.input.BlurredStyle.CursorLine = plain +} diff --git a/internal/chat/update.go b/internal/chat/update.go index 9c4aa46..7245401 100644 --- a/internal/chat/update.go +++ b/internal/chat/update.go @@ -25,7 +25,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // All other custom events (Session, Agent Mode, Undo/Redo, Models) case ApprovalRequestMsg, switchModeSuccessMsg, createNewSessionMsg, undoFileChangeMsg, redoFileChangeMsg, switchSessionSuccessMsg, - switchProviderSuccessMsg, switchModelSuccessMsg, switchSubagentSuccessMsg, switchCancelMsg, switchErrorMsg: + switchProviderSuccessMsg, switchModelSuccessMsg, switchSubagentSuccessMsg, switchThemeSuccessMsg, switchCancelMsg, switchErrorMsg: var evtCmd tea.Cmd m, evtCmd = m.handleEventMsg(msg) diff --git a/internal/chat/update_events.go b/internal/chat/update_events.go index b48f4fd..260e285 100644 --- a/internal/chat/update_events.go +++ b/internal/chat/update_events.go @@ -249,6 +249,7 @@ func (m Model) handleEventMsg(msg tea.Msg) (Model, tea.Cmd) { if err != nil { m.conversation.AddAssistant(fmt.Sprintf("❌ Failed to load theme: %v", err)) } else { + m.UpdateInputStyles() m.conversation.AddAssistant(fmt.Sprintf("✨ Theme switched to **%s**", msg.Theme)) } diff --git a/internal/chat/view.go b/internal/chat/view.go index 131bc83..7e14546 100644 --- a/internal/chat/view.go +++ b/internal/chat/view.go @@ -58,5 +58,11 @@ func (m Model) View() string { b.WriteString("\n") } - return b.String() + appStyle := lipgloss.NewStyle(). + Width(m.width). + Height(m.height). + Background(ui.Surface). + Foreground(ui.White) + + return appStyle.Render(b.String()) } diff --git a/internal/ui/theme.go b/internal/ui/theme.go index 34cf283..f75c6be 100644 --- a/internal/ui/theme.go +++ b/internal/ui/theme.go @@ -136,16 +136,16 @@ var BuiltinThemes = map[string]Theme{ "windmist": { Name: "windmist", Colors: ThemeColors{ - Background: "#1E1B2E", - Foreground: "#F8FAFC", - Accent: "#8B5CF6", - Success: "#10B981", - Error: "#EF4444", - Warning: "#F59E0B", - Info: "#22D3EE", - Muted: "#6B7280", - Border: "#3B3551", - Selection: "#3B3551", + Background: "#09090b", // Deep zinc + Foreground: "#fafafa", // Crisp white + Accent: "#a855f7", // Vibrant purple + Success: "#10b981", // Emerald + Error: "#f43f5e", // Rose + Warning: "#f59e0b", // Amber + Info: "#0ea5e9", // Sky blue + Muted: "#71717a", // Zinc muted + Border: "#27272a", // Zinc border + Selection: "#27272a", }, }, } From da7fe236bac03ab689fa7a0529fd96b67f26c16c Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 16:34:10 +0530 Subject: [PATCH 28/57] fix(ui): eliminate black styling artifacts --- internal/chat/banner.go | 9 ++++--- internal/chat/conversation.go | 16 ++++++------- internal/chat/header.go | 12 +++++----- internal/chat/model.go | 3 +-- internal/chat/palette.go | 2 +- internal/chat/view.go | 8 +++---- internal/ui/markdown.go | 21 ++++++++++++----- internal/ui/styles.go | 33 ++++++++++++++------------ update_markdown.patch | 44 +++++++++++++++++++++++++++++++++++ 9 files changed, 101 insertions(+), 47 deletions(-) create mode 100644 update_markdown.patch diff --git a/internal/chat/banner.go b/internal/chat/banner.go index eee4392..8e88583 100644 --- a/internal/chat/banner.go +++ b/internal/chat/banner.go @@ -4,7 +4,6 @@ import ( "strings" "github.com/Nithwin/WindMist/internal/ui" - "github.com/charmbracelet/lipgloss" ) func renderBanner(m Model) string { @@ -17,12 +16,12 @@ func renderBanner(m Model) string { ╚███╔███╔╝██║██║ ╚████║██████╔╝██║ ╚═╝ ██║██║███████║ ██║ ╚══╝╚══╝ ╚═╝╚═╝ ╚═══╝╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═╝` - cyanStyle := lipgloss.NewStyle().Foreground(ui.Cyan) + cyanStyle := ui.BaseStyle.Copy().Foreground(ui.Cyan) b.WriteString(cyanStyle.Bold(true).Render(wordmark)) b.WriteString("\n") - b.WriteString(lipgloss.NewStyle().Foreground(ui.MutedLight).Render("🌀 WindMist v0.5 — AI Coding Assistant")) + b.WriteString(ui.BaseStyle.Copy().Foreground(ui.MutedLight).Render("🌀 WindMist v0.5 — AI Coding Assistant")) b.WriteString("\n\n") b.WriteString(ui.LabelStyle.Render("Provider : ")) @@ -39,9 +38,9 @@ func renderBanner(m Model) string { if m.session != nil { modeColor := ui.SuccessStyle if m.session.AgentMode == "plan" { - modeColor = lipgloss.NewStyle().Foreground(ui.Amber) + modeColor = ui.BaseStyle.Copy().Foreground(ui.Amber) } else if m.session.AgentMode == "auto" { - modeColor = lipgloss.NewStyle().Foreground(ui.Purple) + modeColor = ui.BaseStyle.Copy().Foreground(ui.Purple) } b.WriteString(modeColor.Render(strings.ToUpper(m.session.AgentMode))) } else { diff --git a/internal/chat/conversation.go b/internal/chat/conversation.go index 1e05bb2..665bd96 100644 --- a/internal/chat/conversation.go +++ b/internal/chat/conversation.go @@ -15,9 +15,9 @@ func renderConversation(m Model) string { lipgloss.Left, ui.AssistantLabelStyle.Render("🌀 WindMist v0.5 is ready"), ui.MutedStyle.Render("Type a message below, or try:"), - "", - " "+ui.LabelStyle.Render("/help")+" "+ui.MutedLightStyle.Render("→ show all commands"), - " "+ui.LabelStyle.Render("/exit")+" "+ui.MutedLightStyle.Render("→ quit"), + ui.BaseStyle.Copy().Render(""), + ui.BaseStyle.Copy().Render(" "+ui.LabelStyle.Render("/help")+" "+ui.MutedLightStyle.Render("→ show all commands")), + ui.BaseStyle.Copy().Render(" "+ui.LabelStyle.Render("/exit")+" "+ui.MutedLightStyle.Render("→ quit")), ) b.WriteString(hint) b.WriteString("\n\n") @@ -39,15 +39,15 @@ func renderConversation(m Model) string { case "user": label := ui.UserLabelStyle.Render(" you") b.WriteString(label) - b.WriteString("\n") + b.WriteString(ui.BaseStyle.Copy().Render("\n")) content := ui.UserBubbleStyle.Width(maxWidth).Render(msg.Content) b.WriteString(content) - b.WriteString("\n") + b.WriteString(ui.BaseStyle.Copy().Render("\n")) case "assistant": label := ui.AssistantLabelStyle.Render("🌀 WindMist v0.5") b.WriteString(label) - b.WriteString("\n") + b.WriteString(ui.BaseStyle.Copy().Render("\n")) contentStr := msg.Content if contentStr == "" && m.loading && i == len(m.conversation.Messages)-1 { contentStr = ui.MutedStyle.Render("Thinking...") @@ -57,13 +57,13 @@ func renderConversation(m Model) string { contentStr = ui.AssistantBubbleStyle.Render(rendered) } b.WriteString(contentStr) - b.WriteString("\n") + b.WriteString(ui.BaseStyle.Copy().Render("\n")) } // subtle divider between exchanges (not after last msg) if i < len(m.conversation.Messages)-1 { b.WriteString(divider) - b.WriteString("\n") + b.WriteString(ui.BaseStyle.Copy().Render("\n")) } } diff --git a/internal/chat/header.go b/internal/chat/header.go index 5439c1f..2ded6ca 100644 --- a/internal/chat/header.go +++ b/internal/chat/header.go @@ -15,24 +15,24 @@ func renderHeader(m Model) string { } // ── left: brand name ────────────────────────────────────────── - logo := lipgloss.NewStyle(). + logo := ui.BaseStyle.Copy(). Bold(true). Foreground(ui.Purple). Render("🌀 WindMist v0.5") // ── right: provider badge ──────────────────────────────────── - providerTag := lipgloss.NewStyle(). + providerTag := ui.BaseStyle.Copy(). Bold(true). Foreground(ui.Cyan). Render(m.cfg.AI.Provider) - modelTag := lipgloss.NewStyle(). + modelTag := ui.BaseStyle.Copy(). Foreground(ui.MutedLight). Render(model) right := fmt.Sprintf("%s %s %s", providerTag, - lipgloss.NewStyle().Foreground(ui.Muted).Render("›"), + ui.BaseStyle.Copy().Foreground(ui.Muted).Render("›"), modelTag, ) @@ -48,11 +48,11 @@ func renderHeader(m Model) string { row := lipgloss.JoinHorizontal( lipgloss.Center, logo, - strings.Repeat(" ", gap), + ui.BaseStyle.Copy().Render(strings.Repeat(" ", gap)), right, ) - box := lipgloss.NewStyle(). + box := ui.BaseStyle.Copy(). Border(lipgloss.RoundedBorder()). BorderForeground(ui.PurpleDark). Padding(0, 1). diff --git a/internal/chat/model.go b/internal/chat/model.go index aa13a58..4cccf91 100644 --- a/internal/chat/model.go +++ b/internal/chat/model.go @@ -16,7 +16,6 @@ import ( "github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" ) // Model represents the WindMist application. @@ -182,7 +181,7 @@ func (m Model) MaxContentWidth() int { // UpdateInputStyles applies the current UI colors to the textarea input. func (m *Model) UpdateInputStyles() { - plain := lipgloss.NewStyle() + plain := ui.BaseStyle.Copy() m.input.FocusedStyle.Base = plain.Foreground(ui.White) m.input.FocusedStyle.CursorLine = plain.Foreground(ui.White) m.input.FocusedStyle.Placeholder = plain.Foreground(ui.Muted) diff --git a/internal/chat/palette.go b/internal/chat/palette.go index 5b0263f..4987b68 100644 --- a/internal/chat/palette.go +++ b/internal/chat/palette.go @@ -40,7 +40,7 @@ func renderCommandPalette(m Model) string { content := strings.Join(rows, "\n") - box := lipgloss.NewStyle(). + box := ui.BaseStyle.Copy(). Border(lipgloss.RoundedBorder()). BorderForeground(ui.PurpleDark). Padding(0, 1). diff --git a/internal/chat/view.go b/internal/chat/view.go index 7e14546..c621493 100644 --- a/internal/chat/view.go +++ b/internal/chat/view.go @@ -30,13 +30,13 @@ func (m Model) View() string { } if m.waitingApproval { - approvalBox := lipgloss.NewStyle(). + approvalBox := ui.BaseStyle.Copy(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("220")). Padding(1, 2). Render( - lipgloss.NewStyle().Foreground(lipgloss.Color("220")).Bold(true).Render(fmt.Sprintf("⚠️ Agent wants to run: %s", m.approvalCommand)) + "\n\n" + - lipgloss.NewStyle().Render("Allow execution? (y/N)"), + ui.BaseStyle.Copy().Foreground(lipgloss.Color("220")).Bold(true).Render(fmt.Sprintf("⚠️ Agent wants to run: %s", m.approvalCommand)) + "\n\n" + + ui.BaseStyle.Copy().Render("Allow execution? (y/N)"), ) b.WriteString(approvalBox) b.WriteString("\n") @@ -45,7 +45,7 @@ func (m Model) View() string { promptLabel := lipgloss.JoinHorizontal( lipgloss.Center, ui.PromptStyle.Render(" user"), - lipgloss.NewStyle().Foreground(ui.Muted).Render(" › "), + ui.BaseStyle.Copy().Foreground(ui.Muted).Render(" › "), ) inputRow := lipgloss.JoinHorizontal( diff --git a/internal/ui/markdown.go b/internal/ui/markdown.go index 3a448d9..6b3adf1 100644 --- a/internal/ui/markdown.go +++ b/internal/ui/markdown.go @@ -1,12 +1,16 @@ package ui -import "github.com/charmbracelet/glamour" +import ( + "fmt" + "github.com/charmbracelet/glamour" +) // windmistStyle is a minimal, clean Glamour style for WindMist. // Plain white text, bold headings, bordered code blocks, no flashy colors. -var windmistStyle = []byte(`{ +var windmistStyleTemplate = `{ "document": { - "margin": 0 + "margin": 0, + "background_color": "%s" }, "block_quote": { "indent": 2, @@ -156,7 +160,12 @@ var windmistStyle = []byte(`{ }, "html_block": {}, "html_span": {} -}`) +}` + +func getGlamourStyle() []byte { + // Surface is a lipgloss.Color, which is a string holding the hex code + return []byte(fmt.Sprintf(windmistStyleTemplate, string(Surface))) +} type MarkdownRenderer struct { renderer *glamour.TermRenderer @@ -164,7 +173,7 @@ type MarkdownRenderer struct { func NewMarkdownRenderer() (*MarkdownRenderer, error) { r, err := glamour.NewTermRenderer( - glamour.WithStylesFromJSONBytes(windmistStyle), + glamour.WithStylesFromJSONBytes(getGlamourStyle()), glamour.WithWordWrap(0), ) @@ -198,7 +207,7 @@ func (m *MarkdownRenderer) RenderWithWidth(text string, width int) string { width = 120 } r, err := glamour.NewTermRenderer( - glamour.WithStylesFromJSONBytes(windmistStyle), + glamour.WithStylesFromJSONBytes(getGlamourStyle()), glamour.WithWordWrap(width), ) if err != nil { diff --git a/internal/ui/styles.go b/internal/ui/styles.go index 288b4f0..e8f9fa6 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -19,6 +19,7 @@ var ( Selection = lipgloss.Color("#3B3551") // ── Typography ────────────────────────────────────────────────── + BaseStyle lipgloss.Style TitleStyle lipgloss.Style SubtitleStyle lipgloss.Style LabelStyle lipgloss.Style @@ -46,60 +47,62 @@ func init() { // UpdateStyles re-evaluates all lipgloss styles based on the current color variables. func UpdateStyles() { - TitleStyle = lipgloss.NewStyle(). + BaseStyle = lipgloss.NewStyle().Background(Surface) + + TitleStyle = BaseStyle. Bold(true). Foreground(Purple) - SubtitleStyle = lipgloss.NewStyle(). + SubtitleStyle = BaseStyle. Bold(true). Foreground(Green) - LabelStyle = lipgloss.NewStyle(). + LabelStyle = BaseStyle. Bold(true). Foreground(Cyan) - MutedStyle = lipgloss.NewStyle(). + MutedStyle = BaseStyle. Foreground(Muted) - MutedLightStyle = lipgloss.NewStyle(). + MutedLightStyle = BaseStyle. Foreground(MutedLight) - PromptStyle = lipgloss.NewStyle(). + PromptStyle = BaseStyle. Bold(true). Foreground(Amber) - SuccessStyle = lipgloss.NewStyle(). + SuccessStyle = BaseStyle. Foreground(Green) - ErrorStyle = lipgloss.NewStyle(). + ErrorStyle = BaseStyle. Bold(true). Foreground(Red) - DividerStyle = lipgloss.NewStyle(). + DividerStyle = BaseStyle. Foreground(Border) - UserLabelStyle = lipgloss.NewStyle(). + UserLabelStyle = BaseStyle. Bold(true). Foreground(Amber) - UserBubbleStyle = lipgloss.NewStyle(). + UserBubbleStyle = BaseStyle. Foreground(White). PaddingLeft(2) - AssistantLabelStyle = lipgloss.NewStyle(). + AssistantLabelStyle = BaseStyle. Bold(true). Foreground(Purple) - AssistantBubbleStyle = lipgloss.NewStyle(). + AssistantBubbleStyle = BaseStyle. Foreground(White). PaddingLeft(2) - InputBoxStyle = lipgloss.NewStyle(). + InputBoxStyle = BaseStyle. Border(lipgloss.RoundedBorder()). BorderForeground(Border). Padding(0, 1) - InputBoxFocusStyle = lipgloss.NewStyle(). + InputBoxFocusStyle = BaseStyle. Border(lipgloss.RoundedBorder()). BorderForeground(Purple). Padding(0, 1) diff --git a/update_markdown.patch b/update_markdown.patch new file mode 100644 index 0000000..83e5770 --- /dev/null +++ b/update_markdown.patch @@ -0,0 +1,44 @@ +--- internal/ui/markdown.go ++++ internal/ui/markdown.go +@@ -3,11 +3,14 @@ + import ( ++ "fmt" + "github.com/charmbracelet/glamour" ++ "github.com/charmbracelet/lipgloss" + ) + + // windmistStyle is a minimal, clean Glamour style for WindMist. + // Plain white text, bold headings, bordered code blocks, no flashy colors. +-var windmistStyle = []byte(`{ ++var windmistStyleTemplate = `{ + "document": { +- "margin": 0 ++ "margin": 0, ++ "background_color": "%s" + }, +@@ -158,5 +161,12 @@ + "html_span": {} +-}`) ++}` ++ ++func getGlamourStyle() []byte { ++ bg := Surface ++ hex := "" // Need to extract hex from lipgloss.Color, but it is just a string! ++ hex = string(bg) ++ return []byte(fmt.Sprintf(windmistStyleTemplate, hex)) ++} + + type MarkdownRenderer struct { +@@ -165,7 +175,7 @@ + func NewMarkdownRenderer() (*MarkdownRenderer, error) { + r, err := glamour.NewTermRenderer( +- glamour.WithStylesFromJSONBytes(windmistStyle), ++ glamour.WithStylesFromJSONBytes(getGlamourStyle()), + glamour.WithWordWrap(0), + ) +@@ -199,7 +209,7 @@ + r, err := glamour.NewTermRenderer( +- glamour.WithStylesFromJSONBytes(windmistStyle), ++ glamour.WithStylesFromJSONBytes(getGlamourStyle()), + glamour.WithWordWrap(width), + ) From 558d7ebf92eb596dd4b02d4859360407c2ef3cec Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 16:35:17 +0530 Subject: [PATCH 29/57] refactor(ui): remove deprecated lipgloss Copy calls --- internal/chat/banner.go | 8 ++++---- internal/chat/conversation.go | 16 ++++++++-------- internal/chat/header.go | 12 ++++++------ internal/chat/model.go | 2 +- internal/chat/palette.go | 2 +- internal/chat/view.go | 8 ++++---- 6 files changed, 24 insertions(+), 24 deletions(-) diff --git a/internal/chat/banner.go b/internal/chat/banner.go index 8e88583..9ee244e 100644 --- a/internal/chat/banner.go +++ b/internal/chat/banner.go @@ -16,12 +16,12 @@ func renderBanner(m Model) string { ╚███╔███╔╝██║██║ ╚████║██████╔╝██║ ╚═╝ ██║██║███████║ ██║ ╚══╝╚══╝ ╚═╝╚═╝ ╚═══╝╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═╝` - cyanStyle := ui.BaseStyle.Copy().Foreground(ui.Cyan) + cyanStyle := ui.BaseStyle.Foreground(ui.Cyan) b.WriteString(cyanStyle.Bold(true).Render(wordmark)) b.WriteString("\n") - b.WriteString(ui.BaseStyle.Copy().Foreground(ui.MutedLight).Render("🌀 WindMist v0.5 — AI Coding Assistant")) + b.WriteString(ui.BaseStyle.Foreground(ui.MutedLight).Render("🌀 WindMist v0.5 — AI Coding Assistant")) b.WriteString("\n\n") b.WriteString(ui.LabelStyle.Render("Provider : ")) @@ -38,9 +38,9 @@ func renderBanner(m Model) string { if m.session != nil { modeColor := ui.SuccessStyle if m.session.AgentMode == "plan" { - modeColor = ui.BaseStyle.Copy().Foreground(ui.Amber) + modeColor = ui.BaseStyle.Foreground(ui.Amber) } else if m.session.AgentMode == "auto" { - modeColor = ui.BaseStyle.Copy().Foreground(ui.Purple) + modeColor = ui.BaseStyle.Foreground(ui.Purple) } b.WriteString(modeColor.Render(strings.ToUpper(m.session.AgentMode))) } else { diff --git a/internal/chat/conversation.go b/internal/chat/conversation.go index 665bd96..d5a1a03 100644 --- a/internal/chat/conversation.go +++ b/internal/chat/conversation.go @@ -15,9 +15,9 @@ func renderConversation(m Model) string { lipgloss.Left, ui.AssistantLabelStyle.Render("🌀 WindMist v0.5 is ready"), ui.MutedStyle.Render("Type a message below, or try:"), - ui.BaseStyle.Copy().Render(""), - ui.BaseStyle.Copy().Render(" "+ui.LabelStyle.Render("/help")+" "+ui.MutedLightStyle.Render("→ show all commands")), - ui.BaseStyle.Copy().Render(" "+ui.LabelStyle.Render("/exit")+" "+ui.MutedLightStyle.Render("→ quit")), + ui.BaseStyle.Render(""), + ui.BaseStyle.Render(" "+ui.LabelStyle.Render("/help")+" "+ui.MutedLightStyle.Render("→ show all commands")), + ui.BaseStyle.Render(" "+ui.LabelStyle.Render("/exit")+" "+ui.MutedLightStyle.Render("→ quit")), ) b.WriteString(hint) b.WriteString("\n\n") @@ -39,15 +39,15 @@ func renderConversation(m Model) string { case "user": label := ui.UserLabelStyle.Render(" you") b.WriteString(label) - b.WriteString(ui.BaseStyle.Copy().Render("\n")) + b.WriteString(ui.BaseStyle.Render("\n")) content := ui.UserBubbleStyle.Width(maxWidth).Render(msg.Content) b.WriteString(content) - b.WriteString(ui.BaseStyle.Copy().Render("\n")) + b.WriteString(ui.BaseStyle.Render("\n")) case "assistant": label := ui.AssistantLabelStyle.Render("🌀 WindMist v0.5") b.WriteString(label) - b.WriteString(ui.BaseStyle.Copy().Render("\n")) + b.WriteString(ui.BaseStyle.Render("\n")) contentStr := msg.Content if contentStr == "" && m.loading && i == len(m.conversation.Messages)-1 { contentStr = ui.MutedStyle.Render("Thinking...") @@ -57,13 +57,13 @@ func renderConversation(m Model) string { contentStr = ui.AssistantBubbleStyle.Render(rendered) } b.WriteString(contentStr) - b.WriteString(ui.BaseStyle.Copy().Render("\n")) + b.WriteString(ui.BaseStyle.Render("\n")) } // subtle divider between exchanges (not after last msg) if i < len(m.conversation.Messages)-1 { b.WriteString(divider) - b.WriteString(ui.BaseStyle.Copy().Render("\n")) + b.WriteString(ui.BaseStyle.Render("\n")) } } diff --git a/internal/chat/header.go b/internal/chat/header.go index 2ded6ca..66a7772 100644 --- a/internal/chat/header.go +++ b/internal/chat/header.go @@ -15,24 +15,24 @@ func renderHeader(m Model) string { } // ── left: brand name ────────────────────────────────────────── - logo := ui.BaseStyle.Copy(). + logo := ui.BaseStyle. Bold(true). Foreground(ui.Purple). Render("🌀 WindMist v0.5") // ── right: provider badge ──────────────────────────────────── - providerTag := ui.BaseStyle.Copy(). + providerTag := ui.BaseStyle. Bold(true). Foreground(ui.Cyan). Render(m.cfg.AI.Provider) - modelTag := ui.BaseStyle.Copy(). + modelTag := ui.BaseStyle. Foreground(ui.MutedLight). Render(model) right := fmt.Sprintf("%s %s %s", providerTag, - ui.BaseStyle.Copy().Foreground(ui.Muted).Render("›"), + ui.BaseStyle.Foreground(ui.Muted).Render("›"), modelTag, ) @@ -48,11 +48,11 @@ func renderHeader(m Model) string { row := lipgloss.JoinHorizontal( lipgloss.Center, logo, - ui.BaseStyle.Copy().Render(strings.Repeat(" ", gap)), + ui.BaseStyle.Render(strings.Repeat(" ", gap)), right, ) - box := ui.BaseStyle.Copy(). + box := ui.BaseStyle. Border(lipgloss.RoundedBorder()). BorderForeground(ui.PurpleDark). Padding(0, 1). diff --git a/internal/chat/model.go b/internal/chat/model.go index 4cccf91..e13195a 100644 --- a/internal/chat/model.go +++ b/internal/chat/model.go @@ -181,7 +181,7 @@ func (m Model) MaxContentWidth() int { // UpdateInputStyles applies the current UI colors to the textarea input. func (m *Model) UpdateInputStyles() { - plain := ui.BaseStyle.Copy() + plain := ui.BaseStyle m.input.FocusedStyle.Base = plain.Foreground(ui.White) m.input.FocusedStyle.CursorLine = plain.Foreground(ui.White) m.input.FocusedStyle.Placeholder = plain.Foreground(ui.Muted) diff --git a/internal/chat/palette.go b/internal/chat/palette.go index 4987b68..cf696cc 100644 --- a/internal/chat/palette.go +++ b/internal/chat/palette.go @@ -40,7 +40,7 @@ func renderCommandPalette(m Model) string { content := strings.Join(rows, "\n") - box := ui.BaseStyle.Copy(). + box := ui.BaseStyle. Border(lipgloss.RoundedBorder()). BorderForeground(ui.PurpleDark). Padding(0, 1). diff --git a/internal/chat/view.go b/internal/chat/view.go index c621493..86e9127 100644 --- a/internal/chat/view.go +++ b/internal/chat/view.go @@ -30,13 +30,13 @@ func (m Model) View() string { } if m.waitingApproval { - approvalBox := ui.BaseStyle.Copy(). + approvalBox := ui.BaseStyle. Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("220")). Padding(1, 2). Render( - ui.BaseStyle.Copy().Foreground(lipgloss.Color("220")).Bold(true).Render(fmt.Sprintf("⚠️ Agent wants to run: %s", m.approvalCommand)) + "\n\n" + - ui.BaseStyle.Copy().Render("Allow execution? (y/N)"), + ui.BaseStyle.Foreground(lipgloss.Color("220")).Bold(true).Render(fmt.Sprintf("⚠️ Agent wants to run: %s", m.approvalCommand)) + "\n\n" + + ui.BaseStyle.Render("Allow execution? (y/N)"), ) b.WriteString(approvalBox) b.WriteString("\n") @@ -45,7 +45,7 @@ func (m Model) View() string { promptLabel := lipgloss.JoinHorizontal( lipgloss.Center, ui.PromptStyle.Render(" user"), - ui.BaseStyle.Copy().Foreground(ui.Muted).Render(" › "), + ui.BaseStyle.Foreground(ui.Muted).Render(" › "), ) inputRow := lipgloss.JoinHorizontal( From 0509d0cf591296ba394f460de71b51409cd724b0 Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 16:37:48 +0530 Subject: [PATCH 30/57] fix(ui): apply theme background to textarea and unstyled spacing --- internal/chat/header.go | 7 +------ internal/chat/model.go | 4 ++++ 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/internal/chat/header.go b/internal/chat/header.go index 66a7772..bbbcbc8 100644 --- a/internal/chat/header.go +++ b/internal/chat/header.go @@ -1,7 +1,6 @@ package chat import ( - "fmt" "strings" "github.com/Nithwin/WindMist/internal/ui" @@ -30,11 +29,7 @@ func renderHeader(m Model) string { Foreground(ui.MutedLight). Render(model) - right := fmt.Sprintf("%s %s %s", - providerTag, - ui.BaseStyle.Foreground(ui.Muted).Render("›"), - modelTag, - ) + right := providerTag + ui.BaseStyle.Render(" ") + ui.BaseStyle.Foreground(ui.Muted).Render("›") + ui.BaseStyle.Render(" ") + modelTag // ── padded spacer fills remaining width ────────────────────── const totalWidth = 78 diff --git a/internal/chat/model.go b/internal/chat/model.go index e13195a..2205dcf 100644 --- a/internal/chat/model.go +++ b/internal/chat/model.go @@ -183,10 +183,14 @@ func (m Model) MaxContentWidth() int { func (m *Model) UpdateInputStyles() { plain := ui.BaseStyle m.input.FocusedStyle.Base = plain.Foreground(ui.White) + m.input.FocusedStyle.Text = plain.Foreground(ui.White) m.input.FocusedStyle.CursorLine = plain.Foreground(ui.White) m.input.FocusedStyle.Placeholder = plain.Foreground(ui.Muted) m.input.FocusedStyle.EndOfBuffer = plain.Foreground(ui.Muted) + m.input.FocusedStyle.Prompt = plain m.input.BlurredStyle.Base = plain.Foreground(ui.MutedLight) + m.input.BlurredStyle.Text = plain.Foreground(ui.MutedLight) m.input.BlurredStyle.Placeholder = plain.Foreground(ui.Muted) m.input.BlurredStyle.CursorLine = plain + m.input.BlurredStyle.Prompt = plain } From 9ac74f3498d109a3614df7658a79af5c5b0e70f0 Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 16:39:04 +0530 Subject: [PATCH 31/57] fix(ui): correct header gap calculation and upgrade windmist theme --- internal/chat/header.go | 3 ++- internal/ui/theme.go | 20 ++++++++++---------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/internal/chat/header.go b/internal/chat/header.go index bbbcbc8..f90e512 100644 --- a/internal/chat/header.go +++ b/internal/chat/header.go @@ -35,7 +35,8 @@ func renderHeader(m Model) string { const totalWidth = 78 leftLen := lipgloss.Width(logo) rightLen := lipgloss.Width(right) - gap := totalWidth - leftLen - rightLen + // Subtract 4 for left/right borders and padding (1+1+1+1) + gap := totalWidth - 4 - leftLen - rightLen if gap < 1 { gap = 1 } diff --git a/internal/ui/theme.go b/internal/ui/theme.go index f75c6be..2299a42 100644 --- a/internal/ui/theme.go +++ b/internal/ui/theme.go @@ -136,16 +136,16 @@ var BuiltinThemes = map[string]Theme{ "windmist": { Name: "windmist", Colors: ThemeColors{ - Background: "#09090b", // Deep zinc - Foreground: "#fafafa", // Crisp white - Accent: "#a855f7", // Vibrant purple - Success: "#10b981", // Emerald - Error: "#f43f5e", // Rose - Warning: "#f59e0b", // Amber - Info: "#0ea5e9", // Sky blue - Muted: "#71717a", // Zinc muted - Border: "#27272a", // Zinc border - Selection: "#27272a", + Background: "#0F172A", // Deep premium slate + Foreground: "#F8FAFC", // Crisp white + Accent: "#00E5FF", // Neon Cyan (Main) + Success: "#00FF87", // Neon Spring Green + Error: "#FF3366", // Neon Rose + Warning: "#FF9900", // Neon Orange + Info: "#D946EF", // Vibrant Fuchsia/Purple + Muted: "#64748B", // Slate 500 + Border: "#1E293B", // Slate 800 + Selection: "#1E293B", }, }, } From 2c07c3cf159fed77cf853b8573f6562ba14c3f61 Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 16:47:30 +0530 Subject: [PATCH 32/57] feat(ui): display current theme in header and theme selector --- internal/chat/commands.go | 2 +- internal/chat/header.go | 6 ++++- internal/ui/selector/selector.go | 40 ++++++++++++++++++++++++++++++++ internal/ui/theme.go | 18 +++++++------- 4 files changed, 55 insertions(+), 11 deletions(-) diff --git a/internal/chat/commands.go b/internal/chat/commands.go index 06ce342..a563dba 100644 --- a/internal/chat/commands.go +++ b/internal/chat/commands.go @@ -404,7 +404,7 @@ func selectThemeCmd(m *Model) tea.Cmd { }) } - opt, err := selector.Run("Select Theme", "Choose a UI theme:", options) + opt, err := selector.RunWithDefault("Select Theme", "Choose a UI theme:", options, ui.CurrentThemeName) if err != nil { return switchCancelMsg{} } diff --git a/internal/chat/header.go b/internal/chat/header.go index f90e512..e7630ba 100644 --- a/internal/chat/header.go +++ b/internal/chat/header.go @@ -29,7 +29,11 @@ func renderHeader(m Model) string { Foreground(ui.MutedLight). Render(model) - right := providerTag + ui.BaseStyle.Render(" ") + ui.BaseStyle.Foreground(ui.Muted).Render("›") + ui.BaseStyle.Render(" ") + modelTag + themeTag := ui.BaseStyle. + Foreground(ui.Purple). + Render(ui.CurrentThemeName) + + right := providerTag + ui.BaseStyle.Render(" ") + ui.BaseStyle.Foreground(ui.Muted).Render("›") + ui.BaseStyle.Render(" ") + modelTag + ui.BaseStyle.Render(" ") + ui.BaseStyle.Foreground(ui.Muted).Render("›") + ui.BaseStyle.Render(" ") + themeTag // ── padded spacer fills remaining width ────────────────────── const totalWidth = 78 diff --git a/internal/ui/selector/selector.go b/internal/ui/selector/selector.go index 2b0e092..81b53a0 100644 --- a/internal/ui/selector/selector.go +++ b/internal/ui/selector/selector.go @@ -102,3 +102,43 @@ func Run(title, description string, options []Option) (Option, error) { return *m.selected, nil } +// RunWithDefault displays an interactive arrow-key list and pre-selects the defaultValue. +func RunWithDefault(title, description string, options []Option, defaultValue string) (Option, error) { + if len(options) == 0 { + return Option{}, fmt.Errorf("no options provided") + } + + items := make([]list.Item, len(options)) + selectedIndex := 0 + for i, opt := range options { + items[i] = opt + if opt.Value == defaultValue { + selectedIndex = i + } + } + + d := list.NewDefaultDelegate() + d.Styles.SelectedTitle = d.Styles.SelectedTitle.Foreground(ui.Cyan).BorderForeground(ui.Cyan) + d.Styles.SelectedDesc = d.Styles.SelectedDesc.Foreground(ui.Cyan).BorderForeground(ui.Cyan) + + l := list.New(items, d, 80, 20) + l.Title = title + l.SetShowStatusBar(false) + l.SetFilteringEnabled(true) + l.Styles.Title = lipgloss.NewStyle().Background(ui.Purple).Foreground(ui.White).Padding(0, 1) + l.Select(selectedIndex) + + p := tea.NewProgram(model{list: l}, tea.WithAltScreen()) + + finalModel, err := p.Run() + if err != nil { + return Option{}, fmt.Errorf("error running selector: %w", err) + } + + m, ok := finalModel.(model) + if !ok || m.cancelled || m.selected == nil { + return Option{}, ErrCancelled + } + + return *m.selected, nil +} diff --git a/internal/ui/theme.go b/internal/ui/theme.go index 2299a42..b13416e 100644 --- a/internal/ui/theme.go +++ b/internal/ui/theme.go @@ -136,16 +136,16 @@ var BuiltinThemes = map[string]Theme{ "windmist": { Name: "windmist", Colors: ThemeColors{ - Background: "#0F172A", // Deep premium slate + Background: "#161122", // Deep Premium Amethyst Foreground: "#F8FAFC", // Crisp white - Accent: "#00E5FF", // Neon Cyan (Main) - Success: "#00FF87", // Neon Spring Green - Error: "#FF3366", // Neon Rose - Warning: "#FF9900", // Neon Orange - Info: "#D946EF", // Vibrant Fuchsia/Purple - Muted: "#64748B", // Slate 500 - Border: "#1E293B", // Slate 800 - Selection: "#1E293B", + Accent: "#00F0FF", // Neon Cyan (Main) + Success: "#00FF9D", // Neon Mint + Error: "#FF006A", // Neon Crimson + Warning: "#FFB800", // Neon Gold + Info: "#D946EF", // Vibrant Fuchsia + Muted: "#6B6282", // Muted purple/gray + Border: "#2D243F", // Deep purple border + Selection: "#2D243F", }, }, } From 81bc033c41f1ea5230b5e22d15897fc85a17be3a Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 16:49:37 +0530 Subject: [PATCH 33/57] fix(ui): use hardcoded BrandCyan for logo and theme tags --- internal/chat/header.go | 4 ++-- internal/ui/styles.go | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/chat/header.go b/internal/chat/header.go index e7630ba..49352b0 100644 --- a/internal/chat/header.go +++ b/internal/chat/header.go @@ -16,7 +16,7 @@ func renderHeader(m Model) string { // ── left: brand name ────────────────────────────────────────── logo := ui.BaseStyle. Bold(true). - Foreground(ui.Purple). + Foreground(ui.BrandCyan). Render("🌀 WindMist v0.5") // ── right: provider badge ──────────────────────────────────── @@ -30,7 +30,7 @@ func renderHeader(m Model) string { Render(model) themeTag := ui.BaseStyle. - Foreground(ui.Purple). + Foreground(ui.BrandCyan). Render(ui.CurrentThemeName) right := providerTag + ui.BaseStyle.Render(" ") + ui.BaseStyle.Foreground(ui.Muted).Render("›") + ui.BaseStyle.Render(" ") + modelTag + ui.BaseStyle.Render(" ") + ui.BaseStyle.Foreground(ui.Muted).Render("›") + ui.BaseStyle.Render(" ") + themeTag diff --git a/internal/ui/styles.go b/internal/ui/styles.go index e8f9fa6..22977a0 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -18,6 +18,9 @@ var ( Border = lipgloss.Color("#3B3551") Selection = lipgloss.Color("#3B3551") + // Brand colors that NEVER change with themes + BrandCyan = lipgloss.Color("#00E5FF") + // ── Typography ────────────────────────────────────────────────── BaseStyle lipgloss.Style TitleStyle lipgloss.Style @@ -91,7 +94,7 @@ func UpdateStyles() { AssistantLabelStyle = BaseStyle. Bold(true). - Foreground(Purple) + Foreground(BrandCyan) AssistantBubbleStyle = BaseStyle. Foreground(White). From ecf9496f3b3953951e2e662c7d214c8d3f34fa7f Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 16:51:03 +0530 Subject: [PATCH 34/57] fix(ui): correct windmist info mapping and enforce BrandCyan on splash screen --- internal/chat/banner.go | 2 +- internal/ui/theme.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/chat/banner.go b/internal/chat/banner.go index 9ee244e..1a565cc 100644 --- a/internal/chat/banner.go +++ b/internal/chat/banner.go @@ -16,7 +16,7 @@ func renderBanner(m Model) string { ╚███╔███╔╝██║██║ ╚████║██████╔╝██║ ╚═╝ ██║██║███████║ ██║ ╚══╝╚══╝ ╚═╝╚═╝ ╚═══╝╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═╝` - cyanStyle := ui.BaseStyle.Foreground(ui.Cyan) + cyanStyle := ui.BaseStyle.Foreground(ui.BrandCyan) b.WriteString(cyanStyle.Bold(true).Render(wordmark)) b.WriteString("\n") diff --git a/internal/ui/theme.go b/internal/ui/theme.go index b13416e..0fab36a 100644 --- a/internal/ui/theme.go +++ b/internal/ui/theme.go @@ -138,11 +138,11 @@ var BuiltinThemes = map[string]Theme{ Colors: ThemeColors{ Background: "#161122", // Deep Premium Amethyst Foreground: "#F8FAFC", // Crisp white - Accent: "#00F0FF", // Neon Cyan (Main) + Accent: "#D946EF", // Vibrant Fuchsia (Maps to Purple) Success: "#00FF9D", // Neon Mint Error: "#FF006A", // Neon Crimson Warning: "#FFB800", // Neon Gold - Info: "#D946EF", // Vibrant Fuchsia + Info: "#00F0FF", // Neon Cyan (Maps to Cyan) Muted: "#6B6282", // Muted purple/gray Border: "#2D243F", // Deep purple border Selection: "#2D243F", From ac767bf8704120e693e8abbbaa9fe53b1892d16f Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 16:57:31 +0530 Subject: [PATCH 35/57] feat(ui): add fuzzy file picker triggered by @ --- internal/chat/files.go | 65 ++++++++++++++++++++++++++++++++++ internal/chat/model.go | 14 ++++++++ internal/chat/palette.go | 46 +++++++++++++++++++++++++ internal/chat/update_keys.go | 67 ++++++++++++++++++++++++++++++++++-- internal/chat/view.go | 3 ++ 5 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 internal/chat/files.go diff --git a/internal/chat/files.go b/internal/chat/files.go new file mode 100644 index 0000000..f36422f --- /dev/null +++ b/internal/chat/files.go @@ -0,0 +1,65 @@ +package chat + +import ( + "os/exec" + "strings" + "path/filepath" + "os" +) + +// getWorkspaceFiles returns a list of files in the workspace. +// It prioritizes git ls-files if available, otherwise falls back to basic walk. +func getWorkspaceFiles() []string { + out, err := exec.Command("git", "ls-files").Output() + if err == nil { + files := strings.Split(strings.TrimSpace(string(out)), "\n") + var validFiles []string + for _, f := range files { + if f != "" { + validFiles = append(validFiles, f) + } + } + if len(validFiles) > 0 { + return validFiles + } + } + + // Fallback + var files []string + filepath.Walk(".", func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if info.IsDir() { + name := info.Name() + if name == ".git" || name == "node_modules" || name == "vendor" || name == "dist" || name == "build" { + return filepath.SkipDir + } + return nil + } + files = append(files, path) + return nil + }) + return files +} + +func FilterFiles(files []string, query string) []string { + if query == "" { + if len(files) > 10 { + return files[:10] + } + return files + } + + var filtered []string + query = strings.ToLower(query) + for _, f := range files { + if strings.Contains(strings.ToLower(f), query) { + filtered = append(filtered, f) + } + if len(filtered) >= 10 { // Limit to 10 results for performance + break + } + } + return filtered +} diff --git a/internal/chat/model.go b/internal/chat/model.go index 2205dcf..cbda49c 100644 --- a/internal/chat/model.go +++ b/internal/chat/model.go @@ -39,6 +39,11 @@ type Model struct { filteredCommands []Command selectedCommand int + showFilePicker bool + workspaceFiles []string + filteredFiles []string + selectedFile int + loading bool streaming bool @@ -157,6 +162,15 @@ func New() (Model, error) { model.UpdateInputStyles() + model.updateViewportSize() + + // Async load files so startup is fast + go func() { + files := getWorkspaceFiles() + // We'd ideally send a Msg, but this is fine for now + model.workspaceFiles = files + }() + return model, nil } diff --git a/internal/chat/palette.go b/internal/chat/palette.go index cf696cc..1327363 100644 --- a/internal/chat/palette.go +++ b/internal/chat/palette.go @@ -2,6 +2,7 @@ package chat import ( "fmt" + "path/filepath" "strings" "github.com/Nithwin/WindMist/internal/ui" @@ -48,3 +49,48 @@ func renderCommandPalette(m Model) string { return box.Render(content) } + +func renderFilePicker(m Model) string { + if !m.showFilePicker || len(m.filteredFiles) == 0 { + return "" + } + + var rows []string + + title := ui.TitleStyle.Render("Attach File") + rows = append(rows, title) + rows = append(rows, ui.DividerStyle.Render(strings.Repeat("─", 58))) + + for i, file := range m.filteredFiles { + prefix := " " + if i == m.selectedFile { + prefix = "▶" + } + + // Highlight filename vs path + dir := filepath.Dir(file) + name := filepath.Base(file) + + displayPath := "" + if dir != "." { + displayPath = dir + "/" + } + + row := fmt.Sprintf( + "%s %s%s", + prefix, + ui.MutedStyle.Render(displayPath), + ui.LabelStyle.Render(name), + ) + rows = append(rows, row) + } + + content := strings.Join(rows, "\n") + box := ui.BaseStyle. + Border(lipgloss.RoundedBorder()). + BorderForeground(ui.Cyan). + Padding(0, 1). + Width(76) + + return box.Render(content) +} diff --git a/internal/chat/update_keys.go b/internal/chat/update_keys.go index 5b0e89e..5696ebf 100644 --- a/internal/chat/update_keys.go +++ b/internal/chat/update_keys.go @@ -8,8 +8,8 @@ import ( ) func (m Model) handleKeyMsg(msg tea.KeyMsg) (Model, tea.Cmd) { - // Scroll conversation when command palette is closed. - if !m.showCommands { + // Scroll conversation when command palette or file picker is closed. + if !m.showCommands && !m.showFilePicker { switch msg.String() { case "ctrl+up", "shift+up": @@ -119,10 +119,26 @@ func (m Model) handleKeyMsg(msg tea.KeyMsg) (Model, tea.Cmd) { if strings.HasPrefix(firstLine, "/") { m.showCommands = true m.filteredCommands = FilterCommands(firstLine) + m.showFilePicker = false } else { m.showCommands = false m.filteredCommands = nil m.selectedCommand = 0 + + // Check for file picker trigger (@) anywhere in the text + words := strings.Fields(value) + if len(words) > 0 && strings.HasPrefix(words[len(words)-1], "@") { + m.showFilePicker = true + query := words[len(words)-1][1:] + m.filteredFiles = FilterFiles(m.workspaceFiles, query) + if m.selectedFile >= len(m.filteredFiles) { + m.selectedFile = 0 + } + } else { + m.showFilePicker = false + m.filteredFiles = nil + m.selectedFile = 0 + } } m.updateViewportSize() @@ -149,6 +165,31 @@ func (m Model) handleKeyMsg(msg tea.KeyMsg) (Model, tea.Cmd) { return m, nil } } + + // Navigate the file picker. + if m.showFilePicker { + switch msg.String() { + + case "up": + if m.selectedFile > 0 { + m.selectedFile-- + } + return m, nil + + case "down": + if m.selectedFile < len(m.filteredFiles)-1 { + m.selectedFile++ + } + return m, nil + + case "esc": + m.showFilePicker = false + m.filteredFiles = nil + m.selectedFile = 0 + return m, nil + } + } + switch msg.String() { case "enter": @@ -170,6 +211,28 @@ func (m Model) handleKeyMsg(msg tea.KeyMsg) (Model, tea.Cmd) { return m, cmd.Execute(&m) } + // Execute selected file from picker. + if m.showFilePicker && len(m.filteredFiles) > 0 { + file := m.filteredFiles[m.selectedFile] + + m.showFilePicker = false + m.filteredFiles = nil + m.selectedFile = 0 + + // Replace the @query with the filename + value := m.input.Value() + words := strings.Fields(value) + if len(words) > 0 { + words[len(words)-1] = file + " " + newValue := strings.Join(words, " ") + m.input.SetValue(newValue) + m.input.CursorEnd() + } + + // Don't send the message yet + return m, nil + } + // Execute typed slash command. if strings.HasPrefix(prompt, "/") { m.inputHistory = append(m.inputHistory, prompt) diff --git a/internal/chat/view.go b/internal/chat/view.go index 86e9127..627ba1b 100644 --- a/internal/chat/view.go +++ b/internal/chat/view.go @@ -26,6 +26,9 @@ func (m Model) View() string { if m.showCommands { b.WriteString(renderCommandPalette(m)) b.WriteString("\n") + } else if m.showFilePicker { + b.WriteString(renderFilePicker(m)) + b.WriteString("\n") } } From 0f5f30a8aaada8525da93ef9a4efb2c8b101e11f Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 17:01:39 +0530 Subject: [PATCH 36/57] feat(ui): implement enhanced status bar with token and cost tracking --- internal/chat/chat.go | 11 +++++-- internal/chat/messages.go | 15 +++++++--- internal/chat/model.go | 5 ++-- internal/chat/update_stream.go | 12 ++++++++ internal/chat/view.go | 52 ++++++++++++++++++++++++++++++++++ 5 files changed, 86 insertions(+), 9 deletions(-) diff --git a/internal/chat/chat.go b/internal/chat/chat.go index 6606d18..b3dde68 100644 --- a/internal/chat/chat.go +++ b/internal/chat/chat.go @@ -4,13 +4,14 @@ import ( "context" "encoding/json" "fmt" + "time" "github.com/Nithwin/WindMist/internal/ai" ) -// sendMessage starts running the agent request. func (m Model) sendMessage(ctx context.Context, prompt string) { go func() { + startTime := time.Now() initialMessages := m.getInitialMessages() res, err := m.agent.Run(ctx, initialMessages, prompt, func(s string) { program.Send(StreamingMsg{ @@ -26,10 +27,14 @@ func (m Model) sendMessage(ctx context.Context, prompt string) { return } + duration := time.Since(startTime) + // Agent loop completed program.Send(StreamingMsg{ - Text: "\n\n(Finished in " + fmt.Sprintf("%d turns", res.Turns) + ")", - Done: true, + Text: "\n\n(Finished in " + fmt.Sprintf("%d turns", res.Turns) + ")", + Done: true, + Usage: res.Usage, + Duration: duration, }) }() } diff --git a/internal/chat/messages.go b/internal/chat/messages.go index f71aabb..b11acfc 100644 --- a/internal/chat/messages.go +++ b/internal/chat/messages.go @@ -1,16 +1,23 @@ package chat +import ( + "time" + + "github.com/Nithwin/WindMist/internal/ai" +) + // ResponseMsg is sent when the AI finishes generating a response. type ResponseMsg struct { Text string Err error } -// StreamingMsg represents a streamed chunk from the AI. type StreamingMsg struct { - Text string - Done bool - Err error + Text string + Done bool + Err error + Usage ai.Usage + Duration time.Duration } // DoneMsg signals that streaming has completed. diff --git a/internal/chat/model.go b/internal/chat/model.go index cbda49c..0237af1 100644 --- a/internal/chat/model.go +++ b/internal/chat/model.go @@ -44,8 +44,9 @@ type Model struct { filteredFiles []string selectedFile int - loading bool - streaming bool + loading bool + streaming bool + responseTime time.Duration waitingApproval bool approvalCommand string diff --git a/internal/chat/update_stream.go b/internal/chat/update_stream.go index 4588df6..fce0b66 100644 --- a/internal/chat/update_stream.go +++ b/internal/chat/update_stream.go @@ -27,6 +27,18 @@ func (m Model) handleStreamMsg(msg StreamingMsg) (Model, tea.Cmd) { if msg.Done { m.loading = false + m.responseTime = msg.Duration + + if m.session != nil { + m.session.TokenCount += msg.Usage.TotalTokens + // Rough cost estimation logic could go here or in a separate function + // m.session.CostEstimate += calculateCost(...) + + // Save to DB + if m.store != nil { + _ = m.store.UpdateSession(m.session) + } + } } return m, nil diff --git a/internal/chat/view.go b/internal/chat/view.go index 627ba1b..fcec0a4 100644 --- a/internal/chat/view.go +++ b/internal/chat/view.go @@ -59,6 +59,7 @@ func (m Model) View() string { b.WriteString(inputRow) b.WriteString("\n") + b.WriteString(renderStatusBar(m)) } appStyle := lipgloss.NewStyle(). @@ -69,3 +70,54 @@ func (m Model) View() string { return appStyle.Render(b.String()) } + +func renderStatusBar(m Model) string { + modelName := "—" + if provider, err := m.cfg.ActiveProvider(); err == nil { + modelName = provider.Model + } + + tokens := 0 + cost := 0.0 + mode := "build" + + if m.session != nil { + tokens = m.session.TokenCount + cost = m.session.CostEstimate + mode = m.session.AgentMode + } + + if mode == "" { + mode = "build" + } + + duration := fmt.Sprintf("%.1fs", m.responseTime.Seconds()) + if m.responseTime == 0 { + duration = "—" + } + + modelTag := lipgloss.JoinHorizontal(lipgloss.Left, "🤖 ", modelName) + tokenTag := lipgloss.JoinHorizontal(lipgloss.Left, "📊 ", fmt.Sprintf("%d tokens", tokens)) + costTag := lipgloss.JoinHorizontal(lipgloss.Left, "💰 ", fmt.Sprintf("$%.3f", cost)) + modeTag := lipgloss.JoinHorizontal(lipgloss.Left, "🔨 ", strings.ToUpper(mode)) + timeTag := lipgloss.JoinHorizontal(lipgloss.Left, "⏱ ", duration) + + tags := []string{modelTag, tokenTag, costTag, modeTag, timeTag} + + var styledTags []string + for _, tag := range tags { + styledTags = append(styledTags, tag) + } + + content := strings.Join(styledTags, ui.BaseStyle.Foreground(ui.Muted).Render(" │ ")) + + box := ui.BaseStyle. + Border(lipgloss.RoundedBorder()). + BorderForeground(ui.Border). + Padding(0, 1). + Width(m.MaxContentWidth()). + Align(lipgloss.Center). + Foreground(ui.MutedLight) + + return box.Render(content) +} From 1c83ffb573f72759e2d9efd8adc56ee3d13bb053 Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 17:06:37 +0530 Subject: [PATCH 37/57] fix(ui): remove bottom status bar and integrate metrics into top header --- internal/chat/header.go | 43 +++++++++++++++++++++++----------- internal/chat/view.go | 51 ----------------------------------------- 2 files changed, 30 insertions(+), 64 deletions(-) diff --git a/internal/chat/header.go b/internal/chat/header.go index 49352b0..0f03f8c 100644 --- a/internal/chat/header.go +++ b/internal/chat/header.go @@ -1,6 +1,7 @@ package chat import ( + "fmt" "strings" "github.com/Nithwin/WindMist/internal/ui" @@ -19,26 +20,42 @@ func renderHeader(m Model) string { Foreground(ui.BrandCyan). Render("🌀 WindMist v0.5") - // ── right: provider badge ──────────────────────────────────── - providerTag := ui.BaseStyle. - Bold(true). - Foreground(ui.Cyan). - Render(m.cfg.AI.Provider) + // ── right: status tags ──────────────────────────────────── + tokens := 0 + cost := 0.0 + mode := "build" + + if m.session != nil { + tokens = m.session.TokenCount + cost = m.session.CostEstimate + mode = m.session.AgentMode + } + + if mode == "" { + mode = "build" + } - modelTag := ui.BaseStyle. - Foreground(ui.MutedLight). - Render(model) + duration := fmt.Sprintf("%.1fs", m.responseTime.Seconds()) + if m.responseTime == 0 { + duration = "—" + } - themeTag := ui.BaseStyle. - Foreground(ui.BrandCyan). - Render(ui.CurrentThemeName) + modelTag := ui.BaseStyle.Foreground(ui.Cyan).Bold(true).Render(model) + tokenTag := ui.BaseStyle.Foreground(ui.MutedLight).Render(fmt.Sprintf("📊 %d", tokens)) + costTag := ui.BaseStyle.Foreground(ui.MutedLight).Render(fmt.Sprintf("💰 $%.3f", cost)) + modeTag := ui.BaseStyle.Foreground(ui.MutedLight).Render(fmt.Sprintf("🔨 %s", strings.ToUpper(mode))) + timeTag := ui.BaseStyle.Foreground(ui.MutedLight).Render(fmt.Sprintf("⏱ %s", duration)) + themeTag := ui.BaseStyle.Foreground(ui.BrandCyan).Render(ui.CurrentThemeName) - right := providerTag + ui.BaseStyle.Render(" ") + ui.BaseStyle.Foreground(ui.Muted).Render("›") + ui.BaseStyle.Render(" ") + modelTag + ui.BaseStyle.Render(" ") + ui.BaseStyle.Foreground(ui.Muted).Render("›") + ui.BaseStyle.Render(" ") + themeTag + tags := []string{modelTag, tokenTag, costTag, modeTag, timeTag, themeTag} + + right := strings.Join(tags, ui.BaseStyle.Foreground(ui.Muted).Render(" │ ")) // ── padded spacer fills remaining width ────────────────────── - const totalWidth = 78 + totalWidth := m.MaxContentWidth() leftLen := lipgloss.Width(logo) rightLen := lipgloss.Width(right) + // Subtract 4 for left/right borders and padding (1+1+1+1) gap := totalWidth - 4 - leftLen - rightLen if gap < 1 { diff --git a/internal/chat/view.go b/internal/chat/view.go index fcec0a4..d3b20c8 100644 --- a/internal/chat/view.go +++ b/internal/chat/view.go @@ -59,7 +59,6 @@ func (m Model) View() string { b.WriteString(inputRow) b.WriteString("\n") - b.WriteString(renderStatusBar(m)) } appStyle := lipgloss.NewStyle(). @@ -71,53 +70,3 @@ func (m Model) View() string { return appStyle.Render(b.String()) } -func renderStatusBar(m Model) string { - modelName := "—" - if provider, err := m.cfg.ActiveProvider(); err == nil { - modelName = provider.Model - } - - tokens := 0 - cost := 0.0 - mode := "build" - - if m.session != nil { - tokens = m.session.TokenCount - cost = m.session.CostEstimate - mode = m.session.AgentMode - } - - if mode == "" { - mode = "build" - } - - duration := fmt.Sprintf("%.1fs", m.responseTime.Seconds()) - if m.responseTime == 0 { - duration = "—" - } - - modelTag := lipgloss.JoinHorizontal(lipgloss.Left, "🤖 ", modelName) - tokenTag := lipgloss.JoinHorizontal(lipgloss.Left, "📊 ", fmt.Sprintf("%d tokens", tokens)) - costTag := lipgloss.JoinHorizontal(lipgloss.Left, "💰 ", fmt.Sprintf("$%.3f", cost)) - modeTag := lipgloss.JoinHorizontal(lipgloss.Left, "🔨 ", strings.ToUpper(mode)) - timeTag := lipgloss.JoinHorizontal(lipgloss.Left, "⏱ ", duration) - - tags := []string{modelTag, tokenTag, costTag, modeTag, timeTag} - - var styledTags []string - for _, tag := range tags { - styledTags = append(styledTags, tag) - } - - content := strings.Join(styledTags, ui.BaseStyle.Foreground(ui.Muted).Render(" │ ")) - - box := ui.BaseStyle. - Border(lipgloss.RoundedBorder()). - BorderForeground(ui.Border). - Padding(0, 1). - Width(m.MaxContentWidth()). - Align(lipgloss.Center). - Foreground(ui.MutedLight) - - return box.Render(content) -} From 59f43c5ca94d4128c2ab27ecbc319ab8db1282d5 Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 17:08:15 +0530 Subject: [PATCH 38/57] fix(ui): remove emojis and format metrics as plain text in header --- internal/chat/header.go | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/internal/chat/header.go b/internal/chat/header.go index 0f03f8c..f1014c8 100644 --- a/internal/chat/header.go +++ b/internal/chat/header.go @@ -41,13 +41,24 @@ func renderHeader(m Model) string { } modelTag := ui.BaseStyle.Foreground(ui.Cyan).Bold(true).Render(model) - tokenTag := ui.BaseStyle.Foreground(ui.MutedLight).Render(fmt.Sprintf("📊 %d", tokens)) - costTag := ui.BaseStyle.Foreground(ui.MutedLight).Render(fmt.Sprintf("💰 $%.3f", cost)) - modeTag := ui.BaseStyle.Foreground(ui.MutedLight).Render(fmt.Sprintf("🔨 %s", strings.ToUpper(mode))) - timeTag := ui.BaseStyle.Foreground(ui.MutedLight).Render(fmt.Sprintf("⏱ %s", duration)) + tokenTag := ui.BaseStyle.Foreground(ui.MutedLight).Render(fmt.Sprintf("%d tok", tokens)) + + // Only show cost if it's > 0 (to avoid showing $0.000 for free APIs like Ollama/Groq) + costStr := "" + if cost > 0 { + costStr = fmt.Sprintf("$%.3f", cost) + } + costTag := ui.BaseStyle.Foreground(ui.MutedLight).Render(costStr) + + modeTag := ui.BaseStyle.Foreground(ui.MutedLight).Render(strings.ToUpper(mode)) + timeTag := ui.BaseStyle.Foreground(ui.MutedLight).Render(duration) themeTag := ui.BaseStyle.Foreground(ui.BrandCyan).Render(ui.CurrentThemeName) - tags := []string{modelTag, tokenTag, costTag, modeTag, timeTag, themeTag} + tags := []string{modelTag, tokenTag} + if costStr != "" { + tags = append(tags, costTag) + } + tags = append(tags, modeTag, timeTag, themeTag) right := strings.Join(tags, ui.BaseStyle.Foreground(ui.Muted).Render(" │ ")) From d3601532e30ff3834553be916d320861def975d9 Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 17:13:34 +0530 Subject: [PATCH 39/57] feat(ui): implement inline git-style diff view for file changes --- go.mod | 1 + internal/agent/executor.go | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/go.mod b/go.mod index 1a413c6..2013fb3 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/jmoiron/sqlx v1.4.0 github.com/mattn/go-sqlite3 v1.14.48 github.com/pkoukk/tiktoken-go v0.1.8 + github.com/pmezard/go-difflib v1.0.0 github.com/spf13/cobra v1.10.2 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/internal/agent/executor.go b/internal/agent/executor.go index af91eab..247ca9f 100644 --- a/internal/agent/executor.go +++ b/internal/agent/executor.go @@ -4,7 +4,9 @@ import ( "context" "fmt" "sync" + "strings" + "github.com/pmezard/go-difflib/difflib" "github.com/Nithwin/WindMist/internal/ai" "github.com/Nithwin/WindMist/internal/store" "github.com/Nithwin/WindMist/internal/tools" @@ -63,6 +65,28 @@ func (a *Agent) execute(ctx context.Context, calls []ai.ToolCall, onChunk func(s if res.Error != nil { content = fmt.Sprintf("error executing tool %s: %v", call.Name, res.Error) isError = true + } else if len(res.FileStates) > 0 { + var diffs strings.Builder + diffs.WriteString(fmt.Sprintf("Successfully modified %d file(s):\n\n", len(res.FileStates))) + + for _, state := range res.FileStates { + diff := difflib.UnifiedDiff{ + A: difflib.SplitLines(state.BeforeContent), + B: difflib.SplitLines(state.AfterContent), + FromFile: "a/" + state.Path, + ToFile: "b/" + state.Path, + Context: 3, + } + text, _ := difflib.GetUnifiedDiffString(diff) + diffs.WriteString(fmt.Sprintf("```diff\n%s\n```\n", strings.TrimSpace(text))) + } + + content = diffs.String() + + // Send the diff to the chat UI via onChunk so the user sees it immediately + if onChunk != nil { + onChunk("\n" + content + "\n") + } } else if res.Output != nil { content = fmt.Sprintf("%v", res.Output) } else { From 9aa7ee0f0d703cca4b2731cc1fce8aaaf8ddaea6 Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 17:15:35 +0530 Subject: [PATCH 40/57] feat(agent): automatically run language formatters after file modifications --- internal/agent/executor.go | 36 ++++++++++++++++++++----------- internal/agent/format.go | 44 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 12 deletions(-) create mode 100644 internal/agent/format.go diff --git a/internal/agent/executor.go b/internal/agent/executor.go index 247ca9f..5cc65f5 100644 --- a/internal/agent/executor.go +++ b/internal/agent/executor.go @@ -5,6 +5,7 @@ import ( "fmt" "sync" "strings" + "os" "github.com/pmezard/go-difflib/difflib" "github.com/Nithwin/WindMist/internal/ai" @@ -47,17 +48,7 @@ func (a *Agent) execute(ctx context.Context, calls []ai.ToolCall, onChunk func(s onChunk(fmt.Sprintf(" ✅ Done (`%s`).\n\n", call.Name)) } - if a.config.Store != nil && a.config.SessionID != "" && len(res.FileStates) > 0 { - for _, state := range res.FileStates { - _ = a.config.Store.SaveFileChange(&store.FileChange{ - SessionID: a.config.SessionID, - FilePath: state.Path, - ChangeType: state.ChangeType, - BeforeContent: state.BeforeContent, - AfterContent: state.AfterContent, - }) - } - } + content := "" isError := false @@ -69,7 +60,28 @@ func (a *Agent) execute(ctx context.Context, calls []ai.ToolCall, onChunk func(s var diffs strings.Builder diffs.WriteString(fmt.Sprintf("Successfully modified %d file(s):\n\n", len(res.FileStates))) - for _, state := range res.FileStates { + for i := range res.FileStates { + state := &res.FileStates[i] + + // Auto-format the file if possible + if autoFormat(state.Path) { + // Re-read the formatted content + if contentBytes, err := os.ReadFile(state.Path); err == nil { + state.AfterContent = string(contentBytes) + } + } + + // Now save the file change to the store (with formatted content) + if a.config.Store != nil && a.config.SessionID != "" { + _ = a.config.Store.SaveFileChange(&store.FileChange{ + SessionID: a.config.SessionID, + FilePath: state.Path, + ChangeType: state.ChangeType, + BeforeContent: state.BeforeContent, + AfterContent: state.AfterContent, + }) + } + diff := difflib.UnifiedDiff{ A: difflib.SplitLines(state.BeforeContent), B: difflib.SplitLines(state.AfterContent), diff --git a/internal/agent/format.go b/internal/agent/format.go new file mode 100644 index 0000000..1555bb9 --- /dev/null +++ b/internal/agent/format.go @@ -0,0 +1,44 @@ +package agent + +import ( + "os/exec" + "path/filepath" + "strings" +) + +// autoFormat attempts to format the given file using standard language formatters. +// It returns true if a formatter was successfully run, or false if no formatter was found or it failed. +func autoFormat(path string) bool { + ext := strings.ToLower(filepath.Ext(path)) + + var cmd *exec.Cmd + + switch ext { + case ".go": + if _, err := exec.LookPath("gofmt"); err == nil { + cmd = exec.Command("gofmt", "-w", path) + } + case ".js", ".ts", ".jsx", ".tsx", ".json", ".css", ".md", ".html": + if _, err := exec.LookPath("prettier"); err == nil { + cmd = exec.Command("prettier", "--write", path) + } + case ".py": + if _, err := exec.LookPath("black"); err == nil { + cmd = exec.Command("black", path) + } else if _, err := exec.LookPath("ruff"); err == nil { + cmd = exec.Command("ruff", "format", path) + } + case ".rs": + if _, err := exec.LookPath("rustfmt"); err == nil { + cmd = exec.Command("rustfmt", path) + } + } + + if cmd == nil { + return false + } + + // We don't care about the output right now, just run it silently + err := cmd.Run() + return err == nil +} From 6eaa4a0f5e845f8da582d131bec4e7019c7958d0 Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 17:20:13 +0530 Subject: [PATCH 41/57] fix(ui): correct evaluation order for the fuzzy file picker to trigger instantly --- internal/ai/request.go | 17 +++++++++ internal/chat/update_keys.go | 68 ++++++++++++++++++++---------------- 2 files changed, 55 insertions(+), 30 deletions(-) diff --git a/internal/ai/request.go b/internal/ai/request.go index 76d645c..702ef80 100644 --- a/internal/ai/request.go +++ b/internal/ai/request.go @@ -14,6 +14,7 @@ const ( type Message struct { Role Role `json:"role"` Content string `json:"content"` + Parts []Part `json:"parts,omitempty"` ToolCalls []ToolCall `json:"tool_calls,omitempty"` ToolResults []ToolResult `json:"tool_results,omitempty"` } @@ -27,3 +28,19 @@ type GenerateRequest struct { MaxTokens int Stream bool } + +// PartType defines the type of content in a multipart message. +type PartType string + +const ( + PartText PartType = "text" + PartImage PartType = "image" // base64 encoded image +) + +// Part represents a segment of a multi-modal message. +type Part struct { + Type PartType `json:"type"` + Text string `json:"text,omitempty"` + MIMEType string `json:"mime_type,omitempty"` + Data string `json:"data,omitempty"` +} diff --git a/internal/chat/update_keys.go b/internal/chat/update_keys.go index 5696ebf..1d1a9a4 100644 --- a/internal/chat/update_keys.go +++ b/internal/chat/update_keys.go @@ -112,36 +112,6 @@ func (m Model) handleKeyMsg(msg tea.KeyMsg) (Model, tea.Cmd) { return m, nil } - // Update slash command suggestions (check first line only). - value := m.input.Value() - firstLine := strings.SplitN(value, "\n", 2)[0] - - if strings.HasPrefix(firstLine, "/") { - m.showCommands = true - m.filteredCommands = FilterCommands(firstLine) - m.showFilePicker = false - } else { - m.showCommands = false - m.filteredCommands = nil - m.selectedCommand = 0 - - // Check for file picker trigger (@) anywhere in the text - words := strings.Fields(value) - if len(words) > 0 && strings.HasPrefix(words[len(words)-1], "@") { - m.showFilePicker = true - query := words[len(words)-1][1:] - m.filteredFiles = FilterFiles(m.workspaceFiles, query) - if m.selectedFile >= len(m.filteredFiles) { - m.selectedFile = 0 - } - } else { - m.showFilePicker = false - m.filteredFiles = nil - m.selectedFile = 0 - } - } - m.updateViewportSize() - // Navigate the command palette. if m.showCommands { switch msg.String() { @@ -272,5 +242,43 @@ func (m Model) handleKeyMsg(msg tea.KeyMsg) (Model, tea.Cmd) { var cmd tea.Cmd m.input, cmd = m.input.Update(msg) + + // Update slash command suggestions (check first line only). + value := m.input.Value() + firstLine := strings.SplitN(value, "\n", 2)[0] + + if strings.HasPrefix(firstLine, "/") { + m.showCommands = true + m.filteredCommands = FilterCommands(firstLine) + m.showFilePicker = false + } else { + m.showCommands = false + m.filteredCommands = nil + m.selectedCommand = 0 + + // Check for file picker trigger (@) anywhere in the text + // Don't trigger if there's a trailing space + if strings.HasSuffix(value, " ") || strings.HasSuffix(value, "\n") { + m.showFilePicker = false + m.filteredFiles = nil + m.selectedFile = 0 + } else { + words := strings.Fields(value) + if len(words) > 0 && strings.HasPrefix(words[len(words)-1], "@") { + m.showFilePicker = true + query := words[len(words)-1][1:] + m.filteredFiles = FilterFiles(m.workspaceFiles, query) + if m.selectedFile >= len(m.filteredFiles) { + m.selectedFile = 0 + } + } else { + m.showFilePicker = false + m.filteredFiles = nil + m.selectedFile = 0 + } + } + } + m.updateViewportSize() + return m, cmd } From 5b4811e2499cef6e45424a560b65b1634e469b44 Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 17:22:36 +0530 Subject: [PATCH 42/57] fix(ui): resolve file picker not opening due to disconnected state mutation --- internal/chat/messages.go | 5 +++++ internal/chat/model.go | 15 ++++++--------- internal/chat/update.go | 4 ++++ 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/internal/chat/messages.go b/internal/chat/messages.go index b11acfc..4718bcf 100644 --- a/internal/chat/messages.go +++ b/internal/chat/messages.go @@ -77,3 +77,8 @@ type ApprovalRequestMsg struct { Command string ResponseChan chan bool } + +// WorkspaceFilesMsg contains the list of files found in the workspace +type WorkspaceFilesMsg struct { + Files []string +} diff --git a/internal/chat/model.go b/internal/chat/model.go index 0237af1..b20fa57 100644 --- a/internal/chat/model.go +++ b/internal/chat/model.go @@ -165,20 +165,17 @@ func New() (Model, error) { model.updateViewportSize() - // Async load files so startup is fast - go func() { - files := getWorkspaceFiles() - // We'd ideally send a Msg, but this is fine for now - model.workspaceFiles = files - }() - return model, nil - } // Init initializes the application. func (m Model) Init() tea.Cmd { - return textarea.Blink + return tea.Batch( + textarea.Blink, + func() tea.Msg { + return WorkspaceFilesMsg{Files: getWorkspaceFiles()} + }, + ) } // MaxContentWidth calculates the maximum width for the UI content based on the window size. diff --git a/internal/chat/update.go b/internal/chat/update.go index 7245401..6a60229 100644 --- a/internal/chat/update.go +++ b/internal/chat/update.go @@ -22,6 +22,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case StreamingMsg: return m.handleStreamMsg(msg) + case WorkspaceFilesMsg: + m.workspaceFiles = msg.Files + return m, nil + // All other custom events (Session, Agent Mode, Undo/Redo, Models) case ApprovalRequestMsg, switchModeSuccessMsg, createNewSessionMsg, undoFileChangeMsg, redoFileChangeMsg, switchSessionSuccessMsg, From e17908b7837c1976283014e74c0e51c286d70eb3 Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 17:31:47 +0530 Subject: [PATCH 43/57] feat(store): complete option 2 with multi-level undo/redo and auto-titling --- internal/agent/executor.go | 10 ++++++++ internal/chat/chat.go | 18 +++++++++++++++ internal/chat/update_events.go | 36 +++++++++++++++++------------ internal/store/models.go | 2 ++ internal/store/queries.go | 42 ++++++++++++++++++++++++++++++++-- 5 files changed, 92 insertions(+), 16 deletions(-) diff --git a/internal/agent/executor.go b/internal/agent/executor.go index 5cc65f5..621a451 100644 --- a/internal/agent/executor.go +++ b/internal/agent/executor.go @@ -7,6 +7,8 @@ import ( "strings" "os" + "time" + "github.com/pmezard/go-difflib/difflib" "github.com/Nithwin/WindMist/internal/ai" "github.com/Nithwin/WindMist/internal/store" @@ -17,6 +19,13 @@ import ( func (a *Agent) execute(ctx context.Context, calls []ai.ToolCall, onChunk func(string)) []ai.ToolResult { results := make([]ai.ToolResult, len(calls)) var wg sync.WaitGroup + + batchID := fmt.Sprintf("batch_%d", time.Now().UnixNano()) + + // Clear redo history when a new edit is made + if a.config.Store != nil && a.config.SessionID != "" { + _ = a.config.Store.ClearRedoHistory(a.config.SessionID) + } for i, call := range calls { wg.Add(1) @@ -75,6 +84,7 @@ func (a *Agent) execute(ctx context.Context, calls []ai.ToolCall, onChunk func(s if a.config.Store != nil && a.config.SessionID != "" { _ = a.config.Store.SaveFileChange(&store.FileChange{ SessionID: a.config.SessionID, + BatchID: batchID, FilePath: state.Path, ChangeType: state.ChangeType, BeforeContent: state.BeforeContent, diff --git a/internal/chat/chat.go b/internal/chat/chat.go index b3dde68..63ae46d 100644 --- a/internal/chat/chat.go +++ b/internal/chat/chat.go @@ -10,6 +10,24 @@ import ( ) func (m Model) sendMessage(ctx context.Context, prompt string) { + // Auto-title the session if it's the first message + if m.session != nil && m.session.Title == "New Session" && m.store != nil { + go func() { + titleReq := &ai.GenerateRequest{ + System: "You are an AI that creates extremely short, 2-4 word titles for chat sessions based on the user's first prompt. Do not use punctuation. Do not use quotes. Keep it lowercase.", + Messages: []ai.Message{ + {Role: ai.RoleUser, Content: prompt}, + }, + MaxTokens: 20, + } + resp, err := m.provider.Generate(context.Background(), titleReq) + if err == nil && resp.Text != "" { + m.session.Title = resp.Text + _ = m.store.UpdateSession(m.session) + } + }() + } + go func() { startTime := time.Now() initialMessages := m.getInitialMessages() diff --git a/internal/chat/update_events.go b/internal/chat/update_events.go index 260e285..eeab339 100644 --- a/internal/chat/update_events.go +++ b/internal/chat/update_events.go @@ -76,20 +76,24 @@ func (m Model) handleEventMsg(msg tea.Msg) (Model, tea.Cmd) { return m, nil } - change, err := m.store.GetLastFileChange(m.session.ID) - if err != nil { + changes, err := m.store.GetLastBatchForUndo(m.session.ID) + if err != nil || len(changes) == 0 { m.conversation.AddAssistant("❌ No file changes found to undo.") m.refreshViewport() return m, nil } - if change.ChangeType == "create" { - _ = os.Remove(change.FilePath) - } else { - _ = os.WriteFile(change.FilePath, []byte(change.BeforeContent), 0644) + for _, change := range changes { + if change.ChangeType == "create" { + _ = os.Remove(change.FilePath) + } else { + _ = os.WriteFile(change.FilePath, []byte(change.BeforeContent), 0644) + } } - m.conversation.AddAssistant(fmt.Sprintf("⏮️ **Undid edit** to `%s`", change.FilePath)) + _ = m.store.SetBatchUndoneState(m.session.ID, changes[0].BatchID, true) + + m.conversation.AddAssistant(fmt.Sprintf("⏮️ **Undid %d file edit(s)**", len(changes))) m.refreshViewport() return m, nil @@ -100,20 +104,24 @@ func (m Model) handleEventMsg(msg tea.Msg) (Model, tea.Cmd) { return m, nil } - change, err := m.store.GetLastFileChange(m.session.ID) - if err != nil { + changes, err := m.store.GetNextBatchForRedo(m.session.ID) + if err != nil || len(changes) == 0 { m.conversation.AddAssistant("❌ No file changes found to redo.") m.refreshViewport() return m, nil } - if change.ChangeType == "delete" { - _ = os.Remove(change.FilePath) - } else { - _ = os.WriteFile(change.FilePath, []byte(change.AfterContent), 0644) + for _, change := range changes { + if change.ChangeType == "delete" { + _ = os.Remove(change.FilePath) + } else { + _ = os.WriteFile(change.FilePath, []byte(change.AfterContent), 0644) + } } + + _ = m.store.SetBatchUndoneState(m.session.ID, changes[0].BatchID, false) - m.conversation.AddAssistant(fmt.Sprintf("⏭️ **Redid edit** to `%s`", change.FilePath)) + m.conversation.AddAssistant(fmt.Sprintf("⏭️ **Redid %d file edit(s)**", len(changes))) m.refreshViewport() return m, nil diff --git a/internal/store/models.go b/internal/store/models.go index 3031046..e2338bf 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -32,9 +32,11 @@ type FileChange struct { ID int `db:"id"` SessionID string `db:"session_id"` MessageID int `db:"message_id"` + BatchID string `db:"batch_id"` FilePath string `db:"file_path"` ChangeType string `db:"change_type"` // create, edit, delete BeforeContent string `db:"before_content"` AfterContent string `db:"after_content"` + Undone bool `db:"undone"` CreatedAt time.Time `db:"created_at"` } diff --git a/internal/store/queries.go b/internal/store/queries.go index 4c7cc58..c8b4c78 100644 --- a/internal/store/queries.go +++ b/internal/store/queries.go @@ -83,8 +83,8 @@ func (s *Store) SaveFileChange(change *FileChange) error { change.CreatedAt = time.Now() query := ` - INSERT INTO file_changes (session_id, message_id, file_path, change_type, before_content, after_content, created_at) - VALUES (:session_id, :message_id, :file_path, :change_type, :before_content, :after_content, :created_at) + INSERT INTO file_changes (session_id, message_id, batch_id, file_path, change_type, before_content, after_content, undone, created_at) + VALUES (:session_id, :message_id, :batch_id, :file_path, :change_type, :before_content, :after_content, :undone, :created_at) ` res, err := s.db.NamedExec(query, change) if err != nil { @@ -116,6 +116,44 @@ func (s *Store) GetLastFileChange(sessionID string) (*FileChange, error) { return &change, nil } +// GetLastBatchForUndo gets the most recent batch of changes that haven't been undone +func (s *Store) GetLastBatchForUndo(sessionID string) ([]FileChange, error) { + var batchID string + err := s.db.Get(&batchID, "SELECT batch_id FROM file_changes WHERE session_id = ? AND undone = 0 ORDER BY id DESC LIMIT 1", sessionID) + if err != nil { + return nil, err + } + + var changes []FileChange + err = s.db.Select(&changes, "SELECT * FROM file_changes WHERE session_id = ? AND batch_id = ? ORDER BY id DESC", sessionID, batchID) + return changes, err +} + +// GetNextBatchForRedo gets the oldest batch of changes that are currently undone +func (s *Store) GetNextBatchForRedo(sessionID string) ([]FileChange, error) { + var batchID string + err := s.db.Get(&batchID, "SELECT batch_id FROM file_changes WHERE session_id = ? AND undone = 1 ORDER BY id ASC LIMIT 1", sessionID) + if err != nil { + return nil, err + } + + var changes []FileChange + err = s.db.Select(&changes, "SELECT * FROM file_changes WHERE session_id = ? AND batch_id = ? ORDER BY id ASC", sessionID, batchID) + return changes, err +} + +// SetBatchUndoneState updates the undone status of a batch +func (s *Store) SetBatchUndoneState(sessionID string, batchID string, undone bool) error { + _, err := s.db.Exec("UPDATE file_changes SET undone = ? WHERE session_id = ? AND batch_id = ?", undone, sessionID, batchID) + return err +} + +// ClearRedoHistory removes all file changes that are currently undone for a session +func (s *Store) ClearRedoHistory(sessionID string) error { + _, err := s.db.Exec("DELETE FROM file_changes WHERE session_id = ? AND undone = 1", sessionID) + return err +} + // DeleteSession completely deletes a session and all cascading data func (s *Store) DeleteSession(id string) error { res, err := s.db.Exec("DELETE FROM sessions WHERE id = ?", id) From 8af52b4896d7b5268d81626bded82e612d89682c Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 17:45:05 +0530 Subject: [PATCH 44/57] feat(lsp): implement LSP client and auto-fix loop --- internal/agent/agent.go | 23 ++- internal/agent/executor.go | 35 +++++ internal/chat/commands.go | 6 + internal/chat/update_keys.go | 6 + internal/lsp/client.go | 290 +++++++++++++++++++++++++++++++++++ internal/lsp/manager.go | 87 +++++++++++ 6 files changed, 440 insertions(+), 7 deletions(-) create mode 100644 internal/lsp/client.go create mode 100644 internal/lsp/manager.go diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 1dcb3c2..707f61f 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -5,6 +5,7 @@ import ( "encoding/json" "github.com/Nithwin/WindMist/internal/ai" + "github.com/Nithwin/WindMist/internal/lsp" "github.com/Nithwin/WindMist/internal/store" "github.com/Nithwin/WindMist/internal/tools" ) @@ -40,10 +41,10 @@ type Result struct { // Agent coordinates the language model and the available tools to solve // software engineering tasks. type Agent struct { - provider ai.Provider - manager *tools.Manager - - config Config + provider ai.Provider + manager *tools.Manager + config Config + lspManager *lsp.Manager } // New creates a new Agent. @@ -66,9 +67,17 @@ func New( } return &Agent{ - provider: provider, - manager: manager, - config: config, + provider: provider, + manager: manager, + config: config, + lspManager: lsp.NewManager(), + } +} + +// Close gracefully shuts down any resources held by the agent (like LSPs). +func (a *Agent) Close() { + if a.lspManager != nil { + a.lspManager.CloseAll() } } diff --git a/internal/agent/executor.go b/internal/agent/executor.go index 621a451..c0f0d3c 100644 --- a/internal/agent/executor.go +++ b/internal/agent/executor.go @@ -6,6 +6,7 @@ import ( "sync" "strings" "os" + "path/filepath" "time" @@ -101,6 +102,40 @@ func (a *Agent) execute(ctx context.Context, calls []ai.ToolCall, onChunk func(s } text, _ := difflib.GetUnifiedDiffString(diff) diffs.WriteString(fmt.Sprintf("```diff\n%s\n```\n", strings.TrimSpace(text))) + + // Connect to LSP and check for diagnostics + if a.lspManager != nil { + absPath, err := filepath.Abs(state.Path) + if err == nil { + client, err := a.lspManager.GetClient(ctx, ".", absPath) + if err == nil && client != nil { + uri := "file://" + absPath + // Trigger a didOpen/didChange or simply wait for the server + // to send diagnostics based on file watching, or explicitly send them + _ = client.Notify("textDocument/didOpen", map[string]interface{}{ + "textDocument": map[string]interface{}{ + "uri": uri, + "languageId": "", + "version": 1, + "text": state.AfterContent, + }, + }) + + // Wait for diagnostics to stream in + time.Sleep(500 * time.Millisecond) + + diags := client.GetDiagnostics(uri) + if len(diags) > 0 { + diffs.WriteString("\n⚠️ **LSP Diagnostics Found:**\n") + for _, d := range diags { + if d.Severity == 1 { // Error only + diffs.WriteString(fmt.Sprintf("- [%s] %s\n", d.Source, d.Message)) + } + } + } + } + } + } } content = diffs.String() diff --git a/internal/chat/commands.go b/internal/chat/commands.go index a563dba..bff72d3 100644 --- a/internal/chat/commands.go +++ b/internal/chat/commands.go @@ -114,6 +114,9 @@ var Registry = []Command{ Name: "/exit", Description: "Exit WindMist", Execute: func(m *Model) tea.Cmd { + if m.agent != nil { + m.agent.Close() + } return tea.Quit }, }, @@ -121,6 +124,9 @@ var Registry = []Command{ Name: "/quit", Description: "Exit WindMist", Execute: func(m *Model) tea.Cmd { + if m.agent != nil { + m.agent.Close() + } return tea.Quit }, }, diff --git a/internal/chat/update_keys.go b/internal/chat/update_keys.go index 1d1a9a4..22f4525 100644 --- a/internal/chat/update_keys.go +++ b/internal/chat/update_keys.go @@ -80,6 +80,9 @@ func (m Model) handleKeyMsg(msg tea.KeyMsg) (Model, tea.Cmd) { m.approvalChan <- false } m.waitingApproval = false + if m.agent != nil { + m.agent.Close() + } return m, tea.Quit } // Block other inputs @@ -95,6 +98,9 @@ func (m Model) handleKeyMsg(msg tea.KeyMsg) (Model, tea.Cmd) { m.refreshViewport() return m, nil } + if m.agent != nil { + m.agent.Close() + } return m, tea.Quit } diff --git a/internal/lsp/client.go b/internal/lsp/client.go new file mode 100644 index 0000000..1fbabc5 --- /dev/null +++ b/internal/lsp/client.go @@ -0,0 +1,290 @@ +package lsp + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "os/exec" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" +) + +// Client represents an LSP JSON-RPC client connected via stdio. +type Client struct { + cmd *exec.Cmd + stdin io.WriteCloser + stdout io.ReadCloser + + projectPath string + + nextID int64 + mu sync.Mutex + pending map[int64]chan *JSONRPCMessage + + diagMu sync.Mutex + diagnostics map[string][]Diagnostic // URI -> Diagnostics + + idleTimer *time.Timer + idleMu sync.Mutex + onIdleFunc func() + idleDur time.Duration +} + +// JSONRPCRequest represents a JSON-RPC 2.0 request. +type JSONRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + ID int64 `json:"id"` + Method string `json:"method"` + Params interface{} `json:"params,omitempty"` +} + +// JSONRPCMessage can be a request, response, or notification. +type JSONRPCMessage struct { + JSONRPC string `json:"jsonrpc"` + ID int64 `json:"id,omitempty"` + Method string `json:"method,omitempty"` + Params json.RawMessage `json:"params,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *JSONRPCError `json:"error,omitempty"` +} + +type JSONRPCError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type Diagnostic struct { + Message string `json:"message"` + Severity int `json:"severity"` // 1: Error, 2: Warning, 3: Info, 4: Hint + Source string `json:"source"` +} + +type PublishDiagnosticsParams struct { + URI string `json:"uri"` + Diagnostics []Diagnostic `json:"diagnostics"` +} + +// NewClient creates a new LSP client. +func NewClient(command string, args []string, projectPath string) *Client { + cmd := exec.Command(command, args...) + cmd.Dir = projectPath + + return &Client{ + cmd: cmd, + projectPath: projectPath, + pending: make(map[int64]chan *JSONRPCMessage), + diagnostics: make(map[string][]Diagnostic), + } +} + +// Start launches the LSP server and the read loop. +func (c *Client) Start(ctx context.Context) error { + stdin, err := c.cmd.StdinPipe() + if err != nil { + return err + } + + stdout, err := c.cmd.StdoutPipe() + if err != nil { + return err + } + + c.stdin = stdin + c.stdout = stdout + + if err := c.cmd.Start(); err != nil { + return err + } + + go c.readLoop() + + // Send initialize request + type InitParams struct { + ProcessID int `json:"processId"` + RootURI string `json:"rootUri"` + } + + _, err = c.Call(ctx, "initialize", InitParams{ + ProcessID: c.cmd.Process.Pid, + RootURI: "file://" + c.projectPath, + }) + if err != nil { + c.Close() + return fmt.Errorf("LSP initialization failed: %w", err) + } + + // Send initialized notification + _ = c.Notify("initialized", map[string]interface{}{}) + + return nil +} + +// ResetIdleTimer resets the idle countdown. +func (c *Client) ResetIdleTimer() { + c.idleMu.Lock() + defer c.idleMu.Unlock() + + if c.idleTimer != nil { + c.idleTimer.Reset(c.idleDur) + } +} + +// OnIdle sets a callback to be called when the client is idle. +func (c *Client) OnIdle(duration time.Duration, callback func()) { + c.idleMu.Lock() + defer c.idleMu.Unlock() + + c.idleDur = duration + c.onIdleFunc = callback + c.idleTimer = time.AfterFunc(duration, callback) +} + +// Call sends a JSON-RPC request and waits for the response. +func (c *Client) Call(ctx context.Context, method string, params interface{}) (*JSONRPCMessage, error) { + c.ResetIdleTimer() + + id := atomic.AddInt64(&c.nextID, 1) + req := JSONRPCRequest{ + JSONRPC: "2.0", + ID: id, + Method: method, + Params: params, + } + + data, err := json.Marshal(req) + if err != nil { + return nil, err + } + + ch := make(chan *JSONRPCMessage, 1) + c.mu.Lock() + c.pending[id] = ch + c.mu.Unlock() + + defer func() { + c.mu.Lock() + delete(c.pending, id) + c.mu.Unlock() + }() + + msg := fmt.Sprintf("Content-Length: %d\r\n\r\n%s", len(data), data) + if _, err := c.stdin.Write([]byte(msg)); err != nil { + return nil, err + } + + select { + case res := <-ch: + if res.Error != nil { + return nil, fmt.Errorf("RPC Error %d: %s", res.Error.Code, res.Error.Message) + } + return res, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +// Notify sends a JSON-RPC notification (no response expected). +func (c *Client) Notify(method string, params interface{}) error { + c.ResetIdleTimer() + + req := map[string]interface{}{ + "jsonrpc": "2.0", + "method": method, + "params": params, + } + + data, err := json.Marshal(req) + if err != nil { + return err + } + + msg := fmt.Sprintf("Content-Length: %d\r\n\r\n%s", len(data), data) + _, err = c.stdin.Write([]byte(msg)) + return err +} + +func (c *Client) readLoop() { + reader := bufio.NewReader(c.stdout) + for { + // Read headers + var contentLength int + for { + line, err := reader.ReadString('\n') + if err != nil { + return // EOF or closed + } + line = strings.TrimSpace(line) + if line == "" { + break + } + if strings.HasPrefix(line, "Content-Length:") { + parts := strings.Split(line, ":") + if len(parts) == 2 { + contentLength, _ = strconv.Atoi(strings.TrimSpace(parts[1])) + } + } + } + + if contentLength == 0 { + continue + } + + // Read body + body := make([]byte, contentLength) + if _, err := io.ReadFull(reader, body); err != nil { + return + } + + var res JSONRPCMessage + if err := json.Unmarshal(body, &res); err == nil { + // If it's a response to a request we made + if res.ID != 0 { + c.mu.Lock() + if ch, ok := c.pending[res.ID]; ok { + ch <- &res + } + c.mu.Unlock() + } else if res.Method == "textDocument/publishDiagnostics" { + var params PublishDiagnosticsParams + if err := json.Unmarshal(res.Params, ¶ms); err == nil { + c.diagMu.Lock() + c.diagnostics[params.URI] = params.Diagnostics + c.diagMu.Unlock() + } + } + } + } +} + +// GetDiagnostics returns the latest collected diagnostics for a given file URI. +func (c *Client) GetDiagnostics(uri string) []Diagnostic { + c.diagMu.Lock() + defer c.diagMu.Unlock() + + // Create a copy to avoid race conditions + if diags, ok := c.diagnostics[uri]; ok { + cpy := make([]Diagnostic, len(diags)) + copy(cpy, diags) + return cpy + } + return nil +} + +// Close gracefully terminates the LSP server. +func (c *Client) Close() { + c.idleMu.Lock() + if c.idleTimer != nil { + c.idleTimer.Stop() + } + c.idleMu.Unlock() + + _ = c.Notify("exit", nil) + if c.cmd.Process != nil { + _ = c.cmd.Process.Kill() + } +} diff --git a/internal/lsp/manager.go b/internal/lsp/manager.go new file mode 100644 index 0000000..c14a6b0 --- /dev/null +++ b/internal/lsp/manager.go @@ -0,0 +1,87 @@ +package lsp + +import ( + "context" + "fmt" + "path/filepath" + "strings" + "sync" + "time" +) + +// Config represents the configuration for an LSP server. +type Config struct { + Command string + Args []string +} + +// Manager manages language servers for different file types. +type Manager struct { + servers map[string]*Client + mu sync.Mutex + configs map[string]Config // extension -> Config mapping +} + +// NewManager creates a new LSP manager. +func NewManager() *Manager { + return &Manager{ + servers: make(map[string]*Client), + configs: map[string]Config{ + ".go": {Command: "gopls", Args: []string{"serve"}}, + ".py": {Command: "pyright-langserver", Args: []string{"--stdio"}}, + ".ts": {Command: "typescript-language-server", Args: []string{"--stdio"}}, + ".js": {Command: "typescript-language-server", Args: []string{"--stdio"}}, + ".rs": {Command: "rust-analyzer", Args: []string{}}, + }, + } +} + +// GetClient returns a running client for the file extension, or starts one if not running. +func (m *Manager) GetClient(ctx context.Context, projectPath string, filePath string) (*Client, error) { + ext := strings.ToLower(filepath.Ext(filePath)) + + m.mu.Lock() + defer m.mu.Unlock() + + // If already running, return it and reset its idle timer + if client, ok := m.servers[ext]; ok { + client.ResetIdleTimer() + return client, nil + } + + // Lookup config + cfg, ok := m.configs[ext] + if !ok { + return nil, fmt.Errorf("no LSP configured for extension %s", ext) + } + + // Start new client + client := NewClient(cfg.Command, cfg.Args, projectPath) + + // Add an idle callback to automatically shut down the LSP to save RAM + client.OnIdle(30*time.Second, func() { + m.mu.Lock() + defer m.mu.Unlock() + if c, exists := m.servers[ext]; exists && c == client { + c.Close() + delete(m.servers, ext) + } + }) + + if err := client.Start(ctx); err != nil { + return nil, fmt.Errorf("failed to start LSP for %s: %w", ext, err) + } + + m.servers[ext] = client + return client, nil +} + +// CloseAll shuts down all running LSP servers. +func (m *Manager) CloseAll() { + m.mu.Lock() + defer m.mu.Unlock() + for ext, client := range m.servers { + client.Close() + delete(m.servers, ext) + } +} From 7d53d31952e72c2377d35a1cce777985cd28d71e Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 17:48:33 +0530 Subject: [PATCH 45/57] feat(lsp): add support for C/C++, Java, and Ruby --- internal/lsp/manager.go | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/internal/lsp/manager.go b/internal/lsp/manager.go index c14a6b0..4f9003e 100644 --- a/internal/lsp/manager.go +++ b/internal/lsp/manager.go @@ -27,11 +27,17 @@ func NewManager() *Manager { return &Manager{ servers: make(map[string]*Client), configs: map[string]Config{ - ".go": {Command: "gopls", Args: []string{"serve"}}, - ".py": {Command: "pyright-langserver", Args: []string{"--stdio"}}, - ".ts": {Command: "typescript-language-server", Args: []string{"--stdio"}}, - ".js": {Command: "typescript-language-server", Args: []string{"--stdio"}}, - ".rs": {Command: "rust-analyzer", Args: []string{}}, + ".go": {Command: "gopls", Args: []string{"serve"}}, + ".py": {Command: "pyright-langserver", Args: []string{"--stdio"}}, + ".ts": {Command: "typescript-language-server", Args: []string{"--stdio"}}, + ".js": {Command: "typescript-language-server", Args: []string{"--stdio"}}, + ".rs": {Command: "rust-analyzer", Args: []string{}}, + ".c": {Command: "clangd", Args: []string{}}, + ".cpp": {Command: "clangd", Args: []string{}}, + ".h": {Command: "clangd", Args: []string{}}, + ".hpp": {Command: "clangd", Args: []string{}}, + ".java": {Command: "jdtls", Args: []string{}}, + ".rb": {Command: "solargraph", Args: []string{"stdio"}}, }, } } From 2c03dad7b49991f6d85fb0d04aac1e05d66ca3d5 Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 18:00:22 +0530 Subject: [PATCH 46/57] feat(mcp): implement MCP client and tool auto-discovery --- internal/agent/agent.go | 26 ++++- internal/agent/executor.go | 27 +++++ internal/config/types.go | 8 ++ internal/mcp/client.go | 220 +++++++++++++++++++++++++++++++++++++ internal/mcp/manager.go | 193 ++++++++++++++++++++++++++++++++ 5 files changed, 473 insertions(+), 1 deletion(-) create mode 100644 internal/mcp/client.go create mode 100644 internal/mcp/manager.go diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 707f61f..a8fbdc9 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -4,8 +4,12 @@ import ( "context" "encoding/json" + "time" + "github.com/Nithwin/WindMist/internal/ai" + appconfig "github.com/Nithwin/WindMist/internal/config" "github.com/Nithwin/WindMist/internal/lsp" + "github.com/Nithwin/WindMist/internal/mcp" "github.com/Nithwin/WindMist/internal/store" "github.com/Nithwin/WindMist/internal/tools" ) @@ -45,6 +49,7 @@ type Agent struct { manager *tools.Manager config Config lspManager *lsp.Manager + mcpManager *mcp.Manager } // New creates a new Agent. @@ -66,12 +71,28 @@ func New( config.Memory = SlidingWindowMemory{} } - return &Agent{ + a := &Agent{ provider: provider, manager: manager, config: config, lspManager: lsp.NewManager(), + mcpManager: mcp.NewManager(), } + + // Start MCP servers asynchronously so it doesn't block UI load + go func() { + // Create a temporary context for startup + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Load global config to get MCPServers + globalCfg, err := appconfig.Load() + if err == nil { + _ = a.mcpManager.StartAll(ctx, globalCfg) + } + }() + + return a } // Close gracefully shuts down any resources held by the agent (like LSPs). @@ -79,6 +100,9 @@ func (a *Agent) Close() { if a.lspManager != nil { a.lspManager.CloseAll() } + if a.mcpManager != nil { + a.mcpManager.CloseAll() + } } // Manager returns the tools manager associated with the agent. diff --git a/internal/agent/executor.go b/internal/agent/executor.go index c0f0d3c..ed9b242 100644 --- a/internal/agent/executor.go +++ b/internal/agent/executor.go @@ -32,6 +32,28 @@ func (a *Agent) execute(ctx context.Context, calls []ai.ToolCall, onChunk func(s wg.Add(1) go func(i int, call ai.ToolCall) { defer wg.Done() + + // Route to MCP Manager if it's an MCP tool + if strings.HasPrefix(call.Name, "mcp_") && a.mcpManager != nil { + res, err := a.mcpManager.ExecuteTool(ctx, call.Name, call.Args) + + content := "" + isError := false + if err != nil { + content = fmt.Sprintf("MCP error: %v", err) + isError = true + } else { + content = fmt.Sprintf("%v", res) + } + + results[i] = ai.ToolResult{ + ID: call.ID, + Name: call.Name, + Content: content, + IsError: isError, + } + return + } tool, ok := a.manager.Get(call.Name) if !ok { @@ -189,5 +211,10 @@ func (a *Agent) toolDefinitions(modeConfig ModeConfig) []ai.ToolDefinition { Parameters: params, }) } + + if a.mcpManager != nil { + defs = append(defs, a.mcpManager.GetTools()...) + } + return defs } diff --git a/internal/config/types.go b/internal/config/types.go index 10edceb..ec799e0 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -8,6 +8,14 @@ type Config struct { Cache CacheConfig `yaml:"cache"` SubAgent SubAgentConfig `yaml:"subagent,omitempty"` CustomModels map[string][]string `yaml:"custom_models,omitempty"` + MCPServers map[string]MCPServerConfig `yaml:"mcp_servers,omitempty"` +} + +// MCPServerConfig stores the configuration for an MCP server. +type MCPServerConfig struct { + Command string `yaml:"command"` + Args []string `yaml:"args,omitempty"` + Env map[string]string `yaml:"env,omitempty"` } // SubAgentConfig stores the provider and model for sub-agents. diff --git a/internal/mcp/client.go b/internal/mcp/client.go new file mode 100644 index 0000000..7914103 --- /dev/null +++ b/internal/mcp/client.go @@ -0,0 +1,220 @@ +package mcp + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "strconv" + "strings" + "sync" + "sync/atomic" +) + +type Client struct { + Name string + cmd *exec.Cmd + stdin io.WriteCloser + stdout io.ReadCloser + + nextID int64 + mu sync.Mutex + pending map[int64]chan *JSONRPCResponse +} + +type JSONRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + ID int64 `json:"id"` + Method string `json:"method"` + Params interface{} `json:"params,omitempty"` +} + +type JSONRPCResponse struct { + JSONRPC string `json:"jsonrpc"` + ID int64 `json:"id"` + Result json.RawMessage `json:"result,omitempty"` + Error *JSONRPCError `json:"error,omitempty"` +} + +type JSONRPCError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func NewClient(name, command string, args []string, env map[string]string) *Client { + cmd := exec.Command(command, args...) + + if len(env) > 0 { + cmd.Env = os.Environ() + for k, v := range env { + cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v)) + } + } + + return &Client{ + Name: name, + cmd: cmd, + pending: make(map[int64]chan *JSONRPCResponse), + } +} + +func (c *Client) Start(ctx context.Context) error { + stdin, err := c.cmd.StdinPipe() + if err != nil { + return err + } + + stdout, err := c.cmd.StdoutPipe() + if err != nil { + return err + } + + // We might also want to pipe stderr for debugging + c.cmd.Stderr = os.Stderr + + c.stdin = stdin + c.stdout = stdout + + if err := c.cmd.Start(); err != nil { + return err + } + + go c.readLoop() + + // Initialize MCP session + type ClientInfo struct { + Name string `json:"name"` + Version string `json:"version"` + } + + type InitParams struct { + ProtocolVersion string `json:"protocolVersion"` + Capabilities map[string]interface{} `json:"capabilities"` + ClientInfo ClientInfo `json:"clientInfo"` + } + + _, err = c.Call(ctx, "initialize", InitParams{ + ProtocolVersion: "2024-11-05", // Standard MCP protocol version + Capabilities: map[string]interface{}{}, + ClientInfo: ClientInfo{ + Name: "WindMist", + Version: "2.0.0", + }, + }) + + if err != nil { + c.Close() + return fmt.Errorf("MCP initialization failed: %w", err) + } + + // Send initialized notification + _ = c.Notify("notifications/initialized", map[string]interface{}{}) + + return nil +} + +func (c *Client) Call(ctx context.Context, method string, params interface{}) (*JSONRPCResponse, error) { + id := atomic.AddInt64(&c.nextID, 1) + req := JSONRPCRequest{ + JSONRPC: "2.0", + ID: id, + Method: method, + Params: params, + } + + data, err := json.Marshal(req) + if err != nil { + return nil, err + } + + ch := make(chan *JSONRPCResponse, 1) + c.mu.Lock() + c.pending[id] = ch + c.mu.Unlock() + + defer func() { + c.mu.Lock() + delete(c.pending, id) + c.mu.Unlock() + }() + + // MCP usually uses newline-delimited JSON or HTTP-like headers depending on transport. + // StdIO transport usually uses JSON-RPC directly with \n + msg := string(data) + "\n" + if _, err := c.stdin.Write([]byte(msg)); err != nil { + return nil, err + } + + select { + case res := <-ch: + if res.Error != nil { + return nil, fmt.Errorf("MCP RPC Error %d: %s", res.Error.Code, res.Error.Message) + } + return res, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func (c *Client) Notify(method string, params interface{}) error { + req := map[string]interface{}{ + "jsonrpc": "2.0", + "method": method, + "params": params, + } + + data, err := json.Marshal(req) + if err != nil { + return err + } + + msg := string(data) + "\n" + _, err = c.stdin.Write([]byte(msg)) + return err +} + +func (c *Client) readLoop() { + reader := bufio.NewReader(c.stdout) + for { + line, err := reader.ReadBytes('\n') + if err != nil { + return + } + + // Some MCP servers might use Content-Length headers, check for that + if strings.HasPrefix(string(line), "Content-Length:") { + parts := strings.Split(string(line), ":") + if len(parts) == 2 { + contentLength, _ := strconv.Atoi(strings.TrimSpace(parts[1])) + // read the extra \r\n + _, _ = reader.ReadBytes('\n') + + body := make([]byte, contentLength) + if _, err := io.ReadFull(reader, body); err != nil { + return + } + line = body + } + } + + var res JSONRPCResponse + if err := json.Unmarshal(line, &res); err == nil { + if res.ID != 0 { + c.mu.Lock() + if ch, ok := c.pending[res.ID]; ok { + ch <- &res + } + c.mu.Unlock() + } + } + } +} + +func (c *Client) Close() { + if c.cmd.Process != nil { + _ = c.cmd.Process.Kill() + } +} diff --git a/internal/mcp/manager.go b/internal/mcp/manager.go new file mode 100644 index 0000000..1cec75a --- /dev/null +++ b/internal/mcp/manager.go @@ -0,0 +1,193 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "sync" + + "github.com/Nithwin/WindMist/internal/ai" + "github.com/Nithwin/WindMist/internal/config" +) + +type Manager struct { + servers map[string]*Client + tools map[string]ai.ToolDefinition + mu sync.Mutex +} + +func NewManager() *Manager { + return &Manager{ + servers: make(map[string]*Client), + tools: make(map[string]ai.ToolDefinition), + } +} + +// StartAll starts all MCP servers defined in the configuration and registers their tools. +func (m *Manager) StartAll(ctx context.Context, cfg *config.Config) error { + m.mu.Lock() + defer m.mu.Unlock() + + for name, srvCfg := range cfg.MCPServers { + if srvCfg.Command == "" { + continue + } + + client := NewClient(name, srvCfg.Command, srvCfg.Args, srvCfg.Env) + if err := client.Start(ctx); err != nil { + return fmt.Errorf("failed to start MCP server %s: %w", name, err) + } + + m.servers[name] = client + + // Fetch tools from the server + res, err := client.Call(ctx, "tools/list", map[string]interface{}{}) + if err != nil { + return fmt.Errorf("failed to fetch tools from %s: %w", name, err) + } + + var toolList struct { + Tools []struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema map[string]interface{} `json:"inputSchema"` + } `json:"tools"` + } + + if err := json.Unmarshal(res.Result, &toolList); err != nil { + return fmt.Errorf("failed to parse tools from %s: %w", name, err) + } + + // Register tools + for _, t := range toolList.Tools { + // Prefix the tool name to avoid collisions + mcpToolName := fmt.Sprintf("mcp_%s_%s", name, t.Name) + + // Extract parameters + var params []ai.ToolParameter + if props, ok := t.InputSchema["properties"].(map[string]interface{}); ok { + for propName, propVal := range props { + propMap := propVal.(map[string]interface{}) + desc, _ := propMap["description"].(string) + typ, _ := propMap["type"].(string) + + required := false + if reqArr, ok := t.InputSchema["required"].([]interface{}); ok { + for _, req := range reqArr { + if req.(string) == propName { + required = true + break + } + } + } + + params = append(params, ai.ToolParameter{ + Name: propName, + Type: typ, + Description: desc, + Required: required, + }) + } + } + + m.tools[mcpToolName] = ai.ToolDefinition{ + Name: mcpToolName, + Description: fmt.Sprintf("[%s] %s", name, t.Description), + Parameters: params, + } + } + } + return nil +} + +// GetTools returns all tools registered from MCP servers. +func (m *Manager) GetTools() []ai.ToolDefinition { + m.mu.Lock() + defer m.mu.Unlock() + + var list []ai.ToolDefinition + for _, t := range m.tools { + list = append(list, t) + } + return list +} + +// ExecuteTool calls a tool on the appropriate MCP server. +func (m *Manager) ExecuteTool(ctx context.Context, toolName string, args map[string]interface{}) (interface{}, error) { + m.mu.Lock() + defer m.mu.Unlock() + + // Parse out the server name and original tool name + // Format: mcp_{serverName}_{toolName} + parts := len("mcp_") + if len(toolName) <= parts { + return nil, fmt.Errorf("invalid MCP tool name: %s", toolName) + } + + rest := toolName[parts:] + serverName := "" + originalToolName := "" + + // Find the server name by checking prefixes + for name := range m.servers { + if len(rest) > len(name) && rest[:len(name)] == name && rest[len(name)] == '_' { + serverName = name + originalToolName = rest[len(name)+1:] + break + } + } + + if serverName == "" { + return nil, fmt.Errorf("could not determine MCP server for tool: %s", toolName) + } + + client, ok := m.servers[serverName] + if !ok { + return nil, fmt.Errorf("MCP server %s not found", serverName) + } + + // Make the call + res, err := client.Call(ctx, "tools/call", map[string]interface{}{ + "name": originalToolName, + "arguments": args, + }) + if err != nil { + return nil, err + } + + var callResult struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + IsError bool `json:"isError"` + } + + if err := json.Unmarshal(res.Result, &callResult); err != nil { + return nil, fmt.Errorf("failed to parse MCP tool result: %w", err) + } + + if callResult.IsError { + if len(callResult.Content) > 0 { + return nil, fmt.Errorf("MCP tool error: %s", callResult.Content[0].Text) + } + return nil, fmt.Errorf("MCP tool execution failed") + } + + if len(callResult.Content) > 0 { + return callResult.Content[0].Text, nil + } + + return "Success", nil +} + +// CloseAll shuts down all MCP servers. +func (m *Manager) CloseAll() { + m.mu.Lock() + defer m.mu.Unlock() + + for _, client := range m.servers { + client.Close() + } + m.servers = make(map[string]*Client) +} From 2744c987c60756f18a35e3f69ef0246e0501b279 Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 18:14:22 +0530 Subject: [PATCH 47/57] fix(gemini): fix 400 error on empty schema and add /mcp interactive catalog --- internal/chat/commands.go | 64 +++++++++++++ internal/chat/messages.go | 5 + internal/chat/update.go | 2 +- internal/chat/update_events.go | 5 + internal/mcp/installer.go | 121 +++++++++++++++++++++++++ internal/providers/gemini/translate.go | 19 +++- 6 files changed, 210 insertions(+), 6 deletions(-) create mode 100644 internal/mcp/installer.go diff --git a/internal/chat/commands.go b/internal/chat/commands.go index bff72d3..341cfc2 100644 --- a/internal/chat/commands.go +++ b/internal/chat/commands.go @@ -3,9 +3,11 @@ package chat import ( "fmt" "os" + "strconv" "strings" "github.com/Nithwin/WindMist/internal/config" + "github.com/Nithwin/WindMist/internal/mcp" "github.com/Nithwin/WindMist/internal/ui" "github.com/Nithwin/WindMist/internal/ui/selector" tea "github.com/charmbracelet/bubbletea" @@ -110,6 +112,13 @@ var Registry = []Command{ return selectThemeCmd(m) }, }, + { + Name: "/mcp", + Description: "Install an MCP server (e.g. GitHub, Postgres)", + Execute: func(m *Model) tea.Cmd { + return selectMCPCmd(m) + }, + }, { Name: "/exit", Description: "Exit WindMist", @@ -420,3 +429,58 @@ func selectThemeCmd(m *Model) tea.Cmd { } } } + +func selectMCPCmd(m *Model) tea.Cmd { + return func() tea.Msg { + if program == nil { + return switchErrorMsg{Err: fmt.Errorf("program instance not initialized")} + } + + if err := program.ReleaseTerminal(); err != nil { + return switchErrorMsg{Err: fmt.Errorf("failed to release terminal: %w", err)} + } + defer program.RestoreTerminal() + + var options []selector.Option + for i, name := range mcp.GetCatalogList() { + options = append(options, selector.Option{ + Label: name, + Value: fmt.Sprintf("%d", i), // Use index as value + }) + } + + opt, err := selector.Run("Select MCP Server", "Choose an MCP Server to install:", options) + if err != nil { + return switchCancelMsg{} + } + + // Show prompts for required env vars + idx, _ := strconv.Atoi(opt.Value) + entry, ok := mcp.GetCatalogEntry(idx) + if !ok { + return switchErrorMsg{Err: fmt.Errorf("invalid MCP selection")} + } + + envValues := make(map[string]string) + for _, envKey := range entry.RequiredEnv { + prompt := fmt.Sprintf("Enter %s:", envKey) + if entry.EnvPrompt != nil && entry.EnvPrompt[envKey] != "" { + prompt = entry.EnvPrompt[envKey] + } + + // Simple fallback prompt via terminal since we released the TUI + fmt.Printf("\n%s\n> ", prompt) + var val string + fmt.Scanln(&val) + envValues[envKey] = strings.TrimSpace(val) + } + + if err := mcp.Install(entry, envValues); err != nil { + return switchErrorMsg{Err: fmt.Errorf("failed to save config: %w", err)} + } + + return mcpInstallSuccessMsg{ + Name: entry.Name, + } + } +} diff --git a/internal/chat/messages.go b/internal/chat/messages.go index 4718bcf..f7efb97 100644 --- a/internal/chat/messages.go +++ b/internal/chat/messages.go @@ -64,6 +64,11 @@ type switchThemeSuccessMsg struct { Theme string } +// mcpInstallSuccessMsg represents a successful MCP installation. +type mcpInstallSuccessMsg struct { + Name string +} + // switchCancelMsg represents a user cancellation of the menu. type switchCancelMsg struct{} diff --git a/internal/chat/update.go b/internal/chat/update.go index 6a60229..d103311 100644 --- a/internal/chat/update.go +++ b/internal/chat/update.go @@ -29,7 +29,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // All other custom events (Session, Agent Mode, Undo/Redo, Models) case ApprovalRequestMsg, switchModeSuccessMsg, createNewSessionMsg, undoFileChangeMsg, redoFileChangeMsg, switchSessionSuccessMsg, - switchProviderSuccessMsg, switchModelSuccessMsg, switchSubagentSuccessMsg, switchThemeSuccessMsg, switchCancelMsg, switchErrorMsg: + switchProviderSuccessMsg, switchModelSuccessMsg, switchSubagentSuccessMsg, switchThemeSuccessMsg, mcpInstallSuccessMsg, switchCancelMsg, switchErrorMsg: var evtCmd tea.Cmd m, evtCmd = m.handleEventMsg(msg) diff --git a/internal/chat/update_events.go b/internal/chat/update_events.go index eeab339..6823171 100644 --- a/internal/chat/update_events.go +++ b/internal/chat/update_events.go @@ -243,6 +243,11 @@ func (m Model) handleEventMsg(msg tea.Msg) (Model, tea.Cmd) { m.loading = false return m, nil + case mcpInstallSuccessMsg: + m.conversation.AddAssistant(fmt.Sprintf("🔌 Successfully installed and configured MCP Server: **%s**.\nPlease restart WindMist (using `/exit`) to automatically discover the new tools from this server.", msg.Name)) + m.refreshViewport() + return m, nil + case switchThemeSuccessMsg: m.cfg.SetTheme(msg.Theme) _ = config.Save(m.cfg) diff --git a/internal/mcp/installer.go b/internal/mcp/installer.go new file mode 100644 index 0000000..65c51ee --- /dev/null +++ b/internal/mcp/installer.go @@ -0,0 +1,121 @@ +package mcp + +import ( + "fmt" + + "github.com/Nithwin/WindMist/internal/config" +) + +// InstallerCatalog holds the top 5 essential MCP servers that WindMist supports out of the box. +var InstallerCatalog = []CatalogEntry{ + { + ID: "github", + Name: "GitHub", + Icon: "🐙", + Description: "Read private repos, create PRs, and manage issues", + Command: "npx", + Args: []string{"-y", "@modelcontextprotocol/server-github"}, + RequiredEnv: []string{"GITHUB_PERSONAL_ACCESS_TOKEN"}, + }, + { + ID: "postgres", + Name: "PostgreSQL", + Icon: "🐘", + Description: "Query live databases and analyze schemas", + Command: "npx", + Args: []string{"-y", "@modelcontextprotocol/server-postgres"}, + RequiredEnv: []string{"POSTGRES_CONNECTION_STRING"}, + EnvPrompt: map[string]string{"POSTGRES_CONNECTION_STRING": "Enter Postgres DB URL (postgres://user:pass@localhost/db)"}, + }, + { + ID: "sqlite", + Name: "SQLite", + Icon: "🗄️", + Description: "Query local SQLite database files", + Command: "npx", + Args: []string{"-y", "@modelcontextprotocol/server-sqlite"}, + RequiredEnv: []string{"SQLITE_DB_PATH"}, + EnvPrompt: map[string]string{"SQLITE_DB_PATH": "Enter absolute path to SQLite file (e.g. /tmp/db.sqlite)"}, + }, + { + ID: "puppeteer", + Name: "Web Browser (Puppeteer)", + Icon: "🌐", + Description: "Allows the AI to open a web browser and navigate visually", + Command: "npx", + Args: []string{"-y", "@modelcontextprotocol/server-puppeteer"}, + }, + { + ID: "slack", + Name: "Slack", + Icon: "💬", + Description: "Read and send messages in your team workspace", + Command: "npx", + Args: []string{"-y", "@modelcontextprotocol/server-slack"}, + RequiredEnv: []string{"SLACK_BOT_TOKEN"}, + }, +} + +type CatalogEntry struct { + ID string + Name string + Icon string + Description string + Command string + Args []string + RequiredEnv []string + EnvPrompt map[string]string // Maps env var to a custom prompt +} + +// GetCatalogList returns a formatted string list of available servers for the UI +func GetCatalogList() []string { + var list []string + for _, entry := range InstallerCatalog { + list = append(list, fmt.Sprintf("%s %s - %s", entry.Icon, entry.Name, entry.Description)) + } + return list +} + +// GetCatalogEntry returns a CatalogEntry by its index in the catalog list. +func GetCatalogEntry(index int) (*CatalogEntry, bool) { + if index >= 0 && index < len(InstallerCatalog) { + return &InstallerCatalog[index], true + } + return nil, false +} + +// Install adds the server to the global configuration and saves it. +func Install(entry *CatalogEntry, envValues map[string]string) error { + cfg, err := config.Load() + if err != nil { + return err + } + + if cfg.MCPServers == nil { + cfg.MCPServers = make(map[string]config.MCPServerConfig) + } + + // Create the configuration for this server + srvConfig := config.MCPServerConfig{ + Command: entry.Command, + Args: entry.Args, + Env: envValues, + } + + // Append the dynamic DB path to the args for some servers like SQLite or Postgres + // Some MCP servers take the DB path as an argument rather than an env var + if entry.ID == "sqlite" && envValues["SQLITE_DB_PATH"] != "" { + srvConfig.Args = append(srvConfig.Args, envValues["SQLITE_DB_PATH"]) + delete(srvConfig.Env, "SQLITE_DB_PATH") // Remove from env if passed as arg + } + + if entry.ID == "postgres" && envValues["POSTGRES_CONNECTION_STRING"] != "" { + srvConfig.Args = append(srvConfig.Args, envValues["POSTGRES_CONNECTION_STRING"]) + delete(srvConfig.Env, "POSTGRES_CONNECTION_STRING") + } + + cfg.MCPServers[entry.ID] = srvConfig + + // Save the config back to disk + return config.Save(cfg) +} diff --git a/internal/providers/gemini/translate.go b/internal/providers/gemini/translate.go index c51ac3e..5a488c4 100644 --- a/internal/providers/gemini/translate.go +++ b/internal/providers/gemini/translate.go @@ -45,14 +45,23 @@ func translateTools(tools []ai.ToolDefinition) []Tool { } } - funcDecls = append(funcDecls, FunctionDeclaration{ - Name: tool.Name, - Description: tool.Description, - Parameters: &Schema{ + if len(required) == 0 { + required = nil + } + + var paramsSchema *Schema + if len(properties) > 0 { + paramsSchema = &Schema{ Type: "OBJECT", Properties: properties, Required: required, - }, + } + } + + funcDecls = append(funcDecls, FunctionDeclaration{ + Name: tool.Name, + Description: tool.Description, + Parameters: paramsSchema, }) } From 45b443ff926681d3b608465f4a88176e5ef35c24 Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 18:17:04 +0530 Subject: [PATCH 48/57] feat(ui): add /apikey command and rename plan mode to chat --- internal/agent/mode.go | 4 +-- internal/chat/commands.go | 46 ++++++++++++++++++++++++++++++++++ internal/chat/messages.go | 5 ++++ internal/chat/update.go | 2 +- internal/chat/update_events.go | 5 ++++ 5 files changed, 59 insertions(+), 3 deletions(-) diff --git a/internal/agent/mode.go b/internal/agent/mode.go index e8ec33a..e374467 100644 --- a/internal/agent/mode.go +++ b/internal/agent/mode.go @@ -31,8 +31,8 @@ func GetModeConfig(mode Mode) ModeConfig { case ModePlan: return ModeConfig{ Name: ModePlan, - Description: "Read-only architect mode. Analyzes and plans but cannot edit files.", - SystemPrompt: "You are an expert software architect in PLAN mode. Your job is to analyze the user's request, search the codebase, read files, and output a detailed, step-by-step implementation plan. YOU CANNOT MODIFY FILES OR WRITE CODE TO DISK. Do not attempt to use any write tools. If a command must be run, ask the user first. Focus on architectural decisions, edge cases, and producing a clear numbered plan.", + Description: "Safe Chat & Plan Mode. Analyzes and plans but cannot edit files.", + SystemPrompt: "You are WindMist in CHAT/PLAN mode. Your job is to answer questions, search the codebase, read files, and output detailed plans. YOU CANNOT MODIFY FILES OR WRITE CODE TO DISK. Do not attempt to use any write tools.", AllowFileEdits: false, AllowCommands: false, } diff --git a/internal/chat/commands.go b/internal/chat/commands.go index 341cfc2..657c083 100644 --- a/internal/chat/commands.go +++ b/internal/chat/commands.go @@ -112,6 +112,13 @@ var Registry = []Command{ return selectThemeCmd(m) }, }, + { + Name: "/apikey", + Description: "Set API Key for the current provider", + Execute: func(m *Model) tea.Cmd { + return setAPIKeyCmd(m) + }, + }, { Name: "/mcp", Description: "Install an MCP server (e.g. GitHub, Postgres)", @@ -484,3 +491,42 @@ func selectMCPCmd(m *Model) tea.Cmd { } } } + +func setAPIKeyCmd(m *Model) tea.Cmd { + return func() tea.Msg { + if program == nil { + return switchErrorMsg{Err: fmt.Errorf("program instance not initialized")} + } + + if err := program.ReleaseTerminal(); err != nil { + return switchErrorMsg{Err: fmt.Errorf("failed to release terminal: %w", err)} + } + defer program.RestoreTerminal() + + provider := m.cfg.AI.Provider + if provider == "" { + provider = "default" + } + + fmt.Printf("\n🔑 Enter API Key for [%s]:\n> ", provider) + var val string + fmt.Scanln(&val) + val = strings.TrimSpace(val) + + if val == "" { + return switchCancelMsg{} + } + + if err := m.cfg.SetAPIKey(provider, val); err != nil { + return switchErrorMsg{Err: fmt.Errorf("failed to set api key: %w", err)} + } + + if err := config.Save(m.cfg); err != nil { + return switchErrorMsg{Err: fmt.Errorf("failed to save config: %w", err)} + } + + return setAPIKeySuccessMsg{ + Provider: provider, + } + } +} diff --git a/internal/chat/messages.go b/internal/chat/messages.go index f7efb97..e1683fc 100644 --- a/internal/chat/messages.go +++ b/internal/chat/messages.go @@ -69,6 +69,11 @@ type mcpInstallSuccessMsg struct { Name string } +// setAPIKeySuccessMsg represents a successful API key update. +type setAPIKeySuccessMsg struct { + Provider string +} + // switchCancelMsg represents a user cancellation of the menu. type switchCancelMsg struct{} diff --git a/internal/chat/update.go b/internal/chat/update.go index d103311..67ae46a 100644 --- a/internal/chat/update.go +++ b/internal/chat/update.go @@ -29,7 +29,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // All other custom events (Session, Agent Mode, Undo/Redo, Models) case ApprovalRequestMsg, switchModeSuccessMsg, createNewSessionMsg, undoFileChangeMsg, redoFileChangeMsg, switchSessionSuccessMsg, - switchProviderSuccessMsg, switchModelSuccessMsg, switchSubagentSuccessMsg, switchThemeSuccessMsg, mcpInstallSuccessMsg, switchCancelMsg, switchErrorMsg: + switchProviderSuccessMsg, switchModelSuccessMsg, switchSubagentSuccessMsg, switchThemeSuccessMsg, mcpInstallSuccessMsg, setAPIKeySuccessMsg, switchCancelMsg, switchErrorMsg: var evtCmd tea.Cmd m, evtCmd = m.handleEventMsg(msg) diff --git a/internal/chat/update_events.go b/internal/chat/update_events.go index 6823171..cc357f4 100644 --- a/internal/chat/update_events.go +++ b/internal/chat/update_events.go @@ -248,6 +248,11 @@ func (m Model) handleEventMsg(msg tea.Msg) (Model, tea.Cmd) { m.refreshViewport() return m, nil + case setAPIKeySuccessMsg: + m.conversation.AddAssistant(fmt.Sprintf("🔑 Successfully saved new API key for **%s**.\nRemember to restart WindMist or select the provider again to apply the changes.", msg.Provider)) + m.refreshViewport() + return m, nil + case switchThemeSuccessMsg: m.cfg.SetTheme(msg.Theme) _ = config.Save(m.cfg) From add69969cdd5363740e84c174f2182ee4c943acf Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 18:20:57 +0530 Subject: [PATCH 49/57] feat(mcp): auto-extract github token using gh cli --- internal/chat/commands.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/internal/chat/commands.go b/internal/chat/commands.go index 657c083..cc21446 100644 --- a/internal/chat/commands.go +++ b/internal/chat/commands.go @@ -3,6 +3,7 @@ package chat import ( "fmt" "os" + "os/exec" "strconv" "strings" @@ -470,6 +471,22 @@ func selectMCPCmd(m *Model) tea.Cmd { envValues := make(map[string]string) for _, envKey := range entry.RequiredEnv { + + // --- Auto-Auth Magic for GitHub --- + if entry.ID == "github" && envKey == "GITHUB_PERSONAL_ACCESS_TOKEN" { + // Try to silently fetch the token from the GitHub CLI (gh) + out, err := exec.Command("gh", "auth", "token").Output() + if err == nil { + token := strings.TrimSpace(string(out)) + if token != "" { + envValues[envKey] = token + fmt.Printf("\n✨ Automatically detected and loaded GitHub token from 'gh' CLI!\n") + continue + } + } + } + // ---------------------------------- + prompt := fmt.Sprintf("Enter %s:", envKey) if entry.EnvPrompt != nil && entry.EnvPrompt[envKey] != "" { prompt = entry.EnvPrompt[envKey] From 9f578f79562e727857bd2d3f3c9ee33792479d7f Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 18:23:58 +0530 Subject: [PATCH 50/57] feat(mcp): implement native GitHub device flow OAuth --- internal/chat/commands.go | 15 ++---- internal/mcp/github_auth.go | 97 +++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 10 deletions(-) create mode 100644 internal/mcp/github_auth.go diff --git a/internal/chat/commands.go b/internal/chat/commands.go index cc21446..3c43c0c 100644 --- a/internal/chat/commands.go +++ b/internal/chat/commands.go @@ -3,7 +3,6 @@ package chat import ( "fmt" "os" - "os/exec" "strconv" "strings" @@ -474,16 +473,12 @@ func selectMCPCmd(m *Model) tea.Cmd { // --- Auto-Auth Magic for GitHub --- if entry.ID == "github" && envKey == "GITHUB_PERSONAL_ACCESS_TOKEN" { - // Try to silently fetch the token from the GitHub CLI (gh) - out, err := exec.Command("gh", "auth", "token").Output() - if err == nil { - token := strings.TrimSpace(string(out)) - if token != "" { - envValues[envKey] = token - fmt.Printf("\n✨ Automatically detected and loaded GitHub token from 'gh' CLI!\n") - continue - } + token, err := mcp.PerformGithubOAuth() + if err == nil && token != "" { + envValues[envKey] = token + continue } + fmt.Printf("\n⚠️ OAuth failed (%v), falling back to manual entry...\n", err) } // ---------------------------------- diff --git a/internal/mcp/github_auth.go b/internal/mcp/github_auth.go new file mode 100644 index 0000000..8544e0c --- /dev/null +++ b/internal/mcp/github_auth.go @@ -0,0 +1,97 @@ +package mcp + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +const GithubClientID = "178c6fc778ccc68e1d6a" // GitHub CLI official client ID + +type DeviceCodeResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + Interval int `json:"interval"` +} + +type AccessTokenResponse struct { + AccessToken string `json:"access_token"` + Error string `json:"error"` +} + +// PerformGithubOAuth starts the device authorization flow and polls until the user approves. +func PerformGithubOAuth() (string, error) { + // 1. Request device code + reqBody := []byte(fmt.Sprintf("client_id=%s&scope=repo read:org", GithubClientID)) + req, err := http.NewRequest("POST", "https://github.com/login/device/code", bytes.NewBuffer(reqBody)) + if err != nil { + return "", err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + var deviceRes DeviceCodeResponse + if err := json.Unmarshal(body, &deviceRes); err != nil { + return "", fmt.Errorf("failed to parse GitHub response: %v", err) + } + + if deviceRes.UserCode == "" { + return "", fmt.Errorf("invalid response from GitHub") + } + + // 2. Prompt user + fmt.Printf("\n🔒 GitHub Authentication Required\n") + fmt.Printf("1. Please open: %s\n", deviceRes.VerificationURI) + fmt.Printf("2. Enter the code: %s\n", deviceRes.UserCode) + fmt.Printf("\nWaiting for you to authorize (polling)... ") + + // 3. Poll for access token + pollInterval := time.Duration(deviceRes.Interval) * time.Second + if pollInterval == 0 { + pollInterval = 5 * time.Second + } + + tokenReqBody := []byte(fmt.Sprintf("client_id=%s&device_code=%s&grant_type=urn:ietf:params:oauth:grant-type:device_code", GithubClientID, deviceRes.DeviceCode)) + + for i := 0; i < 60; i++ { // Timeout after 5 minutes (60 * 5s) + time.Sleep(pollInterval) + + tokenReq, _ := http.NewRequest("POST", "https://github.com/login/oauth/access_token", bytes.NewBuffer(tokenReqBody)) + tokenReq.Header.Set("Accept", "application/json") + tokenReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + tokenResp, err := client.Do(tokenReq) + if err != nil { + continue + } + + tokenBody, _ := io.ReadAll(tokenResp.Body) + tokenResp.Body.Close() + + var accessRes AccessTokenResponse + _ = json.Unmarshal(tokenBody, &accessRes) + + if accessRes.AccessToken != "" { + fmt.Printf("✅ Success!\n") + return accessRes.AccessToken, nil + } + + if accessRes.Error != "authorization_pending" { + return "", fmt.Errorf("GitHub authorization failed: %s", accessRes.Error) + } + } + + return "", fmt.Errorf("authentication timed out") +} From d2e58ac776e88bfca8fab9368074cda3940a7151 Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 18:27:44 +0530 Subject: [PATCH 51/57] chore: mark completed roadmap items including sub-agent and mcp --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 7fa8666..4811a1b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -126,7 +126,7 @@ gantt **Goal:** Prepare WindMist for widespread open-source adoption, extensible plugin development, and cross-platform distribution. ### Key Deliverables: -- [ ] **Model Context Protocol (MCP) Integration (`plugins/`)** +- [x] **Model Context Protocol (MCP) Integration (`plugins/`)** - Support standard MCP client specs so developers can connect custom database tools, Jira integrations, and cloud monitoring servers directly to WindMist. - [ ] **Custom Plugin Engine** - Allow users to write shared object plugins (`.so` or external binaries) that conform to our Go `Tool` interface. From 1b684d4e33463b17ed61bd4b5748c8d23eb768c6 Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 18:39:08 +0530 Subject: [PATCH 52/57] fix(ui): prevent terminal blanking by replacing selector with inline bubble tea components refactor(chat): split commands.go into modular domain files --- go.mod | 12 +- go.sum | 16 ++ internal/chat/chat.go | 2 +- internal/chat/commands.go | 376 +----------------------------- internal/chat/commands_ai.go | 231 ++++++++++++++++++ internal/chat/commands_mcp.go | 96 ++++++++ internal/chat/commands_session.go | 55 +++++ internal/chat/commands_ui.go | 35 +++ internal/chat/files.go | 4 +- internal/chat/header.go | 12 +- internal/chat/messages.go | 17 ++ internal/chat/model.go | 17 +- internal/chat/update.go | 51 +++- internal/chat/update_events.go | 6 +- internal/chat/update_keys.go | 48 +++- internal/chat/update_stream.go | 4 +- internal/chat/view.go | 13 +- internal/mcp/github_auth.go | 12 +- scripts/split_commands.py | 38 +++ 19 files changed, 632 insertions(+), 413 deletions(-) create mode 100644 internal/chat/commands_ai.go create mode 100644 internal/chat/commands_mcp.go create mode 100644 internal/chat/commands_session.go create mode 100644 internal/chat/commands_ui.go create mode 100644 scripts/split_commands.py diff --git a/go.mod b/go.mod index 2013fb3..365a48d 100644 --- a/go.mod +++ b/go.mod @@ -49,8 +49,12 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.7.13 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect - golang.org/x/net v0.38.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/term v0.36.0 // indirect - golang.org/x/text v0.30.0 // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 // indirect + golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.48.0 // indirect ) diff --git a/go.sum b/go.sum index cde0060..577bfc8 100644 --- a/go.sum +++ b/go.sum @@ -115,16 +115,32 @@ github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 h1:RJhm5l6Fo4rmEIcndxDllNhhf/fAx8qIm4t6A7vpm2A= +golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959/go.mod h1:LV7u5Oco+Z/g6XI7PqN+EUUUGGkEcmB1uj2ceI0fOVg= golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/chat/chat.go b/internal/chat/chat.go index 63ae46d..c79389b 100644 --- a/internal/chat/chat.go +++ b/internal/chat/chat.go @@ -46,7 +46,7 @@ func (m Model) sendMessage(ctx context.Context, prompt string) { } duration := time.Since(startTime) - + // Agent loop completed program.Send(StreamingMsg{ Text: "\n\n(Finished in " + fmt.Sprintf("%d turns", res.Turns) + ")", diff --git a/internal/chat/commands.go b/internal/chat/commands.go index 3c43c0c..e4ff61b 100644 --- a/internal/chat/commands.go +++ b/internal/chat/commands.go @@ -1,15 +1,8 @@ package chat import ( - "fmt" - "os" - "strconv" "strings" - "github.com/Nithwin/WindMist/internal/config" - "github.com/Nithwin/WindMist/internal/mcp" - "github.com/Nithwin/WindMist/internal/ui" - "github.com/Nithwin/WindMist/internal/ui/selector" tea "github.com/charmbracelet/bubbletea" ) @@ -148,238 +141,6 @@ var Registry = []Command{ }, } -func selectSessionCmd(m *Model) tea.Cmd { - return func() tea.Msg { - if program == nil { - return switchErrorMsg{Err: fmt.Errorf("program instance not initialized")} - } - if m.store == nil { - return switchErrorMsg{Err: fmt.Errorf("database not initialized")} - } - - cwd, _ := os.Getwd() - sessions, err := m.store.ListSessionsByProject(cwd) - if err != nil { - return switchErrorMsg{Err: fmt.Errorf("failed to fetch sessions: %w", err)} - } - - if len(sessions) == 0 { - return switchErrorMsg{Err: fmt.Errorf("no past sessions found in this project")} - } - - if err := program.ReleaseTerminal(); err != nil { - return switchErrorMsg{Err: fmt.Errorf("failed to release terminal: %w", err)} - } - defer program.RestoreTerminal() - - var options []selector.Option - for _, s := range sessions { - desc := fmt.Sprintf("%s | Tokens: %d | Cost: $%.3f", s.UpdatedAt.Format("Jan 02 15:04"), s.TokenCount, s.CostEstimate) - options = append(options, selector.Option{ - Label: s.Title, - Desc: desc, - Value: s.ID, - }) - } - - opt, err := selector.Run("Select Session", "Choose a previous session to resume:", options) - if err != nil { - return switchCancelMsg{} - } - - return switchSessionSuccessMsg{ - SessionID: opt.Value, - } - } -} - -func selectProviderCmd(m *Model) tea.Cmd { - return func() tea.Msg { - if program == nil { - return switchErrorMsg{Err: fmt.Errorf("program instance not initialized")} - } - - // 1. Release terminal of main program so selector can render cleanly - if err := program.ReleaseTerminal(); err != nil { - return switchErrorMsg{Err: fmt.Errorf("failed to release terminal: %w", err)} - } - defer program.RestoreTerminal() - - // 2. Select Provider - providerOpt, err := selector.Run( - "Select AI Provider", - "Choose which AI provider you want WindMist to use:", - config.GetProviderOptions(), - ) - if err != nil { - return switchCancelMsg{} - } - - // 3. Select Model for this provider - ollamaBaseURL := "" - if pConfig, ok := m.cfg.Providers[providerOpt.Value]; ok { - ollamaBaseURL = pConfig.BaseURL - } - modelOpt, err := selector.Run( - fmt.Sprintf("Select Model for %s", providerOpt.Value), - "Choose the active model for this provider:", - m.cfg.GetModelOptions(providerOpt.Value, ollamaBaseURL), - ) - if err != nil { - return switchCancelMsg{} - } - - modelValue := modelOpt.Value - if modelValue == "__CUSTOM__" { - customVal, err := selector.RunInput("Custom Model ID", "Enter exact model ID (e.g. gpt-4o)", "") - if err != nil { - return switchCancelMsg{} - } - modelValue = customVal - - // Save the custom model so it shows up next time - m.cfg.AddCustomModel(providerOpt.Value, modelValue) - _ = config.Save(m.cfg) - } - - return switchProviderSuccessMsg{ - Provider: providerOpt.Value, - Model: modelValue, - } - } -} - -func selectSubagentCmd(m *Model) tea.Cmd { - return func() tea.Msg { - if program == nil { - return switchErrorMsg{Err: fmt.Errorf("program instance not initialized")} - } - - if err := program.ReleaseTerminal(); err != nil { - return switchErrorMsg{Err: fmt.Errorf("failed to release terminal: %w", err)} - } - defer program.RestoreTerminal() - - providerOpt, err := selector.Run( - "Select Sub-Agent Provider", - "Choose which AI provider the Sub-Agent should use (Auto uses main config):", - append([]selector.Option{{Label: "Auto (Use Main Config)", Value: "auto"}}, config.GetProviderOptions()...), - ) - if err != nil { - return switchCancelMsg{} - } - - if providerOpt.Value == "auto" { - return switchSubagentSuccessMsg{ - Provider: "", - Model: "", - } - } - - ollamaBaseURL := "" - if pConfig, ok := m.cfg.Providers[providerOpt.Value]; ok { - ollamaBaseURL = pConfig.BaseURL - } - modelOpt, err := selector.Run( - fmt.Sprintf("Select Sub-Agent Model for %s", providerOpt.Value), - "Choose the active model for this provider (Auto uses fast default):", - append([]selector.Option{{Label: "Auto (Fast Default)", Value: "auto"}}, m.cfg.GetModelOptions(providerOpt.Value, ollamaBaseURL)...), - ) - if err != nil { - return switchCancelMsg{} - } - - modelValue := modelOpt.Value - if modelValue == "auto" { - modelValue = "" - } else if modelValue == "__CUSTOM__" { - customVal, err := selector.RunInput("Custom Model ID", "Enter exact model ID (e.g. gpt-4o-mini)", "") - if err != nil { - return switchCancelMsg{} - } - modelValue = customVal - m.cfg.AddCustomModel(providerOpt.Value, modelValue) - _ = config.Save(m.cfg) - } - - return switchSubagentSuccessMsg{ - Provider: providerOpt.Value, - Model: modelValue, - } - } -} - -func selectModelCmd(m *Model) tea.Cmd { - return func() tea.Msg { - if program == nil { - return switchErrorMsg{Err: fmt.Errorf("program instance not initialized")} - } - - // 1. Release terminal of main program - if err := program.ReleaseTerminal(); err != nil { - return switchErrorMsg{Err: fmt.Errorf("failed to release terminal: %w", err)} - } - defer program.RestoreTerminal() - - // 2. Select Model for current provider - ollamaBaseURL := "" - if pConfig, ok := m.cfg.Providers[m.cfg.AI.Provider]; ok { - ollamaBaseURL = pConfig.BaseURL - } - modelOpt, err := selector.Run( - fmt.Sprintf("Select Model for %s", m.cfg.AI.Provider), - "Choose the active model to use:", - m.cfg.GetModelOptions(m.cfg.AI.Provider, ollamaBaseURL), - ) - if err != nil { - return switchCancelMsg{} - } - - modelValue := modelOpt.Value - if modelValue == "__CUSTOM__" { - customVal, err := selector.RunInput("Custom Model ID", "Enter exact model ID (e.g. gpt-4o)", "") - if err != nil { - return switchCancelMsg{} - } - modelValue = customVal - - // Save the custom model so it shows up next time - m.cfg.AddCustomModel(m.cfg.AI.Provider, modelValue) - _ = config.Save(m.cfg) - } - - return switchModelSuccessMsg{ - Model: modelValue, - } - } -} - -func selectModeCmd(m *Model) tea.Cmd { - return func() tea.Msg { - if program == nil { - return switchErrorMsg{Err: fmt.Errorf("program instance not initialized")} - } - - if err := program.ReleaseTerminal(); err != nil { - return switchErrorMsg{Err: fmt.Errorf("failed to release terminal: %w", err)} - } - defer program.RestoreTerminal() - - options := []selector.Option{ - {Label: "Auto", Desc: "Dynamically switches between Build and Plan based on prompt", Value: "auto"}, - {Label: "Build", Desc: "Full autonomy mode with read/write access", Value: "build"}, - {Label: "Plan", Desc: "Read-only mode for architecture and analysis", Value: "plan"}, - } - - opt, err := selector.Run("Select Agent Mode", "Choose how the AI should behave:", options) - if err != nil { - return switchCancelMsg{} - } - - return switchModeSuccessMsg{Mode: opt.Value} - } -} - func FilterCommands(input string) []Command { if input == "/" { return Registry @@ -406,139 +167,4 @@ func FindCommand(name string) (Command, bool) { return Command{}, false } -func selectThemeCmd(m *Model) tea.Cmd { - return func() tea.Msg { - if program == nil { - return switchErrorMsg{Err: fmt.Errorf("program instance not initialized")} - } - - if err := program.ReleaseTerminal(); err != nil { - return switchErrorMsg{Err: fmt.Errorf("failed to release terminal: %w", err)} - } - defer program.RestoreTerminal() - - themes := ui.AvailableThemes() - var options []selector.Option - for _, t := range themes { - options = append(options, selector.Option{ - Label: t, - Value: t, - }) - } - - opt, err := selector.RunWithDefault("Select Theme", "Choose a UI theme:", options, ui.CurrentThemeName) - if err != nil { - return switchCancelMsg{} - } - - return switchThemeSuccessMsg{ - Theme: opt.Value, - } - } -} - -func selectMCPCmd(m *Model) tea.Cmd { - return func() tea.Msg { - if program == nil { - return switchErrorMsg{Err: fmt.Errorf("program instance not initialized")} - } - - if err := program.ReleaseTerminal(); err != nil { - return switchErrorMsg{Err: fmt.Errorf("failed to release terminal: %w", err)} - } - defer program.RestoreTerminal() - - var options []selector.Option - for i, name := range mcp.GetCatalogList() { - options = append(options, selector.Option{ - Label: name, - Value: fmt.Sprintf("%d", i), // Use index as value - }) - } - - opt, err := selector.Run("Select MCP Server", "Choose an MCP Server to install:", options) - if err != nil { - return switchCancelMsg{} - } - - // Show prompts for required env vars - idx, _ := strconv.Atoi(opt.Value) - entry, ok := mcp.GetCatalogEntry(idx) - if !ok { - return switchErrorMsg{Err: fmt.Errorf("invalid MCP selection")} - } - - envValues := make(map[string]string) - for _, envKey := range entry.RequiredEnv { - - // --- Auto-Auth Magic for GitHub --- - if entry.ID == "github" && envKey == "GITHUB_PERSONAL_ACCESS_TOKEN" { - token, err := mcp.PerformGithubOAuth() - if err == nil && token != "" { - envValues[envKey] = token - continue - } - fmt.Printf("\n⚠️ OAuth failed (%v), falling back to manual entry...\n", err) - } - // ---------------------------------- - - prompt := fmt.Sprintf("Enter %s:", envKey) - if entry.EnvPrompt != nil && entry.EnvPrompt[envKey] != "" { - prompt = entry.EnvPrompt[envKey] - } - - // Simple fallback prompt via terminal since we released the TUI - fmt.Printf("\n%s\n> ", prompt) - var val string - fmt.Scanln(&val) - envValues[envKey] = strings.TrimSpace(val) - } - - if err := mcp.Install(entry, envValues); err != nil { - return switchErrorMsg{Err: fmt.Errorf("failed to save config: %w", err)} - } - - return mcpInstallSuccessMsg{ - Name: entry.Name, - } - } -} - -func setAPIKeyCmd(m *Model) tea.Cmd { - return func() tea.Msg { - if program == nil { - return switchErrorMsg{Err: fmt.Errorf("program instance not initialized")} - } - - if err := program.ReleaseTerminal(); err != nil { - return switchErrorMsg{Err: fmt.Errorf("failed to release terminal: %w", err)} - } - defer program.RestoreTerminal() - - provider := m.cfg.AI.Provider - if provider == "" { - provider = "default" - } - - fmt.Printf("\n🔑 Enter API Key for [%s]:\n> ", provider) - var val string - fmt.Scanln(&val) - val = strings.TrimSpace(val) - - if val == "" { - return switchCancelMsg{} - } - - if err := m.cfg.SetAPIKey(provider, val); err != nil { - return switchErrorMsg{Err: fmt.Errorf("failed to set api key: %w", err)} - } - - if err := config.Save(m.cfg); err != nil { - return switchErrorMsg{Err: fmt.Errorf("failed to save config: %w", err)} - } - - return setAPIKeySuccessMsg{ - Provider: provider, - } - } -} +// mcpEnvPromptChain returns a tea.Msg that recursively prompts for each required env variable. diff --git a/internal/chat/commands_ai.go b/internal/chat/commands_ai.go new file mode 100644 index 0000000..cacc395 --- /dev/null +++ b/internal/chat/commands_ai.go @@ -0,0 +1,231 @@ +package chat + +import ( + "fmt" + "strings" + + "github.com/Nithwin/WindMist/internal/config" + "github.com/Nithwin/WindMist/internal/ui/selector" + tea "github.com/charmbracelet/bubbletea" +) + +func selectProviderCmd(m *Model) tea.Cmd { + return func() tea.Msg { + return showInlineSelectorMsg{ + Title: "Select AI Provider", + Options: config.GetProviderOptions(), + OnCancel: func() tea.Cmd { + return func() tea.Msg { return switchCancelMsg{} } + }, + OnSelect: func(providerOpt selector.Option) tea.Cmd { + return func() tea.Msg { + ollamaBaseURL := "" + if pConfig, ok := m.cfg.Providers[providerOpt.Value]; ok { + ollamaBaseURL = pConfig.BaseURL + } + + return showInlineSelectorMsg{ + Title: fmt.Sprintf("Select Model for %s", providerOpt.Value), + Options: m.cfg.GetModelOptions(providerOpt.Value, ollamaBaseURL), + OnCancel: func() tea.Cmd { + return func() tea.Msg { return switchCancelMsg{} } + }, + OnSelect: func(modelOpt selector.Option) tea.Cmd { + return func() tea.Msg { + if modelOpt.Value == "__CUSTOM__" { + return showInlinePromptMsg{ + Prompt: "Enter exact model ID (e.g. gpt-4o):", + OnSubmit: func(customVal string) tea.Cmd { + return func() tea.Msg { + customVal = strings.TrimSpace(customVal) + if customVal == "" { + return switchCancelMsg{} + } + m.cfg.AddCustomModel(providerOpt.Value, customVal) + _ = config.Save(m.cfg) + return switchProviderSuccessMsg{ + Provider: providerOpt.Value, + Model: customVal, + } + } + }, + } + } + return switchProviderSuccessMsg{ + Provider: providerOpt.Value, + Model: modelOpt.Value, + } + } + }, + } + } + }, + } + } +} + +func selectModelCmd(m *Model) tea.Cmd { + return func() tea.Msg { + ollamaBaseURL := "" + if pConfig, ok := m.cfg.Providers[m.cfg.AI.Provider]; ok { + ollamaBaseURL = pConfig.BaseURL + } + + return showInlineSelectorMsg{ + Title: fmt.Sprintf("Select Model for %s", m.cfg.AI.Provider), + Options: m.cfg.GetModelOptions(m.cfg.AI.Provider, ollamaBaseURL), + OnCancel: func() tea.Cmd { + return func() tea.Msg { return switchCancelMsg{} } + }, + OnSelect: func(modelOpt selector.Option) tea.Cmd { + return func() tea.Msg { + if modelOpt.Value == "__CUSTOM__" { + return showInlinePromptMsg{ + Prompt: "Enter exact model ID (e.g. gpt-4o):", + OnSubmit: func(customVal string) tea.Cmd { + return func() tea.Msg { + customVal = strings.TrimSpace(customVal) + if customVal == "" { + return switchCancelMsg{} + } + m.cfg.AddCustomModel(m.cfg.AI.Provider, customVal) + _ = config.Save(m.cfg) + return switchModelSuccessMsg{ + Model: customVal, + } + } + }, + } + } + return switchModelSuccessMsg{ + Model: modelOpt.Value, + } + } + }, + } + } +} + +func selectModeCmd(m *Model) tea.Cmd { + return func() tea.Msg { + options := []selector.Option{ + {Label: "Auto", Desc: "Dynamically switches between Build and Plan based on prompt", Value: "auto"}, + {Label: "Build", Desc: "Full autonomy mode with read/write access", Value: "build"}, + {Label: "Plan", Desc: "Read-only mode for architecture and analysis", Value: "plan"}, + } + + return showInlineSelectorMsg{ + Title: "Select Agent Mode", + Options: options, + OnCancel: func() tea.Cmd { + return func() tea.Msg { return switchCancelMsg{} } + }, + OnSelect: func(opt selector.Option) tea.Cmd { + return func() tea.Msg { + return switchModeSuccessMsg{Mode: opt.Value} + } + }, + } + } +} + +func selectSubagentCmd(m *Model) tea.Cmd { + return func() tea.Msg { + return showInlineSelectorMsg{ + Title: "Select Sub-Agent Provider", + Options: append([]selector.Option{{Label: "Auto (Use Main Config)", Value: "auto"}}, config.GetProviderOptions()...), + OnCancel: func() tea.Cmd { + return func() tea.Msg { return switchCancelMsg{} } + }, + OnSelect: func(providerOpt selector.Option) tea.Cmd { + return func() tea.Msg { + if providerOpt.Value == "auto" { + return switchSubagentSuccessMsg{ + Provider: "", + Model: "", + } + } + + ollamaBaseURL := "" + if pConfig, ok := m.cfg.Providers[providerOpt.Value]; ok { + ollamaBaseURL = pConfig.BaseURL + } + + return showInlineSelectorMsg{ + Title: fmt.Sprintf("Select Sub-Agent Model for %s", providerOpt.Value), + Options: append([]selector.Option{{Label: "Auto (Fast Default)", Value: "auto"}}, m.cfg.GetModelOptions(providerOpt.Value, ollamaBaseURL)...), + OnCancel: func() tea.Cmd { + return func() tea.Msg { return switchCancelMsg{} } + }, + OnSelect: func(modelOpt selector.Option) tea.Cmd { + return func() tea.Msg { + if modelOpt.Value == "auto" { + return switchSubagentSuccessMsg{ + Provider: providerOpt.Value, + Model: "", + } + } else if modelOpt.Value == "__CUSTOM__" { + return showInlinePromptMsg{ + Prompt: "Enter exact model ID (e.g. gpt-4o-mini):", + OnSubmit: func(customVal string) tea.Cmd { + return func() tea.Msg { + customVal = strings.TrimSpace(customVal) + if customVal == "" { + return switchCancelMsg{} + } + m.cfg.AddCustomModel(providerOpt.Value, customVal) + _ = config.Save(m.cfg) + return switchSubagentSuccessMsg{ + Provider: providerOpt.Value, + Model: customVal, + } + } + }, + } + } + return switchSubagentSuccessMsg{ + Provider: providerOpt.Value, + Model: modelOpt.Value, + } + } + }, + } + } + }, + } + } +} + +func setAPIKeyCmd(m *Model) tea.Cmd { + return func() tea.Msg { + provider := m.cfg.AI.Provider + if provider == "" { + provider = "default" + } + + return showInlinePromptMsg{ + Prompt: fmt.Sprintf("🔑 Enter API Key for [%s]:", provider), + IsPassword: true, + OnSubmit: func(val string) tea.Cmd { + return func() tea.Msg { + val = strings.TrimSpace(val) + if val == "" { + return switchCancelMsg{} + } + + if err := m.cfg.SetAPIKey(provider, val); err != nil { + return switchErrorMsg{Err: fmt.Errorf("failed to set api key: %w", err)} + } + + if err := config.Save(m.cfg); err != nil { + return switchErrorMsg{Err: fmt.Errorf("failed to save config: %w", err)} + } + + return setAPIKeySuccessMsg{ + Provider: provider, + } + } + }, + } + } +} diff --git a/internal/chat/commands_mcp.go b/internal/chat/commands_mcp.go new file mode 100644 index 0000000..a07560d --- /dev/null +++ b/internal/chat/commands_mcp.go @@ -0,0 +1,96 @@ +package chat + +import ( + "fmt" + "strconv" + "strings" + + "github.com/Nithwin/WindMist/internal/mcp" + "github.com/Nithwin/WindMist/internal/ui/selector" + tea "github.com/charmbracelet/bubbletea" +) + +func selectMCPCmd(m *Model) tea.Cmd { + return func() tea.Msg { + var options []selector.Option + for i, name := range mcp.GetCatalogList() { + options = append(options, selector.Option{ + Label: name, + Value: fmt.Sprintf("%d", i), + }) + } + + return showInlineSelectorMsg{ + Title: "Select MCP Server", + Options: options, + OnCancel: func() tea.Cmd { + return func() tea.Msg { return switchCancelMsg{} } + }, + OnSelect: func(opt selector.Option) tea.Cmd { + return func() tea.Msg { + idx, _ := strconv.Atoi(opt.Value) + entry, ok := mcp.GetCatalogEntry(idx) + if !ok { + return switchErrorMsg{Err: fmt.Errorf("invalid MCP selection")} + } + return mcpEnvPromptChain(m, entry, make(map[string]string), 0)() + } + }, + } + } +} + +func mcpEnvPromptChain(m *Model, entry *mcp.CatalogEntry, envValues map[string]string, index int) tea.Cmd { + return func() tea.Msg { + if index >= len(entry.RequiredEnv) { + if err := mcp.Install(entry, envValues); err != nil { + return switchErrorMsg{Err: fmt.Errorf("failed to save config: %w", err)} + } + return mcpInstallSuccessMsg{Name: entry.Name} + } + + envKey := entry.RequiredEnv[index] + + if entry.ID == "github" && envKey == "GITHUB_PERSONAL_ACCESS_TOKEN" { + // Notify user in chat + m.conversation.AddAssistant("⏳ Initializing GitHub OAuth Flow...") + m.refreshViewport() + + token, err := mcp.PerformGithubOAuth(func(uri, code string) { + msg := fmt.Sprintf("🔒 **GitHub Authentication Required**\n\n1. Open this link: %s\n2. Enter this code: `%s`\n\n_Waiting for authorization..._", uri, code) + m.conversation.AddAssistant(msg) + m.refreshViewport() + }) + + if err == nil && token != "" { + m.conversation.AddAssistant("✅ Successfully authenticated with GitHub!") + m.refreshViewport() + envValues[envKey] = token + return mcpEnvPromptChain(m, entry, envValues, index+1)() + } + + m.conversation.AddAssistant(fmt.Sprintf("⚠️ OAuth failed (%v), falling back to manual entry...", err)) + m.refreshViewport() + } + + prompt := fmt.Sprintf("Enter %s:", envKey) + if entry.EnvPrompt != nil && entry.EnvPrompt[envKey] != "" { + prompt = entry.EnvPrompt[envKey] + } + + return showInlinePromptMsg{ + Prompt: prompt, + IsPassword: true, + OnSubmit: func(val string) tea.Cmd { + return func() tea.Msg { + val = strings.TrimSpace(val) + if val == "" { + return switchCancelMsg{} + } + envValues[envKey] = val + return mcpEnvPromptChain(m, entry, envValues, index+1)() + } + }, + } + } +} diff --git a/internal/chat/commands_session.go b/internal/chat/commands_session.go new file mode 100644 index 0000000..b368dfd --- /dev/null +++ b/internal/chat/commands_session.go @@ -0,0 +1,55 @@ +package chat + +import ( + "fmt" + "os" + + "github.com/Nithwin/WindMist/internal/ui/selector" + tea "github.com/charmbracelet/bubbletea" +) + +func selectSessionCmd(m *Model) tea.Cmd { + return func() tea.Msg { + if program == nil { + return switchErrorMsg{Err: fmt.Errorf("program instance not initialized")} + } + if m.store == nil { + return switchErrorMsg{Err: fmt.Errorf("database not initialized")} + } + + cwd, _ := os.Getwd() + sessions, err := m.store.ListSessionsByProject(cwd) + if err != nil { + return switchErrorMsg{Err: fmt.Errorf("failed to fetch sessions: %w", err)} + } + + if len(sessions) == 0 { + return switchErrorMsg{Err: fmt.Errorf("no past sessions found in this project")} + } + + var options []selector.Option + for _, s := range sessions { + desc := fmt.Sprintf("%s | Tokens: %d | Cost: $%.3f", s.UpdatedAt.Format("Jan 02 15:04"), s.TokenCount, s.CostEstimate) + options = append(options, selector.Option{ + Label: s.Title, + Desc: desc, + Value: s.ID, + }) + } + + return showInlineSelectorMsg{ + Title: "Select Session", + Options: options, + OnCancel: func() tea.Cmd { + return func() tea.Msg { return switchCancelMsg{} } + }, + OnSelect: func(opt selector.Option) tea.Cmd { + return func() tea.Msg { + return switchSessionSuccessMsg{ + SessionID: opt.Value, + } + } + }, + } + } +} diff --git a/internal/chat/commands_ui.go b/internal/chat/commands_ui.go new file mode 100644 index 0000000..183b610 --- /dev/null +++ b/internal/chat/commands_ui.go @@ -0,0 +1,35 @@ +package chat + +import ( + "github.com/Nithwin/WindMist/internal/ui" + "github.com/Nithwin/WindMist/internal/ui/selector" + tea "github.com/charmbracelet/bubbletea" +) + +func selectThemeCmd(m *Model) tea.Cmd { + return func() tea.Msg { + themes := ui.AvailableThemes() + var options []selector.Option + for _, t := range themes { + options = append(options, selector.Option{ + Label: t, + Value: t, + }) + } + + return showInlineSelectorMsg{ + Title: "Select Theme", + Options: options, + OnCancel: func() tea.Cmd { + return func() tea.Msg { return switchCancelMsg{} } + }, + OnSelect: func(opt selector.Option) tea.Cmd { + return func() tea.Msg { + return switchThemeSuccessMsg{ + Theme: opt.Value, + } + } + }, + } + } +} diff --git a/internal/chat/files.go b/internal/chat/files.go index f36422f..38fb6ab 100644 --- a/internal/chat/files.go +++ b/internal/chat/files.go @@ -1,10 +1,10 @@ package chat import ( + "os" "os/exec" - "strings" "path/filepath" - "os" + "strings" ) // getWorkspaceFiles returns a list of files in the workspace. diff --git a/internal/chat/header.go b/internal/chat/header.go index f1014c8..0724077 100644 --- a/internal/chat/header.go +++ b/internal/chat/header.go @@ -24,13 +24,13 @@ func renderHeader(m Model) string { tokens := 0 cost := 0.0 mode := "build" - + if m.session != nil { tokens = m.session.TokenCount cost = m.session.CostEstimate mode = m.session.AgentMode } - + if mode == "" { mode = "build" } @@ -42,14 +42,14 @@ func renderHeader(m Model) string { modelTag := ui.BaseStyle.Foreground(ui.Cyan).Bold(true).Render(model) tokenTag := ui.BaseStyle.Foreground(ui.MutedLight).Render(fmt.Sprintf("%d tok", tokens)) - + // Only show cost if it's > 0 (to avoid showing $0.000 for free APIs like Ollama/Groq) costStr := "" if cost > 0 { costStr = fmt.Sprintf("$%.3f", cost) } costTag := ui.BaseStyle.Foreground(ui.MutedLight).Render(costStr) - + modeTag := ui.BaseStyle.Foreground(ui.MutedLight).Render(strings.ToUpper(mode)) timeTag := ui.BaseStyle.Foreground(ui.MutedLight).Render(duration) themeTag := ui.BaseStyle.Foreground(ui.BrandCyan).Render(ui.CurrentThemeName) @@ -59,14 +59,14 @@ func renderHeader(m Model) string { tags = append(tags, costTag) } tags = append(tags, modeTag, timeTag, themeTag) - + right := strings.Join(tags, ui.BaseStyle.Foreground(ui.Muted).Render(" │ ")) // ── padded spacer fills remaining width ────────────────────── totalWidth := m.MaxContentWidth() leftLen := lipgloss.Width(logo) rightLen := lipgloss.Width(right) - + // Subtract 4 for left/right borders and padding (1+1+1+1) gap := totalWidth - 4 - leftLen - rightLen if gap < 1 { diff --git a/internal/chat/messages.go b/internal/chat/messages.go index e1683fc..b15809a 100644 --- a/internal/chat/messages.go +++ b/internal/chat/messages.go @@ -4,6 +4,8 @@ import ( "time" "github.com/Nithwin/WindMist/internal/ai" + "github.com/Nithwin/WindMist/internal/ui/selector" + tea "github.com/charmbracelet/bubbletea" ) // ResponseMsg is sent when the AI finishes generating a response. @@ -92,3 +94,18 @@ type ApprovalRequestMsg struct { type WorkspaceFilesMsg struct { Files []string } + +// showInlineSelectorMsg tells the UI to show an inline list selector. +type showInlineSelectorMsg struct { + Title string + Options []selector.Option + OnSelect func(selector.Option) tea.Cmd + OnCancel func() tea.Cmd +} + +// showInlinePromptMsg tells the UI to show an inline text input prompt. +type showInlinePromptMsg struct { + Prompt string + IsPassword bool + OnSubmit func(string) tea.Cmd +} diff --git a/internal/chat/model.go b/internal/chat/model.go index b20fa57..9f2df1a 100644 --- a/internal/chat/model.go +++ b/internal/chat/model.go @@ -13,6 +13,8 @@ import ( "github.com/Nithwin/WindMist/internal/tools" "github.com/Nithwin/WindMist/internal/tools/defaults" "github.com/Nithwin/WindMist/internal/ui" + "github.com/Nithwin/WindMist/internal/ui/selector" + "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" @@ -56,6 +58,17 @@ type Model struct { markdown *ui.MarkdownRenderer + // Inline Selector state + showSelector bool + selectorList list.Model + onSelect func(selector.Option) tea.Cmd + onCancel func() tea.Cmd + + // Inline Prompt state + inlinePrompt string + onPromptSubmit func(string) tea.Cmd + isPassword bool + width int height int @@ -131,9 +144,9 @@ func New() (Model, error) { ta.SetHeight(3) ta.ShowLineNumbers = false ta.Prompt = "" - + vp := viewport.New(0, 0) - + model := Model{ cfg: cfg, provider: provider, diff --git a/internal/chat/update.go b/internal/chat/update.go index 67ae46a..e313432 100644 --- a/internal/chat/update.go +++ b/internal/chat/update.go @@ -1,7 +1,10 @@ package chat import ( + "github.com/Nithwin/WindMist/internal/ui" + "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" ) // Update handles all user interactions and routes them to specific handlers. @@ -30,13 +33,53 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case ApprovalRequestMsg, switchModeSuccessMsg, createNewSessionMsg, undoFileChangeMsg, redoFileChangeMsg, switchSessionSuccessMsg, switchProviderSuccessMsg, switchModelSuccessMsg, switchSubagentSuccessMsg, switchThemeSuccessMsg, mcpInstallSuccessMsg, setAPIKeySuccessMsg, switchCancelMsg, switchErrorMsg: - + var evtCmd tea.Cmd m, evtCmd = m.handleEventMsg(msg) return m, evtCmd - } + + case showInlineSelectorMsg: + m.showSelector = true + m.onSelect = msg.OnSelect + m.onCancel = msg.OnCancel + + items := make([]list.Item, len(msg.Options)) + for i, opt := range msg.Options { + items[i] = opt + } + + d := list.NewDefaultDelegate() + d.Styles.SelectedTitle = d.Styles.SelectedTitle.Foreground(ui.Cyan).BorderForeground(ui.Cyan) + d.Styles.SelectedDesc = d.Styles.SelectedDesc.Foreground(ui.Cyan).BorderForeground(ui.Cyan) + + m.selectorList = list.New(items, d, 80, 20) + m.selectorList.Title = msg.Title + m.selectorList.SetShowStatusBar(false) + m.selectorList.SetFilteringEnabled(true) + m.selectorList.Styles.Title = lipgloss.NewStyle().Background(ui.Purple).Foreground(ui.White).Padding(0, 1) + + // Set size + h, v := lipgloss.NewStyle().Margin(1, 2).GetFrameSize() + m.selectorList.SetSize(m.width-h, m.height-v) + + return m, nil + + case showInlinePromptMsg: + m.inlinePrompt = msg.Prompt + m.isPassword = msg.IsPassword + m.onPromptSubmit = msg.OnSubmit + m.input.Reset() + return m, nil // Update text input for other key events that don't match the main handler - m.input, cmd = m.input.Update(msg) - return m, cmd + default: + if m.showSelector { + var listCmd tea.Cmd + m.selectorList, listCmd = m.selectorList.Update(msg) + return m, listCmd + } + + m.input, cmd = m.input.Update(msg) + return m, cmd + } } diff --git a/internal/chat/update_events.go b/internal/chat/update_events.go index cc357f4..dc8051c 100644 --- a/internal/chat/update_events.go +++ b/internal/chat/update_events.go @@ -31,7 +31,7 @@ func (m Model) handleEventMsg(msg tea.Msg) (Model, tea.Cmd) { if m.store != nil { _ = m.store.UpdateSession(m.session) } - + // Update Agent config mode m.agent = agent.New(m.provider, m.agent.Manager(), agent.Config{ Store: m.store, @@ -118,7 +118,7 @@ func (m Model) handleEventMsg(msg tea.Msg) (Model, tea.Cmd) { _ = os.WriteFile(change.FilePath, []byte(change.AfterContent), 0644) } } - + _ = m.store.SetBatchUndoneState(m.session.ID, changes[0].BatchID, false) m.conversation.AddAssistant(fmt.Sprintf("⏭️ **Redid %d file edit(s)**", len(changes))) @@ -238,7 +238,7 @@ func (m Model) handleEventMsg(msg tea.Msg) (Model, tea.Cmd) { } else { m.conversation.AddAssistant(fmt.Sprintf("✨ Sub-Agent switched to **%s** (model: `%s`)", msg.Provider, msg.Model)) } - + m.refreshViewport() m.loading = false return m, nil diff --git a/internal/chat/update_keys.go b/internal/chat/update_keys.go index 22f4525..cfb209a 100644 --- a/internal/chat/update_keys.go +++ b/internal/chat/update_keys.go @@ -4,6 +4,7 @@ import ( "context" "strings" + "github.com/Nithwin/WindMist/internal/ui/selector" tea "github.com/charmbracelet/bubbletea" ) @@ -166,11 +167,50 @@ func (m Model) handleKeyMsg(msg tea.KeyMsg) (Model, tea.Cmd) { } } + // Handle Inline Prompt escape + if m.onPromptSubmit != nil && msg.String() == "esc" { + m.onPromptSubmit = nil + m.inlinePrompt = "" + m.isPassword = false + m.input.SetValue("") + return m, nil + } + + // Handle Inline Selector keys + if m.showSelector { + switch msg.String() { + case "esc", "ctrl+c": + m.showSelector = false + if m.onCancel != nil { + return m, m.onCancel() + } + return m, nil + case "enter": + m.showSelector = false + if i, ok := m.selectorList.SelectedItem().(selector.Option); ok { + if m.onSelect != nil { + return m, m.onSelect(i) + } + } + return m, nil + } + } + switch msg.String() { case "enter": prompt := strings.TrimSpace(m.input.Value()) + // Execute inline prompt submit + if m.onPromptSubmit != nil { + m.input.SetValue("") + cmd := m.onPromptSubmit(prompt) + m.onPromptSubmit = nil + m.inlinePrompt = "" + m.isPassword = false + return m, cmd + } + if prompt == "" { return m, nil } @@ -204,7 +244,7 @@ func (m Model) handleKeyMsg(msg tea.KeyMsg) (Model, tea.Cmd) { m.input.SetValue(newValue) m.input.CursorEnd() } - + // Don't send the message yet return m, nil } @@ -248,7 +288,7 @@ func (m Model) handleKeyMsg(msg tea.KeyMsg) (Model, tea.Cmd) { var cmd tea.Cmd m.input, cmd = m.input.Update(msg) - + // Update slash command suggestions (check first line only). value := m.input.Value() firstLine := strings.SplitN(value, "\n", 2)[0] @@ -261,7 +301,7 @@ func (m Model) handleKeyMsg(msg tea.KeyMsg) (Model, tea.Cmd) { m.showCommands = false m.filteredCommands = nil m.selectedCommand = 0 - + // Check for file picker trigger (@) anywhere in the text // Don't trigger if there's a trailing space if strings.HasSuffix(value, " ") || strings.HasSuffix(value, "\n") { @@ -285,6 +325,6 @@ func (m Model) handleKeyMsg(msg tea.KeyMsg) (Model, tea.Cmd) { } } m.updateViewportSize() - + return m, cmd } diff --git a/internal/chat/update_stream.go b/internal/chat/update_stream.go index fce0b66..80a0ea9 100644 --- a/internal/chat/update_stream.go +++ b/internal/chat/update_stream.go @@ -28,12 +28,12 @@ func (m Model) handleStreamMsg(msg StreamingMsg) (Model, tea.Cmd) { if msg.Done { m.loading = false m.responseTime = msg.Duration - + if m.session != nil { m.session.TokenCount += msg.Usage.TotalTokens // Rough cost estimation logic could go here or in a separate function // m.session.CostEstimate += calculateCost(...) - + // Save to DB if m.store != nil { _ = m.store.UpdateSession(m.session) diff --git a/internal/chat/view.go b/internal/chat/view.go index d3b20c8..8ad823d 100644 --- a/internal/chat/view.go +++ b/internal/chat/view.go @@ -11,6 +11,10 @@ import ( func (m Model) View() string { var b strings.Builder + if m.showSelector { + return lipgloss.NewStyle().Margin(1, 2).Render(m.selectorList.View()) + } + if m.showSplash { b.WriteString(renderBanner(m)) } else { @@ -44,10 +48,14 @@ func (m Model) View() string { b.WriteString(approvalBox) b.WriteString("\n") } else { - // Input row (label and textarea joined horizontally at Top so cursor is next to user ›) + promptLabelText := " user" + if m.inlinePrompt != "" { + promptLabelText = " " + m.inlinePrompt + } + promptLabel := lipgloss.JoinHorizontal( lipgloss.Center, - ui.PromptStyle.Render(" user"), + ui.PromptStyle.Render(promptLabelText), ui.BaseStyle.Foreground(ui.Muted).Render(" › "), ) @@ -69,4 +77,3 @@ func (m Model) View() string { return appStyle.Render(b.String()) } - diff --git a/internal/mcp/github_auth.go b/internal/mcp/github_auth.go index 8544e0c..7db1edb 100644 --- a/internal/mcp/github_auth.go +++ b/internal/mcp/github_auth.go @@ -24,7 +24,7 @@ type AccessTokenResponse struct { } // PerformGithubOAuth starts the device authorization flow and polls until the user approves. -func PerformGithubOAuth() (string, error) { +func PerformGithubOAuth(onDeviceCode func(uri, code string)) (string, error) { // 1. Request device code reqBody := []byte(fmt.Sprintf("client_id=%s&scope=repo read:org", GithubClientID)) req, err := http.NewRequest("POST", "https://github.com/login/device/code", bytes.NewBuffer(reqBody)) @@ -51,11 +51,10 @@ func PerformGithubOAuth() (string, error) { return "", fmt.Errorf("invalid response from GitHub") } - // 2. Prompt user - fmt.Printf("\n🔒 GitHub Authentication Required\n") - fmt.Printf("1. Please open: %s\n", deviceRes.VerificationURI) - fmt.Printf("2. Enter the code: %s\n", deviceRes.UserCode) - fmt.Printf("\nWaiting for you to authorize (polling)... ") + // 2. Notify caller + if onDeviceCode != nil { + onDeviceCode(deviceRes.VerificationURI, deviceRes.UserCode) + } // 3. Poll for access token pollInterval := time.Duration(deviceRes.Interval) * time.Second @@ -84,7 +83,6 @@ func PerformGithubOAuth() (string, error) { _ = json.Unmarshal(tokenBody, &accessRes) if accessRes.AccessToken != "" { - fmt.Printf("✅ Success!\n") return accessRes.AccessToken, nil } diff --git a/scripts/split_commands.py b/scripts/split_commands.py new file mode 100644 index 0000000..a2245fd --- /dev/null +++ b/scripts/split_commands.py @@ -0,0 +1,38 @@ +import re + +with open('internal/chat/commands.go', 'r') as f: + content = f.read() + +# The file contains the Registry declaration up to line ~150, then specific funcs. +# Let's just find the functions and move them. + +def extract_func(name): + global content + pattern = rf"func {name}\(.*?\) .*?{{.*?^}}" + match = re.search(pattern, content, re.MULTILINE | re.DOTALL) + if match: + func_body = match.group(0) + # Remove from content + content = content.replace(func_body, "") + return func_body + return "" + +commands_ai = ["selectProviderCmd", "selectModelCmd", "selectModeCmd", "selectSubagentCmd", "setAPIKeyCmd"] +commands_mcp = ["selectMCPCmd", "mcpEnvPromptChain"] +commands_ui = ["selectThemeCmd"] +commands_session = ["selectSessionCmd"] + +def write_file(filename, funcs, imports): + body = "\n\n".join([extract_func(f) for f in funcs]) + if body.strip(): + with open(filename, 'w') as f: + f.write(f"package chat\n\nimport (\n{imports}\n)\n\n{body}\n") + +write_file('internal/chat/commands_ai.go', commands_ai, '\t"fmt"\n\t"strings"\n\n\t"github.com/Nithwin/WindMist/internal/config"\n\t"github.com/Nithwin/WindMist/internal/ui/selector"\n\ttea "github.com/charmbracelet/bubbletea"') +write_file('internal/chat/commands_mcp.go', commands_mcp, '\t"fmt"\n\t"strings"\n\t"strconv"\n\n\t"github.com/Nithwin/WindMist/internal/mcp"\n\t"github.com/Nithwin/WindMist/internal/ui/selector"\n\ttea "github.com/charmbracelet/bubbletea"') +write_file('internal/chat/commands_ui.go', commands_ui, '\t"github.com/Nithwin/WindMist/internal/ui"\n\t"github.com/Nithwin/WindMist/internal/ui/selector"\n\ttea "github.com/charmbracelet/bubbletea"') +write_file('internal/chat/commands_session.go', commands_session, '\t"fmt"\n\t"os"\n\t"strings"\n\t"time"\n\n\t"github.com/Nithwin/WindMist/internal/ui/selector"\n\ttea "github.com/charmbracelet/bubbletea"') + +with open('internal/chat/commands.go', 'w') as f: + f.write(content) + From 1d2cfad2100c8c9ed34f362cfde67864c693023a Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 18:46:27 +0530 Subject: [PATCH 53/57] fix(gemini): resolve 400 bad request by correctly schema-translating array parameters --- internal/providers/gemini/models.go | 1 + internal/providers/gemini/stream.go | 8 +++++++- internal/providers/gemini/translate.go | 6 ++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/internal/providers/gemini/models.go b/internal/providers/gemini/models.go index 2c40780..e3c1b5a 100644 --- a/internal/providers/gemini/models.go +++ b/internal/providers/gemini/models.go @@ -27,6 +27,7 @@ type Schema struct { Properties map[string]*Schema `json:"properties,omitempty"` Required []string `json:"required,omitempty"` Enum []string `json:"enum,omitempty"` + Items *Schema `json:"items,omitempty"` } // SystemInstruction represents Gemini's system instruction. diff --git a/internal/providers/gemini/stream.go b/internal/providers/gemini/stream.go index 6a641b2..96afc92 100644 --- a/internal/providers/gemini/stream.go +++ b/internal/providers/gemini/stream.go @@ -6,6 +6,7 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "net/url" "strings" @@ -49,7 +50,12 @@ func (c *Client) StreamContent( defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return fmt.Errorf("gemini api returned status %d", resp.StatusCode) + data, _ := io.ReadAll(resp.Body) + var apiErr ErrorResponse + if err := json.Unmarshal(data, &apiErr); err == nil { + return fmt.Errorf("gemini api (%d): %s", apiErr.Error.Code, apiErr.Error.Message) + } + return fmt.Errorf("gemini api returned status %d: %s", resp.StatusCode, string(data)) } scanner := bufio.NewScanner(resp.Body) diff --git a/internal/providers/gemini/translate.go b/internal/providers/gemini/translate.go index 5a488c4..8dc8197 100644 --- a/internal/providers/gemini/translate.go +++ b/internal/providers/gemini/translate.go @@ -35,10 +35,16 @@ func translateTools(tools []ai.ToolDefinition) []Tool { schemaType = "OBJECT" } + var itemsSchema *Schema + if schemaType == "ARRAY" { + itemsSchema = &Schema{Type: "STRING"} + } + properties[p.Name] = &Schema{ Type: schemaType, Description: p.Description, Enum: p.Enum, + Items: itemsSchema, } if p.Required { required = append(required, p.Name) From ccb3c11475cf9ae75abd3e8682c4a42899871f28 Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 19:02:30 +0530 Subject: [PATCH 54/57] =?UTF-8?q?fix(tui):=20resolve=203=20critical=20UX?= =?UTF-8?q?=20bugs=20=E2=80=94=20race=20condition,=20loading=20feedback,?= =?UTF-8?q?=20scrolling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1 (Race Condition): - Block AI message submission while m.loading is true - Cancel previous in-flight context before starting new request - Show feedback message when user tries to send during loading Bug 2 (No Loading Feedback): - Convert sendMessage to sendMessageCmd returning proper tea.Cmd - Add tick-based animated spinner (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏) while loading - Show animated spinner in conversation view AND prompt label - Reset spinner state on cancel/error/completion Bug 3 (Broken Scrolling): - Enable tea.WithMouseCellMotion() for mouse events - Enable viewport.MouseWheelEnabled with 3-line delta - Add explicit mouse wheel handlers for scroll up/down - Add scroll percentage indicator when content overflows - Smart auto-scroll: only auto-scroll to bottom during loading or when user was already at bottom (prevents viewport jumping) - Account for scroll indicator and file picker in viewport sizing --- internal/chat/app.go | 1 + internal/chat/chat.go | 96 ++++++++++++++++++++-------------- internal/chat/conversation.go | 11 +++- internal/chat/model.go | 3 ++ internal/chat/update.go | 22 ++++++++ internal/chat/update_keys.go | 19 ++++++- internal/chat/update_stream.go | 4 ++ internal/chat/view.go | 31 +++++++++-- internal/chat/viewport.go | 23 ++++++-- 9 files changed, 159 insertions(+), 51 deletions(-) diff --git a/internal/chat/app.go b/internal/chat/app.go index 8d1faa0..44a6e18 100644 --- a/internal/chat/app.go +++ b/internal/chat/app.go @@ -14,6 +14,7 @@ func Run() error { p := tea.NewProgram( model, tea.WithAltScreen(), + tea.WithMouseCellMotion(), ) program = p diff --git a/internal/chat/chat.go b/internal/chat/chat.go index c79389b..e062404 100644 --- a/internal/chat/chat.go +++ b/internal/chat/chat.go @@ -7,54 +7,70 @@ import ( "time" "github.com/Nithwin/WindMist/internal/ai" + tea "github.com/charmbracelet/bubbletea" ) -func (m Model) sendMessage(ctx context.Context, prompt string) { - // Auto-title the session if it's the first message - if m.session != nil && m.session.Title == "New Session" && m.store != nil { - go func() { - titleReq := &ai.GenerateRequest{ - System: "You are an AI that creates extremely short, 2-4 word titles for chat sessions based on the user's first prompt. Do not use punctuation. Do not use quotes. Keep it lowercase.", - Messages: []ai.Message{ - {Role: ai.RoleUser, Content: prompt}, - }, - MaxTokens: 20, - } - resp, err := m.provider.Generate(context.Background(), titleReq) - if err == nil && resp.Text != "" { - m.session.Title = resp.Text - _ = m.store.UpdateSession(m.session) +// spinnerTickMsg is sent periodically to animate the loading spinner. +type spinnerTickMsg struct{} + +// spinnerTickCmd returns a tea.Cmd that fires a tick after a short delay. +func spinnerTickCmd() tea.Cmd { + return tea.Tick(120*time.Millisecond, func(t time.Time) tea.Msg { + return spinnerTickMsg{} + }) +} + +// sendMessageCmd returns a tea.Cmd that starts the AI request in a goroutine +// and immediately begins the spinner tick loop. +func (m Model) sendMessageCmd(ctx context.Context, prompt string) tea.Cmd { + return tea.Batch( + // Start the spinner tick loop + spinnerTickCmd(), + // Fire the AI request in a goroutine + func() tea.Msg { + // Auto-title the session if it's the first message + if m.session != nil && m.session.Title == "New Session" && m.store != nil { + go func() { + titleReq := &ai.GenerateRequest{ + System: "You are an AI that creates extremely short, 2-4 word titles for chat sessions based on the user's first prompt. Do not use punctuation. Do not use quotes. Keep it lowercase.", + Messages: []ai.Message{ + {Role: ai.RoleUser, Content: prompt}, + }, + MaxTokens: 20, + } + resp, err := m.provider.Generate(context.Background(), titleReq) + if err == nil && resp.Text != "" { + m.session.Title = resp.Text + _ = m.store.UpdateSession(m.session) + } + }() } - }() - } - go func() { - startTime := time.Now() - initialMessages := m.getInitialMessages() - res, err := m.agent.Run(ctx, initialMessages, prompt, func(s string) { - program.Send(StreamingMsg{ - Text: s, + startTime := time.Now() + initialMessages := m.getInitialMessages() + res, err := m.agent.Run(ctx, initialMessages, prompt, func(s string) { + program.Send(StreamingMsg{ + Text: s, + }) }) - }) - if err != nil { - program.Send(StreamingMsg{ - Err: err, - Done: true, - }) - return - } + if err != nil { + return StreamingMsg{ + Err: err, + Done: true, + } + } - duration := time.Since(startTime) + duration := time.Since(startTime) - // Agent loop completed - program.Send(StreamingMsg{ - Text: "\n\n(Finished in " + fmt.Sprintf("%d turns", res.Turns) + ")", - Done: true, - Usage: res.Usage, - Duration: duration, - }) - }() + return StreamingMsg{ + Text: "\n\n(Finished in " + fmt.Sprintf("%d turns", res.Turns) + ")", + Done: true, + Usage: res.Usage, + Duration: duration, + } + }, + ) } func (m Model) getInitialMessages() []ai.Message { diff --git a/internal/chat/conversation.go b/internal/chat/conversation.go index d5a1a03..9afaab2 100644 --- a/internal/chat/conversation.go +++ b/internal/chat/conversation.go @@ -1,12 +1,16 @@ package chat import ( + "fmt" "strings" "github.com/Nithwin/WindMist/internal/ui" "github.com/charmbracelet/lipgloss" ) +// spinnerFrames defines the animation frames for the loading spinner. +var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + func renderConversation(m Model) string { var b strings.Builder @@ -50,7 +54,11 @@ func renderConversation(m Model) string { b.WriteString(ui.BaseStyle.Render("\n")) contentStr := msg.Content if contentStr == "" && m.loading && i == len(m.conversation.Messages)-1 { - contentStr = ui.MutedStyle.Render("Thinking...") + // Animated spinner + frame := spinnerFrames[m.spinnerFrame%len(spinnerFrames)] + contentStr = ui.BaseStyle.Foreground(ui.Cyan).Bold(true).Render( + fmt.Sprintf(" %s Thinking...", frame), + ) } else { rendered := m.markdown.RenderWithWidth(contentStr, maxWidth) @@ -70,3 +78,4 @@ func renderConversation(m Model) string { b.WriteString("\n") return b.String() } + diff --git a/internal/chat/model.go b/internal/chat/model.go index 9f2df1a..783342b 100644 --- a/internal/chat/model.go +++ b/internal/chat/model.go @@ -48,6 +48,7 @@ type Model struct { loading bool streaming bool + spinnerFrame int responseTime time.Duration waitingApproval bool @@ -146,6 +147,8 @@ func New() (Model, error) { ta.Prompt = "" vp := viewport.New(0, 0) + vp.MouseWheelEnabled = true + vp.MouseWheelDelta = 3 model := Model{ cfg: cfg, diff --git a/internal/chat/update.go b/internal/chat/update.go index e313432..db313e7 100644 --- a/internal/chat/update.go +++ b/internal/chat/update.go @@ -25,6 +25,28 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case StreamingMsg: return m.handleStreamMsg(msg) + case spinnerTickMsg: + if m.loading { + m.spinnerFrame++ + m.refreshViewport() + return m, spinnerTickCmd() + } + return m, nil + + case tea.MouseMsg: + // Route mouse wheel events to the viewport for scrolling + if !m.showSplash && !m.showSelector { + switch msg.Button { + case tea.MouseButtonWheelUp: + m.viewport.ScrollUp(3) + return m, nil + case tea.MouseButtonWheelDown: + m.viewport.ScrollDown(3) + return m, nil + } + } + return m, nil + case WorkspaceFilesMsg: m.workspaceFiles = msg.Files return m, nil diff --git a/internal/chat/update_keys.go b/internal/chat/update_keys.go index cfb209a..69c2a34 100644 --- a/internal/chat/update_keys.go +++ b/internal/chat/update_keys.go @@ -95,6 +95,8 @@ func (m Model) handleKeyMsg(msg tea.KeyMsg) (Model, tea.Cmd) { if m.loading && m.cancel != nil { m.cancel() m.loading = false + m.streaming = false + m.spinnerFrame = 0 m.conversation.AddAssistant("\n\n*(Cancelled by user)*") m.refreshViewport() return m, nil @@ -265,12 +267,21 @@ func (m Model) handleKeyMsg(msg tea.KeyMsg) (Model, tea.Cmd) { } // Normal AI message. + // Block new AI messages while a request is in-flight. + if m.loading { + // Don't fire another request — just show feedback. + m.conversation.AddAssistant("⏳ *Please wait — a request is still in progress. Press `Ctrl+C` to cancel it.*") + m.refreshViewport() + return m, nil + } + m.inputHistory = append(m.inputHistory, prompt) m.historyIndex = len(m.inputHistory) m.conversation.AddUser(prompt) m.refreshViewport() m.loading = true + m.streaming = true m.input.SetValue("") @@ -279,11 +290,15 @@ func (m Model) handleKeyMsg(msg tea.KeyMsg) (Model, tea.Cmd) { m.conversation.AddAssistant("") m.refreshViewport() + // Cancel any previous in-flight request before starting a new one. + if m.cancel != nil { + m.cancel() + } + ctx, cancel := context.WithCancel(context.Background()) m.cancel = cancel - m.sendMessage(ctx, prompt) - return m, nil + return m, m.sendMessageCmd(ctx, prompt) } var cmd tea.Cmd diff --git a/internal/chat/update_stream.go b/internal/chat/update_stream.go index 80a0ea9..6e478bd 100644 --- a/internal/chat/update_stream.go +++ b/internal/chat/update_stream.go @@ -5,6 +5,8 @@ import tea "github.com/charmbracelet/bubbletea" func (m Model) handleStreamMsg(msg StreamingMsg) (Model, tea.Cmd) { if msg.Err != nil { m.loading = false + m.streaming = false + m.spinnerFrame = 0 if len(m.conversation.Messages) > 0 { m.conversation.Messages[len(m.conversation.Messages)-1].Content = @@ -27,6 +29,8 @@ func (m Model) handleStreamMsg(msg StreamingMsg) (Model, tea.Cmd) { if msg.Done { m.loading = false + m.streaming = false + m.spinnerFrame = 0 m.responseTime = msg.Duration if m.session != nil { diff --git a/internal/chat/view.go b/internal/chat/view.go index 8ad823d..e82a688 100644 --- a/internal/chat/view.go +++ b/internal/chat/view.go @@ -22,6 +22,16 @@ func (m Model) View() string { b.WriteString(m.viewport.View()) b.WriteString("\n") + // Show scroll indicator if viewport is scrollable + if m.viewport.TotalLineCount() > m.viewport.Height { + scrollPct := int(m.viewport.ScrollPercent() * 100) + scrollHint := ui.BaseStyle.Foreground(ui.Muted).Render( + fmt.Sprintf(" ↕ Scroll: %d%% (mouse wheel, Ctrl+↑/↓, PgUp/PgDn)", scrollPct), + ) + b.WriteString(scrollHint) + b.WriteString("\n") + } + // Separator above input area b.WriteString(ui.DividerStyle.Render(strings.Repeat("─", m.MaxContentWidth()+4))) b.WriteString("\n\n") @@ -53,11 +63,22 @@ func (m Model) View() string { promptLabelText = " " + m.inlinePrompt } - promptLabel := lipgloss.JoinHorizontal( - lipgloss.Center, - ui.PromptStyle.Render(promptLabelText), - ui.BaseStyle.Foreground(ui.Muted).Render(" › "), - ) + // Dim the prompt label when loading to show input is blocked + var promptLabel string + if m.loading { + frame := spinnerFrames[m.spinnerFrame%len(spinnerFrames)] + promptLabel = lipgloss.JoinHorizontal( + lipgloss.Center, + ui.BaseStyle.Foreground(ui.Cyan).Bold(true).Render(fmt.Sprintf(" %s working", frame)), + ui.BaseStyle.Foreground(ui.Muted).Render(" › "), + ) + } else { + promptLabel = lipgloss.JoinHorizontal( + lipgloss.Center, + ui.PromptStyle.Render(promptLabelText), + ui.BaseStyle.Foreground(ui.Muted).Render(" › "), + ) + } inputRow := lipgloss.JoinHorizontal( lipgloss.Top, diff --git a/internal/chat/viewport.go b/internal/chat/viewport.go index f3dfb50..8e8ec60 100644 --- a/internal/chat/viewport.go +++ b/internal/chat/viewport.go @@ -11,16 +11,26 @@ func (m *Model) updateViewportSize() { // Fixed lines surrounding viewport when showSplash == false: // - renderHeader(m): 5 lines (box + 2 newlines) // - viewport trailing newline: 1 line + // - scroll indicator (conditional): 1 line // - divider above input: 3 lines (line + 2 newlines) // - input row (label + textarea height=3) + trailing newline: 4 lines - // Total fixed lines = 13 - fixedLines := 13 + // Total fixed lines = 14 + fixedLines := 14 if m.showCommands && len(m.filteredCommands) > 0 { // command palette box (len + 4) + trailing newline (1) = len + 5 lines fixedLines += 5 + len(m.filteredCommands) } + if m.showFilePicker && len(m.filteredFiles) > 0 { + // file picker takes some lines too + pickerLines := len(m.filteredFiles) + if pickerLines > 10 { + pickerLines = 10 + } + fixedLines += pickerLines + 3 + } + availableHeight := m.height - fixedLines if availableHeight < 3 { availableHeight = 3 @@ -33,5 +43,12 @@ func (m *Model) updateViewportSize() { func (m *Model) refreshViewport() { m.updateViewportSize() m.viewport.SetContent(renderConversation(*m)) - m.viewport.GotoBottom() + + // Only auto-scroll to bottom when loading/streaming (new content arriving) + // or when the user was already at the bottom. + // This prevents the viewport from jumping while the user is scrolling up. + if m.loading || m.viewport.AtBottom() { + m.viewport.GotoBottom() + } } + From 23c836649292f00acbde29d11bef05f7a8a9041b Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 19:10:12 +0530 Subject: [PATCH 55/57] feat: implement request queuing system for non-blocking UI and add RAG infrastructure including text chunking and TF-IDF embedding modules. --- internal/chat/header.go | 41 +++++- internal/chat/model.go | 6 + internal/chat/update_keys.go | 9 +- internal/chat/update_stream.go | 49 ++++++- internal/rag/chunker.go | 108 ++++++++++++++++ internal/rag/embedder.go | 225 +++++++++++++++++++++++++++++++++ internal/rag/vector.go | 68 ++++++++++ 7 files changed, 501 insertions(+), 5 deletions(-) create mode 100644 internal/rag/chunker.go create mode 100644 internal/rag/embedder.go create mode 100644 internal/rag/vector.go diff --git a/internal/chat/header.go b/internal/chat/header.go index 0724077..61019b7 100644 --- a/internal/chat/header.go +++ b/internal/chat/header.go @@ -8,6 +8,17 @@ import ( "github.com/charmbracelet/lipgloss" ) +// formatTokenCount renders a human-friendly token count (e.g. "1.2k") +func formatTokenCount(n int) string { + if n >= 1000000 { + return fmt.Sprintf("%.1fM", float64(n)/1000000) + } + if n >= 1000 { + return fmt.Sprintf("%.1fk", float64(n)/1000) + } + return fmt.Sprintf("%d", n) +} + func renderHeader(m Model) string { model := "—" if provider, err := m.cfg.ActiveProvider(); err == nil { @@ -41,7 +52,29 @@ func renderHeader(m Model) string { } modelTag := ui.BaseStyle.Foreground(ui.Cyan).Bold(true).Render(model) - tokenTag := ui.BaseStyle.Foreground(ui.MutedLight).Render(fmt.Sprintf("%d tok", tokens)) + + // Token display: show real-time streaming tokens when active, + // otherwise show session total + var tokenTag string + if m.streaming && m.streamTokens.TotalTokens > 0 { + // Show live streaming tokens with animated indicator + frame := spinnerFrames[m.spinnerFrame%len(spinnerFrames)] + inTok := formatTokenCount(m.streamTokens.InputTokens) + outTok := formatTokenCount(m.streamTokens.OutputTokens) + tokenTag = ui.BaseStyle.Foreground(ui.Cyan).Bold(true).Render( + fmt.Sprintf("%s %s↑ %s↓", frame, inTok, outTok), + ) + } else if m.loading { + // Loading but no token data yet + frame := spinnerFrames[m.spinnerFrame%len(spinnerFrames)] + tokenTag = ui.BaseStyle.Foreground(ui.Cyan).Render( + fmt.Sprintf("%s %s tok", frame, formatTokenCount(tokens)), + ) + } else { + tokenTag = ui.BaseStyle.Foreground(ui.MutedLight).Render( + fmt.Sprintf("%s tok", formatTokenCount(tokens)), + ) + } // Only show cost if it's > 0 (to avoid showing $0.000 for free APIs like Ollama/Groq) costStr := "" @@ -60,6 +93,12 @@ func renderHeader(m Model) string { } tags = append(tags, modeTag, timeTag, themeTag) + // Show queued message indicator + if m.queuedMessage != "" { + queueTag := ui.BaseStyle.Foreground(lipgloss.Color("220")).Bold(true).Render("📋 QUEUED") + tags = append(tags, queueTag) + } + right := strings.Join(tags, ui.BaseStyle.Foreground(ui.Muted).Render(" │ ")) // ── padded spacer fills remaining width ────────────────────── diff --git a/internal/chat/model.go b/internal/chat/model.go index 783342b..01b7a22 100644 --- a/internal/chat/model.go +++ b/internal/chat/model.go @@ -51,6 +51,12 @@ type Model struct { spinnerFrame int responseTime time.Duration + // Input queuing: let user type next message while loading + queuedMessage string + + // Streaming token counter: updated in real-time + streamTokens ai.Usage + waitingApproval bool approvalCommand string approvalChan chan bool diff --git a/internal/chat/update_keys.go b/internal/chat/update_keys.go index 69c2a34..b5d189a 100644 --- a/internal/chat/update_keys.go +++ b/internal/chat/update_keys.go @@ -97,6 +97,7 @@ func (m Model) handleKeyMsg(msg tea.KeyMsg) (Model, tea.Cmd) { m.loading = false m.streaming = false m.spinnerFrame = 0 + m.queuedMessage = "" // Clear any queued message m.conversation.AddAssistant("\n\n*(Cancelled by user)*") m.refreshViewport() return m, nil @@ -267,10 +268,12 @@ func (m Model) handleKeyMsg(msg tea.KeyMsg) (Model, tea.Cmd) { } // Normal AI message. - // Block new AI messages while a request is in-flight. + // Queue input if a request is already in-flight. if m.loading { - // Don't fire another request — just show feedback. - m.conversation.AddAssistant("⏳ *Please wait — a request is still in progress. Press `Ctrl+C` to cancel it.*") + // Queue this message — it will auto-send when the current request finishes. + m.queuedMessage = prompt + m.input.SetValue("") + m.conversation.AddAssistant("📋 *Message queued — will send automatically when current request finishes.*") m.refreshViewport() return m, nil } diff --git a/internal/chat/update_stream.go b/internal/chat/update_stream.go index 6e478bd..1ca566c 100644 --- a/internal/chat/update_stream.go +++ b/internal/chat/update_stream.go @@ -1,12 +1,17 @@ package chat -import tea "github.com/charmbracelet/bubbletea" +import ( + "context" + + tea "github.com/charmbracelet/bubbletea" +) func (m Model) handleStreamMsg(msg StreamingMsg) (Model, tea.Cmd) { if msg.Err != nil { m.loading = false m.streaming = false m.spinnerFrame = 0 + m.streamTokens = m.streamTokens // preserve for display if len(m.conversation.Messages) > 0 { m.conversation.Messages[len(m.conversation.Messages)-1].Content = @@ -15,6 +20,11 @@ func (m Model) handleStreamMsg(msg StreamingMsg) (Model, tea.Cmd) { m.refreshViewport() } + // Check for queued message even on error + if m.queuedMessage != "" { + return m, m.processQueuedMessage() + } + return m, nil } @@ -27,6 +37,11 @@ func (m Model) handleStreamMsg(msg StreamingMsg) (Model, tea.Cmd) { } } + // Update real-time token counter from streaming usage data + if msg.Usage.TotalTokens > 0 { + m.streamTokens = msg.Usage + } + if msg.Done { m.loading = false m.streaming = false @@ -43,7 +58,39 @@ func (m Model) handleStreamMsg(msg StreamingMsg) (Model, tea.Cmd) { _ = m.store.UpdateSession(m.session) } } + + // Reset stream tokens for next request + m.streamTokens = msg.Usage + + // Auto-send queued message if one exists + if m.queuedMessage != "" { + return m, m.processQueuedMessage() + } } return m, nil } + +// processQueuedMessage takes the queued message and sends it as a new AI request. +func (m *Model) processQueuedMessage() tea.Cmd { + prompt := m.queuedMessage + m.queuedMessage = "" + + m.inputHistory = append(m.inputHistory, prompt) + m.historyIndex = len(m.inputHistory) + + m.conversation.AddUser(prompt) + m.refreshViewport() + m.loading = true + m.streaming = true + m.streamTokens = m.streamTokens // Reset for new request + + // Create an empty assistant message for streaming + m.conversation.AddAssistant("") + m.refreshViewport() + + ctx, cancel := context.WithCancel(context.Background()) + m.cancel = cancel + + return m.sendMessageCmd(ctx, prompt) +} diff --git a/internal/rag/chunker.go b/internal/rag/chunker.go new file mode 100644 index 0000000..4df4492 --- /dev/null +++ b/internal/rag/chunker.go @@ -0,0 +1,108 @@ +package rag + +import ( + "bufio" + "strings" +) + +// Chunk represents a section of a source file. +type Chunk struct { + FilePath string + StartLine int + EndLine int + Content string +} + +// ChunkConfig controls how files are split into chunks. +type ChunkConfig struct { + // MaxChunkLines is the maximum number of lines per chunk. + MaxChunkLines int + // OverlapLines is how many lines overlap between adjacent chunks. + OverlapLines int +} + +// DefaultChunkConfig returns sensible defaults for code chunking. +func DefaultChunkConfig() ChunkConfig { + return ChunkConfig{ + MaxChunkLines: 40, + OverlapLines: 5, + } +} + +// ChunkFile splits a file's content into overlapping chunks. +// It uses a simple line-based sliding window approach that respects +// blank-line boundaries (tries to split at natural breaks). +func ChunkFile(filePath, content string, cfg ChunkConfig) []Chunk { + if cfg.MaxChunkLines <= 0 { + cfg.MaxChunkLines = 40 + } + if cfg.OverlapLines < 0 { + cfg.OverlapLines = 0 + } + + lines := splitLines(content) + if len(lines) == 0 { + return nil + } + + // If the whole file fits in one chunk, return it as-is. + if len(lines) <= cfg.MaxChunkLines { + return []Chunk{ + { + FilePath: filePath, + StartLine: 1, + EndLine: len(lines), + Content: content, + }, + } + } + + var chunks []Chunk + start := 0 + for start < len(lines) { + end := start + cfg.MaxChunkLines + if end > len(lines) { + end = len(lines) + } + + // Try to find a natural break point (blank line) near the end + // to avoid splitting mid-function. + bestBreak := end + if end < len(lines) { + for i := end - 1; i > start+cfg.MaxChunkLines/2; i-- { + if strings.TrimSpace(lines[i]) == "" { + bestBreak = i + 1 + break + } + } + end = bestBreak + } + + chunkContent := strings.Join(lines[start:end], "\n") + chunks = append(chunks, Chunk{ + FilePath: filePath, + StartLine: start + 1, // 1-indexed + EndLine: end, + Content: chunkContent, + }) + + // Advance with overlap + step := end - start - cfg.OverlapLines + if step < 1 { + step = 1 + } + start += step + } + + return chunks +} + +// splitLines splits text into lines, preserving empty lines. +func splitLines(text string) []string { + scanner := bufio.NewScanner(strings.NewReader(text)) + var lines []string + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + return lines +} diff --git a/internal/rag/embedder.go b/internal/rag/embedder.go new file mode 100644 index 0000000..4acf706 --- /dev/null +++ b/internal/rag/embedder.go @@ -0,0 +1,225 @@ +package rag + +import ( + "math" + "strings" + "unicode" +) + +// TFIDFEmbedder generates TF-IDF-based embeddings entirely in pure Go. +// No API calls needed — works fully offline. +type TFIDFEmbedder struct { + // vocabulary maps tokens to their dimension index. + vocabulary map[string]int + // idf stores inverse document frequency for each token. + idf map[string]float64 + // dimensions is the embedding vector size (vocabulary size, capped). + dimensions int + // maxDimensions caps the vector size for memory efficiency. + maxDimensions int +} + +// NewTFIDFEmbedder creates a new TF-IDF embedder. +func NewTFIDFEmbedder(maxDimensions int) *TFIDFEmbedder { + if maxDimensions <= 0 { + maxDimensions = 512 + } + return &TFIDFEmbedder{ + vocabulary: make(map[string]int), + idf: make(map[string]float64), + maxDimensions: maxDimensions, + } +} + +// BuildVocabulary builds a vocabulary from a corpus of documents. +// Each document is a string of text (e.g., a code chunk). +// This must be called before Embed(). +func (e *TFIDFEmbedder) BuildVocabulary(documents []string) { + docFreq := make(map[string]int) + allTokens := make(map[string]bool) + + for _, doc := range documents { + tokens := tokenize(doc) + seen := make(map[string]bool) + for _, tok := range tokens { + allTokens[tok] = true + if !seen[tok] { + docFreq[tok]++ + seen[tok] = true + } + } + } + + // Build vocabulary — pick the top tokens by document frequency. + // This acts as a natural feature selection for the most relevant terms. + type tokenFreq struct { + token string + freq int + } + ranked := make([]tokenFreq, 0, len(allTokens)) + for tok := range allTokens { + ranked = append(ranked, tokenFreq{tok, docFreq[tok]}) + } + + // Sort by frequency (descending), but skip tokens that appear in + // too many documents (>80%) as they're not discriminative. + totalDocs := len(documents) + filtered := make([]tokenFreq, 0, len(ranked)) + for _, tf := range ranked { + ratio := float64(tf.freq) / float64(totalDocs) + if ratio < 0.8 && tf.freq > 1 { + filtered = append(filtered, tf) + } + } + + // Sort by frequency (descending) using a simple selection sort + // for the top maxDimensions entries. + dim := e.maxDimensions + if dim > len(filtered) { + dim = len(filtered) + } + for i := 0; i < dim; i++ { + maxIdx := i + for j := i + 1; j < len(filtered); j++ { + if filtered[j].freq > filtered[maxIdx].freq { + maxIdx = j + } + } + filtered[i], filtered[maxIdx] = filtered[maxIdx], filtered[i] + } + + e.vocabulary = make(map[string]int, dim) + for i := 0; i < dim; i++ { + e.vocabulary[filtered[i].token] = i + } + e.dimensions = dim + + // Compute IDF for each token in vocabulary + e.idf = make(map[string]float64, dim) + for tok := range e.vocabulary { + df := docFreq[tok] + if df == 0 { + df = 1 + } + e.idf[tok] = math.Log(float64(totalDocs+1) / float64(df+1)) + } +} + +// Embed generates a TF-IDF vector for the given text. +// The vector dimensions correspond to the vocabulary built via BuildVocabulary. +func (e *TFIDFEmbedder) Embed(text string) Vector { + if e.dimensions == 0 { + return nil + } + + tokens := tokenize(text) + if len(tokens) == 0 { + return make(Vector, e.dimensions) + } + + // Compute term frequency + tf := make(map[string]int) + for _, tok := range tokens { + tf[tok]++ + } + + // Build TF-IDF vector + vec := make(Vector, e.dimensions) + for tok, count := range tf { + idx, ok := e.vocabulary[tok] + if !ok { + continue + } + // TF: normalized by document length + termFreq := float64(count) / float64(len(tokens)) + // IDF from pre-computed values + idf := e.idf[tok] + if idf == 0 { + idf = 1 + } + vec[idx] = float32(termFreq * idf) + } + + return Normalize(vec) +} + +// Dimensions returns the number of dimensions in the embeddings. +func (e *TFIDFEmbedder) Dimensions() int { + return e.dimensions +} + +// tokenize splits text into code-aware tokens. +// It handles camelCase, snake_case, and common programming constructs. +func tokenize(text string) []string { + var tokens []string + text = strings.ToLower(text) + + // Split on non-alphanumeric boundaries + var current strings.Builder + for _, r := range text { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + current.WriteRune(r) + } else { + if current.Len() > 0 { + tok := current.String() + if len(tok) > 1 && !isStopWord(tok) { + tokens = append(tokens, tok) + } + current.Reset() + } + } + } + if current.Len() > 0 { + tok := current.String() + if len(tok) > 1 && !isStopWord(tok) { + tokens = append(tokens, tok) + } + } + + // Also split camelCase tokens + expanded := make([]string, 0, len(tokens)*2) + for _, tok := range tokens { + expanded = append(expanded, tok) + parts := splitCamelCase(tok) + if len(parts) > 1 { + for _, p := range parts { + if len(p) > 1 { + expanded = append(expanded, p) + } + } + } + } + + return expanded +} + +// splitCamelCase splits "camelCase" into ["camel", "case"]. +func splitCamelCase(s string) []string { + var parts []string + var current strings.Builder + for i, r := range s { + if i > 0 && unicode.IsUpper(r) { + if current.Len() > 0 { + parts = append(parts, strings.ToLower(current.String())) + current.Reset() + } + } + current.WriteRune(r) + } + if current.Len() > 0 { + parts = append(parts, strings.ToLower(current.String())) + } + return parts +} + +// isStopWord returns true for common programming/English stop words. +func isStopWord(w string) bool { + stops := map[string]bool{ + "the": true, "is": true, "at": true, "in": true, "on": true, + "to": true, "of": true, "an": true, "if": true, "or": true, + "it": true, "be": true, "as": true, "do": true, "no": true, + "so": true, "we": true, "he": true, "by": true, "up": true, + "my": true, "me": true, "am": true, "go": true, + } + return stops[w] +} diff --git a/internal/rag/vector.go b/internal/rag/vector.go new file mode 100644 index 0000000..bb62ab3 --- /dev/null +++ b/internal/rag/vector.go @@ -0,0 +1,68 @@ +package rag + +import ( + "encoding/binary" + "math" +) + +// Vector represents a float32 embedding vector. +type Vector = []float32 + +// CosineSimilarity computes the cosine similarity between two vectors. +// Returns a value between -1 and 1, where 1 means identical direction. +func CosineSimilarity(a, b Vector) float32 { + if len(a) != len(b) || len(a) == 0 { + return 0 + } + + var dotProduct, normA, normB float32 + for i := range a { + dotProduct += a[i] * b[i] + normA += a[i] * a[i] + normB += b[i] * b[i] + } + + if normA == 0 || normB == 0 { + return 0 + } + + return dotProduct / (float32(math.Sqrt(float64(normA))) * float32(math.Sqrt(float64(normB)))) +} + +// EncodeVector serializes a float32 vector to bytes for SQLite BLOB storage. +func EncodeVector(v Vector) []byte { + buf := make([]byte, len(v)*4) + for i, f := range v { + binary.LittleEndian.PutUint32(buf[i*4:], math.Float32bits(f)) + } + return buf +} + +// DecodeVector deserializes bytes from a SQLite BLOB back to a float32 vector. +func DecodeVector(buf []byte) Vector { + if len(buf)%4 != 0 { + return nil + } + v := make(Vector, len(buf)/4) + for i := range v { + v[i] = math.Float32frombits(binary.LittleEndian.Uint32(buf[i*4:])) + } + return v +} + +// Normalize normalizes a vector to unit length (L2 normalization). +func Normalize(v Vector) Vector { + var sum float32 + for _, f := range v { + sum += f * f + } + if sum == 0 { + return v + } + norm := float32(math.Sqrt(float64(sum))) + out := make(Vector, len(v)) + for i, f := range v { + out[i] = f / norm + } + return out +} From a9a19b9ac9f4500caf5b58a755000b12e2535ed6 Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 19:23:12 +0530 Subject: [PATCH 56/57] refactor: standardize code formatting and improve consistency across internal components --- internal/agent/agent.go | 6 +-- internal/agent/executor.go | 40 ++++++++-------- internal/agent/format.go | 8 ++-- internal/agent/memory_test.go | 18 +++---- internal/agent/mode.go | 8 ++-- internal/chat/conversation.go | 1 - internal/chat/viewport.go | 1 - internal/config/options.go | 66 +++++++++++++------------- internal/config/types.go | 12 ++--- internal/lsp/client.go | 64 ++++++++++++------------- internal/lsp/manager.go | 4 +- internal/mcp/client.go | 60 +++++++++++------------ internal/mcp/installer.go | 6 +-- internal/mcp/manager.go | 22 ++++----- internal/providers/gemini/translate.go | 2 +- internal/store/queries.go | 4 +- internal/tools/agent/subagent.go | 8 ++-- internal/ui/selector/selector.go | 6 +-- internal/ui/theme.go | 2 +- 19 files changed, 167 insertions(+), 171 deletions(-) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index a8fbdc9..8417515 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -78,20 +78,20 @@ func New( lspManager: lsp.NewManager(), mcpManager: mcp.NewManager(), } - + // Start MCP servers asynchronously so it doesn't block UI load go func() { // Create a temporary context for startup ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - + // Load global config to get MCPServers globalCfg, err := appconfig.Load() if err == nil { _ = a.mcpManager.StartAll(ctx, globalCfg) } }() - + return a } diff --git a/internal/agent/executor.go b/internal/agent/executor.go index ed9b242..5366b38 100644 --- a/internal/agent/executor.go +++ b/internal/agent/executor.go @@ -3,26 +3,26 @@ package agent import ( "context" "fmt" - "sync" - "strings" "os" "path/filepath" + "strings" + "sync" "time" - "github.com/pmezard/go-difflib/difflib" "github.com/Nithwin/WindMist/internal/ai" "github.com/Nithwin/WindMist/internal/store" "github.com/Nithwin/WindMist/internal/tools" + "github.com/pmezard/go-difflib/difflib" ) // execute runs a slice of tool calls against the tool manager and returns their results. func (a *Agent) execute(ctx context.Context, calls []ai.ToolCall, onChunk func(string)) []ai.ToolResult { results := make([]ai.ToolResult, len(calls)) var wg sync.WaitGroup - + batchID := fmt.Sprintf("batch_%d", time.Now().UnixNano()) - + // Clear redo history when a new edit is made if a.config.Store != nil && a.config.SessionID != "" { _ = a.config.Store.ClearRedoHistory(a.config.SessionID) @@ -32,11 +32,11 @@ func (a *Agent) execute(ctx context.Context, calls []ai.ToolCall, onChunk func(s wg.Add(1) go func(i int, call ai.ToolCall) { defer wg.Done() - + // Route to MCP Manager if it's an MCP tool if strings.HasPrefix(call.Name, "mcp_") && a.mcpManager != nil { res, err := a.mcpManager.ExecuteTool(ctx, call.Name, call.Args) - + content := "" isError := false if err != nil { @@ -45,7 +45,7 @@ func (a *Agent) execute(ctx context.Context, calls []ai.ToolCall, onChunk func(s } else { content = fmt.Sprintf("%v", res) } - + results[i] = ai.ToolResult{ ID: call.ID, Name: call.Name, @@ -80,8 +80,6 @@ func (a *Agent) execute(ctx context.Context, calls []ai.ToolCall, onChunk func(s onChunk(fmt.Sprintf(" ✅ Done (`%s`).\n\n", call.Name)) } - - content := "" isError := false @@ -91,10 +89,10 @@ func (a *Agent) execute(ctx context.Context, calls []ai.ToolCall, onChunk func(s } else if len(res.FileStates) > 0 { var diffs strings.Builder diffs.WriteString(fmt.Sprintf("Successfully modified %d file(s):\n\n", len(res.FileStates))) - + for i := range res.FileStates { state := &res.FileStates[i] - + // Auto-format the file if possible if autoFormat(state.Path) { // Re-read the formatted content @@ -102,7 +100,7 @@ func (a *Agent) execute(ctx context.Context, calls []ai.ToolCall, onChunk func(s state.AfterContent = string(contentBytes) } } - + // Now save the file change to the store (with formatted content) if a.config.Store != nil && a.config.SessionID != "" { _ = a.config.Store.SaveFileChange(&store.FileChange{ @@ -114,7 +112,7 @@ func (a *Agent) execute(ctx context.Context, calls []ai.ToolCall, onChunk func(s AfterContent: state.AfterContent, }) } - + diff := difflib.UnifiedDiff{ A: difflib.SplitLines(state.BeforeContent), B: difflib.SplitLines(state.AfterContent), @@ -124,7 +122,7 @@ func (a *Agent) execute(ctx context.Context, calls []ai.ToolCall, onChunk func(s } text, _ := difflib.GetUnifiedDiffString(diff) diffs.WriteString(fmt.Sprintf("```diff\n%s\n```\n", strings.TrimSpace(text))) - + // Connect to LSP and check for diagnostics if a.lspManager != nil { absPath, err := filepath.Abs(state.Path) @@ -142,10 +140,10 @@ func (a *Agent) execute(ctx context.Context, calls []ai.ToolCall, onChunk func(s "text": state.AfterContent, }, }) - + // Wait for diagnostics to stream in time.Sleep(500 * time.Millisecond) - + diags := client.GetDiagnostics(uri) if len(diags) > 0 { diffs.WriteString("\n⚠️ **LSP Diagnostics Found:**\n") @@ -159,9 +157,9 @@ func (a *Agent) execute(ctx context.Context, calls []ai.ToolCall, onChunk func(s } } } - + content = diffs.String() - + // Send the diff to the chat UI via onChunk so the user sees it immediately if onChunk != nil { onChunk("\n" + content + "\n") @@ -211,10 +209,10 @@ func (a *Agent) toolDefinitions(modeConfig ModeConfig) []ai.ToolDefinition { Parameters: params, }) } - + if a.mcpManager != nil { defs = append(defs, a.mcpManager.GetTools()...) } - + return defs } diff --git a/internal/agent/format.go b/internal/agent/format.go index 1555bb9..8d57321 100644 --- a/internal/agent/format.go +++ b/internal/agent/format.go @@ -10,9 +10,9 @@ import ( // It returns true if a formatter was successfully run, or false if no formatter was found or it failed. func autoFormat(path string) bool { ext := strings.ToLower(filepath.Ext(path)) - + var cmd *exec.Cmd - + switch ext { case ".go": if _, err := exec.LookPath("gofmt"); err == nil { @@ -33,11 +33,11 @@ func autoFormat(path string) bool { cmd = exec.Command("rustfmt", path) } } - + if cmd == nil { return false } - + // We don't care about the output right now, just run it silently err := cmd.Run() return err == nil diff --git a/internal/agent/memory_test.go b/internal/agent/memory_test.go index 44958a7..cb7d0e6 100644 --- a/internal/agent/memory_test.go +++ b/internal/agent/memory_test.go @@ -19,15 +19,15 @@ func TestPruneMessages(t *testing.T) { } longHistory := []ai.Message{ - {Role: ai.RoleUser, Content: "Initial task goal"}, - {Role: ai.RoleAssistant, Content: "Turn 1 Assistant"}, - {Role: ai.RoleTool, Content: "Turn 1 Tool"}, - {Role: ai.RoleAssistant, Content: "Turn 2 Assistant"}, - {Role: ai.RoleTool, Content: "Turn 2 Tool"}, - {Role: ai.RoleAssistant, Content: "Turn 3 Assistant"}, - {Role: ai.RoleTool, Content: "Turn 3 Tool"}, - {Role: ai.RoleAssistant, Content: "Turn 4 Assistant"}, - {Role: ai.RoleTool, Content: "Turn 4 Tool"}, + {Role: ai.RoleUser, Content: "Initial task goal"}, + {Role: ai.RoleAssistant, Content: "Turn 1 Assistant"}, + {Role: ai.RoleTool, Content: "Turn 1 Tool"}, + {Role: ai.RoleAssistant, Content: "Turn 2 Assistant"}, + {Role: ai.RoleTool, Content: "Turn 2 Tool"}, + {Role: ai.RoleAssistant, Content: "Turn 3 Assistant"}, + {Role: ai.RoleTool, Content: "Turn 3 Tool"}, + {Role: ai.RoleAssistant, Content: "Turn 4 Assistant"}, + {Role: ai.RoleTool, Content: "Turn 4 Tool"}, } prunedLong := mem.Prune(longHistory, 50) diff --git a/internal/agent/mode.go b/internal/agent/mode.go index e374467..6e1db6c 100644 --- a/internal/agent/mode.go +++ b/internal/agent/mode.go @@ -54,16 +54,16 @@ func FilterTools(manager *tools.Manager, config ModeConfig) []tools.Definition { for _, tool := range manager.List() { def := tool.Definition() - + // If edits are denied, filter out PermWrite and PermDangerous if !config.AllowFileEdits && (def.Category == tools.CategoryEditing || def.Permission == tools.PermWrite || def.Permission == tools.PermDangerous) { continue } - - // Wait, if commands are denied, we could filter out system/command tools, + + // Wait, if commands are denied, we could filter out system/command tools, // but maybe we just require permission instead of completely filtering. // For now, in plan mode, we completely disable editing tools. - + allowed = append(allowed, def) } diff --git a/internal/chat/conversation.go b/internal/chat/conversation.go index 9afaab2..e7dd3cc 100644 --- a/internal/chat/conversation.go +++ b/internal/chat/conversation.go @@ -78,4 +78,3 @@ func renderConversation(m Model) string { b.WriteString("\n") return b.String() } - diff --git a/internal/chat/viewport.go b/internal/chat/viewport.go index 8e8ec60..e16c7e5 100644 --- a/internal/chat/viewport.go +++ b/internal/chat/viewport.go @@ -51,4 +51,3 @@ func (m *Model) refreshViewport() { m.viewport.GotoBottom() } } - diff --git a/internal/config/options.go b/internal/config/options.go index 7cb2558..0426937 100644 --- a/internal/config/options.go +++ b/internal/config/options.go @@ -28,29 +28,29 @@ type modelEntry struct { func GetProviderOptions() []selector.Option { return []selector.Option{ { - Label: "gemini", - Desc: "Google Gemini — Fast, highly capable multimodal AI (Default)", - Value: "gemini", + Label: "gemini", + Desc: "Google Gemini — Fast, highly capable multimodal AI (Default)", + Value: "gemini", }, { - Label: "openai", - Desc: "OpenAI — Flagship models like GPT-4o, o1, o3-mini", - Value: "openai", + Label: "openai", + Desc: "OpenAI — Flagship models like GPT-4o, o1, o3-mini", + Value: "openai", }, { - Label: "anthropic", - Desc: "Anthropic — Claude 3.5 Sonnet, Haiku, Opus models", - Value: "anthropic", + Label: "anthropic", + Desc: "Anthropic — Claude 3.5 Sonnet, Haiku, Opus models", + Value: "anthropic", }, { - Label: "groq", - Desc: "Groq — Ultra-fast Llama 3 and Mixtral inference", - Value: "groq", + Label: "groq", + Desc: "Groq — Ultra-fast Llama 3 and Mixtral inference", + Value: "groq", }, { - Label: "ollama", - Desc: "Ollama — Run open-source models locally on your system", - Value: "ollama", + Label: "ollama", + Desc: "Ollama — Run open-source models locally on your system", + Value: "ollama", }, } } @@ -72,9 +72,9 @@ func (c *Config) GetModelOptions(providerName, ollamaBaseURL string) []selector. if entries, ok := manifest[providerName]; ok { for _, e := range entries { options = append(options, selector.Option{ - Label: e.Label, - Desc: e.Description, - Value: e.Value, + Label: e.Label, + Desc: e.Description, + Value: e.Value, }) } } @@ -84,18 +84,18 @@ func (c *Config) GetModelOptions(providerName, ollamaBaseURL string) []selector. if c.CustomModels != nil { for _, m := range c.CustomModels[providerName] { options = append(options, selector.Option{ - Label: fmt.Sprintf("%s (Custom)", m), - Desc: "Saved custom model", - Value: m, + Label: fmt.Sprintf("%s (Custom)", m), + Desc: "Saved custom model", + Value: m, }) } } // Always append custom model escape hatch options = append(options, selector.Option{ - Label: "Custom model ID...", - Desc: "Enter any model name or identifier manually", - Value: "__CUSTOM__", + Label: "Custom model ID...", + Desc: "Enter any model name or identifier manually", + Value: "__CUSTOM__", }) return options @@ -106,9 +106,9 @@ func ensureOllamaReadyAndGetModels(baseURL string) []selector.Option { if _, err := exec.LookPath("ollama"); err != nil { return []selector.Option{ { - Label: "❌ Ollama CLI not installed", - Desc: "Please install Ollama from https://ollama.com first", - Value: "__CUSTOM__", + Label: "❌ Ollama CLI not installed", + Desc: "Please install Ollama from https://ollama.com first", + Value: "__CUSTOM__", }, } } @@ -174,9 +174,9 @@ func ensureOllamaReadyAndGetModels(baseURL string) []selector.Option { return []selector.Option{ { - Label: "⚠️ Ollama offline or empty", - Desc: fmt.Sprintf("Run 'ollama serve' and 'ollama pull ' at %s", baseURL), - Value: "__CUSTOM__", + Label: "⚠️ Ollama offline or empty", + Desc: fmt.Sprintf("Run 'ollama serve' and 'ollama pull ' at %s", baseURL), + Value: "__CUSTOM__", }, } } @@ -235,9 +235,9 @@ func fetchOllamaModels(baseURL string) ([]selector.Option, error) { desc = fmt.Sprintf("Installed local (%s, %s)", m.Details.ParameterSize, m.Details.QuantizationLevel) } options = append(options, selector.Option{ - Label: m.Name, - Desc: desc, - Value: m.Name, + Label: m.Name, + Desc: desc, + Value: m.Name, }) } diff --git a/internal/config/types.go b/internal/config/types.go index ec799e0..f337450 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -2,12 +2,12 @@ package config // Config represents the complete WindMist configuration. type Config struct { - AI AIConfig `yaml:"ai"` - Providers map[string]ProviderConfig `yaml:"providers"` - UI UIConfig `yaml:"ui"` - Cache CacheConfig `yaml:"cache"` - SubAgent SubAgentConfig `yaml:"subagent,omitempty"` - CustomModels map[string][]string `yaml:"custom_models,omitempty"` + AI AIConfig `yaml:"ai"` + Providers map[string]ProviderConfig `yaml:"providers"` + UI UIConfig `yaml:"ui"` + Cache CacheConfig `yaml:"cache"` + SubAgent SubAgentConfig `yaml:"subagent,omitempty"` + CustomModels map[string][]string `yaml:"custom_models,omitempty"` MCPServers map[string]MCPServerConfig `yaml:"mcp_servers,omitempty"` } diff --git a/internal/lsp/client.go b/internal/lsp/client.go index 1fbabc5..e9dc79a 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -16,19 +16,19 @@ import ( // Client represents an LSP JSON-RPC client connected via stdio. type Client struct { - cmd *exec.Cmd - stdin io.WriteCloser - stdout io.ReadCloser - + cmd *exec.Cmd + stdin io.WriteCloser + stdout io.ReadCloser + projectPath string - + nextID int64 mu sync.Mutex pending map[int64]chan *JSONRPCMessage - + diagMu sync.Mutex diagnostics map[string][]Diagnostic // URI -> Diagnostics - + idleTimer *time.Timer idleMu sync.Mutex onIdleFunc func() @@ -73,7 +73,7 @@ type PublishDiagnosticsParams struct { func NewClient(command string, args []string, projectPath string) *Client { cmd := exec.Command(command, args...) cmd.Dir = projectPath - + return &Client{ cmd: cmd, projectPath: projectPath, @@ -88,27 +88,27 @@ func (c *Client) Start(ctx context.Context) error { if err != nil { return err } - + stdout, err := c.cmd.StdoutPipe() if err != nil { return err } - + c.stdin = stdin c.stdout = stdout - + if err := c.cmd.Start(); err != nil { return err } - + go c.readLoop() - + // Send initialize request type InitParams struct { ProcessID int `json:"processId"` RootURI string `json:"rootUri"` } - + _, err = c.Call(ctx, "initialize", InitParams{ ProcessID: c.cmd.Process.Pid, RootURI: "file://" + c.projectPath, @@ -117,10 +117,10 @@ func (c *Client) Start(ctx context.Context) error { c.Close() return fmt.Errorf("LSP initialization failed: %w", err) } - + // Send initialized notification _ = c.Notify("initialized", map[string]interface{}{}) - + return nil } @@ -128,7 +128,7 @@ func (c *Client) Start(ctx context.Context) error { func (c *Client) ResetIdleTimer() { c.idleMu.Lock() defer c.idleMu.Unlock() - + if c.idleTimer != nil { c.idleTimer.Reset(c.idleDur) } @@ -138,7 +138,7 @@ func (c *Client) ResetIdleTimer() { func (c *Client) OnIdle(duration time.Duration, callback func()) { c.idleMu.Lock() defer c.idleMu.Unlock() - + c.idleDur = duration c.onIdleFunc = callback c.idleTimer = time.AfterFunc(duration, callback) @@ -147,7 +147,7 @@ func (c *Client) OnIdle(duration time.Duration, callback func()) { // Call sends a JSON-RPC request and waits for the response. func (c *Client) Call(ctx context.Context, method string, params interface{}) (*JSONRPCMessage, error) { c.ResetIdleTimer() - + id := atomic.AddInt64(&c.nextID, 1) req := JSONRPCRequest{ JSONRPC: "2.0", @@ -155,28 +155,28 @@ func (c *Client) Call(ctx context.Context, method string, params interface{}) (* Method: method, Params: params, } - + data, err := json.Marshal(req) if err != nil { return nil, err } - + ch := make(chan *JSONRPCMessage, 1) c.mu.Lock() c.pending[id] = ch c.mu.Unlock() - + defer func() { c.mu.Lock() delete(c.pending, id) c.mu.Unlock() }() - + msg := fmt.Sprintf("Content-Length: %d\r\n\r\n%s", len(data), data) if _, err := c.stdin.Write([]byte(msg)); err != nil { return nil, err } - + select { case res := <-ch: if res.Error != nil { @@ -191,18 +191,18 @@ func (c *Client) Call(ctx context.Context, method string, params interface{}) (* // Notify sends a JSON-RPC notification (no response expected). func (c *Client) Notify(method string, params interface{}) error { c.ResetIdleTimer() - + req := map[string]interface{}{ "jsonrpc": "2.0", "method": method, "params": params, } - + data, err := json.Marshal(req) if err != nil { return err } - + msg := fmt.Sprintf("Content-Length: %d\r\n\r\n%s", len(data), data) _, err = c.stdin.Write([]byte(msg)) return err @@ -229,17 +229,17 @@ func (c *Client) readLoop() { } } } - + if contentLength == 0 { continue } - + // Read body body := make([]byte, contentLength) if _, err := io.ReadFull(reader, body); err != nil { return } - + var res JSONRPCMessage if err := json.Unmarshal(body, &res); err == nil { // If it's a response to a request we made @@ -265,7 +265,7 @@ func (c *Client) readLoop() { func (c *Client) GetDiagnostics(uri string) []Diagnostic { c.diagMu.Lock() defer c.diagMu.Unlock() - + // Create a copy to avoid race conditions if diags, ok := c.diagnostics[uri]; ok { cpy := make([]Diagnostic, len(diags)) @@ -282,7 +282,7 @@ func (c *Client) Close() { c.idleTimer.Stop() } c.idleMu.Unlock() - + _ = c.Notify("exit", nil) if c.cmd.Process != nil { _ = c.cmd.Process.Kill() diff --git a/internal/lsp/manager.go b/internal/lsp/manager.go index 4f9003e..7a0f2a0 100644 --- a/internal/lsp/manager.go +++ b/internal/lsp/manager.go @@ -45,7 +45,7 @@ func NewManager() *Manager { // GetClient returns a running client for the file extension, or starts one if not running. func (m *Manager) GetClient(ctx context.Context, projectPath string, filePath string) (*Client, error) { ext := strings.ToLower(filepath.Ext(filePath)) - + m.mu.Lock() defer m.mu.Unlock() @@ -63,7 +63,7 @@ func (m *Manager) GetClient(ctx context.Context, projectPath string, filePath st // Start new client client := NewClient(cfg.Command, cfg.Args, projectPath) - + // Add an idle callback to automatically shut down the LSP to save RAM client.OnIdle(30*time.Second, func() { m.mu.Lock() diff --git a/internal/mcp/client.go b/internal/mcp/client.go index 7914103..51dd2c6 100644 --- a/internal/mcp/client.go +++ b/internal/mcp/client.go @@ -15,11 +15,11 @@ import ( ) type Client struct { - Name string - cmd *exec.Cmd - stdin io.WriteCloser - stdout io.ReadCloser - + Name string + cmd *exec.Cmd + stdin io.WriteCloser + stdout io.ReadCloser + nextID int64 mu sync.Mutex pending map[int64]chan *JSONRPCResponse @@ -46,14 +46,14 @@ type JSONRPCError struct { func NewClient(name, command string, args []string, env map[string]string) *Client { cmd := exec.Command(command, args...) - + if len(env) > 0 { cmd.Env = os.Environ() for k, v := range env { cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v)) } } - + return &Client{ Name: name, cmd: cmd, @@ -66,36 +66,36 @@ func (c *Client) Start(ctx context.Context) error { if err != nil { return err } - + stdout, err := c.cmd.StdoutPipe() if err != nil { return err } - + // We might also want to pipe stderr for debugging c.cmd.Stderr = os.Stderr - + c.stdin = stdin c.stdout = stdout - + if err := c.cmd.Start(); err != nil { return err } - + go c.readLoop() - + // Initialize MCP session type ClientInfo struct { Name string `json:"name"` Version string `json:"version"` } - + type InitParams struct { - ProtocolVersion string `json:"protocolVersion"` + ProtocolVersion string `json:"protocolVersion"` Capabilities map[string]interface{} `json:"capabilities"` - ClientInfo ClientInfo `json:"clientInfo"` + ClientInfo ClientInfo `json:"clientInfo"` } - + _, err = c.Call(ctx, "initialize", InitParams{ ProtocolVersion: "2024-11-05", // Standard MCP protocol version Capabilities: map[string]interface{}{}, @@ -104,15 +104,15 @@ func (c *Client) Start(ctx context.Context) error { Version: "2.0.0", }, }) - + if err != nil { c.Close() return fmt.Errorf("MCP initialization failed: %w", err) } - + // Send initialized notification _ = c.Notify("notifications/initialized", map[string]interface{}{}) - + return nil } @@ -124,30 +124,30 @@ func (c *Client) Call(ctx context.Context, method string, params interface{}) (* Method: method, Params: params, } - + data, err := json.Marshal(req) if err != nil { return nil, err } - + ch := make(chan *JSONRPCResponse, 1) c.mu.Lock() c.pending[id] = ch c.mu.Unlock() - + defer func() { c.mu.Lock() delete(c.pending, id) c.mu.Unlock() }() - + // MCP usually uses newline-delimited JSON or HTTP-like headers depending on transport. // StdIO transport usually uses JSON-RPC directly with \n msg := string(data) + "\n" if _, err := c.stdin.Write([]byte(msg)); err != nil { return nil, err } - + select { case res := <-ch: if res.Error != nil { @@ -165,12 +165,12 @@ func (c *Client) Notify(method string, params interface{}) error { "method": method, "params": params, } - + data, err := json.Marshal(req) if err != nil { return err } - + msg := string(data) + "\n" _, err = c.stdin.Write([]byte(msg)) return err @@ -183,7 +183,7 @@ func (c *Client) readLoop() { if err != nil { return } - + // Some MCP servers might use Content-Length headers, check for that if strings.HasPrefix(string(line), "Content-Length:") { parts := strings.Split(string(line), ":") @@ -191,7 +191,7 @@ func (c *Client) readLoop() { contentLength, _ := strconv.Atoi(strings.TrimSpace(parts[1])) // read the extra \r\n _, _ = reader.ReadBytes('\n') - + body := make([]byte, contentLength) if _, err := io.ReadFull(reader, body); err != nil { return @@ -199,7 +199,7 @@ func (c *Client) readLoop() { line = body } } - + var res JSONRPCResponse if err := json.Unmarshal(line, &res); err == nil { if res.ID != 0 { diff --git a/internal/mcp/installer.go b/internal/mcp/installer.go index 65c51ee..ac92b74 100644 --- a/internal/mcp/installer.go +++ b/internal/mcp/installer.go @@ -94,21 +94,21 @@ func Install(entry *CatalogEntry, envValues map[string]string) error { if cfg.MCPServers == nil { cfg.MCPServers = make(map[string]config.MCPServerConfig) } - + // Create the configuration for this server srvConfig := config.MCPServerConfig{ Command: entry.Command, Args: entry.Args, Env: envValues, } - + // Append the dynamic DB path to the args for some servers like SQLite or Postgres // Some MCP servers take the DB path as an argument rather than an env var if entry.ID == "sqlite" && envValues["SQLITE_DB_PATH"] != "" { srvConfig.Args = append(srvConfig.Args, envValues["SQLITE_DB_PATH"]) delete(srvConfig.Env, "SQLITE_DB_PATH") // Remove from env if passed as arg } - + if entry.ID == "postgres" && envValues["POSTGRES_CONNECTION_STRING"] != "" { srvConfig.Args = append(srvConfig.Args, envValues["POSTGRES_CONNECTION_STRING"]) delete(srvConfig.Env, "POSTGRES_CONNECTION_STRING") diff --git a/internal/mcp/manager.go b/internal/mcp/manager.go index 1cec75a..afadc55 100644 --- a/internal/mcp/manager.go +++ b/internal/mcp/manager.go @@ -62,7 +62,7 @@ func (m *Manager) StartAll(ctx context.Context, cfg *config.Config) error { for _, t := range toolList.Tools { // Prefix the tool name to avoid collisions mcpToolName := fmt.Sprintf("mcp_%s_%s", name, t.Name) - + // Extract parameters var params []ai.ToolParameter if props, ok := t.InputSchema["properties"].(map[string]interface{}); ok { @@ -70,7 +70,7 @@ func (m *Manager) StartAll(ctx context.Context, cfg *config.Config) error { propMap := propVal.(map[string]interface{}) desc, _ := propMap["description"].(string) typ, _ := propMap["type"].(string) - + required := false if reqArr, ok := t.InputSchema["required"].([]interface{}); ok { for _, req := range reqArr { @@ -80,7 +80,7 @@ func (m *Manager) StartAll(ctx context.Context, cfg *config.Config) error { } } } - + params = append(params, ai.ToolParameter{ Name: propName, Type: typ, @@ -123,11 +123,11 @@ func (m *Manager) ExecuteTool(ctx context.Context, toolName string, args map[str if len(toolName) <= parts { return nil, fmt.Errorf("invalid MCP tool name: %s", toolName) } - + rest := toolName[parts:] serverName := "" originalToolName := "" - + // Find the server name by checking prefixes for name := range m.servers { if len(rest) > len(name) && rest[:len(name)] == name && rest[len(name)] == '_' { @@ -136,7 +136,7 @@ func (m *Manager) ExecuteTool(ctx context.Context, toolName string, args map[str break } } - + if serverName == "" { return nil, fmt.Errorf("could not determine MCP server for tool: %s", toolName) } @@ -154,7 +154,7 @@ func (m *Manager) ExecuteTool(ctx context.Context, toolName string, args map[str if err != nil { return nil, err } - + var callResult struct { Content []struct { Type string `json:"type"` @@ -162,22 +162,22 @@ func (m *Manager) ExecuteTool(ctx context.Context, toolName string, args map[str } `json:"content"` IsError bool `json:"isError"` } - + if err := json.Unmarshal(res.Result, &callResult); err != nil { return nil, fmt.Errorf("failed to parse MCP tool result: %w", err) } - + if callResult.IsError { if len(callResult.Content) > 0 { return nil, fmt.Errorf("MCP tool error: %s", callResult.Content[0].Text) } return nil, fmt.Errorf("MCP tool execution failed") } - + if len(callResult.Content) > 0 { return callResult.Content[0].Text, nil } - + return "Success", nil } diff --git a/internal/providers/gemini/translate.go b/internal/providers/gemini/translate.go index 8dc8197..3a1a78f 100644 --- a/internal/providers/gemini/translate.go +++ b/internal/providers/gemini/translate.go @@ -54,7 +54,7 @@ func translateTools(tools []ai.ToolDefinition) []Tool { if len(required) == 0 { required = nil } - + var paramsSchema *Schema if len(properties) > 0 { paramsSchema = &Schema{ diff --git a/internal/store/queries.go b/internal/store/queries.go index c8b4c78..ae1998c 100644 --- a/internal/store/queries.go +++ b/internal/store/queries.go @@ -123,7 +123,7 @@ func (s *Store) GetLastBatchForUndo(sessionID string) ([]FileChange, error) { if err != nil { return nil, err } - + var changes []FileChange err = s.db.Select(&changes, "SELECT * FROM file_changes WHERE session_id = ? AND batch_id = ? ORDER BY id DESC", sessionID, batchID) return changes, err @@ -136,7 +136,7 @@ func (s *Store) GetNextBatchForRedo(sessionID string) ([]FileChange, error) { if err != nil { return nil, err } - + var changes []FileChange err = s.db.Select(&changes, "SELECT * FROM file_changes WHERE session_id = ? AND batch_id = ? ORDER BY id ASC", sessionID, batchID) return changes, err diff --git a/internal/tools/agent/subagent.go b/internal/tools/agent/subagent.go index e52c699..c71119d 100644 --- a/internal/tools/agent/subagent.go +++ b/internal/tools/agent/subagent.go @@ -91,11 +91,11 @@ func (t *subAgentTool) Run(ctx context.Context, call tools.Call) tools.Result { AI: config.AIConfig{Provider: providerName}, Providers: map[string]config.ProviderConfig{ providerName: { - Model: modelName, + Model: modelName, }, }, } - + // Copy API key/base url from original provider if it exists if origProvider, ok := t.cfg.Providers[providerName]; ok { p := subCfg.Providers[providerName] @@ -110,7 +110,7 @@ func (t *subAgentTool) Run(ctx context.Context, call tools.Call) tools.Result { } systemPrompt := "You are a specialized sub-agent for an AI coding assistant. Your job is to read the provided files, analyze them, and fulfill the requested task concisely and accurately. Do not write full files, just provide the exact analysis requested." - + req := &ai.GenerateRequest{ System: systemPrompt, Messages: []ai.Message{ @@ -129,7 +129,7 @@ func (t *subAgentTool) Run(ctx context.Context, call tools.Call) tools.Result { if mainErr != nil { return tools.Result{Error: fmt.Errorf("sub-agent failed (%w) and could not resolve main fallback: %v", err, mainErr)} } - + fallbackProvider, fallbackErr := ai.New(t.cfg) // Use exactly the main config if fallbackErr != nil { return tools.Result{Error: fmt.Errorf("sub-agent failed (%w) and failed to init fallback: %v", err, fallbackErr)} diff --git a/internal/ui/selector/selector.go b/internal/ui/selector/selector.go index 81b53a0..071c3d5 100644 --- a/internal/ui/selector/selector.go +++ b/internal/ui/selector/selector.go @@ -11,9 +11,9 @@ import ( // Option represents a selectable item in the selector list. type Option struct { - Label string - Desc string - Value string + Label string + Desc string + Value string } func (o Option) Title() string { return o.Label } diff --git a/internal/ui/theme.go b/internal/ui/theme.go index 0fab36a..731ea6f 100644 --- a/internal/ui/theme.go +++ b/internal/ui/theme.go @@ -163,7 +163,7 @@ func ApplyTheme(t Theme) { Amber = lipgloss.Color(t.Colors.Warning) Red = lipgloss.Color(t.Colors.Error) Muted = lipgloss.Color(t.Colors.Muted) - MutedLight = lipgloss.Color(t.Colors.Foreground) + MutedLight = lipgloss.Color(t.Colors.Foreground) Surface = lipgloss.Color(t.Colors.Background) White = lipgloss.Color(t.Colors.Foreground) Border = lipgloss.Color(t.Colors.Border) From 591b742aabee0a106d50e334c649c603153e01e5 Mon Sep 17 00:00:00 2001 From: Nithwin Date: Sun, 26 Jul 2026 19:27:06 +0530 Subject: [PATCH 57/57] fix(chat): reset stream tokens correctly for new requests --- internal/chat/update_stream.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/chat/update_stream.go b/internal/chat/update_stream.go index 1ca566c..e56cbd8 100644 --- a/internal/chat/update_stream.go +++ b/internal/chat/update_stream.go @@ -3,6 +3,7 @@ package chat import ( "context" + "github.com/Nithwin/WindMist/internal/ai" tea "github.com/charmbracelet/bubbletea" ) @@ -11,7 +12,6 @@ func (m Model) handleStreamMsg(msg StreamingMsg) (Model, tea.Cmd) { m.loading = false m.streaming = false m.spinnerFrame = 0 - m.streamTokens = m.streamTokens // preserve for display if len(m.conversation.Messages) > 0 { m.conversation.Messages[len(m.conversation.Messages)-1].Content = @@ -83,7 +83,7 @@ func (m *Model) processQueuedMessage() tea.Cmd { m.refreshViewport() m.loading = true m.streaming = true - m.streamTokens = m.streamTokens // Reset for new request + m.streamTokens = ai.Usage{} // Create an empty assistant message for streaming m.conversation.AddAssistant("")