on main help sign in
Merge branch 'main' into server-client-2
commit99108f0e80e64aff7c56c2de234a291ee94ed1ff authorAyman Bagabas <ayman.bagabas@gmail.com> dateThu 12 Mar 2026, 03:13 (7 months ago) parent33863b15ff8d68
60 files changed+1120−686
.github/workflows/security.yml+1−1 README.md+19 go.mod+39−29 go.sum+83−56 internal/agent/agent.go+20 internal/agent/agent_tool.go+1−1 internal/agent/agentic_fetch_tool.go+5−5 internal/agent/common_test.go+18−10 internal/agent/coordinator.go+51−44 internal/agent/coordinator_test.go+1−1 internal/agent/notify/notify.go+19 internal/agent/prompt/prompt.go+15−14 internal/agent/prompts.go+1−1 internal/agent/tools/list_mcp_resources.go+1−1 internal/agent/tools/mcp-tools.go+2−2 internal/agent/tools/mcp/init.go+4−4 internal/agent/tools/mcp/prompts.go+1−1 internal/agent/tools/mcp/resources.go+2−2 internal/agent/tools/mcp/tools.go+5−5 internal/agent/tools/read_mcp_resource.go+1−1 internal/app/app.go+36−20 internal/backend/backend.go+18−17 internal/backend/events.go+2−2 internal/cmd/login.go+9−9 internal/cmd/logs.go+1−1 internal/cmd/models.go+2−2 internal/cmd/root.go+3−2 internal/cmd/stats.go+1−1 internal/commands/commands.go+1−1 internal/config/config.go+1−248 internal/config/copilot.go−47 internal/config/init.go+15−14 internal/config/load.go+45−17 internal/config/load_test.go+44−40 internal/config/recent_models_test.go+36−28 internal/config/scope.go+11 internal/config/store.go+336 internal/lsp/manager.go+6−6 internal/permission/permission.go+6−6 internal/server/server.go+2−2 internal/ui/common/capabilities.go+2−2 internal/ui/common/common.go+6−1 internal/ui/dialog/api_key_input.go+3−3 internal/ui/dialog/filepicker.go+1−1 internal/ui/dialog/models.go+1−1 internal/ui/dialog/oauth.go+2−2 internal/ui/model/header.go+1−2 internal/ui/model/landing.go+1−1 internal/ui/model/onboarding.go+5−6 internal/ui/model/sidebar.go+2−2 internal/ui/model/ui.go+90−24 internal/ui/notification/crush-icon-solo.png internal/ui/notification/crush-icon.png internal/ui/notification/icon_darwin.go+7 internal/ui/notification/icon_other.go+13 internal/ui/notification/native.go+49 internal/ui/notification/noop.go+10 internal/ui/notification/notification.go+15 internal/ui/notification/notification_test.go+43 schema.json+5
.github/workflows/security.yml+1−1
@@ -67,7 +67,7 @@ jobs:
6767 persist-credentials: false
6868 - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
6969 with:
70− go-version: 1.26.0
70+ go-version: 1.26.1
7171 - name: Install govulncheck
7272 run: go install golang.org/x/vuln/cmd/govulncheck@latest
7373 - name: Run govulncheck
README.md+19
@@ -424,6 +424,25 @@ git clone https://github.com/anthropics/skills.git _temp
424424mv _temp/skills/* . ; rm -r -force _temp
425425```
426426
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+
427446### Initialization
428447
429448When you initialize a project, Crush analyzes your codebase and creates
go.mod+39−29
@@ -1,15 +1,15 @@
11module github.com/charmbracelet/crush
22
3−go 1.26.0
3+go 1.26.1
44
55require (
66 charm.land/bubbles/v2 v2.0.0
7− charm.land/bubbletea/v2 v2.0.1
8− charm.land/catwalk v0.28.1
9− charm.land/fantasy v0.11.1
10− charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b
11− charm.land/lipgloss/v2 v2.0.0
12− charm.land/log/v2 v2.0.0-20251110204020-529bb77f35da
7+ charm.land/bubbletea/v2 v2.0.2
8+ charm.land/catwalk v0.28.4
9+ charm.land/fantasy v0.12.1
10+ charm.land/glamour/v2 v2.0.0
11+ charm.land/lipgloss/v2 v2.0.1
12+ charm.land/log/v2 v2.0.0
1313 charm.land/x/vcr v0.1.1
1414 github.com/JohannesKaufmann/html-to-markdown v1.6.0
1515 github.com/MakeNowJust/heredoc v1.0.0
@@ -17,11 +17,11 @@ require (
1717 github.com/alecthomas/chroma/v2 v2.23.1
1818 github.com/atotto/clipboard v0.1.4
1919 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
2121 github.com/bmatcuk/doublestar/v4 v4.10.0
2222 github.com/charlievieth/fastwalk v1.0.14
23− github.com/charmbracelet/colorprofile v0.4.2
24− github.com/charmbracelet/fang v0.4.4
23+ github.com/charmbracelet/colorprofile v0.4.3
24+ github.com/charmbracelet/fang v1.0.0
2525 github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8
2626 github.com/charmbracelet/x/ansi v0.11.6
2727 github.com/charmbracelet/x/editor v0.2.0
@@ -38,6 +38,7 @@ require (
3838 github.com/denisbrodbeck/machineid v1.0.1
3939 github.com/disintegration/imaging v1.6.2
4040 github.com/dustin/go-humanize v1.0.1
41+ github.com/gen2brain/beeep v0.11.2
4142 github.com/go-git/go-git/v5 v5.17.0
4243 github.com/google/uuid v1.6.0
4344 github.com/invopop/jsonschema v0.13.0
@@ -46,7 +47,7 @@ require (
4647 github.com/lucasb-eyer/go-colorful v1.3.0
4748 github.com/mattn/go-isatty v0.0.20
4849 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
5051 github.com/nxadm/tail v1.4.11
5152 github.com/openai/openai-go/v2 v2.7.1
5253 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
@@ -63,13 +64,13 @@ require (
6364 github.com/zeebo/xxh3 v1.1.0
6465 go.uber.org/goleak v1.3.0
6566 golang.org/x/net v0.51.0
66− golang.org/x/sync v0.19.0
67+ golang.org/x/sync v0.20.0
6768 golang.org/x/text v0.34.0
6869 gopkg.in/natefinch/lumberjack.v2 v2.2.1
6970 gopkg.in/yaml.v3 v3.0.1
7071 modernc.org/sqlite v1.46.1
7172 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
7374)
7475
7576require (
@@ -77,24 +78,25 @@ require (
7778 cloud.google.com/go/auth v0.18.2 // indirect
7879 cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
7980 cloud.google.com/go/compute/metadata v0.9.0 // indirect
81+ git.sr.ht/~jackmordaunt/go-toast v1.1.2 // indirect
8082 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
8183 github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
8284 github.com/Microsoft/go-winio v0.6.2 // indirect
8385 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
8587 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
87− github.com/aws/aws-sdk-go-v2/credentials v1.19.10 // indirect
88− github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 // indirect
89− github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 // indirect
90− github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 // indirect
91− github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
92− github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 // indirect
93− github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 // indirect
94− github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 // indirect
95− github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 // indirect
96− github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 // indirect
97− github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 // indirect
88+ github.com/aws/aws-sdk-go-v2/config v1.32.11 // indirect
89+ github.com/aws/aws-sdk-go-v2/credentials v1.19.11 // indirect
90+ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 // indirect
91+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 // indirect
92+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 // indirect
93+ github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 // indirect
94+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 // indirect
95+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 // indirect
96+ github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 // indirect
97+ github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 // indirect
98+ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 // indirect
99+ github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 // indirect
98100 github.com/aws/smithy-go v1.24.2 // indirect
99101 github.com/aymerick/douceur v0.2.0 // indirect
100102 github.com/bahlo/generic-list-go v0.2.0 // indirect
@@ -107,6 +109,7 @@ require (
107109 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
108110 github.com/dlclark/regexp2 v1.11.5 // indirect
109111 github.com/ebitengine/purego v0.10.0 // indirect
112+ github.com/esiqveland/notify v0.13.3 // indirect
110113 github.com/felixge/httpsnoop v1.0.4 // indirect
111114 github.com/fsnotify/fsnotify v1.9.0 // indirect
112115 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
@@ -115,9 +118,11 @@ require (
115118 github.com/go-logfmt/logfmt v0.6.0 // indirect
116119 github.com/go-logr/logr v1.4.3 // indirect
117120 github.com/go-logr/stdr v1.2.2 // indirect
121+ github.com/go-ole/go-ole v1.3.0 // indirect
118122 github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
119123 github.com/goccy/go-json v0.10.5 // indirect
120124 github.com/goccy/go-yaml v1.19.2 // indirect
125+ github.com/godbus/dbus/v5 v5.2.2 // indirect
121126 github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
122127 github.com/google/go-cmp v0.7.0 // indirect
123128 github.com/google/jsonschema-go v0.4.2 // indirect
@@ -128,6 +133,7 @@ require (
128133 github.com/gorilla/websocket v1.5.3 // indirect
129134 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
130135 github.com/inconshreveable/mousetrap v1.1.0 // indirect
136+ github.com/jackmordaunt/icns/v3 v3.0.1 // indirect
131137 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
132138 github.com/kaptinlin/go-i18n v0.2.12 // indirect
133139 github.com/kaptinlin/jsonpointer v0.4.17 // indirect
@@ -148,13 +154,17 @@ require (
148154 github.com/muesli/roff v0.1.0 // indirect
149155 github.com/ncruces/go-strftime v1.0.0 // indirect
150156 github.com/ncruces/julianday v1.0.0 // indirect
157+ github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
151158 github.com/pierrec/lz4/v4 v4.1.25 // indirect
152159 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
153160 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
154161 github.com/segmentio/asm v1.2.1 // indirect
155162 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
156165 github.com/sethvargo/go-retry v0.3.0 // indirect
157166 github.com/spf13/pflag v1.0.9 // indirect
167+ github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect
158168 github.com/tetratelabs/wazero v1.11.0 // indirect
159169 github.com/tidwall/match v1.1.1 // indirect
160170 github.com/tidwall/pretty v1.2.1 // indirect
@@ -176,12 +186,12 @@ require (
176186 golang.org/x/crypto v0.48.0 // indirect
177187 golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
178188 golang.org/x/image v0.36.0 // indirect
179− golang.org/x/oauth2 v0.35.0 // indirect
180− golang.org/x/sys v0.41.0 // indirect
189+ golang.org/x/oauth2 v0.36.0 // indirect
190+ golang.org/x/sys v0.42.0 // indirect
181191 golang.org/x/term v0.40.0 // indirect
182192 golang.org/x/time v0.14.0 // indirect
183193 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
185195 google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
186196 google.golang.org/grpc v1.79.1 // indirect
187197 google.golang.org/protobuf v1.36.11 // indirect
go.sum+83−56
@@ -1,17 +1,17 @@
11charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
22charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
3−charm.land/bubbletea/v2 v2.0.1 h1:B8e9zzK7x9JJ+XvHGF4xnYu9Xa0E0y0MyggY6dbaCfQ=
4−charm.land/bubbletea/v2 v2.0.1/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
5−charm.land/catwalk v0.28.1 h1:4YJiRRNUb7i8qDEZjFLJPMKvquihGrGsTNTcf5Dfqq0=
6−charm.land/catwalk v0.28.1/go.mod h1:rFC/V96rIHX7VES215c/qzI1EW/Moo1ggs1Q6seTy5s=
7−charm.land/fantasy v0.11.1 h1:G1dRqkzEQ0RJN1Ls5mte8HOi0wFKxYd5bfnRAmeYvDk=
8−charm.land/fantasy v0.11.1/go.mod h1:C8wNxWlw+b2z54zsTor9r1tG2GE2C4QotvAlgXh9KF8=
9−charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b h1:A6IUUyChZDWP16RUdRJCfmYISAKWQGyIcfhZJUCViQ0=
10−charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b/go.mod h1:J3kVhY6oHXZq5f+8vC3hmDO95fEvbqj3z7xDwxrfzU8=
11−charm.land/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo=
12−charm.land/lipgloss/v2 v2.0.0/go.mod h1:w6SnmsBFBmEFBodiEDurGS/sdUY/u1+v72DqUzc6J14=
13−charm.land/log/v2 v2.0.0-20251110204020-529bb77f35da h1:vZa/Ow0uLclpfaDY0ubjzE+B0eLQqi2zanmpeALanow=
14−charm.land/log/v2 v2.0.0-20251110204020-529bb77f35da/go.mod h1:Tj12StbPc4GwksDF6XwhC9wdXouinIVxRGKKmmmzdSU=
3+charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
4+charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
5+charm.land/catwalk v0.28.4 h1:YaaXA1k0v7CKvvT+Gh1pDD7XrlUR93kROdaWqkkglRw=
6+charm.land/catwalk v0.28.4/go.mod h1:+fqw/6YGNtvapvPy9vhwA/fAMxVjD2K8hVIKYov8Vhg=
7+charm.land/fantasy v0.12.1 h1:awszoi5O9FIjMEkfyCMiLJfVRNLckp/zQkFrA6IxQqc=
8+charm.land/fantasy v0.12.1/go.mod h1:QeRVUeG1XNTWBszRAbhUtPyX1VWs6zjkCxwfcwnICdc=
9+charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U=
10+charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w=
11+charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys=
12+charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
13+charm.land/log/v2 v2.0.0 h1:SY3Cey7ipx86/MBXQHwsguOT6X1exT94mmJRdzTNs+s=
14+charm.land/log/v2 v2.0.0/go.mod h1:c3cZSRqm20qUVVAR1WmS/7ab8bgha3C6G7DjPcaVZz0=
1515charm.land/x/vcr v0.1.1 h1:PXCFMUG0rPtyk35rhfzYCJEduOzWXCIbrXTFq4OF/9Q=
1616charm.land/x/vcr v0.1.1/go.mod h1:eByq2gqzWvcct/8XE2XO5KznoWEBiXH56+y2gphbltM=
1717cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
@@ -22,6 +22,8 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi
2222cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
2323cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
2424cloud.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=
2527github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 h1:g0EZJwz7xkXQiZAI5xi9f3WWFYBlX1CPTrR+NDToRkQ=
2628github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ=
2729github.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
5052github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
5153github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
5254github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
53−github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls=
54−github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4=
55+github.com/aws/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA=
56+github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
5557github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q=
5658github.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=
58−github.com/aws/aws-sdk-go-v2/config v1.32.10/go.mod h1:2rUIOnA2JaiqYmSKYmRJlcMWy6qTj1vuRFscppSBMcw=
59−github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8=
60−github.com/aws/aws-sdk-go-v2/credentials v1.19.10/go.mod h1:RnnlFCAlxQCkN2Q379B67USkBMu1PipEEiibzYN5UTE=
61−github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 h1:Ii4s+Sq3yDfaMLpjrJsqD6SmG/Wq/P5L/hw2qa78UAY=
62−github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18/go.mod h1:6x81qnY++ovptLE6nWQeWrpXxbnlIex+4H4eYYGcqfc=
63−github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 h1:F43zk1vemYIqPAwhjTjYIz0irU2EY7sOb/F5eJ3HuyM=
64−github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18/go.mod h1:w1jdlZXrGKaJcNoL+Nnrj+k5wlpGXqnNrKoP22HvAug=
65−github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 h1:xCeWVjj0ki0l3nruoyP2slHsGArMxeiiaoPN5QZH6YQ=
66−github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18/go.mod h1:r/eLGuGCBw6l36ZRWiw6PaZwPXb6YOj+i/7MizNl5/k=
67−github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
68−github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
69−github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 h1:CeY9LUdur+Dxoeldqoun6y4WtJ3RQtzk0JMP2gfUay0=
70−github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5/go.mod h1:AZLZf2fMaahW5s/wMRciu1sYbdsikT/UHwbUjOdEVTc=
71−github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 h1:LTRCYFlnnKFlKsyIQxKhJuDuA3ZkrDQMRYm6rXiHlLY=
72−github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18/go.mod h1:XhwkgGG6bHSd00nO/mexWTcTjgd6PjuvWQMqSn2UaEk=
73−github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 h1:MzORe+J94I+hYu2a6XmV5yC9huoTv8NRcCrUNedDypQ=
74−github.com/aws/aws-sdk-go-v2/service/signin v1.0.6/go.mod h1:hXzcHLARD7GeWnifd8j9RWqtfIgxj4/cAtIVIK7hg8g=
75−github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 h1:7oGD8KPfBOJGXiCoRKrrrQkbvCp8N++u36hrLMPey6o=
76−github.com/aws/aws-sdk-go-v2/service/sso v1.30.11/go.mod h1:0DO9B5EUJQlIDif+XJRWCljZRKsAFKh3gpFz7UnDtOo=
77−github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 h1:edCcNp9eGIUDUCrzoCu1jWAXLGFIizeqkdkKgRlJwWc=
78−github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15/go.mod h1:lyRQKED9xWfgkYC/wmmYfv7iVIM68Z5OQ88ZdcV1QbU=
79−github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 h1:NITQpgo9A5NrDZ57uOWj+abvXSb83BbyggcUBVksN7c=
80−github.com/aws/aws-sdk-go-v2/service/sts v1.41.7/go.mod h1:sks5UWBhEuWYDPdwlnRFn1w7xWdH29Jcpe+/PJQefEs=
59+github.com/aws/aws-sdk-go-v2/config v1.32.11 h1:ftxI5sgz8jZkckuUHXfC/wMUc8u3fG1vQS0plr2F2Zs=
60+github.com/aws/aws-sdk-go-v2/config v1.32.11/go.mod h1:twF11+6ps9aNRKEDimksp923o44w/Thk9+8YIlzWMmo=
61+github.com/aws/aws-sdk-go-v2/credentials v1.19.11 h1:NdV8cwCcAXrCWyxArt58BrvZJ9pZ9Fhf9w6Uh5W3Uyc=
62+github.com/aws/aws-sdk-go-v2/credentials v1.19.11/go.mod h1:30yY2zqkMPdrvxBqzI9xQCM+WrlrZKSOpSJEsylVU+8=
63+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 h1:INUvJxmhdEbVulJYHI061k4TVuS3jzzthNvjqvVvTKM=
64+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19/go.mod h1:FpZN2QISLdEBWkayloda+sZjVJL+e9Gl0k1SyTgcswU=
65+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 h1:/sECfyq2JTifMI2JPyZ4bdRN77zJmr6SrS1eL3augIA=
66+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19/go.mod h1:dMf8A5oAqr9/oxOfLkC/c2LU/uMcALP0Rgn2BD5LWn0=
67+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 h1:AWeJMk33GTBf6J20XJe6qZoRSJo0WfUhsMdUKhoODXE=
68+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19/go.mod h1:+GWrYoaAsV7/4pNHpwh1kiNLXkKaSoppxQq9lbH8Ejw=
69+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 h1:clHU5fm//kWS1C2HgtgWxfQbFbx4b6rx+5jzhgX9HrI=
70+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
71+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 h1:XAq62tBTJP/85lFD5oqOOe7YYgWxY9LvWq8plyDvDVg=
72+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
73+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 h1:X1Tow7suZk9UCJHE1Iw9GMZJJl0dAnKXXP1NaSDHwmw=
74+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19/go.mod h1:/rARO8psX+4sfjUQXp5LLifjUt8DuATZ31WptNJTyQA=
75+github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 h1:Y2cAXlClHsXkkOvWZFXATr34b0hxxloeQu/pAZz2row=
76+github.com/aws/aws-sdk-go-v2/service/signin v1.0.7/go.mod h1:idzZ7gmDeqeNrSPkdbtMp9qWMgcBwykA7P7Rzh5DXVU=
77+github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 h1:iSsvB9EtQ09YrsmIc44Heqlx5ByGErqhPK1ZQLppias=
78+github.com/aws/aws-sdk-go-v2/service/sso v1.30.12/go.mod h1:fEWYKTRGoZNl8tZ77i61/ccwOMJdGxwOhWCkp6TXAr0=
79+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 h1:EnUdUqRP1CNzt2DkV67tJx6XDN4xlfBFm+bzeNOQVb0=
80+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16/go.mod h1:Jic/xv0Rq/pFNCh3WwpH4BEqdbSAl+IyHro8LbibHD8=
81+github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 h1:XQTQTF75vnug2TXS8m7CVJfC2nniYPZnO1D4Np761Oo=
82+github.com/aws/aws-sdk-go-v2/service/sts v1.41.8/go.mod h1:Xgx+PR1NUOjNmQY+tRMnouRp83JRM8pRMw/vCaVhPkI=
8183github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
8284github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
8385github.com/aymanbagabas/go-nativeclipboard v0.1.3 h1:FmAWHPTwneAixu7uGDn3cL42xPlUCdNp2J8egMn3P1k=
8486github.com/aymanbagabas/go-nativeclipboard v0.1.3/go.mod h1:2o7MyZwwi4pmXXpOpvOS5FwaHyoCIUks0ktjUvB0EoE=
85−github.com/aymanbagabas/go-udiff v0.4.0 h1:TKnLPh7IbnizJIBKFWa9mKayRUBQ9Kh1BPCk6w2PnYM=
86−github.com/aymanbagabas/go-udiff v0.4.0/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
87+github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
88+github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
8789github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
8890github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
8991github.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
98100github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY=
99101github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab h1:J7XQLgl9sefgTnTGrmX3xqvp5o6MCiBzEjGv5igAlc4=
100102github.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=
102−github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
103−github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=
104−github.com/charmbracelet/fang v0.4.4/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo=
103+github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
104+github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
105+github.com/charmbracelet/fang v1.0.0 h1:jESBY40agJOlLYnnv9jE0mLqDGTxEk0hkOnx7YGyRlQ=
106+github.com/charmbracelet/fang v1.0.0/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo=
105107github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
106108github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
107109github.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
160162github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
161163github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
162164github.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=
163167github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
164168github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
165169github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
166170github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
167171github.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=
168174github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
169175github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
170176github.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=
180186github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
181187github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
182188github.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=
183191github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
184192github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
185193github.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=
188196github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
189197github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
190198github.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=
191202github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
192203github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
193204github.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
222233github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
223234github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
224235github.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=
225238github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
226239github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
227240github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
@@ -278,12 +291,14 @@ github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe
278291github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=
279292github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
280293github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
281−github.com/ncruces/go-sqlite3 v0.30.5 h1:6usmTQ6khriL8oWilkAZSJM/AIpAlVL2zFrlcpDldCE=
282−github.com/ncruces/go-sqlite3 v0.30.5/go.mod h1:0I0JFflTKzfs3Ogfv8erP7CCoV/Z8uxigVDNOR0AQ5E=
294+github.com/ncruces/go-sqlite3 v0.31.1 h1:F76NF4NTLNOabLUKuEb2xqjBW+/Ub+MR59/Q7dACRo8=
295+github.com/ncruces/go-sqlite3 v0.31.1/go.mod h1:L9OWFjYG/+4dq9O6bFCYoWLG0a7LmtgR6v26TvABmwg=
283296github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
284297github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
285298github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
286299github.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=
287302github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
288303github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=
289304github.com/openai/openai-go/v2 v2.7.1 h1:/tfvTJhfv7hTSL8mWwc5VL4WLLSDL5yn9VqVykdu9r8=
@@ -323,6 +338,10 @@ github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
323338github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
324339github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w=
325340github.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=
326345github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
327346github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
328347github.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
336355github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
337356github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
338357github.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=
339360github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
340361github.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=
341365github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
342366github.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=
343369github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
344370github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
345371github.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=
429455golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
430456golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
431457golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
432−golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
433−golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
458+golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
459+golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
434460golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
435461golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
436462golang.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=
438464golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
439465golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
440466golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
441−golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
442−golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
467+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
468+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
443469golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
444470golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
445471golang.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=
456482golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
457483golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
458484golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
459−golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
460−golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
485+golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
486+golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
461487golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
462488golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
463489golang.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=
497523gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
498524google.golang.org/api v0.269.0 h1:qDrTOxKUQ/P0MveH6a7vZ+DNHxJQjtGm/uvdbdGXCQg=
499525google.golang.org/api v0.269.0/go.mod h1:N8Wpcu23Tlccl0zSHEkcAZQKDLdquxK+l9r2LkwAauE=
500−google.golang.org/genai v1.48.0 h1:1vb15G291wAjJJueisMDpUhssljhEdJU2t5qTidrVPs=
501−google.golang.org/genai v1.48.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
526+google.golang.org/genai v1.49.0 h1:Se+QJaH2GYK1aaR1o5S38mlU2GD5FnVvP76nfkV7LH0=
527+google.golang.org/genai v1.49.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
502528google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
503529google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
504530google.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
520546gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
521547gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
522548gopkg.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=
523550gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
524551gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
525552modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
@@ -552,5 +579,5 @@ modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
552579modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
553580mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5 h1:mO2lyKtGwu4mGQ+Qqjx0+fd5UU5BXhX/rslFmxd5aco=
554581mvdan.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=
556−mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5/go.mod h1:P21wo2gLLe3426sP+CmANLBaixSEbRtPl35w3YlM6dg=
582+mvdan.cc/sh/v3 v3.13.0 h1:dSfq/MVsY4w0Vsi6Lbs0IcQquMVqLdKLESAOZjuHdLg=
583+mvdan.cc/sh/v3 v3.13.0/go.mod h1:KV1GByGPc/Ho0X1E6Uz9euhsIQEj4hwyKnodLlFLoDM=
internal/agent/agent.go+20
@@ -32,14 +32,17 @@ import (
3232 "charm.land/fantasy/providers/vercel"
3333 "charm.land/lipgloss/v2"
3434 "github.com/charmbracelet/crush/internal/agent/hyper"
35+ "github.com/charmbracelet/crush/internal/agent/notify"
3536 "github.com/charmbracelet/crush/internal/agent/tools"
3637 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
3738 "github.com/charmbracelet/crush/internal/config"
3839 "github.com/charmbracelet/crush/internal/csync"
3940 "github.com/charmbracelet/crush/internal/message"
4041 "github.com/charmbracelet/crush/internal/permission"
42+ "github.com/charmbracelet/crush/internal/pubsub"
4143 "github.com/charmbracelet/crush/internal/session"
4244 "github.com/charmbracelet/crush/internal/stringext"
45+ "github.com/charmbracelet/crush/internal/version"
4346 "github.com/charmbracelet/x/exp/charmtone"
4447)
4548
@@ -72,6 +75,7 @@ type SessionAgentCall struct {
7275 TopK *int64
7376 FrequencyPenalty *float64
7477 PresencePenalty *float64
78+ NonInteractive bool
7579}
7680
7781type SessionAgent interface {
@@ -108,6 +112,7 @@ type sessionAgent struct {
108112 messages message.Service
109113 disableAutoSummarize bool
110114 isYolo bool
115+ notify pubsub.Publisher[notify.Notification]
111116
112117 messageQueue *csync.Map[string, []SessionAgentCall]
113118 activeRequests *csync.Map[string, context.CancelFunc]
@@ -124,6 +129,7 @@ type SessionAgentOptions struct {
124129 Sessions session.Service
125130 Messages message.Service
126131 Tools []fantasy.AgentTool
132+ Notify pubsub.Publisher[notify.Notification]
127133}
128134
129135func NewSessionAgent(
@@ -140,6 +146,7 @@ func NewSessionAgent(
140146 disableAutoSummarize: opts.DisableAutoSummarize,
141147 tools: csync.NewSliceFrom(opts.Tools),
142148 isYolo: opts.IsYolo,
149+ notify: opts.Notify,
143150 messageQueue: csync.NewMap[string, []SessionAgentCall](),
144151 activeRequests: csync.NewMap[string, context.CancelFunc](),
145152 }
@@ -194,6 +201,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
194201 largeModel.Model,
195202 fantasy.WithSystemPrompt(systemPrompt),
196203 fantasy.WithTools(agentTools...),
204+ fantasy.WithUserAgent("Charm Crush/"+version.Version),
197205 )
198206
199207 sessionLock := sync.Mutex{}
@@ -530,6 +538,16 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
530538 return nil, err
531539 }
532540
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+
533551 if shouldSummarize {
534552 a.activeRequests.Del(call.SessionID)
535553 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
592610
593611 agent := fantasy.NewAgent(largeModel.Model,
594612 fantasy.WithSystemPrompt(string(summaryPrompt)),
613+ fantasy.WithUserAgent("Charm Crush/"+version.Version),
595614 )
596615 summaryMessage, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
597616 Role: message.Assistant,
@@ -787,6 +806,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
787806 return fantasy.NewAgent(m,
788807 fantasy.WithSystemPrompt(string(p)+"\n /no_think"),
789808 fantasy.WithMaxOutputTokens(tok),
809+ fantasy.WithUserAgent("Charm Crush/"+version.Version),
790810 )
791811 }
792812
internal/agent/agent_tool.go+1−1
@@ -24,7 +24,7 @@ const (
2424)
2525
2626func (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]
2828 if !ok {
2929 return nil, errors.New("task agent not configured")
3030 }
internal/agent/agentic_fetch_tool.go+5−5
@@ -98,7 +98,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
9898 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
9999 }
100100
101− tmpDir, err := os.MkdirTemp(c.cfg.Options.DataDirectory, "crush-fetch-*")
101+ tmpDir, err := os.MkdirTemp(c.cfg.Config().Options.DataDirectory, "crush-fetch-*")
102102 if err != nil {
103103 return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to create temporary directory: %s", err)), nil
104104 }
@@ -151,12 +151,12 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
151151 return fantasy.ToolResponse{}, fmt.Errorf("error building models: %s", err)
152152 }
153153
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)
155155 if err != nil {
156156 return fantasy.ToolResponse{}, fmt.Errorf("error building system prompt: %s", err)
157157 }
158158
159− smallProviderCfg, ok := c.cfg.Providers.Get(small.ModelCfg.Provider)
159+ smallProviderCfg, ok := c.cfg.Config().Providers.Get(small.ModelCfg.Provider)
160160 if !ok {
161161 return fantasy.ToolResponse{}, errors.New("small model provider not configured")
162162 }
@@ -167,7 +167,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
167167 webFetchTool,
168168 webSearchTool,
169169 tools.NewGlobTool(tmpDir),
170− tools.NewGrepTool(tmpDir, c.cfg.Tools.Grep),
170+ tools.NewGrepTool(tmpDir, c.cfg.Config().Tools.Grep),
171171 tools.NewSourcegraphTool(client),
172172 tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, tmpDir),
173173 }
@@ -177,7 +177,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
177177 SmallModel: small,
178178 SystemPromptPrefix: smallProviderCfg.SystemPromptPrefix,
179179 SystemPrompt: systemPrompt,
180− DisableAutoSummarize: c.cfg.Options.DisableAutoSummarize,
180+ DisableAutoSummarize: c.cfg.Config().Options.DisableAutoSummarize,
181181 IsYolo: c.permissions.SkipRequests(),
182182 Sessions: c.sessions,
183183 Messages: c.messages,
internal/agent/common_test.go+18−10
@@ -153,7 +153,15 @@ func testSessionAgent(env fakeEnv, large, small fantasy.LanguageModel, systemPro
153153 DefaultMaxTokens: 10000,
154154 },
155155 }
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+ })
157165 return agent
158166}
159167
@@ -177,36 +185,36 @@ func coderAgent(r *vcr.Recorder, env fakeEnv, large, small fantasy.LanguageModel
177185
178186 // NOTE(@andreynering): Set a fixed config to ensure cassettes match
179187 // independently of user config on `$HOME/.config/crush/crush.json`.
180− cfg.Options.Attribution = &config.Attribution{
188+ cfg.Config().Options.Attribution = &config.Attribution{
181189 TrailerStyle: "co-authored-by",
182190 GeneratedWith: true,
183191 }
184192
185193 // Clear some fields to avoid issues with VCR cassette matching.
186− cfg.Options.SkillsPaths = nil
187− cfg.Options.ContextPaths = nil
188− cfg.LSP = nil
194+ cfg.Config().Options.SkillsPaths = nil
195+ cfg.Config().Options.ContextPaths = nil
196+ cfg.Config().LSP = nil
189197
190− systemPrompt, err := prompt.Build(context.TODO(), large.Provider(), large.Model(), *cfg)
198+ systemPrompt, err := prompt.Build(context.TODO(), large.Provider(), large.Model(), cfg)
191199 if err != nil {
192200 return nil, err
193201 }
194202
195203 // Get the model name for the bash tool
196204 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 {
198206 modelName = model.Name
199207 }
200208
201209 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),
203211 tools.NewDownloadTool(env.permissions, env.workingDir, r.GetDefaultClient()),
204212 tools.NewEditTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir),
205213 tools.NewMultiEditTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir),
206214 tools.NewFetchTool(env.permissions, env.workingDir, r.GetDefaultClient()),
207215 tools.NewGlobTool(env.workingDir),
208− tools.NewGrepTool(env.workingDir, cfg.Tools.Grep),
209− tools.NewLsTool(env.permissions, env.workingDir, cfg.Tools.Ls),
216+ tools.NewGrepTool(env.workingDir, cfg.Config().Tools.Grep),
217+ tools.NewLsTool(env.permissions, env.workingDir, cfg.Config().Tools.Ls),
210218 tools.NewSourcegraphTool(r.GetDefaultClient()),
211219 tools.NewViewTool(nil, env.permissions, *env.filetracker, env.workingDir),
212220 tools.NewWriteTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir),
internal/agent/coordinator.go+51−44
@@ -18,6 +18,7 @@ import (
1818 "charm.land/catwalk/pkg/catwalk"
1919 "charm.land/fantasy"
2020 "github.com/charmbracelet/crush/internal/agent/hyper"
21+ "github.com/charmbracelet/crush/internal/agent/notify"
2122 "github.com/charmbracelet/crush/internal/agent/prompt"
2223 "github.com/charmbracelet/crush/internal/agent/tools"
2324 "github.com/charmbracelet/crush/internal/config"
@@ -28,6 +29,7 @@ import (
2829 "github.com/charmbracelet/crush/internal/message"
2930 "github.com/charmbracelet/crush/internal/oauth/copilot"
3031 "github.com/charmbracelet/crush/internal/permission"
32+ "github.com/charmbracelet/crush/internal/pubsub"
3133 "github.com/charmbracelet/crush/internal/session"
3234 "golang.org/x/sync/errgroup"
3335
@@ -72,13 +74,14 @@ type Coordinator interface {
7274}
7375
7476type coordinator struct {
75− cfg *config.Config
77+ cfg *config.ConfigStore
7678 sessions session.Service
7779 messages message.Service
7880 permissions permission.Service
7981 history history.Service
8082 filetracker filetracker.Service
8183 lspManager *lsp.Manager
84+ notify pubsub.Publisher[notify.Notification]
8285
8386 currentAgent SessionAgent
8487 agents map[string]SessionAgent
@@ -88,13 +91,14 @@ type coordinator struct {
8891
8992func NewCoordinator(
9093 ctx context.Context,
91− cfg *config.Config,
94+ cfg *config.ConfigStore,
9295 sessions session.Service,
9396 messages message.Service,
9497 permissions permission.Service,
9598 history history.Service,
9699 filetracker filetracker.Service,
97100 lspManager *lsp.Manager,
101+ notify pubsub.Publisher[notify.Notification],
98102) (Coordinator, error) {
99103 c := &coordinator{
100104 cfg: cfg,
@@ -104,10 +108,11 @@ func NewCoordinator(
104108 history: history,
105109 filetracker: filetracker,
106110 lspManager: lspManager,
111+ notify: notify,
107112 agents: make(map[string]SessionAgent),
108113 }
109114
110− agentCfg, ok := cfg.Agents[config.AgentCoder]
115+ agentCfg, ok := cfg.Config().Agents[config.AgentCoder]
111116 if !ok {
112117 return nil, errCoderAgentNotConfigured
113118 }
@@ -155,7 +160,7 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
155160 attachments = filteredAttachments
156161 }
157162
158− providerCfg, ok := c.cfg.Providers.Get(model.ModelCfg.Provider)
163+ providerCfg, ok := c.cfg.Config().Providers.Get(model.ModelCfg.Provider)
159164 if !ok {
160165 return nil, errModelProviderNotConfigured
161166 }
@@ -378,22 +383,23 @@ func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, age
378383 return nil, err
379384 }
380385
381− largeProviderCfg, _ := c.cfg.Providers.Get(large.ModelCfg.Provider)
386+ largeProviderCfg, _ := c.cfg.Config().Providers.Get(large.ModelCfg.Provider)
382387 result := NewSessionAgent(SessionAgentOptions{
383− large,
384− small,
385− largeProviderCfg.SystemPromptPrefix,
386− "",
387− isSubAgent,
388− c.cfg.Options.DisableAutoSummarize,
389− c.permissions.SkipRequests(),
390− c.sessions,
391− c.messages,
392− nil,
388+ LargeModel: large,
389+ SmallModel: small,
390+ SystemPromptPrefix: largeProviderCfg.SystemPromptPrefix,
391+ SystemPrompt: "",
392+ IsSubAgent: isSubAgent,
393+ DisableAutoSummarize: c.cfg.Config().Options.DisableAutoSummarize,
394+ IsYolo: c.permissions.SkipRequests(),
395+ Sessions: c.sessions,
396+ Messages: c.messages,
397+ Tools: nil,
398+ Notify: c.notify,
393399 })
394400
395401 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)
397403 if err != nil {
398404 return err
399405 }
@@ -433,14 +439,14 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
433439
434440 // Get the model name for the agent
435441 modelName := ""
436− if modelCfg, ok := c.cfg.Models[agent.Model]; ok {
437− if model := c.cfg.GetModel(modelCfg.Provider, modelCfg.Model); model != nil {
442+ if modelCfg, ok := c.cfg.Config().Models[agent.Model]; ok {
443+ if model := c.cfg.Config().GetModel(modelCfg.Provider, modelCfg.Model); model != nil {
438444 modelName = model.Name
439445 }
440446 }
441447
442448 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),
444450 tools.NewJobOutputTool(),
445451 tools.NewJobKillTool(),
446452 tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
@@ -448,20 +454,20 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
448454 tools.NewMultiEditTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
449455 tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
450456 tools.NewGlobTool(c.cfg.WorkingDir()),
451− tools.NewGrepTool(c.cfg.WorkingDir(), c.cfg.Tools.Grep),
452− tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
457+ tools.NewGrepTool(c.cfg.WorkingDir(), c.cfg.Config().Tools.Grep),
458+ tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Config().Tools.Ls),
453459 tools.NewSourcegraphTool(nil),
454460 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...),
456462 tools.NewWriteTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
457463 )
458464
459465 // 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 {
461467 allTools = append(allTools, tools.NewDiagnosticsTool(c.lspManager), tools.NewReferencesTool(c.lspManager), tools.NewLSPRestartTool(c.lspManager))
462468 }
463469
464− if len(c.cfg.MCP) > 0 {
470+ if len(c.cfg.Config().MCP) > 0 {
465471 allTools = append(
466472 allTools,
467473 tools.NewListMCPResourcesTool(c.cfg, c.permissions),
@@ -507,16 +513,16 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
507513
508514// TODO: when we support multiple agents we need to change this so that we pass in the agent specific model config
509515func (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]
511517 if !ok {
512518 return Model{}, Model{}, errLargeModelNotSelected
513519 }
514− smallModelCfg, ok := c.cfg.Models[config.SelectedModelTypeSmall]
520+ smallModelCfg, ok := c.cfg.Config().Models[config.SelectedModelTypeSmall]
515521 if !ok {
516522 return Model{}, Model{}, errSmallModelNotSelected
517523 }
518524
519− largeProviderCfg, ok := c.cfg.Providers.Get(largeModelCfg.Provider)
525+ largeProviderCfg, ok := c.cfg.Config().Providers.Get(largeModelCfg.Provider)
520526 if !ok {
521527 return Model{}, Model{}, errLargeModelProviderNotConfigured
522528 }
@@ -526,7 +532,7 @@ func (c *coordinator) buildAgentModels(ctx context.Context, isSubAgent bool) (Mo
526532 return Model{}, Model{}, err
527533 }
528534
529− smallProviderCfg, ok := c.cfg.Providers.Get(smallModelCfg.Provider)
535+ smallProviderCfg, ok := c.cfg.Config().Providers.Get(smallModelCfg.Provider)
530536 if !ok {
531537 return Model{}, Model{}, errSmallModelProviderNotConfigured
532538 }
@@ -614,7 +620,7 @@ func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map
614620 opts = append(opts, anthropic.WithBaseURL(baseURL))
615621 }
616622
617− if c.cfg.Options.Debug {
623+ if c.cfg.Config().Options.Debug {
618624 httpClient := log.NewHTTPClient()
619625 opts = append(opts, anthropic.WithHTTPClient(httpClient))
620626 }
@@ -626,7 +632,7 @@ func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[st
626632 openai.WithAPIKey(apiKey),
627633 openai.WithUseResponsesAPI(),
628634 }
629− if c.cfg.Options.Debug {
635+ if c.cfg.Config().Options.Debug {
630636 httpClient := log.NewHTTPClient()
631637 opts = append(opts, openai.WithHTTPClient(httpClient))
632638 }
@@ -643,7 +649,7 @@ func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[stri
643649 opts := []openrouter.Option{
644650 openrouter.WithAPIKey(apiKey),
645651 }
646− if c.cfg.Options.Debug {
652+ if c.cfg.Config().Options.Debug {
647653 httpClient := log.NewHTTPClient()
648654 opts = append(opts, openrouter.WithHTTPClient(httpClient))
649655 }
@@ -657,7 +663,7 @@ func (c *coordinator) buildVercelProvider(_, apiKey string, headers map[string]s
657663 opts := []vercel.Option{
658664 vercel.WithAPIKey(apiKey),
659665 }
660− if c.cfg.Options.Debug {
666+ if c.cfg.Config().Options.Debug {
661667 httpClient := log.NewHTTPClient()
662668 opts = append(opts, vercel.WithHTTPClient(httpClient))
663669 }
@@ -677,8 +683,8 @@ func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers
677683 var httpClient *http.Client
678684 if providerID == string(catwalk.InferenceProviderCopilot) {
679685 opts = append(opts, openaicompat.WithUseResponsesAPI())
680− httpClient = copilot.NewClient(isSubAgent, c.cfg.Options.Debug)
681− } else if c.cfg.Options.Debug {
686+ httpClient = copilot.NewClient(isSubAgent, c.cfg.Config().Options.Debug)
687+ } else if c.cfg.Config().Options.Debug {
682688 httpClient = log.NewHTTPClient()
683689 }
684690 if httpClient != nil {
@@ -702,7 +708,7 @@ func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[str
702708 azure.WithAPIKey(apiKey),
703709 azure.WithUseResponsesAPI(),
704710 }
705− if c.cfg.Options.Debug {
711+ if c.cfg.Config().Options.Debug {
706712 httpClient := log.NewHTTPClient()
707713 opts = append(opts, azure.WithHTTPClient(httpClient))
708714 }
@@ -721,7 +727,7 @@ func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[str
721727
722728func (c *coordinator) buildBedrockProvider(headers map[string]string) (fantasy.Provider, error) {
723729 var opts []bedrock.Option
724− if c.cfg.Options.Debug {
730+ if c.cfg.Config().Options.Debug {
725731 httpClient := log.NewHTTPClient()
726732 opts = append(opts, bedrock.WithHTTPClient(httpClient))
727733 }
@@ -740,7 +746,7 @@ func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[st
740746 google.WithBaseURL(baseURL),
741747 google.WithGeminiAPIKey(apiKey),
742748 }
743− if c.cfg.Options.Debug {
749+ if c.cfg.Config().Options.Debug {
744750 httpClient := log.NewHTTPClient()
745751 opts = append(opts, google.WithHTTPClient(httpClient))
746752 }
@@ -752,7 +758,7 @@ func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[st
752758
753759func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) {
754760 opts := []google.Option{}
755− if c.cfg.Options.Debug {
761+ if c.cfg.Config().Options.Debug {
756762 httpClient := log.NewHTTPClient()
757763 opts = append(opts, google.WithHTTPClient(httpClient))
758764 }
@@ -773,7 +779,7 @@ func (c *coordinator) buildHyperProvider(baseURL, apiKey string) (fantasy.Provid
773779 hyper.WithBaseURL(baseURL),
774780 hyper.WithAPIKey(apiKey),
775781 }
776− if c.cfg.Options.Debug {
782+ if c.cfg.Config().Options.Debug {
777783 httpClient := log.NewHTTPClient()
778784 opts = append(opts, hyper.WithHTTPClient(httpClient))
779785 }
@@ -881,7 +887,7 @@ func (c *coordinator) UpdateModels(ctx context.Context) error {
881887 }
882888 c.currentAgent.SetModels(large, small)
883889
884− agentCfg, ok := c.cfg.Agents[config.AgentCoder]
890+ agentCfg, ok := c.cfg.Config().Agents[config.AgentCoder]
885891 if !ok {
886892 return errCoderAgentNotConfigured
887893 }
@@ -903,7 +909,7 @@ func (c *coordinator) QueuedPromptsList(sessionID string) []string {
903909}
904910
905911func (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)
907913 if !ok {
908914 return errModelProviderNotConfigured
909915 }
@@ -916,7 +922,7 @@ func (c *coordinator) isUnauthorized(err error) bool {
916922}
917923
918924func (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 {
920926 slog.Error("Failed to refresh OAuth token after 401 error", "provider", providerCfg.ID, "error", err)
921927 return err
922928 }
@@ -934,7 +940,7 @@ func (c *coordinator) refreshApiKeyTemplate(ctx context.Context, providerCfg con
934940 }
935941
936942 providerCfg.APIKey = newAPIKey
937− c.cfg.Providers.Set(providerCfg.ID, providerCfg)
943+ c.cfg.Config().Providers.Set(providerCfg.ID, providerCfg)
938944
939945 if err := c.UpdateModels(ctx); err != nil {
940946 return err
@@ -978,7 +984,7 @@ func (c *coordinator) runSubAgent(ctx context.Context, params subAgentParams) (f
978984 maxTokens = model.ModelCfg.MaxTokens
979985 }
980986
981− providerCfg, ok := c.cfg.Providers.Get(model.ModelCfg.Provider)
987+ providerCfg, ok := c.cfg.Config().Providers.Get(model.ModelCfg.Provider)
982988 if !ok {
983989 return fantasy.ToolResponse{}, errModelProviderNotConfigured
984990 }
@@ -994,6 +1000,7 @@ func (c *coordinator) runSubAgent(ctx context.Context, params subAgentParams) (f
9941000 TopK: model.ModelCfg.TopK,
9951001 FrequencyPenalty: model.ModelCfg.FrequencyPenalty,
9961002 PresencePenalty: model.ModelCfg.PresencePenalty,
1003+ NonInteractive: true,
9971004 })
9981005 if err != nil {
9991006 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
4444func newTestCoordinator(t *testing.T, env fakeEnv, providerID string, providerCfg config.ProviderConfig) *coordinator {
4545 cfg, err := config.Init(env.workingDir, "", false)
4646 require.NoError(t, err)
47− cfg.Providers.Set(providerID, providerCfg)
47+ cfg.Config().Providers.Set(providerID, providerCfg)
4848 return &coordinator{
4949 cfg: cfg,
5050 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) {
7676 return p, nil
7777}
7878
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) {
8080 t, err := template.New(p.name).Parse(p.template)
8181 if err != nil {
8282 return "", fmt.Errorf("parsing template: %w", err)
8383 }
8484 var sb strings.Builder
85− d, err := p.promptData(ctx, provider, model, cfg)
85+ d, err := p.promptData(ctx, provider, model, store)
8686 if err != nil {
8787 return "", err
8888 }
@@ -104,11 +104,11 @@ func processFile(filePath string) *ContextFile {
104104 }
105105}
106106
107−func processContextPath(p string, cfg config.Config) []ContextFile {
107+func processContextPath(p string, store *config.ConfigStore) []ContextFile {
108108 var contexts []ContextFile
109109 fullPath := p
110110 if !filepath.IsAbs(p) {
111− fullPath = filepath.Join(cfg.WorkingDir(), p)
111+ fullPath = filepath.Join(store.WorkingDir(), p)
112112 }
113113 info, err := os.Stat(fullPath)
114114 if err != nil {
@@ -136,11 +136,11 @@ func processContextPath(p string, cfg config.Config) []ContextFile {
136136}
137137
138138// 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 {
140140 path = home.Long(path)
141141 // Handle environment variable expansion using the same pattern as config
142142 if strings.HasPrefix(path, "$") {
143− if expanded, err := cfg.Resolver().ResolveValue(path); err == nil {
143+ if expanded, err := store.Resolver().ResolveValue(path); err == nil {
144144 path = expanded
145145 }
146146 }
@@ -148,19 +148,20 @@ func expandPath(path string, cfg config.Config) string {
148148 return path
149149}
150150
151−func (p *Prompt) promptData(ctx context.Context, provider, model string, cfg config.Config) (PromptDat, error) {
152− workingDir := cmp.Or(p.workingDir, cfg.WorkingDir())
151+func (p *Prompt) promptData(ctx context.Context, provider, model string, store *config.ConfigStore) (PromptDat, error) {
152+ workingDir := cmp.Or(p.workingDir, store.WorkingDir())
153153 platform := cmp.Or(p.platform, runtime.GOOS)
154154
155155 files := map[string][]ContextFile{}
156156
157+ cfg := store.Config()
157158 for _, pth := range cfg.Options.ContextPaths {
158− expanded := expandPath(pth, cfg)
159+ expanded := expandPath(pth, store)
159160 pathKey := strings.ToLower(expanded)
160161 if _, ok := files[pathKey]; ok {
161162 continue
162163 }
163− content := processContextPath(expanded, cfg)
164+ content := processContextPath(expanded, store)
164165 files[pathKey] = content
165166 }
166167
@@ -169,18 +170,18 @@ func (p *Prompt) promptData(ctx context.Context, provider, model string, cfg con
169170 if len(cfg.Options.SkillsPaths) > 0 {
170171 expandedPaths := make([]string, 0, len(cfg.Options.SkillsPaths))
171172 for _, pth := range cfg.Options.SkillsPaths {
172− expandedPaths = append(expandedPaths, expandPath(pth, cfg))
173+ expandedPaths = append(expandedPaths, expandPath(pth, store))
173174 }
174175 if discoveredSkills := skills.Discover(expandedPaths); len(discoveredSkills) > 0 {
175176 availSkillXML = skills.ToPromptXML(discoveredSkills)
176177 }
177178 }
178179
179− isGit := isGitRepo(cfg.WorkingDir())
180+ isGit := isGitRepo(store.WorkingDir())
180181 data := PromptDat{
181182 Provider: provider,
182183 Model: model,
183− Config: cfg,
184+ Config: *cfg,
184185 WorkingDir: filepath.ToSlash(workingDir),
185186 IsGitRepo: isGit,
186187 Platform: platform,
@@ -189,7 +190,7 @@ func (p *Prompt) promptData(ctx context.Context, provider, model string, cfg con
189190 }
190191 if isGit {
191192 var err error
192− data.GitStatus, err = getGitStatus(ctx, cfg.WorkingDir())
193+ data.GitStatus, err = getGitStatus(ctx, store.WorkingDir())
193194 if err != nil {
194195 return PromptDat{}, err
195196 }
internal/agent/prompts.go+1−1
@@ -33,7 +33,7 @@ func taskPrompt(opts ...prompt.Option) (*prompt.Prompt, error) {
3333 return systemPrompt, nil
3434}
3535
36−func InitializePrompt(cfg config.Config) (string, error) {
36+func InitializePrompt(cfg *config.ConfigStore) (string, error) {
3737 systemPrompt, err := prompt.NewPrompt("initialize", string(initializePromptTmpl))
3838 if err != nil {
3939 return "", err
internal/agent/tools/list_mcp_resources.go+1−1
@@ -28,7 +28,7 @@ const ListMCPResourcesToolName = "list_mcp_resources"
2828//go:embed list_mcp_resources.md
2929var listMCPResourcesDescription []byte
3030
31−func NewListMCPResourcesTool(cfg *config.Config, permissions permission.Service) fantasy.AgentTool {
31+func NewListMCPResourcesTool(cfg *config.ConfigStore, permissions permission.Service) fantasy.AgentTool {
3232 return fantasy.NewParallelAgentTool(
3333 ListMCPResourcesToolName,
3434 string(listMCPResourcesDescription),
internal/agent/tools/mcp-tools.go+2−2
@@ -11,7 +11,7 @@ import (
1111)
1212
1313// 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 {
1515 var result []*Tool
1616 for mcpName, tools := range mcp.Tools() {
1717 for _, tool := range tools {
@@ -31,7 +31,7 @@ func GetMCPTools(permissions permission.Service, cfg *config.Config, wd string)
3131type Tool struct {
3232 mcpName string
3333 tool *mcp.Tool
34− cfg *config.Config
34+ cfg *config.ConfigStore
3535 permissions permission.Service
3636 workingDir string
3737 providerOptions fantasy.ProviderOptions
internal/agent/tools/mcp/init.go+4−4
@@ -163,11 +163,11 @@ func Close(ctx context.Context) error {
163163}
164164
165165// 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) {
167167 slog.Info("Initializing MCP clients")
168168 var wg sync.WaitGroup
169169 // Initialize states for all configured MCPs
170− for name, m := range cfg.MCP {
170+ for name, m := range cfg.Config().MCP {
171171 if m.Disabled {
172172 updateState(name, StateDisabled, nil, nil, Counts{})
173173 slog.Debug("Skipping disabled MCP", "name", name)
@@ -253,13 +253,13 @@ func WaitForInit(ctx context.Context) error {
253253 }
254254}
255255
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) {
257257 sess, ok := sessions.Get(name)
258258 if !ok {
259259 return nil, fmt.Errorf("mcp '%s' not available", name)
260260 }
261261
262− m := cfg.MCP[name]
262+ m := cfg.Config().MCP[name]
263263 state, _ := states.Get(name)
264264
265265 timeout := mcpTimeout(m)
internal/agent/tools/mcp/prompts.go+1−1
@@ -20,7 +20,7 @@ func Prompts() iter.Seq2[string, []*Prompt] {
2020}
2121
2222// 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) {
2424 c, err := getOrRenewClient(ctx, cfg, clientName)
2525 if err != nil {
2626 return nil, err
internal/agent/tools/mcp/resources.go+2−2
@@ -24,7 +24,7 @@ func Resources() iter.Seq2[string, []*Resource] {
2424}
2525
2626// 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) {
2828 session, err := getOrRenewClient(ctx, cfg, name)
2929 if err != nil {
3030 return nil, err
@@ -43,7 +43,7 @@ func ListResources(ctx context.Context, cfg *config.Config, name string) ([]*Res
4343}
4444
4545// 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) {
4747 session, err := getOrRenewClient(ctx, cfg, name)
4848 if err != nil {
4949 return nil, err
internal/agent/tools/mcp/tools.go+5−5
@@ -32,7 +32,7 @@ func Tools() iter.Seq2[string, []*Tool] {
3232}
3333
3434// 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) {
3636 var args map[string]any
3737 if err := json.Unmarshal([]byte(input), &args); err != nil {
3838 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
108108
109109// RefreshTools gets the updated list of tools from the MCP and updates the
110110// global state.
111−func RefreshTools(ctx context.Context, cfg *config.Config, name string) {
111+func RefreshTools(ctx context.Context, cfg *config.ConfigStore, name string) {
112112 session, ok := sessions.Get(name)
113113 if !ok {
114114 slog.Warn("Refresh tools: no session", "name", name)
@@ -139,7 +139,7 @@ func getTools(ctx context.Context, session *ClientSession) ([]*Tool, error) {
139139 return result.Tools, nil
140140}
141141
142−func updateTools(cfg *config.Config, name string, tools []*Tool) int {
142+func updateTools(cfg *config.ConfigStore, name string, tools []*Tool) int {
143143 tools = filterDisabledTools(cfg, name, tools)
144144 if len(tools) == 0 {
145145 allTools.Del(name)
@@ -150,8 +150,8 @@ func updateTools(cfg *config.Config, name string, tools []*Tool) int {
150150}
151151
152152// filterDisabledTools removes tools that are disabled via config.
153−func filterDisabledTools(cfg *config.Config, mcpName string, tools []*Tool) []*Tool {
154− mcpCfg, ok := cfg.MCP[mcpName]
153+func filterDisabledTools(cfg *config.ConfigStore, mcpName string, tools []*Tool) []*Tool {
154+ mcpCfg, ok := cfg.Config().MCP[mcpName]
155155 if !ok || len(mcpCfg.DisabledTools) == 0 {
156156 return tools
157157 }
internal/agent/tools/read_mcp_resource.go+1−1
@@ -30,7 +30,7 @@ const ReadMCPResourceToolName = "read_mcp_resource"
3030//go:embed read_mcp_resource.md
3131var readMCPResourceDescription []byte
3232
33−func NewReadMCPResourceTool(cfg *config.Config, permissions permission.Service) fantasy.AgentTool {
33+func NewReadMCPResourceTool(cfg *config.ConfigStore, permissions permission.Service) fantasy.AgentTool {
3434 return fantasy.NewParallelAgentTool(
3535 ReadMCPResourceToolName,
3636 string(readMCPResourceDescription),
internal/app/app.go+36−20
@@ -19,6 +19,7 @@ import (
1919 "charm.land/fantasy"
2020 "charm.land/lipgloss/v2"
2121 "github.com/charmbracelet/crush/internal/agent"
22+ "github.com/charmbracelet/crush/internal/agent/notify"
2223 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
2324 "github.com/charmbracelet/crush/internal/config"
2425 "github.com/charmbracelet/crush/internal/db"
@@ -60,7 +61,7 @@ type App struct {
6061
6162 LSPManager *lsp.Manager
6263
63− config *config.Config
64+ config *config.ConfigStore
6465
6566 serviceEventsWG *sync.WaitGroup
6667 eventsCtx context.Context
@@ -68,16 +69,18 @@ type App struct {
6869 tuiWG *sync.WaitGroup
6970
7071 // global context and cleanup functions
71− globalCtx context.Context
72− cleanupFuncs []func(context.Context) error
72+ globalCtx context.Context
73+ cleanupFuncs []func(context.Context) error
74+ agentNotifications *pubsub.Broker[notify.Notification]
7375}
7476
7577// 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) {
7779 q := db.New(conn)
7880 sessions := session.NewService(q, conn)
7981 messages := message.NewService(q)
8082 files := history.NewService(q, conn)
83+ cfg := store.Config()
8184 skipPermissionsRequests := cfg.Permissions != nil && cfg.Permissions.SkipRequests
8285 var allowedTools []string
8386 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) {
8891 Sessions: sessions,
8992 Messages: messages,
9093 History: files,
91− Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests, allowedTools),
94+ Permissions: permission.NewPermissionService(store.WorkingDir(), skipPermissionsRequests, allowedTools),
9295 FileTracker: filetracker.NewService(q),
93− LSPManager: lsp.NewManager(cfg),
96+ LSPManager: lsp.NewManager(store),
9497
9598 globalCtx: ctx,
9699
97− config: cfg,
100+ config: store,
98101
99− events: make(chan tea.Msg, 100),
100− serviceEventsWG: &sync.WaitGroup{},
101− tuiWG: &sync.WaitGroup{},
102+ events: make(chan tea.Msg, 100),
103+ serviceEventsWG: &sync.WaitGroup{},
104+ tuiWG: &sync.WaitGroup{},
105+ agentNotifications: pubsub.NewBroker[notify.Notification](),
102106 }
103107
104108 app.setupEvents()
@@ -106,13 +110,13 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
106110 // Check for updates in the background.
107111 go app.checkForUpdates(ctx)
108112
109− go mcp.Initialize(ctx, app.Permissions, cfg)
113+ go mcp.Initialize(ctx, app.Permissions, store)
110114
111115 // cleanup database upon app shutdown
112116 app.cleanupFuncs = append(
113117 app.cleanupFuncs,
114118 func(context.Context) error { return conn.Close() },
115− mcp.Close,
119+ func(ctx context.Context) error { return mcp.Close(ctx) },
116120 )
117121
118122 // 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) {
138142 return app, nil
139143}
140144
141−// Config returns the application configuration.
145+// Config returns the pure-data configuration.
142146func (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 {
143152 return app.config
144153}
145154
@@ -157,6 +166,11 @@ func (app *App) SendEvent(msg tea.Msg) {
157166 }
158167}
159168
169+// AgentNotifications returns the broker for agent notification events.
170+func (app *App) AgentNotifications() *pubsub.Broker[notify.Notification] {
171+ return app.agentNotifications
172+}
173+
160174// RunNonInteractive runs the application in non-interactive mode with the
161175// given prompt, printing to stdout.
162176func (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,
184198 }
185199 stderrTTY = term.IsTerminal(os.Stderr.Fd())
186200 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
188202
189203 if !hideSpinner && stderrTTY {
190204 t := styles.DefaultStyles()
@@ -337,7 +351,7 @@ func (app *App) UpdateAgentModel(ctx context.Context) error {
337351// If largeModel is provided but smallModel is not, the small model defaults to
338352// the provider's default small model.
339353func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel, smallModel string) error {
340− providers := app.config.Providers.Copy()
354+ providers := app.config.Config().Providers.Copy()
341355
342356 largeMatches, smallMatches, err := findModels(providers, largeModel, smallModel)
343357 if err != nil {
@@ -354,7 +368,7 @@ func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel,
354368 }
355369 largeProviderID = found.provider
356370 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{
358372 Provider: found.provider,
359373 Model: found.modelID,
360374 }
@@ -368,7 +382,7 @@ func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel,
368382 return err
369383 }
370384 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{
372386 Provider: found.provider,
373387 Model: found.modelID,
374388 }
@@ -376,7 +390,7 @@ func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel,
376390 case largeModel != "":
377391 // No small model specified, but large model was - use provider's default.
378392 smallCfg := app.GetDefaultSmallModel(largeProviderID)
379− app.config.Models[config.SelectedModelTypeSmall] = smallCfg
393+ app.config.Config().Models[config.SelectedModelTypeSmall] = smallCfg
380394 }
381395
382396 return app.AgentCoordinator.UpdateModels(ctx)
@@ -385,7 +399,7 @@ func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel,
385399// GetDefaultSmallModel returns the default small model for the given
386400// provider. Falls back to the large model if no default is found.
387401func (app *App) GetDefaultSmallModel(providerID string) config.SelectedModel {
388− cfg := app.config
402+ cfg := app.config.Config()
389403 largeModelCfg := cfg.Models[config.SelectedModelTypeLarge]
390404
391405 // Find the provider in the known providers list to get its default small model.
@@ -428,6 +442,7 @@ func (app *App) setupEvents() {
428442 setupSubscriber(ctx, app.serviceEventsWG, "permissions", app.Permissions.Subscribe, app.events)
429443 setupSubscriber(ctx, app.serviceEventsWG, "permissions-notifications", app.Permissions.SubscribeNotifications, app.events)
430444 setupSubscriber(ctx, app.serviceEventsWG, "history", app.History.Subscribe, app.events)
445+ setupSubscriber(ctx, app.serviceEventsWG, "agent-notifications", app.agentNotifications.Subscribe, app.events)
431446 setupSubscriber(ctx, app.serviceEventsWG, "mcp", mcp.SubscribeEvents, app.events)
432447 setupSubscriber(ctx, app.serviceEventsWG, "lsp", SubscribeLSPEvents, app.events)
433448 cleanupFunc := func(context.Context) error {
@@ -486,7 +501,7 @@ func setupSubscriber[T any](
486501}
487502
488503func (app *App) InitCoderAgent(ctx context.Context) error {
489− coderAgentCfg := app.config.Agents[config.AgentCoder]
504+ coderAgentCfg := app.config.Config().Agents[config.AgentCoder]
490505 if coderAgentCfg.ID == "" {
491506 return fmt.Errorf("coder agent configuration is missing")
492507 }
@@ -500,6 +515,7 @@ func (app *App) InitCoderAgent(ctx context.Context) error {
500515 app.History,
501516 app.FileTracker,
502517 app.LSPManager,
518+ app.agentNotifications,
503519 )
504520 if err != nil {
505521 slog.Error("Failed to create coder agent", "err", err)
internal/backend/backend.go+18−17
@@ -38,7 +38,7 @@ type ShutdownFunc func()
3838// server. It manages workspaces and delegates to [app.App] services.
3939type Backend struct {
4040 workspaces *csync.Map[string, *Workspace]
41− cfg *config.Config
41+ cfg *config.ConfigStore
4242 ctx context.Context
4343 shutdownFn ShutdownFunc
4444}
@@ -49,12 +49,12 @@ type Workspace struct {
4949 *app.App
5050 ID string
5151 Path string
52− Cfg *config.Config
52+ Cfg *config.ConfigStore
5353 Env []string
5454}
5555
5656// 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 {
5858 return &Backend{
5959 workspaces: csync.NewMap[string, *Workspace](),
6060 cfg: cfg,
@@ -95,16 +95,16 @@ func (b *Backend) CreateWorkspace(args proto.Workspace) (*Workspace, proto.Works
9595 return nil, proto.Workspace{}, fmt.Errorf("failed to initialize config: %w", err)
9696 }
9797
98− if cfg.Permissions == nil {
99− cfg.Permissions = &config.Permissions{}
98+ if cfg.Config().Permissions == nil {
99+ cfg.Config().Permissions = &config.Permissions{}
100100 }
101− cfg.Permissions.SkipRequests = args.YOLO
101+ cfg.Config().Permissions.SkipRequests = args.YOLO
102102
103− if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
103+ if err := createDotCrushDir(cfg.Config().Options.DataDirectory); err != nil {
104104 return nil, proto.Workspace{}, fmt.Errorf("failed to create data directory: %w", err)
105105 }
106106
107− conn, err := db.Connect(b.ctx, cfg.Options.DataDirectory)
107+ conn, err := db.Connect(b.ctx, cfg.Config().Options.DataDirectory)
108108 if err != nil {
109109 return nil, proto.Workspace{}, fmt.Errorf("failed to connect to database: %w", err)
110110 }
@@ -138,10 +138,10 @@ func (b *Backend) CreateWorkspace(args proto.Workspace) (*Workspace, proto.Works
138138 result := proto.Workspace{
139139 ID: id,
140140 Path: args.Path,
141− DataDir: cfg.Options.DataDirectory,
142− Debug: cfg.Options.Debug,
143− YOLO: cfg.Permissions.SkipRequests,
144− Config: cfg,
141+ DataDir: cfg.Config().Options.DataDirectory,
142+ Debug: cfg.Config().Options.Debug,
143+ YOLO: cfg.Config().Permissions.SkipRequests,
144+ Config: cfg.Config(),
145145 Env: args.Env,
146146 }
147147
@@ -183,7 +183,7 @@ func (b *Backend) VersionInfo() proto.VersionInfo {
183183}
184184
185185// Config returns the server-level configuration.
186−func (b *Backend) Config() *config.Config {
186+func (b *Backend) Config() *config.ConfigStore {
187187 return b.cfg
188188}
189189
@@ -195,12 +195,13 @@ func (b *Backend) Shutdown() {
195195}
196196
197197func workspaceToProto(ws *Workspace) proto.Workspace {
198+ cfg := ws.Cfg.Config()
198199 return proto.Workspace{
199200 ID: ws.ID,
200201 Path: ws.Path,
201− YOLO: ws.Cfg.Permissions != nil && ws.Cfg.Permissions.SkipRequests,
202− DataDir: ws.Cfg.Options.DataDirectory,
203− Debug: ws.Cfg.Options.Debug,
204− Config: ws.Cfg,
202+ YOLO: cfg.Permissions != nil && cfg.Permissions.SkipRequests,
203+ DataDir: cfg.Options.DataDirectory,
204+ Debug: cfg.Options.Debug,
205+ Config: cfg,
205206 }
206207}
internal/backend/events.go+2−2
@@ -51,7 +51,7 @@ func (b *Backend) GetWorkspaceConfig(workspaceID string) (*config.Config, error)
5151 return nil, err
5252 }
5353
54− return ws.Cfg, nil
54+ return ws.Cfg.Config(), nil
5555}
5656
5757// GetWorkspaceProviders returns the configured providers for a
@@ -62,6 +62,6 @@ func (b *Backend) GetWorkspaceProviders(workspaceID string) (any, error) {
6262 return nil, err
6363 }
6464
65− providers, _ := config.Providers(ws.Cfg)
65+ providers, _ := config.Providers(ws.Cfg.Config())
6666 return providers, nil
6767}
internal/cmd/login.go+9−9
@@ -52,16 +52,16 @@ crush login copilot
5252 }
5353 switch provider {
5454 case "hyper":
55− return loginHyper(app.Config())
55+ return loginHyper(app.Store())
5656 case "copilot", "github", "github-copilot":
57− return loginCopilot(app.Config())
57+ return loginCopilot(app.Store())
5858 default:
5959 return fmt.Errorf("unknown platform: %s", args[0])
6060 }
6161 },
6262}
6363
64−func loginHyper(cfg *config.Config) error {
64+func loginHyper(cfg *config.ConfigStore) error {
6565 if !hyperp.Enabled() {
6666 return fmt.Errorf("hyper not enabled")
6767 }
@@ -112,8 +112,8 @@ func loginHyper(cfg *config.Config) error {
112112 }
113113
114114 if err := cmp.Or(
115− cfg.SetConfigField("providers.hyper.api_key", token.AccessToken),
116− cfg.SetConfigField("providers.hyper.oauth", token),
115+ cfg.SetConfigField(config.ScopeGlobal, "providers.hyper.api_key", token.AccessToken),
116+ cfg.SetConfigField(config.ScopeGlobal, "providers.hyper.oauth", token),
117117 ); err != nil {
118118 return err
119119 }
@@ -123,10 +123,10 @@ func loginHyper(cfg *config.Config) error {
123123 return nil
124124}
125125
126−func loginCopilot(cfg *config.Config) error {
126+func loginCopilot(cfg *config.ConfigStore) error {
127127 ctx := getLoginContext()
128128
129− if cfg.HasConfigField("providers.copilot.oauth") {
129+ if cfg.HasConfigField(config.ScopeGlobal, "providers.copilot.oauth") {
130130 fmt.Println("You are already logged in to GitHub Copilot.")
131131 return nil
132132 }
@@ -177,8 +177,8 @@ func loginCopilot(cfg *config.Config) error {
177177 }
178178
179179 if err := cmp.Or(
180− cfg.SetConfigField("providers.copilot.api_key", token.AccessToken),
181− cfg.SetConfigField("providers.copilot.oauth", token),
180+ cfg.SetConfigField(config.ScopeGlobal, "providers.copilot.api_key", token.AccessToken),
181+ cfg.SetConfigField(config.ScopeGlobal, "providers.copilot.oauth", token),
182182 ); err != nil {
183183 return err
184184 }
internal/cmd/logs.go+1−1
@@ -55,7 +55,7 @@ var logsCmd = &cobra.Command{
5555 if err != nil {
5656 return fmt.Errorf("failed to load configuration: %v", err)
5757 }
58− logsFile := filepath.Join(cfg.Options.DataDirectory, "logs", "crush.log")
58+ logsFile := filepath.Join(cfg.Config().Options.DataDirectory, "logs", "crush.log")
5959 _, err = os.Stat(logsFile)
6060 if os.IsNotExist(err) {
6161 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`,
3838 return err
3939 }
4040
41− if !cfg.IsConfigured() {
41+ if !cfg.Config().IsConfigured() {
4242 return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively")
4343 }
4444
@@ -55,7 +55,7 @@ crush models gpt5`,
5555 var providerIDs []string
5656 providerModels := make(map[string][]string)
5757
58− for providerID, provider := range cfg.Providers.Seq2() {
58+ for providerID, provider := range cfg.Config().Providers.Seq2() {
5959 if provider.Disable {
6060 continue
6161 }
internal/cmd/root.go+3−2
@@ -235,11 +235,12 @@ func setupApp(cmd *cobra.Command) (*app.App, error) {
235235 return nil, err
236236 }
237237
238− cfg, err := config.Init(cwd, dataDir, debug)
238+ store, err := config.Init(cwd, dataDir, debug)
239239 if err != nil {
240240 return nil, err
241241 }
242242
243+ cfg := store.Config()
243244 if cfg.Permissions == nil {
244245 cfg.Permissions = &config.Permissions{}
245246 }
@@ -261,7 +262,7 @@ func setupApp(cmd *cobra.Command) (*app.App, error) {
261262 return nil, err
262263 }
263264
264− appInstance, err := app.New(ctx, conn, cfg)
265+ appInstance, err := app.New(ctx, conn, store)
265266 if err != nil {
266267 slog.Error("Failed to create app instance", "error", err)
267268 return nil, err
internal/cmd/stats.go+1−1
@@ -131,7 +131,7 @@ func runStats(cmd *cobra.Command, _ []string) error {
131131 if err != nil {
132132 return fmt.Errorf("failed to initialize config: %w", err)
133133 }
134− dataDir = cfg.Options.DataDirectory
134+ dataDir = cfg.Config().Options.DataDirectory
135135 }
136136
137137 conn, err := db.Connect(ctx, dataDir)
internal/commands/commands.go+1−1
@@ -227,7 +227,7 @@ func isMarkdownFile(name string) bool {
227227 return strings.HasSuffix(strings.ToLower(name), ".md")
228228}
229229
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) {
231231 // TODO: we should pass the context down
232232 result, err := mcp.GetPromptMessages(context.Background(), cfg, clientID, promptID, args)
233233 if err != nil {
internal/config/config.go+1−248
@@ -8,22 +8,16 @@ import (
88 "maps"
99 "net/http"
1010 "net/url"
11− "os"
12− "path/filepath"
1311 "slices"
1412 "strings"
1513 "time"
1614
1715 "charm.land/catwalk/pkg/catwalk"
18− hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
1916 "github.com/charmbracelet/crush/internal/csync"
2017 "github.com/charmbracelet/crush/internal/env"
2118 "github.com/charmbracelet/crush/internal/oauth"
2219 "github.com/charmbracelet/crush/internal/oauth/copilot"
23− "github.com/charmbracelet/crush/internal/oauth/hyper"
2420 "github.com/invopop/jsonschema"
25− "github.com/tidwall/gjson"
26− "github.com/tidwall/sjson"
2721)
2822
2923const (
@@ -261,6 +255,7 @@ type Options struct {
261255 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"`
262256 AutoLSP *bool `json:"auto_lsp,omitempty" jsonschema:"description=Automatically setup LSPs based on root markers,default=true"`
263257 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"`
264259}
265260
266261type MCPs map[string]MCPConfig
@@ -397,17 +392,6 @@ type Config struct {
397392 Tools Tools `json:"tools,omitzero" jsonschema:"description=Tool configurations"`
398393
399394 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
411395}
412396
413397func (c *Config) EnabledProviders() []ProviderConfig {
@@ -471,235 +455,8 @@ func (c *Config) SmallModel() *catwalk.Model {
471455 return c.GetModel(model.Provider, model.Model)
472456}
473457
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−
660458const maxRecentModelsPerType = 5
661459
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−
703460func allToolNames() []string {
704461 return []string{
705462 "agent",
@@ -779,10 +536,6 @@ func (c *Config) SetupAgents() {
779536 c.Agents = agents
780537}
781538
782−func (c *Config) Resolver() VariableResolver {
783− return c.resolver
784−}
785−
786539func (c *ProviderConfig) TestConnection(resolver VariableResolver) error {
787540 var (
788541 providerID = catwalk.InferenceProvider(c.ID)
internal/config/copilot.go−47
@@ -1,48 +1 @@
11package 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 {
1818 Initialized bool `json:"initialized"`
1919}
2020
21−func Init(workingDir, dataDir string, debug bool) (*Config, error) {
22− cfg, err := Load(workingDir, dataDir, debug)
21+func Init(workingDir, dataDir string, debug bool) (*ConfigStore, error) {
22+ store, err := Load(workingDir, dataDir, debug)
2323 if err != nil {
2424 return nil, err
2525 }
26− return cfg, nil
26+ return store, nil
2727}
2828
29−func ProjectNeedsInitialization(cfg *Config) (bool, error) {
30− if cfg == nil {
29+func ProjectNeedsInitialization(store *ConfigStore) (bool, error) {
30+ if store == nil {
3131 return false, fmt.Errorf("config not loaded")
3232 }
3333
34+ cfg := store.Config()
3435 flagFilePath := filepath.Join(cfg.Options.DataDirectory, InitFlagFilename)
3536
3637 _, err := os.Stat(flagFilePath)
@@ -42,7 +43,7 @@ func ProjectNeedsInitialization(cfg *Config) (bool, error) {
4243 return false, fmt.Errorf("failed to check init flag file: %w", err)
4344 }
4445
45− someContextFileExists, err := contextPathsExist(cfg.WorkingDir())
46+ someContextFileExists, err := contextPathsExist(store.WorkingDir())
4647 if err != nil {
4748 return false, fmt.Errorf("failed to check for context files: %w", err)
4849 }
@@ -51,7 +52,7 @@ func ProjectNeedsInitialization(cfg *Config) (bool, error) {
5152 }
5253
5354 // If the working directory has no non-ignored files, skip initialization step
54− empty, err := dirHasNoVisibleFiles(cfg.WorkingDir())
55+ empty, err := dirHasNoVisibleFiles(store.WorkingDir())
5556 if err != nil {
5657 return false, fmt.Errorf("failed to check if directory is empty: %w", err)
5758 }
@@ -90,7 +91,7 @@ func contextPathsExist(dir string) (bool, error) {
9091 return false, nil
9192}
9293
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.
9495func dirHasNoVisibleFiles(dir string) (bool, error) {
9596 files, _, err := fsext.ListDirectory(dir, nil, 1, 1)
9697 if err != nil {
@@ -99,11 +100,11 @@ func dirHasNoVisibleFiles(dir string) (bool, error) {
99100 return len(files) == 0, nil
100101}
101102
102−func MarkProjectInitialized(cfg *Config) error {
103− if cfg == nil {
103+func MarkProjectInitialized(store *ConfigStore) error {
104+ if store == nil {
104105 return fmt.Errorf("config not loaded")
105106 }
106− flagFilePath := filepath.Join(cfg.Options.DataDirectory, InitFlagFilename)
107+ flagFilePath := filepath.Join(store.Config().Options.DataDirectory, InitFlagFilename)
107108
108109 file, err := os.Create(flagFilePath)
109110 if err != nil {
@@ -114,13 +115,13 @@ func MarkProjectInitialized(cfg *Config) error {
114115 return nil
115116}
116117
117−func HasInitialDataConfig(cfg *Config) bool {
118− if cfg == nil {
118+func HasInitialDataConfig(store *ConfigStore) bool {
119+ if store == nil {
119120 return false
120121 }
121122 cfgPath := GlobalConfigData()
122123 if _, err := os.Stat(cfgPath); err != nil {
123124 return false
124125 }
125− return cfg.IsConfigured()
126+ return store.Config().IsConfigured()
126127}
internal/config/load.go+45−17
@@ -29,8 +29,9 @@ import (
2929
3030const defaultCatwalkURL = "https://catwalk.charm.sh"
3131
32−// Load loads the configuration from the default paths.
33−func Load(workingDir, dataDir string, debug bool) (*Config, error) {
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.
34+func Load(workingDir, dataDir string, debug bool) (*ConfigStore, error) {
3435 configPaths := lookupConfigs(workingDir)
3536
3637 cfg, err := loadFromConfigPaths(configPaths)
@@ -38,10 +39,15 @@ func Load(workingDir, dataDir string, debug bool) (*Config, error) {
3839 return nil, fmt.Errorf("failed to load config from paths %v: %w", configPaths, err)
3940 }
4041
41− cfg.dataConfigDir = GlobalConfigData()
42−
4342 cfg.setDefaults(workingDir, dataDir)
4443
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+
4551 if debug {
4652 cfg.Options.Debug = true
4753 }
@@ -52,6 +58,18 @@ func Load(workingDir, dataDir string, debug bool) (*Config, error) {
5258 cfg.Options.Debug,
5359 )
5460
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+
5573 if !isInsideWorktree() {
5674 const depth = 2
5775 const items = 100
@@ -72,26 +90,36 @@ func Load(workingDir, dataDir string, debug bool) (*Config, error) {
7290 if err != nil {
7391 return nil, err
7492 }
75− cfg.knownProviders = providers
93+ store.knownProviders = providers
7694
7795 env := env.New()
7896 // Configure providers
7997 valueResolver := NewShellVariableResolver(env)
80− cfg.resolver = valueResolver
81− if err := cfg.configureProviders(env, valueResolver, cfg.knownProviders); err != nil {
98+ store.resolver = valueResolver
99+ if err := cfg.configureProviders(store, env, valueResolver, store.knownProviders); err != nil {
82100 return nil, fmt.Errorf("failed to configure providers: %w", err)
83101 }
84102
85103 if !cfg.IsConfigured() {
86104 slog.Warn("No providers configured")
87− return cfg, nil
105+ return store, nil
88106 }
89107
90− if err := cfg.configureSelectedModels(cfg.knownProviders); err != nil {
108+ if err := configureSelectedModels(store, store.knownProviders); err != nil {
91109 return nil, fmt.Errorf("failed to configure selected models: %w", err)
92110 }
93− cfg.SetupAgents()
94− return cfg, nil
111+ store.SetupAgents()
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
95123}
96124
97125func PushPopCrushEnv() func() {
@@ -122,7 +150,7 @@ func PushPopCrushEnv() func() {
122150 return restore
123151}
124152
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 {
126154 knownProviderNames := make(map[string]bool)
127155 restore := PushPopCrushEnv()
128156 defer restore()
@@ -209,7 +237,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
209237 switch {
210238 case p.ID == catwalk.InferenceProviderAnthropic && config.OAuthToken != nil:
211239 // Claude Code subscription is not supported anymore. Remove to show onboarding.
212− c.RemoveConfigField("providers.anthropic")
240+ store.RemoveConfigField(ScopeGlobal, "providers.anthropic")
213241 c.Providers.Del(string(p.ID))
214242 continue
215243 case p.ID == catwalk.InferenceProviderCopilot && config.OAuthToken != nil:
@@ -340,7 +368,6 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
340368}
341369
342370func (c *Config) setDefaults(workingDir, dataDir string) {
343− c.workingDir = workingDir
344371 if c.Options == nil {
345372 c.Options = &Options{}
346373 }
@@ -524,7 +551,8 @@ func (c *Config) defaultModelSelection(knownProviders []catwalk.Provider) (large
524551 return largeModel, smallModel, err
525552}
526553
527−func (c *Config) configureSelectedModels(knownProviders []catwalk.Provider) error {
554+func configureSelectedModels(store *ConfigStore, knownProviders []catwalk.Provider) error {
555+ c := store.config
528556 defaultLarge, defaultSmall, err := c.defaultModelSelection(knownProviders)
529557 if err != nil {
530558 return fmt.Errorf("failed to select default models: %w", err)
@@ -543,7 +571,7 @@ func (c *Config) configureSelectedModels(knownProviders []catwalk.Provider) erro
543571 if model == nil {
544572 large = defaultLarge
545573 // override the model type to large
546− err := c.UpdatePreferredModel(SelectedModelTypeLarge, large)
574+ err := store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeLarge, large)
547575 if err != nil {
548576 return fmt.Errorf("failed to update preferred large model: %w", err)
549577 }
@@ -587,7 +615,7 @@ func (c *Config) configureSelectedModels(knownProviders []catwalk.Provider) erro
587615 if model == nil {
588616 small = defaultSmall
589617 // override the model type to small
590− err := c.UpdatePreferredModel(SelectedModelTypeSmall, small)
618+ err := store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeSmall, small)
591619 if err != nil {
592620 return fmt.Errorf("failed to update preferred small model: %w", err)
593621 }
internal/config/load_test.go+44−40
@@ -36,6 +36,11 @@ func TestConfig_LoadFromBytes(t *testing.T) {
3636 require.Equal(t, "https://api.openai.com/v2", pc.BaseURL)
3737}
3838
39+// testStore wraps a Config in a minimal ConfigStore for testing.
40+func testStore(cfg *Config) *ConfigStore {
41+ return &ConfigStore{config: cfg}
42+}
43+
3944func TestConfig_setDefaults(t *testing.T) {
4045 cfg := &Config{}
4146
@@ -53,7 +58,6 @@ func TestConfig_setDefaults(t *testing.T) {
5358 for _, path := range defaultContextPaths {
5459 require.Contains(t, cfg.Options.ContextPaths, path)
5560 }
56− require.Equal(t, "/tmp", cfg.workingDir)
5761}
5862
5963func TestConfig_configureProviders(t *testing.T) {
@@ -74,7 +78,7 @@ func TestConfig_configureProviders(t *testing.T) {
7478 "OPENAI_API_KEY": "test-key",
7579 })
7680 resolver := NewEnvironmentVariableResolver(env)
77− err := cfg.configureProviders(env, resolver, knownProviders)
81+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
7882 require.NoError(t, err)
7983 require.Equal(t, 1, cfg.Providers.Len())
8084
@@ -117,7 +121,7 @@ func TestConfig_configureProvidersWithOverride(t *testing.T) {
117121 "OPENAI_API_KEY": "test-key",
118122 })
119123 resolver := NewEnvironmentVariableResolver(env)
120− err := cfg.configureProviders(env, resolver, knownProviders)
124+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
121125 require.NoError(t, err)
122126 require.Equal(t, 1, cfg.Providers.Len())
123127
@@ -159,7 +163,7 @@ func TestConfig_configureProvidersWithNewProvider(t *testing.T) {
159163 "OPENAI_API_KEY": "test-key",
160164 })
161165 resolver := NewEnvironmentVariableResolver(env)
162− err := cfg.configureProviders(env, resolver, knownProviders)
166+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
163167 require.NoError(t, err)
164168 // Should be to because of the env variable
165169 require.Equal(t, cfg.Providers.Len(), 2)
@@ -195,7 +199,7 @@ func TestConfig_configureProvidersBedrockWithCredentials(t *testing.T) {
195199 "AWS_SECRET_ACCESS_KEY": "test-secret-key",
196200 })
197201 resolver := NewEnvironmentVariableResolver(env)
198− err := cfg.configureProviders(env, resolver, knownProviders)
202+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
199203 require.NoError(t, err)
200204 require.Equal(t, cfg.Providers.Len(), 1)
201205
@@ -221,7 +225,7 @@ func TestConfig_configureProvidersBedrockWithoutCredentials(t *testing.T) {
221225 cfg.setDefaults("/tmp", "")
222226 env := env.NewFromMap(map[string]string{})
223227 resolver := NewEnvironmentVariableResolver(env)
224− err := cfg.configureProviders(env, resolver, knownProviders)
228+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
225229 require.NoError(t, err)
226230 // Provider should not be configured without credentials
227231 require.Equal(t, cfg.Providers.Len(), 0)
@@ -246,7 +250,7 @@ func TestConfig_configureProvidersBedrockWithoutUnsupportedModel(t *testing.T) {
246250 "AWS_SECRET_ACCESS_KEY": "test-secret-key",
247251 })
248252 resolver := NewEnvironmentVariableResolver(env)
249− err := cfg.configureProviders(env, resolver, knownProviders)
253+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
250254 require.Error(t, err)
251255}
252256
@@ -269,7 +273,7 @@ func TestConfig_configureProvidersVertexAIWithCredentials(t *testing.T) {
269273 "VERTEXAI_LOCATION": "us-central1",
270274 })
271275 resolver := NewEnvironmentVariableResolver(env)
272− err := cfg.configureProviders(env, resolver, knownProviders)
276+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
273277 require.NoError(t, err)
274278 require.Equal(t, cfg.Providers.Len(), 1)
275279
@@ -301,7 +305,7 @@ func TestConfig_configureProvidersVertexAIWithoutCredentials(t *testing.T) {
301305 "GOOGLE_CLOUD_LOCATION": "us-central1",
302306 })
303307 resolver := NewEnvironmentVariableResolver(env)
304− err := cfg.configureProviders(env, resolver, knownProviders)
308+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
305309 require.NoError(t, err)
306310 // Provider should not be configured without proper credentials
307311 require.Equal(t, cfg.Providers.Len(), 0)
@@ -326,7 +330,7 @@ func TestConfig_configureProvidersVertexAIMissingProject(t *testing.T) {
326330 "GOOGLE_CLOUD_LOCATION": "us-central1",
327331 })
328332 resolver := NewEnvironmentVariableResolver(env)
329− err := cfg.configureProviders(env, resolver, knownProviders)
333+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
330334 require.NoError(t, err)
331335 // Provider should not be configured without project
332336 require.Equal(t, cfg.Providers.Len(), 0)
@@ -350,7 +354,7 @@ func TestConfig_configureProvidersSetProviderID(t *testing.T) {
350354 "OPENAI_API_KEY": "test-key",
351355 })
352356 resolver := NewEnvironmentVariableResolver(env)
353− err := cfg.configureProviders(env, resolver, knownProviders)
357+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
354358 require.NoError(t, err)
355359 require.Equal(t, cfg.Providers.Len(), 1)
356360
@@ -541,7 +545,7 @@ func TestConfig_configureProvidersWithDisabledProvider(t *testing.T) {
541545 "OPENAI_API_KEY": "test-key",
542546 })
543547 resolver := NewEnvironmentVariableResolver(env)
544− err := cfg.configureProviders(env, resolver, knownProviders)
548+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
545549 require.NoError(t, err)
546550
547551 require.Equal(t, cfg.Providers.Len(), 1)
@@ -569,7 +573,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
569573
570574 env := env.NewFromMap(map[string]string{})
571575 resolver := NewEnvironmentVariableResolver(env)
572− err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
576+ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
573577 require.NoError(t, err)
574578
575579 require.Equal(t, cfg.Providers.Len(), 1)
@@ -592,7 +596,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
592596
593597 env := env.NewFromMap(map[string]string{})
594598 resolver := NewEnvironmentVariableResolver(env)
595− err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
599+ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
596600 require.NoError(t, err)
597601
598602 require.Equal(t, cfg.Providers.Len(), 0)
@@ -614,7 +618,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
614618
615619 env := env.NewFromMap(map[string]string{})
616620 resolver := NewEnvironmentVariableResolver(env)
617− err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
621+ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
618622 require.NoError(t, err)
619623
620624 require.Equal(t, cfg.Providers.Len(), 0)
@@ -639,7 +643,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
639643
640644 env := env.NewFromMap(map[string]string{})
641645 resolver := NewEnvironmentVariableResolver(env)
642− err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
646+ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
643647 require.NoError(t, err)
644648
645649 require.Equal(t, cfg.Providers.Len(), 0)
@@ -664,7 +668,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
664668
665669 env := env.NewFromMap(map[string]string{})
666670 resolver := NewEnvironmentVariableResolver(env)
667− err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
671+ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
668672 require.NoError(t, err)
669673
670674 require.Equal(t, cfg.Providers.Len(), 1)
@@ -692,7 +696,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
692696
693697 env := env.NewFromMap(map[string]string{})
694698 resolver := NewEnvironmentVariableResolver(env)
695− err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
699+ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
696700 require.NoError(t, err)
697701
698702 require.Equal(t, cfg.Providers.Len(), 1)
@@ -722,7 +726,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
722726
723727 env := env.NewFromMap(map[string]string{})
724728 resolver := NewEnvironmentVariableResolver(env)
725− err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
729+ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
726730 require.NoError(t, err)
727731
728732 require.Equal(t, cfg.Providers.Len(), 0)
@@ -757,7 +761,7 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
757761 "GOOGLE_GENAI_USE_VERTEXAI": "false",
758762 })
759763 resolver := NewEnvironmentVariableResolver(env)
760− err := cfg.configureProviders(env, resolver, knownProviders)
764+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
761765 require.NoError(t, err)
762766
763767 require.Equal(t, cfg.Providers.Len(), 0)
@@ -788,7 +792,7 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
788792
789793 env := env.NewFromMap(map[string]string{})
790794 resolver := NewEnvironmentVariableResolver(env)
791− err := cfg.configureProviders(env, resolver, knownProviders)
795+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
792796 require.NoError(t, err)
793797
794798 require.Equal(t, cfg.Providers.Len(), 0)
@@ -819,7 +823,7 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
819823
820824 env := env.NewFromMap(map[string]string{})
821825 resolver := NewEnvironmentVariableResolver(env)
822− err := cfg.configureProviders(env, resolver, knownProviders)
826+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
823827 require.NoError(t, err)
824828
825829 require.Equal(t, cfg.Providers.Len(), 0)
@@ -852,7 +856,7 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
852856 "OPENAI_API_KEY": "test-key",
853857 })
854858 resolver := NewEnvironmentVariableResolver(env)
855− err := cfg.configureProviders(env, resolver, knownProviders)
859+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
856860 require.NoError(t, err)
857861
858862 require.Equal(t, cfg.Providers.Len(), 1)
@@ -886,7 +890,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
886890 cfg.setDefaults("/tmp", "")
887891 env := env.NewFromMap(map[string]string{})
888892 resolver := NewEnvironmentVariableResolver(env)
889− err := cfg.configureProviders(env, resolver, knownProviders)
893+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
890894 require.NoError(t, err)
891895
892896 large, small, err := cfg.defaultModelSelection(knownProviders)
@@ -922,7 +926,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
922926 cfg.setDefaults("/tmp", "")
923927 env := env.NewFromMap(map[string]string{})
924928 resolver := NewEnvironmentVariableResolver(env)
925− err := cfg.configureProviders(env, resolver, knownProviders)
929+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
926930 require.NoError(t, err)
927931
928932 _, _, err = cfg.defaultModelSelection(knownProviders)
@@ -952,7 +956,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
952956 cfg.setDefaults("/tmp", "")
953957 env := env.NewFromMap(map[string]string{})
954958 resolver := NewEnvironmentVariableResolver(env)
955− err := cfg.configureProviders(env, resolver, knownProviders)
959+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
956960 require.NoError(t, err)
957961 _, _, err = cfg.defaultModelSelection(knownProviders)
958962 require.Error(t, err)
@@ -995,7 +999,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
995999 cfg.setDefaults("/tmp", "")
9961000 env := env.NewFromMap(map[string]string{})
9971001 resolver := NewEnvironmentVariableResolver(env)
998− err := cfg.configureProviders(env, resolver, knownProviders)
1002+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
9991003 require.NoError(t, err)
10001004 large, small, err := cfg.defaultModelSelection(knownProviders)
10011005 require.NoError(t, err)
@@ -1039,7 +1043,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
10391043 cfg.setDefaults("/tmp", "")
10401044 env := env.NewFromMap(map[string]string{})
10411045 resolver := NewEnvironmentVariableResolver(env)
1042− err := cfg.configureProviders(env, resolver, knownProviders)
1046+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
10431047 require.NoError(t, err)
10441048 _, _, err = cfg.defaultModelSelection(knownProviders)
10451049 require.Error(t, err)
@@ -1081,7 +1085,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
10811085 cfg.setDefaults("/tmp", "")
10821086 env := env.NewFromMap(map[string]string{})
10831087 resolver := NewEnvironmentVariableResolver(env)
1084− err := cfg.configureProviders(env, resolver, knownProviders)
1088+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
10851089 require.NoError(t, err)
10861090 large, small, err := cfg.defaultModelSelection(knownProviders)
10871091 require.NoError(t, err)
@@ -1126,7 +1130,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) {
11261130 "OPENAI_API_KEY": "test-key",
11271131 })
11281132 resolver := NewEnvironmentVariableResolver(env)
1129− err := cfg.configureProviders(env, resolver, knownProviders)
1133+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
11301134 require.ErrorContains(t, err, "no custom providers")
11311135
11321136 // openai should NOT be present because it lacks base_url and models.
@@ -1169,7 +1173,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) {
11691173 "OPENAI_API_KEY": "test-key",
11701174 })
11711175 resolver := NewEnvironmentVariableResolver(env)
1172− err := cfg.configureProviders(env, resolver, knownProviders)
1176+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
11731177 require.NoError(t, err)
11741178
11751179 // Only fully specified provider should be present.
@@ -1223,7 +1227,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) {
12231227 "ANTHROPIC_API_KEY": "test-key",
12241228 })
12251229 resolver := NewEnvironmentVariableResolver(env)
1226− err := cfg.configureProviders(env, resolver, knownProviders)
1230+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
12271231 require.NoError(t, err)
12281232
12291233 // Both providers should be present.
@@ -1251,7 +1255,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) {
12511255
12521256 env := env.NewFromMap(map[string]string{})
12531257 resolver := NewEnvironmentVariableResolver(env)
1254− err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
1258+ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
12551259 require.ErrorContains(t, err, "no custom providers")
12561260
12571261 // Provider should be rejected for missing models.
@@ -1275,7 +1279,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) {
12751279
12761280 env := env.NewFromMap(map[string]string{})
12771281 resolver := NewEnvironmentVariableResolver(env)
1278− err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
1282+ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
12791283 require.ErrorContains(t, err, "no custom providers")
12801284
12811285 // Provider should be rejected for missing base_url.
@@ -1340,10 +1344,10 @@ func TestConfig_configureSelectedModels(t *testing.T) {
13401344 cfg.setDefaults("/tmp", "")
13411345 env := env.NewFromMap(map[string]string{})
13421346 resolver := NewEnvironmentVariableResolver(env)
1343− err := cfg.configureProviders(env, resolver, knownProviders)
1347+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
13441348 require.NoError(t, err)
13451349
1346− err = cfg.configureSelectedModels(knownProviders)
1350+ err = configureSelectedModels(testStore(cfg), knownProviders)
13471351 require.NoError(t, err)
13481352 large := cfg.Models[SelectedModelTypeLarge]
13491353 small := cfg.Models[SelectedModelTypeSmall]
@@ -1402,10 +1406,10 @@ func TestConfig_configureSelectedModels(t *testing.T) {
14021406 cfg.setDefaults("/tmp", "")
14031407 env := env.NewFromMap(map[string]string{})
14041408 resolver := NewEnvironmentVariableResolver(env)
1405− err := cfg.configureProviders(env, resolver, knownProviders)
1409+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
14061410 require.NoError(t, err)
14071411
1408− err = cfg.configureSelectedModels(knownProviders)
1412+ err = configureSelectedModels(testStore(cfg), knownProviders)
14091413 require.NoError(t, err)
14101414 large := cfg.Models[SelectedModelTypeLarge]
14111415 small := cfg.Models[SelectedModelTypeSmall]
@@ -1447,10 +1451,10 @@ func TestConfig_configureSelectedModels(t *testing.T) {
14471451 cfg.setDefaults("/tmp", "")
14481452 env := env.NewFromMap(map[string]string{})
14491453 resolver := NewEnvironmentVariableResolver(env)
1450− err := cfg.configureProviders(env, resolver, knownProviders)
1454+ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
14511455 require.NoError(t, err)
14521456
1453− err = cfg.configureSelectedModels(knownProviders)
1457+ err = configureSelectedModels(testStore(cfg), knownProviders)
14541458 require.NoError(t, err)
14551459 large := cfg.Models[SelectedModelTypeLarge]
14561460 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 {
3131 return rm
3232}
3333
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+
3442func TestRecordRecentModel_AddsAndPersists(t *testing.T) {
3543 t.Parallel()
3644
3745 dir := t.TempDir()
3846 cfg := &Config{}
3947 cfg.setDefaults(dir, "")
40− cfg.dataConfigDir = filepath.Join(dir, "config.json")
48+ store := testStoreWithPath(cfg, dir)
4149
42− err := cfg.recordRecentModel(SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"})
50+ err := store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"})
4351 require.NoError(t, err)
4452
4553 // in-memory state
@@ -48,7 +56,7 @@ func TestRecordRecentModel_AddsAndPersists(t *testing.T) {
4856 require.Equal(t, "gpt-4o", cfg.RecentModels[SelectedModelTypeLarge][0].Model)
4957
5058 // persisted state
51− rm := readRecentModels(t, cfg.dataConfigDir)
59+ rm := readRecentModels(t, store.globalDataPath)
5260 large, ok := rm[string(SelectedModelTypeLarge)].([]any)
5361 require.True(t, ok)
5462 require.Len(t, large, 1)
@@ -64,13 +72,13 @@ func TestRecordRecentModel_DedupeAndMoveToFront(t *testing.T) {
6472 dir := t.TempDir()
6573 cfg := &Config{}
6674 cfg.setDefaults(dir, "")
67− cfg.dataConfigDir = filepath.Join(dir, "config.json")
75+ store := testStoreWithPath(cfg, dir)
6876
6977 // Add two entries
70− require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"}))
71− require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, SelectedModel{Provider: "anthropic", Model: "claude"}))
78+ require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"}))
79+ require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, SelectedModel{Provider: "anthropic", Model: "claude"}))
7280 // 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"}))
7482
7583 got := cfg.RecentModels[SelectedModelTypeLarge]
7684 require.Len(t, got, 2)
@@ -84,7 +92,7 @@ func TestRecordRecentModel_TrimsToMax(t *testing.T) {
8492 dir := t.TempDir()
8593 cfg := &Config{}
8694 cfg.setDefaults(dir, "")
87− cfg.dataConfigDir = filepath.Join(dir, "config.json")
95+ store := testStoreWithPath(cfg, dir)
8896
8997 // Insert 6 unique models; max is 5
9098 entries := []SelectedModel{
@@ -96,7 +104,7 @@ func TestRecordRecentModel_TrimsToMax(t *testing.T) {
96104 {Provider: "p6", Model: "m6"},
97105 }
98106 for _, e := range entries {
99− require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, e))
107+ require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, e))
100108 }
101109
102110 // in-memory state
@@ -110,7 +118,7 @@ func TestRecordRecentModel_TrimsToMax(t *testing.T) {
110118 require.Equal(t, SelectedModel{Provider: "p2", Model: "m2"}, got[4])
111119
112120 // persisted state: verify trimmed to 5 and newest-first order
113− rm := readRecentModels(t, cfg.dataConfigDir)
121+ rm := readRecentModels(t, store.globalDataPath)
114122 large, ok := rm[string(SelectedModelTypeLarge)].([]any)
115123 require.True(t, ok)
116124 require.Len(t, large, 5)
@@ -129,12 +137,12 @@ func TestRecordRecentModel_SkipsEmptyValues(t *testing.T) {
129137 dir := t.TempDir()
130138 cfg := &Config{}
131139 cfg.setDefaults(dir, "")
132− cfg.dataConfigDir = filepath.Join(dir, "config.json")
140+ store := testStoreWithPath(cfg, dir)
133141
134142 // 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"}))
136144 // 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: ""}))
138146
139147 _, ok := cfg.RecentModels[SelectedModelTypeLarge]
140148 // Map may be initialized, but should have no entries
@@ -142,8 +150,8 @@ func TestRecordRecentModel_SkipsEmptyValues(t *testing.T) {
142150 require.Len(t, cfg.RecentModels[SelectedModelTypeLarge], 0)
143151 }
144152 // No file should be written (stat via fs.FS)
145− baseDir := filepath.Dir(cfg.dataConfigDir)
146− fileName := filepath.Base(cfg.dataConfigDir)
153+ baseDir := filepath.Dir(store.globalDataPath)
154+ fileName := filepath.Base(store.globalDataPath)
147155 _, err := fs.Stat(os.DirFS(baseDir), fileName)
148156 require.True(t, os.IsNotExist(err))
149157}
@@ -154,13 +162,13 @@ func TestRecordRecentModel_NoPersistOnNoop(t *testing.T) {
154162 dir := t.TempDir()
155163 cfg := &Config{}
156164 cfg.setDefaults(dir, "")
157− cfg.dataConfigDir = filepath.Join(dir, "config.json")
165+ store := testStoreWithPath(cfg, dir)
158166
159167 entry := SelectedModel{Provider: "openai", Model: "gpt-4o"}
160− require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, entry))
168+ require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, entry))
161169
162− baseDir := filepath.Dir(cfg.dataConfigDir)
163− fileName := filepath.Base(cfg.dataConfigDir)
170+ baseDir := filepath.Dir(store.globalDataPath)
171+ fileName := filepath.Base(store.globalDataPath)
164172 before, err := fs.ReadFile(os.DirFS(baseDir), fileName)
165173 require.NoError(t, err)
166174
@@ -170,7 +178,7 @@ func TestRecordRecentModel_NoPersistOnNoop(t *testing.T) {
170178 beforeMod := stBefore.ModTime()
171179
172180 // 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))
174182
175183 after, err := fs.ReadFile(os.DirFS(baseDir), fileName)
176184 require.NoError(t, err)
@@ -188,17 +196,17 @@ func TestUpdatePreferredModel_UpdatesRecents(t *testing.T) {
188196 dir := t.TempDir()
189197 cfg := &Config{}
190198 cfg.setDefaults(dir, "")
191− cfg.dataConfigDir = filepath.Join(dir, "config.json")
199+ store := testStoreWithPath(cfg, dir)
192200
193201 sel := SelectedModel{Provider: "openai", Model: "gpt-4o"}
194− require.NoError(t, cfg.UpdatePreferredModel(SelectedModelTypeSmall, sel))
202+ require.NoError(t, store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeSmall, sel))
195203
196204 // in-memory
197205 require.Equal(t, sel, cfg.Models[SelectedModelTypeSmall])
198206 require.Len(t, cfg.RecentModels[SelectedModelTypeSmall], 1)
199207
200208 // persisted (read via fs.FS)
201− rm := readRecentModels(t, cfg.dataConfigDir)
209+ rm := readRecentModels(t, store.globalDataPath)
202210 small, ok := rm[string(SelectedModelTypeSmall)].([]any)
203211 require.True(t, ok)
204212 require.Len(t, small, 1)
@@ -210,14 +218,14 @@ func TestRecordRecentModel_TypeIsolation(t *testing.T) {
210218 dir := t.TempDir()
211219 cfg := &Config{}
212220 cfg.setDefaults(dir, "")
213− cfg.dataConfigDir = filepath.Join(dir, "config.json")
221+ store := testStoreWithPath(cfg, dir)
214222
215223 // Add models to both large and small types
216224 largeModel := SelectedModel{Provider: "openai", Model: "gpt-4o"}
217225 smallModel := SelectedModel{Provider: "anthropic", Model: "claude"}
218226
219− require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, largeModel))
220− require.NoError(t, cfg.recordRecentModel(SelectedModelTypeSmall, smallModel))
227+ require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, largeModel))
228+ require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeSmall, smallModel))
221229
222230 // in-memory: verify types maintain separate histories
223231 require.Len(t, cfg.RecentModels[SelectedModelTypeLarge], 1)
@@ -227,14 +235,14 @@ func TestRecordRecentModel_TypeIsolation(t *testing.T) {
227235
228236 // Add another to large, verify small unchanged
229237 anotherLarge := SelectedModel{Provider: "google", Model: "gemini"}
230− require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, anotherLarge))
238+ require.NoError(t, store.recordRecentModel(ScopeGlobal, SelectedModelTypeLarge, anotherLarge))
231239
232240 require.Len(t, cfg.RecentModels[SelectedModelTypeLarge], 2)
233241 require.Len(t, cfg.RecentModels[SelectedModelTypeSmall], 1)
234242 require.Equal(t, smallModel, cfg.RecentModels[SelectedModelTypeSmall][0])
235243
236244 // persisted state: verify both types exist with correct lengths and contents
237− rm := readRecentModels(t, cfg.dataConfigDir)
245+ rm := readRecentModels(t, store.globalDataPath)
238246
239247 large, ok := rm[string(SelectedModelTypeLarge)].([]any)
240248 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{}]()
2626// Manager handles lazy initialization of LSP clients based on file types.
2727type Manager struct {
2828 clients *csync.Map[string, *Client]
29− cfg *config.Config
29+ cfg *config.ConfigStore
3030 manager *powernapconfig.Manager
3131 callback func(name string, client *Client)
3232}
3333
3434// NewManager creates a new LSP manager service.
35−func NewManager(cfg *config.Config) *Manager {
35+func NewManager(cfg *config.ConfigStore) *Manager {
3636 manager := powernapconfig.NewManager()
3737 manager.LoadDefaults()
3838
3939 // Merge user-configured LSPs into the manager.
40− for name, clientConfig := range cfg.LSP {
40+ for name, clientConfig := range cfg.Config().LSP {
4141 if clientConfig.Disabled {
4242 slog.Debug("LSP disabled by user config", "name", name)
4343 manager.RemoveServer(name)
@@ -194,7 +194,7 @@ func (s *Manager) startServer(ctx context.Context, name, filepath string, server
194194 cfg,
195195 s.cfg.Resolver(),
196196 s.cfg.WorkingDir(),
197− s.cfg.Options.DebugLSP,
197+ s.cfg.Config().Options.DebugLSP,
198198 )
199199 if err != nil {
200200 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
244244}
245245
246246func (s *Manager) isUserConfigured(name string) bool {
247− cfg, ok := s.cfg.LSP[name]
247+ cfg, ok := s.cfg.Config().LSP[name]
248248 return ok && !cfg.Disabled
249249}
250250
@@ -258,7 +258,7 @@ func (s *Manager) buildConfig(name string, server *powernapconfig.ServerConfig)
258258 InitOptions: server.InitOptions,
259259 Options: server.Settings,
260260 }
261− if userCfg, ok := s.cfg.LSP[name]; ok {
261+ if userCfg, ok := s.cfg.Config().LSP[name]; ok {
262262 cfg.Timeout = userCfg.Timeout
263263 }
264264 return cfg
internal/permission/permission.go+6−6
@@ -134,6 +134,12 @@ func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRe
134134 return true, nil
135135 }
136136
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+
137143 // tell the UI that a permission was requested
138144 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
139145 ToolCallID: opts.ToolCallID,
@@ -141,12 +147,6 @@ func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRe
141147 s.requestMu.Lock()
142148 defer s.requestMu.Unlock()
143149
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−
150150 s.autoApproveSessionsMu.RLock()
151151 autoApprove := s.autoApproveSessions[opts.SessionID]
152152 s.autoApproveSessionsMu.RUnlock()
internal/server/server.go+2−2
@@ -73,7 +73,7 @@ func (s *Server) SetLogger(logger *slog.Logger) {
7373}
7474
7575// DefaultServer returns a new [Server] with the default address.
76−func DefaultServer(cfg *config.Config) *Server {
76+func DefaultServer(cfg *config.ConfigStore) *Server {
7777 hostURL, err := ParseHostURL(DefaultHost())
7878 if err != nil {
7979 panic("invalid default host")
@@ -82,7 +82,7 @@ func DefaultServer(cfg *config.Config) *Server {
8282}
8383
8484// 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 {
8686 s := new(Server)
8787 s.Addr = address
8888 s.network = network
internal/ui/common/capabilities.go+2−2
@@ -58,7 +58,7 @@ func (c *Capabilities) Update(msg any) {
5858 }
5959 case tea.TerminalVersionMsg:
6060 c.TerminalVersion = m.Name
61− case uv.ModeReportEvent:
61+ case tea.ModeReportMsg:
6262 switch m.Mode {
6363 case ansi.ModeFocusEvent:
6464 c.ReportFocusEvents = modeSupported(m.Value)
@@ -77,7 +77,7 @@ func QueryCmd(env uv.Environ) tea.Cmd {
7777 shouldQueryFor := shouldQueryCapabilities(env)
7878 if shouldQueryFor {
7979 sb.WriteString(ansi.RequestNameVersion)
80− // sb.WriteString(ansi.RequestModeFocusEvent) // TODO: re-enable when we need notifications.
80+ sb.WriteString(ansi.RequestModeFocusEvent)
8181 sb.WriteString(ansi.WindowOp(14)) // Window size in pixels
8282 kittyReq := ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24")
8383 if _, isTmux := env.LookupEnv("TMUX"); isTmux {
internal/ui/common/common.go+6−1
@@ -26,11 +26,16 @@ type Common struct {
2626 Styles *styles.Styles
2727}
2828
29−// Config returns the configuration associated with this [Common] instance.
29+// Config returns the pure-data configuration associated with this [Common] instance.
3030func (c *Common) Config() *config.Config {
3131 return c.App.Config()
3232}
3333
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+
3439// DefaultCommon returns the default common UI configurations.
3540func DefaultCommon(app *app.App) *Common {
3641 s := styles.DefaultStyles()
internal/ui/dialog/api_key_input.go+3−3
@@ -296,7 +296,7 @@ func (m *APIKeyInput) verifyAPIKey() tea.Msg {
296296 Type: m.provider.Type,
297297 BaseURL: m.provider.APIEndpoint,
298298 }
299− err := providerConfig.TestConnection(m.com.Config().Resolver())
299+ err := providerConfig.TestConnection(m.com.Store().Resolver())
300300
301301 // intentionally wait for at least 750ms to make sure the user sees the spinner
302302 elapsed := time.Since(start)
@@ -312,9 +312,9 @@ func (m *APIKeyInput) verifyAPIKey() tea.Msg {
312312}
313313
314314func (m *APIKeyInput) saveKeyAndContinue() Action {
315− cfg := m.com.Config()
315+ store := m.com.Store()
316316
317− err := cfg.SetProviderAPIKey(string(m.provider.ID), m.input.Value())
317+ err := store.SetProviderAPIKey(config.ScopeGlobal, string(m.provider.ID), m.input.Value())
318318 if err != nil {
319319 return ActionCmd{util.ReportError(fmt.Errorf("failed to save API key: %w", err))}
320320 }
internal/ui/dialog/filepicker.go+1−1
@@ -123,7 +123,7 @@ func (f *FilePicker) SetImageCapabilities(caps *common.Capabilities) {
123123
124124// WorkingDir returns the current working directory of the [FilePicker].
125125func (f *FilePicker) WorkingDir() string {
126− wd := f.com.Config().WorkingDir()
126+ wd := f.com.Store().WorkingDir()
127127 if len(wd) > 0 {
128128 return wd
129129 }
internal/ui/dialog/models.go+1−1
@@ -490,7 +490,7 @@ func (m *Models) setProviderItems() error {
490490
491491 if len(validRecentItems) != len(recentItems) {
492492 // 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 {
494494 return fmt.Errorf("failed to update recent models: %w", err)
495495 }
496496 }
internal/ui/dialog/oauth.go+2−2
@@ -373,9 +373,9 @@ func (d *OAuth) copyCodeAndOpenURL() tea.Cmd {
373373}
374374
375375func (m *OAuth) saveKeyAndContinue() Action {
376− cfg := m.com.Config()
376+ store := m.com.Store()
377377
378− err := cfg.SetProviderAPIKey(string(m.provider.ID), m.token)
378+ err := store.SetProviderAPIKey(config.ScopeGlobal, string(m.provider.ID), m.token)
379379 if err != nil {
380380 return ActionCmd{util.ReportError(fmt.Errorf("failed to save API key: %w", err))}
381381 }
internal/ui/model/header.go+1−2
@@ -143,8 +143,7 @@ func renderHeaderDetails(
143143 metadata = dot + metadata
144144
145145 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)
148147 cwd = t.Header.WorkingDir.Render(cwd)
149148
150149 result := cwd + metadata
internal/ui/model/landing.go+1−1
@@ -22,7 +22,7 @@ func (m *UI) selectedLargeModel() *agent.Model {
2222func (m *UI) landingView() string {
2323 t := m.com.Styles
2424 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)
2626
2727 parts := []string{
2828 cwd,
internal/ui/model/onboarding.go+5−6
@@ -19,7 +19,7 @@ import (
1919// markProjectInitialized marks the current project as initialized in the config.
2020func (m *UI) markProjectInitialized() tea.Msg {
2121 // 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())
2323 if err != nil {
2424 slog.Error(err.Error())
2525 }
@@ -52,10 +52,10 @@ func (m *UI) initializeProject() tea.Cmd {
5252 if cmd := m.newSession(); cmd != nil {
5353 cmds = append(cmds, cmd)
5454 }
55− cfg := m.com.Config()
55+ cfg := m.com.Store()
5656
5757 initialize := func() tea.Msg {
58− initPrompt, err := agent.InitializePrompt(*cfg)
58+ initPrompt, err := agent.InitializePrompt(cfg)
5959 if err != nil {
6060 return util.InfoMsg{
6161 Type: util.InfoTypeError,
@@ -80,10 +80,9 @@ func (m *UI) skipInitializeProject() tea.Cmd {
8080
8181// initializeView renders the project initialization prompt with Yes/No buttons.
8282func (m *UI) initializeView() string {
83− cfg := m.com.Config()
8483 s := m.com.Styles.Initialize
85− cwd := home.Short(cfg.WorkingDir())
86− initFile := cfg.Options.InitializeAs
84+ cwd := home.Short(m.com.Store().WorkingDir())
85+ initFile := m.com.Config().Options.InitializeAs
8786
8887 header := s.Header.Render("Would you like to initialize this project?")
8988 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) {
112112 height := area.Dy()
113113
114114 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)
116116 sidebarLogo := m.sidebarLogo
117117 if height < logoHeightBreakpoint {
118118 sidebarLogo = logo.SmallRender(m.com.Styles, width)
@@ -138,7 +138,7 @@ func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
138138
139139 lspSection := m.lspInfo(width, maxLSPs, true)
140140 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)
142142
143143 uv.NewStyledString(
144144 lipgloss.NewStyle().
internal/ui/model/ui.go+90−24
@@ -25,6 +25,7 @@ import (
2525 tea "charm.land/bubbletea/v2"
2626 "charm.land/catwalk/pkg/catwalk"
2727 "charm.land/lipgloss/v2"
28+ "github.com/charmbracelet/crush/internal/agent/notify"
2829 agenttools "github.com/charmbracelet/crush/internal/agent/tools"
2930 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
3031 "github.com/charmbracelet/crush/internal/app"
@@ -45,6 +46,7 @@ import (
4546 "github.com/charmbracelet/crush/internal/ui/dialog"
4647 fimage "github.com/charmbracelet/crush/internal/ui/image"
4748 "github.com/charmbracelet/crush/internal/ui/logo"
49+ "github.com/charmbracelet/crush/internal/ui/notification"
4850 "github.com/charmbracelet/crush/internal/ui/styles"
4951 "github.com/charmbracelet/crush/internal/ui/util"
5052 "github.com/charmbracelet/crush/internal/version"
@@ -201,6 +203,9 @@ type UI struct {
201203 // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
202204 sidebarLogo string
203205
206+ // Notification state
207+ notifyBackend notification.Backend
208+ notifyWindowFocused bool
204209 // custom commands & mcp commands
205210 customCommands []commands.CustomCommand
206211 mcpPrompts []commands.MCPPrompt
@@ -280,17 +285,19 @@ func New(com *common.Common) *UI {
280285 header := newHeader(com)
281286
282287 ui := &UI{
283− com: com,
284− dialog: dialog.NewOverlay(),
285− keyMap: keyMap,
286− textarea: ta,
287− chat: ch,
288− header: header,
289− completions: comp,
290− attachments: attachments,
291− todoSpinner: todoSpinner,
292− lspStates: make(map[string]app.LSPClientInfo),
293− mcpStates: make(map[string]mcp.ClientInfo),
288+ com: com,
289+ dialog: dialog.NewOverlay(),
290+ keyMap: keyMap,
291+ textarea: ta,
292+ chat: ch,
293+ header: header,
294+ completions: comp,
295+ attachments: attachments,
296+ todoSpinner: todoSpinner,
297+ lspStates: make(map[string]app.LSPClientInfo),
298+ mcpStates: make(map[string]mcp.ClientInfo),
299+ notifyBackend: notification.NoopBackend{},
300+ notifyWindowFocused: true,
294301 }
295302
296303 status := NewStatus(com, ui)
@@ -310,7 +317,7 @@ func New(com *common.Common) *UI {
310317 desiredFocus := uiFocusEditor
311318 if !com.Config().IsConfigured() {
312319 desiredState = uiOnboarding
313− } else if n, _ := config.ProjectNeedsInitialization(com.Config()); n {
320+ } else if n, _ := config.ProjectNeedsInitialization(com.Store()); n {
314321 desiredState = uiInitialize
315322 }
316323
@@ -342,6 +349,32 @@ func (m *UI) Init() tea.Cmd {
342349 return tea.Batch(cmds...)
343350}
344351
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+
345378// setState changes the UI state and focus.
346379func (m *UI) setState(state uiState, focus uiFocusState) {
347380 if state == uiLanding {
@@ -397,6 +430,18 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
397430 m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
398431 }
399432 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+ }
400445 case loadSessionMsg:
401446 if m.forceCompactMode {
402447 m.isCompact = true
@@ -534,7 +579,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
534579 case mcp.EventPromptsListChanged:
535580 return m, handleMCPPromptsEvent(msg.Payload.Name)
536581 case mcp.EventToolsListChanged:
537− return m, handleMCPToolsEvent(m.com.Config(), msg.Payload.Name)
582+ return m, handleMCPToolsEvent(m.com.Store(), msg.Payload.Name)
538583 case mcp.EventResourcesListChanged:
539584 return m, handleMCPResourcesEvent(msg.Payload.Name)
540585 }
@@ -542,6 +587,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
542587 if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
543588 cmds = append(cmds, cmd)
544589 }
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+ }
545596 case pubsub.Event[permission.PermissionNotification]:
546597 m.handlePermissionNotification(msg.Payload)
547598 case cancelTimerExpiredMsg:
@@ -1253,7 +1304,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
12531304
12541305 currentModel := cfg.Models[agentCfg.Model]
12551306 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 {
12571308 return util.ReportError(err)()
12581309 }
12591310 m.com.App.UpdateAgentModel(context.TODO())
@@ -1294,7 +1345,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
12941345
12951346 // Attempt to import GitHub Copilot tokens from VSCode if available.
12961347 if isCopilot && !isConfigured() && !msg.ReAuthenticate {
1297− m.com.Config().ImportCopilot()
1348+ m.com.Store().ImportCopilot()
12981349 }
12991350
13001351 if !isConfigured() || msg.ReAuthenticate {
@@ -1305,12 +1356,12 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
13051356 break
13061357 }
13071358
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 {
13091360 cmds = append(cmds, util.ReportError(err))
13101361 } else if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
13111362 // Ensure small model is set is unset.
13121363 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 {
13141365 cmds = append(cmds, util.ReportError(err))
13151366 }
13161367 }
@@ -1356,7 +1407,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
13561407
13571408 currentModel := cfg.Models[agentCfg.Model]
13581409 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 {
13601411 cmds = append(cmds, util.ReportError(err))
13611412 break
13621413 }
@@ -1967,7 +2018,8 @@ func (m *UI) View() tea.View {
19672018 v.BackgroundColor = m.com.Styles.Background
19682019 }
19692020 v.MouseMode = tea.MouseModeCellMotion
1970− v.WindowTitle = "crush " + home.Short(m.com.Config().WorkingDir())
2021+ v.ReportFocus = m.caps.ReportFocusEvents
2022+ v.WindowTitle = "crush " + home.Short(m.com.Store().WorkingDir())
19712023
19722024 canvas := uv.NewScreenBuffer(m.width, m.height)
19732025 v.Cursor = m.Draw(canvas, canvas.Bounds())
@@ -2206,7 +2258,7 @@ func (m *UI) FullHelp() [][]key.Binding {
22062258func (m *UI) toggleCompactMode() tea.Cmd {
22072259 m.forceCompactMode = !m.forceCompactMode
22082260
2209− err := m.com.Config().SetCompactMode(m.forceCompactMode)
2261+ err := m.com.Store().SetCompactMode(config.ScopeGlobal, m.forceCompactMode)
22102262 if err != nil {
22112263 return util.ReportError(err)
22122264 }
@@ -2588,7 +2640,7 @@ func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValu
25882640 return func() tea.Msg {
25892641 contents, err := mcp.ReadResource(
25902642 context.Background(),
2591− m.com.Config(),
2643+ m.com.Store(),
25922644 item.MCPName,
25932645 item.URI,
25942646 )
@@ -2983,6 +3035,20 @@ func (m *UI) handlePermissionNotification(notification permission.PermissionNoti
29833035 }
29843036}
29853037
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+
29863052// newSession clears the current session state and prepares for a new session.
29873053// The actual session creation happens when the user sends their first message.
29883054// Returns a command to reload prompt history.
@@ -3236,7 +3302,7 @@ func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
32363302
32373303 lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
32383304 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)
32403306 sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
32413307 uv.NewStyledString(
32423308 s.CompactDetails.View.
@@ -3254,7 +3320,7 @@ func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
32543320
32553321func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
32563322 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)
32583324 if err != nil {
32593325 // TODO: make this better
32603326 return util.ReportError(err)()
@@ -3295,7 +3361,7 @@ func handleMCPPromptsEvent(name string) tea.Cmd {
32953361 }
32963362}
32973363
3298−func handleMCPToolsEvent(cfg *config.Config, name string) tea.Cmd {
3364+func handleMCPToolsEvent(cfg *config.ConfigStore, name string) tea.Cmd {
32993365 return func() tea.Msg {
33003366 mcp.RefreshTools(
33013367 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 @@
451451 "type": "boolean",
452452 "description": "Show indeterminate progress updates during long operations",
453453 "default": true
454+ },
455+ "disable_notifications": {
456+ "type": "boolean",
457+ "description": "Disable desktop notifications",
458+ "default": false
454459 }
455460 },
456461 "additionalProperties": false,