From 3250fef354eb7ebc40faaa0a1c4c5c9f37f0f6dc Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Wed, 20 May 2026 13:32:41 -0300 Subject: [PATCH 01/34] fix: update fantasy with stream fixes (#2968) * https://github.com/charmbracelet/fantasy/pull/245 * https://github.com/charmbracelet/fantasy/pull/246 By @ethanndickson --- go.mod | 22 +++++++++++----------- go.sum | 56 ++++++++++++++++++++++++++++---------------------------- 2 files changed, 39 insertions(+), 39 deletions(-) diff --git a/go.mod b/go.mod index 0a2f5504d49db03e2c64f09ac34a08c1a4485f3f..10004cef4cd20c9237382a0ba7db7b4e291faa24 100644 --- a/go.mod +++ b/go.mod @@ -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.3 charm.land/fang/v2 v2.0.1 - charm.land/fantasy v0.25.0 + charm.land/fantasy v0.25.1 charm.land/glamour/v2 v2.0.0 charm.land/lipgloss/v2 v2.0.3 charm.land/log/v2 v2.0.0 @@ -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 @@ -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 diff --git a/go.sum b/go.sum index cbda3f3010c5b05ac4e70cc1487bbc95dbec1d32..4e52b966ad5a6391d54ac5ab3be87de8c90ba459 100644 --- a/go.sum +++ b/go.sum @@ -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.3 h1:Ft02rkrPVrgWnaGAllcPx7+6yki7/cL7h2f3Bv1aRCM= +charm.land/catwalk v0.41.3/go.mod h1:LmMFJdRqF5F7qKa+xqD9SBq7tph7L98GU3ZFa1TxftA= 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.1 h1:xGDRDC9R/h72A67ACbZcJ8vP2zgF4qTJbKX4SnzUNiw= +charm.land/fantasy v0.25.1/go.mod h1:qQasIyQ0QBH2EYb4JLuPGDggha9Gw7u1mX9hFfQv84M= 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= @@ -184,8 +184,8 @@ github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmm 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-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= @@ -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= @@ -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= From 6ed8852b621faff72fe3f791efdc41678b439b9e Mon Sep 17 00:00:00 2001 From: Greg Slepak Date: Tue, 12 May 2026 15:16:59 -0700 Subject: [PATCH 02/34] fix(agent): estimate missing streamed usage Add a fallback token estimator for streamed steps that return zero usage so session context pressure remains accurate when providers omit final usage chunks. Estimated usage updates prompt/completion counters but never contributes cost, while provider-reported usage continues to preserve normal cost accounting and OpenRouter overrides. Zero-usage updates now leave existing nonzero token counters intact. --- internal/agent/agent.go | 42 ++++-- internal/agent/usage_fallback.go | 172 ++++++++++++++++++++++ internal/agent/usage_fallback_test.go | 203 ++++++++++++++++++++++++++ 3 files changed, 405 insertions(+), 12 deletions(-) create mode 100644 internal/agent/usage_fallback.go create mode 100644 internal/agent/usage_fallback_test.go diff --git a/internal/agent/agent.go b/internal/agent/agent.go index e8707a3e2b3e35281e57a9d396adbe81c5b7ebbd..c54331910b13a0e5a7ef747451be32895596ab65 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -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,7 +755,7 @@ 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 @@ -1132,28 +1138,40 @@ 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) { 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 + eventCost := cost + if estimated { + eventCost = 0 } + a.eventTokensUsed(session.ID, model, usage, eventCost) - // 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 + if usage.OutputTokens != 0 { + session.CompletionTokens = usage.OutputTokens + } + if promptTokens := usage.InputTokens + usage.CacheReadTokens; promptTokens != 0 { + session.PromptTokens = promptTokens + } } func (a *sessionAgent) Cancel(sessionID string) { diff --git a/internal/agent/usage_fallback.go b/internal/agent/usage_fallback.go new file mode 100644 index 0000000000000000000000000000000000000000..78903c57a6c8c54a3598964c1c132579d0cad36d --- /dev/null +++ b/internal/agent/usage_fallback.go @@ -0,0 +1,172 @@ +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: + tokens += estimateToolResultContentTokens(c.ToolCallID, c.ToolName, c.ClientMetadata, c.Result) + case *fantasy.ToolResultContent: + 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) +} diff --git a/internal/agent/usage_fallback_test.go b/internal/agent/usage_fallback_test.go new file mode 100644 index 0000000000000000000000000000000000000000..96263f214a1be035dc5978827c286b81dde2e0ba --- /dev/null +++ b/internal/agent/usage_fallback_test.go @@ -0,0 +1,203 @@ +package agent + +import ( + "errors" + "testing" + + "charm.land/catwalk/pkg/catwalk" + "charm.land/fantasy" + "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 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) +} + +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 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) +} From 2e9c65057b8ca5abc8aa54b21856b869c733d52c Mon Sep 17 00:00:00 2001 From: Greg Slepak Date: Tue, 12 May 2026 15:56:50 -0700 Subject: [PATCH 03/34] fix(agent): correct fallback usage accounting --- internal/agent/agent.go | 6 +-- internal/agent/usage_fallback.go | 8 +++- internal/agent/usage_fallback_test.go | 63 +++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 6 deletions(-) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index c54331910b13a0e5a7ef747451be32895596ab65..fcedcc55615db8afd29f109231cae627b67ac98f 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -1166,11 +1166,9 @@ func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session, } session.Cost += cost - if usage.OutputTokens != 0 { + if !usageIsZero(usage) { session.CompletionTokens = usage.OutputTokens - } - if promptTokens := usage.InputTokens + usage.CacheReadTokens; promptTokens != 0 { - session.PromptTokens = promptTokens + session.PromptTokens = usage.InputTokens + usage.CacheReadTokens } } diff --git a/internal/agent/usage_fallback.go b/internal/agent/usage_fallback.go index 78903c57a6c8c54a3598964c1c132579d0cad36d..3cf347c38f91a72968c3537a5d954bb6feb1d0b9 100644 --- a/internal/agent/usage_fallback.go +++ b/internal/agent/usage_fallback.go @@ -78,9 +78,13 @@ func estimateStepCompletionTokens(step fantasy.StepResult) int64 { case *fantasy.ToolCallContent: tokens += estimateToolCallTokens(c.ToolName, c.Input) case fantasy.ToolResultContent: - tokens += estimateToolResultContentTokens(c.ToolCallID, c.ToolName, c.ClientMetadata, c.Result) + if c.ProviderExecuted { + tokens += estimateToolResultContentTokens(c.ToolCallID, c.ToolName, c.ClientMetadata, c.Result) + } case *fantasy.ToolResultContent: - tokens += estimateToolResultContentTokens(c.ToolCallID, c.ToolName, c.ClientMetadata, c.Result) + if c.ProviderExecuted { + tokens += estimateToolResultContentTokens(c.ToolCallID, c.ToolName, c.ClientMetadata, c.Result) + } } } return tokens diff --git a/internal/agent/usage_fallback_test.go b/internal/agent/usage_fallback_test.go index 96263f214a1be035dc5978827c286b81dde2e0ba..ec925c4148da922edec666a5d8bfec350c9295bf 100644 --- a/internal/agent/usage_fallback_test.go +++ b/internal/agent/usage_fallback_test.go @@ -145,6 +145,51 @@ func TestFallbackStepUsageEstimatesToolResults(t *testing.T) { 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() @@ -187,6 +232,24 @@ func TestUpdateSessionUsageKeepsCountersForZeroUsage(t *testing.T) { require.Equal(t, int64(456), currentSession.CompletionTokens) } +func TestUpdateSessionUsageReplacesCountersForPartialUsage(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.Zero(t, currentSession.CompletionTokens) +} + func TestUpdateSessionUsageAddsProviderCost(t *testing.T) { t.Parallel() From 74e6e378e37493440cb01c956ef6de7ebcde38e1 Mon Sep 17 00:00:00 2001 From: Greg Slepak Date: Tue, 12 May 2026 16:27:01 -0700 Subject: [PATCH 04/34] fix(agent): harden fallback usage accounting --- internal/agent/agent.go | 27 +++++++--- internal/agent/usage_fallback_test.go | 75 ++++++++++++++++++++++++++- 2 files changed, 93 insertions(+), 9 deletions(-) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index fcedcc55615db8afd29f109231cae627b67ac98f..80d8ae64a359fec27b0b712fc5ecef5514d579b4 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -760,7 +760,9 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan // Just in case, get just the last usage info. usage := resp.Response.Usage currentSession.SummaryMessageID = summaryMessage.ID - currentSession.CompletionTokens = usage.OutputTokens + if completionTokens := summaryCompletionTokens(usage, summaryMessage); completionTokens != 0 { + currentSession.CompletionTokens = completionTokens + } currentSession.PromptTokens = 0 _, err = a.sessions.Save(genCtx, currentSession) if err != nil { @@ -1145,11 +1147,9 @@ func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session, modelConfig.CostPer1MIn/1e6*float64(usage.InputTokens) + modelConfig.CostPer1MOut/1e6*float64(usage.OutputTokens) - eventCost := cost - if estimated { - eventCost = 0 + if !estimated { + a.eventTokensUsed(session.ID, model, usage, cost) } - a.eventTokensUsed(session.ID, model, usage, eventCost) if estimated { cost = 0 @@ -1166,10 +1166,23 @@ func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session, } session.Cost += cost - if !usageIsZero(usage) { + updateSessionTokenCounters(session, usage) +} + +func updateSessionTokenCounters(session *session.Session, usage fantasy.Usage) { + if usage.OutputTokens != 0 { session.CompletionTokens = usage.OutputTokens - session.PromptTokens = usage.InputTokens + usage.CacheReadTokens } + 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) { diff --git a/internal/agent/usage_fallback_test.go b/internal/agent/usage_fallback_test.go index ec925c4148da922edec666a5d8bfec350c9295bf..1f2442aef7d4a1623419f70c81cd494ccc88ef69 100644 --- a/internal/agent/usage_fallback_test.go +++ b/internal/agent/usage_fallback_test.go @@ -6,6 +6,7 @@ import ( "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" ) @@ -232,7 +233,7 @@ func TestUpdateSessionUsageKeepsCountersForZeroUsage(t *testing.T) { require.Equal(t, int64(456), currentSession.CompletionTokens) } -func TestUpdateSessionUsageReplacesCountersForPartialUsage(t *testing.T) { +func TestUpdateSessionUsagePreservesOmittedCountersForPartialUsage(t *testing.T) { t.Parallel() agent := &sessionAgent{} @@ -247,7 +248,77 @@ func TestUpdateSessionUsageReplacesCountersForPartialUsage(t *testing.T) { agent.updateSessionUsage(model, currentSession, usage, nil, false) require.Equal(t, int64(789), currentSession.PromptTokens) - require.Zero(t, currentSession.CompletionTokens) + 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) { From 83d2abbf0a62f8bda0cb4b5ef499eff11dd81a20 Mon Sep 17 00:00:00 2001 From: Greg Slepak Date: Tue, 12 May 2026 17:04:10 -0700 Subject: [PATCH 05/34] fix(agent): clear stale summary token counts --- internal/agent/agent.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 80d8ae64a359fec27b0b712fc5ecef5514d579b4..15b0c1af161a7e5631ba00e55e988a19aa28fe0e 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -760,9 +760,7 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan // Just in case, get just the last usage info. usage := resp.Response.Usage currentSession.SummaryMessageID = summaryMessage.ID - if completionTokens := summaryCompletionTokens(usage, summaryMessage); completionTokens != 0 { - currentSession.CompletionTokens = completionTokens - } + currentSession.CompletionTokens = summaryCompletionTokens(usage, summaryMessage) currentSession.PromptTokens = 0 _, err = a.sessions.Save(genCtx, currentSession) if err != nil { From 2736e487cf511a686daf691d9135f9491d4ac865 Mon Sep 17 00:00:00 2001 From: Greg Slepak Date: Tue, 12 May 2026 17:34:08 -0700 Subject: [PATCH 06/34] fix(ui): mark estimated context usage --- internal/agent/agent.go | 5 +++++ internal/agent/usage_fallback_test.go | 2 ++ internal/session/session.go | 3 +++ internal/ui/common/elements.go | 17 +++++++++----- internal/ui/common/elements_test.go | 32 +++++++++++++++++++++++++++ internal/ui/model/header.go | 6 ++++- internal/ui/model/sidebar.go | 7 +++--- internal/ui/styles/quickstyle.go | 1 + internal/ui/styles/styles.go | 21 +++++++++--------- 9 files changed, 74 insertions(+), 20 deletions(-) create mode 100644 internal/ui/common/elements_test.go diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 15b0c1af161a7e5631ba00e55e988a19aa28fe0e..407604978bbd54d6e38485a6ac0af6f6315edaf4 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -762,6 +762,7 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan currentSession.SummaryMessageID = summaryMessage.ID currentSession.CompletionTokens = summaryCompletionTokens(usage, summaryMessage) currentSession.PromptTokens = 0 + currentSession.EstimatedUsage = usageIsZero(usage) _, err = a.sessions.Save(genCtx, currentSession) if err != nil { return err @@ -1139,6 +1140,10 @@ func (a *sessionAgent) openrouterCost(metadata fantasy.ProviderMetadata) *float6 } 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) + diff --git a/internal/agent/usage_fallback_test.go b/internal/agent/usage_fallback_test.go index 1f2442aef7d4a1623419f70c81cd494ccc88ef69..3b6fc19b943fbb1cf636782c051e91a6ff64a5df 100644 --- a/internal/agent/usage_fallback_test.go +++ b/internal/agent/usage_fallback_test.go @@ -212,6 +212,7 @@ func TestUpdateSessionUsageSkipsEstimatedCost(t *testing.T) { 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) { @@ -334,4 +335,5 @@ func TestUpdateSessionUsageAddsProviderCost(t *testing.T) { 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) } diff --git a/internal/session/session.go b/internal/session/session.go index 66bd9f4c9a12916d02c6d22ed7d51f81d74efdfd..6de6b9111d2f81fa49ae15e9ffaa9390f842d114 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -53,6 +53,7 @@ type Session struct { MessageCount int64 PromptTokens int64 CompletionTokens int64 + EstimatedUsage bool SummaryMessageID string Cost float64 Todos []Todo @@ -199,7 +200,9 @@ func (s *service) Save(ctx context.Context, session Session) (Session, error) { if err != nil { return Session{}, err } + estimatedUsage := session.EstimatedUsage session = s.fromDBItem(dbSession) + session.EstimatedUsage = estimatedUsage s.Publish(pubsub.UpdatedEvent, session) return session, nil } diff --git a/internal/ui/common/elements.go b/internal/ui/common/elements.go index 902c8a816247d97f0d9dd0ce7538b1bf130bc07d..de32645f2806b6c1790f10d4d0be2532cfdbea67 100644 --- a/internal/ui/common/elements.go +++ b/internal/ui/common/elements.go @@ -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: @@ -115,7 +116,11 @@ func formatTokensAndCost(t *styles.Styles, tokens, contextWindow int64, cost flo 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 = t.ModelInfo.EstimatedUsagePrefix.Render("~") + 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) diff --git a/internal/ui/common/elements_test.go b/internal/ui/common/elements_test.go new file mode 100644 index 0000000000000000000000000000000000000000..801e4e52100fb6ed2d6960039da42b66fd609da6 --- /dev/null +++ b/internal/ui/common/elements_test.go @@ -0,0 +1,32 @@ +package common + +import ( + "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() + + actual := ansi.Strip(formatTokensAndCost(&sty, 120, 1000, 0, true)) + + require.Contains(t, actual, "~12%") + require.Contains(t, actual, "(120)") + require.Contains(t, actual, "$0.00") +} + +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%") +} diff --git a/internal/ui/model/header.go b/internal/ui/model/header.go index c7091a6835c48f06a2eb0d4a504d1cc11b79f09c..0d012f67ae9a6b8b1ef7d26d85d26b90a9e1c624 100644 --- a/internal/ui/model/header.go +++ b/internal/ui/model/header.go @@ -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 = t.ModelInfo.EstimatedUsagePrefix.Render("~") + percentageText + } + formattedPercentage := t.Header.Percentage.Render(percentageText) parts = append(parts, formattedPercentage) } diff --git a/internal/ui/model/sidebar.go b/internal/ui/model/sidebar.go index 2f548628f24168bbe5f7d4d8390fc24e472f9559..e98ef33423cff6147afe24ee97d6a18fe0c57ced 100644 --- a/internal/ui/model/sidebar.go +++ b/internal/ui/model/sidebar.go @@ -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 diff --git a/internal/ui/styles/quickstyle.go b/internal/ui/styles/quickstyle.go index 7e01299788620df6c7bc9e25f913e8968c8fd110..cf685cf66f65a1abc388337a47e35ecbccc61c3d 100644 --- a/internal/ui/styles/quickstyle.go +++ b/internal/ui/styles/quickstyle.go @@ -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 = lipgloss.NewStyle().Foreground(o.fgBase) s.ModelInfo.Cost = lipgloss.NewStyle().Foreground(o.fgMoreSubtle) s.ModelInfo.HypercreditIcon = lipgloss.NewStyle().Foreground(charmtone.Dolly) s.ModelInfo.HypercreditText = lipgloss.NewStyle().Foreground(o.fgMoreSubtle) diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index b8841e5c2881d25006840eb61d339bd104024901..20bb5d2424e774858045cdbf1bae836a518cde9e 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -183,16 +183,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 " 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 " 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, From 9595d1f073dde42e1749853688299a4bad19f496 Mon Sep 17 00:00:00 2001 From: Greg Slepak Date: Tue, 12 May 2026 18:10:17 -0700 Subject: [PATCH 07/34] fix(session): preserve estimated usage marker Keep estimated usage state in memory across session fetch-modify-save updates so unrelated saves do not clear the UI marker, and align the marker color with context percentages. --- internal/session/session.go | 49 ++++++++++++++++--- internal/session/session_test.go | 81 ++++++++++++++++++++++++++++++++ internal/ui/styles/quickstyle.go | 2 +- 3 files changed, 125 insertions(+), 7 deletions(-) create mode 100644 internal/session/session_test.go diff --git a/internal/session/session.go b/internal/session/session.go index 6de6b9111d2f81fa49ae15e9ffaa9390f842d114..f6e3c8568adcf19e4eb8fc21db0ef1afd40cc4c5 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -7,6 +7,7 @@ import ( "fmt" "log/slog" "strings" + "sync" "github.com/charmbracelet/crush/internal/db" "github.com/charmbracelet/crush/internal/event" @@ -84,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) { @@ -155,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 @@ -165,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) { @@ -173,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) { @@ -201,6 +213,7 @@ func (s *service) Save(ctx context.Context, session Session) (Session, error) { return Session{}, err } estimatedUsage := session.EstimatedUsage + s.setEstimatedUsageState(session.ID, estimatedUsage) session = s.fromDBItem(dbSession) session.EstimatedUsage = estimatedUsage s.Publish(pubsub.UpdatedEvent, session) @@ -236,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) @@ -285,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), } } diff --git a/internal/session/session_test.go b/internal/session/session_test.go new file mode 100644 index 0000000000000000000000000000000000000000..50af7e23c130f5dda5627c2af29558cdec11799b --- /dev/null +++ b/internal/session/session_test.go @@ -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) +} diff --git a/internal/ui/styles/quickstyle.go b/internal/ui/styles/quickstyle.go index cf685cf66f65a1abc388337a47e35ecbccc61c3d..b631dceff8041af6844586a1cf4585bd140c8b41 100644 --- a/internal/ui/styles/quickstyle.go +++ b/internal/ui/styles/quickstyle.go @@ -763,7 +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 = lipgloss.NewStyle().Foreground(o.fgBase) + 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) From 4fc17ddcae6a37eb14f824f81b3befbcd1da03fd Mon Sep 17 00:00:00 2001 From: Greg Slepak Date: Tue, 12 May 2026 18:17:17 -0700 Subject: [PATCH 08/34] fix(ui): preserve estimated usage percentage color --- internal/ui/common/elements.go | 2 +- internal/ui/common/elements_test.go | 5 ++++- internal/ui/model/header.go | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/ui/common/elements.go b/internal/ui/common/elements.go index de32645f2806b6c1790f10d4d0be2532cfdbea67..2cdb9993b611cba768b99ee528b41a7634c7ef61 100644 --- a/internal/ui/common/elements.go +++ b/internal/ui/common/elements.go @@ -118,7 +118,7 @@ func formatTokensAndCost(t *styles.Styles, tokens, contextWindow int64, cost flo formattedTokens = t.ModelInfo.TokenCount.Render(fmt.Sprintf("(%s)", formattedTokens)) percentageText := fmt.Sprintf("%d%%", int(percentage)) if estimated { - percentageText = t.ModelInfo.EstimatedUsagePrefix.Render("~") + percentageText + percentageText = "~" + percentageText } formattedPercentage := t.ModelInfo.TokenPercentage.Render(percentageText) formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens) diff --git a/internal/ui/common/elements_test.go b/internal/ui/common/elements_test.go index 801e4e52100fb6ed2d6960039da42b66fd609da6..eabe1046b1e96a4e4713f7cb9da9aa430ad372fb 100644 --- a/internal/ui/common/elements_test.go +++ b/internal/ui/common/elements_test.go @@ -1,6 +1,7 @@ package common import ( + "strings" "testing" "github.com/charmbracelet/crush/internal/ui/styles" @@ -13,11 +14,13 @@ func TestFormatTokensAndCostPrefixesEstimatedUsage(t *testing.T) { sty := styles.CharmtonePantera() - actual := ansi.Strip(formatTokensAndCost(&sty, 120, 1000, 0, true)) + 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) { diff --git a/internal/ui/model/header.go b/internal/ui/model/header.go index 0d012f67ae9a6b8b1ef7d26d85d26b90a9e1c624..f030910566a84a47d1abf570bf05e734932388d9 100644 --- a/internal/ui/model/header.go +++ b/internal/ui/model/header.go @@ -150,7 +150,7 @@ func renderHeaderDetails( percentage := (float64(session.CompletionTokens+session.PromptTokens) / float64(model.ContextWindow)) * 100 percentageText := fmt.Sprintf("%d%%", int(percentage)) if session.EstimatedUsage { - percentageText = t.ModelInfo.EstimatedUsagePrefix.Render("~") + percentageText + percentageText = "~" + percentageText } formattedPercentage := t.Header.Percentage.Render(percentageText) parts = append(parts, formattedPercentage) From 5d7797d0f1b37a1ccf19f5ab1e1c48c3598daa5d Mon Sep 17 00:00:00 2001 From: Evan Wies Date: Thu, 21 May 2026 08:21:11 -0400 Subject: [PATCH 09/34] fix(ui): add locking around markdown rendering Needed to prevent race conditions, which may segfault. Signed-off-by: Evan Wies --- internal/ui/chat/user.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/ui/chat/user.go b/internal/ui/chat/user.go index f355ad0fd545bafa10994f8aea421b185f5eceac..b948d63e147a3288d8c331526b0ede1e61cfaffd 100644 --- a/internal/ui/chat/user.go +++ b/internal/ui/chat/user.go @@ -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 } From 5b1b2b435cd805462e6c01fa6d3453bea0199166 Mon Sep 17 00:00:00 2001 From: Evan Wies Date: Thu, 21 May 2026 10:22:07 -0400 Subject: [PATCH 10/34] fix(ui): guard divide-by-zero display error Otherwise, if contextWindow == 0, then it's a divide-by-zero error and a wild number % is shown. Now it defaults to 0% in that case. Signed-off-by: Evan Wies --- internal/ui/common/elements.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/ui/common/elements.go b/internal/ui/common/elements.go index 2cdb9993b611cba768b99ee528b41a7634c7ef61..506824a34dfdad0a9d8cedb870c2e4066c62fdb5 100644 --- a/internal/ui/common/elements.go +++ b/internal/ui/common/elements.go @@ -111,7 +111,10 @@ 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)) From 5723a24677705f6a6e5d06a45f06635ee828dca2 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Thu, 21 May 2026 18:58:38 -0300 Subject: [PATCH 11/34] chore(legal): @g2mt has signed the CLA --- .github/cla-signatures.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json index ea07a185b6d6ddaca3f235c0a7a354b63dba88d2..536023d4fe51e769d0325efd4627f0b0a23b8059 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1815,6 +1815,14 @@ "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 } ] } \ No newline at end of file From b9eb08230a189a73f13250ae370d10fc24f3e063 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Thu, 21 May 2026 23:37:18 -0300 Subject: [PATCH 12/34] chore(legal): @Ricardo-M-L has signed the CLA --- .github/cla-signatures.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json index 536023d4fe51e769d0325efd4627f0b0a23b8059..09a35830f5f0445b5c955156268365c73eb5e21a 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1823,6 +1823,14 @@ "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 } ] } \ No newline at end of file From 2faa467ab08de94aaa7583836491c6d4f7fabcf8 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Fri, 22 May 2026 11:04:52 -0300 Subject: [PATCH 13/34] fix: fix sometimes sending reasoning effort when it shouldn't (#2982) For some reason, I sometimes see a reasoning effort set on `model.ModelCfg.ReasoningEffort` (like "high") when the model doesn't supports it. This may being set by a previous select model and not properly cleared. We should check if the model supports reasoning effort levels before setting it. Otherwise, we should set only `thinking` (if supported). --- internal/agent/coordinator.go | 13 ++++++++----- internal/agent/coordinator_test.go | 6 +++++- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index 145665eee8d3bca81d054e8796d3ab80f4d85dd9..39332b60710cb2cb433dca2132acffbb89fa3441 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -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} diff --git a/internal/agent/coordinator_test.go b/internal/agent/coordinator_test.go index c1d5ede005e4d3f69cf9482c38d99c2ca8565d98..da0ddd0db2bf77c3c1e3eb6463549875a989a4ca 100644 --- a/internal/agent/coordinator_test.go +++ b/internal/agent/coordinator_test.go @@ -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", From a15a407487a0627322e39502f1237b3cf804ada1 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Fri, 22 May 2026 12:49:28 -0300 Subject: [PATCH 14/34] chore(legal): @Muttaqin86 has signed the CLA --- .github/cla-signatures.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json index 09a35830f5f0445b5c955156268365c73eb5e21a..3e61650b0fb1907a6acadcee1c01ef5b4eafc48a 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1831,6 +1831,14 @@ "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 } ] } \ No newline at end of file From ce395837b7f584d025574fab7939fc37a445fae1 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Fri, 22 May 2026 15:33:00 -0300 Subject: [PATCH 15/34] feat: render scrollbar for model list (dialog and onboarding) (#2978) --- internal/ui/common/scrollbar.go | 2 +- internal/ui/dialog/models.go | 12 +++++++++++- internal/ui/list/list.go | 27 +++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/internal/ui/common/scrollbar.go b/internal/ui/common/scrollbar.go index 7e701659348c90100534c18620f5e9949db3d050..74e384dff88192b96e15452d7cf00e556f731985 100644 --- a/internal/ui/common/scrollbar.go +++ b/internal/ui/common/scrollbar.go @@ -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) diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index 366669f90902dfeefa63445bd61c6830e9be9f18..1336459741c4557d29b93a389a67bca93b2766b9 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -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) diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go index d474a3307ba6bff830f4702f960f31e3335a83b0..d6e01993a9c1c7d386daec38743462f6264f0c6a 100644 --- a/internal/ui/list/list.go +++ b/internal/ui/list/list.go @@ -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) { From 74b84f6968fde0d80562be9f31f56cbc8cf35cf7 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Fri, 22 May 2026 15:33:36 -0300 Subject: [PATCH 16/34] fix(bedrock): enforce `us-east-1` as region for bedrock (#2985) This is the only region with access to all models. Having a fixed region should avoid confusion as some users might have `AWS_REGION` or `AWS_DEFAULT_REGION` set, but other regions won't really work. * Catwalk PR: https://github.com/charmbracelet/catwalk/pull/289 * Fantasy PR: https://github.com/charmbracelet/fantasy/pull/248 * Closes #2568 * Closes #2759 --- go.mod | 8 ++++---- go.sum | 16 ++++++++-------- internal/config/load.go | 11 +---------- internal/config/load_test.go | 23 ----------------------- 4 files changed, 13 insertions(+), 45 deletions(-) diff --git a/go.mod b/go.mod index 10004cef4cd20c9237382a0ba7db7b4e291faa24..79ef0226a911b9fc779f9b9a397dc22e51f858f9 100644 --- a/go.mod +++ b/go.mod @@ -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.3 + charm.land/catwalk v0.41.7 charm.land/fang/v2 v2.0.1 - charm.land/fantasy v0.25.1 + 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 @@ -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 diff --git a/go.sum b/go.sum index 4e52b966ad5a6391d54ac5ab3be87de8c90ba459..56cb04ebd7694928ee0d27f9c743f409398b1a33 100644 --- a/go.sum +++ b/go.sum @@ -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.3 h1:Ft02rkrPVrgWnaGAllcPx7+6yki7/cL7h2f3Bv1aRCM= -charm.land/catwalk v0.41.3/go.mod h1:LmMFJdRqF5F7qKa+xqD9SBq7tph7L98GU3ZFa1TxftA= +charm.land/catwalk v0.41.7 h1:zUlnSxJGaw0c3UWnzbX/oP9qqw5KLwP1qCbEvL/Skeg= +charm.land/catwalk v0.41.7/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.1 h1:xGDRDC9R/h72A67ACbZcJ8vP2zgF4qTJbKX4SnzUNiw= -charm.land/fantasy v0.25.1/go.mod h1:qQasIyQ0QBH2EYb4JLuPGDggha9Gw7u1mX9hFfQv84M= +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= @@ -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= diff --git a/internal/config/load.go b/internal/config/load.go index f816d2692e14c9baf2e281d852003088ffbe8a5d..08fa3cf2c7b24204146d9790c96e3936761933df 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -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 diff --git a/internal/config/load_test.go b/internal/config/load_test.go index 026c8971555f49d910f658ff9a0c61f4c0438b11..1a56d6e6120ab1212bbfa6254e695d571926e40d 100644 --- a/internal/config/load_test.go +++ b/internal/config/load_test.go @@ -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{ { From 88e97a05a307e87fbddb25aa692d7be1ee906d1a Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Fri, 22 May 2026 18:35:23 +0000 Subject: [PATCH 17/34] chore: auto-update files --- internal/agent/hyper/provider.json | 114 +++++++++++++++++++---------- 1 file changed, 75 insertions(+), 39 deletions(-) diff --git a/internal/agent/hyper/provider.json b/internal/agent/hyper/provider.json index 6dae90ed3bcb3dc24330380d3dfe2f32a0f1040a..9611b216d6851f886703a490f1f0b3d246de4a34 100644 --- a/internal/agent/hyper/provider.json +++ b/internal/agent/hyper/provider.json @@ -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, From 56cf50adac921ae8cb57d894e7930f06cfc1e8d7 Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Tue, 19 May 2026 16:45:57 -0400 Subject: [PATCH 18/34] fix(db): keep SQLite temp files in memory --- internal/db/connect.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/db/connect.go b/internal/db/connect.go index b247bb3f3807088b00fcc14fa5085843393a4ae6..ce1ed1172f8ef194b3f60fe298f00dd42e4f1409 100644 --- a/internal/db/connect.go +++ b/internal/db/connect.go @@ -18,6 +18,7 @@ var ( "foreign_keys": "ON", "journal_mode": "WAL", "page_size": "4096", + "temp_store": "MEMORY", "cache_size": "-8000", "synchronous": "NORMAL", "secure_delete": "ON", From 841eec8f89335d0eebb8f334057a1a2ee0efb159 Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Thu, 21 May 2026 13:03:30 -0400 Subject: [PATCH 19/34] fix(models): fix sorting of hyper --- internal/config/provider.go | 12 ++++++++++-- internal/ui/dialog/models.go | 16 ++-------------- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/internal/config/provider.go b/internal/config/provider.go index dffd4dd854f029bef250a1dde9ca557ace4d33c2..32a3894358d7710743f6e0ce7c7e3f3d5212910c 100644 --- a/internal/config/provider.go +++ b/internal/config/provider.go @@ -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 diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index 1336459741c4557d29b93a389a67bca93b2766b9..b06c62662c27fcd5d79a95618bc988b9a9b57468 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -408,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] { From df3a04d19d8a43e4568c1dc89cd86ebe6c707424 Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Thu, 21 May 2026 15:20:20 -0400 Subject: [PATCH 20/34] chore: isolate store test --- internal/config/store_test.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/internal/config/store_test.go b/internal/config/store_test.go index e977547c74388f632993b1101eb6aacdaf1e74d1..66f9a00a44ffd82bef18eb70afdae93cfa66fc69 100644 --- a/internal/config/store_test.go +++ b/internal/config/store_test.go @@ -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": { From 1320fcf5ce10ce0b8ac0e2f3580ae3adf1da18cc Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Thu, 21 May 2026 15:22:23 -0400 Subject: [PATCH 21/34] fix(ui): scroll to the properly select model --- internal/ui/dialog/models.go | 6 +++++- internal/ui/dialog/models_list.go | 15 ++++++--------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index b06c62662c27fcd5d79a95618bc988b9a9b57468..215006577026285760fe875319546306a029dee1 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -496,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 { diff --git a/internal/ui/dialog/models_list.go b/internal/ui/dialog/models_list.go index cec75c8e882c2db635a50ac3aa8839caa9a17601..735e36cff9ed7699614bd40b6f94ae9ac91a15ee 100644 --- a/internal/ui/dialog/models_list.go +++ b/internal/ui/dialog/models_list.go @@ -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 } } } From 55721653ceb67f04abd53c278ade01d05043bf7d Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Wed, 20 May 2026 12:42:19 -0400 Subject: [PATCH 23/34] feat(skills): add descriptions to skill picker and use attachements Co-authored-by: Amolith Assisted-by: Crush:deepseek-v4-pro --- internal/backend/backend.go | 9 +- internal/backend/config.go | 45 ++++++++ internal/client/config.go | 36 ++++++ internal/cmd/root.go | 10 +- internal/message/attachment.go | 5 +- internal/proto/proto.go | 29 +++++ internal/server/config.go | 51 +++++++++ internal/server/server.go | 2 + internal/skills/catalog.go | 152 +++++++++++++++++++++++++ internal/skills/manager.go | 71 ++++++++++-- internal/ui/attachments/attachments.go | 11 +- internal/ui/chat/messages.go | 1 + internal/ui/chat/prefix_cache_test.go | 1 + internal/ui/chat/version_bump_test.go | 2 + internal/ui/dialog/actions.go | 6 + internal/ui/dialog/commands.go | 19 +++- internal/ui/dialog/commands_item.go | 55 ++++++--- internal/ui/model/ui.go | 29 +++++ internal/ui/styles/quickstyle.go | 1 + internal/ui/styles/styles.go | 2 + internal/workspace/app_workspace.go | 11 ++ internal/workspace/client_workspace.go | 31 +++++ internal/workspace/workspace.go | 3 + 23 files changed, 543 insertions(+), 39 deletions(-) create mode 100644 internal/skills/catalog.go diff --git a/internal/backend/backend.go b/internal/backend/backend.go index 8d2ebb017bda61914f6002f68a5a62adeb3bdabe..5a593ee30b014848e982e6075a5ae64c1d17eab7 100644 --- a/internal/backend/backend.go +++ b/internal/backend/backend.go @@ -112,8 +112,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 { @@ -173,6 +177,7 @@ func skillsDiscoveryConfig(cfg *config.ConfigStore) skills.DiscoveryConfig { return skills.DiscoveryConfig{ SkillsPaths: paths, DisabledSkills: disabled, + WorkingDir: cfg.WorkingDir(), Resolver: resolver, } } diff --git a/internal/backend/config.go b/internal/backend/config.go index c7e01ff3bd08d3e96edcf875d6198d168fbeb1a5..553b0c2e18225a1ccff3460dfe1a7e8a32610aa4 100644 --- a/internal/backend/config.go +++ b/internal/backend/config.go @@ -10,6 +10,8 @@ import ( "github.com/charmbracelet/crush/internal/commands" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/oauth" + "github.com/charmbracelet/crush/internal/proto" + "github.com/charmbracelet/crush/internal/skills" ) // MCPResourceContents holds the contents of an MCP resource returned @@ -116,6 +118,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 { diff --git a/internal/client/config.go b/internal/client/config.go index e882464969eab7bfdbc428c0281fb12e38ab7347..36ceff21d6b4a1b50d230f6e2fbd6aad81ee7355 100644 --- a/internal/client/config.go +++ b/internal/client/config.go @@ -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"` diff --git a/internal/cmd/root.go b/internal/cmd/root.go index d94f2b0be08317f8adb8a3f4f3ad703fbc92add6..2e4eb1d22b3c724a81b1cbc31feaf4c2b3eb85b4 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -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, } } diff --git a/internal/message/attachment.go b/internal/message/attachment.go index c3c04aaea237e9ad060a8687c123a82643edba24..0ac83707212e11133925306ff93a4a8504a7ddec 100644 --- a/internal/message/attachment.go +++ b/internal/message/attachment.go @@ -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 { diff --git a/internal/proto/proto.go b/internal/proto/proto.go index 22a503b4879806d8b13d109e079604055ebba78b..03afa6b1c7083ea7f55f92faaa6d4f4709311ef0 100644 --- a/internal/proto/proto.go +++ b/internal/proto/proto.go @@ -32,6 +32,35 @@ type Error struct { Message string `json:"message"` } +// 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"` diff --git a/internal/server/config.go b/internal/server/config.go index cd96c3603fc94a41aa0d3ae4607e54fb487531ba..58f4ff4c60f4ac03c0e326b7071a4cb651724a21 100644 --- a/internal/server/config.go +++ b/internal/server/config.go @@ -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 diff --git a/internal/server/server.go b/internal/server/server.go index 75ef626d952af7183bcad5681dce7b0fdd85975c..87b7009f4a80894e18a849a215072cea464592c5 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -157,6 +157,8 @@ func NewServer(cfg *config.ConfigStore, network, address string) *Server { 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) diff --git a/internal/skills/catalog.go b/internal/skills/catalog.go new file mode 100644 index 0000000000000000000000000000000000000000..c1af90ca8bf11e9be1f5993338362b19f4749d22 --- /dev/null +++ b/internal/skills/catalog.go @@ -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) +} diff --git a/internal/skills/manager.go b/internal/skills/manager.go index 8128665c0422fbabccb76382f099999aad8885d2..33e808a221eef3fe3824332542d4d6e3e72f507c 100644 --- a/internal/skills/manager.go +++ b/internal/skills/manager.go @@ -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 +} diff --git a/internal/ui/attachments/attachments.go b/internal/ui/attachments/attachments.go index d56ea7ac43706ecf36b35fd9ec912d660370eaf1..cb1867cbc2adb128786b1a2cb5a5df20415c7a8a 100644 --- a/internal/ui/attachments/attachments.go +++ b/internal/ui/attachments/attachments.go @@ -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 } diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index 1e83ec6f6797a866f3587ad6f349bb4c760b1eb3..4fb54b8a9ccfac96529eca7b616501ecbf9d6262 100644 --- a/internal/ui/chat/messages.go +++ b/internal/ui/chat/messages.go @@ -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: diff --git a/internal/ui/chat/prefix_cache_test.go b/internal/ui/chat/prefix_cache_test.go index 443f5a68dab73d8e518b37d867fc9aa8c758ca25..2ecfc83f9a5f1c635d130ca1da7ca9559af5c988 100644 --- a/internal/ui/chat/prefix_cache_test.go +++ b/internal/ui/chat/prefix_cache_test.go @@ -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) diff --git a/internal/ui/chat/version_bump_test.go b/internal/ui/chat/version_bump_test.go index 47ce7c5ed8ee615fb57d73df15260b9cddab0482..a65155f72f25c1e2dad9c2182335517e54ada724 100644 --- a/internal/ui/chat/version_bump_test.go +++ b/internal/ui/chat/version_bump_test.go @@ -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", diff --git a/internal/ui/dialog/actions.go b/internal/ui/dialog/actions.go index 4ec9a59316a703ab49ba571cbf29446d6af3829d..09a3e5b5a0eb267727e67cdf06199e26ef63337c 100644 --- a/internal/ui/dialog/actions.go +++ b/internal/ui/dialog/actions.go @@ -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 diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index 185185e5ea3ffc15744d75e605bf8b17b94a8081..ad0eede97c1a17f3cd986da2520e03a8b52d76ee 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -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 { diff --git a/internal/ui/dialog/commands_item.go b/internal/ui/dialog/commands_item.go index 8f656fb388e49eaf9d4b419142c936b4305b1945..6df6b6950041539a70724ac9f3bc2fe786f722ae 100644 --- a/internal/ui/dialog/commands_item.go +++ b/internal/ui/dialog/commands_item.go @@ -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 } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index df5cd26a7a91fdfb3fa674647aa434d59d20cb97..0b103b266e607a367d105cf9404132d9ffdac524 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -317,6 +317,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, @@ -1551,6 +1552,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() @@ -3136,12 +3140,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() { diff --git a/internal/ui/styles/quickstyle.go b/internal/ui/styles/quickstyle.go index b631dceff8041af6844586a1cf4585bd140c8b41..4600ea9fe8692d8c7175336379e8080270c46a42 100644 --- a/internal/ui/styles/quickstyle.go +++ b/internal/ui/styles/quickstyle.go @@ -923,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) diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 20bb5d2424e774858045cdbf1bae836a518cde9e..f0a08537352eac2ee821265db5b76457bc4402d2 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -43,6 +43,7 @@ const ( ImageIcon string = "■" TextIcon string = "≡" + SkillIcon string = "▲" ScrollbarThumb string = "┃" ScrollbarTrack string = "│" @@ -502,6 +503,7 @@ type Styles struct { Normal lipgloss.Style Image lipgloss.Style Text lipgloss.Style + Skill lipgloss.Style Deleting lipgloss.Style } diff --git a/internal/workspace/app_workspace.go b/internal/workspace/app_workspace.go index d4e5ed790e3a1cf7a4bcc299e4e4e63bedfbacd5..17d5903793d3a42b38014d122be0c8d11216d803 100644 --- a/internal/workspace/app_workspace.go +++ b/internal/workspace/app_workspace.go @@ -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 @@ -304,6 +305,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 { diff --git a/internal/workspace/client_workspace.go b/internal/workspace/client_workspace.go index 6b3fc362ea923b2092c22ba65de88a92b8675ba2..a959cb3842b891000195f7b51dcd0e2a7e0b240e 100644 --- a/internal/workspace/client_workspace.go +++ b/internal/workspace/client_workspace.go @@ -483,6 +483,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 { diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index 02c54c616f3251140bbee441451c3a4cb14845bd..0434ba21512ea9b732d6b94edb3015a1cd26a1e6 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -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 @@ -127,6 +128,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 From 66795b1ec57abeef4ecff1f4cf3e8b040687427c Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Fri, 22 May 2026 17:26:41 -0400 Subject: [PATCH 24/34] fix(tests): fix flaky async windows test --- internal/shell/background_test.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/internal/shell/background_test.go b/internal/shell/background_test.go index 9926fb1fd94241d1ea8a2411a8a570c0a1018386..063f5c31b3daaf3f75fc7a99604561cfcad07f9c 100644 --- a/internal/shell/background_test.go +++ b/internal/shell/background_test.go @@ -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) From 5cbf053c311d0ad6c287deff4317e9f476ffd9c4 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Sun, 24 May 2026 05:44:25 -0300 Subject: [PATCH 25/34] chore(legal): @officialasishkumar has signed the CLA --- .github/cla-signatures.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json index 3e61650b0fb1907a6acadcee1c01ef5b4eafc48a..0fdbe69fea28484025ff561bf4634c716b4c16b7 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1839,6 +1839,14 @@ "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 } ] } \ No newline at end of file From 1ceeaa93270b32dd3b8775fc3178263238dd6559 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Sun, 24 May 2026 07:19:38 -0300 Subject: [PATCH 26/34] chore(legal): @yhyu13 has signed the CLA --- .github/cla-signatures.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json index 0fdbe69fea28484025ff561bf4634c716b4c16b7..9fa832587109ac17e8c8fbd22ab33bc3ad8b1ac4 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1847,6 +1847,14 @@ "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 } ] } \ No newline at end of file From 92cfeea1b251cd111b8cc969f7dfff351cc8c8e4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 10:31:27 -0300 Subject: [PATCH 27/34] chore(deps): bump the all group with 2 updates (#3003) --- .github/workflows/security.yml | 10 +++++----- .github/workflows/snapshot.yml | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 2dbc53ec3b8b254cbeeb04ab71a4986d74c97656..422c2c324bc888f5daabf9956edc54d915f327fb 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -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 diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml index 353b1a8d8b4803cb29668ece79272c5c07bd5217..6ab0ee3f4142e05405cd4be2d5b61f7e089078ab 100644 --- a/.github/workflows/snapshot.yml +++ b/.github/workflows/snapshot.yml @@ -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 From 229af09f4264516d13a58613f21a77e6dabe65c8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 10:34:21 -0300 Subject: [PATCH 28/34] chore(deps): bump the all group with 5 updates (#3004) --- go.mod | 12 ++++++------ go.sum | 24 ++++++++++++------------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/go.mod b/go.mod index 79ef0226a911b9fc779f9b9a397dc22e51f858f9..65618d4483edf6daa94a99c776b7a4e4a48107a6 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.26.3 require ( charm.land/bubbles/v2 v2.1.0 charm.land/bubbletea/v2 v2.0.6 - charm.land/catwalk v0.41.7 + charm.land/catwalk v0.41.8 charm.land/fang/v2 v2.0.1 charm.land/fantasy v0.25.2 charm.land/glamour/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 @@ -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 diff --git a/go.sum b/go.sum index 56cb04ebd7694928ee0d27f9c743f409398b1a33..fc5bb32dbe1b588b93d3a202051c23b006c1aab9 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ 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.7 h1:zUlnSxJGaw0c3UWnzbX/oP9qqw5KLwP1qCbEvL/Skeg= -charm.land/catwalk v0.41.7/go.mod h1:dtK2+UfdsFJgIriRPodMsSJw0XefrFOq6fdvuS57v3s= +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.2 h1:K7ZOM3UEay//NHfiFAeIMRaOqhspxe0UyccIJOYrjuo= @@ -182,8 +182,8 @@ 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-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= @@ -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= @@ -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= From 092ac3e73a100d3ae7ec7fc16306de3fe5476f31 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Mon, 25 May 2026 14:05:55 -0300 Subject: [PATCH 29/34] feat(ui): add scrollbar to sessions dialog (#3005) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 💘 Generated with Crush Assisted-by: Crush:qwen3.7-max --- internal/ui/dialog/sessions.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/internal/ui/dialog/sessions.go b/internal/ui/dialog/sessions.go index 8f3ce81960e5170c0059d44261c1e79ca2bbfea8..3b0a033218a1dafa547b8c6b016b0123b48b97f5 100644 --- a/internal/ui/dialog/sessions.go +++ b/internal/ui/dialog/sessions.go @@ -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) From ba692cc55a73e027b9ea5805bac63fd8b3936400 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Fri, 15 May 2026 17:05:02 -0300 Subject: [PATCH 30/34] feat(ui): auto-expand pills when terminal height is sufficient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The todo/queue panel now opens automatically when the terminal is tall enough (≥40 rows) and there are active items, eliminating the need for a manual ctrl+t toggle in typical desktop use. 💘 Generated with Crush Assisted-by: Crush:kimi-k2.6 --- internal/ui/model/layout_test.go | 107 +++++++++++++++++++++++++++++++ internal/ui/model/pills.go | 35 +++++++++- internal/ui/model/ui.go | 4 ++ 3 files changed, 145 insertions(+), 1 deletion(-) diff --git a/internal/ui/model/layout_test.go b/internal/ui/model/layout_test.go index ea4c33f1c3ce459054b01e66a1e32f4d8cc031de..36e25485691069ddc33c6691426959ee67eee00f 100644 --- a/internal/ui/model/layout_test.go +++ b/internal/ui/model/layout_test.go @@ -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") + } + }) +} diff --git a/internal/ui/model/pills.go b/internal/ui/model/pills.go index bd0ac9d0294cd215bd4b388728a36b1c1f015f63..6bbfc34463a20658411fb74e847e87e4a7c2841b 100644 --- a/internal/ui/model/pills.go +++ b/internal/ui/model/pills.go @@ -134,6 +134,39 @@ 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 + } + m.pillsExpanded = 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 +282,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) } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 0b103b266e607a367d105cf9404132d9ffdac524..078f051ce1c11adfbe76de103933a200078ee153 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -537,6 +537,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() { @@ -612,6 +615,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. From db9d9399d31aa883cb74f688d8dafc2f067188ce Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Fri, 22 May 2026 15:45:13 -0300 Subject: [PATCH 31/34] fix(ui): only auto-expand pills once per session lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auto-expand logic now fires only when the todo/queue panel is first created, not on subsequent session updates. A `pillsAutoExpanded` guard flag prevents re-triggering until the next session. 💘 Generated with Crush Assisted-by: Crush:kimi-k2.6 --- internal/ui/model/pills.go | 4 ++++ internal/ui/model/ui.go | 2 ++ 2 files changed, 6 insertions(+) diff --git a/internal/ui/model/pills.go b/internal/ui/model/pills.go index 6bbfc34463a20658411fb74e847e87e4a7c2841b..ae3b09725884a3cb33c42bd363c8f7acceaaae62 100644 --- a/internal/ui/model/pills.go +++ b/internal/ui/model/pills.go @@ -154,7 +154,11 @@ func (m *UI) autoExpandPillsIfReasonable() tea.Cmd { 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 { diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 078f051ce1c11adfbe76de103933a200078ee153..6ae48007848ea52f82eba8a0167f6ceb474ed92d 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -259,6 +259,7 @@ type UI struct { // pills state pillsExpanded bool + pillsAutoExpanded bool focusedPillSection pillSection promptQueue int pillsView string @@ -3500,6 +3501,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() From 2b941aaa0d3b983e0c64e92b3b04a0e4ba855747 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Mon, 25 May 2026 11:13:09 -0300 Subject: [PATCH 32/34] feat(ui): add ctrl+y keybinding to toggle yolo mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 💘 Generated with Crush Assisted-by: Crush:qwen3.7-max --- internal/ui/dialog/commands.go | 2 +- internal/ui/model/keys.go | 19 ++++++++++++------- internal/ui/model/ui.go | 7 +++++++ 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index ad0eede97c1a17f3cd986da2520e03a8b52d76ee..b4c2606bfac3089adda8dd39a7161a7caa0e6ab7 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -522,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{}), ) diff --git a/internal/ui/model/keys.go b/internal/ui/model/keys.go index f623a122a4aecf638c98a231d416781399ffd1a7..ebf377035ef11d6fa733ae1fbc1e0fb92123c9ae 100644 --- a/internal/ui/model/keys.go +++ b/internal/ui/model/keys.go @@ -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( diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 6ae48007848ea52f82eba8a0167f6ceb474ed92d..d5608141c19b80063c5beddfc9de8a3b9e13bbc0 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -1805,6 +1805,11 @@ 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) + return true } return false } @@ -2410,6 +2415,7 @@ func (m *UI) FullHelp() [][]key.Binding { commands, k.Models, k.Sessions, + k.ToggleYolo, ) if hasSession { mainBinds = append(mainBinds, k.Chat.NewSession) @@ -2471,6 +2477,7 @@ func (m *UI) FullHelp() [][]key.Binding { commands, k.Models, k.Sessions, + k.ToggleYolo, }, ) editorBinds := []key.Binding{ From 8ad3f5bf3a9fc482c339aad5069569988f8fe45d Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Mon, 25 May 2026 13:05:17 -0400 Subject: [PATCH 33/34] feat(ui): show notification when toggling yolo mode (#3008) Display "Yolo mode enabled/disabled" via ReportInfo so the user gets immediate visual feedback after pressing ctrl+y. Assisted-by: Crush:claude-opus-4-6 --- internal/ui/model/ui.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index d5608141c19b80063c5beddfc9de8a3b9e13bbc0..93d970a943ba15dcd694f60418be1658a9eec332 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -1809,6 +1809,11 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { 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