on main help sign in
Merge branch 'main' into ui
commita3a584c7b1c9793ff7d2095ea1e41b8b6fb97c8f authorAndrey Nering <andreynering@users.noreply.github.com> dateWed 14 Jan 2026, 17:10 (9 months ago) parent19c67f7f7de0d5
21 files changed+539−204
.github/cla-signatures.json+16 .github/workflows/build.yml+22−6 .github/workflows/security.yml+92 README.md+1−1 Taskfile.yaml+6−4 go.mod+6−4 go.sum+12−8 internal/agent/agent.go+60−49 internal/agent/event.go+6−6 internal/csync/maps_test.go+4−3 internal/csync/slices.go+10−34 internal/csync/slices_test.go−57 internal/csync/value.go+44 internal/csync/value_test.go+99 internal/permission/permission.go+11−2 internal/permission/permission_test.go+2−2 internal/shell/background.go+36−7 internal/shell/background_test.go+8−8 internal/tui/components/chat/editor/editor.go+79 internal/tui/components/chat/editor/keys.go+6 internal/tui/styles/theme.go+19−13
.github/cla-signatures.json+16
@@ -1055,6 +1055,22 @@
10551055 "created_at": "2026-01-12T22:16:05Z",
10561056 "repoId": 987670088,
10571057 "pullRequestNo": 1841
1058+ },
1059+ {
1060+ "name": "kuxoapp",
1061+ "id": 254052994,
1062+ "comment_id": 3747622477,
1063+ "created_at": "2026-01-14T04:18:44Z",
1064+ "repoId": 987670088,
1065+ "pullRequestNo": 1864
1066+ },
1067+ {
1068+ "name": "mhpenta",
1069+ "id": 183146177,
1070+ "comment_id": 3749703014,
1071+ "created_at": "2026-01-14T14:02:04Z",
1072+ "repoId": 987670088,
1073+ "pullRequestNo": 1870
10581074 }
10591075 ]
10601076}
.github/workflows/build.yml+22−6
@@ -1,11 +1,27 @@
11name: build
22on: [push, pull_request]
33
4+permissions:
5+ contents: read
6+
7+concurrency:
8+ group: build-${{ github.event.pull_request.number || github.ref }}
9+ cancel-in-progress: true
10+
411jobs:
512 build:
6− uses: charmbracelet/meta/.github/workflows/build.yml@main
7− with:
8− go-version: ""
9− go-version-file: ./go.mod
10− secrets:
11− gh_pat: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
13+ strategy:
14+ matrix:
15+ os: [ubuntu-latest, macos-latest, windows-latest]
16+ runs-on: ${{ matrix.os }}
17+ steps:
18+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
19+ with:
20+ persist-credentials: false
21+ - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0
22+ with:
23+ go-version-file: go.mod
24+ - run: go mod tidy
25+ - run: git diff --exit-code
26+ - run: go build -race ./...
27+ - run: go test -race -failfast ./...
.github/workflows/security.ymlnew file+92
@@ -0,0 +1,92 @@
1+name: "security"
2+
3+on:
4+ pull_request:
5+ push:
6+ branches: [main]
7+ schedule:
8+ - cron: "0 2 * * *"
9+
10+permissions:
11+ contents: read
12+
13+concurrency:
14+ group: security-${{ github.event.pull_request.number || github.ref }}
15+ cancel-in-progress: true
16+
17+jobs:
18+ codeql:
19+ runs-on: ubuntu-latest
20+ strategy:
21+ fail-fast: false
22+ matrix:
23+ language: ["go", "actions"]
24+ permissions:
25+ actions: read
26+ contents: read
27+ pull-requests: read
28+ security-events: write
29+ steps:
30+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
31+ with:
32+ persist-credentials: false
33+ - uses: github/codeql-action/init@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
34+ with:
35+ languages: ${{ matrix.language }}
36+ - uses: github/codeql-action/autobuild@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
37+ - uses: github/codeql-action/analyze@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
38+
39+ grype:
40+ runs-on: ubuntu-latest
41+ permissions:
42+ security-events: write
43+ actions: read
44+ contents: read
45+ steps:
46+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
47+ with:
48+ persist-credentials: false
49+ - uses: anchore/scan-action@40a61b52209e9d50e87917c5b901783d546b12d0 # v7.2.1
50+ id: scan
51+ with:
52+ path: "."
53+ fail-build: true
54+ severity-cutoff: critical
55+ - uses: github/codeql-action/upload-sarif@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
56+ with:
57+ sarif_file: ${{ steps.scan.outputs.sarif }}
58+
59+ govulncheck:
60+ runs-on: ubuntu-latest
61+ permissions:
62+ security-events: write
63+ contents: read
64+ steps:
65+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
66+ with:
67+ persist-credentials: false
68+ - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
69+ with:
70+ go-version: 1.26.0-rc.1 # change to "stable" once Go 1.26 is released
71+ - name: Install govulncheck
72+ run: go install golang.org/x/vuln/cmd/govulncheck@latest
73+ - name: Run govulncheck
74+ run: |
75+ govulncheck -C . -format sarif ./... > results.sarif
76+ - uses: github/codeql-action/upload-sarif@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
77+ with:
78+ sarif_file: results.sarif
79+
80+ dependency-review:
81+ runs-on: ubuntu-latest
82+ if: github.event_name == 'pull_request'
83+ permissions:
84+ contents: read
85+ steps:
86+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
87+ with:
88+ persist-credentials: false
89+ - uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
90+ with:
91+ fail-on-severity: critical
92+ allow-licenses: BSD-2-Clause, BSD-3-Clause, MIT, Apache-2.0, MPL-2.0, ISC, LicenseRef-scancode-google-patent-license-golang
README.md+1−1
@@ -1,7 +1,7 @@
11# Crush
22
33<p align="center">
4− <a href="https://stuff.charm.sh/crush/charm-crush.png"><img width="450" alt="Charm Crush Logo" src="https://github.com/user-attachments/assets/adc1a6f4-b284-4603-836c-59038caa2e8b" /></a><br />
4+ <a href="https://stuff.charm.sh/crush/charm-crush.png"><img width="450" alt="Charm Crush Logo" src="https://github.com/user-attachments/assets/cf8ca3ce-8b02-43f0-9d0f-5a331488da4b" /></a><br />
55 <a href="https://github.com/charmbracelet/crush/releases"><img src="https://img.shields.io/github/release/charmbracelet/crush" alt="Latest Release"></a>
66 <a href="https://github.com/charmbracelet/crush/actions"><img src="https://github.com/charmbracelet/crush/actions/workflows/build.yml/badge.svg" alt="Build Status"></a>
77</p>
Taskfile.yaml+6−4
@@ -5,6 +5,8 @@ version: "3"
55vars:
66 VERSION:
77 sh: git describe --long 2>/dev/null || echo ""
8+ RACE:
9+ sh: test -f race.log && echo "1" || echo ""
810
911env:
1012 CGO_ENABLED: 0
@@ -37,20 +39,20 @@ tasks:
3739 vars:
3840 LDFLAGS: '{{if .VERSION}}-ldflags="-X github.com/charmbracelet/crush/internal/version.Version={{.VERSION}}"{{end}}'
3941 cmds:
40− - go build {{.LDFLAGS}} .
42+ - "go build {{if .RACE}}-race{{end}} {{.LDFLAGS}} ."
4143 generates:
4244 - crush
4345
4446 run:
4547 desc: Run build
4648 cmds:
47− - go build -o crush .
48− - ./crush {{.CLI_ARGS}}
49+ - task: build
50+ - "./crush {{.CLI_ARGS}} {{if .RACE}}2>race.log{{end}}"
4951
5052 test:
5153 desc: Run tests
5254 cmds:
53− - go test ./... {{.CLI_ARGS}}
55+ - go test -race -failfast ./... {{.CLI_ARGS}}
5456
5557 test:record:
5658 desc: Run tests and record all VCR cassettes again
go.mod+6−4
@@ -15,10 +15,11 @@ require (
1515 github.com/PuerkitoBio/goquery v1.11.0
1616 github.com/alecthomas/chroma/v2 v2.22.0
1717 github.com/atotto/clipboard v0.1.4
18+ github.com/aymanbagabas/go-nativeclipboard v0.1.2
1819 github.com/aymanbagabas/go-udiff v0.3.1
1920 github.com/bmatcuk/doublestar/v4 v4.9.2
2021 github.com/charlievieth/fastwalk v1.0.14
21− github.com/charmbracelet/catwalk v0.13.0
22+ github.com/charmbracelet/catwalk v0.14.1
2223 github.com/charmbracelet/colorprofile v0.4.1
2324 github.com/charmbracelet/fang v0.4.4
2425 github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560
@@ -29,7 +30,7 @@ require (
2930 github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f
3031 github.com/charmbracelet/x/exp/ordered v0.1.0
3132 github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff
32− github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4
33+ github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b
3334 github.com/charmbracelet/x/term v0.2.2
3435 github.com/denisbrodbeck/machineid v1.0.1
3536 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
@@ -101,12 +102,13 @@ require (
101102 github.com/charmbracelet/x/json v0.2.0 // indirect
102103 github.com/charmbracelet/x/termios v0.1.1 // indirect
103104 github.com/charmbracelet/x/windows v0.2.2 // indirect
104− github.com/clipperhouse/displaywidth v0.6.1 // indirect
105+ github.com/clipperhouse/displaywidth v0.6.2 // indirect
105106 github.com/clipperhouse/stringish v0.1.1 // indirect
106107 github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
107108 github.com/davecgh/go-spew v1.1.1 // indirect
108109 github.com/disintegration/gift v1.1.2 // indirect
109110 github.com/dlclark/regexp2 v1.11.5 // indirect
111+ github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff // indirect
110112 github.com/felixge/httpsnoop v1.0.4 // indirect
111113 github.com/fsnotify/fsnotify v1.9.0 // indirect
112114 github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect
@@ -171,7 +173,7 @@ require (
171173 go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect
172174 golang.org/x/crypto v0.47.0 // indirect
173175 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
174− golang.org/x/image v0.27.0 // indirect
176+ golang.org/x/image v0.34.0 // indirect
175177 golang.org/x/oauth2 v0.34.0 // indirect
176178 golang.org/x/sys v0.40.0 // indirect
177179 golang.org/x/term v0.39.0 // indirect
go.sum+12−8
@@ -78,6 +78,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/
7878github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
7979github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
8080github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
81+github.com/aymanbagabas/go-nativeclipboard v0.1.2 h1:Z2iVRWQ4IynMLWM6a+lWH2Nk5gPyEtPRMuBIyZ2dECM=
82+github.com/aymanbagabas/go-nativeclipboard v0.1.2/go.mod h1:BVJhN7hs5DieCzUB2Atf4Yk9Y9kFe62E95+gOjpJq6Q=
8183github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
8284github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
8385github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
@@ -94,8 +96,8 @@ github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICg
9496github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY=
9597github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 h1:rwLdEpG9wE6kL69KkEKDiWprO8pQOZHZXeod6+9K+mw=
9698github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904/go.mod h1:8TIYxZxsuCqqeJ0lga/b91tBwrbjoHDC66Sq5t8N2R4=
97−github.com/charmbracelet/catwalk v0.13.0 h1:L+chddP+PJvX3Vl+hqlWW5HAwBErlkL/friQXih1JQI=
98−github.com/charmbracelet/catwalk v0.13.0/go.mod h1:qg+Yl9oaZTkTvRscqbxfttzOFQ4v0pOT5XwC7b5O0NQ=
99+github.com/charmbracelet/catwalk v0.14.1 h1:n16H880MHW8PPgQeh0dorP77AJMxw5JcOUPuC3FFhaQ=
100+github.com/charmbracelet/catwalk v0.14.1/go.mod h1:qg+Yl9oaZTkTvRscqbxfttzOFQ4v0pOT5XwC7b5O0NQ=
99101github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
100102github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
101103github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=
@@ -118,16 +120,16 @@ github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff h1:Uwr+/
118120github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
119121github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
120122github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
121−github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4 h1:i/XilBPYK4L1Yo/mc9FPx0SyJzIsN0y4sj1MWq9Sscc=
122−github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
123+github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b h1:5ye9hzBKH623bMVz5auIuY6K21loCdxpRmFle2O9R/8=
124+github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
123125github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
124126github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
125127github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
126128github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
127129github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
128130github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
129−github.com/clipperhouse/displaywidth v0.6.1 h1:/zMlAezfDzT2xy6acHBzwIfyu2ic0hgkT83UX5EY2gY=
130−github.com/clipperhouse/displaywidth v0.6.1/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
131+github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo=
132+github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
131133github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
132134github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
133135github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
@@ -150,6 +152,8 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ
150152github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
151153github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
152154github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
155+github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff h1:vAcU1VsCRstZ9ty11yD/L0WDyT73S/gVfmuWvcWX5DA=
156+github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
153157github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
154158github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=
155159github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
@@ -389,8 +393,8 @@ golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
389393golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
390394golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
391395golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
392−golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
393−golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
396+golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
397+golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
394398golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
395399golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
396400golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
internal/agent/agent.go+60−49
@@ -87,12 +87,13 @@ type Model struct {
8787}
8888
8989type sessionAgent struct {
90− largeModel Model
91− smallModel Model
92− systemPromptPrefix string
93− systemPrompt string
90+ largeModel *csync.Value[Model]
91+ smallModel *csync.Value[Model]
92+ systemPromptPrefix *csync.Value[string]
93+ systemPrompt *csync.Value[string]
94+ tools *csync.Slice[fantasy.AgentTool]
95+
9496 isSubAgent bool
95− tools []fantasy.AgentTool
9697 sessions session.Service
9798 messages message.Service
9899 disableAutoSummarize bool
@@ -119,15 +120,15 @@ func NewSessionAgent(
119120 opts SessionAgentOptions,
120121) SessionAgent {
121122 return &sessionAgent{
122− largeModel: opts.LargeModel,
123− smallModel: opts.SmallModel,
124− systemPromptPrefix: opts.SystemPromptPrefix,
125− systemPrompt: opts.SystemPrompt,
123+ largeModel: csync.NewValue(opts.LargeModel),
124+ smallModel: csync.NewValue(opts.SmallModel),
125+ systemPromptPrefix: csync.NewValue(opts.SystemPromptPrefix),
126+ systemPrompt: csync.NewValue(opts.SystemPrompt),
126127 isSubAgent: opts.IsSubAgent,
127128 sessions: opts.Sessions,
128129 messages: opts.Messages,
129130 disableAutoSummarize: opts.DisableAutoSummarize,
130− tools: opts.Tools,
131+ tools: csync.NewSliceFrom(opts.Tools),
131132 isYolo: opts.IsYolo,
132133 messageQueue: csync.NewMap[string, []SessionAgentCall](),
133134 activeRequests: csync.NewMap[string, context.CancelFunc](),
@@ -153,15 +154,21 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
153154 return nil, nil
154155 }
155156
156− if len(a.tools) > 0 {
157+ // Copy mutable fields under lock to avoid races with SetTools/SetModels.
158+ agentTools := a.tools.Copy()
159+ largeModel := a.largeModel.Get()
160+ systemPrompt := a.systemPrompt.Get()
161+ promptPrefix := a.systemPromptPrefix.Get()
162+
163+ if len(agentTools) > 0 {
157164 // Add Anthropic caching to the last tool.
158− a.tools[len(a.tools)-1].SetProviderOptions(a.getCacheControlOptions())
165+ agentTools[len(agentTools)-1].SetProviderOptions(a.getCacheControlOptions())
159166 }
160167
161168 agent := fantasy.NewAgent(
162− a.largeModel.Model,
163− fantasy.WithSystemPrompt(a.systemPrompt),
164− fantasy.WithTools(a.tools...),
169+ largeModel.Model,
170+ fantasy.WithSystemPrompt(systemPrompt),
171+ fantasy.WithTools(agentTools...),
165172 )
166173
167174 sessionLock := sync.Mutex{}
@@ -183,6 +190,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
183190 a.generateTitle(titleCtx, call.SessionID, call.Prompt)
184191 })
185192 }
193+ defer wg.Wait()
186194
187195 // Add the user message to the session.
188196 _, err = a.createUserMessage(ctx, call)
@@ -233,7 +241,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
233241 prepared.Messages = append(prepared.Messages, userMessage.ToAIMessage()...)
234242 }
235243
236− prepared.Messages = a.workaroundProviderMediaLimitations(prepared.Messages)
244+ prepared.Messages = a.workaroundProviderMediaLimitations(prepared.Messages, largeModel)
237245
238246 lastSystemRoleInx := 0
239247 systemMessageUpdated := false
@@ -251,7 +259,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
251259 }
252260 }
253261
254− if promptPrefix := a.promptPrefix(); promptPrefix != "" {
262+ if promptPrefix != "" {
255263 prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(promptPrefix)}, prepared.Messages...)
256264 }
257265
@@ -259,15 +267,15 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
259267 assistantMsg, err = a.messages.Create(callContext, call.SessionID, message.CreateMessageParams{
260268 Role: message.Assistant,
261269 Parts: []message.ContentPart{},
262− Model: a.largeModel.ModelCfg.Model,
263− Provider: a.largeModel.ModelCfg.Provider,
270+ Model: largeModel.ModelCfg.Model,
271+ Provider: largeModel.ModelCfg.Provider,
264272 })
265273 if err != nil {
266274 return callContext, prepared, err
267275 }
268276 callContext = context.WithValue(callContext, tools.MessageIDContextKey, assistantMsg.ID)
269− callContext = context.WithValue(callContext, tools.SupportsImagesContextKey, a.largeModel.CatwalkCfg.SupportsImages)
270− callContext = context.WithValue(callContext, tools.ModelNameContextKey, a.largeModel.CatwalkCfg.Name)
277+ callContext = context.WithValue(callContext, tools.SupportsImagesContextKey, largeModel.CatwalkCfg.SupportsImages)
278+ callContext = context.WithValue(callContext, tools.ModelNameContextKey, largeModel.CatwalkCfg.Name)
271279 currentAssistant = &assistantMsg
272280 return callContext, prepared, err
273281 },
@@ -361,7 +369,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
361369 sessionLock.Unlock()
362370 return getSessionErr
363371 }
364− a.updateSessionUsage(a.largeModel, &updatedSession, stepResult.Usage, a.openrouterCost(stepResult.ProviderMetadata))
372+ a.updateSessionUsage(largeModel, &updatedSession, stepResult.Usage, a.openrouterCost(stepResult.ProviderMetadata))
365373 _, sessionErr := a.sessions.Save(genCtx, updatedSession)
366374 sessionLock.Unlock()
367375 if sessionErr != nil {
@@ -371,7 +379,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
371379 },
372380 StopWhen: []fantasy.StopCondition{
373381 func(_ []fantasy.StepResult) bool {
374− cw := int64(a.largeModel.CatwalkCfg.ContextWindow)
382+ cw := int64(largeModel.CatwalkCfg.ContextWindow)
375383 tokens := currentSession.CompletionTokens + currentSession.PromptTokens
376384 remaining := cw - tokens
377385 var threshold int64
@@ -473,7 +481,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
473481 currentAssistant.AddFinish(
474482 message.FinishReasonError,
475483 "Copilot model not enabled",
476− fmt.Sprintf("%q is not enabled in Copilot. Go to the following page to enable it. Then, wait 5 minutes before trying again. %s", a.largeModel.CatwalkCfg.Name, link),
484+ fmt.Sprintf("%q is not enabled in Copilot. Go to the following page to enable it. Then, wait 5 minutes before trying again. %s", largeModel.CatwalkCfg.Name, link),
477485 )
478486 } else {
479487 currentAssistant.AddFinish(message.FinishReasonError, cmp.Or(stringext.Capitalize(providerErr.Title), defaultTitle), providerErr.Message)
@@ -491,7 +499,6 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
491499 }
492500 return nil, err
493501 }
494− wg.Wait()
495502
496503 if shouldSummarize {
497504 a.activeRequests.Del(call.SessionID)
@@ -529,6 +536,10 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan
529536 return ErrSessionBusy
530537 }
531538
539+ // Copy mutable fields under lock to avoid races with SetModels.
540+ largeModel := a.largeModel.Get()
541+ systemPromptPrefix := a.systemPromptPrefix.Get()
542+
532543 currentSession, err := a.sessions.Get(ctx, sessionID)
533544 if err != nil {
534545 return fmt.Errorf("failed to get session: %w", err)
@@ -549,13 +560,13 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan
549560 defer a.activeRequests.Del(sessionID)
550561 defer cancel()
551562
552− agent := fantasy.NewAgent(a.largeModel.Model,
563+ agent := fantasy.NewAgent(largeModel.Model,
553564 fantasy.WithSystemPrompt(string(summaryPrompt)),
554565 )
555566 summaryMessage, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
556567 Role: message.Assistant,
557− Model: a.largeModel.Model.Model(),
558− Provider: a.largeModel.Model.Provider(),
568+ Model: largeModel.Model.Model(),
569+ Provider: largeModel.Model.Provider(),
559570 IsSummaryMessage: true,
560571 })
561572 if err != nil {
@@ -570,8 +581,8 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan
570581 ProviderOptions: opts,
571582 PrepareStep: func(callContext context.Context, options fantasy.PrepareStepFunctionOptions) (_ context.Context, prepared fantasy.PrepareStepResult, err error) {
572583 prepared.Messages = options.Messages
573− if a.systemPromptPrefix != "" {
574− prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(a.systemPromptPrefix)}, prepared.Messages...)
584+ if systemPromptPrefix != "" {
585+ prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(systemPromptPrefix)}, prepared.Messages...)
575586 }
576587 return callContext, prepared, nil
577588 },
@@ -622,7 +633,7 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan
622633 }
623634 }
624635
625− a.updateSessionUsage(a.largeModel, &currentSession, resp.TotalUsage, openrouterCost)
636+ a.updateSessionUsage(largeModel, &currentSession, resp.TotalUsage, openrouterCost)
626637
627638 // Just in case, get just the last usage info.
628639 usage := resp.Response.Usage
@@ -730,9 +741,13 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
730741 return
731742 }
732743
744+ smallModel := a.smallModel.Get()
745+ largeModel := a.largeModel.Get()
746+ systemPromptPrefix := a.systemPromptPrefix.Get()
747+
733748 var maxOutputTokens int64 = 40
734− if a.smallModel.CatwalkCfg.CanReason {
735− maxOutputTokens = a.smallModel.CatwalkCfg.DefaultMaxTokens
749+ if smallModel.CatwalkCfg.CanReason {
750+ maxOutputTokens = smallModel.CatwalkCfg.DefaultMaxTokens
736751 }
737752
738753 newAgent := func(m fantasy.LanguageModel, p []byte, tok int64) fantasy.Agent {
@@ -746,9 +761,9 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
746761 Prompt: fmt.Sprintf("Generate a concise title for the following content:\n\n%s\n <think>\n\n</think>", userPrompt),
747762 PrepareStep: func(callCtx context.Context, opts fantasy.PrepareStepFunctionOptions) (_ context.Context, prepared fantasy.PrepareStepResult, err error) {
748763 prepared.Messages = opts.Messages
749− if a.systemPromptPrefix != "" {
764+ if systemPromptPrefix != "" {
750765 prepared.Messages = append([]fantasy.Message{
751− fantasy.NewSystemMessage(a.systemPromptPrefix),
766+ fantasy.NewSystemMessage(systemPromptPrefix),
752767 }, prepared.Messages...)
753768 }
754769 return callCtx, prepared, nil
@@ -756,7 +771,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
756771 }
757772
758773 // Use the small model to generate the title.
759− model := &a.smallModel
774+ model := smallModel
760775 agent := newAgent(model.Model, titlePrompt, maxOutputTokens)
761776 resp, err := agent.Stream(ctx, streamCall)
762777 if err == nil {
@@ -765,7 +780,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
765780 } else {
766781 // It didn't work. Let's try with the big model.
767782 slog.Error("error generating title with small model; trying big model", "err", err)
768− model = &a.largeModel
783+ model = largeModel
769784 agent = newAgent(model.Model, titlePrompt, maxOutputTokens)
770785 resp, err = agent.Stream(ctx, streamCall)
771786 if err == nil {
@@ -960,24 +975,20 @@ func (a *sessionAgent) QueuedPromptsList(sessionID string) []string {
960975}
961976
962977func (a *sessionAgent) SetModels(large Model, small Model) {
963− a.largeModel = large
964− a.smallModel = small
978+ a.largeModel.Set(large)
979+ a.smallModel.Set(small)
965980}
966981
967982func (a *sessionAgent) SetTools(tools []fantasy.AgentTool) {
968− a.tools = tools
983+ a.tools.SetSlice(tools)
969984}
970985
971986func (a *sessionAgent) SetSystemPrompt(systemPrompt string) {
972− a.systemPrompt = systemPrompt
987+ a.systemPrompt.Set(systemPrompt)
973988}
974989
975990func (a *sessionAgent) Model() Model {
976− return a.largeModel
977−}
978−
979−func (a *sessionAgent) promptPrefix() string {
980− return a.systemPromptPrefix
991+ return a.largeModel.Get()
981992}
982993
983994// convertToToolResult converts a fantasy tool result to a message tool result.
@@ -1034,9 +1045,9 @@ func (a *sessionAgent) convertToToolResult(result fantasy.ToolResultContent) mes
10341045//
10351046// BEFORE: [tool result: image data]
10361047// AFTER: [tool result: "Image loaded - see attached"], [user: image attachment]
1037−func (a *sessionAgent) workaroundProviderMediaLimitations(messages []fantasy.Message) []fantasy.Message {
1038− providerSupportsMedia := a.largeModel.ModelCfg.Provider == string(catwalk.InferenceProviderAnthropic) ||
1039− a.largeModel.ModelCfg.Provider == string(catwalk.InferenceProviderBedrock)
1048+func (a *sessionAgent) workaroundProviderMediaLimitations(messages []fantasy.Message, largeModel Model) []fantasy.Message {
1049+ providerSupportsMedia := largeModel.ModelCfg.Provider == string(catwalk.InferenceProviderAnthropic) ||
1050+ largeModel.ModelCfg.Provider == string(catwalk.InferenceProviderBedrock)
10401051
10411052 if providerSupportsMedia {
10421053 return messages
internal/agent/event.go+6−6
@@ -7,23 +7,23 @@ import (
77 "github.com/charmbracelet/crush/internal/event"
88)
99
10−func (a sessionAgent) eventPromptSent(sessionID string) {
10+func (a *sessionAgent) eventPromptSent(sessionID string) {
1111 event.PromptSent(
12− a.eventCommon(sessionID, a.largeModel)...,
12+ a.eventCommon(sessionID, a.largeModel.Get())...,
1313 )
1414}
1515
16−func (a sessionAgent) eventPromptResponded(sessionID string, duration time.Duration) {
16+func (a *sessionAgent) eventPromptResponded(sessionID string, duration time.Duration) {
1717 event.PromptResponded(
1818 append(
19− a.eventCommon(sessionID, a.largeModel),
19+ a.eventCommon(sessionID, a.largeModel.Get()),
2020 "prompt duration pretty", duration.String(),
2121 "prompt duration in seconds", int64(duration.Seconds()),
2222 )...,
2323 )
2424}
2525
26−func (a sessionAgent) eventTokensUsed(sessionID string, model Model, usage fantasy.Usage, cost float64) {
26+func (a *sessionAgent) eventTokensUsed(sessionID string, model Model, usage fantasy.Usage, cost float64) {
2727 event.TokensUsed(
2828 append(
2929 a.eventCommon(sessionID, model),
@@ -37,7 +37,7 @@ func (a sessionAgent) eventTokensUsed(sessionID string, model Model, usage fanta
3737 )
3838}
3939
40−func (a sessionAgent) eventCommon(sessionID string, model Model) []any {
40+func (a *sessionAgent) eventCommon(sessionID string, model Model) []any {
4141 m := model.ModelCfg
4242
4343 return []any{
internal/csync/maps_test.go+4−3
@@ -4,6 +4,7 @@ import (
44 "encoding/json"
55 "maps"
66 "sync"
7+ "sync/atomic"
78 "testing"
89 "testing/synctest"
910 "time"
@@ -46,12 +47,12 @@ func TestNewLazyMap(t *testing.T) {
4647
4748 waiter := sync.Mutex{}
4849 waiter.Lock()
49− loadCalled := false
50+ var loadCalled atomic.Bool
5051
5152 loadFunc := func() map[string]int {
5253 waiter.Lock()
5354 defer waiter.Unlock()
54− loadCalled = true
55+ loadCalled.Store(true)
5556 return map[string]int{
5657 "key1": 1,
5758 "key2": 2,
@@ -63,7 +64,7 @@ func TestNewLazyMap(t *testing.T) {
6364
6465 waiter.Unlock() // Allow the load function to proceed
6566 time.Sleep(100 * time.Millisecond)
66− require.True(t, loadCalled)
67+ require.True(t, loadCalled.Load())
6768 require.Equal(t, 2, m.Len())
6869
6970 value, ok := m.Get("key1")
internal/csync/slices.go+10−34
@@ -2,7 +2,6 @@ package csync
22
33import (
44 "iter"
5− "slices"
65 "sync"
76)
87
@@ -63,24 +62,6 @@ func (s *Slice[T]) Append(items ...T) {
6362 s.inner = append(s.inner, items...)
6463}
6564
66−// Prepend adds an element to the beginning of the slice.
67−func (s *Slice[T]) Prepend(item T) {
68− s.mu.Lock()
69− defer s.mu.Unlock()
70− s.inner = append([]T{item}, s.inner...)
71−}
72−
73−// Delete removes the element at the specified index.
74−func (s *Slice[T]) Delete(index int) bool {
75− s.mu.Lock()
76− defer s.mu.Unlock()
77− if index < 0 || index >= len(s.inner) {
78− return false
79− }
80− s.inner = slices.Delete(s.inner, index, index+1)
81− return true
82−}
83−
8465// Get returns the element at the specified index.
8566func (s *Slice[T]) Get(index int) (T, bool) {
8667 s.mu.RLock()
@@ -92,17 +73,6 @@ func (s *Slice[T]) Get(index int) (T, bool) {
9273 return s.inner[index], true
9374}
9475
95−// Set updates the element at the specified index.
96−func (s *Slice[T]) Set(index int, item T) bool {
97− s.mu.Lock()
98− defer s.mu.Unlock()
99− if index < 0 || index >= len(s.inner) {
100− return false
101− }
102− s.inner[index] = item
103− return true
104−}
105−
10676// Len returns the number of elements in the slice.
10777func (s *Slice[T]) Len() int {
10878 s.mu.RLock()
@@ -131,10 +101,7 @@ func (s *Slice[T]) Seq() iter.Seq[T] {
131101
132102// Seq2 returns an iterator that yields index-value pairs from the slice.
133103func (s *Slice[T]) Seq2() iter.Seq2[int, T] {
134− s.mu.RLock()
135− items := make([]T, len(s.inner))
136− copy(items, s.inner)
137− s.mu.RUnlock()
104+ items := s.Copy()
138105 return func(yield func(int, T) bool) {
139106 for i, v := range items {
140107 if !yield(i, v) {
@@ -143,3 +110,12 @@ func (s *Slice[T]) Seq2() iter.Seq2[int, T] {
143110 }
144111 }
145112}
113+
114+// Copy returns a copy of the inner slice.
115+func (s *Slice[T]) Copy() []T {
116+ s.mu.RLock()
117+ defer s.mu.RUnlock()
118+ items := make([]T, len(s.inner))
119+ copy(items, s.inner)
120+ return items
121+}
internal/csync/slices_test.go−57
@@ -109,44 +109,6 @@ func TestSlice(t *testing.T) {
109109 require.Equal(t, "world", val)
110110 })
111111
112− t.Run("Prepend", func(t *testing.T) {
113− s := NewSlice[string]()
114− s.Append("world")
115− s.Prepend("hello")
116−
117− require.Equal(t, 2, s.Len())
118− val, ok := s.Get(0)
119− require.True(t, ok)
120− require.Equal(t, "hello", val)
121−
122− val, ok = s.Get(1)
123− require.True(t, ok)
124− require.Equal(t, "world", val)
125− })
126−
127− t.Run("Delete", func(t *testing.T) {
128− s := NewSliceFrom([]int{1, 2, 3, 4, 5})
129−
130− // Delete middle element
131− ok := s.Delete(2)
132− require.True(t, ok)
133− require.Equal(t, 4, s.Len())
134−
135− expected := []int{1, 2, 4, 5}
136− actual := slices.Collect(s.Seq())
137− require.Equal(t, expected, actual)
138−
139− // Delete out of bounds
140− ok = s.Delete(10)
141− require.False(t, ok)
142− require.Equal(t, 4, s.Len())
143−
144− // Delete negative index
145− ok = s.Delete(-1)
146− require.False(t, ok)
147− require.Equal(t, 4, s.Len())
148− })
149−
150112 t.Run("Get", func(t *testing.T) {
151113 s := NewSliceFrom([]string{"a", "b", "c"})
152114
@@ -163,25 +125,6 @@ func TestSlice(t *testing.T) {
163125 require.False(t, ok)
164126 })
165127
166− t.Run("Set", func(t *testing.T) {
167− s := NewSliceFrom([]string{"a", "b", "c"})
168−
169− ok := s.Set(1, "modified")
170− require.True(t, ok)
171−
172− val, ok := s.Get(1)
173− require.True(t, ok)
174− require.Equal(t, "modified", val)
175−
176− // Out of bounds
177− ok = s.Set(10, "invalid")
178− require.False(t, ok)
179−
180− // Negative index
181− ok = s.Set(-1, "invalid")
182− require.False(t, ok)
183− })
184−
185128 t.Run("SetSlice", func(t *testing.T) {
186129 s := NewSlice[int]()
187130 s.Append(1)
internal/csync/value.gonew file+44
@@ -0,0 +1,44 @@
1+package csync
2+
3+import (
4+ "reflect"
5+ "sync"
6+)
7+
8+// Value is a generic thread-safe wrapper for any value type.
9+//
10+// For slices, use [Slice]. For maps, use [Map]. Pointers are not supported.
11+type Value[T any] struct {
12+ v T
13+ mu sync.RWMutex
14+}
15+
16+// NewValue creates a new Value with the given initial value.
17+//
18+// Panics if t is a pointer, slice, or map. Use the dedicated types for those.
19+func NewValue[T any](t T) *Value[T] {
20+ v := reflect.ValueOf(t)
21+ switch v.Kind() {
22+ case reflect.Pointer:
23+ panic("csync.Value does not support pointer types")
24+ case reflect.Slice:
25+ panic("csync.Value does not support slice types; use csync.Slice")
26+ case reflect.Map:
27+ panic("csync.Value does not support map types; use csync.Map")
28+ }
29+ return &Value[T]{v: t}
30+}
31+
32+// Get returns the current value.
33+func (v *Value[T]) Get() T {
34+ v.mu.RLock()
35+ defer v.mu.RUnlock()
36+ return v.v
37+}
38+
39+// Set updates the value.
40+func (v *Value[T]) Set(t T) {
41+ v.mu.Lock()
42+ defer v.mu.Unlock()
43+ v.v = t
44+}
internal/csync/value_test.gonew file+99
@@ -0,0 +1,99 @@
1+package csync
2+
3+import (
4+ "sync"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestValue_GetSet(t *testing.T) {
11+ t.Parallel()
12+
13+ v := NewValue(42)
14+ require.Equal(t, 42, v.Get())
15+
16+ v.Set(100)
17+ require.Equal(t, 100, v.Get())
18+}
19+
20+func TestValue_ZeroValue(t *testing.T) {
21+ t.Parallel()
22+
23+ v := NewValue("")
24+ require.Equal(t, "", v.Get())
25+
26+ v.Set("hello")
27+ require.Equal(t, "hello", v.Get())
28+}
29+
30+func TestValue_Struct(t *testing.T) {
31+ t.Parallel()
32+
33+ type config struct {
34+ Name string
35+ Count int
36+ }
37+
38+ v := NewValue(config{Name: "test", Count: 1})
39+ require.Equal(t, config{Name: "test", Count: 1}, v.Get())
40+
41+ v.Set(config{Name: "updated", Count: 2})
42+ require.Equal(t, config{Name: "updated", Count: 2}, v.Get())
43+}
44+
45+func TestValue_PointerPanics(t *testing.T) {
46+ t.Parallel()
47+
48+ require.Panics(t, func() {
49+ NewValue(&struct{}{})
50+ })
51+}
52+
53+func TestValue_SlicePanics(t *testing.T) {
54+ t.Parallel()
55+
56+ require.Panics(t, func() {
57+ NewValue([]string{"a", "b"})
58+ })
59+}
60+
61+func TestValue_MapPanics(t *testing.T) {
62+ t.Parallel()
63+
64+ require.Panics(t, func() {
65+ NewValue(map[string]int{"a": 1})
66+ })
67+}
68+
69+func TestValue_ConcurrentAccess(t *testing.T) {
70+ t.Parallel()
71+
72+ v := NewValue(0)
73+ var wg sync.WaitGroup
74+
75+ // Concurrent writers.
76+ for i := range 100 {
77+ wg.Add(1)
78+ go func(val int) {
79+ defer wg.Done()
80+ v.Set(val)
81+ }(i)
82+ }
83+
84+ // Concurrent readers.
85+ for range 100 {
86+ wg.Add(1)
87+ go func() {
88+ defer wg.Done()
89+ _ = v.Get()
90+ }()
91+ }
92+
93+ wg.Wait()
94+
95+ // Value should be one of the set values (0-99).
96+ got := v.Get()
97+ require.GreaterOrEqual(t, got, 0)
98+ require.Less(t, got, 100)
99+}
internal/permission/permission.go+11−2
@@ -68,8 +68,9 @@ type permissionService struct {
6868 allowedTools []string
6969
7070 // used to make sure we only process one request at a time
71− requestMu sync.Mutex
72− activeRequest *PermissionRequest
71+ requestMu sync.Mutex
72+ activeRequest *PermissionRequest
73+ activeRequestMu sync.Mutex
7374}
7475
7576func (s *permissionService) GrantPersistent(permission PermissionRequest) {
@@ -86,9 +87,11 @@ func (s *permissionService) GrantPersistent(permission PermissionRequest) {
8687 s.sessionPermissions = append(s.sessionPermissions, permission)
8788 s.sessionPermissionsMu.Unlock()
8889
90+ s.activeRequestMu.Lock()
8991 if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
9092 s.activeRequest = nil
9193 }
94+ s.activeRequestMu.Unlock()
9295}
9396
9497func (s *permissionService) Grant(permission PermissionRequest) {
@@ -101,9 +104,11 @@ func (s *permissionService) Grant(permission PermissionRequest) {
101104 respCh <- true
102105 }
103106
107+ s.activeRequestMu.Lock()
104108 if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
105109 s.activeRequest = nil
106110 }
111+ s.activeRequestMu.Unlock()
107112}
108113
109114func (s *permissionService) Deny(permission PermissionRequest) {
@@ -117,9 +122,11 @@ func (s *permissionService) Deny(permission PermissionRequest) {
117122 respCh <- false
118123 }
119124
125+ s.activeRequestMu.Lock()
120126 if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
121127 s.activeRequest = nil
122128 }
129+ s.activeRequestMu.Unlock()
123130}
124131
125132func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRequest) (bool, error) {
@@ -190,7 +197,9 @@ func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRe
190197 }
191198 s.sessionPermissionsMu.RUnlock()
192199
200+ s.activeRequestMu.Lock()
193201 s.activeRequest = &permission
202+ s.activeRequestMu.Unlock()
194203
195204 respCh := make(chan bool, 1)
196205 s.pendingRequests.Set(permission.ID, respCh)
internal/permission/permission_test.go+2−2
@@ -189,7 +189,7 @@ func TestPermissionService_SequentialProperties(t *testing.T) {
189189 events := service.Subscribe(t.Context())
190190
191191 var wg sync.WaitGroup
192− results := make([]bool, 0)
192+ results := make([]bool, 3)
193193
194194 requests := []CreatePermissionRequest{
195195 {
@@ -220,7 +220,7 @@ func TestPermissionService_SequentialProperties(t *testing.T) {
220220 go func(index int, request CreatePermissionRequest) {
221221 defer wg.Done()
222222 result, _ := service.Request(t.Context(), request)
223− results = append(results, result)
223+ results[index] = result
224224 }(i, req)
225225 }
226226
internal/shell/background.go+36−7
@@ -19,6 +19,30 @@ const (
1919 CompletedJobRetentionMinutes = 8 * 60
2020)
2121
22+// syncBuffer is a thread-safe wrapper around bytes.Buffer.
23+type syncBuffer struct {
24+ buf bytes.Buffer
25+ mu sync.RWMutex
26+}
27+
28+func (sb *syncBuffer) Write(p []byte) (n int, err error) {
29+ sb.mu.Lock()
30+ defer sb.mu.Unlock()
31+ return sb.buf.Write(p)
32+}
33+
34+func (sb *syncBuffer) WriteString(s string) (n int, err error) {
35+ sb.mu.Lock()
36+ defer sb.mu.Unlock()
37+ return sb.buf.WriteString(s)
38+}
39+
40+func (sb *syncBuffer) String() string {
41+ sb.mu.RLock()
42+ defer sb.mu.RUnlock()
43+ return sb.buf.String()
44+}
45+
2246// BackgroundShell represents a shell running in the background.
2347type BackgroundShell struct {
2448 ID string
@@ -28,8 +52,8 @@ type BackgroundShell struct {
2852 WorkingDir string
2953 ctx context.Context
3054 cancel context.CancelFunc
31− stdout *bytes.Buffer
32− stderr *bytes.Buffer
55+ stdout *syncBuffer
56+ stderr *syncBuffer
3357 done chan struct{}
3458 exitErr error
3559 completedAt int64 // Unix timestamp when job completed (0 if still running)
@@ -46,12 +70,17 @@ var (
4670 idCounter atomic.Uint64
4771)
4872
73+// newBackgroundShellManager creates a new BackgroundShellManager instance.
74+func newBackgroundShellManager() *BackgroundShellManager {
75+ return &BackgroundShellManager{
76+ shells: csync.NewMap[string, *BackgroundShell](),
77+ }
78+}
79+
4980// GetBackgroundShellManager returns the singleton background shell manager.
5081func GetBackgroundShellManager() *BackgroundShellManager {
5182 backgroundManagerOnce.Do(func() {
52− backgroundManager = &BackgroundShellManager{
53− shells: csync.NewMap[string, *BackgroundShell](),
54− }
83+ backgroundManager = newBackgroundShellManager()
5584 })
5685 return backgroundManager
5786}
@@ -80,8 +109,8 @@ func (m *BackgroundShellManager) Start(ctx context.Context, workingDir string, b
80109 Shell: shell,
81110 ctx: shellCtx,
82111 cancel: cancel,
83− stdout: &bytes.Buffer{},
84− stderr: &bytes.Buffer{},
112+ stdout: &syncBuffer{},
113+ stderr: &syncBuffer{},
85114 done: make(chan struct{}),
86115 }
87116
internal/shell/background_test.go+8−8
@@ -14,7 +14,7 @@ func TestBackgroundShellManager_Start(t *testing.T) {
1414
1515 ctx := context.Background()
1616 workingDir := t.TempDir()
17− manager := GetBackgroundShellManager()
17+ manager := newBackgroundShellManager()
1818
1919 bgShell, err := manager.Start(ctx, workingDir, nil, "echo 'hello world'", "")
2020 if err != nil {
@@ -51,7 +51,7 @@ func TestBackgroundShellManager_Get(t *testing.T) {
5151
5252 ctx := context.Background()
5353 workingDir := t.TempDir()
54− manager := GetBackgroundShellManager()
54+ manager := newBackgroundShellManager()
5555
5656 bgShell, err := manager.Start(ctx, workingDir, nil, "echo 'test'", "")
5757 if err != nil {
@@ -77,7 +77,7 @@ func TestBackgroundShellManager_Kill(t *testing.T) {
7777
7878 ctx := context.Background()
7979 workingDir := t.TempDir()
80− manager := GetBackgroundShellManager()
80+ manager := newBackgroundShellManager()
8181
8282 // Start a long-running command
8383 bgShell, err := manager.Start(ctx, workingDir, nil, "sleep 10", "")
@@ -106,7 +106,7 @@ func TestBackgroundShellManager_Kill(t *testing.T) {
106106func TestBackgroundShellManager_KillNonExistent(t *testing.T) {
107107 t.Parallel()
108108
109− manager := GetBackgroundShellManager()
109+ manager := newBackgroundShellManager()
110110
111111 err := manager.Kill("non-existent-id")
112112 if err == nil {
@@ -119,7 +119,7 @@ func TestBackgroundShell_IsDone(t *testing.T) {
119119
120120 ctx := context.Background()
121121 workingDir := t.TempDir()
122− manager := GetBackgroundShellManager()
122+ manager := newBackgroundShellManager()
123123
124124 bgShell, err := manager.Start(ctx, workingDir, nil, "echo 'quick'", "")
125125 if err != nil {
@@ -142,7 +142,7 @@ func TestBackgroundShell_WithBlockFuncs(t *testing.T) {
142142
143143 ctx := context.Background()
144144 workingDir := t.TempDir()
145− manager := GetBackgroundShellManager()
145+ manager := newBackgroundShellManager()
146146
147147 blockFuncs := []BlockFunc{
148148 CommandsBlocker([]string{"curl", "wget"}),
@@ -180,7 +180,7 @@ func TestBackgroundShellManager_List(t *testing.T) {
180180
181181 ctx := context.Background()
182182 workingDir := t.TempDir()
183− manager := GetBackgroundShellManager()
183+ manager := newBackgroundShellManager()
184184
185185 // Start two shells
186186 bgShell1, err := manager.Start(ctx, workingDir, nil, "sleep 1", "")
@@ -224,7 +224,7 @@ func TestBackgroundShellManager_KillAll(t *testing.T) {
224224
225225 ctx := context.Background()
226226 workingDir := t.TempDir()
227− manager := GetBackgroundShellManager()
227+ manager := newBackgroundShellManager()
228228
229229 // Start multiple long-running shells
230230 shell1, err := manager.Start(ctx, workingDir, nil, "sleep 10", "")
internal/tui/components/chat/editor/editor.go+79
@@ -16,6 +16,7 @@ import (
1616 "charm.land/bubbles/v2/textarea"
1717 tea "charm.land/bubbletea/v2"
1818 "charm.land/lipgloss/v2"
19+ nativeclipboard "github.com/aymanbagabas/go-nativeclipboard"
1920 "github.com/charmbracelet/crush/internal/app"
2021 "github.com/charmbracelet/crush/internal/filetracker"
2122 "github.com/charmbracelet/crush/internal/fsext"
@@ -338,6 +339,84 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
338339 m.textarea.InsertRune('\n')
339340 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
340341 }
342+ // Handle image paste from clipboard
343+ if key.Matches(msg, m.keyMap.PasteImage) {
344+ imageData, err := nativeclipboard.Image.Read()
345+
346+ if err != nil || len(imageData) == 0 {
347+ // If no image data found, try to get text data (could be file path)
348+ var textData []byte
349+ textData, err = nativeclipboard.Text.Read()
350+ if err != nil || len(textData) == 0 {
351+ // If clipboard is empty, show a warning
352+ return m, util.ReportWarn("No data found in clipboard. Note: Some terminals may not support reading image data from clipboard directly.")
353+ }
354+
355+ // Check if the text data is a file path
356+ textStr := string(textData)
357+ // First, try to interpret as a file path (existing functionality)
358+ path := strings.ReplaceAll(textStr, "\\ ", " ")
359+ path, err = filepath.Abs(strings.TrimSpace(path))
360+ if err == nil {
361+ isAllowedType := false
362+ for _, ext := range filepicker.AllowedTypes {
363+ if strings.HasSuffix(path, ext) {
364+ isAllowedType = true
365+ break
366+ }
367+ }
368+ if isAllowedType {
369+ tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize)
370+ if !tooBig {
371+ content, err := os.ReadFile(path)
372+ if err == nil {
373+ mimeBufferSize := min(512, len(content))
374+ mimeType := http.DetectContentType(content[:mimeBufferSize])
375+ fileName := filepath.Base(path)
376+ attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
377+ return m, util.CmdHandler(filepicker.FilePickedMsg{
378+ Attachment: attachment,
379+ })
380+ }
381+ }
382+ }
383+ }
384+
385+ // If not a valid file path, show a warning
386+ return m, util.ReportWarn("No image found in clipboard")
387+ } else {
388+ // We have image data from the clipboard
389+ // Create a temporary file to store the clipboard image data
390+ tempFile, err := os.CreateTemp("", "clipboard_image_crush_*")
391+ if err != nil {
392+ return m, util.ReportError(err)
393+ }
394+ defer tempFile.Close()
395+
396+ // Write clipboard content to the temporary file
397+ _, err = tempFile.Write(imageData)
398+ if err != nil {
399+ return m, util.ReportError(err)
400+ }
401+
402+ // Determine the file extension based on the image data
403+ mimeBufferSize := min(512, len(imageData))
404+ mimeType := http.DetectContentType(imageData[:mimeBufferSize])
405+
406+ // Create an attachment from the temporary file
407+ fileName := filepath.Base(tempFile.Name())
408+ attachment := message.Attachment{
409+ FilePath: tempFile.Name(),
410+ FileName: fileName,
411+ MimeType: mimeType,
412+ Content: imageData,
413+ }
414+
415+ return m, util.CmdHandler(filepicker.FilePickedMsg{
416+ Attachment: attachment,
417+ })
418+ }
419+ }
341420 // Handle Enter key
342421 if m.textarea.Focused() && key.Matches(msg, m.keyMap.SendMessage) {
343422 value := m.textarea.Value()
internal/tui/components/chat/editor/keys.go+6
@@ -9,6 +9,7 @@ type EditorKeyMap struct {
99 SendMessage key.Binding
1010 OpenEditor key.Binding
1111 Newline key.Binding
12+ PasteImage key.Binding
1213}
1314
1415func DefaultEditorKeyMap() EditorKeyMap {
@@ -32,6 +33,10 @@ func DefaultEditorKeyMap() EditorKeyMap {
3233 // to reflect that.
3334 key.WithHelp("ctrl+j", "newline"),
3435 ),
36+ PasteImage: key.NewBinding(
37+ key.WithKeys("ctrl+v"),
38+ key.WithHelp("ctrl+v", "paste image from clipboard"),
39+ ),
3540 }
3641}
3742
@@ -42,6 +47,7 @@ func (k EditorKeyMap) KeyBindings() []key.Binding {
4247 k.SendMessage,
4348 k.OpenEditor,
4449 k.Newline,
50+ k.PasteImage,
4551 AttachmentsKeyMaps.AttachmentDeleteMode,
4652 AttachmentsKeyMaps.DeleteAllAttachments,
4753 AttachmentsKeyMaps.Escape,
internal/tui/styles/theme.go+19−13
@@ -4,6 +4,7 @@ import (
44 "fmt"
55 "image/color"
66 "strings"
7+ "sync"
78
89 "charm.land/bubbles/v2/filepicker"
910 "charm.land/bubbles/v2/help"
@@ -97,7 +98,8 @@ type Theme struct {
9798 AuthBorderUnselected lipgloss.Style
9899 AuthTextUnselected lipgloss.Style
99100
100− styles *Styles
101+ styles *Styles
102+ stylesOnce sync.Once
101103}
102104
103105type Styles struct {
@@ -134,9 +136,9 @@ type Styles struct {
134136}
135137
136138func (t *Theme) S() *Styles {
137− if t.styles == nil {
139+ t.stylesOnce.Do(func() {
138140 t.styles = t.buildStyles()
139− }
141+ })
140142 return t.styles
141143}
142144
@@ -500,27 +502,31 @@ type Manager struct {
500502 current *Theme
501503}
502504
503−var defaultManager *Manager
505+var (
506+ defaultManager *Manager
507+ defaultManagerOnce sync.Once
508+)
509+
510+func initDefaultManager() *Manager {
511+ defaultManagerOnce.Do(func() {
512+ defaultManager = newManager()
513+ })
514+ return defaultManager
515+}
504516
505517func SetDefaultManager(m *Manager) {
506518 defaultManager = m
507519}
508520
509521func DefaultManager() *Manager {
510− if defaultManager == nil {
511− defaultManager = NewManager()
512− }
513− return defaultManager
522+ return initDefaultManager()
514523}
515524
516525func CurrentTheme() *Theme {
517− if defaultManager == nil {
518− defaultManager = NewManager()
519− }
520− return defaultManager.Current()
526+ return initDefaultManager().Current()
521527}
522528
523−func NewManager() *Manager {
529+func newManager() *Manager {
524530 m := &Manager{
525531 themes: make(map[string]*Theme),
526532 }