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 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) 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. diff --git a/cmd/chat.go b/cmd/chat.go index 28134ee..05d4420 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -52,11 +52,11 @@ var chatCmd = &cobra.Command{ var ans string fmt.Scanln(&ans) return ans == "y" || ans == "Y" - }) + }, cfg) 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/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/root.go b/cmd/root.go index b7de833..3991cd5 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,10 +2,15 @@ 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" + _ "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/cmd/set.go b/cmd/set.go index 451fb11..45402d6 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) @@ -116,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 b1f3b09..365a48d 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,15 @@ 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 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/pmezard/go-difflib v1.0.0 github.com/spf13/cobra v1.10.2 gopkg.in/yaml.v3 v3.0.1 ) @@ -26,6 +31,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 @@ -38,12 +44,17 @@ 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 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 d409e05..577bfc8 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= @@ -14,6 +16,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= @@ -41,16 +45,28 @@ 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= 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/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= 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= @@ -60,6 +76,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= @@ -70,15 +89,23 @@ 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= 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= 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= @@ -88,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/agent/agent.go b/internal/agent/agent.go index 94713b9..8417515 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -2,8 +2,15 @@ package agent 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" ) @@ -15,6 +22,14 @@ 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 + // 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. @@ -30,10 +45,11 @@ 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 + mcpManager *mcp.Manager } // New creates a new Agent. @@ -48,16 +64,80 @@ func New( if config.MaxContextTokens <= 0 { config.MaxContextTokens = DefaultMaxContextTokens } + if config.Mode == "" { + config.Mode = string(ModeBuild) + } + if config.Memory == nil { + config.Memory = SlidingWindowMemory{} + } + + a := &Agent{ + provider: provider, + manager: manager, + config: config, + lspManager: lsp.NewManager(), + mcpManager: mcp.NewManager(), + } - return &Agent{ - provider: provider, - manager: manager, - config: config, + // 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). +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. +func (a *Agent) Manager() *tools.Manager { + return a.manager +} + // 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/executor.go b/internal/agent/executor.go index ca38bcd..5366b38 100644 --- a/internal/agent/executor.go +++ b/internal/agent/executor.go @@ -3,65 +3,196 @@ package agent import ( "context" "fmt" + "os" + "path/filepath" + "strings" + "sync" + + "time" "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) []ai.ToolResult { - results := make([]ai.ToolResult, 0, len(calls)) +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 - 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, + 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) + 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 { + 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 + } + + 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, }) - continue - } - // Execute the tool. - res := tool.Run(ctx, tools.Call{ - Name: call.Name, - Args: call.Args, - }) + if onChunk != nil { + onChunk(fmt.Sprintf(" āœ… Done (`%s`).\n\n", call.Name)) + } - content := "" - isError := false + content := "" + isError := false - 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" - } + 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))) - results = append(results, ai.ToolResult{ - ID: call.ID, - Name: call.Name, - Content: content, - IsError: isError, - }) + 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, + BatchID: batchID, + 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), + 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))) + + // 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() + + // 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 { + content = "success" + } + + results[i] = ai.ToolResult{ + ID: call.ID, + Name: call.Name, + Content: content, + IsError: isError, + } + }(i, call) } + wg.Wait() return results } // 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{ @@ -78,5 +209,10 @@ func (a *Agent) toolDefinitions() []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 new file mode 100644 index 0000000..8d57321 --- /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 +} diff --git a/internal/agent/loop.go b/internal/agent/loop.go index f17754c..e77ef2a 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -2,7 +2,10 @@ package agent import ( "context" + "fmt" "os" + "strings" + "time" "github.com/Nithwin/WindMist/internal/agent/prompt" "github.com/Nithwin/WindMist/internal/ai" @@ -12,32 +15,67 @@ 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 + 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 + prunedHistory := a.config.Memory.Prune(messages, a.config.MaxContextTokens) + // 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 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 } @@ -47,6 +85,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{ @@ -56,9 +95,41 @@ 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) + a.saveMessage(messages[len(messages)-1]) } 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/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..cb7d0e6 --- /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) - } -} diff --git a/internal/agent/mode.go b/internal/agent/mode.go new file mode 100644 index 0000000..6e1db6c --- /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: "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, + } + 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/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/app.go b/internal/chat/app.go index bd1e925..44a6e18 100644 --- a/internal/chat/app.go +++ b/internal/chat/app.go @@ -6,13 +6,19 @@ 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(), + tea.WithMouseCellMotion(), ) program = p - _, err := p.Run() + _, err = p.Run() return err } diff --git a/internal/chat/banner.go b/internal/chat/banner.go index 098606e..1a565cc 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.Foreground(ui.BrandCyan) 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.Foreground(ui.MutedLight).Render("šŸŒ€ WindMist v0.5 — AI Coding Assistant")) b.WriteString("\n\n") b.WriteString(ui.LabelStyle.Render("Provider : ")) @@ -33,6 +32,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 = ui.BaseStyle.Foreground(ui.Amber) + } else if m.session.AgentMode == "auto" { + modeColor = ui.BaseStyle.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/chat.go b/internal/chat/chat.go index 92d5d93..e062404 100644 --- a/internal/chat/chat.go +++ b/internal/chat/chat.go @@ -2,30 +2,110 @@ package chat import ( "context" + "encoding/json" "fmt" + "time" + + "github.com/Nithwin/WindMist/internal/ai" + tea "github.com/charmbracelet/bubbletea" ) -// sendMessage starts running the agent request. -func (m Model) sendMessage(prompt string) { - go func() { - res, err := m.agent.Run(context.Background(), prompt, func(s string) { - program.Send(StreamingMsg{ - Text: s, - }) - }) +// 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) + } + }() + } - if err != nil { - program.Send(StreamingMsg{ - Err: err, - Done: true, + startTime := time.Now() + initialMessages := m.getInitialMessages() + res, err := m.agent.Run(ctx, initialMessages, prompt, func(s string) { + program.Send(StreamingMsg{ + Text: s, + }) }) - return + + if err != nil { + return StreamingMsg{ + Err: err, + Done: true, + } + } + + duration := time.Since(startTime) + + 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 { + 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 + } } - // Agent loop completed - program.Send(StreamingMsg{ - Text: "\n\n(Finished in " + fmt.Sprintf("%d turns", res.Turns) + ")", - Done: true, - }) - }() + msgs = append(msgs, msg) + } + + return msgs } diff --git a/internal/chat/commands.go b/internal/chat/commands.go index 81b613a..e4ff61b 100644 --- a/internal/chat/commands.go +++ b/internal/chat/commands.go @@ -1,11 +1,8 @@ package chat import ( - "fmt" "strings" - "github.com/Nithwin/WindMist/internal/config" - "github.com/Nithwin/WindMist/internal/ui/selector" tea "github.com/charmbracelet/bubbletea" ) @@ -26,9 +23,14 @@ 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 +/mode Change agent mode /provider Change provider -/clear Clear conversation +/subagent Configure sub-agent (cheaper background model) +/theme Change UI theme /exit Exit WindMist`, ) return nil @@ -38,18 +40,34 @@ 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{} + } }, }, { - Name: "/clear", - Description: "Clear conversation", + Name: "/sessions", + Description: "Load a previous session", Execute: func(m *Model) tea.Cmd { - m.conversation.Clear() - m.refreshViewport() - return nil + 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{} + } }, }, { @@ -59,6 +77,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", @@ -66,10 +91,41 @@ 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: "/theme", + Description: "Change UI theme", + Execute: func(m *Model) tea.Cmd { + 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)", + Execute: func(m *Model) tea.Cmd { + return selectMCPCmd(m) + }, + }, { Name: "/exit", Description: "Exit WindMist", Execute: func(m *Model) tea.Cmd { + if m.agent != nil { + m.agent.Close() + } return tea.Quit }, }, @@ -77,104 +133,14 @@ var Registry = []Command{ Name: "/quit", Description: "Exit WindMist", Execute: func(m *Model) tea.Cmd { + if m.agent != nil { + m.agent.Close() + } return tea.Quit }, }, } -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:", - config.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 - } - - return switchProviderSuccessMsg{ - 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:", - config.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 - } - - return switchModelSuccessMsg{ - Model: modelValue, - } - } -} - func FilterCommands(input string) []Command { if input == "/" { return Registry @@ -200,3 +166,5 @@ func FindCommand(name string) (Command, bool) { return Command{}, false } + +// 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/conversation.go b/internal/chat/conversation.go index 1e05bb2..e7dd3cc 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 @@ -15,9 +19,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.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,31 +43,35 @@ func renderConversation(m Model) string { case "user": label := ui.UserLabelStyle.Render(" you") b.WriteString(label) - b.WriteString("\n") + b.WriteString(ui.BaseStyle.Render("\n")) content := ui.UserBubbleStyle.Width(maxWidth).Render(msg.Content) b.WriteString(content) - b.WriteString("\n") + b.WriteString(ui.BaseStyle.Render("\n")) case "assistant": label := ui.AssistantLabelStyle.Render("šŸŒ€ WindMist v0.5") b.WriteString(label) - b.WriteString("\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...") + // 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) contentStr = ui.AssistantBubbleStyle.Render(rendered) } b.WriteString(contentStr) - b.WriteString("\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("\n") + b.WriteString(ui.BaseStyle.Render("\n")) } } diff --git a/internal/chat/files.go b/internal/chat/files.go new file mode 100644 index 0000000..38fb6ab --- /dev/null +++ b/internal/chat/files.go @@ -0,0 +1,65 @@ +package chat + +import ( + "os" + "os/exec" + "path/filepath" + "strings" +) + +// 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/header.go b/internal/chat/header.go index 5439c1f..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 { @@ -15,32 +26,88 @@ func renderHeader(m Model) string { } // ── left: brand name ────────────────────────────────────────── - logo := lipgloss.NewStyle(). + logo := ui.BaseStyle. Bold(true). - Foreground(ui.Purple). + Foreground(ui.BrandCyan). Render("šŸŒ€ WindMist v0.5") - // ── right: provider badge ──────────────────────────────────── - providerTag := lipgloss.NewStyle(). - Bold(true). - Foreground(ui.Cyan). - Render(m.cfg.AI.Provider) + // ── right: status tags ──────────────────────────────────── + tokens := 0 + cost := 0.0 + mode := "build" - modelTag := lipgloss.NewStyle(). - Foreground(ui.MutedLight). - Render(model) + if m.session != nil { + tokens = m.session.TokenCount + cost = m.session.CostEstimate + mode = m.session.AgentMode + } - right := fmt.Sprintf("%s %s %s", - providerTag, - lipgloss.NewStyle().Foreground(ui.Muted).Render("›"), - modelTag, - ) + if mode == "" { + mode = "build" + } + + duration := fmt.Sprintf("%.1fs", m.responseTime.Seconds()) + if m.responseTime == 0 { + duration = "—" + } + + modelTag := ui.BaseStyle.Foreground(ui.Cyan).Bold(true).Render(model) + + // 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 := "" + 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} + if costStr != "" { + tags = append(tags, costTag) + } + 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 ────────────────────── - const totalWidth = 78 + totalWidth := m.MaxContentWidth() 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 } @@ -48,11 +115,11 @@ func renderHeader(m Model) string { row := lipgloss.JoinHorizontal( lipgloss.Center, logo, - strings.Repeat(" ", gap), + ui.BaseStyle.Render(strings.Repeat(" ", gap)), right, ) - box := lipgloss.NewStyle(). + box := ui.BaseStyle. Border(lipgloss.RoundedBorder()). BorderForeground(ui.PurpleDark). Padding(0, 1). diff --git a/internal/chat/messages.go b/internal/chat/messages.go index 613e89b..b15809a 100644 --- a/internal/chat/messages.go +++ b/internal/chat/messages.go @@ -1,16 +1,25 @@ package chat +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. 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. @@ -22,11 +31,51 @@ 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 +} + +// switchSessionSuccessMsg represents a successful session change. +type switchSessionSuccessMsg struct { + SessionID string +} + +// 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 } +// switchThemeSuccessMsg represents a successful theme change. +type switchThemeSuccessMsg struct { + Theme string +} + +// mcpInstallSuccessMsg represents a successful MCP installation. +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{} @@ -40,3 +89,23 @@ type ApprovalRequestMsg struct { Command string ResponseChan chan bool } + +// WorkspaceFilesMsg contains the list of files found in the workspace +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 e61322a..01b7a22 100644 --- a/internal/chat/model.go +++ b/internal/chat/model.go @@ -1,16 +1,23 @@ package chat import ( + "context" + "fmt" + "path/filepath" + "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" + "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" - "github.com/charmbracelet/lipgloss" ) // Model represents the WindMist application. @@ -19,10 +26,14 @@ type Model struct { provider ai.Provider agent *agent.Agent + store *store.Store + session *store.Session conversation Conversation - input textarea.Model + input textarea.Model + inputHistory []string + historyIndex int showSplash bool @@ -30,8 +41,21 @@ type Model struct { filteredCommands []Command selectedCommand int - loading bool - streaming bool + showFilePicker bool + workspaceFiles []string + filteredFiles []string + selectedFile int + + loading bool + streaming bool + 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 @@ -41,20 +65,40 @@ 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 + + cancel context.CancelFunc } // 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) } + 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 { - panic(err) + return Model{}, fmt.Errorf("failed to initialize AI provider: %w", err) } manager := tools.NewManager() @@ -68,12 +112,35 @@ func New() Model { ResponseChan: ch, }) return <-ch + }, cfg) + 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: "auto", + } + _ = dbStore.CreateSession(sess) + + ag := agent.New(provider, manager, agent.Config{ + Store: dbStore, + SessionID: sess.ID, + Mode: sess.AgentMode, }) - ag := agent.New(provider, manager, agent.Config{}) renderer, err := ui.NewMarkdownRenderer() if err != nil { - panic(err) + return Model{}, fmt.Errorf("failed to initialize markdown renderer: %w", err) } ta := textarea.New() @@ -85,24 +152,20 @@ func New() Model { 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) + vp.MouseWheelEnabled = true + vp.MouseWheelDelta = 3 - return Model{ + model := Model{ cfg: cfg, provider: provider, agent: ag, + store: dbStore, + session: sess, conversation: Conversation{}, input: ta, + inputHistory: make([]string, 0), + historyIndex: 0, showSplash: true, @@ -119,11 +182,22 @@ func New() Model { markdown: renderer, } + + model.UpdateInputStyles() + + model.updateViewportSize() + + 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. @@ -138,3 +212,19 @@ func (m Model) MaxContentWidth() int { } return w } + +// UpdateInputStyles applies the current UI colors to the textarea input. +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 +} diff --git a/internal/chat/palette.go b/internal/chat/palette.go index 5b0263f..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" @@ -40,7 +41,7 @@ func renderCommandPalette(m Model) string { content := strings.Join(rows, "\n") - box := lipgloss.NewStyle(). + box := ui.BaseStyle. Border(lipgloss.RoundedBorder()). BorderForeground(ui.PurpleDark). Padding(0, 1). @@ -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.go b/internal/chat/update.go index 385ead1..db313e7 100644 --- a/internal/chat/update.go +++ b/internal/chat/update.go @@ -1,23 +1,17 @@ package chat import ( - "fmt" - "strings" - - "github.com/Nithwin/WindMist/internal/agent" - "github.com/Nithwin/WindMist/internal/ai" - "github.com/Nithwin/WindMist/internal/config" - "github.com/Nithwin/WindMist/internal/tools" - "github.com/Nithwin/WindMist/internal/tools/defaults" + "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. +// 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 @@ -26,270 +20,88 @@ 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 "up": - m.viewport.ScrollUp(1) - return m, nil - - case "down": - m.viewport.ScrollDown(1) - 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 + return m.handleKeyMsg(msg) - 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": - 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() - } + case StreamingMsg: + return m.handleStreamMsg(msg) + case spinnerTickMsg: + if m.loading { + m.spinnerFrame++ m.refreshViewport() - return m, nil + return m, spinnerTickCmd() } + 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 == "" { + 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 - } - - // 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, "/") { - if command, ok := FindCommand(prompt); ok { - m.input.SetValue("") - return m, command.Execute(&m) - } - - m.conversation.AddAssistant("Unknown command: " + prompt) - m.input.SetValue("") + case tea.MouseButtonWheelDown: + m.viewport.ScrollDown(3) return m, nil } - - // Normal AI message. - 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() - - m.sendMessage(prompt) - - return m, nil } - - case ApprovalRequestMsg: - m.waitingApproval = true - m.approvalCommand = msg.Command - m.approvalChan = msg.ResponseChan - m.refreshViewport() return m, nil - case StreamingMsg: + case WorkspaceFilesMsg: + m.workspaceFiles = msg.Files + return m, nil - if msg.Err != nil { - m.loading = false + // All other custom events (Session, Agent Mode, Undo/Redo, Models) + case ApprovalRequestMsg, switchModeSuccessMsg, createNewSessionMsg, + undoFileChangeMsg, redoFileChangeMsg, switchSessionSuccessMsg, + switchProviderSuccessMsg, switchModelSuccessMsg, switchSubagentSuccessMsg, switchThemeSuccessMsg, mcpInstallSuccessMsg, setAPIKeySuccessMsg, switchCancelMsg, switchErrorMsg: - if len(m.conversation.Messages) > 0 { - m.conversation.Messages[len(m.conversation.Messages)-1].Content = - "Error: " + msg.Err.Error() + var evtCmd tea.Cmd + m, evtCmd = m.handleEventMsg(msg) + return m, evtCmd - m.refreshViewport() - } + case showInlineSelectorMsg: + m.showSelector = true + m.onSelect = msg.OnSelect + m.onCancel = msg.OnCancel - return m, nil + items := make([]list.Item, len(msg.Options)) + for i, opt := range msg.Options { + items[i] = opt } - if len(m.conversation.Messages) > 0 { - last := &m.conversation.Messages[len(m.conversation.Messages)-1] + 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) - if last.Role == "assistant" { - last.Content += msg.Text - m.refreshViewport() - } - } + 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) - if msg.Done { - m.loading = false - } + // Set size + h, v := lipgloss.NewStyle().Margin(1, 2).GetFrameSize() + m.selectorList.SetSize(m.width-h, m.height-v) 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 + case showInlinePromptMsg: + m.inlinePrompt = msg.Prompt + m.isPassword = msg.IsPassword + m.onPromptSubmit = msg.OnSubmit + m.input.Reset() 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{}) + // Update text input for other key events that don't match the main handler + default: + if m.showSelector { + var listCmd tea.Cmd + m.selectorList, listCmd = m.selectorList.Update(msg) + return m, listCmd } - 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 + m.input, cmd = m.input.Update(msg) + return m, cmd } - - 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..dc8051c --- /dev/null +++ b/internal/chat/update_events.go @@ -0,0 +1,292 @@ +package chat + +import ( + "fmt" + "os" + "path/filepath" + "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" + "github.com/Nithwin/WindMist/internal/ui" + 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 + } + + 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 + } + + for _, change := range changes { + if change.ChangeType == "create" { + _ = os.Remove(change.FilePath) + } else { + _ = os.WriteFile(change.FilePath, []byte(change.BeforeContent), 0644) + } + } + + _ = 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 + + case redoFileChangeMsg: + if m.store == nil || m.session == nil { + m.conversation.AddAssistant("āŒ Persistence not enabled.") + m.refreshViewport() + return m, 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 + } + + 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 %d file edit(s)**", len(changes))) + 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.cfg) + 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.cfg) + 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 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 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 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) + + 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.UpdateInputStyles() + 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() + 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..b5d189a --- /dev/null +++ b/internal/chat/update_keys.go @@ -0,0 +1,348 @@ +package chat + +import ( + "context" + "strings" + + "github.com/Nithwin/WindMist/internal/ui/selector" + tea "github.com/charmbracelet/bubbletea" +) + +func (m Model) handleKeyMsg(msg tea.KeyMsg) (Model, tea.Cmd) { + // Scroll conversation when command palette or file picker is closed. + if !m.showCommands && !m.showFilePicker { + 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 + if m.agent != nil { + m.agent.Close() + } + 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.streaming = false + m.spinnerFrame = 0 + m.queuedMessage = "" // Clear any queued message + m.conversation.AddAssistant("\n\n*(Cancelled by user)*") + m.refreshViewport() + return m, nil + } + if m.agent != nil { + m.agent.Close() + } + 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 + } + + // 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 + } + } + + // 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 + } + } + + // 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 + } + + // 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 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) + 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. + // Queue input if a request is already in-flight. + if m.loading { + // 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 + } + + 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("") + + // Create an empty assistant message. + // Streaming chunks will be appended to this. + 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 + + return m, m.sendMessageCmd(ctx, prompt) + } + + 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 +} diff --git a/internal/chat/update_stream.go b/internal/chat/update_stream.go new file mode 100644 index 0000000..e56cbd8 --- /dev/null +++ b/internal/chat/update_stream.go @@ -0,0 +1,96 @@ +package chat + +import ( + "context" + + "github.com/Nithwin/WindMist/internal/ai" + 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 = + "Error: " + msg.Err.Error() + + m.refreshViewport() + } + + // Check for queued message even on error + if m.queuedMessage != "" { + return m, m.processQueuedMessage() + } + + 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() + } + } + + // 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 + m.spinnerFrame = 0 + 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) + } + } + + // 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 = ai.Usage{} + + // 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/chat/view.go b/internal/chat/view.go index 131bc83..e82a688 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 { @@ -18,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") @@ -26,27 +40,45 @@ 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") } } if m.waitingApproval { - approvalBox := lipgloss.NewStyle(). + approvalBox := ui.BaseStyle. 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.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") } else { - // Input row (label and textarea joined horizontally at Top so cursor is next to user ›) - promptLabel := lipgloss.JoinHorizontal( - lipgloss.Center, - ui.PromptStyle.Render(" user"), - lipgloss.NewStyle().Foreground(ui.Muted).Render(" › "), - ) + promptLabelText := " user" + if m.inlinePrompt != "" { + promptLabelText = " " + m.inlinePrompt + } + + // 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, @@ -58,5 +90,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/chat/viewport.go b/internal/chat/viewport.go index f3dfb50..e16c7e5 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,11 @@ 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() + } } diff --git a/internal/config/config.go b/internal/config/config.go index b5aeac9..12f9731 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,17 +6,27 @@ import ( ) const ( - EnvGeminiAPIKey = "GEMINI_API_KEY" - EnvGroqAPIKey = "GROQ_API_KEY" - EnvOpenAIAPIKey = "OPENAI_API_KEY" - EnvAnthropicAPIKey = "ANTHROPIC_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, + "gemini": EnvGeminiAPIKey, + "groq": EnvGroqAPIKey, + "openai": EnvOpenAIAPIKey, + "anthropic": EnvAnthropicAPIKey, + "deepseek": EnvDeepSeekAPIKey, + "mistral": EnvMistralAPIKey, + "kimi": EnvMoonshotAPIKey, + "perplexity": EnvPerplexityAPIKey, + "together": EnvTogetherAPIKey, } // ActiveProvider returns the active provider configuration. @@ -104,10 +114,73 @@ 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. 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) +} + +// 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/default.go b/internal/config/default.go index a898baa..2da94d3 100644 --- a/internal/config/default.go +++ b/internal/config/default.go @@ -24,6 +24,21 @@ func DefaultConfig() *Config { "anthropic": { Model: "claude-3-5-sonnet-latest", }, + "deepseek": { + Model: "deepseek-chat", + }, + "mistral": { + Model: "mistral-large-latest", + }, + "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 cb3ee64..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" }, @@ -27,5 +27,30 @@ { "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" } + ], + "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/config/options.go b/internal/config/options.go index 595f63c..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", - Description: "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", - Description: "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", - Description: "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", - Description: "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", - Description: "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", }, } } @@ -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" { @@ -72,19 +72,30 @@ func GetModelOptions(providerName, ollamaBaseURL string) []selector.Option { if entries, ok := manifest[providerName]; ok { for _, e := range entries { options = append(options, selector.Option{ - Label: e.Label, - Description: e.Description, - Value: e.Value, + Label: e.Label, + Desc: e.Description, + Value: e.Value, }) } } } + // Append custom models + 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, + }) + } + } + // Always append custom model escape hatch options = append(options, selector.Option{ - Label: "Custom model ID...", - Description: "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 @@ -95,9 +106,9 @@ func ensureOllamaReadyAndGetModels(baseURL string) []selector.Option { if _, err := exec.LookPath("ollama"); err != nil { return []selector.Option{ { - Label: "āŒ Ollama CLI not installed", - Description: "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__", }, } } @@ -110,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" { @@ -138,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" { @@ -163,9 +174,9 @@ 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), - Value: "__CUSTOM__", + Label: "āš ļø Ollama offline or empty", + Desc: fmt.Sprintf("Run 'ollama serve' and 'ollama pull ' at %s", baseURL), + Value: "__CUSTOM__", }, } } @@ -224,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, - Description: 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 529bfaf..f337450 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -2,10 +2,26 @@ 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"` + 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. +type SubAgentConfig struct { + Provider string `yaml:"provider,omitempty"` + Model string `yaml:"model,omitempty"` } // AIConfig stores the active AI provider. diff --git a/internal/lsp/client.go b/internal/lsp/client.go new file mode 100644 index 0000000..e9dc79a --- /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..7a0f2a0 --- /dev/null +++ b/internal/lsp/manager.go @@ -0,0 +1,93 @@ +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{}}, + ".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"}}, + }, + } +} + +// 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) + } +} diff --git a/internal/mcp/client.go b/internal/mcp/client.go new file mode 100644 index 0000000..51dd2c6 --- /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/github_auth.go b/internal/mcp/github_auth.go new file mode 100644 index 0000000..7db1edb --- /dev/null +++ b/internal/mcp/github_auth.go @@ -0,0 +1,95 @@ +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(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)) + 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. Notify caller + if onDeviceCode != nil { + onDeviceCode(deviceRes.VerificationURI, deviceRes.UserCode) + } + + // 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 != "" { + return accessRes.AccessToken, nil + } + + if accessRes.Error != "authorization_pending" { + return "", fmt.Errorf("GitHub authorization failed: %s", accessRes.Error) + } + } + + return "", fmt.Errorf("authentication timed out") +} diff --git a/internal/mcp/installer.go b/internal/mcp/installer.go new file mode 100644 index 0000000..ac92b74 --- /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/mcp/manager.go b/internal/mcp/manager.go new file mode 100644 index 0000000..afadc55 --- /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) +} 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/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 c51ac3e..3a1a78f 100644 --- a/internal/providers/gemini/translate.go +++ b/internal/providers/gemini/translate.go @@ -35,24 +35,39 @@ 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) } } - 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, }) } 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 == "" { 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) +} 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 +} 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..e2338bf --- /dev/null +++ b/internal/store/models.go @@ -0,0 +1,42 @@ +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"` + 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 new file mode 100644 index 0000000..ae1998c --- /dev/null +++ b/internal/store/queries.go @@ -0,0 +1,168 @@ +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, 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 { + 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 +} + +// 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 +} + +// 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) + if err != nil { + return err + } + rows, _ := res.RowsAffected() + if rows == 0 { + return fmt.Errorf("session not found") + } + return nil +} diff --git a/internal/tools/agent/subagent.go b/internal/tools/agent/subagent.go new file mode 100644 index 0000000..c71119d --- /dev/null +++ b/internal/tools/agent/subagent.go @@ -0,0 +1,162 @@ +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 { + // 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, + } +} diff --git a/internal/tools/agent/todo.go b/internal/tools/agent/todo.go new file mode 100644 index 0000000..43348db --- /dev/null +++ b/internal/tools/agent/todo.go @@ -0,0 +1,108 @@ +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.", + Category: tools.CategoryAgent, + Permission: tools.PermWrite, + 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..73c267e 100644 --- a/internal/tools/defaults/defaults.go +++ b/internal/tools/defaults/defaults.go @@ -1,14 +1,17 @@ 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" "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. -func RegisterAll(m *tools.Manager, approvalCb system.ApprovalCallback) { +func RegisterAll(m *tools.Manager, approvalCb system.ApprovalCallback, cfg *config.Config) { if m == nil { return } @@ -23,6 +26,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()) @@ -31,7 +36,19 @@ 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()) + m.Register(web.NewFetchTool()) + + // Agent tools + m.Register(agent.NewTodoTool()) + if cfg != nil { + m.Register(agent.NewSubAgentTool(cfg)) + } } 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_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..408a81f 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", @@ -67,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 40aa7a7..5135f2c 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", @@ -69,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_patch.go b/internal/tools/editing/tool_patch.go new file mode 100644 index 0000000..0421aff --- /dev/null +++ b/internal/tools/editing/tool_patch.go @@ -0,0 +1,73 @@ +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.", + Category: tools.CategoryEditing, + Permission: tools.PermWrite, + 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/editing/tool_range.go b/internal/tools/editing/tool_range.go index 58cf763..462b8c5 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", @@ -76,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 09ad111..12aae6b 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", @@ -88,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/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..00bc4ed 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", @@ -46,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} @@ -58,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 af1a103..9ccec63 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", @@ -73,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 71b407c..b2bd62b 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", @@ -35,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/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 new file mode 100644 index 0000000..7aaba41 --- /dev/null +++ b/internal/tools/filesystem/glob.go @@ -0,0 +1,65 @@ +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.", + Category: tools.CategorySearch, + Permission: tools.PermReadOnly, + 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..59a48a1 --- /dev/null +++ b/internal/tools/filesystem/grep.go @@ -0,0 +1,141 @@ +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.", + Category: tools.CategorySearch, + Permission: tools.PermReadOnly, + 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/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..3050179 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", @@ -50,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) @@ -67,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/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 new file mode 100644 index 0000000..b3ef06c --- /dev/null +++ b/internal/tools/system/git.go @@ -0,0 +1,75 @@ +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.", + Category: tools.CategoryGit, + Permission: tools.PermDangerous, + 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") + } +} diff --git a/internal/tools/types.go b/internal/tools/types.go index dd3684c..370bd68 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 } @@ -21,9 +46,21 @@ 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 + 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 } type Tool interface { diff --git a/internal/tools/web/fetch.go b/internal/tools/web/fetch.go new file mode 100644 index 0000000..b8f49e2 --- /dev/null +++ b/internal/tools/web/fetch.go @@ -0,0 +1,88 @@ +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.", + Category: tools.CategoryWeb, + Permission: tools.PermReadOnly, + 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..7895b00 --- /dev/null +++ b/internal/tools/web/search.go @@ -0,0 +1,117 @@ +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.", + Category: tools.CategoryWeb, + Permission: tools.PermReadOnly, + 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, "")) +} 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/selector/selector.go b/internal/ui/selector/selector.go index 18d2dcb..071c3d5 100644 --- a/internal/ui/selector/selector.go +++ b/internal/ui/selector/selector.go @@ -2,30 +2,31 @@ 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" ) // Option represents a selectable item in the selector list. type Option struct { - Label string - Description string - Value string + Label 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,101 +36,99 @@ 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 + } + case "esc": + if !m.list.SettingFilter() { + m.cancelled = true + return m, tea.Quit } - return m, tea.Quit } + + case tea.WindowSizeMsg: + h, v := lipgloss.NewStyle().Margin(1, 2).GetFrameSize() + m.list.SetSize(msg.Width-h, msg.Height-v) } - return m, nil + + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd } 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") + return "\n" + m.list.View() +} + +// Run displays an interactive arrow-key list and returns the selected Option. +func Run(title, description string, options []Option) (Option, error) { + if len(options) == 0 { + return Option{}, fmt.Errorf("no options provided") } - // Options - for i, opt := range m.options { - cursor := " " - if m.cursor == i { - cursor = lipgloss.NewStyle().Foreground(ui.Cyan).Bold(true).Render("āÆ ") - } + items := make([]list.Item, len(options)) + for i, opt := range options { + items[i] = opt + } - labelStyle := lipgloss.NewStyle().Foreground(ui.White) - if m.cursor == i { - labelStyle = lipgloss.NewStyle().Foreground(ui.Cyan).Bold(true) - } + 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) - label := labelStyle.Render(opt.Label) + 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) - var desc string - if opt.Description != "" { - descStyle := lipgloss.NewStyle().Foreground(ui.Muted) - if m.cursor == i { - descStyle = lipgloss.NewStyle().Foreground(ui.MutedLight) - } - desc = " " + descStyle.Render(opt.Description) - } + p := tea.NewProgram(model{list: l}, tea.WithAltScreen()) - b.WriteString(fmt.Sprintf("%s%s%s\n", cursor, label, desc)) + finalModel, err := p.Run() + if err != nil { + return Option{}, fmt.Errorf("error running selector: %w", err) } - // Footer instructions - footerStyle := lipgloss.NewStyle(). - Foreground(ui.Muted). - MarginTop(1) - b.WriteString("\n" + footerStyle.Render("↑/↓ navigate • enter select • esc/q cancel") + "\n") + m, ok := finalModel.(model) + if !ok || m.cancelled || m.selected == nil { + return Option{}, ErrCancelled + } - return b.String() + return *m.selected, nil } -// Run displays an interactive arrow-key list and returns the selected Option. -func Run(title, description string, options []Option) (Option, error) { +// 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") } - p := tea.NewProgram(model{ - title: title, - description: description, - options: options, - }) + 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 { diff --git a/internal/ui/styles.go b/internal/ui/styles.go index 0f2f564..22977a0 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -15,65 +15,98 @@ var ( MutedLight = lipgloss.Color("#9CA3AF") Surface = lipgloss.Color("#1E1B2E") White = lipgloss.Color("#F8FAFC") + Border = lipgloss.Color("#3B3551") + Selection = lipgloss.Color("#3B3551") + + // Brand colors that NEVER change with themes + BrandCyan = lipgloss.Color("#00E5FF") // ── Typography ────────────────────────────────────────────────── - TitleStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(Purple) + BaseStyle lipgloss.Style + 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 - SubtitleStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(Green) + // ── Chat bubbles ──────────────────────────────────────────────── + UserLabelStyle lipgloss.Style + UserBubbleStyle lipgloss.Style + AssistantLabelStyle lipgloss.Style + AssistantBubbleStyle lipgloss.Style - LabelStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(Cyan) + // ── Input area ────────────────────────────────────────────────── + InputBoxStyle lipgloss.Style + InputBoxFocusStyle lipgloss.Style +) - MutedStyle = lipgloss.NewStyle(). - Foreground(Muted) +func init() { + UpdateStyles() +} - MutedLightStyle = lipgloss.NewStyle(). - Foreground(MutedLight) +// UpdateStyles re-evaluates all lipgloss styles based on the current color variables. +func UpdateStyles() { + BaseStyle = lipgloss.NewStyle().Background(Surface) - PromptStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(Amber) + TitleStyle = BaseStyle. + Bold(true). + Foreground(Purple) - SuccessStyle = lipgloss.NewStyle(). - Foreground(Green) + SubtitleStyle = BaseStyle. + Bold(true). + Foreground(Green) - ErrorStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(Red) + LabelStyle = BaseStyle. + Bold(true). + Foreground(Cyan) - DividerStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#3B3551")) + MutedStyle = BaseStyle. + Foreground(Muted) - // ── Chat bubbles ──────────────────────────────────────────────── - UserLabelStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(Amber) + MutedLightStyle = BaseStyle. + Foreground(MutedLight) - UserBubbleStyle = lipgloss.NewStyle(). - Foreground(White). - PaddingLeft(2) + PromptStyle = BaseStyle. + Bold(true). + Foreground(Amber) - AssistantLabelStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(Purple) + SuccessStyle = BaseStyle. + Foreground(Green) - AssistantBubbleStyle = lipgloss.NewStyle(). - Foreground(MutedLight). - PaddingLeft(2) + ErrorStyle = BaseStyle. + Bold(true). + Foreground(Red) - // ── Input area ────────────────────────────────────────────────── - InputBoxStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#3B3551")). - Padding(0, 1) - - InputBoxFocusStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(Purple). - Padding(0, 1) -) + DividerStyle = BaseStyle. + Foreground(Border) + + UserLabelStyle = BaseStyle. + Bold(true). + Foreground(Amber) + + UserBubbleStyle = BaseStyle. + Foreground(White). + PaddingLeft(2) + + AssistantLabelStyle = BaseStyle. + Bold(true). + Foreground(BrandCyan) + + AssistantBubbleStyle = BaseStyle. + Foreground(White). + PaddingLeft(2) + + InputBoxStyle = BaseStyle. + Border(lipgloss.RoundedBorder()). + BorderForeground(Border). + Padding(0, 1) + + InputBoxFocusStyle = BaseStyle. + 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..731ea6f --- /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: "#161122", // Deep Premium Amethyst + Foreground: "#F8FAFC", // Crisp white + Accent: "#D946EF", // Vibrant Fuchsia (Maps to Purple) + Success: "#00FF9D", // Neon Mint + Error: "#FF006A", // Neon Crimson + Warning: "#FFB800", // Neon Gold + Info: "#00F0FF", // Neon Cyan (Maps to Cyan) + Muted: "#6B6282", // Muted purple/gray + Border: "#2D243F", // Deep purple border + Selection: "#2D243F", + }, + }, +} + +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 +} 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}") 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) + 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), + )