on main help sign in
Merge branch 'ui' into ui-picker
b442bf9Ayman Bagabas
.github/cla-signatures.json+16
@@ -1055,6 +1055,22 @@
1055 "created_at": "2026-01-12T22:16:05Z",
1055 "created_at": "2026-01-12T22:16:05Z",
1056 "repoId": 987670088,
1056 "repoId": 987670088,
1057 "pullRequestNo": 1841
1057 "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
1058 }
1074 }
1059 ]
1075 ]
1060}
1076}
.github/workflows/build.yml+22−6
@@ -1,11 +1,27 @@
1name: build
1name: build
2on: [push, pull_request]
2on: [push, pull_request]
3
3
4+permissions:
5+ contents: read
6+
7+concurrency:
8+ group: build-${{ github.event.pull_request.number || github.ref }}
9+ cancel-in-progress: true
10+
4jobs:
11jobs:
5 build:
12 build:
6− uses: charmbracelet/meta/.github/workflows/build.yml@main
13+ strategy:
7− with:
14+ matrix:
8− go-version: ""
15+ os: [ubuntu-latest, macos-latest, windows-latest]
9− go-version-file: ./go.mod
16+ runs-on: ${{ matrix.os }}
10− secrets:
17+ steps:
11− gh_pat: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
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 @@
1# Crush
1# Crush
2
2
3<p align="center">
3<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 />
5 <a href="https://github.com/charmbracelet/crush/releases"><img src="https://img.shields.io/github/release/charmbracelet/crush" alt="Latest Release"></a>
5 <a href="https://github.com/charmbracelet/crush/releases"><img src="https://img.shields.io/github/release/charmbracelet/crush" alt="Latest Release"></a>
6 <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>
6 <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>
7</p>
7</p>
Taskfile.yaml+6−4
@@ -5,6 +5,8 @@ version: "3"
5vars:
5vars:
6 VERSION:
6 VERSION:
7 sh: git describe --long 2>/dev/null || echo ""
7 sh: git describe --long 2>/dev/null || echo ""
8+ RACE:
9+ sh: test -f race.log && echo "1" || echo ""
8
10
9env:
11env:
10 CGO_ENABLED: 0
12 CGO_ENABLED: 0
@@ -37,20 +39,20 @@ tasks:
37 vars:
39 vars:
38 LDFLAGS: '{{if .VERSION}}-ldflags="-X github.com/charmbracelet/crush/internal/version.Version={{.VERSION}}"{{end}}'
40 LDFLAGS: '{{if .VERSION}}-ldflags="-X github.com/charmbracelet/crush/internal/version.Version={{.VERSION}}"{{end}}'
39 cmds:
41 cmds:
40− - go build {{.LDFLAGS}} .
42+ - "go build {{if .RACE}}-race{{end}} {{.LDFLAGS}} ."
41 generates:
43 generates:
42 - crush
44 - crush
43
45
44 run:
46 run:
45 desc: Run build
47 desc: Run build
46 cmds:
48 cmds:
47− - go build -o crush .
49+ - task: build
48− - ./crush {{.CLI_ARGS}}
50+ - "./crush {{.CLI_ARGS}} {{if .RACE}}2>race.log{{end}}"
49
51
50 test:
52 test:
51 desc: Run tests
53 desc: Run tests
52 cmds:
54 cmds:
53− - go test ./... {{.CLI_ARGS}}
55+ - go test -race -failfast ./... {{.CLI_ARGS}}
54
56
55 test:record:
57 test:record:
56 desc: Run tests and record all VCR cassettes again
58 desc: Run tests and record all VCR cassettes again
go.mod+5−3
@@ -15,10 +15,11 @@ require (
15 github.com/PuerkitoBio/goquery v1.11.0
15 github.com/PuerkitoBio/goquery v1.11.0
16 github.com/alecthomas/chroma/v2 v2.22.0
16 github.com/alecthomas/chroma/v2 v2.22.0
17 github.com/atotto/clipboard v0.1.4
17 github.com/atotto/clipboard v0.1.4
18+ github.com/aymanbagabas/go-nativeclipboard v0.1.2
18 github.com/aymanbagabas/go-udiff v0.3.1
19 github.com/aymanbagabas/go-udiff v0.3.1
19 github.com/bmatcuk/doublestar/v4 v4.9.2
20 github.com/bmatcuk/doublestar/v4 v4.9.2
20 github.com/charlievieth/fastwalk v1.0.14
21 github.com/charlievieth/fastwalk v1.0.14
21− github.com/charmbracelet/catwalk v0.13.0
22+ github.com/charmbracelet/catwalk v0.14.1
22 github.com/charmbracelet/colorprofile v0.4.1
23 github.com/charmbracelet/colorprofile v0.4.1
23 github.com/charmbracelet/fang v0.4.4
24 github.com/charmbracelet/fang v0.4.4
24 github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560
25 github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560
@@ -30,7 +31,7 @@ require (
30 github.com/charmbracelet/x/exp/ordered v0.1.0
31 github.com/charmbracelet/x/exp/ordered v0.1.0
31 github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff
32 github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff
32 github.com/charmbracelet/x/mosaic v0.0.0-20251215102626-e0db08df7383
33 github.com/charmbracelet/x/mosaic v0.0.0-20251215102626-e0db08df7383
33− github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4
34+ github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b
34 github.com/charmbracelet/x/term v0.2.2
35 github.com/charmbracelet/x/term v0.2.2
35 github.com/denisbrodbeck/machineid v1.0.1
36 github.com/denisbrodbeck/machineid v1.0.1
36 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
37 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
@@ -103,12 +104,13 @@ require (
103 github.com/charmbracelet/x/json v0.2.0 // indirect
104 github.com/charmbracelet/x/json v0.2.0 // indirect
104 github.com/charmbracelet/x/termios v0.1.1 // indirect
105 github.com/charmbracelet/x/termios v0.1.1 // indirect
105 github.com/charmbracelet/x/windows v0.2.2 // indirect
106 github.com/charmbracelet/x/windows v0.2.2 // indirect
106− github.com/clipperhouse/displaywidth v0.6.1 // indirect
107+ github.com/clipperhouse/displaywidth v0.6.2 // indirect
107 github.com/clipperhouse/stringish v0.1.1 // indirect
108 github.com/clipperhouse/stringish v0.1.1 // indirect
108 github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
109 github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
109 github.com/davecgh/go-spew v1.1.1 // indirect
110 github.com/davecgh/go-spew v1.1.1 // indirect
110 github.com/disintegration/gift v1.1.2 // indirect
111 github.com/disintegration/gift v1.1.2 // indirect
111 github.com/dlclark/regexp2 v1.11.5 // indirect
112 github.com/dlclark/regexp2 v1.11.5 // indirect
113+ github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff // indirect
112 github.com/felixge/httpsnoop v1.0.4 // indirect
114 github.com/felixge/httpsnoop v1.0.4 // indirect
113 github.com/fsnotify/fsnotify v1.9.0 // indirect
115 github.com/fsnotify/fsnotify v1.9.0 // indirect
114 github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect
116 github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect
go.sum+10−6
@@ -78,6 +78,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/
78github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
78github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
79github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
79github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
80github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
80github.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=
81github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
83github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
82github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
84github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
83github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
85github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
@@ -94,8 +96,8 @@ github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICg
94github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY=
96github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY=
95github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 h1:rwLdEpG9wE6kL69KkEKDiWprO8pQOZHZXeod6+9K+mw=
97github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 h1:rwLdEpG9wE6kL69KkEKDiWprO8pQOZHZXeod6+9K+mw=
96github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904/go.mod h1:8TIYxZxsuCqqeJ0lga/b91tBwrbjoHDC66Sq5t8N2R4=
98github.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=
99+github.com/charmbracelet/catwalk v0.14.1 h1:n16H880MHW8PPgQeh0dorP77AJMxw5JcOUPuC3FFhaQ=
98−github.com/charmbracelet/catwalk v0.13.0/go.mod h1:qg+Yl9oaZTkTvRscqbxfttzOFQ4v0pOT5XwC7b5O0NQ=
100+github.com/charmbracelet/catwalk v0.14.1/go.mod h1:qg+Yl9oaZTkTvRscqbxfttzOFQ4v0pOT5XwC7b5O0NQ=
99github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
101github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
100github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
102github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
101github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=
103github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=
@@ -120,16 +122,16 @@ github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQA
120github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
122github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
121github.com/charmbracelet/x/mosaic v0.0.0-20251215102626-e0db08df7383 h1:YpTd2/abobMn/dCRM6Vo+G7JO/VS6RW0Ln3YkVJih8Y=
123github.com/charmbracelet/x/mosaic v0.0.0-20251215102626-e0db08df7383 h1:YpTd2/abobMn/dCRM6Vo+G7JO/VS6RW0Ln3YkVJih8Y=
122github.com/charmbracelet/x/mosaic v0.0.0-20251215102626-e0db08df7383/go.mod h1:r+fiJS0jb0Z5XKO+1mgKbwbPWzTy8e2dMjBMqa+XqsY=
124github.com/charmbracelet/x/mosaic v0.0.0-20251215102626-e0db08df7383/go.mod h1:r+fiJS0jb0Z5XKO+1mgKbwbPWzTy8e2dMjBMqa+XqsY=
123−github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4 h1:i/XilBPYK4L1Yo/mc9FPx0SyJzIsN0y4sj1MWq9Sscc=
125+github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b h1:5ye9hzBKH623bMVz5auIuY6K21loCdxpRmFle2O9R/8=
124−github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
126+github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
125github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
127github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
126github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
128github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
127github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
129github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
128github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
130github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
129github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
131github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
130github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
132github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
131−github.com/clipperhouse/displaywidth v0.6.1 h1:/zMlAezfDzT2xy6acHBzwIfyu2ic0hgkT83UX5EY2gY=
133+github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo=
132−github.com/clipperhouse/displaywidth v0.6.1/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
134+github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
133github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
135github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
134github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
136github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
135github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
137github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
@@ -154,6 +156,8 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ
154github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
156github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
155github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
157github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
156github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
158github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
159+github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff h1:vAcU1VsCRstZ9ty11yD/L0WDyT73S/gVfmuWvcWX5DA=
160+github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
157github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
161github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
158github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=
162github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=
159github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
163github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
internal/agent/agent.go+60−49
@@ -87,12 +87,13 @@ type Model struct {
87}
87}
88
88
89type sessionAgent struct {
89type sessionAgent struct {
90− largeModel Model
90+ largeModel *csync.Value[Model]
91− smallModel Model
91+ smallModel *csync.Value[Model]
92− systemPromptPrefix string
92+ systemPromptPrefix *csync.Value[string]
93− systemPrompt string
93+ systemPrompt *csync.Value[string]
94+ tools *csync.Slice[fantasy.AgentTool]
95+
94 isSubAgent bool
96 isSubAgent bool
95− tools []fantasy.AgentTool
96 sessions session.Service
97 sessions session.Service
97 messages message.Service
98 messages message.Service
98 disableAutoSummarize bool
99 disableAutoSummarize bool
@@ -119,15 +120,15 @@ func NewSessionAgent(
119 opts SessionAgentOptions,
120 opts SessionAgentOptions,
120) SessionAgent {
121) SessionAgent {
121 return &sessionAgent{
122 return &sessionAgent{
122− largeModel: opts.LargeModel,
123+ largeModel: csync.NewValue(opts.LargeModel),
123− smallModel: opts.SmallModel,
124+ smallModel: csync.NewValue(opts.SmallModel),
124− systemPromptPrefix: opts.SystemPromptPrefix,
125+ systemPromptPrefix: csync.NewValue(opts.SystemPromptPrefix),
125− systemPrompt: opts.SystemPrompt,
126+ systemPrompt: csync.NewValue(opts.SystemPrompt),
126 isSubAgent: opts.IsSubAgent,
127 isSubAgent: opts.IsSubAgent,
127 sessions: opts.Sessions,
128 sessions: opts.Sessions,
128 messages: opts.Messages,
129 messages: opts.Messages,
129 disableAutoSummarize: opts.DisableAutoSummarize,
130 disableAutoSummarize: opts.DisableAutoSummarize,
130− tools: opts.Tools,
131+ tools: csync.NewSliceFrom(opts.Tools),
131 isYolo: opts.IsYolo,
132 isYolo: opts.IsYolo,
132 messageQueue: csync.NewMap[string, []SessionAgentCall](),
133 messageQueue: csync.NewMap[string, []SessionAgentCall](),
133 activeRequests: csync.NewMap[string, context.CancelFunc](),
134 activeRequests: csync.NewMap[string, context.CancelFunc](),
@@ -153,15 +154,21 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
153 return nil, nil
154 return nil, nil
154 }
155 }
155
156
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+
156− if len(a.tools) > 0 {
163+ if len(agentTools) > 0 {
157 // Add Anthropic caching to the last tool.
164 // 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())
159 }
166 }
160
167
161 agent := fantasy.NewAgent(
168 agent := fantasy.NewAgent(
162− a.largeModel.Model,
169+ largeModel.Model,
163− fantasy.WithSystemPrompt(a.systemPrompt),
170+ fantasy.WithSystemPrompt(systemPrompt),
164− fantasy.WithTools(a.tools...),
171+ fantasy.WithTools(agentTools...),
165 )
172 )
166
173
167 sessionLock := sync.Mutex{}
174 sessionLock := sync.Mutex{}
@@ -183,6 +190,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
183 a.generateTitle(titleCtx, call.SessionID, call.Prompt)
190 a.generateTitle(titleCtx, call.SessionID, call.Prompt)
184 })
191 })
185 }
192 }
193+ defer wg.Wait()
186
194
187 // Add the user message to the session.
195 // Add the user message to the session.
188 _, err = a.createUserMessage(ctx, call)
196 _, err = a.createUserMessage(ctx, call)
@@ -233,7 +241,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
233 prepared.Messages = append(prepared.Messages, userMessage.ToAIMessage()...)
241 prepared.Messages = append(prepared.Messages, userMessage.ToAIMessage()...)
234 }
242 }
235
243
236− prepared.Messages = a.workaroundProviderMediaLimitations(prepared.Messages)
244+ prepared.Messages = a.workaroundProviderMediaLimitations(prepared.Messages, largeModel)
237
245
238 lastSystemRoleInx := 0
246 lastSystemRoleInx := 0
239 systemMessageUpdated := false
247 systemMessageUpdated := false
@@ -251,7 +259,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
251 }
259 }
252 }
260 }
253
261
254− if promptPrefix := a.promptPrefix(); promptPrefix != "" {
262+ if promptPrefix != "" {
255 prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(promptPrefix)}, prepared.Messages...)
263 prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(promptPrefix)}, prepared.Messages...)
256 }
264 }
257
265
@@ -259,15 +267,15 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
259 assistantMsg, err = a.messages.Create(callContext, call.SessionID, message.CreateMessageParams{
267 assistantMsg, err = a.messages.Create(callContext, call.SessionID, message.CreateMessageParams{
260 Role: message.Assistant,
268 Role: message.Assistant,
261 Parts: []message.ContentPart{},
269 Parts: []message.ContentPart{},
262− Model: a.largeModel.ModelCfg.Model,
270+ Model: largeModel.ModelCfg.Model,
263− Provider: a.largeModel.ModelCfg.Provider,
271+ Provider: largeModel.ModelCfg.Provider,
264 })
272 })
265 if err != nil {
273 if err != nil {
266 return callContext, prepared, err
274 return callContext, prepared, err
267 }
275 }
268 callContext = context.WithValue(callContext, tools.MessageIDContextKey, assistantMsg.ID)
276 callContext = context.WithValue(callContext, tools.MessageIDContextKey, assistantMsg.ID)
269− callContext = context.WithValue(callContext, tools.SupportsImagesContextKey, a.largeModel.CatwalkCfg.SupportsImages)
277+ callContext = context.WithValue(callContext, tools.SupportsImagesContextKey, largeModel.CatwalkCfg.SupportsImages)
270− callContext = context.WithValue(callContext, tools.ModelNameContextKey, a.largeModel.CatwalkCfg.Name)
278+ callContext = context.WithValue(callContext, tools.ModelNameContextKey, largeModel.CatwalkCfg.Name)
271 currentAssistant = &assistantMsg
279 currentAssistant = &assistantMsg
272 return callContext, prepared, err
280 return callContext, prepared, err
273 },
281 },
@@ -361,7 +369,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
361 sessionLock.Unlock()
369 sessionLock.Unlock()
362 return getSessionErr
370 return getSessionErr
363 }
371 }
364− a.updateSessionUsage(a.largeModel, &updatedSession, stepResult.Usage, a.openrouterCost(stepResult.ProviderMetadata))
372+ a.updateSessionUsage(largeModel, &updatedSession, stepResult.Usage, a.openrouterCost(stepResult.ProviderMetadata))
365 _, sessionErr := a.sessions.Save(genCtx, updatedSession)
373 _, sessionErr := a.sessions.Save(genCtx, updatedSession)
366 sessionLock.Unlock()
374 sessionLock.Unlock()
367 if sessionErr != nil {
375 if sessionErr != nil {
@@ -371,7 +379,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
371 },
379 },
372 StopWhen: []fantasy.StopCondition{
380 StopWhen: []fantasy.StopCondition{
373 func(_ []fantasy.StepResult) bool {
381 func(_ []fantasy.StepResult) bool {
374− cw := int64(a.largeModel.CatwalkCfg.ContextWindow)
382+ cw := int64(largeModel.CatwalkCfg.ContextWindow)
375 tokens := currentSession.CompletionTokens + currentSession.PromptTokens
383 tokens := currentSession.CompletionTokens + currentSession.PromptTokens
376 remaining := cw - tokens
384 remaining := cw - tokens
377 var threshold int64
385 var threshold int64
@@ -473,7 +481,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
473 currentAssistant.AddFinish(
481 currentAssistant.AddFinish(
474 message.FinishReasonError,
482 message.FinishReasonError,
475 "Copilot model not enabled",
483 "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),
477 )
485 )
478 } else {
486 } else {
479 currentAssistant.AddFinish(message.FinishReasonError, cmp.Or(stringext.Capitalize(providerErr.Title), defaultTitle), providerErr.Message)
487 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
491 }
499 }
492 return nil, err
500 return nil, err
493 }
501 }
494− wg.Wait()
495
502
496 if shouldSummarize {
503 if shouldSummarize {
497 a.activeRequests.Del(call.SessionID)
504 a.activeRequests.Del(call.SessionID)
@@ -529,6 +536,10 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan
529 return ErrSessionBusy
536 return ErrSessionBusy
530 }
537 }
531
538
539+ // Copy mutable fields under lock to avoid races with SetModels.
540+ largeModel := a.largeModel.Get()
541+ systemPromptPrefix := a.systemPromptPrefix.Get()
542+
532 currentSession, err := a.sessions.Get(ctx, sessionID)
543 currentSession, err := a.sessions.Get(ctx, sessionID)
533 if err != nil {
544 if err != nil {
534 return fmt.Errorf("failed to get session: %w", err)
545 return fmt.Errorf("failed to get session: %w", err)
@@ -549,13 +560,13 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan
549 defer a.activeRequests.Del(sessionID)
560 defer a.activeRequests.Del(sessionID)
550 defer cancel()
561 defer cancel()
551
562
552− agent := fantasy.NewAgent(a.largeModel.Model,
563+ agent := fantasy.NewAgent(largeModel.Model,
553 fantasy.WithSystemPrompt(string(summaryPrompt)),
564 fantasy.WithSystemPrompt(string(summaryPrompt)),
554 )
565 )
555 summaryMessage, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
566 summaryMessage, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
556 Role: message.Assistant,
567 Role: message.Assistant,
557− Model: a.largeModel.Model.Model(),
568+ Model: largeModel.Model.Model(),
558− Provider: a.largeModel.Model.Provider(),
569+ Provider: largeModel.Model.Provider(),
559 IsSummaryMessage: true,
570 IsSummaryMessage: true,
560 })
571 })
561 if err != nil {
572 if err != nil {
@@ -570,8 +581,8 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan
570 ProviderOptions: opts,
581 ProviderOptions: opts,
571 PrepareStep: func(callContext context.Context, options fantasy.PrepareStepFunctionOptions) (_ context.Context, prepared fantasy.PrepareStepResult, err error) {
582 PrepareStep: func(callContext context.Context, options fantasy.PrepareStepFunctionOptions) (_ context.Context, prepared fantasy.PrepareStepResult, err error) {
572 prepared.Messages = options.Messages
583 prepared.Messages = options.Messages
573− if a.systemPromptPrefix != "" {
584+ if systemPromptPrefix != "" {
574− prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(a.systemPromptPrefix)}, prepared.Messages...)
585+ prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(systemPromptPrefix)}, prepared.Messages...)
575 }
586 }
576 return callContext, prepared, nil
587 return callContext, prepared, nil
577 },
588 },
@@ -622,7 +633,7 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan
622 }
633 }
623 }
634 }
624
635
625− a.updateSessionUsage(a.largeModel, &currentSession, resp.TotalUsage, openrouterCost)
636+ a.updateSessionUsage(largeModel, &currentSession, resp.TotalUsage, openrouterCost)
626
637
627 // Just in case, get just the last usage info.
638 // Just in case, get just the last usage info.
628 usage := resp.Response.Usage
639 usage := resp.Response.Usage
@@ -730,9 +741,13 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
730 return
741 return
731 }
742 }
732
743
744+ smallModel := a.smallModel.Get()
745+ largeModel := a.largeModel.Get()
746+ systemPromptPrefix := a.systemPromptPrefix.Get()
747+
733 var maxOutputTokens int64 = 40
748 var maxOutputTokens int64 = 40
734− if a.smallModel.CatwalkCfg.CanReason {
749+ if smallModel.CatwalkCfg.CanReason {
735− maxOutputTokens = a.smallModel.CatwalkCfg.DefaultMaxTokens
750+ maxOutputTokens = smallModel.CatwalkCfg.DefaultMaxTokens
736 }
751 }
737
752
738 newAgent := func(m fantasy.LanguageModel, p []byte, tok int64) fantasy.Agent {
753 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
746 Prompt: fmt.Sprintf("Generate a concise title for the following content:\n\n%s\n <think>\n\n</think>", userPrompt),
761 Prompt: fmt.Sprintf("Generate a concise title for the following content:\n\n%s\n <think>\n\n</think>", userPrompt),
747 PrepareStep: func(callCtx context.Context, opts fantasy.PrepareStepFunctionOptions) (_ context.Context, prepared fantasy.PrepareStepResult, err error) {
762 PrepareStep: func(callCtx context.Context, opts fantasy.PrepareStepFunctionOptions) (_ context.Context, prepared fantasy.PrepareStepResult, err error) {
748 prepared.Messages = opts.Messages
763 prepared.Messages = opts.Messages
749− if a.systemPromptPrefix != "" {
764+ if systemPromptPrefix != "" {
750 prepared.Messages = append([]fantasy.Message{
765 prepared.Messages = append([]fantasy.Message{
751− fantasy.NewSystemMessage(a.systemPromptPrefix),
766+ fantasy.NewSystemMessage(systemPromptPrefix),
752 }, prepared.Messages...)
767 }, prepared.Messages...)
753 }
768 }
754 return callCtx, prepared, nil
769 return callCtx, prepared, nil
@@ -756,7 +771,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
756 }
771 }
757
772
758 // Use the small model to generate the title.
773 // Use the small model to generate the title.
759− model := &a.smallModel
774+ model := smallModel
760 agent := newAgent(model.Model, titlePrompt, maxOutputTokens)
775 agent := newAgent(model.Model, titlePrompt, maxOutputTokens)
761 resp, err := agent.Stream(ctx, streamCall)
776 resp, err := agent.Stream(ctx, streamCall)
762 if err == nil {
777 if err == nil {
@@ -765,7 +780,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
765 } else {
780 } else {
766 // It didn't work. Let's try with the big model.
781 // It didn't work. Let's try with the big model.
767 slog.Error("error generating title with small model; trying big model", "err", err)
782 slog.Error("error generating title with small model; trying big model", "err", err)
768− model = &a.largeModel
783+ model = largeModel
769 agent = newAgent(model.Model, titlePrompt, maxOutputTokens)
784 agent = newAgent(model.Model, titlePrompt, maxOutputTokens)
770 resp, err = agent.Stream(ctx, streamCall)
785 resp, err = agent.Stream(ctx, streamCall)
771 if err == nil {
786 if err == nil {
@@ -960,24 +975,20 @@ func (a *sessionAgent) QueuedPromptsList(sessionID string) []string {
960}
975}
961
976
962func (a *sessionAgent) SetModels(large Model, small Model) {
977func (a *sessionAgent) SetModels(large Model, small Model) {
963− a.largeModel = large
978+ a.largeModel.Set(large)
964− a.smallModel = small
979+ a.smallModel.Set(small)
965}
980}
966
981
967func (a *sessionAgent) SetTools(tools []fantasy.AgentTool) {
982func (a *sessionAgent) SetTools(tools []fantasy.AgentTool) {
968− a.tools = tools
983+ a.tools.SetSlice(tools)
969}
984}
970
985
971func (a *sessionAgent) SetSystemPrompt(systemPrompt string) {
986func (a *sessionAgent) SetSystemPrompt(systemPrompt string) {
972− a.systemPrompt = systemPrompt
987+ a.systemPrompt.Set(systemPrompt)
973}
988}
974
989
975func (a *sessionAgent) Model() Model {
990func (a *sessionAgent) Model() Model {
976− return a.largeModel
991+ return a.largeModel.Get()
977−}
978−
979−func (a *sessionAgent) promptPrefix() string {
980− return a.systemPromptPrefix
981}
992}
982
993
983// convertToToolResult converts a fantasy tool result to a message tool result.
994// convertToToolResult converts a fantasy tool result to a message tool result.
@@ -1034,9 +1045,9 @@ func (a *sessionAgent) convertToToolResult(result fantasy.ToolResultContent) mes
1034//
1045//
1035// BEFORE: [tool result: image data]
1046// BEFORE: [tool result: image data]
1036// AFTER: [tool result: "Image loaded - see attached"], [user: image attachment]
1047// AFTER: [tool result: "Image loaded - see attached"], [user: image attachment]
1037−func (a *sessionAgent) workaroundProviderMediaLimitations(messages []fantasy.Message) []fantasy.Message {
1048+func (a *sessionAgent) workaroundProviderMediaLimitations(messages []fantasy.Message, largeModel Model) []fantasy.Message {
1038− providerSupportsMedia := a.largeModel.ModelCfg.Provider == string(catwalk.InferenceProviderAnthropic) ||
1049+ providerSupportsMedia := largeModel.ModelCfg.Provider == string(catwalk.InferenceProviderAnthropic) ||
1039− a.largeModel.ModelCfg.Provider == string(catwalk.InferenceProviderBedrock)
1050+ largeModel.ModelCfg.Provider == string(catwalk.InferenceProviderBedrock)
1040
1051
1041 if providerSupportsMedia {
1052 if providerSupportsMedia {
1042 return messages
1053 return messages
internal/agent/event.go+6−6
@@ -7,23 +7,23 @@ import (
7 "github.com/charmbracelet/crush/internal/event"
7 "github.com/charmbracelet/crush/internal/event"
8)
8)
9
9
10−func (a sessionAgent) eventPromptSent(sessionID string) {
10+func (a *sessionAgent) eventPromptSent(sessionID string) {
11 event.PromptSent(
11 event.PromptSent(
12− a.eventCommon(sessionID, a.largeModel)...,
12+ a.eventCommon(sessionID, a.largeModel.Get())...,
13 )
13 )
14}
14}
15
15
16−func (a sessionAgent) eventPromptResponded(sessionID string, duration time.Duration) {
16+func (a *sessionAgent) eventPromptResponded(sessionID string, duration time.Duration) {
17 event.PromptResponded(
17 event.PromptResponded(
18 append(
18 append(
19− a.eventCommon(sessionID, a.largeModel),
19+ a.eventCommon(sessionID, a.largeModel.Get()),
20 "prompt duration pretty", duration.String(),
20 "prompt duration pretty", duration.String(),
21 "prompt duration in seconds", int64(duration.Seconds()),
21 "prompt duration in seconds", int64(duration.Seconds()),
22 )...,
22 )...,
23 )
23 )
24}
24}
25
25
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) {
27 event.TokensUsed(
27 event.TokensUsed(
28 append(
28 append(
29 a.eventCommon(sessionID, model),
29 a.eventCommon(sessionID, model),
@@ -37,7 +37,7 @@ func (a sessionAgent) eventTokensUsed(sessionID string, model Model, usage fanta
37 )
37 )
38}
38}
39
39
40−func (a sessionAgent) eventCommon(sessionID string, model Model) []any {
40+func (a *sessionAgent) eventCommon(sessionID string, model Model) []any {
41 m := model.ModelCfg
41 m := model.ModelCfg
42
42
43 return []any{
43 return []any{
internal/commands/commands.go+44−20
@@ -1,6 +1,7 @@
1package commands
1package commands
2
2
3import (
3import (
4+ "context"
4 "io/fs"
5 "io/fs"
5 "os"
6 "os"
6 "path/filepath"
7 "path/filepath"
@@ -19,18 +20,22 @@ const (
19 projectCommandPrefix = "project:"
20 projectCommandPrefix = "project:"
20)
21)
21
22
22−// Argument represents a command argument with its name and required status.
23+// Argument represents a command argument with its metadata.
23type Argument struct {
24type Argument struct {
25+ ID string
26+ Title string
24− Name string
27+ Description string
25− Required bool
28+ Required bool
26}
29}
27
30
28−// MCPCustomCommand represents a custom command loaded from an MCP server.
31+// MCPPrompt represents a custom command loaded from an MCP server.
29−type MCPCustomCommand struct {
32+type MCPPrompt struct {
33+ ID string
34+ Title string
30− ID string
35+ Description string
31− Name string
36+ PromptID string
32− Client string
37+ ClientID string
33− Arguments []Argument
38+ Arguments []Argument
34}
39}
35
40
36// CustomCommand represents a user-defined custom command loaded from markdown files.
41// CustomCommand represents a user-defined custom command loaded from markdown files.
@@ -52,22 +57,32 @@ func LoadCustomCommands(cfg *config.Config) ([]CustomCommand, error) {
52 return loadAll(buildCommandSources(cfg))
57 return loadAll(buildCommandSources(cfg))
53}
58}
54
59
55−// LoadMCPCustomCommands loads custom commands from available MCP servers.
60+// LoadMCPPrompts loads custom commands from available MCP servers.
56−func LoadMCPCustomCommands() ([]MCPCustomCommand, error) {
61+func LoadMCPPrompts() ([]MCPPrompt, error) {
57− var commands []MCPCustomCommand
62+ var commands []MCPPrompt
58 for mcpName, prompts := range mcp.Prompts() {
63 for mcpName, prompts := range mcp.Prompts() {
59 for _, prompt := range prompts {
64 for _, prompt := range prompts {
60 key := mcpName + ":" + prompt.Name
65 key := mcpName + ":" + prompt.Name
61 var args []Argument
66 var args []Argument
62 for _, arg := range prompt.Arguments {
67 for _, arg := range prompt.Arguments {
68+ title := arg.Title
69+ if title == "" {
70+ title = arg.Name
71+ }
63− args = append(args, Argument{Name: arg.Name, Required: arg.Required})
72+ args = append(args, Argument{
73+ ID: arg.Name,
74+ Title: title,
75+ Description: arg.Description,
76+ Required: arg.Required,
77+ })
64 }
78 }
65−
66− commands = append(commands, MCPCustomCommand{
79+ commands = append(commands, MCPPrompt{
67− ID: key,
80+ ID: key,
81+ Title: prompt.Title,
82+ Description: prompt.Description,
68− Name: prompt.Name,
83+ PromptID: prompt.Name,
69− Client: mcpName,
84+ ClientID: mcpName,
70− Arguments: args,
85+ Arguments: args,
71 })
86 })
72 }
87 }
73 }
88 }
@@ -168,7 +183,7 @@ func extractArgNames(content string) []Argument {
168 if !seen[arg] {
183 if !seen[arg] {
169 seen[arg] = true
184 seen[arg] = true
170 // for normal custom commands, all args are required
185 // for normal custom commands, all args are required
171− args = append(args, Argument{Name: arg, Required: true})
186+ args = append(args, Argument{ID: arg, Title: arg, Required: true})
172 }
187 }
173 }
188 }
174
189
@@ -211,3 +226,12 @@ func ensureDir(path string) error {
211func isMarkdownFile(name string) bool {
226func isMarkdownFile(name string) bool {
212 return strings.HasSuffix(strings.ToLower(name), ".md")
227 return strings.HasSuffix(strings.ToLower(name), ".md")
213}
228}
229+
230+func GetMCPPrompt(clientID, promptID string, args map[string]string) (string, error) {
231+ // TODO: we should pass the context down
232+ result, err := mcp.GetPromptMessages(context.Background(), clientID, promptID, args)
233+ if err != nil {
234+ return "", err
235+ }
236+ return strings.Join(result, " "), nil
237+}
internal/csync/maps_test.go+4−3
@@ -4,6 +4,7 @@ import (
4 "encoding/json"
4 "encoding/json"
5 "maps"
5 "maps"
6 "sync"
6 "sync"
7+ "sync/atomic"
7 "testing"
8 "testing"
8 "testing/synctest"
9 "testing/synctest"
9 "time"
10 "time"
@@ -46,12 +47,12 @@ func TestNewLazyMap(t *testing.T) {
46
47
47 waiter := sync.Mutex{}
48 waiter := sync.Mutex{}
48 waiter.Lock()
49 waiter.Lock()
49− loadCalled := false
50+ var loadCalled atomic.Bool
50
51
51 loadFunc := func() map[string]int {
52 loadFunc := func() map[string]int {
52 waiter.Lock()
53 waiter.Lock()
53 defer waiter.Unlock()
54 defer waiter.Unlock()
54− loadCalled = true
55+ loadCalled.Store(true)
55 return map[string]int{
56 return map[string]int{
56 "key1": 1,
57 "key1": 1,
57 "key2": 2,
58 "key2": 2,
@@ -63,7 +64,7 @@ func TestNewLazyMap(t *testing.T) {
63
64
64 waiter.Unlock() // Allow the load function to proceed
65 waiter.Unlock() // Allow the load function to proceed
65 time.Sleep(100 * time.Millisecond)
66 time.Sleep(100 * time.Millisecond)
66− require.True(t, loadCalled)
67+ require.True(t, loadCalled.Load())
67 require.Equal(t, 2, m.Len())
68 require.Equal(t, 2, m.Len())
68
69
69 value, ok := m.Get("key1")
70 value, ok := m.Get("key1")
internal/csync/slices.go+10−34
@@ -2,7 +2,6 @@ package csync
2
2
3import (
3import (
4 "iter"
4 "iter"
5− "slices"
6 "sync"
5 "sync"
7)
6)
8
7
@@ -63,24 +62,6 @@ func (s *Slice[T]) Append(items ...T) {
63 s.inner = append(s.inner, items...)
62 s.inner = append(s.inner, items...)
64}
63}
65
64
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−
84// Get returns the element at the specified index.
65// Get returns the element at the specified index.
85func (s *Slice[T]) Get(index int) (T, bool) {
66func (s *Slice[T]) Get(index int) (T, bool) {
86 s.mu.RLock()
67 s.mu.RLock()
@@ -92,17 +73,6 @@ func (s *Slice[T]) Get(index int) (T, bool) {
92 return s.inner[index], true
73 return s.inner[index], true
93}
74}
94
75
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−
106// Len returns the number of elements in the slice.
76// Len returns the number of elements in the slice.
107func (s *Slice[T]) Len() int {
77func (s *Slice[T]) Len() int {
108 s.mu.RLock()
78 s.mu.RLock()
@@ -131,10 +101,7 @@ func (s *Slice[T]) Seq() iter.Seq[T] {
131
101
132// Seq2 returns an iterator that yields index-value pairs from the slice.
102// Seq2 returns an iterator that yields index-value pairs from the slice.
133func (s *Slice[T]) Seq2() iter.Seq2[int, T] {
103func (s *Slice[T]) Seq2() iter.Seq2[int, T] {
134− s.mu.RLock()
135− items := make([]T, len(s.inner))
104+ items := s.Copy()
136− copy(items, s.inner)
137− s.mu.RUnlock()
138 return func(yield func(int, T) bool) {
105 return func(yield func(int, T) bool) {
139 for i, v := range items {
106 for i, v := range items {
140 if !yield(i, v) {
107 if !yield(i, v) {
@@ -143,3 +110,12 @@ func (s *Slice[T]) Seq2() iter.Seq2[int, T] {
143 }
110 }
144 }
111 }
145}
112}
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) {
109 require.Equal(t, "world", val)
109 require.Equal(t, "world", val)
110 })
110 })
111
111
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−
150 t.Run("Get", func(t *testing.T) {
112 t.Run("Get", func(t *testing.T) {
151 s := NewSliceFrom([]string{"a", "b", "c"})
113 s := NewSliceFrom([]string{"a", "b", "c"})
152
114
@@ -163,25 +125,6 @@ func TestSlice(t *testing.T) {
163 require.False(t, ok)
125 require.False(t, ok)
164 })
126 })
165
127
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−
185 t.Run("SetSlice", func(t *testing.T) {
128 t.Run("SetSlice", func(t *testing.T) {
186 s := NewSlice[int]()
129 s := NewSlice[int]()
187 s.Append(1)
130 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 {
68 allowedTools []string
68 allowedTools []string
69
69
70 // used to make sure we only process one request at a time
70 // used to make sure we only process one request at a time
71− requestMu sync.Mutex
71+ requestMu sync.Mutex
72− activeRequest *PermissionRequest
72+ activeRequest *PermissionRequest
73+ activeRequestMu sync.Mutex
73}
74}
74
75
75func (s *permissionService) GrantPersistent(permission PermissionRequest) {
76func (s *permissionService) GrantPersistent(permission PermissionRequest) {
@@ -86,9 +87,11 @@ func (s *permissionService) GrantPersistent(permission PermissionRequest) {
86 s.sessionPermissions = append(s.sessionPermissions, permission)
87 s.sessionPermissions = append(s.sessionPermissions, permission)
87 s.sessionPermissionsMu.Unlock()
88 s.sessionPermissionsMu.Unlock()
88
89
90+ s.activeRequestMu.Lock()
89 if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
91 if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
90 s.activeRequest = nil
92 s.activeRequest = nil
91 }
93 }
94+ s.activeRequestMu.Unlock()
92}
95}
93
96
94func (s *permissionService) Grant(permission PermissionRequest) {
97func (s *permissionService) Grant(permission PermissionRequest) {
@@ -101,9 +104,11 @@ func (s *permissionService) Grant(permission PermissionRequest) {
101 respCh <- true
104 respCh <- true
102 }
105 }
103
106
107+ s.activeRequestMu.Lock()
104 if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
108 if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
105 s.activeRequest = nil
109 s.activeRequest = nil
106 }
110 }
111+ s.activeRequestMu.Unlock()
107}
112}
108
113
109func (s *permissionService) Deny(permission PermissionRequest) {
114func (s *permissionService) Deny(permission PermissionRequest) {
@@ -117,9 +122,11 @@ func (s *permissionService) Deny(permission PermissionRequest) {
117 respCh <- false
122 respCh <- false
118 }
123 }
119
124
125+ s.activeRequestMu.Lock()
120 if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
126 if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
121 s.activeRequest = nil
127 s.activeRequest = nil
122 }
128 }
129+ s.activeRequestMu.Unlock()
123}
130}
124
131
125func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRequest) (bool, error) {
132func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRequest) (bool, error) {
@@ -190,7 +197,9 @@ func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRe
190 }
197 }
191 s.sessionPermissionsMu.RUnlock()
198 s.sessionPermissionsMu.RUnlock()
192
199
200+ s.activeRequestMu.Lock()
193 s.activeRequest = &permission
201 s.activeRequest = &permission
202+ s.activeRequestMu.Unlock()
194
203
195 respCh := make(chan bool, 1)
204 respCh := make(chan bool, 1)
196 s.pendingRequests.Set(permission.ID, respCh)
205 s.pendingRequests.Set(permission.ID, respCh)
internal/permission/permission_test.go+2−2
@@ -189,7 +189,7 @@ func TestPermissionService_SequentialProperties(t *testing.T) {
189 events := service.Subscribe(t.Context())
189 events := service.Subscribe(t.Context())
190
190
191 var wg sync.WaitGroup
191 var wg sync.WaitGroup
192− results := make([]bool, 0)
192+ results := make([]bool, 3)
193
193
194 requests := []CreatePermissionRequest{
194 requests := []CreatePermissionRequest{
195 {
195 {
@@ -220,7 +220,7 @@ func TestPermissionService_SequentialProperties(t *testing.T) {
220 go func(index int, request CreatePermissionRequest) {
220 go func(index int, request CreatePermissionRequest) {
221 defer wg.Done()
221 defer wg.Done()
222 result, _ := service.Request(t.Context(), request)
222 result, _ := service.Request(t.Context(), request)
223− results = append(results, result)
223+ results[index] = result
224 }(i, req)
224 }(i, req)
225 }
225 }
226
226
internal/shell/background.go+36−7
@@ -19,6 +19,30 @@ const (
19 CompletedJobRetentionMinutes = 8 * 60
19 CompletedJobRetentionMinutes = 8 * 60
20)
20)
21
21
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+
22// BackgroundShell represents a shell running in the background.
46// BackgroundShell represents a shell running in the background.
23type BackgroundShell struct {
47type BackgroundShell struct {
24 ID string
48 ID string
@@ -28,8 +52,8 @@ type BackgroundShell struct {
28 WorkingDir string
52 WorkingDir string
29 ctx context.Context
53 ctx context.Context
30 cancel context.CancelFunc
54 cancel context.CancelFunc
31− stdout *bytes.Buffer
55+ stdout *syncBuffer
32− stderr *bytes.Buffer
56+ stderr *syncBuffer
33 done chan struct{}
57 done chan struct{}
34 exitErr error
58 exitErr error
35 completedAt int64 // Unix timestamp when job completed (0 if still running)
59 completedAt int64 // Unix timestamp when job completed (0 if still running)
@@ -46,12 +70,17 @@ var (
46 idCounter atomic.Uint64
70 idCounter atomic.Uint64
47)
71)
48
72
73+// newBackgroundShellManager creates a new BackgroundShellManager instance.
74+func newBackgroundShellManager() *BackgroundShellManager {
75+ return &BackgroundShellManager{
76+ shells: csync.NewMap[string, *BackgroundShell](),
77+ }
78+}
79+
49// GetBackgroundShellManager returns the singleton background shell manager.
80// GetBackgroundShellManager returns the singleton background shell manager.
50func GetBackgroundShellManager() *BackgroundShellManager {
81func GetBackgroundShellManager() *BackgroundShellManager {
51 backgroundManagerOnce.Do(func() {
82 backgroundManagerOnce.Do(func() {
52− backgroundManager = &BackgroundShellManager{
83+ backgroundManager = newBackgroundShellManager()
53− shells: csync.NewMap[string, *BackgroundShell](),
54− }
55 })
84 })
56 return backgroundManager
85 return backgroundManager
57}
86}
@@ -80,8 +109,8 @@ func (m *BackgroundShellManager) Start(ctx context.Context, workingDir string, b
80 Shell: shell,
109 Shell: shell,
81 ctx: shellCtx,
110 ctx: shellCtx,
82 cancel: cancel,
111 cancel: cancel,
83− stdout: &bytes.Buffer{},
112+ stdout: &syncBuffer{},
84− stderr: &bytes.Buffer{},
113+ stderr: &syncBuffer{},
85 done: make(chan struct{}),
114 done: make(chan struct{}),
86 }
115 }
87
116
internal/shell/background_test.go+8−8
@@ -14,7 +14,7 @@ func TestBackgroundShellManager_Start(t *testing.T) {
14
14
15 ctx := context.Background()
15 ctx := context.Background()
16 workingDir := t.TempDir()
16 workingDir := t.TempDir()
17− manager := GetBackgroundShellManager()
17+ manager := newBackgroundShellManager()
18
18
19 bgShell, err := manager.Start(ctx, workingDir, nil, "echo 'hello world'", "")
19 bgShell, err := manager.Start(ctx, workingDir, nil, "echo 'hello world'", "")
20 if err != nil {
20 if err != nil {
@@ -51,7 +51,7 @@ func TestBackgroundShellManager_Get(t *testing.T) {
51
51
52 ctx := context.Background()
52 ctx := context.Background()
53 workingDir := t.TempDir()
53 workingDir := t.TempDir()
54− manager := GetBackgroundShellManager()
54+ manager := newBackgroundShellManager()
55
55
56 bgShell, err := manager.Start(ctx, workingDir, nil, "echo 'test'", "")
56 bgShell, err := manager.Start(ctx, workingDir, nil, "echo 'test'", "")
57 if err != nil {
57 if err != nil {
@@ -77,7 +77,7 @@ func TestBackgroundShellManager_Kill(t *testing.T) {
77
77
78 ctx := context.Background()
78 ctx := context.Background()
79 workingDir := t.TempDir()
79 workingDir := t.TempDir()
80− manager := GetBackgroundShellManager()
80+ manager := newBackgroundShellManager()
81
81
82 // Start a long-running command
82 // Start a long-running command
83 bgShell, err := manager.Start(ctx, workingDir, nil, "sleep 10", "")
83 bgShell, err := manager.Start(ctx, workingDir, nil, "sleep 10", "")
@@ -106,7 +106,7 @@ func TestBackgroundShellManager_Kill(t *testing.T) {
106func TestBackgroundShellManager_KillNonExistent(t *testing.T) {
106func TestBackgroundShellManager_KillNonExistent(t *testing.T) {
107 t.Parallel()
107 t.Parallel()
108
108
109− manager := GetBackgroundShellManager()
109+ manager := newBackgroundShellManager()
110
110
111 err := manager.Kill("non-existent-id")
111 err := manager.Kill("non-existent-id")
112 if err == nil {
112 if err == nil {
@@ -119,7 +119,7 @@ func TestBackgroundShell_IsDone(t *testing.T) {
119
119
120 ctx := context.Background()
120 ctx := context.Background()
121 workingDir := t.TempDir()
121 workingDir := t.TempDir()
122− manager := GetBackgroundShellManager()
122+ manager := newBackgroundShellManager()
123
123
124 bgShell, err := manager.Start(ctx, workingDir, nil, "echo 'quick'", "")
124 bgShell, err := manager.Start(ctx, workingDir, nil, "echo 'quick'", "")
125 if err != nil {
125 if err != nil {
@@ -142,7 +142,7 @@ func TestBackgroundShell_WithBlockFuncs(t *testing.T) {
142
142
143 ctx := context.Background()
143 ctx := context.Background()
144 workingDir := t.TempDir()
144 workingDir := t.TempDir()
145− manager := GetBackgroundShellManager()
145+ manager := newBackgroundShellManager()
146
146
147 blockFuncs := []BlockFunc{
147 blockFuncs := []BlockFunc{
148 CommandsBlocker([]string{"curl", "wget"}),
148 CommandsBlocker([]string{"curl", "wget"}),
@@ -180,7 +180,7 @@ func TestBackgroundShellManager_List(t *testing.T) {
180
180
181 ctx := context.Background()
181 ctx := context.Background()
182 workingDir := t.TempDir()
182 workingDir := t.TempDir()
183− manager := GetBackgroundShellManager()
183+ manager := newBackgroundShellManager()
184
184
185 // Start two shells
185 // Start two shells
186 bgShell1, err := manager.Start(ctx, workingDir, nil, "sleep 1", "")
186 bgShell1, err := manager.Start(ctx, workingDir, nil, "sleep 1", "")
@@ -224,7 +224,7 @@ func TestBackgroundShellManager_KillAll(t *testing.T) {
224
224
225 ctx := context.Background()
225 ctx := context.Background()
226 workingDir := t.TempDir()
226 workingDir := t.TempDir()
227− manager := GetBackgroundShellManager()
227+ manager := newBackgroundShellManager()
228
228
229 // Start multiple long-running shells
229 // Start multiple long-running shells
230 shell1, err := manager.Start(ctx, workingDir, nil, "sleep 10", "")
230 shell1, err := manager.Start(ctx, workingDir, nil, "sleep 10", "")
internal/tui/components/chat/editor/editor.go+79
@@ -16,6 +16,7 @@ import (
16 "charm.land/bubbles/v2/textarea"
16 "charm.land/bubbles/v2/textarea"
17 tea "charm.land/bubbletea/v2"
17 tea "charm.land/bubbletea/v2"
18 "charm.land/lipgloss/v2"
18 "charm.land/lipgloss/v2"
19+ nativeclipboard "github.com/aymanbagabas/go-nativeclipboard"
19 "github.com/charmbracelet/crush/internal/app"
20 "github.com/charmbracelet/crush/internal/app"
20 "github.com/charmbracelet/crush/internal/filetracker"
21 "github.com/charmbracelet/crush/internal/filetracker"
21 "github.com/charmbracelet/crush/internal/fsext"
22 "github.com/charmbracelet/crush/internal/fsext"
@@ -338,6 +339,84 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
338 m.textarea.InsertRune('\n')
339 m.textarea.InsertRune('\n')
339 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
340 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
340 }
341 }
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+ }
341 // Handle Enter key
420 // Handle Enter key
342 if m.textarea.Focused() && key.Matches(msg, m.keyMap.SendMessage) {
421 if m.textarea.Focused() && key.Matches(msg, m.keyMap.SendMessage) {
343 value := m.textarea.Value()
422 value := m.textarea.Value()
internal/tui/components/chat/editor/keys.go+6
@@ -9,6 +9,7 @@ type EditorKeyMap struct {
9 SendMessage key.Binding
9 SendMessage key.Binding
10 OpenEditor key.Binding
10 OpenEditor key.Binding
11 Newline key.Binding
11 Newline key.Binding
12+ PasteImage key.Binding
12}
13}
13
14
14func DefaultEditorKeyMap() EditorKeyMap {
15func DefaultEditorKeyMap() EditorKeyMap {
@@ -32,6 +33,10 @@ func DefaultEditorKeyMap() EditorKeyMap {
32 // to reflect that.
33 // to reflect that.
33 key.WithHelp("ctrl+j", "newline"),
34 key.WithHelp("ctrl+j", "newline"),
34 ),
35 ),
36+ PasteImage: key.NewBinding(
37+ key.WithKeys("ctrl+v"),
38+ key.WithHelp("ctrl+v", "paste image from clipboard"),
39+ ),
35 }
40 }
36}
41}
37
42
@@ -42,6 +47,7 @@ func (k EditorKeyMap) KeyBindings() []key.Binding {
42 k.SendMessage,
47 k.SendMessage,
43 k.OpenEditor,
48 k.OpenEditor,
44 k.Newline,
49 k.Newline,
50+ k.PasteImage,
45 AttachmentsKeyMaps.AttachmentDeleteMode,
51 AttachmentsKeyMaps.AttachmentDeleteMode,
46 AttachmentsKeyMaps.DeleteAllAttachments,
52 AttachmentsKeyMaps.DeleteAllAttachments,
47 AttachmentsKeyMaps.Escape,
53 AttachmentsKeyMaps.Escape,
internal/tui/styles/theme.go+19−13
@@ -4,6 +4,7 @@ import (
4 "fmt"
4 "fmt"
5 "image/color"
5 "image/color"
6 "strings"
6 "strings"
7+ "sync"
7
8
8 "charm.land/bubbles/v2/filepicker"
9 "charm.land/bubbles/v2/filepicker"
9 "charm.land/bubbles/v2/help"
10 "charm.land/bubbles/v2/help"
@@ -97,7 +98,8 @@ type Theme struct {
97 AuthBorderUnselected lipgloss.Style
98 AuthBorderUnselected lipgloss.Style
98 AuthTextUnselected lipgloss.Style
99 AuthTextUnselected lipgloss.Style
99
100
100− styles *Styles
101+ styles *Styles
102+ stylesOnce sync.Once
101}
103}
102
104
103type Styles struct {
105type Styles struct {
@@ -134,9 +136,9 @@ type Styles struct {
134}
136}
135
137
136func (t *Theme) S() *Styles {
138func (t *Theme) S() *Styles {
137− if t.styles == nil {
139+ t.stylesOnce.Do(func() {
138 t.styles = t.buildStyles()
140 t.styles = t.buildStyles()
139− }
141+ })
140 return t.styles
142 return t.styles
141}
143}
142
144
@@ -500,27 +502,31 @@ type Manager struct {
500 current *Theme
502 current *Theme
501}
503}
502
504
505+var (
503−var defaultManager *Manager
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+}
504
516
505func SetDefaultManager(m *Manager) {
517func SetDefaultManager(m *Manager) {
506 defaultManager = m
518 defaultManager = m
507}
519}
508
520
509func DefaultManager() *Manager {
521func DefaultManager() *Manager {
510− if defaultManager == nil {
522+ return initDefaultManager()
511− defaultManager = NewManager()
512− }
513− return defaultManager
514}
523}
515
524
516func CurrentTheme() *Theme {
525func CurrentTheme() *Theme {
517− if defaultManager == nil {
518− defaultManager = NewManager()
519− }
520− return defaultManager.Current()
526+ return initDefaultManager().Current()
521}
527}
522
528
523−func NewManager() *Manager {
529+func newManager() *Manager {
524 m := &Manager{
530 m := &Manager{
525 themes: make(map[string]*Theme),
531 themes: make(map[string]*Theme),
526 }
532 }
internal/ui/dialog/actions.go+11−13
@@ -59,20 +59,18 @@ type (
59 }
59 }
60 // ActionRunCustomCommand is a message to run a custom command.
60 // ActionRunCustomCommand is a message to run a custom command.
61 ActionRunCustomCommand struct {
61 ActionRunCustomCommand struct {
62− CommandID string
63− // Used when running a user-defined command
64− Content string
62+ Content string
65− // Used when running a prompt from MCP
66− Client string
67− }
68− // ActionOpenCustomCommandArgumentsDialog is a message to open the custom command arguments dialog.
69− ActionOpenCustomCommandArgumentsDialog struct {
70− CommandID string
71− // Used when running a user-defined command
72− Content string
73− // Used when running a prompt from MCP
74− Client string
75 Arguments []commands.Argument
63 Arguments []commands.Argument
64+ Args map[string]string // Actual argument values
65+ }
66+ // ActionRunMCPPrompt is a message to run a custom command.
67+ ActionRunMCPPrompt struct {
68+ Title string
69+ Description string
70+ PromptID string
71+ ClientID string
72+ Arguments []commands.Argument
73+ Args map[string]string // Actual argument values
76 }
74 }
77)
75)
78
76
internal/ui/dialog/api_key_input.go+4−4
@@ -95,7 +95,7 @@ func (m *APIKeyInput) ID() string {
95 return APIKeyInputID
95 return APIKeyInputID
96}
96}
97
97
98−// Update implements tea.Model.
98+// HandleMsg implements [Dialog].
99func (m *APIKeyInput) HandleMsg(msg tea.Msg) Action {
99func (m *APIKeyInput) HandleMsg(msg tea.Msg) Action {
100 switch msg := msg.(type) {
100 switch msg := msg.(type) {
101 case ActionChangeAPIKeyState:
101 case ActionChangeAPIKeyState:
@@ -149,7 +149,7 @@ func (m *APIKeyInput) HandleMsg(msg tea.Msg) Action {
149 return nil
149 return nil
150}
150}
151
151
152−// View implements tea.Model.
152+// Draw implements [Dialog].
153func (m *APIKeyInput) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
153func (m *APIKeyInput) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
154 t := m.com.Styles
154 t := m.com.Styles
155
155
@@ -239,8 +239,8 @@ func (m *APIKeyInput) inputView() string {
239}
239}
240
240
241// Cursor returns the cursor position relative to the dialog.
241// Cursor returns the cursor position relative to the dialog.
242−func (c *APIKeyInput) Cursor() *tea.Cursor {
242+func (m *APIKeyInput) Cursor() *tea.Cursor {
243− return InputCursor(c.com.Styles, c.input.Cursor())
243+ return InputCursor(m.com.Styles, m.input.Cursor())
244}
244}
245
245
246// FullHelp returns the full help view.
246// FullHelp returns the full help view.
internal/ui/dialog/arguments.gonew file+399
@@ -0,0 +1,399 @@
1+package dialog
2+
3+import (
4+ "strings"
5+
6+ "charm.land/bubbles/v2/help"
7+ "charm.land/bubbles/v2/key"
8+ "charm.land/bubbles/v2/spinner"
9+ "charm.land/bubbles/v2/textinput"
10+ "charm.land/bubbles/v2/viewport"
11+ tea "charm.land/bubbletea/v2"
12+ "charm.land/lipgloss/v2"
13+ "golang.org/x/text/cases"
14+ "golang.org/x/text/language"
15+
16+ "github.com/charmbracelet/crush/internal/commands"
17+ "github.com/charmbracelet/crush/internal/ui/common"
18+ "github.com/charmbracelet/crush/internal/uiutil"
19+ uv "github.com/charmbracelet/ultraviolet"
20+)
21+
22+// ArgumentsID is the identifier for the arguments dialog.
23+const ArgumentsID = "arguments"
24+
25+// Dialog sizing for arguments.
26+const (
27+ maxInputWidth = 120
28+ minInputWidth = 30
29+ maxViewportHeight = 20
30+ argumentsFieldHeight = 3 // label + input + spacing per field
31+)
32+
33+// Arguments represents a dialog for collecting command arguments.
34+type Arguments struct {
35+ com *common.Common
36+ title string
37+ arguments []commands.Argument
38+ inputs []textinput.Model
39+ focused int
40+ spinner spinner.Model
41+ loading bool
42+
43+ description string
44+ resultAction Action
45+
46+ help help.Model
47+ keyMap struct {
48+ Confirm,
49+ Next,
50+ Previous,
51+ ScrollUp,
52+ ScrollDown,
53+ Close key.Binding
54+ }
55+
56+ viewport viewport.Model
57+}
58+
59+var _ Dialog = (*Arguments)(nil)
60+
61+// NewArguments creates a new arguments dialog.
62+func NewArguments(com *common.Common, title, description string, arguments []commands.Argument, resultAction Action) *Arguments {
63+ a := &Arguments{
64+ com: com,
65+ title: title,
66+ description: description,
67+ arguments: arguments,
68+ resultAction: resultAction,
69+ }
70+
71+ a.help = help.New()
72+ a.help.Styles = com.Styles.DialogHelpStyles()
73+
74+ a.keyMap.Confirm = key.NewBinding(
75+ key.WithKeys("enter"),
76+ key.WithHelp("enter", "confirm"),
77+ )
78+ a.keyMap.Next = key.NewBinding(
79+ key.WithKeys("down", "tab"),
80+ key.WithHelp("↓/tab", "next"),
81+ )
82+ a.keyMap.Previous = key.NewBinding(
83+ key.WithKeys("up", "shift+tab"),
84+ key.WithHelp("↑/shift+tab", "previous"),
85+ )
86+ a.keyMap.Close = CloseKey
87+
88+ // Create input fields for each argument.
89+ a.inputs = make([]textinput.Model, len(arguments))
90+ for i, arg := range arguments {
91+ input := textinput.New()
92+ input.SetVirtualCursor(false)
93+ input.SetStyles(com.Styles.TextInput)
94+ input.Prompt = "> "
95+ // Use description as placeholder if available, otherwise title
96+ if arg.Description != "" {
97+ input.Placeholder = arg.Description
98+ } else {
99+ input.Placeholder = arg.Title
100+ }
101+
102+ if i == 0 {
103+ input.Focus()
104+ } else {
105+ input.Blur()
106+ }
107+
108+ a.inputs[i] = input
109+ }
110+ s := spinner.New()
111+ s.Spinner = spinner.Dot
112+ s.Style = com.Styles.Dialog.Spinner
113+ a.spinner = s
114+
115+ return a
116+}
117+
118+// ID implements Dialog.
119+func (a *Arguments) ID() string {
120+ return ArgumentsID
121+}
122+
123+// focusInput changes focus to a new input by index with wrap-around.
124+func (a *Arguments) focusInput(newIndex int) {
125+ a.inputs[a.focused].Blur()
126+
127+ // Wrap around: Go's modulo can return negative, so add len first.
128+ n := len(a.inputs)
129+ a.focused = ((newIndex % n) + n) % n
130+
131+ a.inputs[a.focused].Focus()
132+
133+ // Ensure the newly focused field is visible in the viewport
134+ a.ensureFieldVisible(a.focused)
135+}
136+
137+// isFieldVisible checks if a field at the given index is visible in the viewport.
138+func (a *Arguments) isFieldVisible(fieldIndex int) bool {
139+ fieldStart := fieldIndex * argumentsFieldHeight
140+ fieldEnd := fieldStart + argumentsFieldHeight - 1
141+ viewportTop := a.viewport.YOffset()
142+ viewportBottom := viewportTop + a.viewport.Height() - 1
143+
144+ return fieldStart >= viewportTop && fieldEnd <= viewportBottom
145+}
146+
147+// ensureFieldVisible scrolls the viewport to make the field visible.
148+func (a *Arguments) ensureFieldVisible(fieldIndex int) {
149+ if a.isFieldVisible(fieldIndex) {
150+ return
151+ }
152+
153+ fieldStart := fieldIndex * argumentsFieldHeight
154+ fieldEnd := fieldStart + argumentsFieldHeight - 1
155+ viewportTop := a.viewport.YOffset()
156+ viewportHeight := a.viewport.Height()
157+
158+ // If field is above viewport, scroll up to show it at top
159+ if fieldStart < viewportTop {
160+ a.viewport.SetYOffset(fieldStart)
161+ return
162+ }
163+
164+ // If field is below viewport, scroll down to show it at bottom
165+ if fieldEnd > viewportTop+viewportHeight-1 {
166+ a.viewport.SetYOffset(fieldEnd - viewportHeight + 1)
167+ }
168+}
169+
170+// findVisibleFieldByOffset returns the field index closest to the given viewport offset.
171+func (a *Arguments) findVisibleFieldByOffset(fromTop bool) int {
172+ offset := a.viewport.YOffset()
173+ if !fromTop {
174+ offset += a.viewport.Height() - 1
175+ }
176+
177+ fieldIndex := offset / argumentsFieldHeight
178+ if fieldIndex >= len(a.inputs) {
179+ return len(a.inputs) - 1
180+ }
181+ return fieldIndex
182+}
183+
184+// HandleMsg implements Dialog.
185+func (a *Arguments) HandleMsg(msg tea.Msg) Action {
186+ switch msg := msg.(type) {
187+ case spinner.TickMsg:
188+ if a.loading {
189+ var cmd tea.Cmd
190+ a.spinner, cmd = a.spinner.Update(msg)
191+ return ActionCmd{Cmd: cmd}
192+ }
193+ case tea.KeyPressMsg:
194+ switch {
195+ case key.Matches(msg, a.keyMap.Close):
196+ return ActionClose{}
197+ case key.Matches(msg, a.keyMap.Confirm):
198+ // If we're on the last input or there's only one input, submit.
199+ if a.focused == len(a.inputs)-1 || len(a.inputs) == 1 {
200+ args := make(map[string]string)
201+ var warning tea.Cmd
202+ for i, arg := range a.arguments {
203+ args[arg.ID] = a.inputs[i].Value()
204+ if arg.Required && strings.TrimSpace(a.inputs[i].Value()) == "" {
205+ warning = uiutil.ReportWarn("Required argument '" + arg.Title + "' is missing.")
206+ break
207+ }
208+ }
209+ if warning != nil {
210+ return ActionCmd{Cmd: warning}
211+ }
212+
213+ switch action := a.resultAction.(type) {
214+ case ActionRunCustomCommand:
215+ action.Args = args
216+ return action
217+ case ActionRunMCPPrompt:
218+ action.Args = args
219+ return action
220+ }
221+ }
222+ a.focusInput(a.focused + 1)
223+ case key.Matches(msg, a.keyMap.Next):
224+ a.focusInput(a.focused + 1)
225+ case key.Matches(msg, a.keyMap.Previous):
226+ a.focusInput(a.focused - 1)
227+ default:
228+ var cmd tea.Cmd
229+ a.inputs[a.focused], cmd = a.inputs[a.focused].Update(msg)
230+ return ActionCmd{Cmd: cmd}
231+ }
232+ case tea.MouseWheelMsg:
233+ a.viewport, _ = a.viewport.Update(msg)
234+ // If focused field scrolled out of view, focus the visible field
235+ if !a.isFieldVisible(a.focused) {
236+ a.focusInput(a.findVisibleFieldByOffset(msg.Button == tea.MouseWheelDown))
237+ }
238+ case tea.PasteMsg:
239+ var cmd tea.Cmd
240+ a.inputs[a.focused], cmd = a.inputs[a.focused].Update(msg)
241+ return ActionCmd{Cmd: cmd}
242+ }
243+ return nil
244+}
245+
246+// Cursor returns the cursor position relative to the dialog.
247+// we pass the description height to offset the cursor correctly.
248+func (a *Arguments) Cursor(descriptionHeight int) *tea.Cursor {
249+ cursor := InputCursor(a.com.Styles, a.inputs[a.focused].Cursor())
250+ if cursor == nil {
251+ return nil
252+ }
253+ cursor.Y += descriptionHeight + a.focused*argumentsFieldHeight - a.viewport.YOffset() + 1
254+ return cursor
255+}
256+
257+// Draw implements Dialog.
258+func (a *Arguments) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
259+ s := a.com.Styles
260+
261+ dialogContentStyle := s.Dialog.Arguments.Content
262+ possibleWidth := area.Dx() - s.Dialog.View.GetHorizontalFrameSize() - dialogContentStyle.GetHorizontalFrameSize()
263+ // Build fields with label and input.
264+ caser := cases.Title(language.English)
265+
266+ var fields []string
267+ for i, arg := range a.arguments {
268+ isFocused := i == a.focused
269+
270+ // Try to pretty up the title for the label.
271+ title := strings.ReplaceAll(arg.Title, "_", " ")
272+ title = strings.ReplaceAll(title, "-", " ")
273+ titleParts := strings.Fields(title)
274+ for i, part := range titleParts {
275+ titleParts[i] = caser.String(strings.ToLower(part))
276+ }
277+ labelText := strings.Join(titleParts, " ")
278+
279+ markRequiredStyle := s.Dialog.Arguments.InputRequiredMarkBlurred
280+
281+ labelStyle := s.Dialog.Arguments.InputLabelBlurred
282+ if isFocused {
283+ labelStyle = s.Dialog.Arguments.InputLabelFocused
284+ markRequiredStyle = s.Dialog.Arguments.InputRequiredMarkFocused
285+ }
286+ if arg.Required {
287+ labelText += markRequiredStyle.String()
288+ }
289+ label := labelStyle.Render(labelText)
290+
291+ labelWidth := lipgloss.Width(labelText)
292+ placeholderWidth := lipgloss.Width(a.inputs[i].Placeholder)
293+
294+ inputWidth := max(placeholderWidth, labelWidth, minInputWidth)
295+ inputWidth = min(inputWidth, min(possibleWidth, maxInputWidth))
296+ a.inputs[i].SetWidth(inputWidth)
297+
298+ inputLine := a.inputs[i].View()
299+
300+ field := lipgloss.JoinVertical(lipgloss.Left, label, inputLine, "")
301+ fields = append(fields, field)
302+ }
303+
304+ renderedFields := lipgloss.JoinVertical(lipgloss.Left, fields...)
305+
306+ // Anchor width to the longest field, capped at maxInputWidth.
307+ const scrollbarWidth = 1
308+ width := lipgloss.Width(renderedFields)
309+ height := lipgloss.Height(renderedFields)
310+
311+ // Use standard header
312+ titleStyle := s.Dialog.Title
313+
314+ titleText := a.title
315+ if titleText == "" {
316+ titleText = "Arguments"
317+ }
318+
319+ header := common.DialogTitle(s, titleText, width)
320+
321+ // Add description if available.
322+ var description string
323+ if a.description != "" {
324+ descStyle := s.Dialog.Arguments.Description.Width(width)
325+ description = descStyle.Render(a.description)
326+ }
327+
328+ helpView := s.Dialog.HelpView.Width(width).Render(a.help.View(a))
329+ if a.loading {
330+ helpView = s.Dialog.HelpView.Width(width).Render(a.spinner.View() + " Generating Prompt...")
331+ }
332+
333+ availableHeight := area.Dy() - s.Dialog.View.GetVerticalFrameSize() - dialogContentStyle.GetVerticalFrameSize() - lipgloss.Height(header) - lipgloss.Height(description) - lipgloss.Height(helpView) - 2 // extra spacing
334+ viewportHeight := min(height, maxViewportHeight, availableHeight)
335+
336+ a.viewport.SetWidth(width) // -1 for scrollbar
337+ a.viewport.SetHeight(viewportHeight)
338+ a.viewport.SetContent(renderedFields)
339+
340+ scrollbar := common.Scrollbar(s, viewportHeight, a.viewport.TotalLineCount(), viewportHeight, a.viewport.YOffset())
341+ content := a.viewport.View()
342+ if scrollbar != "" {
343+ content = lipgloss.JoinHorizontal(lipgloss.Top, content, scrollbar)
344+ }
345+ contentParts := []string{}
346+ if description != "" {
347+ contentParts = append(contentParts, description)
348+ }
349+ contentParts = append(contentParts, content)
350+
351+ view := lipgloss.JoinVertical(
352+ lipgloss.Left,
353+ titleStyle.Render(header),
354+ dialogContentStyle.Render(lipgloss.JoinVertical(lipgloss.Left, contentParts...)),
355+ helpView,
356+ )
357+
358+ dialog := s.Dialog.View.Render(view)
359+
360+ descriptionHeight := 0
361+ if a.description != "" {
362+ descriptionHeight = lipgloss.Height(description)
363+ }
364+ cur := a.Cursor(descriptionHeight)
365+
366+ DrawCenterCursor(scr, area, dialog, cur)
367+ return cur
368+}
369+
370+// StartLoading implements [LoadingDialog].
371+func (a *Arguments) StartLoading() tea.Cmd {
372+ if a.loading {
373+ return nil
374+ }
375+ a.loading = true
376+ return a.spinner.Tick
377+}
378+
379+// StopLoading implements [LoadingDialog].
380+func (a *Arguments) StopLoading() {
381+ a.loading = false
382+}
383+
384+// ShortHelp implements help.KeyMap.
385+func (a *Arguments) ShortHelp() []key.Binding {
386+ return []key.Binding{
387+ a.keyMap.Confirm,
388+ a.keyMap.Next,
389+ a.keyMap.Close,
390+ }
391+}
392+
393+// FullHelp implements help.KeyMap.
394+func (a *Arguments) FullHelp() [][]key.Binding {
395+ return [][]key.Binding{
396+ {a.keyMap.Confirm, a.keyMap.Next, a.keyMap.Previous},
397+ {a.keyMap.Close},
398+ }
399+}
internal/ui/dialog/commands.go+62−44
@@ -6,6 +6,7 @@ import (
6
6
7 "charm.land/bubbles/v2/help"
7 "charm.land/bubbles/v2/help"
8 "charm.land/bubbles/v2/key"
8 "charm.land/bubbles/v2/key"
9+ "charm.land/bubbles/v2/spinner"
9 "charm.land/bubbles/v2/textinput"
10 "charm.land/bubbles/v2/textinput"
10 tea "charm.land/bubbletea/v2"
11 tea "charm.land/bubbletea/v2"
11 "charm.land/lipgloss/v2"
12 "charm.land/lipgloss/v2"
@@ -52,26 +53,29 @@ type Commands struct {
52 sessionID string // can be empty for non-session-specific commands
53 sessionID string // can be empty for non-session-specific commands
53 selected CommandType
54 selected CommandType
54
55
56+ spinner spinner.Model
57+ loading bool
58+
55 help help.Model
59 help help.Model
56 input textinput.Model
60 input textinput.Model
57 list *list.FilterableList
61 list *list.FilterableList
58
62
59 windowWidth int
63 windowWidth int
60
64
61− customCommands []commands.CustomCommand
65+ customCommands []commands.CustomCommand
62− mcpCustomCommands []commands.MCPCustomCommand
66+ mcpPrompts []commands.MCPPrompt
63}
67}
64
68
65var _ Dialog = (*Commands)(nil)
69var _ Dialog = (*Commands)(nil)
66
70
67// NewCommands creates a new commands dialog.
71// NewCommands creates a new commands dialog.
68−func NewCommands(com *common.Common, sessionID string, customCommands []commands.CustomCommand, mcpCustomCommands []commands.MCPCustomCommand) (*Commands, error) {
72+func NewCommands(com *common.Common, sessionID string, customCommands []commands.CustomCommand, mcpPrompts []commands.MCPPrompt) (*Commands, error) {
69 c := &Commands{
73 c := &Commands{
70− com: com,
74+ com: com,
71− selected: SystemCommands,
75+ selected: SystemCommands,
72− sessionID: sessionID,
76+ sessionID: sessionID,
73− customCommands: customCommands,
77+ customCommands: customCommands,
74− mcpCustomCommands: mcpCustomCommands,
78+ mcpPrompts: mcpPrompts,
75 }
79 }
76
80
77 help := help.New()
81 help := help.New()
@@ -120,6 +124,11 @@ func NewCommands(com *common.Common, sessionID string, customCommands []commands
120 // Set initial commands
124 // Set initial commands
121 c.setCommandItems(c.selected)
125 c.setCommandItems(c.selected)
122
126
127+ s := spinner.New()
128+ s.Spinner = spinner.Dot
129+ s.Style = com.Styles.Dialog.Spinner
130+ c.spinner = s
131+
123 return c, nil
132 return c, nil
124}
133}
125
134
@@ -128,9 +137,15 @@ func (c *Commands) ID() string {
128 return CommandsID
137 return CommandsID
129}
138}
130
139
131−// HandleMsg implements Dialog.
140+// HandleMsg implements [Dialog].
132func (c *Commands) HandleMsg(msg tea.Msg) Action {
141func (c *Commands) HandleMsg(msg tea.Msg) Action {
133 switch msg := msg.(type) {
142 switch msg := msg.(type) {
143+ case spinner.TickMsg:
144+ if c.loading {
145+ var cmd tea.Cmd
146+ c.spinner, cmd = c.spinner.Update(msg)
147+ return ActionCmd{Cmd: cmd}
148+ }
134 case tea.KeyPressMsg:
149 case tea.KeyPressMsg:
135 switch {
150 switch {
136 case key.Matches(msg, c.keyMap.Close):
151 case key.Matches(msg, c.keyMap.Close):
@@ -160,12 +175,12 @@ func (c *Commands) HandleMsg(msg tea.Msg) Action {
160 }
175 }
161 }
176 }
162 case key.Matches(msg, c.keyMap.Tab):
177 case key.Matches(msg, c.keyMap.Tab):
163− if len(c.customCommands) > 0 || len(c.mcpCustomCommands) > 0 {
178+ if len(c.customCommands) > 0 || len(c.mcpPrompts) > 0 {
164 c.selected = c.nextCommandType()
179 c.selected = c.nextCommandType()
165 c.setCommandItems(c.selected)
180 c.setCommandItems(c.selected)
166 }
181 }
167 case key.Matches(msg, c.keyMap.ShiftTab):
182 case key.Matches(msg, c.keyMap.ShiftTab):
168− if len(c.customCommands) > 0 || len(c.mcpCustomCommands) > 0 {
183+ if len(c.customCommands) > 0 || len(c.mcpPrompts) > 0 {
169 c.selected = c.previousCommandType()
184 c.selected = c.previousCommandType()
170 c.setCommandItems(c.selected)
185 c.setCommandItems(c.selected)
171 }
186 }
@@ -242,12 +257,16 @@ func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
242 c.list.SetSize(innerWidth, height-heightOffset)
257 c.list.SetSize(innerWidth, height-heightOffset)
243 c.help.SetWidth(innerWidth)
258 c.help.SetWidth(innerWidth)
244
259
245− radio := commandsRadioView(t, c.selected, len(c.customCommands) > 0, len(c.mcpCustomCommands) > 0)
260+ radio := commandsRadioView(t, c.selected, len(c.customCommands) > 0, len(c.mcpPrompts) > 0)
246 titleStyle := t.Dialog.Title
261 titleStyle := t.Dialog.Title
247 dialogStyle := t.Dialog.View.Width(width)
262 dialogStyle := t.Dialog.View.Width(width)
248 headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
263 headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
249 helpView := ansi.Truncate(c.help.View(c), innerWidth, "")
264 helpView := ansi.Truncate(c.help.View(c), innerWidth, "")
250 header := common.DialogTitle(t, "Commands", width-headerOffset) + radio
265 header := common.DialogTitle(t, "Commands", width-headerOffset) + radio
266+
267+ if c.loading {
268+ helpView = t.Dialog.HelpView.Width(width).Render(c.spinner.View() + " Generating Prompt...")
269+ }
251 view := HeaderInputListHelpView(t, width, c.list.Height(), header,
270 view := HeaderInputListHelpView(t, width, c.list.Height(), header,
252 c.input.View(), c.list.Render(), helpView)
271 c.input.View(), c.list.Render(), helpView)
253
272
@@ -281,12 +300,12 @@ func (c *Commands) nextCommandType() CommandType {
281 if len(c.customCommands) > 0 {
300 if len(c.customCommands) > 0 {
282 return UserCommands
301 return UserCommands
283 }
302 }
284− if len(c.mcpCustomCommands) > 0 {
303+ if len(c.mcpPrompts) > 0 {
285 return MCPPrompts
304 return MCPPrompts
286 }
305 }
287 fallthrough
306 fallthrough
288 case UserCommands:
307 case UserCommands:
289− if len(c.mcpCustomCommands) > 0 {
308+ if len(c.mcpPrompts) > 0 {
290 return MCPPrompts
309 return MCPPrompts
291 }
310 }
292 fallthrough
311 fallthrough
@@ -301,7 +320,7 @@ func (c *Commands) nextCommandType() CommandType {
301func (c *Commands) previousCommandType() CommandType {
320func (c *Commands) previousCommandType() CommandType {
302 switch c.selected {
321 switch c.selected {
303 case SystemCommands:
322 case SystemCommands:
304− if len(c.mcpCustomCommands) > 0 {
323+ if len(c.mcpPrompts) > 0 {
305 return MCPPrompts
324 return MCPPrompts
306 }
325 }
307 if len(c.customCommands) > 0 {
326 if len(c.customCommands) > 0 {
@@ -332,37 +351,22 @@ func (c *Commands) setCommandItems(commandType CommandType) {
332 }
351 }
333 case UserCommands:
352 case UserCommands:
334 for _, cmd := range c.customCommands {
353 for _, cmd := range c.customCommands {
335− var action Action
336− if len(cmd.Arguments) > 0 {
337− action = ActionOpenCustomCommandArgumentsDialog{
338− CommandID: cmd.ID,
339− Content: cmd.Content,
340− Arguments: cmd.Arguments,
341− }
342− } else {
343− action = ActionRunCustomCommand{
354+ action := ActionRunCustomCommand{
344− CommandID: cmd.ID,
345− Content: cmd.Content,
355+ Content: cmd.Content,
346− }
356+ Arguments: cmd.Arguments,
347 }
357 }
348 commandItems = append(commandItems, NewCommandItem(c.com.Styles, "custom_"+cmd.ID, cmd.Name, "", action))
358 commandItems = append(commandItems, NewCommandItem(c.com.Styles, "custom_"+cmd.ID, cmd.Name, "", action))
349 }
359 }
350 case MCPPrompts:
360 case MCPPrompts:
351− for _, cmd := range c.mcpCustomCommands {
361+ for _, cmd := range c.mcpPrompts {
352− var action Action
362+ action := ActionRunMCPPrompt{
353− if len(cmd.Arguments) > 0 {
363+ Title: cmd.Title,
354− action = ActionOpenCustomCommandArgumentsDialog{
364+ Description: cmd.Description,
355− CommandID: cmd.ID,
365+ PromptID: cmd.PromptID,
356− Client: cmd.Client,
366+ ClientID: cmd.ClientID,
357− Arguments: cmd.Arguments,
367+ Arguments: cmd.Arguments,
358− }
359− } else {
360− action = ActionRunCustomCommand{
361− CommandID: cmd.ID,
362− Client: cmd.Client,
363− }
364 }
368 }
365− commandItems = append(commandItems, NewCommandItem(c.com.Styles, "mcp_"+cmd.ID, cmd.Name, "", action))
369+ commandItems = append(commandItems, NewCommandItem(c.com.Styles, "mcp_"+cmd.ID, cmd.PromptID, "", action))
366 }
370 }
367 }
371 }
368
372
@@ -448,10 +452,24 @@ func (c *Commands) SetCustomCommands(customCommands []commands.CustomCommand) {
448 }
452 }
449}
453}
450
454
451−// SetMCPCustomCommands sets the MCP custom commands and refreshes the view if MCP prompts are currently displayed.
455+// SetMCPPrompts sets the MCP prompts and refreshes the view if MCP prompts are currently displayed.
452−func (c *Commands) SetMCPCustomCommands(mcpCustomCommands []commands.MCPCustomCommand) {
456+func (c *Commands) SetMCPPrompts(mcpPrompts []commands.MCPPrompt) {
453− c.mcpCustomCommands = mcpCustomCommands
457+ c.mcpPrompts = mcpPrompts
454 if c.selected == MCPPrompts {
458 if c.selected == MCPPrompts {
455 c.setCommandItems(c.selected)
459 c.setCommandItems(c.selected)
456 }
460 }
457}
461}
462+
463+// StartLoading implements [LoadingDialog].
464+func (a *Commands) StartLoading() tea.Cmd {
465+ if a.loading {
466+ return nil
467+ }
468+ a.loading = true
469+ return a.spinner.Tick
470+}
471+
472+// StopLoading implements [LoadingDialog].
473+func (a *Commands) StopLoading() {
474+ a.loading = false
475+}
internal/ui/dialog/dialog.go+26−1
@@ -27,7 +27,7 @@ var CloseKey = key.NewBinding(
27)
27)
28
28
29// Action represents an action taken in a dialog after handling a message.
29// Action represents an action taken in a dialog after handling a message.
30−type Action interface{}
30+type Action any
31
31
32// Dialog is a component that can be displayed on top of the UI.
32// Dialog is a component that can be displayed on top of the UI.
33type Dialog interface {
33type Dialog interface {
@@ -41,6 +41,12 @@ type Dialog interface {
41 Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor
41 Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor
42}
42}
43
43
44+// LoadingDialog is a dialog that can show a loading state.
45+type LoadingDialog interface {
46+ StartLoading() tea.Cmd
47+ StopLoading()
48+}
49+
44// Overlay manages multiple dialogs as an overlay.
50// Overlay manages multiple dialogs as an overlay.
45type Overlay struct {
51type Overlay struct {
46 dialogs []Dialog
52 dialogs []Dialog
@@ -136,6 +142,25 @@ func (d *Overlay) Update(msg tea.Msg) tea.Msg {
136 return dialog.HandleMsg(msg)
142 return dialog.HandleMsg(msg)
137}
143}
138
144
145+// StartLoading starts the loading state for the front dialog if it
146+// implements [LoadingDialog].
147+func (d *Overlay) StartLoading() tea.Cmd {
148+ dialog := d.DialogLast()
149+ if ld, ok := dialog.(LoadingDialog); ok {
150+ return ld.StartLoading()
151+ }
152+ return nil
153+}
154+
155+// StopLoading stops the loading state for the front dialog if it
156+// implements [LoadingDialog].
157+func (d *Overlay) StopLoading() {
158+ dialog := d.DialogLast()
159+ if ld, ok := dialog.(LoadingDialog); ok {
160+ ld.StopLoading()
161+ }
162+}
163+
139// DrawCenterCursor draws the given string view centered in the screen area and
164// DrawCenterCursor draws the given string view centered in the screen area and
140// adjusts the cursor position accordingly.
165// adjusts the cursor position accordingly.
141func DrawCenterCursor(scr uv.Screen, area uv.Rectangle, view string, cur *tea.Cursor) {
166func DrawCenterCursor(scr uv.Screen, area uv.Rectangle, view string, cur *tea.Cursor) {
internal/ui/model/ui.go+117−15
@@ -18,6 +18,7 @@ import (
18
18
19 "charm.land/bubbles/v2/help"
19 "charm.land/bubbles/v2/help"
20 "charm.land/bubbles/v2/key"
20 "charm.land/bubbles/v2/key"
21+ "charm.land/bubbles/v2/spinner"
21 "charm.land/bubbles/v2/textarea"
22 "charm.land/bubbles/v2/textarea"
22 tea "charm.land/bubbletea/v2"
23 tea "charm.land/bubbletea/v2"
23 "charm.land/lipgloss/v2"
24 "charm.land/lipgloss/v2"
@@ -88,10 +89,19 @@ type (
88 userCommandsLoadedMsg struct {
89 userCommandsLoadedMsg struct {
89 Commands []commands.CustomCommand
90 Commands []commands.CustomCommand
90 }
91 }
91− // mcpCustomCommandsLoadedMsg is sent when mcp prompts are loaded.
92+ // mcpPromptsLoadedMsg is sent when mcp prompts are loaded.
92− mcpCustomCommandsLoadedMsg struct {
93+ mcpPromptsLoadedMsg struct {
93− Prompts []commands.MCPCustomCommand
94+ Prompts []commands.MCPPrompt
94 }
95 }
96+ // sendMessageMsg is sent to send a message.
97+ // currently only used for mcp prompts.
98+ sendMessageMsg struct {
99+ Content string
100+ Attachments []message.Attachment
101+ }
102+
103+ // closeDialogMsg is sent to close the current dialog.
104+ closeDialogMsg struct{}
95)
105)
96
106
97// UI represents the main user interface model.
107// UI represents the main user interface model.
@@ -165,8 +175,8 @@ type UI struct {
165 imgCaps timage.Capabilities
175 imgCaps timage.Capabilities
166
176
167 // custom commands & mcp commands
177 // custom commands & mcp commands
168− customCommands []commands.CustomCommand
178+ customCommands []commands.CustomCommand
169− mcpCustomCommands []commands.MCPCustomCommand
179+ mcpPrompts []commands.MCPPrompt
170
180
171 // forceCompactMode tracks whether compact mode is forced by user toggle
181 // forceCompactMode tracks whether compact mode is forced by user toggle
172 forceCompactMode bool
182 forceCompactMode bool
@@ -285,15 +295,15 @@ func (m *UI) loadCustomCommands() tea.Cmd {
285// loadMCPrompts loads the MCP prompts asynchronously.
295// loadMCPrompts loads the MCP prompts asynchronously.
286func (m *UI) loadMCPrompts() tea.Cmd {
296func (m *UI) loadMCPrompts() tea.Cmd {
287 return func() tea.Msg {
297 return func() tea.Msg {
288− prompts, err := commands.LoadMCPCustomCommands()
298+ prompts, err := commands.LoadMCPPrompts()
289 if err != nil {
299 if err != nil {
290 slog.Error("failed to load mcp prompts", "error", err)
300 slog.Error("failed to load mcp prompts", "error", err)
291 }
301 }
292 if prompts == nil {
302 if prompts == nil {
293 // flag them as loaded even if there is none or an error
303 // flag them as loaded even if there is none or an error
294− prompts = []commands.MCPCustomCommand{}
304+ prompts = []commands.MCPPrompt{}
295 }
305 }
296− return mcpCustomCommandsLoadedMsg{Prompts: prompts}
306+ return mcpPromptsLoadedMsg{Prompts: prompts}
297 }
307 }
298}
308}
299
309
@@ -322,6 +332,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
322 cmds = append(cmds, cmd)
332 cmds = append(cmds, cmd)
323 }
333 }
324
334
335+ case sendMessageMsg:
336+ cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...))
337+
325 case userCommandsLoadedMsg:
338 case userCommandsLoadedMsg:
326 m.customCommands = msg.Commands
339 m.customCommands = msg.Commands
327 dia := m.dialog.Dialog(dialog.CommandsID)
340 dia := m.dialog.Dialog(dialog.CommandsID)
@@ -333,8 +346,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
333 if ok {
346 if ok {
334 commands.SetCustomCommands(m.customCommands)
347 commands.SetCustomCommands(m.customCommands)
335 }
348 }
336− case mcpCustomCommandsLoadedMsg:
349+ case mcpPromptsLoadedMsg:
337− m.mcpCustomCommands = msg.Prompts
350+ m.mcpPrompts = msg.Prompts
338 dia := m.dialog.Dialog(dialog.CommandsID)
351 dia := m.dialog.Dialog(dialog.CommandsID)
339 if dia == nil {
352 if dia == nil {
340 break
353 break
@@ -342,9 +355,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
342
355
343 commands, ok := dia.(*dialog.Commands)
356 commands, ok := dia.(*dialog.Commands)
344 if ok {
357 if ok {
345− commands.SetMCPCustomCommands(m.mcpCustomCommands)
358+ commands.SetMCPPrompts(m.mcpPrompts)
346 }
359 }
347
360
361+ case closeDialogMsg:
362+ m.dialog.CloseFrontDialog()
363+
348 case pubsub.Event[message.Message]:
364 case pubsub.Event[message.Message]:
349 // Check if this is a child session message for an agent tool.
365 // Check if this is a child session message for an agent tool.
350 if m.session == nil {
366 if m.session == nil {
@@ -377,7 +393,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
377 break
393 break
378 }
394 }
379 }
395 }
380− if initialized && m.mcpCustomCommands == nil {
396+ if initialized && m.mcpPrompts == nil {
381 cmds = append(cmds, m.loadMCPrompts())
397 cmds = append(cmds, m.loadMCPrompts())
382 }
398 }
383 case pubsub.Event[permission.PermissionRequest]:
399 case pubsub.Event[permission.PermissionRequest]:
@@ -497,6 +513,14 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
497 cmds = append(cmds, cmd)
513 cmds = append(cmds, cmd)
498 }
514 }
499 }
515 }
516+ case spinner.TickMsg:
517+ if m.dialog.HasDialogs() {
518+ // route to dialog
519+ if cmd := m.handleDialogMsg(msg); cmd != nil {
520+ cmds = append(cmds, cmd)
521+ }
522+ }
523+
500 case tea.KeyPressMsg:
524 case tea.KeyPressMsg:
501 if cmd := m.handleKeyPressMsg(msg); cmd != nil {
525 if cmd := m.handleKeyPressMsg(msg); cmd != nil {
502 cmds = append(cmds, cmd)
526 cmds = append(cmds, cmd)
@@ -660,6 +684,11 @@ func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
660// if the message is a tool result it will update the corresponding tool call message
684// if the message is a tool result it will update the corresponding tool call message
661func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
685func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
662 var cmds []tea.Cmd
686 var cmds []tea.Cmd
687+ existing := m.chat.MessageItem(msg.ID)
688+ if existing != nil {
689+ // message already exists, skip
690+ return nil
691+ }
663 switch msg.Role {
692 switch msg.Role {
664 case message.User, message.Assistant:
693 case message.User, message.Assistant:
665 items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
694 items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
@@ -945,6 +974,43 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
945 },
974 },
946 ))
975 ))
947
976
977+ case dialog.ActionRunCustomCommand:
978+ if len(msg.Arguments) > 0 && msg.Args == nil {
979+ m.dialog.CloseFrontDialog()
980+ argsDialog := dialog.NewArguments(
981+ m.com,
982+ "Custom Command Arguments",
983+ "",
984+ msg.Arguments,
985+ msg, // Pass the action as the result
986+ )
987+ m.dialog.OpenDialog(argsDialog)
988+ break
989+ }
990+ content := msg.Content
991+ if msg.Args != nil {
992+ content = substituteArgs(content, msg.Args)
993+ }
994+ cmds = append(cmds, m.sendMessage(content))
995+ m.dialog.CloseFrontDialog()
996+ case dialog.ActionRunMCPPrompt:
997+ if len(msg.Arguments) > 0 && msg.Args == nil {
998+ m.dialog.CloseFrontDialog()
999+ title := msg.Title
1000+ if title == "" {
1001+ title = "MCP Prompt Arguments"
1002+ }
1003+ argsDialog := dialog.NewArguments(
1004+ m.com,
1005+ title,
1006+ msg.Description,
1007+ msg.Arguments,
1008+ msg, // Pass the action as the result
1009+ )
1010+ m.dialog.OpenDialog(argsDialog)
1011+ break
1012+ }
1013+ cmds = append(cmds, m.runMCPPrompt(msg.ClientID, msg.PromptID, msg.Args))
948 default:
1014 default:
949 cmds = append(cmds, uiutil.CmdHandler(msg))
1015 cmds = append(cmds, uiutil.CmdHandler(msg))
950 }
1016 }
@@ -952,6 +1018,15 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
952 return tea.Batch(cmds...)
1018 return tea.Batch(cmds...)
953}
1019}
954
1020
1021+// substituteArgs replaces $ARG_NAME placeholders in content with actual values.
1022+func substituteArgs(content string, args map[string]string) string {
1023+ for name, value := range args {
1024+ placeholder := "$" + name
1025+ content = strings.ReplaceAll(content, placeholder, value)
1026+ }
1027+ return content
1028+}
1029+
955// openAPIKeyInputDialog opens the API key input dialog.
1030// openAPIKeyInputDialog opens the API key input dialog.
956func (m *UI) openAPIKeyInputDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
1031func (m *UI) openAPIKeyInputDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
957 if m.dialog.ContainsDialog(dialog.APIKeyInputID) {
1032 if m.dialog.ContainsDialog(dialog.APIKeyInputID) {
@@ -1085,7 +1160,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1085
1160
1086 m.randomizePlaceholders()
1161 m.randomizePlaceholders()
1087
1162
1088− return m.sendMessage(value, attachments)
1163+ return m.sendMessage(value, attachments...)
1089 case key.Matches(msg, m.keyMap.Chat.NewSession):
1164 case key.Matches(msg, m.keyMap.Chat.NewSession):
1090 if m.session == nil || m.session.ID == "" {
1165 if m.session == nil || m.session.ID == "" {
1091 break
1166 break
@@ -2043,7 +2118,7 @@ func (m *UI) renderSidebarLogo(width int) {
2043}
2118}
2044
2119
2045// sendMessage sends a message with the given content and attachments.
2120// sendMessage sends a message with the given content and attachments.
2046−func (m *UI) sendMessage(content string, attachments []message.Attachment) tea.Cmd {
2121+func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
2047 if m.com.App.AgentCoordinator == nil {
2122 if m.com.App.AgentCoordinator == nil {
2048 return uiutil.ReportError(fmt.Errorf("coder agent is not initialized"))
2123 return uiutil.ReportError(fmt.Errorf("coder agent is not initialized"))
2049 }
2124 }
@@ -2195,7 +2270,7 @@ func (m *UI) openCommandsDialog() tea.Cmd {
2195 sessionID = m.session.ID
2270 sessionID = m.session.ID
2196 }
2271 }
2197
2272
2198− commands, err := dialog.NewCommands(m.com, sessionID, m.customCommands, m.mcpCustomCommands)
2273+ commands, err := dialog.NewCommands(m.com, sessionID, m.customCommands, m.mcpPrompts)
2199 if err != nil {
2274 if err != nil {
2200 return uiutil.ReportError(err)
2275 return uiutil.ReportError(err)
2201 }
2276 }
@@ -2442,6 +2517,33 @@ func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
2442 ).Draw(scr, area)
2517 ).Draw(scr, area)
2443}
2518}
2444
2519
2520+func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
2521+ load := func() tea.Msg {
2522+ prompt, err := commands.GetMCPPrompt(clientID, promptID, arguments)
2523+ if err != nil {
2524+ // TODO: make this better
2525+ return uiutil.ReportError(err)()
2526+ }
2527+
2528+ if prompt == "" {
2529+ return nil
2530+ }
2531+ return sendMessageMsg{
2532+ Content: prompt,
2533+ }
2534+ }
2535+
2536+ var cmds []tea.Cmd
2537+ if cmd := m.dialog.StartLoading(); cmd != nil {
2538+ cmds = append(cmds, cmd)
2539+ }
2540+ cmds = append(cmds, load, func() tea.Msg {
2541+ return closeDialogMsg{}
2542+ })
2543+
2544+ return tea.Sequence(cmds...)
2545+}
2546+
2445// renderLogo renders the Crush logo with the given styles and dimensions.
2547// renderLogo renders the Crush logo with the given styles and dimensions.
2446func renderLogo(t *styles.Styles, compact bool, width int) string {
2548func renderLogo(t *styles.Styles, compact bool, width int) string {
2447 return logo.Render(version.Version, compact, logo.Opts{
2549 return logo.Render(version.Version, compact, logo.Opts{
internal/ui/styles/styles.go+20
@@ -333,6 +333,8 @@ type Styles struct {
333
333
334 List lipgloss.Style
334 List lipgloss.Style
335
335
336+ Spinner lipgloss.Style
337+
336 // ContentPanel is used for content blocks with subtle background.
338 // ContentPanel is used for content blocks with subtle background.
337 ContentPanel lipgloss.Style
339 ContentPanel lipgloss.Style
338
340
@@ -340,6 +342,16 @@ type Styles struct {
340 ScrollbarThumb lipgloss.Style
342 ScrollbarThumb lipgloss.Style
341 ScrollbarTrack lipgloss.Style
343 ScrollbarTrack lipgloss.Style
342
344
345+ // Arguments
346+ Arguments struct {
347+ Content lipgloss.Style
348+ Description lipgloss.Style
349+ InputLabelBlurred lipgloss.Style
350+ InputLabelFocused lipgloss.Style
351+ InputRequiredMarkBlurred lipgloss.Style
352+ InputRequiredMarkFocused lipgloss.Style
353+ }
354+
343 Commands struct{}
355 Commands struct{}
344
356
345 ImagePreview lipgloss.Style
357 ImagePreview lipgloss.Style
@@ -1207,11 +1219,19 @@ func DefaultStyles() Styles {
1207
1219
1208 s.Dialog.List = base.Margin(0, 0, 1, 0)
1220 s.Dialog.List = base.Margin(0, 0, 1, 0)
1209 s.Dialog.ContentPanel = base.Background(bgSubtle).Foreground(fgBase).Padding(1, 2)
1221 s.Dialog.ContentPanel = base.Background(bgSubtle).Foreground(fgBase).Padding(1, 2)
1222+ s.Dialog.Spinner = base.Foreground(secondary)
1210 s.Dialog.ScrollbarThumb = base.Foreground(secondary)
1223 s.Dialog.ScrollbarThumb = base.Foreground(secondary)
1211 s.Dialog.ScrollbarTrack = base.Foreground(border)
1224 s.Dialog.ScrollbarTrack = base.Foreground(border)
1212
1225
1213 s.Dialog.ImagePreview = lipgloss.NewStyle().Padding(0, 1).Foreground(fgSubtle)
1226 s.Dialog.ImagePreview = lipgloss.NewStyle().Padding(0, 1).Foreground(fgSubtle)
1214
1227
1228+ s.Dialog.Arguments.Content = base.Padding(1)
1229+ s.Dialog.Arguments.Description = base.MarginBottom(1).MaxHeight(3)
1230+ s.Dialog.Arguments.InputLabelBlurred = base.Foreground(fgMuted)
1231+ s.Dialog.Arguments.InputLabelFocused = base.Bold(true)
1232+ s.Dialog.Arguments.InputRequiredMarkBlurred = base.Foreground(fgMuted).SetString("*")
1233+ s.Dialog.Arguments.InputRequiredMarkFocused = base.Foreground(primary).Bold(true).SetString("*")
1234+
1215 s.Status.Help = lipgloss.NewStyle().Padding(0, 1)
1235 s.Status.Help = lipgloss.NewStyle().Padding(0, 1)
1216 s.Status.SuccessIndicator = base.Foreground(bgSubtle).Background(green).Padding(0, 1).Bold(true).SetString("OKAY!")
1236 s.Status.SuccessIndicator = base.Foreground(bgSubtle).Background(green).Padding(0, 1).Bold(true).SetString("OKAY!")
1217 s.Status.InfoIndicator = s.Status.SuccessIndicator
1237 s.Status.InfoIndicator = s.Status.SuccessIndicator