Merge branch 'main' into multi-client

Christian Rocha created

Change summary

.github/cla-signatures.json            |  40 +++
.github/workflows/security.yml         |  10 
.github/workflows/snapshot.yml         |   2 
go.mod                                 |  36 +-
go.sum                                 |  84 +++---
internal/agent/agent.go                |  58 +++-
internal/agent/coordinator.go          |  13 
internal/agent/coordinator_test.go     |   6 
internal/agent/hyper/provider.json     | 114 ++++++---
internal/agent/usage_fallback.go       | 176 ++++++++++++++
internal/agent/usage_fallback_test.go  | 339 ++++++++++++++++++++++++++++
internal/backend/backend.go            |   9 
internal/backend/config.go             |  44 +++
internal/client/config.go              |  36 ++
internal/cmd/root.go                   |  10 
internal/config/load.go                |  11 
internal/config/load_test.go           |  23 -
internal/config/provider.go            |  12 
internal/config/store_test.go          |   9 
internal/db/connect.go                 |   1 
internal/message/attachment.go         |   5 
internal/proto/proto.go                |  29 ++
internal/server/config.go              |  51 ++++
internal/server/server.go              |   2 
internal/session/session.go            |  52 +++
internal/session/session_test.go       |  81 ++++++
internal/shell/background_test.go      |   8 
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/user.go               |   9 
internal/ui/chat/version_bump_test.go  |   2 
internal/ui/common/elements.go         |  22 +
internal/ui/common/elements_test.go    |  35 ++
internal/ui/common/scrollbar.go        |   2 
internal/ui/dialog/actions.go          |   6 
internal/ui/dialog/commands.go         |  21 +
internal/ui/dialog/commands_item.go    |  55 +++-
internal/ui/dialog/models.go           |  34 +-
internal/ui/dialog/models_list.go      |  15 
internal/ui/dialog/sessions.go         |   9 
internal/ui/list/list.go               |  27 ++
internal/ui/model/header.go            |   6 
internal/ui/model/keys.go              |  19 
internal/ui/model/layout_test.go       | 107 ++++++++
internal/ui/model/pills.go             |  39 +++
internal/ui/model/sidebar.go           |   7 
internal/ui/model/ui.go                |  47 +++
internal/ui/styles/quickstyle.go       |   2 
internal/ui/styles/styles.go           |  23 +
internal/workspace/app_workspace.go    |  11 
internal/workspace/client_workspace.go |  31 ++
internal/workspace/workspace.go        |   3 
55 files changed, 1,760 insertions(+), 269 deletions(-)

Detailed changes

.github/cla-signatures.json 🔗

@@ -1815,6 +1815,46 @@
       "created_at": "2026-05-20T06:53:43Z",
       "repoId": 987670088,
       "pullRequestNo": 2964
+    },
+    {
+      "name": "g2mt",
+      "id": 166577174,
+      "comment_id": 4513096006,
+      "created_at": "2026-05-21T21:56:14Z",
+      "repoId": 987670088,
+      "pullRequestNo": 2979
+    },
+    {
+      "name": "Ricardo-M-L",
+      "id": 69202550,
+      "comment_id": 4514494248,
+      "created_at": "2026-05-22T02:37:08Z",
+      "repoId": 987670088,
+      "pullRequestNo": 2847
+    },
+    {
+      "name": "Muttaqin86",
+      "id": 69788027,
+      "comment_id": 4520181780,
+      "created_at": "2026-05-22T15:46:32Z",
+      "repoId": 987670088,
+      "pullRequestNo": 2984
+    },
+    {
+      "name": "officialasishkumar",
+      "id": 87874775,
+      "comment_id": 4527874300,
+      "created_at": "2026-05-24T08:44:14Z",
+      "repoId": 987670088,
+      "pullRequestNo": 2995
+    },
+    {
+      "name": "yhyu13",
+      "id": 19365678,
+      "comment_id": 4528076561,
+      "created_at": "2026-05-24T10:19:27Z",
+      "repoId": 987670088,
+      "pullRequestNo": 2996
     }
   ]
 }

.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
 

.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

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.8
 	charm.land/fang/v2 v2.0.1
-	charm.land/fantasy v0.25.0
+	charm.land/fantasy v0.25.2
 	charm.land/glamour/v2 v2.0.0
 	charm.land/lipgloss/v2 v2.0.3
 	charm.land/log/v2 v2.0.0
@@ -41,7 +41,7 @@ require (
 	github.com/disintegration/imaging v1.6.2
 	github.com/dustin/go-humanize v1.0.1
 	github.com/gen2brain/beeep v0.11.2
-	github.com/go-git/go-git/v5 v5.19.0
+	github.com/go-git/go-git/v5 v5.19.1
 	github.com/google/uuid v1.6.0
 	github.com/invopop/jsonschema v0.14.0
 	github.com/itchyny/gojq v0.12.19
@@ -49,11 +49,11 @@ require (
 	github.com/jordanella/go-ansi-paintbrush v0.0.0-20240728195301-b7ad996ecf3d
 	github.com/lucasb-eyer/go-colorful v1.4.0
 	github.com/mattn/go-isatty v0.0.22
-	github.com/modelcontextprotocol/go-sdk v1.6.0
-	github.com/ncruces/go-sqlite3 v0.34.1
+	github.com/modelcontextprotocol/go-sdk v1.6.1
+	github.com/ncruces/go-sqlite3 v0.34.2
 	github.com/nxadm/tail v1.4.11
 	github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
-	github.com/posthog/posthog-go v1.12.5
+	github.com/posthog/posthog-go v1.12.6
 	github.com/pressly/goose/v3 v3.27.1
 	github.com/qjebbs/go-jsons v1.0.0-alpha.5
 	github.com/rivo/uniseg v0.4.7
@@ -67,9 +67,9 @@ require (
 	github.com/tidwall/sjson v1.2.5
 	github.com/zeebo/xxh3 v1.1.0
 	go.uber.org/goleak v1.3.0
-	golang.org/x/net v0.54.0
+	golang.org/x/net v0.55.0
 	golang.org/x/sync v0.20.0
-	golang.org/x/sys v0.44.0
+	golang.org/x/sys v0.45.0
 	golang.org/x/text v0.37.0
 	gopkg.in/natefinch/lumberjack.v2 v2.2.1
 	gopkg.in/yaml.v3 v3.0.1
@@ -120,7 +120,7 @@ require (
 	github.com/fsnotify/fsnotify v1.9.0 // indirect
 	github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
 	github.com/go-git/go-billy/v5 v5.9.0 // indirect
-	github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4 // indirect
+	github.com/go-json-experiment/json v0.0.0-20260505212615-e40f80bf6836 // indirect
 	github.com/go-logfmt/logfmt v0.6.0 // indirect
 	github.com/go-logr/logr v1.4.3 // indirect
 	github.com/go-logr/stdr v1.2.2 // indirect
@@ -147,10 +147,10 @@ require (
 	github.com/jackmordaunt/icns/v3 v3.0.1 // indirect
 	github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
 	github.com/josharian/intern v1.0.0 // indirect
-	github.com/kaptinlin/go-i18n v0.4.5 // indirect
-	github.com/kaptinlin/jsonpointer v0.4.20 // indirect
-	github.com/kaptinlin/jsonschema v0.7.13 // indirect
-	github.com/kaptinlin/messageformat-go v0.6.0 // indirect
+	github.com/kaptinlin/go-i18n v0.4.8 // indirect
+	github.com/kaptinlin/jsonpointer v0.4.23 // indirect
+	github.com/kaptinlin/jsonschema v0.7.14 // indirect
+	github.com/kaptinlin/messageformat-go v0.6.4 // indirect
 	github.com/klauspost/compress v1.18.6 // indirect
 	github.com/klauspost/cpuid/v2 v2.3.0 // indirect
 	github.com/klauspost/pgzip v1.2.6 // indirect
@@ -164,7 +164,7 @@ require (
 	github.com/muesli/mango-cobra v1.2.0 // indirect
 	github.com/muesli/mango-pflag v0.1.0 // indirect
 	github.com/muesli/roff v0.1.0 // indirect
-	github.com/ncruces/go-sqlite3-wasm/v2 v2.2.35301 // indirect
+	github.com/ncruces/go-sqlite3-wasm/v2 v2.4.35301 // indirect
 	github.com/ncruces/go-strftime v1.0.0 // indirect
 	github.com/ncruces/julianday v1.0.0 // indirect
 	github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
@@ -205,10 +205,10 @@ require (
 	golang.org/x/term v0.43.0 // indirect
 	golang.org/x/time v0.15.0 // indirect
 	golang.org/x/tools v0.44.0 // indirect
-	google.golang.org/api v0.278.0 // indirect
-	google.golang.org/genai v1.56.0 // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 // indirect
-	google.golang.org/grpc v1.81.0 // indirect
+	google.golang.org/api v0.279.0 // indirect
+	google.golang.org/genai v1.57.0 // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect
+	google.golang.org/grpc v1.81.1 // indirect
 	google.golang.org/protobuf v1.36.11 // indirect
 	gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290 // indirect
 	gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect

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.8 h1:SxM6KyFD5jtBF2lZZKk6cYCbw1GVlNfn05ZSRtynxEE=
+charm.land/catwalk v0.41.8/go.mod h1:dtK2+UfdsFJgIriRPodMsSJw0XefrFOq6fdvuS57v3s=
 charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY=
 charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII=
-charm.land/fantasy v0.25.0 h1:oXOWY1ivmTSnhYGzAolscF8zKtavWZyBWv0LHRSwN5Q=
-charm.land/fantasy v0.25.0/go.mod h1:8QrWUzIcKwZQP+aAnC9vLu3iID6hu9/Jt+rPMiieBkc=
+charm.land/fantasy v0.25.2 h1:K7ZOM3UEay//NHfiFAeIMRaOqhspxe0UyccIJOYrjuo=
+charm.land/fantasy v0.25.2/go.mod h1:9ykD5gjn8BCjpZqA66vet7H1KsmR+kP0Q0qw1FiqCk0=
 charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U=
 charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w=
 charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
@@ -182,10 +182,10 @@ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66D
 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
 github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA=
 github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw=
-github.com/go-git/go-git/v5 v5.19.0 h1:+WkVUQZSy/F1Gb13udrMKjIM2PrzsNfDKFSfo5tkMtc=
-github.com/go-git/go-git/v5 v5.19.0/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ=
-github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4 h1:2WmHkJINIjgXXYDGik8d3oJvFA3DAwPy00csDJ3vo+o=
-github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
+github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00=
+github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ=
+github.com/go-json-experiment/json v0.0.0-20260505212615-e40f80bf6836 h1:5KGUhXZFTN1PrCY4zUZLe1J8n7uBNmPDbCLCn78EbPQ=
+github.com/go-json-experiment/json v0.0.0-20260505212615-e40f80bf6836/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
 github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
 github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -264,14 +264,14 @@ github.com/jordanella/go-ansi-paintbrush v0.0.0-20240728195301-b7ad996ecf3d h1:o
 github.com/jordanella/go-ansi-paintbrush v0.0.0-20240728195301-b7ad996ecf3d/go.mod h1:SV0W0APWP9MZ1/gfDQ/NzzTlWdIgYZ/ZbpN4d/UXRYw=
 github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
 github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
-github.com/kaptinlin/go-i18n v0.4.5 h1:9tIlo5A0RXth+yZJO2MG7Bhpu/X9PlzQnGz/qyYWNoY=
-github.com/kaptinlin/go-i18n v0.4.5/go.mod h1:mU/7BH4molY5lGZYBwBRKAaiJ70dWRHuqmQ0/pFLGno=
-github.com/kaptinlin/jsonpointer v0.4.20 h1:otSZZnCVdVo9OwOm+AQhS8ke31CLLQYXmG5Q0GOrXYg=
-github.com/kaptinlin/jsonpointer v0.4.20/go.mod h1:Mo7+DX8RlQTFqS4dnYJl0izSP4ob+Rl5xO/mGDETgaU=
-github.com/kaptinlin/jsonschema v0.7.13 h1:kahVXTy/rURL0XJjyQ9WELm59wEmXi6IY0TWswQEFvU=
-github.com/kaptinlin/jsonschema v0.7.13/go.mod h1:Uh0aUBusnhXDCEXJ2oimL/hx7YTo7F+sKniE+tM0ERc=
-github.com/kaptinlin/messageformat-go v0.6.0 h1:D6jiXFsKW4/JG2CMddv/F6Rev9KVbCRKEzzV5QOAcpc=
-github.com/kaptinlin/messageformat-go v0.6.0/go.mod h1:NKjwS6e9u7DRhAK+vydjDDwJ7UbdHhYjk/yk2WPuZPs=
+github.com/kaptinlin/go-i18n v0.4.8 h1:ymGkz0uU974wljuuHZufHP1BlFWVk5Tf/sSMO8Cl9yQ=
+github.com/kaptinlin/go-i18n v0.4.8/go.mod h1:F+ezt0Q39p5x8PQW6p4xKMovhjNbhZYHuqwyEV1hHMw=
+github.com/kaptinlin/jsonpointer v0.4.23 h1:0VisnCL7rJT7BRTwxSWMU7vC0PD/RFgmisNcURkWp3k=
+github.com/kaptinlin/jsonpointer v0.4.23/go.mod h1:Mo7+DX8RlQTFqS4dnYJl0izSP4ob+Rl5xO/mGDETgaU=
+github.com/kaptinlin/jsonschema v0.7.14 h1:6grzaTJiRuLXlIGEdlGX5HEII3Za2tV+xxGpW3Kg4Rc=
+github.com/kaptinlin/jsonschema v0.7.14/go.mod h1:9WFuBzJjrvNkXVjo0L2Ujl1T/yqAGurwgbx4JWgF5C8=
+github.com/kaptinlin/messageformat-go v0.6.4 h1:6nC70fsqEn2xxg/Xoby2+Dk2r77kvxa3QNnYL/hsNcM=
+github.com/kaptinlin/messageformat-go v0.6.4/go.mod h1:553UGZ1x5jmGtyH4pQKYwLGMyPm71deCoZICjq1DtR8=
 github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
 github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
 github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
@@ -304,8 +304,8 @@ github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwX
 github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
 github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
 github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
-github.com/modelcontextprotocol/go-sdk v1.6.0 h1:PPLS3kn7WtOEnR+Af4X5H96SG0qSab8R/ZQT/HkhPkY=
-github.com/modelcontextprotocol/go-sdk v1.6.0/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
+github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
+github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
 github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
 github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
 github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI=
@@ -316,10 +316,10 @@ github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe
 github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=
 github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
 github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
-github.com/ncruces/go-sqlite3 v0.34.1 h1:N4NU/MqvZtSseGyTzJXdFI8RoVIR0lWUYeainj4pX2o=
-github.com/ncruces/go-sqlite3 v0.34.1/go.mod h1:QuztC+fMQvmDSPEk3E807xYbAVZGsxnismh23mzZPhM=
-github.com/ncruces/go-sqlite3-wasm/v2 v2.2.35301 h1:PPb3vECU21cZr2CrzYG7idXLBpWETDcdVEnXg3vqRpM=
-github.com/ncruces/go-sqlite3-wasm/v2 v2.2.35301/go.mod h1:q34C+veYmxQ7XRFgCKSeD6hq5vP/C517Hkl4iu+A5ao=
+github.com/ncruces/go-sqlite3 v0.34.2 h1:+B50kRdn2BfMTSoRbkgnNaIolxIq1qS6lhcXyvNe230=
+github.com/ncruces/go-sqlite3 v0.34.2/go.mod h1:ZUqB9w9k4ACD7X5YeISBY05glvkgTur3dwhoDFGASK4=
+github.com/ncruces/go-sqlite3-wasm/v2 v2.4.35301 h1:xGFgiIf1SS4yTqyuW3cSR6hd9KRlUFzVloJ873AyrxU=
+github.com/ncruces/go-sqlite3-wasm/v2 v2.4.35301/go.mod h1:7dV8P4xml/vrgb/zKfJaZ5aas5el3VyBR28XkpBq5NM=
 github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
 github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
 github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
@@ -333,8 +333,8 @@ github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
 github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
 github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY=
 github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ=
-github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
-github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
+github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
 github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
 github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
 github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
@@ -349,8 +349,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/posthog/posthog-go v1.12.5 h1:l/x3mpqisXJ0sTOyyRutsTQAgiWYuJT1uhN4cQraJ8o=
-github.com/posthog/posthog-go v1.12.5/go.mod h1:xsVOW9YImilUcazwPNEq4PJDqEZf2KeCS758zXjwkPg=
+github.com/posthog/posthog-go v1.12.6 h1:N+FrKWY6DOuDhV2OMgvtKAKDYGTdtS9/nuvr0BTyBp0=
+github.com/posthog/posthog-go v1.12.6/go.mod h1:xsVOW9YImilUcazwPNEq4PJDqEZf2KeCS758zXjwkPg=
 github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
 github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
 github.com/qjebbs/go-jsons v1.0.0-alpha.5 h1:U2PPDxeKI1MMOSw7e7xyxhwH9Ggc7UrDvaRIkJ+l0n8=
@@ -488,8 +488,8 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
 golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
 golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
 golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
-golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
-golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
+golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
+golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
 golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
 golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -516,8 +516,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
-golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
+golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
 golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -555,18 +555,18 @@ golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
 gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
-google.golang.org/api v0.278.0 h1:W7jiRvRi53VYFfZ/HoZjQBtJk7gOFbHD8ot1RzVZU6E=
-google.golang.org/api v0.278.0/go.mod h1:B9TqLBwJqVjp1mtt7WeoQwWRwvu/400y5lETOql+giQ=
-google.golang.org/genai v1.56.0 h1:IwWrg1K0cn1/WBiPno/dYr0Q6o75NeH/bh3G4JEFERE=
-google.golang.org/genai v1.56.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
-google.golang.org/genproto v0.0.0-20260504160031-60b97b32f348 h1:JjVGDZYWkJWZcxveJGzfkXC5myDVWAd4dZdgbzrDUv8=
-google.golang.org/genproto v0.0.0-20260504160031-60b97b32f348/go.mod h1:95PqD4xM+AdOcBGsmgfaofXsiA37uXDtDufVbntT3TU=
-google.golang.org/genproto/googleapis/api v0.0.0-20260504160031-60b97b32f348 h1:U8orV30l6KpDsi9dxU0CoJZGbjS8EEpw+6ba+XwGPQA=
-google.golang.org/genproto/googleapis/api v0.0.0-20260504160031-60b97b32f348/go.mod h1:Yzdzr5OOZFgSsEV2D/Xi9NL3bszpXFAg0hFJiRohcD8=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 h1:pfIbyB44sWzHiCpRqIen67ZQnVXSfIxWrqUMk1qwODE=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
-google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw=
-google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
+google.golang.org/api v0.279.0 h1:hsx2M2OaRcaKtVYK6vXEUnQvdjnend7ZYES+lYaot74=
+google.golang.org/api v0.279.0/go.mod h1:B9TqLBwJqVjp1mtt7WeoQwWRwvu/400y5lETOql+giQ=
+google.golang.org/genai v1.57.0 h1:qTyG2ynz5dQy2jF4CvZdLHHVslhR0heMue+zM1a4GNM=
+google.golang.org/genai v1.57.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
+google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60 h1:rhBdfmsOlOZIvz3Y5/BdUzPg2CkO8L7QQPKj96B8554=
+google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60/go.mod h1:8xo2Pj1b20ZOCpzlU3B9qieMwVIAXx1QVZWLMlPL6sM=
+google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 h1:3WsB1FAbiRIf2tOxscWKs3pQBD9he1NsrnbhMuWfekc=
+google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60/go.mod h1:7yoXV7RIh5gblj/xVYoogxAWvA9wUeVbpsK/M694l00=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
+google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
 google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
 google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

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,13 +755,14 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan
 		}
 	}
 
-	a.updateSessionUsage(largeModel, &currentSession, resp.TotalUsage, openrouterCost)
+	a.updateSessionUsage(largeModel, &currentSession, resp.TotalUsage, openrouterCost, false)
 
 	// Just in case, get just the last usage info.
 	usage := resp.Response.Usage
 	currentSession.SummaryMessageID = summaryMessage.ID
-	currentSession.CompletionTokens = usage.OutputTokens
+	currentSession.CompletionTokens = summaryCompletionTokens(usage, summaryMessage)
 	currentSession.PromptTokens = 0
+	currentSession.EstimatedUsage = usageIsZero(usage)
 	_, err = a.sessions.Save(genCtx, currentSession)
 	if err != nil {
 		return err
@@ -1132,28 +1139,53 @@ func (a *sessionAgent) openrouterCost(metadata fantasy.ProviderMetadata) *float6
 	return &opts.Usage.Cost
 }
 
-func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session, usage fantasy.Usage, overrideCost *float64) {
+func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session, usage fantasy.Usage, overrideCost *float64, estimated bool) {
+	if !usageIsZero(usage) {
+		session.EstimatedUsage = estimated
+	}
+
 	modelConfig := model.CatwalkCfg
 	cost := modelConfig.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) +
 		modelConfig.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) +
 		modelConfig.CostPer1MIn/1e6*float64(usage.InputTokens) +
 		modelConfig.CostPer1MOut/1e6*float64(usage.OutputTokens)
 
-	a.eventTokensUsed(session.ID, model, usage, cost)
-
-	// Use override cost if available (e.g., from OpenRouter).
-	if overrideCost != nil {
-		cost = *overrideCost
+	if !estimated {
+		a.eventTokensUsed(session.ID, model, usage, cost)
 	}
 
-	// Skip cost accumulation
-	if model.FlatRate {
+	if estimated {
 		cost = 0
+	} else {
+		// Use override cost if available (e.g., from OpenRouter).
+		if overrideCost != nil {
+			cost = *overrideCost
+		}
+
+		// Skip cost accumulation
+		if model.FlatRate {
+			cost = 0
+		}
 	}
 
 	session.Cost += cost
-	session.CompletionTokens = usage.OutputTokens
-	session.PromptTokens = usage.InputTokens + usage.CacheReadTokens
+	updateSessionTokenCounters(session, usage)
+}
+
+func updateSessionTokenCounters(session *session.Session, usage fantasy.Usage) {
+	if usage.OutputTokens != 0 {
+		session.CompletionTokens = usage.OutputTokens
+	}
+	if promptTokens := usage.InputTokens + usage.CacheReadTokens; promptTokens != 0 {
+		session.PromptTokens = promptTokens
+	}
+}
+
+func summaryCompletionTokens(usage fantasy.Usage, summaryMessage message.Message) int64 {
+	if usage.OutputTokens != 0 {
+		return usage.OutputTokens
+	}
+	return approxTokenCount(summaryMessage.Content().Text) + approxTokenCount(summaryMessage.ReasoningContent().String())
 }
 
 func (a *sessionAgent) Cancel(sessionID string) {

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}

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",

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,

internal/agent/usage_fallback.go 🔗

@@ -0,0 +1,176 @@
+package agent
+
+import (
+	"fmt"
+
+	"charm.land/fantasy"
+)
+
+func usageIsZero(usage fantasy.Usage) bool {
+	return usage.InputTokens == 0 &&
+		usage.OutputTokens == 0 &&
+		usage.TotalTokens == 0 &&
+		usage.ReasoningTokens == 0 &&
+		usage.CacheCreationTokens == 0 &&
+		usage.CacheReadTokens == 0
+}
+
+func fallbackStepUsage(messages []fantasy.Message, step fantasy.StepResult) (fantasy.Usage, bool) {
+	if !usageIsZero(step.Usage) {
+		return step.Usage, false
+	}
+
+	inputTokens := estimateMessageTokens(messages)
+	outputTokens := estimateStepCompletionTokens(step)
+	if inputTokens == 0 && outputTokens == 0 {
+		return fantasy.Usage{}, false
+	}
+
+	return fantasy.Usage{
+		InputTokens:  inputTokens,
+		OutputTokens: outputTokens,
+		TotalTokens:  inputTokens + outputTokens,
+	}, true
+}
+
+func cloneFantasyMessages(messages []fantasy.Message) []fantasy.Message {
+	cloned := make([]fantasy.Message, len(messages))
+	for i, msg := range messages {
+		cloned[i] = msg
+		cloned[i].Content = append([]fantasy.MessagePart(nil), msg.Content...)
+	}
+	return cloned
+}
+
+func estimateMessageTokens(messages []fantasy.Message) int64 {
+	var tokens int64
+	for _, msg := range messages {
+		tokens += approxTokenCount(string(msg.Role))
+		for _, part := range msg.Content {
+			tokens += estimateMessagePartTokens(part)
+		}
+	}
+	return tokens
+}
+
+func estimateStepCompletionTokens(step fantasy.StepResult) int64 {
+	var tokens int64
+	for _, content := range step.Content {
+		switch c := content.(type) {
+		case fantasy.TextContent:
+			tokens += approxTokenCount(c.Text)
+		case *fantasy.TextContent:
+			tokens += approxTokenCount(c.Text)
+		case fantasy.ReasoningContent:
+			tokens += approxTokenCount(c.Text)
+		case *fantasy.ReasoningContent:
+			tokens += approxTokenCount(c.Text)
+		case fantasy.FileContent:
+			tokens += estimateGeneratedFileTokens(c)
+		case *fantasy.FileContent:
+			tokens += estimateGeneratedFileTokens(*c)
+		case fantasy.SourceContent:
+			tokens += estimateSourceTokens(c)
+		case *fantasy.SourceContent:
+			tokens += estimateSourceTokens(*c)
+		case fantasy.ToolCallContent:
+			tokens += estimateToolCallTokens(c.ToolName, c.Input)
+		case *fantasy.ToolCallContent:
+			tokens += estimateToolCallTokens(c.ToolName, c.Input)
+		case fantasy.ToolResultContent:
+			if c.ProviderExecuted {
+				tokens += estimateToolResultContentTokens(c.ToolCallID, c.ToolName, c.ClientMetadata, c.Result)
+			}
+		case *fantasy.ToolResultContent:
+			if c.ProviderExecuted {
+				tokens += estimateToolResultContentTokens(c.ToolCallID, c.ToolName, c.ClientMetadata, c.Result)
+			}
+		}
+	}
+	return tokens
+}
+
+func estimateMessagePartTokens(part fantasy.MessagePart) int64 {
+	switch p := part.(type) {
+	case fantasy.TextPart:
+		return approxTokenCount(p.Text)
+	case *fantasy.TextPart:
+		return approxTokenCount(p.Text)
+	case fantasy.ReasoningPart:
+		return approxTokenCount(p.Text)
+	case *fantasy.ReasoningPart:
+		return approxTokenCount(p.Text)
+	case fantasy.FilePart:
+		return estimateFilePartTokens(p)
+	case *fantasy.FilePart:
+		return estimateFilePartTokens(*p)
+	case fantasy.ToolCallPart:
+		return estimateToolCallTokens(p.ToolName, p.Input)
+	case *fantasy.ToolCallPart:
+		return estimateToolCallTokens(p.ToolName, p.Input)
+	case fantasy.ToolResultPart:
+		return estimateToolResultContentTokens(p.ToolCallID, "", "", p.Output)
+	case *fantasy.ToolResultPart:
+		return estimateToolResultContentTokens(p.ToolCallID, "", "", p.Output)
+	default:
+		return 0
+	}
+}
+
+func estimateToolCallTokens(toolName, input string) int64 {
+	return approxTokenCount(toolName) + approxTokenCount(input)
+}
+
+func estimateToolResultContentTokens(toolCallID, toolName, metadata string, output fantasy.ToolResultOutputContent) int64 {
+	tokens := approxTokenCount(toolCallID) + approxTokenCount(toolName) + approxTokenCount(metadata)
+	switch result := output.(type) {
+	case fantasy.ToolResultOutputContentText:
+		tokens += approxTokenCount(result.Text)
+	case *fantasy.ToolResultOutputContentText:
+		tokens += approxTokenCount(result.Text)
+	case fantasy.ToolResultOutputContentError:
+		if result.Error != nil {
+			tokens += approxTokenCount(result.Error.Error())
+		}
+	case *fantasy.ToolResultOutputContentError:
+		if result.Error != nil {
+			tokens += approxTokenCount(result.Error.Error())
+		}
+	case fantasy.ToolResultOutputContentMedia:
+		tokens += estimateMediaTokens(result.MediaType, result.Text, len(result.Data))
+	case *fantasy.ToolResultOutputContentMedia:
+		tokens += estimateMediaTokens(result.MediaType, result.Text, len(result.Data))
+	}
+	return tokens
+}
+
+func estimateFilePartTokens(file fantasy.FilePart) int64 {
+	return estimateMediaTokens(file.MediaType, file.Filename, len(file.Data))
+}
+
+func estimateGeneratedFileTokens(file fantasy.FileContent) int64 {
+	return estimateMediaTokens(file.MediaType, "", len(file.Data))
+}
+
+func estimateMediaTokens(mediaType, text string, dataBytes int) int64 {
+	if dataBytes == 0 {
+		return approxTokenCount(mediaType) + approxTokenCount(text)
+	}
+	return approxTokenCount(fmt.Sprintf("%s %s %d bytes", mediaType, text, dataBytes))
+}
+
+func estimateSourceTokens(source fantasy.SourceContent) int64 {
+	return approxTokenCount(string(source.SourceType)) +
+		approxTokenCount(source.ID) +
+		approxTokenCount(source.URL) +
+		approxTokenCount(source.Title) +
+		approxTokenCount(source.MediaType) +
+		approxTokenCount(source.Filename)
+}
+
+func approxTokenCount(s string) int64 {
+	if s == "" {
+		return 0
+	}
+	return int64((len(s) + 3) / 4)
+}

internal/agent/usage_fallback_test.go 🔗

@@ -0,0 +1,339 @@
+package agent
+
+import (
+	"errors"
+	"testing"
+
+	"charm.land/catwalk/pkg/catwalk"
+	"charm.land/fantasy"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/session"
+	"github.com/stretchr/testify/require"
+)
+
+func TestUsageIsZero(t *testing.T) {
+	t.Parallel()
+
+	require.True(t, usageIsZero(fantasy.Usage{}))
+	require.False(t, usageIsZero(fantasy.Usage{InputTokens: 1}))
+	require.False(t, usageIsZero(fantasy.Usage{OutputTokens: 1}))
+	require.False(t, usageIsZero(fantasy.Usage{TotalTokens: 1}))
+	require.False(t, usageIsZero(fantasy.Usage{ReasoningTokens: 1}))
+	require.False(t, usageIsZero(fantasy.Usage{CacheCreationTokens: 1}))
+	require.False(t, usageIsZero(fantasy.Usage{CacheReadTokens: 1}))
+}
+
+func TestFallbackStepUsageKeepsProviderUsage(t *testing.T) {
+	t.Parallel()
+
+	usage := fantasy.Usage{
+		InputTokens:  10,
+		OutputTokens: 5,
+		TotalTokens:  15,
+	}
+	step := fantasy.StepResult{
+		Response: fantasy.Response{Usage: usage},
+	}
+
+	fallbackUsage, estimated := fallbackStepUsage(nil, step)
+	require.False(t, estimated)
+	require.Equal(t, usage, fallbackUsage)
+}
+
+func TestFallbackStepUsageEstimatesPromptAndAssistantText(t *testing.T) {
+	t.Parallel()
+
+	messages := []fantasy.Message{
+		fantasy.NewUserMessage("please explain the implementation details"),
+	}
+	step := fantasy.StepResult{
+		Response: fantasy.Response{
+			Content: fantasy.ResponseContent{
+				fantasy.TextContent{Text: "the implementation stores state safely"},
+			},
+		},
+	}
+
+	usage, estimated := fallbackStepUsage(messages, step)
+	require.True(t, estimated)
+	require.Positive(t, usage.InputTokens)
+	require.Positive(t, usage.OutputTokens)
+	require.Equal(t, usage.InputTokens+usage.OutputTokens, usage.TotalTokens)
+}
+
+func TestFallbackStepUsageEstimatesReasoning(t *testing.T) {
+	t.Parallel()
+
+	messages := []fantasy.Message{
+		{
+			Role: fantasy.MessageRoleAssistant,
+			Content: []fantasy.MessagePart{
+				fantasy.ReasoningPart{Text: "first reason about the request"},
+			},
+		},
+	}
+	step := fantasy.StepResult{
+		Response: fantasy.Response{
+			Content: fantasy.ResponseContent{
+				fantasy.ReasoningContent{Text: "second reason about the answer"},
+			},
+		},
+	}
+
+	usage, estimated := fallbackStepUsage(messages, step)
+	require.True(t, estimated)
+	require.Positive(t, usage.InputTokens)
+	require.Positive(t, usage.OutputTokens)
+}
+
+func TestFallbackStepUsageEstimatesToolCalls(t *testing.T) {
+	t.Parallel()
+
+	step := fantasy.StepResult{
+		Response: fantasy.Response{
+			Content: fantasy.ResponseContent{
+				fantasy.ToolCallContent{
+					ToolCallID: "tool-call-1",
+					ToolName:   "view",
+					Input:      `{"file_path":"/tmp/example.go"}`,
+				},
+			},
+		},
+	}
+
+	usage, estimated := fallbackStepUsage(nil, step)
+	require.True(t, estimated)
+	require.Zero(t, usage.InputTokens)
+	require.Positive(t, usage.OutputTokens)
+	require.Equal(t, usage.OutputTokens, usage.TotalTokens)
+}
+
+func TestFallbackStepUsageEstimatesToolResults(t *testing.T) {
+	t.Parallel()
+
+	messages := []fantasy.Message{
+		{
+			Role: fantasy.MessageRoleTool,
+			Content: []fantasy.MessagePart{
+				fantasy.ToolResultPart{
+					ToolCallID: "tool-call-1",
+					Output: fantasy.ToolResultOutputContentText{
+						Text: "file contents returned by the tool",
+					},
+				},
+				fantasy.ToolResultPart{
+					ToolCallID: "tool-call-2",
+					Output: fantasy.ToolResultOutputContentError{
+						Error: errors.New("permission denied"),
+					},
+				},
+				fantasy.ToolResultPart{
+					ToolCallID: "tool-call-3",
+					Output: fantasy.ToolResultOutputContentMedia{
+						MediaType: "image/png",
+						Text:      "screenshot",
+						Data:      "abc123",
+					},
+				},
+			},
+		},
+	}
+
+	usage, estimated := fallbackStepUsage(messages, fantasy.StepResult{})
+	require.True(t, estimated)
+	require.Positive(t, usage.InputTokens)
+	require.Zero(t, usage.OutputTokens)
+	require.Equal(t, usage.InputTokens, usage.TotalTokens)
+}
+
+func TestFallbackStepUsageSkipsClientToolResultsAsOutput(t *testing.T) {
+	t.Parallel()
+
+	step := fantasy.StepResult{
+		Response: fantasy.Response{
+			Content: fantasy.ResponseContent{
+				fantasy.ToolResultContent{
+					ToolCallID: "tool-call-1",
+					ToolName:   "bash",
+					Result: fantasy.ToolResultOutputContentText{
+						Text: "large client-executed payload that should not count as model output tokens",
+					},
+				},
+			},
+		},
+	}
+
+	usage, estimated := fallbackStepUsage(nil, step)
+	require.False(t, estimated)
+	require.Zero(t, usage.OutputTokens)
+}
+
+func TestFallbackStepUsageCountsProviderToolResultsAsOutput(t *testing.T) {
+	t.Parallel()
+
+	step := fantasy.StepResult{
+		Response: fantasy.Response{
+			Content: fantasy.ResponseContent{
+				fantasy.ToolResultContent{
+					ToolCallID:       "tool-call-1",
+					ToolName:         "web_search",
+					ProviderExecuted: true,
+					ClientMetadata:   "provider metadata",
+					Result:           fantasy.ToolResultOutputContentText{Text: "provider-executed result"},
+				},
+			},
+		},
+	}
+
+	usage, estimated := fallbackStepUsage(nil, step)
+	require.True(t, estimated)
+	require.Positive(t, usage.OutputTokens)
+	require.Equal(t, usage.OutputTokens, usage.TotalTokens)
+}
+
+func TestFallbackStepUsageReturnsZeroWithoutContent(t *testing.T) {
+	t.Parallel()
+
+	usage, estimated := fallbackStepUsage(nil, fantasy.StepResult{})
+	require.False(t, estimated)
+	require.True(t, usageIsZero(usage))
+}
+
+func TestUpdateSessionUsageSkipsEstimatedCost(t *testing.T) {
+	t.Parallel()
+
+	agent := &sessionAgent{}
+	currentSession := &session.Session{ID: "session-id", Cost: 1.25}
+	model := Model{CatwalkCfg: catwalk.Model{CostPer1MIn: 10, CostPer1MOut: 20}}
+	usage := fantasy.Usage{InputTokens: 1000, OutputTokens: 2000}
+
+	agent.updateSessionUsage(model, currentSession, usage, nil, true)
+
+	require.Equal(t, 1.25, currentSession.Cost)
+	require.Equal(t, int64(1000), currentSession.PromptTokens)
+	require.Equal(t, int64(2000), currentSession.CompletionTokens)
+	require.True(t, currentSession.EstimatedUsage)
+}
+
+func TestUpdateSessionUsageKeepsCountersForZeroUsage(t *testing.T) {
+	t.Parallel()
+
+	agent := &sessionAgent{}
+	currentSession := &session.Session{
+		ID:               "session-id",
+		PromptTokens:     123,
+		CompletionTokens: 456,
+		Cost:             1.25,
+	}
+	model := Model{CatwalkCfg: catwalk.Model{CostPer1MIn: 10, CostPer1MOut: 20}}
+
+	agent.updateSessionUsage(model, currentSession, fantasy.Usage{}, nil, false)
+
+	require.Equal(t, 1.25, currentSession.Cost)
+	require.Equal(t, int64(123), currentSession.PromptTokens)
+	require.Equal(t, int64(456), currentSession.CompletionTokens)
+}
+
+func TestUpdateSessionUsagePreservesOmittedCountersForPartialUsage(t *testing.T) {
+	t.Parallel()
+
+	agent := &sessionAgent{}
+	currentSession := &session.Session{
+		ID:               "session-id",
+		PromptTokens:     123,
+		CompletionTokens: 456,
+	}
+	model := Model{CatwalkCfg: catwalk.Model{CostPer1MIn: 10, CostPer1MOut: 20}}
+	usage := fantasy.Usage{InputTokens: 789}
+
+	agent.updateSessionUsage(model, currentSession, usage, nil, false)
+
+	require.Equal(t, int64(789), currentSession.PromptTokens)
+	require.Equal(t, int64(456), currentSession.CompletionTokens)
+}
+
+func TestUpdateSessionUsagePreservesCountersForTotalOnlyUsage(t *testing.T) {
+	t.Parallel()
+
+	agent := &sessionAgent{}
+	currentSession := &session.Session{
+		ID:               "session-id",
+		PromptTokens:     123,
+		CompletionTokens: 456,
+	}
+	model := Model{CatwalkCfg: catwalk.Model{CostPer1MIn: 10, CostPer1MOut: 20}}
+	usage := fantasy.Usage{TotalTokens: 100}
+
+	agent.updateSessionUsage(model, currentSession, usage, nil, false)
+
+	require.Equal(t, int64(123), currentSession.PromptTokens)
+	require.Equal(t, int64(456), currentSession.CompletionTokens)
+}
+
+func TestUpdateSessionUsagePreservesPromptForOutputOnlyUsage(t *testing.T) {
+	t.Parallel()
+
+	agent := &sessionAgent{}
+	currentSession := &session.Session{
+		ID:               "session-id",
+		PromptTokens:     123,
+		CompletionTokens: 456,
+	}
+	model := Model{CatwalkCfg: catwalk.Model{CostPer1MIn: 10, CostPer1MOut: 20}}
+	usage := fantasy.Usage{OutputTokens: 50}
+
+	agent.updateSessionUsage(model, currentSession, usage, nil, false)
+
+	require.Equal(t, int64(123), currentSession.PromptTokens)
+	require.Equal(t, int64(50), currentSession.CompletionTokens)
+}
+
+func TestUpdateSessionUsageKeepsCountersForEstimatedZeroUsage(t *testing.T) {
+	t.Parallel()
+
+	agent := &sessionAgent{}
+	currentSession := &session.Session{
+		ID:               "session-id",
+		PromptTokens:     123,
+		CompletionTokens: 456,
+		Cost:             1.25,
+	}
+	model := Model{CatwalkCfg: catwalk.Model{CostPer1MIn: 10, CostPer1MOut: 20}}
+
+	agent.updateSessionUsage(model, currentSession, fantasy.Usage{}, nil, true)
+
+	require.Equal(t, 1.25, currentSession.Cost)
+	require.Equal(t, int64(123), currentSession.PromptTokens)
+	require.Equal(t, int64(456), currentSession.CompletionTokens)
+}
+
+func TestSummaryCompletionTokens(t *testing.T) {
+	t.Parallel()
+
+	summaryMessage := message.Message{
+		Parts: []message.ContentPart{
+			message.TextContent{Text: "summary text"},
+			message.ReasoningContent{Thinking: "reasoning text"},
+		},
+	}
+
+	require.Equal(t, int64(42), summaryCompletionTokens(fantasy.Usage{OutputTokens: 42}, summaryMessage))
+	require.Equal(t, approxTokenCount("summary text")+approxTokenCount("reasoning text"), summaryCompletionTokens(fantasy.Usage{}, summaryMessage))
+	require.Zero(t, summaryCompletionTokens(fantasy.Usage{}, message.Message{}))
+}
+
+func TestUpdateSessionUsageAddsProviderCost(t *testing.T) {
+	t.Parallel()
+
+	agent := &sessionAgent{}
+	currentSession := &session.Session{ID: "session-id", Cost: 1.25}
+	model := Model{CatwalkCfg: catwalk.Model{CostPer1MIn: 10, CostPer1MOut: 20}}
+	usage := fantasy.Usage{InputTokens: 1000, OutputTokens: 2000}
+
+	agent.updateSessionUsage(model, currentSession, usage, nil, false)
+
+	require.Equal(t, 1.3, currentSession.Cost)
+	require.Equal(t, int64(1000), currentSession.PromptTokens)
+	require.Equal(t, int64(2000), currentSession.CompletionTokens)
+	require.False(t, currentSession.EstimatedUsage)
+}

internal/backend/backend.go 🔗

@@ -234,8 +234,12 @@ func (b *Backend) CreateWorkspace(args proto.Workspace) (*Workspace, proto.Works
 	// hosts multiple workspaces concurrently, so the manager is
 	// constructed WITHOUT WithGlobalMirror to prevent last-writer-wins
 	// cross-talk between workspaces.
-	allSkills, activeSkills, skillStates := skills.DiscoverFromConfig(skillsDiscoveryConfig(cfg))
-	skillsMgr := skills.NewManager(allSkills, activeSkills, skillStates)
+	discoveryCfg := skillsDiscoveryConfig(cfg)
+	allSkills, activeSkills, skillStates := skills.DiscoverFromConfig(discoveryCfg)
+	skillsMgr := skills.NewManager(allSkills, activeSkills, skillStates,
+		skills.WithResolvedPaths(discoveryCfg.ResolvePaths()),
+		skills.WithWorkingDir(discoveryCfg.WorkingDir),
+	)
 
 	appWorkspace, err := app.New(b.ctx, conn, cfg, skillsMgr)
 	if err != nil {
@@ -308,6 +312,7 @@ func skillsDiscoveryConfig(cfg *config.ConfigStore) skills.DiscoveryConfig {
 	return skills.DiscoveryConfig{
 		SkillsPaths:    paths,
 		DisabledSkills: disabled,
+		WorkingDir:     cfg.WorkingDir(),
 		Resolver:       resolver,
 	}
 }

internal/backend/config.go 🔗

@@ -12,6 +12,7 @@ import (
 	"github.com/charmbracelet/crush/internal/oauth"
 	"github.com/charmbracelet/crush/internal/proto"
 	"github.com/charmbracelet/crush/internal/pubsub"
+	"github.com/charmbracelet/crush/internal/skills"
 )
 
 // publishConfigChanged publishes a ConfigChanged event on the workspace's
@@ -162,6 +163,49 @@ func (b *Backend) InitializePrompt(workspaceID string) (string, error) {
 	return agent.InitializePrompt(ws.Cfg)
 }
 
+// ReadSkill reads a skill's content by ID.
+func (b *Backend) ReadSkill(ctx context.Context, workspaceID, skillID string) ([]byte, proto.SkillReadResult, error) {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return nil, proto.SkillReadResult{}, err
+	}
+
+	mgr := ws.Skills
+	content, result, err := skills.ReadContent(
+		mgr.ActiveSkills(), mgr.ResolvedPaths(), mgr.WorkingDir(), skillID,
+	)
+	if err != nil {
+		return nil, proto.SkillReadResult{}, err
+	}
+	return content, proto.SkillReadResult{
+		Name:        result.Name,
+		Description: result.Description,
+		Source:      string(result.Source),
+		Builtin:     result.Builtin,
+	}, nil
+}
+
+// ListSkills returns the effective visible skills for a workspace.
+func (b *Backend) ListSkills(workspaceID string) ([]proto.SkillInfo, error) {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return nil, err
+	}
+	mgr := ws.Skills
+	entries := skills.Catalog(mgr.ActiveSkills(), mgr.ResolvedPaths(), mgr.WorkingDir())
+	result := make([]proto.SkillInfo, len(entries))
+	for i, entry := range entries {
+		result[i] = proto.SkillInfo{
+			ID:          entry.ID,
+			Name:        entry.Name,
+			Description: entry.Description,
+			Label:       entry.Label,
+			Source:      string(entry.Source),
+		}
+	}
+	return result, nil
+}
+
 // EnableDockerMCP validates Docker MCP availability, stages the
 // configuration, starts the MCP client, and persists the config.
 func (b *Backend) EnableDockerMCP(ctx context.Context, workspaceID string) error {

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"`

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,
 	}
 }

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

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{
 		{

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

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": {

internal/db/connect.go 🔗

@@ -19,6 +19,7 @@ var (
 		"foreign_keys":  "ON",
 		"journal_mode":  "WAL",
 		"page_size":     "4096",
+		"temp_store":    "MEMORY",
 		"cache_size":    "-8000",
 		"synchronous":   "NORMAL",
 		"secure_delete": "ON",

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 {

internal/proto/proto.go 🔗

@@ -46,6 +46,35 @@ type CurrentSession struct {
 	SessionID string `json:"session_id"`
 }
 
+// SkillInfo describes a visible skill exposed to a frontend.
+type SkillInfo struct {
+	ID          string `json:"id"`
+	Name        string `json:"name"`
+	Description string `json:"description"`
+	Label       string `json:"label"`
+	Source      string `json:"source"`
+}
+
+// ReadSkillRequest is the request body for reading a skill's content.
+type ReadSkillRequest struct {
+	SkillID string `json:"skill_id"`
+}
+
+// ReadSkillResponse is the response for reading a skill's content.
+type ReadSkillResponse struct {
+	Content []byte          `json:"content"`
+	Result  SkillReadResult `json:"result"`
+}
+
+// SkillReadResult holds metadata about a skill returned alongside its
+// content.
+type SkillReadResult struct {
+	Name        string `json:"name"`
+	Description string `json:"description"`
+	Source      string `json:"source"`
+	Builtin     bool   `json:"builtin"`
+}
+
 // AgentInfo represents information about the agent.
 type AgentInfo struct {
 	IsBusy   bool                 `json:"is_busy"`

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

internal/server/server.go 🔗

@@ -169,6 +169,8 @@ func (s *Server) installHandler() {
 	mux.HandleFunc("GET /v1/workspaces/{id}/project/needs-init", c.handleGetWorkspaceProjectNeedsInit)
 	mux.HandleFunc("POST /v1/workspaces/{id}/project/init", c.handlePostWorkspaceProjectInit)
 	mux.HandleFunc("GET /v1/workspaces/{id}/project/init-prompt", c.handleGetWorkspaceProjectInitPrompt)
+	mux.HandleFunc("GET /v1/workspaces/{id}/skills", c.handleGetWorkspaceSkills)
+	mux.HandleFunc("POST /v1/workspaces/{id}/skills/read", c.handlePostWorkspaceSkillRead)
 	mux.HandleFunc("POST /v1/workspaces/{id}/mcp/refresh-tools", c.handlePostWorkspaceMCPRefreshTools)
 	mux.HandleFunc("POST /v1/workspaces/{id}/mcp/read-resource", c.handlePostWorkspaceMCPReadResource)
 	mux.HandleFunc("POST /v1/workspaces/{id}/mcp/get-prompt", c.handlePostWorkspaceMCPGetPrompt)

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"
@@ -53,6 +54,7 @@ type Session struct {
 	MessageCount     int64
 	PromptTokens     int64
 	CompletionTokens int64
+	EstimatedUsage   bool
 	SummaryMessageID string
 	Cost             float64
 	Todos            []Todo
@@ -83,6 +85,12 @@ type service struct {
 	*pubsub.Broker[Session]
 	db *sql.DB
 	q  *db.Queries
+
+	// Estimated usage stays in memory so fetch-modify-save paths (e.g.,
+	// updating todos or parent-session cost) do not rebuild a session from
+	// SQLite and incorrectly clear the UI "~" marker.
+	estimatedUsageMu sync.RWMutex
+	estimatedUsage   map[string]bool
 }
 
 func (s *service) Create(ctx context.Context, title string) (Session, error) {
@@ -154,6 +162,7 @@ func (s *service) Delete(ctx context.Context, id string) error {
 	}
 
 	session := s.fromDBItem(dbSession)
+	s.clearEstimatedUsageState(dbSession.ID)
 	s.Publish(pubsub.DeletedEvent, session)
 	event.SessionDeleted()
 	return nil
@@ -164,7 +173,9 @@ func (s *service) Get(ctx context.Context, id string) (Session, error) {
 	if err != nil {
 		return Session{}, err
 	}
-	return s.fromDBItem(dbSession), nil
+	session := s.fromDBItem(dbSession)
+	s.applyEstimatedUsageState(&session)
+	return session, nil
 }
 
 func (s *service) GetLast(ctx context.Context) (Session, error) {
@@ -172,7 +183,9 @@ func (s *service) GetLast(ctx context.Context) (Session, error) {
 	if err != nil {
 		return Session{}, err
 	}
-	return s.fromDBItem(dbSession), nil
+	session := s.fromDBItem(dbSession)
+	s.applyEstimatedUsageState(&session)
+	return session, nil
 }
 
 func (s *service) Save(ctx context.Context, session Session) (Session, error) {
@@ -199,7 +212,10 @@ func (s *service) Save(ctx context.Context, session Session) (Session, error) {
 	if err != nil {
 		return Session{}, err
 	}
+	estimatedUsage := session.EstimatedUsage
+	s.setEstimatedUsageState(session.ID, estimatedUsage)
 	session = s.fromDBItem(dbSession)
+	session.EstimatedUsage = estimatedUsage
 	s.Publish(pubsub.UpdatedEvent, session)
 	return session, nil
 }
@@ -233,11 +249,34 @@ func (s *service) List(ctx context.Context) ([]Session, error) {
 	sessions := make([]Session, len(dbSessions))
 	for i, dbSession := range dbSessions {
 		sessions[i] = s.fromDBItem(dbSession)
+		s.applyEstimatedUsageState(&sessions[i])
 	}
 	return sessions, nil
 }
 
-func (s service) fromDBItem(item db.Session) Session {
+func (s *service) applyEstimatedUsageState(session *Session) {
+	s.estimatedUsageMu.RLock()
+	session.EstimatedUsage = s.estimatedUsage[session.ID]
+	s.estimatedUsageMu.RUnlock()
+}
+
+func (s *service) setEstimatedUsageState(sessionID string, estimatedUsage bool) {
+	s.estimatedUsageMu.Lock()
+	defer s.estimatedUsageMu.Unlock()
+	if estimatedUsage {
+		s.estimatedUsage[sessionID] = true
+		return
+	}
+	delete(s.estimatedUsage, sessionID)
+}
+
+func (s *service) clearEstimatedUsageState(sessionID string) {
+	s.estimatedUsageMu.Lock()
+	delete(s.estimatedUsage, sessionID)
+	s.estimatedUsageMu.Unlock()
+}
+
+func (s *service) fromDBItem(item db.Session) Session {
 	todos, err := unmarshalTodos(item.Todos.String)
 	if err != nil {
 		slog.Error("Failed to unmarshal todos", "session_id", item.ID, "error", err)
@@ -282,9 +321,10 @@ func unmarshalTodos(data string) ([]Todo, error) {
 func NewService(q *db.Queries, conn *sql.DB) Service {
 	broker := pubsub.NewBroker[Session]()
 	return &service{
-		Broker: broker,
-		db:     conn,
-		q:      q,
+		Broker:         broker,
+		db:             conn,
+		q:              q,
+		estimatedUsage: make(map[string]bool),
 	}
 }
 

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)
+}

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)

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)
+}

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
+}

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
 }

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:

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)
 

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
 		}

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",

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:
@@ -110,12 +111,19 @@ func formatTokensAndCost(t *styles.Styles, tokens, contextWindow int64, cost flo
 		formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
 	}
 
-	percentage := (float64(tokens) / float64(contextWindow)) * 100
+	var percentage float64
+	if contextWindow > 0 {
+		percentage = (float64(tokens) / float64(contextWindow)) * 100
+	}
 
 	formattedCost := t.ModelInfo.Cost.Render(fmt.Sprintf("$%.2f", cost))
 
 	formattedTokens = t.ModelInfo.TokenCount.Render(fmt.Sprintf("(%s)", formattedTokens))
-	formattedPercentage := t.ModelInfo.TokenPercentage.Render(fmt.Sprintf("%d%%", int(percentage)))
+	percentageText := fmt.Sprintf("%d%%", int(percentage))
+	if estimated {
+		percentageText = "~" + percentageText
+	}
+	formattedPercentage := t.ModelInfo.TokenPercentage.Render(percentageText)
 	formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens)
 	if percentage > 80 {
 		formattedTokens = fmt.Sprintf("%s %s", styles.LSPWarningIcon, formattedTokens)

internal/ui/common/elements_test.go 🔗

@@ -0,0 +1,35 @@
+package common
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/x/ansi"
+	"github.com/stretchr/testify/require"
+)
+
+func TestFormatTokensAndCostPrefixesEstimatedUsage(t *testing.T) {
+	t.Parallel()
+
+	sty := styles.CharmtonePantera()
+
+	rendered := formatTokensAndCost(&sty, 120, 1000, 0, true)
+	actual := ansi.Strip(rendered)
+
+	require.Contains(t, actual, "~12%")
+	require.Contains(t, actual, "(120)")
+	require.Contains(t, actual, "$0.00")
+	require.True(t, strings.Contains(rendered, sty.ModelInfo.TokenPercentage.Render("~12%")))
+}
+
+func TestFormatTokensAndCostOmitsEstimatedPrefix(t *testing.T) {
+	t.Parallel()
+
+	sty := styles.CharmtonePantera()
+
+	actual := ansi.Strip(formatTokensAndCost(&sty, 120, 1000, 0, false))
+
+	require.Contains(t, actual, "12%")
+	require.NotContains(t, actual, "~12%")
+}

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)

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

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 {
@@ -513,7 +522,7 @@ func (c *Commands) defaultCommands() []*CommandItem {
 
 	commands = append(
 		commands,
-		NewCommandItem(c.com.Styles, "toggle_yolo", "Toggle Yolo Mode", "", ActionToggleYoloMode{}),
+		NewCommandItem(c.com.Styles, "toggle_yolo", "Toggle Yolo Mode", "ctrl+y", ActionToggleYoloMode{}),
 		NewCommandItem(c.com.Styles, "toggle_help", "Toggle Help", "ctrl+g", ActionToggleHelp{}),
 		NewCommandItem(c.com.Styles, "init", "Initialize Project", "", ActionInitializeProject{}),
 	)

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
 }

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)
@@ -398,20 +408,8 @@ func (m *Models) setProviderItems() error {
 		}
 	}
 
-	// Move "Charm Hyper" to first position.
-	// (But still after recent models and custom providers).
-	slices.SortStableFunc(m.providers, func(a, b catwalk.Provider) int {
-		switch {
-		case a.ID == "hyper":
-			return -1
-		case b.ID == "hyper":
-			return 1
-		default:
-			return 0
-		}
-	})
-
-	// Now add known providers from the predefined list
+	// Now add known providers from the predefined list.
+	// Providers already has Hyper at the front of the list.
 	for _, provider := range m.providers {
 		providerID := string(provider.ID)
 		if addedProviders[providerID] {
@@ -498,7 +496,11 @@ func (m *Models) setProviderItems() error {
 	// Set model groups in the list.
 	m.list.SetGroups(groups...)
 	m.list.SetSelectedItem(selectedItemID)
-	m.list.ScrollToTop()
+	if selectedItemID != "" {
+		m.list.ScrollToSelected()
+	} else {
+		m.list.ScrollToTop()
+	}
 
 	// Update placeholder based on model type
 	if !m.isOnboarding {

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
 		}
 	}
 }

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)
 

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) {

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 = "~" + percentageText
+		}
+		formattedPercentage := t.Header.Percentage.Render(percentageText)
 		parts = append(parts, formattedPercentage)
 	}
 

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(

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")
+		}
+	})
+}

internal/ui/model/pills.go 🔗

@@ -134,6 +134,43 @@ func queueList(queueItems []string, t *styles.Styles) string {
 	return strings.Join(lines, "\n")
 }
 
+// pillsHeightReasonableTerminalHeight is the minimum terminal height at which
+// we auto-expand pills when there are incomplete todos.
+const pillsHeightReasonableTerminalHeight = 40
+
+// autoExpandPillsIfReasonable expands the pills panel if the terminal has
+// enough vertical space to show the expanded list comfortably.
+func (m *UI) autoExpandPillsIfReasonable() tea.Cmd {
+	if !m.hasSession() {
+		return nil
+	}
+	if m.height < pillsHeightReasonableTerminalHeight {
+		return nil
+	}
+	hasPills := hasIncompleteTodos(m.session.Todos) || m.promptQueue > 0
+	if !hasPills {
+		return nil
+	}
+	if m.pillsExpanded {
+		return nil
+	}
+	if m.pillsAutoExpanded {
+		return nil
+	}
+	m.pillsExpanded = true
+	m.pillsAutoExpanded = true
+	if hasIncompleteTodos(m.session.Todos) {
+		m.focusedPillSection = pillSectionTodos
+	} else {
+		m.focusedPillSection = pillSectionQueue
+	}
+	m.updateLayoutAndSize()
+	if m.chat.Follow() {
+		m.chat.ScrollToBottom()
+	}
+	return nil
+}
+
 // togglePillsExpanded toggles the pills panel expansion state.
 func (m *UI) togglePillsExpanded() tea.Cmd {
 	if !m.hasSession() {
@@ -249,7 +286,7 @@ func (m *UI) renderPills() {
 		if todosFocused && hasIncomplete {
 			expandedList = todoList(m.session.Todos, inProgressIcon, t, contentWidth)
 		} else if queueFocused && hasQueue {
-			if m.com.Workspace.AgentIsReady() {
+			if m.com != nil && m.com.Workspace != nil && m.com.Workspace.AgentIsReady() {
 				queueItems := m.com.Workspace.AgentQueuedPromptsList(m.session.ID)
 				expandedList = queueList(queueItems, t)
 			}

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

internal/ui/model/ui.go 🔗

@@ -259,6 +259,7 @@ type UI struct {
 
 	// pills state
 	pillsExpanded      bool
+	pillsAutoExpanded  bool
 	focusedPillSection pillSection
 	promptQueue        int
 	pillsView          string
@@ -317,6 +318,7 @@ func New(com *common.Common, initialSessionID string, continueLast bool) *UI {
 			com.Styles.Attachments.Deleting,
 			com.Styles.Attachments.Image,
 			com.Styles.Attachments.Text,
+			com.Styles.Attachments.Skill,
 		),
 		attachments.Keymap{
 			DeleteMode: keyMap.Editor.AttachmentDeleteMode,
@@ -536,6 +538,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if cmd := m.setSessionMessages(msgs); cmd != nil {
 			cmds = append(cmds, cmd)
 		}
+		if cmd := m.autoExpandPillsIfReasonable(); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
 		if hasInProgressTodo(m.session.Todos) {
 			// only start spinner if there is an in-progress todo
 			if m.isAgentBusy() {
@@ -611,6 +616,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				cmds = append(cmds, m.todoSpinner.Tick)
 				m.updateLayoutAndSize()
 			}
+			m.autoExpandPillsIfReasonable()
 		}
 	case pubsub.Event[message.Message]:
 		// Check if this is a child session message for an agent tool.
@@ -1551,6 +1557,9 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 		}
 		cmds = append(cmds, m.sendMessage(content))
 		m.dialog.CloseFrontDialog()
+	case dialog.ActionAttachSkill:
+		m.dialog.CloseFrontDialog()
+		cmds = append(cmds, m.attachSkill(msg.ID, msg.Name))
 	case dialog.ActionRunMCPPrompt:
 		if len(msg.Arguments) > 0 && msg.Args == nil {
 			m.dialog.CloseFrontDialog()
@@ -1796,6 +1805,16 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 			}
 			cmds = append(cmds, tea.Suspend)
 			return true
+		case key.Matches(msg, m.keyMap.ToggleYolo):
+			yolo := !m.com.Workspace.PermissionSkipRequests()
+			m.com.Workspace.PermissionSetSkipRequests(yolo)
+			m.setEditorPrompt(yolo)
+			status := "disabled"
+			if yolo {
+				status = "enabled"
+			}
+			cmds = append(cmds, util.ReportInfo("Yolo mode "+status))
+			return true
 		}
 		return false
 	}
@@ -2401,6 +2420,7 @@ func (m *UI) FullHelp() [][]key.Binding {
 			commands,
 			k.Models,
 			k.Sessions,
+			k.ToggleYolo,
 		)
 		if hasSession {
 			mainBinds = append(mainBinds, k.Chat.NewSession)
@@ -2462,6 +2482,7 @@ func (m *UI) FullHelp() [][]key.Binding {
 					commands,
 					k.Models,
 					k.Sessions,
+					k.ToggleYolo,
 				},
 			)
 			editorBinds := []key.Binding{
@@ -3136,12 +3157,37 @@ func (m *UI) refreshStyles() {
 		t.Attachments.Deleting,
 		t.Attachments.Image,
 		t.Attachments.Text,
+		t.Attachments.Skill,
 	)
 	m.todoSpinner.Style = t.Pills.TodoSpinner
 	m.status.help.Styles = t.Help
 	m.chat.InvalidateRenderCaches()
 }
 
+// attachSkill reads a skill's content by ID and returns it as a markdown
+// attachment to be added to the attachment toolbar. The user can then
+// compose a message and send it with the skill attached.
+// The name parameter is used as a fallback when the server does not
+// return one.
+func (m *UI) attachSkill(skillID, name string) tea.Cmd {
+	return func() tea.Msg {
+		content, result, err := m.com.Workspace.ReadSkill(context.Background(), skillID)
+		if err != nil {
+			return util.NewErrorMsg(err)
+		}
+		fileName := result.Name
+		if fileName == "" {
+			fileName = name
+		}
+		return message.Attachment{
+			FilePath: fileName,
+			FileName: fileName,
+			MimeType: "text/markdown",
+			Content:  content,
+		}
+	}
+}
+
 // sendMessage sends a message with the given content and attachments.
 func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
 	if !m.com.Workspace.AgentIsReady() {
@@ -3476,6 +3522,7 @@ func (m *UI) newSession() tea.Cmd {
 	m.chat.Blur()
 	m.chat.ClearMessages()
 	m.pillsExpanded = false
+	m.pillsAutoExpanded = false
 	m.promptQueue = 0
 	m.pillsView = ""
 	m.historyReset()

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 = s.ModelInfo.TokenPercentage
 	s.ModelInfo.Cost = lipgloss.NewStyle().Foreground(o.fgMoreSubtle)
 	s.ModelInfo.HypercreditIcon = lipgloss.NewStyle().Foreground(charmtone.Dolly)
 	s.ModelInfo.HypercreditText = lipgloss.NewStyle().Foreground(o.fgMoreSubtle)
@@ -922,6 +923,7 @@ func quickStyle(o quickStyleOpts) Styles {
 	attachmentIconStyle := base.Foreground(o.bgLessVisible).Background(o.success).Padding(0, 1)
 	s.Attachments.Image = attachmentIconStyle.SetString(ImageIcon)
 	s.Attachments.Text = attachmentIconStyle.SetString(TextIcon)
+	s.Attachments.Skill = attachmentIconStyle.SetString(SkillIcon)
 	s.Attachments.Normal = base.Padding(0, 1).MarginRight(1).Background(o.fgMoreSubtle).Foreground(o.fgBase)
 	s.Attachments.Deleting = base.Padding(0, 1).Bold(true).Background(o.destructive).Foreground(o.fgBase)
 

internal/ui/styles/styles.go 🔗

@@ -43,6 +43,7 @@ const (
 
 	ImageIcon string = "■"
 	TextIcon  string = "≡"
+	SkillIcon string = "▲"
 
 	ScrollbarThumb string = "┃"
 	ScrollbarTrack string = "│"
@@ -183,16 +184,17 @@ type Styles struct {
 
 	// ModelInfo (model name, provider, reasoning, token/cost summary)
 	ModelInfo struct {
-		Icon             lipgloss.Style // Model icon (◇)
-		Name             lipgloss.Style // Model name text
-		Provider         lipgloss.Style // "via <provider>" text
-		ProviderFallback lipgloss.Style // Provider on its own second line
-		Reasoning        lipgloss.Style // Reasoning effort text
-		TokenCount       lipgloss.Style // "(42K)" token count
-		TokenPercentage  lipgloss.Style // "42%" percent of context window
-		Cost             lipgloss.Style // "$0.42" cost readout
-		HypercreditIcon  lipgloss.Style // Hypercredit icon (◆)
-		HypercreditText  lipgloss.Style // Remaining Hypercredits text
+		Icon                 lipgloss.Style // Model icon (◇)
+		Name                 lipgloss.Style // Model name text
+		Provider             lipgloss.Style // "via <provider>" text
+		ProviderFallback     lipgloss.Style // Provider on its own second line
+		Reasoning            lipgloss.Style // Reasoning effort text
+		TokenCount           lipgloss.Style // "(42K)" token count
+		TokenPercentage      lipgloss.Style // "42%" percent of context window
+		EstimatedUsagePrefix lipgloss.Style // "~" prefix for estimated usage
+		Cost                 lipgloss.Style // "$0.42" cost readout
+		HypercreditIcon      lipgloss.Style // Hypercredit icon (◆)
+		HypercreditText      lipgloss.Style // Remaining Hypercredits text
 	}
 
 	// Resource styles the LSP/MCP/skills sidebar lists: their heading,
@@ -501,6 +503,7 @@ type Styles struct {
 		Normal   lipgloss.Style
 		Image    lipgloss.Style
 		Text     lipgloss.Style
+		Skill    lipgloss.Style
 		Deleting lipgloss.Style
 	}
 

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
@@ -311,6 +312,16 @@ func (w *AppWorkspace) InitializePrompt() (string, error) {
 	return agent.InitializePrompt(w.store)
 }
 
+func (w *AppWorkspace) ListSkills(_ context.Context) ([]skills.CatalogEntry, error) {
+	mgr := w.app.Skills
+	return skills.Catalog(mgr.ActiveSkills(), mgr.ResolvedPaths(), mgr.WorkingDir()), nil
+}
+
+func (w *AppWorkspace) ReadSkill(_ context.Context, skillID string) ([]byte, skills.SkillReadResult, error) {
+	mgr := w.app.Skills
+	return skills.ReadContent(mgr.ActiveSkills(), mgr.ResolvedPaths(), mgr.WorkingDir(), skillID)
+}
+
 // -- MCP operations --
 
 func (w *AppWorkspace) MCPGetStates() map[string]mcptools.ClientInfo {

internal/workspace/client_workspace.go 🔗

@@ -494,6 +494,37 @@ func (w *ClientWorkspace) InitializePrompt() (string, error) {
 	return w.client.GetInitializePrompt(context.Background(), w.workspaceID())
 }
 
+func (w *ClientWorkspace) ListSkills(ctx context.Context) ([]skills.CatalogEntry, error) {
+	entries, err := w.client.ListSkills(ctx, w.workspaceID())
+	if err != nil {
+		return nil, err
+	}
+	result := make([]skills.CatalogEntry, len(entries))
+	for i, entry := range entries {
+		result[i] = skills.CatalogEntry{
+			ID:          entry.ID,
+			Name:        entry.Name,
+			Description: entry.Description,
+			Label:       entry.Label,
+			Source:      skills.SourceType(entry.Source),
+		}
+	}
+	return result, nil
+}
+
+func (w *ClientWorkspace) ReadSkill(ctx context.Context, skillID string) ([]byte, skills.SkillReadResult, error) {
+	resp, err := w.client.ReadSkill(ctx, w.workspaceID(), skillID)
+	if err != nil {
+		return nil, skills.SkillReadResult{}, err
+	}
+	return resp.Content, skills.SkillReadResult{
+		Name:        resp.Result.Name,
+		Description: resp.Result.Description,
+		Source:      skills.SourceType(resp.Result.Source),
+		Builtin:     resp.Result.Builtin,
+	}, nil
+}
+
 // -- MCP operations --
 
 func (w *ClientWorkspace) MCPGetStates() map[string]mcp.ClientInfo {

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
@@ -141,6 +142,8 @@ type Workspace interface {
 	ProjectNeedsInitialization() (bool, error)
 	MarkProjectInitialized() error
 	InitializePrompt() (string, error)
+	ListSkills(ctx context.Context) ([]skills.CatalogEntry, error)
+	ReadSkill(ctx context.Context, skillID string) ([]byte, skills.SkillReadResult, error)
 
 	// MCP operations (server-side in client mode)
 	MCPGetStates() map[string]mcptools.ClientInfo