on main help sign in
Merge remote-tracking branch 'origin/main' into ui
9b26ea2Kujtim Hoxha
.github/cla-signatures.json+40
@@ -959,6 +959,46 @@
959 "created_at": "2025-12-14T09:41:12Z",
959 "created_at": "2025-12-14T09:41:12Z",
960 "repoId": 987670088,
960 "repoId": 987670088,
961 "pullRequestNo": 1628
961 "pullRequestNo": 1628
962+ },
963+ {
964+ "name": "flatsponge",
965+ "id": 104839509,
966+ "comment_id": 3673002560,
967+ "created_at": "2025-12-19T01:11:45Z",
968+ "repoId": 987670088,
969+ "pullRequestNo": 1668
970+ },
971+ {
972+ "name": "jonhoo",
973+ "id": 176295,
974+ "comment_id": 3674853134,
975+ "created_at": "2025-12-19T12:14:08Z",
976+ "repoId": 987670088,
977+ "pullRequestNo": 1675
978+ },
979+ {
980+ "name": "Mr777x-enf",
981+ "id": 248610315,
982+ "comment_id": 3682737876,
983+ "created_at": "2025-12-22T16:07:47Z",
984+ "repoId": 987670088,
985+ "pullRequestNo": 1694
986+ },
987+ {
988+ "name": "yuguorui",
989+ "id": 6182414,
990+ "comment_id": 3687495909,
991+ "created_at": "2025-12-23T17:59:11Z",
992+ "repoId": 987670088,
993+ "pullRequestNo": 1709
994+ },
995+ {
996+ "name": "aeroxy",
997+ "id": 2761307,
998+ "comment_id": 3693734613,
999+ "created_at": "2025-12-27T06:01:58Z",
1000+ "repoId": 987670088,
1001+ "pullRequestNo": 1723
962 }
1002 }
963 ]
1003 ]
964}
1004}
.github/workflows/schema-update.yml+1−1
@@ -19,7 +19,7 @@ jobs:
19 go-version-file: go.mod
19 go-version-file: go.mod
20 - run: go run . schema > ./schema.json
20 - run: go run . schema > ./schema.json
21 - run: go generate ./internal/agent/hyper/...
21 - run: go generate ./internal/agent/hyper/...
22− - uses: stefanzweifel/git-auto-commit-action@28e16e81777b558cc906c8750092100bbb34c5e3 # v5
22+ - uses: stefanzweifel/git-auto-commit-action@04702edda442b2e678b25b537cec683a1493fcb9 # v5
23 with:
23 with:
24 commit_message: "chore: auto-update files"
24 commit_message: "chore: auto-update files"
25 branch: main
25 branch: main
README.md+43
@@ -362,6 +362,49 @@ completely hidden from the agent.
362
362
363To disable tools from MCP servers, see the [MCP config section](#mcps).
363To disable tools from MCP servers, see the [MCP config section](#mcps).
364
364
365+### Agent Skills
366+
367+Crush supports the [Agent Skills](https://agentskills.io) open standard for
368+extending agent capabilities with reusable skill packages. Skills are folders
369+containing a `SKILL.md` file with instructions that Crush can discover and
370+activate on demand.
371+
372+Skills are discovered from:
373+
374+- `~/.config/crush/skills/` on Unix (default, can be overridden with `CRUSH_SKILLS_DIR`)
375+- `%LOCALAPPDATA%\crush\skills\` on Windows (default, can be overridden with `CRUSH_SKILLS_DIR`)
376+- Additional paths configured via `options.skills_paths`
377+
378+```jsonc
379+{
380+ "$schema": "https://charm.land/crush.json",
381+ "options": {
382+ "skills_paths": [
383+ "~/.config/crush/skills", // Windows: "%LOCALAPPDATA%\\crush\\skills",
384+ "./project-skills"
385+ ]
386+ }
387+}
388+```
389+
390+You can get started with example skills from [anthropics/skills](https://github.com/anthropics/skills):
391+
392+```bash
393+# Unix
394+mkdir -p ~/.config/crush/skills
395+cd ~/.config/crush/skills
396+git clone https://github.com/anthropics/skills.git _temp
397+mv _temp/skills/* . && rm -rf _temp
398+```
399+
400+```powershell
401+# Windows (PowerShell)
402+mkdir -Force "$env:LOCALAPPDATA\crush\skills"
403+cd "$env:LOCALAPPDATA\crush\skills"
404+git clone https://github.com/anthropics/skills.git _temp
405+mv _temp/skills/* . ; rm -r -force _temp
406+```
407+
365### Initialization
408### Initialization
366
409
367When you initialize a project, Crush analyzes your codebase and creates
410When you initialize a project, Crush analyzes your codebase and creates
go.mod+6−6
@@ -5,7 +5,7 @@ go 1.25.5
5require (
5require (
6 charm.land/bubbles/v2 v2.0.0-rc.1
6 charm.land/bubbles/v2 v2.0.0-rc.1
7 charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e
7 charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e
8− charm.land/fantasy v0.5.3
8+ charm.land/fantasy v0.5.5
9 charm.land/glamour/v2 v2.0.0-20251110203732-69649f93d3b1
9 charm.land/glamour/v2 v2.0.0-20251110203732-69649f93d3b1
10 charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251205162909-7869489d8971
10 charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251205162909-7869489d8971
11 charm.land/log/v2 v2.0.0-20251110204020-529bb77f35da
11 charm.land/log/v2 v2.0.0-20251110204020-529bb77f35da
@@ -13,12 +13,12 @@ require (
13 github.com/JohannesKaufmann/html-to-markdown v1.6.0
13 github.com/JohannesKaufmann/html-to-markdown v1.6.0
14 github.com/MakeNowJust/heredoc v1.0.0
14 github.com/MakeNowJust/heredoc v1.0.0
15 github.com/PuerkitoBio/goquery v1.11.0
15 github.com/PuerkitoBio/goquery v1.11.0
16− github.com/alecthomas/chroma/v2 v2.20.0
16+ github.com/alecthomas/chroma/v2 v2.21.1
17 github.com/atotto/clipboard v0.1.4
17 github.com/atotto/clipboard v0.1.4
18 github.com/aymanbagabas/go-udiff v0.3.1
18 github.com/aymanbagabas/go-udiff v0.3.1
19 github.com/bmatcuk/doublestar/v4 v4.9.1
19 github.com/bmatcuk/doublestar/v4 v4.9.1
20 github.com/charlievieth/fastwalk v1.0.14
20 github.com/charlievieth/fastwalk v1.0.14
21− github.com/charmbracelet/catwalk v0.11.0
21+ github.com/charmbracelet/catwalk v0.11.2
22 github.com/charmbracelet/colorprofile v0.4.1
22 github.com/charmbracelet/colorprofile v0.4.1
23 github.com/charmbracelet/fang v0.4.4
23 github.com/charmbracelet/fang v0.4.4
24 github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560
24 github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560
@@ -39,7 +39,7 @@ require (
39 github.com/lucasb-eyer/go-colorful v1.3.0
39 github.com/lucasb-eyer/go-colorful v1.3.0
40 github.com/modelcontextprotocol/go-sdk v1.1.0
40 github.com/modelcontextprotocol/go-sdk v1.1.0
41 github.com/muesli/termenv v0.16.0
41 github.com/muesli/termenv v0.16.0
42− github.com/ncruces/go-sqlite3 v0.30.3
42+ github.com/ncruces/go-sqlite3 v0.30.4
43 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
43 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
44 github.com/nxadm/tail v1.4.11
44 github.com/nxadm/tail v1.4.11
45 github.com/openai/openai-go/v2 v2.7.1
45 github.com/openai/openai-go/v2 v2.7.1
@@ -62,6 +62,7 @@ require (
62 golang.org/x/sync v0.19.0
62 golang.org/x/sync v0.19.0
63 golang.org/x/text v0.32.0
63 golang.org/x/text v0.32.0
64 gopkg.in/natefinch/lumberjack.v2 v2.2.1
64 gopkg.in/natefinch/lumberjack.v2 v2.2.1
65+ gopkg.in/yaml.v3 v3.0.1
65 mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5
66 mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5
66 mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5
67 mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5
67)
68)
@@ -145,7 +146,7 @@ require (
145 github.com/sethvargo/go-retry v0.3.0 // indirect
146 github.com/sethvargo/go-retry v0.3.0 // indirect
146 github.com/sourcegraph/jsonrpc2 v0.2.1 // indirect
147 github.com/sourcegraph/jsonrpc2 v0.2.1 // indirect
147 github.com/spf13/pflag v1.0.9 // indirect
148 github.com/spf13/pflag v1.0.9 // indirect
148− github.com/tetratelabs/wazero v1.10.1 // indirect
149+ github.com/tetratelabs/wazero v1.11.0 // indirect
149 github.com/tidwall/match v1.1.1 // indirect
150 github.com/tidwall/match v1.1.1 // indirect
150 github.com/tidwall/pretty v1.2.1 // indirect
151 github.com/tidwall/pretty v1.2.1 // indirect
151 github.com/u-root/u-root v0.14.1-0.20250807200646-5e7721023dc7 // indirect
152 github.com/u-root/u-root v0.14.1-0.20250807200646-5e7721023dc7 // indirect
@@ -177,5 +178,4 @@ require (
177 google.golang.org/protobuf v1.36.10 // indirect
178 google.golang.org/protobuf v1.36.10 // indirect
178 gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290 // indirect
179 gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290 // indirect
179 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
180 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
180− gopkg.in/yaml.v3 v3.0.1 // indirect
181)
181)
go.sum+12−12
@@ -2,8 +2,8 @@ charm.land/bubbles/v2 v2.0.0-rc.1 h1:EiIFVAc3Zi/yY86td+79mPhHR7AqZ1OxF+6ztpOCRaM
2charm.land/bubbles/v2 v2.0.0-rc.1/go.mod h1:5AbN6cEd/47gkEf8TgiQ2O3RZ5QxMS14l9W+7F9fPC4=
2charm.land/bubbles/v2 v2.0.0-rc.1/go.mod h1:5AbN6cEd/47gkEf8TgiQ2O3RZ5QxMS14l9W+7F9fPC4=
3charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e h1:tXwTmgGpwZT7ParKF5xbEQBVjM2e1uKhKi/GpfU3mYQ=
3charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e h1:tXwTmgGpwZT7ParKF5xbEQBVjM2e1uKhKi/GpfU3mYQ=
4charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e/go.mod h1:pDM18flq3Z4njKZPA3zCvyVSSIJbMcoqlE82BdGUtL8=
4charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e/go.mod h1:pDM18flq3Z4njKZPA3zCvyVSSIJbMcoqlE82BdGUtL8=
5−charm.land/fantasy v0.5.3 h1:+6meCTaH9lrqrcVTEBgsaSkkY0ctC/6dtIufKZcMdMI=
5+charm.land/fantasy v0.5.5 h1:Dw/NBLH9HLX/ouCz604RXGD7BYzr0lT56/B4ylMGZjg=
6−charm.land/fantasy v0.5.3/go.mod h1:WnH5fJJRMGylx1fL1ow9Kfq0+sPMr5fenpHYAnoTlTg=
6+charm.land/fantasy v0.5.5/go.mod h1:QyJLJGissYdBifvitgAxFcYhNACSr0G1faC75CIESUk=
7charm.land/glamour/v2 v2.0.0-20251110203732-69649f93d3b1 h1:9q4+yyU7105T3OrOx0csMyKnw89yMSijJ+rVld/Z2ek=
7charm.land/glamour/v2 v2.0.0-20251110203732-69649f93d3b1 h1:9q4+yyU7105T3OrOx0csMyKnw89yMSijJ+rVld/Z2ek=
8charm.land/glamour/v2 v2.0.0-20251110203732-69649f93d3b1/go.mod h1:J3kVhY6oHXZq5f+8vC3hmDO95fEvbqj3z7xDwxrfzU8=
8charm.land/glamour/v2 v2.0.0-20251110203732-69649f93d3b1/go.mod h1:J3kVhY6oHXZq5f+8vC3hmDO95fEvbqj3z7xDwxrfzU8=
9charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251205162909-7869489d8971 h1:xZFcNsJMiIDbFtWRyDmkKNk1sjojfaom4Zoe0cyH/8c=
9charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251205162909-7869489d8971 h1:xZFcNsJMiIDbFtWRyDmkKNk1sjojfaom4Zoe0cyH/8c=
@@ -39,10 +39,10 @@ github.com/RealAlexandreAI/json-repair v0.0.14 h1:4kTqotVonDVTio5n2yweRUELVcNe2x
39github.com/RealAlexandreAI/json-repair v0.0.14/go.mod h1:GKJi5borR78O8c7HCVbgqjhoiVibZ6hJldxbc6dGrAI=
39github.com/RealAlexandreAI/json-repair v0.0.14/go.mod h1:GKJi5borR78O8c7HCVbgqjhoiVibZ6hJldxbc6dGrAI=
40github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
40github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
41github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
41github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
42−github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
42+github.com/alecthomas/chroma/v2 v2.21.1 h1:FaSDrp6N+3pphkNKU6HPCiYLgm8dbe5UXIXcoBhZSWA=
43−github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
43+github.com/alecthomas/chroma/v2 v2.21.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
44−github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
44+github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
45−github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
45+github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
46github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
46github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
47github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
47github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
48github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
48github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
@@ -92,8 +92,8 @@ github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICg
92github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY=
92github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY=
93github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 h1:rwLdEpG9wE6kL69KkEKDiWprO8pQOZHZXeod6+9K+mw=
93github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 h1:rwLdEpG9wE6kL69KkEKDiWprO8pQOZHZXeod6+9K+mw=
94github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904/go.mod h1:8TIYxZxsuCqqeJ0lga/b91tBwrbjoHDC66Sq5t8N2R4=
94github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904/go.mod h1:8TIYxZxsuCqqeJ0lga/b91tBwrbjoHDC66Sq5t8N2R4=
95−github.com/charmbracelet/catwalk v0.11.0 h1:PU3rkc4h4YVJEn9Iyb/1rQAaF4hEd04fuG4tj3vv4dg=
95+github.com/charmbracelet/catwalk v0.11.2 h1:m+eE7yv/uIrKW95FpFeGDMFrAugotylX89XzpkZwlLk=
96−github.com/charmbracelet/catwalk v0.11.0/go.mod h1:qg+Yl9oaZTkTvRscqbxfttzOFQ4v0pOT5XwC7b5O0NQ=
96+github.com/charmbracelet/catwalk v0.11.2/go.mod h1:qg+Yl9oaZTkTvRscqbxfttzOFQ4v0pOT5XwC7b5O0NQ=
97github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
97github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
98github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
98github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
99github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=
99github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=
@@ -248,8 +248,8 @@ github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
248github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
248github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
249github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
249github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
250github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
250github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
251−github.com/ncruces/go-sqlite3 v0.30.3 h1:X/CgWW9GzmIAkEPrifhKqf0cC15DuOVxAJaHFTTAURQ=
251+github.com/ncruces/go-sqlite3 v0.30.4 h1:j9hEoOL7f9ZoXl8uqXVniaq1VNwlWAXihZbTvhqPPjA=
252−github.com/ncruces/go-sqlite3 v0.30.3/go.mod h1:AxKu9sRxkludimFocbktlY6LiYSkxiI5gTA8r+os/Nw=
252+github.com/ncruces/go-sqlite3 v0.30.4/go.mod h1:7WR20VSC5IZusKhUdiR9y1NsUqnZgqIYCmKKoMEYg68=
253github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
253github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
254github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
254github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
255github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
255github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
@@ -310,8 +310,8 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
310github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
310github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
311github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
311github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
312github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
312github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
313−github.com/tetratelabs/wazero v1.10.1 h1:2DugeJf6VVk58KTPszlNfeeN8AhhpwcZqkJj2wwFuH8=
313+github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
314−github.com/tetratelabs/wazero v1.10.1/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU=
314+github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
315github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
315github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
316github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
316github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
317github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
317github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
internal/agent/agent.go+30−10
@@ -171,10 +171,9 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
171 var wg sync.WaitGroup
171 var wg sync.WaitGroup
172 // Generate title if first message.
172 // Generate title if first message.
173 if len(msgs) == 0 {
173 if len(msgs) == 0 {
174+ titleCtx := ctx // Copy to avoid race with ctx reassignment below.
174 wg.Go(func() {
175 wg.Go(func() {
175− sessionLock.Lock()
176− a.generateTitle(ctx, &currentSession, call.Prompt)
176+ a.generateTitle(titleCtx, call.SessionID, call.Prompt)
177− sessionLock.Unlock()
178 })
177 })
179 }
178 }
180
179
@@ -201,7 +200,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
201 var currentAssistant *message.Message
200 var currentAssistant *message.Message
202 var shouldSummarize bool
201 var shouldSummarize bool
203 result, err := agent.Stream(genCtx, fantasy.AgentStreamCall{
202 result, err := agent.Stream(genCtx, fantasy.AgentStreamCall{
204− Prompt: call.Prompt,
203+ Prompt: message.PromptWithTextAttachments(call.Prompt, call.Attachments),
205 Files: files,
204 Files: files,
206 Messages: history,
205 Messages: history,
207 ProviderOptions: call.ProviderOptions,
206 ProviderOptions: call.ProviderOptions,
@@ -650,11 +649,11 @@ func (a *sessionAgent) getCacheControlOptions() fantasy.ProviderOptions {
650}
649}
651
650
652func (a *sessionAgent) createUserMessage(ctx context.Context, call SessionAgentCall) (message.Message, error) {
651func (a *sessionAgent) createUserMessage(ctx context.Context, call SessionAgentCall) (message.Message, error) {
652+ parts := []message.ContentPart{message.TextContent{Text: call.Prompt}}
653 var attachmentParts []message.ContentPart
653 var attachmentParts []message.ContentPart
654 for _, attachment := range call.Attachments {
654 for _, attachment := range call.Attachments {
655 attachmentParts = append(attachmentParts, message.BinaryContent{Path: attachment.FilePath, MIMEType: attachment.MimeType, Data: attachment.Content})
655 attachmentParts = append(attachmentParts, message.BinaryContent{Path: attachment.FilePath, MIMEType: attachment.MimeType, Data: attachment.Content})
656 }
656 }
657− parts := []message.ContentPart{message.TextContent{Text: call.Prompt}}
658 parts = append(parts, attachmentParts...)
657 parts = append(parts, attachmentParts...)
659 msg, err := a.messages.Create(ctx, call.SessionID, message.CreateMessageParams{
658 msg, err := a.messages.Create(ctx, call.SessionID, message.CreateMessageParams{
660 Role: message.User,
659 Role: message.User,
@@ -691,6 +690,9 @@ If not, please feel free to ignore. Again do not mention this message to the use
691
690
692 var files []fantasy.FilePart
691 var files []fantasy.FilePart
693 for _, attachment := range attachments {
692 for _, attachment := range attachments {
693+ if attachment.IsText() {
694+ continue
695+ }
694 files = append(files, fantasy.FilePart{
696 files = append(files, fantasy.FilePart{
695 Filename: attachment.FileName,
697 Filename: attachment.FileName,
696 Data: attachment.Content,
698 Data: attachment.Content,
@@ -723,7 +725,7 @@ func (a *sessionAgent) getSessionMessages(ctx context.Context, session session.S
723 return msgs, nil
725 return msgs, nil
724}
726}
725
727
726−func (a *sessionAgent) generateTitle(ctx context.Context, session *session.Session, prompt string) {
728+func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, prompt string) {
727 if prompt == "" {
729 if prompt == "" {
728 return
730 return
729 }
731 }
@@ -768,8 +770,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, session *session.Sessi
768 return
770 return
769 }
771 }
770
772
771− session.Title = title
773+ // Calculate usage and cost.
772−
773 var openrouterCost *float64
774 var openrouterCost *float64
774 for _, step := range resp.Steps {
775 for _, step := range resp.Steps {
775 stepCost := a.openrouterCost(step.ProviderMetadata)
776 stepCost := a.openrouterCost(step.ProviderMetadata)
@@ -782,8 +783,27 @@ func (a *sessionAgent) generateTitle(ctx context.Context, session *session.Sessi
782 }
783 }
783 }
784 }
784
785
785− a.updateSessionUsage(a.smallModel, session, resp.TotalUsage, openrouterCost)
786+ modelConfig := a.smallModel.CatwalkCfg
786− _, saveErr := a.sessions.Save(ctx, *session)
787+ cost := modelConfig.CostPer1MInCached/1e6*float64(resp.TotalUsage.CacheCreationTokens) +
788+ modelConfig.CostPer1MOutCached/1e6*float64(resp.TotalUsage.CacheReadTokens) +
789+ modelConfig.CostPer1MIn/1e6*float64(resp.TotalUsage.InputTokens) +
790+ modelConfig.CostPer1MOut/1e6*float64(resp.TotalUsage.OutputTokens)
791+
792+ if a.isClaudeCode() {
793+ cost = 0
794+ }
795+
796+ // Use override cost if available (e.g., from OpenRouter).
797+ if openrouterCost != nil {
798+ cost = *openrouterCost
799+ }
800+
801+ promptTokens := resp.TotalUsage.InputTokens + resp.TotalUsage.CacheCreationTokens
802+ completionTokens := resp.TotalUsage.OutputTokens + resp.TotalUsage.CacheReadTokens
803+
804+ // Atomically update only title and usage fields to avoid overriding other
805+ // concurrent session updates.
806+ saveErr := a.sessions.UpdateTitleAndUsage(ctx, sessionID, title, promptTokens, completionTokens, cost)
787 if saveErr != nil {
807 if saveErr != nil {
788 slog.Error("failed to save session title & usage", "error", saveErr)
808 slog.Error("failed to save session title & usage", "error", saveErr)
789 return
809 return
internal/agent/common_test.go+4
@@ -178,6 +178,10 @@ func coderAgent(r *vcr.Recorder, env fakeEnv, large, small fantasy.LanguageModel
178 GeneratedWith: true,
178 GeneratedWith: true,
179 }
179 }
180
180
181+ // Clear skills paths to ensure test reproducibility - user's skills
182+ // would be included in prompt and break VCR cassette matching.
183+ cfg.Options.SkillsPaths = []string{}
184+
181 systemPrompt, err := prompt.Build(context.TODO(), large.Provider(), large.Model(), *cfg)
185 systemPrompt, err := prompt.Build(context.TODO(), large.Provider(), large.Model(), *cfg)
182 if err != nil {
186 if err != nil {
183 return nil, err
187 return nil, err
internal/agent/coordinator.go+13−8
@@ -123,7 +123,14 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
123 }
123 }
124
124
125 if !model.CatwalkCfg.SupportsImages && attachments != nil {
125 if !model.CatwalkCfg.SupportsImages && attachments != nil {
126+ // filter out image attachments
127+ filteredAttachments := make([]message.Attachment, 0, len(attachments))
126− attachments = nil
128+ for _, att := range attachments {
129+ if att.IsText() {
130+ filteredAttachments = append(filteredAttachments, att)
131+ }
132+ }
133+ attachments = filteredAttachments
127 }
134 }
128
135
129 providerCfg, ok := c.cfg.Providers.Get(model.ModelCfg.Provider)
136 providerCfg, ok := c.cfg.Providers.Get(model.ModelCfg.Provider)
@@ -383,7 +390,7 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
383 tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
390 tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
384 tools.NewSourcegraphTool(nil),
391 tools.NewSourcegraphTool(nil),
385 tools.NewTodosTool(c.sessions),
392 tools.NewTodosTool(c.sessions),
386− tools.NewViewTool(c.lspClients, c.permissions, c.cfg.WorkingDir()),
393+ tools.NewViewTool(c.lspClients, c.permissions, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...),
387 tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
394 tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
388 )
395 )
389
396
@@ -516,14 +523,13 @@ func (c *coordinator) buildAgentModels(ctx context.Context) (Model, Model, error
516 }, nil
523 }, nil
517}
524}
518
525
519−func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
526+func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string, isOauth bool) (fantasy.Provider, error) {
520 var opts []anthropic.Option
527 var opts []anthropic.Option
521
528
522− if strings.HasPrefix(apiKey, "Bearer ") {
529+ if isOauth {
523 // NOTE: Prevent the SDK from picking up the API key from env.
530 // NOTE: Prevent the SDK from picking up the API key from env.
524 os.Setenv("ANTHROPIC_API_KEY", "")
531 os.Setenv("ANTHROPIC_API_KEY", "")
525−
526− headers["Authorization"] = apiKey
532+ headers["Authorization"] = fmt.Sprintf("Bearer %s", apiKey)
527 } else if apiKey != "" {
533 } else if apiKey != "" {
528 // X-Api-Key header
534 // X-Api-Key header
529 opts = append(opts, anthropic.WithAPIKey(apiKey))
535 opts = append(opts, anthropic.WithAPIKey(apiKey))
@@ -541,7 +547,6 @@ func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map
541 httpClient := log.NewHTTPClient()
547 httpClient := log.NewHTTPClient()
542 opts = append(opts, anthropic.WithHTTPClient(httpClient))
548 opts = append(opts, anthropic.WithHTTPClient(httpClient))
543 }
549 }
544−
545 return anthropic.New(opts...)
550 return anthropic.New(opts...)
546}
551}
547
552
@@ -722,7 +727,7 @@ func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model con
722 case openai.Name:
727 case openai.Name:
723 return c.buildOpenaiProvider(baseURL, apiKey, headers)
728 return c.buildOpenaiProvider(baseURL, apiKey, headers)
724 case anthropic.Name:
729 case anthropic.Name:
725− return c.buildAnthropicProvider(baseURL, apiKey, headers)
730+ return c.buildAnthropicProvider(baseURL, apiKey, headers, providerCfg.OAuthToken != nil)
726 case openrouter.Name:
731 case openrouter.Name:
727 return c.buildOpenrouterProvider(baseURL, apiKey, headers)
732 return c.buildOpenrouterProvider(baseURL, apiKey, headers)
728 case azure.Name:
733 case azure.Name:
internal/agent/prompt/prompt.go+31−16
@@ -14,6 +14,7 @@ import (
14 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/config"
15 "github.com/charmbracelet/crush/internal/home"
15 "github.com/charmbracelet/crush/internal/home"
16 "github.com/charmbracelet/crush/internal/shell"
16 "github.com/charmbracelet/crush/internal/shell"
17+ "github.com/charmbracelet/crush/internal/skills"
17)
18)
18
19
19// Prompt represents a template-based prompt generator.
20// Prompt represents a template-based prompt generator.
@@ -26,15 +27,16 @@ type Prompt struct {
26}
27}
27
28
28type PromptDat struct {
29type PromptDat struct {
29− Provider string
30+ Provider string
30− Model string
31+ Model string
31− Config config.Config
32+ Config config.Config
32− WorkingDir string
33+ WorkingDir string
33− IsGitRepo bool
34+ IsGitRepo bool
34− Platform string
35+ Platform string
35− Date string
36+ Date string
36− GitStatus string
37+ GitStatus string
37− ContextFiles []ContextFile
38+ ContextFiles []ContextFile
39+ AvailSkillXML string
38}
40}
39
41
40type ContextFile struct {
42type ContextFile struct {
@@ -162,15 +164,28 @@ func (p *Prompt) promptData(ctx context.Context, provider, model string, cfg con
162 files[pathKey] = content
164 files[pathKey] = content
163 }
165 }
164
166
167+ // Discover and load skills metadata.
168+ var availSkillXML string
169+ if len(cfg.Options.SkillsPaths) > 0 {
170+ expandedPaths := make([]string, 0, len(cfg.Options.SkillsPaths))
171+ for _, pth := range cfg.Options.SkillsPaths {
172+ expandedPaths = append(expandedPaths, expandPath(pth, cfg))
173+ }
174+ if discoveredSkills := skills.Discover(expandedPaths); len(discoveredSkills) > 0 {
175+ availSkillXML = skills.ToPromptXML(discoveredSkills)
176+ }
177+ }
178+
165 isGit := isGitRepo(cfg.WorkingDir())
179 isGit := isGitRepo(cfg.WorkingDir())
166 data := PromptDat{
180 data := PromptDat{
167− Provider: provider,
181+ Provider: provider,
168− Model: model,
182+ Model: model,
169− Config: cfg,
183+ Config: cfg,
170− WorkingDir: filepath.ToSlash(workingDir),
184+ WorkingDir: filepath.ToSlash(workingDir),
171− IsGitRepo: isGit,
185+ IsGitRepo: isGit,
172− Platform: platform,
186+ Platform: platform,
173− Date: p.now().Format("1/2/2006"),
187+ Date: p.now().Format("1/2/2006"),
188+ AvailSkillXML: availSkillXML,
174 }
189 }
175 if isGit {
190 if isGit {
176 var err error
191 var err error
internal/agent/templates/coder.md.tpl+10
@@ -360,6 +360,16 @@ Diagnostics (lint/typecheck) included in tool output.
360- Ignore issues in files you didn't touch (unless user asks)
360- Ignore issues in files you didn't touch (unless user asks)
361</lsp>
361</lsp>
362{{end}}
362{{end}}
363+{{- if .AvailSkillXML}}
364+
365+{{.AvailSkillXML}}
366+
367+<skills_usage>
368+When a user task matches a skill's description, read the skill's SKILL.md file to get full instructions.
369+Skills are activated by reading their location path. Follow the skill's instructions to complete the task.
370+If a skill mentions scripts, references, or assets, they are placed in the same folder as the skill itself (e.g., scripts/, references/, assets/ subdirectories within the skill's folder).
371+</skills_usage>
372+{{end}}
363
373
364{{if .ContextFiles}}
374{{if .ContextFiles}}
365<memory>
375<memory>
internal/agent/tools/view.go+58−13
@@ -38,6 +38,7 @@ type viewTool struct {
38 lspClients *csync.Map[string, *lsp.Client]
38 lspClients *csync.Map[string, *lsp.Client]
39 workingDir string
39 workingDir string
40 permissions permission.Service
40 permissions permission.Service
41+ skillsPaths []string
41}
42}
42
43
43type ViewResponseMetadata struct {
44type ViewResponseMetadata struct {
@@ -52,7 +53,7 @@ const (
52 MaxLineLength = 2000
53 MaxLineLength = 2000
53)
54)
54
55
55−func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, workingDir string) fantasy.AgentTool {
56+func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, workingDir string, skillsPaths ...string) fantasy.AgentTool {
56 return fantasy.NewAgentTool(
57 return fantasy.NewAgentTool(
57 ViewToolName,
58 ViewToolName,
58 string(viewDescription),
59 string(viewDescription),
@@ -76,8 +77,11 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss
76 }
77 }
77
78
78 relPath, err := filepath.Rel(absWorkingDir, absFilePath)
79 relPath, err := filepath.Rel(absWorkingDir, absFilePath)
79− if err != nil || strings.HasPrefix(relPath, "..") {
80+ isOutsideWorkDir := err != nil || strings.HasPrefix(relPath, "..")
81+ isSkillFile := isInSkillsPath(absFilePath, skillsPaths)
82+
80− // File is outside working directory, request permission
83+ // Request permission for files outside working directory, unless it's a skill file.
84+ if isOutsideWorkDir && !isSkillFile {
81 sessionID := GetSessionFromContext(ctx)
85 sessionID := GetSessionFromContext(ctx)
82 if sessionID == "" {
86 if sessionID == "" {
83 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing files outside working directory")
87 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing files outside working directory")
@@ -137,19 +141,23 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss
137 return fantasy.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
141 return fantasy.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
138 }
142 }
139
143
140− // Check file size
144+ // Based on the specifications we should not limit the skills read.
141− if fileInfo.Size() > MaxReadSize {
145+ if !isSkillFile && fileInfo.Size() > MaxReadSize {
142 return fantasy.NewTextErrorResponse(fmt.Sprintf("File is too large (%d bytes). Maximum size is %d bytes",
146 return fantasy.NewTextErrorResponse(fmt.Sprintf("File is too large (%d bytes). Maximum size is %d bytes",
143 fileInfo.Size(), MaxReadSize)), nil
147 fileInfo.Size(), MaxReadSize)), nil
144 }
148 }
145
149
146− // Set default limit if not provided
150+ // Set default limit if not provided (no limit for SKILL.md files)
147 if params.Limit <= 0 {
151 if params.Limit <= 0 {
152+ if isSkillFile {
153+ params.Limit = 1000000 // Effectively no limit for skill files
154+ } else {
148− params.Limit = DefaultReadLimit
155+ params.Limit = DefaultReadLimit
156+ }
149 }
157 }
150
158
151− isImage, mimeType := getImageMimeType(filePath)
159+ isSupportedImage, mimeType := getImageMimeType(filePath)
152− if isImage {
160+ if isSupportedImage {
153 if !GetSupportsImagesFromContext(ctx) {
161 if !GetSupportsImagesFromContext(ctx) {
154 modelName := GetModelNameFromContext(ctx)
162 modelName := GetModelNameFromContext(ctx)
155 return fantasy.NewTextErrorResponse(fmt.Sprintf("This model (%s) does not support image data.", modelName)), nil
163 return fantasy.NewTextErrorResponse(fmt.Sprintf("This model (%s) does not support image data.", modelName)), nil
@@ -282,10 +290,6 @@ func getImageMimeType(filePath string) (bool, string) {
282 return true, "image/png"
290 return true, "image/png"
283 case ".gif":
291 case ".gif":
284 return true, "image/gif"
292 return true, "image/gif"
285− case ".bmp":
286− return true, "image/bmp"
287− case ".svg":
288− return true, "image/svg+xml"
289 case ".webp":
293 case ".webp":
290 return true, "image/webp"
294 return true, "image/webp"
291 default:
295 default:
@@ -319,3 +323,44 @@ func (s *LineScanner) Text() string {
319func (s *LineScanner) Err() error {
323func (s *LineScanner) Err() error {
320 return s.scanner.Err()
324 return s.scanner.Err()
321}
325}
326+
327+// isInSkillsPath checks if filePath is within any of the configured skills
328+// directories. Returns true for files that can be read without permission
329+// prompts and without size limits.
330+//
331+// Note that symlinks are resolved to prevent path traversal attacks via
332+// symbolic links.
333+func isInSkillsPath(filePath string, skillsPaths []string) bool {
334+ if len(skillsPaths) == 0 {
335+ return false
336+ }
337+
338+ absFilePath, err := filepath.Abs(filePath)
339+ if err != nil {
340+ return false
341+ }
342+
343+ evalFilePath, err := filepath.EvalSymlinks(absFilePath)
344+ if err != nil {
345+ return false
346+ }
347+
348+ for _, skillsPath := range skillsPaths {
349+ absSkillsPath, err := filepath.Abs(skillsPath)
350+ if err != nil {
351+ continue
352+ }
353+
354+ evalSkillsPath, err := filepath.EvalSymlinks(absSkillsPath)
355+ if err != nil {
356+ continue
357+ }
358+
359+ relPath, err := filepath.Rel(evalSkillsPath, evalFilePath)
360+ if err == nil && !strings.HasPrefix(relPath, "..") {
361+ return true
362+ }
363+ }
364+
365+ return false
366+}
internal/app/app.go+2−2
@@ -69,7 +69,7 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
69 messages := message.NewService(q)
69 messages := message.NewService(q)
70 files := history.NewService(q, conn)
70 files := history.NewService(q, conn)
71 skipPermissionsRequests := cfg.Permissions != nil && cfg.Permissions.SkipRequests
71 skipPermissionsRequests := cfg.Permissions != nil && cfg.Permissions.SkipRequests
72− allowedTools := []string{}
72+ var allowedTools []string
73 if cfg.Permissions != nil && cfg.Permissions.AllowedTools != nil {
73 if cfg.Permissions != nil && cfg.Permissions.AllowedTools != nil {
74 allowedTools = cfg.Permissions.AllowedTools
74 allowedTools = cfg.Permissions.AllowedTools
75 }
75 }
@@ -415,7 +415,7 @@ func (app *App) Shutdown() {
415 })
415 })
416 }
416 }
417
417
418− // Call call cleanup functions.
418+ // Call all cleanup functions.
419 for _, cleanup := range app.cleanupFuncs {
419 for _, cleanup := range app.cleanupFuncs {
420 if cleanup != nil {
420 if cleanup != nil {
421 wg.Go(func() {
421 wg.Go(func() {
internal/app/lsp.go+1−1
@@ -38,7 +38,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, config
38 // Create LSP client.
38 // Create LSP client.
39 lspClient, err := lsp.New(ctx, name, config, app.config.Resolver())
39 lspClient, err := lsp.New(ctx, name, config, app.config.Resolver())
40 if err != nil {
40 if err != nil {
41− slog.Error("Failed to create LSP client for", name, err)
41+ slog.Error("Failed to create LSP client for", "name", name, "error", err)
42 updateLSPState(name, lsp.StateError, err, nil, 0)
42 updateLSPState(name, lsp.StateError, err, nil, 0)
43 return
43 return
44 }
44 }
internal/cmd/login.go+1−2
@@ -75,8 +75,7 @@ func loginHyper() error {
75 if !hyperp.Enabled() {
75 if !hyperp.Enabled() {
76 return fmt.Errorf("hyper not enabled")
76 return fmt.Errorf("hyper not enabled")
77 }
77 }
78− ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
78+ ctx := getLoginContext()
79− defer cancel()
80
79
81 resp, err := hyper.InitiateDeviceAuth(ctx)
80 resp, err := hyper.InitiateDeviceAuth(ctx)
82 if err != nil {
81 if err != nil {
internal/config/config.go+6−4
@@ -9,6 +9,7 @@ import (
9 "net/http"
9 "net/http"
10 "net/url"
10 "net/url"
11 "os"
11 "os"
12+ "path/filepath"
12 "slices"
13 "slices"
13 "strings"
14 "strings"
14 "time"
15 "time"
@@ -255,6 +256,7 @@ func (Attribution) JSONSchemaExtend(schema *jsonschema.Schema) {
255
256
256type Options struct {
257type Options struct {
257 ContextPaths []string `json:"context_paths,omitempty" jsonschema:"description=Paths to files containing context information for the AI,example=.cursorrules,example=CRUSH.md"`
258 ContextPaths []string `json:"context_paths,omitempty" jsonschema:"description=Paths to files containing context information for the AI,example=.cursorrules,example=CRUSH.md"`
259+ SkillsPaths []string `json:"skills_paths,omitempty" jsonschema:"description=Paths to directories containing Agent Skills (folders with SKILL.md files),example=~/.config/crush/skills,example=./skills"`
258 TUI *TUIOptions `json:"tui,omitempty" jsonschema:"description=Terminal user interface options"`
260 TUI *TUIOptions `json:"tui,omitempty" jsonschema:"description=Terminal user interface options"`
259 Debug bool `json:"debug,omitempty" jsonschema:"description=Enable debug logging,default=false"`
261 Debug bool `json:"debug,omitempty" jsonschema:"description=Enable debug logging,default=false"`
260 DebugLSP bool `json:"debug_lsp,omitempty" jsonschema:"description=Enable debug logging for LSP servers,default=false"`
262 DebugLSP bool `json:"debug_lsp,omitempty" jsonschema:"description=Enable debug logging for LSP servers,default=false"`
@@ -498,7 +500,6 @@ func (c *Config) HasConfigField(key string) bool {
498}
500}
499
501
500func (c *Config) SetConfigField(key string, value any) error {
502func (c *Config) SetConfigField(key string, value any) error {
501− // read the data
502 data, err := os.ReadFile(c.dataConfigDir)
503 data, err := os.ReadFile(c.dataConfigDir)
503 if err != nil {
504 if err != nil {
504 if os.IsNotExist(err) {
505 if os.IsNotExist(err) {
@@ -512,6 +513,9 @@ func (c *Config) SetConfigField(key string, value any) error {
512 if err != nil {
513 if err != nil {
513 return fmt.Errorf("failed to set config field %s: %w", key, err)
514 return fmt.Errorf("failed to set config field %s: %w", key, err)
514 }
515 }
516+ if err := os.MkdirAll(filepath.Dir(c.dataConfigDir), 0o755); err != nil {
517+ return fmt.Errorf("failed to create config directory %q: %w", c.dataConfigDir, err)
518+ }
515 if err := os.WriteFile(c.dataConfigDir, []byte(newValue), 0o600); err != nil {
519 if err := os.WriteFile(c.dataConfigDir, []byte(newValue), 0o600); err != nil {
516 return fmt.Errorf("failed to write config file: %w", err)
520 return fmt.Errorf("failed to write config file: %w", err)
517 }
521 }
@@ -547,13 +551,12 @@ func (c *Config) RefreshOAuthToken(ctx context.Context, providerID string) error
547
551
548 slog.Info("Successfully refreshed OAuth token", "provider", providerID)
552 slog.Info("Successfully refreshed OAuth token", "provider", providerID)
549 providerConfig.OAuthToken = newToken
553 providerConfig.OAuthToken = newToken
554+ providerConfig.APIKey = newToken.AccessToken
550
555
551 switch providerID {
556 switch providerID {
552 case string(catwalk.InferenceProviderAnthropic):
557 case string(catwalk.InferenceProviderAnthropic):
553− providerConfig.APIKey = fmt.Sprintf("Bearer %s", newToken.AccessToken)
554 providerConfig.SetupClaudeCode()
558 providerConfig.SetupClaudeCode()
555 case string(catwalk.InferenceProviderCopilot):
559 case string(catwalk.InferenceProviderCopilot):
556− providerConfig.APIKey = newToken.AccessToken
557 providerConfig.SetupGitHubCopilot()
560 providerConfig.SetupGitHubCopilot()
558 }
561 }
559
562
@@ -592,7 +595,6 @@ func (c *Config) SetProviderAPIKey(providerID string, apiKey any) error {
592 providerConfig.OAuthToken = v
595 providerConfig.OAuthToken = v
593 switch providerID {
596 switch providerID {
594 case string(catwalk.InferenceProviderAnthropic):
597 case string(catwalk.InferenceProviderAnthropic):
595− providerConfig.APIKey = fmt.Sprintf("Bearer %s", v.AccessToken)
596 providerConfig.SetupClaudeCode()
598 providerConfig.SetupClaudeCode()
597 case string(catwalk.InferenceProviderCopilot):
599 case string(catwalk.InferenceProviderCopilot):
598 providerConfig.SetupGitHubCopilot()
600 providerConfig.SetupGitHubCopilot()
internal/config/copilot.go+6−1
@@ -6,11 +6,12 @@ import (
6 "log/slog"
6 "log/slog"
7 "testing"
7 "testing"
8
8
9+ "github.com/charmbracelet/catwalk/pkg/catwalk"
9 "github.com/charmbracelet/crush/internal/oauth"
10 "github.com/charmbracelet/crush/internal/oauth"
10 "github.com/charmbracelet/crush/internal/oauth/copilot"
11 "github.com/charmbracelet/crush/internal/oauth/copilot"
11)
12)
12
13
13−func (c *Config) importCopilot() (*oauth.Token, bool) {
14+func (c *Config) ImportCopilot() (*oauth.Token, bool) {
14 if testing.Testing() {
15 if testing.Testing() {
15 return nil, false
16 return nil, false
16 }
17 }
@@ -31,6 +32,10 @@ func (c *Config) importCopilot() (*oauth.Token, bool) {
31 return nil, false
32 return nil, false
32 }
33 }
33
34
35+ if err := c.SetProviderAPIKey(string(catwalk.InferenceProviderCopilot), token); err != nil {
36+ return token, false
37+ }
38+
34 if err := cmp.Or(
39 if err := cmp.Or(
35 c.SetConfigField("providers.copilot.api_key", token.AccessToken),
40 c.SetConfigField("providers.copilot.api_key", token.AccessToken),
36 c.SetConfigField("providers.copilot.oauth", token),
41 c.SetConfigField("providers.copilot.oauth", token),
internal/config/load.go+31−7
@@ -133,8 +133,6 @@ func PushPopCrushEnv() func() {
133}
133}
134
134
135func (c *Config) configureProviders(env env.Env, resolver VariableResolver, knownProviders []catwalk.Provider) error {
135func (c *Config) configureProviders(env env.Env, resolver VariableResolver, knownProviders []catwalk.Provider) error {
136− c.importCopilot()
137−
138 knownProviderNames := make(map[string]bool)
136 knownProviderNames := make(map[string]bool)
139 restore := PushPopCrushEnv()
137 restore := PushPopCrushEnv()
140 defer restore()
138 defer restore()
@@ -206,11 +204,6 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
206 case p.ID == catwalk.InferenceProviderAnthropic && config.OAuthToken != nil:
204 case p.ID == catwalk.InferenceProviderAnthropic && config.OAuthToken != nil:
207 prepared.SetupClaudeCode()
205 prepared.SetupClaudeCode()
208 case p.ID == catwalk.InferenceProviderCopilot:
206 case p.ID == catwalk.InferenceProviderCopilot:
209− if config.OAuthToken != nil {
210− if token, ok := c.importCopilot(); ok {
211− prepared.OAuthToken = token
212− }
213− }
214 if config.OAuthToken != nil {
207 if config.OAuthToken != nil {
215 prepared.SetupGitHubCopilot()
208 prepared.SetupGitHubCopilot()
216 }
209 }
@@ -336,6 +329,9 @@ func (c *Config) setDefaults(workingDir, dataDir string) {
336 if c.Options.ContextPaths == nil {
329 if c.Options.ContextPaths == nil {
337 c.Options.ContextPaths = []string{}
330 c.Options.ContextPaths = []string{}
338 }
331 }
332+ if c.Options.SkillsPaths == nil {
333+ c.Options.SkillsPaths = []string{}
334+ }
339 if dataDir != "" {
335 if dataDir != "" {
340 c.Options.DataDirectory = dataDir
336 c.Options.DataDirectory = dataDir
341 } else if c.Options.DataDirectory == "" {
337 } else if c.Options.DataDirectory == "" {
@@ -369,6 +365,12 @@ func (c *Config) setDefaults(workingDir, dataDir string) {
369 slices.Sort(c.Options.ContextPaths)
365 slices.Sort(c.Options.ContextPaths)
370 c.Options.ContextPaths = slices.Compact(c.Options.ContextPaths)
366 c.Options.ContextPaths = slices.Compact(c.Options.ContextPaths)
371
367
368+ // Add the default skills directory if not already present.
369+ defaultSkillsDir := GlobalSkillsDir()
370+ if !slices.Contains(c.Options.SkillsPaths, defaultSkillsDir) {
371+ c.Options.SkillsPaths = append([]string{defaultSkillsDir}, c.Options.SkillsPaths...)
372+ }
373+
372 if str, ok := os.LookupEnv("CRUSH_DISABLE_PROVIDER_AUTO_UPDATE"); ok {
374 if str, ok := os.LookupEnv("CRUSH_DISABLE_PROVIDER_AUTO_UPDATE"); ok {
373 c.Options.DisableProviderAutoUpdate, _ = strconv.ParseBool(str)
375 c.Options.DisableProviderAutoUpdate, _ = strconv.ParseBool(str)
374 }
376 }
@@ -743,3 +745,25 @@ func isInsideWorktree() bool {
743 ).CombinedOutput()
745 ).CombinedOutput()
744 return err == nil && strings.TrimSpace(string(bts)) == "true"
746 return err == nil && strings.TrimSpace(string(bts)) == "true"
745}
747}
748+
749+// GlobalSkillsDir returns the default directory for Agent Skills.
750+// Skills in this directory are auto-discovered and their files can be read
751+// without permission prompts.
752+func GlobalSkillsDir() string {
753+ if crushSkills := os.Getenv("CRUSH_SKILLS_DIR"); crushSkills != "" {
754+ return crushSkills
755+ }
756+ if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" {
757+ return filepath.Join(xdgConfigHome, appName, "skills")
758+ }
759+
760+ if runtime.GOOS == "windows" {
761+ localAppData := cmp.Or(
762+ os.Getenv("LOCALAPPDATA"),
763+ filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
764+ )
765+ return filepath.Join(localAppData, appName, "skills")
766+ }
767+
768+ return filepath.Join(home.Dir(), ".config", appName, "skills")
769+}
internal/db/db.go+54−44
@@ -84,6 +84,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
84 if q.updateSessionStmt, err = db.PrepareContext(ctx, updateSession); err != nil {
84 if q.updateSessionStmt, err = db.PrepareContext(ctx, updateSession); err != nil {
85 return nil, fmt.Errorf("error preparing query UpdateSession: %w", err)
85 return nil, fmt.Errorf("error preparing query UpdateSession: %w", err)
86 }
86 }
87+ if q.updateSessionTitleAndUsageStmt, err = db.PrepareContext(ctx, updateSessionTitleAndUsage); err != nil {
88+ return nil, fmt.Errorf("error preparing query UpdateSessionTitleAndUsage: %w", err)
89+ }
87 return &q, nil
90 return &q, nil
88}
91}
89
92
@@ -189,6 +192,11 @@ func (q *Queries) Close() error {
189 err = fmt.Errorf("error closing updateSessionStmt: %w", cerr)
192 err = fmt.Errorf("error closing updateSessionStmt: %w", cerr)
190 }
193 }
191 }
194 }
195+ if q.updateSessionTitleAndUsageStmt != nil {
196+ if cerr := q.updateSessionTitleAndUsageStmt.Close(); cerr != nil {
197+ err = fmt.Errorf("error closing updateSessionTitleAndUsageStmt: %w", cerr)
198+ }
199+ }
192 return err
200 return err
193}
201}
194
202
@@ -226,53 +234,55 @@ func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, ar
226}
234}
227
235
228type Queries struct {
236type Queries struct {
229− db DBTX
237+ db DBTX
230− tx *sql.Tx
238+ tx *sql.Tx
231− createFileStmt *sql.Stmt
239+ createFileStmt *sql.Stmt
232− createMessageStmt *sql.Stmt
240+ createMessageStmt *sql.Stmt
233− createSessionStmt *sql.Stmt
241+ createSessionStmt *sql.Stmt
234− deleteFileStmt *sql.Stmt
242+ deleteFileStmt *sql.Stmt
235− deleteMessageStmt *sql.Stmt
243+ deleteMessageStmt *sql.Stmt
236− deleteSessionStmt *sql.Stmt
244+ deleteSessionStmt *sql.Stmt
237− deleteSessionFilesStmt *sql.Stmt
245+ deleteSessionFilesStmt *sql.Stmt
238− deleteSessionMessagesStmt *sql.Stmt
246+ deleteSessionMessagesStmt *sql.Stmt
239− getFileStmt *sql.Stmt
247+ getFileStmt *sql.Stmt
240− getFileByPathAndSessionStmt *sql.Stmt
248+ getFileByPathAndSessionStmt *sql.Stmt
241− getMessageStmt *sql.Stmt
249+ getMessageStmt *sql.Stmt
242− getSessionByIDStmt *sql.Stmt
250+ getSessionByIDStmt *sql.Stmt
243− listFilesByPathStmt *sql.Stmt
251+ listFilesByPathStmt *sql.Stmt
244− listFilesBySessionStmt *sql.Stmt
252+ listFilesBySessionStmt *sql.Stmt
245− listLatestSessionFilesStmt *sql.Stmt
253+ listLatestSessionFilesStmt *sql.Stmt
246− listMessagesBySessionStmt *sql.Stmt
254+ listMessagesBySessionStmt *sql.Stmt
247− listNewFilesStmt *sql.Stmt
255+ listNewFilesStmt *sql.Stmt
248− listSessionsStmt *sql.Stmt
256+ listSessionsStmt *sql.Stmt
249− updateMessageStmt *sql.Stmt
257+ updateMessageStmt *sql.Stmt
250− updateSessionStmt *sql.Stmt
258+ updateSessionStmt *sql.Stmt
259+ updateSessionTitleAndUsageStmt *sql.Stmt
251}
260}
252
261
253func (q *Queries) WithTx(tx *sql.Tx) *Queries {
262func (q *Queries) WithTx(tx *sql.Tx) *Queries {
254 return &Queries{
263 return &Queries{
255− db: tx,
264+ db: tx,
256− tx: tx,
265+ tx: tx,
257− createFileStmt: q.createFileStmt,
266+ createFileStmt: q.createFileStmt,
258− createMessageStmt: q.createMessageStmt,
267+ createMessageStmt: q.createMessageStmt,
259− createSessionStmt: q.createSessionStmt,
268+ createSessionStmt: q.createSessionStmt,
260− deleteFileStmt: q.deleteFileStmt,
269+ deleteFileStmt: q.deleteFileStmt,
261− deleteMessageStmt: q.deleteMessageStmt,
270+ deleteMessageStmt: q.deleteMessageStmt,
262− deleteSessionStmt: q.deleteSessionStmt,
271+ deleteSessionStmt: q.deleteSessionStmt,
263− deleteSessionFilesStmt: q.deleteSessionFilesStmt,
272+ deleteSessionFilesStmt: q.deleteSessionFilesStmt,
264− deleteSessionMessagesStmt: q.deleteSessionMessagesStmt,
273+ deleteSessionMessagesStmt: q.deleteSessionMessagesStmt,
265− getFileStmt: q.getFileStmt,
274+ getFileStmt: q.getFileStmt,
266− getFileByPathAndSessionStmt: q.getFileByPathAndSessionStmt,
275+ getFileByPathAndSessionStmt: q.getFileByPathAndSessionStmt,
267− getMessageStmt: q.getMessageStmt,
276+ getMessageStmt: q.getMessageStmt,
268− getSessionByIDStmt: q.getSessionByIDStmt,
277+ getSessionByIDStmt: q.getSessionByIDStmt,
269− listFilesByPathStmt: q.listFilesByPathStmt,
278+ listFilesByPathStmt: q.listFilesByPathStmt,
270− listFilesBySessionStmt: q.listFilesBySessionStmt,
279+ listFilesBySessionStmt: q.listFilesBySessionStmt,
271− listLatestSessionFilesStmt: q.listLatestSessionFilesStmt,
280+ listLatestSessionFilesStmt: q.listLatestSessionFilesStmt,
272− listMessagesBySessionStmt: q.listMessagesBySessionStmt,
281+ listMessagesBySessionStmt: q.listMessagesBySessionStmt,
273− listNewFilesStmt: q.listNewFilesStmt,
282+ listNewFilesStmt: q.listNewFilesStmt,
274− listSessionsStmt: q.listSessionsStmt,
283+ listSessionsStmt: q.listSessionsStmt,
275− updateMessageStmt: q.updateMessageStmt,
284+ updateMessageStmt: q.updateMessageStmt,
276− updateSessionStmt: q.updateSessionStmt,
285+ updateSessionStmt: q.updateSessionStmt,
286+ updateSessionTitleAndUsageStmt: q.updateSessionTitleAndUsageStmt,
277 }
287 }
278}
288}
internal/db/querier.go+1
@@ -29,6 +29,7 @@ type Querier interface {
29 ListSessions(ctx context.Context) ([]Session, error)
29 ListSessions(ctx context.Context) ([]Session, error)
30 UpdateMessage(ctx context.Context, arg UpdateMessageParams) error
30 UpdateMessage(ctx context.Context, arg UpdateMessageParams) error
31 UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error)
31 UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error)
32+ UpdateSessionTitleAndUsage(ctx context.Context, arg UpdateSessionTitleAndUsageParams) error
32}
33}
33
34
34var _ Querier = (*Queries)(nil)
35var _ Querier = (*Queries)(nil)
internal/db/sessions.sql.go+29
@@ -199,3 +199,32 @@ func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) (S
199 )
199 )
200 return i, err
200 return i, err
201}
201}
202+
203+const updateSessionTitleAndUsage = `-- name: UpdateSessionTitleAndUsage :exec
204+UPDATE sessions
205+SET
206+ title = ?,
207+ prompt_tokens = prompt_tokens + ?,
208+ completion_tokens = completion_tokens + ?,
209+ cost = cost + ?
210+WHERE id = ?
211+`
212+
213+type UpdateSessionTitleAndUsageParams struct {
214+ Title string `json:"title"`
215+ PromptTokens int64 `json:"prompt_tokens"`
216+ CompletionTokens int64 `json:"completion_tokens"`
217+ Cost float64 `json:"cost"`
218+ ID string `json:"id"`
219+}
220+
221+func (q *Queries) UpdateSessionTitleAndUsage(ctx context.Context, arg UpdateSessionTitleAndUsageParams) error {
222+ _, err := q.exec(ctx, q.updateSessionTitleAndUsageStmt, updateSessionTitleAndUsage,
223+ arg.Title,
224+ arg.PromptTokens,
225+ arg.CompletionTokens,
226+ arg.Cost,
227+ arg.ID,
228+ )
229+ return err
230+}
internal/db/sql/sessions.sql+9
@@ -46,6 +46,15 @@ SET
46WHERE id = ?
46WHERE id = ?
47RETURNING *;
47RETURNING *;
48
48
49+-- name: UpdateSessionTitleAndUsage :exec
50+UPDATE sessions
51+SET
52+ title = ?,
53+ prompt_tokens = prompt_tokens + ?,
54+ completion_tokens = completion_tokens + ?,
55+ cost = cost + ?
56+WHERE id = ?;
57+
49
58
50-- name: DeleteSession :exec
59-- name: DeleteSession :exec
51DELETE FROM sessions
60DELETE FROM sessions
internal/message/attachment.go+5
@@ -1,8 +1,13 @@
1package message
1package message
2
2
3+import "strings"
4+
3type Attachment struct {
5type Attachment struct {
4 FilePath string
6 FilePath string
5 FileName string
7 FileName string
6 MimeType string
8 MimeType string
7 Content []byte
9 Content []byte
8}
10}
11+
12+func (a Attachment) IsText() bool { return strings.HasPrefix(a.MimeType, "text/") }
13+func (a Attachment) IsImage() bool { return strings.HasPrefix(a.MimeType, "image/") }
internal/message/content.go+46
@@ -3,6 +3,7 @@ package message
3import (
3import (
4 "encoding/base64"
4 "encoding/base64"
5 "errors"
5 "errors"
6+ "fmt"
6 "slices"
7 "slices"
7 "strings"
8 "strings"
8 "time"
9 "time"
@@ -407,6 +408,15 @@ func (m *Message) SetToolResults(tr []ToolResult) {
407 }
408 }
408}
409}
409
410
411+// Clone returns a deep copy of the message with an independent Parts slice.
412+// This prevents race conditions when the message is modified concurrently.
413+func (m *Message) Clone() Message {
414+ clone := *m
415+ clone.Parts = make([]ContentPart, len(m.Parts))
416+ copy(clone.Parts, m.Parts)
417+ return clone
418+}
419+
410func (m *Message) AddFinish(reason FinishReason, message, details string) {
420func (m *Message) AddFinish(reason FinishReason, message, details string) {
411 // remove any existing finish part
421 // remove any existing finish part
412 for i, part := range m.Parts {
422 for i, part := range m.Parts {
@@ -426,16 +436,52 @@ func (m *Message) AddBinary(mimeType string, data []byte) {
426 m.Parts = append(m.Parts, BinaryContent{MIMEType: mimeType, Data: data})
436 m.Parts = append(m.Parts, BinaryContent{MIMEType: mimeType, Data: data})
427}
437}
428
438
439+func PromptWithTextAttachments(prompt string, attachments []Attachment) string {
440+ addedAttachments := false
441+ for _, content := range attachments {
442+ if !content.IsText() {
443+ continue
444+ }
445+ if !addedAttachments {
446+ prompt += "\n<system_info>The files below have been attached by the user, consider them in your response</system_info>\n"
447+ addedAttachments = true
448+ }
449+ tag := `<file>\n`
450+ if content.FilePath != "" {
451+ tag = fmt.Sprintf("<file path='%s'>\n", content.FilePath)
452+ }
453+ prompt += tag
454+ prompt += "\n" + string(content.Content) + "\n</file>\n"
455+ }
456+ return prompt
457+}
458+
429func (m *Message) ToAIMessage() []fantasy.Message {
459func (m *Message) ToAIMessage() []fantasy.Message {
430 var messages []fantasy.Message
460 var messages []fantasy.Message
431 switch m.Role {
461 switch m.Role {
432 case User:
462 case User:
433 var parts []fantasy.MessagePart
463 var parts []fantasy.MessagePart
434 text := strings.TrimSpace(m.Content().Text)
464 text := strings.TrimSpace(m.Content().Text)
465+ var textAttachments []Attachment
466+ for _, content := range m.BinaryContent() {
467+ if !strings.HasPrefix(content.MIMEType, "text/") {
468+ continue
469+ }
470+ textAttachments = append(textAttachments, Attachment{
471+ FilePath: content.Path,
472+ MimeType: content.MIMEType,
473+ Content: content.Data,
474+ })
475+ }
476+ text = PromptWithTextAttachments(text, textAttachments)
435 if text != "" {
477 if text != "" {
436 parts = append(parts, fantasy.TextPart{Text: text})
478 parts = append(parts, fantasy.TextPart{Text: text})
437 }
479 }
438 for _, content := range m.BinaryContent() {
480 for _, content := range m.BinaryContent() {
481+ // skip text attachements
482+ if strings.HasPrefix(content.MIMEType, "text/") {
483+ continue
484+ }
439 parts = append(parts, fantasy.FilePart{
485 parts = append(parts, fantasy.FilePart{
440 Filename: content.Path,
486 Filename: content.Path,
441 Data: content.Data,
487 Data: content.Data,
internal/message/message.go+9−3
@@ -51,7 +51,9 @@ func (s *service) Delete(ctx context.Context, id string) error {
51 if err != nil {
51 if err != nil {
52 return err
52 return err
53 }
53 }
54+ // Clone the message before publishing to avoid race conditions with
55+ // concurrent modifications to the Parts slice.
54− s.Publish(pubsub.DeletedEvent, message)
56+ s.Publish(pubsub.DeletedEvent, message.Clone())
55 return nil
57 return nil
56}
58}
57
59
@@ -85,7 +87,9 @@ func (s *service) Create(ctx context.Context, sessionID string, params CreateMes
85 if err != nil {
87 if err != nil {
86 return Message{}, err
88 return Message{}, err
87 }
89 }
90+ // Clone the message before publishing to avoid race conditions with
91+ // concurrent modifications to the Parts slice.
88− s.Publish(pubsub.CreatedEvent, message)
92+ s.Publish(pubsub.CreatedEvent, message.Clone())
89 return message, nil
93 return message, nil
90}
94}
91
95
@@ -124,7 +128,9 @@ func (s *service) Update(ctx context.Context, message Message) error {
124 return err
128 return err
125 }
129 }
126 message.UpdatedAt = time.Now().Unix()
130 message.UpdatedAt = time.Now().Unix()
131+ // Clone the message before publishing to avoid race conditions with
132+ // concurrent modifications to the Parts slice.
127− s.Publish(pubsub.UpdatedEvent, message)
133+ s.Publish(pubsub.UpdatedEvent, message.Clone())
128 return nil
134 return nil
129}
135}
130
136
internal/pubsub/broker.go+3−8
@@ -92,22 +92,17 @@ func (b *Broker[T]) GetSubscriberCount() int {
92
92
93func (b *Broker[T]) Publish(t EventType, payload T) {
93func (b *Broker[T]) Publish(t EventType, payload T) {
94 b.mu.RLock()
94 b.mu.RLock()
95+ defer b.mu.RUnlock()
96+
95 select {
97 select {
96 case <-b.done:
98 case <-b.done:
97− b.mu.RUnlock()
98 return
99 return
99 default:
100 default:
100 }
101 }
101
102
102− subscribers := make([]chan Event[T], 0, len(b.subs))
103− for sub := range b.subs {
104− subscribers = append(subscribers, sub)
105− }
106− b.mu.RUnlock()
107−
108 event := Event[T]{Type: t, Payload: payload}
103 event := Event[T]{Type: t, Payload: payload}
109
104
110− for _, sub := range subscribers {
105+ for sub := range b.subs {
111 select {
106 select {
112 case sub <- event:
107 case sub <- event:
113 default:
108 default:
internal/session/session.go+13
@@ -50,6 +50,7 @@ type Service interface {
50 Get(ctx context.Context, id string) (Session, error)
50 Get(ctx context.Context, id string) (Session, error)
51 List(ctx context.Context) ([]Session, error)
51 List(ctx context.Context) ([]Session, error)
52 Save(ctx context.Context, session Session) (Session, error)
52 Save(ctx context.Context, session Session) (Session, error)
53+ UpdateTitleAndUsage(ctx context.Context, sessionID, title string, promptTokens, completionTokens int64, cost float64) error
53 Delete(ctx context.Context, id string) error
54 Delete(ctx context.Context, id string) error
54
55
55 // Agent tool session management
56 // Agent tool session management
@@ -156,6 +157,18 @@ func (s *service) Save(ctx context.Context, session Session) (Session, error) {
156 return session, nil
157 return session, nil
157}
158}
158
159
160+// UpdateTitleAndUsage updates only the title and usage fields atomically.
161+// This is safer than fetching, modifying, and saving the entire session.
162+func (s *service) UpdateTitleAndUsage(ctx context.Context, sessionID, title string, promptTokens, completionTokens int64, cost float64) error {
163+ return s.q.UpdateSessionTitleAndUsage(ctx, db.UpdateSessionTitleAndUsageParams{
164+ ID: sessionID,
165+ Title: title,
166+ PromptTokens: promptTokens,
167+ CompletionTokens: completionTokens,
168+ Cost: cost,
169+ })
170+}
171+
159func (s *service) List(ctx context.Context) ([]Session, error) {
172func (s *service) List(ctx context.Context) ([]Session, error) {
160 dbSessions, err := s.q.ListSessions(ctx)
173 dbSessions, err := s.q.ListSessions(ctx)
161 if err != nil {
174 if err != nil {
internal/skills/skills.gonew file+164
@@ -0,0 +1,164 @@
1+// Package skills implements the Agent Skills open standard.
2+// See https://agentskills.io for the specification.
3+package skills
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "log/slog"
9+ "os"
10+ "path/filepath"
11+ "regexp"
12+ "strings"
13+
14+ "gopkg.in/yaml.v3"
15+)
16+
17+const (
18+ SkillFileName = "SKILL.md"
19+ MaxNameLength = 64
20+ MaxDescriptionLength = 1024
21+ MaxCompatibilityLength = 500
22+)
23+
24+var namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
25+
26+// Skill represents a parsed SKILL.md file.
27+type Skill struct {
28+ Name string `yaml:"name" json:"name"`
29+ Description string `yaml:"description" json:"description"`
30+ License string `yaml:"license,omitempty" json:"license,omitempty"`
31+ Compatibility string `yaml:"compatibility,omitempty" json:"compatibility,omitempty"`
32+ Metadata map[string]string `yaml:"metadata,omitempty" json:"metadata,omitempty"`
33+ Instructions string `yaml:"-" json:"instructions"`
34+ Path string `yaml:"-" json:"path"`
35+ SkillFilePath string `yaml:"-" json:"skill_file_path"`
36+}
37+
38+// Validate checks if the skill meets spec requirements.
39+func (s *Skill) Validate() error {
40+ var errs []error
41+
42+ if s.Name == "" {
43+ errs = append(errs, errors.New("name is required"))
44+ } else {
45+ if len(s.Name) > MaxNameLength {
46+ errs = append(errs, fmt.Errorf("name exceeds %d characters", MaxNameLength))
47+ }
48+ if !namePattern.MatchString(s.Name) {
49+ errs = append(errs, errors.New("name must be alphanumeric with hyphens, no leading/trailing/consecutive hyphens"))
50+ }
51+ if s.Path != "" && !strings.EqualFold(filepath.Base(s.Path), s.Name) {
52+ errs = append(errs, fmt.Errorf("name %q must match directory %q", s.Name, filepath.Base(s.Path)))
53+ }
54+ }
55+
56+ if s.Description == "" {
57+ errs = append(errs, errors.New("description is required"))
58+ } else if len(s.Description) > MaxDescriptionLength {
59+ errs = append(errs, fmt.Errorf("description exceeds %d characters", MaxDescriptionLength))
60+ }
61+
62+ if len(s.Compatibility) > MaxCompatibilityLength {
63+ errs = append(errs, fmt.Errorf("compatibility exceeds %d characters", MaxCompatibilityLength))
64+ }
65+
66+ return errors.Join(errs...)
67+}
68+
69+// Parse parses a SKILL.md file.
70+func Parse(path string) (*Skill, error) {
71+ content, err := os.ReadFile(path)
72+ if err != nil {
73+ return nil, err
74+ }
75+
76+ frontmatter, body, err := splitFrontmatter(string(content))
77+ if err != nil {
78+ return nil, err
79+ }
80+
81+ var skill Skill
82+ if err := yaml.Unmarshal([]byte(frontmatter), &skill); err != nil {
83+ return nil, fmt.Errorf("parsing frontmatter: %w", err)
84+ }
85+
86+ skill.Instructions = strings.TrimSpace(body)
87+ skill.Path = filepath.Dir(path)
88+ skill.SkillFilePath = path
89+
90+ return &skill, nil
91+}
92+
93+// splitFrontmatter extracts YAML frontmatter and body from markdown content.
94+func splitFrontmatter(content string) (frontmatter, body string, err error) {
95+ // Normalize line endings to \n for consistent parsing.
96+ content = strings.ReplaceAll(content, "\r\n", "\n")
97+ if !strings.HasPrefix(content, "---\n") {
98+ return "", "", errors.New("no YAML frontmatter found")
99+ }
100+
101+ rest := strings.TrimPrefix(content, "---\n")
102+ before, after, ok := strings.Cut(rest, "\n---")
103+ if !ok {
104+ return "", "", errors.New("unclosed frontmatter")
105+ }
106+
107+ return before, after, nil
108+}
109+
110+// Discover finds all valid skills in the given paths.
111+func Discover(paths []string) []*Skill {
112+ var skills []*Skill
113+ seen := make(map[string]bool)
114+
115+ for _, base := range paths {
116+ filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error {
117+ if err != nil {
118+ return nil
119+ }
120+ if d.IsDir() || d.Name() != SkillFileName || seen[path] {
121+ return nil
122+ }
123+ seen[path] = true
124+ skill, err := Parse(path)
125+ if err != nil {
126+ slog.Warn("Failed to parse skill file", "path", path, "error", err)
127+ return nil
128+ }
129+ if err := skill.Validate(); err != nil {
130+ slog.Warn("Skill validation failed", "path", path, "error", err)
131+ return nil
132+ }
133+ slog.Info("Successfully loaded skill", "name", skill.Name, "path", path)
134+ skills = append(skills, skill)
135+ return nil
136+ })
137+ }
138+
139+ return skills
140+}
141+
142+// ToPromptXML generates XML for injection into the system prompt.
143+func ToPromptXML(skills []*Skill) string {
144+ if len(skills) == 0 {
145+ return ""
146+ }
147+
148+ var sb strings.Builder
149+ sb.WriteString("<available_skills>\n")
150+ for _, s := range skills {
151+ sb.WriteString(" <skill>\n")
152+ fmt.Fprintf(&sb, " <name>%s</name>\n", escape(s.Name))
153+ fmt.Fprintf(&sb, " <description>%s</description>\n", escape(s.Description))
154+ fmt.Fprintf(&sb, " <location>%s</location>\n", escape(s.SkillFilePath))
155+ sb.WriteString(" </skill>\n")
156+ }
157+ sb.WriteString("</available_skills>")
158+ return sb.String()
159+}
160+
161+func escape(s string) string {
162+ r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", "\"", "&quot;", "'", "&apos;")
163+ return r.Replace(s)
164+}
internal/skills/skills_test.gonew file+249
@@ -0,0 +1,249 @@
1+package skills
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestParse(t *testing.T) {
13+ t.Parallel()
14+
15+ tests := []struct {
16+ name string
17+ content string
18+ wantName string
19+ wantDesc string
20+ wantLicense string
21+ wantCompat string
22+ wantMeta map[string]string
23+ wantTools string
24+ wantInstr string
25+ wantErr bool
26+ }{
27+ {
28+ name: "full skill",
29+ content: `---
30+name: pdf-processing
31+description: Extracts text and tables from PDF files, fills PDF forms, and merges multiple PDFs.
32+license: Apache-2.0
33+compatibility: Requires python 3.8+, pdfplumber, pdfrw libraries
34+metadata:
35+ author: example-org
36+ version: "1.0"
37+---
38+
39+# PDF Processing
40+
41+## When to use this skill
42+Use this skill when the user needs to work with PDF files.
43+`,
44+ wantName: "pdf-processing",
45+ wantDesc: "Extracts text and tables from PDF files, fills PDF forms, and merges multiple PDFs.",
46+ wantLicense: "Apache-2.0",
47+ wantCompat: "Requires python 3.8+, pdfplumber, pdfrw libraries",
48+ wantMeta: map[string]string{"author": "example-org", "version": "1.0"},
49+ wantInstr: "# PDF Processing\n\n## When to use this skill\nUse this skill when the user needs to work with PDF files.",
50+ },
51+ {
52+ name: "minimal skill",
53+ content: `---
54+name: my-skill
55+description: A simple skill for testing.
56+---
57+
58+# My Skill
59+
60+Instructions here.
61+`,
62+ wantName: "my-skill",
63+ wantDesc: "A simple skill for testing.",
64+ wantInstr: "# My Skill\n\nInstructions here.",
65+ },
66+ {
67+ name: "no frontmatter",
68+ content: "# Just Markdown\n\nNo frontmatter here.",
69+ wantErr: true,
70+ },
71+ }
72+
73+ for _, tt := range tests {
74+ t.Run(tt.name, func(t *testing.T) {
75+ t.Parallel()
76+
77+ // Write content to temp file.
78+ dir := t.TempDir()
79+ path := filepath.Join(dir, "SKILL.md")
80+ require.NoError(t, os.WriteFile(path, []byte(tt.content), 0o644))
81+
82+ skill, err := Parse(path)
83+ if tt.wantErr {
84+ require.Error(t, err)
85+ return
86+ }
87+ require.NoError(t, err)
88+
89+ require.Equal(t, tt.wantName, skill.Name)
90+ require.Equal(t, tt.wantDesc, skill.Description)
91+ require.Equal(t, tt.wantLicense, skill.License)
92+ require.Equal(t, tt.wantCompat, skill.Compatibility)
93+
94+ if tt.wantMeta != nil {
95+ require.Equal(t, tt.wantMeta, skill.Metadata)
96+ }
97+
98+ require.Equal(t, tt.wantInstr, skill.Instructions)
99+ })
100+ }
101+}
102+
103+func TestSkillValidate(t *testing.T) {
104+ t.Parallel()
105+
106+ tests := []struct {
107+ name string
108+ skill Skill
109+ wantErr bool
110+ errMsg string
111+ }{
112+ {
113+ name: "valid skill",
114+ skill: Skill{
115+ Name: "pdf-processing",
116+ Description: "Processes PDF files.",
117+ Path: "/skills/pdf-processing",
118+ },
119+ },
120+ {
121+ name: "missing name",
122+ skill: Skill{Description: "Some description."},
123+ wantErr: true,
124+ errMsg: "name is required",
125+ },
126+ {
127+ name: "missing description",
128+ skill: Skill{Name: "my-skill", Path: "/skills/my-skill"},
129+ wantErr: true,
130+ errMsg: "description is required",
131+ },
132+ {
133+ name: "name too long",
134+ skill: Skill{Name: strings.Repeat("a", 65), Description: "Some description."},
135+ wantErr: true,
136+ errMsg: "exceeds",
137+ },
138+ {
139+ name: "valid name - mixed case",
140+ skill: Skill{Name: "MySkill", Description: "Some description.", Path: "/skills/MySkill"},
141+ wantErr: false,
142+ },
143+ {
144+ name: "invalid name - starts with hyphen",
145+ skill: Skill{Name: "-my-skill", Description: "Some description."},
146+ wantErr: true,
147+ errMsg: "alphanumeric with hyphens",
148+ },
149+ {
150+ name: "name doesn't match directory",
151+ skill: Skill{Name: "my-skill", Description: "Some description.", Path: "/skills/other-skill"},
152+ wantErr: true,
153+ errMsg: "must match directory",
154+ },
155+ {
156+ name: "description too long",
157+ skill: Skill{Name: "my-skill", Description: strings.Repeat("a", 1025), Path: "/skills/my-skill"},
158+ wantErr: true,
159+ errMsg: "description exceeds",
160+ },
161+ {
162+ name: "compatibility too long",
163+ skill: Skill{Name: "my-skill", Description: "desc", Compatibility: strings.Repeat("a", 501), Path: "/skills/my-skill"},
164+ wantErr: true,
165+ errMsg: "compatibility exceeds",
166+ },
167+ }
168+
169+ for _, tt := range tests {
170+ t.Run(tt.name, func(t *testing.T) {
171+ t.Parallel()
172+
173+ err := tt.skill.Validate()
174+ if tt.wantErr {
175+ require.Error(t, err)
176+ require.Contains(t, err.Error(), tt.errMsg)
177+ } else {
178+ require.NoError(t, err)
179+ }
180+ })
181+ }
182+}
183+
184+func TestDiscover(t *testing.T) {
185+ t.Parallel()
186+
187+ tmpDir := t.TempDir()
188+
189+ // Create valid skill 1.
190+ skill1Dir := filepath.Join(tmpDir, "skill-one")
191+ require.NoError(t, os.MkdirAll(skill1Dir, 0o755))
192+ require.NoError(t, os.WriteFile(filepath.Join(skill1Dir, "SKILL.md"), []byte(`---
193+name: skill-one
194+description: First test skill.
195+---
196+# Skill One
197+`), 0o644))
198+
199+ // Create valid skill 2 in nested directory.
200+ skill2Dir := filepath.Join(tmpDir, "nested", "skill-two")
201+ require.NoError(t, os.MkdirAll(skill2Dir, 0o755))
202+ require.NoError(t, os.WriteFile(filepath.Join(skill2Dir, "SKILL.md"), []byte(`---
203+name: skill-two
204+description: Second test skill.
205+---
206+# Skill Two
207+`), 0o644))
208+
209+ // Create invalid skill (won't be included).
210+ invalidDir := filepath.Join(tmpDir, "invalid-dir")
211+ require.NoError(t, os.MkdirAll(invalidDir, 0o755))
212+ require.NoError(t, os.WriteFile(filepath.Join(invalidDir, "SKILL.md"), []byte(`---
213+name: wrong-name
214+description: Name doesn't match directory.
215+---
216+`), 0o644))
217+
218+ skills := Discover([]string{tmpDir})
219+ require.Len(t, skills, 2)
220+
221+ names := make(map[string]bool)
222+ for _, s := range skills {
223+ names[s.Name] = true
224+ }
225+ require.True(t, names["skill-one"])
226+ require.True(t, names["skill-two"])
227+}
228+
229+func TestToPromptXML(t *testing.T) {
230+ t.Parallel()
231+
232+ skills := []*Skill{
233+ {Name: "pdf-processing", Description: "Extracts text from PDFs.", SkillFilePath: "/skills/pdf-processing/SKILL.md"},
234+ {Name: "data-analysis", Description: "Analyzes datasets & charts.", SkillFilePath: "/skills/data-analysis/SKILL.md"},
235+ }
236+
237+ xml := ToPromptXML(skills)
238+
239+ require.Contains(t, xml, "<available_skills>")
240+ require.Contains(t, xml, "<name>pdf-processing</name>")
241+ require.Contains(t, xml, "<description>Extracts text from PDFs.</description>")
242+ require.Contains(t, xml, "&amp;") // XML escaping
243+}
244+
245+func TestToPromptXMLEmpty(t *testing.T) {
246+ t.Parallel()
247+ require.Empty(t, ToPromptXML(nil))
248+ require.Empty(t, ToPromptXML([]*Skill{}))
249+}
internal/tui/components/chat/editor/editor.go+117−52
@@ -2,6 +2,7 @@ package editor
2
2
3import (
3import (
4 "context"
4 "context"
5+ "errors"
5 "fmt"
6 "fmt"
6 "math/rand"
7 "math/rand"
7 "net/http"
8 "net/http"
@@ -29,6 +30,7 @@ import (
29 "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
30 "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
30 "github.com/charmbracelet/crush/internal/tui/styles"
31 "github.com/charmbracelet/crush/internal/tui/styles"
31 "github.com/charmbracelet/crush/internal/tui/util"
32 "github.com/charmbracelet/crush/internal/tui/util"
33+ "github.com/charmbracelet/x/ansi"
32)
34)
33
35
34type Editor interface {
36type Editor interface {
@@ -84,10 +86,7 @@ var DeleteKeyMaps = DeleteAttachmentKeyMaps{
84 ),
86 ),
85}
87}
86
88
87−const (
88− maxAttachments = 5
89− maxFileResults = 25
89+const maxFileResults = 25
90−)
91
90
92type OpenEditorMsg struct {
91type OpenEditorMsg struct {
93 Text string
92 Text string
@@ -145,14 +144,14 @@ func (m *editorCmp) send() tea.Cmd {
145 return util.CmdHandler(dialogs.OpenDialogMsg{Model: quit.NewQuitDialog()})
144 return util.CmdHandler(dialogs.OpenDialogMsg{Model: quit.NewQuitDialog()})
146 }
145 }
147
146
148− m.textarea.Reset()
149 attachments := m.attachments
147 attachments := m.attachments
150
148
151− m.attachments = nil
152 if value == "" {
149 if value == "" {
153 return nil
150 return nil
154 }
151 }
155
152
153+ m.textarea.Reset()
154+ m.attachments = nil
156 // Change the placeholder when sending a new message.
155 // Change the placeholder when sending a new message.
157 m.randomizePlaceholders()
156 m.randomizePlaceholders()
158
157
@@ -176,9 +175,6 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
176 case tea.WindowSizeMsg:
175 case tea.WindowSizeMsg:
177 return m, m.repositionCompletions
176 return m, m.repositionCompletions
178 case filepicker.FilePickedMsg:
177 case filepicker.FilePickedMsg:
179− if len(m.attachments) >= maxAttachments {
180− return m, util.ReportError(fmt.Errorf("cannot add more than %d images", maxAttachments))
181− }
182 m.attachments = append(m.attachments, msg.Attachment)
178 m.attachments = append(m.attachments, msg.Attachment)
183 return m, nil
179 return m, nil
184 case completions.CompletionsOpenedMsg:
180 case completions.CompletionsOpenedMsg:
@@ -206,6 +202,17 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
206 m.currentQuery = ""
202 m.currentQuery = ""
207 m.completionsStartIndex = 0
203 m.completionsStartIndex = 0
208 }
204 }
205+ content, err := os.ReadFile(item.Path)
206+ if err != nil {
207+ // if it fails, let the LLM handle it later.
208+ return m, nil
209+ }
210+ m.attachments = append(m.attachments, message.Attachment{
211+ FilePath: item.Path,
212+ FileName: filepath.Base(item.Path),
213+ MimeType: mimeOf(content),
214+ Content: content,
215+ })
209 }
216 }
210
217
211 case commands.OpenExternalEditorMsg:
218 case commands.OpenExternalEditorMsg:
@@ -217,39 +224,29 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
217 m.textarea.SetValue(msg.Text)
224 m.textarea.SetValue(msg.Text)
218 m.textarea.MoveToEnd()
225 m.textarea.MoveToEnd()
219 case tea.PasteMsg:
226 case tea.PasteMsg:
220− path := strings.ReplaceAll(msg.Content, "\\ ", " ")
227+ content, path, err := pasteToFile(msg)
221− // try to get an image
228+ if errors.Is(err, errNotAFile) {
222− path, err := filepath.Abs(strings.TrimSpace(path))
223− if err != nil {
224 m.textarea, cmd = m.textarea.Update(msg)
229 m.textarea, cmd = m.textarea.Update(msg)
225 return m, cmd
230 return m, cmd
226 }
231 }
227− isAllowedType := false
232+ if err != nil {
228− for _, ext := range filepicker.AllowedTypes {
229− if strings.HasSuffix(path, ext) {
230− isAllowedType = true
231− break
232− }
233− }
234− if !isAllowedType {
235− m.textarea, cmd = m.textarea.Update(msg)
236− return m, cmd
233+ return m, util.ReportError(err)
237 }
234 }
238− tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize)
235+
239− if tooBig {
236+ if len(content) > maxAttachmentSize {
240− m.textarea, cmd = m.textarea.Update(msg)
237+ return m, util.ReportWarn("File is too big (>5mb)")
241− return m, cmd
242 }
238 }
243
239
244− content, err := os.ReadFile(path)
240+ mimeType := mimeOf(content)
245− if err != nil {
241+ attachment := message.Attachment{
246− m.textarea, cmd = m.textarea.Update(msg)
242+ FilePath: path,
247− return m, cmd
243+ FileName: filepath.Base(path),
244+ MimeType: mimeType,
245+ Content: content,
246+ }
247+ if !attachment.IsText() && !attachment.IsImage() {
248+ return m, util.ReportWarn("Invalid file content type: " + mimeType)
248 }
249 }
249− mimeBufferSize := min(512, len(content))
250− mimeType := http.DetectContentType(content[:mimeBufferSize])
251− fileName := filepath.Base(path)
252− attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
253 return m, util.CmdHandler(filepicker.FilePickedMsg{
250 return m, util.CmdHandler(filepicker.FilePickedMsg{
254 Attachment: attachment,
251 Attachment: attachment,
255 })
252 })
@@ -427,18 +424,17 @@ func (m *editorCmp) View() string {
427 m.textarea.Placeholder = "Yolo mode!"
424 m.textarea.Placeholder = "Yolo mode!"
428 }
425 }
429 if len(m.attachments) == 0 {
426 if len(m.attachments) == 0 {
430− content := t.S().Base.Padding(1).Render(
427+ return t.S().Base.Padding(1).Render(
431 m.textarea.View(),
428 m.textarea.View(),
432 )
429 )
433− return content
434 }
430 }
435− content := t.S().Base.Padding(0, 1, 1, 1).Render(
431+ return t.S().Base.Padding(0, 1, 1, 1).Render(
436− lipgloss.JoinVertical(lipgloss.Top,
432+ lipgloss.JoinVertical(
433+ lipgloss.Top,
437 m.attachmentsContent(),
434 m.attachmentsContent(),
438 m.textarea.View(),
435 m.textarea.View(),
439 ),
436 ),
440 )
437 )
441− return content
442}
438}
443
439
444func (m *editorCmp) SetSize(width, height int) tea.Cmd {
440func (m *editorCmp) SetSize(width, height int) tea.Cmd {
@@ -456,24 +452,45 @@ func (m *editorCmp) GetSize() (int, int) {
456func (m *editorCmp) attachmentsContent() string {
452func (m *editorCmp) attachmentsContent() string {
457 var styledAttachments []string
453 var styledAttachments []string
458 t := styles.CurrentTheme()
454 t := styles.CurrentTheme()
459− attachmentStyles := t.S().Base.
455+ attachmentStyle := t.S().Base.
460− MarginLeft(1).
456+ Padding(0, 1).
457+ MarginRight(1).
461 Background(t.FgMuted).
458 Background(t.FgMuted).
462− Foreground(t.FgBase)
459+ Foreground(t.FgBase).
460+ Render
461+ iconStyle := t.S().Base.
462+ Foreground(t.BgSubtle).
463+ Background(t.Green).
464+ Padding(0, 1).
465+ Bold(true).
466+ Render
467+ rmStyle := t.S().Base.
468+ Padding(0, 1).
469+ Bold(true).
470+ Background(t.Red).
471+ Foreground(t.FgBase).
472+ Render
463 for i, attachment := range m.attachments {
473 for i, attachment := range m.attachments {
464− var filename string
465− if len(attachment.FileName) > 10 {
474+ filename := ansi.Truncate(filepath.Base(attachment.FileName), 10, "...")
466− filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7])
475+ icon := styles.ImageIcon
467− } else {
476+ if attachment.IsText() {
468− filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName)
477+ icon = styles.TextIcon
469 }
478 }
470 if m.deleteMode {
479 if m.deleteMode {
480+ styledAttachments = append(
481+ styledAttachments,
471− filename = fmt.Sprintf("%d%s", i, filename)
482+ rmStyle(fmt.Sprintf("%d", i)),
483+ attachmentStyle(filename),
484+ )
485+ continue
472 }
486 }
473− styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
487+ styledAttachments = append(
488+ styledAttachments,
489+ iconStyle(icon),
490+ attachmentStyle(filename),
491+ )
474 }
492 }
475− content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
493+ return lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
476− return content
477}
494}
478
495
479func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
496func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
@@ -597,3 +614,51 @@ func New(app *app.App) Editor {
597
614
598 return e
615 return e
599}
616}
617+
618+var maxAttachmentSize = 5 * 1024 * 1024 // 5MB
619+
620+var errNotAFile = errors.New("not a file")
621+
622+func pasteToFile(msg tea.PasteMsg) ([]byte, string, error) {
623+ content, path, err := filepathToFile(msg.Content)
624+ if err == nil {
625+ return content, path, err
626+ }
627+
628+ if strings.Count(msg.Content, "\n") > 2 {
629+ return contentToFile([]byte(msg.Content))
630+ }
631+
632+ return nil, "", errNotAFile
633+}
634+
635+func contentToFile(content []byte) ([]byte, string, error) {
636+ f, err := os.CreateTemp("", "paste_*.txt")
637+ if err != nil {
638+ return nil, "", err
639+ }
640+ if _, err := f.Write(content); err != nil {
641+ return nil, "", err
642+ }
643+ if err := f.Close(); err != nil {
644+ return nil, "", err
645+ }
646+ return content, f.Name(), nil
647+}
648+
649+func filepathToFile(name string) ([]byte, string, error) {
650+ path, err := filepath.Abs(strings.TrimSpace(strings.ReplaceAll(name, "\\", "")))
651+ if err != nil {
652+ return nil, "", err
653+ }
654+ content, err := os.ReadFile(path)
655+ if err != nil {
656+ return nil, "", err
657+ }
658+ return content, path, nil
659+}
660+
661+func mimeOf(content []byte) string {
662+ mimeBufferSize := min(512, len(content))
663+ return http.DetectContentType(content[:mimeBufferSize])
664+}
internal/tui/components/chat/messages/messages.go+22−9
@@ -227,19 +227,32 @@ func (m *messageCmp) renderUserMessage() string {
227 m.toMarkdown(m.message.Content().String()),
227 m.toMarkdown(m.message.Content().String()),
228 }
228 }
229
229
230− attachmentStyles := t.S().Text.
230+ attachmentStyle := t.S().Base.
231− MarginLeft(1).
231+ Padding(0, 1).
232+ MarginRight(1).
232− Background(t.BgSubtle)
233+ Background(t.FgMuted).
234+ Foreground(t.FgBase).
235+ Render
236+ iconStyle := t.S().Base.
237+ Foreground(t.BgSubtle).
238+ Background(t.Green).
239+ Padding(0, 1).
240+ Bold(true).
241+ Render
233
242
234 attachments := make([]string, len(m.message.BinaryContent()))
243 attachments := make([]string, len(m.message.BinaryContent()))
235 for i, attachment := range m.message.BinaryContent() {
244 for i, attachment := range m.message.BinaryContent() {
236 const maxFilenameWidth = 10
245 const maxFilenameWidth = 10
237− filename := filepath.Base(attachment.Path)
246+ filename := ansi.Truncate(filepath.Base(attachment.Path), 10, "...")
238− attachments[i] = attachmentStyles.Render(fmt.Sprintf(
247+ icon := styles.ImageIcon
239− " %s %s ",
248+ if strings.HasPrefix(attachment.MIMEType, "text/") {
240− styles.DocumentIcon,
249+ icon = styles.TextIcon
241− ansi.Truncate(filename, maxFilenameWidth, "..."),
250+ }
251+ attachments[i] = lipgloss.JoinHorizontal(
252+ lipgloss.Left,
253+ iconStyle(icon),
254+ attachmentStyle(filename),
242− ))
255+ )
243 }
256 }
244
257
245 if len(attachments) > 0 {
258 if len(attachments) > 0 {
internal/tui/components/chat/splash/splash.go+199−69
@@ -12,12 +12,15 @@ import (
12 "github.com/atotto/clipboard"
12 "github.com/atotto/clipboard"
13 "github.com/charmbracelet/catwalk/pkg/catwalk"
13 "github.com/charmbracelet/catwalk/pkg/catwalk"
14 "github.com/charmbracelet/crush/internal/agent"
14 "github.com/charmbracelet/crush/internal/agent"
15+ hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
15 "github.com/charmbracelet/crush/internal/config"
16 "github.com/charmbracelet/crush/internal/config"
16 "github.com/charmbracelet/crush/internal/home"
17 "github.com/charmbracelet/crush/internal/home"
17 "github.com/charmbracelet/crush/internal/tui/components/chat"
18 "github.com/charmbracelet/crush/internal/tui/components/chat"
18 "github.com/charmbracelet/crush/internal/tui/components/core"
19 "github.com/charmbracelet/crush/internal/tui/components/core"
19 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
20 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
20 "github.com/charmbracelet/crush/internal/tui/components/dialogs/claude"
21 "github.com/charmbracelet/crush/internal/tui/components/dialogs/claude"
22+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot"
23+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper"
21 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
24 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
22 "github.com/charmbracelet/crush/internal/tui/components/logo"
25 "github.com/charmbracelet/crush/internal/tui/components/logo"
23 lspcomponent "github.com/charmbracelet/crush/internal/tui/components/lsp"
26 lspcomponent "github.com/charmbracelet/crush/internal/tui/components/lsp"
@@ -55,6 +58,12 @@ type Splash interface {
55
58
56 // IsClaudeOAuthComplete returns whether Claude OAuth flow is complete
59 // IsClaudeOAuthComplete returns whether Claude OAuth flow is complete
57 IsClaudeOAuthComplete() bool
60 IsClaudeOAuthComplete() bool
61+
62+ // IsShowingClaudeOAuth2 returns whether showing Hyper OAuth2 flow
63+ IsShowingHyperOAuth2() bool
64+
65+ // IsShowingClaudeOAuth2 returns whether showing GitHub Copilot OAuth2 flow
66+ IsShowingCopilotOAuth2() bool
58}
67}
59
68
60const (
69const (
@@ -87,6 +96,14 @@ type splashCmp struct {
87 isAPIKeyValid bool
96 isAPIKeyValid bool
88 apiKeyValue string
97 apiKeyValue string
89
98
99+ // Hyper device flow state
100+ hyperDeviceFlow *hyper.DeviceFlow
101+ showHyperDeviceFlow bool
102+
103+ // Copilot device flow state
104+ copilotDeviceFlow *copilot.DeviceFlow
105+ showCopilotDeviceFlow bool
106+
90 // Claude state
107 // Claude state
91 claudeAuthMethodChooser *claude.AuthMethodChooser
108 claudeAuthMethodChooser *claude.AuthMethodChooser
92 claudeOAuth2 *claude.OAuth2
109 claudeOAuth2 *claude.OAuth2
@@ -186,6 +203,26 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
186 }
203 }
187
204
188 return s, tea.Batch(cmds...)
205 return s, tea.Batch(cmds...)
206+ case hyper.DeviceFlowCompletedMsg:
207+ s.showHyperDeviceFlow = false
208+ return s, s.saveAPIKeyAndContinue(msg.Token, true)
209+ case hyper.DeviceAuthInitiatedMsg, hyper.DeviceFlowErrorMsg:
210+ if s.hyperDeviceFlow != nil {
211+ u, cmd := s.hyperDeviceFlow.Update(msg)
212+ s.hyperDeviceFlow = u.(*hyper.DeviceFlow)
213+ return s, cmd
214+ }
215+ return s, nil
216+ case copilot.DeviceAuthInitiatedMsg, copilot.DeviceFlowErrorMsg:
217+ if s.copilotDeviceFlow != nil {
218+ u, cmd := s.copilotDeviceFlow.Update(msg)
219+ s.copilotDeviceFlow = u.(*copilot.DeviceFlow)
220+ return s, cmd
221+ }
222+ return s, nil
223+ case copilot.DeviceFlowCompletedMsg:
224+ s.showCopilotDeviceFlow = false
225+ return s, s.saveAPIKeyAndContinue(msg.Token, true)
189 case claude.AuthenticationCompleteMsg:
226 case claude.AuthenticationCompleteMsg:
190 s.showClaudeAuthMethodChooser = false
227 s.showClaudeAuthMethodChooser = false
191 s.showClaudeOAuth2 = false
228 s.showClaudeOAuth2 = false
@@ -205,41 +242,49 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
205 }
242 }
206 case tea.KeyPressMsg:
243 case tea.KeyPressMsg:
207 switch {
244 switch {
208− case key.Matches(msg, s.keyMap.Copy):
245+ case key.Matches(msg, s.keyMap.Copy) && s.showHyperDeviceFlow:
246+ return s, s.hyperDeviceFlow.CopyCode()
247+ case key.Matches(msg, s.keyMap.Copy) && s.showCopilotDeviceFlow:
248+ return s, s.copilotDeviceFlow.CopyCode()
209− if s.showClaudeOAuth2 && s.claudeOAuth2.State == claude.OAuthStateURL {
249+ case key.Matches(msg, s.keyMap.Copy) && s.showClaudeOAuth2 && s.claudeOAuth2.State == claude.OAuthStateURL:
210− return s, tea.Sequence(
250+ return s, tea.Sequence(
211− tea.SetClipboard(s.claudeOAuth2.URL),
251+ tea.SetClipboard(s.claudeOAuth2.URL),
212− func() tea.Msg {
252+ func() tea.Msg {
213− _ = clipboard.WriteAll(s.claudeOAuth2.URL)
253+ _ = clipboard.WriteAll(s.claudeOAuth2.URL)
214− return nil
254+ return nil
215− },
255+ },
216− util.ReportInfo("URL copied to clipboard"),
256+ util.ReportInfo("URL copied to clipboard"),
217− )
257+ )
218− } else if s.showClaudeAuthMethodChooser {
258+ case key.Matches(msg, s.keyMap.Copy) && s.showClaudeAuthMethodChooser:
219− u, cmd := s.claudeAuthMethodChooser.Update(msg)
259+ u, cmd := s.claudeAuthMethodChooser.Update(msg)
220− s.claudeAuthMethodChooser = u.(*claude.AuthMethodChooser)
260+ s.claudeAuthMethodChooser = u.(*claude.AuthMethodChooser)
221− return s, cmd
261+ return s, cmd
222− } else if s.showClaudeOAuth2 {
262+ case key.Matches(msg, s.keyMap.Copy) && s.showClaudeOAuth2:
223− u, cmd := s.claudeOAuth2.Update(msg)
263+ u, cmd := s.claudeOAuth2.Update(msg)
224− s.claudeOAuth2 = u.(*claude.OAuth2)
264+ s.claudeOAuth2 = u.(*claude.OAuth2)
225− return s, cmd
265+ return s, cmd
226− }
227 case key.Matches(msg, s.keyMap.Back):
266 case key.Matches(msg, s.keyMap.Back):
267+ switch {
228− if s.showClaudeAuthMethodChooser {
268+ case s.showClaudeAuthMethodChooser:
229 s.claudeAuthMethodChooser.SetDefaults()
269 s.claudeAuthMethodChooser.SetDefaults()
230 s.showClaudeAuthMethodChooser = false
270 s.showClaudeAuthMethodChooser = false
231 return s, nil
271 return s, nil
232− }
233− if s.showClaudeOAuth2 {
272+ case s.showClaudeOAuth2:
234 s.claudeOAuth2.SetDefaults()
273 s.claudeOAuth2.SetDefaults()
235 s.showClaudeOAuth2 = false
274 s.showClaudeOAuth2 = false
236 s.showClaudeAuthMethodChooser = true
275 s.showClaudeAuthMethodChooser = true
237 return s, nil
276 return s, nil
238− }
277+ case s.showHyperDeviceFlow:
239− if s.isAPIKeyValid {
278+ s.hyperDeviceFlow = nil
279+ s.showHyperDeviceFlow = false
240 return s, nil
280 return s, nil
241− }
281+ case s.showCopilotDeviceFlow:
282+ s.copilotDeviceFlow = nil
283+ s.showCopilotDeviceFlow = false
284+ return s, nil
285+ case s.isAPIKeyValid:
286+ return s, nil
242− if s.needsAPIKey {
287+ case s.needsAPIKey:
243 if s.selectedModel.Provider.ID == catwalk.InferenceProviderAnthropic {
288 if s.selectedModel.Provider.ID == catwalk.InferenceProviderAnthropic {
244 s.showClaudeAuthMethodChooser = true
289 s.showClaudeAuthMethodChooser = true
245 }
290 }
@@ -251,7 +296,8 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
251 return s, nil
296 return s, nil
252 }
297 }
253 case key.Matches(msg, s.keyMap.Select):
298 case key.Matches(msg, s.keyMap.Select):
299+ switch {
254− if s.showClaudeAuthMethodChooser {
300+ case s.showClaudeAuthMethodChooser:
255 selectedItem := s.modelList.SelectedModel()
301 selectedItem := s.modelList.SelectedModel()
256 if selectedItem == nil {
302 if selectedItem == nil {
257 return s, nil
303 return s, nil
@@ -269,16 +315,17 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
269 s.showClaudeOAuth2 = true
315 s.showClaudeOAuth2 = true
270 }
316 }
271 return s, nil
317 return s, nil
272− }
273− if s.showClaudeOAuth2 {
318+ case s.showClaudeOAuth2:
274 m2, cmd2 := s.claudeOAuth2.ValidationConfirm()
319 m2, cmd2 := s.claudeOAuth2.ValidationConfirm()
275 s.claudeOAuth2 = m2.(*claude.OAuth2)
320 s.claudeOAuth2 = m2.(*claude.OAuth2)
276 return s, cmd2
321 return s, cmd2
277− }
322+ case s.showHyperDeviceFlow:
323+ return s, s.hyperDeviceFlow.CopyCodeAndOpenURL()
324+ case s.showCopilotDeviceFlow:
325+ return s, s.copilotDeviceFlow.CopyCodeAndOpenURL()
278− if s.isAPIKeyValid {
326+ case s.isAPIKeyValid:
279 return s, s.saveAPIKeyAndContinue(s.apiKeyValue, true)
327 return s, s.saveAPIKeyAndContinue(s.apiKeyValue, true)
280− }
281− if s.isOnboarding && !s.needsAPIKey {
328+ case s.isOnboarding && !s.needsAPIKey:
282 selectedItem := s.modelList.SelectedModel()
329 selectedItem := s.modelList.SelectedModel()
283 if selectedItem == nil {
330 if selectedItem == nil {
284 return s, nil
331 return s, nil
@@ -288,9 +335,26 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
288 s.isOnboarding = false
335 s.isOnboarding = false
289 return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
336 return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
290 } else {
337 } else {
338+ switch selectedItem.Provider.ID {
291− if selectedItem.Provider.ID == catwalk.InferenceProviderAnthropic {
339+ case catwalk.InferenceProviderAnthropic:
292 s.showClaudeAuthMethodChooser = true
340 s.showClaudeAuthMethodChooser = true
293 return s, nil
341 return s, nil
342+ case hyperp.Name:
343+ s.selectedModel = selectedItem
344+ s.showHyperDeviceFlow = true
345+ s.hyperDeviceFlow = hyper.NewDeviceFlow()
346+ s.hyperDeviceFlow.SetWidth(min(s.width-2, 60))
347+ return s, s.hyperDeviceFlow.Init()
348+ case catwalk.InferenceProviderCopilot:
349+ if token, ok := config.Get().ImportCopilot(); ok {
350+ s.selectedModel = selectedItem
351+ return s, s.saveAPIKeyAndContinue(token, true)
352+ }
353+ s.selectedModel = selectedItem
354+ s.showCopilotDeviceFlow = true
355+ s.copilotDeviceFlow = copilot.NewDeviceFlow()
356+ s.copilotDeviceFlow.SetWidth(min(s.width-2, 60))
357+ return s, s.copilotDeviceFlow.Init()
294 }
358 }
295 // Provider not configured, show API key input
359 // Provider not configured, show API key input
296 s.needsAPIKey = true
360 s.needsAPIKey = true
@@ -298,7 +362,7 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
298 s.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
362 s.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
299 return s, nil
363 return s, nil
300 }
364 }
301− } else if s.needsAPIKey {
365+ case s.needsAPIKey:
302 // Handle API key submission
366 // Handle API key submission
303 s.apiKeyValue = strings.TrimSpace(s.apiKeyInput.Value())
367 s.apiKeyValue = strings.TrimSpace(s.apiKeyInput.Value())
304 if s.apiKeyValue == "" {
368 if s.apiKeyValue == "" {
@@ -339,7 +403,7 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
339 }
403 }
340 },
404 },
341 )
405 )
342− } else if s.needsProjectInit {
406+ case s.needsProjectInit:
343 return s, s.initializeProject()
407 return s, s.initializeProject()
344 }
408 }
345 case key.Matches(msg, s.keyMap.Tab, s.keyMap.LeftRight):
409 case key.Matches(msg, s.keyMap.Tab, s.keyMap.LeftRight):
@@ -387,44 +451,71 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
387 return s, s.initializeProject()
451 return s, s.initializeProject()
388 }
452 }
389 default:
453 default:
454+ switch {
390− if s.showClaudeAuthMethodChooser {
455+ case s.showClaudeAuthMethodChooser:
391 u, cmd := s.claudeAuthMethodChooser.Update(msg)
456 u, cmd := s.claudeAuthMethodChooser.Update(msg)
392 s.claudeAuthMethodChooser = u.(*claude.AuthMethodChooser)
457 s.claudeAuthMethodChooser = u.(*claude.AuthMethodChooser)
393 return s, cmd
458 return s, cmd
394− } else if s.showClaudeOAuth2 {
459+ case s.showClaudeOAuth2:
395 u, cmd := s.claudeOAuth2.Update(msg)
460 u, cmd := s.claudeOAuth2.Update(msg)
396 s.claudeOAuth2 = u.(*claude.OAuth2)
461 s.claudeOAuth2 = u.(*claude.OAuth2)
397 return s, cmd
462 return s, cmd
463+ case s.showHyperDeviceFlow:
464+ u, cmd := s.hyperDeviceFlow.Update(msg)
465+ s.hyperDeviceFlow = u.(*hyper.DeviceFlow)
466+ return s, cmd
467+ case s.showCopilotDeviceFlow:
468+ u, cmd := s.copilotDeviceFlow.Update(msg)
469+ s.copilotDeviceFlow = u.(*copilot.DeviceFlow)
470+ return s, cmd
398− } else if s.needsAPIKey {
471+ case s.needsAPIKey:
399 u, cmd := s.apiKeyInput.Update(msg)
472 u, cmd := s.apiKeyInput.Update(msg)
400 s.apiKeyInput = u.(*models.APIKeyInput)
473 s.apiKeyInput = u.(*models.APIKeyInput)
401 return s, cmd
474 return s, cmd
402− } else if s.isOnboarding {
475+ case s.isOnboarding:
403 u, cmd := s.modelList.Update(msg)
476 u, cmd := s.modelList.Update(msg)
404 s.modelList = u
477 s.modelList = u
405 return s, cmd
478 return s, cmd
406 }
479 }
407 }
480 }
408 case tea.PasteMsg:
481 case tea.PasteMsg:
482+ switch {
409− if s.showClaudeOAuth2 {
483+ case s.showClaudeOAuth2:
410 u, cmd := s.claudeOAuth2.Update(msg)
484 u, cmd := s.claudeOAuth2.Update(msg)
411 s.claudeOAuth2 = u.(*claude.OAuth2)
485 s.claudeOAuth2 = u.(*claude.OAuth2)
412 return s, cmd
486 return s, cmd
487+ case s.showHyperDeviceFlow:
488+ u, cmd := s.hyperDeviceFlow.Update(msg)
489+ s.hyperDeviceFlow = u.(*hyper.DeviceFlow)
490+ return s, cmd
491+ case s.showCopilotDeviceFlow:
492+ u, cmd := s.copilotDeviceFlow.Update(msg)
493+ s.copilotDeviceFlow = u.(*copilot.DeviceFlow)
494+ return s, cmd
413− } else if s.needsAPIKey {
495+ case s.needsAPIKey:
414 u, cmd := s.apiKeyInput.Update(msg)
496 u, cmd := s.apiKeyInput.Update(msg)
415 s.apiKeyInput = u.(*models.APIKeyInput)
497 s.apiKeyInput = u.(*models.APIKeyInput)
416 return s, cmd
498 return s, cmd
417− } else if s.isOnboarding {
499+ case s.isOnboarding:
418 var cmd tea.Cmd
500 var cmd tea.Cmd
419 s.modelList, cmd = s.modelList.Update(msg)
501 s.modelList, cmd = s.modelList.Update(msg)
420 return s, cmd
502 return s, cmd
421 }
503 }
422 case spinner.TickMsg:
504 case spinner.TickMsg:
505+ switch {
423− if s.showClaudeOAuth2 {
506+ case s.showClaudeOAuth2:
424 u, cmd := s.claudeOAuth2.Update(msg)
507 u, cmd := s.claudeOAuth2.Update(msg)
425 s.claudeOAuth2 = u.(*claude.OAuth2)
508 s.claudeOAuth2 = u.(*claude.OAuth2)
426 return s, cmd
509 return s, cmd
427− } else {
510+ case s.showHyperDeviceFlow:
511+ u, cmd := s.hyperDeviceFlow.Update(msg)
512+ s.hyperDeviceFlow = u.(*hyper.DeviceFlow)
513+ return s, cmd
514+ case s.showCopilotDeviceFlow:
515+ u, cmd := s.copilotDeviceFlow.Update(msg)
516+ s.copilotDeviceFlow = u.(*copilot.DeviceFlow)
517+ return s, cmd
518+ default:
428 u, cmd := s.apiKeyInput.Update(msg)
519 u, cmd := s.apiKeyInput.Update(msg)
429 s.apiKeyInput = u.(*models.APIKeyInput)
520 s.apiKeyInput = u.(*models.APIKeyInput)
430 return s, cmd
521 return s, cmd
@@ -562,8 +653,10 @@ func (s *splashCmp) isProviderConfigured(providerID string) bool {
562func (s *splashCmp) View() string {
653func (s *splashCmp) View() string {
563 t := styles.CurrentTheme()
654 t := styles.CurrentTheme()
564 var content string
655 var content string
656+
657+ switch {
565− if s.showClaudeAuthMethodChooser {
658+ case s.showClaudeAuthMethodChooser:
566− remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
659+ remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY
567 chooserView := s.claudeAuthMethodChooser.View()
660 chooserView := s.claudeAuthMethodChooser.View()
568 authMethodSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
661 authMethodSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
569 lipgloss.JoinVertical(
662 lipgloss.JoinVertical(
@@ -578,8 +671,8 @@ func (s *splashCmp) View() string {
578 s.logoRendered,
671 s.logoRendered,
579 authMethodSelector,
672 authMethodSelector,
580 )
673 )
581− } else if s.showClaudeOAuth2 {
674+ case s.showClaudeOAuth2:
582− remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
675+ remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY
583 oauth2View := s.claudeOAuth2.View()
676 oauth2View := s.claudeOAuth2.View()
584 oauthSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
677 oauthSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
585 lipgloss.JoinVertical(
678 lipgloss.JoinVertical(
@@ -594,8 +687,38 @@ func (s *splashCmp) View() string {
594 s.logoRendered,
687 s.logoRendered,
595 oauthSelector,
688 oauthSelector,
596 )
689 )
690+ case s.showHyperDeviceFlow:
691+ remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY
692+ hyperView := s.hyperDeviceFlow.View()
693+ hyperSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
694+ lipgloss.JoinVertical(
695+ lipgloss.Left,
696+ t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Let's Auth Hyper"),
697+ hyperView,
698+ ),
699+ )
700+ content = lipgloss.JoinVertical(
701+ lipgloss.Left,
702+ s.logoRendered,
703+ hyperSelector,
704+ )
705+ case s.showCopilotDeviceFlow:
706+ remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY
707+ copilotView := s.copilotDeviceFlow.View()
708+ copilotSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
709+ lipgloss.JoinVertical(
710+ lipgloss.Left,
711+ t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Let's Auth GitHub Copilot"),
712+ copilotView,
713+ ),
714+ )
715+ content = lipgloss.JoinVertical(
716+ lipgloss.Left,
717+ s.logoRendered,
718+ copilotSelector,
719+ )
597− } else if s.needsAPIKey {
720+ case s.needsAPIKey:
598− remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
721+ remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY
599 apiKeyView := t.S().Base.PaddingLeft(1).Render(s.apiKeyInput.View())
722 apiKeyView := t.S().Base.PaddingLeft(1).Render(s.apiKeyInput.View())
600 apiKeySelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
723 apiKeySelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
601 lipgloss.JoinVertical(
724 lipgloss.JoinVertical(
@@ -608,9 +731,9 @@ func (s *splashCmp) View() string {
608 s.logoRendered,
731 s.logoRendered,
609 apiKeySelector,
732 apiKeySelector,
610 )
733 )
611− } else if s.isOnboarding {
734+ case s.isOnboarding:
612 modelListView := s.modelList.View()
735 modelListView := s.modelList.View()
613− remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
736+ remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY
614 modelSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
737 modelSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
615 lipgloss.JoinVertical(
738 lipgloss.JoinVertical(
616 lipgloss.Left,
739 lipgloss.Left,
@@ -624,7 +747,7 @@ func (s *splashCmp) View() string {
624 s.logoRendered,
747 s.logoRendered,
625 modelSelector,
748 modelSelector,
626 )
749 )
627− } else if s.needsProjectInit {
750+ case s.needsProjectInit:
628 titleStyle := t.S().Base.Foreground(t.FgBase)
751 titleStyle := t.S().Base.Foreground(t.FgBase)
629 pathStyle := t.S().Base.Foreground(t.Success).PaddingLeft(2)
752 pathStyle := t.S().Base.Foreground(t.Success).PaddingLeft(2)
630 bodyStyle := t.S().Base.Foreground(t.FgMuted)
753 bodyStyle := t.S().Base.Foreground(t.FgMuted)
@@ -675,7 +798,7 @@ func (s *splashCmp) View() string {
675 "",
798 "",
676 initContent,
799 initContent,
677 )
800 )
678− } else {
801+ default:
679 parts := []string{
802 parts := []string{
680 s.logoRendered,
803 s.logoRendered,
681 s.infoSection(),
804 s.infoSection(),
@@ -692,28 +815,25 @@ func (s *splashCmp) View() string {
692}
815}
693
816
694func (s *splashCmp) Cursor() *tea.Cursor {
817func (s *splashCmp) Cursor() *tea.Cursor {
818+ switch {
695− if s.showClaudeAuthMethodChooser {
819+ case s.showClaudeAuthMethodChooser:
696 return nil
820 return nil
697− }
698− if s.showClaudeOAuth2 {
821+ case s.showClaudeOAuth2:
699 if cursor := s.claudeOAuth2.CodeInput.Cursor(); cursor != nil {
822 if cursor := s.claudeOAuth2.CodeInput.Cursor(); cursor != nil {
700 cursor.Y += 2 // FIXME(@andreynering): Why do we need this?
823 cursor.Y += 2 // FIXME(@andreynering): Why do we need this?
701 return s.moveCursor(cursor)
824 return s.moveCursor(cursor)
702 }
825 }
703 return nil
826 return nil
704− }
705− if s.needsAPIKey {
827+ case s.needsAPIKey:
706 cursor := s.apiKeyInput.Cursor()
828 cursor := s.apiKeyInput.Cursor()
707 if cursor != nil {
829 if cursor != nil {
708 return s.moveCursor(cursor)
830 return s.moveCursor(cursor)
709 }
831 }
710− } else if s.isOnboarding {
832+ case s.isOnboarding:
711 cursor := s.modelList.Cursor()
833 cursor := s.modelList.Cursor()
712 if cursor != nil {
834 if cursor != nil {
713 return s.moveCursor(cursor)
835 return s.moveCursor(cursor)
714 }
836 }
715− } else {
716− return nil
717 }
837 }
718 return nil
838 return nil
719}
839}
@@ -805,13 +925,14 @@ func (s *splashCmp) logoGap() int {
805
925
806// Bindings implements SplashPage.
926// Bindings implements SplashPage.
807func (s *splashCmp) Bindings() []key.Binding {
927func (s *splashCmp) Bindings() []key.Binding {
928+ switch {
808− if s.showClaudeAuthMethodChooser {
929+ case s.showClaudeAuthMethodChooser:
809 return []key.Binding{
930 return []key.Binding{
810 s.keyMap.Select,
931 s.keyMap.Select,
811 s.keyMap.Tab,
932 s.keyMap.Tab,
812 s.keyMap.Back,
933 s.keyMap.Back,
813 }
934 }
814− } else if s.showClaudeOAuth2 {
935+ case s.showClaudeOAuth2:
815 bindings := []key.Binding{
936 bindings := []key.Binding{
816 s.keyMap.Select,
937 s.keyMap.Select,
817 }
938 }
@@ -819,18 +940,18 @@ func (s *splashCmp) Bindings() []key.Binding {
819 bindings = append(bindings, s.keyMap.Copy)
940 bindings = append(bindings, s.keyMap.Copy)
820 }
941 }
821 return bindings
942 return bindings
822− } else if s.needsAPIKey {
943+ case s.needsAPIKey:
823 return []key.Binding{
944 return []key.Binding{
824 s.keyMap.Select,
945 s.keyMap.Select,
825 s.keyMap.Back,
946 s.keyMap.Back,
826 }
947 }
827− } else if s.isOnboarding {
948+ case s.isOnboarding:
828 return []key.Binding{
949 return []key.Binding{
829 s.keyMap.Select,
950 s.keyMap.Select,
830 s.keyMap.Next,
951 s.keyMap.Next,
831 s.keyMap.Previous,
952 s.keyMap.Previous,
832 }
953 }
833− } else if s.needsProjectInit {
954+ case s.needsProjectInit:
834 return []key.Binding{
955 return []key.Binding{
835 s.keyMap.Select,
956 s.keyMap.Select,
836 s.keyMap.Yes,
957 s.keyMap.Yes,
@@ -838,8 +959,9 @@ func (s *splashCmp) Bindings() []key.Binding {
838 s.keyMap.Tab,
959 s.keyMap.Tab,
839 s.keyMap.LeftRight,
960 s.keyMap.LeftRight,
840 }
961 }
962+ default:
963+ return []key.Binding{}
841 }
964 }
842− return []key.Binding{}
843}
965}
844
966
845func (s *splashCmp) getMaxInfoWidth() int {
967func (s *splashCmp) getMaxInfoWidth() int {
@@ -940,3 +1062,11 @@ func (s *splashCmp) IsClaudeOAuthURLState() bool {
940func (s *splashCmp) IsClaudeOAuthComplete() bool {
1062func (s *splashCmp) IsClaudeOAuthComplete() bool {
941 return s.showClaudeOAuth2 && s.claudeOAuth2.State == claude.OAuthStateCode && s.claudeOAuth2.ValidationState == claude.OAuthValidationStateValid
1063 return s.showClaudeOAuth2 && s.claudeOAuth2.State == claude.OAuthStateCode && s.claudeOAuth2.ValidationState == claude.OAuthValidationStateValid
942}
1064}
1065+
1066+func (s *splashCmp) IsShowingHyperOAuth2() bool {
1067+ return s.showHyperDeviceFlow
1068+}
1069+
1070+func (s *splashCmp) IsShowingCopilotOAuth2() bool {
1071+ return s.showCopilotDeviceFlow
1072+}
internal/tui/components/dialogs/models/models.go+28−22
@@ -174,13 +174,10 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
174 case tea.KeyPressMsg:
174 case tea.KeyPressMsg:
175 switch {
175 switch {
176 // Handle Hyper device flow keys
176 // Handle Hyper device flow keys
177− case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && (m.showHyperDeviceFlow || m.showCopilotDeviceFlow):
177+ case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showHyperDeviceFlow:
178− if m.hyperDeviceFlow != nil {
178+ return m, m.hyperDeviceFlow.CopyCode()
179− return m, m.hyperDeviceFlow.CopyCode()
179+ case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showCopilotDeviceFlow:
180− }
181− if m.copilotDeviceFlow != nil {
182− return m, m.copilotDeviceFlow.CopyCode()
180+ return m, m.copilotDeviceFlow.CopyCode()
183− }
184 case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showClaudeOAuth2 && m.claudeOAuth2.State == claude.OAuthStateURL:
181 case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showClaudeOAuth2 && m.claudeOAuth2.State == claude.OAuthStateURL:
185 return m, tea.Sequence(
182 return m, tea.Sequence(
186 tea.SetClipboard(m.claudeOAuth2.URL),
183 tea.SetClipboard(m.claudeOAuth2.URL),
@@ -202,6 +199,9 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
202 return m, m.copilotDeviceFlow.CopyCodeAndOpenURL()
199 return m, m.copilotDeviceFlow.CopyCodeAndOpenURL()
203 }
200 }
204 selectedItem := m.modelList.SelectedModel()
201 selectedItem := m.modelList.SelectedModel()
202+ if selectedItem == nil {
203+ return m, nil
204+ }
205
205
206 modelType := config.SelectedModelTypeLarge
206 modelType := config.SelectedModelTypeLarge
207 if m.modelList.GetModelType() == SmallModelType {
207 if m.modelList.GetModelType() == SmallModelType {
@@ -310,6 +310,11 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
310 m.hyperDeviceFlow.SetWidth(m.width - 2)
310 m.hyperDeviceFlow.SetWidth(m.width - 2)
311 return m, m.hyperDeviceFlow.Init()
311 return m, m.hyperDeviceFlow.Init()
312 case catwalk.InferenceProviderCopilot:
312 case catwalk.InferenceProviderCopilot:
313+ if token, ok := config.Get().ImportCopilot(); ok {
314+ m.selectedModel = selectedItem
315+ m.selectedModelType = modelType
316+ return m, m.saveOauthTokenAndContinue(token, true)
317+ }
313 m.showCopilotDeviceFlow = true
318 m.showCopilotDeviceFlow = true
314 m.selectedModel = selectedItem
319 m.selectedModel = selectedItem
315 m.selectedModelType = modelType
320 m.selectedModelType = modelType
@@ -337,28 +342,26 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
337 return m, m.modelList.SetModelType(LargeModelType)
342 return m, m.modelList.SetModelType(LargeModelType)
338 }
343 }
339 case key.Matches(msg, m.keyMap.Close):
344 case key.Matches(msg, m.keyMap.Close):
345+ switch {
340− if m.showHyperDeviceFlow {
346+ case m.showHyperDeviceFlow:
341 if m.hyperDeviceFlow != nil {
347 if m.hyperDeviceFlow != nil {
342 m.hyperDeviceFlow.Cancel()
348 m.hyperDeviceFlow.Cancel()
343 }
349 }
344 m.showHyperDeviceFlow = false
350 m.showHyperDeviceFlow = false
345 m.selectedModel = nil
351 m.selectedModel = nil
346− }
347− if m.showCopilotDeviceFlow {
352+ case m.showCopilotDeviceFlow:
348 if m.copilotDeviceFlow != nil {
353 if m.copilotDeviceFlow != nil {
349 m.copilotDeviceFlow.Cancel()
354 m.copilotDeviceFlow.Cancel()
350 }
355 }
351 m.showCopilotDeviceFlow = false
356 m.showCopilotDeviceFlow = false
352 m.selectedModel = nil
357 m.selectedModel = nil
353− }
354− if m.showClaudeAuthMethodChooser {
358+ case m.showClaudeAuthMethodChooser:
355 m.claudeAuthMethodChooser.SetDefaults()
359 m.claudeAuthMethodChooser.SetDefaults()
356 m.showClaudeAuthMethodChooser = false
360 m.showClaudeAuthMethodChooser = false
357 m.keyMap.isClaudeAuthChoiceHelp = false
361 m.keyMap.isClaudeAuthChoiceHelp = false
358 m.keyMap.isClaudeOAuthHelp = false
362 m.keyMap.isClaudeOAuthHelp = false
359 return m, nil
363 return m, nil
360− }
361− if m.needsAPIKey {
364+ case m.needsAPIKey:
362 if m.isAPIKeyValid {
365 if m.isAPIKeyValid {
363 return m, nil
366 return m, nil
364 }
367 }
@@ -369,37 +372,40 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
369 m.apiKeyValue = ""
372 m.apiKeyValue = ""
370 m.apiKeyInput.Reset()
373 m.apiKeyInput.Reset()
371 return m, nil
374 return m, nil
375+ default:
376+ return m, util.CmdHandler(dialogs.CloseDialogMsg{})
372 }
377 }
373− return m, util.CmdHandler(dialogs.CloseDialogMsg{})
374 default:
378 default:
379+ switch {
375− if m.showClaudeAuthMethodChooser {
380+ case m.showClaudeAuthMethodChooser:
376 u, cmd := m.claudeAuthMethodChooser.Update(msg)
381 u, cmd := m.claudeAuthMethodChooser.Update(msg)
377 m.claudeAuthMethodChooser = u.(*claude.AuthMethodChooser)
382 m.claudeAuthMethodChooser = u.(*claude.AuthMethodChooser)
378 return m, cmd
383 return m, cmd
379− } else if m.showClaudeOAuth2 {
384+ case m.showClaudeOAuth2:
380 u, cmd := m.claudeOAuth2.Update(msg)
385 u, cmd := m.claudeOAuth2.Update(msg)
381 m.claudeOAuth2 = u.(*claude.OAuth2)
386 m.claudeOAuth2 = u.(*claude.OAuth2)
382 return m, cmd
387 return m, cmd
383− } else if m.needsAPIKey {
388+ case m.needsAPIKey:
384 u, cmd := m.apiKeyInput.Update(msg)
389 u, cmd := m.apiKeyInput.Update(msg)
385 m.apiKeyInput = u.(*APIKeyInput)
390 m.apiKeyInput = u.(*APIKeyInput)
386 return m, cmd
391 return m, cmd
387− } else {
392+ default:
388 u, cmd := m.modelList.Update(msg)
393 u, cmd := m.modelList.Update(msg)
389 m.modelList = u
394 m.modelList = u
390 return m, cmd
395 return m, cmd
391 }
396 }
392 }
397 }
393 case tea.PasteMsg:
398 case tea.PasteMsg:
399+ switch {
394− if m.showClaudeOAuth2 {
400+ case m.showClaudeOAuth2:
395 u, cmd := m.claudeOAuth2.Update(msg)
401 u, cmd := m.claudeOAuth2.Update(msg)
396 m.claudeOAuth2 = u.(*claude.OAuth2)
402 m.claudeOAuth2 = u.(*claude.OAuth2)
397 return m, cmd
403 return m, cmd
398− } else if m.needsAPIKey {
404+ case m.needsAPIKey:
399 u, cmd := m.apiKeyInput.Update(msg)
405 u, cmd := m.apiKeyInput.Update(msg)
400 m.apiKeyInput = u.(*APIKeyInput)
406 m.apiKeyInput = u.(*APIKeyInput)
401 return m, cmd
407 return m, cmd
402− } else {
408+ default:
403 var cmd tea.Cmd
409 var cmd tea.Cmd
404 m.modelList, cmd = m.modelList.Update(msg)
410 m.modelList, cmd = m.modelList.Update(msg)
405 return m, cmd
411 return m, cmd
internal/tui/page/chat/chat.go+30−6
@@ -31,7 +31,9 @@ import (
31 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
31 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
32 "github.com/charmbracelet/crush/internal/tui/components/dialogs/claude"
32 "github.com/charmbracelet/crush/internal/tui/components/dialogs/claude"
33 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
33 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
34+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot"
34 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
35 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
36+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper"
35 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
37 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
36 "github.com/charmbracelet/crush/internal/tui/components/dialogs/reasoning"
38 "github.com/charmbracelet/crush/internal/tui/components/dialogs/reasoning"
37 "github.com/charmbracelet/crush/internal/tui/page"
39 "github.com/charmbracelet/crush/internal/tui/page"
@@ -335,7 +337,14 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
335 cmds = append(cmds, cmd)
337 cmds = append(cmds, cmd)
336 return p, tea.Batch(cmds...)
338 return p, tea.Batch(cmds...)
337
339
338− case claude.ValidationCompletedMsg, claude.AuthenticationCompleteMsg:
340+ case claude.ValidationCompletedMsg,
341+ claude.AuthenticationCompleteMsg,
342+ hyper.DeviceFlowCompletedMsg,
343+ hyper.DeviceAuthInitiatedMsg,
344+ hyper.DeviceFlowErrorMsg,
345+ copilot.DeviceAuthInitiatedMsg,
346+ copilot.DeviceFlowErrorMsg,
347+ copilot.DeviceFlowCompletedMsg:
339 if p.focusedPane == PanelTypeSplash {
348 if p.focusedPane == PanelTypeSplash {
340 u, cmd := p.splash.Update(msg)
349 u, cmd := p.splash.Update(msg)
341 p.splash = u.(splash.Splash)
350 p.splash = u.(splash.Splash)
@@ -604,8 +613,11 @@ func (p *chatPage) View() string {
604 pillsArea = pillsRow
613 pillsArea = pillsRow
605 }
614 }
606
615
616+ pillsArea = t.S().Base.
617+ MaxWidth(p.width).
618+ MarginTop(1).
607− style := t.S().Base.MarginTop(1).PaddingLeft(3)
619+ PaddingLeft(3).
608− pillsArea = style.Render(pillsArea)
620+ Render(pillsArea)
609 }
621 }
610
622
611 if p.compact {
623 if p.compact {
@@ -1050,7 +1062,8 @@ func (p *chatPage) Help() help.KeyMap {
1050 fullList = append(fullList, []key.Binding{v})
1062 fullList = append(fullList, []key.Binding{v})
1051 }
1063 }
1052 case p.isOnboarding && p.splash.IsShowingClaudeOAuth2():
1064 case p.isOnboarding && p.splash.IsShowingClaudeOAuth2():
1065+ switch {
1053− if p.splash.IsClaudeOAuthURLState() {
1066+ case p.splash.IsClaudeOAuthURLState():
1054 shortList = append(shortList,
1067 shortList = append(shortList,
1055 key.NewBinding(
1068 key.NewBinding(
1056 key.WithKeys("enter"),
1069 key.WithKeys("enter"),
@@ -1061,14 +1074,25 @@ func (p *chatPage) Help() help.KeyMap {
1061 key.WithHelp("c", "copy url"),
1074 key.WithHelp("c", "copy url"),
1062 ),
1075 ),
1063 )
1076 )
1064− } else if p.splash.IsClaudeOAuthComplete() {
1077+ case p.splash.IsClaudeOAuthComplete():
1065 shortList = append(shortList,
1078 shortList = append(shortList,
1066 key.NewBinding(
1079 key.NewBinding(
1067 key.WithKeys("enter"),
1080 key.WithKeys("enter"),
1068 key.WithHelp("enter", "continue"),
1081 key.WithHelp("enter", "continue"),
1069 ),
1082 ),
1070 )
1083 )
1071− } else {
1084+ case p.splash.IsShowingHyperOAuth2() || p.splash.IsShowingCopilotOAuth2():
1085+ shortList = append(shortList,
1086+ key.NewBinding(
1087+ key.WithKeys("enter"),
1088+ key.WithHelp("enter", "copy url & open signup"),
1089+ ),
1090+ key.NewBinding(
1091+ key.WithKeys("c"),
1092+ key.WithHelp("c", "copy url"),
1093+ ),
1094+ )
1095+ default:
1072 shortList = append(shortList,
1096 shortList = append(shortList,
1073 key.NewBinding(
1097 key.NewBinding(
1074 key.WithKeys("enter"),
1098 key.WithKeys("enter"),
internal/tui/styles/icons.go+2−1
@@ -10,7 +10,8 @@ const (
10 ArrowRightIcon string = "→"
10 ArrowRightIcon string = "→"
11 CenterSpinnerIcon string = "⋯"
11 CenterSpinnerIcon string = "⋯"
12 LoadingIcon string = "⟳"
12 LoadingIcon string = "⟳"
13− DocumentIcon string = "🖼"
13+ ImageIcon string = "■"
14+ TextIcon string = "☰"
14 ModelIcon string = "◇"
15 ModelIcon string = "◇"
15
16
16 // Tool call icons
17 // Tool call icons
schema.json+11
@@ -367,6 +367,17 @@
367 "type": "array",
367 "type": "array",
368 "description": "Paths to files containing context information for the AI"
368 "description": "Paths to files containing context information for the AI"
369 },
369 },
370+ "skills_paths": {
371+ "items": {
372+ "type": "string",
373+ "examples": [
374+ "~/.config/crush/skills",
375+ "./skills"
376+ ]
377+ },
378+ "type": "array",
379+ "description": "Paths to directories containing Agent Skills (folders with SKILL.md files)"
380+ },
370 "tui": {
381 "tui": {
371 "$ref": "#/$defs/TUIOptions",
382 "$ref": "#/$defs/TUIOptions",
372 "description": "Terminal user interface options"
383 "description": "Terminal user interface options"