on main help sign in
Merge branch 'main' into server-client-2
99108f0Ayman Bagabas
.github/workflows/security.yml+1−1
@@ -67,7 +67,7 @@ jobs:
67 persist-credentials: false
67 persist-credentials: false
68 - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
68 - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
69 with:
69 with:
70− go-version: 1.26.0
70+ go-version: 1.26.1
71 - name: Install govulncheck
71 - name: Install govulncheck
72 run: go install golang.org/x/vuln/cmd/govulncheck@latest
72 run: go install golang.org/x/vuln/cmd/govulncheck@latest
73 - name: Run govulncheck
73 - name: Run govulncheck
README.md+19
@@ -424,6 +424,25 @@ git clone https://github.com/anthropics/skills.git _temp
424mv _temp/skills/* . ; rm -r -force _temp
424mv _temp/skills/* . ; rm -r -force _temp
425```
425```
426
426
427+### Desktop notifications
428+
429+Crush sends desktop notifications when a tool call requires permission and when
430+the agent finishes its turn. They're only sent when the terminal window isn't
431+focused _and_ your terminal supports reporting the focus state.
432+
433+```jsonc
434+{
435+ "$schema": "https://charm.land/crush.json",
436+ "options": {
437+ "disable_notifications": false // default
438+ }
439+}
440+```
441+
442+To disable desktop notifications, set `disable_notifications` to `true` in your
443+configuration. On macOS, notifications currently lack icons due to platform
444+limitations.
445+
427### Initialization
446### Initialization
428
447
429When you initialize a project, Crush analyzes your codebase and creates
448When you initialize a project, Crush analyzes your codebase and creates
go.mod+39−29
@@ -1,15 +1,15 @@
1module github.com/charmbracelet/crush
1module github.com/charmbracelet/crush
2
2
3−go 1.26.0
3+go 1.26.1
4
4
5require (
5require (
6 charm.land/bubbles/v2 v2.0.0
6 charm.land/bubbles/v2 v2.0.0
7− charm.land/bubbletea/v2 v2.0.1
7+ charm.land/bubbletea/v2 v2.0.2
8− charm.land/catwalk v0.28.1
8+ charm.land/catwalk v0.28.4
9− charm.land/fantasy v0.11.1
9+ charm.land/fantasy v0.12.1
10− charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b
10+ charm.land/glamour/v2 v2.0.0
11− charm.land/lipgloss/v2 v2.0.0
11+ charm.land/lipgloss/v2 v2.0.1
12− charm.land/log/v2 v2.0.0-20251110204020-529bb77f35da
12+ charm.land/log/v2 v2.0.0
13 charm.land/x/vcr v0.1.1
13 charm.land/x/vcr v0.1.1
14 github.com/JohannesKaufmann/html-to-markdown v1.6.0
14 github.com/JohannesKaufmann/html-to-markdown v1.6.0
15 github.com/MakeNowJust/heredoc v1.0.0
15 github.com/MakeNowJust/heredoc v1.0.0
@@ -17,11 +17,11 @@ require (
17 github.com/alecthomas/chroma/v2 v2.23.1
17 github.com/alecthomas/chroma/v2 v2.23.1
18 github.com/atotto/clipboard v0.1.4
18 github.com/atotto/clipboard v0.1.4
19 github.com/aymanbagabas/go-nativeclipboard v0.1.3
19 github.com/aymanbagabas/go-nativeclipboard v0.1.3
20− github.com/aymanbagabas/go-udiff v0.4.0
20+ github.com/aymanbagabas/go-udiff v0.4.1
21 github.com/bmatcuk/doublestar/v4 v4.10.0
21 github.com/bmatcuk/doublestar/v4 v4.10.0
22 github.com/charlievieth/fastwalk v1.0.14
22 github.com/charlievieth/fastwalk v1.0.14
23− github.com/charmbracelet/colorprofile v0.4.2
23+ github.com/charmbracelet/colorprofile v0.4.3
24− github.com/charmbracelet/fang v0.4.4
24+ github.com/charmbracelet/fang v1.0.0
25 github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8
25 github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8
26 github.com/charmbracelet/x/ansi v0.11.6
26 github.com/charmbracelet/x/ansi v0.11.6
27 github.com/charmbracelet/x/editor v0.2.0
27 github.com/charmbracelet/x/editor v0.2.0
@@ -38,6 +38,7 @@ require (
38 github.com/denisbrodbeck/machineid v1.0.1
38 github.com/denisbrodbeck/machineid v1.0.1
39 github.com/disintegration/imaging v1.6.2
39 github.com/disintegration/imaging v1.6.2
40 github.com/dustin/go-humanize v1.0.1
40 github.com/dustin/go-humanize v1.0.1
41+ github.com/gen2brain/beeep v0.11.2
41 github.com/go-git/go-git/v5 v5.17.0
42 github.com/go-git/go-git/v5 v5.17.0
42 github.com/google/uuid v1.6.0
43 github.com/google/uuid v1.6.0
43 github.com/invopop/jsonschema v0.13.0
44 github.com/invopop/jsonschema v0.13.0
@@ -46,7 +47,7 @@ require (
46 github.com/lucasb-eyer/go-colorful v1.3.0
47 github.com/lucasb-eyer/go-colorful v1.3.0
47 github.com/mattn/go-isatty v0.0.20
48 github.com/mattn/go-isatty v0.0.20
48 github.com/modelcontextprotocol/go-sdk v1.4.0
49 github.com/modelcontextprotocol/go-sdk v1.4.0
49− github.com/ncruces/go-sqlite3 v0.30.5
50+ github.com/ncruces/go-sqlite3 v0.31.1
50 github.com/nxadm/tail v1.4.11
51 github.com/nxadm/tail v1.4.11
51 github.com/openai/openai-go/v2 v2.7.1
52 github.com/openai/openai-go/v2 v2.7.1
52 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
53 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
@@ -63,13 +64,13 @@ require (
63 github.com/zeebo/xxh3 v1.1.0
64 github.com/zeebo/xxh3 v1.1.0
64 go.uber.org/goleak v1.3.0
65 go.uber.org/goleak v1.3.0
65 golang.org/x/net v0.51.0
66 golang.org/x/net v0.51.0
66− golang.org/x/sync v0.19.0
67+ golang.org/x/sync v0.20.0
67 golang.org/x/text v0.34.0
68 golang.org/x/text v0.34.0
68 gopkg.in/natefinch/lumberjack.v2 v2.2.1
69 gopkg.in/natefinch/lumberjack.v2 v2.2.1
69 gopkg.in/yaml.v3 v3.0.1
70 gopkg.in/yaml.v3 v3.0.1
70 modernc.org/sqlite v1.46.1
71 modernc.org/sqlite v1.46.1
71 mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5
72 mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5
72− mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5
73+ mvdan.cc/sh/v3 v3.13.0
73)
74)
74
75
75require (
76require (
@@ -77,24 +78,25 @@ require (
77 cloud.google.com/go/auth v0.18.2 // indirect
78 cloud.google.com/go/auth v0.18.2 // indirect
78 cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
79 cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
79 cloud.google.com/go/compute/metadata v0.9.0 // indirect
80 cloud.google.com/go/compute/metadata v0.9.0 // indirect
81+ git.sr.ht/~jackmordaunt/go-toast v1.1.2 // indirect
80 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
82 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
81 github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
83 github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
82 github.com/Microsoft/go-winio v0.6.2 // indirect
84 github.com/Microsoft/go-winio v0.6.2 // indirect
83 github.com/andybalholm/cascadia v1.3.3 // indirect
85 github.com/andybalholm/cascadia v1.3.3 // indirect
84− github.com/aws/aws-sdk-go-v2 v1.41.2 // indirect
86+ github.com/aws/aws-sdk-go-v2 v1.41.3 // indirect
85 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect
87 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect
86− github.com/aws/aws-sdk-go-v2/config v1.32.10 // indirect
88+ github.com/aws/aws-sdk-go-v2/config v1.32.11 // indirect
87− github.com/aws/aws-sdk-go-v2/credentials v1.19.10 // indirect
89+ github.com/aws/aws-sdk-go-v2/credentials v1.19.11 // indirect
88− github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 // indirect
90+ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 // indirect
89− github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 // indirect
91+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 // indirect
90− github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 // indirect
92+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 // indirect
91− github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
93+ github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 // indirect
92− github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 // indirect
94+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 // indirect
93− github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 // indirect
95+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 // indirect
94− github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 // indirect
96+ github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 // indirect
95− github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 // indirect
97+ github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 // indirect
96− github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 // indirect
98+ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 // indirect
97− github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 // indirect
99+ github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 // indirect
98 github.com/aws/smithy-go v1.24.2 // indirect
100 github.com/aws/smithy-go v1.24.2 // indirect
99 github.com/aymerick/douceur v0.2.0 // indirect
101 github.com/aymerick/douceur v0.2.0 // indirect
100 github.com/bahlo/generic-list-go v0.2.0 // indirect
102 github.com/bahlo/generic-list-go v0.2.0 // indirect
@@ -107,6 +109,7 @@ require (
107 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
109 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
108 github.com/dlclark/regexp2 v1.11.5 // indirect
110 github.com/dlclark/regexp2 v1.11.5 // indirect
109 github.com/ebitengine/purego v0.10.0 // indirect
111 github.com/ebitengine/purego v0.10.0 // indirect
112+ github.com/esiqveland/notify v0.13.3 // indirect
110 github.com/felixge/httpsnoop v1.0.4 // indirect
113 github.com/felixge/httpsnoop v1.0.4 // indirect
111 github.com/fsnotify/fsnotify v1.9.0 // indirect
114 github.com/fsnotify/fsnotify v1.9.0 // indirect
112 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
115 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
@@ -115,9 +118,11 @@ require (
115 github.com/go-logfmt/logfmt v0.6.0 // indirect
118 github.com/go-logfmt/logfmt v0.6.0 // indirect
116 github.com/go-logr/logr v1.4.3 // indirect
119 github.com/go-logr/logr v1.4.3 // indirect
117 github.com/go-logr/stdr v1.2.2 // indirect
120 github.com/go-logr/stdr v1.2.2 // indirect
121+ github.com/go-ole/go-ole v1.3.0 // indirect
118 github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
122 github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
119 github.com/goccy/go-json v0.10.5 // indirect
123 github.com/goccy/go-json v0.10.5 // indirect
120 github.com/goccy/go-yaml v1.19.2 // indirect
124 github.com/goccy/go-yaml v1.19.2 // indirect
125+ github.com/godbus/dbus/v5 v5.2.2 // indirect
121 github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
126 github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
122 github.com/google/go-cmp v0.7.0 // indirect
127 github.com/google/go-cmp v0.7.0 // indirect
123 github.com/google/jsonschema-go v0.4.2 // indirect
128 github.com/google/jsonschema-go v0.4.2 // indirect
@@ -128,6 +133,7 @@ require (
128 github.com/gorilla/websocket v1.5.3 // indirect
133 github.com/gorilla/websocket v1.5.3 // indirect
129 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
134 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
130 github.com/inconshreveable/mousetrap v1.1.0 // indirect
135 github.com/inconshreveable/mousetrap v1.1.0 // indirect
136+ github.com/jackmordaunt/icns/v3 v3.0.1 // indirect
131 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
137 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
132 github.com/kaptinlin/go-i18n v0.2.12 // indirect
138 github.com/kaptinlin/go-i18n v0.2.12 // indirect
133 github.com/kaptinlin/jsonpointer v0.4.17 // indirect
139 github.com/kaptinlin/jsonpointer v0.4.17 // indirect
@@ -148,13 +154,17 @@ require (
148 github.com/muesli/roff v0.1.0 // indirect
154 github.com/muesli/roff v0.1.0 // indirect
149 github.com/ncruces/go-strftime v1.0.0 // indirect
155 github.com/ncruces/go-strftime v1.0.0 // indirect
150 github.com/ncruces/julianday v1.0.0 // indirect
156 github.com/ncruces/julianday v1.0.0 // indirect
157+ github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
151 github.com/pierrec/lz4/v4 v4.1.25 // indirect
158 github.com/pierrec/lz4/v4 v4.1.25 // indirect
152 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
159 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
153 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
160 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
154 github.com/segmentio/asm v1.2.1 // indirect
161 github.com/segmentio/asm v1.2.1 // indirect
155 github.com/segmentio/encoding v0.5.3 // indirect
162 github.com/segmentio/encoding v0.5.3 // indirect
163+ github.com/sergeymakinen/go-bmp v1.0.0 // indirect
164+ github.com/sergeymakinen/go-ico v1.0.0-beta.0 // indirect
156 github.com/sethvargo/go-retry v0.3.0 // indirect
165 github.com/sethvargo/go-retry v0.3.0 // indirect
157 github.com/spf13/pflag v1.0.9 // indirect
166 github.com/spf13/pflag v1.0.9 // indirect
167+ github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect
158 github.com/tetratelabs/wazero v1.11.0 // indirect
168 github.com/tetratelabs/wazero v1.11.0 // indirect
159 github.com/tidwall/match v1.1.1 // indirect
169 github.com/tidwall/match v1.1.1 // indirect
160 github.com/tidwall/pretty v1.2.1 // indirect
170 github.com/tidwall/pretty v1.2.1 // indirect
@@ -176,12 +186,12 @@ require (
176 golang.org/x/crypto v0.48.0 // indirect
186 golang.org/x/crypto v0.48.0 // indirect
177 golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
187 golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
178 golang.org/x/image v0.36.0 // indirect
188 golang.org/x/image v0.36.0 // indirect
179− golang.org/x/oauth2 v0.35.0 // indirect
189+ golang.org/x/oauth2 v0.36.0 // indirect
180− golang.org/x/sys v0.41.0 // indirect
190+ golang.org/x/sys v0.42.0 // indirect
181 golang.org/x/term v0.40.0 // indirect
191 golang.org/x/term v0.40.0 // indirect
182 golang.org/x/time v0.14.0 // indirect
192 golang.org/x/time v0.14.0 // indirect
183 google.golang.org/api v0.269.0 // indirect
193 google.golang.org/api v0.269.0 // indirect
184− google.golang.org/genai v1.48.0 // indirect
194+ google.golang.org/genai v1.49.0 // indirect
185 google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
195 google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
186 google.golang.org/grpc v1.79.1 // indirect
196 google.golang.org/grpc v1.79.1 // indirect
187 google.golang.org/protobuf v1.36.11 // indirect
197 google.golang.org/protobuf v1.36.11 // indirect
go.sum+83−56
@@ -1,17 +1,17 @@
1charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
1charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
2charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
2charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
3−charm.land/bubbletea/v2 v2.0.1 h1:B8e9zzK7x9JJ+XvHGF4xnYu9Xa0E0y0MyggY6dbaCfQ=
3+charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
4−charm.land/bubbletea/v2 v2.0.1/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
4+charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
5−charm.land/catwalk v0.28.1 h1:4YJiRRNUb7i8qDEZjFLJPMKvquihGrGsTNTcf5Dfqq0=
5+charm.land/catwalk v0.28.4 h1:YaaXA1k0v7CKvvT+Gh1pDD7XrlUR93kROdaWqkkglRw=
6−charm.land/catwalk v0.28.1/go.mod h1:rFC/V96rIHX7VES215c/qzI1EW/Moo1ggs1Q6seTy5s=
6+charm.land/catwalk v0.28.4/go.mod h1:+fqw/6YGNtvapvPy9vhwA/fAMxVjD2K8hVIKYov8Vhg=
7−charm.land/fantasy v0.11.1 h1:G1dRqkzEQ0RJN1Ls5mte8HOi0wFKxYd5bfnRAmeYvDk=
7+charm.land/fantasy v0.12.1 h1:awszoi5O9FIjMEkfyCMiLJfVRNLckp/zQkFrA6IxQqc=
8−charm.land/fantasy v0.11.1/go.mod h1:C8wNxWlw+b2z54zsTor9r1tG2GE2C4QotvAlgXh9KF8=
8+charm.land/fantasy v0.12.1/go.mod h1:QeRVUeG1XNTWBszRAbhUtPyX1VWs6zjkCxwfcwnICdc=
9−charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b h1:A6IUUyChZDWP16RUdRJCfmYISAKWQGyIcfhZJUCViQ0=
9+charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U=
10−charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b/go.mod h1:J3kVhY6oHXZq5f+8vC3hmDO95fEvbqj3z7xDwxrfzU8=
10+charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w=
11−charm.land/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo=
11+charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys=
12−charm.land/lipgloss/v2 v2.0.0/go.mod h1:w6SnmsBFBmEFBodiEDurGS/sdUY/u1+v72DqUzc6J14=
12+charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
13−charm.land/log/v2 v2.0.0-20251110204020-529bb77f35da h1:vZa/Ow0uLclpfaDY0ubjzE+B0eLQqi2zanmpeALanow=
13+charm.land/log/v2 v2.0.0 h1:SY3Cey7ipx86/MBXQHwsguOT6X1exT94mmJRdzTNs+s=
14−charm.land/log/v2 v2.0.0-20251110204020-529bb77f35da/go.mod h1:Tj12StbPc4GwksDF6XwhC9wdXouinIVxRGKKmmmzdSU=
14+charm.land/log/v2 v2.0.0/go.mod h1:c3cZSRqm20qUVVAR1WmS/7ab8bgha3C6G7DjPcaVZz0=
15charm.land/x/vcr v0.1.1 h1:PXCFMUG0rPtyk35rhfzYCJEduOzWXCIbrXTFq4OF/9Q=
15charm.land/x/vcr v0.1.1 h1:PXCFMUG0rPtyk35rhfzYCJEduOzWXCIbrXTFq4OF/9Q=
16charm.land/x/vcr v0.1.1/go.mod h1:eByq2gqzWvcct/8XE2XO5KznoWEBiXH56+y2gphbltM=
16charm.land/x/vcr v0.1.1/go.mod h1:eByq2gqzWvcct/8XE2XO5KznoWEBiXH56+y2gphbltM=
17cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
17cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
@@ -22,6 +22,8 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi
22cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
22cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
23cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
23cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
24cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
24cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
25+git.sr.ht/~jackmordaunt/go-toast v1.1.2 h1:/yrfI55LRt1M7H1vkaw+NaH1+L1CDxrqDltwm5euVuE=
26+git.sr.ht/~jackmordaunt/go-toast v1.1.2/go.mod h1:jA4OqHKTQ4AFBdwrSnwnskUIIS3HYzlJSgdzCKqfavo=
25github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 h1:g0EZJwz7xkXQiZAI5xi9f3WWFYBlX1CPTrR+NDToRkQ=
27github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 h1:g0EZJwz7xkXQiZAI5xi9f3WWFYBlX1CPTrR+NDToRkQ=
26github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ=
28github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ=
27github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 h1:tfLQ34V6F7tVSwoTf/4lH5sE0o6eCJuNDTmH09nDpbc=
29github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 h1:tfLQ34V6F7tVSwoTf/4lH5sE0o6eCJuNDTmH09nDpbc=
@@ -50,40 +52,40 @@ github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kk
50github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
52github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
51github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
53github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
52github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
54github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
53−github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls=
55+github.com/aws/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA=
54−github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4=
56+github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
55github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q=
57github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q=
56github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5/go.mod h1:nVUlMLVV8ycXSb7mSkcNu9e3v/1TJq2RTlrPwhYWr5c=
58github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5/go.mod h1:nVUlMLVV8ycXSb7mSkcNu9e3v/1TJq2RTlrPwhYWr5c=
57−github.com/aws/aws-sdk-go-v2/config v1.32.10 h1:9DMthfO6XWZYLfzZglAgW5Fyou2nRI5CuV44sTedKBI=
59+github.com/aws/aws-sdk-go-v2/config v1.32.11 h1:ftxI5sgz8jZkckuUHXfC/wMUc8u3fG1vQS0plr2F2Zs=
58−github.com/aws/aws-sdk-go-v2/config v1.32.10/go.mod h1:2rUIOnA2JaiqYmSKYmRJlcMWy6qTj1vuRFscppSBMcw=
60+github.com/aws/aws-sdk-go-v2/config v1.32.11/go.mod h1:twF11+6ps9aNRKEDimksp923o44w/Thk9+8YIlzWMmo=
59−github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8=
61+github.com/aws/aws-sdk-go-v2/credentials v1.19.11 h1:NdV8cwCcAXrCWyxArt58BrvZJ9pZ9Fhf9w6Uh5W3Uyc=
60−github.com/aws/aws-sdk-go-v2/credentials v1.19.10/go.mod h1:RnnlFCAlxQCkN2Q379B67USkBMu1PipEEiibzYN5UTE=
62+github.com/aws/aws-sdk-go-v2/credentials v1.19.11/go.mod h1:30yY2zqkMPdrvxBqzI9xQCM+WrlrZKSOpSJEsylVU+8=
61−github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 h1:Ii4s+Sq3yDfaMLpjrJsqD6SmG/Wq/P5L/hw2qa78UAY=
63+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 h1:INUvJxmhdEbVulJYHI061k4TVuS3jzzthNvjqvVvTKM=
62−github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18/go.mod h1:6x81qnY++ovptLE6nWQeWrpXxbnlIex+4H4eYYGcqfc=
64+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19/go.mod h1:FpZN2QISLdEBWkayloda+sZjVJL+e9Gl0k1SyTgcswU=
63−github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 h1:F43zk1vemYIqPAwhjTjYIz0irU2EY7sOb/F5eJ3HuyM=
65+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 h1:/sECfyq2JTifMI2JPyZ4bdRN77zJmr6SrS1eL3augIA=
64−github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18/go.mod h1:w1jdlZXrGKaJcNoL+Nnrj+k5wlpGXqnNrKoP22HvAug=
66+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19/go.mod h1:dMf8A5oAqr9/oxOfLkC/c2LU/uMcALP0Rgn2BD5LWn0=
65−github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 h1:xCeWVjj0ki0l3nruoyP2slHsGArMxeiiaoPN5QZH6YQ=
67+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 h1:AWeJMk33GTBf6J20XJe6qZoRSJo0WfUhsMdUKhoODXE=
66−github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18/go.mod h1:r/eLGuGCBw6l36ZRWiw6PaZwPXb6YOj+i/7MizNl5/k=
68+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19/go.mod h1:+GWrYoaAsV7/4pNHpwh1kiNLXkKaSoppxQq9lbH8Ejw=
67−github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
69+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 h1:clHU5fm//kWS1C2HgtgWxfQbFbx4b6rx+5jzhgX9HrI=
68−github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
70+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
69−github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 h1:CeY9LUdur+Dxoeldqoun6y4WtJ3RQtzk0JMP2gfUay0=
71+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 h1:XAq62tBTJP/85lFD5oqOOe7YYgWxY9LvWq8plyDvDVg=
70−github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5/go.mod h1:AZLZf2fMaahW5s/wMRciu1sYbdsikT/UHwbUjOdEVTc=
72+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
71−github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 h1:LTRCYFlnnKFlKsyIQxKhJuDuA3ZkrDQMRYm6rXiHlLY=
73+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 h1:X1Tow7suZk9UCJHE1Iw9GMZJJl0dAnKXXP1NaSDHwmw=
72−github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18/go.mod h1:XhwkgGG6bHSd00nO/mexWTcTjgd6PjuvWQMqSn2UaEk=
74+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19/go.mod h1:/rARO8psX+4sfjUQXp5LLifjUt8DuATZ31WptNJTyQA=
73−github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 h1:MzORe+J94I+hYu2a6XmV5yC9huoTv8NRcCrUNedDypQ=
75+github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 h1:Y2cAXlClHsXkkOvWZFXATr34b0hxxloeQu/pAZz2row=
74−github.com/aws/aws-sdk-go-v2/service/signin v1.0.6/go.mod h1:hXzcHLARD7GeWnifd8j9RWqtfIgxj4/cAtIVIK7hg8g=
76+github.com/aws/aws-sdk-go-v2/service/signin v1.0.7/go.mod h1:idzZ7gmDeqeNrSPkdbtMp9qWMgcBwykA7P7Rzh5DXVU=
75−github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 h1:7oGD8KPfBOJGXiCoRKrrrQkbvCp8N++u36hrLMPey6o=
77+github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 h1:iSsvB9EtQ09YrsmIc44Heqlx5ByGErqhPK1ZQLppias=
76−github.com/aws/aws-sdk-go-v2/service/sso v1.30.11/go.mod h1:0DO9B5EUJQlIDif+XJRWCljZRKsAFKh3gpFz7UnDtOo=
78+github.com/aws/aws-sdk-go-v2/service/sso v1.30.12/go.mod h1:fEWYKTRGoZNl8tZ77i61/ccwOMJdGxwOhWCkp6TXAr0=
77−github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 h1:edCcNp9eGIUDUCrzoCu1jWAXLGFIizeqkdkKgRlJwWc=
79+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 h1:EnUdUqRP1CNzt2DkV67tJx6XDN4xlfBFm+bzeNOQVb0=
78−github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15/go.mod h1:lyRQKED9xWfgkYC/wmmYfv7iVIM68Z5OQ88ZdcV1QbU=
80+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16/go.mod h1:Jic/xv0Rq/pFNCh3WwpH4BEqdbSAl+IyHro8LbibHD8=
79−github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 h1:NITQpgo9A5NrDZ57uOWj+abvXSb83BbyggcUBVksN7c=
81+github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 h1:XQTQTF75vnug2TXS8m7CVJfC2nniYPZnO1D4Np761Oo=
80−github.com/aws/aws-sdk-go-v2/service/sts v1.41.7/go.mod h1:sks5UWBhEuWYDPdwlnRFn1w7xWdH29Jcpe+/PJQefEs=
82+github.com/aws/aws-sdk-go-v2/service/sts v1.41.8/go.mod h1:Xgx+PR1NUOjNmQY+tRMnouRp83JRM8pRMw/vCaVhPkI=
81github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
83github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
82github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
84github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
83github.com/aymanbagabas/go-nativeclipboard v0.1.3 h1:FmAWHPTwneAixu7uGDn3cL42xPlUCdNp2J8egMn3P1k=
85github.com/aymanbagabas/go-nativeclipboard v0.1.3 h1:FmAWHPTwneAixu7uGDn3cL42xPlUCdNp2J8egMn3P1k=
84github.com/aymanbagabas/go-nativeclipboard v0.1.3/go.mod h1:2o7MyZwwi4pmXXpOpvOS5FwaHyoCIUks0ktjUvB0EoE=
86github.com/aymanbagabas/go-nativeclipboard v0.1.3/go.mod h1:2o7MyZwwi4pmXXpOpvOS5FwaHyoCIUks0ktjUvB0EoE=
85−github.com/aymanbagabas/go-udiff v0.4.0 h1:TKnLPh7IbnizJIBKFWa9mKayRUBQ9Kh1BPCk6w2PnYM=
87+github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
86−github.com/aymanbagabas/go-udiff v0.4.0/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
88+github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
87github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
89github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
88github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
90github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
89github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
91github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
@@ -98,10 +100,10 @@ github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICg
98github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY=
100github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY=
99github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab h1:J7XQLgl9sefgTnTGrmX3xqvp5o6MCiBzEjGv5igAlc4=
101github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab h1:J7XQLgl9sefgTnTGrmX3xqvp5o6MCiBzEjGv5igAlc4=
100github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab/go.mod h1:hqlYqR7uPKOKfnNeicUbZp0Ps0GeYFlKYtwh5HGDCx8=
102github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab/go.mod h1:hqlYqR7uPKOKfnNeicUbZp0Ps0GeYFlKYtwh5HGDCx8=
101−github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
103+github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
102−github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
104+github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
103−github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=
105+github.com/charmbracelet/fang v1.0.0 h1:jESBY40agJOlLYnnv9jE0mLqDGTxEk0hkOnx7YGyRlQ=
104−github.com/charmbracelet/fang v0.4.4/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo=
106+github.com/charmbracelet/fang v1.0.0/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo=
105github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
107github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
106github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
108github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
107github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
109github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
@@ -160,11 +162,15 @@ github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNf
160github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
162github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
161github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
163github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
162github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
164github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
165+github.com/esiqveland/notify v0.13.3 h1:QCMw6o1n+6rl+oLUfg8P1IIDSFsDEb2WlXvVvIJbI/o=
166+github.com/esiqveland/notify v0.13.3/go.mod h1:hesw/IRYTO0x99u1JPweAl4+5mwXJibQVUcP0Iu5ORE=
163github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
167github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
164github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
168github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
165github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
169github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
166github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
170github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
167github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
171github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
172+github.com/gen2brain/beeep v0.11.2 h1:+KfiKQBbQCuhfJFPANZuJ+oxsSKAYNe88hIpJuyKWDA=
173+github.com/gen2brain/beeep v0.11.2/go.mod h1:jQVvuwnLuwOcdctHn/uyh8horSBNJ8uGb9Cn2W4tvoc=
168github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
174github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
169github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
175github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
170github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0=
176github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0=
@@ -180,6 +186,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
180github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
186github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
181github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
187github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
182github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
188github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
189+github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
190+github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
183github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
191github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
184github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
192github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
185github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
193github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
@@ -188,6 +196,9 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
188github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
196github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
189github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
197github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
190github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
198github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
199+github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
200+github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
201+github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
191github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
202github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
192github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
203github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
193github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
204github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
@@ -222,6 +233,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
222github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
233github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
223github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
234github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
224github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
235github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
236+github.com/jackmordaunt/icns/v3 v3.0.1 h1:xxot6aNuGrU+lNgxz5I5H0qSeCjNKp8uTXB1j8D4S3o=
237+github.com/jackmordaunt/icns/v3 v3.0.1/go.mod h1:5sHL59nqTd2ynTnowxB/MDQFhKNqkK8X687uKNygaSQ=
225github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
238github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
226github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
239github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
227github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
240github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
@@ -278,12 +291,14 @@ github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe
278github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=
291github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=
279github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
292github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
280github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
293github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
281−github.com/ncruces/go-sqlite3 v0.30.5 h1:6usmTQ6khriL8oWilkAZSJM/AIpAlVL2zFrlcpDldCE=
294+github.com/ncruces/go-sqlite3 v0.31.1 h1:F76NF4NTLNOabLUKuEb2xqjBW+/Ub+MR59/Q7dACRo8=
282−github.com/ncruces/go-sqlite3 v0.30.5/go.mod h1:0I0JFflTKzfs3Ogfv8erP7CCoV/Z8uxigVDNOR0AQ5E=
295+github.com/ncruces/go-sqlite3 v0.31.1/go.mod h1:L9OWFjYG/+4dq9O6bFCYoWLG0a7LmtgR6v26TvABmwg=
283github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
296github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
284github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
297github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
285github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
298github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
286github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
299github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
300+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
301+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
287github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
302github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
288github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=
303github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=
289github.com/openai/openai-go/v2 v2.7.1 h1:/tfvTJhfv7hTSL8mWwc5VL4WLLSDL5yn9VqVykdu9r8=
304github.com/openai/openai-go/v2 v2.7.1 h1:/tfvTJhfv7hTSL8mWwc5VL4WLLSDL5yn9VqVykdu9r8=
@@ -323,6 +338,10 @@ github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
323github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
338github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
324github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w=
339github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w=
325github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
340github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
341+github.com/sergeymakinen/go-bmp v1.0.0 h1:SdGTzp9WvCV0A1V0mBeaS7kQAwNLdVJbmHlqNWq0R+M=
342+github.com/sergeymakinen/go-bmp v1.0.0/go.mod h1:/mxlAQZRLxSvJFNIEGGLBE/m40f3ZnUifpgVDlcUIEY=
343+github.com/sergeymakinen/go-ico v1.0.0-beta.0 h1:m5qKH7uPKLdrygMWxbamVn+tl2HfiA3K6MFJw4GfZvQ=
344+github.com/sergeymakinen/go-ico v1.0.0-beta.0/go.mod h1:wQ47mTczswBO5F0NoDt7O0IXgnV4Xy3ojrroMQzyhUk=
326github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
345github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
327github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
346github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
328github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
347github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
@@ -336,10 +355,17 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT
336github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
355github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
337github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
356github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
338github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
357github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
358+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
359+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
339github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
360github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
340github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
361github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
362+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
363+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
364+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
341github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
365github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
342github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
366github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
367+github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk=
368+github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o=
343github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
369github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
344github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
370github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
345github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
371github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
@@ -429,8 +455,8 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
429golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
455golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
430golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
456golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
431golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
457golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
432−golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
458+golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
433−golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
459+golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
434golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
460golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
435golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
461golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
436golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
462golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -438,8 +464,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
438golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
464golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
439golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
465golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
440golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
466golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
441−golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
467+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
442−golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
468+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
443golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
469golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
444golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
470golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
445golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
471golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -456,8 +482,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
456golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
482golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
457golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
483golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
458golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
484golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
459−golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
485+golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
460−golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
486+golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
461golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
487golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
462golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
488golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
463golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
489golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -497,8 +523,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
497gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
523gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
498google.golang.org/api v0.269.0 h1:qDrTOxKUQ/P0MveH6a7vZ+DNHxJQjtGm/uvdbdGXCQg=
524google.golang.org/api v0.269.0 h1:qDrTOxKUQ/P0MveH6a7vZ+DNHxJQjtGm/uvdbdGXCQg=
499google.golang.org/api v0.269.0/go.mod h1:N8Wpcu23Tlccl0zSHEkcAZQKDLdquxK+l9r2LkwAauE=
525google.golang.org/api v0.269.0/go.mod h1:N8Wpcu23Tlccl0zSHEkcAZQKDLdquxK+l9r2LkwAauE=
500−google.golang.org/genai v1.48.0 h1:1vb15G291wAjJJueisMDpUhssljhEdJU2t5qTidrVPs=
526+google.golang.org/genai v1.49.0 h1:Se+QJaH2GYK1aaR1o5S38mlU2GD5FnVvP76nfkV7LH0=
501−google.golang.org/genai v1.48.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
527+google.golang.org/genai v1.49.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
502google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
528google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
503google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
529google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
504google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
530google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
@@ -520,6 +546,7 @@ gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRN
520gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
546gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
521gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
547gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
522gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
548gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
549+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
523gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
550gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
524gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
551gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
525modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
552modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
@@ -552,5 +579,5 @@ modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
552modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
579modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
553mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5 h1:mO2lyKtGwu4mGQ+Qqjx0+fd5UU5BXhX/rslFmxd5aco=
580mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5 h1:mO2lyKtGwu4mGQ+Qqjx0+fd5UU5BXhX/rslFmxd5aco=
554mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5/go.mod h1:Of9PCedbLDYT8b3EyiYG64rNnx5nOp27OLCVdDrjJyo=
581mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5/go.mod h1:Of9PCedbLDYT8b3EyiYG64rNnx5nOp27OLCVdDrjJyo=
555−mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5 h1:e7Z/Lgw/zMijvQBVrfh/vUDZ+9FpuSLrJDVGBuoJtuo=
582+mvdan.cc/sh/v3 v3.13.0 h1:dSfq/MVsY4w0Vsi6Lbs0IcQquMVqLdKLESAOZjuHdLg=
556−mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5/go.mod h1:P21wo2gLLe3426sP+CmANLBaixSEbRtPl35w3YlM6dg=
583+mvdan.cc/sh/v3 v3.13.0/go.mod h1:KV1GByGPc/Ho0X1E6Uz9euhsIQEj4hwyKnodLlFLoDM=
internal/agent/agent.go+20
@@ -32,14 +32,17 @@ import (
32 "charm.land/fantasy/providers/vercel"
32 "charm.land/fantasy/providers/vercel"
33 "charm.land/lipgloss/v2"
33 "charm.land/lipgloss/v2"
34 "github.com/charmbracelet/crush/internal/agent/hyper"
34 "github.com/charmbracelet/crush/internal/agent/hyper"
35+ "github.com/charmbracelet/crush/internal/agent/notify"
35 "github.com/charmbracelet/crush/internal/agent/tools"
36 "github.com/charmbracelet/crush/internal/agent/tools"
36 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
37 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
37 "github.com/charmbracelet/crush/internal/config"
38 "github.com/charmbracelet/crush/internal/config"
38 "github.com/charmbracelet/crush/internal/csync"
39 "github.com/charmbracelet/crush/internal/csync"
39 "github.com/charmbracelet/crush/internal/message"
40 "github.com/charmbracelet/crush/internal/message"
40 "github.com/charmbracelet/crush/internal/permission"
41 "github.com/charmbracelet/crush/internal/permission"
42+ "github.com/charmbracelet/crush/internal/pubsub"
41 "github.com/charmbracelet/crush/internal/session"
43 "github.com/charmbracelet/crush/internal/session"
42 "github.com/charmbracelet/crush/internal/stringext"
44 "github.com/charmbracelet/crush/internal/stringext"
45+ "github.com/charmbracelet/crush/internal/version"
43 "github.com/charmbracelet/x/exp/charmtone"
46 "github.com/charmbracelet/x/exp/charmtone"
44)
47)
45
48
@@ -72,6 +75,7 @@ type SessionAgentCall struct {
72 TopK *int64
75 TopK *int64
73 FrequencyPenalty *float64
76 FrequencyPenalty *float64
74 PresencePenalty *float64
77 PresencePenalty *float64
78+ NonInteractive bool
75}
79}
76
80
77type SessionAgent interface {
81type SessionAgent interface {
@@ -108,6 +112,7 @@ type sessionAgent struct {
108 messages message.Service
112 messages message.Service
109 disableAutoSummarize bool
113 disableAutoSummarize bool
110 isYolo bool
114 isYolo bool
115+ notify pubsub.Publisher[notify.Notification]
111
116
112 messageQueue *csync.Map[string, []SessionAgentCall]
117 messageQueue *csync.Map[string, []SessionAgentCall]
113 activeRequests *csync.Map[string, context.CancelFunc]
118 activeRequests *csync.Map[string, context.CancelFunc]
@@ -124,6 +129,7 @@ type SessionAgentOptions struct {
124 Sessions session.Service
129 Sessions session.Service
125 Messages message.Service
130 Messages message.Service
126 Tools []fantasy.AgentTool
131 Tools []fantasy.AgentTool
132+ Notify pubsub.Publisher[notify.Notification]
127}
133}
128
134
129func NewSessionAgent(
135func NewSessionAgent(
@@ -140,6 +146,7 @@ func NewSessionAgent(
140 disableAutoSummarize: opts.DisableAutoSummarize,
146 disableAutoSummarize: opts.DisableAutoSummarize,
141 tools: csync.NewSliceFrom(opts.Tools),
147 tools: csync.NewSliceFrom(opts.Tools),
142 isYolo: opts.IsYolo,
148 isYolo: opts.IsYolo,
149+ notify: opts.Notify,
143 messageQueue: csync.NewMap[string, []SessionAgentCall](),
150 messageQueue: csync.NewMap[string, []SessionAgentCall](),
144 activeRequests: csync.NewMap[string, context.CancelFunc](),
151 activeRequests: csync.NewMap[string, context.CancelFunc](),
145 }
152 }
@@ -194,6 +201,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
194 largeModel.Model,
201 largeModel.Model,
195 fantasy.WithSystemPrompt(systemPrompt),
202 fantasy.WithSystemPrompt(systemPrompt),
196 fantasy.WithTools(agentTools...),
203 fantasy.WithTools(agentTools...),
204+ fantasy.WithUserAgent("Charm Crush/"+version.Version),
197 )
205 )
198
206
199 sessionLock := sync.Mutex{}
207 sessionLock := sync.Mutex{}
@@ -530,6 +538,16 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
530 return nil, err
538 return nil, err
531 }
539 }
532
540
541+ // Send notification that agent has finished its turn (skip for
542+ // nested/non-interactive sessions).
543+ if !call.NonInteractive && a.notify != nil {
544+ a.notify.Publish(pubsub.CreatedEvent, notify.Notification{
545+ SessionID: call.SessionID,
546+ SessionTitle: currentSession.Title,
547+ Type: notify.TypeAgentFinished,
548+ })
549+ }
550+
533 if shouldSummarize {
551 if shouldSummarize {
534 a.activeRequests.Del(call.SessionID)
552 a.activeRequests.Del(call.SessionID)
535 if summarizeErr := a.Summarize(genCtx, call.SessionID, call.ProviderOptions); summarizeErr != nil {
553 if summarizeErr := a.Summarize(genCtx, call.SessionID, call.ProviderOptions); summarizeErr != nil {
@@ -592,6 +610,7 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan
592
610
593 agent := fantasy.NewAgent(largeModel.Model,
611 agent := fantasy.NewAgent(largeModel.Model,
594 fantasy.WithSystemPrompt(string(summaryPrompt)),
612 fantasy.WithSystemPrompt(string(summaryPrompt)),
613+ fantasy.WithUserAgent("Charm Crush/"+version.Version),
595 )
614 )
596 summaryMessage, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
615 summaryMessage, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
597 Role: message.Assistant,
616 Role: message.Assistant,
@@ -787,6 +806,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
787 return fantasy.NewAgent(m,
806 return fantasy.NewAgent(m,
788 fantasy.WithSystemPrompt(string(p)+"\n /no_think"),
807 fantasy.WithSystemPrompt(string(p)+"\n /no_think"),
789 fantasy.WithMaxOutputTokens(tok),
808 fantasy.WithMaxOutputTokens(tok),
809+ fantasy.WithUserAgent("Charm Crush/"+version.Version),
790 )
810 )
791 }
811 }
792
812
internal/agent/agent_tool.go+1−1
@@ -24,7 +24,7 @@ const (
24)
24)
25
25
26func (c *coordinator) agentTool(ctx context.Context) (fantasy.AgentTool, error) {
26func (c *coordinator) agentTool(ctx context.Context) (fantasy.AgentTool, error) {
27− agentCfg, ok := c.cfg.Agents[config.AgentTask]
27+ agentCfg, ok := c.cfg.Config().Agents[config.AgentTask]
28 if !ok {
28 if !ok {
29 return nil, errors.New("task agent not configured")
29 return nil, errors.New("task agent not configured")
30 }
30 }
internal/agent/agentic_fetch_tool.go+5−5
@@ -98,7 +98,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
98 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
98 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
99 }
99 }
100
100
101− tmpDir, err := os.MkdirTemp(c.cfg.Options.DataDirectory, "crush-fetch-*")
101+ tmpDir, err := os.MkdirTemp(c.cfg.Config().Options.DataDirectory, "crush-fetch-*")
102 if err != nil {
102 if err != nil {
103 return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to create temporary directory: %s", err)), nil
103 return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to create temporary directory: %s", err)), nil
104 }
104 }
@@ -151,12 +151,12 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
151 return fantasy.ToolResponse{}, fmt.Errorf("error building models: %s", err)
151 return fantasy.ToolResponse{}, fmt.Errorf("error building models: %s", err)
152 }
152 }
153
153
154− systemPrompt, err := promptTemplate.Build(ctx, small.Model.Provider(), small.Model.Model(), *c.cfg)
154+ systemPrompt, err := promptTemplate.Build(ctx, small.Model.Provider(), small.Model.Model(), c.cfg)
155 if err != nil {
155 if err != nil {
156 return fantasy.ToolResponse{}, fmt.Errorf("error building system prompt: %s", err)
156 return fantasy.ToolResponse{}, fmt.Errorf("error building system prompt: %s", err)
157 }
157 }
158
158
159− smallProviderCfg, ok := c.cfg.Providers.Get(small.ModelCfg.Provider)
159+ smallProviderCfg, ok := c.cfg.Config().Providers.Get(small.ModelCfg.Provider)
160 if !ok {
160 if !ok {
161 return fantasy.ToolResponse{}, errors.New("small model provider not configured")
161 return fantasy.ToolResponse{}, errors.New("small model provider not configured")
162 }
162 }
@@ -167,7 +167,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
167 webFetchTool,
167 webFetchTool,
168 webSearchTool,
168 webSearchTool,
169 tools.NewGlobTool(tmpDir),
169 tools.NewGlobTool(tmpDir),
170− tools.NewGrepTool(tmpDir, c.cfg.Tools.Grep),
170+ tools.NewGrepTool(tmpDir, c.cfg.Config().Tools.Grep),
171 tools.NewSourcegraphTool(client),
171 tools.NewSourcegraphTool(client),
172 tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, tmpDir),
172 tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, tmpDir),
173 }
173 }
@@ -177,7 +177,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
177 SmallModel: small,
177 SmallModel: small,
178 SystemPromptPrefix: smallProviderCfg.SystemPromptPrefix,
178 SystemPromptPrefix: smallProviderCfg.SystemPromptPrefix,
179 SystemPrompt: systemPrompt,
179 SystemPrompt: systemPrompt,
180− DisableAutoSummarize: c.cfg.Options.DisableAutoSummarize,
180+ DisableAutoSummarize: c.cfg.Config().Options.DisableAutoSummarize,
181 IsYolo: c.permissions.SkipRequests(),
181 IsYolo: c.permissions.SkipRequests(),
182 Sessions: c.sessions,
182 Sessions: c.sessions,
183 Messages: c.messages,
183 Messages: c.messages,
internal/agent/common_test.go+18−10
@@ -153,7 +153,15 @@ func testSessionAgent(env fakeEnv, large, small fantasy.LanguageModel, systemPro
153 DefaultMaxTokens: 10000,
153 DefaultMaxTokens: 10000,
154 },
154 },
155 }
155 }
156− agent := NewSessionAgent(SessionAgentOptions{largeModel, smallModel, "", systemPrompt, false, false, true, env.sessions, env.messages, tools})
156+ agent := NewSessionAgent(SessionAgentOptions{
157+ LargeModel: largeModel,
158+ SmallModel: smallModel,
159+ SystemPrompt: systemPrompt,
160+ IsYolo: true,
161+ Sessions: env.sessions,
162+ Messages: env.messages,
163+ Tools: tools,
164+ })
157 return agent
165 return agent
158}
166}
159
167
@@ -177,36 +185,36 @@ func coderAgent(r *vcr.Recorder, env fakeEnv, large, small fantasy.LanguageModel
177
185
178 // NOTE(@andreynering): Set a fixed config to ensure cassettes match
186 // NOTE(@andreynering): Set a fixed config to ensure cassettes match
179 // independently of user config on `$HOME/.config/crush/crush.json`.
187 // independently of user config on `$HOME/.config/crush/crush.json`.
180− cfg.Options.Attribution = &config.Attribution{
188+ cfg.Config().Options.Attribution = &config.Attribution{
181 TrailerStyle: "co-authored-by",
189 TrailerStyle: "co-authored-by",
182 GeneratedWith: true,
190 GeneratedWith: true,
183 }
191 }
184
192
185 // Clear some fields to avoid issues with VCR cassette matching.
193 // Clear some fields to avoid issues with VCR cassette matching.
186− cfg.Options.SkillsPaths = nil
194+ cfg.Config().Options.SkillsPaths = nil
187− cfg.Options.ContextPaths = nil
195+ cfg.Config().Options.ContextPaths = nil
188− cfg.LSP = nil
196+ cfg.Config().LSP = nil
189
197
190− systemPrompt, err := prompt.Build(context.TODO(), large.Provider(), large.Model(), *cfg)
198+ systemPrompt, err := prompt.Build(context.TODO(), large.Provider(), large.Model(), cfg)
191 if err != nil {
199 if err != nil {
192 return nil, err
200 return nil, err
193 }
201 }
194
202
195 // Get the model name for the bash tool
203 // Get the model name for the bash tool
196 modelName := large.Model() // fallback to ID if Name not available
204 modelName := large.Model() // fallback to ID if Name not available
197− if model := cfg.GetModel(large.Provider(), large.Model()); model != nil {
205+ if model := cfg.Config().GetModel(large.Provider(), large.Model()); model != nil {
198 modelName = model.Name
206 modelName = model.Name
199 }
207 }
200
208
201 allTools := []fantasy.AgentTool{
209 allTools := []fantasy.AgentTool{
202− tools.NewBashTool(env.permissions, env.workingDir, cfg.Options.Attribution, modelName),
210+ tools.NewBashTool(env.permissions, env.workingDir, cfg.Config().Options.Attribution, modelName),
203 tools.NewDownloadTool(env.permissions, env.workingDir, r.GetDefaultClient()),
211 tools.NewDownloadTool(env.permissions, env.workingDir, r.GetDefaultClient()),
204 tools.NewEditTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir),
212 tools.NewEditTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir),
205 tools.NewMultiEditTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir),
213 tools.NewMultiEditTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir),
206 tools.NewFetchTool(env.permissions, env.workingDir, r.GetDefaultClient()),
214 tools.NewFetchTool(env.permissions, env.workingDir, r.GetDefaultClient()),
207 tools.NewGlobTool(env.workingDir),
215 tools.NewGlobTool(env.workingDir),
208− tools.NewGrepTool(env.workingDir, cfg.Tools.Grep),
216+ tools.NewGrepTool(env.workingDir, cfg.Config().Tools.Grep),
209− tools.NewLsTool(env.permissions, env.workingDir, cfg.Tools.Ls),
217+ tools.NewLsTool(env.permissions, env.workingDir, cfg.Config().Tools.Ls),
210 tools.NewSourcegraphTool(r.GetDefaultClient()),
218 tools.NewSourcegraphTool(r.GetDefaultClient()),
211 tools.NewViewTool(nil, env.permissions, *env.filetracker, env.workingDir),
219 tools.NewViewTool(nil, env.permissions, *env.filetracker, env.workingDir),
212 tools.NewWriteTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir),
220 tools.NewWriteTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir),
internal/agent/coordinator.go+51−44
@@ -18,6 +18,7 @@ import (
18 "charm.land/catwalk/pkg/catwalk"
18 "charm.land/catwalk/pkg/catwalk"
19 "charm.land/fantasy"
19 "charm.land/fantasy"
20 "github.com/charmbracelet/crush/internal/agent/hyper"
20 "github.com/charmbracelet/crush/internal/agent/hyper"
21+ "github.com/charmbracelet/crush/internal/agent/notify"
21 "github.com/charmbracelet/crush/internal/agent/prompt"
22 "github.com/charmbracelet/crush/internal/agent/prompt"
22 "github.com/charmbracelet/crush/internal/agent/tools"
23 "github.com/charmbracelet/crush/internal/agent/tools"
23 "github.com/charmbracelet/crush/internal/config"
24 "github.com/charmbracelet/crush/internal/config"
@@ -28,6 +29,7 @@ import (
28 "github.com/charmbracelet/crush/internal/message"
29 "github.com/charmbracelet/crush/internal/message"
29 "github.com/charmbracelet/crush/internal/oauth/copilot"
30 "github.com/charmbracelet/crush/internal/oauth/copilot"
30 "github.com/charmbracelet/crush/internal/permission"
31 "github.com/charmbracelet/crush/internal/permission"
32+ "github.com/charmbracelet/crush/internal/pubsub"
31 "github.com/charmbracelet/crush/internal/session"
33 "github.com/charmbracelet/crush/internal/session"
32 "golang.org/x/sync/errgroup"
34 "golang.org/x/sync/errgroup"
33
35
@@ -72,13 +74,14 @@ type Coordinator interface {
72}
74}
73
75
74type coordinator struct {
76type coordinator struct {
75− cfg *config.Config
77+ cfg *config.ConfigStore
76 sessions session.Service
78 sessions session.Service
77 messages message.Service
79 messages message.Service
78 permissions permission.Service
80 permissions permission.Service
79 history history.Service
81 history history.Service
80 filetracker filetracker.Service
82 filetracker filetracker.Service
81 lspManager *lsp.Manager
83 lspManager *lsp.Manager
84+ notify pubsub.Publisher[notify.Notification]
82
85
83 currentAgent SessionAgent
86 currentAgent SessionAgent
84 agents map[string]SessionAgent
87 agents map[string]SessionAgent
@@ -88,13 +91,14 @@ type coordinator struct {
88
91
89func NewCoordinator(
92func NewCoordinator(
90 ctx context.Context,
93 ctx context.Context,
91− cfg *config.Config,
94+ cfg *config.ConfigStore,
92 sessions session.Service,
95 sessions session.Service,
93 messages message.Service,
96 messages message.Service,
94 permissions permission.Service,
97 permissions permission.Service,
95 history history.Service,
98 history history.Service,
96 filetracker filetracker.Service,
99 filetracker filetracker.Service,
97 lspManager *lsp.Manager,
100 lspManager *lsp.Manager,
101+ notify pubsub.Publisher[notify.Notification],
98) (Coordinator, error) {
102) (Coordinator, error) {
99 c := &coordinator{
103 c := &coordinator{
100 cfg: cfg,
104 cfg: cfg,
@@ -104,10 +108,11 @@ func NewCoordinator(
104 history: history,
108 history: history,
105 filetracker: filetracker,
109 filetracker: filetracker,
106 lspManager: lspManager,
110 lspManager: lspManager,
111+ notify: notify,
107 agents: make(map[string]SessionAgent),
112 agents: make(map[string]SessionAgent),
108 }
113 }
109
114
110− agentCfg, ok := cfg.Agents[config.AgentCoder]
115+ agentCfg, ok := cfg.Config().Agents[config.AgentCoder]
111 if !ok {
116 if !ok {
112 return nil, errCoderAgentNotConfigured
117 return nil, errCoderAgentNotConfigured
113 }
118 }
@@ -155,7 +160,7 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
155 attachments = filteredAttachments
160 attachments = filteredAttachments
156 }
161 }
157
162
158− providerCfg, ok := c.cfg.Providers.Get(model.ModelCfg.Provider)
163+ providerCfg, ok := c.cfg.Config().Providers.Get(model.ModelCfg.Provider)
159 if !ok {
164 if !ok {
160 return nil, errModelProviderNotConfigured
165 return nil, errModelProviderNotConfigured
161 }
166 }
@@ -378,22 +383,23 @@ func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, age
378 return nil, err
383 return nil, err
379 }
384 }
380
385
381− largeProviderCfg, _ := c.cfg.Providers.Get(large.ModelCfg.Provider)
386+ largeProviderCfg, _ := c.cfg.Config().Providers.Get(large.ModelCfg.Provider)
382 result := NewSessionAgent(SessionAgentOptions{
387 result := NewSessionAgent(SessionAgentOptions{
383− large,
388+ LargeModel: large,
384− small,
389+ SmallModel: small,
385− largeProviderCfg.SystemPromptPrefix,
390+ SystemPromptPrefix: largeProviderCfg.SystemPromptPrefix,
386− "",
391+ SystemPrompt: "",
387− isSubAgent,
392+ IsSubAgent: isSubAgent,
388− c.cfg.Options.DisableAutoSummarize,
393+ DisableAutoSummarize: c.cfg.Config().Options.DisableAutoSummarize,
389− c.permissions.SkipRequests(),
394+ IsYolo: c.permissions.SkipRequests(),
390− c.sessions,
395+ Sessions: c.sessions,
391− c.messages,
396+ Messages: c.messages,
392− nil,
397+ Tools: nil,
398+ Notify: c.notify,
393 })
399 })
394
400
395 c.readyWg.Go(func() error {
401 c.readyWg.Go(func() error {
396− systemPrompt, err := prompt.Build(ctx, large.Model.Provider(), large.Model.Model(), *c.cfg)
402+ systemPrompt, err := prompt.Build(ctx, large.Model.Provider(), large.Model.Model(), c.cfg)
397 if err != nil {
403 if err != nil {
398 return err
404 return err
399 }
405 }
@@ -433,14 +439,14 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
433
439
434 // Get the model name for the agent
440 // Get the model name for the agent
435 modelName := ""
441 modelName := ""
436− if modelCfg, ok := c.cfg.Models[agent.Model]; ok {
442+ if modelCfg, ok := c.cfg.Config().Models[agent.Model]; ok {
437− if model := c.cfg.GetModel(modelCfg.Provider, modelCfg.Model); model != nil {
443+ if model := c.cfg.Config().GetModel(modelCfg.Provider, modelCfg.Model); model != nil {
438 modelName = model.Name
444 modelName = model.Name
439 }
445 }
440 }
446 }
441
447
442 allTools = append(allTools,
448 allTools = append(allTools,
443− tools.NewBashTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Options.Attribution, modelName),
449+ tools.NewBashTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Config().Options.Attribution, modelName),
444 tools.NewJobOutputTool(),
450 tools.NewJobOutputTool(),
445 tools.NewJobKillTool(),
451 tools.NewJobKillTool(),
446 tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
452 tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
@@ -448,20 +454,20 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
448 tools.NewMultiEditTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
454 tools.NewMultiEditTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
449 tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
455 tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
450 tools.NewGlobTool(c.cfg.WorkingDir()),
456 tools.NewGlobTool(c.cfg.WorkingDir()),
451− tools.NewGrepTool(c.cfg.WorkingDir(), c.cfg.Tools.Grep),
457+ tools.NewGrepTool(c.cfg.WorkingDir(), c.cfg.Config().Tools.Grep),
452− tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
458+ tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Config().Tools.Ls),
453 tools.NewSourcegraphTool(nil),
459 tools.NewSourcegraphTool(nil),
454 tools.NewTodosTool(c.sessions),
460 tools.NewTodosTool(c.sessions),
455− tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...),
461+ tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, c.cfg.WorkingDir(), c.cfg.Config().Options.SkillsPaths...),
456 tools.NewWriteTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
462 tools.NewWriteTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
457 )
463 )
458
464
459 // Add LSP tools if user has configured LSPs or auto_lsp is enabled (nil or true).
465 // Add LSP tools if user has configured LSPs or auto_lsp is enabled (nil or true).
460− if len(c.cfg.LSP) > 0 || c.cfg.Options.AutoLSP == nil || *c.cfg.Options.AutoLSP {
466+ if len(c.cfg.Config().LSP) > 0 || c.cfg.Config().Options.AutoLSP == nil || *c.cfg.Config().Options.AutoLSP {
461 allTools = append(allTools, tools.NewDiagnosticsTool(c.lspManager), tools.NewReferencesTool(c.lspManager), tools.NewLSPRestartTool(c.lspManager))
467 allTools = append(allTools, tools.NewDiagnosticsTool(c.lspManager), tools.NewReferencesTool(c.lspManager), tools.NewLSPRestartTool(c.lspManager))
462 }
468 }
463
469
464− if len(c.cfg.MCP) > 0 {
470+ if len(c.cfg.Config().MCP) > 0 {
465 allTools = append(
471 allTools = append(
466 allTools,
472 allTools,
467 tools.NewListMCPResourcesTool(c.cfg, c.permissions),
473 tools.NewListMCPResourcesTool(c.cfg, c.permissions),
@@ -507,16 +513,16 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
507
513
508// TODO: when we support multiple agents we need to change this so that we pass in the agent specific model config
514// TODO: when we support multiple agents we need to change this so that we pass in the agent specific model config
509func (c *coordinator) buildAgentModels(ctx context.Context, isSubAgent bool) (Model, Model, error) {
515func (c *coordinator) buildAgentModels(ctx context.Context, isSubAgent bool) (Model, Model, error) {
510− largeModelCfg, ok := c.cfg.Models[config.SelectedModelTypeLarge]
516+ largeModelCfg, ok := c.cfg.Config().Models[config.SelectedModelTypeLarge]
511 if !ok {
517 if !ok {
512 return Model{}, Model{}, errLargeModelNotSelected
518 return Model{}, Model{}, errLargeModelNotSelected
513 }
519 }
514− smallModelCfg, ok := c.cfg.Models[config.SelectedModelTypeSmall]
520+ smallModelCfg, ok := c.cfg.Config().Models[config.SelectedModelTypeSmall]
515 if !ok {
521 if !ok {
516 return Model{}, Model{}, errSmallModelNotSelected
522 return Model{}, Model{}, errSmallModelNotSelected
517 }
523 }
518
524
519− largeProviderCfg, ok := c.cfg.Providers.Get(largeModelCfg.Provider)
525+ largeProviderCfg, ok := c.cfg.Config().Providers.Get(largeModelCfg.Provider)
520 if !ok {
526 if !ok {
521 return Model{}, Model{}, errLargeModelProviderNotConfigured
527 return Model{}, Model{}, errLargeModelProviderNotConfigured
522 }
528 }
@@ -526,7 +532,7 @@ func (c *coordinator) buildAgentModels(ctx context.Context, isSubAgent bool) (Mo
526 return Model{}, Model{}, err
532 return Model{}, Model{}, err
527 }
533 }
528
534
529− smallProviderCfg, ok := c.cfg.Providers.Get(smallModelCfg.Provider)
535+ smallProviderCfg, ok := c.cfg.Config().Providers.Get(smallModelCfg.Provider)
530 if !ok {
536 if !ok {
531 return Model{}, Model{}, errSmallModelProviderNotConfigured
537 return Model{}, Model{}, errSmallModelProviderNotConfigured
532 }
538 }
@@ -614,7 +620,7 @@ func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map
614 opts = append(opts, anthropic.WithBaseURL(baseURL))
620 opts = append(opts, anthropic.WithBaseURL(baseURL))
615 }
621 }
616
622
617− if c.cfg.Options.Debug {
623+ if c.cfg.Config().Options.Debug {
618 httpClient := log.NewHTTPClient()
624 httpClient := log.NewHTTPClient()
619 opts = append(opts, anthropic.WithHTTPClient(httpClient))
625 opts = append(opts, anthropic.WithHTTPClient(httpClient))
620 }
626 }
@@ -626,7 +632,7 @@ func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[st
626 openai.WithAPIKey(apiKey),
632 openai.WithAPIKey(apiKey),
627 openai.WithUseResponsesAPI(),
633 openai.WithUseResponsesAPI(),
628 }
634 }
629− if c.cfg.Options.Debug {
635+ if c.cfg.Config().Options.Debug {
630 httpClient := log.NewHTTPClient()
636 httpClient := log.NewHTTPClient()
631 opts = append(opts, openai.WithHTTPClient(httpClient))
637 opts = append(opts, openai.WithHTTPClient(httpClient))
632 }
638 }
@@ -643,7 +649,7 @@ func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[stri
643 opts := []openrouter.Option{
649 opts := []openrouter.Option{
644 openrouter.WithAPIKey(apiKey),
650 openrouter.WithAPIKey(apiKey),
645 }
651 }
646− if c.cfg.Options.Debug {
652+ if c.cfg.Config().Options.Debug {
647 httpClient := log.NewHTTPClient()
653 httpClient := log.NewHTTPClient()
648 opts = append(opts, openrouter.WithHTTPClient(httpClient))
654 opts = append(opts, openrouter.WithHTTPClient(httpClient))
649 }
655 }
@@ -657,7 +663,7 @@ func (c *coordinator) buildVercelProvider(_, apiKey string, headers map[string]s
657 opts := []vercel.Option{
663 opts := []vercel.Option{
658 vercel.WithAPIKey(apiKey),
664 vercel.WithAPIKey(apiKey),
659 }
665 }
660− if c.cfg.Options.Debug {
666+ if c.cfg.Config().Options.Debug {
661 httpClient := log.NewHTTPClient()
667 httpClient := log.NewHTTPClient()
662 opts = append(opts, vercel.WithHTTPClient(httpClient))
668 opts = append(opts, vercel.WithHTTPClient(httpClient))
663 }
669 }
@@ -677,8 +683,8 @@ func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers
677 var httpClient *http.Client
683 var httpClient *http.Client
678 if providerID == string(catwalk.InferenceProviderCopilot) {
684 if providerID == string(catwalk.InferenceProviderCopilot) {
679 opts = append(opts, openaicompat.WithUseResponsesAPI())
685 opts = append(opts, openaicompat.WithUseResponsesAPI())
680− httpClient = copilot.NewClient(isSubAgent, c.cfg.Options.Debug)
686+ httpClient = copilot.NewClient(isSubAgent, c.cfg.Config().Options.Debug)
681− } else if c.cfg.Options.Debug {
687+ } else if c.cfg.Config().Options.Debug {
682 httpClient = log.NewHTTPClient()
688 httpClient = log.NewHTTPClient()
683 }
689 }
684 if httpClient != nil {
690 if httpClient != nil {
@@ -702,7 +708,7 @@ func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[str
702 azure.WithAPIKey(apiKey),
708 azure.WithAPIKey(apiKey),
703 azure.WithUseResponsesAPI(),
709 azure.WithUseResponsesAPI(),
704 }
710 }
705− if c.cfg.Options.Debug {
711+ if c.cfg.Config().Options.Debug {
706 httpClient := log.NewHTTPClient()
712 httpClient := log.NewHTTPClient()
707 opts = append(opts, azure.WithHTTPClient(httpClient))
713 opts = append(opts, azure.WithHTTPClient(httpClient))
708 }
714 }
@@ -721,7 +727,7 @@ func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[str
721
727
722func (c *coordinator) buildBedrockProvider(headers map[string]string) (fantasy.Provider, error) {
728func (c *coordinator) buildBedrockProvider(headers map[string]string) (fantasy.Provider, error) {
723 var opts []bedrock.Option
729 var opts []bedrock.Option
724− if c.cfg.Options.Debug {
730+ if c.cfg.Config().Options.Debug {
725 httpClient := log.NewHTTPClient()
731 httpClient := log.NewHTTPClient()
726 opts = append(opts, bedrock.WithHTTPClient(httpClient))
732 opts = append(opts, bedrock.WithHTTPClient(httpClient))
727 }
733 }
@@ -740,7 +746,7 @@ func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[st
740 google.WithBaseURL(baseURL),
746 google.WithBaseURL(baseURL),
741 google.WithGeminiAPIKey(apiKey),
747 google.WithGeminiAPIKey(apiKey),
742 }
748 }
743− if c.cfg.Options.Debug {
749+ if c.cfg.Config().Options.Debug {
744 httpClient := log.NewHTTPClient()
750 httpClient := log.NewHTTPClient()
745 opts = append(opts, google.WithHTTPClient(httpClient))
751 opts = append(opts, google.WithHTTPClient(httpClient))
746 }
752 }
@@ -752,7 +758,7 @@ func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[st
752
758
753func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) {
759func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) {
754 opts := []google.Option{}
760 opts := []google.Option{}
755− if c.cfg.Options.Debug {
761+ if c.cfg.Config().Options.Debug {
756 httpClient := log.NewHTTPClient()
762 httpClient := log.NewHTTPClient()
757 opts = append(opts, google.WithHTTPClient(httpClient))
763 opts = append(opts, google.WithHTTPClient(httpClient))
758 }
764 }
@@ -773,7 +779,7 @@ func (c *coordinator) buildHyperProvider(baseURL, apiKey string) (fantasy.Provid
773 hyper.WithBaseURL(baseURL),
779 hyper.WithBaseURL(baseURL),
774 hyper.WithAPIKey(apiKey),
780 hyper.WithAPIKey(apiKey),
775 }
781 }
776− if c.cfg.Options.Debug {
782+ if c.cfg.Config().Options.Debug {
777 httpClient := log.NewHTTPClient()
783 httpClient := log.NewHTTPClient()
778 opts = append(opts, hyper.WithHTTPClient(httpClient))
784 opts = append(opts, hyper.WithHTTPClient(httpClient))
779 }
785 }
@@ -881,7 +887,7 @@ func (c *coordinator) UpdateModels(ctx context.Context) error {
881 }
887 }
882 c.currentAgent.SetModels(large, small)
888 c.currentAgent.SetModels(large, small)
883
889
884− agentCfg, ok := c.cfg.Agents[config.AgentCoder]
890+ agentCfg, ok := c.cfg.Config().Agents[config.AgentCoder]
885 if !ok {
891 if !ok {
886 return errCoderAgentNotConfigured
892 return errCoderAgentNotConfigured
887 }
893 }
@@ -903,7 +909,7 @@ func (c *coordinator) QueuedPromptsList(sessionID string) []string {
903}
909}
904
910
905func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
911func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
906− providerCfg, ok := c.cfg.Providers.Get(c.currentAgent.Model().ModelCfg.Provider)
912+ providerCfg, ok := c.cfg.Config().Providers.Get(c.currentAgent.Model().ModelCfg.Provider)
907 if !ok {
913 if !ok {
908 return errModelProviderNotConfigured
914 return errModelProviderNotConfigured
909 }
915 }
@@ -916,7 +922,7 @@ func (c *coordinator) isUnauthorized(err error) bool {
916}
922}
917
923
918func (c *coordinator) refreshOAuth2Token(ctx context.Context, providerCfg config.ProviderConfig) error {
924func (c *coordinator) refreshOAuth2Token(ctx context.Context, providerCfg config.ProviderConfig) error {
919− if err := c.cfg.RefreshOAuthToken(ctx, providerCfg.ID); err != nil {
925+ if err := c.cfg.RefreshOAuthToken(ctx, config.ScopeGlobal, providerCfg.ID); err != nil {
920 slog.Error("Failed to refresh OAuth token after 401 error", "provider", providerCfg.ID, "error", err)
926 slog.Error("Failed to refresh OAuth token after 401 error", "provider", providerCfg.ID, "error", err)
921 return err
927 return err
922 }
928 }
@@ -934,7 +940,7 @@ func (c *coordinator) refreshApiKeyTemplate(ctx context.Context, providerCfg con
934 }
940 }
935
941
936 providerCfg.APIKey = newAPIKey
942 providerCfg.APIKey = newAPIKey
937− c.cfg.Providers.Set(providerCfg.ID, providerCfg)
943+ c.cfg.Config().Providers.Set(providerCfg.ID, providerCfg)
938
944
939 if err := c.UpdateModels(ctx); err != nil {
945 if err := c.UpdateModels(ctx); err != nil {
940 return err
946 return err
@@ -978,7 +984,7 @@ func (c *coordinator) runSubAgent(ctx context.Context, params subAgentParams) (f
978 maxTokens = model.ModelCfg.MaxTokens
984 maxTokens = model.ModelCfg.MaxTokens
979 }
985 }
980
986
981− providerCfg, ok := c.cfg.Providers.Get(model.ModelCfg.Provider)
987+ providerCfg, ok := c.cfg.Config().Providers.Get(model.ModelCfg.Provider)
982 if !ok {
988 if !ok {
983 return fantasy.ToolResponse{}, errModelProviderNotConfigured
989 return fantasy.ToolResponse{}, errModelProviderNotConfigured
984 }
990 }
@@ -994,6 +1000,7 @@ func (c *coordinator) runSubAgent(ctx context.Context, params subAgentParams) (f
994 TopK: model.ModelCfg.TopK,
1000 TopK: model.ModelCfg.TopK,
995 FrequencyPenalty: model.ModelCfg.FrequencyPenalty,
1001 FrequencyPenalty: model.ModelCfg.FrequencyPenalty,
996 PresencePenalty: model.ModelCfg.PresencePenalty,
1002 PresencePenalty: model.ModelCfg.PresencePenalty,
1003+ NonInteractive: true,
997 })
1004 })
998 if err != nil {
1005 if err != nil {
999 return fantasy.NewTextErrorResponse("error generating response"), nil
1006 return fantasy.NewTextErrorResponse("error generating response"), nil
internal/agent/coordinator_test.go+1−1
@@ -44,7 +44,7 @@ func (m *mockSessionAgent) Summarize(context.Context, string, fantasy.ProviderOp
44func newTestCoordinator(t *testing.T, env fakeEnv, providerID string, providerCfg config.ProviderConfig) *coordinator {
44func newTestCoordinator(t *testing.T, env fakeEnv, providerID string, providerCfg config.ProviderConfig) *coordinator {
45 cfg, err := config.Init(env.workingDir, "", false)
45 cfg, err := config.Init(env.workingDir, "", false)
46 require.NoError(t, err)
46 require.NoError(t, err)
47− cfg.Providers.Set(providerID, providerCfg)
47+ cfg.Config().Providers.Set(providerID, providerCfg)
48 return &coordinator{
48 return &coordinator{
49 cfg: cfg,
49 cfg: cfg,
50 sessions: env.sessions,
50 sessions: env.sessions,
internal/agent/notify/notify.gonew file+19
@@ -0,0 +1,19 @@
1+// Package notify defines domain notification types for agent events.
2+// These types are decoupled from UI concerns so the agent can publish
3+// events without importing UI packages.
4+package notify
5+
6+// Type identifies the kind of agent notification.
7+type Type string
8+
9+const (
10+ // TypeAgentFinished indicates the agent has completed its turn.
11+ TypeAgentFinished Type = "agent_finished"
12+)
13+
14+// Notification represents a domain event published by the agent.
15+type Notification struct {
16+ SessionID string
17+ SessionTitle string
18+ Type Type
19+}
internal/agent/prompt/prompt.go+15−14
@@ -76,13 +76,13 @@ func NewPrompt(name, promptTemplate string, opts ...Option) (*Prompt, error) {
76 return p, nil
76 return p, nil
77}
77}
78
78
79−func (p *Prompt) Build(ctx context.Context, provider, model string, cfg config.Config) (string, error) {
79+func (p *Prompt) Build(ctx context.Context, provider, model string, store *config.ConfigStore) (string, error) {
80 t, err := template.New(p.name).Parse(p.template)
80 t, err := template.New(p.name).Parse(p.template)
81 if err != nil {
81 if err != nil {
82 return "", fmt.Errorf("parsing template: %w", err)
82 return "", fmt.Errorf("parsing template: %w", err)
83 }
83 }
84 var sb strings.Builder
84 var sb strings.Builder
85− d, err := p.promptData(ctx, provider, model, cfg)
85+ d, err := p.promptData(ctx, provider, model, store)
86 if err != nil {
86 if err != nil {
87 return "", err
87 return "", err
88 }
88 }
@@ -104,11 +104,11 @@ func processFile(filePath string) *ContextFile {
104 }
104 }
105}
105}
106
106
107−func processContextPath(p string, cfg config.Config) []ContextFile {
107+func processContextPath(p string, store *config.ConfigStore) []ContextFile {
108 var contexts []ContextFile
108 var contexts []ContextFile
109 fullPath := p
109 fullPath := p
110 if !filepath.IsAbs(p) {
110 if !filepath.IsAbs(p) {
111− fullPath = filepath.Join(cfg.WorkingDir(), p)
111+ fullPath = filepath.Join(store.WorkingDir(), p)
112 }
112 }
113 info, err := os.Stat(fullPath)
113 info, err := os.Stat(fullPath)
114 if err != nil {
114 if err != nil {
@@ -136,11 +136,11 @@ func processContextPath(p string, cfg config.Config) []ContextFile {
136}
136}
137
137
138// expandPath expands ~ and environment variables in file paths
138// expandPath expands ~ and environment variables in file paths
139−func expandPath(path string, cfg config.Config) string {
139+func expandPath(path string, store *config.ConfigStore) string {
140 path = home.Long(path)
140 path = home.Long(path)
141 // Handle environment variable expansion using the same pattern as config
141 // Handle environment variable expansion using the same pattern as config
142 if strings.HasPrefix(path, "$") {
142 if strings.HasPrefix(path, "$") {
143− if expanded, err := cfg.Resolver().ResolveValue(path); err == nil {
143+ if expanded, err := store.Resolver().ResolveValue(path); err == nil {
144 path = expanded
144 path = expanded
145 }
145 }
146 }
146 }
@@ -148,19 +148,20 @@ func expandPath(path string, cfg config.Config) string {
148 return path
148 return path
149}
149}
150
150
151−func (p *Prompt) promptData(ctx context.Context, provider, model string, cfg config.Config) (PromptDat, error) {
151+func (p *Prompt) promptData(ctx context.Context, provider, model string, store *config.ConfigStore) (PromptDat, error) {
152− workingDir := cmp.Or(p.workingDir, cfg.WorkingDir())
152+ workingDir := cmp.Or(p.workingDir, store.WorkingDir())
153 platform := cmp.Or(p.platform, runtime.GOOS)
153 platform := cmp.Or(p.platform, runtime.GOOS)
154
154
155 files := map[string][]ContextFile{}
155 files := map[string][]ContextFile{}
156
156
157+ cfg := store.Config()
157 for _, pth := range cfg.Options.ContextPaths {
158 for _, pth := range cfg.Options.ContextPaths {
158− expanded := expandPath(pth, cfg)
159+ expanded := expandPath(pth, store)
159 pathKey := strings.ToLower(expanded)
160 pathKey := strings.ToLower(expanded)
160 if _, ok := files[pathKey]; ok {
161 if _, ok := files[pathKey]; ok {
161 continue
162 continue
162 }
163 }
163− content := processContextPath(expanded, cfg)
164+ content := processContextPath(expanded, store)
164 files[pathKey] = content
165 files[pathKey] = content
165 }
166 }
166
167
@@ -169,18 +170,18 @@ func (p *Prompt) promptData(ctx context.Context, provider, model string, cfg con
169 if len(cfg.Options.SkillsPaths) > 0 {
170 if len(cfg.Options.SkillsPaths) > 0 {
170 expandedPaths := make([]string, 0, len(cfg.Options.SkillsPaths))
171 expandedPaths := make([]string, 0, len(cfg.Options.SkillsPaths))
171 for _, pth := range cfg.Options.SkillsPaths {
172 for _, pth := range cfg.Options.SkillsPaths {
172− expandedPaths = append(expandedPaths, expandPath(pth, cfg))
173+ expandedPaths = append(expandedPaths, expandPath(pth, store))
173 }
174 }
174 if discoveredSkills := skills.Discover(expandedPaths); len(discoveredSkills) > 0 {
175 if discoveredSkills := skills.Discover(expandedPaths); len(discoveredSkills) > 0 {
175 availSkillXML = skills.ToPromptXML(discoveredSkills)
176 availSkillXML = skills.ToPromptXML(discoveredSkills)
176 }
177 }
177 }
178 }
178
179
179− isGit := isGitRepo(cfg.WorkingDir())
180+ isGit := isGitRepo(store.WorkingDir())
180 data := PromptDat{
181 data := PromptDat{
181 Provider: provider,
182 Provider: provider,
182 Model: model,
183 Model: model,
183− Config: cfg,
184+ Config: *cfg,
184 WorkingDir: filepath.ToSlash(workingDir),
185 WorkingDir: filepath.ToSlash(workingDir),
185 IsGitRepo: isGit,
186 IsGitRepo: isGit,
186 Platform: platform,
187 Platform: platform,
@@ -189,7 +190,7 @@ func (p *Prompt) promptData(ctx context.Context, provider, model string, cfg con
189 }
190 }
190 if isGit {
191 if isGit {
191 var err error
192 var err error
192− data.GitStatus, err = getGitStatus(ctx, cfg.WorkingDir())
193+ data.GitStatus, err = getGitStatus(ctx, store.WorkingDir())
193 if err != nil {
194 if err != nil {
194 return PromptDat{}, err
195 return PromptDat{}, err
195 }
196 }
internal/agent/prompts.go+1−1
@@ -33,7 +33,7 @@ func taskPrompt(opts ...prompt.Option) (*prompt.Prompt, error) {
33 return systemPrompt, nil
33 return systemPrompt, nil
34}
34}
35
35
36−func InitializePrompt(cfg config.Config) (string, error) {
36+func InitializePrompt(cfg *config.ConfigStore) (string, error) {
37 systemPrompt, err := prompt.NewPrompt("initialize", string(initializePromptTmpl))
37 systemPrompt, err := prompt.NewPrompt("initialize", string(initializePromptTmpl))
38 if err != nil {
38 if err != nil {
39 return "", err
39 return "", err
internal/agent/tools/list_mcp_resources.go+1−1
@@ -28,7 +28,7 @@ const ListMCPResourcesToolName = "list_mcp_resources"
28//go:embed list_mcp_resources.md
28//go:embed list_mcp_resources.md
29var listMCPResourcesDescription []byte
29var listMCPResourcesDescription []byte
30
30
31−func NewListMCPResourcesTool(cfg *config.Config, permissions permission.Service) fantasy.AgentTool {
31+func NewListMCPResourcesTool(cfg *config.ConfigStore, permissions permission.Service) fantasy.AgentTool {
32 return fantasy.NewParallelAgentTool(
32 return fantasy.NewParallelAgentTool(
33 ListMCPResourcesToolName,
33 ListMCPResourcesToolName,
34 string(listMCPResourcesDescription),
34 string(listMCPResourcesDescription),
internal/agent/tools/mcp-tools.go+2−2
@@ -11,7 +11,7 @@ import (
11)
11)
12
12
13// GetMCPTools gets all the currently available MCP tools.
13// GetMCPTools gets all the currently available MCP tools.
14−func GetMCPTools(permissions permission.Service, cfg *config.Config, wd string) []*Tool {
14+func GetMCPTools(permissions permission.Service, cfg *config.ConfigStore, wd string) []*Tool {
15 var result []*Tool
15 var result []*Tool
16 for mcpName, tools := range mcp.Tools() {
16 for mcpName, tools := range mcp.Tools() {
17 for _, tool := range tools {
17 for _, tool := range tools {
@@ -31,7 +31,7 @@ func GetMCPTools(permissions permission.Service, cfg *config.Config, wd string)
31type Tool struct {
31type Tool struct {
32 mcpName string
32 mcpName string
33 tool *mcp.Tool
33 tool *mcp.Tool
34− cfg *config.Config
34+ cfg *config.ConfigStore
35 permissions permission.Service
35 permissions permission.Service
36 workingDir string
36 workingDir string
37 providerOptions fantasy.ProviderOptions
37 providerOptions fantasy.ProviderOptions
internal/agent/tools/mcp/init.go+4−4
@@ -163,11 +163,11 @@ func Close(ctx context.Context) error {
163}
163}
164
164
165// Initialize initializes MCP clients based on the provided configuration.
165// Initialize initializes MCP clients based on the provided configuration.
166−func Initialize(ctx context.Context, permissions permission.Service, cfg *config.Config) {
166+func Initialize(ctx context.Context, permissions permission.Service, cfg *config.ConfigStore) {
167 slog.Info("Initializing MCP clients")
167 slog.Info("Initializing MCP clients")
168 var wg sync.WaitGroup
168 var wg sync.WaitGroup
169 // Initialize states for all configured MCPs
169 // Initialize states for all configured MCPs
170− for name, m := range cfg.MCP {
170+ for name, m := range cfg.Config().MCP {
171 if m.Disabled {
171 if m.Disabled {
172 updateState(name, StateDisabled, nil, nil, Counts{})
172 updateState(name, StateDisabled, nil, nil, Counts{})
173 slog.Debug("Skipping disabled MCP", "name", name)
173 slog.Debug("Skipping disabled MCP", "name", name)
@@ -253,13 +253,13 @@ func WaitForInit(ctx context.Context) error {
253 }
253 }
254}
254}
255
255
256−func getOrRenewClient(ctx context.Context, cfg *config.Config, name string) (*ClientSession, error) {
256+func getOrRenewClient(ctx context.Context, cfg *config.ConfigStore, name string) (*ClientSession, error) {
257 sess, ok := sessions.Get(name)
257 sess, ok := sessions.Get(name)
258 if !ok {
258 if !ok {
259 return nil, fmt.Errorf("mcp '%s' not available", name)
259 return nil, fmt.Errorf("mcp '%s' not available", name)
260 }
260 }
261
261
262− m := cfg.MCP[name]
262+ m := cfg.Config().MCP[name]
263 state, _ := states.Get(name)
263 state, _ := states.Get(name)
264
264
265 timeout := mcpTimeout(m)
265 timeout := mcpTimeout(m)
internal/agent/tools/mcp/prompts.go+1−1
@@ -20,7 +20,7 @@ func Prompts() iter.Seq2[string, []*Prompt] {
20}
20}
21
21
22// GetPromptMessages retrieves the content of an MCP prompt with the given arguments.
22// GetPromptMessages retrieves the content of an MCP prompt with the given arguments.
23−func GetPromptMessages(ctx context.Context, cfg *config.Config, clientName, promptName string, args map[string]string) ([]string, error) {
23+func GetPromptMessages(ctx context.Context, cfg *config.ConfigStore, clientName, promptName string, args map[string]string) ([]string, error) {
24 c, err := getOrRenewClient(ctx, cfg, clientName)
24 c, err := getOrRenewClient(ctx, cfg, clientName)
25 if err != nil {
25 if err != nil {
26 return nil, err
26 return nil, err
internal/agent/tools/mcp/resources.go+2−2
@@ -24,7 +24,7 @@ func Resources() iter.Seq2[string, []*Resource] {
24}
24}
25
25
26// ListResources returns the current resources for an MCP server.
26// ListResources returns the current resources for an MCP server.
27−func ListResources(ctx context.Context, cfg *config.Config, name string) ([]*Resource, error) {
27+func ListResources(ctx context.Context, cfg *config.ConfigStore, name string) ([]*Resource, error) {
28 session, err := getOrRenewClient(ctx, cfg, name)
28 session, err := getOrRenewClient(ctx, cfg, name)
29 if err != nil {
29 if err != nil {
30 return nil, err
30 return nil, err
@@ -43,7 +43,7 @@ func ListResources(ctx context.Context, cfg *config.Config, name string) ([]*Res
43}
43}
44
44
45// ReadResource reads the contents of a resource from an MCP server.
45// ReadResource reads the contents of a resource from an MCP server.
46−func ReadResource(ctx context.Context, cfg *config.Config, name, uri string) ([]*ResourceContents, error) {
46+func ReadResource(ctx context.Context, cfg *config.ConfigStore, name, uri string) ([]*ResourceContents, error) {
47 session, err := getOrRenewClient(ctx, cfg, name)
47 session, err := getOrRenewClient(ctx, cfg, name)
48 if err != nil {
48 if err != nil {
49 return nil, err
49 return nil, err
internal/agent/tools/mcp/tools.go+5−5
@@ -32,7 +32,7 @@ func Tools() iter.Seq2[string, []*Tool] {
32}
32}
33
33
34// RunTool runs an MCP tool with the given input parameters.
34// RunTool runs an MCP tool with the given input parameters.
35−func RunTool(ctx context.Context, cfg *config.Config, name, toolName string, input string) (ToolResult, error) {
35+func RunTool(ctx context.Context, cfg *config.ConfigStore, name, toolName string, input string) (ToolResult, error) {
36 var args map[string]any
36 var args map[string]any
37 if err := json.Unmarshal([]byte(input), &args); err != nil {
37 if err := json.Unmarshal([]byte(input), &args); err != nil {
38 return ToolResult{}, fmt.Errorf("error parsing parameters: %s", err)
38 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
108
108
109// RefreshTools gets the updated list of tools from the MCP and updates the
109// RefreshTools gets the updated list of tools from the MCP and updates the
110// global state.
110// global state.
111−func RefreshTools(ctx context.Context, cfg *config.Config, name string) {
111+func RefreshTools(ctx context.Context, cfg *config.ConfigStore, name string) {
112 session, ok := sessions.Get(name)
112 session, ok := sessions.Get(name)
113 if !ok {
113 if !ok {
114 slog.Warn("Refresh tools: no session", "name", name)
114 slog.Warn("Refresh tools: no session", "name", name)
@@ -139,7 +139,7 @@ func getTools(ctx context.Context, session *ClientSession) ([]*Tool, error) {
139 return result.Tools, nil
139 return result.Tools, nil
140}
140}
141
141
142−func updateTools(cfg *config.Config, name string, tools []*Tool) int {
142+func updateTools(cfg *config.ConfigStore, name string, tools []*Tool) int {
143 tools = filterDisabledTools(cfg, name, tools)
143 tools = filterDisabledTools(cfg, name, tools)
144 if len(tools) == 0 {
144 if len(tools) == 0 {
145 allTools.Del(name)
145 allTools.Del(name)
@@ -150,8 +150,8 @@ func updateTools(cfg *config.Config, name string, tools []*Tool) int {
150}
150}
151
151
152// filterDisabledTools removes tools that are disabled via config.
152// filterDisabledTools removes tools that are disabled via config.
153−func filterDisabledTools(cfg *config.Config, mcpName string, tools []*Tool) []*Tool {
153+func filterDisabledTools(cfg *config.ConfigStore, mcpName string, tools []*Tool) []*Tool {
154− mcpCfg, ok := cfg.MCP[mcpName]
154+ mcpCfg, ok := cfg.Config().MCP[mcpName]
155 if !ok || len(mcpCfg.DisabledTools) == 0 {
155 if !ok || len(mcpCfg.DisabledTools) == 0 {
156 return tools
156 return tools
157 }
157 }
internal/agent/tools/read_mcp_resource.go+1−1
@@ -30,7 +30,7 @@ const ReadMCPResourceToolName = "read_mcp_resource"
30//go:embed read_mcp_resource.md
30//go:embed read_mcp_resource.md
31var readMCPResourceDescription []byte
31var readMCPResourceDescription []byte
32
32
33−func NewReadMCPResourceTool(cfg *config.Config, permissions permission.Service) fantasy.AgentTool {
33+func NewReadMCPResourceTool(cfg *config.ConfigStore, permissions permission.Service) fantasy.AgentTool {
34 return fantasy.NewParallelAgentTool(
34 return fantasy.NewParallelAgentTool(
35 ReadMCPResourceToolName,
35 ReadMCPResourceToolName,
36 string(readMCPResourceDescription),
36 string(readMCPResourceDescription),
internal/app/app.go+36−20
@@ -19,6 +19,7 @@ import (
19 "charm.land/fantasy"
19 "charm.land/fantasy"
20 "charm.land/lipgloss/v2"
20 "charm.land/lipgloss/v2"
21 "github.com/charmbracelet/crush/internal/agent"
21 "github.com/charmbracelet/crush/internal/agent"
22+ "github.com/charmbracelet/crush/internal/agent/notify"
22 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
23 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
23 "github.com/charmbracelet/crush/internal/config"
24 "github.com/charmbracelet/crush/internal/config"
24 "github.com/charmbracelet/crush/internal/db"
25 "github.com/charmbracelet/crush/internal/db"
@@ -60,7 +61,7 @@ type App struct {
60
61
61 LSPManager *lsp.Manager
62 LSPManager *lsp.Manager
62
63
63− config *config.Config
64+ config *config.ConfigStore
64
65
65 serviceEventsWG *sync.WaitGroup
66 serviceEventsWG *sync.WaitGroup
66 eventsCtx context.Context
67 eventsCtx context.Context
@@ -68,16 +69,18 @@ type App struct {
68 tuiWG *sync.WaitGroup
69 tuiWG *sync.WaitGroup
69
70
70 // global context and cleanup functions
71 // global context and cleanup functions
71− globalCtx context.Context
72+ globalCtx context.Context
72− cleanupFuncs []func(context.Context) error
73+ cleanupFuncs []func(context.Context) error
74+ agentNotifications *pubsub.Broker[notify.Notification]
73}
75}
74
76
75// New initializes a new application instance.
77// New initializes a new application instance.
76−func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
78+func New(ctx context.Context, conn *sql.DB, store *config.ConfigStore) (*App, error) {
77 q := db.New(conn)
79 q := db.New(conn)
78 sessions := session.NewService(q, conn)
80 sessions := session.NewService(q, conn)
79 messages := message.NewService(q)
81 messages := message.NewService(q)
80 files := history.NewService(q, conn)
82 files := history.NewService(q, conn)
83+ cfg := store.Config()
81 skipPermissionsRequests := cfg.Permissions != nil && cfg.Permissions.SkipRequests
84 skipPermissionsRequests := cfg.Permissions != nil && cfg.Permissions.SkipRequests
82 var allowedTools []string
85 var allowedTools []string
83 if cfg.Permissions != nil && cfg.Permissions.AllowedTools != nil {
86 if cfg.Permissions != nil && cfg.Permissions.AllowedTools != nil {
@@ -88,17 +91,18 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
88 Sessions: sessions,
91 Sessions: sessions,
89 Messages: messages,
92 Messages: messages,
90 History: files,
93 History: files,
91− Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests, allowedTools),
94+ Permissions: permission.NewPermissionService(store.WorkingDir(), skipPermissionsRequests, allowedTools),
92 FileTracker: filetracker.NewService(q),
95 FileTracker: filetracker.NewService(q),
93− LSPManager: lsp.NewManager(cfg),
96+ LSPManager: lsp.NewManager(store),
94
97
95 globalCtx: ctx,
98 globalCtx: ctx,
96
99
97− config: cfg,
100+ config: store,
98
101
99− events: make(chan tea.Msg, 100),
102+ events: make(chan tea.Msg, 100),
100− serviceEventsWG: &sync.WaitGroup{},
103+ serviceEventsWG: &sync.WaitGroup{},
101− tuiWG: &sync.WaitGroup{},
104+ tuiWG: &sync.WaitGroup{},
105+ agentNotifications: pubsub.NewBroker[notify.Notification](),
102 }
106 }
103
107
104 app.setupEvents()
108 app.setupEvents()
@@ -106,13 +110,13 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
106 // Check for updates in the background.
110 // Check for updates in the background.
107 go app.checkForUpdates(ctx)
111 go app.checkForUpdates(ctx)
108
112
109− go mcp.Initialize(ctx, app.Permissions, cfg)
113+ go mcp.Initialize(ctx, app.Permissions, store)
110
114
111 // cleanup database upon app shutdown
115 // cleanup database upon app shutdown
112 app.cleanupFuncs = append(
116 app.cleanupFuncs = append(
113 app.cleanupFuncs,
117 app.cleanupFuncs,
114 func(context.Context) error { return conn.Close() },
118 func(context.Context) error { return conn.Close() },
115− mcp.Close,
119+ func(ctx context.Context) error { return mcp.Close(ctx) },
116 )
120 )
117
121
118 // TODO: remove the concept of agent config, most likely.
122 // TODO: remove the concept of agent config, most likely.
@@ -138,8 +142,13 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
138 return app, nil
142 return app, nil
139}
143}
140
144
141−// Config returns the application configuration.
145+// Config returns the pure-data configuration.
142func (app *App) Config() *config.Config {
146func (app *App) Config() *config.Config {
147+ return app.config.Config()
148+}
149+
150+// Store returns the config store.
151+func (app *App) Store() *config.ConfigStore {
143 return app.config
152 return app.config
144}
153}
145
154
@@ -157,6 +166,11 @@ func (app *App) SendEvent(msg tea.Msg) {
157 }
166 }
158}
167}
159
168
169+// AgentNotifications returns the broker for agent notification events.
170+func (app *App) AgentNotifications() *pubsub.Broker[notify.Notification] {
171+ return app.agentNotifications
172+}
173+
160// RunNonInteractive runs the application in non-interactive mode with the
174// RunNonInteractive runs the application in non-interactive mode with the
161// given prompt, printing to stdout.
175// given prompt, printing to stdout.
162func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, largeModel, smallModel string, hideSpinner bool) error {
176func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, largeModel, smallModel string, hideSpinner bool) error {
@@ -184,7 +198,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
184 }
198 }
185 stderrTTY = term.IsTerminal(os.Stderr.Fd())
199 stderrTTY = term.IsTerminal(os.Stderr.Fd())
186 stdinTTY = term.IsTerminal(os.Stdin.Fd())
200 stdinTTY = term.IsTerminal(os.Stdin.Fd())
187− progress = app.config.Options.Progress == nil || *app.config.Options.Progress
201+ progress = app.config.Config().Options.Progress == nil || *app.config.Config().Options.Progress
188
202
189 if !hideSpinner && stderrTTY {
203 if !hideSpinner && stderrTTY {
190 t := styles.DefaultStyles()
204 t := styles.DefaultStyles()
@@ -337,7 +351,7 @@ func (app *App) UpdateAgentModel(ctx context.Context) error {
337// If largeModel is provided but smallModel is not, the small model defaults to
351// If largeModel is provided but smallModel is not, the small model defaults to
338// the provider's default small model.
352// the provider's default small model.
339func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel, smallModel string) error {
353func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel, smallModel string) error {
340− providers := app.config.Providers.Copy()
354+ providers := app.config.Config().Providers.Copy()
341
355
342 largeMatches, smallMatches, err := findModels(providers, largeModel, smallModel)
356 largeMatches, smallMatches, err := findModels(providers, largeModel, smallModel)
343 if err != nil {
357 if err != nil {
@@ -354,7 +368,7 @@ func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel,
354 }
368 }
355 largeProviderID = found.provider
369 largeProviderID = found.provider
356 slog.Info("Overriding large model for non-interactive run", "provider", found.provider, "model", found.modelID)
370 slog.Info("Overriding large model for non-interactive run", "provider", found.provider, "model", found.modelID)
357− app.config.Models[config.SelectedModelTypeLarge] = config.SelectedModel{
371+ app.config.Config().Models[config.SelectedModelTypeLarge] = config.SelectedModel{
358 Provider: found.provider,
372 Provider: found.provider,
359 Model: found.modelID,
373 Model: found.modelID,
360 }
374 }
@@ -368,7 +382,7 @@ func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel,
368 return err
382 return err
369 }
383 }
370 slog.Info("Overriding small model for non-interactive run", "provider", found.provider, "model", found.modelID)
384 slog.Info("Overriding small model for non-interactive run", "provider", found.provider, "model", found.modelID)
371− app.config.Models[config.SelectedModelTypeSmall] = config.SelectedModel{
385+ app.config.Config().Models[config.SelectedModelTypeSmall] = config.SelectedModel{
372 Provider: found.provider,
386 Provider: found.provider,
373 Model: found.modelID,
387 Model: found.modelID,
374 }
388 }
@@ -376,7 +390,7 @@ func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel,
376 case largeModel != "":
390 case largeModel != "":
377 // No small model specified, but large model was - use provider's default.
391 // No small model specified, but large model was - use provider's default.
378 smallCfg := app.GetDefaultSmallModel(largeProviderID)
392 smallCfg := app.GetDefaultSmallModel(largeProviderID)
379− app.config.Models[config.SelectedModelTypeSmall] = smallCfg
393+ app.config.Config().Models[config.SelectedModelTypeSmall] = smallCfg
380 }
394 }
381
395
382 return app.AgentCoordinator.UpdateModels(ctx)
396 return app.AgentCoordinator.UpdateModels(ctx)
@@ -385,7 +399,7 @@ func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel,
385// GetDefaultSmallModel returns the default small model for the given
399// GetDefaultSmallModel returns the default small model for the given
386// provider. Falls back to the large model if no default is found.
400// provider. Falls back to the large model if no default is found.
387func (app *App) GetDefaultSmallModel(providerID string) config.SelectedModel {
401func (app *App) GetDefaultSmallModel(providerID string) config.SelectedModel {
388− cfg := app.config
402+ cfg := app.config.Config()
389 largeModelCfg := cfg.Models[config.SelectedModelTypeLarge]
403 largeModelCfg := cfg.Models[config.SelectedModelTypeLarge]
390
404
391 // Find the provider in the known providers list to get its default small model.
405 // Find the provider in the known providers list to get its default small model.
@@ -428,6 +442,7 @@ func (app *App) setupEvents() {
428 setupSubscriber(ctx, app.serviceEventsWG, "permissions", app.Permissions.Subscribe, app.events)
442 setupSubscriber(ctx, app.serviceEventsWG, "permissions", app.Permissions.Subscribe, app.events)
429 setupSubscriber(ctx, app.serviceEventsWG, "permissions-notifications", app.Permissions.SubscribeNotifications, app.events)
443 setupSubscriber(ctx, app.serviceEventsWG, "permissions-notifications", app.Permissions.SubscribeNotifications, app.events)
430 setupSubscriber(ctx, app.serviceEventsWG, "history", app.History.Subscribe, app.events)
444 setupSubscriber(ctx, app.serviceEventsWG, "history", app.History.Subscribe, app.events)
445+ setupSubscriber(ctx, app.serviceEventsWG, "agent-notifications", app.agentNotifications.Subscribe, app.events)
431 setupSubscriber(ctx, app.serviceEventsWG, "mcp", mcp.SubscribeEvents, app.events)
446 setupSubscriber(ctx, app.serviceEventsWG, "mcp", mcp.SubscribeEvents, app.events)
432 setupSubscriber(ctx, app.serviceEventsWG, "lsp", SubscribeLSPEvents, app.events)
447 setupSubscriber(ctx, app.serviceEventsWG, "lsp", SubscribeLSPEvents, app.events)
433 cleanupFunc := func(context.Context) error {
448 cleanupFunc := func(context.Context) error {
@@ -486,7 +501,7 @@ func setupSubscriber[T any](
486}
501}
487
502
488func (app *App) InitCoderAgent(ctx context.Context) error {
503func (app *App) InitCoderAgent(ctx context.Context) error {
489− coderAgentCfg := app.config.Agents[config.AgentCoder]
504+ coderAgentCfg := app.config.Config().Agents[config.AgentCoder]
490 if coderAgentCfg.ID == "" {
505 if coderAgentCfg.ID == "" {
491 return fmt.Errorf("coder agent configuration is missing")
506 return fmt.Errorf("coder agent configuration is missing")
492 }
507 }
@@ -500,6 +515,7 @@ func (app *App) InitCoderAgent(ctx context.Context) error {
500 app.History,
515 app.History,
501 app.FileTracker,
516 app.FileTracker,
502 app.LSPManager,
517 app.LSPManager,
518+ app.agentNotifications,
503 )
519 )
504 if err != nil {
520 if err != nil {
505 slog.Error("Failed to create coder agent", "err", err)
521 slog.Error("Failed to create coder agent", "err", err)
internal/backend/backend.go+18−17
@@ -38,7 +38,7 @@ type ShutdownFunc func()
38// server. It manages workspaces and delegates to [app.App] services.
38// server. It manages workspaces and delegates to [app.App] services.
39type Backend struct {
39type Backend struct {
40 workspaces *csync.Map[string, *Workspace]
40 workspaces *csync.Map[string, *Workspace]
41− cfg *config.Config
41+ cfg *config.ConfigStore
42 ctx context.Context
42 ctx context.Context
43 shutdownFn ShutdownFunc
43 shutdownFn ShutdownFunc
44}
44}
@@ -49,12 +49,12 @@ type Workspace struct {
49 *app.App
49 *app.App
50 ID string
50 ID string
51 Path string
51 Path string
52− Cfg *config.Config
52+ Cfg *config.ConfigStore
53 Env []string
53 Env []string
54}
54}
55
55
56// New creates a new [Backend].
56// New creates a new [Backend].
57−func New(ctx context.Context, cfg *config.Config, shutdownFn ShutdownFunc) *Backend {
57+func New(ctx context.Context, cfg *config.ConfigStore, shutdownFn ShutdownFunc) *Backend {
58 return &Backend{
58 return &Backend{
59 workspaces: csync.NewMap[string, *Workspace](),
59 workspaces: csync.NewMap[string, *Workspace](),
60 cfg: cfg,
60 cfg: cfg,
@@ -95,16 +95,16 @@ func (b *Backend) CreateWorkspace(args proto.Workspace) (*Workspace, proto.Works
95 return nil, proto.Workspace{}, fmt.Errorf("failed to initialize config: %w", err)
95 return nil, proto.Workspace{}, fmt.Errorf("failed to initialize config: %w", err)
96 }
96 }
97
97
98− if cfg.Permissions == nil {
98+ if cfg.Config().Permissions == nil {
99− cfg.Permissions = &config.Permissions{}
99+ cfg.Config().Permissions = &config.Permissions{}
100 }
100 }
101− cfg.Permissions.SkipRequests = args.YOLO
101+ cfg.Config().Permissions.SkipRequests = args.YOLO
102
102
103− if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
103+ if err := createDotCrushDir(cfg.Config().Options.DataDirectory); err != nil {
104 return nil, proto.Workspace{}, fmt.Errorf("failed to create data directory: %w", err)
104 return nil, proto.Workspace{}, fmt.Errorf("failed to create data directory: %w", err)
105 }
105 }
106
106
107− conn, err := db.Connect(b.ctx, cfg.Options.DataDirectory)
107+ conn, err := db.Connect(b.ctx, cfg.Config().Options.DataDirectory)
108 if err != nil {
108 if err != nil {
109 return nil, proto.Workspace{}, fmt.Errorf("failed to connect to database: %w", err)
109 return nil, proto.Workspace{}, fmt.Errorf("failed to connect to database: %w", err)
110 }
110 }
@@ -138,10 +138,10 @@ func (b *Backend) CreateWorkspace(args proto.Workspace) (*Workspace, proto.Works
138 result := proto.Workspace{
138 result := proto.Workspace{
139 ID: id,
139 ID: id,
140 Path: args.Path,
140 Path: args.Path,
141− DataDir: cfg.Options.DataDirectory,
141+ DataDir: cfg.Config().Options.DataDirectory,
142− Debug: cfg.Options.Debug,
142+ Debug: cfg.Config().Options.Debug,
143− YOLO: cfg.Permissions.SkipRequests,
143+ YOLO: cfg.Config().Permissions.SkipRequests,
144− Config: cfg,
144+ Config: cfg.Config(),
145 Env: args.Env,
145 Env: args.Env,
146 }
146 }
147
147
@@ -183,7 +183,7 @@ func (b *Backend) VersionInfo() proto.VersionInfo {
183}
183}
184
184
185// Config returns the server-level configuration.
185// Config returns the server-level configuration.
186−func (b *Backend) Config() *config.Config {
186+func (b *Backend) Config() *config.ConfigStore {
187 return b.cfg
187 return b.cfg
188}
188}
189
189
@@ -195,12 +195,13 @@ func (b *Backend) Shutdown() {
195}
195}
196
196
197func workspaceToProto(ws *Workspace) proto.Workspace {
197func workspaceToProto(ws *Workspace) proto.Workspace {
198+ cfg := ws.Cfg.Config()
198 return proto.Workspace{
199 return proto.Workspace{
199 ID: ws.ID,
200 ID: ws.ID,
200 Path: ws.Path,
201 Path: ws.Path,
201− YOLO: ws.Cfg.Permissions != nil && ws.Cfg.Permissions.SkipRequests,
202+ YOLO: cfg.Permissions != nil && cfg.Permissions.SkipRequests,
202− DataDir: ws.Cfg.Options.DataDirectory,
203+ DataDir: cfg.Options.DataDirectory,
203− Debug: ws.Cfg.Options.Debug,
204+ Debug: cfg.Options.Debug,
204− Config: ws.Cfg,
205+ Config: cfg,
205 }
206 }
206}
207}
internal/backend/events.go+2−2
@@ -51,7 +51,7 @@ func (b *Backend) GetWorkspaceConfig(workspaceID string) (*config.Config, error)
51 return nil, err
51 return nil, err
52 }
52 }
53
53
54− return ws.Cfg, nil
54+ return ws.Cfg.Config(), nil
55}
55}
56
56
57// GetWorkspaceProviders returns the configured providers for a
57// GetWorkspaceProviders returns the configured providers for a
@@ -62,6 +62,6 @@ func (b *Backend) GetWorkspaceProviders(workspaceID string) (any, error) {
62 return nil, err
62 return nil, err
63 }
63 }
64
64
65− providers, _ := config.Providers(ws.Cfg)
65+ providers, _ := config.Providers(ws.Cfg.Config())
66 return providers, nil
66 return providers, nil
67}
67}
internal/cmd/login.go+9−9
@@ -52,16 +52,16 @@ crush login copilot
52 }
52 }
53 switch provider {
53 switch provider {
54 case "hyper":
54 case "hyper":
55− return loginHyper(app.Config())
55+ return loginHyper(app.Store())
56 case "copilot", "github", "github-copilot":
56 case "copilot", "github", "github-copilot":
57− return loginCopilot(app.Config())
57+ return loginCopilot(app.Store())
58 default:
58 default:
59 return fmt.Errorf("unknown platform: %s", args[0])
59 return fmt.Errorf("unknown platform: %s", args[0])
60 }
60 }
61 },
61 },
62}
62}
63
63
64−func loginHyper(cfg *config.Config) error {
64+func loginHyper(cfg *config.ConfigStore) error {
65 if !hyperp.Enabled() {
65 if !hyperp.Enabled() {
66 return fmt.Errorf("hyper not enabled")
66 return fmt.Errorf("hyper not enabled")
67 }
67 }
@@ -112,8 +112,8 @@ func loginHyper(cfg *config.Config) error {
112 }
112 }
113
113
114 if err := cmp.Or(
114 if err := cmp.Or(
115− cfg.SetConfigField("providers.hyper.api_key", token.AccessToken),
115+ cfg.SetConfigField(config.ScopeGlobal, "providers.hyper.api_key", token.AccessToken),
116− cfg.SetConfigField("providers.hyper.oauth", token),
116+ cfg.SetConfigField(config.ScopeGlobal, "providers.hyper.oauth", token),
117 ); err != nil {
117 ); err != nil {
118 return err
118 return err
119 }
119 }
@@ -123,10 +123,10 @@ func loginHyper(cfg *config.Config) error {
123 return nil
123 return nil
124}
124}
125
125
126−func loginCopilot(cfg *config.Config) error {
126+func loginCopilot(cfg *config.ConfigStore) error {
127 ctx := getLoginContext()
127 ctx := getLoginContext()
128
128
129− if cfg.HasConfigField("providers.copilot.oauth") {
129+ if cfg.HasConfigField(config.ScopeGlobal, "providers.copilot.oauth") {
130 fmt.Println("You are already logged in to GitHub Copilot.")
130 fmt.Println("You are already logged in to GitHub Copilot.")
131 return nil
131 return nil
132 }
132 }
@@ -177,8 +177,8 @@ func loginCopilot(cfg *config.Config) error {
177 }
177 }
178
178
179 if err := cmp.Or(
179 if err := cmp.Or(
180− cfg.SetConfigField("providers.copilot.api_key", token.AccessToken),
180+ cfg.SetConfigField(config.ScopeGlobal, "providers.copilot.api_key", token.AccessToken),
181− cfg.SetConfigField("providers.copilot.oauth", token),
181+ cfg.SetConfigField(config.ScopeGlobal, "providers.copilot.oauth", token),
182 ); err != nil {
182 ); err != nil {
183 return err
183 return err
184 }
184 }
internal/cmd/logs.go+1−1
@@ -55,7 +55,7 @@ var logsCmd = &cobra.Command{
55 if err != nil {
55 if err != nil {
56 return fmt.Errorf("failed to load configuration: %v", err)
56 return fmt.Errorf("failed to load configuration: %v", err)
57 }
57 }
58− logsFile := filepath.Join(cfg.Options.DataDirectory, "logs", "crush.log")
58+ logsFile := filepath.Join(cfg.Config().Options.DataDirectory, "logs", "crush.log")
59 _, err = os.Stat(logsFile)
59 _, err = os.Stat(logsFile)
60 if os.IsNotExist(err) {
60 if os.IsNotExist(err) {
61 log.Warn("Looks like you are not in a crush project. No logs found.")
61 log.Warn("Looks like you are not in a crush project. No logs found.")
internal/cmd/models.go+2−2
@@ -38,7 +38,7 @@ crush models gpt5`,
38 return err
38 return err
39 }
39 }
40
40
41− if !cfg.IsConfigured() {
41+ if !cfg.Config().IsConfigured() {
42 return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively")
42 return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively")
43 }
43 }
44
44
@@ -55,7 +55,7 @@ crush models gpt5`,
55 var providerIDs []string
55 var providerIDs []string
56 providerModels := make(map[string][]string)
56 providerModels := make(map[string][]string)
57
57
58− for providerID, provider := range cfg.Providers.Seq2() {
58+ for providerID, provider := range cfg.Config().Providers.Seq2() {
59 if provider.Disable {
59 if provider.Disable {
60 continue
60 continue
61 }
61 }
internal/cmd/root.go+3−2
@@ -235,11 +235,12 @@ func setupApp(cmd *cobra.Command) (*app.App, error) {
235 return nil, err
235 return nil, err
236 }
236 }
237
237
238− cfg, err := config.Init(cwd, dataDir, debug)
238+ store, err := config.Init(cwd, dataDir, debug)
239 if err != nil {
239 if err != nil {
240 return nil, err
240 return nil, err
241 }
241 }
242
242
243+ cfg := store.Config()
243 if cfg.Permissions == nil {
244 if cfg.Permissions == nil {
244 cfg.Permissions = &config.Permissions{}
245 cfg.Permissions = &config.Permissions{}
245 }
246 }
@@ -261,7 +262,7 @@ func setupApp(cmd *cobra.Command) (*app.App, error) {
261 return nil, err
262 return nil, err
262 }
263 }
263
264
264− appInstance, err := app.New(ctx, conn, cfg)
265+ appInstance, err := app.New(ctx, conn, store)
265 if err != nil {
266 if err != nil {
266 slog.Error("Failed to create app instance", "error", err)
267 slog.Error("Failed to create app instance", "error", err)
267 return nil, err
268 return nil, err
internal/cmd/stats.go+1−1
@@ -131,7 +131,7 @@ func runStats(cmd *cobra.Command, _ []string) error {
131 if err != nil {
131 if err != nil {
132 return fmt.Errorf("failed to initialize config: %w", err)
132 return fmt.Errorf("failed to initialize config: %w", err)
133 }
133 }
134− dataDir = cfg.Options.DataDirectory
134+ dataDir = cfg.Config().Options.DataDirectory
135 }
135 }
136
136
137 conn, err := db.Connect(ctx, dataDir)
137 conn, err := db.Connect(ctx, dataDir)
internal/commands/commands.go+1−1
@@ -227,7 +227,7 @@ func isMarkdownFile(name string) bool {
227 return strings.HasSuffix(strings.ToLower(name), ".md")
227 return strings.HasSuffix(strings.ToLower(name), ".md")
228}
228}
229
229
230−func GetMCPPrompt(cfg *config.Config, clientID, promptID string, args map[string]string) (string, error) {
230+func GetMCPPrompt(cfg *config.ConfigStore, clientID, promptID string, args map[string]string) (string, error) {
231 // TODO: we should pass the context down
231 // TODO: we should pass the context down
232 result, err := mcp.GetPromptMessages(context.Background(), cfg, clientID, promptID, args)
232 result, err := mcp.GetPromptMessages(context.Background(), cfg, clientID, promptID, args)
233 if err != nil {
233 if err != nil {
internal/config/config.go+1−248
@@ -8,22 +8,16 @@ import (
8 "maps"
8 "maps"
9 "net/http"
9 "net/http"
10 "net/url"
10 "net/url"
11− "os"
12− "path/filepath"
13 "slices"
11 "slices"
14 "strings"
12 "strings"
15 "time"
13 "time"
16
14
17 "charm.land/catwalk/pkg/catwalk"
15 "charm.land/catwalk/pkg/catwalk"
18− hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
19 "github.com/charmbracelet/crush/internal/csync"
16 "github.com/charmbracelet/crush/internal/csync"
20 "github.com/charmbracelet/crush/internal/env"
17 "github.com/charmbracelet/crush/internal/env"
21 "github.com/charmbracelet/crush/internal/oauth"
18 "github.com/charmbracelet/crush/internal/oauth"
22 "github.com/charmbracelet/crush/internal/oauth/copilot"
19 "github.com/charmbracelet/crush/internal/oauth/copilot"
23− "github.com/charmbracelet/crush/internal/oauth/hyper"
24 "github.com/invopop/jsonschema"
20 "github.com/invopop/jsonschema"
25− "github.com/tidwall/gjson"
26− "github.com/tidwall/sjson"
27)
21)
28
22
29const (
23const (
@@ -261,6 +255,7 @@ type Options struct {
261 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"`
255 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"`
262 AutoLSP *bool `json:"auto_lsp,omitempty" jsonschema:"description=Automatically setup LSPs based on root markers,default=true"`
256 AutoLSP *bool `json:"auto_lsp,omitempty" jsonschema:"description=Automatically setup LSPs based on root markers,default=true"`
263 Progress *bool `json:"progress,omitempty" jsonschema:"description=Show indeterminate progress updates during long operations,default=true"`
257 Progress *bool `json:"progress,omitempty" jsonschema:"description=Show indeterminate progress updates during long operations,default=true"`
258+ DisableNotifications bool `json:"disable_notifications,omitempty" jsonschema:"description=Disable desktop notifications,default=false"`
264}
259}
265
260
266type MCPs map[string]MCPConfig
261type MCPs map[string]MCPConfig
@@ -397,17 +392,6 @@ type Config struct {
397 Tools Tools `json:"tools,omitzero" jsonschema:"description=Tool configurations"`
392 Tools Tools `json:"tools,omitzero" jsonschema:"description=Tool configurations"`
398
393
399 Agents map[string]Agent `json:"-"`
394 Agents map[string]Agent `json:"-"`
400−
401− // Internal
402− workingDir string `json:"-"`
403− // TODO: find a better way to do this this should probably not be part of the config
404− resolver VariableResolver
405− dataConfigDir string `json:"-"`
406− knownProviders []catwalk.Provider `json:"-"`
407−}
408−
409−func (c *Config) WorkingDir() string {
410− return c.workingDir
411}
395}
412
396
413func (c *Config) EnabledProviders() []ProviderConfig {
397func (c *Config) EnabledProviders() []ProviderConfig {
@@ -471,235 +455,8 @@ func (c *Config) SmallModel() *catwalk.Model {
471 return c.GetModel(model.Provider, model.Model)
455 return c.GetModel(model.Provider, model.Model)
472}
456}
473
457
474−func (c *Config) SetCompactMode(enabled bool) error {
475− if c.Options == nil {
476− c.Options = &Options{}
477− }
478− c.Options.TUI.CompactMode = enabled
479− return c.SetConfigField("options.tui.compact_mode", enabled)
480−}
481−
482−func (c *Config) Resolve(key string) (string, error) {
483− if c.resolver == nil {
484− return "", fmt.Errorf("no variable resolver configured")
485− }
486− return c.resolver.ResolveValue(key)
487−}
488−
489−func (c *Config) UpdatePreferredModel(modelType SelectedModelType, model SelectedModel) error {
490− c.Models[modelType] = model
491− if err := c.SetConfigField(fmt.Sprintf("models.%s", modelType), model); err != nil {
492− return fmt.Errorf("failed to update preferred model: %w", err)
493− }
494− if err := c.recordRecentModel(modelType, model); err != nil {
495− return err
496− }
497− return nil
498−}
499−
500−func (c *Config) HasConfigField(key string) bool {
501− data, err := os.ReadFile(c.dataConfigDir)
502− if err != nil {
503− return false
504− }
505− return gjson.Get(string(data), key).Exists()
506−}
507−
508−func (c *Config) SetConfigField(key string, value any) error {
509− data, err := os.ReadFile(c.dataConfigDir)
510− if err != nil {
511− if os.IsNotExist(err) {
512− data = []byte("{}")
513− } else {
514− return fmt.Errorf("failed to read config file: %w", err)
515− }
516− }
517−
518− newValue, err := sjson.Set(string(data), key, value)
519− if err != nil {
520− return fmt.Errorf("failed to set config field %s: %w", key, err)
521− }
522− if err := os.MkdirAll(filepath.Dir(c.dataConfigDir), 0o755); err != nil {
523− return fmt.Errorf("failed to create config directory %q: %w", c.dataConfigDir, err)
524− }
525− if err := os.WriteFile(c.dataConfigDir, []byte(newValue), 0o600); err != nil {
526− return fmt.Errorf("failed to write config file: %w", err)
527− }
528− return nil
529−}
530−
531−func (c *Config) RemoveConfigField(key string) error {
532− data, err := os.ReadFile(c.dataConfigDir)
533− if err != nil {
534− return fmt.Errorf("failed to read config file: %w", err)
535− }
536−
537− newValue, err := sjson.Delete(string(data), key)
538− if err != nil {
539− return fmt.Errorf("failed to delete config field %s: %w", key, err)
540− }
541− if err := os.MkdirAll(filepath.Dir(c.dataConfigDir), 0o755); err != nil {
542− return fmt.Errorf("failed to create config directory %q: %w", c.dataConfigDir, err)
543− }
544− if err := os.WriteFile(c.dataConfigDir, []byte(newValue), 0o600); err != nil {
545− return fmt.Errorf("failed to write config file: %w", err)
546− }
547− return nil
548−}
549−
550−// RefreshOAuthToken refreshes the OAuth token for the given provider.
551−func (c *Config) RefreshOAuthToken(ctx context.Context, providerID string) error {
552− providerConfig, exists := c.Providers.Get(providerID)
553− if !exists {
554− return fmt.Errorf("provider %s not found", providerID)
555− }
556−
557− if providerConfig.OAuthToken == nil {
558− return fmt.Errorf("provider %s does not have an OAuth token", providerID)
559− }
560−
561− var newToken *oauth.Token
562− var refreshErr error
563− switch providerID {
564− case string(catwalk.InferenceProviderCopilot):
565− newToken, refreshErr = copilot.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken)
566− case hyperp.Name:
567− newToken, refreshErr = hyper.ExchangeToken(ctx, providerConfig.OAuthToken.RefreshToken)
568− default:
569− return fmt.Errorf("OAuth refresh not supported for provider %s", providerID)
570− }
571− if refreshErr != nil {
572− return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, refreshErr)
573− }
574−
575− slog.Info("Successfully refreshed OAuth token", "provider", providerID)
576− providerConfig.OAuthToken = newToken
577− providerConfig.APIKey = newToken.AccessToken
578−
579− switch providerID {
580− case string(catwalk.InferenceProviderCopilot):
581− providerConfig.SetupGitHubCopilot()
582− }
583−
584− c.Providers.Set(providerID, providerConfig)
585−
586− if err := cmp.Or(
587− c.SetConfigField(fmt.Sprintf("providers.%s.api_key", providerID), newToken.AccessToken),
588− c.SetConfigField(fmt.Sprintf("providers.%s.oauth", providerID), newToken),
589− ); err != nil {
590− return fmt.Errorf("failed to persist refreshed token: %w", err)
591− }
592−
593− return nil
594−}
595−
596−func (c *Config) SetProviderAPIKey(providerID string, apiKey any) error {
597− var providerConfig ProviderConfig
598− var exists bool
599− var setKeyOrToken func()
600−
601− switch v := apiKey.(type) {
602− case string:
603− if err := c.SetConfigField(fmt.Sprintf("providers.%s.api_key", providerID), v); err != nil {
604− return fmt.Errorf("failed to save api key to config file: %w", err)
605− }
606− setKeyOrToken = func() { providerConfig.APIKey = v }
607− case *oauth.Token:
608− if err := cmp.Or(
609− c.SetConfigField(fmt.Sprintf("providers.%s.api_key", providerID), v.AccessToken),
610− c.SetConfigField(fmt.Sprintf("providers.%s.oauth", providerID), v),
611− ); err != nil {
612− return err
613− }
614− setKeyOrToken = func() {
615− providerConfig.APIKey = v.AccessToken
616− providerConfig.OAuthToken = v
617− switch providerID {
618− case string(catwalk.InferenceProviderCopilot):
619− providerConfig.SetupGitHubCopilot()
620− }
621− }
622− }
623−
624− providerConfig, exists = c.Providers.Get(providerID)
625− if exists {
626− setKeyOrToken()
627− c.Providers.Set(providerID, providerConfig)
628− return nil
629− }
630−
631− var foundProvider *catwalk.Provider
632− for _, p := range c.knownProviders {
633− if string(p.ID) == providerID {
634− foundProvider = &p
635− break
636− }
637− }
638−
639− if foundProvider != nil {
640− // Create new provider config based on known provider
641− providerConfig = ProviderConfig{
642− ID: providerID,
643− Name: foundProvider.Name,
644− BaseURL: foundProvider.APIEndpoint,
645− Type: foundProvider.Type,
646− Disable: false,
647− ExtraHeaders: make(map[string]string),
648− ExtraParams: make(map[string]string),
649− Models: foundProvider.Models,
650− }
651− setKeyOrToken()
652− } else {
653− return fmt.Errorf("provider with ID %s not found in known providers", providerID)
654− }
655− // Store the updated provider config
656− c.Providers.Set(providerID, providerConfig)
657− return nil
658−}
659−
660const maxRecentModelsPerType = 5
458const maxRecentModelsPerType = 5
661
459
662−func (c *Config) recordRecentModel(modelType SelectedModelType, model SelectedModel) error {
663− if model.Provider == "" || model.Model == "" {
664− return nil
665− }
666−
667− if c.RecentModels == nil {
668− c.RecentModels = make(map[SelectedModelType][]SelectedModel)
669− }
670−
671− eq := func(a, b SelectedModel) bool {
672− return a.Provider == b.Provider && a.Model == b.Model
673− }
674−
675− entry := SelectedModel{
676− Provider: model.Provider,
677− Model: model.Model,
678− }
679−
680− current := c.RecentModels[modelType]
681− withoutCurrent := slices.DeleteFunc(slices.Clone(current), func(existing SelectedModel) bool {
682− return eq(existing, entry)
683− })
684−
685− updated := append([]SelectedModel{entry}, withoutCurrent...)
686− if len(updated) > maxRecentModelsPerType {
687− updated = updated[:maxRecentModelsPerType]
688− }
689−
690− if slices.EqualFunc(current, updated, eq) {
691− return nil
692− }
693−
694− c.RecentModels[modelType] = updated
695−
696− if err := c.SetConfigField(fmt.Sprintf("recent_models.%s", modelType), updated); err != nil {
697− return fmt.Errorf("failed to persist recent models: %w", err)
698− }
699−
700− return nil
701−}
702−
703func allToolNames() []string {
460func allToolNames() []string {
704 return []string{
461 return []string{
705 "agent",
462 "agent",
@@ -779,10 +536,6 @@ func (c *Config) SetupAgents() {
779 c.Agents = agents
536 c.Agents = agents
780}
537}
781
538
782−func (c *Config) Resolver() VariableResolver {
783− return c.resolver
784−}
785−
786func (c *ProviderConfig) TestConnection(resolver VariableResolver) error {
539func (c *ProviderConfig) TestConnection(resolver VariableResolver) error {
787 var (
540 var (
788 providerID = catwalk.InferenceProvider(c.ID)
541 providerID = catwalk.InferenceProvider(c.ID)
internal/config/copilot.go−47
@@ -1,48 +1 @@
1package config
1package config
2−
3−import (
4− "cmp"
5− "context"
6− "log/slog"
7− "testing"
8−
9− "charm.land/catwalk/pkg/catwalk"
10− "github.com/charmbracelet/crush/internal/oauth"
11− "github.com/charmbracelet/crush/internal/oauth/copilot"
12−)
13−
14−func (c *Config) ImportCopilot() (*oauth.Token, bool) {
15− if testing.Testing() {
16− return nil, false
17− }
18−
19− if c.HasConfigField("providers.copilot.api_key") || c.HasConfigField("providers.copilot.oauth") {
20− return nil, false
21− }
22−
23− diskToken, hasDiskToken := copilot.RefreshTokenFromDisk()
24− if !hasDiskToken {
25− return nil, false
26− }
27−
28− slog.Info("Found existing GitHub Copilot token on disk. Authenticating...")
29− token, err := copilot.RefreshToken(context.TODO(), diskToken)
30− if err != nil {
31− slog.Error("Unable to import GitHub Copilot token", "error", err)
32− return nil, false
33− }
34−
35− if err := c.SetProviderAPIKey(string(catwalk.InferenceProviderCopilot), token); err != nil {
36− return token, false
37− }
38−
39− if err := cmp.Or(
40− c.SetConfigField("providers.copilot.api_key", token.AccessToken),
41− c.SetConfigField("providers.copilot.oauth", token),
42− ); err != nil {
43− slog.Error("Unable to save GitHub Copilot token to disk", "error", err)
44− }
45−
46− slog.Info("GitHub Copilot successfully imported")
47− return token, true
48−}
internal/config/init.go+15−14
@@ -18,19 +18,20 @@ type ProjectInitFlag struct {
18 Initialized bool `json:"initialized"`
18 Initialized bool `json:"initialized"`
19}
19}
20
20
21−func Init(workingDir, dataDir string, debug bool) (*Config, error) {
21+func Init(workingDir, dataDir string, debug bool) (*ConfigStore, error) {
22− cfg, err := Load(workingDir, dataDir, debug)
22+ store, err := Load(workingDir, dataDir, debug)
23 if err != nil {
23 if err != nil {
24 return nil, err
24 return nil, err
25 }
25 }
26− return cfg, nil
26+ return store, nil
27}
27}
28
28
29−func ProjectNeedsInitialization(cfg *Config) (bool, error) {
29+func ProjectNeedsInitialization(store *ConfigStore) (bool, error) {
30− if cfg == nil {
30+ if store == nil {
31 return false, fmt.Errorf("config not loaded")
31 return false, fmt.Errorf("config not loaded")
32 }
32 }
33
33
34+ cfg := store.Config()
34 flagFilePath := filepath.Join(cfg.Options.DataDirectory, InitFlagFilename)
35 flagFilePath := filepath.Join(cfg.Options.DataDirectory, InitFlagFilename)
35
36
36 _, err := os.Stat(flagFilePath)
37 _, err := os.Stat(flagFilePath)
@@ -42,7 +43,7 @@ func ProjectNeedsInitialization(cfg *Config) (bool, error) {
42 return false, fmt.Errorf("failed to check init flag file: %w", err)
43 return false, fmt.Errorf("failed to check init flag file: %w", err)
43 }
44 }
44
45
45− someContextFileExists, err := contextPathsExist(cfg.WorkingDir())
46+ someContextFileExists, err := contextPathsExist(store.WorkingDir())
46 if err != nil {
47 if err != nil {
47 return false, fmt.Errorf("failed to check for context files: %w", err)
48 return false, fmt.Errorf("failed to check for context files: %w", err)
48 }
49 }
@@ -51,7 +52,7 @@ func ProjectNeedsInitialization(cfg *Config) (bool, error) {
51 }
52 }
52
53
53 // If the working directory has no non-ignored files, skip initialization step
54 // If the working directory has no non-ignored files, skip initialization step
54− empty, err := dirHasNoVisibleFiles(cfg.WorkingDir())
55+ empty, err := dirHasNoVisibleFiles(store.WorkingDir())
55 if err != nil {
56 if err != nil {
56 return false, fmt.Errorf("failed to check if directory is empty: %w", err)
57 return false, fmt.Errorf("failed to check if directory is empty: %w", err)
57 }
58 }
@@ -90,7 +91,7 @@ func contextPathsExist(dir string) (bool, error) {
90 return false, nil
91 return false, nil
91}
92}
92
93
93−// dirHasNoVisibleFiles returns true if the directory has no files/dirs after applying ignore rules
94+// dirHasNoVisibleFiles returns true if the directory has no files/dirs after applying ignore rules.
94func dirHasNoVisibleFiles(dir string) (bool, error) {
95func dirHasNoVisibleFiles(dir string) (bool, error) {
95 files, _, err := fsext.ListDirectory(dir, nil, 1, 1)
96 files, _, err := fsext.ListDirectory(dir, nil, 1, 1)
96 if err != nil {
97 if err != nil {
@@ -99,11 +100,11 @@ func dirHasNoVisibleFiles(dir string) (bool, error) {
99 return len(files) == 0, nil
100 return len(files) == 0, nil
100}
101}
101
102
102−func MarkProjectInitialized(cfg *Config) error {
103+func MarkProjectInitialized(store *ConfigStore) error {
103− if cfg == nil {
104+ if store == nil {
104 return fmt.Errorf("config not loaded")
105 return fmt.Errorf("config not loaded")
105 }
106 }
106− flagFilePath := filepath.Join(cfg.Options.DataDirectory, InitFlagFilename)
107+ flagFilePath := filepath.Join(store.Config().Options.DataDirectory, InitFlagFilename)
107
108
108 file, err := os.Create(flagFilePath)
109 file, err := os.Create(flagFilePath)
109 if err != nil {
110 if err != nil {
@@ -114,13 +115,13 @@ func MarkProjectInitialized(cfg *Config) error {
114 return nil
115 return nil
115}
116}
116
117
117−func HasInitialDataConfig(cfg *Config) bool {
118+func HasInitialDataConfig(store *ConfigStore) bool {
118− if cfg == nil {
119+ if store == nil {
119 return false
120 return false
120 }
121 }
121 cfgPath := GlobalConfigData()
122 cfgPath := GlobalConfigData()
122 if _, err := os.Stat(cfgPath); err != nil {
123 if _, err := os.Stat(cfgPath); err != nil {
123 return false
124 return false
124 }
125 }
125− return cfg.IsConfigured()
126+ return store.Config().IsConfigured()
126}
127}
internal/config/load.go+45−17
@@ -29,8 +29,9 @@ import (
29
29
30const defaultCatwalkURL = "https://catwalk.charm.sh"
30const defaultCatwalkURL = "https://catwalk.charm.sh"
31
31
32−// Load loads the configuration from the default paths.
32+// Load loads the configuration from the default paths and returns a
33+// ConfigStore that owns both the pure-data Config and all runtime state.
33−func Load(workingDir, dataDir string, debug bool) (*Config, error) {
34+func Load(workingDir, dataDir string, debug bool) (*ConfigStore, error) {
34 configPaths := lookupConfigs(workingDir)
35 configPaths := lookupConfigs(workingDir)
35
36
36 cfg, err := loadFromConfigPaths(configPaths)
37 cfg, err := loadFromConfigPaths(configPaths)
@@ -38,10 +39,15 @@ func Load(workingDir, dataDir string, debug bool) (*Config, error) {
38 return nil, fmt.Errorf("failed to load config from paths %v: %w", configPaths, err)
39 return nil, fmt.Errorf("failed to load config from paths %v: %w", configPaths, err)
39 }
40 }
40
41
41− cfg.dataConfigDir = GlobalConfigData()
42−
43 cfg.setDefaults(workingDir, dataDir)
42 cfg.setDefaults(workingDir, dataDir)
44
43
44+ store := &ConfigStore{
45+ config: cfg,
46+ workingDir: workingDir,
47+ globalDataPath: GlobalConfigData(),
48+ workspacePath: filepath.Join(cfg.Options.DataDirectory, fmt.Sprintf("%s.json", appName)),
49+ }
50+
45 if debug {
51 if debug {
46 cfg.Options.Debug = true
52 cfg.Options.Debug = true
47 }
53 }
@@ -52,6 +58,18 @@ func Load(workingDir, dataDir string, debug bool) (*Config, error) {
52 cfg.Options.Debug,
58 cfg.Options.Debug,
53 )
59 )
54
60
61+ // Load workspace config last so it has highest priority.
62+ if wsData, err := os.ReadFile(store.workspacePath); err == nil && len(wsData) > 0 {
63+ merged, mergeErr := loadFromBytes(append([][]byte{mustMarshalConfig(cfg)}, wsData))
64+ if mergeErr == nil {
65+ // Preserve defaults that setDefaults already applied.
66+ dataDir := cfg.Options.DataDirectory
67+ *cfg = *merged
68+ cfg.setDefaults(workingDir, dataDir)
69+ store.config = cfg
70+ }
71+ }
72+
55 if !isInsideWorktree() {
73 if !isInsideWorktree() {
56 const depth = 2
74 const depth = 2
57 const items = 100
75 const items = 100
@@ -72,26 +90,36 @@ func Load(workingDir, dataDir string, debug bool) (*Config, error) {
72 if err != nil {
90 if err != nil {
73 return nil, err
91 return nil, err
74 }
92 }
75− cfg.knownProviders = providers
93+ store.knownProviders = providers
76
94
77 env := env.New()
95 env := env.New()
78 // Configure providers
96 // Configure providers
79 valueResolver := NewShellVariableResolver(env)
97 valueResolver := NewShellVariableResolver(env)
80− cfg.resolver = valueResolver
98+ store.resolver = valueResolver
81− if err := cfg.configureProviders(env, valueResolver, cfg.knownProviders); err != nil {
99+ if err := cfg.configureProviders(store, env, valueResolver, store.knownProviders); err != nil {
82 return nil, fmt.Errorf("failed to configure providers: %w", err)
100 return nil, fmt.Errorf("failed to configure providers: %w", err)
83 }
101 }
84
102
85 if !cfg.IsConfigured() {
103 if !cfg.IsConfigured() {
86 slog.Warn("No providers configured")
104 slog.Warn("No providers configured")
87− return cfg, nil
105+ return store, nil
88 }
106 }
89
107
90− if err := cfg.configureSelectedModels(cfg.knownProviders); err != nil {
108+ if err := configureSelectedModels(store, store.knownProviders); err != nil {
91 return nil, fmt.Errorf("failed to configure selected models: %w", err)
109 return nil, fmt.Errorf("failed to configure selected models: %w", err)
92 }
110 }
93− cfg.SetupAgents()
111+ store.SetupAgents()
94− return cfg, nil
112+ return store, nil
113+}
114+
115+// mustMarshalConfig marshals the config to JSON bytes, returning empty JSON on
116+// error.
117+func mustMarshalConfig(cfg *Config) []byte {
118+ data, err := json.Marshal(cfg)
119+ if err != nil {
120+ return []byte("{}")
121+ }
122+ return data
95}
123}
96
124
97func PushPopCrushEnv() func() {
125func PushPopCrushEnv() func() {
@@ -122,7 +150,7 @@ func PushPopCrushEnv() func() {
122 return restore
150 return restore
123}
151}
124
152
125−func (c *Config) configureProviders(env env.Env, resolver VariableResolver, knownProviders []catwalk.Provider) error {
153+func (c *Config) configureProviders(store *ConfigStore, env env.Env, resolver VariableResolver, knownProviders []catwalk.Provider) error {
126 knownProviderNames := make(map[string]bool)
154 knownProviderNames := make(map[string]bool)
127 restore := PushPopCrushEnv()
155 restore := PushPopCrushEnv()
128 defer restore()
156 defer restore()
@@ -209,7 +237,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
209 switch {
237 switch {
210 case p.ID == catwalk.InferenceProviderAnthropic && config.OAuthToken != nil:
238 case p.ID == catwalk.InferenceProviderAnthropic && config.OAuthToken != nil:
211 // Claude Code subscription is not supported anymore. Remove to show onboarding.
239 // Claude Code subscription is not supported anymore. Remove to show onboarding.
212− c.RemoveConfigField("providers.anthropic")
240+ store.RemoveConfigField(ScopeGlobal, "providers.anthropic")
213 c.Providers.Del(string(p.ID))
241 c.Providers.Del(string(p.ID))
214 continue
242 continue
215 case p.ID == catwalk.InferenceProviderCopilot && config.OAuthToken != nil:
243 case p.ID == catwalk.InferenceProviderCopilot && config.OAuthToken != nil:
@@ -340,7 +368,6 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
340}
368}
341
369
342func (c *Config) setDefaults(workingDir, dataDir string) {
370func (c *Config) setDefaults(workingDir, dataDir string) {
343− c.workingDir = workingDir
344 if c.Options == nil {
371 if c.Options == nil {
345 c.Options = &Options{}
372 c.Options = &Options{}
346 }
373 }
@@ -524,7 +551,8 @@ func (c *Config) defaultModelSelection(knownProviders []catwalk.Provider) (large
524 return largeModel, smallModel, err
551 return largeModel, smallModel, err
525}
552}
526
553
527−func (c *Config) configureSelectedModels(knownProviders []catwalk.Provider) error {
554+func configureSelectedModels(store *ConfigStore, knownProviders []catwalk.Provider) error {
555+ c := store.config
528 defaultLarge, defaultSmall, err := c.defaultModelSelection(knownProviders)
556 defaultLarge, defaultSmall, err := c.defaultModelSelection(knownProviders)
529 if err != nil {
557 if err != nil {
530 return fmt.Errorf("failed to select default models: %w", err)
558 return fmt.Errorf("failed to select default models: %w", err)
@@ -543,7 +571,7 @@ func (c *Config) configureSelectedModels(knownProviders []catwalk.Provider) erro
543 if model == nil {
571 if model == nil {
544 large = defaultLarge
572 large = defaultLarge
545 // override the model type to large
573 // override the model type to large
546− err := c.UpdatePreferredModel(SelectedModelTypeLarge, large)
574+ err := store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeLarge, large)
547 if err != nil {
575 if err != nil {
548 return fmt.Errorf("failed to update preferred large model: %w", err)
576 return fmt.Errorf("failed to update preferred large model: %w", err)
549 }
577 }
@@ -587,7 +615,7 @@ func (c *Config) configureSelectedModels(knownProviders []catwalk.Provider) erro
587 if model == nil {
615 if model == nil {
588 small = defaultSmall
616 small = defaultSmall
589 // override the model type to small
617 // override the model type to small
590− err := c.UpdatePreferredModel(SelectedModelTypeSmall, small)
618+ err := store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeSmall, small)
591 if err != nil {
619 if err != nil {
592 return fmt.Errorf("failed to update preferred small model: %w", err)
620 return fmt.Errorf("failed to update preferred small model: %w", err)
593 }
621 }
internal/config/load_test.go+44−40
@@ -36,6 +36,11 @@ func TestConfig_LoadFromBytes(t *testing.T) {
36 require.Equal(t, "https://api.openai.com/v2", pc.BaseURL)
36 require.Equal(t, "https://api.openai.com/v2", pc.BaseURL)
37}
37}
38
38
39+// testStore wraps a Config in a minimal ConfigStore for testing.
40+func testStore(cfg *Config) *ConfigStore {
41+ return &ConfigStore{config: cfg}
42+}
43+
39func TestConfig_setDefaults(t *testing.T) {
44func TestConfig_setDefaults(t *testing.T) {
40 cfg := &Config{}
45 cfg := &Config{}
41
46
@@ -53,7 +58,6 @@ func TestConfig_setDefaults(t *testing.T) {
53 for _, path := range defaultContextPaths {
58 for _, path := range defaultContextPaths {
54 require.Contains(t, cfg.Options.ContextPaths, path)
59 require.Contains(t, cfg.Options.ContextPaths, path)
55 }
60 }
56− require.Equal(t, "/tmp", cfg.workingDir)
57}
61}
58
62
59func TestConfig_configureProviders(t *testing.T) {
63func TestConfig_configureProviders(t *testing.T) {
@@ -74,7 +78,7 @@ func TestConfig_configureProviders(t *testing.T) {
74 "OPENAI_API_KEY": "test-key",
78 "OPENAI_API_KEY": "test-key",
75 })
79 })
76 resolver := NewEnvironmentVariableResolver(env)
80 resolver := NewEnvironmentVariableResolver(env)
77− err := cfg.configureProviders(env, resolver, knownProviders)
81+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
78 require.NoError(t, err)
82 require.NoError(t, err)
79 require.Equal(t, 1, cfg.Providers.Len())
83 require.Equal(t, 1, cfg.Providers.Len())
80
84
@@ -117,7 +121,7 @@ func TestConfig_configureProvidersWithOverride(t *testing.T) {
117 "OPENAI_API_KEY": "test-key",
121 "OPENAI_API_KEY": "test-key",
118 })
122 })
119 resolver := NewEnvironmentVariableResolver(env)
123 resolver := NewEnvironmentVariableResolver(env)
120− err := cfg.configureProviders(env, resolver, knownProviders)
124+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
121 require.NoError(t, err)
125 require.NoError(t, err)
122 require.Equal(t, 1, cfg.Providers.Len())
126 require.Equal(t, 1, cfg.Providers.Len())
123
127
@@ -159,7 +163,7 @@ func TestConfig_configureProvidersWithNewProvider(t *testing.T) {
159 "OPENAI_API_KEY": "test-key",
163 "OPENAI_API_KEY": "test-key",
160 })
164 })
161 resolver := NewEnvironmentVariableResolver(env)
165 resolver := NewEnvironmentVariableResolver(env)
162− err := cfg.configureProviders(env, resolver, knownProviders)
166+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
163 require.NoError(t, err)
167 require.NoError(t, err)
164 // Should be to because of the env variable
168 // Should be to because of the env variable
165 require.Equal(t, cfg.Providers.Len(), 2)
169 require.Equal(t, cfg.Providers.Len(), 2)
@@ -195,7 +199,7 @@ func TestConfig_configureProvidersBedrockWithCredentials(t *testing.T) {
195 "AWS_SECRET_ACCESS_KEY": "test-secret-key",
199 "AWS_SECRET_ACCESS_KEY": "test-secret-key",
196 })
200 })
197 resolver := NewEnvironmentVariableResolver(env)
201 resolver := NewEnvironmentVariableResolver(env)
198− err := cfg.configureProviders(env, resolver, knownProviders)
202+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
199 require.NoError(t, err)
203 require.NoError(t, err)
200 require.Equal(t, cfg.Providers.Len(), 1)
204 require.Equal(t, cfg.Providers.Len(), 1)
201
205
@@ -221,7 +225,7 @@ func TestConfig_configureProvidersBedrockWithoutCredentials(t *testing.T) {
221 cfg.setDefaults("/tmp", "")
225 cfg.setDefaults("/tmp", "")
222 env := env.NewFromMap(map[string]string{})
226 env := env.NewFromMap(map[string]string{})
223 resolver := NewEnvironmentVariableResolver(env)
227 resolver := NewEnvironmentVariableResolver(env)
224− err := cfg.configureProviders(env, resolver, knownProviders)
228+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
225 require.NoError(t, err)
229 require.NoError(t, err)
226 // Provider should not be configured without credentials
230 // Provider should not be configured without credentials
227 require.Equal(t, cfg.Providers.Len(), 0)
231 require.Equal(t, cfg.Providers.Len(), 0)
@@ -246,7 +250,7 @@ func TestConfig_configureProvidersBedrockWithoutUnsupportedModel(t *testing.T) {
246 "AWS_SECRET_ACCESS_KEY": "test-secret-key",
250 "AWS_SECRET_ACCESS_KEY": "test-secret-key",
247 })
251 })
248 resolver := NewEnvironmentVariableResolver(env)
252 resolver := NewEnvironmentVariableResolver(env)
249− err := cfg.configureProviders(env, resolver, knownProviders)
253+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
250 require.Error(t, err)
254 require.Error(t, err)
251}
255}
252
256
@@ -269,7 +273,7 @@ func TestConfig_configureProvidersVertexAIWithCredentials(t *testing.T) {
269 "VERTEXAI_LOCATION": "us-central1",
273 "VERTEXAI_LOCATION": "us-central1",
270 })
274 })
271 resolver := NewEnvironmentVariableResolver(env)
275 resolver := NewEnvironmentVariableResolver(env)
272− err := cfg.configureProviders(env, resolver, knownProviders)
276+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
273 require.NoError(t, err)
277 require.NoError(t, err)
274 require.Equal(t, cfg.Providers.Len(), 1)
278 require.Equal(t, cfg.Providers.Len(), 1)
275
279
@@ -301,7 +305,7 @@ func TestConfig_configureProvidersVertexAIWithoutCredentials(t *testing.T) {
301 "GOOGLE_CLOUD_LOCATION": "us-central1",
305 "GOOGLE_CLOUD_LOCATION": "us-central1",
302 })
306 })
303 resolver := NewEnvironmentVariableResolver(env)
307 resolver := NewEnvironmentVariableResolver(env)
304− err := cfg.configureProviders(env, resolver, knownProviders)
308+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
305 require.NoError(t, err)
309 require.NoError(t, err)
306 // Provider should not be configured without proper credentials
310 // Provider should not be configured without proper credentials
307 require.Equal(t, cfg.Providers.Len(), 0)
311 require.Equal(t, cfg.Providers.Len(), 0)
@@ -326,7 +330,7 @@ func TestConfig_configureProvidersVertexAIMissingProject(t *testing.T) {
326 "GOOGLE_CLOUD_LOCATION": "us-central1",
330 "GOOGLE_CLOUD_LOCATION": "us-central1",
327 })
331 })
328 resolver := NewEnvironmentVariableResolver(env)
332 resolver := NewEnvironmentVariableResolver(env)
329− err := cfg.configureProviders(env, resolver, knownProviders)
333+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
330 require.NoError(t, err)
334 require.NoError(t, err)
331 // Provider should not be configured without project
335 // Provider should not be configured without project
332 require.Equal(t, cfg.Providers.Len(), 0)
336 require.Equal(t, cfg.Providers.Len(), 0)
@@ -350,7 +354,7 @@ func TestConfig_configureProvidersSetProviderID(t *testing.T) {
350 "OPENAI_API_KEY": "test-key",
354 "OPENAI_API_KEY": "test-key",
351 })
355 })
352 resolver := NewEnvironmentVariableResolver(env)
356 resolver := NewEnvironmentVariableResolver(env)
353− err := cfg.configureProviders(env, resolver, knownProviders)
357+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
354 require.NoError(t, err)
358 require.NoError(t, err)
355 require.Equal(t, cfg.Providers.Len(), 1)
359 require.Equal(t, cfg.Providers.Len(), 1)
356
360
@@ -541,7 +545,7 @@ func TestConfig_configureProvidersWithDisabledProvider(t *testing.T) {
541 "OPENAI_API_KEY": "test-key",
545 "OPENAI_API_KEY": "test-key",
542 })
546 })
543 resolver := NewEnvironmentVariableResolver(env)
547 resolver := NewEnvironmentVariableResolver(env)
544− err := cfg.configureProviders(env, resolver, knownProviders)
548+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
545 require.NoError(t, err)
549 require.NoError(t, err)
546
550
547 require.Equal(t, cfg.Providers.Len(), 1)
551 require.Equal(t, cfg.Providers.Len(), 1)
@@ -569,7 +573,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
569
573
570 env := env.NewFromMap(map[string]string{})
574 env := env.NewFromMap(map[string]string{})
571 resolver := NewEnvironmentVariableResolver(env)
575 resolver := NewEnvironmentVariableResolver(env)
572− err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
576+ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
573 require.NoError(t, err)
577 require.NoError(t, err)
574
578
575 require.Equal(t, cfg.Providers.Len(), 1)
579 require.Equal(t, cfg.Providers.Len(), 1)
@@ -592,7 +596,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
592
596
593 env := env.NewFromMap(map[string]string{})
597 env := env.NewFromMap(map[string]string{})
594 resolver := NewEnvironmentVariableResolver(env)
598 resolver := NewEnvironmentVariableResolver(env)
595− err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
599+ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
596 require.NoError(t, err)
600 require.NoError(t, err)
597
601
598 require.Equal(t, cfg.Providers.Len(), 0)
602 require.Equal(t, cfg.Providers.Len(), 0)
@@ -614,7 +618,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
614
618
615 env := env.NewFromMap(map[string]string{})
619 env := env.NewFromMap(map[string]string{})
616 resolver := NewEnvironmentVariableResolver(env)
620 resolver := NewEnvironmentVariableResolver(env)
617− err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
621+ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
618 require.NoError(t, err)
622 require.NoError(t, err)
619
623
620 require.Equal(t, cfg.Providers.Len(), 0)
624 require.Equal(t, cfg.Providers.Len(), 0)
@@ -639,7 +643,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
639
643
640 env := env.NewFromMap(map[string]string{})
644 env := env.NewFromMap(map[string]string{})
641 resolver := NewEnvironmentVariableResolver(env)
645 resolver := NewEnvironmentVariableResolver(env)
642− err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
646+ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
643 require.NoError(t, err)
647 require.NoError(t, err)
644
648
645 require.Equal(t, cfg.Providers.Len(), 0)
649 require.Equal(t, cfg.Providers.Len(), 0)
@@ -664,7 +668,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
664
668
665 env := env.NewFromMap(map[string]string{})
669 env := env.NewFromMap(map[string]string{})
666 resolver := NewEnvironmentVariableResolver(env)
670 resolver := NewEnvironmentVariableResolver(env)
667− err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
671+ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
668 require.NoError(t, err)
672 require.NoError(t, err)
669
673
670 require.Equal(t, cfg.Providers.Len(), 1)
674 require.Equal(t, cfg.Providers.Len(), 1)
@@ -692,7 +696,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
692
696
693 env := env.NewFromMap(map[string]string{})
697 env := env.NewFromMap(map[string]string{})
694 resolver := NewEnvironmentVariableResolver(env)
698 resolver := NewEnvironmentVariableResolver(env)
695− err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
699+ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
696 require.NoError(t, err)
700 require.NoError(t, err)
697
701
698 require.Equal(t, cfg.Providers.Len(), 1)
702 require.Equal(t, cfg.Providers.Len(), 1)
@@ -722,7 +726,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
722
726
723 env := env.NewFromMap(map[string]string{})
727 env := env.NewFromMap(map[string]string{})
724 resolver := NewEnvironmentVariableResolver(env)
728 resolver := NewEnvironmentVariableResolver(env)
725− err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
729+ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
726 require.NoError(t, err)
730 require.NoError(t, err)
727
731
728 require.Equal(t, cfg.Providers.Len(), 0)
732 require.Equal(t, cfg.Providers.Len(), 0)
@@ -757,7 +761,7 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
757 "GOOGLE_GENAI_USE_VERTEXAI": "false",
761 "GOOGLE_GENAI_USE_VERTEXAI": "false",
758 })
762 })
759 resolver := NewEnvironmentVariableResolver(env)
763 resolver := NewEnvironmentVariableResolver(env)
760− err := cfg.configureProviders(env, resolver, knownProviders)
764+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
761 require.NoError(t, err)
765 require.NoError(t, err)
762
766
763 require.Equal(t, cfg.Providers.Len(), 0)
767 require.Equal(t, cfg.Providers.Len(), 0)
@@ -788,7 +792,7 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
788
792
789 env := env.NewFromMap(map[string]string{})
793 env := env.NewFromMap(map[string]string{})
790 resolver := NewEnvironmentVariableResolver(env)
794 resolver := NewEnvironmentVariableResolver(env)
791− err := cfg.configureProviders(env, resolver, knownProviders)
795+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
792 require.NoError(t, err)
796 require.NoError(t, err)
793
797
794 require.Equal(t, cfg.Providers.Len(), 0)
798 require.Equal(t, cfg.Providers.Len(), 0)
@@ -819,7 +823,7 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
819
823
820 env := env.NewFromMap(map[string]string{})
824 env := env.NewFromMap(map[string]string{})
821 resolver := NewEnvironmentVariableResolver(env)
825 resolver := NewEnvironmentVariableResolver(env)
822− err := cfg.configureProviders(env, resolver, knownProviders)
826+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
823 require.NoError(t, err)
827 require.NoError(t, err)
824
828
825 require.Equal(t, cfg.Providers.Len(), 0)
829 require.Equal(t, cfg.Providers.Len(), 0)
@@ -852,7 +856,7 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
852 "OPENAI_API_KEY": "test-key",
856 "OPENAI_API_KEY": "test-key",
853 })
857 })
854 resolver := NewEnvironmentVariableResolver(env)
858 resolver := NewEnvironmentVariableResolver(env)
855− err := cfg.configureProviders(env, resolver, knownProviders)
859+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
856 require.NoError(t, err)
860 require.NoError(t, err)
857
861
858 require.Equal(t, cfg.Providers.Len(), 1)
862 require.Equal(t, cfg.Providers.Len(), 1)
@@ -886,7 +890,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
886 cfg.setDefaults("/tmp", "")
890 cfg.setDefaults("/tmp", "")
887 env := env.NewFromMap(map[string]string{})
891 env := env.NewFromMap(map[string]string{})
888 resolver := NewEnvironmentVariableResolver(env)
892 resolver := NewEnvironmentVariableResolver(env)
889− err := cfg.configureProviders(env, resolver, knownProviders)
893+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
890 require.NoError(t, err)
894 require.NoError(t, err)
891
895
892 large, small, err := cfg.defaultModelSelection(knownProviders)
896 large, small, err := cfg.defaultModelSelection(knownProviders)
@@ -922,7 +926,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
922 cfg.setDefaults("/tmp", "")
926 cfg.setDefaults("/tmp", "")
923 env := env.NewFromMap(map[string]string{})
927 env := env.NewFromMap(map[string]string{})
924 resolver := NewEnvironmentVariableResolver(env)
928 resolver := NewEnvironmentVariableResolver(env)
925− err := cfg.configureProviders(env, resolver, knownProviders)
929+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
926 require.NoError(t, err)
930 require.NoError(t, err)
927
931
928 _, _, err = cfg.defaultModelSelection(knownProviders)
932 _, _, err = cfg.defaultModelSelection(knownProviders)
@@ -952,7 +956,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
952 cfg.setDefaults("/tmp", "")
956 cfg.setDefaults("/tmp", "")
953 env := env.NewFromMap(map[string]string{})
957 env := env.NewFromMap(map[string]string{})
954 resolver := NewEnvironmentVariableResolver(env)
958 resolver := NewEnvironmentVariableResolver(env)
955− err := cfg.configureProviders(env, resolver, knownProviders)
959+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
956 require.NoError(t, err)
960 require.NoError(t, err)
957 _, _, err = cfg.defaultModelSelection(knownProviders)
961 _, _, err = cfg.defaultModelSelection(knownProviders)
958 require.Error(t, err)
962 require.Error(t, err)
@@ -995,7 +999,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
995 cfg.setDefaults("/tmp", "")
999 cfg.setDefaults("/tmp", "")
996 env := env.NewFromMap(map[string]string{})
1000 env := env.NewFromMap(map[string]string{})
997 resolver := NewEnvironmentVariableResolver(env)
1001 resolver := NewEnvironmentVariableResolver(env)
998− err := cfg.configureProviders(env, resolver, knownProviders)
1002+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
999 require.NoError(t, err)
1003 require.NoError(t, err)
1000 large, small, err := cfg.defaultModelSelection(knownProviders)
1004 large, small, err := cfg.defaultModelSelection(knownProviders)
1001 require.NoError(t, err)
1005 require.NoError(t, err)
@@ -1039,7 +1043,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
1039 cfg.setDefaults("/tmp", "")
1043 cfg.setDefaults("/tmp", "")
1040 env := env.NewFromMap(map[string]string{})
1044 env := env.NewFromMap(map[string]string{})
1041 resolver := NewEnvironmentVariableResolver(env)
1045 resolver := NewEnvironmentVariableResolver(env)
1042− err := cfg.configureProviders(env, resolver, knownProviders)
1046+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1043 require.NoError(t, err)
1047 require.NoError(t, err)
1044 _, _, err = cfg.defaultModelSelection(knownProviders)
1048 _, _, err = cfg.defaultModelSelection(knownProviders)
1045 require.Error(t, err)
1049 require.Error(t, err)
@@ -1081,7 +1085,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
1081 cfg.setDefaults("/tmp", "")
1085 cfg.setDefaults("/tmp", "")
1082 env := env.NewFromMap(map[string]string{})
1086 env := env.NewFromMap(map[string]string{})
1083 resolver := NewEnvironmentVariableResolver(env)
1087 resolver := NewEnvironmentVariableResolver(env)
1084− err := cfg.configureProviders(env, resolver, knownProviders)
1088+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1085 require.NoError(t, err)
1089 require.NoError(t, err)
1086 large, small, err := cfg.defaultModelSelection(knownProviders)
1090 large, small, err := cfg.defaultModelSelection(knownProviders)
1087 require.NoError(t, err)
1091 require.NoError(t, err)
@@ -1126,7 +1130,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) {
1126 "OPENAI_API_KEY": "test-key",
1130 "OPENAI_API_KEY": "test-key",
1127 })
1131 })
1128 resolver := NewEnvironmentVariableResolver(env)
1132 resolver := NewEnvironmentVariableResolver(env)
1129− err := cfg.configureProviders(env, resolver, knownProviders)
1133+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1130 require.ErrorContains(t, err, "no custom providers")
1134 require.ErrorContains(t, err, "no custom providers")
1131
1135
1132 // openai should NOT be present because it lacks base_url and models.
1136 // openai should NOT be present because it lacks base_url and models.
@@ -1169,7 +1173,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) {
1169 "OPENAI_API_KEY": "test-key",
1173 "OPENAI_API_KEY": "test-key",
1170 })
1174 })
1171 resolver := NewEnvironmentVariableResolver(env)
1175 resolver := NewEnvironmentVariableResolver(env)
1172− err := cfg.configureProviders(env, resolver, knownProviders)
1176+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1173 require.NoError(t, err)
1177 require.NoError(t, err)
1174
1178
1175 // Only fully specified provider should be present.
1179 // Only fully specified provider should be present.
@@ -1223,7 +1227,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) {
1223 "ANTHROPIC_API_KEY": "test-key",
1227 "ANTHROPIC_API_KEY": "test-key",
1224 })
1228 })
1225 resolver := NewEnvironmentVariableResolver(env)
1229 resolver := NewEnvironmentVariableResolver(env)
1226− err := cfg.configureProviders(env, resolver, knownProviders)
1230+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1227 require.NoError(t, err)
1231 require.NoError(t, err)
1228
1232
1229 // Both providers should be present.
1233 // Both providers should be present.
@@ -1251,7 +1255,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) {
1251
1255
1252 env := env.NewFromMap(map[string]string{})
1256 env := env.NewFromMap(map[string]string{})
1253 resolver := NewEnvironmentVariableResolver(env)
1257 resolver := NewEnvironmentVariableResolver(env)
1254− err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
1258+ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
1255 require.ErrorContains(t, err, "no custom providers")
1259 require.ErrorContains(t, err, "no custom providers")
1256
1260
1257 // Provider should be rejected for missing models.
1261 // Provider should be rejected for missing models.
@@ -1275,7 +1279,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) {
1275
1279
1276 env := env.NewFromMap(map[string]string{})
1280 env := env.NewFromMap(map[string]string{})
1277 resolver := NewEnvironmentVariableResolver(env)
1281 resolver := NewEnvironmentVariableResolver(env)
1278− err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
1282+ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
1279 require.ErrorContains(t, err, "no custom providers")
1283 require.ErrorContains(t, err, "no custom providers")
1280
1284
1281 // Provider should be rejected for missing base_url.
1285 // Provider should be rejected for missing base_url.
@@ -1340,10 +1344,10 @@ func TestConfig_configureSelectedModels(t *testing.T) {
1340 cfg.setDefaults("/tmp", "")
1344 cfg.setDefaults("/tmp", "")
1341 env := env.NewFromMap(map[string]string{})
1345 env := env.NewFromMap(map[string]string{})
1342 resolver := NewEnvironmentVariableResolver(env)
1346 resolver := NewEnvironmentVariableResolver(env)
1343− err := cfg.configureProviders(env, resolver, knownProviders)
1347+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1344 require.NoError(t, err)
1348 require.NoError(t, err)
1345
1349
1346− err = cfg.configureSelectedModels(knownProviders)
1350+ err = configureSelectedModels(testStore(cfg), knownProviders)
1347 require.NoError(t, err)
1351 require.NoError(t, err)
1348 large := cfg.Models[SelectedModelTypeLarge]
1352 large := cfg.Models[SelectedModelTypeLarge]
1349 small := cfg.Models[SelectedModelTypeSmall]
1353 small := cfg.Models[SelectedModelTypeSmall]
@@ -1402,10 +1406,10 @@ func TestConfig_configureSelectedModels(t *testing.T) {
1402 cfg.setDefaults("/tmp", "")
1406 cfg.setDefaults("/tmp", "")
1403 env := env.NewFromMap(map[string]string{})
1407 env := env.NewFromMap(map[string]string{})
1404 resolver := NewEnvironmentVariableResolver(env)
1408 resolver := NewEnvironmentVariableResolver(env)
1405− err := cfg.configureProviders(env, resolver, knownProviders)
1409+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1406 require.NoError(t, err)
1410 require.NoError(t, err)
1407
1411
1408− err = cfg.configureSelectedModels(knownProviders)
1412+ err = configureSelectedModels(testStore(cfg), knownProviders)
1409 require.NoError(t, err)
1413 require.NoError(t, err)
1410 large := cfg.Models[SelectedModelTypeLarge]
1414 large := cfg.Models[SelectedModelTypeLarge]
1411 small := cfg.Models[SelectedModelTypeSmall]
1415 small := cfg.Models[SelectedModelTypeSmall]
@@ -1447,10 +1451,10 @@ func TestConfig_configureSelectedModels(t *testing.T) {
1447 cfg.setDefaults("/tmp", "")
1451 cfg.setDefaults("/tmp", "")
1448 env := env.NewFromMap(map[string]string{})
1452 env := env.NewFromMap(map[string]string{})
1449 resolver := NewEnvironmentVariableResolver(env)
1453 resolver := NewEnvironmentVariableResolver(env)
1450− err := cfg.configureProviders(env, resolver, knownProviders)
1454+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1451 require.NoError(t, err)
1455 require.NoError(t, err)
1452
1456
1453− err = cfg.configureSelectedModels(knownProviders)
1457+ err = configureSelectedModels(testStore(cfg), knownProviders)
1454 require.NoError(t, err)
1458 require.NoError(t, err)
1455 large := cfg.Models[SelectedModelTypeLarge]
1459 large := cfg.Models[SelectedModelTypeLarge]
1456 require.Equal(t, "large-model", large.Model)
1460 require.Equal(t, "large-model", large.Model)
internal/config/recent_models_test.go+36−28
@@ -31,15 +31,23 @@ func readRecentModels(t *testing.T, path string) map[string]any {
31 return rm
31 return rm
32}
32}
33
33
34+// testStoreWithPath creates a ConfigStore backed by a Config for recent model tests.
35+func testStoreWithPath(cfg *Config, dir string) *ConfigStore {
36+ return &ConfigStore{
37+ config: cfg,
38+ globalDataPath: filepath.Join(dir, "config.json"),
39+ }
40+}
41+
34func TestRecordRecentModel_AddsAndPersists(t *testing.T) {
42func TestRecordRecentModel_AddsAndPersists(t *testing.T) {
35 t.Parallel()
43 t.Parallel()
36
44
37 dir := t.TempDir()
45 dir := t.TempDir()
38 cfg := &Config{}
46 cfg := &Config{}
39 cfg.setDefaults(dir, "")
47 cfg.setDefaults(dir, "")
40− cfg.dataConfigDir = filepath.Join(dir, "config.json")
48+ store := testStoreWithPath(cfg, dir)
41
49
42− err := cfg.recordRecentModel(SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"})
50+ err := store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"})
43 require.NoError(t, err)
51 require.NoError(t, err)
44
52
45 // in-memory state
53 // in-memory state
@@ -48,7 +56,7 @@ func TestRecordRecentModel_AddsAndPersists(t *testing.T) {
48 require.Equal(t, "gpt-4o", cfg.RecentModels[SelectedModelTypeLarge][0].Model)
56 require.Equal(t, "gpt-4o", cfg.RecentModels[SelectedModelTypeLarge][0].Model)
49
57
50 // persisted state
58 // persisted state
51− rm := readRecentModels(t, cfg.dataConfigDir)
59+ rm := readRecentModels(t, store.globalDataPath)
52 large, ok := rm[string(SelectedModelTypeLarge)].([]any)
60 large, ok := rm[string(SelectedModelTypeLarge)].([]any)
53 require.True(t, ok)
61 require.True(t, ok)
54 require.Len(t, large, 1)
62 require.Len(t, large, 1)
@@ -64,13 +72,13 @@ func TestRecordRecentModel_DedupeAndMoveToFront(t *testing.T) {
64 dir := t.TempDir()
72 dir := t.TempDir()
65 cfg := &Config{}
73 cfg := &Config{}
66 cfg.setDefaults(dir, "")
74 cfg.setDefaults(dir, "")
67− cfg.dataConfigDir = filepath.Join(dir, "config.json")
75+ store := testStoreWithPath(cfg, dir)
68
76
69 // Add two entries
77 // Add two entries
70− require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"}))
78+ require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"}))
71− require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, SelectedModel{Provider: "anthropic", Model: "claude"}))
79+ require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, SelectedModel{Provider: "anthropic", Model: "claude"}))
72 // Re-add first; should move to front and not duplicate
80 // Re-add first; should move to front and not duplicate
73− require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"}))
81+ require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"}))
74
82
75 got := cfg.RecentModels[SelectedModelTypeLarge]
83 got := cfg.RecentModels[SelectedModelTypeLarge]
76 require.Len(t, got, 2)
84 require.Len(t, got, 2)
@@ -84,7 +92,7 @@ func TestRecordRecentModel_TrimsToMax(t *testing.T) {
84 dir := t.TempDir()
92 dir := t.TempDir()
85 cfg := &Config{}
93 cfg := &Config{}
86 cfg.setDefaults(dir, "")
94 cfg.setDefaults(dir, "")
87− cfg.dataConfigDir = filepath.Join(dir, "config.json")
95+ store := testStoreWithPath(cfg, dir)
88
96
89 // Insert 6 unique models; max is 5
97 // Insert 6 unique models; max is 5
90 entries := []SelectedModel{
98 entries := []SelectedModel{
@@ -96,7 +104,7 @@ func TestRecordRecentModel_TrimsToMax(t *testing.T) {
96 {Provider: "p6", Model: "m6"},
104 {Provider: "p6", Model: "m6"},
97 }
105 }
98 for _, e := range entries {
106 for _, e := range entries {
99− require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, e))
107+ require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, e))
100 }
108 }
101
109
102 // in-memory state
110 // in-memory state
@@ -110,7 +118,7 @@ func TestRecordRecentModel_TrimsToMax(t *testing.T) {
110 require.Equal(t, SelectedModel{Provider: "p2", Model: "m2"}, got[4])
118 require.Equal(t, SelectedModel{Provider: "p2", Model: "m2"}, got[4])
111
119
112 // persisted state: verify trimmed to 5 and newest-first order
120 // persisted state: verify trimmed to 5 and newest-first order
113− rm := readRecentModels(t, cfg.dataConfigDir)
121+ rm := readRecentModels(t, store.globalDataPath)
114 large, ok := rm[string(SelectedModelTypeLarge)].([]any)
122 large, ok := rm[string(SelectedModelTypeLarge)].([]any)
115 require.True(t, ok)
123 require.True(t, ok)
116 require.Len(t, large, 5)
124 require.Len(t, large, 5)
@@ -129,12 +137,12 @@ func TestRecordRecentModel_SkipsEmptyValues(t *testing.T) {
129 dir := t.TempDir()
137 dir := t.TempDir()
130 cfg := &Config{}
138 cfg := &Config{}
131 cfg.setDefaults(dir, "")
139 cfg.setDefaults(dir, "")
132− cfg.dataConfigDir = filepath.Join(dir, "config.json")
140+ store := testStoreWithPath(cfg, dir)
133
141
134 // Missing provider
142 // Missing provider
135− require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, SelectedModel{Provider: "", Model: "m"}))
143+ require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, SelectedModel{Provider: "", Model: "m"}))
136 // Missing model
144 // Missing model
137− require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, SelectedModel{Provider: "p", Model: ""}))
145+ require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, SelectedModel{Provider: "p", Model: ""}))
138
146
139 _, ok := cfg.RecentModels[SelectedModelTypeLarge]
147 _, ok := cfg.RecentModels[SelectedModelTypeLarge]
140 // Map may be initialized, but should have no entries
148 // Map may be initialized, but should have no entries
@@ -142,8 +150,8 @@ func TestRecordRecentModel_SkipsEmptyValues(t *testing.T) {
142 require.Len(t, cfg.RecentModels[SelectedModelTypeLarge], 0)
150 require.Len(t, cfg.RecentModels[SelectedModelTypeLarge], 0)
143 }
151 }
144 // No file should be written (stat via fs.FS)
152 // No file should be written (stat via fs.FS)
145− baseDir := filepath.Dir(cfg.dataConfigDir)
153+ baseDir := filepath.Dir(store.globalDataPath)
146− fileName := filepath.Base(cfg.dataConfigDir)
154+ fileName := filepath.Base(store.globalDataPath)
147 _, err := fs.Stat(os.DirFS(baseDir), fileName)
155 _, err := fs.Stat(os.DirFS(baseDir), fileName)
148 require.True(t, os.IsNotExist(err))
156 require.True(t, os.IsNotExist(err))
149}
157}
@@ -154,13 +162,13 @@ func TestRecordRecentModel_NoPersistOnNoop(t *testing.T) {
154 dir := t.TempDir()
162 dir := t.TempDir()
155 cfg := &Config{}
163 cfg := &Config{}
156 cfg.setDefaults(dir, "")
164 cfg.setDefaults(dir, "")
157− cfg.dataConfigDir = filepath.Join(dir, "config.json")
165+ store := testStoreWithPath(cfg, dir)
158
166
159 entry := SelectedModel{Provider: "openai", Model: "gpt-4o"}
167 entry := SelectedModel{Provider: "openai", Model: "gpt-4o"}
160− require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, entry))
168+ require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, entry))
161
169
162− baseDir := filepath.Dir(cfg.dataConfigDir)
170+ baseDir := filepath.Dir(store.globalDataPath)
163− fileName := filepath.Base(cfg.dataConfigDir)
171+ fileName := filepath.Base(store.globalDataPath)
164 before, err := fs.ReadFile(os.DirFS(baseDir), fileName)
172 before, err := fs.ReadFile(os.DirFS(baseDir), fileName)
165 require.NoError(t, err)
173 require.NoError(t, err)
166
174
@@ -170,7 +178,7 @@ func TestRecordRecentModel_NoPersistOnNoop(t *testing.T) {
170 beforeMod := stBefore.ModTime()
178 beforeMod := stBefore.ModTime()
171
179
172 // Re-record same entry should be a no-op (no write)
180 // Re-record same entry should be a no-op (no write)
173− require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, entry))
181+ require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, entry))
174
182
175 after, err := fs.ReadFile(os.DirFS(baseDir), fileName)
183 after, err := fs.ReadFile(os.DirFS(baseDir), fileName)
176 require.NoError(t, err)
184 require.NoError(t, err)
@@ -188,17 +196,17 @@ func TestUpdatePreferredModel_UpdatesRecents(t *testing.T) {
188 dir := t.TempDir()
196 dir := t.TempDir()
189 cfg := &Config{}
197 cfg := &Config{}
190 cfg.setDefaults(dir, "")
198 cfg.setDefaults(dir, "")
191− cfg.dataConfigDir = filepath.Join(dir, "config.json")
199+ store := testStoreWithPath(cfg, dir)
192
200
193 sel := SelectedModel{Provider: "openai", Model: "gpt-4o"}
201 sel := SelectedModel{Provider: "openai", Model: "gpt-4o"}
194− require.NoError(t, cfg.UpdatePreferredModel(SelectedModelTypeSmall, sel))
202+ require.NoError(t, store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeSmall, sel))
195
203
196 // in-memory
204 // in-memory
197 require.Equal(t, sel, cfg.Models[SelectedModelTypeSmall])
205 require.Equal(t, sel, cfg.Models[SelectedModelTypeSmall])
198 require.Len(t, cfg.RecentModels[SelectedModelTypeSmall], 1)
206 require.Len(t, cfg.RecentModels[SelectedModelTypeSmall], 1)
199
207
200 // persisted (read via fs.FS)
208 // persisted (read via fs.FS)
201− rm := readRecentModels(t, cfg.dataConfigDir)
209+ rm := readRecentModels(t, store.globalDataPath)
202 small, ok := rm[string(SelectedModelTypeSmall)].([]any)
210 small, ok := rm[string(SelectedModelTypeSmall)].([]any)
203 require.True(t, ok)
211 require.True(t, ok)
204 require.Len(t, small, 1)
212 require.Len(t, small, 1)
@@ -210,14 +218,14 @@ func TestRecordRecentModel_TypeIsolation(t *testing.T) {
210 dir := t.TempDir()
218 dir := t.TempDir()
211 cfg := &Config{}
219 cfg := &Config{}
212 cfg.setDefaults(dir, "")
220 cfg.setDefaults(dir, "")
213− cfg.dataConfigDir = filepath.Join(dir, "config.json")
221+ store := testStoreWithPath(cfg, dir)
214
222
215 // Add models to both large and small types
223 // Add models to both large and small types
216 largeModel := SelectedModel{Provider: "openai", Model: "gpt-4o"}
224 largeModel := SelectedModel{Provider: "openai", Model: "gpt-4o"}
217 smallModel := SelectedModel{Provider: "anthropic", Model: "claude"}
225 smallModel := SelectedModel{Provider: "anthropic", Model: "claude"}
218
226
219− require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, largeModel))
227+ require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, largeModel))
220− require.NoError(t, cfg.recordRecentModel(SelectedModelTypeSmall, smallModel))
228+ require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeSmall, smallModel))
221
229
222 // in-memory: verify types maintain separate histories
230 // in-memory: verify types maintain separate histories
223 require.Len(t, cfg.RecentModels[SelectedModelTypeLarge], 1)
231 require.Len(t, cfg.RecentModels[SelectedModelTypeLarge], 1)
@@ -227,14 +235,14 @@ func TestRecordRecentModel_TypeIsolation(t *testing.T) {
227
235
228 // Add another to large, verify small unchanged
236 // Add another to large, verify small unchanged
229 anotherLarge := SelectedModel{Provider: "google", Model: "gemini"}
237 anotherLarge := SelectedModel{Provider: "google", Model: "gemini"}
230− require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, anotherLarge))
238+ require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, anotherLarge))
231
239
232 require.Len(t, cfg.RecentModels[SelectedModelTypeLarge], 2)
240 require.Len(t, cfg.RecentModels[SelectedModelTypeLarge], 2)
233 require.Len(t, cfg.RecentModels[SelectedModelTypeSmall], 1)
241 require.Len(t, cfg.RecentModels[SelectedModelTypeSmall], 1)
234 require.Equal(t, smallModel, cfg.RecentModels[SelectedModelTypeSmall][0])
242 require.Equal(t, smallModel, cfg.RecentModels[SelectedModelTypeSmall][0])
235
243
236 // persisted state: verify both types exist with correct lengths and contents
244 // persisted state: verify both types exist with correct lengths and contents
237− rm := readRecentModels(t, cfg.dataConfigDir)
245+ rm := readRecentModels(t, store.globalDataPath)
238
246
239 large, ok := rm[string(SelectedModelTypeLarge)].([]any)
247 large, ok := rm[string(SelectedModelTypeLarge)].([]any)
240 require.True(t, ok)
248 require.True(t, ok)
internal/config/scope.gonew file+11
@@ -0,0 +1,11 @@
1+package config
2+
3+// Scope determines which config file is targeted for read/write operations.
4+type Scope int
5+
6+const (
7+ // ScopeGlobal targets the global data config (~/.local/share/crush/crush.json).
8+ ScopeGlobal Scope = iota
9+ // ScopeWorkspace targets the workspace config (.crush/crush.json).
10+ ScopeWorkspace
11+)
internal/config/store.gonew file+336
@@ -0,0 +1,336 @@
1+package config
2+
3+import (
4+ "cmp"
5+ "context"
6+ "fmt"
7+ "log/slog"
8+ "os"
9+ "path/filepath"
10+ "slices"
11+
12+ "charm.land/catwalk/pkg/catwalk"
13+ hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
14+ "github.com/charmbracelet/crush/internal/oauth"
15+ "github.com/charmbracelet/crush/internal/oauth/copilot"
16+ "github.com/charmbracelet/crush/internal/oauth/hyper"
17+ "github.com/tidwall/gjson"
18+ "github.com/tidwall/sjson"
19+)
20+
21+// ConfigStore is the single entry point for all config access. It owns the
22+// pure-data Config, runtime state (working directory, resolver, known
23+// providers), and persistence to both global and workspace config files.
24+type ConfigStore struct {
25+ config *Config
26+ workingDir string
27+ resolver VariableResolver
28+ globalDataPath string // ~/.local/share/crush/crush.json
29+ workspacePath string // .crush/crush.json
30+ knownProviders []catwalk.Provider
31+}
32+
33+// Config returns the pure-data config struct (read-only after load).
34+func (s *ConfigStore) Config() *Config {
35+ return s.config
36+}
37+
38+// WorkingDir returns the current working directory.
39+func (s *ConfigStore) WorkingDir() string {
40+ return s.workingDir
41+}
42+
43+// Resolver returns the variable resolver.
44+func (s *ConfigStore) Resolver() VariableResolver {
45+ return s.resolver
46+}
47+
48+// Resolve resolves a variable reference using the configured resolver.
49+func (s *ConfigStore) Resolve(key string) (string, error) {
50+ if s.resolver == nil {
51+ return "", fmt.Errorf("no variable resolver configured")
52+ }
53+ return s.resolver.ResolveValue(key)
54+}
55+
56+// KnownProviders returns the list of known providers.
57+func (s *ConfigStore) KnownProviders() []catwalk.Provider {
58+ return s.knownProviders
59+}
60+
61+// SetupAgents configures the coder and task agents on the config.
62+func (s *ConfigStore) SetupAgents() {
63+ s.config.SetupAgents()
64+}
65+
66+// configPath returns the file path for the given scope.
67+func (s *ConfigStore) configPath(scope Scope) string {
68+ switch scope {
69+ case ScopeWorkspace:
70+ return s.workspacePath
71+ default:
72+ return s.globalDataPath
73+ }
74+}
75+
76+// HasConfigField checks whether a key exists in the config file for the given
77+// scope.
78+func (s *ConfigStore) HasConfigField(scope Scope, key string) bool {
79+ data, err := os.ReadFile(s.configPath(scope))
80+ if err != nil {
81+ return false
82+ }
83+ return gjson.Get(string(data), key).Exists()
84+}
85+
86+// SetConfigField sets a key/value pair in the config file for the given scope.
87+func (s *ConfigStore) SetConfigField(scope Scope, key string, value any) error {
88+ path := s.configPath(scope)
89+ data, err := os.ReadFile(path)
90+ if err != nil {
91+ if os.IsNotExist(err) {
92+ data = []byte("{}")
93+ } else {
94+ return fmt.Errorf("failed to read config file: %w", err)
95+ }
96+ }
97+
98+ newValue, err := sjson.Set(string(data), key, value)
99+ if err != nil {
100+ return fmt.Errorf("failed to set config field %s: %w", key, err)
101+ }
102+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
103+ return fmt.Errorf("failed to create config directory %q: %w", path, err)
104+ }
105+ if err := os.WriteFile(path, []byte(newValue), 0o600); err != nil {
106+ return fmt.Errorf("failed to write config file: %w", err)
107+ }
108+ return nil
109+}
110+
111+// RemoveConfigField removes a key from the config file for the given scope.
112+func (s *ConfigStore) RemoveConfigField(scope Scope, key string) error {
113+ path := s.configPath(scope)
114+ data, err := os.ReadFile(path)
115+ if err != nil {
116+ return fmt.Errorf("failed to read config file: %w", err)
117+ }
118+
119+ newValue, err := sjson.Delete(string(data), key)
120+ if err != nil {
121+ return fmt.Errorf("failed to delete config field %s: %w", key, err)
122+ }
123+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
124+ return fmt.Errorf("failed to create config directory %q: %w", path, err)
125+ }
126+ if err := os.WriteFile(path, []byte(newValue), 0o600); err != nil {
127+ return fmt.Errorf("failed to write config file: %w", err)
128+ }
129+ return nil
130+}
131+
132+// UpdatePreferredModel updates the preferred model for the given type and
133+// persists it to the config file at the given scope.
134+func (s *ConfigStore) UpdatePreferredModel(scope Scope, modelType SelectedModelType, model SelectedModel) error {
135+ s.config.Models[modelType] = model
136+ if err := s.SetConfigField(scope, fmt.Sprintf("models.%s", modelType), model); err != nil {
137+ return fmt.Errorf("failed to update preferred model: %w", err)
138+ }
139+ if err := s.recordRecentModel(scope, modelType, model); err != nil {
140+ return err
141+ }
142+ return nil
143+}
144+
145+// SetCompactMode sets the compact mode setting and persists it.
146+func (s *ConfigStore) SetCompactMode(scope Scope, enabled bool) error {
147+ if s.config.Options == nil {
148+ s.config.Options = &Options{}
149+ }
150+ s.config.Options.TUI.CompactMode = enabled
151+ return s.SetConfigField(scope, "options.tui.compact_mode", enabled)
152+}
153+
154+// SetProviderAPIKey sets the API key for a provider and persists it.
155+func (s *ConfigStore) SetProviderAPIKey(scope Scope, providerID string, apiKey any) error {
156+ var providerConfig ProviderConfig
157+ var exists bool
158+ var setKeyOrToken func()
159+
160+ switch v := apiKey.(type) {
161+ case string:
162+ if err := s.SetConfigField(scope, fmt.Sprintf("providers.%s.api_key", providerID), v); err != nil {
163+ return fmt.Errorf("failed to save api key to config file: %w", err)
164+ }
165+ setKeyOrToken = func() { providerConfig.APIKey = v }
166+ case *oauth.Token:
167+ if err := cmp.Or(
168+ s.SetConfigField(scope, fmt.Sprintf("providers.%s.api_key", providerID), v.AccessToken),
169+ s.SetConfigField(scope, fmt.Sprintf("providers.%s.oauth", providerID), v),
170+ ); err != nil {
171+ return err
172+ }
173+ setKeyOrToken = func() {
174+ providerConfig.APIKey = v.AccessToken
175+ providerConfig.OAuthToken = v
176+ switch providerID {
177+ case string(catwalk.InferenceProviderCopilot):
178+ providerConfig.SetupGitHubCopilot()
179+ }
180+ }
181+ }
182+
183+ providerConfig, exists = s.config.Providers.Get(providerID)
184+ if exists {
185+ setKeyOrToken()
186+ s.config.Providers.Set(providerID, providerConfig)
187+ return nil
188+ }
189+
190+ var foundProvider *catwalk.Provider
191+ for _, p := range s.knownProviders {
192+ if string(p.ID) == providerID {
193+ foundProvider = &p
194+ break
195+ }
196+ }
197+
198+ if foundProvider != nil {
199+ providerConfig = ProviderConfig{
200+ ID: providerID,
201+ Name: foundProvider.Name,
202+ BaseURL: foundProvider.APIEndpoint,
203+ Type: foundProvider.Type,
204+ Disable: false,
205+ ExtraHeaders: make(map[string]string),
206+ ExtraParams: make(map[string]string),
207+ Models: foundProvider.Models,
208+ }
209+ setKeyOrToken()
210+ } else {
211+ return fmt.Errorf("provider with ID %s not found in known providers", providerID)
212+ }
213+ s.config.Providers.Set(providerID, providerConfig)
214+ return nil
215+}
216+
217+// RefreshOAuthToken refreshes the OAuth token for the given provider.
218+func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, providerID string) error {
219+ providerConfig, exists := s.config.Providers.Get(providerID)
220+ if !exists {
221+ return fmt.Errorf("provider %s not found", providerID)
222+ }
223+
224+ if providerConfig.OAuthToken == nil {
225+ return fmt.Errorf("provider %s does not have an OAuth token", providerID)
226+ }
227+
228+ var newToken *oauth.Token
229+ var refreshErr error
230+ switch providerID {
231+ case string(catwalk.InferenceProviderCopilot):
232+ newToken, refreshErr = copilot.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken)
233+ case hyperp.Name:
234+ newToken, refreshErr = hyper.ExchangeToken(ctx, providerConfig.OAuthToken.RefreshToken)
235+ default:
236+ return fmt.Errorf("OAuth refresh not supported for provider %s", providerID)
237+ }
238+ if refreshErr != nil {
239+ return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, refreshErr)
240+ }
241+
242+ slog.Info("Successfully refreshed OAuth token", "provider", providerID)
243+ providerConfig.OAuthToken = newToken
244+ providerConfig.APIKey = newToken.AccessToken
245+
246+ switch providerID {
247+ case string(catwalk.InferenceProviderCopilot):
248+ providerConfig.SetupGitHubCopilot()
249+ }
250+
251+ s.config.Providers.Set(providerID, providerConfig)
252+
253+ if err := cmp.Or(
254+ s.SetConfigField(scope, fmt.Sprintf("providers.%s.api_key", providerID), newToken.AccessToken),
255+ s.SetConfigField(scope, fmt.Sprintf("providers.%s.oauth", providerID), newToken),
256+ ); err != nil {
257+ return fmt.Errorf("failed to persist refreshed token: %w", err)
258+ }
259+
260+ return nil
261+}
262+
263+// recordRecentModel records a model in the recent models list.
264+func (s *ConfigStore) recordRecentModel(scope Scope, modelType SelectedModelType, model SelectedModel) error {
265+ if model.Provider == "" || model.Model == "" {
266+ return nil
267+ }
268+
269+ if s.config.RecentModels == nil {
270+ s.config.RecentModels = make(map[SelectedModelType][]SelectedModel)
271+ }
272+
273+ eq := func(a, b SelectedModel) bool {
274+ return a.Provider == b.Provider && a.Model == b.Model
275+ }
276+
277+ entry := SelectedModel{
278+ Provider: model.Provider,
279+ Model: model.Model,
280+ }
281+
282+ current := s.config.RecentModels[modelType]
283+ withoutCurrent := slices.DeleteFunc(slices.Clone(current), func(existing SelectedModel) bool {
284+ return eq(existing, entry)
285+ })
286+
287+ updated := append([]SelectedModel{entry}, withoutCurrent...)
288+ if len(updated) > maxRecentModelsPerType {
289+ updated = updated[:maxRecentModelsPerType]
290+ }
291+
292+ if slices.EqualFunc(current, updated, eq) {
293+ return nil
294+ }
295+
296+ s.config.RecentModels[modelType] = updated
297+
298+ if err := s.SetConfigField(scope, fmt.Sprintf("recent_models.%s", modelType), updated); err != nil {
299+ return fmt.Errorf("failed to persist recent models: %w", err)
300+ }
301+
302+ return nil
303+}
304+
305+// ImportCopilot attempts to import a GitHub Copilot token from disk.
306+func (s *ConfigStore) ImportCopilot() (*oauth.Token, bool) {
307+ if s.HasConfigField(ScopeGlobal, "providers.copilot.api_key") || s.HasConfigField(ScopeGlobal, "providers.copilot.oauth") {
308+ return nil, false
309+ }
310+
311+ diskToken, hasDiskToken := copilot.RefreshTokenFromDisk()
312+ if !hasDiskToken {
313+ return nil, false
314+ }
315+
316+ slog.Info("Found existing GitHub Copilot token on disk. Authenticating...")
317+ token, err := copilot.RefreshToken(context.TODO(), diskToken)
318+ if err != nil {
319+ slog.Error("Unable to import GitHub Copilot token", "error", err)
320+ return nil, false
321+ }
322+
323+ if err := s.SetProviderAPIKey(ScopeGlobal, string(catwalk.InferenceProviderCopilot), token); err != nil {
324+ return token, false
325+ }
326+
327+ if err := cmp.Or(
328+ s.SetConfigField(ScopeGlobal, "providers.copilot.api_key", token.AccessToken),
329+ s.SetConfigField(ScopeGlobal, "providers.copilot.oauth", token),
330+ ); err != nil {
331+ slog.Error("Unable to save GitHub Copilot token to disk", "error", err)
332+ }
333+
334+ slog.Info("GitHub Copilot successfully imported")
335+ return token, true
336+}
internal/lsp/manager.go+6−6
@@ -26,18 +26,18 @@ var unavailable = csync.NewMap[string, struct{}]()
26// Manager handles lazy initialization of LSP clients based on file types.
26// Manager handles lazy initialization of LSP clients based on file types.
27type Manager struct {
27type Manager struct {
28 clients *csync.Map[string, *Client]
28 clients *csync.Map[string, *Client]
29− cfg *config.Config
29+ cfg *config.ConfigStore
30 manager *powernapconfig.Manager
30 manager *powernapconfig.Manager
31 callback func(name string, client *Client)
31 callback func(name string, client *Client)
32}
32}
33
33
34// NewManager creates a new LSP manager service.
34// NewManager creates a new LSP manager service.
35−func NewManager(cfg *config.Config) *Manager {
35+func NewManager(cfg *config.ConfigStore) *Manager {
36 manager := powernapconfig.NewManager()
36 manager := powernapconfig.NewManager()
37 manager.LoadDefaults()
37 manager.LoadDefaults()
38
38
39 // Merge user-configured LSPs into the manager.
39 // Merge user-configured LSPs into the manager.
40− for name, clientConfig := range cfg.LSP {
40+ for name, clientConfig := range cfg.Config().LSP {
41 if clientConfig.Disabled {
41 if clientConfig.Disabled {
42 slog.Debug("LSP disabled by user config", "name", name)
42 slog.Debug("LSP disabled by user config", "name", name)
43 manager.RemoveServer(name)
43 manager.RemoveServer(name)
@@ -194,7 +194,7 @@ func (s *Manager) startServer(ctx context.Context, name, filepath string, server
194 cfg,
194 cfg,
195 s.cfg.Resolver(),
195 s.cfg.Resolver(),
196 s.cfg.WorkingDir(),
196 s.cfg.WorkingDir(),
197− s.cfg.Options.DebugLSP,
197+ s.cfg.Config().Options.DebugLSP,
198 )
198 )
199 if err != nil {
199 if err != nil {
200 slog.Error("Failed to create LSP client", "name", name, "error", err)
200 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
244}
244}
245
245
246func (s *Manager) isUserConfigured(name string) bool {
246func (s *Manager) isUserConfigured(name string) bool {
247− cfg, ok := s.cfg.LSP[name]
247+ cfg, ok := s.cfg.Config().LSP[name]
248 return ok && !cfg.Disabled
248 return ok && !cfg.Disabled
249}
249}
250
250
@@ -258,7 +258,7 @@ func (s *Manager) buildConfig(name string, server *powernapconfig.ServerConfig)
258 InitOptions: server.InitOptions,
258 InitOptions: server.InitOptions,
259 Options: server.Settings,
259 Options: server.Settings,
260 }
260 }
261− if userCfg, ok := s.cfg.LSP[name]; ok {
261+ if userCfg, ok := s.cfg.Config().LSP[name]; ok {
262 cfg.Timeout = userCfg.Timeout
262 cfg.Timeout = userCfg.Timeout
263 }
263 }
264 return cfg
264 return cfg
internal/permission/permission.go+6−6
@@ -134,6 +134,12 @@ func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRe
134 return true, nil
134 return true, nil
135 }
135 }
136
136
137+ // Check if the tool/action combination is in the allowlist
138+ commandKey := opts.ToolName + ":" + opts.Action
139+ if slices.Contains(s.allowedTools, commandKey) || slices.Contains(s.allowedTools, opts.ToolName) {
140+ return true, nil
141+ }
142+
137 // tell the UI that a permission was requested
143 // tell the UI that a permission was requested
138 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
144 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
139 ToolCallID: opts.ToolCallID,
145 ToolCallID: opts.ToolCallID,
@@ -141,12 +147,6 @@ func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRe
141 s.requestMu.Lock()
147 s.requestMu.Lock()
142 defer s.requestMu.Unlock()
148 defer s.requestMu.Unlock()
143
149
144− // Check if the tool/action combination is in the allowlist
145− commandKey := opts.ToolName + ":" + opts.Action
146− if slices.Contains(s.allowedTools, commandKey) || slices.Contains(s.allowedTools, opts.ToolName) {
147− return true, nil
148− }
149−
150 s.autoApproveSessionsMu.RLock()
150 s.autoApproveSessionsMu.RLock()
151 autoApprove := s.autoApproveSessions[opts.SessionID]
151 autoApprove := s.autoApproveSessions[opts.SessionID]
152 s.autoApproveSessionsMu.RUnlock()
152 s.autoApproveSessionsMu.RUnlock()
internal/server/server.go+2−2
@@ -73,7 +73,7 @@ func (s *Server) SetLogger(logger *slog.Logger) {
73}
73}
74
74
75// DefaultServer returns a new [Server] with the default address.
75// DefaultServer returns a new [Server] with the default address.
76−func DefaultServer(cfg *config.Config) *Server {
76+func DefaultServer(cfg *config.ConfigStore) *Server {
77 hostURL, err := ParseHostURL(DefaultHost())
77 hostURL, err := ParseHostURL(DefaultHost())
78 if err != nil {
78 if err != nil {
79 panic("invalid default host")
79 panic("invalid default host")
@@ -82,7 +82,7 @@ func DefaultServer(cfg *config.Config) *Server {
82}
82}
83
83
84// NewServer creates a new [Server] with the given network and address.
84// NewServer creates a new [Server] with the given network and address.
85−func NewServer(cfg *config.Config, network, address string) *Server {
85+func NewServer(cfg *config.ConfigStore, network, address string) *Server {
86 s := new(Server)
86 s := new(Server)
87 s.Addr = address
87 s.Addr = address
88 s.network = network
88 s.network = network
internal/ui/common/capabilities.go+2−2
@@ -58,7 +58,7 @@ func (c *Capabilities) Update(msg any) {
58 }
58 }
59 case tea.TerminalVersionMsg:
59 case tea.TerminalVersionMsg:
60 c.TerminalVersion = m.Name
60 c.TerminalVersion = m.Name
61− case uv.ModeReportEvent:
61+ case tea.ModeReportMsg:
62 switch m.Mode {
62 switch m.Mode {
63 case ansi.ModeFocusEvent:
63 case ansi.ModeFocusEvent:
64 c.ReportFocusEvents = modeSupported(m.Value)
64 c.ReportFocusEvents = modeSupported(m.Value)
@@ -77,7 +77,7 @@ func QueryCmd(env uv.Environ) tea.Cmd {
77 shouldQueryFor := shouldQueryCapabilities(env)
77 shouldQueryFor := shouldQueryCapabilities(env)
78 if shouldQueryFor {
78 if shouldQueryFor {
79 sb.WriteString(ansi.RequestNameVersion)
79 sb.WriteString(ansi.RequestNameVersion)
80− // sb.WriteString(ansi.RequestModeFocusEvent) // TODO: re-enable when we need notifications.
80+ sb.WriteString(ansi.RequestModeFocusEvent)
81 sb.WriteString(ansi.WindowOp(14)) // Window size in pixels
81 sb.WriteString(ansi.WindowOp(14)) // Window size in pixels
82 kittyReq := ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24")
82 kittyReq := ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24")
83 if _, isTmux := env.LookupEnv("TMUX"); isTmux {
83 if _, isTmux := env.LookupEnv("TMUX"); isTmux {
internal/ui/common/common.go+6−1
@@ -26,11 +26,16 @@ type Common struct {
26 Styles *styles.Styles
26 Styles *styles.Styles
27}
27}
28
28
29−// Config returns the configuration associated with this [Common] instance.
29+// Config returns the pure-data configuration associated with this [Common] instance.
30func (c *Common) Config() *config.Config {
30func (c *Common) Config() *config.Config {
31 return c.App.Config()
31 return c.App.Config()
32}
32}
33
33
34+// Store returns the config store associated with this [Common] instance.
35+func (c *Common) Store() *config.ConfigStore {
36+ return c.App.Store()
37+}
38+
34// DefaultCommon returns the default common UI configurations.
39// DefaultCommon returns the default common UI configurations.
35func DefaultCommon(app *app.App) *Common {
40func DefaultCommon(app *app.App) *Common {
36 s := styles.DefaultStyles()
41 s := styles.DefaultStyles()
internal/ui/dialog/api_key_input.go+3−3
@@ -296,7 +296,7 @@ func (m *APIKeyInput) verifyAPIKey() tea.Msg {
296 Type: m.provider.Type,
296 Type: m.provider.Type,
297 BaseURL: m.provider.APIEndpoint,
297 BaseURL: m.provider.APIEndpoint,
298 }
298 }
299− err := providerConfig.TestConnection(m.com.Config().Resolver())
299+ err := providerConfig.TestConnection(m.com.Store().Resolver())
300
300
301 // intentionally wait for at least 750ms to make sure the user sees the spinner
301 // intentionally wait for at least 750ms to make sure the user sees the spinner
302 elapsed := time.Since(start)
302 elapsed := time.Since(start)
@@ -312,9 +312,9 @@ func (m *APIKeyInput) verifyAPIKey() tea.Msg {
312}
312}
313
313
314func (m *APIKeyInput) saveKeyAndContinue() Action {
314func (m *APIKeyInput) saveKeyAndContinue() Action {
315− cfg := m.com.Config()
315+ store := m.com.Store()
316
316
317− err := cfg.SetProviderAPIKey(string(m.provider.ID), m.input.Value())
317+ err := store.SetProviderAPIKey(config.ScopeGlobal, string(m.provider.ID), m.input.Value())
318 if err != nil {
318 if err != nil {
319 return ActionCmd{util.ReportError(fmt.Errorf("failed to save API key: %w", err))}
319 return ActionCmd{util.ReportError(fmt.Errorf("failed to save API key: %w", err))}
320 }
320 }
internal/ui/dialog/filepicker.go+1−1
@@ -123,7 +123,7 @@ func (f *FilePicker) SetImageCapabilities(caps *common.Capabilities) {
123
123
124// WorkingDir returns the current working directory of the [FilePicker].
124// WorkingDir returns the current working directory of the [FilePicker].
125func (f *FilePicker) WorkingDir() string {
125func (f *FilePicker) WorkingDir() string {
126− wd := f.com.Config().WorkingDir()
126+ wd := f.com.Store().WorkingDir()
127 if len(wd) > 0 {
127 if len(wd) > 0 {
128 return wd
128 return wd
129 }
129 }
internal/ui/dialog/models.go+1−1
@@ -490,7 +490,7 @@ func (m *Models) setProviderItems() error {
490
490
491 if len(validRecentItems) != len(recentItems) {
491 if len(validRecentItems) != len(recentItems) {
492 // FIXME: Does this need to be here? Is it mutating the config during a read?
492 // FIXME: Does this need to be here? Is it mutating the config during a read?
493− if err := cfg.SetConfigField(fmt.Sprintf("recent_models.%s", selectedType), validRecentItems); err != nil {
493+ if err := m.com.Store().SetConfigField(config.ScopeGlobal, fmt.Sprintf("recent_models.%s", selectedType), validRecentItems); err != nil {
494 return fmt.Errorf("failed to update recent models: %w", err)
494 return fmt.Errorf("failed to update recent models: %w", err)
495 }
495 }
496 }
496 }
internal/ui/dialog/oauth.go+2−2
@@ -373,9 +373,9 @@ func (d *OAuth) copyCodeAndOpenURL() tea.Cmd {
373}
373}
374
374
375func (m *OAuth) saveKeyAndContinue() Action {
375func (m *OAuth) saveKeyAndContinue() Action {
376− cfg := m.com.Config()
376+ store := m.com.Store()
377
377
378− err := cfg.SetProviderAPIKey(string(m.provider.ID), m.token)
378+ err := store.SetProviderAPIKey(config.ScopeGlobal, string(m.provider.ID), m.token)
379 if err != nil {
379 if err != nil {
380 return ActionCmd{util.ReportError(fmt.Errorf("failed to save API key: %w", err))}
380 return ActionCmd{util.ReportError(fmt.Errorf("failed to save API key: %w", err))}
381 }
381 }
internal/ui/model/header.go+1−2
@@ -143,8 +143,7 @@ func renderHeaderDetails(
143 metadata = dot + metadata
143 metadata = dot + metadata
144
144
145 const dirTrimLimit = 4
145 const dirTrimLimit = 4
146− cfg := com.Config()
147− cwd := fsext.DirTrim(fsext.PrettyPath(cfg.WorkingDir()), dirTrimLimit)
146+ cwd := fsext.DirTrim(fsext.PrettyPath(com.Store().WorkingDir()), dirTrimLimit)
148 cwd = t.Header.WorkingDir.Render(cwd)
147 cwd = t.Header.WorkingDir.Render(cwd)
149
148
150 result := cwd + metadata
149 result := cwd + metadata
internal/ui/model/landing.go+1−1
@@ -22,7 +22,7 @@ func (m *UI) selectedLargeModel() *agent.Model {
22func (m *UI) landingView() string {
22func (m *UI) landingView() string {
23 t := m.com.Styles
23 t := m.com.Styles
24 width := m.layout.main.Dx()
24 width := m.layout.main.Dx()
25− cwd := common.PrettyPath(t, m.com.Config().WorkingDir(), width)
25+ cwd := common.PrettyPath(t, m.com.Store().WorkingDir(), width)
26
26
27 parts := []string{
27 parts := []string{
28 cwd,
28 cwd,
internal/ui/model/onboarding.go+5−6
@@ -19,7 +19,7 @@ import (
19// markProjectInitialized marks the current project as initialized in the config.
19// markProjectInitialized marks the current project as initialized in the config.
20func (m *UI) markProjectInitialized() tea.Msg {
20func (m *UI) markProjectInitialized() tea.Msg {
21 // TODO: handle error so we show it in the tui footer
21 // TODO: handle error so we show it in the tui footer
22− err := config.MarkProjectInitialized(m.com.Config())
22+ err := config.MarkProjectInitialized(m.com.Store())
23 if err != nil {
23 if err != nil {
24 slog.Error(err.Error())
24 slog.Error(err.Error())
25 }
25 }
@@ -52,10 +52,10 @@ func (m *UI) initializeProject() tea.Cmd {
52 if cmd := m.newSession(); cmd != nil {
52 if cmd := m.newSession(); cmd != nil {
53 cmds = append(cmds, cmd)
53 cmds = append(cmds, cmd)
54 }
54 }
55− cfg := m.com.Config()
55+ cfg := m.com.Store()
56
56
57 initialize := func() tea.Msg {
57 initialize := func() tea.Msg {
58− initPrompt, err := agent.InitializePrompt(*cfg)
58+ initPrompt, err := agent.InitializePrompt(cfg)
59 if err != nil {
59 if err != nil {
60 return util.InfoMsg{
60 return util.InfoMsg{
61 Type: util.InfoTypeError,
61 Type: util.InfoTypeError,
@@ -80,10 +80,9 @@ func (m *UI) skipInitializeProject() tea.Cmd {
80
80
81// initializeView renders the project initialization prompt with Yes/No buttons.
81// initializeView renders the project initialization prompt with Yes/No buttons.
82func (m *UI) initializeView() string {
82func (m *UI) initializeView() string {
83− cfg := m.com.Config()
84 s := m.com.Styles.Initialize
83 s := m.com.Styles.Initialize
85− cwd := home.Short(cfg.WorkingDir())
84+ cwd := home.Short(m.com.Store().WorkingDir())
86− initFile := cfg.Options.InitializeAs
85+ initFile := m.com.Config().Options.InitializeAs
87
86
88 header := s.Header.Render("Would you like to initialize this project?")
87 header := s.Header.Render("Would you like to initialize this project?")
89 path := s.Accent.PaddingLeft(2).Render(cwd)
88 path := s.Accent.PaddingLeft(2).Render(cwd)
internal/ui/model/sidebar.go+2−2
@@ -112,7 +112,7 @@ func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
112 height := area.Dy()
112 height := area.Dy()
113
113
114 title := t.Muted.Width(width).MaxHeight(2).Render(m.session.Title)
114 title := t.Muted.Width(width).MaxHeight(2).Render(m.session.Title)
115− cwd := common.PrettyPath(t, m.com.Config().WorkingDir(), width)
115+ cwd := common.PrettyPath(t, m.com.Store().WorkingDir(), width)
116 sidebarLogo := m.sidebarLogo
116 sidebarLogo := m.sidebarLogo
117 if height < logoHeightBreakpoint {
117 if height < logoHeightBreakpoint {
118 sidebarLogo = logo.SmallRender(m.com.Styles, width)
118 sidebarLogo = logo.SmallRender(m.com.Styles, width)
@@ -138,7 +138,7 @@ func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
138
138
139 lspSection := m.lspInfo(width, maxLSPs, true)
139 lspSection := m.lspInfo(width, maxLSPs, true)
140 mcpSection := m.mcpInfo(width, maxMCPs, true)
140 mcpSection := m.mcpInfo(width, maxMCPs, true)
141− filesSection := m.filesInfo(m.com.Config().WorkingDir(), width, maxFiles, true)
141+ filesSection := m.filesInfo(m.com.Store().WorkingDir(), width, maxFiles, true)
142
142
143 uv.NewStyledString(
143 uv.NewStyledString(
144 lipgloss.NewStyle().
144 lipgloss.NewStyle().
internal/ui/model/ui.go+90−24
@@ -25,6 +25,7 @@ import (
25 tea "charm.land/bubbletea/v2"
25 tea "charm.land/bubbletea/v2"
26 "charm.land/catwalk/pkg/catwalk"
26 "charm.land/catwalk/pkg/catwalk"
27 "charm.land/lipgloss/v2"
27 "charm.land/lipgloss/v2"
28+ "github.com/charmbracelet/crush/internal/agent/notify"
28 agenttools "github.com/charmbracelet/crush/internal/agent/tools"
29 agenttools "github.com/charmbracelet/crush/internal/agent/tools"
29 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
30 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
30 "github.com/charmbracelet/crush/internal/app"
31 "github.com/charmbracelet/crush/internal/app"
@@ -45,6 +46,7 @@ import (
45 "github.com/charmbracelet/crush/internal/ui/dialog"
46 "github.com/charmbracelet/crush/internal/ui/dialog"
46 fimage "github.com/charmbracelet/crush/internal/ui/image"
47 fimage "github.com/charmbracelet/crush/internal/ui/image"
47 "github.com/charmbracelet/crush/internal/ui/logo"
48 "github.com/charmbracelet/crush/internal/ui/logo"
49+ "github.com/charmbracelet/crush/internal/ui/notification"
48 "github.com/charmbracelet/crush/internal/ui/styles"
50 "github.com/charmbracelet/crush/internal/ui/styles"
49 "github.com/charmbracelet/crush/internal/ui/util"
51 "github.com/charmbracelet/crush/internal/ui/util"
50 "github.com/charmbracelet/crush/internal/version"
52 "github.com/charmbracelet/crush/internal/version"
@@ -201,6 +203,9 @@ type UI struct {
201 // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
203 // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
202 sidebarLogo string
204 sidebarLogo string
203
205
206+ // Notification state
207+ notifyBackend notification.Backend
208+ notifyWindowFocused bool
204 // custom commands & mcp commands
209 // custom commands & mcp commands
205 customCommands []commands.CustomCommand
210 customCommands []commands.CustomCommand
206 mcpPrompts []commands.MCPPrompt
211 mcpPrompts []commands.MCPPrompt
@@ -280,17 +285,19 @@ func New(com *common.Common) *UI {
280 header := newHeader(com)
285 header := newHeader(com)
281
286
282 ui := &UI{
287 ui := &UI{
283− com: com,
288+ com: com,
284− dialog: dialog.NewOverlay(),
289+ dialog: dialog.NewOverlay(),
285− keyMap: keyMap,
290+ keyMap: keyMap,
286− textarea: ta,
291+ textarea: ta,
287− chat: ch,
292+ chat: ch,
288− header: header,
293+ header: header,
289− completions: comp,
294+ completions: comp,
290− attachments: attachments,
295+ attachments: attachments,
291− todoSpinner: todoSpinner,
296+ todoSpinner: todoSpinner,
292− lspStates: make(map[string]app.LSPClientInfo),
297+ lspStates: make(map[string]app.LSPClientInfo),
293− mcpStates: make(map[string]mcp.ClientInfo),
298+ mcpStates: make(map[string]mcp.ClientInfo),
299+ notifyBackend: notification.NoopBackend{},
300+ notifyWindowFocused: true,
294 }
301 }
295
302
296 status := NewStatus(com, ui)
303 status := NewStatus(com, ui)
@@ -310,7 +317,7 @@ func New(com *common.Common) *UI {
310 desiredFocus := uiFocusEditor
317 desiredFocus := uiFocusEditor
311 if !com.Config().IsConfigured() {
318 if !com.Config().IsConfigured() {
312 desiredState = uiOnboarding
319 desiredState = uiOnboarding
313− } else if n, _ := config.ProjectNeedsInitialization(com.Config()); n {
320+ } else if n, _ := config.ProjectNeedsInitialization(com.Store()); n {
314 desiredState = uiInitialize
321 desiredState = uiInitialize
315 }
322 }
316
323
@@ -342,6 +349,32 @@ func (m *UI) Init() tea.Cmd {
342 return tea.Batch(cmds...)
349 return tea.Batch(cmds...)
343}
350}
344
351
352+// sendNotification returns a command that sends a notification if allowed by policy.
353+func (m *UI) sendNotification(n notification.Notification) tea.Cmd {
354+ if !m.shouldSendNotification() {
355+ return nil
356+ }
357+
358+ backend := m.notifyBackend
359+ return func() tea.Msg {
360+ if err := backend.Send(n); err != nil {
361+ slog.Error("Failed to send notification", "error", err)
362+ }
363+ return nil
364+ }
365+}
366+
367+// shouldSendNotification returns true if notifications should be sent based on
368+// current state. Focus reporting must be supported, window must not focused,
369+// and notifications must not be disabled in config.
370+func (m *UI) shouldSendNotification() bool {
371+ cfg := m.com.Config()
372+ if cfg != nil && cfg.Options != nil && cfg.Options.DisableNotifications {
373+ return false
374+ }
375+ return m.caps.ReportFocusEvents && !m.notifyWindowFocused
376+}
377+
345// setState changes the UI state and focus.
378// setState changes the UI state and focus.
346func (m *UI) setState(state uiState, focus uiFocusState) {
379func (m *UI) setState(state uiState, focus uiFocusState) {
347 if state == uiLanding {
380 if state == uiLanding {
@@ -397,6 +430,18 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
397 m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
430 m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
398 }
431 }
399 cmds = append(cmds, common.QueryCmd(uv.Environ(msg)))
432 cmds = append(cmds, common.QueryCmd(uv.Environ(msg)))
433+ case tea.ModeReportMsg:
434+ if m.caps.ReportFocusEvents {
435+ m.notifyBackend = notification.NewNativeBackend(notification.Icon)
436+ }
437+ case tea.FocusMsg:
438+ m.notifyWindowFocused = true
439+ case tea.BlurMsg:
440+ m.notifyWindowFocused = false
441+ case pubsub.Event[notify.Notification]:
442+ if cmd := m.handleAgentNotification(msg.Payload); cmd != nil {
443+ cmds = append(cmds, cmd)
444+ }
400 case loadSessionMsg:
445 case loadSessionMsg:
401 if m.forceCompactMode {
446 if m.forceCompactMode {
402 m.isCompact = true
447 m.isCompact = true
@@ -534,7 +579,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
534 case mcp.EventPromptsListChanged:
579 case mcp.EventPromptsListChanged:
535 return m, handleMCPPromptsEvent(msg.Payload.Name)
580 return m, handleMCPPromptsEvent(msg.Payload.Name)
536 case mcp.EventToolsListChanged:
581 case mcp.EventToolsListChanged:
537− return m, handleMCPToolsEvent(m.com.Config(), msg.Payload.Name)
582+ return m, handleMCPToolsEvent(m.com.Store(), msg.Payload.Name)
538 case mcp.EventResourcesListChanged:
583 case mcp.EventResourcesListChanged:
539 return m, handleMCPResourcesEvent(msg.Payload.Name)
584 return m, handleMCPResourcesEvent(msg.Payload.Name)
540 }
585 }
@@ -542,6 +587,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
542 if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
587 if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
543 cmds = append(cmds, cmd)
588 cmds = append(cmds, cmd)
544 }
589 }
590+ if cmd := m.sendNotification(notification.Notification{
591+ Title: "Crush is waiting...",
592+ Message: fmt.Sprintf("Permission required to execute \"%s\"", msg.Payload.ToolName),
593+ }); cmd != nil {
594+ cmds = append(cmds, cmd)
595+ }
545 case pubsub.Event[permission.PermissionNotification]:
596 case pubsub.Event[permission.PermissionNotification]:
546 m.handlePermissionNotification(msg.Payload)
597 m.handlePermissionNotification(msg.Payload)
547 case cancelTimerExpiredMsg:
598 case cancelTimerExpiredMsg:
@@ -1253,7 +1304,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1253
1304
1254 currentModel := cfg.Models[agentCfg.Model]
1305 currentModel := cfg.Models[agentCfg.Model]
1255 currentModel.Think = !currentModel.Think
1306 currentModel.Think = !currentModel.Think
1256− if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
1307+ if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
1257 return util.ReportError(err)()
1308 return util.ReportError(err)()
1258 }
1309 }
1259 m.com.App.UpdateAgentModel(context.TODO())
1310 m.com.App.UpdateAgentModel(context.TODO())
@@ -1294,7 +1345,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1294
1345
1295 // Attempt to import GitHub Copilot tokens from VSCode if available.
1346 // Attempt to import GitHub Copilot tokens from VSCode if available.
1296 if isCopilot && !isConfigured() && !msg.ReAuthenticate {
1347 if isCopilot && !isConfigured() && !msg.ReAuthenticate {
1297− m.com.Config().ImportCopilot()
1348+ m.com.Store().ImportCopilot()
1298 }
1349 }
1299
1350
1300 if !isConfigured() || msg.ReAuthenticate {
1351 if !isConfigured() || msg.ReAuthenticate {
@@ -1305,12 +1356,12 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1305 break
1356 break
1306 }
1357 }
1307
1358
1308− if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil {
1359+ if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, msg.ModelType, msg.Model); err != nil {
1309 cmds = append(cmds, util.ReportError(err))
1360 cmds = append(cmds, util.ReportError(err))
1310 } else if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
1361 } else if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
1311 // Ensure small model is set is unset.
1362 // Ensure small model is set is unset.
1312 smallModel := m.com.App.GetDefaultSmallModel(providerID)
1363 smallModel := m.com.App.GetDefaultSmallModel(providerID)
1313− if err := cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, smallModel); err != nil {
1364+ if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, config.SelectedModelTypeSmall, smallModel); err != nil {
1314 cmds = append(cmds, util.ReportError(err))
1365 cmds = append(cmds, util.ReportError(err))
1315 }
1366 }
1316 }
1367 }
@@ -1356,7 +1407,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1356
1407
1357 currentModel := cfg.Models[agentCfg.Model]
1408 currentModel := cfg.Models[agentCfg.Model]
1358 currentModel.ReasoningEffort = msg.Effort
1409 currentModel.ReasoningEffort = msg.Effort
1359− if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
1410+ if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
1360 cmds = append(cmds, util.ReportError(err))
1411 cmds = append(cmds, util.ReportError(err))
1361 break
1412 break
1362 }
1413 }
@@ -1967,7 +2018,8 @@ func (m *UI) View() tea.View {
1967 v.BackgroundColor = m.com.Styles.Background
2018 v.BackgroundColor = m.com.Styles.Background
1968 }
2019 }
1969 v.MouseMode = tea.MouseModeCellMotion
2020 v.MouseMode = tea.MouseModeCellMotion
2021+ v.ReportFocus = m.caps.ReportFocusEvents
1970− v.WindowTitle = "crush " + home.Short(m.com.Config().WorkingDir())
2022+ v.WindowTitle = "crush " + home.Short(m.com.Store().WorkingDir())
1971
2023
1972 canvas := uv.NewScreenBuffer(m.width, m.height)
2024 canvas := uv.NewScreenBuffer(m.width, m.height)
1973 v.Cursor = m.Draw(canvas, canvas.Bounds())
2025 v.Cursor = m.Draw(canvas, canvas.Bounds())
@@ -2206,7 +2258,7 @@ func (m *UI) FullHelp() [][]key.Binding {
2206func (m *UI) toggleCompactMode() tea.Cmd {
2258func (m *UI) toggleCompactMode() tea.Cmd {
2207 m.forceCompactMode = !m.forceCompactMode
2259 m.forceCompactMode = !m.forceCompactMode
2208
2260
2209− err := m.com.Config().SetCompactMode(m.forceCompactMode)
2261+ err := m.com.Store().SetCompactMode(config.ScopeGlobal, m.forceCompactMode)
2210 if err != nil {
2262 if err != nil {
2211 return util.ReportError(err)
2263 return util.ReportError(err)
2212 }
2264 }
@@ -2588,7 +2640,7 @@ func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValu
2588 return func() tea.Msg {
2640 return func() tea.Msg {
2589 contents, err := mcp.ReadResource(
2641 contents, err := mcp.ReadResource(
2590 context.Background(),
2642 context.Background(),
2591− m.com.Config(),
2643+ m.com.Store(),
2592 item.MCPName,
2644 item.MCPName,
2593 item.URI,
2645 item.URI,
2594 )
2646 )
@@ -2983,6 +3035,20 @@ func (m *UI) handlePermissionNotification(notification permission.PermissionNoti
2983 }
3035 }
2984}
3036}
2985
3037
3038+// handleAgentNotification translates domain agent events into desktop
3039+// notifications using the UI notification backend.
3040+func (m *UI) handleAgentNotification(n notify.Notification) tea.Cmd {
3041+ switch n.Type {
3042+ case notify.TypeAgentFinished:
3043+ return m.sendNotification(notification.Notification{
3044+ Title: "Crush is waiting...",
3045+ Message: fmt.Sprintf("Agent's turn completed in \"%s\"", n.SessionTitle),
3046+ })
3047+ default:
3048+ return nil
3049+ }
3050+}
3051+
2986// newSession clears the current session state and prepares for a new session.
3052// newSession clears the current session state and prepares for a new session.
2987// The actual session creation happens when the user sends their first message.
3053// The actual session creation happens when the user sends their first message.
2988// Returns a command to reload prompt history.
3054// Returns a command to reload prompt history.
@@ -3236,7 +3302,7 @@ func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
3236
3302
3237 lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
3303 lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
3238 mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
3304 mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
3239− filesSection := m.filesInfo(m.com.Config().WorkingDir(), sectionWidth, maxItemsPerSection, false)
3305+ filesSection := m.filesInfo(m.com.Store().WorkingDir(), sectionWidth, maxItemsPerSection, false)
3240 sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
3306 sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
3241 uv.NewStyledString(
3307 uv.NewStyledString(
3242 s.CompactDetails.View.
3308 s.CompactDetails.View.
@@ -3254,7 +3320,7 @@ func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
3254
3320
3255func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
3321func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
3256 load := func() tea.Msg {
3322 load := func() tea.Msg {
3257− prompt, err := commands.GetMCPPrompt(m.com.Config(), clientID, promptID, arguments)
3323+ prompt, err := commands.GetMCPPrompt(m.com.Store(), clientID, promptID, arguments)
3258 if err != nil {
3324 if err != nil {
3259 // TODO: make this better
3325 // TODO: make this better
3260 return util.ReportError(err)()
3326 return util.ReportError(err)()
@@ -3295,7 +3361,7 @@ func handleMCPPromptsEvent(name string) tea.Cmd {
3295 }
3361 }
3296}
3362}
3297
3363
3298−func handleMCPToolsEvent(cfg *config.Config, name string) tea.Cmd {
3364+func handleMCPToolsEvent(cfg *config.ConfigStore, name string) tea.Cmd {
3299 return func() tea.Msg {
3365 return func() tea.Msg {
3300 mcp.RefreshTools(
3366 mcp.RefreshTools(
3301 context.Background(),
3367 context.Background(),
internal/ui/notification/crush-icon-solo.pngnew file

Binary file not shown.

internal/ui/notification/crush-icon.pngnew file

Binary file not shown.

internal/ui/notification/icon_darwin.gonew file+7
@@ -0,0 +1,7 @@
1+//go:build darwin
2+
3+package notification
4+
5+// Icon is currently empty on darwin because platform icon support is broken. Do
6+// use the icon for OSC notifications, just not native.
7+var Icon any = ""
internal/ui/notification/icon_other.gonew file+13
@@ -0,0 +1,13 @@
1+//go:build !darwin
2+
3+package notification
4+
5+import (
6+ _ "embed"
7+)
8+
9+//go:embed crush-icon-solo.png
10+var icon []byte
11+
12+// Icon contains the embedded PNG icon data for desktop notifications.
13+var Icon any = icon
internal/ui/notification/native.gonew file+49
@@ -0,0 +1,49 @@
1+package notification
2+
3+import (
4+ "log/slog"
5+
6+ "github.com/gen2brain/beeep"
7+)
8+
9+// NativeBackend sends desktop notifications using the native OS notification
10+// system via beeep.
11+type NativeBackend struct {
12+ // icon is the notification icon data (platform-specific).
13+ icon any
14+ // notifyFunc is the function used to send notifications (swappable for testing).
15+ notifyFunc func(title, message string, icon any) error
16+}
17+
18+// NewNativeBackend creates a new native notification backend.
19+func NewNativeBackend(icon any) *NativeBackend {
20+ beeep.AppName = "Crush"
21+ return &NativeBackend{
22+ icon: icon,
23+ notifyFunc: beeep.Notify,
24+ }
25+}
26+
27+// Send sends a desktop notification using the native OS notification system.
28+func (b *NativeBackend) Send(n Notification) error {
29+ slog.Debug("Sending native notification", "title", n.Title, "message", n.Message)
30+
31+ err := b.notifyFunc(n.Title, n.Message, b.icon)
32+ if err != nil {
33+ slog.Error("Failed to send notification", "error", err)
34+ } else {
35+ slog.Debug("Notification sent successfully")
36+ }
37+
38+ return err
39+}
40+
41+// SetNotifyFunc allows replacing the notification function for testing.
42+func (b *NativeBackend) SetNotifyFunc(fn func(title, message string, icon any) error) {
43+ b.notifyFunc = fn
44+}
45+
46+// ResetNotifyFunc resets the notification function to the default.
47+func (b *NativeBackend) ResetNotifyFunc() {
48+ b.notifyFunc = beeep.Notify
49+}
internal/ui/notification/noop.gonew file+10
@@ -0,0 +1,10 @@
1+package notification
2+
3+// NoopBackend is a no-op notification backend that does nothing.
4+// This is the default backend used when notifications are not supported.
5+type NoopBackend struct{}
6+
7+// Send does nothing and returns nil.
8+func (NoopBackend) Send(_ Notification) error {
9+ return nil
10+}
internal/ui/notification/notification.gonew file+15
@@ -0,0 +1,15 @@
1+// Package notification provides desktop notification support for the UI.
2+package notification
3+
4+// Notification represents a desktop notification request.
5+type Notification struct {
6+ Title string
7+ Message string
8+}
9+
10+// Backend defines the interface for sending desktop notifications.
11+// Implementations are pure transport - policy decisions (config, focus state)
12+// are handled by the caller.
13+type Backend interface {
14+ Send(n Notification) error
15+}
internal/ui/notification/notification_test.gonew file+43
@@ -0,0 +1,43 @@
1+package notification_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/charmbracelet/crush/internal/ui/notification"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestNoopBackend_Send(t *testing.T) {
11+ t.Parallel()
12+
13+ backend := notification.NoopBackend{}
14+ err := backend.Send(notification.Notification{
15+ Title: "Test Title",
16+ Message: "Test Message",
17+ })
18+ require.NoError(t, err)
19+}
20+
21+func TestNativeBackend_Send(t *testing.T) {
22+ t.Parallel()
23+
24+ backend := notification.NewNativeBackend(nil)
25+
26+ var capturedTitle, capturedMessage string
27+ var capturedIcon any
28+ backend.SetNotifyFunc(func(title, message string, icon any) error {
29+ capturedTitle = title
30+ capturedMessage = message
31+ capturedIcon = icon
32+ return nil
33+ })
34+
35+ err := backend.Send(notification.Notification{
36+ Title: "Hello",
37+ Message: "World",
38+ })
39+ require.NoError(t, err)
40+ require.Equal(t, "Hello", capturedTitle)
41+ require.Equal(t, "World", capturedMessage)
42+ require.Nil(t, capturedIcon)
43+}
schema.json+5
@@ -451,6 +451,11 @@
451 "type": "boolean",
451 "type": "boolean",
452 "description": "Show indeterminate progress updates during long operations",
452 "description": "Show indeterminate progress updates during long operations",
453 "default": true
453 "default": true
454+ },
455+ "disable_notifications": {
456+ "type": "boolean",
457+ "description": "Disable desktop notifications",
458+ "default": false
454 }
459 }
455 },
460 },
456 "additionalProperties": false,
461 "additionalProperties": false,