on main help sign in
Merge branch 'main' into multi-client
e530b1fChristian Rocha
.github/cla-signatures.json+40
@@ -1815,6 +1815,46 @@
1815 "created_at": "2026-05-20T06:53:43Z",
1815 "created_at": "2026-05-20T06:53:43Z",
1816 "repoId": 987670088,
1816 "repoId": 987670088,
1817 "pullRequestNo": 2964
1817 "pullRequestNo": 2964
1818+ },
1819+ {
1820+ "name": "g2mt",
1821+ "id": 166577174,
1822+ "comment_id": 4513096006,
1823+ "created_at": "2026-05-21T21:56:14Z",
1824+ "repoId": 987670088,
1825+ "pullRequestNo": 2979
1826+ },
1827+ {
1828+ "name": "Ricardo-M-L",
1829+ "id": 69202550,
1830+ "comment_id": 4514494248,
1831+ "created_at": "2026-05-22T02:37:08Z",
1832+ "repoId": 987670088,
1833+ "pullRequestNo": 2847
1834+ },
1835+ {
1836+ "name": "Muttaqin86",
1837+ "id": 69788027,
1838+ "comment_id": 4520181780,
1839+ "created_at": "2026-05-22T15:46:32Z",
1840+ "repoId": 987670088,
1841+ "pullRequestNo": 2984
1842+ },
1843+ {
1844+ "name": "officialasishkumar",
1845+ "id": 87874775,
1846+ "comment_id": 4527874300,
1847+ "created_at": "2026-05-24T08:44:14Z",
1848+ "repoId": 987670088,
1849+ "pullRequestNo": 2995
1850+ },
1851+ {
1852+ "name": "yhyu13",
1853+ "id": 19365678,
1854+ "comment_id": 4528076561,
1855+ "created_at": "2026-05-24T10:19:27Z",
1856+ "repoId": 987670088,
1857+ "pullRequestNo": 2996
1818 }
1858 }
1819 ]
1859 ]
1820}
1860}
.github/workflows/security.yml+5−5
@@ -30,11 +30,11 @@ jobs:
30 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
30 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
31 with:
31 with:
32 persist-credentials: false
32 persist-credentials: false
33− - uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
33+ - uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
34 with:
34 with:
35 languages: ${{ matrix.language }}
35 languages: ${{ matrix.language }}
36− - uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
36+ - uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
37− - uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
37+ - uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
38
38
39 grype:
39 grype:
40 runs-on: ubuntu-latest
40 runs-on: ubuntu-latest
@@ -52,7 +52,7 @@ jobs:
52 path: "."
52 path: "."
53 fail-build: true
53 fail-build: true
54 severity-cutoff: critical
54 severity-cutoff: critical
55− - uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
55+ - uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
56 with:
56 with:
57 sarif_file: ${{ steps.scan.outputs.sarif }}
57 sarif_file: ${{ steps.scan.outputs.sarif }}
58
58
@@ -73,7 +73,7 @@ jobs:
73 - name: Run govulncheck
73 - name: Run govulncheck
74 run: |
74 run: |
75 govulncheck -C . -format sarif ./... > results.sarif
75 govulncheck -C . -format sarif ./... > results.sarif
76− - uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
76+ - uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
77 with:
77 with:
78 sarif_file: results.sarif
78 sarif_file: results.sarif
79
79
.github/workflows/snapshot.yml+1−1
@@ -25,7 +25,7 @@ jobs:
25 - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
25 - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
26 with:
26 with:
27 go-version-file: go.mod
27 go-version-file: go.mod
28− - uses: goreleaser/goreleaser-action@1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8 # v7.2.1
28+ - uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
29 with:
29 with:
30 version: "nightly"
30 version: "nightly"
31 distribution: goreleaser-pro
31 distribution: goreleaser-pro
go.mod+18−18
@@ -5,9 +5,9 @@ go 1.26.3
5require (
5require (
6 charm.land/bubbles/v2 v2.1.0
6 charm.land/bubbles/v2 v2.1.0
7 charm.land/bubbletea/v2 v2.0.6
7 charm.land/bubbletea/v2 v2.0.6
8− charm.land/catwalk v0.41.0
8+ charm.land/catwalk v0.41.8
9 charm.land/fang/v2 v2.0.1
9 charm.land/fang/v2 v2.0.1
10− charm.land/fantasy v0.25.0
10+ charm.land/fantasy v0.25.2
11 charm.land/glamour/v2 v2.0.0
11 charm.land/glamour/v2 v2.0.0
12 charm.land/lipgloss/v2 v2.0.3
12 charm.land/lipgloss/v2 v2.0.3
13 charm.land/log/v2 v2.0.0
13 charm.land/log/v2 v2.0.0
@@ -41,7 +41,7 @@ require (
41 github.com/disintegration/imaging v1.6.2
41 github.com/disintegration/imaging v1.6.2
42 github.com/dustin/go-humanize v1.0.1
42 github.com/dustin/go-humanize v1.0.1
43 github.com/gen2brain/beeep v0.11.2
43 github.com/gen2brain/beeep v0.11.2
44− github.com/go-git/go-git/v5 v5.19.0
44+ github.com/go-git/go-git/v5 v5.19.1
45 github.com/google/uuid v1.6.0
45 github.com/google/uuid v1.6.0
46 github.com/invopop/jsonschema v0.14.0
46 github.com/invopop/jsonschema v0.14.0
47 github.com/itchyny/gojq v0.12.19
47 github.com/itchyny/gojq v0.12.19
@@ -49,11 +49,11 @@ require (
49 github.com/jordanella/go-ansi-paintbrush v0.0.0-20240728195301-b7ad996ecf3d
49 github.com/jordanella/go-ansi-paintbrush v0.0.0-20240728195301-b7ad996ecf3d
50 github.com/lucasb-eyer/go-colorful v1.4.0
50 github.com/lucasb-eyer/go-colorful v1.4.0
51 github.com/mattn/go-isatty v0.0.22
51 github.com/mattn/go-isatty v0.0.22
52− github.com/modelcontextprotocol/go-sdk v1.6.0
52+ github.com/modelcontextprotocol/go-sdk v1.6.1
53− github.com/ncruces/go-sqlite3 v0.34.1
53+ github.com/ncruces/go-sqlite3 v0.34.2
54 github.com/nxadm/tail v1.4.11
54 github.com/nxadm/tail v1.4.11
55 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
55 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
56− github.com/posthog/posthog-go v1.12.5
56+ github.com/posthog/posthog-go v1.12.6
57 github.com/pressly/goose/v3 v3.27.1
57 github.com/pressly/goose/v3 v3.27.1
58 github.com/qjebbs/go-jsons v1.0.0-alpha.5
58 github.com/qjebbs/go-jsons v1.0.0-alpha.5
59 github.com/rivo/uniseg v0.4.7
59 github.com/rivo/uniseg v0.4.7
@@ -67,9 +67,9 @@ require (
67 github.com/tidwall/sjson v1.2.5
67 github.com/tidwall/sjson v1.2.5
68 github.com/zeebo/xxh3 v1.1.0
68 github.com/zeebo/xxh3 v1.1.0
69 go.uber.org/goleak v1.3.0
69 go.uber.org/goleak v1.3.0
70− golang.org/x/net v0.54.0
70+ golang.org/x/net v0.55.0
71 golang.org/x/sync v0.20.0
71 golang.org/x/sync v0.20.0
72− golang.org/x/sys v0.44.0
72+ golang.org/x/sys v0.45.0
73 golang.org/x/text v0.37.0
73 golang.org/x/text v0.37.0
74 gopkg.in/natefinch/lumberjack.v2 v2.2.1
74 gopkg.in/natefinch/lumberjack.v2 v2.2.1
75 gopkg.in/yaml.v3 v3.0.1
75 gopkg.in/yaml.v3 v3.0.1
@@ -120,7 +120,7 @@ require (
120 github.com/fsnotify/fsnotify v1.9.0 // indirect
120 github.com/fsnotify/fsnotify v1.9.0 // indirect
121 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
121 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
122 github.com/go-git/go-billy/v5 v5.9.0 // indirect
122 github.com/go-git/go-billy/v5 v5.9.0 // indirect
123− github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4 // indirect
123+ github.com/go-json-experiment/json v0.0.0-20260505212615-e40f80bf6836 // indirect
124 github.com/go-logfmt/logfmt v0.6.0 // indirect
124 github.com/go-logfmt/logfmt v0.6.0 // indirect
125 github.com/go-logr/logr v1.4.3 // indirect
125 github.com/go-logr/logr v1.4.3 // indirect
126 github.com/go-logr/stdr v1.2.2 // indirect
126 github.com/go-logr/stdr v1.2.2 // indirect
@@ -147,10 +147,10 @@ require (
147 github.com/jackmordaunt/icns/v3 v3.0.1 // indirect
147 github.com/jackmordaunt/icns/v3 v3.0.1 // indirect
148 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
148 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
149 github.com/josharian/intern v1.0.0 // indirect
149 github.com/josharian/intern v1.0.0 // indirect
150− github.com/kaptinlin/go-i18n v0.4.5 // indirect
150+ github.com/kaptinlin/go-i18n v0.4.8 // indirect
151− github.com/kaptinlin/jsonpointer v0.4.20 // indirect
151+ github.com/kaptinlin/jsonpointer v0.4.23 // indirect
152− github.com/kaptinlin/jsonschema v0.7.13 // indirect
152+ github.com/kaptinlin/jsonschema v0.7.14 // indirect
153− github.com/kaptinlin/messageformat-go v0.6.0 // indirect
153+ github.com/kaptinlin/messageformat-go v0.6.4 // indirect
154 github.com/klauspost/compress v1.18.6 // indirect
154 github.com/klauspost/compress v1.18.6 // indirect
155 github.com/klauspost/cpuid/v2 v2.3.0 // indirect
155 github.com/klauspost/cpuid/v2 v2.3.0 // indirect
156 github.com/klauspost/pgzip v1.2.6 // indirect
156 github.com/klauspost/pgzip v1.2.6 // indirect
@@ -164,7 +164,7 @@ require (
164 github.com/muesli/mango-cobra v1.2.0 // indirect
164 github.com/muesli/mango-cobra v1.2.0 // indirect
165 github.com/muesli/mango-pflag v0.1.0 // indirect
165 github.com/muesli/mango-pflag v0.1.0 // indirect
166 github.com/muesli/roff v0.1.0 // indirect
166 github.com/muesli/roff v0.1.0 // indirect
167− github.com/ncruces/go-sqlite3-wasm/v2 v2.2.35301 // indirect
167+ github.com/ncruces/go-sqlite3-wasm/v2 v2.4.35301 // indirect
168 github.com/ncruces/go-strftime v1.0.0 // indirect
168 github.com/ncruces/go-strftime v1.0.0 // indirect
169 github.com/ncruces/julianday v1.0.0 // indirect
169 github.com/ncruces/julianday v1.0.0 // indirect
170 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
170 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
@@ -205,10 +205,10 @@ require (
205 golang.org/x/term v0.43.0 // indirect
205 golang.org/x/term v0.43.0 // indirect
206 golang.org/x/time v0.15.0 // indirect
206 golang.org/x/time v0.15.0 // indirect
207 golang.org/x/tools v0.44.0 // indirect
207 golang.org/x/tools v0.44.0 // indirect
208− google.golang.org/api v0.278.0 // indirect
208+ google.golang.org/api v0.279.0 // indirect
209− google.golang.org/genai v1.56.0 // indirect
209+ google.golang.org/genai v1.57.0 // indirect
210− google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 // indirect
210+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect
211− google.golang.org/grpc v1.81.0 // indirect
211+ google.golang.org/grpc v1.81.1 // indirect
212 google.golang.org/protobuf v1.36.11 // indirect
212 google.golang.org/protobuf v1.36.11 // indirect
213 gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290 // indirect
213 gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290 // indirect
214 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
214 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
go.sum+42−42
@@ -2,12 +2,12 @@ charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
2charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
2charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
3charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo=
3charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo=
4charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g=
4charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g=
5−charm.land/catwalk v0.41.0 h1:rGeGrEJLFIFqz+glpCD4ICTo2PzL1GMFqGN+jpQn7O4=
5+charm.land/catwalk v0.41.8 h1:SxM6KyFD5jtBF2lZZKk6cYCbw1GVlNfn05ZSRtynxEE=
6−charm.land/catwalk v0.41.0/go.mod h1:LmMFJdRqF5F7qKa+xqD9SBq7tph7L98GU3ZFa1TxftA=
6+charm.land/catwalk v0.41.8/go.mod h1:dtK2+UfdsFJgIriRPodMsSJw0XefrFOq6fdvuS57v3s=
7charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY=
7charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY=
8charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII=
8charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII=
9−charm.land/fantasy v0.25.0 h1:oXOWY1ivmTSnhYGzAolscF8zKtavWZyBWv0LHRSwN5Q=
9+charm.land/fantasy v0.25.2 h1:K7ZOM3UEay//NHfiFAeIMRaOqhspxe0UyccIJOYrjuo=
10−charm.land/fantasy v0.25.0/go.mod h1:8QrWUzIcKwZQP+aAnC9vLu3iID6hu9/Jt+rPMiieBkc=
10+charm.land/fantasy v0.25.2/go.mod h1:9ykD5gjn8BCjpZqA66vet7H1KsmR+kP0Q0qw1FiqCk0=
11charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U=
11charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U=
12charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w=
12charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w=
13charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
13charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
@@ -182,10 +182,10 @@ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66D
182github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
182github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
183github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA=
183github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA=
184github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw=
184github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw=
185−github.com/go-git/go-git/v5 v5.19.0 h1:+WkVUQZSy/F1Gb13udrMKjIM2PrzsNfDKFSfo5tkMtc=
185+github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00=
186−github.com/go-git/go-git/v5 v5.19.0/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ=
186+github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ=
187−github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4 h1:2WmHkJINIjgXXYDGik8d3oJvFA3DAwPy00csDJ3vo+o=
187+github.com/go-json-experiment/json v0.0.0-20260505212615-e40f80bf6836 h1:5KGUhXZFTN1PrCY4zUZLe1J8n7uBNmPDbCLCn78EbPQ=
188−github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
188+github.com/go-json-experiment/json v0.0.0-20260505212615-e40f80bf6836/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
189github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
189github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
190github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
190github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
191github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
191github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -264,14 +264,14 @@ github.com/jordanella/go-ansi-paintbrush v0.0.0-20240728195301-b7ad996ecf3d h1:o
264github.com/jordanella/go-ansi-paintbrush v0.0.0-20240728195301-b7ad996ecf3d/go.mod h1:SV0W0APWP9MZ1/gfDQ/NzzTlWdIgYZ/ZbpN4d/UXRYw=
264github.com/jordanella/go-ansi-paintbrush v0.0.0-20240728195301-b7ad996ecf3d/go.mod h1:SV0W0APWP9MZ1/gfDQ/NzzTlWdIgYZ/ZbpN4d/UXRYw=
265github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
265github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
266github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
266github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
267−github.com/kaptinlin/go-i18n v0.4.5 h1:9tIlo5A0RXth+yZJO2MG7Bhpu/X9PlzQnGz/qyYWNoY=
267+github.com/kaptinlin/go-i18n v0.4.8 h1:ymGkz0uU974wljuuHZufHP1BlFWVk5Tf/sSMO8Cl9yQ=
268−github.com/kaptinlin/go-i18n v0.4.5/go.mod h1:mU/7BH4molY5lGZYBwBRKAaiJ70dWRHuqmQ0/pFLGno=
268+github.com/kaptinlin/go-i18n v0.4.8/go.mod h1:F+ezt0Q39p5x8PQW6p4xKMovhjNbhZYHuqwyEV1hHMw=
269−github.com/kaptinlin/jsonpointer v0.4.20 h1:otSZZnCVdVo9OwOm+AQhS8ke31CLLQYXmG5Q0GOrXYg=
269+github.com/kaptinlin/jsonpointer v0.4.23 h1:0VisnCL7rJT7BRTwxSWMU7vC0PD/RFgmisNcURkWp3k=
270−github.com/kaptinlin/jsonpointer v0.4.20/go.mod h1:Mo7+DX8RlQTFqS4dnYJl0izSP4ob+Rl5xO/mGDETgaU=
270+github.com/kaptinlin/jsonpointer v0.4.23/go.mod h1:Mo7+DX8RlQTFqS4dnYJl0izSP4ob+Rl5xO/mGDETgaU=
271−github.com/kaptinlin/jsonschema v0.7.13 h1:kahVXTy/rURL0XJjyQ9WELm59wEmXi6IY0TWswQEFvU=
271+github.com/kaptinlin/jsonschema v0.7.14 h1:6grzaTJiRuLXlIGEdlGX5HEII3Za2tV+xxGpW3Kg4Rc=
272−github.com/kaptinlin/jsonschema v0.7.13/go.mod h1:Uh0aUBusnhXDCEXJ2oimL/hx7YTo7F+sKniE+tM0ERc=
272+github.com/kaptinlin/jsonschema v0.7.14/go.mod h1:9WFuBzJjrvNkXVjo0L2Ujl1T/yqAGurwgbx4JWgF5C8=
273−github.com/kaptinlin/messageformat-go v0.6.0 h1:D6jiXFsKW4/JG2CMddv/F6Rev9KVbCRKEzzV5QOAcpc=
273+github.com/kaptinlin/messageformat-go v0.6.4 h1:6nC70fsqEn2xxg/Xoby2+Dk2r77kvxa3QNnYL/hsNcM=
274−github.com/kaptinlin/messageformat-go v0.6.0/go.mod h1:NKjwS6e9u7DRhAK+vydjDDwJ7UbdHhYjk/yk2WPuZPs=
274+github.com/kaptinlin/messageformat-go v0.6.4/go.mod h1:553UGZ1x5jmGtyH4pQKYwLGMyPm71deCoZICjq1DtR8=
275github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
275github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
276github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
276github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
277github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
277github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
@@ -304,8 +304,8 @@ github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwX
304github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
304github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
305github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
305github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
306github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
306github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
307−github.com/modelcontextprotocol/go-sdk v1.6.0 h1:PPLS3kn7WtOEnR+Af4X5H96SG0qSab8R/ZQT/HkhPkY=
307+github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
308−github.com/modelcontextprotocol/go-sdk v1.6.0/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
308+github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
309github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
309github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
310github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
310github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
311github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI=
311github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI=
@@ -316,10 +316,10 @@ github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe
316github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=
316github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=
317github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
317github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
318github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
318github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
319−github.com/ncruces/go-sqlite3 v0.34.1 h1:N4NU/MqvZtSseGyTzJXdFI8RoVIR0lWUYeainj4pX2o=
319+github.com/ncruces/go-sqlite3 v0.34.2 h1:+B50kRdn2BfMTSoRbkgnNaIolxIq1qS6lhcXyvNe230=
320−github.com/ncruces/go-sqlite3 v0.34.1/go.mod h1:QuztC+fMQvmDSPEk3E807xYbAVZGsxnismh23mzZPhM=
320+github.com/ncruces/go-sqlite3 v0.34.2/go.mod h1:ZUqB9w9k4ACD7X5YeISBY05glvkgTur3dwhoDFGASK4=
321−github.com/ncruces/go-sqlite3-wasm/v2 v2.2.35301 h1:PPb3vECU21cZr2CrzYG7idXLBpWETDcdVEnXg3vqRpM=
321+github.com/ncruces/go-sqlite3-wasm/v2 v2.4.35301 h1:xGFgiIf1SS4yTqyuW3cSR6hd9KRlUFzVloJ873AyrxU=
322−github.com/ncruces/go-sqlite3-wasm/v2 v2.2.35301/go.mod h1:q34C+veYmxQ7XRFgCKSeD6hq5vP/C517Hkl4iu+A5ao=
322+github.com/ncruces/go-sqlite3-wasm/v2 v2.4.35301/go.mod h1:7dV8P4xml/vrgb/zKfJaZ5aas5el3VyBR28XkpBq5NM=
323github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
323github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
324github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
324github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
325github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
325github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
@@ -333,8 +333,8 @@ github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
333github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
333github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
334github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY=
334github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY=
335github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ=
335github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ=
336−github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
336+github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
337−github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
337+github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
338github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
338github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
339github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
339github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
340github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
340github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
@@ -349,8 +349,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1
349github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
349github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
350github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
350github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
351github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
351github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
352−github.com/posthog/posthog-go v1.12.5 h1:l/x3mpqisXJ0sTOyyRutsTQAgiWYuJT1uhN4cQraJ8o=
352+github.com/posthog/posthog-go v1.12.6 h1:N+FrKWY6DOuDhV2OMgvtKAKDYGTdtS9/nuvr0BTyBp0=
353−github.com/posthog/posthog-go v1.12.5/go.mod h1:xsVOW9YImilUcazwPNEq4PJDqEZf2KeCS758zXjwkPg=
353+github.com/posthog/posthog-go v1.12.6/go.mod h1:xsVOW9YImilUcazwPNEq4PJDqEZf2KeCS758zXjwkPg=
354github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
354github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
355github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
355github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
356github.com/qjebbs/go-jsons v1.0.0-alpha.5 h1:U2PPDxeKI1MMOSw7e7xyxhwH9Ggc7UrDvaRIkJ+l0n8=
356github.com/qjebbs/go-jsons v1.0.0-alpha.5 h1:U2PPDxeKI1MMOSw7e7xyxhwH9Ggc7UrDvaRIkJ+l0n8=
@@ -488,8 +488,8 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
488golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
488golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
489golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
489golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
490golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
490golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
491−golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
491+golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
492−golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
492+golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
493golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
493golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
494golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
494golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
495golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
495golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -516,8 +516,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
516golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
516golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
517golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
517golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
518golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
518golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
519−golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
519+golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
520−golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
520+golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
521golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
521golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
522golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
522golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
523golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
523golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -555,18 +555,18 @@ golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI
555golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
555golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
556gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
556gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
557gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
557gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
558−google.golang.org/api v0.278.0 h1:W7jiRvRi53VYFfZ/HoZjQBtJk7gOFbHD8ot1RzVZU6E=
558+google.golang.org/api v0.279.0 h1:hsx2M2OaRcaKtVYK6vXEUnQvdjnend7ZYES+lYaot74=
559−google.golang.org/api v0.278.0/go.mod h1:B9TqLBwJqVjp1mtt7WeoQwWRwvu/400y5lETOql+giQ=
559+google.golang.org/api v0.279.0/go.mod h1:B9TqLBwJqVjp1mtt7WeoQwWRwvu/400y5lETOql+giQ=
560−google.golang.org/genai v1.56.0 h1:IwWrg1K0cn1/WBiPno/dYr0Q6o75NeH/bh3G4JEFERE=
560+google.golang.org/genai v1.57.0 h1:qTyG2ynz5dQy2jF4CvZdLHHVslhR0heMue+zM1a4GNM=
561−google.golang.org/genai v1.56.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
561+google.golang.org/genai v1.57.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
562−google.golang.org/genproto v0.0.0-20260504160031-60b97b32f348 h1:JjVGDZYWkJWZcxveJGzfkXC5myDVWAd4dZdgbzrDUv8=
562+google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60 h1:rhBdfmsOlOZIvz3Y5/BdUzPg2CkO8L7QQPKj96B8554=
563−google.golang.org/genproto v0.0.0-20260504160031-60b97b32f348/go.mod h1:95PqD4xM+AdOcBGsmgfaofXsiA37uXDtDufVbntT3TU=
563+google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60/go.mod h1:8xo2Pj1b20ZOCpzlU3B9qieMwVIAXx1QVZWLMlPL6sM=
564−google.golang.org/genproto/googleapis/api v0.0.0-20260504160031-60b97b32f348 h1:U8orV30l6KpDsi9dxU0CoJZGbjS8EEpw+6ba+XwGPQA=
564+google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 h1:3WsB1FAbiRIf2tOxscWKs3pQBD9he1NsrnbhMuWfekc=
565−google.golang.org/genproto/googleapis/api v0.0.0-20260504160031-60b97b32f348/go.mod h1:Yzdzr5OOZFgSsEV2D/Xi9NL3bszpXFAg0hFJiRohcD8=
565+google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60/go.mod h1:7yoXV7RIh5gblj/xVYoogxAWvA9wUeVbpsK/M694l00=
566−google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 h1:pfIbyB44sWzHiCpRqIen67ZQnVXSfIxWrqUMk1qwODE=
566+google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo=
567−google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
567+google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
568−google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw=
568+google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
569−google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
569+google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
570google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
570google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
571google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
571google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
572gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
572gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
internal/agent/agent.go+45−13
@@ -261,6 +261,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
261 a.eventPromptSent(call.SessionID)
261 a.eventPromptSent(call.SessionID)
262
262
263 var currentAssistant *message.Message
263 var currentAssistant *message.Message
264+ var stepMessages []fantasy.Message
264 var shouldSummarize bool
265 var shouldSummarize bool
265 // Don't send MaxOutputTokens if 0 — some providers (e.g. LM Studio) reject it
266 // Don't send MaxOutputTokens if 0 — some providers (e.g. LM Studio) reject it
266 var maxOutputTokens *int64
267 var maxOutputTokens *int64
@@ -319,6 +320,10 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
319 prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(promptPrefix)}, prepared.Messages...)
320 prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(promptPrefix)}, prepared.Messages...)
320 }
321 }
321
322
323+ sessionLock.Lock()
324+ stepMessages = cloneFantasyMessages(prepared.Messages)
325+ sessionLock.Unlock()
326+
322 var assistantMsg message.Message
327 var assistantMsg message.Message
323 assistantMsg, err = a.messages.Create(callContext, call.SessionID, message.CreateMessageParams{
328 assistantMsg, err = a.messages.Create(callContext, call.SessionID, message.CreateMessageParams{
324 Role: message.Assistant,
329 Role: message.Assistant,
@@ -444,7 +449,8 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
444 if getSessionErr != nil {
449 if getSessionErr != nil {
445 return getSessionErr
450 return getSessionErr
446 }
451 }
452+ usage, estimated := fallbackStepUsage(stepMessages, stepResult)
447− a.updateSessionUsage(largeModel, &updatedSession, stepResult.Usage, a.openrouterCost(stepResult.ProviderMetadata))
453+ a.updateSessionUsage(largeModel, &updatedSession, usage, a.openrouterCost(stepResult.ProviderMetadata), estimated)
448 _, sessionErr := a.sessions.Save(ctx, updatedSession)
454 _, sessionErr := a.sessions.Save(ctx, updatedSession)
449 if sessionErr != nil {
455 if sessionErr != nil {
450 return sessionErr
456 return sessionErr
@@ -749,13 +755,14 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan
749 }
755 }
750 }
756 }
751
757
752− a.updateSessionUsage(largeModel, &currentSession, resp.TotalUsage, openrouterCost)
758+ a.updateSessionUsage(largeModel, &currentSession, resp.TotalUsage, openrouterCost, false)
753
759
754 // Just in case, get just the last usage info.
760 // Just in case, get just the last usage info.
755 usage := resp.Response.Usage
761 usage := resp.Response.Usage
756 currentSession.SummaryMessageID = summaryMessage.ID
762 currentSession.SummaryMessageID = summaryMessage.ID
757− currentSession.CompletionTokens = usage.OutputTokens
763+ currentSession.CompletionTokens = summaryCompletionTokens(usage, summaryMessage)
758 currentSession.PromptTokens = 0
764 currentSession.PromptTokens = 0
765+ currentSession.EstimatedUsage = usageIsZero(usage)
759 _, err = a.sessions.Save(genCtx, currentSession)
766 _, err = a.sessions.Save(genCtx, currentSession)
760 if err != nil {
767 if err != nil {
761 return err
768 return err
@@ -1132,28 +1139,53 @@ func (a *sessionAgent) openrouterCost(metadata fantasy.ProviderMetadata) *float6
1132 return &opts.Usage.Cost
1139 return &opts.Usage.Cost
1133}
1140}
1134
1141
1135−func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session, usage fantasy.Usage, overrideCost *float64) {
1142+func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session, usage fantasy.Usage, overrideCost *float64, estimated bool) {
1143+ if !usageIsZero(usage) {
1144+ session.EstimatedUsage = estimated
1145+ }
1146+
1136 modelConfig := model.CatwalkCfg
1147 modelConfig := model.CatwalkCfg
1137 cost := modelConfig.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) +
1148 cost := modelConfig.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) +
1138 modelConfig.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) +
1149 modelConfig.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) +
1139 modelConfig.CostPer1MIn/1e6*float64(usage.InputTokens) +
1150 modelConfig.CostPer1MIn/1e6*float64(usage.InputTokens) +
1140 modelConfig.CostPer1MOut/1e6*float64(usage.OutputTokens)
1151 modelConfig.CostPer1MOut/1e6*float64(usage.OutputTokens)
1141
1152
1153+ if !estimated {
1142− a.eventTokensUsed(session.ID, model, usage, cost)
1154+ a.eventTokensUsed(session.ID, model, usage, cost)
1143−
1144− // Use override cost if available (e.g., from OpenRouter).
1145− if overrideCost != nil {
1146− cost = *overrideCost
1147 }
1155 }
1148
1156
1149− // Skip cost accumulation
1157+ if estimated {
1150− if model.FlatRate {
1151 cost = 0
1158 cost = 0
1159+ } else {
1160+ // Use override cost if available (e.g., from OpenRouter).
1161+ if overrideCost != nil {
1162+ cost = *overrideCost
1163+ }
1164+
1165+ // Skip cost accumulation
1166+ if model.FlatRate {
1167+ cost = 0
1168+ }
1152 }
1169 }
1153
1170
1154 session.Cost += cost
1171 session.Cost += cost
1172+ updateSessionTokenCounters(session, usage)
1173+}
1174+
1175+func updateSessionTokenCounters(session *session.Session, usage fantasy.Usage) {
1176+ if usage.OutputTokens != 0 {
1155− session.CompletionTokens = usage.OutputTokens
1177+ session.CompletionTokens = usage.OutputTokens
1178+ }
1156− session.PromptTokens = usage.InputTokens + usage.CacheReadTokens
1179+ if promptTokens := usage.InputTokens + usage.CacheReadTokens; promptTokens != 0 {
1180+ session.PromptTokens = promptTokens
1181+ }
1182+}
1183+
1184+func summaryCompletionTokens(usage fantasy.Usage, summaryMessage message.Message) int64 {
1185+ if usage.OutputTokens != 0 {
1186+ return usage.OutputTokens
1187+ }
1188+ return approxTokenCount(summaryMessage.Content().Text) + approxTokenCount(summaryMessage.ReasoningContent().String())
1157}
1189}
1158
1190
1159func (a *sessionAgent) Cancel(sessionID string) {
1191func (a *sessionAgent) Cancel(sessionID string) {
internal/agent/coordinator.go+8−5
@@ -283,10 +283,13 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.
283 return options
283 return options
284 }
284 }
285
285
286+ shouldSetEffort := model.CatwalkCfg.CanReason &&
287+ slices.Contains(model.CatwalkCfg.ReasoningLevels, model.ModelCfg.ReasoningEffort)
288+
286 switch providerCfg.Type {
289 switch providerCfg.Type {
287 case openai.Name, azure.Name:
290 case openai.Name, azure.Name:
288 _, hasReasoningEffort := mergedOptions["reasoning_effort"]
291 _, hasReasoningEffort := mergedOptions["reasoning_effort"]
289− if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" && model.CatwalkCfg.CanReason {
292+ if !hasReasoningEffort && shouldSetEffort {
290 mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
293 mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
291 }
294 }
292 if openai.IsResponsesModel(model.CatwalkCfg.ID) {
295 if openai.IsResponsesModel(model.CatwalkCfg.ID) {
@@ -310,7 +313,7 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.
310 _, hasThink = mergedOptions["thinking"]
313 _, hasThink = mergedOptions["thinking"]
311 )
314 )
312 switch {
315 switch {
313− case !hasEffort && model.ModelCfg.ReasoningEffort != "" && model.CatwalkCfg.CanReason:
316+ case !hasEffort && shouldSetEffort:
314 mergedOptions["effort"] = model.ModelCfg.ReasoningEffort
317 mergedOptions["effort"] = model.ModelCfg.ReasoningEffort
315 case !hasThink && model.ModelCfg.Think:
318 case !hasThink && model.ModelCfg.Think:
316 mergedOptions["thinking"] = map[string]any{"budget_tokens": 2000}
319 mergedOptions["thinking"] = map[string]any{"budget_tokens": 2000}
@@ -322,7 +325,7 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.
322
325
323 case openrouter.Name:
326 case openrouter.Name:
324 _, hasReasoning := mergedOptions["reasoning"]
327 _, hasReasoning := mergedOptions["reasoning"]
325− if !hasReasoning && model.ModelCfg.ReasoningEffort != "" {
328+ if !hasReasoning && shouldSetEffort {
326 mergedOptions["reasoning"] = map[string]any{
329 mergedOptions["reasoning"] = map[string]any{
327 "enabled": true,
330 "enabled": true,
328 "effort": model.ModelCfg.ReasoningEffort,
331 "effort": model.ModelCfg.ReasoningEffort,
@@ -334,7 +337,7 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.
334 }
337 }
335 case vercel.Name:
338 case vercel.Name:
336 _, hasReasoning := mergedOptions["reasoning"]
339 _, hasReasoning := mergedOptions["reasoning"]
337− if !hasReasoning && model.ModelCfg.ReasoningEffort != "" {
340+ if !hasReasoning && shouldSetEffort {
338 mergedOptions["reasoning"] = map[string]any{
341 mergedOptions["reasoning"] = map[string]any{
339 "enabled": true,
342 "enabled": true,
340 "effort": model.ModelCfg.ReasoningEffort,
343 "effort": model.ModelCfg.ReasoningEffort,
@@ -367,7 +370,7 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.
367 extraBody := make(map[string]any)
370 extraBody := make(map[string]any)
368
371
369 _, hasReasoningEffort := mergedOptions["reasoning_effort"]
372 _, hasReasoningEffort := mergedOptions["reasoning_effort"]
370− if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" && model.CatwalkCfg.CanReason {
373+ if !hasReasoningEffort && shouldSetEffort {
371 switch providerCfg.ID {
374 switch providerCfg.ID {
372 case string(catwalk.InferenceProviderIoNet):
375 case string(catwalk.InferenceProviderIoNet):
373 extraBody["reasoning"] = map[string]string{"effort": model.ModelCfg.ReasoningEffort}
376 extraBody["reasoning"] = map[string]string{"effort": model.ModelCfg.ReasoningEffort}
internal/agent/coordinator_test.go+5−1
@@ -399,7 +399,11 @@ func TestGetProviderOptionsReasoningEffort(t *testing.T) {
399 for _, tc := range tests {
399 for _, tc := range tests {
400 t.Run(tc.name, func(t *testing.T) {
400 t.Run(tc.name, func(t *testing.T) {
401 model := Model{
401 model := Model{
402− CatwalkCfg: catwalk.Model{ID: "claude-opus-4-7", CanReason: true},
402+ CatwalkCfg: catwalk.Model{
403+ ID: "claude-opus-4-7",
404+ CanReason: true,
405+ ReasoningLevels: []string{"max"},
406+ },
403 ModelCfg: config.SelectedModel{
407 ModelCfg: config.SelectedModel{
404 Provider: "test",
408 Provider: "test",
405 ReasoningEffort: "max",
409 ReasoningEffort: "max",
internal/agent/hyper/provider.json+75−39
@@ -9,9 +9,9 @@
9 {
9 {
10 "id": "deepseek-v4-flash",
10 "id": "deepseek-v4-flash",
11 "name": "DeepSeek V4 Flash",
11 "name": "DeepSeek V4 Flash",
12− "cost_per_1m_in": 1.55,
12+ "cost_per_1m_in": 0.146,
13− "cost_per_1m_out": 2.28,
13+ "cost_per_1m_out": 0.294,
14− "cost_per_1m_in_cached": 0.38,
14+ "cost_per_1m_in_cached": 0.073,
15 "cost_per_1m_out_cached": 0,
15 "cost_per_1m_out_cached": 0,
16 "context_window": 1048576,
16 "context_window": 1048576,
17 "default_max_tokens": 104857,
17 "default_max_tokens": 104857,
@@ -21,9 +21,9 @@
21 {
21 {
22 "id": "deepseek-v4-pro",
22 "id": "deepseek-v4-pro",
23 "name": "DeepSeek V4 Pro",
23 "name": "DeepSeek V4 Pro",
24− "cost_per_1m_in": 4.45,
24+ "cost_per_1m_in": 1.788,
25− "cost_per_1m_out": 5.5,
25+ "cost_per_1m_out": 3.62,
26− "cost_per_1m_in_cached": 0.35,
26+ "cost_per_1m_in_cached": 0.894,
27 "cost_per_1m_out_cached": 0,
27 "cost_per_1m_out_cached": 0,
28 "context_window": 1048576,
28 "context_window": 1048576,
29 "default_max_tokens": 60000,
29 "default_max_tokens": 60000,
@@ -33,9 +33,9 @@
33 {
33 {
34 "id": "gemma-4-26b-a4b-it",
34 "id": "gemma-4-26b-a4b-it",
35 "name": "Gemma 4 26B A4B",
35 "name": "Gemma 4 26B A4B",
36− "cost_per_1m_in": 0.145,
36+ "cost_per_1m_in": 0.1225,
37− "cost_per_1m_out": 0.5,
37+ "cost_per_1m_out": 0.428,
38− "cost_per_1m_in_cached": 0.08,
38+ "cost_per_1m_in_cached": 0.06125,
39 "cost_per_1m_out_cached": 0,
39 "cost_per_1m_out_cached": 0,
40 "context_window": 256000,
40 "context_window": 256000,
41 "default_max_tokens": 25600,
41 "default_max_tokens": 25600,
@@ -45,9 +45,9 @@
45 {
45 {
46 "id": "glm-5",
46 "id": "glm-5",
47 "name": "GLM-5",
47 "name": "GLM-5",
48− "cost_per_1m_in": 1,
48+ "cost_per_1m_in": 0.92,
49− "cost_per_1m_out": 3,
49+ "cost_per_1m_out": 2.976,
50− "cost_per_1m_in_cached": 0.5,
50+ "cost_per_1m_in_cached": 0.46,
51 "cost_per_1m_out_cached": 0,
51 "cost_per_1m_out_cached": 0,
52 "context_window": 202752,
52 "context_window": 202752,
53 "default_max_tokens": 20275,
53 "default_max_tokens": 20275,
@@ -57,9 +57,9 @@
57 {
57 {
58 "id": "glm-5.1",
58 "id": "glm-5.1",
59 "name": "GLM-5.1",
59 "name": "GLM-5.1",
60− "cost_per_1m_in": 1.5,
60+ "cost_per_1m_in": 1.33,
61− "cost_per_1m_out": 4.4,
61+ "cost_per_1m_out": 4.22,
62− "cost_per_1m_in_cached": 0.26,
62+ "cost_per_1m_in_cached": 0.665,
63 "cost_per_1m_out_cached": 0,
63 "cost_per_1m_out_cached": 0,
64 "context_window": 202750,
64 "context_window": 202750,
65 "default_max_tokens": 1638,
65 "default_max_tokens": 1638,
@@ -69,9 +69,9 @@
69 {
69 {
70 "id": "gpt-oss-120b",
70 "id": "gpt-oss-120b",
71 "name": "gpt-oss-120b",
71 "name": "gpt-oss-120b",
72− "cost_per_1m_in": 0.1,
72+ "cost_per_1m_in": 0.162,
73− "cost_per_1m_out": 0.4,
73+ "cost_per_1m_out": 0.69,
74− "cost_per_1m_in_cached": 0.01,
74+ "cost_per_1m_in_cached": 0.081,
75 "cost_per_1m_out_cached": 0,
75 "cost_per_1m_out_cached": 0,
76 "context_window": 131072,
76 "context_window": 131072,
77 "default_max_tokens": 13107,
77 "default_max_tokens": 13107,
@@ -87,9 +87,9 @@
87 {
87 {
88 "id": "kimi-k2.5",
88 "id": "kimi-k2.5",
89 "name": "Kimi K2.5",
89 "name": "Kimi K2.5",
90− "cost_per_1m_in": 0.445,
90+ "cost_per_1m_in": 0.562,
91− "cost_per_1m_out": 2,
91+ "cost_per_1m_out": 2.91,
92− "cost_per_1m_in_cached": 0.225,
92+ "cost_per_1m_in_cached": 0.281,
93 "cost_per_1m_out_cached": 0,
93 "cost_per_1m_out_cached": 0,
94 "context_window": 262144,
94 "context_window": 262144,
95 "default_max_tokens": 26214,
95 "default_max_tokens": 26214,
@@ -99,9 +99,9 @@
99 {
99 {
100 "id": "kimi-k2.6",
100 "id": "kimi-k2.6",
101 "name": "Kimi K2.6",
101 "name": "Kimi K2.6",
102− "cost_per_1m_in": 0.74,
102+ "cost_per_1m_in": 1,
103− "cost_per_1m_out": 3.5,
103+ "cost_per_1m_out": 4.1,
104− "cost_per_1m_in_cached": 0.25,
104+ "cost_per_1m_in_cached": 0.5,
105 "cost_per_1m_out_cached": 0,
105 "cost_per_1m_out_cached": 0,
106 "context_window": 262142,
106 "context_window": 262142,
107 "default_max_tokens": 26214,
107 "default_max_tokens": 26214,
@@ -111,9 +111,9 @@
111 {
111 {
112 "id": "llama-3.3-70b-instruct",
112 "id": "llama-3.3-70b-instruct",
113 "name": "Llama 3.3 70B Instruct",
113 "name": "Llama 3.3 70B Instruct",
114− "cost_per_1m_in": 0.1,
114+ "cost_per_1m_in": 0.638,
115− "cost_per_1m_out": 0.32,
115+ "cost_per_1m_out": 0.768,
116− "cost_per_1m_in_cached": 0.05,
116+ "cost_per_1m_in_cached": 0.319,
117 "cost_per_1m_out_cached": 0,
117 "cost_per_1m_out_cached": 0,
118 "context_window": 128000,
118 "context_window": 128000,
119 "default_max_tokens": 12800,
119 "default_max_tokens": 12800,
@@ -123,9 +123,9 @@
123 {
123 {
124 "id": "llama-4-maverick-17b-128e-instruct-fp8",
124 "id": "llama-4-maverick-17b-128e-instruct-fp8",
125 "name": "Llama 4 Maverick 17B 128E Instruct FP8",
125 "name": "Llama 4 Maverick 17B 128E Instruct FP8",
126− "cost_per_1m_in": 0.15,
126+ "cost_per_1m_in": 0.35,
127− "cost_per_1m_out": 0.6,
127+ "cost_per_1m_out": 1.0625,
128− "cost_per_1m_in_cached": 0.075,
128+ "cost_per_1m_in_cached": 0.175,
129 "cost_per_1m_out_cached": 0,
129 "cost_per_1m_out_cached": 0,
130 "context_window": 430000,
130 "context_window": 430000,
131 "default_max_tokens": 43000,
131 "default_max_tokens": 43000,
@@ -135,9 +135,9 @@
135 {
135 {
136 "id": "minimax-m2.7",
136 "id": "minimax-m2.7",
137 "name": "MiniMax M2.7",
137 "name": "MiniMax M2.7",
138− "cost_per_1m_in": 0.3,
138+ "cost_per_1m_in": 0.4158,
139− "cost_per_1m_out": 1.2,
139+ "cost_per_1m_out": 1.68,
140− "cost_per_1m_in_cached": 0.06,
140+ "cost_per_1m_in_cached": 0.2079,
141 "cost_per_1m_out_cached": 0,
141 "cost_per_1m_out_cached": 0,
142 "context_window": 204800,
142 "context_window": 204800,
143 "default_max_tokens": 20480,
143 "default_max_tokens": 20480,
@@ -156,12 +156,48 @@
156 "can_reason": false,
156 "can_reason": false,
157 "supports_attachments": true
157 "supports_attachments": true
158 },
158 },
159+ {
160+ "id": "qwen3.6-flash",
161+ "name": "Qwen3.6-Flash",
162+ "cost_per_1m_in": 1,
163+ "cost_per_1m_out": 4,
164+ "cost_per_1m_in_cached": 0.1,
165+ "cost_per_1m_out_cached": 1.25,
166+ "context_window": 1000000,
167+ "default_max_tokens": 64000,
168+ "can_reason": true,
169+ "supports_attachments": true
170+ },
171+ {
172+ "id": "qwen3.6-max",
173+ "name": "Qwen3.6-Max",
174+ "cost_per_1m_in": 2,
175+ "cost_per_1m_out": 12,
176+ "cost_per_1m_in_cached": 0.2,
177+ "cost_per_1m_out_cached": 2.5,
178+ "context_window": 256000,
179+ "default_max_tokens": 64000,
180+ "can_reason": true,
181+ "supports_attachments": false
182+ },
183+ {
184+ "id": "qwen3.6-plus",
185+ "name": "Qwen3.6-Plus",
186+ "cost_per_1m_in": 2,
187+ "cost_per_1m_out": 6,
188+ "cost_per_1m_in_cached": 0.2,
189+ "cost_per_1m_out_cached": 2.5,
190+ "context_window": 1000000,
191+ "default_max_tokens": 64000,
192+ "can_reason": true,
193+ "supports_attachments": true
194+ },
159 {
195 {
160 "id": "qwen3-coder-480b-a35b-instruct-int4-mixed-ar",
196 "id": "qwen3-coder-480b-a35b-instruct-int4-mixed-ar",
161 "name": "Qwen3 Coder 480B A35B Instruct INT4 Mixed AR",
197 "name": "Qwen3 Coder 480B A35B Instruct INT4 Mixed AR",
162− "cost_per_1m_in": 0.22,
198+ "cost_per_1m_in": 0.746,
163− "cost_per_1m_out": 0.95,
199+ "cost_per_1m_out": 2.13,
164− "cost_per_1m_in_cached": 0.11,
200+ "cost_per_1m_in_cached": 0.373,
165 "cost_per_1m_out_cached": 0,
201 "cost_per_1m_out_cached": 0,
166 "context_window": 106000,
202 "context_window": 106000,
167 "default_max_tokens": 10600,
203 "default_max_tokens": 10600,
@@ -171,9 +207,9 @@
171 {
207 {
172 "id": "qwen3-next-80b-a3b-instruct",
208 "id": "qwen3-next-80b-a3b-instruct",
173 "name": "Qwen3 Next 80B A3B Instruct",
209 "name": "Qwen3 Next 80B A3B Instruct",
174− "cost_per_1m_in": 0.06,
210+ "cost_per_1m_in": 0.128,
175− "cost_per_1m_out": 0.6,
211+ "cost_per_1m_out": 1.28,
176− "cost_per_1m_in_cached": 0.03,
212+ "cost_per_1m_in_cached": 0.064,
177 "cost_per_1m_out_cached": 0,
213 "cost_per_1m_out_cached": 0,
178 "context_window": 262144,
214 "context_window": 262144,
179 "default_max_tokens": 26214,
215 "default_max_tokens": 26214,
internal/agent/usage_fallback.gonew file+176
@@ -0,0 +1,176 @@
1+package agent
2+
3+import (
4+ "fmt"
5+
6+ "charm.land/fantasy"
7+)
8+
9+func usageIsZero(usage fantasy.Usage) bool {
10+ return usage.InputTokens == 0 &&
11+ usage.OutputTokens == 0 &&
12+ usage.TotalTokens == 0 &&
13+ usage.ReasoningTokens == 0 &&
14+ usage.CacheCreationTokens == 0 &&
15+ usage.CacheReadTokens == 0
16+}
17+
18+func fallbackStepUsage(messages []fantasy.Message, step fantasy.StepResult) (fantasy.Usage, bool) {
19+ if !usageIsZero(step.Usage) {
20+ return step.Usage, false
21+ }
22+
23+ inputTokens := estimateMessageTokens(messages)
24+ outputTokens := estimateStepCompletionTokens(step)
25+ if inputTokens == 0 && outputTokens == 0 {
26+ return fantasy.Usage{}, false
27+ }
28+
29+ return fantasy.Usage{
30+ InputTokens: inputTokens,
31+ OutputTokens: outputTokens,
32+ TotalTokens: inputTokens + outputTokens,
33+ }, true
34+}
35+
36+func cloneFantasyMessages(messages []fantasy.Message) []fantasy.Message {
37+ cloned := make([]fantasy.Message, len(messages))
38+ for i, msg := range messages {
39+ cloned[i] = msg
40+ cloned[i].Content = append([]fantasy.MessagePart(nil), msg.Content...)
41+ }
42+ return cloned
43+}
44+
45+func estimateMessageTokens(messages []fantasy.Message) int64 {
46+ var tokens int64
47+ for _, msg := range messages {
48+ tokens += approxTokenCount(string(msg.Role))
49+ for _, part := range msg.Content {
50+ tokens += estimateMessagePartTokens(part)
51+ }
52+ }
53+ return tokens
54+}
55+
56+func estimateStepCompletionTokens(step fantasy.StepResult) int64 {
57+ var tokens int64
58+ for _, content := range step.Content {
59+ switch c := content.(type) {
60+ case fantasy.TextContent:
61+ tokens += approxTokenCount(c.Text)
62+ case *fantasy.TextContent:
63+ tokens += approxTokenCount(c.Text)
64+ case fantasy.ReasoningContent:
65+ tokens += approxTokenCount(c.Text)
66+ case *fantasy.ReasoningContent:
67+ tokens += approxTokenCount(c.Text)
68+ case fantasy.FileContent:
69+ tokens += estimateGeneratedFileTokens(c)
70+ case *fantasy.FileContent:
71+ tokens += estimateGeneratedFileTokens(*c)
72+ case fantasy.SourceContent:
73+ tokens += estimateSourceTokens(c)
74+ case *fantasy.SourceContent:
75+ tokens += estimateSourceTokens(*c)
76+ case fantasy.ToolCallContent:
77+ tokens += estimateToolCallTokens(c.ToolName, c.Input)
78+ case *fantasy.ToolCallContent:
79+ tokens += estimateToolCallTokens(c.ToolName, c.Input)
80+ case fantasy.ToolResultContent:
81+ if c.ProviderExecuted {
82+ tokens += estimateToolResultContentTokens(c.ToolCallID, c.ToolName, c.ClientMetadata, c.Result)
83+ }
84+ case *fantasy.ToolResultContent:
85+ if c.ProviderExecuted {
86+ tokens += estimateToolResultContentTokens(c.ToolCallID, c.ToolName, c.ClientMetadata, c.Result)
87+ }
88+ }
89+ }
90+ return tokens
91+}
92+
93+func estimateMessagePartTokens(part fantasy.MessagePart) int64 {
94+ switch p := part.(type) {
95+ case fantasy.TextPart:
96+ return approxTokenCount(p.Text)
97+ case *fantasy.TextPart:
98+ return approxTokenCount(p.Text)
99+ case fantasy.ReasoningPart:
100+ return approxTokenCount(p.Text)
101+ case *fantasy.ReasoningPart:
102+ return approxTokenCount(p.Text)
103+ case fantasy.FilePart:
104+ return estimateFilePartTokens(p)
105+ case *fantasy.FilePart:
106+ return estimateFilePartTokens(*p)
107+ case fantasy.ToolCallPart:
108+ return estimateToolCallTokens(p.ToolName, p.Input)
109+ case *fantasy.ToolCallPart:
110+ return estimateToolCallTokens(p.ToolName, p.Input)
111+ case fantasy.ToolResultPart:
112+ return estimateToolResultContentTokens(p.ToolCallID, "", "", p.Output)
113+ case *fantasy.ToolResultPart:
114+ return estimateToolResultContentTokens(p.ToolCallID, "", "", p.Output)
115+ default:
116+ return 0
117+ }
118+}
119+
120+func estimateToolCallTokens(toolName, input string) int64 {
121+ return approxTokenCount(toolName) + approxTokenCount(input)
122+}
123+
124+func estimateToolResultContentTokens(toolCallID, toolName, metadata string, output fantasy.ToolResultOutputContent) int64 {
125+ tokens := approxTokenCount(toolCallID) + approxTokenCount(toolName) + approxTokenCount(metadata)
126+ switch result := output.(type) {
127+ case fantasy.ToolResultOutputContentText:
128+ tokens += approxTokenCount(result.Text)
129+ case *fantasy.ToolResultOutputContentText:
130+ tokens += approxTokenCount(result.Text)
131+ case fantasy.ToolResultOutputContentError:
132+ if result.Error != nil {
133+ tokens += approxTokenCount(result.Error.Error())
134+ }
135+ case *fantasy.ToolResultOutputContentError:
136+ if result.Error != nil {
137+ tokens += approxTokenCount(result.Error.Error())
138+ }
139+ case fantasy.ToolResultOutputContentMedia:
140+ tokens += estimateMediaTokens(result.MediaType, result.Text, len(result.Data))
141+ case *fantasy.ToolResultOutputContentMedia:
142+ tokens += estimateMediaTokens(result.MediaType, result.Text, len(result.Data))
143+ }
144+ return tokens
145+}
146+
147+func estimateFilePartTokens(file fantasy.FilePart) int64 {
148+ return estimateMediaTokens(file.MediaType, file.Filename, len(file.Data))
149+}
150+
151+func estimateGeneratedFileTokens(file fantasy.FileContent) int64 {
152+ return estimateMediaTokens(file.MediaType, "", len(file.Data))
153+}
154+
155+func estimateMediaTokens(mediaType, text string, dataBytes int) int64 {
156+ if dataBytes == 0 {
157+ return approxTokenCount(mediaType) + approxTokenCount(text)
158+ }
159+ return approxTokenCount(fmt.Sprintf("%s %s %d bytes", mediaType, text, dataBytes))
160+}
161+
162+func estimateSourceTokens(source fantasy.SourceContent) int64 {
163+ return approxTokenCount(string(source.SourceType)) +
164+ approxTokenCount(source.ID) +
165+ approxTokenCount(source.URL) +
166+ approxTokenCount(source.Title) +
167+ approxTokenCount(source.MediaType) +
168+ approxTokenCount(source.Filename)
169+}
170+
171+func approxTokenCount(s string) int64 {
172+ if s == "" {
173+ return 0
174+ }
175+ return int64((len(s) + 3) / 4)
176+}
internal/agent/usage_fallback_test.gonew file+339
@@ -0,0 +1,339 @@
1+package agent
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "charm.land/catwalk/pkg/catwalk"
8+ "charm.land/fantasy"
9+ "github.com/charmbracelet/crush/internal/message"
10+ "github.com/charmbracelet/crush/internal/session"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestUsageIsZero(t *testing.T) {
15+ t.Parallel()
16+
17+ require.True(t, usageIsZero(fantasy.Usage{}))
18+ require.False(t, usageIsZero(fantasy.Usage{InputTokens: 1}))
19+ require.False(t, usageIsZero(fantasy.Usage{OutputTokens: 1}))
20+ require.False(t, usageIsZero(fantasy.Usage{TotalTokens: 1}))
21+ require.False(t, usageIsZero(fantasy.Usage{ReasoningTokens: 1}))
22+ require.False(t, usageIsZero(fantasy.Usage{CacheCreationTokens: 1}))
23+ require.False(t, usageIsZero(fantasy.Usage{CacheReadTokens: 1}))
24+}
25+
26+func TestFallbackStepUsageKeepsProviderUsage(t *testing.T) {
27+ t.Parallel()
28+
29+ usage := fantasy.Usage{
30+ InputTokens: 10,
31+ OutputTokens: 5,
32+ TotalTokens: 15,
33+ }
34+ step := fantasy.StepResult{
35+ Response: fantasy.Response{Usage: usage},
36+ }
37+
38+ fallbackUsage, estimated := fallbackStepUsage(nil, step)
39+ require.False(t, estimated)
40+ require.Equal(t, usage, fallbackUsage)
41+}
42+
43+func TestFallbackStepUsageEstimatesPromptAndAssistantText(t *testing.T) {
44+ t.Parallel()
45+
46+ messages := []fantasy.Message{
47+ fantasy.NewUserMessage("please explain the implementation details"),
48+ }
49+ step := fantasy.StepResult{
50+ Response: fantasy.Response{
51+ Content: fantasy.ResponseContent{
52+ fantasy.TextContent{Text: "the implementation stores state safely"},
53+ },
54+ },
55+ }
56+
57+ usage, estimated := fallbackStepUsage(messages, step)
58+ require.True(t, estimated)
59+ require.Positive(t, usage.InputTokens)
60+ require.Positive(t, usage.OutputTokens)
61+ require.Equal(t, usage.InputTokens+usage.OutputTokens, usage.TotalTokens)
62+}
63+
64+func TestFallbackStepUsageEstimatesReasoning(t *testing.T) {
65+ t.Parallel()
66+
67+ messages := []fantasy.Message{
68+ {
69+ Role: fantasy.MessageRoleAssistant,
70+ Content: []fantasy.MessagePart{
71+ fantasy.ReasoningPart{Text: "first reason about the request"},
72+ },
73+ },
74+ }
75+ step := fantasy.StepResult{
76+ Response: fantasy.Response{
77+ Content: fantasy.ResponseContent{
78+ fantasy.ReasoningContent{Text: "second reason about the answer"},
79+ },
80+ },
81+ }
82+
83+ usage, estimated := fallbackStepUsage(messages, step)
84+ require.True(t, estimated)
85+ require.Positive(t, usage.InputTokens)
86+ require.Positive(t, usage.OutputTokens)
87+}
88+
89+func TestFallbackStepUsageEstimatesToolCalls(t *testing.T) {
90+ t.Parallel()
91+
92+ step := fantasy.StepResult{
93+ Response: fantasy.Response{
94+ Content: fantasy.ResponseContent{
95+ fantasy.ToolCallContent{
96+ ToolCallID: "tool-call-1",
97+ ToolName: "view",
98+ Input: `{"file_path":"/tmp/example.go"}`,
99+ },
100+ },
101+ },
102+ }
103+
104+ usage, estimated := fallbackStepUsage(nil, step)
105+ require.True(t, estimated)
106+ require.Zero(t, usage.InputTokens)
107+ require.Positive(t, usage.OutputTokens)
108+ require.Equal(t, usage.OutputTokens, usage.TotalTokens)
109+}
110+
111+func TestFallbackStepUsageEstimatesToolResults(t *testing.T) {
112+ t.Parallel()
113+
114+ messages := []fantasy.Message{
115+ {
116+ Role: fantasy.MessageRoleTool,
117+ Content: []fantasy.MessagePart{
118+ fantasy.ToolResultPart{
119+ ToolCallID: "tool-call-1",
120+ Output: fantasy.ToolResultOutputContentText{
121+ Text: "file contents returned by the tool",
122+ },
123+ },
124+ fantasy.ToolResultPart{
125+ ToolCallID: "tool-call-2",
126+ Output: fantasy.ToolResultOutputContentError{
127+ Error: errors.New("permission denied"),
128+ },
129+ },
130+ fantasy.ToolResultPart{
131+ ToolCallID: "tool-call-3",
132+ Output: fantasy.ToolResultOutputContentMedia{
133+ MediaType: "image/png",
134+ Text: "screenshot",
135+ Data: "abc123",
136+ },
137+ },
138+ },
139+ },
140+ }
141+
142+ usage, estimated := fallbackStepUsage(messages, fantasy.StepResult{})
143+ require.True(t, estimated)
144+ require.Positive(t, usage.InputTokens)
145+ require.Zero(t, usage.OutputTokens)
146+ require.Equal(t, usage.InputTokens, usage.TotalTokens)
147+}
148+
149+func TestFallbackStepUsageSkipsClientToolResultsAsOutput(t *testing.T) {
150+ t.Parallel()
151+
152+ step := fantasy.StepResult{
153+ Response: fantasy.Response{
154+ Content: fantasy.ResponseContent{
155+ fantasy.ToolResultContent{
156+ ToolCallID: "tool-call-1",
157+ ToolName: "bash",
158+ Result: fantasy.ToolResultOutputContentText{
159+ Text: "large client-executed payload that should not count as model output tokens",
160+ },
161+ },
162+ },
163+ },
164+ }
165+
166+ usage, estimated := fallbackStepUsage(nil, step)
167+ require.False(t, estimated)
168+ require.Zero(t, usage.OutputTokens)
169+}
170+
171+func TestFallbackStepUsageCountsProviderToolResultsAsOutput(t *testing.T) {
172+ t.Parallel()
173+
174+ step := fantasy.StepResult{
175+ Response: fantasy.Response{
176+ Content: fantasy.ResponseContent{
177+ fantasy.ToolResultContent{
178+ ToolCallID: "tool-call-1",
179+ ToolName: "web_search",
180+ ProviderExecuted: true,
181+ ClientMetadata: "provider metadata",
182+ Result: fantasy.ToolResultOutputContentText{Text: "provider-executed result"},
183+ },
184+ },
185+ },
186+ }
187+
188+ usage, estimated := fallbackStepUsage(nil, step)
189+ require.True(t, estimated)
190+ require.Positive(t, usage.OutputTokens)
191+ require.Equal(t, usage.OutputTokens, usage.TotalTokens)
192+}
193+
194+func TestFallbackStepUsageReturnsZeroWithoutContent(t *testing.T) {
195+ t.Parallel()
196+
197+ usage, estimated := fallbackStepUsage(nil, fantasy.StepResult{})
198+ require.False(t, estimated)
199+ require.True(t, usageIsZero(usage))
200+}
201+
202+func TestUpdateSessionUsageSkipsEstimatedCost(t *testing.T) {
203+ t.Parallel()
204+
205+ agent := &sessionAgent{}
206+ currentSession := &session.Session{ID: "session-id", Cost: 1.25}
207+ model := Model{CatwalkCfg: catwalk.Model{CostPer1MIn: 10, CostPer1MOut: 20}}
208+ usage := fantasy.Usage{InputTokens: 1000, OutputTokens: 2000}
209+
210+ agent.updateSessionUsage(model, currentSession, usage, nil, true)
211+
212+ require.Equal(t, 1.25, currentSession.Cost)
213+ require.Equal(t, int64(1000), currentSession.PromptTokens)
214+ require.Equal(t, int64(2000), currentSession.CompletionTokens)
215+ require.True(t, currentSession.EstimatedUsage)
216+}
217+
218+func TestUpdateSessionUsageKeepsCountersForZeroUsage(t *testing.T) {
219+ t.Parallel()
220+
221+ agent := &sessionAgent{}
222+ currentSession := &session.Session{
223+ ID: "session-id",
224+ PromptTokens: 123,
225+ CompletionTokens: 456,
226+ Cost: 1.25,
227+ }
228+ model := Model{CatwalkCfg: catwalk.Model{CostPer1MIn: 10, CostPer1MOut: 20}}
229+
230+ agent.updateSessionUsage(model, currentSession, fantasy.Usage{}, nil, false)
231+
232+ require.Equal(t, 1.25, currentSession.Cost)
233+ require.Equal(t, int64(123), currentSession.PromptTokens)
234+ require.Equal(t, int64(456), currentSession.CompletionTokens)
235+}
236+
237+func TestUpdateSessionUsagePreservesOmittedCountersForPartialUsage(t *testing.T) {
238+ t.Parallel()
239+
240+ agent := &sessionAgent{}
241+ currentSession := &session.Session{
242+ ID: "session-id",
243+ PromptTokens: 123,
244+ CompletionTokens: 456,
245+ }
246+ model := Model{CatwalkCfg: catwalk.Model{CostPer1MIn: 10, CostPer1MOut: 20}}
247+ usage := fantasy.Usage{InputTokens: 789}
248+
249+ agent.updateSessionUsage(model, currentSession, usage, nil, false)
250+
251+ require.Equal(t, int64(789), currentSession.PromptTokens)
252+ require.Equal(t, int64(456), currentSession.CompletionTokens)
253+}
254+
255+func TestUpdateSessionUsagePreservesCountersForTotalOnlyUsage(t *testing.T) {
256+ t.Parallel()
257+
258+ agent := &sessionAgent{}
259+ currentSession := &session.Session{
260+ ID: "session-id",
261+ PromptTokens: 123,
262+ CompletionTokens: 456,
263+ }
264+ model := Model{CatwalkCfg: catwalk.Model{CostPer1MIn: 10, CostPer1MOut: 20}}
265+ usage := fantasy.Usage{TotalTokens: 100}
266+
267+ agent.updateSessionUsage(model, currentSession, usage, nil, false)
268+
269+ require.Equal(t, int64(123), currentSession.PromptTokens)
270+ require.Equal(t, int64(456), currentSession.CompletionTokens)
271+}
272+
273+func TestUpdateSessionUsagePreservesPromptForOutputOnlyUsage(t *testing.T) {
274+ t.Parallel()
275+
276+ agent := &sessionAgent{}
277+ currentSession := &session.Session{
278+ ID: "session-id",
279+ PromptTokens: 123,
280+ CompletionTokens: 456,
281+ }
282+ model := Model{CatwalkCfg: catwalk.Model{CostPer1MIn: 10, CostPer1MOut: 20}}
283+ usage := fantasy.Usage{OutputTokens: 50}
284+
285+ agent.updateSessionUsage(model, currentSession, usage, nil, false)
286+
287+ require.Equal(t, int64(123), currentSession.PromptTokens)
288+ require.Equal(t, int64(50), currentSession.CompletionTokens)
289+}
290+
291+func TestUpdateSessionUsageKeepsCountersForEstimatedZeroUsage(t *testing.T) {
292+ t.Parallel()
293+
294+ agent := &sessionAgent{}
295+ currentSession := &session.Session{
296+ ID: "session-id",
297+ PromptTokens: 123,
298+ CompletionTokens: 456,
299+ Cost: 1.25,
300+ }
301+ model := Model{CatwalkCfg: catwalk.Model{CostPer1MIn: 10, CostPer1MOut: 20}}
302+
303+ agent.updateSessionUsage(model, currentSession, fantasy.Usage{}, nil, true)
304+
305+ require.Equal(t, 1.25, currentSession.Cost)
306+ require.Equal(t, int64(123), currentSession.PromptTokens)
307+ require.Equal(t, int64(456), currentSession.CompletionTokens)
308+}
309+
310+func TestSummaryCompletionTokens(t *testing.T) {
311+ t.Parallel()
312+
313+ summaryMessage := message.Message{
314+ Parts: []message.ContentPart{
315+ message.TextContent{Text: "summary text"},
316+ message.ReasoningContent{Thinking: "reasoning text"},
317+ },
318+ }
319+
320+ require.Equal(t, int64(42), summaryCompletionTokens(fantasy.Usage{OutputTokens: 42}, summaryMessage))
321+ require.Equal(t, approxTokenCount("summary text")+approxTokenCount("reasoning text"), summaryCompletionTokens(fantasy.Usage{}, summaryMessage))
322+ require.Zero(t, summaryCompletionTokens(fantasy.Usage{}, message.Message{}))
323+}
324+
325+func TestUpdateSessionUsageAddsProviderCost(t *testing.T) {
326+ t.Parallel()
327+
328+ agent := &sessionAgent{}
329+ currentSession := &session.Session{ID: "session-id", Cost: 1.25}
330+ model := Model{CatwalkCfg: catwalk.Model{CostPer1MIn: 10, CostPer1MOut: 20}}
331+ usage := fantasy.Usage{InputTokens: 1000, OutputTokens: 2000}
332+
333+ agent.updateSessionUsage(model, currentSession, usage, nil, false)
334+
335+ require.Equal(t, 1.3, currentSession.Cost)
336+ require.Equal(t, int64(1000), currentSession.PromptTokens)
337+ require.Equal(t, int64(2000), currentSession.CompletionTokens)
338+ require.False(t, currentSession.EstimatedUsage)
339+}
internal/backend/backend.go+7−2
@@ -234,8 +234,12 @@ func (b *Backend) CreateWorkspace(args proto.Workspace) (*Workspace, proto.Works
234 // hosts multiple workspaces concurrently, so the manager is
234 // hosts multiple workspaces concurrently, so the manager is
235 // constructed WITHOUT WithGlobalMirror to prevent last-writer-wins
235 // constructed WITHOUT WithGlobalMirror to prevent last-writer-wins
236 // cross-talk between workspaces.
236 // cross-talk between workspaces.
237− allSkills, activeSkills, skillStates := skills.DiscoverFromConfig(skillsDiscoveryConfig(cfg))
237+ discoveryCfg := skillsDiscoveryConfig(cfg)
238+ allSkills, activeSkills, skillStates := skills.DiscoverFromConfig(discoveryCfg)
238− skillsMgr := skills.NewManager(allSkills, activeSkills, skillStates)
239+ skillsMgr := skills.NewManager(allSkills, activeSkills, skillStates,
240+ skills.WithResolvedPaths(discoveryCfg.ResolvePaths()),
241+ skills.WithWorkingDir(discoveryCfg.WorkingDir),
242+ )
239
243
240 appWorkspace, err := app.New(b.ctx, conn, cfg, skillsMgr)
244 appWorkspace, err := app.New(b.ctx, conn, cfg, skillsMgr)
241 if err != nil {
245 if err != nil {
@@ -308,6 +312,7 @@ func skillsDiscoveryConfig(cfg *config.ConfigStore) skills.DiscoveryConfig {
308 return skills.DiscoveryConfig{
312 return skills.DiscoveryConfig{
309 SkillsPaths: paths,
313 SkillsPaths: paths,
310 DisabledSkills: disabled,
314 DisabledSkills: disabled,
315+ WorkingDir: cfg.WorkingDir(),
311 Resolver: resolver,
316 Resolver: resolver,
312 }
317 }
313}
318}
internal/backend/config.go+44
@@ -12,6 +12,7 @@ import (
12 "github.com/charmbracelet/crush/internal/oauth"
12 "github.com/charmbracelet/crush/internal/oauth"
13 "github.com/charmbracelet/crush/internal/proto"
13 "github.com/charmbracelet/crush/internal/proto"
14 "github.com/charmbracelet/crush/internal/pubsub"
14 "github.com/charmbracelet/crush/internal/pubsub"
15+ "github.com/charmbracelet/crush/internal/skills"
15)
16)
16
17
17// publishConfigChanged publishes a ConfigChanged event on the workspace's
18// publishConfigChanged publishes a ConfigChanged event on the workspace's
@@ -162,6 +163,49 @@ func (b *Backend) InitializePrompt(workspaceID string) (string, error) {
162 return agent.InitializePrompt(ws.Cfg)
163 return agent.InitializePrompt(ws.Cfg)
163}
164}
164
165
166+// ReadSkill reads a skill's content by ID.
167+func (b *Backend) ReadSkill(ctx context.Context, workspaceID, skillID string) ([]byte, proto.SkillReadResult, error) {
168+ ws, err := b.GetWorkspace(workspaceID)
169+ if err != nil {
170+ return nil, proto.SkillReadResult{}, err
171+ }
172+
173+ mgr := ws.Skills
174+ content, result, err := skills.ReadContent(
175+ mgr.ActiveSkills(), mgr.ResolvedPaths(), mgr.WorkingDir(), skillID,
176+ )
177+ if err != nil {
178+ return nil, proto.SkillReadResult{}, err
179+ }
180+ return content, proto.SkillReadResult{
181+ Name: result.Name,
182+ Description: result.Description,
183+ Source: string(result.Source),
184+ Builtin: result.Builtin,
185+ }, nil
186+}
187+
188+// ListSkills returns the effective visible skills for a workspace.
189+func (b *Backend) ListSkills(workspaceID string) ([]proto.SkillInfo, error) {
190+ ws, err := b.GetWorkspace(workspaceID)
191+ if err != nil {
192+ return nil, err
193+ }
194+ mgr := ws.Skills
195+ entries := skills.Catalog(mgr.ActiveSkills(), mgr.ResolvedPaths(), mgr.WorkingDir())
196+ result := make([]proto.SkillInfo, len(entries))
197+ for i, entry := range entries {
198+ result[i] = proto.SkillInfo{
199+ ID: entry.ID,
200+ Name: entry.Name,
201+ Description: entry.Description,
202+ Label: entry.Label,
203+ Source: string(entry.Source),
204+ }
205+ }
206+ return result, nil
207+}
208+
165// EnableDockerMCP validates Docker MCP availability, stages the
209// EnableDockerMCP validates Docker MCP availability, stages the
166// configuration, starts the MCP client, and persists the config.
210// configuration, starts the MCP client, and persists the config.
167func (b *Backend) EnableDockerMCP(ctx context.Context, workspaceID string) error {
211func (b *Backend) EnableDockerMCP(ctx context.Context, workspaceID string) error {
internal/client/config.go+36
@@ -216,6 +216,42 @@ func (c *Client) GetInitializePrompt(ctx context.Context, id string) (string, er
216 return result.Prompt, nil
216 return result.Prompt, nil
217}
217}
218
218
219+// ListSkills retrieves the visible skills for a workspace.
220+func (c *Client) ListSkills(ctx context.Context, id string) ([]proto.SkillInfo, error) {
221+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/skills", id), nil, nil)
222+ if err != nil {
223+ return nil, fmt.Errorf("failed to list skills: %w", err)
224+ }
225+ defer rsp.Body.Close()
226+ if rsp.StatusCode != http.StatusOK {
227+ return nil, fmt.Errorf("failed to list skills: status code %d", rsp.StatusCode)
228+ }
229+ var skills []proto.SkillInfo
230+ if err := json.NewDecoder(rsp.Body).Decode(&skills); err != nil {
231+ return nil, fmt.Errorf("failed to decode skills: %w", err)
232+ }
233+ return skills, nil
234+}
235+
236+// ReadSkill reads a skill's content by ID from the server.
237+func (c *Client) ReadSkill(ctx context.Context, id, skillID string) (*proto.ReadSkillResponse, error) {
238+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/skills/read", id), nil, jsonBody(proto.ReadSkillRequest{
239+ SkillID: skillID,
240+ }), http.Header{"Content-Type": []string{"application/json"}})
241+ if err != nil {
242+ return nil, fmt.Errorf("failed to read skill: %w", err)
243+ }
244+ defer rsp.Body.Close()
245+ if rsp.StatusCode != http.StatusOK {
246+ return nil, fmt.Errorf("failed to read skill: status code %d", rsp.StatusCode)
247+ }
248+ var result proto.ReadSkillResponse
249+ if err := json.NewDecoder(rsp.Body).Decode(&result); err != nil {
250+ return nil, fmt.Errorf("failed to decode skill response: %w", err)
251+ }
252+ return &result, nil
253+}
254+
219// MCPResourceContents holds the contents of an MCP resource.
255// MCPResourceContents holds the contents of an MCP resource.
220type MCPResourceContents struct {
256type MCPResourceContents struct {
221 URI string `json:"uri"`
257 URI string `json:"uri"`
internal/cmd/root.go+8−2
@@ -291,8 +291,13 @@ func setupLocalWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error
291 // workspace per process, so WithGlobalMirror keeps the package
291 // workspace per process, so WithGlobalMirror keeps the package
292 // globals (which the TUI reads via skills.GetLatestStates) in sync
292 // globals (which the TUI reads via skills.GetLatestStates) in sync
293 // with the manager.
293 // with the manager.
294− allSkills, activeSkills, skillStates := skills.DiscoverFromConfig(localSkillsDiscoveryConfig(store))
294+ discoveryCfg := localSkillsDiscoveryConfig(store)
295+ allSkills, activeSkills, skillStates := skills.DiscoverFromConfig(discoveryCfg)
295− skillsMgr := skills.NewManager(allSkills, activeSkills, skillStates, skills.WithGlobalMirror())
296+ skillsMgr := skills.NewManager(allSkills, activeSkills, skillStates,
297+ skills.WithGlobalMirror(),
298+ skills.WithResolvedPaths(discoveryCfg.ResolvePaths()),
299+ skills.WithWorkingDir(discoveryCfg.WorkingDir),
300+ )
296
301
297 appInstance, err := app.New(ctx, conn, store, skillsMgr)
302 appInstance, err := app.New(ctx, conn, store, skillsMgr)
298 if err != nil {
303 if err != nil {
@@ -326,6 +331,7 @@ func localSkillsDiscoveryConfig(store *config.ConfigStore) skills.DiscoveryConfi
326 return skills.DiscoveryConfig{
331 return skills.DiscoveryConfig{
327 SkillsPaths: paths,
332 SkillsPaths: paths,
328 DisabledSkills: disabled,
333 DisabledSkills: disabled,
334+ WorkingDir: store.WorkingDir(),
329 Resolver: resolver,
335 Resolver: resolver,
330 }
336 }
331}
337}
internal/config/load.go+1−10
@@ -298,22 +298,13 @@ func (c *Config) configureProviders(store *ConfigStore, env env.Env, resolver Va
298 prepared.BaseURL = endpoint
298 prepared.BaseURL = endpoint
299 prepared.ExtraParams["apiVersion"] = env.Get("AZURE_OPENAI_API_VERSION")
299 prepared.ExtraParams["apiVersion"] = env.Get("AZURE_OPENAI_API_VERSION")
300 case catwalk.InferenceProviderBedrock:
300 case catwalk.InferenceProviderBedrock:
301− if !hasAWSCredentials(env) {
301+ if p.APIKey == "" && !hasAWSCredentials(env) {
302 if configExists {
302 if configExists {
303 slog.Warn("Skipping Bedrock provider due to missing AWS credentials")
303 slog.Warn("Skipping Bedrock provider due to missing AWS credentials")
304 c.Providers.Del(string(p.ID))
304 c.Providers.Del(string(p.ID))
305 }
305 }
306 continue
306 continue
307 }
307 }
308− prepared.ExtraParams["region"] = env.Get("AWS_REGION")
309− if prepared.ExtraParams["region"] == "" {
310− prepared.ExtraParams["region"] = env.Get("AWS_DEFAULT_REGION")
311− }
312− for _, model := range p.Models {
313− if !strings.HasPrefix(model.ID, "anthropic.") {
314− return fmt.Errorf("bedrock provider only supports anthropic models for now, found: %s", model.ID)
315− }
316− }
317 case catwalk.InferenceProvider("hyper"):
308 case catwalk.InferenceProvider("hyper"):
318 if apiKey := env.Get("HYPER_API_KEY"); apiKey != "" {
309 if apiKey := env.Get("HYPER_API_KEY"); apiKey != "" {
319 prepared.APIKey = apiKey
310 prepared.APIKey = apiKey
internal/config/load_test.go−23
@@ -470,29 +470,6 @@ func TestConfig_configureProvidersBedrockWithoutCredentials(t *testing.T) {
470 require.Equal(t, cfg.Providers.Len(), 0)
470 require.Equal(t, cfg.Providers.Len(), 0)
471}
471}
472
472
473−func TestConfig_configureProvidersBedrockWithoutUnsupportedModel(t *testing.T) {
474− knownProviders := []catwalk.Provider{
475− {
476− ID: catwalk.InferenceProviderBedrock,
477− APIKey: "",
478− APIEndpoint: "",
479− Models: []catwalk.Model{{
480− ID: "some-random-model",
481− }},
482− },
483− }
484−
485− cfg := &Config{}
486− cfg.setDefaults("/tmp", "")
487− env := env.NewFromMap(map[string]string{
488− "AWS_ACCESS_KEY_ID": "test-key-id",
489− "AWS_SECRET_ACCESS_KEY": "test-secret-key",
490− })
491− resolver := NewShellVariableResolver(env)
492− err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
493− require.Error(t, err)
494−}
495−
496func TestConfig_configureProvidersVertexAIWithCredentials(t *testing.T) {
473func TestConfig_configureProvidersVertexAIWithCredentials(t *testing.T) {
497 knownProviders := []catwalk.Provider{
474 knownProviders := []catwalk.Provider{
498 {
475 {
internal/config/provider.go+10−2
@@ -147,6 +147,9 @@ func Providers(cfg *Config) ([]catwalk.Provider, error) {
147 ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
147 ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
148 defer cancel()
148 defer cancel()
149
149
150+ var hyperProvider catwalk.Provider
151+ var hyperFound bool
152+
150 wg.Go(func() {
153 wg.Go(func() {
151 if customProvidersOnly {
154 if customProvidersOnly {
152 return
155 return
@@ -177,12 +180,17 @@ func Providers(cfg *Config) ([]catwalk.Provider, error) {
177 errs = append(errs, fmt.Errorf("Crush was unable to fetch updated information from Hyper: %w", err)) //nolint:staticcheck
180 errs = append(errs, fmt.Errorf("Crush was unable to fetch updated information from Hyper: %w", err)) //nolint:staticcheck
178 return
181 return
179 }
182 }
180− providers.Append(item)
183+ hyperProvider = item
184+ hyperFound = true
181 })
185 })
182
186
183 wg.Wait()
187 wg.Wait()
184
188
189+ if hyperFound {
185− providerList = slices.Collect(providers.Seq())
190+ providerList = append([]catwalk.Provider{hyperProvider}, slices.Collect(providers.Seq())...)
191+ } else {
192+ providerList = slices.Collect(providers.Seq())
193+ }
186 providerErr = errors.Join(errs...)
194 providerErr = errors.Join(errs...)
187 })
195 })
188 return providerList, providerErr
196 return providerList, providerErr
internal/config/store_test.go+7−2
@@ -318,11 +318,16 @@ func TestConfigStaleness_RefreshClearsDirtyState(t *testing.T) {
318// ReloadFromDisk updates store state BEFORE running model/agent setup,
318// ReloadFromDisk updates store state BEFORE running model/agent setup,
319// so the new config values are used rather than stale pre-reload values.
319// so the new config values are used rather than stale pre-reload values.
320func TestReloadFromDisk_UsesNewConfigValues(t *testing.T) {
320func TestReloadFromDisk_UsesNewConfigValues(t *testing.T) {
321− t.Parallel()
322−
323 dir := t.TempDir()
321 dir := t.TempDir()
324 configPath := filepath.Join(dir, "crush.json")
322 configPath := filepath.Join(dir, "crush.json")
325
323
324+ // Isolate from the host's global config so only test-provided
325+ // providers are visible.
326+ t.Setenv("CRUSH_GLOBAL_CONFIG", dir)
327+ t.Setenv("CRUSH_GLOBAL_DATA", dir)
328+ resetProviderState()
329+ t.Cleanup(resetProviderState)
330+
326 // Create initial config with one model preference
331 // Create initial config with one model preference
327 initialConfig := `{
332 initialConfig := `{
328 "models": {
333 "models": {
internal/db/connect.go+1
@@ -19,6 +19,7 @@ var (
19 "foreign_keys": "ON",
19 "foreign_keys": "ON",
20 "journal_mode": "WAL",
20 "journal_mode": "WAL",
21 "page_size": "4096",
21 "page_size": "4096",
22+ "temp_store": "MEMORY",
22 "cache_size": "-8000",
23 "cache_size": "-8000",
23 "synchronous": "NORMAL",
24 "synchronous": "NORMAL",
24 "secure_delete": "ON",
25 "secure_delete": "ON",
internal/message/attachment.go+3−2
@@ -12,8 +12,9 @@ type Attachment struct {
12 Content []byte
12 Content []byte
13}
13}
14
14
15−func (a Attachment) IsText() bool { return strings.HasPrefix(a.MimeType, "text/") }
15+func (a Attachment) IsText() bool { return strings.HasPrefix(a.MimeType, "text/") }
16−func (a Attachment) IsImage() bool { return strings.HasPrefix(a.MimeType, "image/") }
16+func (a Attachment) IsImage() bool { return strings.HasPrefix(a.MimeType, "image/") }
17+func (a Attachment) IsMarkdown() bool { return a.MimeType == "text/markdown" }
17
18
18// ContainsTextAttachment returns true if any of the attachments is a text attachment.
19// ContainsTextAttachment returns true if any of the attachments is a text attachment.
19func ContainsTextAttachment(attachments []Attachment) bool {
20func ContainsTextAttachment(attachments []Attachment) bool {
internal/proto/proto.go+29
@@ -46,6 +46,35 @@ type CurrentSession struct {
46 SessionID string `json:"session_id"`
46 SessionID string `json:"session_id"`
47}
47}
48
48
49+// SkillInfo describes a visible skill exposed to a frontend.
50+type SkillInfo struct {
51+ ID string `json:"id"`
52+ Name string `json:"name"`
53+ Description string `json:"description"`
54+ Label string `json:"label"`
55+ Source string `json:"source"`
56+}
57+
58+// ReadSkillRequest is the request body for reading a skill's content.
59+type ReadSkillRequest struct {
60+ SkillID string `json:"skill_id"`
61+}
62+
63+// ReadSkillResponse is the response for reading a skill's content.
64+type ReadSkillResponse struct {
65+ Content []byte `json:"content"`
66+ Result SkillReadResult `json:"result"`
67+}
68+
69+// SkillReadResult holds metadata about a skill returned alongside its
70+// content.
71+type SkillReadResult struct {
72+ Name string `json:"name"`
73+ Description string `json:"description"`
74+ Source string `json:"source"`
75+ Builtin bool `json:"builtin"`
76+}
77+
49// AgentInfo represents information about the agent.
78// AgentInfo represents information about the agent.
50type AgentInfo struct {
79type AgentInfo struct {
51 IsBusy bool `json:"is_busy"`
80 IsBusy bool `json:"is_busy"`
internal/server/config.go+51
@@ -266,6 +266,57 @@ func (c *controllerV1) handleGetWorkspaceProjectInitPrompt(w http.ResponseWriter
266 jsonEncode(w, proto.ProjectInitPromptResponse{Prompt: prompt})
266 jsonEncode(w, proto.ProjectInitPromptResponse{Prompt: prompt})
267}
267}
268
268
269+// handleGetWorkspaceSkills returns the effective visible skills for a workspace.
270+//
271+// @Summary List visible skills
272+// @Tags skills
273+// @Produce json
274+// @Param id path string true "Workspace ID"
275+// @Success 200 {array} proto.SkillInfo
276+// @Failure 404 {object} proto.Error
277+// @Failure 500 {object} proto.Error
278+// @Router /workspaces/{id}/skills [get]
279+func (c *controllerV1) handleGetWorkspaceSkills(w http.ResponseWriter, r *http.Request) {
280+ id := r.PathValue("id")
281+ skills, err := c.backend.ListSkills(id)
282+ if err != nil {
283+ c.handleError(w, r, err)
284+ return
285+ }
286+ jsonEncode(w, skills)
287+}
288+
289+// handlePostWorkspaceSkillRead reads a skill's content by ID.
290+//
291+// @Summary Read skill content
292+// @Tags skills
293+// @Accept json
294+// @Produce json
295+// @Param id path string true "Workspace ID"
296+// @Param request body proto.ReadSkillRequest true "Read skill request"
297+// @Success 200 {object} proto.ReadSkillResponse
298+// @Failure 400 {object} proto.Error
299+// @Failure 404 {object} proto.Error
300+// @Failure 500 {object} proto.Error
301+// @Router /workspaces/{id}/skills/read [post]
302+func (c *controllerV1) handlePostWorkspaceSkillRead(w http.ResponseWriter, r *http.Request) {
303+ id := r.PathValue("id")
304+
305+ var req proto.ReadSkillRequest
306+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
307+ c.server.logError(r, "Failed to decode request", "error", err)
308+ jsonError(w, http.StatusBadRequest, "failed to decode request")
309+ return
310+ }
311+
312+ content, result, err := c.backend.ReadSkill(r.Context(), id, req.SkillID)
313+ if err != nil {
314+ c.handleError(w, r, err)
315+ return
316+ }
317+ jsonEncode(w, proto.ReadSkillResponse{Content: content, Result: result})
318+}
319+
269// handlePostWorkspaceMCPEnableDocker enables the Docker MCP server.
320// handlePostWorkspaceMCPEnableDocker enables the Docker MCP server.
270//
321//
271// @Summary Enable Docker MCP
322// @Summary Enable Docker MCP
internal/server/server.go+2
@@ -169,6 +169,8 @@ func (s *Server) installHandler() {
169 mux.HandleFunc("GET /v1/workspaces/{id}/project/needs-init", c.handleGetWorkspaceProjectNeedsInit)
169 mux.HandleFunc("GET /v1/workspaces/{id}/project/needs-init", c.handleGetWorkspaceProjectNeedsInit)
170 mux.HandleFunc("POST /v1/workspaces/{id}/project/init", c.handlePostWorkspaceProjectInit)
170 mux.HandleFunc("POST /v1/workspaces/{id}/project/init", c.handlePostWorkspaceProjectInit)
171 mux.HandleFunc("GET /v1/workspaces/{id}/project/init-prompt", c.handleGetWorkspaceProjectInitPrompt)
171 mux.HandleFunc("GET /v1/workspaces/{id}/project/init-prompt", c.handleGetWorkspaceProjectInitPrompt)
172+ mux.HandleFunc("GET /v1/workspaces/{id}/skills", c.handleGetWorkspaceSkills)
173+ mux.HandleFunc("POST /v1/workspaces/{id}/skills/read", c.handlePostWorkspaceSkillRead)
172 mux.HandleFunc("POST /v1/workspaces/{id}/mcp/refresh-tools", c.handlePostWorkspaceMCPRefreshTools)
174 mux.HandleFunc("POST /v1/workspaces/{id}/mcp/refresh-tools", c.handlePostWorkspaceMCPRefreshTools)
173 mux.HandleFunc("POST /v1/workspaces/{id}/mcp/read-resource", c.handlePostWorkspaceMCPReadResource)
175 mux.HandleFunc("POST /v1/workspaces/{id}/mcp/read-resource", c.handlePostWorkspaceMCPReadResource)
174 mux.HandleFunc("POST /v1/workspaces/{id}/mcp/get-prompt", c.handlePostWorkspaceMCPGetPrompt)
176 mux.HandleFunc("POST /v1/workspaces/{id}/mcp/get-prompt", c.handlePostWorkspaceMCPGetPrompt)
internal/session/session.go+46−6
@@ -7,6 +7,7 @@ import (
7 "fmt"
7 "fmt"
8 "log/slog"
8 "log/slog"
9 "strings"
9 "strings"
10+ "sync"
10
11
11 "github.com/charmbracelet/crush/internal/db"
12 "github.com/charmbracelet/crush/internal/db"
12 "github.com/charmbracelet/crush/internal/event"
13 "github.com/charmbracelet/crush/internal/event"
@@ -53,6 +54,7 @@ type Session struct {
53 MessageCount int64
54 MessageCount int64
54 PromptTokens int64
55 PromptTokens int64
55 CompletionTokens int64
56 CompletionTokens int64
57+ EstimatedUsage bool
56 SummaryMessageID string
58 SummaryMessageID string
57 Cost float64
59 Cost float64
58 Todos []Todo
60 Todos []Todo
@@ -83,6 +85,12 @@ type service struct {
83 *pubsub.Broker[Session]
85 *pubsub.Broker[Session]
84 db *sql.DB
86 db *sql.DB
85 q *db.Queries
87 q *db.Queries
88+
89+ // Estimated usage stays in memory so fetch-modify-save paths (e.g.,
90+ // updating todos or parent-session cost) do not rebuild a session from
91+ // SQLite and incorrectly clear the UI "~" marker.
92+ estimatedUsageMu sync.RWMutex
93+ estimatedUsage map[string]bool
86}
94}
87
95
88func (s *service) Create(ctx context.Context, title string) (Session, error) {
96func (s *service) Create(ctx context.Context, title string) (Session, error) {
@@ -154,6 +162,7 @@ func (s *service) Delete(ctx context.Context, id string) error {
154 }
162 }
155
163
156 session := s.fromDBItem(dbSession)
164 session := s.fromDBItem(dbSession)
165+ s.clearEstimatedUsageState(dbSession.ID)
157 s.Publish(pubsub.DeletedEvent, session)
166 s.Publish(pubsub.DeletedEvent, session)
158 event.SessionDeleted()
167 event.SessionDeleted()
159 return nil
168 return nil
@@ -164,7 +173,9 @@ func (s *service) Get(ctx context.Context, id string) (Session, error) {
164 if err != nil {
173 if err != nil {
165 return Session{}, err
174 return Session{}, err
166 }
175 }
167− return s.fromDBItem(dbSession), nil
176+ session := s.fromDBItem(dbSession)
177+ s.applyEstimatedUsageState(&session)
178+ return session, nil
168}
179}
169
180
170func (s *service) GetLast(ctx context.Context) (Session, error) {
181func (s *service) GetLast(ctx context.Context) (Session, error) {
@@ -172,7 +183,9 @@ func (s *service) GetLast(ctx context.Context) (Session, error) {
172 if err != nil {
183 if err != nil {
173 return Session{}, err
184 return Session{}, err
174 }
185 }
175− return s.fromDBItem(dbSession), nil
186+ session := s.fromDBItem(dbSession)
187+ s.applyEstimatedUsageState(&session)
188+ return session, nil
176}
189}
177
190
178func (s *service) Save(ctx context.Context, session Session) (Session, error) {
191func (s *service) Save(ctx context.Context, session Session) (Session, error) {
@@ -199,7 +212,10 @@ func (s *service) Save(ctx context.Context, session Session) (Session, error) {
199 if err != nil {
212 if err != nil {
200 return Session{}, err
213 return Session{}, err
201 }
214 }
215+ estimatedUsage := session.EstimatedUsage
216+ s.setEstimatedUsageState(session.ID, estimatedUsage)
202 session = s.fromDBItem(dbSession)
217 session = s.fromDBItem(dbSession)
218+ session.EstimatedUsage = estimatedUsage
203 s.Publish(pubsub.UpdatedEvent, session)
219 s.Publish(pubsub.UpdatedEvent, session)
204 return session, nil
220 return session, nil
205}
221}
@@ -233,11 +249,34 @@ func (s *service) List(ctx context.Context) ([]Session, error) {
233 sessions := make([]Session, len(dbSessions))
249 sessions := make([]Session, len(dbSessions))
234 for i, dbSession := range dbSessions {
250 for i, dbSession := range dbSessions {
235 sessions[i] = s.fromDBItem(dbSession)
251 sessions[i] = s.fromDBItem(dbSession)
252+ s.applyEstimatedUsageState(&sessions[i])
236 }
253 }
237 return sessions, nil
254 return sessions, nil
238}
255}
239
256
257+func (s *service) applyEstimatedUsageState(session *Session) {
258+ s.estimatedUsageMu.RLock()
259+ session.EstimatedUsage = s.estimatedUsage[session.ID]
260+ s.estimatedUsageMu.RUnlock()
261+}
262+
263+func (s *service) setEstimatedUsageState(sessionID string, estimatedUsage bool) {
264+ s.estimatedUsageMu.Lock()
265+ defer s.estimatedUsageMu.Unlock()
266+ if estimatedUsage {
267+ s.estimatedUsage[sessionID] = true
268+ return
269+ }
270+ delete(s.estimatedUsage, sessionID)
271+}
272+
273+func (s *service) clearEstimatedUsageState(sessionID string) {
274+ s.estimatedUsageMu.Lock()
275+ delete(s.estimatedUsage, sessionID)
276+ s.estimatedUsageMu.Unlock()
277+}
278+
240−func (s service) fromDBItem(item db.Session) Session {
279+func (s *service) fromDBItem(item db.Session) Session {
241 todos, err := unmarshalTodos(item.Todos.String)
280 todos, err := unmarshalTodos(item.Todos.String)
242 if err != nil {
281 if err != nil {
243 slog.Error("Failed to unmarshal todos", "session_id", item.ID, "error", err)
282 slog.Error("Failed to unmarshal todos", "session_id", item.ID, "error", err)
@@ -282,9 +321,10 @@ func unmarshalTodos(data string) ([]Todo, error) {
282func NewService(q *db.Queries, conn *sql.DB) Service {
321func NewService(q *db.Queries, conn *sql.DB) Service {
283 broker := pubsub.NewBroker[Session]()
322 broker := pubsub.NewBroker[Session]()
284 return &service{
323 return &service{
285− Broker: broker,
324+ Broker: broker,
286− db: conn,
325+ db: conn,
287− q: q,
326+ q: q,
327+ estimatedUsage: make(map[string]bool),
288 }
328 }
289}
329}
290
330
internal/session/session_test.gonew file+81
@@ -0,0 +1,81 @@
1+package session
2+
3+import (
4+ "testing"
5+
6+ "github.com/charmbracelet/crush/internal/db"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestEstimatedUsageStateSurvivesFetchModifySave(t *testing.T) {
11+ dataDir := t.TempDir()
12+ t.Cleanup(func() {
13+ require.NoError(t, db.Release(dataDir))
14+ db.ResetPool()
15+ })
16+
17+ conn, err := db.Connect(t.Context(), dataDir)
18+ require.NoError(t, err)
19+
20+ sessions := NewService(db.New(conn), conn)
21+
22+ created, err := sessions.Create(t.Context(), "test")
23+ require.NoError(t, err)
24+ created.PromptTokens = 100
25+ created.CompletionTokens = 50
26+ created.EstimatedUsage = true
27+
28+ saved, err := sessions.Save(t.Context(), created)
29+ require.NoError(t, err)
30+ require.True(t, saved.EstimatedUsage)
31+
32+ fetched, err := sessions.Get(t.Context(), created.ID)
33+ require.NoError(t, err)
34+ require.True(t, fetched.EstimatedUsage)
35+
36+ fetched.Todos = []Todo{{
37+ Content: "Check estimate state",
38+ Status: TodoStatusInProgress,
39+ ActiveForm: "Checking estimate state",
40+ }}
41+
42+ updated, err := sessions.Save(t.Context(), fetched)
43+ require.NoError(t, err)
44+ require.True(t, updated.EstimatedUsage)
45+
46+ refetched, err := sessions.Get(t.Context(), created.ID)
47+ require.NoError(t, err)
48+ require.True(t, refetched.EstimatedUsage)
49+}
50+
51+func TestEstimatedUsageStateCanBeClearedByExplicitSave(t *testing.T) {
52+ dataDir := t.TempDir()
53+ t.Cleanup(func() {
54+ require.NoError(t, db.Release(dataDir))
55+ db.ResetPool()
56+ })
57+
58+ conn, err := db.Connect(t.Context(), dataDir)
59+ require.NoError(t, err)
60+
61+ sessions := NewService(db.New(conn), conn)
62+
63+ created, err := sessions.Create(t.Context(), "test")
64+ require.NoError(t, err)
65+ created.PromptTokens = 100
66+ created.CompletionTokens = 50
67+ created.EstimatedUsage = true
68+
69+ saved, err := sessions.Save(t.Context(), created)
70+ require.NoError(t, err)
71+ require.True(t, saved.EstimatedUsage)
72+
73+ saved.EstimatedUsage = false
74+ updated, err := sessions.Save(t.Context(), saved)
75+ require.NoError(t, err)
76+ require.False(t, updated.EstimatedUsage)
77+
78+ refetched, err := sessions.Get(t.Context(), created.ID)
79+ require.NoError(t, err)
80+ require.False(t, refetched.EstimatedUsage)
81+}
internal/shell/background_test.go+2−6
@@ -128,12 +128,8 @@ func TestBackgroundShell_IsDone(t *testing.T) {
128 t.Fatalf("failed to start background shell: %v", err)
128 t.Fatalf("failed to start background shell: %v", err)
129 }
129 }
130
130
131− // Wait a bit for the command to complete
131+ // Wait for the command to complete (Windows is slower to spin up).
132− time.Sleep(100 * time.Millisecond)
132+ require.Eventually(t, bgShell.IsDone, 5*time.Second, 50*time.Millisecond, "expected shell to be done")
133−
134− if !bgShell.IsDone() {
135− t.Error("expected shell to be done")
136− }
137
133
138 // Clean up
134 // Clean up
139 manager.Kill(bgShell.ID)
135 manager.Kill(bgShell.ID)
internal/skills/catalog.gonew file+152
@@ -0,0 +1,152 @@
1+package skills
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+)
10+
11+// SourceType describes where a visible skill comes from.
12+type SourceType string
13+
14+const (
15+ SourceSystem SourceType = "system"
16+ SourceUser SourceType = "user"
17+ SourceProject SourceType = "project"
18+)
19+
20+// CatalogEntry describes an effective visible skill for frontend display.
21+type CatalogEntry struct {
22+ ID string `json:"id"`
23+ Name string `json:"name"`
24+ Description string `json:"description"`
25+ Label string `json:"label"`
26+ Source SourceType `json:"source"`
27+}
28+
29+// SkillReadResult holds metadata about a skill returned alongside its
30+// content.
31+type SkillReadResult struct {
32+ Name string `json:"name"`
33+ Description string `json:"description"`
34+ Source SourceType `json:"source"`
35+ Builtin bool `json:"builtin"`
36+}
37+
38+// ErrSkillNotFound is returned when a skill ID is not part of the
39+// effective visible skill set.
40+var ErrSkillNotFound = errors.New("skill not found")
41+
42+// Catalog builds a slice of CatalogEntry values from pre-discovered
43+// skills. The skillPaths and workingDir parameters are used only for
44+// labelling (system / user / project); pass nil/empty when labels are
45+// not needed.
46+func Catalog(active []*Skill, skillPaths []string, workingDir string) []CatalogEntry {
47+ entries := make([]CatalogEntry, 0, len(active))
48+ for _, skill := range active {
49+ label, source := skillLabel(skillPaths, workingDir, skill)
50+ entries = append(entries, CatalogEntry{
51+ ID: skill.SkillFilePath,
52+ Name: skill.Name,
53+ Description: skill.Description,
54+ Label: label,
55+ Source: source,
56+ })
57+ }
58+ return entries
59+}
60+
61+// FindEffective returns the named skill from the given active skill
62+// set.
63+func FindEffective(active []*Skill, skillID string) (*Skill, error) {
64+ for _, skill := range active {
65+ if skill.SkillFilePath == skillID {
66+ return skill, nil
67+ }
68+ }
69+ return nil, fmt.Errorf("%w: %s", ErrSkillNotFound, skillID)
70+}
71+
72+// ReadContent reads the contents of a visible skill by ID and returns
73+// the raw bytes along with metadata about the skill.
74+func ReadContent(active []*Skill, skillPaths []string, workingDir string, skillID string) ([]byte, SkillReadResult, error) {
75+ skill, err := FindEffective(active, skillID)
76+ if err != nil {
77+ return nil, SkillReadResult{}, err
78+ }
79+
80+ _, source := skillLabel(skillPaths, workingDir, skill)
81+ result := SkillReadResult{
82+ Name: skill.Name,
83+ Description: skill.Description,
84+ Source: source,
85+ Builtin: skill.Builtin,
86+ }
87+
88+ if skill.Builtin {
89+ embeddedPath := "builtin/" + strings.TrimPrefix(skill.SkillFilePath, BuiltinPrefix)
90+ content, err := BuiltinFS().ReadFile(embeddedPath)
91+ if err != nil {
92+ return nil, SkillReadResult{}, fmt.Errorf("read builtin skill %q: %w", skillID, err)
93+ }
94+ return content, result, nil
95+ }
96+
97+ content, err := os.ReadFile(skill.SkillFilePath)
98+ if err != nil {
99+ return nil, SkillReadResult{}, fmt.Errorf("read skill %q: %w", skillID, err)
100+ }
101+ return content, result, nil
102+}
103+
104+func skillLabel(skillPaths []string, workingDir string, skill *Skill) (string, SourceType) {
105+ if skill.Builtin {
106+ return string(SourceSystem) + ":" + skill.Name, SourceSystem
107+ }
108+
109+ cleanFile := filepath.Clean(skill.SkillFilePath)
110+ for _, base := range skillPaths {
111+ cleanBase := filepath.Clean(base)
112+ rel, err := filepath.Rel(cleanBase, cleanFile)
113+ if err != nil || escapesParent(rel) {
114+ continue
115+ }
116+
117+ source := SourceUser
118+ prefix := string(SourceUser) + ":"
119+ if isProjectSkillPath(cleanBase, workingDir) {
120+ source = SourceProject
121+ prefix = string(SourceProject) + ":"
122+ }
123+ return prefix + filepath.Base(filepath.Dir(cleanFile)), source
124+ }
125+
126+ return string(SourceUser) + ":" + filepath.Base(filepath.Dir(cleanFile)), SourceUser
127+}
128+
129+func escapesParent(rel string) bool {
130+ return rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator))
131+}
132+
133+func isProjectSkillPath(basePath, workingDir string) bool {
134+ if workingDir == "" {
135+ return false
136+ }
137+ absBase, err := filepath.Abs(basePath)
138+ if err != nil {
139+ return false
140+ }
141+ absWD, err := filepath.Abs(workingDir)
142+ if err != nil {
143+ return false
144+ }
145+ cleanBase := filepath.Clean(absBase)
146+ cleanWD := filepath.Clean(absWD)
147+ rel, err := filepath.Rel(cleanWD, cleanBase)
148+ if err != nil {
149+ return false
150+ }
151+ return !escapesParent(rel)
152+}
internal/skills/manager.go+59−12
@@ -27,6 +27,12 @@ type Manager struct {
27 activeSkills []*Skill
27 activeSkills []*Skill
28 states []*SkillState
28 states []*SkillState
29
29
30+ // resolvedPaths are the expanded SkillsPaths used during discovery.
31+ // Stored so Catalog/ReadContent can label skills without
32+ // re-resolving.
33+ resolvedPaths []string
34+ workingDir string
35+
30 broker *pubsub.Broker[Event]
36 broker *pubsub.Broker[Event]
31 globalMirror bool
37 globalMirror bool
32}
38}
@@ -44,6 +50,23 @@ func WithGlobalMirror() ManagerOption {
44 }
50 }
45}
51}
46
52
53+// WithResolvedPaths stores the expanded skills directory paths that
54+// were used during discovery. Catalog and ReadContent use these for
55+// source labelling.
56+func WithResolvedPaths(paths []string) ManagerOption {
57+ return func(m *Manager) {
58+ m.resolvedPaths = paths
59+ }
60+}
61+
62+// WithWorkingDir stores the workspace working directory. Catalog and
63+// ReadContent use it to distinguish project skills from user skills.
64+func WithWorkingDir(dir string) ManagerOption {
65+ return func(m *Manager) {
66+ m.workingDir = dir
67+ }
68+}
69+
47// NewManager constructs a workspace-scoped Manager with the given
70// NewManager constructs a workspace-scoped Manager with the given
48// pre-computed discovery results. The slices are stored as-is; callers
71// pre-computed discovery results. The slices are stored as-is; callers
49// should not mutate them afterwards.
72// should not mutate them afterwards.
@@ -78,6 +101,18 @@ func (m *Manager) ActiveSkills() []*Skill {
78 return m.activeSkills
101 return m.activeSkills
79}
102}
80
103
104+// ResolvedPaths returns the expanded skills directory paths stored at
105+// construction time.
106+func (m *Manager) ResolvedPaths() []string {
107+ return m.resolvedPaths
108+}
109+
110+// WorkingDir returns the workspace working directory stored at
111+// construction time.
112+func (m *Manager) WorkingDir() string {
113+ return m.workingDir
114+}
115+
81// States returns a clone of the latest discovery state snapshot.
116// States returns a clone of the latest discovery state snapshot.
82func (m *Manager) States() []*SkillState {
117func (m *Manager) States() []*SkillState {
83 m.mu.RLock()
118 m.mu.RLock()
@@ -140,18 +175,8 @@ func DiscoverFromConfig(cfg DiscoveryConfig) (allSkills, activeSkills []*Skill,
140 discovered := append([]*Skill(nil), builtin...)
175 discovered := append([]*Skill(nil), builtin...)
141
176
142 var userStates []*SkillState
177 var userStates []*SkillState
143− var userPaths []string
178+ userPaths := cfg.ResolvePaths()
144− if len(cfg.SkillsPaths) > 0 {
179+ if len(userPaths) > 0 {
145− userPaths = make([]string, 0, len(cfg.SkillsPaths))
146− for _, pth := range cfg.SkillsPaths {
147− expanded := home.Long(pth)
148− if strings.HasPrefix(expanded, "$") && cfg.Resolver != nil {
149− if resolved, err := cfg.Resolver(expanded); err == nil {
150− expanded = resolved
151− }
152− }
153− userPaths = append(userPaths, expanded)
154− }
155 var userSkills []*Skill
180 var userSkills []*Skill
156 userSkills, userStates = DiscoverWithStates(userPaths)
181 userSkills, userStates = DiscoverWithStates(userPaths)
157 discovered = append(discovered, userSkills...)
182 discovered = append(discovered, userSkills...)
@@ -175,6 +200,28 @@ func DiscoverFromConfig(cfg DiscoveryConfig) (allSkills, activeSkills []*Skill,
175type DiscoveryConfig struct {
200type DiscoveryConfig struct {
176 SkillsPaths []string
201 SkillsPaths []string
177 DisabledSkills []string
202 DisabledSkills []string
203+ WorkingDir string
178 // Resolver expands $VAR-style references in paths. May be nil.
204 // Resolver expands $VAR-style references in paths. May be nil.
179 Resolver func(string) (string, error)
205 Resolver func(string) (string, error)
180}
206}
207+
208+// ResolvePaths expands home-directory and $VAR references in
209+// SkillsPaths. This is the canonical path-resolution logic used by
210+// DiscoverFromConfig; callers that need the resolved list (e.g. for
211+// Catalog labels) can call this directly.
212+func (c DiscoveryConfig) ResolvePaths() []string {
213+ if len(c.SkillsPaths) == 0 {
214+ return nil
215+ }
216+ out := make([]string, 0, len(c.SkillsPaths))
217+ for _, pth := range c.SkillsPaths {
218+ expanded := home.Long(pth)
219+ if strings.HasPrefix(expanded, "$") && c.Resolver != nil {
220+ if resolved, err := c.Resolver(expanded); err == nil {
221+ expanded = resolved
222+ }
223+ }
224+ out = append(out, expanded)
225+ }
226+ return out
227+}
internal/ui/attachments/attachments.go+8−3
@@ -82,25 +82,27 @@ func (m *Attachments) Render(width int) string {
82// styles in place.
82// styles in place.
83func (m *Attachments) Renderer() *Renderer { return m.renderer }
83func (m *Attachments) Renderer() *Renderer { return m.renderer }
84
84
85−func NewRenderer(normalStyle, deletingStyle, imageStyle, textStyle lipgloss.Style) *Renderer {
85+func NewRenderer(normalStyle, deletingStyle, imageStyle, textStyle, skillStyle lipgloss.Style) *Renderer {
86 return &Renderer{
86 return &Renderer{
87 normalStyle: normalStyle,
87 normalStyle: normalStyle,
88 textStyle: textStyle,
88 textStyle: textStyle,
89 imageStyle: imageStyle,
89 imageStyle: imageStyle,
90+ skillStyle: skillStyle,
90 deletingStyle: deletingStyle,
91 deletingStyle: deletingStyle,
91 }
92 }
92}
93}
93
94
94// SetStyles updates the renderer styles in place.
95// SetStyles updates the renderer styles in place.
95−func (r *Renderer) SetStyles(normalStyle, deletingStyle, imageStyle, textStyle lipgloss.Style) {
96+func (r *Renderer) SetStyles(normalStyle, deletingStyle, imageStyle, textStyle, skillStyle lipgloss.Style) {
96 r.normalStyle = normalStyle
97 r.normalStyle = normalStyle
97 r.textStyle = textStyle
98 r.textStyle = textStyle
98 r.imageStyle = imageStyle
99 r.imageStyle = imageStyle
100+ r.skillStyle = skillStyle
99 r.deletingStyle = deletingStyle
101 r.deletingStyle = deletingStyle
100}
102}
101
103
102type Renderer struct {
104type Renderer struct {
103− normalStyle, textStyle, imageStyle, deletingStyle lipgloss.Style
105+ normalStyle, textStyle, imageStyle, skillStyle, deletingStyle lipgloss.Style
104}
106}
105
107
106func (r *Renderer) Render(attachments []message.Attachment, deleting bool, width int) string {
108func (r *Renderer) Render(attachments []message.Attachment, deleting bool, width int) string {
@@ -143,5 +145,8 @@ func (r *Renderer) icon(a message.Attachment) lipgloss.Style {
143 if a.IsImage() {
145 if a.IsImage() {
144 return r.imageStyle
146 return r.imageStyle
145 }
147 }
148+ if a.IsMarkdown() {
149+ return r.skillStyle
150+ }
146 return r.textStyle
151 return r.textStyle
147}
152}
internal/ui/chat/messages.go+1
@@ -372,6 +372,7 @@ func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults m
372 sty.Attachments.Deleting,
372 sty.Attachments.Deleting,
373 sty.Attachments.Image,
373 sty.Attachments.Image,
374 sty.Attachments.Text,
374 sty.Attachments.Text,
375+ sty.Attachments.Skill,
375 )
376 )
376 return []MessageItem{NewUserMessageItem(sty, msg, r)}
377 return []MessageItem{NewUserMessageItem(sty, msg, r)}
377 case message.Assistant:
378 case message.Assistant:
internal/ui/chat/prefix_cache_test.go+1
@@ -116,6 +116,7 @@ func TestUserMessageItemRender_PrefixCacheFocusBlur(t *testing.T) {
116 sty.Attachments.Deleting,
116 sty.Attachments.Deleting,
117 sty.Attachments.Image,
117 sty.Attachments.Image,
118 sty.Attachments.Text,
118 sty.Attachments.Text,
119+ sty.Attachments.Skill,
119 )
120 )
120 item := NewUserMessageItem(&sty, msg, r).(*UserMessageItem)
121 item := NewUserMessageItem(&sty, msg, r).(*UserMessageItem)
121
122
internal/ui/chat/user.go+9
@@ -74,8 +74,12 @@ func (m *UserMessageItem) RawRender(width int) string {
74 }
74 }
75
75
76 renderer := common.MarkdownRenderer(m.sty, cappedWidth)
76 renderer := common.MarkdownRenderer(m.sty, cappedWidth)
77+ mu := common.LockMarkdownRenderer(renderer)
77
78
79+ mu.Lock()
78 result, err := renderer.Render(msgContent)
80 result, err := renderer.Render(msgContent)
81+ mu.Unlock()
82+
79 if err != nil {
83 if err != nil {
80 content = msgContent
84 content = msgContent
81 } else {
85 } else {
@@ -102,7 +106,12 @@ func (m *UserMessageItem) renderSkillInvocation(content string, width int) strin
102 if err := xml.Unmarshal([]byte(content), &skill); err != nil {
106 if err := xml.Unmarshal([]byte(content), &skill); err != nil {
103 // If parsing fails, just render as markdown
107 // If parsing fails, just render as markdown
104 renderer := common.MarkdownRenderer(m.sty, width)
108 renderer := common.MarkdownRenderer(m.sty, width)
109+ mu := common.LockMarkdownRenderer(renderer)
110+
111+ mu.Lock()
105 result, err := renderer.Render(content)
112 result, err := renderer.Render(content)
113+ mu.Unlock()
114+
106 if err != nil {
115 if err != nil {
107 return content
116 return content
108 }
117 }
internal/ui/chat/version_bump_test.go+2
@@ -83,6 +83,7 @@ func TestUserMessageItem_MutatorsBumpVersion(t *testing.T) {
83 sty.Attachments.Deleting,
83 sty.Attachments.Deleting,
84 sty.Attachments.Image,
84 sty.Attachments.Image,
85 sty.Attachments.Text,
85 sty.Attachments.Text,
86+ sty.Attachments.Skill,
86 )
87 )
87 msg := &message.Message{
88 msg := &message.Message{
88 ID: "u-mut",
89 ID: "u-mut",
@@ -253,6 +254,7 @@ func TestUserMessageItem_FinishedAlwaysTrue(t *testing.T) {
253 sty.Attachments.Deleting,
254 sty.Attachments.Deleting,
254 sty.Attachments.Image,
255 sty.Attachments.Image,
255 sty.Attachments.Text,
256 sty.Attachments.Text,
257+ sty.Attachments.Skill,
256 )
258 )
257 msg := &message.Message{
259 msg := &message.Message{
258 ID: "u-fin",
260 ID: "u-fin",
internal/ui/common/elements.go+15−7
@@ -33,9 +33,10 @@ func FormatReasoningEffort(effort string) string {
33
33
34// ModelContextInfo contains token usage and cost information for a model.
34// ModelContextInfo contains token usage and cost information for a model.
35type ModelContextInfo struct {
35type ModelContextInfo struct {
36− ContextUsed int64
36+ ContextUsed int64
37− ModelContext int64
37+ ModelContext int64
38− Cost float64
38+ Cost float64
39+ EstimatedUsage bool
39}
40}
40
41
41// ModelInfo renders model information including name, provider, reasoning
42// ModelInfo renders model information including name, provider, reasoning
@@ -74,7 +75,7 @@ func ModelInfo(t *styles.Styles, modelName, providerName, reasoningInfo string,
74 }
75 }
75
76
76 if context != nil {
77 if context != nil {
77− formattedInfo := formatTokensAndCost(t, context.ContextUsed, context.ModelContext, context.Cost)
78+ formattedInfo := formatTokensAndCost(t, context.ContextUsed, context.ModelContext, context.Cost, context.EstimatedUsage)
78 parts = append(parts, lipgloss.NewStyle().PaddingLeft(2).Render(formattedInfo))
79 parts = append(parts, lipgloss.NewStyle().PaddingLeft(2).Render(formattedInfo))
79 }
80 }
80
81
@@ -92,7 +93,7 @@ func ModelInfo(t *styles.Styles, modelName, providerName, reasoningInfo string,
92
93
93// formatTokensAndCost formats token usage and cost with appropriate units
94// formatTokensAndCost formats token usage and cost with appropriate units
94// (K/M) and percentage of context window.
95// (K/M) and percentage of context window.
95−func formatTokensAndCost(t *styles.Styles, tokens, contextWindow int64, cost float64) string {
96+func formatTokensAndCost(t *styles.Styles, tokens, contextWindow int64, cost float64, estimated bool) string {
96 var formattedTokens string
97 var formattedTokens string
97 switch {
98 switch {
98 case tokens >= 1_000_000:
99 case tokens >= 1_000_000:
@@ -110,12 +111,19 @@ func formatTokensAndCost(t *styles.Styles, tokens, contextWindow int64, cost flo
110 formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
111 formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
111 }
112 }
112
113
114+ var percentage float64
115+ if contextWindow > 0 {
113− percentage := (float64(tokens) / float64(contextWindow)) * 100
116+ percentage = (float64(tokens) / float64(contextWindow)) * 100
117+ }
114
118
115 formattedCost := t.ModelInfo.Cost.Render(fmt.Sprintf("$%.2f", cost))
119 formattedCost := t.ModelInfo.Cost.Render(fmt.Sprintf("$%.2f", cost))
116
120
117 formattedTokens = t.ModelInfo.TokenCount.Render(fmt.Sprintf("(%s)", formattedTokens))
121 formattedTokens = t.ModelInfo.TokenCount.Render(fmt.Sprintf("(%s)", formattedTokens))
118− formattedPercentage := t.ModelInfo.TokenPercentage.Render(fmt.Sprintf("%d%%", int(percentage)))
122+ percentageText := fmt.Sprintf("%d%%", int(percentage))
123+ if estimated {
124+ percentageText = "~" + percentageText
125+ }
126+ formattedPercentage := t.ModelInfo.TokenPercentage.Render(percentageText)
119 formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens)
127 formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens)
120 if percentage > 80 {
128 if percentage > 80 {
121 formattedTokens = fmt.Sprintf("%s %s", styles.LSPWarningIcon, formattedTokens)
129 formattedTokens = fmt.Sprintf("%s %s", styles.LSPWarningIcon, formattedTokens)
internal/ui/common/elements_test.gonew file+35
@@ -0,0 +1,35 @@
1+package common
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/charmbracelet/crush/internal/ui/styles"
8+ "github.com/charmbracelet/x/ansi"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestFormatTokensAndCostPrefixesEstimatedUsage(t *testing.T) {
13+ t.Parallel()
14+
15+ sty := styles.CharmtonePantera()
16+
17+ rendered := formatTokensAndCost(&sty, 120, 1000, 0, true)
18+ actual := ansi.Strip(rendered)
19+
20+ require.Contains(t, actual, "~12%")
21+ require.Contains(t, actual, "(120)")
22+ require.Contains(t, actual, "$0.00")
23+ require.True(t, strings.Contains(rendered, sty.ModelInfo.TokenPercentage.Render("~12%")))
24+}
25+
26+func TestFormatTokensAndCostOmitsEstimatedPrefix(t *testing.T) {
27+ t.Parallel()
28+
29+ sty := styles.CharmtonePantera()
30+
31+ actual := ansi.Strip(formatTokensAndCost(&sty, 120, 1000, 0, false))
32+
33+ require.Contains(t, actual, "12%")
34+ require.NotContains(t, actual, "~12%")
35+}
internal/ui/common/scrollbar.go+1−1
@@ -23,7 +23,7 @@ func Scrollbar(s *styles.Styles, height, contentSize, viewportSize, offset int)
23 }
23 }
24
24
25 // Calculate where the thumb starts.
25 // Calculate where the thumb starts.
26− trackSpace := height - thumbSize
26+ trackSpace := height - thumbSize + 1
27 thumbPos := 0
27 thumbPos := 0
28 if trackSpace > 0 && maxOffset > 0 {
28 if trackSpace > 0 && maxOffset > 0 {
29 thumbPos = min(trackSpace, offset*trackSpace/maxOffset)
29 thumbPos = min(trackSpace, offset*trackSpace/maxOffset)
internal/ui/dialog/actions.go+6
@@ -74,6 +74,12 @@ type (
74 Args map[string]string // Actual argument values
74 Args map[string]string // Actual argument values
75 Skill *skills.Skill // Set when this is a skill command
75 Skill *skills.Skill // Set when this is a skill command
76 }
76 }
77+ // ActionAttachSkill is sent when a skill is selected from the commands
78+ // dialog to be attached to the conversation as a markdown attachment.
79+ ActionAttachSkill struct {
80+ ID string
81+ Name string
82+ }
77 // ActionRunMCPPrompt is a message to run a custom command.
83 // ActionRunMCPPrompt is a message to run a custom command.
78 ActionRunMCPPrompt struct {
84 ActionRunMCPPrompt struct {
79 Title string
85 Title string
internal/ui/dialog/commands.go+15−6
@@ -390,12 +390,21 @@ func (c *Commands) setCommandItems(commandType CommandType) {
390 }
390 }
391 case UserCommands:
391 case UserCommands:
392 for _, cmd := range c.customCommands {
392 for _, cmd := range c.customCommands {
393+ var action Action
394+ if cmd.Skill != nil {
395+ action = ActionAttachSkill{ID: cmd.Skill.SkillFilePath, Name: cmd.Skill.Name}
396+ } else {
393− action := ActionRunCustomCommand{
397+ action = ActionRunCustomCommand{
394− Content: cmd.Content,
398+ Content: cmd.Content,
395− Arguments: cmd.Arguments,
399+ Arguments: cmd.Arguments,
396− Skill: cmd.Skill,
400+ Skill: cmd.Skill,
401+ }
402+ }
403+ item := NewCommandItem(c.com.Styles, "custom_"+cmd.ID, cmd.Name, "", action)
404+ if cmd.Skill != nil {
405+ item = item.WithDescription(cmd.Skill.Description)
397 }
406 }
398− commandItems = append(commandItems, NewCommandItem(c.com.Styles, "custom_"+cmd.ID, cmd.Name, "", action))
407+ commandItems = append(commandItems, item)
399 }
408 }
400 case MCPPrompts:
409 case MCPPrompts:
401 for _, cmd := range c.mcpPrompts {
410 for _, cmd := range c.mcpPrompts {
@@ -513,7 +522,7 @@ func (c *Commands) defaultCommands() []*CommandItem {
513
522
514 commands = append(
523 commands = append(
515 commands,
524 commands,
516− NewCommandItem(c.com.Styles, "toggle_yolo", "Toggle Yolo Mode", "", ActionToggleYoloMode{}),
525+ NewCommandItem(c.com.Styles, "toggle_yolo", "Toggle Yolo Mode", "ctrl+y", ActionToggleYoloMode{}),
517 NewCommandItem(c.com.Styles, "toggle_help", "Toggle Help", "ctrl+g", ActionToggleHelp{}),
526 NewCommandItem(c.com.Styles, "toggle_help", "Toggle Help", "ctrl+g", ActionToggleHelp{}),
518 NewCommandItem(c.com.Styles, "init", "Initialize Project", "", ActionInitializeProject{}),
527 NewCommandItem(c.com.Styles, "init", "Initialize Project", "", ActionInitializeProject{}),
519 )
528 )
internal/ui/dialog/commands_item.go+42−13
@@ -3,23 +3,26 @@ package dialog
3import (
3import (
4 "strings"
4 "strings"
5
5
6+ "charm.land/lipgloss/v2"
6 "github.com/charmbracelet/crush/internal/ui/list"
7 "github.com/charmbracelet/crush/internal/ui/list"
7 "github.com/charmbracelet/crush/internal/ui/styles"
8 "github.com/charmbracelet/crush/internal/ui/styles"
9+ "github.com/charmbracelet/x/ansi"
8 "github.com/sahilm/fuzzy"
10 "github.com/sahilm/fuzzy"
9)
11)
10
12
11// CommandItem wraps a uicmd.Command to implement the ListItem interface.
13// CommandItem wraps a uicmd.Command to implement the ListItem interface.
12type CommandItem struct {
14type CommandItem struct {
13 *list.Versioned
15 *list.Versioned
14− id string
16+ id string
15− title string
17+ title string
16− shortcut string
18+ shortcut string
19+ description string
17− action Action
20+ action Action
18− aliases []string
21+ aliases []string
19− t *styles.Styles
22+ t *styles.Styles
20− m fuzzy.Match
23+ m fuzzy.Match
21− cache map[int]string
24+ cache map[int]string
22− focused bool
25+ focused bool
23}
26}
24
27
25var _ ListItem = &CommandItem{Versioned: list.NewVersioned()}
28var _ ListItem = &CommandItem{Versioned: list.NewVersioned()}
@@ -48,12 +51,23 @@ func (c *CommandItem) WithAliases(aliases ...string) *CommandItem {
48 return c
51 return c
49}
52}
50
53
54+// WithDescription returns the CommandItem with a description displayed below
55+// the title.
56+func (c *CommandItem) WithDescription(desc string) *CommandItem {
57+ c.description = desc
58+ return c
59+}
60+
51// Filter implements ListItem.
61// Filter implements ListItem.
52func (c *CommandItem) Filter() string {
62func (c *CommandItem) Filter() string {
63+ base := c.title
53− if len(c.aliases) == 0 {
64+ if len(c.aliases) > 0 {
54− return c.title
65+ base = c.title + " " + strings.Join(c.aliases, " ")
55 }
66 }
56− return c.title + " " + strings.Join(c.aliases, " ")
67+ if c.description != "" {
68+ base = base + " " + c.description
69+ }
70+ return base
57}
71}
58
72
59// ID implements ListItem.
73// ID implements ListItem.
@@ -103,5 +117,20 @@ func (c *CommandItem) Render(width int) string {
103 InfoTextBlurred: c.t.Dialog.ListItem.InfoBlurred,
117 InfoTextBlurred: c.t.Dialog.ListItem.InfoBlurred,
104 InfoTextFocused: c.t.Dialog.ListItem.InfoFocused,
118 InfoTextFocused: c.t.Dialog.ListItem.InfoFocused,
105 }
119 }
106− return renderItem(styles, c.title, c.shortcut, c.focused, width, c.cache, &c.m)
120+ rendered := renderItem(styles, c.title, c.shortcut, c.focused, width, c.cache, &c.m)
121+ if c.description != "" {
122+ descStyle := c.t.Dialog.SecondaryText
123+ if c.focused {
124+ descStyle = c.t.Dialog.SelectedItem
125+ }
126+ contentWidth := max(0, width-descStyle.GetHorizontalFrameSize()+1)
127+ description := ansi.Truncate(strings.TrimSpace(c.description), contentWidth, "...")
128+ descVisWidth := lipgloss.Width(description)
129+ gap := strings.Repeat(" ", max(0, contentWidth-descVisWidth))
130+ if description == "" {
131+ description = " "
132+ }
133+ rendered = lipgloss.JoinVertical(lipgloss.Left, rendered, descStyle.Render(description+gap))
134+ }
135+ return rendered
107}
136}
internal/ui/dialog/models.go+18−16
@@ -10,6 +10,7 @@ import (
10 "charm.land/bubbles/v2/textinput"
10 "charm.land/bubbles/v2/textinput"
11 tea "charm.land/bubbletea/v2"
11 tea "charm.land/bubbletea/v2"
12 "charm.land/catwalk/pkg/catwalk"
12 "charm.land/catwalk/pkg/catwalk"
13+ "charm.land/lipgloss/v2"
13 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/ui/common"
15 "github.com/charmbracelet/crush/internal/ui/common"
15 "github.com/charmbracelet/crush/internal/ui/util"
16 "github.com/charmbracelet/crush/internal/ui/util"
@@ -265,9 +266,14 @@ func (m *Models) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
265 t.Dialog.View.GetVerticalFrameSize()
266 t.Dialog.View.GetVerticalFrameSize()
266
267
267 m.input.SetWidth(max(0, innerWidth-t.Dialog.InputPrompt.GetHorizontalFrameSize()-1)) // (1) cursor padding
268 m.input.SetWidth(max(0, innerWidth-t.Dialog.InputPrompt.GetHorizontalFrameSize()-1)) // (1) cursor padding
268− m.list.SetSize(innerWidth, height-heightOffset)
269 m.help.SetWidth(innerWidth)
269 m.help.SetWidth(innerWidth)
270
270
271+ listHeight := height - heightOffset
272+ m.list.SetSize(innerWidth, listHeight)
273+ listTotalHeight := m.list.TotalHeight()
274+ listWidth := max(0, innerWidth-3) // Reserve space for scrollbar.
275+ m.list.SetSize(listWidth, listHeight)
276+
271 rc := NewRenderContext(t, width)
277 rc := NewRenderContext(t, width)
272 rc.Title = "Switch Model"
278 rc.Title = "Switch Model"
273 rc.TitleInfo = m.modelTypeRadioView()
279 rc.TitleInfo = m.modelTypeRadioView()
@@ -281,6 +287,10 @@ func (m *Models) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
281 rc.AddPart(inputView)
287 rc.AddPart(inputView)
282
288
283 listView := t.Dialog.List.Height(m.list.Height()).Render(m.list.Render())
289 listView := t.Dialog.List.Height(m.list.Height()).Render(m.list.Render())
290+ scrollbar := common.Scrollbar(t, listHeight, listTotalHeight, listHeight, m.list.Offset())
291+ if scrollbar != "" {
292+ listView = lipgloss.JoinHorizontal(lipgloss.Top, listView, scrollbar)
293+ }
284 rc.AddPart(listView)
294 rc.AddPart(listView)
285
295
286 rc.Help = m.help.View(m)
296 rc.Help = m.help.View(m)
@@ -398,20 +408,8 @@ func (m *Models) setProviderItems() error {
398 }
408 }
399 }
409 }
400
410
401− // Move "Charm Hyper" to first position.
402− // (But still after recent models and custom providers).
403− slices.SortStableFunc(m.providers, func(a, b catwalk.Provider) int {
404− switch {
405− case a.ID == "hyper":
406− return -1
407− case b.ID == "hyper":
408− return 1
409− default:
410− return 0
411− }
412− })
413−
414− // Now add known providers from the predefined list
411+ // Now add known providers from the predefined list.
412+ // Providers already has Hyper at the front of the list.
415 for _, provider := range m.providers {
413 for _, provider := range m.providers {
416 providerID := string(provider.ID)
414 providerID := string(provider.ID)
417 if addedProviders[providerID] {
415 if addedProviders[providerID] {
@@ -498,7 +496,11 @@ func (m *Models) setProviderItems() error {
498 // Set model groups in the list.
496 // Set model groups in the list.
499 m.list.SetGroups(groups...)
497 m.list.SetGroups(groups...)
500 m.list.SetSelectedItem(selectedItemID)
498 m.list.SetSelectedItem(selectedItemID)
499+ if selectedItemID != "" {
500+ m.list.ScrollToSelected()
501+ } else {
501− m.list.ScrollToTop()
502+ m.list.ScrollToTop()
503+ }
502
504
503 // Update placeholder based on model type
505 // Update placeholder based on model type
504 if !m.isOnboarding {
506 if !m.isOnboarding {
internal/ui/dialog/models_list.go+6−9
@@ -85,18 +85,15 @@ func (f *ModelsList) SetSelected(index int) {
85// SetSelectedItem sets the selected item in the list by item ID.
85// SetSelectedItem sets the selected item in the list by item ID.
86func (f *ModelsList) SetSelectedItem(itemID string) {
86func (f *ModelsList) SetSelectedItem(itemID string) {
87 if itemID == "" {
87 if itemID == "" {
88− f.SetSelected(0)
89 return
88 return
90 }
89 }
91
90
92− count := 0
91+ // Walk the selectable model items using the same helpers that
93− for _, g := range f.groups {
92+ // keyboard navigation uses, so we stay in sync with the flat
94− for _, item := range g.Items {
93+ // list layout.
95− if item.ID() == itemID {
94+ for ok := f.SelectFirst(); ok; ok = f.SelectNext() {
96− f.SetSelected(count)
95+ if mi, is := f.SelectedItem().(*ModelItem); is && mi.ID() == itemID {
97− return
96+ return
98− }
99− count++
100 }
97 }
101 }
98 }
102}
99}
internal/ui/dialog/sessions.go+8−1
@@ -237,7 +237,10 @@ func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
237 t.Dialog.HelpView.GetVerticalFrameSize() +
237 t.Dialog.HelpView.GetVerticalFrameSize() +
238 t.Dialog.View.GetVerticalFrameSize()
238 t.Dialog.View.GetVerticalFrameSize()
239 s.input.SetWidth(max(0, innerWidth-t.Dialog.InputPrompt.GetHorizontalFrameSize()-1)) // (1) cursor padding
239 s.input.SetWidth(max(0, innerWidth-t.Dialog.InputPrompt.GetHorizontalFrameSize()-1)) // (1) cursor padding
240− s.list.SetSize(innerWidth, height-heightOffset)
240+ listHeight := height - heightOffset
241+ listTotalHeight := s.list.TotalHeight()
242+ listWidth := max(0, innerWidth-3) // Reserve space for scrollbar.
243+ s.list.SetSize(listWidth, listHeight)
241 s.help.SetWidth(innerWidth)
244 s.help.SetWidth(innerWidth)
242
245
243 // This makes it so we do not scroll the list if we don't have to
246 // This makes it so we do not scroll the list if we don't have to
@@ -309,6 +312,10 @@ func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
309 rc.AddPart(inputView)
312 rc.AddPart(inputView)
310 }
313 }
311 listView := t.Dialog.List.Height(s.list.Height()).Render(s.list.Render())
314 listView := t.Dialog.List.Height(s.list.Height()).Render(s.list.Render())
315+ scrollbar := common.Scrollbar(t, listHeight, listTotalHeight, listHeight, s.list.Offset())
316+ if scrollbar != "" {
317+ listView = lipgloss.JoinHorizontal(lipgloss.Top, listView, scrollbar)
318+ }
312 rc.AddPart(listView)
319 rc.AddPart(listView)
313 rc.Help = s.help.View(s)
320 rc.Help = s.help.View(s)
314
321
internal/ui/list/list.go+27
@@ -157,6 +157,33 @@ func (l *List) Len() int {
157 return len(l.items)
157 return len(l.items)
158}
158}
159
159
160+// TotalHeight returns the total height of all items in the list.
161+func (l *List) TotalHeight() int {
162+ total := 0
163+ for idx := range l.items {
164+ item := l.getItem(idx)
165+ total += item.height
166+ if l.gap > 0 && idx < len(l.items)-1 {
167+ total += l.gap
168+ }
169+ }
170+ return total
171+}
172+
173+// Offset returns the current scroll offset in lines from the top.
174+func (l *List) Offset() int {
175+ offset := 0
176+ for idx := 0; idx < l.offsetIdx; idx++ {
177+ item := l.getItem(idx)
178+ offset += item.height
179+ if l.gap > 0 && idx < len(l.items)-1 {
180+ offset += l.gap
181+ }
182+ }
183+ offset += l.offsetLine
184+ return offset
185+}
186+
160// lastOffsetItem returns the index and line offsets of the last item that can
187// lastOffsetItem returns the index and line offsets of the last item that can
161// be partially visible in the viewport.
188// be partially visible in the viewport.
162func (l *List) lastOffsetItem() (int, int, int) {
189func (l *List) lastOffsetItem() (int, int, int) {
internal/ui/model/header.go+5−1
@@ -148,7 +148,11 @@ func renderHeaderDetails(
148 model := com.Config().GetModelByType(agentCfg.Model)
148 model := com.Config().GetModelByType(agentCfg.Model)
149 if model != nil && model.ContextWindow > 0 {
149 if model != nil && model.ContextWindow > 0 {
150 percentage := (float64(session.CompletionTokens+session.PromptTokens) / float64(model.ContextWindow)) * 100
150 percentage := (float64(session.CompletionTokens+session.PromptTokens) / float64(model.ContextWindow)) * 100
151− formattedPercentage := t.Header.Percentage.Render(fmt.Sprintf("%d%%", int(percentage)))
151+ percentageText := fmt.Sprintf("%d%%", int(percentage))
152+ if session.EstimatedUsage {
153+ percentageText = "~" + percentageText
154+ }
155+ formattedPercentage := t.Header.Percentage.Render(percentageText)
152 parts = append(parts, formattedPercentage)
156 parts = append(parts, formattedPercentage)
153 }
157 }
154
158
internal/ui/model/keys.go+12−7
@@ -57,13 +57,14 @@ type KeyMap struct {
57 }
57 }
58
58
59 // Global key maps
59 // Global key maps
60− Quit key.Binding
60+ Quit key.Binding
61− Help key.Binding
61+ Help key.Binding
62− Commands key.Binding
62+ Commands key.Binding
63− Models key.Binding
63+ Models key.Binding
64− Suspend key.Binding
64+ Suspend key.Binding
65− Sessions key.Binding
65+ Sessions key.Binding
66+ Tab key.Binding
66− Tab key.Binding
67+ ToggleYolo key.Binding
67}
68}
68
69
69func DefaultKeyMap() KeyMap {
70func DefaultKeyMap() KeyMap {
@@ -96,6 +97,10 @@ func DefaultKeyMap() KeyMap {
96 key.WithKeys("tab"),
97 key.WithKeys("tab"),
97 key.WithHelp("tab", "change focus"),
98 key.WithHelp("tab", "change focus"),
98 ),
99 ),
100+ ToggleYolo: key.NewBinding(
101+ key.WithKeys("ctrl+y"),
102+ key.WithHelp("ctrl+y", "toggle yolo"),
103+ ),
99 }
104 }
100
105
101 km.Editor.AddFile = key.NewBinding(
106 km.Editor.AddFile = key.NewBinding(
internal/ui/model/layout_test.go+107
@@ -6,6 +6,7 @@ import (
6 "testing"
6 "testing"
7
7
8 "charm.land/bubbles/v2/textarea"
8 "charm.land/bubbles/v2/textarea"
9+ "github.com/charmbracelet/crush/internal/session"
9 "github.com/charmbracelet/crush/internal/ui/chat"
10 "github.com/charmbracelet/crush/internal/ui/chat"
10 "github.com/charmbracelet/crush/internal/ui/common"
11 "github.com/charmbracelet/crush/internal/ui/common"
11)
12)
@@ -118,3 +119,109 @@ func TestHandleTextareaHeightChange_FollowModeStaysAtBottom(t *testing.T) {
118 t.Fatal("expected chat to remain at bottom after editor resize in follow mode")
119 t.Fatal("expected chat to remain at bottom after editor resize in follow mode")
119 }
120 }
120}
121}
122+
123+func TestAutoExpandPillsIfReasonable(t *testing.T) {
124+ t.Parallel()
125+
126+ t.Run("expands when terminal is tall enough and todos exist", func(t *testing.T) {
127+ t.Parallel()
128+
129+ u := newTestUI()
130+ u.height = 50
131+ u.session = &session.Session{ID: "s1", Todos: []session.Todo{
132+ {Status: session.TodoStatusInProgress, Content: "do work"},
133+ {Status: session.TodoStatusPending, Content: "do more"},
134+ }}
135+
136+ u.autoExpandPillsIfReasonable()
137+
138+ if !u.pillsExpanded {
139+ t.Fatal("expected pillsExpanded to be true")
140+ }
141+ if u.focusedPillSection != pillSectionTodos {
142+ t.Fatalf("expected focusedPillSection to be pillSectionTodos, got %d", u.focusedPillSection)
143+ }
144+ })
145+
146+ t.Run("does not expand when terminal is too short", func(t *testing.T) {
147+ t.Parallel()
148+
149+ u := newTestUI()
150+ u.height = 30
151+ u.session = &session.Session{ID: "s1", Todos: []session.Todo{
152+ {Status: session.TodoStatusInProgress, Content: "do work"},
153+ }}
154+
155+ u.autoExpandPillsIfReasonable()
156+
157+ if u.pillsExpanded {
158+ t.Fatal("expected pillsExpanded to be false when terminal height is below threshold")
159+ }
160+ })
161+
162+ t.Run("does not expand when all todos are completed", func(t *testing.T) {
163+ t.Parallel()
164+
165+ u := newTestUI()
166+ u.height = 50
167+ u.session = &session.Session{ID: "s1", Todos: []session.Todo{
168+ {Status: session.TodoStatusCompleted, Content: "done"},
169+ }}
170+
171+ u.autoExpandPillsIfReasonable()
172+
173+ if u.pillsExpanded {
174+ t.Fatal("expected pillsExpanded to be false when all todos are completed")
175+ }
176+ })
177+
178+ t.Run("does not expand when already expanded", func(t *testing.T) {
179+ t.Parallel()
180+
181+ u := newTestUI()
182+ u.height = 50
183+ u.pillsExpanded = true
184+ u.session = &session.Session{ID: "s1", Todos: []session.Todo{
185+ {Status: session.TodoStatusInProgress, Content: "do work"},
186+ }}
187+ u.updateLayoutAndSize()
188+
189+ u.autoExpandPillsIfReasonable()
190+
191+ if !u.pillsExpanded {
192+ t.Fatal("expected pillsExpanded to stay true")
193+ }
194+ })
195+
196+ t.Run("expands for prompt queue when no todos", func(t *testing.T) {
197+ t.Parallel()
198+
199+ u := newTestUI()
200+ u.height = 50
201+ u.session = &session.Session{ID: "s1", Todos: []session.Todo{}}
202+ u.promptQueue = 2
203+
204+ u.autoExpandPillsIfReasonable()
205+
206+ if !u.pillsExpanded {
207+ t.Fatal("expected pillsExpanded to be true for prompt queue")
208+ }
209+ if u.focusedPillSection != pillSectionQueue {
210+ t.Fatalf("expected focusedPillSection to be pillSectionQueue, got %d", u.focusedPillSection)
211+ }
212+ })
213+
214+ t.Run("does not expand when no session", func(t *testing.T) {
215+ t.Parallel()
216+
217+ u := newTestUI()
218+ u.height = 50
219+ u.session = nil
220+
221+ u.autoExpandPillsIfReasonable()
222+
223+ if u.pillsExpanded {
224+ t.Fatal("expected pillsExpanded to be false when there is no session")
225+ }
226+ })
227+}
internal/ui/model/pills.go+38−1
@@ -134,6 +134,43 @@ func queueList(queueItems []string, t *styles.Styles) string {
134 return strings.Join(lines, "\n")
134 return strings.Join(lines, "\n")
135}
135}
136
136
137+// pillsHeightReasonableTerminalHeight is the minimum terminal height at which
138+// we auto-expand pills when there are incomplete todos.
139+const pillsHeightReasonableTerminalHeight = 40
140+
141+// autoExpandPillsIfReasonable expands the pills panel if the terminal has
142+// enough vertical space to show the expanded list comfortably.
143+func (m *UI) autoExpandPillsIfReasonable() tea.Cmd {
144+ if !m.hasSession() {
145+ return nil
146+ }
147+ if m.height < pillsHeightReasonableTerminalHeight {
148+ return nil
149+ }
150+ hasPills := hasIncompleteTodos(m.session.Todos) || m.promptQueue > 0
151+ if !hasPills {
152+ return nil
153+ }
154+ if m.pillsExpanded {
155+ return nil
156+ }
157+ if m.pillsAutoExpanded {
158+ return nil
159+ }
160+ m.pillsExpanded = true
161+ m.pillsAutoExpanded = true
162+ if hasIncompleteTodos(m.session.Todos) {
163+ m.focusedPillSection = pillSectionTodos
164+ } else {
165+ m.focusedPillSection = pillSectionQueue
166+ }
167+ m.updateLayoutAndSize()
168+ if m.chat.Follow() {
169+ m.chat.ScrollToBottom()
170+ }
171+ return nil
172+}
173+
137// togglePillsExpanded toggles the pills panel expansion state.
174// togglePillsExpanded toggles the pills panel expansion state.
138func (m *UI) togglePillsExpanded() tea.Cmd {
175func (m *UI) togglePillsExpanded() tea.Cmd {
139 if !m.hasSession() {
176 if !m.hasSession() {
@@ -249,7 +286,7 @@ func (m *UI) renderPills() {
249 if todosFocused && hasIncomplete {
286 if todosFocused && hasIncomplete {
250 expandedList = todoList(m.session.Todos, inProgressIcon, t, contentWidth)
287 expandedList = todoList(m.session.Todos, inProgressIcon, t, contentWidth)
251 } else if queueFocused && hasQueue {
288 } else if queueFocused && hasQueue {
252− if m.com.Workspace.AgentIsReady() {
289+ if m.com != nil && m.com.Workspace != nil && m.com.Workspace.AgentIsReady() {
253 queueItems := m.com.Workspace.AgentQueuedPromptsList(m.session.ID)
290 queueItems := m.com.Workspace.AgentQueuedPromptsList(m.session.ID)
254 expandedList = queueList(queueItems, t)
291 expandedList = queueList(queueItems, t)
255 }
292 }
internal/ui/model/sidebar.go+4−3
@@ -44,9 +44,10 @@ func (m *UI) modelInfo(width int) string {
44 var modelContext *common.ModelContextInfo
44 var modelContext *common.ModelContextInfo
45 if model != nil && m.session != nil {
45 if model != nil && m.session != nil {
46 modelContext = &common.ModelContextInfo{
46 modelContext = &common.ModelContextInfo{
47− ContextUsed: m.session.CompletionTokens + m.session.PromptTokens,
47+ ContextUsed: m.session.CompletionTokens + m.session.PromptTokens,
48− Cost: m.session.Cost,
48+ Cost: m.session.Cost,
49− ModelContext: model.CatwalkCfg.ContextWindow,
49+ ModelContext: model.CatwalkCfg.ContextWindow,
50+ EstimatedUsage: m.session.EstimatedUsage,
50 }
51 }
51 }
52 }
52 var modelName string
53 var modelName string
internal/ui/model/ui.go+47
@@ -259,6 +259,7 @@ type UI struct {
259
259
260 // pills state
260 // pills state
261 pillsExpanded bool
261 pillsExpanded bool
262+ pillsAutoExpanded bool
262 focusedPillSection pillSection
263 focusedPillSection pillSection
263 promptQueue int
264 promptQueue int
264 pillsView string
265 pillsView string
@@ -317,6 +318,7 @@ func New(com *common.Common, initialSessionID string, continueLast bool) *UI {
317 com.Styles.Attachments.Deleting,
318 com.Styles.Attachments.Deleting,
318 com.Styles.Attachments.Image,
319 com.Styles.Attachments.Image,
319 com.Styles.Attachments.Text,
320 com.Styles.Attachments.Text,
321+ com.Styles.Attachments.Skill,
320 ),
322 ),
321 attachments.Keymap{
323 attachments.Keymap{
322 DeleteMode: keyMap.Editor.AttachmentDeleteMode,
324 DeleteMode: keyMap.Editor.AttachmentDeleteMode,
@@ -536,6 +538,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
536 if cmd := m.setSessionMessages(msgs); cmd != nil {
538 if cmd := m.setSessionMessages(msgs); cmd != nil {
537 cmds = append(cmds, cmd)
539 cmds = append(cmds, cmd)
538 }
540 }
541+ if cmd := m.autoExpandPillsIfReasonable(); cmd != nil {
542+ cmds = append(cmds, cmd)
543+ }
539 if hasInProgressTodo(m.session.Todos) {
544 if hasInProgressTodo(m.session.Todos) {
540 // only start spinner if there is an in-progress todo
545 // only start spinner if there is an in-progress todo
541 if m.isAgentBusy() {
546 if m.isAgentBusy() {
@@ -611,6 +616,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
611 cmds = append(cmds, m.todoSpinner.Tick)
616 cmds = append(cmds, m.todoSpinner.Tick)
612 m.updateLayoutAndSize()
617 m.updateLayoutAndSize()
613 }
618 }
619+ m.autoExpandPillsIfReasonable()
614 }
620 }
615 case pubsub.Event[message.Message]:
621 case pubsub.Event[message.Message]:
616 // Check if this is a child session message for an agent tool.
622 // Check if this is a child session message for an agent tool.
@@ -1551,6 +1557,9 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1551 }
1557 }
1552 cmds = append(cmds, m.sendMessage(content))
1558 cmds = append(cmds, m.sendMessage(content))
1553 m.dialog.CloseFrontDialog()
1559 m.dialog.CloseFrontDialog()
1560+ case dialog.ActionAttachSkill:
1561+ m.dialog.CloseFrontDialog()
1562+ cmds = append(cmds, m.attachSkill(msg.ID, msg.Name))
1554 case dialog.ActionRunMCPPrompt:
1563 case dialog.ActionRunMCPPrompt:
1555 if len(msg.Arguments) > 0 && msg.Args == nil {
1564 if len(msg.Arguments) > 0 && msg.Args == nil {
1556 m.dialog.CloseFrontDialog()
1565 m.dialog.CloseFrontDialog()
@@ -1796,6 +1805,16 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1796 }
1805 }
1797 cmds = append(cmds, tea.Suspend)
1806 cmds = append(cmds, tea.Suspend)
1798 return true
1807 return true
1808+ case key.Matches(msg, m.keyMap.ToggleYolo):
1809+ yolo := !m.com.Workspace.PermissionSkipRequests()
1810+ m.com.Workspace.PermissionSetSkipRequests(yolo)
1811+ m.setEditorPrompt(yolo)
1812+ status := "disabled"
1813+ if yolo {
1814+ status = "enabled"
1815+ }
1816+ cmds = append(cmds, util.ReportInfo("Yolo mode "+status))
1817+ return true
1799 }
1818 }
1800 return false
1819 return false
1801 }
1820 }
@@ -2401,6 +2420,7 @@ func (m *UI) FullHelp() [][]key.Binding {
2401 commands,
2420 commands,
2402 k.Models,
2421 k.Models,
2403 k.Sessions,
2422 k.Sessions,
2423+ k.ToggleYolo,
2404 )
2424 )
2405 if hasSession {
2425 if hasSession {
2406 mainBinds = append(mainBinds, k.Chat.NewSession)
2426 mainBinds = append(mainBinds, k.Chat.NewSession)
@@ -2462,6 +2482,7 @@ func (m *UI) FullHelp() [][]key.Binding {
2462 commands,
2482 commands,
2463 k.Models,
2483 k.Models,
2464 k.Sessions,
2484 k.Sessions,
2485+ k.ToggleYolo,
2465 },
2486 },
2466 )
2487 )
2467 editorBinds := []key.Binding{
2488 editorBinds := []key.Binding{
@@ -3136,12 +3157,37 @@ func (m *UI) refreshStyles() {
3136 t.Attachments.Deleting,
3157 t.Attachments.Deleting,
3137 t.Attachments.Image,
3158 t.Attachments.Image,
3138 t.Attachments.Text,
3159 t.Attachments.Text,
3160+ t.Attachments.Skill,
3139 )
3161 )
3140 m.todoSpinner.Style = t.Pills.TodoSpinner
3162 m.todoSpinner.Style = t.Pills.TodoSpinner
3141 m.status.help.Styles = t.Help
3163 m.status.help.Styles = t.Help
3142 m.chat.InvalidateRenderCaches()
3164 m.chat.InvalidateRenderCaches()
3143}
3165}
3144
3166
3167+// attachSkill reads a skill's content by ID and returns it as a markdown
3168+// attachment to be added to the attachment toolbar. The user can then
3169+// compose a message and send it with the skill attached.
3170+// The name parameter is used as a fallback when the server does not
3171+// return one.
3172+func (m *UI) attachSkill(skillID, name string) tea.Cmd {
3173+ return func() tea.Msg {
3174+ content, result, err := m.com.Workspace.ReadSkill(context.Background(), skillID)
3175+ if err != nil {
3176+ return util.NewErrorMsg(err)
3177+ }
3178+ fileName := result.Name
3179+ if fileName == "" {
3180+ fileName = name
3181+ }
3182+ return message.Attachment{
3183+ FilePath: fileName,
3184+ FileName: fileName,
3185+ MimeType: "text/markdown",
3186+ Content: content,
3187+ }
3188+ }
3189+}
3190+
3145// sendMessage sends a message with the given content and attachments.
3191// sendMessage sends a message with the given content and attachments.
3146func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
3192func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
3147 if !m.com.Workspace.AgentIsReady() {
3193 if !m.com.Workspace.AgentIsReady() {
@@ -3476,6 +3522,7 @@ func (m *UI) newSession() tea.Cmd {
3476 m.chat.Blur()
3522 m.chat.Blur()
3477 m.chat.ClearMessages()
3523 m.chat.ClearMessages()
3478 m.pillsExpanded = false
3524 m.pillsExpanded = false
3525+ m.pillsAutoExpanded = false
3479 m.promptQueue = 0
3526 m.promptQueue = 0
3480 m.pillsView = ""
3527 m.pillsView = ""
3481 m.historyReset()
3528 m.historyReset()
internal/ui/styles/quickstyle.go+2
@@ -763,6 +763,7 @@ func quickStyle(o quickStyleOpts) Styles {
763 s.ModelInfo.Reasoning = lipgloss.NewStyle().Foreground(o.fgMostSubtle).PaddingLeft(2)
763 s.ModelInfo.Reasoning = lipgloss.NewStyle().Foreground(o.fgMostSubtle).PaddingLeft(2)
764 s.ModelInfo.TokenCount = lipgloss.NewStyle().Foreground(o.fgMostSubtle)
764 s.ModelInfo.TokenCount = lipgloss.NewStyle().Foreground(o.fgMostSubtle)
765 s.ModelInfo.TokenPercentage = lipgloss.NewStyle().Foreground(o.fgMoreSubtle)
765 s.ModelInfo.TokenPercentage = lipgloss.NewStyle().Foreground(o.fgMoreSubtle)
766+ s.ModelInfo.EstimatedUsagePrefix = s.ModelInfo.TokenPercentage
766 s.ModelInfo.Cost = lipgloss.NewStyle().Foreground(o.fgMoreSubtle)
767 s.ModelInfo.Cost = lipgloss.NewStyle().Foreground(o.fgMoreSubtle)
767 s.ModelInfo.HypercreditIcon = lipgloss.NewStyle().Foreground(charmtone.Dolly)
768 s.ModelInfo.HypercreditIcon = lipgloss.NewStyle().Foreground(charmtone.Dolly)
768 s.ModelInfo.HypercreditText = lipgloss.NewStyle().Foreground(o.fgMoreSubtle)
769 s.ModelInfo.HypercreditText = lipgloss.NewStyle().Foreground(o.fgMoreSubtle)
@@ -922,6 +923,7 @@ func quickStyle(o quickStyleOpts) Styles {
922 attachmentIconStyle := base.Foreground(o.bgLessVisible).Background(o.success).Padding(0, 1)
923 attachmentIconStyle := base.Foreground(o.bgLessVisible).Background(o.success).Padding(0, 1)
923 s.Attachments.Image = attachmentIconStyle.SetString(ImageIcon)
924 s.Attachments.Image = attachmentIconStyle.SetString(ImageIcon)
924 s.Attachments.Text = attachmentIconStyle.SetString(TextIcon)
925 s.Attachments.Text = attachmentIconStyle.SetString(TextIcon)
926+ s.Attachments.Skill = attachmentIconStyle.SetString(SkillIcon)
925 s.Attachments.Normal = base.Padding(0, 1).MarginRight(1).Background(o.fgMoreSubtle).Foreground(o.fgBase)
927 s.Attachments.Normal = base.Padding(0, 1).MarginRight(1).Background(o.fgMoreSubtle).Foreground(o.fgBase)
926 s.Attachments.Deleting = base.Padding(0, 1).Bold(true).Background(o.destructive).Foreground(o.fgBase)
928 s.Attachments.Deleting = base.Padding(0, 1).Bold(true).Background(o.destructive).Foreground(o.fgBase)
927
929
internal/ui/styles/styles.go+13−10
@@ -43,6 +43,7 @@ const (
43
43
44 ImageIcon string = "■"
44 ImageIcon string = "■"
45 TextIcon string = "≡"
45 TextIcon string = "≡"
46+ SkillIcon string = "▲"
46
47
47 ScrollbarThumb string = "┃"
48 ScrollbarThumb string = "┃"
48 ScrollbarTrack string = "│"
49 ScrollbarTrack string = "│"
@@ -183,16 +184,17 @@ type Styles struct {
183
184
184 // ModelInfo (model name, provider, reasoning, token/cost summary)
185 // ModelInfo (model name, provider, reasoning, token/cost summary)
185 ModelInfo struct {
186 ModelInfo struct {
186− Icon lipgloss.Style // Model icon (◇)
187+ Icon lipgloss.Style // Model icon (◇)
187− Name lipgloss.Style // Model name text
188+ Name lipgloss.Style // Model name text
188− Provider lipgloss.Style // "via <provider>" text
189+ Provider lipgloss.Style // "via <provider>" text
189− ProviderFallback lipgloss.Style // Provider on its own second line
190+ ProviderFallback lipgloss.Style // Provider on its own second line
190− Reasoning lipgloss.Style // Reasoning effort text
191+ Reasoning lipgloss.Style // Reasoning effort text
191− TokenCount lipgloss.Style // "(42K)" token count
192+ TokenCount lipgloss.Style // "(42K)" token count
192− TokenPercentage lipgloss.Style // "42%" percent of context window
193+ TokenPercentage lipgloss.Style // "42%" percent of context window
194+ EstimatedUsagePrefix lipgloss.Style // "~" prefix for estimated usage
193− Cost lipgloss.Style // "$0.42" cost readout
195+ Cost lipgloss.Style // "$0.42" cost readout
194− HypercreditIcon lipgloss.Style // Hypercredit icon (◆)
196+ HypercreditIcon lipgloss.Style // Hypercredit icon (◆)
195− HypercreditText lipgloss.Style // Remaining Hypercredits text
197+ HypercreditText lipgloss.Style // Remaining Hypercredits text
196 }
198 }
197
199
198 // Resource styles the LSP/MCP/skills sidebar lists: their heading,
200 // Resource styles the LSP/MCP/skills sidebar lists: their heading,
@@ -501,6 +503,7 @@ type Styles struct {
501 Normal lipgloss.Style
503 Normal lipgloss.Style
502 Image lipgloss.Style
504 Image lipgloss.Style
503 Text lipgloss.Style
505 Text lipgloss.Style
506+ Skill lipgloss.Style
504 Deleting lipgloss.Style
507 Deleting lipgloss.Style
505 }
508 }
506
509
internal/workspace/app_workspace.go+11
@@ -18,6 +18,7 @@ import (
18 "github.com/charmbracelet/crush/internal/oauth"
18 "github.com/charmbracelet/crush/internal/oauth"
19 "github.com/charmbracelet/crush/internal/permission"
19 "github.com/charmbracelet/crush/internal/permission"
20 "github.com/charmbracelet/crush/internal/session"
20 "github.com/charmbracelet/crush/internal/session"
21+ "github.com/charmbracelet/crush/internal/skills"
21)
22)
22
23
23// AppWorkspace implements the Workspace interface by delegating
24// AppWorkspace implements the Workspace interface by delegating
@@ -311,6 +312,16 @@ func (w *AppWorkspace) InitializePrompt() (string, error) {
311 return agent.InitializePrompt(w.store)
312 return agent.InitializePrompt(w.store)
312}
313}
313
314
315+func (w *AppWorkspace) ListSkills(_ context.Context) ([]skills.CatalogEntry, error) {
316+ mgr := w.app.Skills
317+ return skills.Catalog(mgr.ActiveSkills(), mgr.ResolvedPaths(), mgr.WorkingDir()), nil
318+}
319+
320+func (w *AppWorkspace) ReadSkill(_ context.Context, skillID string) ([]byte, skills.SkillReadResult, error) {
321+ mgr := w.app.Skills
322+ return skills.ReadContent(mgr.ActiveSkills(), mgr.ResolvedPaths(), mgr.WorkingDir(), skillID)
323+}
324+
314// -- MCP operations --
325// -- MCP operations --
315
326
316func (w *AppWorkspace) MCPGetStates() map[string]mcptools.ClientInfo {
327func (w *AppWorkspace) MCPGetStates() map[string]mcptools.ClientInfo {
internal/workspace/client_workspace.go+31
@@ -494,6 +494,37 @@ func (w *ClientWorkspace) InitializePrompt() (string, error) {
494 return w.client.GetInitializePrompt(context.Background(), w.workspaceID())
494 return w.client.GetInitializePrompt(context.Background(), w.workspaceID())
495}
495}
496
496
497+func (w *ClientWorkspace) ListSkills(ctx context.Context) ([]skills.CatalogEntry, error) {
498+ entries, err := w.client.ListSkills(ctx, w.workspaceID())
499+ if err != nil {
500+ return nil, err
501+ }
502+ result := make([]skills.CatalogEntry, len(entries))
503+ for i, entry := range entries {
504+ result[i] = skills.CatalogEntry{
505+ ID: entry.ID,
506+ Name: entry.Name,
507+ Description: entry.Description,
508+ Label: entry.Label,
509+ Source: skills.SourceType(entry.Source),
510+ }
511+ }
512+ return result, nil
513+}
514+
515+func (w *ClientWorkspace) ReadSkill(ctx context.Context, skillID string) ([]byte, skills.SkillReadResult, error) {
516+ resp, err := w.client.ReadSkill(ctx, w.workspaceID(), skillID)
517+ if err != nil {
518+ return nil, skills.SkillReadResult{}, err
519+ }
520+ return resp.Content, skills.SkillReadResult{
521+ Name: resp.Result.Name,
522+ Description: resp.Result.Description,
523+ Source: skills.SourceType(resp.Result.Source),
524+ Builtin: resp.Result.Builtin,
525+ }, nil
526+}
527+
497// -- MCP operations --
528// -- MCP operations --
498
529
499func (w *ClientWorkspace) MCPGetStates() map[string]mcp.ClientInfo {
530func (w *ClientWorkspace) MCPGetStates() map[string]mcp.ClientInfo {
internal/workspace/workspace.go+3
@@ -18,6 +18,7 @@ import (
18 "github.com/charmbracelet/crush/internal/oauth"
18 "github.com/charmbracelet/crush/internal/oauth"
19 "github.com/charmbracelet/crush/internal/permission"
19 "github.com/charmbracelet/crush/internal/permission"
20 "github.com/charmbracelet/crush/internal/session"
20 "github.com/charmbracelet/crush/internal/session"
21+ "github.com/charmbracelet/crush/internal/skills"
21)
22)
22
23
23// LSPClientInfo holds information about an LSP client's state. This is
24// LSPClientInfo holds information about an LSP client's state. This is
@@ -141,6 +142,8 @@ type Workspace interface {
141 ProjectNeedsInitialization() (bool, error)
142 ProjectNeedsInitialization() (bool, error)
142 MarkProjectInitialized() error
143 MarkProjectInitialized() error
143 InitializePrompt() (string, error)
144 InitializePrompt() (string, error)
145+ ListSkills(ctx context.Context) ([]skills.CatalogEntry, error)
146+ ReadSkill(ctx context.Context, skillID string) ([]byte, skills.SkillReadResult, error)
144
147
145 // MCP operations (server-side in client mode)
148 // MCP operations (server-side in client mode)
146 MCPGetStates() map[string]mcptools.ClientInfo
149 MCPGetStates() map[string]mcptools.ClientInfo