Detailed changes
@@ -1815,6 +1815,46 @@
"created_at": "2026-05-20T06:53:43Z",
"repoId": 987670088,
"pullRequestNo": 2964
+ },
+ {
+ "name": "g2mt",
+ "id": 166577174,
+ "comment_id": 4513096006,
+ "created_at": "2026-05-21T21:56:14Z",
+ "repoId": 987670088,
+ "pullRequestNo": 2979
+ },
+ {
+ "name": "Ricardo-M-L",
+ "id": 69202550,
+ "comment_id": 4514494248,
+ "created_at": "2026-05-22T02:37:08Z",
+ "repoId": 987670088,
+ "pullRequestNo": 2847
+ },
+ {
+ "name": "Muttaqin86",
+ "id": 69788027,
+ "comment_id": 4520181780,
+ "created_at": "2026-05-22T15:46:32Z",
+ "repoId": 987670088,
+ "pullRequestNo": 2984
+ },
+ {
+ "name": "officialasishkumar",
+ "id": 87874775,
+ "comment_id": 4527874300,
+ "created_at": "2026-05-24T08:44:14Z",
+ "repoId": 987670088,
+ "pullRequestNo": 2995
+ },
+ {
+ "name": "yhyu13",
+ "id": 19365678,
+ "comment_id": 4528076561,
+ "created_at": "2026-05-24T10:19:27Z",
+ "repoId": 987670088,
+ "pullRequestNo": 2996
}
]
}
@@ -30,11 +30,11 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- - uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
+ - uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
languages: ${{ matrix.language }}
- - uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
- - uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
+ - uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
+ - uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
grype:
runs-on: ubuntu-latest
@@ -52,7 +52,7 @@ jobs:
path: "."
fail-build: true
severity-cutoff: critical
- - uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
+ - uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
sarif_file: ${{ steps.scan.outputs.sarif }}
@@ -73,7 +73,7 @@ jobs:
- name: Run govulncheck
run: |
govulncheck -C . -format sarif ./... > results.sarif
- - uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
+ - uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
sarif_file: results.sarif
@@ -25,7 +25,7 @@ jobs:
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
- - uses: goreleaser/goreleaser-action@1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8 # v7.2.1
+ - uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
with:
version: "nightly"
distribution: goreleaser-pro
@@ -5,9 +5,9 @@ go 1.26.3
require (
charm.land/bubbles/v2 v2.1.0
charm.land/bubbletea/v2 v2.0.6
- charm.land/catwalk v0.41.0
+ charm.land/catwalk v0.41.8
charm.land/fang/v2 v2.0.1
- charm.land/fantasy v0.25.0
+ charm.land/fantasy v0.25.2
charm.land/glamour/v2 v2.0.0
charm.land/lipgloss/v2 v2.0.3
charm.land/log/v2 v2.0.0
@@ -41,7 +41,7 @@ require (
github.com/disintegration/imaging v1.6.2
github.com/dustin/go-humanize v1.0.1
github.com/gen2brain/beeep v0.11.2
- github.com/go-git/go-git/v5 v5.19.0
+ github.com/go-git/go-git/v5 v5.19.1
github.com/google/uuid v1.6.0
github.com/invopop/jsonschema v0.14.0
github.com/itchyny/gojq v0.12.19
@@ -49,11 +49,11 @@ require (
github.com/jordanella/go-ansi-paintbrush v0.0.0-20240728195301-b7ad996ecf3d
github.com/lucasb-eyer/go-colorful v1.4.0
github.com/mattn/go-isatty v0.0.22
- github.com/modelcontextprotocol/go-sdk v1.6.0
- github.com/ncruces/go-sqlite3 v0.34.1
+ github.com/modelcontextprotocol/go-sdk v1.6.1
+ github.com/ncruces/go-sqlite3 v0.34.2
github.com/nxadm/tail v1.4.11
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
- github.com/posthog/posthog-go v1.12.5
+ github.com/posthog/posthog-go v1.12.6
github.com/pressly/goose/v3 v3.27.1
github.com/qjebbs/go-jsons v1.0.0-alpha.5
github.com/rivo/uniseg v0.4.7
@@ -67,9 +67,9 @@ require (
github.com/tidwall/sjson v1.2.5
github.com/zeebo/xxh3 v1.1.0
go.uber.org/goleak v1.3.0
- golang.org/x/net v0.54.0
+ golang.org/x/net v0.55.0
golang.org/x/sync v0.20.0
- golang.org/x/sys v0.44.0
+ golang.org/x/sys v0.45.0
golang.org/x/text v0.37.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
@@ -120,7 +120,7 @@ require (
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.9.0 // indirect
- github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4 // indirect
+ github.com/go-json-experiment/json v0.0.0-20260505212615-e40f80bf6836 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
@@ -147,10 +147,10 @@ require (
github.com/jackmordaunt/icns/v3 v3.0.1 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/josharian/intern v1.0.0 // indirect
- github.com/kaptinlin/go-i18n v0.4.5 // indirect
- github.com/kaptinlin/jsonpointer v0.4.20 // indirect
- github.com/kaptinlin/jsonschema v0.7.13 // indirect
- github.com/kaptinlin/messageformat-go v0.6.0 // indirect
+ github.com/kaptinlin/go-i18n v0.4.8 // indirect
+ github.com/kaptinlin/jsonpointer v0.4.23 // indirect
+ github.com/kaptinlin/jsonschema v0.7.14 // indirect
+ github.com/kaptinlin/messageformat-go v0.6.4 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
@@ -164,7 +164,7 @@ require (
github.com/muesli/mango-cobra v1.2.0 // indirect
github.com/muesli/mango-pflag v0.1.0 // indirect
github.com/muesli/roff v0.1.0 // indirect
- github.com/ncruces/go-sqlite3-wasm/v2 v2.2.35301 // indirect
+ github.com/ncruces/go-sqlite3-wasm/v2 v2.4.35301 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/ncruces/julianday v1.0.0 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
@@ -205,10 +205,10 @@ require (
golang.org/x/term v0.43.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.44.0 // indirect
- google.golang.org/api v0.278.0 // indirect
- google.golang.org/genai v1.56.0 // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 // indirect
- google.golang.org/grpc v1.81.0 // indirect
+ google.golang.org/api v0.279.0 // indirect
+ google.golang.org/genai v1.57.0 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect
+ google.golang.org/grpc v1.81.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290 // indirect
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
@@ -2,12 +2,12 @@ charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo=
charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g=
-charm.land/catwalk v0.41.0 h1:rGeGrEJLFIFqz+glpCD4ICTo2PzL1GMFqGN+jpQn7O4=
-charm.land/catwalk v0.41.0/go.mod h1:LmMFJdRqF5F7qKa+xqD9SBq7tph7L98GU3ZFa1TxftA=
+charm.land/catwalk v0.41.8 h1:SxM6KyFD5jtBF2lZZKk6cYCbw1GVlNfn05ZSRtynxEE=
+charm.land/catwalk v0.41.8/go.mod h1:dtK2+UfdsFJgIriRPodMsSJw0XefrFOq6fdvuS57v3s=
charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY=
charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII=
-charm.land/fantasy v0.25.0 h1:oXOWY1ivmTSnhYGzAolscF8zKtavWZyBWv0LHRSwN5Q=
-charm.land/fantasy v0.25.0/go.mod h1:8QrWUzIcKwZQP+aAnC9vLu3iID6hu9/Jt+rPMiieBkc=
+charm.land/fantasy v0.25.2 h1:K7ZOM3UEay//NHfiFAeIMRaOqhspxe0UyccIJOYrjuo=
+charm.land/fantasy v0.25.2/go.mod h1:9ykD5gjn8BCjpZqA66vet7H1KsmR+kP0Q0qw1FiqCk0=
charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U=
charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w=
charm.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
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA=
github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw=
-github.com/go-git/go-git/v5 v5.19.0 h1:+WkVUQZSy/F1Gb13udrMKjIM2PrzsNfDKFSfo5tkMtc=
-github.com/go-git/go-git/v5 v5.19.0/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ=
-github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4 h1:2WmHkJINIjgXXYDGik8d3oJvFA3DAwPy00csDJ3vo+o=
-github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
+github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00=
+github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ=
+github.com/go-json-experiment/json v0.0.0-20260505212615-e40f80bf6836 h1:5KGUhXZFTN1PrCY4zUZLe1J8n7uBNmPDbCLCn78EbPQ=
+github.com/go-json-experiment/json v0.0.0-20260505212615-e40f80bf6836/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.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
github.com/jordanella/go-ansi-paintbrush v0.0.0-20240728195301-b7ad996ecf3d/go.mod h1:SV0W0APWP9MZ1/gfDQ/NzzTlWdIgYZ/ZbpN4d/UXRYw=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
-github.com/kaptinlin/go-i18n v0.4.5 h1:9tIlo5A0RXth+yZJO2MG7Bhpu/X9PlzQnGz/qyYWNoY=
-github.com/kaptinlin/go-i18n v0.4.5/go.mod h1:mU/7BH4molY5lGZYBwBRKAaiJ70dWRHuqmQ0/pFLGno=
-github.com/kaptinlin/jsonpointer v0.4.20 h1:otSZZnCVdVo9OwOm+AQhS8ke31CLLQYXmG5Q0GOrXYg=
-github.com/kaptinlin/jsonpointer v0.4.20/go.mod h1:Mo7+DX8RlQTFqS4dnYJl0izSP4ob+Rl5xO/mGDETgaU=
-github.com/kaptinlin/jsonschema v0.7.13 h1:kahVXTy/rURL0XJjyQ9WELm59wEmXi6IY0TWswQEFvU=
-github.com/kaptinlin/jsonschema v0.7.13/go.mod h1:Uh0aUBusnhXDCEXJ2oimL/hx7YTo7F+sKniE+tM0ERc=
-github.com/kaptinlin/messageformat-go v0.6.0 h1:D6jiXFsKW4/JG2CMddv/F6Rev9KVbCRKEzzV5QOAcpc=
-github.com/kaptinlin/messageformat-go v0.6.0/go.mod h1:NKjwS6e9u7DRhAK+vydjDDwJ7UbdHhYjk/yk2WPuZPs=
+github.com/kaptinlin/go-i18n v0.4.8 h1:ymGkz0uU974wljuuHZufHP1BlFWVk5Tf/sSMO8Cl9yQ=
+github.com/kaptinlin/go-i18n v0.4.8/go.mod h1:F+ezt0Q39p5x8PQW6p4xKMovhjNbhZYHuqwyEV1hHMw=
+github.com/kaptinlin/jsonpointer v0.4.23 h1:0VisnCL7rJT7BRTwxSWMU7vC0PD/RFgmisNcURkWp3k=
+github.com/kaptinlin/jsonpointer v0.4.23/go.mod h1:Mo7+DX8RlQTFqS4dnYJl0izSP4ob+Rl5xO/mGDETgaU=
+github.com/kaptinlin/jsonschema v0.7.14 h1:6grzaTJiRuLXlIGEdlGX5HEII3Za2tV+xxGpW3Kg4Rc=
+github.com/kaptinlin/jsonschema v0.7.14/go.mod h1:9WFuBzJjrvNkXVjo0L2Ujl1T/yqAGurwgbx4JWgF5C8=
+github.com/kaptinlin/messageformat-go v0.6.4 h1:6nC70fsqEn2xxg/Xoby2+Dk2r77kvxa3QNnYL/hsNcM=
+github.com/kaptinlin/messageformat-go v0.6.4/go.mod h1:553UGZ1x5jmGtyH4pQKYwLGMyPm71deCoZICjq1DtR8=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.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
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
-github.com/modelcontextprotocol/go-sdk v1.6.0 h1:PPLS3kn7WtOEnR+Af4X5H96SG0qSab8R/ZQT/HkhPkY=
-github.com/modelcontextprotocol/go-sdk v1.6.0/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
+github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
+github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI=
@@ -316,10 +316,10 @@ github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe
github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=
github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
-github.com/ncruces/go-sqlite3 v0.34.1 h1:N4NU/MqvZtSseGyTzJXdFI8RoVIR0lWUYeainj4pX2o=
-github.com/ncruces/go-sqlite3 v0.34.1/go.mod h1:QuztC+fMQvmDSPEk3E807xYbAVZGsxnismh23mzZPhM=
-github.com/ncruces/go-sqlite3-wasm/v2 v2.2.35301 h1:PPb3vECU21cZr2CrzYG7idXLBpWETDcdVEnXg3vqRpM=
-github.com/ncruces/go-sqlite3-wasm/v2 v2.2.35301/go.mod h1:q34C+veYmxQ7XRFgCKSeD6hq5vP/C517Hkl4iu+A5ao=
+github.com/ncruces/go-sqlite3 v0.34.2 h1:+B50kRdn2BfMTSoRbkgnNaIolxIq1qS6lhcXyvNe230=
+github.com/ncruces/go-sqlite3 v0.34.2/go.mod h1:ZUqB9w9k4ACD7X5YeISBY05glvkgTur3dwhoDFGASK4=
+github.com/ncruces/go-sqlite3-wasm/v2 v2.4.35301 h1:xGFgiIf1SS4yTqyuW3cSR6hd9KRlUFzVloJ873AyrxU=
+github.com/ncruces/go-sqlite3-wasm/v2 v2.4.35301/go.mod h1:7dV8P4xml/vrgb/zKfJaZ5aas5el3VyBR28XkpBq5NM=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
@@ -333,8 +333,8 @@ github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY=
github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ=
-github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
-github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
+github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.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
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/posthog/posthog-go v1.12.5 h1:l/x3mpqisXJ0sTOyyRutsTQAgiWYuJT1uhN4cQraJ8o=
-github.com/posthog/posthog-go v1.12.5/go.mod h1:xsVOW9YImilUcazwPNEq4PJDqEZf2KeCS758zXjwkPg=
+github.com/posthog/posthog-go v1.12.6 h1:N+FrKWY6DOuDhV2OMgvtKAKDYGTdtS9/nuvr0BTyBp0=
+github.com/posthog/posthog-go v1.12.6/go.mod h1:xsVOW9YImilUcazwPNEq4PJDqEZf2KeCS758zXjwkPg=
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
github.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=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
-golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
-golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
+golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
+golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.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=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
-golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
+golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.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
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
-google.golang.org/api v0.278.0 h1:W7jiRvRi53VYFfZ/HoZjQBtJk7gOFbHD8ot1RzVZU6E=
-google.golang.org/api v0.278.0/go.mod h1:B9TqLBwJqVjp1mtt7WeoQwWRwvu/400y5lETOql+giQ=
-google.golang.org/genai v1.56.0 h1:IwWrg1K0cn1/WBiPno/dYr0Q6o75NeH/bh3G4JEFERE=
-google.golang.org/genai v1.56.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
-google.golang.org/genproto v0.0.0-20260504160031-60b97b32f348 h1:JjVGDZYWkJWZcxveJGzfkXC5myDVWAd4dZdgbzrDUv8=
-google.golang.org/genproto v0.0.0-20260504160031-60b97b32f348/go.mod h1:95PqD4xM+AdOcBGsmgfaofXsiA37uXDtDufVbntT3TU=
-google.golang.org/genproto/googleapis/api v0.0.0-20260504160031-60b97b32f348 h1:U8orV30l6KpDsi9dxU0CoJZGbjS8EEpw+6ba+XwGPQA=
-google.golang.org/genproto/googleapis/api v0.0.0-20260504160031-60b97b32f348/go.mod h1:Yzdzr5OOZFgSsEV2D/Xi9NL3bszpXFAg0hFJiRohcD8=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 h1:pfIbyB44sWzHiCpRqIen67ZQnVXSfIxWrqUMk1qwODE=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
-google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw=
-google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
+google.golang.org/api v0.279.0 h1:hsx2M2OaRcaKtVYK6vXEUnQvdjnend7ZYES+lYaot74=
+google.golang.org/api v0.279.0/go.mod h1:B9TqLBwJqVjp1mtt7WeoQwWRwvu/400y5lETOql+giQ=
+google.golang.org/genai v1.57.0 h1:qTyG2ynz5dQy2jF4CvZdLHHVslhR0heMue+zM1a4GNM=
+google.golang.org/genai v1.57.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
+google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60 h1:rhBdfmsOlOZIvz3Y5/BdUzPg2CkO8L7QQPKj96B8554=
+google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60/go.mod h1:8xo2Pj1b20ZOCpzlU3B9qieMwVIAXx1QVZWLMlPL6sM=
+google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 h1:3WsB1FAbiRIf2tOxscWKs3pQBD9he1NsrnbhMuWfekc=
+google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60/go.mod h1:7yoXV7RIh5gblj/xVYoogxAWvA9wUeVbpsK/M694l00=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
+google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -261,6 +261,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
a.eventPromptSent(call.SessionID)
var currentAssistant *message.Message
+ var stepMessages []fantasy.Message
var shouldSummarize bool
// Don't send MaxOutputTokens if 0 — some providers (e.g. LM Studio) reject it
var maxOutputTokens *int64
@@ -319,6 +320,10 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(promptPrefix)}, prepared.Messages...)
}
+ sessionLock.Lock()
+ stepMessages = cloneFantasyMessages(prepared.Messages)
+ sessionLock.Unlock()
+
var assistantMsg message.Message
assistantMsg, err = a.messages.Create(callContext, call.SessionID, message.CreateMessageParams{
Role: message.Assistant,
@@ -444,7 +449,8 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
if getSessionErr != nil {
return getSessionErr
}
- a.updateSessionUsage(largeModel, &updatedSession, stepResult.Usage, a.openrouterCost(stepResult.ProviderMetadata))
+ usage, estimated := fallbackStepUsage(stepMessages, stepResult)
+ a.updateSessionUsage(largeModel, &updatedSession, usage, a.openrouterCost(stepResult.ProviderMetadata), estimated)
_, sessionErr := a.sessions.Save(ctx, updatedSession)
if sessionErr != nil {
return sessionErr
@@ -749,13 +755,14 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan
}
}
- a.updateSessionUsage(largeModel, ¤tSession, resp.TotalUsage, openrouterCost)
+ a.updateSessionUsage(largeModel, ¤tSession, resp.TotalUsage, openrouterCost, false)
// Just in case, get just the last usage info.
usage := resp.Response.Usage
currentSession.SummaryMessageID = summaryMessage.ID
- currentSession.CompletionTokens = usage.OutputTokens
+ currentSession.CompletionTokens = summaryCompletionTokens(usage, summaryMessage)
currentSession.PromptTokens = 0
+ currentSession.EstimatedUsage = usageIsZero(usage)
_, err = a.sessions.Save(genCtx, currentSession)
if err != nil {
return err
@@ -1132,28 +1139,53 @@ func (a *sessionAgent) openrouterCost(metadata fantasy.ProviderMetadata) *float6
return &opts.Usage.Cost
}
-func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session, usage fantasy.Usage, overrideCost *float64) {
+func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session, usage fantasy.Usage, overrideCost *float64, estimated bool) {
+ if !usageIsZero(usage) {
+ session.EstimatedUsage = estimated
+ }
+
modelConfig := model.CatwalkCfg
cost := modelConfig.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) +
modelConfig.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) +
modelConfig.CostPer1MIn/1e6*float64(usage.InputTokens) +
modelConfig.CostPer1MOut/1e6*float64(usage.OutputTokens)
- a.eventTokensUsed(session.ID, model, usage, cost)
-
- // Use override cost if available (e.g., from OpenRouter).
- if overrideCost != nil {
- cost = *overrideCost
+ if !estimated {
+ a.eventTokensUsed(session.ID, model, usage, cost)
}
- // Skip cost accumulation
- if model.FlatRate {
+ if estimated {
cost = 0
+ } else {
+ // Use override cost if available (e.g., from OpenRouter).
+ if overrideCost != nil {
+ cost = *overrideCost
+ }
+
+ // Skip cost accumulation
+ if model.FlatRate {
+ cost = 0
+ }
}
session.Cost += cost
- session.CompletionTokens = usage.OutputTokens
- session.PromptTokens = usage.InputTokens + usage.CacheReadTokens
+ updateSessionTokenCounters(session, usage)
+}
+
+func updateSessionTokenCounters(session *session.Session, usage fantasy.Usage) {
+ if usage.OutputTokens != 0 {
+ session.CompletionTokens = usage.OutputTokens
+ }
+ if promptTokens := usage.InputTokens + usage.CacheReadTokens; promptTokens != 0 {
+ session.PromptTokens = promptTokens
+ }
+}
+
+func summaryCompletionTokens(usage fantasy.Usage, summaryMessage message.Message) int64 {
+ if usage.OutputTokens != 0 {
+ return usage.OutputTokens
+ }
+ return approxTokenCount(summaryMessage.Content().Text) + approxTokenCount(summaryMessage.ReasoningContent().String())
}
func (a *sessionAgent) Cancel(sessionID string) {
@@ -283,10 +283,13 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.
return options
}
+ shouldSetEffort := model.CatwalkCfg.CanReason &&
+ slices.Contains(model.CatwalkCfg.ReasoningLevels, model.ModelCfg.ReasoningEffort)
+
switch providerCfg.Type {
case openai.Name, azure.Name:
_, hasReasoningEffort := mergedOptions["reasoning_effort"]
- if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" && model.CatwalkCfg.CanReason {
+ if !hasReasoningEffort && shouldSetEffort {
mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
}
if openai.IsResponsesModel(model.CatwalkCfg.ID) {
@@ -310,7 +313,7 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.
_, hasThink = mergedOptions["thinking"]
)
switch {
- case !hasEffort && model.ModelCfg.ReasoningEffort != "" && model.CatwalkCfg.CanReason:
+ case !hasEffort && shouldSetEffort:
mergedOptions["effort"] = model.ModelCfg.ReasoningEffort
case !hasThink && model.ModelCfg.Think:
mergedOptions["thinking"] = map[string]any{"budget_tokens": 2000}
@@ -322,7 +325,7 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.
case openrouter.Name:
_, hasReasoning := mergedOptions["reasoning"]
- if !hasReasoning && model.ModelCfg.ReasoningEffort != "" {
+ if !hasReasoning && shouldSetEffort {
mergedOptions["reasoning"] = map[string]any{
"enabled": true,
"effort": model.ModelCfg.ReasoningEffort,
@@ -334,7 +337,7 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.
}
case vercel.Name:
_, hasReasoning := mergedOptions["reasoning"]
- if !hasReasoning && model.ModelCfg.ReasoningEffort != "" {
+ if !hasReasoning && shouldSetEffort {
mergedOptions["reasoning"] = map[string]any{
"enabled": true,
"effort": model.ModelCfg.ReasoningEffort,
@@ -367,7 +370,7 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.
extraBody := make(map[string]any)
_, hasReasoningEffort := mergedOptions["reasoning_effort"]
- if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" && model.CatwalkCfg.CanReason {
+ if !hasReasoningEffort && shouldSetEffort {
switch providerCfg.ID {
case string(catwalk.InferenceProviderIoNet):
extraBody["reasoning"] = map[string]string{"effort": model.ModelCfg.ReasoningEffort}
@@ -399,7 +399,11 @@ func TestGetProviderOptionsReasoningEffort(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
model := Model{
- CatwalkCfg: catwalk.Model{ID: "claude-opus-4-7", CanReason: true},
+ CatwalkCfg: catwalk.Model{
+ ID: "claude-opus-4-7",
+ CanReason: true,
+ ReasoningLevels: []string{"max"},
+ },
ModelCfg: config.SelectedModel{
Provider: "test",
ReasoningEffort: "max",
@@ -9,9 +9,9 @@
{
"id": "deepseek-v4-flash",
"name": "DeepSeek V4 Flash",
- "cost_per_1m_in": 1.55,
- "cost_per_1m_out": 2.28,
- "cost_per_1m_in_cached": 0.38,
+ "cost_per_1m_in": 0.146,
+ "cost_per_1m_out": 0.294,
+ "cost_per_1m_in_cached": 0.073,
"cost_per_1m_out_cached": 0,
"context_window": 1048576,
"default_max_tokens": 104857,
@@ -21,9 +21,9 @@
{
"id": "deepseek-v4-pro",
"name": "DeepSeek V4 Pro",
- "cost_per_1m_in": 4.45,
- "cost_per_1m_out": 5.5,
- "cost_per_1m_in_cached": 0.35,
+ "cost_per_1m_in": 1.788,
+ "cost_per_1m_out": 3.62,
+ "cost_per_1m_in_cached": 0.894,
"cost_per_1m_out_cached": 0,
"context_window": 1048576,
"default_max_tokens": 60000,
@@ -33,9 +33,9 @@
{
"id": "gemma-4-26b-a4b-it",
"name": "Gemma 4 26B A4B",
- "cost_per_1m_in": 0.145,
- "cost_per_1m_out": 0.5,
- "cost_per_1m_in_cached": 0.08,
+ "cost_per_1m_in": 0.1225,
+ "cost_per_1m_out": 0.428,
+ "cost_per_1m_in_cached": 0.06125,
"cost_per_1m_out_cached": 0,
"context_window": 256000,
"default_max_tokens": 25600,
@@ -45,9 +45,9 @@
{
"id": "glm-5",
"name": "GLM-5",
- "cost_per_1m_in": 1,
- "cost_per_1m_out": 3,
- "cost_per_1m_in_cached": 0.5,
+ "cost_per_1m_in": 0.92,
+ "cost_per_1m_out": 2.976,
+ "cost_per_1m_in_cached": 0.46,
"cost_per_1m_out_cached": 0,
"context_window": 202752,
"default_max_tokens": 20275,
@@ -57,9 +57,9 @@
{
"id": "glm-5.1",
"name": "GLM-5.1",
- "cost_per_1m_in": 1.5,
- "cost_per_1m_out": 4.4,
- "cost_per_1m_in_cached": 0.26,
+ "cost_per_1m_in": 1.33,
+ "cost_per_1m_out": 4.22,
+ "cost_per_1m_in_cached": 0.665,
"cost_per_1m_out_cached": 0,
"context_window": 202750,
"default_max_tokens": 1638,
@@ -69,9 +69,9 @@
{
"id": "gpt-oss-120b",
"name": "gpt-oss-120b",
- "cost_per_1m_in": 0.1,
- "cost_per_1m_out": 0.4,
- "cost_per_1m_in_cached": 0.01,
+ "cost_per_1m_in": 0.162,
+ "cost_per_1m_out": 0.69,
+ "cost_per_1m_in_cached": 0.081,
"cost_per_1m_out_cached": 0,
"context_window": 131072,
"default_max_tokens": 13107,
@@ -87,9 +87,9 @@
{
"id": "kimi-k2.5",
"name": "Kimi K2.5",
- "cost_per_1m_in": 0.445,
- "cost_per_1m_out": 2,
- "cost_per_1m_in_cached": 0.225,
+ "cost_per_1m_in": 0.562,
+ "cost_per_1m_out": 2.91,
+ "cost_per_1m_in_cached": 0.281,
"cost_per_1m_out_cached": 0,
"context_window": 262144,
"default_max_tokens": 26214,
@@ -99,9 +99,9 @@
{
"id": "kimi-k2.6",
"name": "Kimi K2.6",
- "cost_per_1m_in": 0.74,
- "cost_per_1m_out": 3.5,
- "cost_per_1m_in_cached": 0.25,
+ "cost_per_1m_in": 1,
+ "cost_per_1m_out": 4.1,
+ "cost_per_1m_in_cached": 0.5,
"cost_per_1m_out_cached": 0,
"context_window": 262142,
"default_max_tokens": 26214,
@@ -111,9 +111,9 @@
{
"id": "llama-3.3-70b-instruct",
"name": "Llama 3.3 70B Instruct",
- "cost_per_1m_in": 0.1,
- "cost_per_1m_out": 0.32,
- "cost_per_1m_in_cached": 0.05,
+ "cost_per_1m_in": 0.638,
+ "cost_per_1m_out": 0.768,
+ "cost_per_1m_in_cached": 0.319,
"cost_per_1m_out_cached": 0,
"context_window": 128000,
"default_max_tokens": 12800,
@@ -123,9 +123,9 @@
{
"id": "llama-4-maverick-17b-128e-instruct-fp8",
"name": "Llama 4 Maverick 17B 128E Instruct FP8",
- "cost_per_1m_in": 0.15,
- "cost_per_1m_out": 0.6,
- "cost_per_1m_in_cached": 0.075,
+ "cost_per_1m_in": 0.35,
+ "cost_per_1m_out": 1.0625,
+ "cost_per_1m_in_cached": 0.175,
"cost_per_1m_out_cached": 0,
"context_window": 430000,
"default_max_tokens": 43000,
@@ -135,9 +135,9 @@
{
"id": "minimax-m2.7",
"name": "MiniMax M2.7",
- "cost_per_1m_in": 0.3,
- "cost_per_1m_out": 1.2,
- "cost_per_1m_in_cached": 0.06,
+ "cost_per_1m_in": 0.4158,
+ "cost_per_1m_out": 1.68,
+ "cost_per_1m_in_cached": 0.2079,
"cost_per_1m_out_cached": 0,
"context_window": 204800,
"default_max_tokens": 20480,
@@ -156,12 +156,48 @@
"can_reason": false,
"supports_attachments": true
},
+ {
+ "id": "qwen3.6-flash",
+ "name": "Qwen3.6-Flash",
+ "cost_per_1m_in": 1,
+ "cost_per_1m_out": 4,
+ "cost_per_1m_in_cached": 0.1,
+ "cost_per_1m_out_cached": 1.25,
+ "context_window": 1000000,
+ "default_max_tokens": 64000,
+ "can_reason": true,
+ "supports_attachments": true
+ },
+ {
+ "id": "qwen3.6-max",
+ "name": "Qwen3.6-Max",
+ "cost_per_1m_in": 2,
+ "cost_per_1m_out": 12,
+ "cost_per_1m_in_cached": 0.2,
+ "cost_per_1m_out_cached": 2.5,
+ "context_window": 256000,
+ "default_max_tokens": 64000,
+ "can_reason": true,
+ "supports_attachments": false
+ },
+ {
+ "id": "qwen3.6-plus",
+ "name": "Qwen3.6-Plus",
+ "cost_per_1m_in": 2,
+ "cost_per_1m_out": 6,
+ "cost_per_1m_in_cached": 0.2,
+ "cost_per_1m_out_cached": 2.5,
+ "context_window": 1000000,
+ "default_max_tokens": 64000,
+ "can_reason": true,
+ "supports_attachments": true
+ },
{
"id": "qwen3-coder-480b-a35b-instruct-int4-mixed-ar",
"name": "Qwen3 Coder 480B A35B Instruct INT4 Mixed AR",
- "cost_per_1m_in": 0.22,
- "cost_per_1m_out": 0.95,
- "cost_per_1m_in_cached": 0.11,
+ "cost_per_1m_in": 0.746,
+ "cost_per_1m_out": 2.13,
+ "cost_per_1m_in_cached": 0.373,
"cost_per_1m_out_cached": 0,
"context_window": 106000,
"default_max_tokens": 10600,
@@ -171,9 +207,9 @@
{
"id": "qwen3-next-80b-a3b-instruct",
"name": "Qwen3 Next 80B A3B Instruct",
- "cost_per_1m_in": 0.06,
- "cost_per_1m_out": 0.6,
- "cost_per_1m_in_cached": 0.03,
+ "cost_per_1m_in": 0.128,
+ "cost_per_1m_out": 1.28,
+ "cost_per_1m_in_cached": 0.064,
"cost_per_1m_out_cached": 0,
"context_window": 262144,
"default_max_tokens": 26214,
@@ -0,0 +1,176 @@
+package agent
+
+import (
+ "fmt"
+
+ "charm.land/fantasy"
+)
+
+func usageIsZero(usage fantasy.Usage) bool {
+ return usage.InputTokens == 0 &&
+ usage.OutputTokens == 0 &&
+ usage.TotalTokens == 0 &&
+ usage.ReasoningTokens == 0 &&
+ usage.CacheCreationTokens == 0 &&
+ usage.CacheReadTokens == 0
+}
+
+func fallbackStepUsage(messages []fantasy.Message, step fantasy.StepResult) (fantasy.Usage, bool) {
+ if !usageIsZero(step.Usage) {
+ return step.Usage, false
+ }
+
+ inputTokens := estimateMessageTokens(messages)
+ outputTokens := estimateStepCompletionTokens(step)
+ if inputTokens == 0 && outputTokens == 0 {
+ return fantasy.Usage{}, false
+ }
+
+ return fantasy.Usage{
+ InputTokens: inputTokens,
+ OutputTokens: outputTokens,
+ TotalTokens: inputTokens + outputTokens,
+ }, true
+}
+
+func cloneFantasyMessages(messages []fantasy.Message) []fantasy.Message {
+ cloned := make([]fantasy.Message, len(messages))
+ for i, msg := range messages {
+ cloned[i] = msg
+ cloned[i].Content = append([]fantasy.MessagePart(nil), msg.Content...)
+ }
+ return cloned
+}
+
+func estimateMessageTokens(messages []fantasy.Message) int64 {
+ var tokens int64
+ for _, msg := range messages {
+ tokens += approxTokenCount(string(msg.Role))
+ for _, part := range msg.Content {
+ tokens += estimateMessagePartTokens(part)
+ }
+ }
+ return tokens
+}
+
+func estimateStepCompletionTokens(step fantasy.StepResult) int64 {
+ var tokens int64
+ for _, content := range step.Content {
+ switch c := content.(type) {
+ case fantasy.TextContent:
+ tokens += approxTokenCount(c.Text)
+ case *fantasy.TextContent:
+ tokens += approxTokenCount(c.Text)
+ case fantasy.ReasoningContent:
+ tokens += approxTokenCount(c.Text)
+ case *fantasy.ReasoningContent:
+ tokens += approxTokenCount(c.Text)
+ case fantasy.FileContent:
+ tokens += estimateGeneratedFileTokens(c)
+ case *fantasy.FileContent:
+ tokens += estimateGeneratedFileTokens(*c)
+ case fantasy.SourceContent:
+ tokens += estimateSourceTokens(c)
+ case *fantasy.SourceContent:
+ tokens += estimateSourceTokens(*c)
+ case fantasy.ToolCallContent:
+ tokens += estimateToolCallTokens(c.ToolName, c.Input)
+ case *fantasy.ToolCallContent:
+ tokens += estimateToolCallTokens(c.ToolName, c.Input)
+ case fantasy.ToolResultContent:
+ if c.ProviderExecuted {
+ tokens += estimateToolResultContentTokens(c.ToolCallID, c.ToolName, c.ClientMetadata, c.Result)
+ }
+ case *fantasy.ToolResultContent:
+ if c.ProviderExecuted {
+ tokens += estimateToolResultContentTokens(c.ToolCallID, c.ToolName, c.ClientMetadata, c.Result)
+ }
+ }
+ }
+ return tokens
+}
+
+func estimateMessagePartTokens(part fantasy.MessagePart) int64 {
+ switch p := part.(type) {
+ case fantasy.TextPart:
+ return approxTokenCount(p.Text)
+ case *fantasy.TextPart:
+ return approxTokenCount(p.Text)
+ case fantasy.ReasoningPart:
+ return approxTokenCount(p.Text)
+ case *fantasy.ReasoningPart:
+ return approxTokenCount(p.Text)
+ case fantasy.FilePart:
+ return estimateFilePartTokens(p)
+ case *fantasy.FilePart:
+ return estimateFilePartTokens(*p)
+ case fantasy.ToolCallPart:
+ return estimateToolCallTokens(p.ToolName, p.Input)
+ case *fantasy.ToolCallPart:
+ return estimateToolCallTokens(p.ToolName, p.Input)
+ case fantasy.ToolResultPart:
+ return estimateToolResultContentTokens(p.ToolCallID, "", "", p.Output)
+ case *fantasy.ToolResultPart:
+ return estimateToolResultContentTokens(p.ToolCallID, "", "", p.Output)
+ default:
+ return 0
+ }
+}
+
+func estimateToolCallTokens(toolName, input string) int64 {
+ return approxTokenCount(toolName) + approxTokenCount(input)
+}
+
+func estimateToolResultContentTokens(toolCallID, toolName, metadata string, output fantasy.ToolResultOutputContent) int64 {
+ tokens := approxTokenCount(toolCallID) + approxTokenCount(toolName) + approxTokenCount(metadata)
+ switch result := output.(type) {
+ case fantasy.ToolResultOutputContentText:
+ tokens += approxTokenCount(result.Text)
+ case *fantasy.ToolResultOutputContentText:
+ tokens += approxTokenCount(result.Text)
+ case fantasy.ToolResultOutputContentError:
+ if result.Error != nil {
+ tokens += approxTokenCount(result.Error.Error())
+ }
+ case *fantasy.ToolResultOutputContentError:
+ if result.Error != nil {
+ tokens += approxTokenCount(result.Error.Error())
+ }
+ case fantasy.ToolResultOutputContentMedia:
+ tokens += estimateMediaTokens(result.MediaType, result.Text, len(result.Data))
+ case *fantasy.ToolResultOutputContentMedia:
+ tokens += estimateMediaTokens(result.MediaType, result.Text, len(result.Data))
+ }
+ return tokens
+}
+
+func estimateFilePartTokens(file fantasy.FilePart) int64 {
+ return estimateMediaTokens(file.MediaType, file.Filename, len(file.Data))
+}
+
+func estimateGeneratedFileTokens(file fantasy.FileContent) int64 {
+ return estimateMediaTokens(file.MediaType, "", len(file.Data))
+}
+
+func estimateMediaTokens(mediaType, text string, dataBytes int) int64 {
+ if dataBytes == 0 {
+ return approxTokenCount(mediaType) + approxTokenCount(text)
+ }
+ return approxTokenCount(fmt.Sprintf("%s %s %d bytes", mediaType, text, dataBytes))
+}
+
+func estimateSourceTokens(source fantasy.SourceContent) int64 {
+ return approxTokenCount(string(source.SourceType)) +
+ approxTokenCount(source.ID) +
+ approxTokenCount(source.URL) +
+ approxTokenCount(source.Title) +
+ approxTokenCount(source.MediaType) +
+ approxTokenCount(source.Filename)
+}
+
+func approxTokenCount(s string) int64 {
+ if s == "" {
+ return 0
+ }
+ return int64((len(s) + 3) / 4)
+}
@@ -0,0 +1,339 @@
+package agent
+
+import (
+ "errors"
+ "testing"
+
+ "charm.land/catwalk/pkg/catwalk"
+ "charm.land/fantasy"
+ "github.com/charmbracelet/crush/internal/message"
+ "github.com/charmbracelet/crush/internal/session"
+ "github.com/stretchr/testify/require"
+)
+
+func TestUsageIsZero(t *testing.T) {
+ t.Parallel()
+
+ require.True(t, usageIsZero(fantasy.Usage{}))
+ require.False(t, usageIsZero(fantasy.Usage{InputTokens: 1}))
+ require.False(t, usageIsZero(fantasy.Usage{OutputTokens: 1}))
+ require.False(t, usageIsZero(fantasy.Usage{TotalTokens: 1}))
+ require.False(t, usageIsZero(fantasy.Usage{ReasoningTokens: 1}))
+ require.False(t, usageIsZero(fantasy.Usage{CacheCreationTokens: 1}))
+ require.False(t, usageIsZero(fantasy.Usage{CacheReadTokens: 1}))
+}
+
+func TestFallbackStepUsageKeepsProviderUsage(t *testing.T) {
+ t.Parallel()
+
+ usage := fantasy.Usage{
+ InputTokens: 10,
+ OutputTokens: 5,
+ TotalTokens: 15,
+ }
+ step := fantasy.StepResult{
+ Response: fantasy.Response{Usage: usage},
+ }
+
+ fallbackUsage, estimated := fallbackStepUsage(nil, step)
+ require.False(t, estimated)
+ require.Equal(t, usage, fallbackUsage)
+}
+
+func TestFallbackStepUsageEstimatesPromptAndAssistantText(t *testing.T) {
+ t.Parallel()
+
+ messages := []fantasy.Message{
+ fantasy.NewUserMessage("please explain the implementation details"),
+ }
+ step := fantasy.StepResult{
+ Response: fantasy.Response{
+ Content: fantasy.ResponseContent{
+ fantasy.TextContent{Text: "the implementation stores state safely"},
+ },
+ },
+ }
+
+ usage, estimated := fallbackStepUsage(messages, step)
+ require.True(t, estimated)
+ require.Positive(t, usage.InputTokens)
+ require.Positive(t, usage.OutputTokens)
+ require.Equal(t, usage.InputTokens+usage.OutputTokens, usage.TotalTokens)
+}
+
+func TestFallbackStepUsageEstimatesReasoning(t *testing.T) {
+ t.Parallel()
+
+ messages := []fantasy.Message{
+ {
+ Role: fantasy.MessageRoleAssistant,
+ Content: []fantasy.MessagePart{
+ fantasy.ReasoningPart{Text: "first reason about the request"},
+ },
+ },
+ }
+ step := fantasy.StepResult{
+ Response: fantasy.Response{
+ Content: fantasy.ResponseContent{
+ fantasy.ReasoningContent{Text: "second reason about the answer"},
+ },
+ },
+ }
+
+ usage, estimated := fallbackStepUsage(messages, step)
+ require.True(t, estimated)
+ require.Positive(t, usage.InputTokens)
+ require.Positive(t, usage.OutputTokens)
+}
+
+func TestFallbackStepUsageEstimatesToolCalls(t *testing.T) {
+ t.Parallel()
+
+ step := fantasy.StepResult{
+ Response: fantasy.Response{
+ Content: fantasy.ResponseContent{
+ fantasy.ToolCallContent{
+ ToolCallID: "tool-call-1",
+ ToolName: "view",
+ Input: `{"file_path":"/tmp/example.go"}`,
+ },
+ },
+ },
+ }
+
+ usage, estimated := fallbackStepUsage(nil, step)
+ require.True(t, estimated)
+ require.Zero(t, usage.InputTokens)
+ require.Positive(t, usage.OutputTokens)
+ require.Equal(t, usage.OutputTokens, usage.TotalTokens)
+}
+
+func TestFallbackStepUsageEstimatesToolResults(t *testing.T) {
+ t.Parallel()
+
+ messages := []fantasy.Message{
+ {
+ Role: fantasy.MessageRoleTool,
+ Content: []fantasy.MessagePart{
+ fantasy.ToolResultPart{
+ ToolCallID: "tool-call-1",
+ Output: fantasy.ToolResultOutputContentText{
+ Text: "file contents returned by the tool",
+ },
+ },
+ fantasy.ToolResultPart{
+ ToolCallID: "tool-call-2",
+ Output: fantasy.ToolResultOutputContentError{
+ Error: errors.New("permission denied"),
+ },
+ },
+ fantasy.ToolResultPart{
+ ToolCallID: "tool-call-3",
+ Output: fantasy.ToolResultOutputContentMedia{
+ MediaType: "image/png",
+ Text: "screenshot",
+ Data: "abc123",
+ },
+ },
+ },
+ },
+ }
+
+ usage, estimated := fallbackStepUsage(messages, fantasy.StepResult{})
+ require.True(t, estimated)
+ require.Positive(t, usage.InputTokens)
+ require.Zero(t, usage.OutputTokens)
+ require.Equal(t, usage.InputTokens, usage.TotalTokens)
+}
+
+func TestFallbackStepUsageSkipsClientToolResultsAsOutput(t *testing.T) {
+ t.Parallel()
+
+ step := fantasy.StepResult{
+ Response: fantasy.Response{
+ Content: fantasy.ResponseContent{
+ fantasy.ToolResultContent{
+ ToolCallID: "tool-call-1",
+ ToolName: "bash",
+ Result: fantasy.ToolResultOutputContentText{
+ Text: "large client-executed payload that should not count as model output tokens",
+ },
+ },
+ },
+ },
+ }
+
+ usage, estimated := fallbackStepUsage(nil, step)
+ require.False(t, estimated)
+ require.Zero(t, usage.OutputTokens)
+}
+
+func TestFallbackStepUsageCountsProviderToolResultsAsOutput(t *testing.T) {
+ t.Parallel()
+
+ step := fantasy.StepResult{
+ Response: fantasy.Response{
+ Content: fantasy.ResponseContent{
+ fantasy.ToolResultContent{
+ ToolCallID: "tool-call-1",
+ ToolName: "web_search",
+ ProviderExecuted: true,
+ ClientMetadata: "provider metadata",
+ Result: fantasy.ToolResultOutputContentText{Text: "provider-executed result"},
+ },
+ },
+ },
+ }
+
+ usage, estimated := fallbackStepUsage(nil, step)
+ require.True(t, estimated)
+ require.Positive(t, usage.OutputTokens)
+ require.Equal(t, usage.OutputTokens, usage.TotalTokens)
+}
+
+func TestFallbackStepUsageReturnsZeroWithoutContent(t *testing.T) {
+ t.Parallel()
+
+ usage, estimated := fallbackStepUsage(nil, fantasy.StepResult{})
+ require.False(t, estimated)
+ require.True(t, usageIsZero(usage))
+}
+
+func TestUpdateSessionUsageSkipsEstimatedCost(t *testing.T) {
+ t.Parallel()
+
+ agent := &sessionAgent{}
+ currentSession := &session.Session{ID: "session-id", Cost: 1.25}
+ model := Model{CatwalkCfg: catwalk.Model{CostPer1MIn: 10, CostPer1MOut: 20}}
+ usage := fantasy.Usage{InputTokens: 1000, OutputTokens: 2000}
+
+ agent.updateSessionUsage(model, currentSession, usage, nil, true)
+
+ require.Equal(t, 1.25, currentSession.Cost)
+ require.Equal(t, int64(1000), currentSession.PromptTokens)
+ require.Equal(t, int64(2000), currentSession.CompletionTokens)
+ require.True(t, currentSession.EstimatedUsage)
+}
+
+func TestUpdateSessionUsageKeepsCountersForZeroUsage(t *testing.T) {
+ t.Parallel()
+
+ agent := &sessionAgent{}
+ currentSession := &session.Session{
+ ID: "session-id",
+ PromptTokens: 123,
+ CompletionTokens: 456,
+ Cost: 1.25,
+ }
+ model := Model{CatwalkCfg: catwalk.Model{CostPer1MIn: 10, CostPer1MOut: 20}}
+
+ agent.updateSessionUsage(model, currentSession, fantasy.Usage{}, nil, false)
+
+ require.Equal(t, 1.25, currentSession.Cost)
+ require.Equal(t, int64(123), currentSession.PromptTokens)
+ require.Equal(t, int64(456), currentSession.CompletionTokens)
+}
+
+func TestUpdateSessionUsagePreservesOmittedCountersForPartialUsage(t *testing.T) {
+ t.Parallel()
+
+ agent := &sessionAgent{}
+ currentSession := &session.Session{
+ ID: "session-id",
+ PromptTokens: 123,
+ CompletionTokens: 456,
+ }
+ model := Model{CatwalkCfg: catwalk.Model{CostPer1MIn: 10, CostPer1MOut: 20}}
+ usage := fantasy.Usage{InputTokens: 789}
+
+ agent.updateSessionUsage(model, currentSession, usage, nil, false)
+
+ require.Equal(t, int64(789), currentSession.PromptTokens)
+ require.Equal(t, int64(456), currentSession.CompletionTokens)
+}
+
+func TestUpdateSessionUsagePreservesCountersForTotalOnlyUsage(t *testing.T) {
+ t.Parallel()
+
+ agent := &sessionAgent{}
+ currentSession := &session.Session{
+ ID: "session-id",
+ PromptTokens: 123,
+ CompletionTokens: 456,
+ }
+ model := Model{CatwalkCfg: catwalk.Model{CostPer1MIn: 10, CostPer1MOut: 20}}
+ usage := fantasy.Usage{TotalTokens: 100}
+
+ agent.updateSessionUsage(model, currentSession, usage, nil, false)
+
+ require.Equal(t, int64(123), currentSession.PromptTokens)
+ require.Equal(t, int64(456), currentSession.CompletionTokens)
+}
+
+func TestUpdateSessionUsagePreservesPromptForOutputOnlyUsage(t *testing.T) {
+ t.Parallel()
+
+ agent := &sessionAgent{}
+ currentSession := &session.Session{
+ ID: "session-id",
+ PromptTokens: 123,
+ CompletionTokens: 456,
+ }
+ model := Model{CatwalkCfg: catwalk.Model{CostPer1MIn: 10, CostPer1MOut: 20}}
+ usage := fantasy.Usage{OutputTokens: 50}
+
+ agent.updateSessionUsage(model, currentSession, usage, nil, false)
+
+ require.Equal(t, int64(123), currentSession.PromptTokens)
+ require.Equal(t, int64(50), currentSession.CompletionTokens)
+}
+
+func TestUpdateSessionUsageKeepsCountersForEstimatedZeroUsage(t *testing.T) {
+ t.Parallel()
+
+ agent := &sessionAgent{}
+ currentSession := &session.Session{
+ ID: "session-id",
+ PromptTokens: 123,
+ CompletionTokens: 456,
+ Cost: 1.25,
+ }
+ model := Model{CatwalkCfg: catwalk.Model{CostPer1MIn: 10, CostPer1MOut: 20}}
+
+ agent.updateSessionUsage(model, currentSession, fantasy.Usage{}, nil, true)
+
+ require.Equal(t, 1.25, currentSession.Cost)
+ require.Equal(t, int64(123), currentSession.PromptTokens)
+ require.Equal(t, int64(456), currentSession.CompletionTokens)
+}
+
+func TestSummaryCompletionTokens(t *testing.T) {
+ t.Parallel()
+
+ summaryMessage := message.Message{
+ Parts: []message.ContentPart{
+ message.TextContent{Text: "summary text"},
+ message.ReasoningContent{Thinking: "reasoning text"},
+ },
+ }
+
+ require.Equal(t, int64(42), summaryCompletionTokens(fantasy.Usage{OutputTokens: 42}, summaryMessage))
+ require.Equal(t, approxTokenCount("summary text")+approxTokenCount("reasoning text"), summaryCompletionTokens(fantasy.Usage{}, summaryMessage))
+ require.Zero(t, summaryCompletionTokens(fantasy.Usage{}, message.Message{}))
+}
+
+func TestUpdateSessionUsageAddsProviderCost(t *testing.T) {
+ t.Parallel()
+
+ agent := &sessionAgent{}
+ currentSession := &session.Session{ID: "session-id", Cost: 1.25}
+ model := Model{CatwalkCfg: catwalk.Model{CostPer1MIn: 10, CostPer1MOut: 20}}
+ usage := fantasy.Usage{InputTokens: 1000, OutputTokens: 2000}
+
+ agent.updateSessionUsage(model, currentSession, usage, nil, false)
+
+ require.Equal(t, 1.3, currentSession.Cost)
+ require.Equal(t, int64(1000), currentSession.PromptTokens)
+ require.Equal(t, int64(2000), currentSession.CompletionTokens)
+ require.False(t, currentSession.EstimatedUsage)
+}
@@ -234,8 +234,12 @@ func (b *Backend) CreateWorkspace(args proto.Workspace) (*Workspace, proto.Works
// hosts multiple workspaces concurrently, so the manager is
// constructed WITHOUT WithGlobalMirror to prevent last-writer-wins
// cross-talk between workspaces.
- allSkills, activeSkills, skillStates := skills.DiscoverFromConfig(skillsDiscoveryConfig(cfg))
- skillsMgr := skills.NewManager(allSkills, activeSkills, skillStates)
+ discoveryCfg := skillsDiscoveryConfig(cfg)
+ allSkills, activeSkills, skillStates := skills.DiscoverFromConfig(discoveryCfg)
+ skillsMgr := skills.NewManager(allSkills, activeSkills, skillStates,
+ skills.WithResolvedPaths(discoveryCfg.ResolvePaths()),
+ skills.WithWorkingDir(discoveryCfg.WorkingDir),
+ )
appWorkspace, err := app.New(b.ctx, conn, cfg, skillsMgr)
if err != nil {
@@ -308,6 +312,7 @@ func skillsDiscoveryConfig(cfg *config.ConfigStore) skills.DiscoveryConfig {
return skills.DiscoveryConfig{
SkillsPaths: paths,
DisabledSkills: disabled,
+ WorkingDir: cfg.WorkingDir(),
Resolver: resolver,
}
}
@@ -12,6 +12,7 @@ import (
"github.com/charmbracelet/crush/internal/oauth"
"github.com/charmbracelet/crush/internal/proto"
"github.com/charmbracelet/crush/internal/pubsub"
+ "github.com/charmbracelet/crush/internal/skills"
)
// publishConfigChanged publishes a ConfigChanged event on the workspace's
@@ -162,6 +163,49 @@ func (b *Backend) InitializePrompt(workspaceID string) (string, error) {
return agent.InitializePrompt(ws.Cfg)
}
+// ReadSkill reads a skill's content by ID.
+func (b *Backend) ReadSkill(ctx context.Context, workspaceID, skillID string) ([]byte, proto.SkillReadResult, error) {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return nil, proto.SkillReadResult{}, err
+ }
+
+ mgr := ws.Skills
+ content, result, err := skills.ReadContent(
+ mgr.ActiveSkills(), mgr.ResolvedPaths(), mgr.WorkingDir(), skillID,
+ )
+ if err != nil {
+ return nil, proto.SkillReadResult{}, err
+ }
+ return content, proto.SkillReadResult{
+ Name: result.Name,
+ Description: result.Description,
+ Source: string(result.Source),
+ Builtin: result.Builtin,
+ }, nil
+}
+
+// ListSkills returns the effective visible skills for a workspace.
+func (b *Backend) ListSkills(workspaceID string) ([]proto.SkillInfo, error) {
+ ws, err := b.GetWorkspace(workspaceID)
+ if err != nil {
+ return nil, err
+ }
+ mgr := ws.Skills
+ entries := skills.Catalog(mgr.ActiveSkills(), mgr.ResolvedPaths(), mgr.WorkingDir())
+ result := make([]proto.SkillInfo, len(entries))
+ for i, entry := range entries {
+ result[i] = proto.SkillInfo{
+ ID: entry.ID,
+ Name: entry.Name,
+ Description: entry.Description,
+ Label: entry.Label,
+ Source: string(entry.Source),
+ }
+ }
+ return result, nil
+}
+
// EnableDockerMCP validates Docker MCP availability, stages the
// configuration, starts the MCP client, and persists the config.
func (b *Backend) EnableDockerMCP(ctx context.Context, workspaceID string) error {
@@ -216,6 +216,42 @@ func (c *Client) GetInitializePrompt(ctx context.Context, id string) (string, er
return result.Prompt, nil
}
+// ListSkills retrieves the visible skills for a workspace.
+func (c *Client) ListSkills(ctx context.Context, id string) ([]proto.SkillInfo, error) {
+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/skills", id), nil, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to list skills: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("failed to list skills: status code %d", rsp.StatusCode)
+ }
+ var skills []proto.SkillInfo
+ if err := json.NewDecoder(rsp.Body).Decode(&skills); err != nil {
+ return nil, fmt.Errorf("failed to decode skills: %w", err)
+ }
+ return skills, nil
+}
+
+// ReadSkill reads a skill's content by ID from the server.
+func (c *Client) ReadSkill(ctx context.Context, id, skillID string) (*proto.ReadSkillResponse, error) {
+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/skills/read", id), nil, jsonBody(proto.ReadSkillRequest{
+ SkillID: skillID,
+ }), http.Header{"Content-Type": []string{"application/json"}})
+ if err != nil {
+ return nil, fmt.Errorf("failed to read skill: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("failed to read skill: status code %d", rsp.StatusCode)
+ }
+ var result proto.ReadSkillResponse
+ if err := json.NewDecoder(rsp.Body).Decode(&result); err != nil {
+ return nil, fmt.Errorf("failed to decode skill response: %w", err)
+ }
+ return &result, nil
+}
+
// MCPResourceContents holds the contents of an MCP resource.
type MCPResourceContents struct {
URI string `json:"uri"`
@@ -291,8 +291,13 @@ func setupLocalWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error
// workspace per process, so WithGlobalMirror keeps the package
// globals (which the TUI reads via skills.GetLatestStates) in sync
// with the manager.
- allSkills, activeSkills, skillStates := skills.DiscoverFromConfig(localSkillsDiscoveryConfig(store))
- skillsMgr := skills.NewManager(allSkills, activeSkills, skillStates, skills.WithGlobalMirror())
+ discoveryCfg := localSkillsDiscoveryConfig(store)
+ allSkills, activeSkills, skillStates := skills.DiscoverFromConfig(discoveryCfg)
+ skillsMgr := skills.NewManager(allSkills, activeSkills, skillStates,
+ skills.WithGlobalMirror(),
+ skills.WithResolvedPaths(discoveryCfg.ResolvePaths()),
+ skills.WithWorkingDir(discoveryCfg.WorkingDir),
+ )
appInstance, err := app.New(ctx, conn, store, skillsMgr)
if err != nil {
@@ -326,6 +331,7 @@ func localSkillsDiscoveryConfig(store *config.ConfigStore) skills.DiscoveryConfi
return skills.DiscoveryConfig{
SkillsPaths: paths,
DisabledSkills: disabled,
+ WorkingDir: store.WorkingDir(),
Resolver: resolver,
}
}
@@ -298,22 +298,13 @@ func (c *Config) configureProviders(store *ConfigStore, env env.Env, resolver Va
prepared.BaseURL = endpoint
prepared.ExtraParams["apiVersion"] = env.Get("AZURE_OPENAI_API_VERSION")
case catwalk.InferenceProviderBedrock:
- if !hasAWSCredentials(env) {
+ if p.APIKey == "" && !hasAWSCredentials(env) {
if configExists {
slog.Warn("Skipping Bedrock provider due to missing AWS credentials")
c.Providers.Del(string(p.ID))
}
continue
}
- prepared.ExtraParams["region"] = env.Get("AWS_REGION")
- if prepared.ExtraParams["region"] == "" {
- prepared.ExtraParams["region"] = env.Get("AWS_DEFAULT_REGION")
- }
- for _, model := range p.Models {
- if !strings.HasPrefix(model.ID, "anthropic.") {
- return fmt.Errorf("bedrock provider only supports anthropic models for now, found: %s", model.ID)
- }
- }
case catwalk.InferenceProvider("hyper"):
if apiKey := env.Get("HYPER_API_KEY"); apiKey != "" {
prepared.APIKey = apiKey
@@ -470,29 +470,6 @@ func TestConfig_configureProvidersBedrockWithoutCredentials(t *testing.T) {
require.Equal(t, cfg.Providers.Len(), 0)
}
-func TestConfig_configureProvidersBedrockWithoutUnsupportedModel(t *testing.T) {
- knownProviders := []catwalk.Provider{
- {
- ID: catwalk.InferenceProviderBedrock,
- APIKey: "",
- APIEndpoint: "",
- Models: []catwalk.Model{{
- ID: "some-random-model",
- }},
- },
- }
-
- cfg := &Config{}
- cfg.setDefaults("/tmp", "")
- env := env.NewFromMap(map[string]string{
- "AWS_ACCESS_KEY_ID": "test-key-id",
- "AWS_SECRET_ACCESS_KEY": "test-secret-key",
- })
- resolver := NewShellVariableResolver(env)
- err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
- require.Error(t, err)
-}
-
func TestConfig_configureProvidersVertexAIWithCredentials(t *testing.T) {
knownProviders := []catwalk.Provider{
{
@@ -147,6 +147,9 @@ func Providers(cfg *Config) ([]catwalk.Provider, error) {
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
+ var hyperProvider catwalk.Provider
+ var hyperFound bool
+
wg.Go(func() {
if customProvidersOnly {
return
@@ -177,12 +180,17 @@ func Providers(cfg *Config) ([]catwalk.Provider, error) {
errs = append(errs, fmt.Errorf("Crush was unable to fetch updated information from Hyper: %w", err)) //nolint:staticcheck
return
}
- providers.Append(item)
+ hyperProvider = item
+ hyperFound = true
})
wg.Wait()
- providerList = slices.Collect(providers.Seq())
+ if hyperFound {
+ providerList = append([]catwalk.Provider{hyperProvider}, slices.Collect(providers.Seq())...)
+ } else {
+ providerList = slices.Collect(providers.Seq())
+ }
providerErr = errors.Join(errs...)
})
return providerList, providerErr
@@ -318,11 +318,16 @@ func TestConfigStaleness_RefreshClearsDirtyState(t *testing.T) {
// ReloadFromDisk updates store state BEFORE running model/agent setup,
// so the new config values are used rather than stale pre-reload values.
func TestReloadFromDisk_UsesNewConfigValues(t *testing.T) {
- t.Parallel()
-
dir := t.TempDir()
configPath := filepath.Join(dir, "crush.json")
+ // Isolate from the host's global config so only test-provided
+ // providers are visible.
+ t.Setenv("CRUSH_GLOBAL_CONFIG", dir)
+ t.Setenv("CRUSH_GLOBAL_DATA", dir)
+ resetProviderState()
+ t.Cleanup(resetProviderState)
+
// Create initial config with one model preference
initialConfig := `{
"models": {
@@ -19,6 +19,7 @@ var (
"foreign_keys": "ON",
"journal_mode": "WAL",
"page_size": "4096",
+ "temp_store": "MEMORY",
"cache_size": "-8000",
"synchronous": "NORMAL",
"secure_delete": "ON",
@@ -12,8 +12,9 @@ type Attachment struct {
Content []byte
}
-func (a Attachment) IsText() bool { return strings.HasPrefix(a.MimeType, "text/") }
-func (a Attachment) IsImage() bool { return strings.HasPrefix(a.MimeType, "image/") }
+func (a Attachment) IsText() bool { return strings.HasPrefix(a.MimeType, "text/") }
+func (a Attachment) IsImage() bool { return strings.HasPrefix(a.MimeType, "image/") }
+func (a Attachment) IsMarkdown() bool { return a.MimeType == "text/markdown" }
// ContainsTextAttachment returns true if any of the attachments is a text attachment.
func ContainsTextAttachment(attachments []Attachment) bool {
@@ -46,6 +46,35 @@ type CurrentSession struct {
SessionID string `json:"session_id"`
}
+// SkillInfo describes a visible skill exposed to a frontend.
+type SkillInfo struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Label string `json:"label"`
+ Source string `json:"source"`
+}
+
+// ReadSkillRequest is the request body for reading a skill's content.
+type ReadSkillRequest struct {
+ SkillID string `json:"skill_id"`
+}
+
+// ReadSkillResponse is the response for reading a skill's content.
+type ReadSkillResponse struct {
+ Content []byte `json:"content"`
+ Result SkillReadResult `json:"result"`
+}
+
+// SkillReadResult holds metadata about a skill returned alongside its
+// content.
+type SkillReadResult struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Source string `json:"source"`
+ Builtin bool `json:"builtin"`
+}
+
// AgentInfo represents information about the agent.
type AgentInfo struct {
IsBusy bool `json:"is_busy"`
@@ -266,6 +266,57 @@ func (c *controllerV1) handleGetWorkspaceProjectInitPrompt(w http.ResponseWriter
jsonEncode(w, proto.ProjectInitPromptResponse{Prompt: prompt})
}
+// handleGetWorkspaceSkills returns the effective visible skills for a workspace.
+//
+// @Summary List visible skills
+// @Tags skills
+// @Produce json
+// @Param id path string true "Workspace ID"
+// @Success 200 {array} proto.SkillInfo
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/skills [get]
+func (c *controllerV1) handleGetWorkspaceSkills(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ skills, err := c.backend.ListSkills(id)
+ if err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ jsonEncode(w, skills)
+}
+
+// handlePostWorkspaceSkillRead reads a skill's content by ID.
+//
+// @Summary Read skill content
+// @Tags skills
+// @Accept json
+// @Produce json
+// @Param id path string true "Workspace ID"
+// @Param request body proto.ReadSkillRequest true "Read skill request"
+// @Success 200 {object} proto.ReadSkillResponse
+// @Failure 400 {object} proto.Error
+// @Failure 404 {object} proto.Error
+// @Failure 500 {object} proto.Error
+// @Router /workspaces/{id}/skills/read [post]
+func (c *controllerV1) handlePostWorkspaceSkillRead(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+
+ var req proto.ReadSkillRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ c.server.logError(r, "Failed to decode request", "error", err)
+ jsonError(w, http.StatusBadRequest, "failed to decode request")
+ return
+ }
+
+ content, result, err := c.backend.ReadSkill(r.Context(), id, req.SkillID)
+ if err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+ jsonEncode(w, proto.ReadSkillResponse{Content: content, Result: result})
+}
+
// handlePostWorkspaceMCPEnableDocker enables the Docker MCP server.
//
// @Summary Enable Docker MCP
@@ -169,6 +169,8 @@ func (s *Server) installHandler() {
mux.HandleFunc("GET /v1/workspaces/{id}/project/needs-init", c.handleGetWorkspaceProjectNeedsInit)
mux.HandleFunc("POST /v1/workspaces/{id}/project/init", c.handlePostWorkspaceProjectInit)
mux.HandleFunc("GET /v1/workspaces/{id}/project/init-prompt", c.handleGetWorkspaceProjectInitPrompt)
+ mux.HandleFunc("GET /v1/workspaces/{id}/skills", c.handleGetWorkspaceSkills)
+ mux.HandleFunc("POST /v1/workspaces/{id}/skills/read", c.handlePostWorkspaceSkillRead)
mux.HandleFunc("POST /v1/workspaces/{id}/mcp/refresh-tools", c.handlePostWorkspaceMCPRefreshTools)
mux.HandleFunc("POST /v1/workspaces/{id}/mcp/read-resource", c.handlePostWorkspaceMCPReadResource)
mux.HandleFunc("POST /v1/workspaces/{id}/mcp/get-prompt", c.handlePostWorkspaceMCPGetPrompt)
@@ -7,6 +7,7 @@ import (
"fmt"
"log/slog"
"strings"
+ "sync"
"github.com/charmbracelet/crush/internal/db"
"github.com/charmbracelet/crush/internal/event"
@@ -53,6 +54,7 @@ type Session struct {
MessageCount int64
PromptTokens int64
CompletionTokens int64
+ EstimatedUsage bool
SummaryMessageID string
Cost float64
Todos []Todo
@@ -83,6 +85,12 @@ type service struct {
*pubsub.Broker[Session]
db *sql.DB
q *db.Queries
+
+ // Estimated usage stays in memory so fetch-modify-save paths (e.g.,
+ // updating todos or parent-session cost) do not rebuild a session from
+ // SQLite and incorrectly clear the UI "~" marker.
+ estimatedUsageMu sync.RWMutex
+ estimatedUsage map[string]bool
}
func (s *service) Create(ctx context.Context, title string) (Session, error) {
@@ -154,6 +162,7 @@ func (s *service) Delete(ctx context.Context, id string) error {
}
session := s.fromDBItem(dbSession)
+ s.clearEstimatedUsageState(dbSession.ID)
s.Publish(pubsub.DeletedEvent, session)
event.SessionDeleted()
return nil
@@ -164,7 +173,9 @@ func (s *service) Get(ctx context.Context, id string) (Session, error) {
if err != nil {
return Session{}, err
}
- return s.fromDBItem(dbSession), nil
+ session := s.fromDBItem(dbSession)
+ s.applyEstimatedUsageState(&session)
+ return session, nil
}
func (s *service) GetLast(ctx context.Context) (Session, error) {
@@ -172,7 +183,9 @@ func (s *service) GetLast(ctx context.Context) (Session, error) {
if err != nil {
return Session{}, err
}
- return s.fromDBItem(dbSession), nil
+ session := s.fromDBItem(dbSession)
+ s.applyEstimatedUsageState(&session)
+ return session, nil
}
func (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) {
if err != nil {
return Session{}, err
}
+ estimatedUsage := session.EstimatedUsage
+ s.setEstimatedUsageState(session.ID, estimatedUsage)
session = s.fromDBItem(dbSession)
+ session.EstimatedUsage = estimatedUsage
s.Publish(pubsub.UpdatedEvent, session)
return session, nil
}
@@ -233,11 +249,34 @@ func (s *service) List(ctx context.Context) ([]Session, error) {
sessions := make([]Session, len(dbSessions))
for i, dbSession := range dbSessions {
sessions[i] = s.fromDBItem(dbSession)
+ s.applyEstimatedUsageState(&sessions[i])
}
return sessions, nil
}
-func (s service) fromDBItem(item db.Session) Session {
+func (s *service) applyEstimatedUsageState(session *Session) {
+ s.estimatedUsageMu.RLock()
+ session.EstimatedUsage = s.estimatedUsage[session.ID]
+ s.estimatedUsageMu.RUnlock()
+}
+
+func (s *service) setEstimatedUsageState(sessionID string, estimatedUsage bool) {
+ s.estimatedUsageMu.Lock()
+ defer s.estimatedUsageMu.Unlock()
+ if estimatedUsage {
+ s.estimatedUsage[sessionID] = true
+ return
+ }
+ delete(s.estimatedUsage, sessionID)
+}
+
+func (s *service) clearEstimatedUsageState(sessionID string) {
+ s.estimatedUsageMu.Lock()
+ delete(s.estimatedUsage, sessionID)
+ s.estimatedUsageMu.Unlock()
+}
+
+func (s *service) fromDBItem(item db.Session) Session {
todos, err := unmarshalTodos(item.Todos.String)
if err != nil {
slog.Error("Failed to unmarshal todos", "session_id", item.ID, "error", err)
@@ -282,9 +321,10 @@ func unmarshalTodos(data string) ([]Todo, error) {
func NewService(q *db.Queries, conn *sql.DB) Service {
broker := pubsub.NewBroker[Session]()
return &service{
- Broker: broker,
- db: conn,
- q: q,
+ Broker: broker,
+ db: conn,
+ q: q,
+ estimatedUsage: make(map[string]bool),
}
}
@@ -0,0 +1,81 @@
+package session
+
+import (
+ "testing"
+
+ "github.com/charmbracelet/crush/internal/db"
+ "github.com/stretchr/testify/require"
+)
+
+func TestEstimatedUsageStateSurvivesFetchModifySave(t *testing.T) {
+ dataDir := t.TempDir()
+ t.Cleanup(func() {
+ require.NoError(t, db.Release(dataDir))
+ db.ResetPool()
+ })
+
+ conn, err := db.Connect(t.Context(), dataDir)
+ require.NoError(t, err)
+
+ sessions := NewService(db.New(conn), conn)
+
+ created, err := sessions.Create(t.Context(), "test")
+ require.NoError(t, err)
+ created.PromptTokens = 100
+ created.CompletionTokens = 50
+ created.EstimatedUsage = true
+
+ saved, err := sessions.Save(t.Context(), created)
+ require.NoError(t, err)
+ require.True(t, saved.EstimatedUsage)
+
+ fetched, err := sessions.Get(t.Context(), created.ID)
+ require.NoError(t, err)
+ require.True(t, fetched.EstimatedUsage)
+
+ fetched.Todos = []Todo{{
+ Content: "Check estimate state",
+ Status: TodoStatusInProgress,
+ ActiveForm: "Checking estimate state",
+ }}
+
+ updated, err := sessions.Save(t.Context(), fetched)
+ require.NoError(t, err)
+ require.True(t, updated.EstimatedUsage)
+
+ refetched, err := sessions.Get(t.Context(), created.ID)
+ require.NoError(t, err)
+ require.True(t, refetched.EstimatedUsage)
+}
+
+func TestEstimatedUsageStateCanBeClearedByExplicitSave(t *testing.T) {
+ dataDir := t.TempDir()
+ t.Cleanup(func() {
+ require.NoError(t, db.Release(dataDir))
+ db.ResetPool()
+ })
+
+ conn, err := db.Connect(t.Context(), dataDir)
+ require.NoError(t, err)
+
+ sessions := NewService(db.New(conn), conn)
+
+ created, err := sessions.Create(t.Context(), "test")
+ require.NoError(t, err)
+ created.PromptTokens = 100
+ created.CompletionTokens = 50
+ created.EstimatedUsage = true
+
+ saved, err := sessions.Save(t.Context(), created)
+ require.NoError(t, err)
+ require.True(t, saved.EstimatedUsage)
+
+ saved.EstimatedUsage = false
+ updated, err := sessions.Save(t.Context(), saved)
+ require.NoError(t, err)
+ require.False(t, updated.EstimatedUsage)
+
+ refetched, err := sessions.Get(t.Context(), created.ID)
+ require.NoError(t, err)
+ require.False(t, refetched.EstimatedUsage)
+}
@@ -128,12 +128,8 @@ func TestBackgroundShell_IsDone(t *testing.T) {
t.Fatalf("failed to start background shell: %v", err)
}
- // Wait a bit for the command to complete
- time.Sleep(100 * time.Millisecond)
-
- if !bgShell.IsDone() {
- t.Error("expected shell to be done")
- }
+ // Wait for the command to complete (Windows is slower to spin up).
+ require.Eventually(t, bgShell.IsDone, 5*time.Second, 50*time.Millisecond, "expected shell to be done")
// Clean up
manager.Kill(bgShell.ID)
@@ -0,0 +1,152 @@
+package skills
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// SourceType describes where a visible skill comes from.
+type SourceType string
+
+const (
+ SourceSystem SourceType = "system"
+ SourceUser SourceType = "user"
+ SourceProject SourceType = "project"
+)
+
+// CatalogEntry describes an effective visible skill for frontend display.
+type CatalogEntry struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Label string `json:"label"`
+ Source SourceType `json:"source"`
+}
+
+// SkillReadResult holds metadata about a skill returned alongside its
+// content.
+type SkillReadResult struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Source SourceType `json:"source"`
+ Builtin bool `json:"builtin"`
+}
+
+// ErrSkillNotFound is returned when a skill ID is not part of the
+// effective visible skill set.
+var ErrSkillNotFound = errors.New("skill not found")
+
+// Catalog builds a slice of CatalogEntry values from pre-discovered
+// skills. The skillPaths and workingDir parameters are used only for
+// labelling (system / user / project); pass nil/empty when labels are
+// not needed.
+func Catalog(active []*Skill, skillPaths []string, workingDir string) []CatalogEntry {
+ entries := make([]CatalogEntry, 0, len(active))
+ for _, skill := range active {
+ label, source := skillLabel(skillPaths, workingDir, skill)
+ entries = append(entries, CatalogEntry{
+ ID: skill.SkillFilePath,
+ Name: skill.Name,
+ Description: skill.Description,
+ Label: label,
+ Source: source,
+ })
+ }
+ return entries
+}
+
+// FindEffective returns the named skill from the given active skill
+// set.
+func FindEffective(active []*Skill, skillID string) (*Skill, error) {
+ for _, skill := range active {
+ if skill.SkillFilePath == skillID {
+ return skill, nil
+ }
+ }
+ return nil, fmt.Errorf("%w: %s", ErrSkillNotFound, skillID)
+}
+
+// ReadContent reads the contents of a visible skill by ID and returns
+// the raw bytes along with metadata about the skill.
+func ReadContent(active []*Skill, skillPaths []string, workingDir string, skillID string) ([]byte, SkillReadResult, error) {
+ skill, err := FindEffective(active, skillID)
+ if err != nil {
+ return nil, SkillReadResult{}, err
+ }
+
+ _, source := skillLabel(skillPaths, workingDir, skill)
+ result := SkillReadResult{
+ Name: skill.Name,
+ Description: skill.Description,
+ Source: source,
+ Builtin: skill.Builtin,
+ }
+
+ if skill.Builtin {
+ embeddedPath := "builtin/" + strings.TrimPrefix(skill.SkillFilePath, BuiltinPrefix)
+ content, err := BuiltinFS().ReadFile(embeddedPath)
+ if err != nil {
+ return nil, SkillReadResult{}, fmt.Errorf("read builtin skill %q: %w", skillID, err)
+ }
+ return content, result, nil
+ }
+
+ content, err := os.ReadFile(skill.SkillFilePath)
+ if err != nil {
+ return nil, SkillReadResult{}, fmt.Errorf("read skill %q: %w", skillID, err)
+ }
+ return content, result, nil
+}
+
+func skillLabel(skillPaths []string, workingDir string, skill *Skill) (string, SourceType) {
+ if skill.Builtin {
+ return string(SourceSystem) + ":" + skill.Name, SourceSystem
+ }
+
+ cleanFile := filepath.Clean(skill.SkillFilePath)
+ for _, base := range skillPaths {
+ cleanBase := filepath.Clean(base)
+ rel, err := filepath.Rel(cleanBase, cleanFile)
+ if err != nil || escapesParent(rel) {
+ continue
+ }
+
+ source := SourceUser
+ prefix := string(SourceUser) + ":"
+ if isProjectSkillPath(cleanBase, workingDir) {
+ source = SourceProject
+ prefix = string(SourceProject) + ":"
+ }
+ return prefix + filepath.Base(filepath.Dir(cleanFile)), source
+ }
+
+ return string(SourceUser) + ":" + filepath.Base(filepath.Dir(cleanFile)), SourceUser
+}
+
+func escapesParent(rel string) bool {
+ return rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator))
+}
+
+func isProjectSkillPath(basePath, workingDir string) bool {
+ if workingDir == "" {
+ return false
+ }
+ absBase, err := filepath.Abs(basePath)
+ if err != nil {
+ return false
+ }
+ absWD, err := filepath.Abs(workingDir)
+ if err != nil {
+ return false
+ }
+ cleanBase := filepath.Clean(absBase)
+ cleanWD := filepath.Clean(absWD)
+ rel, err := filepath.Rel(cleanWD, cleanBase)
+ if err != nil {
+ return false
+ }
+ return !escapesParent(rel)
+}
@@ -27,6 +27,12 @@ type Manager struct {
activeSkills []*Skill
states []*SkillState
+ // resolvedPaths are the expanded SkillsPaths used during discovery.
+ // Stored so Catalog/ReadContent can label skills without
+ // re-resolving.
+ resolvedPaths []string
+ workingDir string
+
broker *pubsub.Broker[Event]
globalMirror bool
}
@@ -44,6 +50,23 @@ func WithGlobalMirror() ManagerOption {
}
}
+// WithResolvedPaths stores the expanded skills directory paths that
+// were used during discovery. Catalog and ReadContent use these for
+// source labelling.
+func WithResolvedPaths(paths []string) ManagerOption {
+ return func(m *Manager) {
+ m.resolvedPaths = paths
+ }
+}
+
+// WithWorkingDir stores the workspace working directory. Catalog and
+// ReadContent use it to distinguish project skills from user skills.
+func WithWorkingDir(dir string) ManagerOption {
+ return func(m *Manager) {
+ m.workingDir = dir
+ }
+}
+
// NewManager constructs a workspace-scoped Manager with the given
// pre-computed discovery results. The slices are stored as-is; callers
// should not mutate them afterwards.
@@ -78,6 +101,18 @@ func (m *Manager) ActiveSkills() []*Skill {
return m.activeSkills
}
+// ResolvedPaths returns the expanded skills directory paths stored at
+// construction time.
+func (m *Manager) ResolvedPaths() []string {
+ return m.resolvedPaths
+}
+
+// WorkingDir returns the workspace working directory stored at
+// construction time.
+func (m *Manager) WorkingDir() string {
+ return m.workingDir
+}
+
// States returns a clone of the latest discovery state snapshot.
func (m *Manager) States() []*SkillState {
m.mu.RLock()
@@ -140,18 +175,8 @@ func DiscoverFromConfig(cfg DiscoveryConfig) (allSkills, activeSkills []*Skill,
discovered := append([]*Skill(nil), builtin...)
var userStates []*SkillState
- var userPaths []string
- if len(cfg.SkillsPaths) > 0 {
- userPaths = make([]string, 0, len(cfg.SkillsPaths))
- for _, pth := range cfg.SkillsPaths {
- expanded := home.Long(pth)
- if strings.HasPrefix(expanded, "$") && cfg.Resolver != nil {
- if resolved, err := cfg.Resolver(expanded); err == nil {
- expanded = resolved
- }
- }
- userPaths = append(userPaths, expanded)
- }
+ userPaths := cfg.ResolvePaths()
+ if len(userPaths) > 0 {
var userSkills []*Skill
userSkills, userStates = DiscoverWithStates(userPaths)
discovered = append(discovered, userSkills...)
@@ -175,6 +200,28 @@ func DiscoverFromConfig(cfg DiscoveryConfig) (allSkills, activeSkills []*Skill,
type DiscoveryConfig struct {
SkillsPaths []string
DisabledSkills []string
+ WorkingDir string
// Resolver expands $VAR-style references in paths. May be nil.
Resolver func(string) (string, error)
}
+
+// ResolvePaths expands home-directory and $VAR references in
+// SkillsPaths. This is the canonical path-resolution logic used by
+// DiscoverFromConfig; callers that need the resolved list (e.g. for
+// Catalog labels) can call this directly.
+func (c DiscoveryConfig) ResolvePaths() []string {
+ if len(c.SkillsPaths) == 0 {
+ return nil
+ }
+ out := make([]string, 0, len(c.SkillsPaths))
+ for _, pth := range c.SkillsPaths {
+ expanded := home.Long(pth)
+ if strings.HasPrefix(expanded, "$") && c.Resolver != nil {
+ if resolved, err := c.Resolver(expanded); err == nil {
+ expanded = resolved
+ }
+ }
+ out = append(out, expanded)
+ }
+ return out
+}
@@ -82,25 +82,27 @@ func (m *Attachments) Render(width int) string {
// styles in place.
func (m *Attachments) Renderer() *Renderer { return m.renderer }
-func NewRenderer(normalStyle, deletingStyle, imageStyle, textStyle lipgloss.Style) *Renderer {
+func NewRenderer(normalStyle, deletingStyle, imageStyle, textStyle, skillStyle lipgloss.Style) *Renderer {
return &Renderer{
normalStyle: normalStyle,
textStyle: textStyle,
imageStyle: imageStyle,
+ skillStyle: skillStyle,
deletingStyle: deletingStyle,
}
}
// SetStyles updates the renderer styles in place.
-func (r *Renderer) SetStyles(normalStyle, deletingStyle, imageStyle, textStyle lipgloss.Style) {
+func (r *Renderer) SetStyles(normalStyle, deletingStyle, imageStyle, textStyle, skillStyle lipgloss.Style) {
r.normalStyle = normalStyle
r.textStyle = textStyle
r.imageStyle = imageStyle
+ r.skillStyle = skillStyle
r.deletingStyle = deletingStyle
}
type Renderer struct {
- normalStyle, textStyle, imageStyle, deletingStyle lipgloss.Style
+ normalStyle, textStyle, imageStyle, skillStyle, deletingStyle lipgloss.Style
}
func (r *Renderer) Render(attachments []message.Attachment, deleting bool, width int) string {
@@ -143,5 +145,8 @@ func (r *Renderer) icon(a message.Attachment) lipgloss.Style {
if a.IsImage() {
return r.imageStyle
}
+ if a.IsMarkdown() {
+ return r.skillStyle
+ }
return r.textStyle
}
@@ -372,6 +372,7 @@ func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults m
sty.Attachments.Deleting,
sty.Attachments.Image,
sty.Attachments.Text,
+ sty.Attachments.Skill,
)
return []MessageItem{NewUserMessageItem(sty, msg, r)}
case message.Assistant:
@@ -116,6 +116,7 @@ func TestUserMessageItemRender_PrefixCacheFocusBlur(t *testing.T) {
sty.Attachments.Deleting,
sty.Attachments.Image,
sty.Attachments.Text,
+ sty.Attachments.Skill,
)
item := NewUserMessageItem(&sty, msg, r).(*UserMessageItem)
@@ -74,8 +74,12 @@ func (m *UserMessageItem) RawRender(width int) string {
}
renderer := common.MarkdownRenderer(m.sty, cappedWidth)
+ mu := common.LockMarkdownRenderer(renderer)
+ mu.Lock()
result, err := renderer.Render(msgContent)
+ mu.Unlock()
+
if err != nil {
content = msgContent
} else {
@@ -102,7 +106,12 @@ func (m *UserMessageItem) renderSkillInvocation(content string, width int) strin
if err := xml.Unmarshal([]byte(content), &skill); err != nil {
// If parsing fails, just render as markdown
renderer := common.MarkdownRenderer(m.sty, width)
+ mu := common.LockMarkdownRenderer(renderer)
+
+ mu.Lock()
result, err := renderer.Render(content)
+ mu.Unlock()
+
if err != nil {
return content
}
@@ -83,6 +83,7 @@ func TestUserMessageItem_MutatorsBumpVersion(t *testing.T) {
sty.Attachments.Deleting,
sty.Attachments.Image,
sty.Attachments.Text,
+ sty.Attachments.Skill,
)
msg := &message.Message{
ID: "u-mut",
@@ -253,6 +254,7 @@ func TestUserMessageItem_FinishedAlwaysTrue(t *testing.T) {
sty.Attachments.Deleting,
sty.Attachments.Image,
sty.Attachments.Text,
+ sty.Attachments.Skill,
)
msg := &message.Message{
ID: "u-fin",
@@ -33,9 +33,10 @@ func FormatReasoningEffort(effort string) string {
// ModelContextInfo contains token usage and cost information for a model.
type ModelContextInfo struct {
- ContextUsed int64
- ModelContext int64
- Cost float64
+ ContextUsed int64
+ ModelContext int64
+ Cost float64
+ EstimatedUsage bool
}
// ModelInfo renders model information including name, provider, reasoning
@@ -74,7 +75,7 @@ func ModelInfo(t *styles.Styles, modelName, providerName, reasoningInfo string,
}
if context != nil {
- formattedInfo := formatTokensAndCost(t, context.ContextUsed, context.ModelContext, context.Cost)
+ formattedInfo := formatTokensAndCost(t, context.ContextUsed, context.ModelContext, context.Cost, context.EstimatedUsage)
parts = append(parts, lipgloss.NewStyle().PaddingLeft(2).Render(formattedInfo))
}
@@ -92,7 +93,7 @@ func ModelInfo(t *styles.Styles, modelName, providerName, reasoningInfo string,
// formatTokensAndCost formats token usage and cost with appropriate units
// (K/M) and percentage of context window.
-func formatTokensAndCost(t *styles.Styles, tokens, contextWindow int64, cost float64) string {
+func formatTokensAndCost(t *styles.Styles, tokens, contextWindow int64, cost float64, estimated bool) string {
var formattedTokens string
switch {
case tokens >= 1_000_000:
@@ -110,12 +111,19 @@ func formatTokensAndCost(t *styles.Styles, tokens, contextWindow int64, cost flo
formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
}
- percentage := (float64(tokens) / float64(contextWindow)) * 100
+ var percentage float64
+ if contextWindow > 0 {
+ percentage = (float64(tokens) / float64(contextWindow)) * 100
+ }
formattedCost := t.ModelInfo.Cost.Render(fmt.Sprintf("$%.2f", cost))
formattedTokens = t.ModelInfo.TokenCount.Render(fmt.Sprintf("(%s)", formattedTokens))
- formattedPercentage := t.ModelInfo.TokenPercentage.Render(fmt.Sprintf("%d%%", int(percentage)))
+ percentageText := fmt.Sprintf("%d%%", int(percentage))
+ if estimated {
+ percentageText = "~" + percentageText
+ }
+ formattedPercentage := t.ModelInfo.TokenPercentage.Render(percentageText)
formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens)
if percentage > 80 {
formattedTokens = fmt.Sprintf("%s %s", styles.LSPWarningIcon, formattedTokens)
@@ -0,0 +1,35 @@
+package common
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/charmbracelet/crush/internal/ui/styles"
+ "github.com/charmbracelet/x/ansi"
+ "github.com/stretchr/testify/require"
+)
+
+func TestFormatTokensAndCostPrefixesEstimatedUsage(t *testing.T) {
+ t.Parallel()
+
+ sty := styles.CharmtonePantera()
+
+ rendered := formatTokensAndCost(&sty, 120, 1000, 0, true)
+ actual := ansi.Strip(rendered)
+
+ require.Contains(t, actual, "~12%")
+ require.Contains(t, actual, "(120)")
+ require.Contains(t, actual, "$0.00")
+ require.True(t, strings.Contains(rendered, sty.ModelInfo.TokenPercentage.Render("~12%")))
+}
+
+func TestFormatTokensAndCostOmitsEstimatedPrefix(t *testing.T) {
+ t.Parallel()
+
+ sty := styles.CharmtonePantera()
+
+ actual := ansi.Strip(formatTokensAndCost(&sty, 120, 1000, 0, false))
+
+ require.Contains(t, actual, "12%")
+ require.NotContains(t, actual, "~12%")
+}
@@ -23,7 +23,7 @@ func Scrollbar(s *styles.Styles, height, contentSize, viewportSize, offset int)
}
// Calculate where the thumb starts.
- trackSpace := height - thumbSize
+ trackSpace := height - thumbSize + 1
thumbPos := 0
if trackSpace > 0 && maxOffset > 0 {
thumbPos = min(trackSpace, offset*trackSpace/maxOffset)
@@ -74,6 +74,12 @@ type (
Args map[string]string // Actual argument values
Skill *skills.Skill // Set when this is a skill command
}
+ // ActionAttachSkill is sent when a skill is selected from the commands
+ // dialog to be attached to the conversation as a markdown attachment.
+ ActionAttachSkill struct {
+ ID string
+ Name string
+ }
// ActionRunMCPPrompt is a message to run a custom command.
ActionRunMCPPrompt struct {
Title string
@@ -390,12 +390,21 @@ func (c *Commands) setCommandItems(commandType CommandType) {
}
case UserCommands:
for _, cmd := range c.customCommands {
- action := ActionRunCustomCommand{
- Content: cmd.Content,
- Arguments: cmd.Arguments,
- Skill: cmd.Skill,
+ var action Action
+ if cmd.Skill != nil {
+ action = ActionAttachSkill{ID: cmd.Skill.SkillFilePath, Name: cmd.Skill.Name}
+ } else {
+ action = ActionRunCustomCommand{
+ Content: cmd.Content,
+ Arguments: cmd.Arguments,
+ Skill: cmd.Skill,
+ }
+ }
+ item := NewCommandItem(c.com.Styles, "custom_"+cmd.ID, cmd.Name, "", action)
+ if cmd.Skill != nil {
+ item = item.WithDescription(cmd.Skill.Description)
}
- commandItems = append(commandItems, NewCommandItem(c.com.Styles, "custom_"+cmd.ID, cmd.Name, "", action))
+ commandItems = append(commandItems, item)
}
case MCPPrompts:
for _, cmd := range c.mcpPrompts {
@@ -513,7 +522,7 @@ func (c *Commands) defaultCommands() []*CommandItem {
commands = append(
commands,
- NewCommandItem(c.com.Styles, "toggle_yolo", "Toggle Yolo Mode", "", ActionToggleYoloMode{}),
+ NewCommandItem(c.com.Styles, "toggle_yolo", "Toggle Yolo Mode", "ctrl+y", ActionToggleYoloMode{}),
NewCommandItem(c.com.Styles, "toggle_help", "Toggle Help", "ctrl+g", ActionToggleHelp{}),
NewCommandItem(c.com.Styles, "init", "Initialize Project", "", ActionInitializeProject{}),
)
@@ -3,23 +3,26 @@ package dialog
import (
"strings"
+ "charm.land/lipgloss/v2"
"github.com/charmbracelet/crush/internal/ui/list"
"github.com/charmbracelet/crush/internal/ui/styles"
+ "github.com/charmbracelet/x/ansi"
"github.com/sahilm/fuzzy"
)
// CommandItem wraps a uicmd.Command to implement the ListItem interface.
type CommandItem struct {
*list.Versioned
- id string
- title string
- shortcut string
- action Action
- aliases []string
- t *styles.Styles
- m fuzzy.Match
- cache map[int]string
- focused bool
+ id string
+ title string
+ shortcut string
+ description string
+ action Action
+ aliases []string
+ t *styles.Styles
+ m fuzzy.Match
+ cache map[int]string
+ focused bool
}
var _ ListItem = &CommandItem{Versioned: list.NewVersioned()}
@@ -48,12 +51,23 @@ func (c *CommandItem) WithAliases(aliases ...string) *CommandItem {
return c
}
+// WithDescription returns the CommandItem with a description displayed below
+// the title.
+func (c *CommandItem) WithDescription(desc string) *CommandItem {
+ c.description = desc
+ return c
+}
+
// Filter implements ListItem.
func (c *CommandItem) Filter() string {
- if len(c.aliases) == 0 {
- return c.title
+ base := c.title
+ if len(c.aliases) > 0 {
+ base = c.title + " " + strings.Join(c.aliases, " ")
}
- return c.title + " " + strings.Join(c.aliases, " ")
+ if c.description != "" {
+ base = base + " " + c.description
+ }
+ return base
}
// ID implements ListItem.
@@ -103,5 +117,20 @@ func (c *CommandItem) Render(width int) string {
InfoTextBlurred: c.t.Dialog.ListItem.InfoBlurred,
InfoTextFocused: c.t.Dialog.ListItem.InfoFocused,
}
- return renderItem(styles, c.title, c.shortcut, c.focused, width, c.cache, &c.m)
+ rendered := renderItem(styles, c.title, c.shortcut, c.focused, width, c.cache, &c.m)
+ if c.description != "" {
+ descStyle := c.t.Dialog.SecondaryText
+ if c.focused {
+ descStyle = c.t.Dialog.SelectedItem
+ }
+ contentWidth := max(0, width-descStyle.GetHorizontalFrameSize()+1)
+ description := ansi.Truncate(strings.TrimSpace(c.description), contentWidth, "...")
+ descVisWidth := lipgloss.Width(description)
+ gap := strings.Repeat(" ", max(0, contentWidth-descVisWidth))
+ if description == "" {
+ description = " "
+ }
+ rendered = lipgloss.JoinVertical(lipgloss.Left, rendered, descStyle.Render(description+gap))
+ }
+ return rendered
}
@@ -10,6 +10,7 @@ import (
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/catwalk/pkg/catwalk"
+ "charm.land/lipgloss/v2"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/ui/common"
"github.com/charmbracelet/crush/internal/ui/util"
@@ -265,9 +266,14 @@ func (m *Models) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
t.Dialog.View.GetVerticalFrameSize()
m.input.SetWidth(max(0, innerWidth-t.Dialog.InputPrompt.GetHorizontalFrameSize()-1)) // (1) cursor padding
- m.list.SetSize(innerWidth, height-heightOffset)
m.help.SetWidth(innerWidth)
+ listHeight := height - heightOffset
+ m.list.SetSize(innerWidth, listHeight)
+ listTotalHeight := m.list.TotalHeight()
+ listWidth := max(0, innerWidth-3) // Reserve space for scrollbar.
+ m.list.SetSize(listWidth, listHeight)
+
rc := NewRenderContext(t, width)
rc.Title = "Switch Model"
rc.TitleInfo = m.modelTypeRadioView()
@@ -281,6 +287,10 @@ func (m *Models) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
rc.AddPart(inputView)
listView := t.Dialog.List.Height(m.list.Height()).Render(m.list.Render())
+ scrollbar := common.Scrollbar(t, listHeight, listTotalHeight, listHeight, m.list.Offset())
+ if scrollbar != "" {
+ listView = lipgloss.JoinHorizontal(lipgloss.Top, listView, scrollbar)
+ }
rc.AddPart(listView)
rc.Help = m.help.View(m)
@@ -398,20 +408,8 @@ func (m *Models) setProviderItems() error {
}
}
- // Move "Charm Hyper" to first position.
- // (But still after recent models and custom providers).
- slices.SortStableFunc(m.providers, func(a, b catwalk.Provider) int {
- switch {
- case a.ID == "hyper":
- return -1
- case b.ID == "hyper":
- return 1
- default:
- return 0
- }
- })
-
- // Now add known providers from the predefined list
+ // Now add known providers from the predefined list.
+ // Providers already has Hyper at the front of the list.
for _, provider := range m.providers {
providerID := string(provider.ID)
if addedProviders[providerID] {
@@ -498,7 +496,11 @@ func (m *Models) setProviderItems() error {
// Set model groups in the list.
m.list.SetGroups(groups...)
m.list.SetSelectedItem(selectedItemID)
- m.list.ScrollToTop()
+ if selectedItemID != "" {
+ m.list.ScrollToSelected()
+ } else {
+ m.list.ScrollToTop()
+ }
// Update placeholder based on model type
if !m.isOnboarding {
@@ -85,18 +85,15 @@ func (f *ModelsList) SetSelected(index int) {
// SetSelectedItem sets the selected item in the list by item ID.
func (f *ModelsList) SetSelectedItem(itemID string) {
if itemID == "" {
- f.SetSelected(0)
return
}
- count := 0
- for _, g := range f.groups {
- for _, item := range g.Items {
- if item.ID() == itemID {
- f.SetSelected(count)
- return
- }
- count++
+ // Walk the selectable model items using the same helpers that
+ // keyboard navigation uses, so we stay in sync with the flat
+ // list layout.
+ for ok := f.SelectFirst(); ok; ok = f.SelectNext() {
+ if mi, is := f.SelectedItem().(*ModelItem); is && mi.ID() == itemID {
+ return
}
}
}
@@ -237,7 +237,10 @@ func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
t.Dialog.HelpView.GetVerticalFrameSize() +
t.Dialog.View.GetVerticalFrameSize()
s.input.SetWidth(max(0, innerWidth-t.Dialog.InputPrompt.GetHorizontalFrameSize()-1)) // (1) cursor padding
- s.list.SetSize(innerWidth, height-heightOffset)
+ listHeight := height - heightOffset
+ listTotalHeight := s.list.TotalHeight()
+ listWidth := max(0, innerWidth-3) // Reserve space for scrollbar.
+ s.list.SetSize(listWidth, listHeight)
s.help.SetWidth(innerWidth)
// 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 {
rc.AddPart(inputView)
}
listView := t.Dialog.List.Height(s.list.Height()).Render(s.list.Render())
+ scrollbar := common.Scrollbar(t, listHeight, listTotalHeight, listHeight, s.list.Offset())
+ if scrollbar != "" {
+ listView = lipgloss.JoinHorizontal(lipgloss.Top, listView, scrollbar)
+ }
rc.AddPart(listView)
rc.Help = s.help.View(s)
@@ -157,6 +157,33 @@ func (l *List) Len() int {
return len(l.items)
}
+// TotalHeight returns the total height of all items in the list.
+func (l *List) TotalHeight() int {
+ total := 0
+ for idx := range l.items {
+ item := l.getItem(idx)
+ total += item.height
+ if l.gap > 0 && idx < len(l.items)-1 {
+ total += l.gap
+ }
+ }
+ return total
+}
+
+// Offset returns the current scroll offset in lines from the top.
+func (l *List) Offset() int {
+ offset := 0
+ for idx := 0; idx < l.offsetIdx; idx++ {
+ item := l.getItem(idx)
+ offset += item.height
+ if l.gap > 0 && idx < len(l.items)-1 {
+ offset += l.gap
+ }
+ }
+ offset += l.offsetLine
+ return offset
+}
+
// lastOffsetItem returns the index and line offsets of the last item that can
// be partially visible in the viewport.
func (l *List) lastOffsetItem() (int, int, int) {
@@ -148,7 +148,11 @@ func renderHeaderDetails(
model := com.Config().GetModelByType(agentCfg.Model)
if model != nil && model.ContextWindow > 0 {
percentage := (float64(session.CompletionTokens+session.PromptTokens) / float64(model.ContextWindow)) * 100
- formattedPercentage := t.Header.Percentage.Render(fmt.Sprintf("%d%%", int(percentage)))
+ percentageText := fmt.Sprintf("%d%%", int(percentage))
+ if session.EstimatedUsage {
+ percentageText = "~" + percentageText
+ }
+ formattedPercentage := t.Header.Percentage.Render(percentageText)
parts = append(parts, formattedPercentage)
}
@@ -57,13 +57,14 @@ type KeyMap struct {
}
// Global key maps
- Quit key.Binding
- Help key.Binding
- Commands key.Binding
- Models key.Binding
- Suspend key.Binding
- Sessions key.Binding
- Tab key.Binding
+ Quit key.Binding
+ Help key.Binding
+ Commands key.Binding
+ Models key.Binding
+ Suspend key.Binding
+ Sessions key.Binding
+ Tab key.Binding
+ ToggleYolo key.Binding
}
func DefaultKeyMap() KeyMap {
@@ -96,6 +97,10 @@ func DefaultKeyMap() KeyMap {
key.WithKeys("tab"),
key.WithHelp("tab", "change focus"),
),
+ ToggleYolo: key.NewBinding(
+ key.WithKeys("ctrl+y"),
+ key.WithHelp("ctrl+y", "toggle yolo"),
+ ),
}
km.Editor.AddFile = key.NewBinding(
@@ -6,6 +6,7 @@ import (
"testing"
"charm.land/bubbles/v2/textarea"
+ "github.com/charmbracelet/crush/internal/session"
"github.com/charmbracelet/crush/internal/ui/chat"
"github.com/charmbracelet/crush/internal/ui/common"
)
@@ -118,3 +119,109 @@ func TestHandleTextareaHeightChange_FollowModeStaysAtBottom(t *testing.T) {
t.Fatal("expected chat to remain at bottom after editor resize in follow mode")
}
}
+
+func TestAutoExpandPillsIfReasonable(t *testing.T) {
+ t.Parallel()
+
+ t.Run("expands when terminal is tall enough and todos exist", func(t *testing.T) {
+ t.Parallel()
+
+ u := newTestUI()
+ u.height = 50
+ u.session = &session.Session{ID: "s1", Todos: []session.Todo{
+ {Status: session.TodoStatusInProgress, Content: "do work"},
+ {Status: session.TodoStatusPending, Content: "do more"},
+ }}
+
+ u.autoExpandPillsIfReasonable()
+
+ if !u.pillsExpanded {
+ t.Fatal("expected pillsExpanded to be true")
+ }
+ if u.focusedPillSection != pillSectionTodos {
+ t.Fatalf("expected focusedPillSection to be pillSectionTodos, got %d", u.focusedPillSection)
+ }
+ })
+
+ t.Run("does not expand when terminal is too short", func(t *testing.T) {
+ t.Parallel()
+
+ u := newTestUI()
+ u.height = 30
+ u.session = &session.Session{ID: "s1", Todos: []session.Todo{
+ {Status: session.TodoStatusInProgress, Content: "do work"},
+ }}
+
+ u.autoExpandPillsIfReasonable()
+
+ if u.pillsExpanded {
+ t.Fatal("expected pillsExpanded to be false when terminal height is below threshold")
+ }
+ })
+
+ t.Run("does not expand when all todos are completed", func(t *testing.T) {
+ t.Parallel()
+
+ u := newTestUI()
+ u.height = 50
+ u.session = &session.Session{ID: "s1", Todos: []session.Todo{
+ {Status: session.TodoStatusCompleted, Content: "done"},
+ }}
+
+ u.autoExpandPillsIfReasonable()
+
+ if u.pillsExpanded {
+ t.Fatal("expected pillsExpanded to be false when all todos are completed")
+ }
+ })
+
+ t.Run("does not expand when already expanded", func(t *testing.T) {
+ t.Parallel()
+
+ u := newTestUI()
+ u.height = 50
+ u.pillsExpanded = true
+ u.session = &session.Session{ID: "s1", Todos: []session.Todo{
+ {Status: session.TodoStatusInProgress, Content: "do work"},
+ }}
+ u.updateLayoutAndSize()
+
+ u.autoExpandPillsIfReasonable()
+
+ if !u.pillsExpanded {
+ t.Fatal("expected pillsExpanded to stay true")
+ }
+ })
+
+ t.Run("expands for prompt queue when no todos", func(t *testing.T) {
+ t.Parallel()
+
+ u := newTestUI()
+ u.height = 50
+ u.session = &session.Session{ID: "s1", Todos: []session.Todo{}}
+ u.promptQueue = 2
+
+ u.autoExpandPillsIfReasonable()
+
+ if !u.pillsExpanded {
+ t.Fatal("expected pillsExpanded to be true for prompt queue")
+ }
+ if u.focusedPillSection != pillSectionQueue {
+ t.Fatalf("expected focusedPillSection to be pillSectionQueue, got %d", u.focusedPillSection)
+ }
+ })
+
+ t.Run("does not expand when no session", func(t *testing.T) {
+ t.Parallel()
+
+ u := newTestUI()
+ u.height = 50
+ u.session = nil
+
+ u.autoExpandPillsIfReasonable()
+
+ if u.pillsExpanded {
+ t.Fatal("expected pillsExpanded to be false when there is no session")
+ }
+ })
+}
@@ -134,6 +134,43 @@ func queueList(queueItems []string, t *styles.Styles) string {
return strings.Join(lines, "\n")
}
+// pillsHeightReasonableTerminalHeight is the minimum terminal height at which
+// we auto-expand pills when there are incomplete todos.
+const pillsHeightReasonableTerminalHeight = 40
+
+// autoExpandPillsIfReasonable expands the pills panel if the terminal has
+// enough vertical space to show the expanded list comfortably.
+func (m *UI) autoExpandPillsIfReasonable() tea.Cmd {
+ if !m.hasSession() {
+ return nil
+ }
+ if m.height < pillsHeightReasonableTerminalHeight {
+ return nil
+ }
+ hasPills := hasIncompleteTodos(m.session.Todos) || m.promptQueue > 0
+ if !hasPills {
+ return nil
+ }
+ if m.pillsExpanded {
+ return nil
+ }
+ if m.pillsAutoExpanded {
+ return nil
+ }
+ m.pillsExpanded = true
+ m.pillsAutoExpanded = true
+ if hasIncompleteTodos(m.session.Todos) {
+ m.focusedPillSection = pillSectionTodos
+ } else {
+ m.focusedPillSection = pillSectionQueue
+ }
+ m.updateLayoutAndSize()
+ if m.chat.Follow() {
+ m.chat.ScrollToBottom()
+ }
+ return nil
+}
+
// togglePillsExpanded toggles the pills panel expansion state.
func (m *UI) togglePillsExpanded() tea.Cmd {
if !m.hasSession() {
@@ -249,7 +286,7 @@ func (m *UI) renderPills() {
if todosFocused && hasIncomplete {
expandedList = todoList(m.session.Todos, inProgressIcon, t, contentWidth)
} else if queueFocused && hasQueue {
- if m.com.Workspace.AgentIsReady() {
+ if m.com != nil && m.com.Workspace != nil && m.com.Workspace.AgentIsReady() {
queueItems := m.com.Workspace.AgentQueuedPromptsList(m.session.ID)
expandedList = queueList(queueItems, t)
}
@@ -44,9 +44,10 @@ func (m *UI) modelInfo(width int) string {
var modelContext *common.ModelContextInfo
if model != nil && m.session != nil {
modelContext = &common.ModelContextInfo{
- ContextUsed: m.session.CompletionTokens + m.session.PromptTokens,
- Cost: m.session.Cost,
- ModelContext: model.CatwalkCfg.ContextWindow,
+ ContextUsed: m.session.CompletionTokens + m.session.PromptTokens,
+ Cost: m.session.Cost,
+ ModelContext: model.CatwalkCfg.ContextWindow,
+ EstimatedUsage: m.session.EstimatedUsage,
}
}
var modelName string
@@ -259,6 +259,7 @@ type UI struct {
// pills state
pillsExpanded bool
+ pillsAutoExpanded bool
focusedPillSection pillSection
promptQueue int
pillsView string
@@ -317,6 +318,7 @@ func New(com *common.Common, initialSessionID string, continueLast bool) *UI {
com.Styles.Attachments.Deleting,
com.Styles.Attachments.Image,
com.Styles.Attachments.Text,
+ com.Styles.Attachments.Skill,
),
attachments.Keymap{
DeleteMode: keyMap.Editor.AttachmentDeleteMode,
@@ -536,6 +538,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if cmd := m.setSessionMessages(msgs); cmd != nil {
cmds = append(cmds, cmd)
}
+ if cmd := m.autoExpandPillsIfReasonable(); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
if hasInProgressTodo(m.session.Todos) {
// only start spinner if there is an in-progress todo
if m.isAgentBusy() {
@@ -611,6 +616,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, m.todoSpinner.Tick)
m.updateLayoutAndSize()
}
+ m.autoExpandPillsIfReasonable()
}
case pubsub.Event[message.Message]:
// 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 {
}
cmds = append(cmds, m.sendMessage(content))
m.dialog.CloseFrontDialog()
+ case dialog.ActionAttachSkill:
+ m.dialog.CloseFrontDialog()
+ cmds = append(cmds, m.attachSkill(msg.ID, msg.Name))
case dialog.ActionRunMCPPrompt:
if len(msg.Arguments) > 0 && msg.Args == nil {
m.dialog.CloseFrontDialog()
@@ -1796,6 +1805,16 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
}
cmds = append(cmds, tea.Suspend)
return true
+ case key.Matches(msg, m.keyMap.ToggleYolo):
+ yolo := !m.com.Workspace.PermissionSkipRequests()
+ m.com.Workspace.PermissionSetSkipRequests(yolo)
+ m.setEditorPrompt(yolo)
+ status := "disabled"
+ if yolo {
+ status = "enabled"
+ }
+ cmds = append(cmds, util.ReportInfo("Yolo mode "+status))
+ return true
}
return false
}
@@ -2401,6 +2420,7 @@ func (m *UI) FullHelp() [][]key.Binding {
commands,
k.Models,
k.Sessions,
+ k.ToggleYolo,
)
if hasSession {
mainBinds = append(mainBinds, k.Chat.NewSession)
@@ -2462,6 +2482,7 @@ func (m *UI) FullHelp() [][]key.Binding {
commands,
k.Models,
k.Sessions,
+ k.ToggleYolo,
},
)
editorBinds := []key.Binding{
@@ -3136,12 +3157,37 @@ func (m *UI) refreshStyles() {
t.Attachments.Deleting,
t.Attachments.Image,
t.Attachments.Text,
+ t.Attachments.Skill,
)
m.todoSpinner.Style = t.Pills.TodoSpinner
m.status.help.Styles = t.Help
m.chat.InvalidateRenderCaches()
}
+// attachSkill reads a skill's content by ID and returns it as a markdown
+// attachment to be added to the attachment toolbar. The user can then
+// compose a message and send it with the skill attached.
+// The name parameter is used as a fallback when the server does not
+// return one.
+func (m *UI) attachSkill(skillID, name string) tea.Cmd {
+ return func() tea.Msg {
+ content, result, err := m.com.Workspace.ReadSkill(context.Background(), skillID)
+ if err != nil {
+ return util.NewErrorMsg(err)
+ }
+ fileName := result.Name
+ if fileName == "" {
+ fileName = name
+ }
+ return message.Attachment{
+ FilePath: fileName,
+ FileName: fileName,
+ MimeType: "text/markdown",
+ Content: content,
+ }
+ }
+}
+
// sendMessage sends a message with the given content and attachments.
func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
if !m.com.Workspace.AgentIsReady() {
@@ -3476,6 +3522,7 @@ func (m *UI) newSession() tea.Cmd {
m.chat.Blur()
m.chat.ClearMessages()
m.pillsExpanded = false
+ m.pillsAutoExpanded = false
m.promptQueue = 0
m.pillsView = ""
m.historyReset()
@@ -763,6 +763,7 @@ func quickStyle(o quickStyleOpts) Styles {
s.ModelInfo.Reasoning = lipgloss.NewStyle().Foreground(o.fgMostSubtle).PaddingLeft(2)
s.ModelInfo.TokenCount = lipgloss.NewStyle().Foreground(o.fgMostSubtle)
s.ModelInfo.TokenPercentage = lipgloss.NewStyle().Foreground(o.fgMoreSubtle)
+ s.ModelInfo.EstimatedUsagePrefix = s.ModelInfo.TokenPercentage
s.ModelInfo.Cost = lipgloss.NewStyle().Foreground(o.fgMoreSubtle)
s.ModelInfo.HypercreditIcon = lipgloss.NewStyle().Foreground(charmtone.Dolly)
s.ModelInfo.HypercreditText = lipgloss.NewStyle().Foreground(o.fgMoreSubtle)
@@ -922,6 +923,7 @@ func quickStyle(o quickStyleOpts) Styles {
attachmentIconStyle := base.Foreground(o.bgLessVisible).Background(o.success).Padding(0, 1)
s.Attachments.Image = attachmentIconStyle.SetString(ImageIcon)
s.Attachments.Text = attachmentIconStyle.SetString(TextIcon)
+ s.Attachments.Skill = attachmentIconStyle.SetString(SkillIcon)
s.Attachments.Normal = base.Padding(0, 1).MarginRight(1).Background(o.fgMoreSubtle).Foreground(o.fgBase)
s.Attachments.Deleting = base.Padding(0, 1).Bold(true).Background(o.destructive).Foreground(o.fgBase)
@@ -43,6 +43,7 @@ const (
ImageIcon string = "■"
TextIcon string = "≡"
+ SkillIcon string = "▲"
ScrollbarThumb string = "┃"
ScrollbarTrack string = "│"
@@ -183,16 +184,17 @@ type Styles struct {
// ModelInfo (model name, provider, reasoning, token/cost summary)
ModelInfo struct {
- Icon lipgloss.Style // Model icon (◇)
- Name lipgloss.Style // Model name text
- Provider lipgloss.Style // "via <provider>" text
- ProviderFallback lipgloss.Style // Provider on its own second line
- Reasoning lipgloss.Style // Reasoning effort text
- TokenCount lipgloss.Style // "(42K)" token count
- TokenPercentage lipgloss.Style // "42%" percent of context window
- Cost lipgloss.Style // "$0.42" cost readout
- HypercreditIcon lipgloss.Style // Hypercredit icon (◆)
- HypercreditText lipgloss.Style // Remaining Hypercredits text
+ Icon lipgloss.Style // Model icon (◇)
+ Name lipgloss.Style // Model name text
+ Provider lipgloss.Style // "via <provider>" text
+ ProviderFallback lipgloss.Style // Provider on its own second line
+ Reasoning lipgloss.Style // Reasoning effort text
+ TokenCount lipgloss.Style // "(42K)" token count
+ TokenPercentage lipgloss.Style // "42%" percent of context window
+ EstimatedUsagePrefix lipgloss.Style // "~" prefix for estimated usage
+ Cost lipgloss.Style // "$0.42" cost readout
+ HypercreditIcon lipgloss.Style // Hypercredit icon (◆)
+ HypercreditText lipgloss.Style // Remaining Hypercredits text
}
// Resource styles the LSP/MCP/skills sidebar lists: their heading,
@@ -501,6 +503,7 @@ type Styles struct {
Normal lipgloss.Style
Image lipgloss.Style
Text lipgloss.Style
+ Skill lipgloss.Style
Deleting lipgloss.Style
}
@@ -18,6 +18,7 @@ import (
"github.com/charmbracelet/crush/internal/oauth"
"github.com/charmbracelet/crush/internal/permission"
"github.com/charmbracelet/crush/internal/session"
+ "github.com/charmbracelet/crush/internal/skills"
)
// AppWorkspace implements the Workspace interface by delegating
@@ -311,6 +312,16 @@ func (w *AppWorkspace) InitializePrompt() (string, error) {
return agent.InitializePrompt(w.store)
}
+func (w *AppWorkspace) ListSkills(_ context.Context) ([]skills.CatalogEntry, error) {
+ mgr := w.app.Skills
+ return skills.Catalog(mgr.ActiveSkills(), mgr.ResolvedPaths(), mgr.WorkingDir()), nil
+}
+
+func (w *AppWorkspace) ReadSkill(_ context.Context, skillID string) ([]byte, skills.SkillReadResult, error) {
+ mgr := w.app.Skills
+ return skills.ReadContent(mgr.ActiveSkills(), mgr.ResolvedPaths(), mgr.WorkingDir(), skillID)
+}
+
// -- MCP operations --
func (w *AppWorkspace) MCPGetStates() map[string]mcptools.ClientInfo {
@@ -494,6 +494,37 @@ func (w *ClientWorkspace) InitializePrompt() (string, error) {
return w.client.GetInitializePrompt(context.Background(), w.workspaceID())
}
+func (w *ClientWorkspace) ListSkills(ctx context.Context) ([]skills.CatalogEntry, error) {
+ entries, err := w.client.ListSkills(ctx, w.workspaceID())
+ if err != nil {
+ return nil, err
+ }
+ result := make([]skills.CatalogEntry, len(entries))
+ for i, entry := range entries {
+ result[i] = skills.CatalogEntry{
+ ID: entry.ID,
+ Name: entry.Name,
+ Description: entry.Description,
+ Label: entry.Label,
+ Source: skills.SourceType(entry.Source),
+ }
+ }
+ return result, nil
+}
+
+func (w *ClientWorkspace) ReadSkill(ctx context.Context, skillID string) ([]byte, skills.SkillReadResult, error) {
+ resp, err := w.client.ReadSkill(ctx, w.workspaceID(), skillID)
+ if err != nil {
+ return nil, skills.SkillReadResult{}, err
+ }
+ return resp.Content, skills.SkillReadResult{
+ Name: resp.Result.Name,
+ Description: resp.Result.Description,
+ Source: skills.SourceType(resp.Result.Source),
+ Builtin: resp.Result.Builtin,
+ }, nil
+}
+
// -- MCP operations --
func (w *ClientWorkspace) MCPGetStates() map[string]mcp.ClientInfo {
@@ -18,6 +18,7 @@ import (
"github.com/charmbracelet/crush/internal/oauth"
"github.com/charmbracelet/crush/internal/permission"
"github.com/charmbracelet/crush/internal/session"
+ "github.com/charmbracelet/crush/internal/skills"
)
// LSPClientInfo holds information about an LSP client's state. This is
@@ -141,6 +142,8 @@ type Workspace interface {
ProjectNeedsInitialization() (bool, error)
MarkProjectInitialized() error
InitializePrompt() (string, error)
+ ListSkills(ctx context.Context) ([]skills.CatalogEntry, error)
+ ReadSkill(ctx context.Context, skillID string) ([]byte, skills.SkillReadResult, error)
// MCP operations (server-side in client mode)
MCPGetStates() map[string]mcptools.ClientInfo