From a897f65d682f7634539a763f74da8c32c998b0d4 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Tue, 10 Mar 2026 11:36:45 -0600 Subject: [PATCH 1/7] feat: set user-agent to Charm Crush/ (#2357) --- go.mod | 2 +- go.sum | 4 ++-- internal/agent/agent.go | 4 ++++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index a8467a055e4d78bc244061459e499d0473426afe..da96f6f48d48730d4eb9bef0325654cd8e583767 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( charm.land/bubbles/v2 v2.0.0 charm.land/bubbletea/v2 v2.0.1 charm.land/catwalk v0.28.1 - charm.land/fantasy v0.11.1 + charm.land/fantasy v0.11.2-0.20260310172626-1b0027b03f8b charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b charm.land/lipgloss/v2 v2.0.0 charm.land/log/v2 v2.0.0-20251110204020-529bb77f35da diff --git a/go.sum b/go.sum index 8f27c3547b963695ad31ac7e03dbeebb0e9e612a..6cf4bfd1416a3563c7da44802ab690d04771ab41 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,8 @@ charm.land/bubbletea/v2 v2.0.1 h1:B8e9zzK7x9JJ+XvHGF4xnYu9Xa0E0y0MyggY6dbaCfQ= charm.land/bubbletea/v2 v2.0.1/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= charm.land/catwalk v0.28.1 h1:4YJiRRNUb7i8qDEZjFLJPMKvquihGrGsTNTcf5Dfqq0= charm.land/catwalk v0.28.1/go.mod h1:rFC/V96rIHX7VES215c/qzI1EW/Moo1ggs1Q6seTy5s= -charm.land/fantasy v0.11.1 h1:G1dRqkzEQ0RJN1Ls5mte8HOi0wFKxYd5bfnRAmeYvDk= -charm.land/fantasy v0.11.1/go.mod h1:C8wNxWlw+b2z54zsTor9r1tG2GE2C4QotvAlgXh9KF8= +charm.land/fantasy v0.11.2-0.20260310172626-1b0027b03f8b h1:a/Y0xG4Ux2T3s7Kf8ih8VrzVF8P92a1KEj0hYbRA6vE= +charm.land/fantasy v0.11.2-0.20260310172626-1b0027b03f8b/go.mod h1:C8wNxWlw+b2z54zsTor9r1tG2GE2C4QotvAlgXh9KF8= charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b h1:A6IUUyChZDWP16RUdRJCfmYISAKWQGyIcfhZJUCViQ0= charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b/go.mod h1:J3kVhY6oHXZq5f+8vC3hmDO95fEvbqj3z7xDwxrfzU8= charm.land/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo= diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 53305884e9e6f0f10cb0613c2a8e892901d31e5d..c3c2750317fd4b64648cf68f1b0a83c3801af5e3 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -40,6 +40,7 @@ import ( "github.com/charmbracelet/crush/internal/permission" "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/stringext" + "github.com/charmbracelet/crush/internal/version" "github.com/charmbracelet/x/exp/charmtone" ) @@ -194,6 +195,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy largeModel.Model, fantasy.WithSystemPrompt(systemPrompt), fantasy.WithTools(agentTools...), + fantasy.WithUserAgent("Charm Crush/"+version.Version), ) sessionLock := sync.Mutex{} @@ -592,6 +594,7 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan agent := fantasy.NewAgent(largeModel.Model, fantasy.WithSystemPrompt(string(summaryPrompt)), + fantasy.WithUserAgent("Charm Crush/"+version.Version), ) summaryMessage, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{ Role: message.Assistant, @@ -787,6 +790,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user return fantasy.NewAgent(m, fantasy.WithSystemPrompt(string(p)+"\n /no_think"), fantasy.WithMaxOutputTokens(tok), + fantasy.WithUserAgent("Charm Crush/"+version.Version), ) } From f69f366967af09ae07502b2834e99fb141223a9e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:48:28 -0300 Subject: [PATCH 2/7] chore(deps): bump the all group across 1 directory with 11 updates (#2394) Bumps the all group with 10 updates in the / directory: | Package | From | To | | --- | --- | --- | | [charm.land/bubbletea/v2](https://github.com/charmbracelet/bubbletea) | `2.0.1` | `2.0.2` | | [charm.land/catwalk](https://github.com/charmbracelet/catwalk) | `0.28.1` | `0.28.4` | | [charm.land/glamour/v2](https://github.com/charmbracelet/glamour) | `2.0.0-20260123212943-6014aa153a9b` | `2.0.0` | | [charm.land/lipgloss/v2](https://github.com/charmbracelet/lipgloss) | `2.0.0` | `2.0.1` | | [charm.land/log/v2](https://github.com/charmbracelet/log) | `2.0.0-20251110204020-529bb77f35da` | `2.0.0` | | [github.com/charmbracelet/colorprofile](https://github.com/charmbracelet/colorprofile) | `0.4.2` | `0.4.3` | | [github.com/charmbracelet/fang](https://github.com/charmbracelet/fang) | `0.4.4` | `1.0.0` | | [github.com/ncruces/go-sqlite3](https://github.com/ncruces/go-sqlite3) | `0.30.5` | `0.31.1` | | [golang.org/x/sync](https://github.com/golang/sync) | `0.19.0` | `0.20.0` | | [mvdan.cc/sh/v3](https://github.com/mvdan/sh) | `3.12.1-0.20250902163504-3cf4fd5717a5` | `3.13.0` | Updates `charm.land/bubbletea/v2` from 2.0.1 to 2.0.2 - [Release notes](https://github.com/charmbracelet/bubbletea/releases) - [Commits](https://github.com/charmbracelet/bubbletea/compare/v2.0.1...v2.0.2) Updates `charm.land/catwalk` from 0.28.1 to 0.28.4 - [Release notes](https://github.com/charmbracelet/catwalk/releases) - [Commits](https://github.com/charmbracelet/catwalk/compare/v0.28.1...v0.28.4) Updates `charm.land/glamour/v2` from 2.0.0-20260123212943-6014aa153a9b to 2.0.0 - [Release notes](https://github.com/charmbracelet/glamour/releases) - [Commits](https://github.com/charmbracelet/glamour/commits/v2.0.0) Updates `charm.land/lipgloss/v2` from 2.0.0 to 2.0.1 - [Release notes](https://github.com/charmbracelet/lipgloss/releases) - [Commits](https://github.com/charmbracelet/lipgloss/compare/v2.0.0...v2.0.1) Updates `charm.land/log/v2` from 2.0.0-20251110204020-529bb77f35da to 2.0.0 - [Release notes](https://github.com/charmbracelet/log/releases) - [Commits](https://github.com/charmbracelet/log/commits/v2.0.0) Updates `github.com/aymanbagabas/go-udiff` from 0.4.0 to 0.4.1 - [Release notes](https://github.com/aymanbagabas/go-udiff/releases) - [Commits](https://github.com/aymanbagabas/go-udiff/compare/v0.4.0...v0.4.1) Updates `github.com/charmbracelet/colorprofile` from 0.4.2 to 0.4.3 - [Release notes](https://github.com/charmbracelet/colorprofile/releases) - [Commits](https://github.com/charmbracelet/colorprofile/compare/v0.4.2...v0.4.3) Updates `github.com/charmbracelet/fang` from 0.4.4 to 1.0.0 - [Release notes](https://github.com/charmbracelet/fang/releases) - [Commits](https://github.com/charmbracelet/fang/compare/v0.4.4...v1.0.0) Updates `github.com/ncruces/go-sqlite3` from 0.30.5 to 0.31.1 - [Release notes](https://github.com/ncruces/go-sqlite3/releases) - [Commits](https://github.com/ncruces/go-sqlite3/compare/v0.30.5...v0.31.1) Updates `golang.org/x/sync` from 0.19.0 to 0.20.0 - [Commits](https://github.com/golang/sync/compare/v0.19.0...v0.20.0) Updates `mvdan.cc/sh/v3` from 3.12.1-0.20250902163504-3cf4fd5717a5 to 3.13.0 - [Release notes](https://github.com/mvdan/sh/releases) - [Changelog](https://github.com/mvdan/sh/blob/master/CHANGELOG.md) - [Commits](https://github.com/mvdan/sh/commits/v3.13.0) --- updated-dependencies: - dependency-name: charm.land/bubbletea/v2 dependency-version: 2.0.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all - dependency-name: charm.land/catwalk dependency-version: 0.28.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all - dependency-name: charm.land/glamour/v2 dependency-version: 2.0.0 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all - dependency-name: charm.land/lipgloss/v2 dependency-version: 2.0.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all - dependency-name: charm.land/log/v2 dependency-version: 2.0.0 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all - dependency-name: github.com/aymanbagabas/go-udiff dependency-version: 0.4.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all - dependency-name: github.com/charmbracelet/colorprofile dependency-version: 0.4.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all - dependency-name: github.com/charmbracelet/fang dependency-version: 1.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: all - dependency-name: github.com/ncruces/go-sqlite3 dependency-version: 0.31.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all - dependency-name: golang.org/x/sync dependency-version: 0.20.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all - dependency-name: mvdan.cc/sh/v3 dependency-version: 3.13.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 24 ++++++++++++------------ go.sum | 48 ++++++++++++++++++++++++------------------------ 2 files changed, 36 insertions(+), 36 deletions(-) diff --git a/go.mod b/go.mod index da96f6f48d48730d4eb9bef0325654cd8e583767..587660fafa888c3b7605407059c8fff9c5ea2b32 100644 --- a/go.mod +++ b/go.mod @@ -4,12 +4,12 @@ go 1.26.0 require ( charm.land/bubbles/v2 v2.0.0 - charm.land/bubbletea/v2 v2.0.1 - charm.land/catwalk v0.28.1 + charm.land/bubbletea/v2 v2.0.2 + charm.land/catwalk v0.28.4 charm.land/fantasy v0.11.2-0.20260310172626-1b0027b03f8b - charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b - charm.land/lipgloss/v2 v2.0.0 - charm.land/log/v2 v2.0.0-20251110204020-529bb77f35da + charm.land/glamour/v2 v2.0.0 + charm.land/lipgloss/v2 v2.0.1 + charm.land/log/v2 v2.0.0 charm.land/x/vcr v0.1.1 github.com/JohannesKaufmann/html-to-markdown v1.6.0 github.com/MakeNowJust/heredoc v1.0.0 @@ -17,11 +17,11 @@ require ( github.com/alecthomas/chroma/v2 v2.23.1 github.com/atotto/clipboard v0.1.4 github.com/aymanbagabas/go-nativeclipboard v0.1.3 - github.com/aymanbagabas/go-udiff v0.4.0 + github.com/aymanbagabas/go-udiff v0.4.1 github.com/bmatcuk/doublestar/v4 v4.10.0 github.com/charlievieth/fastwalk v1.0.14 - github.com/charmbracelet/colorprofile v0.4.2 - github.com/charmbracelet/fang v0.4.4 + github.com/charmbracelet/colorprofile v0.4.3 + github.com/charmbracelet/fang v1.0.0 github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 github.com/charmbracelet/x/ansi v0.11.6 github.com/charmbracelet/x/editor v0.2.0 @@ -46,7 +46,7 @@ require ( github.com/lucasb-eyer/go-colorful v1.3.0 github.com/mattn/go-isatty v0.0.20 github.com/modelcontextprotocol/go-sdk v1.4.0 - github.com/ncruces/go-sqlite3 v0.30.5 + github.com/ncruces/go-sqlite3 v0.31.1 github.com/nxadm/tail v1.4.11 github.com/openai/openai-go/v2 v2.7.1 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c @@ -63,13 +63,13 @@ require ( github.com/zeebo/xxh3 v1.1.0 go.uber.org/goleak v1.3.0 golang.org/x/net v0.51.0 - golang.org/x/sync v0.19.0 + golang.org/x/sync v0.20.0 golang.org/x/text v0.34.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.46.1 mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5 - mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5 + mvdan.cc/sh/v3 v3.13.0 ) require ( @@ -176,7 +176,7 @@ require ( golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect golang.org/x/image v0.36.0 // indirect golang.org/x/oauth2 v0.35.0 // indirect - golang.org/x/sys v0.41.0 // indirect + golang.org/x/sys v0.42.0 // indirect golang.org/x/term v0.40.0 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/api v0.269.0 // indirect diff --git a/go.sum b/go.sum index 6cf4bfd1416a3563c7da44802ab690d04771ab41..5f4d06d7fdbe2c47b010e1c8745ee1611a87c32c 100644 --- a/go.sum +++ b/go.sum @@ -1,17 +1,17 @@ charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= -charm.land/bubbletea/v2 v2.0.1 h1:B8e9zzK7x9JJ+XvHGF4xnYu9Xa0E0y0MyggY6dbaCfQ= -charm.land/bubbletea/v2 v2.0.1/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= -charm.land/catwalk v0.28.1 h1:4YJiRRNUb7i8qDEZjFLJPMKvquihGrGsTNTcf5Dfqq0= -charm.land/catwalk v0.28.1/go.mod h1:rFC/V96rIHX7VES215c/qzI1EW/Moo1ggs1Q6seTy5s= +charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= +charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= +charm.land/catwalk v0.28.4 h1:YaaXA1k0v7CKvvT+Gh1pDD7XrlUR93kROdaWqkkglRw= +charm.land/catwalk v0.28.4/go.mod h1:+fqw/6YGNtvapvPy9vhwA/fAMxVjD2K8hVIKYov8Vhg= charm.land/fantasy v0.11.2-0.20260310172626-1b0027b03f8b h1:a/Y0xG4Ux2T3s7Kf8ih8VrzVF8P92a1KEj0hYbRA6vE= charm.land/fantasy v0.11.2-0.20260310172626-1b0027b03f8b/go.mod h1:C8wNxWlw+b2z54zsTor9r1tG2GE2C4QotvAlgXh9KF8= -charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b h1:A6IUUyChZDWP16RUdRJCfmYISAKWQGyIcfhZJUCViQ0= -charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b/go.mod h1:J3kVhY6oHXZq5f+8vC3hmDO95fEvbqj3z7xDwxrfzU8= -charm.land/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo= -charm.land/lipgloss/v2 v2.0.0/go.mod h1:w6SnmsBFBmEFBodiEDurGS/sdUY/u1+v72DqUzc6J14= -charm.land/log/v2 v2.0.0-20251110204020-529bb77f35da h1:vZa/Ow0uLclpfaDY0ubjzE+B0eLQqi2zanmpeALanow= -charm.land/log/v2 v2.0.0-20251110204020-529bb77f35da/go.mod h1:Tj12StbPc4GwksDF6XwhC9wdXouinIVxRGKKmmmzdSU= +charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U= +charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w= +charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys= +charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= +charm.land/log/v2 v2.0.0 h1:SY3Cey7ipx86/MBXQHwsguOT6X1exT94mmJRdzTNs+s= +charm.land/log/v2 v2.0.0/go.mod h1:c3cZSRqm20qUVVAR1WmS/7ab8bgha3C6G7DjPcaVZz0= charm.land/x/vcr v0.1.1 h1:PXCFMUG0rPtyk35rhfzYCJEduOzWXCIbrXTFq4OF/9Q= charm.land/x/vcr v0.1.1/go.mod h1:eByq2gqzWvcct/8XE2XO5KznoWEBiXH56+y2gphbltM= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= @@ -80,8 +80,8 @@ github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aymanbagabas/go-nativeclipboard v0.1.3 h1:FmAWHPTwneAixu7uGDn3cL42xPlUCdNp2J8egMn3P1k= github.com/aymanbagabas/go-nativeclipboard v0.1.3/go.mod h1:2o7MyZwwi4pmXXpOpvOS5FwaHyoCIUks0ktjUvB0EoE= -github.com/aymanbagabas/go-udiff v0.4.0 h1:TKnLPh7IbnizJIBKFWa9mKayRUBQ9Kh1BPCk6w2PnYM= -github.com/aymanbagabas/go-udiff v0.4.0/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= 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/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= @@ -96,10 +96,10 @@ github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICg github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY= github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab h1:J7XQLgl9sefgTnTGrmX3xqvp5o6MCiBzEjGv5igAlc4= github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab/go.mod h1:hqlYqR7uPKOKfnNeicUbZp0Ps0GeYFlKYtwh5HGDCx8= -github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= -github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= -github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY= -github.com/charmbracelet/fang v0.4.4/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/fang v1.0.0 h1:jESBY40agJOlLYnnv9jE0mLqDGTxEk0hkOnx7YGyRlQ= +github.com/charmbracelet/fang v1.0.0/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= @@ -276,8 +276,8 @@ github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= -github.com/ncruces/go-sqlite3 v0.30.5 h1:6usmTQ6khriL8oWilkAZSJM/AIpAlVL2zFrlcpDldCE= -github.com/ncruces/go-sqlite3 v0.30.5/go.mod h1:0I0JFflTKzfs3Ogfv8erP7CCoV/Z8uxigVDNOR0AQ5E= +github.com/ncruces/go-sqlite3 v0.31.1 h1:F76NF4NTLNOabLUKuEb2xqjBW+/Ub+MR59/Q7dACRo8= +github.com/ncruces/go-sqlite3 v0.31.1/go.mod h1:L9OWFjYG/+4dq9O6bFCYoWLG0a7LmtgR6v26TvABmwg= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M= @@ -436,8 +436,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -454,8 +454,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -550,5 +550,5 @@ 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= -mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5/go.mod h1:P21wo2gLLe3426sP+CmANLBaixSEbRtPl35w3YlM6dg= +mvdan.cc/sh/v3 v3.13.0 h1:dSfq/MVsY4w0Vsi6Lbs0IcQquMVqLdKLESAOZjuHdLg= +mvdan.cc/sh/v3 v3.13.0/go.mod h1:KV1GByGPc/Ho0X1E6Uz9euhsIQEj4hwyKnodLlFLoDM= From ae720e36fc3e005ea57197c5aed75c978580af51 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 10 Mar 2026 14:55:39 -0300 Subject: [PATCH 3/7] chore(deps): pin fantasy v0.12.0 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 587660fafa888c3b7605407059c8fff9c5ea2b32..bd8e7106e5de48bda9be54e7ee37031f896fa73f 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( charm.land/bubbles/v2 v2.0.0 charm.land/bubbletea/v2 v2.0.2 charm.land/catwalk v0.28.4 - charm.land/fantasy v0.11.2-0.20260310172626-1b0027b03f8b + charm.land/fantasy v0.12.0 charm.land/glamour/v2 v2.0.0 charm.land/lipgloss/v2 v2.0.1 charm.land/log/v2 v2.0.0 diff --git a/go.sum b/go.sum index 5f4d06d7fdbe2c47b010e1c8745ee1611a87c32c..eb4dc991882806ae4c6f1796949622ca0b692125 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,8 @@ charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= charm.land/catwalk v0.28.4 h1:YaaXA1k0v7CKvvT+Gh1pDD7XrlUR93kROdaWqkkglRw= charm.land/catwalk v0.28.4/go.mod h1:+fqw/6YGNtvapvPy9vhwA/fAMxVjD2K8hVIKYov8Vhg= -charm.land/fantasy v0.11.2-0.20260310172626-1b0027b03f8b h1:a/Y0xG4Ux2T3s7Kf8ih8VrzVF8P92a1KEj0hYbRA6vE= -charm.land/fantasy v0.11.2-0.20260310172626-1b0027b03f8b/go.mod h1:C8wNxWlw+b2z54zsTor9r1tG2GE2C4QotvAlgXh9KF8= +charm.land/fantasy v0.12.0 h1:ZNCLDFr9mAeI0WI0sDrOJ9QC7zq0xZCk0U0K/eZSw14= +charm.land/fantasy v0.12.0/go.mod h1:C8wNxWlw+b2z54zsTor9r1tG2GE2C4QotvAlgXh9KF8= charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U= charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w= charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys= From f8da538c509f77dffd1ad1926f8a41ac75953016 Mon Sep 17 00:00:00 2001 From: Amolith Date: Wed, 11 Mar 2026 09:12:11 -0600 Subject: [PATCH 4/7] feat(notification): alert on turn completion and permission request (#1356) * feat(notification): add em' Assisted-by: Claude Sonnet 4.5 via Crush * refactor(permission): check allowlist first * docs(notification): correct example, fix rendering * fix(notification): bump godbus/dbus to v5.2.2 v5.1.0's FreeBSD SendNullByte() was in a CGO file, so it was excluded when building with CGO_ENABLED=0, causing the freebsd/amd64 cross-build to fail. v5.2.2 rewrites it in pure Go. --- README.md | 19 ++++ go.mod | 10 ++ go.sum | 27 ++++++ internal/agent/agent.go | 16 ++++ internal/agent/common_test.go | 10 +- internal/agent/coordinator.go | 27 ++++-- internal/agent/notify/notify.go | 19 ++++ internal/app/app.go | 22 +++-- internal/config/config.go | 1 + internal/permission/permission.go | 12 +-- internal/ui/common/capabilities.go | 4 +- internal/ui/model/ui.go | 88 +++++++++++++++--- internal/ui/notification/crush-icon-solo.png | Bin 0 -> 55448 bytes internal/ui/notification/crush-icon.png | Bin 0 -> 49983 bytes internal/ui/notification/icon_darwin.go | 7 ++ internal/ui/notification/icon_other.go | 13 +++ internal/ui/notification/native.go | 49 ++++++++++ internal/ui/notification/noop.go | 10 ++ internal/ui/notification/notification.go | 15 +++ internal/ui/notification/notification_test.go | 43 +++++++++ schema.json | 5 + 21 files changed, 361 insertions(+), 36 deletions(-) create mode 100644 internal/agent/notify/notify.go create mode 100644 internal/ui/notification/crush-icon-solo.png create mode 100644 internal/ui/notification/crush-icon.png create mode 100644 internal/ui/notification/icon_darwin.go create mode 100644 internal/ui/notification/icon_other.go create mode 100644 internal/ui/notification/native.go create mode 100644 internal/ui/notification/noop.go create mode 100644 internal/ui/notification/notification.go create mode 100644 internal/ui/notification/notification_test.go diff --git a/README.md b/README.md index 49563822772d28762215f90f257e17a9710ac25f..46d5da5413ac3e30aaec094dbfb12d832d8647d5 100644 --- a/README.md +++ b/README.md @@ -424,6 +424,25 @@ git clone https://github.com/anthropics/skills.git _temp mv _temp/skills/* . ; rm -r -force _temp ``` +### Desktop notifications + +Crush sends desktop notifications when a tool call requires permission and when +the agent finishes its turn. They're only sent when the terminal window isn't +focused _and_ your terminal supports reporting the focus state. + +```jsonc +{ + "$schema": "https://charm.land/crush.json", + "options": { + "disable_notifications": false // default + } +} +``` + +To disable desktop notifications, set `disable_notifications` to `true` in your +configuration. On macOS, notifications currently lack icons due to platform +limitations. + ### Initialization When you initialize a project, Crush analyzes your codebase and creates diff --git a/go.mod b/go.mod index bd8e7106e5de48bda9be54e7ee37031f896fa73f..ca0d161e1d8ecfebbf78d82bb9bed7113a86a025 100644 --- a/go.mod +++ b/go.mod @@ -38,6 +38,7 @@ require ( github.com/denisbrodbeck/machineid v1.0.1 github.com/disintegration/imaging v1.6.2 github.com/dustin/go-humanize v1.0.1 + github.com/gen2brain/beeep v0.11.2 github.com/go-git/go-git/v5 v5.17.0 github.com/google/uuid v1.6.0 github.com/invopop/jsonschema v0.13.0 @@ -77,6 +78,7 @@ require ( cloud.google.com/go/auth v0.18.2 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect + git.sr.ht/~jackmordaunt/go-toast v1.1.2 // indirect github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect @@ -106,6 +108,7 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/ebitengine/purego v0.10.0 // indirect + github.com/esiqveland/notify v0.13.3 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect @@ -114,9 +117,11 @@ require ( github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/jsonschema-go v0.4.2 // indirect @@ -127,6 +132,7 @@ require ( github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackmordaunt/icns/v3 v3.0.1 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kaptinlin/go-i18n v0.2.12 // indirect github.com/kaptinlin/jsonpointer v0.4.17 // indirect @@ -147,13 +153,17 @@ require ( github.com/muesli/roff v0.1.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/julianday v1.0.0 // indirect + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect github.com/pierrec/lz4/v4 v4.1.25 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/encoding v0.5.3 // indirect + github.com/sergeymakinen/go-bmp v1.0.0 // indirect + github.com/sergeymakinen/go-ico v1.0.0-beta.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect github.com/tetratelabs/wazero v1.11.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect diff --git a/go.sum b/go.sum index eb4dc991882806ae4c6f1796949622ca0b692125..de51c7ebd8425ca87b1a473fa8758ceed684b04a 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +git.sr.ht/~jackmordaunt/go-toast v1.1.2 h1:/yrfI55LRt1M7H1vkaw+NaH1+L1CDxrqDltwm5euVuE= +git.sr.ht/~jackmordaunt/go-toast v1.1.2/go.mod h1:jA4OqHKTQ4AFBdwrSnwnskUIIS3HYzlJSgdzCKqfavo= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 h1:g0EZJwz7xkXQiZAI5xi9f3WWFYBlX1CPTrR+NDToRkQ= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 h1:tfLQ34V6F7tVSwoTf/4lH5sE0o6eCJuNDTmH09nDpbc= @@ -158,11 +160,15 @@ github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNf github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= +github.com/esiqveland/notify v0.13.3 h1:QCMw6o1n+6rl+oLUfg8P1IIDSFsDEb2WlXvVvIJbI/o= +github.com/esiqveland/notify v0.13.3/go.mod h1:hesw/IRYTO0x99u1JPweAl4+5mwXJibQVUcP0Iu5ORE= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gen2brain/beeep v0.11.2 h1:+KfiKQBbQCuhfJFPANZuJ+oxsSKAYNe88hIpJuyKWDA= +github.com/gen2brain/beeep v0.11.2/go.mod h1:jQVvuwnLuwOcdctHn/uyh8horSBNJ8uGb9Cn2W4tvoc= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0= @@ -178,6 +184,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= @@ -186,6 +194,9 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= @@ -220,6 +231,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/jackmordaunt/icns/v3 v3.0.1 h1:xxot6aNuGrU+lNgxz5I5H0qSeCjNKp8uTXB1j8D4S3o= +github.com/jackmordaunt/icns/v3 v3.0.1/go.mod h1:5sHL59nqTd2ynTnowxB/MDQFhKNqkK8X687uKNygaSQ= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= @@ -282,6 +295,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M= github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= github.com/openai/openai-go/v2 v2.7.1 h1:/tfvTJhfv7hTSL8mWwc5VL4WLLSDL5yn9VqVykdu9r8= @@ -321,6 +336,10 @@ github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/sergeymakinen/go-bmp v1.0.0 h1:SdGTzp9WvCV0A1V0mBeaS7kQAwNLdVJbmHlqNWq0R+M= +github.com/sergeymakinen/go-bmp v1.0.0/go.mod h1:/mxlAQZRLxSvJFNIEGGLBE/m40f3ZnUifpgVDlcUIEY= +github.com/sergeymakinen/go-ico v1.0.0-beta.0 h1:m5qKH7uPKLdrygMWxbamVn+tl2HfiA3K6MFJw4GfZvQ= +github.com/sergeymakinen/go-ico v1.0.0-beta.0/go.mod h1:wQ47mTczswBO5F0NoDt7O0IXgnV4Xy3ojrroMQzyhUk= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= @@ -334,10 +353,17 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT 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/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk= +github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o= github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -518,6 +544,7 @@ gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRN gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 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/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= diff --git a/internal/agent/agent.go b/internal/agent/agent.go index c3c2750317fd4b64648cf68f1b0a83c3801af5e3..7d41339811b6f4ca1d74fc903f5058ec833d5b8d 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -32,12 +32,14 @@ import ( "charm.land/fantasy/providers/vercel" "charm.land/lipgloss/v2" "github.com/charmbracelet/crush/internal/agent/hyper" + "github.com/charmbracelet/crush/internal/agent/notify" "github.com/charmbracelet/crush/internal/agent/tools" "github.com/charmbracelet/crush/internal/agent/tools/mcp" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/permission" + "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/stringext" "github.com/charmbracelet/crush/internal/version" @@ -73,6 +75,7 @@ type SessionAgentCall struct { TopK *int64 FrequencyPenalty *float64 PresencePenalty *float64 + NonInteractive bool } type SessionAgent interface { @@ -109,6 +112,7 @@ type sessionAgent struct { messages message.Service disableAutoSummarize bool isYolo bool + notify pubsub.Publisher[notify.Notification] messageQueue *csync.Map[string, []SessionAgentCall] activeRequests *csync.Map[string, context.CancelFunc] @@ -125,6 +129,7 @@ type SessionAgentOptions struct { Sessions session.Service Messages message.Service Tools []fantasy.AgentTool + Notify pubsub.Publisher[notify.Notification] } func NewSessionAgent( @@ -141,6 +146,7 @@ func NewSessionAgent( disableAutoSummarize: opts.DisableAutoSummarize, tools: csync.NewSliceFrom(opts.Tools), isYolo: opts.IsYolo, + notify: opts.Notify, messageQueue: csync.NewMap[string, []SessionAgentCall](), activeRequests: csync.NewMap[string, context.CancelFunc](), } @@ -532,6 +538,16 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy return nil, err } + // Send notification that agent has finished its turn (skip for + // nested/non-interactive sessions). + if !call.NonInteractive && a.notify != nil { + a.notify.Publish(pubsub.CreatedEvent, notify.Notification{ + SessionID: call.SessionID, + SessionTitle: currentSession.Title, + Type: notify.TypeAgentFinished, + }) + } + if shouldSummarize { a.activeRequests.Del(call.SessionID) if summarizeErr := a.Summarize(genCtx, call.SessionID, call.ProviderOptions); summarizeErr != nil { diff --git a/internal/agent/common_test.go b/internal/agent/common_test.go index 101b987f2417659828fa68ae68405c1a723322b3..89fc6ff3d29d27c60a8091f17ebe0fad057dc44a 100644 --- a/internal/agent/common_test.go +++ b/internal/agent/common_test.go @@ -153,7 +153,15 @@ func testSessionAgent(env fakeEnv, large, small fantasy.LanguageModel, systemPro DefaultMaxTokens: 10000, }, } - agent := NewSessionAgent(SessionAgentOptions{largeModel, smallModel, "", systemPrompt, false, false, true, env.sessions, env.messages, tools}) + agent := NewSessionAgent(SessionAgentOptions{ + LargeModel: largeModel, + SmallModel: smallModel, + SystemPrompt: systemPrompt, + IsYolo: true, + Sessions: env.sessions, + Messages: env.messages, + Tools: tools, + }) return agent } diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index 6fd36661ed6ee3065b86cceb78e9253ddd5b42b7..3968952ae4e10bd59e596d02797a845d943bd378 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -18,6 +18,7 @@ import ( "charm.land/catwalk/pkg/catwalk" "charm.land/fantasy" "github.com/charmbracelet/crush/internal/agent/hyper" + "github.com/charmbracelet/crush/internal/agent/notify" "github.com/charmbracelet/crush/internal/agent/prompt" "github.com/charmbracelet/crush/internal/agent/tools" "github.com/charmbracelet/crush/internal/config" @@ -28,6 +29,7 @@ import ( "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/oauth/copilot" "github.com/charmbracelet/crush/internal/permission" + "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/session" "golang.org/x/sync/errgroup" @@ -79,6 +81,7 @@ type coordinator struct { history history.Service filetracker filetracker.Service lspManager *lsp.Manager + notify pubsub.Publisher[notify.Notification] currentAgent SessionAgent agents map[string]SessionAgent @@ -95,6 +98,7 @@ func NewCoordinator( history history.Service, filetracker filetracker.Service, lspManager *lsp.Manager, + notify pubsub.Publisher[notify.Notification], ) (Coordinator, error) { c := &coordinator{ cfg: cfg, @@ -104,6 +108,7 @@ func NewCoordinator( history: history, filetracker: filetracker, lspManager: lspManager, + notify: notify, agents: make(map[string]SessionAgent), } @@ -380,16 +385,17 @@ func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, age largeProviderCfg, _ := c.cfg.Providers.Get(large.ModelCfg.Provider) result := NewSessionAgent(SessionAgentOptions{ - large, - small, - largeProviderCfg.SystemPromptPrefix, - "", - isSubAgent, - c.cfg.Options.DisableAutoSummarize, - c.permissions.SkipRequests(), - c.sessions, - c.messages, - nil, + LargeModel: large, + SmallModel: small, + SystemPromptPrefix: largeProviderCfg.SystemPromptPrefix, + SystemPrompt: "", + IsSubAgent: isSubAgent, + DisableAutoSummarize: c.cfg.Options.DisableAutoSummarize, + IsYolo: c.permissions.SkipRequests(), + Sessions: c.sessions, + Messages: c.messages, + Tools: nil, + Notify: c.notify, }) c.readyWg.Go(func() error { @@ -994,6 +1000,7 @@ func (c *coordinator) runSubAgent(ctx context.Context, params subAgentParams) (f TopK: model.ModelCfg.TopK, FrequencyPenalty: model.ModelCfg.FrequencyPenalty, PresencePenalty: model.ModelCfg.PresencePenalty, + NonInteractive: true, }) if err != nil { return fantasy.NewTextErrorResponse("error generating response"), nil diff --git a/internal/agent/notify/notify.go b/internal/agent/notify/notify.go new file mode 100644 index 0000000000000000000000000000000000000000..aba0069a1dc945dd42dd8f6a513095fa8d14157e --- /dev/null +++ b/internal/agent/notify/notify.go @@ -0,0 +1,19 @@ +// Package notify defines domain notification types for agent events. +// These types are decoupled from UI concerns so the agent can publish +// events without importing UI packages. +package notify + +// Type identifies the kind of agent notification. +type Type string + +const ( + // TypeAgentFinished indicates the agent has completed its turn. + TypeAgentFinished Type = "agent_finished" +) + +// Notification represents a domain event published by the agent. +type Notification struct { + SessionID string + SessionTitle string + Type Type +} diff --git a/internal/app/app.go b/internal/app/app.go index 4f353f1bf2037593976f84b19508e52b1019a028..7d87bd1231000cb2a1c88c1fa7a0ceae5b4316a9 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -19,6 +19,7 @@ import ( "charm.land/fantasy" "charm.land/lipgloss/v2" "github.com/charmbracelet/crush/internal/agent" + "github.com/charmbracelet/crush/internal/agent/notify" "github.com/charmbracelet/crush/internal/agent/tools/mcp" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/db" @@ -68,8 +69,9 @@ type App struct { tuiWG *sync.WaitGroup // global context and cleanup functions - globalCtx context.Context - cleanupFuncs []func(context.Context) error + globalCtx context.Context + cleanupFuncs []func(context.Context) error + agentNotifications *pubsub.Broker[notify.Notification] } // New initializes a new application instance. @@ -96,9 +98,10 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) { config: cfg, - events: make(chan tea.Msg, 100), - serviceEventsWG: &sync.WaitGroup{}, - tuiWG: &sync.WaitGroup{}, + events: make(chan tea.Msg, 100), + serviceEventsWG: &sync.WaitGroup{}, + tuiWG: &sync.WaitGroup{}, + agentNotifications: pubsub.NewBroker[notify.Notification](), } app.setupEvents() @@ -112,7 +115,7 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) { app.cleanupFuncs = append( app.cleanupFuncs, func(context.Context) error { return conn.Close() }, - mcp.Close, + func(ctx context.Context) error { return mcp.Close(ctx) }, ) // TODO: remove the concept of agent config, most likely. @@ -143,6 +146,11 @@ func (app *App) Config() *config.Config { return app.config } +// AgentNotifications returns the broker for agent notification events. +func (app *App) AgentNotifications() *pubsub.Broker[notify.Notification] { + return app.agentNotifications +} + // RunNonInteractive runs the application in non-interactive mode with the // given prompt, printing to stdout. func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, largeModel, smallModel string, hideSpinner bool) error { @@ -414,6 +422,7 @@ func (app *App) setupEvents() { setupSubscriber(ctx, app.serviceEventsWG, "permissions", app.Permissions.Subscribe, app.events) setupSubscriber(ctx, app.serviceEventsWG, "permissions-notifications", app.Permissions.SubscribeNotifications, app.events) setupSubscriber(ctx, app.serviceEventsWG, "history", app.History.Subscribe, app.events) + setupSubscriber(ctx, app.serviceEventsWG, "agent-notifications", app.agentNotifications.Subscribe, app.events) setupSubscriber(ctx, app.serviceEventsWG, "mcp", mcp.SubscribeEvents, app.events) setupSubscriber(ctx, app.serviceEventsWG, "lsp", SubscribeLSPEvents, app.events) cleanupFunc := func(context.Context) error { @@ -486,6 +495,7 @@ func (app *App) InitCoderAgent(ctx context.Context) error { app.History, app.FileTracker, app.LSPManager, + app.agentNotifications, ) if err != nil { slog.Error("Failed to create coder agent", "err", err) diff --git a/internal/config/config.go b/internal/config/config.go index c4ef08760ca329d5d0b5644985552e6013d9edd2..118afef344f8a022add7a13db406ccce27a1391e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -261,6 +261,7 @@ type Options struct { InitializeAs string `json:"initialize_as,omitempty" jsonschema:"description=Name of the context file to create/update during project initialization,default=AGENTS.md,example=AGENTS.md,example=CRUSH.md,example=CLAUDE.md,example=docs/LLMs.md"` AutoLSP *bool `json:"auto_lsp,omitempty" jsonschema:"description=Automatically setup LSPs based on root markers,default=true"` Progress *bool `json:"progress,omitempty" jsonschema:"description=Show indeterminate progress updates during long operations,default=true"` + DisableNotifications bool `json:"disable_notifications,omitempty" jsonschema:"description=Disable desktop notifications,default=false"` } type MCPs map[string]MCPConfig diff --git a/internal/permission/permission.go b/internal/permission/permission.go index fc47b7dc93869a1b0a39d30ddb0e408ce479429f..a5d238b379137362dba4989a954f0b1fbb84979e 100644 --- a/internal/permission/permission.go +++ b/internal/permission/permission.go @@ -134,6 +134,12 @@ func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRe return true, nil } + // Check if the tool/action combination is in the allowlist + commandKey := opts.ToolName + ":" + opts.Action + if slices.Contains(s.allowedTools, commandKey) || slices.Contains(s.allowedTools, opts.ToolName) { + return true, nil + } + // tell the UI that a permission was requested s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{ ToolCallID: opts.ToolCallID, @@ -141,12 +147,6 @@ func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRe s.requestMu.Lock() defer s.requestMu.Unlock() - // Check if the tool/action combination is in the allowlist - commandKey := opts.ToolName + ":" + opts.Action - if slices.Contains(s.allowedTools, commandKey) || slices.Contains(s.allowedTools, opts.ToolName) { - return true, nil - } - s.autoApproveSessionsMu.RLock() autoApprove := s.autoApproveSessions[opts.SessionID] s.autoApproveSessionsMu.RUnlock() diff --git a/internal/ui/common/capabilities.go b/internal/ui/common/capabilities.go index b9a9de674d4b17e4ac59ff41a93b9f0db2e0028a..36d72f3e27f5d837107712849c3d4d7882c94cac 100644 --- a/internal/ui/common/capabilities.go +++ b/internal/ui/common/capabilities.go @@ -58,7 +58,7 @@ func (c *Capabilities) Update(msg any) { } case tea.TerminalVersionMsg: c.TerminalVersion = m.Name - case uv.ModeReportEvent: + case tea.ModeReportMsg: switch m.Mode { case ansi.ModeFocusEvent: c.ReportFocusEvents = modeSupported(m.Value) @@ -77,7 +77,7 @@ func QueryCmd(env uv.Environ) tea.Cmd { shouldQueryFor := shouldQueryCapabilities(env) if shouldQueryFor { sb.WriteString(ansi.RequestNameVersion) - // sb.WriteString(ansi.RequestModeFocusEvent) // TODO: re-enable when we need notifications. + sb.WriteString(ansi.RequestModeFocusEvent) sb.WriteString(ansi.WindowOp(14)) // Window size in pixels kittyReq := ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24") if _, isTmux := env.LookupEnv("TMUX"); isTmux { diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 3e840faeffe1523eeb0346c07baa4f751733651d..89b3b37608500f1a02eea98d4ebfabeba262bcd1 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -25,6 +25,7 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/catwalk/pkg/catwalk" "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/agent/notify" agenttools "github.com/charmbracelet/crush/internal/agent/tools" "github.com/charmbracelet/crush/internal/agent/tools/mcp" "github.com/charmbracelet/crush/internal/app" @@ -45,6 +46,7 @@ import ( "github.com/charmbracelet/crush/internal/ui/dialog" fimage "github.com/charmbracelet/crush/internal/ui/image" "github.com/charmbracelet/crush/internal/ui/logo" + "github.com/charmbracelet/crush/internal/ui/notification" "github.com/charmbracelet/crush/internal/ui/styles" "github.com/charmbracelet/crush/internal/ui/util" "github.com/charmbracelet/crush/internal/version" @@ -201,6 +203,9 @@ type UI struct { // sidebarLogo keeps a cached version of the sidebar sidebarLogo. sidebarLogo string + // Notification state + notifyBackend notification.Backend + notifyWindowFocused bool // custom commands & mcp commands customCommands []commands.CustomCommand mcpPrompts []commands.MCPPrompt @@ -280,17 +285,19 @@ func New(com *common.Common) *UI { header := newHeader(com) ui := &UI{ - com: com, - dialog: dialog.NewOverlay(), - keyMap: keyMap, - textarea: ta, - chat: ch, - header: header, - completions: comp, - attachments: attachments, - todoSpinner: todoSpinner, - lspStates: make(map[string]app.LSPClientInfo), - mcpStates: make(map[string]mcp.ClientInfo), + com: com, + dialog: dialog.NewOverlay(), + keyMap: keyMap, + textarea: ta, + chat: ch, + header: header, + completions: comp, + attachments: attachments, + todoSpinner: todoSpinner, + lspStates: make(map[string]app.LSPClientInfo), + mcpStates: make(map[string]mcp.ClientInfo), + notifyBackend: notification.NoopBackend{}, + notifyWindowFocused: true, } status := NewStatus(com, ui) @@ -342,6 +349,32 @@ func (m *UI) Init() tea.Cmd { return tea.Batch(cmds...) } +// sendNotification returns a command that sends a notification if allowed by policy. +func (m *UI) sendNotification(n notification.Notification) tea.Cmd { + if !m.shouldSendNotification() { + return nil + } + + backend := m.notifyBackend + return func() tea.Msg { + if err := backend.Send(n); err != nil { + slog.Error("Failed to send notification", "error", err) + } + return nil + } +} + +// shouldSendNotification returns true if notifications should be sent based on +// current state. Focus reporting must be supported, window must not focused, +// and notifications must not be disabled in config. +func (m *UI) shouldSendNotification() bool { + cfg := m.com.Config() + if cfg != nil && cfg.Options != nil && cfg.Options.DisableNotifications { + return false + } + return m.caps.ReportFocusEvents && !m.notifyWindowFocused +} + // setState changes the UI state and focus. func (m *UI) setState(state uiState, focus uiFocusState) { if state == uiLanding { @@ -397,6 +430,18 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.sendProgressBar = slices.Contains(msg, "WT_SESSION") } cmds = append(cmds, common.QueryCmd(uv.Environ(msg))) + case tea.ModeReportMsg: + if m.caps.ReportFocusEvents { + m.notifyBackend = notification.NewNativeBackend(notification.Icon) + } + case tea.FocusMsg: + m.notifyWindowFocused = true + case tea.BlurMsg: + m.notifyWindowFocused = false + case pubsub.Event[notify.Notification]: + if cmd := m.handleAgentNotification(msg.Payload); cmd != nil { + cmds = append(cmds, cmd) + } case loadSessionMsg: if m.forceCompactMode { m.isCompact = true @@ -542,6 +587,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil { cmds = append(cmds, cmd) } + if cmd := m.sendNotification(notification.Notification{ + Title: "Crush is waiting...", + Message: fmt.Sprintf("Permission required to execute \"%s\"", msg.Payload.ToolName), + }); cmd != nil { + cmds = append(cmds, cmd) + } case pubsub.Event[permission.PermissionNotification]: m.handlePermissionNotification(msg.Payload) case cancelTimerExpiredMsg: @@ -1964,6 +2015,7 @@ func (m *UI) View() tea.View { v.BackgroundColor = m.com.Styles.Background } v.MouseMode = tea.MouseModeCellMotion + v.ReportFocus = m.caps.ReportFocusEvents v.WindowTitle = "crush " + home.Short(m.com.Config().WorkingDir()) canvas := uv.NewScreenBuffer(m.width, m.height) @@ -2980,6 +3032,20 @@ func (m *UI) handlePermissionNotification(notification permission.PermissionNoti } } +// handleAgentNotification translates domain agent events into desktop +// notifications using the UI notification backend. +func (m *UI) handleAgentNotification(n notify.Notification) tea.Cmd { + switch n.Type { + case notify.TypeAgentFinished: + return m.sendNotification(notification.Notification{ + Title: "Crush is waiting...", + Message: fmt.Sprintf("Agent's turn completed in \"%s\"", n.SessionTitle), + }) + default: + return nil + } +} + // newSession clears the current session state and prepares for a new session. // The actual session creation happens when the user sends their first message. // Returns a command to reload prompt history. diff --git a/internal/ui/notification/crush-icon-solo.png b/internal/ui/notification/crush-icon-solo.png new file mode 100644 index 0000000000000000000000000000000000000000..eed026660d0d5882c9b6e98912ee2afd9748f2a6 GIT binary patch literal 55448 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

-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 literal 0 HcmV?d00001 diff --git a/internal/ui/notification/icon_darwin.go b/internal/ui/notification/icon_darwin.go new file mode 100644 index 0000000000000000000000000000000000000000..27df25009be6bb849afc7b39b631fbbe3c61b6b3 --- /dev/null +++ b/internal/ui/notification/icon_darwin.go @@ -0,0 +1,7 @@ +//go:build darwin + +package notification + +// Icon is currently empty on darwin because platform icon support is broken. Do +// use the icon for OSC notifications, just not native. +var Icon any = "" diff --git a/internal/ui/notification/icon_other.go b/internal/ui/notification/icon_other.go new file mode 100644 index 0000000000000000000000000000000000000000..27240ad93fc653c9e742a879e76914481e5f1d55 --- /dev/null +++ b/internal/ui/notification/icon_other.go @@ -0,0 +1,13 @@ +//go:build !darwin + +package notification + +import ( + _ "embed" +) + +//go:embed crush-icon-solo.png +var icon []byte + +// Icon contains the embedded PNG icon data for desktop notifications. +var Icon any = icon diff --git a/internal/ui/notification/native.go b/internal/ui/notification/native.go new file mode 100644 index 0000000000000000000000000000000000000000..4fffa6d2de6798f8c343c3789689844a911b6eb0 --- /dev/null +++ b/internal/ui/notification/native.go @@ -0,0 +1,49 @@ +package notification + +import ( + "log/slog" + + "github.com/gen2brain/beeep" +) + +// NativeBackend sends desktop notifications using the native OS notification +// system via beeep. +type NativeBackend struct { + // icon is the notification icon data (platform-specific). + icon any + // notifyFunc is the function used to send notifications (swappable for testing). + notifyFunc func(title, message string, icon any) error +} + +// NewNativeBackend creates a new native notification backend. +func NewNativeBackend(icon any) *NativeBackend { + beeep.AppName = "Crush" + return &NativeBackend{ + icon: icon, + notifyFunc: beeep.Notify, + } +} + +// Send sends a desktop notification using the native OS notification system. +func (b *NativeBackend) Send(n Notification) error { + slog.Debug("Sending native notification", "title", n.Title, "message", n.Message) + + err := b.notifyFunc(n.Title, n.Message, b.icon) + if err != nil { + slog.Error("Failed to send notification", "error", err) + } else { + slog.Debug("Notification sent successfully") + } + + return err +} + +// SetNotifyFunc allows replacing the notification function for testing. +func (b *NativeBackend) SetNotifyFunc(fn func(title, message string, icon any) error) { + b.notifyFunc = fn +} + +// ResetNotifyFunc resets the notification function to the default. +func (b *NativeBackend) ResetNotifyFunc() { + b.notifyFunc = beeep.Notify +} diff --git a/internal/ui/notification/noop.go b/internal/ui/notification/noop.go new file mode 100644 index 0000000000000000000000000000000000000000..7e943e38af15ad4e2dcd47c95158bb4abcb6bb56 --- /dev/null +++ b/internal/ui/notification/noop.go @@ -0,0 +1,10 @@ +package notification + +// NoopBackend is a no-op notification backend that does nothing. +// This is the default backend used when notifications are not supported. +type NoopBackend struct{} + +// Send does nothing and returns nil. +func (NoopBackend) Send(_ Notification) error { + return nil +} diff --git a/internal/ui/notification/notification.go b/internal/ui/notification/notification.go new file mode 100644 index 0000000000000000000000000000000000000000..f6be12bfe8b84c2cf18b4c5f1ae3720e820e6cd5 --- /dev/null +++ b/internal/ui/notification/notification.go @@ -0,0 +1,15 @@ +// Package notification provides desktop notification support for the UI. +package notification + +// Notification represents a desktop notification request. +type Notification struct { + Title string + Message string +} + +// Backend defines the interface for sending desktop notifications. +// Implementations are pure transport - policy decisions (config, focus state) +// are handled by the caller. +type Backend interface { + Send(n Notification) error +} diff --git a/internal/ui/notification/notification_test.go b/internal/ui/notification/notification_test.go new file mode 100644 index 0000000000000000000000000000000000000000..715be608c75328e3bc2b9e820c301a62a17f08a5 --- /dev/null +++ b/internal/ui/notification/notification_test.go @@ -0,0 +1,43 @@ +package notification_test + +import ( + "testing" + + "github.com/charmbracelet/crush/internal/ui/notification" + "github.com/stretchr/testify/require" +) + +func TestNoopBackend_Send(t *testing.T) { + t.Parallel() + + backend := notification.NoopBackend{} + err := backend.Send(notification.Notification{ + Title: "Test Title", + Message: "Test Message", + }) + require.NoError(t, err) +} + +func TestNativeBackend_Send(t *testing.T) { + t.Parallel() + + backend := notification.NewNativeBackend(nil) + + var capturedTitle, capturedMessage string + var capturedIcon any + backend.SetNotifyFunc(func(title, message string, icon any) error { + capturedTitle = title + capturedMessage = message + capturedIcon = icon + return nil + }) + + err := backend.Send(notification.Notification{ + Title: "Hello", + Message: "World", + }) + require.NoError(t, err) + require.Equal(t, "Hello", capturedTitle) + require.Equal(t, "World", capturedMessage) + require.Nil(t, capturedIcon) +} diff --git a/schema.json b/schema.json index 298d8fe814b80fa693759e9de5a1dafb921b389f..3f9754158f3bc91cc1b6570d5e5393a6b594c22d 100644 --- a/schema.json +++ b/schema.json @@ -451,6 +451,11 @@ "type": "boolean", "description": "Show indeterminate progress updates during long operations", "default": true + }, + "disable_notifications": { + "type": "boolean", + "description": "Disable desktop notifications", + "default": false } }, "additionalProperties": false, From 871b367e6fc8ab6bb64d968ab52df23ae37c97eb Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Wed, 11 Mar 2026 14:37:39 -0300 Subject: [PATCH 5/7] chore(deps): update fantasy --- go.mod | 34 +++++++++++++++---------------- go.sum | 64 +++++++++++++++++++++++++++++----------------------------- 2 files changed, 49 insertions(+), 49 deletions(-) diff --git a/go.mod b/go.mod index ca0d161e1d8ecfebbf78d82bb9bed7113a86a025..9f91fda2fc86914d595bf8b5eaabaddcd7b05319 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,12 @@ module github.com/charmbracelet/crush -go 1.26.0 +go 1.26.1 require ( charm.land/bubbles/v2 v2.0.0 charm.land/bubbletea/v2 v2.0.2 charm.land/catwalk v0.28.4 - charm.land/fantasy v0.12.0 + charm.land/fantasy v0.12.1 charm.land/glamour/v2 v2.0.0 charm.land/lipgloss/v2 v2.0.1 charm.land/log/v2 v2.0.0 @@ -82,20 +82,20 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect - github.com/aws/aws-sdk-go-v2 v1.41.2 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.3 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect - github.com/aws/aws-sdk-go-v2/config v1.32.10 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.10 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.11 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.11 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 // indirect github.com/aws/smithy-go v1.24.2 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect @@ -185,12 +185,12 @@ require ( golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect golang.org/x/image v0.36.0 // indirect - golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/term v0.40.0 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/api v0.269.0 // indirect - google.golang.org/genai v1.48.0 // indirect + google.golang.org/genai v1.49.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/grpc v1.79.1 // indirect google.golang.org/protobuf v1.36.11 // indirect diff --git a/go.sum b/go.sum index de51c7ebd8425ca87b1a473fa8758ceed684b04a..aff01decd2858103938d3c73f3584760189ccd91 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,8 @@ charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= charm.land/catwalk v0.28.4 h1:YaaXA1k0v7CKvvT+Gh1pDD7XrlUR93kROdaWqkkglRw= charm.land/catwalk v0.28.4/go.mod h1:+fqw/6YGNtvapvPy9vhwA/fAMxVjD2K8hVIKYov8Vhg= -charm.land/fantasy v0.12.0 h1:ZNCLDFr9mAeI0WI0sDrOJ9QC7zq0xZCk0U0K/eZSw14= -charm.land/fantasy v0.12.0/go.mod h1:C8wNxWlw+b2z54zsTor9r1tG2GE2C4QotvAlgXh9KF8= +charm.land/fantasy v0.12.1 h1:awszoi5O9FIjMEkfyCMiLJfVRNLckp/zQkFrA6IxQqc= +charm.land/fantasy v0.12.1/go.mod h1:QeRVUeG1XNTWBszRAbhUtPyX1VWs6zjkCxwfcwnICdc= charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U= charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w= charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys= @@ -50,34 +50,34 @@ github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kk github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls= -github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4= +github.com/aws/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA= +github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5/go.mod h1:nVUlMLVV8ycXSb7mSkcNu9e3v/1TJq2RTlrPwhYWr5c= -github.com/aws/aws-sdk-go-v2/config v1.32.10 h1:9DMthfO6XWZYLfzZglAgW5Fyou2nRI5CuV44sTedKBI= -github.com/aws/aws-sdk-go-v2/config v1.32.10/go.mod h1:2rUIOnA2JaiqYmSKYmRJlcMWy6qTj1vuRFscppSBMcw= -github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8= -github.com/aws/aws-sdk-go-v2/credentials v1.19.10/go.mod h1:RnnlFCAlxQCkN2Q379B67USkBMu1PipEEiibzYN5UTE= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 h1:Ii4s+Sq3yDfaMLpjrJsqD6SmG/Wq/P5L/hw2qa78UAY= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18/go.mod h1:6x81qnY++ovptLE6nWQeWrpXxbnlIex+4H4eYYGcqfc= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 h1:F43zk1vemYIqPAwhjTjYIz0irU2EY7sOb/F5eJ3HuyM= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18/go.mod h1:w1jdlZXrGKaJcNoL+Nnrj+k5wlpGXqnNrKoP22HvAug= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 h1:xCeWVjj0ki0l3nruoyP2slHsGArMxeiiaoPN5QZH6YQ= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18/go.mod h1:r/eLGuGCBw6l36ZRWiw6PaZwPXb6YOj+i/7MizNl5/k= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 h1:CeY9LUdur+Dxoeldqoun6y4WtJ3RQtzk0JMP2gfUay0= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5/go.mod h1:AZLZf2fMaahW5s/wMRciu1sYbdsikT/UHwbUjOdEVTc= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 h1:LTRCYFlnnKFlKsyIQxKhJuDuA3ZkrDQMRYm6rXiHlLY= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18/go.mod h1:XhwkgGG6bHSd00nO/mexWTcTjgd6PjuvWQMqSn2UaEk= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 h1:MzORe+J94I+hYu2a6XmV5yC9huoTv8NRcCrUNedDypQ= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.6/go.mod h1:hXzcHLARD7GeWnifd8j9RWqtfIgxj4/cAtIVIK7hg8g= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 h1:7oGD8KPfBOJGXiCoRKrrrQkbvCp8N++u36hrLMPey6o= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.11/go.mod h1:0DO9B5EUJQlIDif+XJRWCljZRKsAFKh3gpFz7UnDtOo= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 h1:edCcNp9eGIUDUCrzoCu1jWAXLGFIizeqkdkKgRlJwWc= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15/go.mod h1:lyRQKED9xWfgkYC/wmmYfv7iVIM68Z5OQ88ZdcV1QbU= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 h1:NITQpgo9A5NrDZ57uOWj+abvXSb83BbyggcUBVksN7c= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.7/go.mod h1:sks5UWBhEuWYDPdwlnRFn1w7xWdH29Jcpe+/PJQefEs= +github.com/aws/aws-sdk-go-v2/config v1.32.11 h1:ftxI5sgz8jZkckuUHXfC/wMUc8u3fG1vQS0plr2F2Zs= +github.com/aws/aws-sdk-go-v2/config v1.32.11/go.mod h1:twF11+6ps9aNRKEDimksp923o44w/Thk9+8YIlzWMmo= +github.com/aws/aws-sdk-go-v2/credentials v1.19.11 h1:NdV8cwCcAXrCWyxArt58BrvZJ9pZ9Fhf9w6Uh5W3Uyc= +github.com/aws/aws-sdk-go-v2/credentials v1.19.11/go.mod h1:30yY2zqkMPdrvxBqzI9xQCM+WrlrZKSOpSJEsylVU+8= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 h1:INUvJxmhdEbVulJYHI061k4TVuS3jzzthNvjqvVvTKM= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19/go.mod h1:FpZN2QISLdEBWkayloda+sZjVJL+e9Gl0k1SyTgcswU= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 h1:/sECfyq2JTifMI2JPyZ4bdRN77zJmr6SrS1eL3augIA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19/go.mod h1:dMf8A5oAqr9/oxOfLkC/c2LU/uMcALP0Rgn2BD5LWn0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 h1:AWeJMk33GTBf6J20XJe6qZoRSJo0WfUhsMdUKhoODXE= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19/go.mod h1:+GWrYoaAsV7/4pNHpwh1kiNLXkKaSoppxQq9lbH8Ejw= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 h1:clHU5fm//kWS1C2HgtgWxfQbFbx4b6rx+5jzhgX9HrI= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 h1:XAq62tBTJP/85lFD5oqOOe7YYgWxY9LvWq8plyDvDVg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 h1:X1Tow7suZk9UCJHE1Iw9GMZJJl0dAnKXXP1NaSDHwmw= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19/go.mod h1:/rARO8psX+4sfjUQXp5LLifjUt8DuATZ31WptNJTyQA= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 h1:Y2cAXlClHsXkkOvWZFXATr34b0hxxloeQu/pAZz2row= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.7/go.mod h1:idzZ7gmDeqeNrSPkdbtMp9qWMgcBwykA7P7Rzh5DXVU= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 h1:iSsvB9EtQ09YrsmIc44Heqlx5ByGErqhPK1ZQLppias= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.12/go.mod h1:fEWYKTRGoZNl8tZ77i61/ccwOMJdGxwOhWCkp6TXAr0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 h1:EnUdUqRP1CNzt2DkV67tJx6XDN4xlfBFm+bzeNOQVb0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16/go.mod h1:Jic/xv0Rq/pFNCh3WwpH4BEqdbSAl+IyHro8LbibHD8= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 h1:XQTQTF75vnug2TXS8m7CVJfC2nniYPZnO1D4Np761Oo= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.8/go.mod h1:Xgx+PR1NUOjNmQY+tRMnouRp83JRM8pRMw/vCaVhPkI= github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aymanbagabas/go-nativeclipboard v0.1.3 h1:FmAWHPTwneAixu7uGDn3cL42xPlUCdNp2J8egMn3P1k= @@ -453,8 +453,8 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -521,8 +521,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.269.0 h1:qDrTOxKUQ/P0MveH6a7vZ+DNHxJQjtGm/uvdbdGXCQg= google.golang.org/api v0.269.0/go.mod h1:N8Wpcu23Tlccl0zSHEkcAZQKDLdquxK+l9r2LkwAauE= -google.golang.org/genai v1.48.0 h1:1vb15G291wAjJJueisMDpUhssljhEdJU2t5qTidrVPs= -google.golang.org/genai v1.48.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= +google.golang.org/genai v1.49.0 h1:Se+QJaH2GYK1aaR1o5S38mlU2GD5FnVvP76nfkV7LH0= +google.golang.org/genai v1.49.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= From 562d49f576878502522e3960d1679c97ee48c446 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Wed, 11 Mar 2026 17:59:03 -0300 Subject: [PATCH 6/7] ci: fix govulncheck (#2399) --- .github/workflows/security.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index ba2c56a67e35665b955683fcec659e1da0282ede..75b8023df6416ebfe776ce5daac758d1b7c38bef 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -67,7 +67,7 @@ jobs: persist-credentials: false - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: - go-version: 1.26.0 + go-version: 1.26.1 - name: Install govulncheck run: go install golang.org/x/vuln/cmd/govulncheck@latest - name: Run govulncheck From 5ff8d6876005ba48686a58fa71417c3a3f17bebe Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 11 Mar 2026 20:12:02 -0400 Subject: [PATCH 7/7] =?UTF-8?q?refactor(config):=20introduce=20ConfigStore?= =?UTF-8?q?=20and=20Scope=20for=20better=20config=20m=E2=80=A6=20(#2395)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(config): introduce ConfigStore and Scope for better config management This makes config.Config immutable and introduces a ConfigStore that manages the config and provides helper methods for accessing config values with proper scoping (global, workspace). This allows us to avoid passing around mutable config objects and ensures that all parts of the code are accessing the most up-to-date config values. It also lays the groundwork for future features like per-workspace config overrides. * fixt: lint --- internal/agent/agent_tool.go | 2 +- internal/agent/agentic_fetch_tool.go | 10 +- internal/agent/common_test.go | 18 +- internal/agent/coordinator.go | 70 ++--- internal/agent/coordinator_test.go | 2 +- internal/agent/prompt/prompt.go | 29 +- internal/agent/prompts.go | 2 +- internal/agent/tools/list_mcp_resources.go | 2 +- internal/agent/tools/mcp-tools.go | 4 +- internal/agent/tools/mcp/init.go | 8 +- internal/agent/tools/mcp/prompts.go | 2 +- internal/agent/tools/mcp/resources.go | 4 +- internal/agent/tools/mcp/tools.go | 10 +- internal/agent/tools/read_mcp_resource.go | 2 +- internal/app/app.go | 34 ++- internal/cmd/login.go | 18 +- internal/cmd/logs.go | 2 +- internal/cmd/models.go | 4 +- internal/cmd/root.go | 5 +- internal/cmd/stats.go | 2 +- internal/commands/commands.go | 2 +- internal/config/config.go | 248 --------------- internal/config/copilot.go | 47 --- internal/config/init.go | 29 +- internal/config/load.go | 62 ++-- internal/config/load_test.go | 84 +++--- internal/config/recent_models_test.go | 64 ++-- internal/config/scope.go | 11 + internal/config/store.go | 336 +++++++++++++++++++++ internal/lsp/manager.go | 12 +- internal/ui/common/common.go | 7 +- internal/ui/dialog/api_key_input.go | 6 +- internal/ui/dialog/filepicker.go | 2 +- internal/ui/dialog/models.go | 2 +- internal/ui/dialog/oauth.go | 4 +- internal/ui/model/header.go | 3 +- internal/ui/model/landing.go | 2 +- internal/ui/model/onboarding.go | 11 +- internal/ui/model/sidebar.go | 4 +- internal/ui/model/ui.go | 26 +- 40 files changed, 648 insertions(+), 544 deletions(-) create mode 100644 internal/config/scope.go create mode 100644 internal/config/store.go diff --git a/internal/agent/agent_tool.go b/internal/agent/agent_tool.go index 1a7286e342d245c7e7ac1161111d8c205300018b..0d7677dee702b813e0a0d6f02e67837f084d5c29 100644 --- a/internal/agent/agent_tool.go +++ b/internal/agent/agent_tool.go @@ -24,7 +24,7 @@ const ( ) func (c *coordinator) agentTool(ctx context.Context) (fantasy.AgentTool, error) { - agentCfg, ok := c.cfg.Agents[config.AgentTask] + agentCfg, ok := c.cfg.Config().Agents[config.AgentTask] if !ok { return nil, errors.New("task agent not configured") } diff --git a/internal/agent/agentic_fetch_tool.go b/internal/agent/agentic_fetch_tool.go index 0bd942e013b706389fb90352c891a4f2ea014f30..ffbe0f49e45c259db3f0bba9f07fda771ad3ecd4 100644 --- a/internal/agent/agentic_fetch_tool.go +++ b/internal/agent/agentic_fetch_tool.go @@ -98,7 +98,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) ( return fantasy.ToolResponse{}, permission.ErrorPermissionDenied } - tmpDir, err := os.MkdirTemp(c.cfg.Options.DataDirectory, "crush-fetch-*") + tmpDir, err := os.MkdirTemp(c.cfg.Config().Options.DataDirectory, "crush-fetch-*") if err != nil { return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to create temporary directory: %s", err)), nil } @@ -151,12 +151,12 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) ( return fantasy.ToolResponse{}, fmt.Errorf("error building models: %s", err) } - systemPrompt, err := promptTemplate.Build(ctx, small.Model.Provider(), small.Model.Model(), *c.cfg) + systemPrompt, err := promptTemplate.Build(ctx, small.Model.Provider(), small.Model.Model(), c.cfg) if err != nil { return fantasy.ToolResponse{}, fmt.Errorf("error building system prompt: %s", err) } - smallProviderCfg, ok := c.cfg.Providers.Get(small.ModelCfg.Provider) + smallProviderCfg, ok := c.cfg.Config().Providers.Get(small.ModelCfg.Provider) if !ok { return fantasy.ToolResponse{}, errors.New("small model provider not configured") } @@ -167,7 +167,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) ( webFetchTool, webSearchTool, tools.NewGlobTool(tmpDir), - tools.NewGrepTool(tmpDir, c.cfg.Tools.Grep), + tools.NewGrepTool(tmpDir, c.cfg.Config().Tools.Grep), tools.NewSourcegraphTool(client), tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, tmpDir), } @@ -177,7 +177,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) ( SmallModel: small, SystemPromptPrefix: smallProviderCfg.SystemPromptPrefix, SystemPrompt: systemPrompt, - DisableAutoSummarize: c.cfg.Options.DisableAutoSummarize, + DisableAutoSummarize: c.cfg.Config().Options.DisableAutoSummarize, IsYolo: c.permissions.SkipRequests(), Sessions: c.sessions, Messages: c.messages, diff --git a/internal/agent/common_test.go b/internal/agent/common_test.go index 89fc6ff3d29d27c60a8091f17ebe0fad057dc44a..132c27d21aee81bd3930c469963f1d73885d58a7 100644 --- a/internal/agent/common_test.go +++ b/internal/agent/common_test.go @@ -185,36 +185,36 @@ func coderAgent(r *vcr.Recorder, env fakeEnv, large, small fantasy.LanguageModel // NOTE(@andreynering): Set a fixed config to ensure cassettes match // independently of user config on `$HOME/.config/crush/crush.json`. - cfg.Options.Attribution = &config.Attribution{ + cfg.Config().Options.Attribution = &config.Attribution{ TrailerStyle: "co-authored-by", GeneratedWith: true, } // Clear some fields to avoid issues with VCR cassette matching. - cfg.Options.SkillsPaths = nil - cfg.Options.ContextPaths = nil - cfg.LSP = nil + cfg.Config().Options.SkillsPaths = nil + cfg.Config().Options.ContextPaths = nil + cfg.Config().LSP = nil - systemPrompt, err := prompt.Build(context.TODO(), large.Provider(), large.Model(), *cfg) + systemPrompt, err := prompt.Build(context.TODO(), large.Provider(), large.Model(), cfg) if err != nil { return nil, err } // Get the model name for the bash tool modelName := large.Model() // fallback to ID if Name not available - if model := cfg.GetModel(large.Provider(), large.Model()); model != nil { + if model := cfg.Config().GetModel(large.Provider(), large.Model()); model != nil { modelName = model.Name } allTools := []fantasy.AgentTool{ - tools.NewBashTool(env.permissions, env.workingDir, cfg.Options.Attribution, modelName), + tools.NewBashTool(env.permissions, env.workingDir, cfg.Config().Options.Attribution, modelName), tools.NewDownloadTool(env.permissions, env.workingDir, r.GetDefaultClient()), tools.NewEditTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir), tools.NewMultiEditTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir), tools.NewFetchTool(env.permissions, env.workingDir, r.GetDefaultClient()), tools.NewGlobTool(env.workingDir), - tools.NewGrepTool(env.workingDir, cfg.Tools.Grep), - tools.NewLsTool(env.permissions, env.workingDir, cfg.Tools.Ls), + tools.NewGrepTool(env.workingDir, cfg.Config().Tools.Grep), + tools.NewLsTool(env.permissions, env.workingDir, cfg.Config().Tools.Ls), tools.NewSourcegraphTool(r.GetDefaultClient()), tools.NewViewTool(nil, env.permissions, *env.filetracker, env.workingDir), tools.NewWriteTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir), diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index 3968952ae4e10bd59e596d02797a845d943bd378..4bca96d5946630423fc532ac6ccb5be833638dd2 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -74,7 +74,7 @@ type Coordinator interface { } type coordinator struct { - cfg *config.Config + cfg *config.ConfigStore sessions session.Service messages message.Service permissions permission.Service @@ -91,7 +91,7 @@ type coordinator struct { func NewCoordinator( ctx context.Context, - cfg *config.Config, + cfg *config.ConfigStore, sessions session.Service, messages message.Service, permissions permission.Service, @@ -112,7 +112,7 @@ func NewCoordinator( agents: make(map[string]SessionAgent), } - agentCfg, ok := cfg.Agents[config.AgentCoder] + agentCfg, ok := cfg.Config().Agents[config.AgentCoder] if !ok { return nil, errCoderAgentNotConfigured } @@ -160,7 +160,7 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string, attachments = filteredAttachments } - providerCfg, ok := c.cfg.Providers.Get(model.ModelCfg.Provider) + providerCfg, ok := c.cfg.Config().Providers.Get(model.ModelCfg.Provider) if !ok { return nil, errModelProviderNotConfigured } @@ -383,14 +383,14 @@ func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, age return nil, err } - largeProviderCfg, _ := c.cfg.Providers.Get(large.ModelCfg.Provider) + largeProviderCfg, _ := c.cfg.Config().Providers.Get(large.ModelCfg.Provider) result := NewSessionAgent(SessionAgentOptions{ LargeModel: large, SmallModel: small, SystemPromptPrefix: largeProviderCfg.SystemPromptPrefix, SystemPrompt: "", IsSubAgent: isSubAgent, - DisableAutoSummarize: c.cfg.Options.DisableAutoSummarize, + DisableAutoSummarize: c.cfg.Config().Options.DisableAutoSummarize, IsYolo: c.permissions.SkipRequests(), Sessions: c.sessions, Messages: c.messages, @@ -399,7 +399,7 @@ func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, age }) c.readyWg.Go(func() error { - systemPrompt, err := prompt.Build(ctx, large.Model.Provider(), large.Model.Model(), *c.cfg) + systemPrompt, err := prompt.Build(ctx, large.Model.Provider(), large.Model.Model(), c.cfg) if err != nil { return err } @@ -439,14 +439,14 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan // Get the model name for the agent modelName := "" - if modelCfg, ok := c.cfg.Models[agent.Model]; ok { - if model := c.cfg.GetModel(modelCfg.Provider, modelCfg.Model); model != nil { + if modelCfg, ok := c.cfg.Config().Models[agent.Model]; ok { + if model := c.cfg.Config().GetModel(modelCfg.Provider, modelCfg.Model); model != nil { modelName = model.Name } } allTools = append(allTools, - tools.NewBashTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Options.Attribution, modelName), + tools.NewBashTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Config().Options.Attribution, modelName), tools.NewJobOutputTool(), tools.NewJobKillTool(), tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil), @@ -454,20 +454,20 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan tools.NewMultiEditTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()), tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil), tools.NewGlobTool(c.cfg.WorkingDir()), - tools.NewGrepTool(c.cfg.WorkingDir(), c.cfg.Tools.Grep), - tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls), + tools.NewGrepTool(c.cfg.WorkingDir(), c.cfg.Config().Tools.Grep), + tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Config().Tools.Ls), tools.NewSourcegraphTool(nil), tools.NewTodosTool(c.sessions), - tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...), + tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, c.cfg.WorkingDir(), c.cfg.Config().Options.SkillsPaths...), tools.NewWriteTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()), ) // Add LSP tools if user has configured LSPs or auto_lsp is enabled (nil or true). - if len(c.cfg.LSP) > 0 || c.cfg.Options.AutoLSP == nil || *c.cfg.Options.AutoLSP { + if len(c.cfg.Config().LSP) > 0 || c.cfg.Config().Options.AutoLSP == nil || *c.cfg.Config().Options.AutoLSP { allTools = append(allTools, tools.NewDiagnosticsTool(c.lspManager), tools.NewReferencesTool(c.lspManager), tools.NewLSPRestartTool(c.lspManager)) } - if len(c.cfg.MCP) > 0 { + if len(c.cfg.Config().MCP) > 0 { allTools = append( allTools, tools.NewListMCPResourcesTool(c.cfg, c.permissions), @@ -513,16 +513,16 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan // TODO: when we support multiple agents we need to change this so that we pass in the agent specific model config func (c *coordinator) buildAgentModels(ctx context.Context, isSubAgent bool) (Model, Model, error) { - largeModelCfg, ok := c.cfg.Models[config.SelectedModelTypeLarge] + largeModelCfg, ok := c.cfg.Config().Models[config.SelectedModelTypeLarge] if !ok { return Model{}, Model{}, errLargeModelNotSelected } - smallModelCfg, ok := c.cfg.Models[config.SelectedModelTypeSmall] + smallModelCfg, ok := c.cfg.Config().Models[config.SelectedModelTypeSmall] if !ok { return Model{}, Model{}, errSmallModelNotSelected } - largeProviderCfg, ok := c.cfg.Providers.Get(largeModelCfg.Provider) + largeProviderCfg, ok := c.cfg.Config().Providers.Get(largeModelCfg.Provider) if !ok { return Model{}, Model{}, errLargeModelProviderNotConfigured } @@ -532,7 +532,7 @@ func (c *coordinator) buildAgentModels(ctx context.Context, isSubAgent bool) (Mo return Model{}, Model{}, err } - smallProviderCfg, ok := c.cfg.Providers.Get(smallModelCfg.Provider) + smallProviderCfg, ok := c.cfg.Config().Providers.Get(smallModelCfg.Provider) if !ok { return Model{}, Model{}, errSmallModelProviderNotConfigured } @@ -620,7 +620,7 @@ func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map opts = append(opts, anthropic.WithBaseURL(baseURL)) } - if c.cfg.Options.Debug { + if c.cfg.Config().Options.Debug { httpClient := log.NewHTTPClient() opts = append(opts, anthropic.WithHTTPClient(httpClient)) } @@ -632,7 +632,7 @@ func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[st openai.WithAPIKey(apiKey), openai.WithUseResponsesAPI(), } - if c.cfg.Options.Debug { + if c.cfg.Config().Options.Debug { httpClient := log.NewHTTPClient() opts = append(opts, openai.WithHTTPClient(httpClient)) } @@ -649,7 +649,7 @@ func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[stri opts := []openrouter.Option{ openrouter.WithAPIKey(apiKey), } - if c.cfg.Options.Debug { + if c.cfg.Config().Options.Debug { httpClient := log.NewHTTPClient() opts = append(opts, openrouter.WithHTTPClient(httpClient)) } @@ -663,7 +663,7 @@ func (c *coordinator) buildVercelProvider(_, apiKey string, headers map[string]s opts := []vercel.Option{ vercel.WithAPIKey(apiKey), } - if c.cfg.Options.Debug { + if c.cfg.Config().Options.Debug { httpClient := log.NewHTTPClient() opts = append(opts, vercel.WithHTTPClient(httpClient)) } @@ -683,8 +683,8 @@ func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers var httpClient *http.Client if providerID == string(catwalk.InferenceProviderCopilot) { opts = append(opts, openaicompat.WithUseResponsesAPI()) - httpClient = copilot.NewClient(isSubAgent, c.cfg.Options.Debug) - } else if c.cfg.Options.Debug { + httpClient = copilot.NewClient(isSubAgent, c.cfg.Config().Options.Debug) + } else if c.cfg.Config().Options.Debug { httpClient = log.NewHTTPClient() } if httpClient != nil { @@ -708,7 +708,7 @@ func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[str azure.WithAPIKey(apiKey), azure.WithUseResponsesAPI(), } - if c.cfg.Options.Debug { + if c.cfg.Config().Options.Debug { httpClient := log.NewHTTPClient() opts = append(opts, azure.WithHTTPClient(httpClient)) } @@ -727,7 +727,7 @@ func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[str func (c *coordinator) buildBedrockProvider(headers map[string]string) (fantasy.Provider, error) { var opts []bedrock.Option - if c.cfg.Options.Debug { + if c.cfg.Config().Options.Debug { httpClient := log.NewHTTPClient() opts = append(opts, bedrock.WithHTTPClient(httpClient)) } @@ -746,7 +746,7 @@ func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[st google.WithBaseURL(baseURL), google.WithGeminiAPIKey(apiKey), } - if c.cfg.Options.Debug { + if c.cfg.Config().Options.Debug { httpClient := log.NewHTTPClient() opts = append(opts, google.WithHTTPClient(httpClient)) } @@ -758,7 +758,7 @@ func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[st func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) { opts := []google.Option{} - if c.cfg.Options.Debug { + if c.cfg.Config().Options.Debug { httpClient := log.NewHTTPClient() opts = append(opts, google.WithHTTPClient(httpClient)) } @@ -779,7 +779,7 @@ func (c *coordinator) buildHyperProvider(baseURL, apiKey string) (fantasy.Provid hyper.WithBaseURL(baseURL), hyper.WithAPIKey(apiKey), } - if c.cfg.Options.Debug { + if c.cfg.Config().Options.Debug { httpClient := log.NewHTTPClient() opts = append(opts, hyper.WithHTTPClient(httpClient)) } @@ -887,7 +887,7 @@ func (c *coordinator) UpdateModels(ctx context.Context) error { } c.currentAgent.SetModels(large, small) - agentCfg, ok := c.cfg.Agents[config.AgentCoder] + agentCfg, ok := c.cfg.Config().Agents[config.AgentCoder] if !ok { return errCoderAgentNotConfigured } @@ -909,7 +909,7 @@ func (c *coordinator) QueuedPromptsList(sessionID string) []string { } func (c *coordinator) Summarize(ctx context.Context, sessionID string) error { - providerCfg, ok := c.cfg.Providers.Get(c.currentAgent.Model().ModelCfg.Provider) + providerCfg, ok := c.cfg.Config().Providers.Get(c.currentAgent.Model().ModelCfg.Provider) if !ok { return errModelProviderNotConfigured } @@ -922,7 +922,7 @@ func (c *coordinator) isUnauthorized(err error) bool { } func (c *coordinator) refreshOAuth2Token(ctx context.Context, providerCfg config.ProviderConfig) error { - if err := c.cfg.RefreshOAuthToken(ctx, providerCfg.ID); err != nil { + if err := c.cfg.RefreshOAuthToken(ctx, config.ScopeGlobal, providerCfg.ID); err != nil { slog.Error("Failed to refresh OAuth token after 401 error", "provider", providerCfg.ID, "error", err) return err } @@ -940,7 +940,7 @@ func (c *coordinator) refreshApiKeyTemplate(ctx context.Context, providerCfg con } providerCfg.APIKey = newAPIKey - c.cfg.Providers.Set(providerCfg.ID, providerCfg) + c.cfg.Config().Providers.Set(providerCfg.ID, providerCfg) if err := c.UpdateModels(ctx); err != nil { return err @@ -984,7 +984,7 @@ func (c *coordinator) runSubAgent(ctx context.Context, params subAgentParams) (f maxTokens = model.ModelCfg.MaxTokens } - providerCfg, ok := c.cfg.Providers.Get(model.ModelCfg.Provider) + providerCfg, ok := c.cfg.Config().Providers.Get(model.ModelCfg.Provider) if !ok { return fantasy.ToolResponse{}, errModelProviderNotConfigured } diff --git a/internal/agent/coordinator_test.go b/internal/agent/coordinator_test.go index 3c270394cba9c1758e4a9029a149027af6bf36c2..657575b6458d7fb815c7a9646a9d605c8b89ec42 100644 --- a/internal/agent/coordinator_test.go +++ b/internal/agent/coordinator_test.go @@ -44,7 +44,7 @@ func (m *mockSessionAgent) Summarize(context.Context, string, fantasy.ProviderOp func newTestCoordinator(t *testing.T, env fakeEnv, providerID string, providerCfg config.ProviderConfig) *coordinator { cfg, err := config.Init(env.workingDir, "", false) require.NoError(t, err) - cfg.Providers.Set(providerID, providerCfg) + cfg.Config().Providers.Set(providerID, providerCfg) return &coordinator{ cfg: cfg, sessions: env.sessions, diff --git a/internal/agent/prompt/prompt.go b/internal/agent/prompt/prompt.go index d68c7c132116c49cd004bee52169be7487133efa..c8f488319f04238c476aae4719728fb94521695e 100644 --- a/internal/agent/prompt/prompt.go +++ b/internal/agent/prompt/prompt.go @@ -76,13 +76,13 @@ func NewPrompt(name, promptTemplate string, opts ...Option) (*Prompt, error) { return p, nil } -func (p *Prompt) Build(ctx context.Context, provider, model string, cfg config.Config) (string, error) { +func (p *Prompt) Build(ctx context.Context, provider, model string, store *config.ConfigStore) (string, error) { t, err := template.New(p.name).Parse(p.template) if err != nil { return "", fmt.Errorf("parsing template: %w", err) } var sb strings.Builder - d, err := p.promptData(ctx, provider, model, cfg) + d, err := p.promptData(ctx, provider, model, store) if err != nil { return "", err } @@ -104,11 +104,11 @@ func processFile(filePath string) *ContextFile { } } -func processContextPath(p string, cfg config.Config) []ContextFile { +func processContextPath(p string, store *config.ConfigStore) []ContextFile { var contexts []ContextFile fullPath := p if !filepath.IsAbs(p) { - fullPath = filepath.Join(cfg.WorkingDir(), p) + fullPath = filepath.Join(store.WorkingDir(), p) } info, err := os.Stat(fullPath) if err != nil { @@ -136,11 +136,11 @@ func processContextPath(p string, cfg config.Config) []ContextFile { } // expandPath expands ~ and environment variables in file paths -func expandPath(path string, cfg config.Config) string { +func expandPath(path string, store *config.ConfigStore) string { path = home.Long(path) // Handle environment variable expansion using the same pattern as config if strings.HasPrefix(path, "$") { - if expanded, err := cfg.Resolver().ResolveValue(path); err == nil { + if expanded, err := store.Resolver().ResolveValue(path); err == nil { path = expanded } } @@ -148,19 +148,20 @@ func expandPath(path string, cfg config.Config) string { return path } -func (p *Prompt) promptData(ctx context.Context, provider, model string, cfg config.Config) (PromptDat, error) { - workingDir := cmp.Or(p.workingDir, cfg.WorkingDir()) +func (p *Prompt) promptData(ctx context.Context, provider, model string, store *config.ConfigStore) (PromptDat, error) { + workingDir := cmp.Or(p.workingDir, store.WorkingDir()) platform := cmp.Or(p.platform, runtime.GOOS) files := map[string][]ContextFile{} + cfg := store.Config() for _, pth := range cfg.Options.ContextPaths { - expanded := expandPath(pth, cfg) + expanded := expandPath(pth, store) pathKey := strings.ToLower(expanded) if _, ok := files[pathKey]; ok { continue } - content := processContextPath(expanded, cfg) + content := processContextPath(expanded, store) files[pathKey] = content } @@ -169,18 +170,18 @@ func (p *Prompt) promptData(ctx context.Context, provider, model string, cfg con if len(cfg.Options.SkillsPaths) > 0 { expandedPaths := make([]string, 0, len(cfg.Options.SkillsPaths)) for _, pth := range cfg.Options.SkillsPaths { - expandedPaths = append(expandedPaths, expandPath(pth, cfg)) + expandedPaths = append(expandedPaths, expandPath(pth, store)) } if discoveredSkills := skills.Discover(expandedPaths); len(discoveredSkills) > 0 { availSkillXML = skills.ToPromptXML(discoveredSkills) } } - isGit := isGitRepo(cfg.WorkingDir()) + isGit := isGitRepo(store.WorkingDir()) data := PromptDat{ Provider: provider, Model: model, - Config: cfg, + Config: *cfg, WorkingDir: filepath.ToSlash(workingDir), IsGitRepo: isGit, Platform: platform, @@ -189,7 +190,7 @@ func (p *Prompt) promptData(ctx context.Context, provider, model string, cfg con } if isGit { var err error - data.GitStatus, err = getGitStatus(ctx, cfg.WorkingDir()) + data.GitStatus, err = getGitStatus(ctx, store.WorkingDir()) if err != nil { return PromptDat{}, err } diff --git a/internal/agent/prompts.go b/internal/agent/prompts.go index 577d32e4e274d9cb8274bd862af583208a613f08..448fe0425c3b700b1d6edafc842c4815ad3d5760 100644 --- a/internal/agent/prompts.go +++ b/internal/agent/prompts.go @@ -33,7 +33,7 @@ func taskPrompt(opts ...prompt.Option) (*prompt.Prompt, error) { return systemPrompt, nil } -func InitializePrompt(cfg config.Config) (string, error) { +func InitializePrompt(cfg *config.ConfigStore) (string, error) { systemPrompt, err := prompt.NewPrompt("initialize", string(initializePromptTmpl)) if err != nil { return "", err diff --git a/internal/agent/tools/list_mcp_resources.go b/internal/agent/tools/list_mcp_resources.go index 032d1eb1888a65e9a14daecc3b503698a6fa60d4..7ea8998a1dc80955b2a5b0a79d4aef7d19fb9011 100644 --- a/internal/agent/tools/list_mcp_resources.go +++ b/internal/agent/tools/list_mcp_resources.go @@ -28,7 +28,7 @@ const ListMCPResourcesToolName = "list_mcp_resources" //go:embed list_mcp_resources.md var listMCPResourcesDescription []byte -func NewListMCPResourcesTool(cfg *config.Config, permissions permission.Service) fantasy.AgentTool { +func NewListMCPResourcesTool(cfg *config.ConfigStore, permissions permission.Service) fantasy.AgentTool { return fantasy.NewParallelAgentTool( ListMCPResourcesToolName, string(listMCPResourcesDescription), diff --git a/internal/agent/tools/mcp-tools.go b/internal/agent/tools/mcp-tools.go index 429cadaf6b686b83e170ef35976881d839b07e17..e1184118552ee62e75f60c6943f59ecca2868563 100644 --- a/internal/agent/tools/mcp-tools.go +++ b/internal/agent/tools/mcp-tools.go @@ -11,7 +11,7 @@ import ( ) // GetMCPTools gets all the currently available MCP tools. -func GetMCPTools(permissions permission.Service, cfg *config.Config, wd string) []*Tool { +func GetMCPTools(permissions permission.Service, cfg *config.ConfigStore, wd string) []*Tool { var result []*Tool for mcpName, tools := range mcp.Tools() { for _, tool := range tools { @@ -31,7 +31,7 @@ func GetMCPTools(permissions permission.Service, cfg *config.Config, wd string) type Tool struct { mcpName string tool *mcp.Tool - cfg *config.Config + cfg *config.ConfigStore permissions permission.Service workingDir string providerOptions fantasy.ProviderOptions diff --git a/internal/agent/tools/mcp/init.go b/internal/agent/tools/mcp/init.go index f8cfe0ce84bf7b1987496607d42753b8ca72263f..cba9a51c717b1866b823762f85bfadf90e1a7a10 100644 --- a/internal/agent/tools/mcp/init.go +++ b/internal/agent/tools/mcp/init.go @@ -163,11 +163,11 @@ func Close(ctx context.Context) error { } // Initialize initializes MCP clients based on the provided configuration. -func Initialize(ctx context.Context, permissions permission.Service, cfg *config.Config) { +func Initialize(ctx context.Context, permissions permission.Service, cfg *config.ConfigStore) { slog.Info("Initializing MCP clients") var wg sync.WaitGroup // Initialize states for all configured MCPs - for name, m := range cfg.MCP { + for name, m := range cfg.Config().MCP { if m.Disabled { updateState(name, StateDisabled, nil, nil, Counts{}) slog.Debug("Skipping disabled MCP", "name", name) @@ -253,13 +253,13 @@ func WaitForInit(ctx context.Context) error { } } -func getOrRenewClient(ctx context.Context, cfg *config.Config, name string) (*ClientSession, error) { +func getOrRenewClient(ctx context.Context, cfg *config.ConfigStore, name string) (*ClientSession, error) { sess, ok := sessions.Get(name) if !ok { return nil, fmt.Errorf("mcp '%s' not available", name) } - m := cfg.MCP[name] + m := cfg.Config().MCP[name] state, _ := states.Get(name) timeout := mcpTimeout(m) diff --git a/internal/agent/tools/mcp/prompts.go b/internal/agent/tools/mcp/prompts.go index 2b39d5dc2db43aff418c3dd7561edbcebd6af865..d84be303ecb103d4fdd37423b7b6d088374d2c70 100644 --- a/internal/agent/tools/mcp/prompts.go +++ b/internal/agent/tools/mcp/prompts.go @@ -20,7 +20,7 @@ func Prompts() iter.Seq2[string, []*Prompt] { } // GetPromptMessages retrieves the content of an MCP prompt with the given arguments. -func GetPromptMessages(ctx context.Context, cfg *config.Config, clientName, promptName string, args map[string]string) ([]string, error) { +func GetPromptMessages(ctx context.Context, cfg *config.ConfigStore, clientName, promptName string, args map[string]string) ([]string, error) { c, err := getOrRenewClient(ctx, cfg, clientName) if err != nil { return nil, err diff --git a/internal/agent/tools/mcp/resources.go b/internal/agent/tools/mcp/resources.go index 8e2bcc796b28c698481dd90b0c70511273f7c98d..21616761e81212960f4d6ad59da1505049abbffb 100644 --- a/internal/agent/tools/mcp/resources.go +++ b/internal/agent/tools/mcp/resources.go @@ -24,7 +24,7 @@ func Resources() iter.Seq2[string, []*Resource] { } // ListResources returns the current resources for an MCP server. -func ListResources(ctx context.Context, cfg *config.Config, name string) ([]*Resource, error) { +func ListResources(ctx context.Context, cfg *config.ConfigStore, name string) ([]*Resource, error) { session, err := getOrRenewClient(ctx, cfg, name) if err != nil { return nil, err @@ -43,7 +43,7 @@ func ListResources(ctx context.Context, cfg *config.Config, name string) ([]*Res } // ReadResource reads the contents of a resource from an MCP server. -func ReadResource(ctx context.Context, cfg *config.Config, name, uri string) ([]*ResourceContents, error) { +func ReadResource(ctx context.Context, cfg *config.ConfigStore, name, uri string) ([]*ResourceContents, error) { session, err := getOrRenewClient(ctx, cfg, name) if err != nil { return nil, err diff --git a/internal/agent/tools/mcp/tools.go b/internal/agent/tools/mcp/tools.go index b6e208f7ccb3363bee0a0b60ef56c103ad9cd41b..8d1d2649ba4381e14fa8d99933f1dfb3b42d27ae 100644 --- a/internal/agent/tools/mcp/tools.go +++ b/internal/agent/tools/mcp/tools.go @@ -32,7 +32,7 @@ func Tools() iter.Seq2[string, []*Tool] { } // RunTool runs an MCP tool with the given input parameters. -func RunTool(ctx context.Context, cfg *config.Config, name, toolName string, input string) (ToolResult, error) { +func RunTool(ctx context.Context, cfg *config.ConfigStore, name, toolName string, input string) (ToolResult, error) { var args map[string]any if err := json.Unmarshal([]byte(input), &args); err != nil { return ToolResult{}, fmt.Errorf("error parsing parameters: %s", err) @@ -108,7 +108,7 @@ func RunTool(ctx context.Context, cfg *config.Config, name, toolName string, inp // RefreshTools gets the updated list of tools from the MCP and updates the // global state. -func RefreshTools(ctx context.Context, cfg *config.Config, name string) { +func RefreshTools(ctx context.Context, cfg *config.ConfigStore, name string) { session, ok := sessions.Get(name) if !ok { slog.Warn("Refresh tools: no session", "name", name) @@ -139,7 +139,7 @@ func getTools(ctx context.Context, session *ClientSession) ([]*Tool, error) { return result.Tools, nil } -func updateTools(cfg *config.Config, name string, tools []*Tool) int { +func updateTools(cfg *config.ConfigStore, name string, tools []*Tool) int { tools = filterDisabledTools(cfg, name, tools) if len(tools) == 0 { allTools.Del(name) @@ -150,8 +150,8 @@ func updateTools(cfg *config.Config, name string, tools []*Tool) int { } // filterDisabledTools removes tools that are disabled via config. -func filterDisabledTools(cfg *config.Config, mcpName string, tools []*Tool) []*Tool { - mcpCfg, ok := cfg.MCP[mcpName] +func filterDisabledTools(cfg *config.ConfigStore, mcpName string, tools []*Tool) []*Tool { + mcpCfg, ok := cfg.Config().MCP[mcpName] if !ok || len(mcpCfg.DisabledTools) == 0 { return tools } diff --git a/internal/agent/tools/read_mcp_resource.go b/internal/agent/tools/read_mcp_resource.go index cc0450d63aa94574e45e4264906c77fc2b7a1127..c96b00194b92b05e40953a49a90c3453fe6b16b2 100644 --- a/internal/agent/tools/read_mcp_resource.go +++ b/internal/agent/tools/read_mcp_resource.go @@ -30,7 +30,7 @@ const ReadMCPResourceToolName = "read_mcp_resource" //go:embed read_mcp_resource.md var readMCPResourceDescription []byte -func NewReadMCPResourceTool(cfg *config.Config, permissions permission.Service) fantasy.AgentTool { +func NewReadMCPResourceTool(cfg *config.ConfigStore, permissions permission.Service) fantasy.AgentTool { return fantasy.NewParallelAgentTool( ReadMCPResourceToolName, string(readMCPResourceDescription), diff --git a/internal/app/app.go b/internal/app/app.go index 7d87bd1231000cb2a1c88c1fa7a0ceae5b4316a9..8ed3e2e41cb2b235771eba24c3b59945f73cdfda 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -61,7 +61,7 @@ type App struct { LSPManager *lsp.Manager - config *config.Config + config *config.ConfigStore serviceEventsWG *sync.WaitGroup eventsCtx context.Context @@ -75,11 +75,12 @@ type App struct { } // New initializes a new application instance. -func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) { +func New(ctx context.Context, conn *sql.DB, store *config.ConfigStore) (*App, error) { q := db.New(conn) sessions := session.NewService(q, conn) messages := message.NewService(q) files := history.NewService(q, conn) + cfg := store.Config() skipPermissionsRequests := cfg.Permissions != nil && cfg.Permissions.SkipRequests var allowedTools []string if cfg.Permissions != nil && cfg.Permissions.AllowedTools != nil { @@ -90,13 +91,13 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) { Sessions: sessions, Messages: messages, History: files, - Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests, allowedTools), + Permissions: permission.NewPermissionService(store.WorkingDir(), skipPermissionsRequests, allowedTools), FileTracker: filetracker.NewService(q), - LSPManager: lsp.NewManager(cfg), + LSPManager: lsp.NewManager(store), globalCtx: ctx, - config: cfg, + config: store, events: make(chan tea.Msg, 100), serviceEventsWG: &sync.WaitGroup{}, @@ -109,7 +110,7 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) { // Check for updates in the background. go app.checkForUpdates(ctx) - go mcp.Initialize(ctx, app.Permissions, cfg) + go mcp.Initialize(ctx, app.Permissions, store) // cleanup database upon app shutdown app.cleanupFuncs = append( @@ -141,8 +142,13 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) { return app, nil } -// Config returns the application configuration. +// Config returns the pure-data configuration. func (app *App) Config() *config.Config { + return app.config.Config() +} + +// Store returns the config store. +func (app *App) Store() *config.ConfigStore { return app.config } @@ -178,7 +184,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, } stderrTTY = term.IsTerminal(os.Stderr.Fd()) stdinTTY = term.IsTerminal(os.Stdin.Fd()) - progress = app.config.Options.Progress == nil || *app.config.Options.Progress + progress = app.config.Config().Options.Progress == nil || *app.config.Config().Options.Progress if !hideSpinner && stderrTTY { t := styles.DefaultStyles() @@ -331,7 +337,7 @@ func (app *App) UpdateAgentModel(ctx context.Context) error { // If largeModel is provided but smallModel is not, the small model defaults to // the provider's default small model. func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel, smallModel string) error { - providers := app.config.Providers.Copy() + providers := app.config.Config().Providers.Copy() largeMatches, smallMatches, err := findModels(providers, largeModel, smallModel) if err != nil { @@ -348,7 +354,7 @@ func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel, } largeProviderID = found.provider slog.Info("Overriding large model for non-interactive run", "provider", found.provider, "model", found.modelID) - app.config.Models[config.SelectedModelTypeLarge] = config.SelectedModel{ + app.config.Config().Models[config.SelectedModelTypeLarge] = config.SelectedModel{ Provider: found.provider, Model: found.modelID, } @@ -362,7 +368,7 @@ func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel, return err } slog.Info("Overriding small model for non-interactive run", "provider", found.provider, "model", found.modelID) - app.config.Models[config.SelectedModelTypeSmall] = config.SelectedModel{ + app.config.Config().Models[config.SelectedModelTypeSmall] = config.SelectedModel{ Provider: found.provider, Model: found.modelID, } @@ -370,7 +376,7 @@ func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel, case largeModel != "": // No small model specified, but large model was - use provider's default. smallCfg := app.GetDefaultSmallModel(largeProviderID) - app.config.Models[config.SelectedModelTypeSmall] = smallCfg + app.config.Config().Models[config.SelectedModelTypeSmall] = smallCfg } return app.AgentCoordinator.UpdateModels(ctx) @@ -379,7 +385,7 @@ func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel, // GetDefaultSmallModel returns the default small model for the given // provider. Falls back to the large model if no default is found. func (app *App) GetDefaultSmallModel(providerID string) config.SelectedModel { - cfg := app.config + cfg := app.config.Config() largeModelCfg := cfg.Models[config.SelectedModelTypeLarge] // Find the provider in the known providers list to get its default small model. @@ -481,7 +487,7 @@ func setupSubscriber[T any]( } func (app *App) InitCoderAgent(ctx context.Context) error { - coderAgentCfg := app.config.Agents[config.AgentCoder] + coderAgentCfg := app.config.Config().Agents[config.AgentCoder] if coderAgentCfg.ID == "" { return fmt.Errorf("coder agent configuration is missing") } diff --git a/internal/cmd/login.go b/internal/cmd/login.go index bdad4547d6f583b5ae7e5a97bbbbd88a1421e6ee..c9acb12df19875f48b242bee96e377bf5548aacb 100644 --- a/internal/cmd/login.go +++ b/internal/cmd/login.go @@ -52,16 +52,16 @@ crush login copilot } switch provider { case "hyper": - return loginHyper(app.Config()) + return loginHyper(app.Store()) case "copilot", "github", "github-copilot": - return loginCopilot(app.Config()) + return loginCopilot(app.Store()) default: return fmt.Errorf("unknown platform: %s", args[0]) } }, } -func loginHyper(cfg *config.Config) error { +func loginHyper(cfg *config.ConfigStore) error { if !hyperp.Enabled() { return fmt.Errorf("hyper not enabled") } @@ -112,8 +112,8 @@ func loginHyper(cfg *config.Config) error { } if err := cmp.Or( - cfg.SetConfigField("providers.hyper.api_key", token.AccessToken), - cfg.SetConfigField("providers.hyper.oauth", token), + cfg.SetConfigField(config.ScopeGlobal, "providers.hyper.api_key", token.AccessToken), + cfg.SetConfigField(config.ScopeGlobal, "providers.hyper.oauth", token), ); err != nil { return err } @@ -123,10 +123,10 @@ func loginHyper(cfg *config.Config) error { return nil } -func loginCopilot(cfg *config.Config) error { +func loginCopilot(cfg *config.ConfigStore) error { ctx := getLoginContext() - if cfg.HasConfigField("providers.copilot.oauth") { + if cfg.HasConfigField(config.ScopeGlobal, "providers.copilot.oauth") { fmt.Println("You are already logged in to GitHub Copilot.") return nil } @@ -177,8 +177,8 @@ func loginCopilot(cfg *config.Config) error { } if err := cmp.Or( - cfg.SetConfigField("providers.copilot.api_key", token.AccessToken), - cfg.SetConfigField("providers.copilot.oauth", token), + cfg.SetConfigField(config.ScopeGlobal, "providers.copilot.api_key", token.AccessToken), + cfg.SetConfigField(config.ScopeGlobal, "providers.copilot.oauth", token), ); err != nil { return err } diff --git a/internal/cmd/logs.go b/internal/cmd/logs.go index 804b23310fa1e3fb86e4b32983bfcdd571df47aa..87e106feb7cc934567b183d454ef1537970dca88 100644 --- a/internal/cmd/logs.go +++ b/internal/cmd/logs.go @@ -55,7 +55,7 @@ var logsCmd = &cobra.Command{ if err != nil { return fmt.Errorf("failed to load configuration: %v", err) } - logsFile := filepath.Join(cfg.Options.DataDirectory, "logs", "crush.log") + logsFile := filepath.Join(cfg.Config().Options.DataDirectory, "logs", "crush.log") _, err = os.Stat(logsFile) if os.IsNotExist(err) { log.Warn("Looks like you are not in a crush project. No logs found.") diff --git a/internal/cmd/models.go b/internal/cmd/models.go index e2aa5c991d5cf49ba78dbff9d3f79c4f6493523d..f4fa559ebe41d93bee54ed5e2272f8fb0b8dc9ad 100644 --- a/internal/cmd/models.go +++ b/internal/cmd/models.go @@ -38,7 +38,7 @@ crush models gpt5`, return err } - if !cfg.IsConfigured() { + if !cfg.Config().IsConfigured() { return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively") } @@ -55,7 +55,7 @@ crush models gpt5`, var providerIDs []string providerModels := make(map[string][]string) - for providerID, provider := range cfg.Providers.Seq2() { + for providerID, provider := range cfg.Config().Providers.Seq2() { if provider.Disable { continue } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 52ffda3fb09a0e6fdfb88084b80f7bdd261fb3c2..6e1bc08f2f14e8af3d65b5dca7826b95d890b116 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -189,11 +189,12 @@ func setupApp(cmd *cobra.Command) (*app.App, error) { return nil, err } - cfg, err := config.Init(cwd, dataDir, debug) + store, err := config.Init(cwd, dataDir, debug) if err != nil { return nil, err } + cfg := store.Config() if cfg.Permissions == nil { cfg.Permissions = &config.Permissions{} } @@ -215,7 +216,7 @@ func setupApp(cmd *cobra.Command) (*app.App, error) { return nil, err } - appInstance, err := app.New(ctx, conn, cfg) + appInstance, err := app.New(ctx, conn, store) if err != nil { slog.Error("Failed to create app instance", "error", err) return nil, err diff --git a/internal/cmd/stats.go b/internal/cmd/stats.go index 8831c2a647a283bfe6d6edff15c5eff4dafb3377..3900acadec059869b1896c8adeb49f93155f17fa 100644 --- a/internal/cmd/stats.go +++ b/internal/cmd/stats.go @@ -131,7 +131,7 @@ func runStats(cmd *cobra.Command, _ []string) error { if err != nil { return fmt.Errorf("failed to initialize config: %w", err) } - dataDir = cfg.Options.DataDirectory + dataDir = cfg.Config().Options.DataDirectory } conn, err := db.Connect(ctx, dataDir) diff --git a/internal/commands/commands.go b/internal/commands/commands.go index aeb2ca305dc984c2c450d249d51028858e4e9802..96302bde1281adebfe74a009e4f76443f5368afe 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -227,7 +227,7 @@ func isMarkdownFile(name string) bool { return strings.HasSuffix(strings.ToLower(name), ".md") } -func GetMCPPrompt(cfg *config.Config, clientID, promptID string, args map[string]string) (string, error) { +func GetMCPPrompt(cfg *config.ConfigStore, clientID, promptID string, args map[string]string) (string, error) { // TODO: we should pass the context down result, err := mcp.GetPromptMessages(context.Background(), cfg, clientID, promptID, args) if err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index 118afef344f8a022add7a13db406ccce27a1391e..8e9b3f0fb7349f4b911c9a6c41fc3e3890f3f19e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,22 +8,16 @@ import ( "maps" "net/http" "net/url" - "os" - "path/filepath" "slices" "strings" "time" "charm.land/catwalk/pkg/catwalk" - hyperp "github.com/charmbracelet/crush/internal/agent/hyper" "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/env" "github.com/charmbracelet/crush/internal/oauth" "github.com/charmbracelet/crush/internal/oauth/copilot" - "github.com/charmbracelet/crush/internal/oauth/hyper" "github.com/invopop/jsonschema" - "github.com/tidwall/gjson" - "github.com/tidwall/sjson" ) const ( @@ -398,17 +392,6 @@ type Config struct { Tools Tools `json:"tools,omitzero" jsonschema:"description=Tool configurations"` Agents map[string]Agent `json:"-"` - - // Internal - workingDir string `json:"-"` - // TODO: find a better way to do this this should probably not be part of the config - resolver VariableResolver - dataConfigDir string `json:"-"` - knownProviders []catwalk.Provider `json:"-"` -} - -func (c *Config) WorkingDir() string { - return c.workingDir } func (c *Config) EnabledProviders() []ProviderConfig { @@ -472,235 +455,8 @@ func (c *Config) SmallModel() *catwalk.Model { return c.GetModel(model.Provider, model.Model) } -func (c *Config) SetCompactMode(enabled bool) error { - if c.Options == nil { - c.Options = &Options{} - } - c.Options.TUI.CompactMode = enabled - return c.SetConfigField("options.tui.compact_mode", enabled) -} - -func (c *Config) Resolve(key string) (string, error) { - if c.resolver == nil { - return "", fmt.Errorf("no variable resolver configured") - } - return c.resolver.ResolveValue(key) -} - -func (c *Config) UpdatePreferredModel(modelType SelectedModelType, model SelectedModel) error { - c.Models[modelType] = model - if err := c.SetConfigField(fmt.Sprintf("models.%s", modelType), model); err != nil { - return fmt.Errorf("failed to update preferred model: %w", err) - } - if err := c.recordRecentModel(modelType, model); err != nil { - return err - } - return nil -} - -func (c *Config) HasConfigField(key string) bool { - data, err := os.ReadFile(c.dataConfigDir) - if err != nil { - return false - } - return gjson.Get(string(data), key).Exists() -} - -func (c *Config) SetConfigField(key string, value any) error { - data, err := os.ReadFile(c.dataConfigDir) - if err != nil { - if os.IsNotExist(err) { - data = []byte("{}") - } else { - return fmt.Errorf("failed to read config file: %w", err) - } - } - - newValue, err := sjson.Set(string(data), key, value) - if err != nil { - return fmt.Errorf("failed to set config field %s: %w", key, err) - } - if err := os.MkdirAll(filepath.Dir(c.dataConfigDir), 0o755); err != nil { - return fmt.Errorf("failed to create config directory %q: %w", c.dataConfigDir, err) - } - if err := os.WriteFile(c.dataConfigDir, []byte(newValue), 0o600); err != nil { - return fmt.Errorf("failed to write config file: %w", err) - } - return nil -} - -func (c *Config) RemoveConfigField(key string) error { - data, err := os.ReadFile(c.dataConfigDir) - if err != nil { - return fmt.Errorf("failed to read config file: %w", err) - } - - newValue, err := sjson.Delete(string(data), key) - if err != nil { - return fmt.Errorf("failed to delete config field %s: %w", key, err) - } - if err := os.MkdirAll(filepath.Dir(c.dataConfigDir), 0o755); err != nil { - return fmt.Errorf("failed to create config directory %q: %w", c.dataConfigDir, err) - } - if err := os.WriteFile(c.dataConfigDir, []byte(newValue), 0o600); err != nil { - return fmt.Errorf("failed to write config file: %w", err) - } - return nil -} - -// RefreshOAuthToken refreshes the OAuth token for the given provider. -func (c *Config) RefreshOAuthToken(ctx context.Context, providerID string) error { - providerConfig, exists := c.Providers.Get(providerID) - if !exists { - return fmt.Errorf("provider %s not found", providerID) - } - - if providerConfig.OAuthToken == nil { - return fmt.Errorf("provider %s does not have an OAuth token", providerID) - } - - var newToken *oauth.Token - var refreshErr error - switch providerID { - case string(catwalk.InferenceProviderCopilot): - newToken, refreshErr = copilot.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken) - case hyperp.Name: - newToken, refreshErr = hyper.ExchangeToken(ctx, providerConfig.OAuthToken.RefreshToken) - default: - return fmt.Errorf("OAuth refresh not supported for provider %s", providerID) - } - if refreshErr != nil { - return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, refreshErr) - } - - slog.Info("Successfully refreshed OAuth token", "provider", providerID) - providerConfig.OAuthToken = newToken - providerConfig.APIKey = newToken.AccessToken - - switch providerID { - case string(catwalk.InferenceProviderCopilot): - providerConfig.SetupGitHubCopilot() - } - - c.Providers.Set(providerID, providerConfig) - - if err := cmp.Or( - c.SetConfigField(fmt.Sprintf("providers.%s.api_key", providerID), newToken.AccessToken), - c.SetConfigField(fmt.Sprintf("providers.%s.oauth", providerID), newToken), - ); err != nil { - return fmt.Errorf("failed to persist refreshed token: %w", err) - } - - return nil -} - -func (c *Config) SetProviderAPIKey(providerID string, apiKey any) error { - var providerConfig ProviderConfig - var exists bool - var setKeyOrToken func() - - switch v := apiKey.(type) { - case string: - if err := c.SetConfigField(fmt.Sprintf("providers.%s.api_key", providerID), v); err != nil { - return fmt.Errorf("failed to save api key to config file: %w", err) - } - setKeyOrToken = func() { providerConfig.APIKey = v } - case *oauth.Token: - if err := cmp.Or( - c.SetConfigField(fmt.Sprintf("providers.%s.api_key", providerID), v.AccessToken), - c.SetConfigField(fmt.Sprintf("providers.%s.oauth", providerID), v), - ); err != nil { - return err - } - setKeyOrToken = func() { - providerConfig.APIKey = v.AccessToken - providerConfig.OAuthToken = v - switch providerID { - case string(catwalk.InferenceProviderCopilot): - providerConfig.SetupGitHubCopilot() - } - } - } - - providerConfig, exists = c.Providers.Get(providerID) - if exists { - setKeyOrToken() - c.Providers.Set(providerID, providerConfig) - return nil - } - - var foundProvider *catwalk.Provider - for _, p := range c.knownProviders { - if string(p.ID) == providerID { - foundProvider = &p - break - } - } - - if foundProvider != nil { - // Create new provider config based on known provider - providerConfig = ProviderConfig{ - ID: providerID, - Name: foundProvider.Name, - BaseURL: foundProvider.APIEndpoint, - Type: foundProvider.Type, - Disable: false, - ExtraHeaders: make(map[string]string), - ExtraParams: make(map[string]string), - Models: foundProvider.Models, - } - setKeyOrToken() - } else { - return fmt.Errorf("provider with ID %s not found in known providers", providerID) - } - // Store the updated provider config - c.Providers.Set(providerID, providerConfig) - return nil -} - const maxRecentModelsPerType = 5 -func (c *Config) recordRecentModel(modelType SelectedModelType, model SelectedModel) error { - if model.Provider == "" || model.Model == "" { - return nil - } - - if c.RecentModels == nil { - c.RecentModels = make(map[SelectedModelType][]SelectedModel) - } - - eq := func(a, b SelectedModel) bool { - return a.Provider == b.Provider && a.Model == b.Model - } - - entry := SelectedModel{ - Provider: model.Provider, - Model: model.Model, - } - - current := c.RecentModels[modelType] - withoutCurrent := slices.DeleteFunc(slices.Clone(current), func(existing SelectedModel) bool { - return eq(existing, entry) - }) - - updated := append([]SelectedModel{entry}, withoutCurrent...) - if len(updated) > maxRecentModelsPerType { - updated = updated[:maxRecentModelsPerType] - } - - if slices.EqualFunc(current, updated, eq) { - return nil - } - - c.RecentModels[modelType] = updated - - if err := c.SetConfigField(fmt.Sprintf("recent_models.%s", modelType), updated); err != nil { - return fmt.Errorf("failed to persist recent models: %w", err) - } - - return nil -} - func allToolNames() []string { return []string{ "agent", @@ -780,10 +536,6 @@ func (c *Config) SetupAgents() { c.Agents = agents } -func (c *Config) Resolver() VariableResolver { - return c.resolver -} - func (c *ProviderConfig) TestConnection(resolver VariableResolver) error { var ( providerID = catwalk.InferenceProvider(c.ID) diff --git a/internal/config/copilot.go b/internal/config/copilot.go index d72e7d5048ba4d31c88d7f7152a6b3a9510960a2..d912156bec00a9f00850ab2ec3a3baf1016c2141 100644 --- a/internal/config/copilot.go +++ b/internal/config/copilot.go @@ -1,48 +1 @@ package config - -import ( - "cmp" - "context" - "log/slog" - "testing" - - "charm.land/catwalk/pkg/catwalk" - "github.com/charmbracelet/crush/internal/oauth" - "github.com/charmbracelet/crush/internal/oauth/copilot" -) - -func (c *Config) ImportCopilot() (*oauth.Token, bool) { - if testing.Testing() { - return nil, false - } - - if c.HasConfigField("providers.copilot.api_key") || c.HasConfigField("providers.copilot.oauth") { - return nil, false - } - - diskToken, hasDiskToken := copilot.RefreshTokenFromDisk() - if !hasDiskToken { - return nil, false - } - - slog.Info("Found existing GitHub Copilot token on disk. Authenticating...") - token, err := copilot.RefreshToken(context.TODO(), diskToken) - if err != nil { - slog.Error("Unable to import GitHub Copilot token", "error", err) - return nil, false - } - - if err := c.SetProviderAPIKey(string(catwalk.InferenceProviderCopilot), token); err != nil { - return token, false - } - - if err := cmp.Or( - c.SetConfigField("providers.copilot.api_key", token.AccessToken), - c.SetConfigField("providers.copilot.oauth", token), - ); err != nil { - slog.Error("Unable to save GitHub Copilot token to disk", "error", err) - } - - slog.Info("GitHub Copilot successfully imported") - return token, true -} diff --git a/internal/config/init.go b/internal/config/init.go index 5a4683f77485f54409d4372a33d1933b47abd33f..6138c49d496b16d054ea9ff3f6c49906b3f433ca 100644 --- a/internal/config/init.go +++ b/internal/config/init.go @@ -18,19 +18,20 @@ type ProjectInitFlag struct { Initialized bool `json:"initialized"` } -func Init(workingDir, dataDir string, debug bool) (*Config, error) { - cfg, err := Load(workingDir, dataDir, debug) +func Init(workingDir, dataDir string, debug bool) (*ConfigStore, error) { + store, err := Load(workingDir, dataDir, debug) if err != nil { return nil, err } - return cfg, nil + return store, nil } -func ProjectNeedsInitialization(cfg *Config) (bool, error) { - if cfg == nil { +func ProjectNeedsInitialization(store *ConfigStore) (bool, error) { + if store == nil { return false, fmt.Errorf("config not loaded") } + cfg := store.Config() flagFilePath := filepath.Join(cfg.Options.DataDirectory, InitFlagFilename) _, err := os.Stat(flagFilePath) @@ -42,7 +43,7 @@ func ProjectNeedsInitialization(cfg *Config) (bool, error) { return false, fmt.Errorf("failed to check init flag file: %w", err) } - someContextFileExists, err := contextPathsExist(cfg.WorkingDir()) + someContextFileExists, err := contextPathsExist(store.WorkingDir()) if err != nil { return false, fmt.Errorf("failed to check for context files: %w", err) } @@ -51,7 +52,7 @@ func ProjectNeedsInitialization(cfg *Config) (bool, error) { } // If the working directory has no non-ignored files, skip initialization step - empty, err := dirHasNoVisibleFiles(cfg.WorkingDir()) + empty, err := dirHasNoVisibleFiles(store.WorkingDir()) if err != nil { return false, fmt.Errorf("failed to check if directory is empty: %w", err) } @@ -90,7 +91,7 @@ func contextPathsExist(dir string) (bool, error) { return false, nil } -// dirHasNoVisibleFiles returns true if the directory has no files/dirs after applying ignore rules +// dirHasNoVisibleFiles returns true if the directory has no files/dirs after applying ignore rules. func dirHasNoVisibleFiles(dir string) (bool, error) { files, _, err := fsext.ListDirectory(dir, nil, 1, 1) if err != nil { @@ -99,11 +100,11 @@ func dirHasNoVisibleFiles(dir string) (bool, error) { return len(files) == 0, nil } -func MarkProjectInitialized(cfg *Config) error { - if cfg == nil { +func MarkProjectInitialized(store *ConfigStore) error { + if store == nil { return fmt.Errorf("config not loaded") } - flagFilePath := filepath.Join(cfg.Options.DataDirectory, InitFlagFilename) + flagFilePath := filepath.Join(store.Config().Options.DataDirectory, InitFlagFilename) file, err := os.Create(flagFilePath) if err != nil { @@ -114,13 +115,13 @@ func MarkProjectInitialized(cfg *Config) error { return nil } -func HasInitialDataConfig(cfg *Config) bool { - if cfg == nil { +func HasInitialDataConfig(store *ConfigStore) bool { + if store == nil { return false } cfgPath := GlobalConfigData() if _, err := os.Stat(cfgPath); err != nil { return false } - return cfg.IsConfigured() + return store.Config().IsConfigured() } diff --git a/internal/config/load.go b/internal/config/load.go index 3fba44aa9142c52b8966b1dbe994cef0ae654c48..0c63950e84434d7bebd20e39c25734541027a4d9 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -29,8 +29,9 @@ import ( const defaultCatwalkURL = "https://catwalk.charm.sh" -// Load loads the configuration from the default paths. -func Load(workingDir, dataDir string, debug bool) (*Config, error) { +// Load loads the configuration from the default paths and returns a +// ConfigStore that owns both the pure-data Config and all runtime state. +func Load(workingDir, dataDir string, debug bool) (*ConfigStore, error) { configPaths := lookupConfigs(workingDir) cfg, err := loadFromConfigPaths(configPaths) @@ -38,10 +39,15 @@ func Load(workingDir, dataDir string, debug bool) (*Config, error) { return nil, fmt.Errorf("failed to load config from paths %v: %w", configPaths, err) } - cfg.dataConfigDir = GlobalConfigData() - cfg.setDefaults(workingDir, dataDir) + store := &ConfigStore{ + config: cfg, + workingDir: workingDir, + globalDataPath: GlobalConfigData(), + workspacePath: filepath.Join(cfg.Options.DataDirectory, fmt.Sprintf("%s.json", appName)), + } + if debug { cfg.Options.Debug = true } @@ -52,6 +58,18 @@ func Load(workingDir, dataDir string, debug bool) (*Config, error) { cfg.Options.Debug, ) + // Load workspace config last so it has highest priority. + if wsData, err := os.ReadFile(store.workspacePath); err == nil && len(wsData) > 0 { + merged, mergeErr := loadFromBytes(append([][]byte{mustMarshalConfig(cfg)}, wsData)) + if mergeErr == nil { + // Preserve defaults that setDefaults already applied. + dataDir := cfg.Options.DataDirectory + *cfg = *merged + cfg.setDefaults(workingDir, dataDir) + store.config = cfg + } + } + if !isInsideWorktree() { const depth = 2 const items = 100 @@ -72,26 +90,36 @@ func Load(workingDir, dataDir string, debug bool) (*Config, error) { if err != nil { return nil, err } - cfg.knownProviders = providers + store.knownProviders = providers env := env.New() // Configure providers valueResolver := NewShellVariableResolver(env) - cfg.resolver = valueResolver - if err := cfg.configureProviders(env, valueResolver, cfg.knownProviders); err != nil { + store.resolver = valueResolver + if err := cfg.configureProviders(store, env, valueResolver, store.knownProviders); err != nil { return nil, fmt.Errorf("failed to configure providers: %w", err) } if !cfg.IsConfigured() { slog.Warn("No providers configured") - return cfg, nil + return store, nil } - if err := cfg.configureSelectedModels(cfg.knownProviders); err != nil { + if err := configureSelectedModels(store, store.knownProviders); err != nil { return nil, fmt.Errorf("failed to configure selected models: %w", err) } - cfg.SetupAgents() - return cfg, nil + store.SetupAgents() + return store, nil +} + +// mustMarshalConfig marshals the config to JSON bytes, returning empty JSON on +// error. +func mustMarshalConfig(cfg *Config) []byte { + data, err := json.Marshal(cfg) + if err != nil { + return []byte("{}") + } + return data } func PushPopCrushEnv() func() { @@ -122,7 +150,7 @@ func PushPopCrushEnv() func() { return restore } -func (c *Config) configureProviders(env env.Env, resolver VariableResolver, knownProviders []catwalk.Provider) error { +func (c *Config) configureProviders(store *ConfigStore, env env.Env, resolver VariableResolver, knownProviders []catwalk.Provider) error { knownProviderNames := make(map[string]bool) restore := PushPopCrushEnv() defer restore() @@ -209,7 +237,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know switch { case p.ID == catwalk.InferenceProviderAnthropic && config.OAuthToken != nil: // Claude Code subscription is not supported anymore. Remove to show onboarding. - c.RemoveConfigField("providers.anthropic") + store.RemoveConfigField(ScopeGlobal, "providers.anthropic") c.Providers.Del(string(p.ID)) continue case p.ID == catwalk.InferenceProviderCopilot && config.OAuthToken != nil: @@ -340,7 +368,6 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know } func (c *Config) setDefaults(workingDir, dataDir string) { - c.workingDir = workingDir if c.Options == nil { c.Options = &Options{} } @@ -524,7 +551,8 @@ func (c *Config) defaultModelSelection(knownProviders []catwalk.Provider) (large return largeModel, smallModel, err } -func (c *Config) configureSelectedModels(knownProviders []catwalk.Provider) error { +func configureSelectedModels(store *ConfigStore, knownProviders []catwalk.Provider) error { + c := store.config defaultLarge, defaultSmall, err := c.defaultModelSelection(knownProviders) if err != nil { return fmt.Errorf("failed to select default models: %w", err) @@ -543,7 +571,7 @@ func (c *Config) configureSelectedModels(knownProviders []catwalk.Provider) erro if model == nil { large = defaultLarge // override the model type to large - err := c.UpdatePreferredModel(SelectedModelTypeLarge, large) + err := store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeLarge, large) if err != nil { return fmt.Errorf("failed to update preferred large model: %w", err) } @@ -587,7 +615,7 @@ func (c *Config) configureSelectedModels(knownProviders []catwalk.Provider) erro if model == nil { small = defaultSmall // override the model type to small - err := c.UpdatePreferredModel(SelectedModelTypeSmall, small) + err := store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeSmall, small) if err != nil { return fmt.Errorf("failed to update preferred small model: %w", err) } diff --git a/internal/config/load_test.go b/internal/config/load_test.go index 93d2245193463e2a6539e23aeb0e16ac14c0ccef..62b1eaa2437116b0a051fe10183689650d388472 100644 --- a/internal/config/load_test.go +++ b/internal/config/load_test.go @@ -36,6 +36,11 @@ func TestConfig_LoadFromBytes(t *testing.T) { require.Equal(t, "https://api.openai.com/v2", pc.BaseURL) } +// testStore wraps a Config in a minimal ConfigStore for testing. +func testStore(cfg *Config) *ConfigStore { + return &ConfigStore{config: cfg} +} + func TestConfig_setDefaults(t *testing.T) { cfg := &Config{} @@ -53,7 +58,6 @@ func TestConfig_setDefaults(t *testing.T) { for _, path := range defaultContextPaths { require.Contains(t, cfg.Options.ContextPaths, path) } - require.Equal(t, "/tmp", cfg.workingDir) } func TestConfig_configureProviders(t *testing.T) { @@ -74,7 +78,7 @@ func TestConfig_configureProviders(t *testing.T) { "OPENAI_API_KEY": "test-key", }) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) require.Equal(t, 1, cfg.Providers.Len()) @@ -117,7 +121,7 @@ func TestConfig_configureProvidersWithOverride(t *testing.T) { "OPENAI_API_KEY": "test-key", }) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) require.Equal(t, 1, cfg.Providers.Len()) @@ -159,7 +163,7 @@ func TestConfig_configureProvidersWithNewProvider(t *testing.T) { "OPENAI_API_KEY": "test-key", }) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) // Should be to because of the env variable require.Equal(t, cfg.Providers.Len(), 2) @@ -195,7 +199,7 @@ func TestConfig_configureProvidersBedrockWithCredentials(t *testing.T) { "AWS_SECRET_ACCESS_KEY": "test-secret-key", }) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) require.Equal(t, cfg.Providers.Len(), 1) @@ -221,7 +225,7 @@ func TestConfig_configureProvidersBedrockWithoutCredentials(t *testing.T) { cfg.setDefaults("/tmp", "") env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) // Provider should not be configured without credentials require.Equal(t, cfg.Providers.Len(), 0) @@ -246,7 +250,7 @@ func TestConfig_configureProvidersBedrockWithoutUnsupportedModel(t *testing.T) { "AWS_SECRET_ACCESS_KEY": "test-secret-key", }) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.Error(t, err) } @@ -269,7 +273,7 @@ func TestConfig_configureProvidersVertexAIWithCredentials(t *testing.T) { "VERTEXAI_LOCATION": "us-central1", }) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) require.Equal(t, cfg.Providers.Len(), 1) @@ -301,7 +305,7 @@ func TestConfig_configureProvidersVertexAIWithoutCredentials(t *testing.T) { "GOOGLE_CLOUD_LOCATION": "us-central1", }) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) // Provider should not be configured without proper credentials require.Equal(t, cfg.Providers.Len(), 0) @@ -326,7 +330,7 @@ func TestConfig_configureProvidersVertexAIMissingProject(t *testing.T) { "GOOGLE_CLOUD_LOCATION": "us-central1", }) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) // Provider should not be configured without project require.Equal(t, cfg.Providers.Len(), 0) @@ -350,7 +354,7 @@ func TestConfig_configureProvidersSetProviderID(t *testing.T) { "OPENAI_API_KEY": "test-key", }) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) require.Equal(t, cfg.Providers.Len(), 1) @@ -541,7 +545,7 @@ func TestConfig_configureProvidersWithDisabledProvider(t *testing.T) { "OPENAI_API_KEY": "test-key", }) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) require.Equal(t, cfg.Providers.Len(), 1) @@ -569,7 +573,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) { env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, []catwalk.Provider{}) + err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{}) require.NoError(t, err) require.Equal(t, cfg.Providers.Len(), 1) @@ -592,7 +596,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) { env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, []catwalk.Provider{}) + err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{}) require.NoError(t, err) require.Equal(t, cfg.Providers.Len(), 0) @@ -614,7 +618,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) { env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, []catwalk.Provider{}) + err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{}) require.NoError(t, err) require.Equal(t, cfg.Providers.Len(), 0) @@ -639,7 +643,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) { env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, []catwalk.Provider{}) + err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{}) require.NoError(t, err) require.Equal(t, cfg.Providers.Len(), 0) @@ -664,7 +668,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) { env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, []catwalk.Provider{}) + err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{}) require.NoError(t, err) require.Equal(t, cfg.Providers.Len(), 1) @@ -692,7 +696,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) { env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, []catwalk.Provider{}) + err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{}) require.NoError(t, err) require.Equal(t, cfg.Providers.Len(), 1) @@ -722,7 +726,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) { env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, []catwalk.Provider{}) + err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{}) require.NoError(t, err) require.Equal(t, cfg.Providers.Len(), 0) @@ -757,7 +761,7 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) { "GOOGLE_GENAI_USE_VERTEXAI": "false", }) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) require.Equal(t, cfg.Providers.Len(), 0) @@ -788,7 +792,7 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) { env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) require.Equal(t, cfg.Providers.Len(), 0) @@ -819,7 +823,7 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) { env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) require.Equal(t, cfg.Providers.Len(), 0) @@ -852,7 +856,7 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) { "OPENAI_API_KEY": "test-key", }) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) require.Equal(t, cfg.Providers.Len(), 1) @@ -886,7 +890,7 @@ func TestConfig_defaultModelSelection(t *testing.T) { cfg.setDefaults("/tmp", "") env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) large, small, err := cfg.defaultModelSelection(knownProviders) @@ -922,7 +926,7 @@ func TestConfig_defaultModelSelection(t *testing.T) { cfg.setDefaults("/tmp", "") env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) _, _, err = cfg.defaultModelSelection(knownProviders) @@ -952,7 +956,7 @@ func TestConfig_defaultModelSelection(t *testing.T) { cfg.setDefaults("/tmp", "") env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) _, _, err = cfg.defaultModelSelection(knownProviders) require.Error(t, err) @@ -995,7 +999,7 @@ func TestConfig_defaultModelSelection(t *testing.T) { cfg.setDefaults("/tmp", "") env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) large, small, err := cfg.defaultModelSelection(knownProviders) require.NoError(t, err) @@ -1039,7 +1043,7 @@ func TestConfig_defaultModelSelection(t *testing.T) { cfg.setDefaults("/tmp", "") env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) _, _, err = cfg.defaultModelSelection(knownProviders) require.Error(t, err) @@ -1081,7 +1085,7 @@ func TestConfig_defaultModelSelection(t *testing.T) { cfg.setDefaults("/tmp", "") env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) large, small, err := cfg.defaultModelSelection(knownProviders) require.NoError(t, err) @@ -1126,7 +1130,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) { "OPENAI_API_KEY": "test-key", }) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.ErrorContains(t, err, "no custom providers") // openai should NOT be present because it lacks base_url and models. @@ -1169,7 +1173,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) { "OPENAI_API_KEY": "test-key", }) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) // Only fully specified provider should be present. @@ -1223,7 +1227,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) { "ANTHROPIC_API_KEY": "test-key", }) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) // Both providers should be present. @@ -1251,7 +1255,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) { env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, []catwalk.Provider{}) + err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{}) require.ErrorContains(t, err, "no custom providers") // Provider should be rejected for missing models. @@ -1275,7 +1279,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) { env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, []catwalk.Provider{}) + err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{}) require.ErrorContains(t, err, "no custom providers") // Provider should be rejected for missing base_url. @@ -1340,10 +1344,10 @@ func TestConfig_configureSelectedModels(t *testing.T) { cfg.setDefaults("/tmp", "") env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) - err = cfg.configureSelectedModels(knownProviders) + err = configureSelectedModels(testStore(cfg), knownProviders) require.NoError(t, err) large := cfg.Models[SelectedModelTypeLarge] small := cfg.Models[SelectedModelTypeSmall] @@ -1402,10 +1406,10 @@ func TestConfig_configureSelectedModels(t *testing.T) { cfg.setDefaults("/tmp", "") env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) - err = cfg.configureSelectedModels(knownProviders) + err = configureSelectedModels(testStore(cfg), knownProviders) require.NoError(t, err) large := cfg.Models[SelectedModelTypeLarge] small := cfg.Models[SelectedModelTypeSmall] @@ -1447,10 +1451,10 @@ func TestConfig_configureSelectedModels(t *testing.T) { cfg.setDefaults("/tmp", "") env := env.NewFromMap(map[string]string{}) resolver := NewEnvironmentVariableResolver(env) - err := cfg.configureProviders(env, resolver, knownProviders) + err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders) require.NoError(t, err) - err = cfg.configureSelectedModels(knownProviders) + err = configureSelectedModels(testStore(cfg), knownProviders) require.NoError(t, err) large := cfg.Models[SelectedModelTypeLarge] require.Equal(t, "large-model", large.Model) diff --git a/internal/config/recent_models_test.go b/internal/config/recent_models_test.go index 739ddc0031a65cab261723772c3f38658dcd1561..7c46d5d5202927932ed154a4da8b0719ce9e114e 100644 --- a/internal/config/recent_models_test.go +++ b/internal/config/recent_models_test.go @@ -31,15 +31,23 @@ func readRecentModels(t *testing.T, path string) map[string]any { return rm } +// testStoreWithPath creates a ConfigStore backed by a Config for recent model tests. +func testStoreWithPath(cfg *Config, dir string) *ConfigStore { + return &ConfigStore{ + config: cfg, + globalDataPath: filepath.Join(dir, "config.json"), + } +} + func TestRecordRecentModel_AddsAndPersists(t *testing.T) { t.Parallel() dir := t.TempDir() cfg := &Config{} cfg.setDefaults(dir, "") - cfg.dataConfigDir = filepath.Join(dir, "config.json") + store := testStoreWithPath(cfg, dir) - err := cfg.recordRecentModel(SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"}) + err := store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"}) require.NoError(t, err) // in-memory state @@ -48,7 +56,7 @@ func TestRecordRecentModel_AddsAndPersists(t *testing.T) { require.Equal(t, "gpt-4o", cfg.RecentModels[SelectedModelTypeLarge][0].Model) // persisted state - rm := readRecentModels(t, cfg.dataConfigDir) + rm := readRecentModels(t, store.globalDataPath) large, ok := rm[string(SelectedModelTypeLarge)].([]any) require.True(t, ok) require.Len(t, large, 1) @@ -64,13 +72,13 @@ func TestRecordRecentModel_DedupeAndMoveToFront(t *testing.T) { dir := t.TempDir() cfg := &Config{} cfg.setDefaults(dir, "") - cfg.dataConfigDir = filepath.Join(dir, "config.json") + store := testStoreWithPath(cfg, dir) // Add two entries - require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"})) - require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, SelectedModel{Provider: "anthropic", Model: "claude"})) + require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"})) + require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, SelectedModel{Provider: "anthropic", Model: "claude"})) // Re-add first; should move to front and not duplicate - require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"})) + require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"})) got := cfg.RecentModels[SelectedModelTypeLarge] require.Len(t, got, 2) @@ -84,7 +92,7 @@ func TestRecordRecentModel_TrimsToMax(t *testing.T) { dir := t.TempDir() cfg := &Config{} cfg.setDefaults(dir, "") - cfg.dataConfigDir = filepath.Join(dir, "config.json") + store := testStoreWithPath(cfg, dir) // Insert 6 unique models; max is 5 entries := []SelectedModel{ @@ -96,7 +104,7 @@ func TestRecordRecentModel_TrimsToMax(t *testing.T) { {Provider: "p6", Model: "m6"}, } for _, e := range entries { - require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, e)) + require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, e)) } // in-memory state @@ -110,7 +118,7 @@ func TestRecordRecentModel_TrimsToMax(t *testing.T) { require.Equal(t, SelectedModel{Provider: "p2", Model: "m2"}, got[4]) // persisted state: verify trimmed to 5 and newest-first order - rm := readRecentModels(t, cfg.dataConfigDir) + rm := readRecentModels(t, store.globalDataPath) large, ok := rm[string(SelectedModelTypeLarge)].([]any) require.True(t, ok) require.Len(t, large, 5) @@ -129,12 +137,12 @@ func TestRecordRecentModel_SkipsEmptyValues(t *testing.T) { dir := t.TempDir() cfg := &Config{} cfg.setDefaults(dir, "") - cfg.dataConfigDir = filepath.Join(dir, "config.json") + store := testStoreWithPath(cfg, dir) // Missing provider - require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, SelectedModel{Provider: "", Model: "m"})) + require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, SelectedModel{Provider: "", Model: "m"})) // Missing model - require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, SelectedModel{Provider: "p", Model: ""})) + require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, SelectedModel{Provider: "p", Model: ""})) _, ok := cfg.RecentModels[SelectedModelTypeLarge] // Map may be initialized, but should have no entries @@ -142,8 +150,8 @@ func TestRecordRecentModel_SkipsEmptyValues(t *testing.T) { require.Len(t, cfg.RecentModels[SelectedModelTypeLarge], 0) } // No file should be written (stat via fs.FS) - baseDir := filepath.Dir(cfg.dataConfigDir) - fileName := filepath.Base(cfg.dataConfigDir) + baseDir := filepath.Dir(store.globalDataPath) + fileName := filepath.Base(store.globalDataPath) _, err := fs.Stat(os.DirFS(baseDir), fileName) require.True(t, os.IsNotExist(err)) } @@ -154,13 +162,13 @@ func TestRecordRecentModel_NoPersistOnNoop(t *testing.T) { dir := t.TempDir() cfg := &Config{} cfg.setDefaults(dir, "") - cfg.dataConfigDir = filepath.Join(dir, "config.json") + store := testStoreWithPath(cfg, dir) entry := SelectedModel{Provider: "openai", Model: "gpt-4o"} - require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, entry)) + require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, entry)) - baseDir := filepath.Dir(cfg.dataConfigDir) - fileName := filepath.Base(cfg.dataConfigDir) + baseDir := filepath.Dir(store.globalDataPath) + fileName := filepath.Base(store.globalDataPath) before, err := fs.ReadFile(os.DirFS(baseDir), fileName) require.NoError(t, err) @@ -170,7 +178,7 @@ func TestRecordRecentModel_NoPersistOnNoop(t *testing.T) { beforeMod := stBefore.ModTime() // Re-record same entry should be a no-op (no write) - require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, entry)) + require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, entry)) after, err := fs.ReadFile(os.DirFS(baseDir), fileName) require.NoError(t, err) @@ -188,17 +196,17 @@ func TestUpdatePreferredModel_UpdatesRecents(t *testing.T) { dir := t.TempDir() cfg := &Config{} cfg.setDefaults(dir, "") - cfg.dataConfigDir = filepath.Join(dir, "config.json") + store := testStoreWithPath(cfg, dir) sel := SelectedModel{Provider: "openai", Model: "gpt-4o"} - require.NoError(t, cfg.UpdatePreferredModel(SelectedModelTypeSmall, sel)) + require.NoError(t, store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeSmall, sel)) // in-memory require.Equal(t, sel, cfg.Models[SelectedModelTypeSmall]) require.Len(t, cfg.RecentModels[SelectedModelTypeSmall], 1) // persisted (read via fs.FS) - rm := readRecentModels(t, cfg.dataConfigDir) + rm := readRecentModels(t, store.globalDataPath) small, ok := rm[string(SelectedModelTypeSmall)].([]any) require.True(t, ok) require.Len(t, small, 1) @@ -210,14 +218,14 @@ func TestRecordRecentModel_TypeIsolation(t *testing.T) { dir := t.TempDir() cfg := &Config{} cfg.setDefaults(dir, "") - cfg.dataConfigDir = filepath.Join(dir, "config.json") + store := testStoreWithPath(cfg, dir) // Add models to both large and small types largeModel := SelectedModel{Provider: "openai", Model: "gpt-4o"} smallModel := SelectedModel{Provider: "anthropic", Model: "claude"} - require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, largeModel)) - require.NoError(t, cfg.recordRecentModel(SelectedModelTypeSmall, smallModel)) + require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, largeModel)) + require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeSmall, smallModel)) // in-memory: verify types maintain separate histories require.Len(t, cfg.RecentModels[SelectedModelTypeLarge], 1) @@ -227,14 +235,14 @@ func TestRecordRecentModel_TypeIsolation(t *testing.T) { // Add another to large, verify small unchanged anotherLarge := SelectedModel{Provider: "google", Model: "gemini"} - require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, anotherLarge)) + require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, anotherLarge)) require.Len(t, cfg.RecentModels[SelectedModelTypeLarge], 2) require.Len(t, cfg.RecentModels[SelectedModelTypeSmall], 1) require.Equal(t, smallModel, cfg.RecentModels[SelectedModelTypeSmall][0]) // persisted state: verify both types exist with correct lengths and contents - rm := readRecentModels(t, cfg.dataConfigDir) + rm := readRecentModels(t, store.globalDataPath) large, ok := rm[string(SelectedModelTypeLarge)].([]any) require.True(t, ok) diff --git a/internal/config/scope.go b/internal/config/scope.go new file mode 100644 index 0000000000000000000000000000000000000000..971ce32c3ed662dd0d0627c4f1c858372f3b4514 --- /dev/null +++ b/internal/config/scope.go @@ -0,0 +1,11 @@ +package config + +// Scope determines which config file is targeted for read/write operations. +type Scope int + +const ( + // ScopeGlobal targets the global data config (~/.local/share/crush/crush.json). + ScopeGlobal Scope = iota + // ScopeWorkspace targets the workspace config (.crush/crush.json). + ScopeWorkspace +) diff --git a/internal/config/store.go b/internal/config/store.go new file mode 100644 index 0000000000000000000000000000000000000000..4dfe6130bc23007ec3df12e5a88cb53bc3ad5a2d --- /dev/null +++ b/internal/config/store.go @@ -0,0 +1,336 @@ +package config + +import ( + "cmp" + "context" + "fmt" + "log/slog" + "os" + "path/filepath" + "slices" + + "charm.land/catwalk/pkg/catwalk" + hyperp "github.com/charmbracelet/crush/internal/agent/hyper" + "github.com/charmbracelet/crush/internal/oauth" + "github.com/charmbracelet/crush/internal/oauth/copilot" + "github.com/charmbracelet/crush/internal/oauth/hyper" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// ConfigStore is the single entry point for all config access. It owns the +// pure-data Config, runtime state (working directory, resolver, known +// providers), and persistence to both global and workspace config files. +type ConfigStore struct { + config *Config + workingDir string + resolver VariableResolver + globalDataPath string // ~/.local/share/crush/crush.json + workspacePath string // .crush/crush.json + knownProviders []catwalk.Provider +} + +// Config returns the pure-data config struct (read-only after load). +func (s *ConfigStore) Config() *Config { + return s.config +} + +// WorkingDir returns the current working directory. +func (s *ConfigStore) WorkingDir() string { + return s.workingDir +} + +// Resolver returns the variable resolver. +func (s *ConfigStore) Resolver() VariableResolver { + return s.resolver +} + +// Resolve resolves a variable reference using the configured resolver. +func (s *ConfigStore) Resolve(key string) (string, error) { + if s.resolver == nil { + return "", fmt.Errorf("no variable resolver configured") + } + return s.resolver.ResolveValue(key) +} + +// KnownProviders returns the list of known providers. +func (s *ConfigStore) KnownProviders() []catwalk.Provider { + return s.knownProviders +} + +// SetupAgents configures the coder and task agents on the config. +func (s *ConfigStore) SetupAgents() { + s.config.SetupAgents() +} + +// configPath returns the file path for the given scope. +func (s *ConfigStore) configPath(scope Scope) string { + switch scope { + case ScopeWorkspace: + return s.workspacePath + default: + return s.globalDataPath + } +} + +// HasConfigField checks whether a key exists in the config file for the given +// scope. +func (s *ConfigStore) HasConfigField(scope Scope, key string) bool { + data, err := os.ReadFile(s.configPath(scope)) + if err != nil { + return false + } + return gjson.Get(string(data), key).Exists() +} + +// SetConfigField sets a key/value pair in the config file for the given scope. +func (s *ConfigStore) SetConfigField(scope Scope, key string, value any) error { + path := s.configPath(scope) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + data = []byte("{}") + } else { + return fmt.Errorf("failed to read config file: %w", err) + } + } + + newValue, err := sjson.Set(string(data), key, value) + if err != nil { + return fmt.Errorf("failed to set config field %s: %w", key, err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("failed to create config directory %q: %w", path, err) + } + if err := os.WriteFile(path, []byte(newValue), 0o600); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + return nil +} + +// RemoveConfigField removes a key from the config file for the given scope. +func (s *ConfigStore) RemoveConfigField(scope Scope, key string) error { + path := s.configPath(scope) + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read config file: %w", err) + } + + newValue, err := sjson.Delete(string(data), key) + if err != nil { + return fmt.Errorf("failed to delete config field %s: %w", key, err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("failed to create config directory %q: %w", path, err) + } + if err := os.WriteFile(path, []byte(newValue), 0o600); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + return nil +} + +// UpdatePreferredModel updates the preferred model for the given type and +// persists it to the config file at the given scope. +func (s *ConfigStore) UpdatePreferredModel(scope Scope, modelType SelectedModelType, model SelectedModel) error { + s.config.Models[modelType] = model + if err := s.SetConfigField(scope, fmt.Sprintf("models.%s", modelType), model); err != nil { + return fmt.Errorf("failed to update preferred model: %w", err) + } + if err := s.recordRecentModel(scope, modelType, model); err != nil { + return err + } + return nil +} + +// SetCompactMode sets the compact mode setting and persists it. +func (s *ConfigStore) SetCompactMode(scope Scope, enabled bool) error { + if s.config.Options == nil { + s.config.Options = &Options{} + } + s.config.Options.TUI.CompactMode = enabled + return s.SetConfigField(scope, "options.tui.compact_mode", enabled) +} + +// SetProviderAPIKey sets the API key for a provider and persists it. +func (s *ConfigStore) SetProviderAPIKey(scope Scope, providerID string, apiKey any) error { + var providerConfig ProviderConfig + var exists bool + var setKeyOrToken func() + + switch v := apiKey.(type) { + case string: + if err := s.SetConfigField(scope, fmt.Sprintf("providers.%s.api_key", providerID), v); err != nil { + return fmt.Errorf("failed to save api key to config file: %w", err) + } + setKeyOrToken = func() { providerConfig.APIKey = v } + case *oauth.Token: + if err := cmp.Or( + s.SetConfigField(scope, fmt.Sprintf("providers.%s.api_key", providerID), v.AccessToken), + s.SetConfigField(scope, fmt.Sprintf("providers.%s.oauth", providerID), v), + ); err != nil { + return err + } + setKeyOrToken = func() { + providerConfig.APIKey = v.AccessToken + providerConfig.OAuthToken = v + switch providerID { + case string(catwalk.InferenceProviderCopilot): + providerConfig.SetupGitHubCopilot() + } + } + } + + providerConfig, exists = s.config.Providers.Get(providerID) + if exists { + setKeyOrToken() + s.config.Providers.Set(providerID, providerConfig) + return nil + } + + var foundProvider *catwalk.Provider + for _, p := range s.knownProviders { + if string(p.ID) == providerID { + foundProvider = &p + break + } + } + + if foundProvider != nil { + providerConfig = ProviderConfig{ + ID: providerID, + Name: foundProvider.Name, + BaseURL: foundProvider.APIEndpoint, + Type: foundProvider.Type, + Disable: false, + ExtraHeaders: make(map[string]string), + ExtraParams: make(map[string]string), + Models: foundProvider.Models, + } + setKeyOrToken() + } else { + return fmt.Errorf("provider with ID %s not found in known providers", providerID) + } + s.config.Providers.Set(providerID, providerConfig) + return nil +} + +// RefreshOAuthToken refreshes the OAuth token for the given provider. +func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, providerID string) error { + providerConfig, exists := s.config.Providers.Get(providerID) + if !exists { + return fmt.Errorf("provider %s not found", providerID) + } + + if providerConfig.OAuthToken == nil { + return fmt.Errorf("provider %s does not have an OAuth token", providerID) + } + + var newToken *oauth.Token + var refreshErr error + switch providerID { + case string(catwalk.InferenceProviderCopilot): + newToken, refreshErr = copilot.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken) + case hyperp.Name: + newToken, refreshErr = hyper.ExchangeToken(ctx, providerConfig.OAuthToken.RefreshToken) + default: + return fmt.Errorf("OAuth refresh not supported for provider %s", providerID) + } + if refreshErr != nil { + return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, refreshErr) + } + + slog.Info("Successfully refreshed OAuth token", "provider", providerID) + providerConfig.OAuthToken = newToken + providerConfig.APIKey = newToken.AccessToken + + switch providerID { + case string(catwalk.InferenceProviderCopilot): + providerConfig.SetupGitHubCopilot() + } + + s.config.Providers.Set(providerID, providerConfig) + + if err := cmp.Or( + s.SetConfigField(scope, fmt.Sprintf("providers.%s.api_key", providerID), newToken.AccessToken), + s.SetConfigField(scope, fmt.Sprintf("providers.%s.oauth", providerID), newToken), + ); err != nil { + return fmt.Errorf("failed to persist refreshed token: %w", err) + } + + return nil +} + +// recordRecentModel records a model in the recent models list. +func (s *ConfigStore) recordRecentModel(scope Scope, modelType SelectedModelType, model SelectedModel) error { + if model.Provider == "" || model.Model == "" { + return nil + } + + if s.config.RecentModels == nil { + s.config.RecentModels = make(map[SelectedModelType][]SelectedModel) + } + + eq := func(a, b SelectedModel) bool { + return a.Provider == b.Provider && a.Model == b.Model + } + + entry := SelectedModel{ + Provider: model.Provider, + Model: model.Model, + } + + current := s.config.RecentModels[modelType] + withoutCurrent := slices.DeleteFunc(slices.Clone(current), func(existing SelectedModel) bool { + return eq(existing, entry) + }) + + updated := append([]SelectedModel{entry}, withoutCurrent...) + if len(updated) > maxRecentModelsPerType { + updated = updated[:maxRecentModelsPerType] + } + + if slices.EqualFunc(current, updated, eq) { + return nil + } + + s.config.RecentModels[modelType] = updated + + if err := s.SetConfigField(scope, fmt.Sprintf("recent_models.%s", modelType), updated); err != nil { + return fmt.Errorf("failed to persist recent models: %w", err) + } + + return nil +} + +// ImportCopilot attempts to import a GitHub Copilot token from disk. +func (s *ConfigStore) ImportCopilot() (*oauth.Token, bool) { + if s.HasConfigField(ScopeGlobal, "providers.copilot.api_key") || s.HasConfigField(ScopeGlobal, "providers.copilot.oauth") { + return nil, false + } + + diskToken, hasDiskToken := copilot.RefreshTokenFromDisk() + if !hasDiskToken { + return nil, false + } + + slog.Info("Found existing GitHub Copilot token on disk. Authenticating...") + token, err := copilot.RefreshToken(context.TODO(), diskToken) + if err != nil { + slog.Error("Unable to import GitHub Copilot token", "error", err) + return nil, false + } + + if err := s.SetProviderAPIKey(ScopeGlobal, string(catwalk.InferenceProviderCopilot), token); err != nil { + return token, false + } + + if err := cmp.Or( + s.SetConfigField(ScopeGlobal, "providers.copilot.api_key", token.AccessToken), + s.SetConfigField(ScopeGlobal, "providers.copilot.oauth", token), + ); err != nil { + slog.Error("Unable to save GitHub Copilot token to disk", "error", err) + } + + slog.Info("GitHub Copilot successfully imported") + return token, true +} diff --git a/internal/lsp/manager.go b/internal/lsp/manager.go index 13a78cef2a471a71c1e741e32e08e8d7edcb7484..b564c0e602c0234462a32cfaae67c8f8179551c4 100644 --- a/internal/lsp/manager.go +++ b/internal/lsp/manager.go @@ -26,18 +26,18 @@ var unavailable = csync.NewMap[string, struct{}]() // Manager handles lazy initialization of LSP clients based on file types. type Manager struct { clients *csync.Map[string, *Client] - cfg *config.Config + cfg *config.ConfigStore manager *powernapconfig.Manager callback func(name string, client *Client) } // NewManager creates a new LSP manager service. -func NewManager(cfg *config.Config) *Manager { +func NewManager(cfg *config.ConfigStore) *Manager { manager := powernapconfig.NewManager() manager.LoadDefaults() // Merge user-configured LSPs into the manager. - for name, clientConfig := range cfg.LSP { + for name, clientConfig := range cfg.Config().LSP { if clientConfig.Disabled { slog.Debug("LSP disabled by user config", "name", name) manager.RemoveServer(name) @@ -194,7 +194,7 @@ func (s *Manager) startServer(ctx context.Context, name, filepath string, server cfg, s.cfg.Resolver(), s.cfg.WorkingDir(), - s.cfg.Options.DebugLSP, + s.cfg.Config().Options.DebugLSP, ) if err != nil { slog.Error("Failed to create LSP client", "name", name, "error", err) @@ -244,7 +244,7 @@ func (s *Manager) startServer(ctx context.Context, name, filepath string, server } func (s *Manager) isUserConfigured(name string) bool { - cfg, ok := s.cfg.LSP[name] + cfg, ok := s.cfg.Config().LSP[name] return ok && !cfg.Disabled } @@ -258,7 +258,7 @@ func (s *Manager) buildConfig(name string, server *powernapconfig.ServerConfig) InitOptions: server.InitOptions, Options: server.Settings, } - if userCfg, ok := s.cfg.LSP[name]; ok { + if userCfg, ok := s.cfg.Config().LSP[name]; ok { cfg.Timeout = userCfg.Timeout } return cfg diff --git a/internal/ui/common/common.go b/internal/ui/common/common.go index 6e7c632474389aa5455295e4132818941bc18244..143b20305464da33d2f350a36176bab0e45b85aa 100644 --- a/internal/ui/common/common.go +++ b/internal/ui/common/common.go @@ -26,11 +26,16 @@ type Common struct { Styles *styles.Styles } -// Config returns the configuration associated with this [Common] instance. +// Config returns the pure-data configuration associated with this [Common] instance. func (c *Common) Config() *config.Config { return c.App.Config() } +// Store returns the config store associated with this [Common] instance. +func (c *Common) Store() *config.ConfigStore { + return c.App.Store() +} + // DefaultCommon returns the default common UI configurations. func DefaultCommon(app *app.App) *Common { s := styles.DefaultStyles() diff --git a/internal/ui/dialog/api_key_input.go b/internal/ui/dialog/api_key_input.go index 9677763b2f4f2436376f5bf16ab58aed79140c68..cc37d742903d5a80bbcffcf1ff24fb24596dfccd 100644 --- a/internal/ui/dialog/api_key_input.go +++ b/internal/ui/dialog/api_key_input.go @@ -296,7 +296,7 @@ func (m *APIKeyInput) verifyAPIKey() tea.Msg { Type: m.provider.Type, BaseURL: m.provider.APIEndpoint, } - err := providerConfig.TestConnection(m.com.Config().Resolver()) + err := providerConfig.TestConnection(m.com.Store().Resolver()) // intentionally wait for at least 750ms to make sure the user sees the spinner elapsed := time.Since(start) @@ -312,9 +312,9 @@ func (m *APIKeyInput) verifyAPIKey() tea.Msg { } func (m *APIKeyInput) saveKeyAndContinue() Action { - cfg := m.com.Config() + store := m.com.Store() - err := cfg.SetProviderAPIKey(string(m.provider.ID), m.input.Value()) + err := store.SetProviderAPIKey(config.ScopeGlobal, string(m.provider.ID), m.input.Value()) if err != nil { return ActionCmd{util.ReportError(fmt.Errorf("failed to save API key: %w", err))} } diff --git a/internal/ui/dialog/filepicker.go b/internal/ui/dialog/filepicker.go index 4b0b844e4ed869a4347af10e9d0b1b3c70a7d2f0..78f82a05f7e2e0db7a9bb561fb1b6248d8045513 100644 --- a/internal/ui/dialog/filepicker.go +++ b/internal/ui/dialog/filepicker.go @@ -123,7 +123,7 @@ func (f *FilePicker) SetImageCapabilities(caps *common.Capabilities) { // WorkingDir returns the current working directory of the [FilePicker]. func (f *FilePicker) WorkingDir() string { - wd := f.com.Config().WorkingDir() + wd := f.com.Store().WorkingDir() if len(wd) > 0 { return wd } diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index 977f04a61e98f79adb9bb35777fac905508f47d5..434f699e91b4c227c4e54f6ff553affff76a1c43 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -490,7 +490,7 @@ func (m *Models) setProviderItems() error { if len(validRecentItems) != len(recentItems) { // FIXME: Does this need to be here? Is it mutating the config during a read? - if err := cfg.SetConfigField(fmt.Sprintf("recent_models.%s", selectedType), validRecentItems); err != nil { + if err := m.com.Store().SetConfigField(config.ScopeGlobal, fmt.Sprintf("recent_models.%s", selectedType), validRecentItems); err != nil { return fmt.Errorf("failed to update recent models: %w", err) } } diff --git a/internal/ui/dialog/oauth.go b/internal/ui/dialog/oauth.go index 93d5fe052db11d036d29d7790810807d5630bb57..2803070381e65bd0380a8ddab5f256481c117c15 100644 --- a/internal/ui/dialog/oauth.go +++ b/internal/ui/dialog/oauth.go @@ -373,9 +373,9 @@ func (d *OAuth) copyCodeAndOpenURL() tea.Cmd { } func (m *OAuth) saveKeyAndContinue() Action { - cfg := m.com.Config() + store := m.com.Store() - err := cfg.SetProviderAPIKey(string(m.provider.ID), m.token) + err := store.SetProviderAPIKey(config.ScopeGlobal, string(m.provider.ID), m.token) if err != nil { return ActionCmd{util.ReportError(fmt.Errorf("failed to save API key: %w", err))} } diff --git a/internal/ui/model/header.go b/internal/ui/model/header.go index 24254a0f69e5803e4bcbe89274f21db5b04ef541..06bb4ff92981b28625efb11683081e29fc55a21e 100644 --- a/internal/ui/model/header.go +++ b/internal/ui/model/header.go @@ -143,8 +143,7 @@ func renderHeaderDetails( metadata = dot + metadata const dirTrimLimit = 4 - cfg := com.Config() - cwd := fsext.DirTrim(fsext.PrettyPath(cfg.WorkingDir()), dirTrimLimit) + cwd := fsext.DirTrim(fsext.PrettyPath(com.Store().WorkingDir()), dirTrimLimit) cwd = t.Header.WorkingDir.Render(cwd) result := cwd + metadata diff --git a/internal/ui/model/landing.go b/internal/ui/model/landing.go index 45d376ff5ddc691b978e438ddef04a702af100f9..72c2671ccd297f4bade087f6b2cb960f6c6a92a9 100644 --- a/internal/ui/model/landing.go +++ b/internal/ui/model/landing.go @@ -22,7 +22,7 @@ func (m *UI) selectedLargeModel() *agent.Model { func (m *UI) landingView() string { t := m.com.Styles width := m.layout.main.Dx() - cwd := common.PrettyPath(t, m.com.Config().WorkingDir(), width) + cwd := common.PrettyPath(t, m.com.Store().WorkingDir(), width) parts := []string{ cwd, diff --git a/internal/ui/model/onboarding.go b/internal/ui/model/onboarding.go index 075067d75333fc539152f0041b4e5a3c2eed1c5e..5bba37ea8599944df77602014aa8c8d61dd73e80 100644 --- a/internal/ui/model/onboarding.go +++ b/internal/ui/model/onboarding.go @@ -19,7 +19,7 @@ import ( // markProjectInitialized marks the current project as initialized in the config. func (m *UI) markProjectInitialized() tea.Msg { // TODO: handle error so we show it in the tui footer - err := config.MarkProjectInitialized(m.com.Config()) + err := config.MarkProjectInitialized(m.com.Store()) if err != nil { slog.Error(err.Error()) } @@ -52,10 +52,10 @@ func (m *UI) initializeProject() tea.Cmd { if cmd := m.newSession(); cmd != nil { cmds = append(cmds, cmd) } - cfg := m.com.Config() + cfg := m.com.Store() initialize := func() tea.Msg { - initPrompt, err := agent.InitializePrompt(*cfg) + initPrompt, err := agent.InitializePrompt(cfg) if err != nil { return util.InfoMsg{Type: util.InfoTypeError, Msg: err.Error()} } @@ -77,10 +77,9 @@ func (m *UI) skipInitializeProject() tea.Cmd { // initializeView renders the project initialization prompt with Yes/No buttons. func (m *UI) initializeView() string { - cfg := m.com.Config() s := m.com.Styles.Initialize - cwd := home.Short(cfg.WorkingDir()) - initFile := cfg.Options.InitializeAs + cwd := home.Short(m.com.Store().WorkingDir()) + initFile := m.com.Config().Options.InitializeAs header := s.Header.Render("Would you like to initialize this project?") path := s.Accent.PaddingLeft(2).Render(cwd) diff --git a/internal/ui/model/sidebar.go b/internal/ui/model/sidebar.go index 88113a593034b09ed8d2859bc7628a103f5728b1..8849d86a8e1c8bda02092e3f165e85b8e32a8b1d 100644 --- a/internal/ui/model/sidebar.go +++ b/internal/ui/model/sidebar.go @@ -112,7 +112,7 @@ func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) { height := area.Dy() title := t.Muted.Width(width).MaxHeight(2).Render(m.session.Title) - cwd := common.PrettyPath(t, m.com.Config().WorkingDir(), width) + cwd := common.PrettyPath(t, m.com.Store().WorkingDir(), width) sidebarLogo := m.sidebarLogo if height < logoHeightBreakpoint { sidebarLogo = logo.SmallRender(m.com.Styles, width) @@ -138,7 +138,7 @@ func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) { lspSection := m.lspInfo(width, maxLSPs, true) mcpSection := m.mcpInfo(width, maxMCPs, true) - filesSection := m.filesInfo(m.com.Config().WorkingDir(), width, maxFiles, true) + filesSection := m.filesInfo(m.com.Store().WorkingDir(), width, maxFiles, true) uv.NewStyledString( lipgloss.NewStyle(). diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 89b3b37608500f1a02eea98d4ebfabeba262bcd1..66d57321824833e91b78539357d55013e4322e87 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -317,7 +317,7 @@ func New(com *common.Common) *UI { desiredFocus := uiFocusEditor if !com.Config().IsConfigured() { desiredState = uiOnboarding - } else if n, _ := config.ProjectNeedsInitialization(com.Config()); n { + } else if n, _ := config.ProjectNeedsInitialization(com.Store()); n { desiredState = uiInitialize } @@ -579,7 +579,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case mcp.EventPromptsListChanged: return m, handleMCPPromptsEvent(msg.Payload.Name) case mcp.EventToolsListChanged: - return m, handleMCPToolsEvent(m.com.Config(), msg.Payload.Name) + return m, handleMCPToolsEvent(m.com.Store(), msg.Payload.Name) case mcp.EventResourcesListChanged: return m, handleMCPResourcesEvent(msg.Payload.Name) } @@ -1301,7 +1301,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { currentModel := cfg.Models[agentCfg.Model] currentModel.Think = !currentModel.Think - if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil { + if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil { return util.ReportError(err)() } m.com.App.UpdateAgentModel(context.TODO()) @@ -1342,7 +1342,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { // Attempt to import GitHub Copilot tokens from VSCode if available. if isCopilot && !isConfigured() && !msg.ReAuthenticate { - m.com.Config().ImportCopilot() + m.com.Store().ImportCopilot() } if !isConfigured() || msg.ReAuthenticate { @@ -1353,12 +1353,12 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { break } - if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil { + if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, msg.ModelType, msg.Model); err != nil { cmds = append(cmds, util.ReportError(err)) } else if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok { // Ensure small model is set is unset. smallModel := m.com.App.GetDefaultSmallModel(providerID) - if err := cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, smallModel); err != nil { + if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, config.SelectedModelTypeSmall, smallModel); err != nil { cmds = append(cmds, util.ReportError(err)) } } @@ -1404,7 +1404,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { currentModel := cfg.Models[agentCfg.Model] currentModel.ReasoningEffort = msg.Effort - if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil { + if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil { cmds = append(cmds, util.ReportError(err)) break } @@ -2016,7 +2016,7 @@ func (m *UI) View() tea.View { } v.MouseMode = tea.MouseModeCellMotion v.ReportFocus = m.caps.ReportFocusEvents - v.WindowTitle = "crush " + home.Short(m.com.Config().WorkingDir()) + v.WindowTitle = "crush " + home.Short(m.com.Store().WorkingDir()) canvas := uv.NewScreenBuffer(m.width, m.height) v.Cursor = m.Draw(canvas, canvas.Bounds()) @@ -2255,7 +2255,7 @@ func (m *UI) FullHelp() [][]key.Binding { func (m *UI) toggleCompactMode() tea.Cmd { m.forceCompactMode = !m.forceCompactMode - err := m.com.Config().SetCompactMode(m.forceCompactMode) + err := m.com.Store().SetCompactMode(config.ScopeGlobal, m.forceCompactMode) if err != nil { return util.ReportError(err) } @@ -2637,7 +2637,7 @@ func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValu return func() tea.Msg { contents, err := mcp.ReadResource( context.Background(), - m.com.Config(), + m.com.Store(), item.MCPName, item.URI, ) @@ -3299,7 +3299,7 @@ func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) { lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false) mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false) - filesSection := m.filesInfo(m.com.Config().WorkingDir(), sectionWidth, maxItemsPerSection, false) + filesSection := m.filesInfo(m.com.Store().WorkingDir(), sectionWidth, maxItemsPerSection, false) sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection) uv.NewStyledString( s.CompactDetails.View. @@ -3317,7 +3317,7 @@ func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) { func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd { load := func() tea.Msg { - prompt, err := commands.GetMCPPrompt(m.com.Config(), clientID, promptID, arguments) + prompt, err := commands.GetMCPPrompt(m.com.Store(), clientID, promptID, arguments) if err != nil { // TODO: make this better return util.ReportError(err)() @@ -3358,7 +3358,7 @@ func handleMCPPromptsEvent(name string) tea.Cmd { } } -func handleMCPToolsEvent(cfg *config.Config, name string) tea.Cmd { +func handleMCPToolsEvent(cfg *config.ConfigStore, name string) tea.Cmd { return func() tea.Msg { mcp.RefreshTools( context.Background(),