From d1382bb55d03936047df0fc81b3ab9da3cafe4b5 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 9 Jan 2026 14:21:42 -0300 Subject: [PATCH] perf: reduce memory usage (#1812) Signed-off-by: Carlos Alexandro Becker --- Taskfile.yaml | 3 +- go.mod | 8 ++- go.sum | 32 +++++++++--- internal/agent/common_test.go | 4 ++ internal/agent/tools/fetch_helpers.go | 5 +- internal/agent/tools/mcp/init.go | 3 +- internal/app/lsp_events.go | 3 +- internal/csync/maps.go | 12 +++-- internal/csync/versionedmap.go | 5 ++ internal/db/connect.go | 27 ++-------- internal/db/connect_modernc.go | 30 +++++++++++ internal/db/connect_ncruces.go | 37 ++++++++++++++ internal/lsp/client.go | 50 ++++++++++++++++++- internal/oauth/copilot/client.go | 3 +- internal/tui/components/chat/header/header.go | 9 +--- internal/tui/components/lsp/lsp.go | 42 ++++++---------- 16 files changed, 193 insertions(+), 80 deletions(-) create mode 100644 internal/db/connect_modernc.go create mode 100644 internal/db/connect_ncruces.go diff --git a/Taskfile.yaml b/Taskfile.yaml index 2f5574f7ab1f07a03f47e8534d477afd293d9248..68c805c599314cadde5c86fc37a0e3d1a6184f4e 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -44,7 +44,8 @@ tasks: run: desc: Run build cmds: - - go run . {{.CLI_ARGS}} + - go build -o crush . + - ./crush {{.CLI_ARGS}} test: desc: Run tests diff --git a/go.mod b/go.mod index a5fe985d15007fcf5176ca4e3c9fadb0095a0905..ffe05622459585903e108903bd30f4f1ccedb917 100644 --- a/go.mod +++ b/go.mod @@ -25,7 +25,7 @@ require ( github.com/charmbracelet/x/ansi v0.11.3 github.com/charmbracelet/x/editor v0.2.0 github.com/charmbracelet/x/etag v0.2.0 - github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3 + github.com/charmbracelet/x/exp/charmtone v0.0.0-20260109001716-2fbdffcb221f github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f github.com/charmbracelet/x/exp/ordered v0.1.0 github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff @@ -63,6 +63,7 @@ require ( golang.org/x/text v0.32.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 + modernc.org/sqlite v1.43.0 mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5 mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5 ) @@ -141,9 +142,11 @@ require ( github.com/muesli/mango-cobra v1.2.0 // indirect github.com/muesli/mango-pflag v0.1.0 // indirect github.com/muesli/roff v0.1.0 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect github.com/ncruces/julianday v1.0.0 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/sourcegraph/jsonrpc2 v0.2.1 // indirect @@ -180,4 +183,7 @@ require ( google.golang.org/protobuf v1.36.10 // indirect gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect + modernc.org/libc v1.66.10 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index 3131993a7dd1473522938031ebb2be23b906e061..71b6a2b0b921c031d8a5925bb09c40847a3530d7 100644 --- a/go.sum +++ b/go.sum @@ -108,8 +108,8 @@ github.com/charmbracelet/x/editor v0.2.0 h1:7XLUKtaRaB8jN7bWU2p2UChiySyaAuIfYiIR github.com/charmbracelet/x/editor v0.2.0/go.mod h1:p3oQ28TSL3YPd+GKJ1fHWcp+7bVGpedHpXmo0D6t1dY= github.com/charmbracelet/x/etag v0.2.0 h1:Euj1VkheoHfTYA9y+TCwkeXF/hN8Fb9l4LqZl79pt04= github.com/charmbracelet/x/etag v0.2.0/go.mod h1:C1B7/bsgvzzxpfu0Rabbd+rTHJa5TmC/qgTseCf6DF0= -github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3 h1:1xwHZg6eMZ9Wv5TE1UGub6ARubyOd1Lo5kPUI/6VL50= -github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20260109001716-2fbdffcb221f h1:OKFNbG2sSmgpQW9EC3gYNG+QrcQ4+wWYjzfmJvWkkDo= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20260109001716-2fbdffcb221f/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= @@ -184,6 +184,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q= github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -501,14 +503,32 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= -modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= +modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= +modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= +modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= +modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= -modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.43.0 h1:8YqiFx3G1VhHTXO2Q00bl1Wz9KhS9Q5okwfp9Y97VnA= +modernc.org/sqlite v1.43.0/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5 h1:mO2lyKtGwu4mGQ+Qqjx0+fd5UU5BXhX/rslFmxd5aco= mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5/go.mod h1:Of9PCedbLDYT8b3EyiYG64rNnx5nOp27OLCVdDrjJyo= mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5 h1:e7Z/Lgw/zMijvQBVrfh/vUDZ+9FpuSLrJDVGBuoJtuo= diff --git a/internal/agent/common_test.go b/internal/agent/common_test.go index bfe987ffb9a3bf73556b502724a115f41fcc6caf..bdf7990cf8a8aff509ed39d1167213b45ff92615 100644 --- a/internal/agent/common_test.go +++ b/internal/agent/common_test.go @@ -182,6 +182,10 @@ func coderAgent(r *vcr.Recorder, env fakeEnv, large, small fantasy.LanguageModel // would be included in prompt and break VCR cassette matching. cfg.Options.SkillsPaths = []string{} + // Clear LSP config to ensure test reproducibility - user's LSP config + // would be included in prompt and break VCR cassette matching. + cfg.LSP = nil + systemPrompt, err := prompt.Build(context.TODO(), large.Provider(), large.Model(), *cfg) if err != nil { return nil, err diff --git a/internal/agent/tools/fetch_helpers.go b/internal/agent/tools/fetch_helpers.go index 34eb3b2fcd4424997338307560661172ed5f6662..dfcf31c882431ab468f8847fb4bedf609ffeb756 100644 --- a/internal/agent/tools/fetch_helpers.go +++ b/internal/agent/tools/fetch_helpers.go @@ -19,6 +19,8 @@ import ( // BrowserUserAgent is a realistic browser User-Agent for better compatibility. const BrowserUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" +var multipleNewlinesRe = regexp.MustCompile(`\n{3,}`) + // FetchURLAndConvert fetches a URL and converts HTML content to markdown. func FetchURLAndConvert(ctx context.Context, client *http.Client, url string) (string, error) { req, err := http.NewRequestWithContext(ctx, "GET", url, nil) @@ -128,8 +130,7 @@ func removeNoisyElements(htmlContent string) string { // cleanupMarkdown removes excessive whitespace and blank lines from markdown. func cleanupMarkdown(content string) string { // Collapse multiple blank lines into at most two. - multipleNewlines := regexp.MustCompile(`\n{3,}`) - content = multipleNewlines.ReplaceAllString(content, "\n\n") + content = multipleNewlinesRe.ReplaceAllString(content, "\n\n") // Remove trailing whitespace from each line. lines := strings.Split(content, "\n") diff --git a/internal/agent/tools/mcp/init.go b/internal/agent/tools/mcp/init.go index be27ce3f8ae5b9b7f425e496a1726bc23eaf3aae..c28da6c1722413d276bbb47b3dbf3e9f66826263 100644 --- a/internal/agent/tools/mcp/init.go +++ b/internal/agent/tools/mcp/init.go @@ -9,7 +9,6 @@ import ( "fmt" "io" "log/slog" - "maps" "net/http" "os" "os/exec" @@ -98,7 +97,7 @@ func SubscribeEvents(ctx context.Context) <-chan pubsub.Event[Event] { // GetStates returns the current state of all MCP clients func GetStates() map[string]ClientInfo { - return maps.Collect(states.Seq2()) + return states.Copy() } // GetState returns the state of a specific MCP client diff --git a/internal/app/lsp_events.go b/internal/app/lsp_events.go index 08e54582b95d8db725bffc7ff8bd43d4a37528b1..5292983d46cf867b9380ad45f7831007da54f0d7 100644 --- a/internal/app/lsp_events.go +++ b/internal/app/lsp_events.go @@ -2,7 +2,6 @@ package app import ( "context" - "maps" "time" "github.com/charmbracelet/crush/internal/csync" @@ -49,7 +48,7 @@ func SubscribeLSPEvents(ctx context.Context) <-chan pubsub.Event[LSPEvent] { // GetLSPStates returns the current state of all LSP clients func GetLSPStates() map[string]LSPClientInfo { - return maps.Collect(lspStates.Seq2()) + return lspStates.Copy() } // GetLSPState returns the state of a specific LSP client diff --git a/internal/csync/maps.go b/internal/csync/maps.go index 1fd2005790014b2ce4bd5a78dbb7931d54cbe66c..97cb580f7a012559aafbc7bbef8386211b72ee90 100644 --- a/internal/csync/maps.go +++ b/internal/csync/maps.go @@ -96,12 +96,16 @@ func (m *Map[K, V]) Take(key K) (V, bool) { return v, ok } +// Copy returns a copy of the inner map. +func (m *Map[K, V]) Copy() map[K]V { + m.mu.RLock() + defer m.mu.RUnlock() + return maps.Clone(m.inner) +} + // Seq2 returns an iter.Seq2 that yields key-value pairs from the map. func (m *Map[K, V]) Seq2() iter.Seq2[K, V] { - dst := make(map[K]V) - m.mu.RLock() - maps.Copy(dst, m.inner) - m.mu.RUnlock() + dst := m.Copy() return func(yield func(K, V) bool) { for k, v := range dst { if !yield(k, v) { diff --git a/internal/csync/versionedmap.go b/internal/csync/versionedmap.go index f0f4e0249c3b0102976840bd82400e18c1703c47..6ed996b2ff8d1380aa7fd22cab57342bf71e4a8f 100644 --- a/internal/csync/versionedmap.go +++ b/internal/csync/versionedmap.go @@ -40,6 +40,11 @@ func (m *VersionedMap[K, V]) Seq2() iter.Seq2[K, V] { return m.m.Seq2() } +// Copy returns a copy of the inner map. +func (m *VersionedMap[K, V]) Copy() map[K]V { + return m.m.Copy() +} + // Len returns the number of items in the map. func (m *VersionedMap[K, V]) Len() int { return m.m.Len() diff --git a/internal/db/connect.go b/internal/db/connect.go index bfe768c7ae9a399afd61a9d0692841fbacbe164c..20f0c3f31b1506e32ed9d53327d839ac7616bbc9 100644 --- a/internal/db/connect.go +++ b/internal/db/connect.go @@ -7,42 +7,21 @@ import ( "log/slog" "path/filepath" - "github.com/ncruces/go-sqlite3" - "github.com/ncruces/go-sqlite3/driver" - _ "github.com/ncruces/go-sqlite3/embed" - "github.com/pressly/goose/v3" ) +// Connect opens a SQLite database connection and runs migrations. func Connect(ctx context.Context, dataDir string) (*sql.DB, error) { if dataDir == "" { return nil, fmt.Errorf("data.dir is not set") } dbPath := filepath.Join(dataDir, "crush.db") - // Set pragmas for better performance - pragmas := []string{ - "PRAGMA foreign_keys = ON;", - "PRAGMA journal_mode = WAL;", - "PRAGMA page_size = 4096;", - "PRAGMA cache_size = -8000;", - "PRAGMA synchronous = NORMAL;", - "PRAGMA secure_delete = ON;", - } - - db, err := driver.Open(dbPath, func(c *sqlite3.Conn) error { - for _, pragma := range pragmas { - if err := c.Exec(pragma); err != nil { - return fmt.Errorf("failed to set pragma `%s`: %w", pragma, err) - } - } - return nil - }) + db, err := openDB(dbPath) if err != nil { - return nil, fmt.Errorf("failed to open database: %w", err) + return nil, err } - // Verify connection if err = db.PingContext(ctx); err != nil { db.Close() return nil, fmt.Errorf("failed to connect to database: %w", err) diff --git a/internal/db/connect_modernc.go b/internal/db/connect_modernc.go new file mode 100644 index 0000000000000000000000000000000000000000..5a44a696d633ac56661fd2d25d841979a850b6e4 --- /dev/null +++ b/internal/db/connect_modernc.go @@ -0,0 +1,30 @@ +//go:build (darwin && (amd64 || arm64)) || (freebsd && (amd64 || arm64)) || (linux && (386 || amd64 || arm || arm64 || loong64 || ppc64le || riscv64 || s390x)) || (windows && (386 || amd64 || arm64)) + +package db + +import ( + "database/sql" + "fmt" + "net/url" + + _ "modernc.org/sqlite" +) + +func openDB(dbPath string) (*sql.DB, error) { + // Set pragmas for better performance via _pragma query params. + // Format: _pragma=name(value) + params := url.Values{} + params.Add("_pragma", "foreign_keys(on)") + params.Add("_pragma", "journal_mode(WAL)") + params.Add("_pragma", "page_size(4096)") + params.Add("_pragma", "cache_size(-8000)") + params.Add("_pragma", "synchronous(NORMAL)") + params.Add("_pragma", "secure_delete(on)") + + dsn := fmt.Sprintf("file:%s?%s", dbPath, params.Encode()) + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + return db, nil +} diff --git a/internal/db/connect_ncruces.go b/internal/db/connect_ncruces.go new file mode 100644 index 0000000000000000000000000000000000000000..45305e73a866717a94f2028b37bdc7681ed07c11 --- /dev/null +++ b/internal/db/connect_ncruces.go @@ -0,0 +1,37 @@ +//go:build !((darwin && (amd64 || arm64)) || (freebsd && (amd64 || arm64)) || (linux && (386 || amd64 || arm || arm64 || loong64 || ppc64le || riscv64 || s390x)) || (windows && (386 || amd64 || arm64))) + +package db + +import ( + "database/sql" + "fmt" + + "github.com/ncruces/go-sqlite3" + "github.com/ncruces/go-sqlite3/driver" + _ "github.com/ncruces/go-sqlite3/embed" +) + +func openDB(dbPath string) (*sql.DB, error) { + // Set pragmas for better performance. + pragmas := []string{ + "PRAGMA foreign_keys = ON;", + "PRAGMA journal_mode = WAL;", + "PRAGMA page_size = 4096;", + "PRAGMA cache_size = -8000;", + "PRAGMA synchronous = NORMAL;", + "PRAGMA secure_delete = ON;", + } + + db, err := driver.Open(dbPath, func(c *sqlite3.Conn) error { + for _, pragma := range pragmas { + if err := c.Exec(pragma); err != nil { + return fmt.Errorf("failed to set pragma %q: %w", pragma, err) + } + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + return db, nil +} diff --git a/internal/lsp/client.go b/internal/lsp/client.go index 7d914d9a52ce75f621715273e8f6b9588aa912b7..7dba52fdf48a2205bc9fca8436390326be2a2d39 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "strings" + "sync" "sync/atomic" "time" @@ -21,6 +22,14 @@ import ( "github.com/charmbracelet/x/powernap/pkg/transport" ) +// DiagnosticCounts holds the count of diagnostics by severity. +type DiagnosticCounts struct { + Error int + Warning int + Information int + Hint int +} + type Client struct { client *powernap.Client name string @@ -37,6 +46,11 @@ type Client struct { // Diagnostic cache diagnostics *csync.VersionedMap[protocol.DocumentURI, []protocol.Diagnostic] + // Cached diagnostic counts to avoid map copy on every UI render. + diagCountsCache DiagnosticCounts + diagCountsVersion uint64 + diagCountsMu sync.Mutex + // Files are currently opened by the LSP openFiles *csync.Map[string, *OpenFileInfo] @@ -350,7 +364,41 @@ func (c *Client) GetFileDiagnostics(uri protocol.DocumentURI) []protocol.Diagnos // GetDiagnostics returns all diagnostics for all files. func (c *Client) GetDiagnostics() map[protocol.DocumentURI][]protocol.Diagnostic { - return maps.Collect(c.diagnostics.Seq2()) + return c.diagnostics.Copy() +} + +// GetDiagnosticCounts returns cached diagnostic counts by severity. +// Uses the VersionedMap version to avoid recomputing on every call. +func (c *Client) GetDiagnosticCounts() DiagnosticCounts { + currentVersion := c.diagnostics.Version() + + c.diagCountsMu.Lock() + defer c.diagCountsMu.Unlock() + + if currentVersion == c.diagCountsVersion { + return c.diagCountsCache + } + + // Recompute counts. + counts := DiagnosticCounts{} + for _, diags := range c.diagnostics.Seq2() { + for _, diag := range diags { + switch diag.Severity { + case protocol.SeverityError: + counts.Error++ + case protocol.SeverityWarning: + counts.Warning++ + case protocol.SeverityInformation: + counts.Information++ + case protocol.SeverityHint: + counts.Hint++ + } + } + } + + c.diagCountsCache = counts + c.diagCountsVersion = currentVersion + return counts } // OpenFileOnDemand opens a file only if it's not already open. diff --git a/internal/oauth/copilot/client.go b/internal/oauth/copilot/client.go index f76f3bf640c4331968b4173cf0d48e0dbc69aed2..fd243f78b477465063c369dc4dc8f1ff38b72a8c 100644 --- a/internal/oauth/copilot/client.go +++ b/internal/oauth/copilot/client.go @@ -12,6 +12,8 @@ import ( "github.com/charmbracelet/crush/internal/log" ) +var assistantRolePattern = regexp.MustCompile(`"role"\s*:\s*"assistant"`) + // NewClient creates a new HTTP client with a custom transport that adds the // X-Initiator header based on message history in the request body. func NewClient(isSubAgent, debug bool) *http.Client { @@ -58,7 +60,6 @@ func (t *initiatorTransport) RoundTrip(req *http.Request) (*http.Response, error // Check for assistant messages using regex to handle whitespace // variations in the JSON while avoiding full unmarshalling overhead. initiator := userInitiator - assistantRolePattern := regexp.MustCompile(`"role"\s*:\s*"assistant"`) if assistantRolePattern.Match(bodyBytes) || t.isSubAgent { slog.Debug("Setting X-Initiator header to agent (found assistant messages in history)") initiator = agentInitiator diff --git a/internal/tui/components/chat/header/header.go b/internal/tui/components/chat/header/header.go index 59389815ac63ac127ac000abf872b000eb8f2347..c8848440b1193fda9a7b5df4b31e03edeaf744c4 100644 --- a/internal/tui/components/chat/header/header.go +++ b/internal/tui/components/chat/header/header.go @@ -15,7 +15,6 @@ import ( "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/x/ansi" - "github.com/charmbracelet/x/powernap/pkg/lsp/protocol" ) type Header interface { @@ -106,13 +105,7 @@ func (h *header) details(availWidth int) string { errorCount := 0 for l := range h.lspClients.Seq() { - for _, diagnostics := range l.GetDiagnostics() { - for _, diagnostic := range diagnostics { - if diagnostic.Severity == protocol.SeverityError { - errorCount++ - } - } - } + errorCount += l.GetDiagnosticCounts().Error } if errorCount > 0 { diff --git a/internal/tui/components/lsp/lsp.go b/internal/tui/components/lsp/lsp.go index 18c3f74b71768b88d068093759245615d2f7a284..f9118143cbfd9a7bf19aa569bc85448746debecd 100644 --- a/internal/tui/components/lsp/lsp.go +++ b/internal/tui/components/lsp/lsp.go @@ -11,7 +11,6 @@ import ( "github.com/charmbracelet/crush/internal/lsp" "github.com/charmbracelet/crush/internal/tui/components/core" "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/x/powernap/pkg/lsp/protocol" ) // RenderOptions contains options for rendering LSP lists. @@ -61,36 +60,23 @@ func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOption // Calculate diagnostic counts if we have LSP clients var extraContent string if lspClients != nil { - lspErrs := map[protocol.DiagnosticSeverity]int{ - protocol.SeverityError: 0, - protocol.SeverityWarning: 0, - protocol.SeverityHint: 0, - protocol.SeverityInformation: 0, - } if client, ok := lspClients.Get(l.Name); ok { - for _, diagnostics := range client.GetDiagnostics() { - for _, diagnostic := range diagnostics { - if severity, ok := lspErrs[diagnostic.Severity]; ok { - lspErrs[diagnostic.Severity] = severity + 1 - } - } + counts := client.GetDiagnosticCounts() + errs := []string{} + if counts.Error > 0 { + errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, counts.Error))) } + if counts.Warning > 0 { + errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, counts.Warning))) + } + if counts.Hint > 0 { + errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, counts.Hint))) + } + if counts.Information > 0 { + errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, counts.Information))) + } + extraContent = strings.Join(errs, " ") } - - errs := []string{} - if lspErrs[protocol.SeverityError] > 0 { - errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError]))) - } - if lspErrs[protocol.SeverityWarning] > 0 { - errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning]))) - } - if lspErrs[protocol.SeverityHint] > 0 { - errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint]))) - } - if lspErrs[protocol.SeverityInformation] > 0 { - errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation]))) - } - extraContent = strings.Join(errs, " ") } lspList = append(lspList,