Detailed changes
@@ -1127,6 +1127,54 @@
"created_at": "2026-01-24T22:42:46Z",
"repoId": 987670088,
"pullRequestNo": 1978
+ },
+ {
+ "name": "oug-t",
+ "id": 252025851,
+ "comment_id": 3811704206,
+ "created_at": "2026-01-28T14:42:29Z",
+ "repoId": 987670088,
+ "pullRequestNo": 2022
+ },
+ {
+ "name": "liannnix",
+ "id": 779758,
+ "comment_id": 3815867093,
+ "created_at": "2026-01-29T07:05:12Z",
+ "repoId": 987670088,
+ "pullRequestNo": 2043
+ },
+ {
+ "name": "bittoby",
+ "id": 218712309,
+ "comment_id": 3824931235,
+ "created_at": "2026-01-30T17:52:15Z",
+ "repoId": 987670088,
+ "pullRequestNo": 2065
+ },
+ {
+ "name": "ijt",
+ "id": 15530,
+ "comment_id": 3832667774,
+ "created_at": "2026-02-02T03:06:23Z",
+ "repoId": 987670088,
+ "pullRequestNo": 2080
+ },
+ {
+ "name": "khalilgharbaoui",
+ "id": 8024057,
+ "comment_id": 3832796060,
+ "created_at": "2026-02-02T04:04:04Z",
+ "repoId": 987670088,
+ "pullRequestNo": 2081
+ },
+ {
+ "name": "acmacalister",
+ "id": 1024755,
+ "comment_id": 3837172797,
+ "created_at": "2026-02-02T19:27:08Z",
+ "repoId": 987670088,
+ "pullRequestNo": 2095
}
]
}
@@ -30,11 +30,11 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- - uses: github/codeql-action/init@19b2f06db2b6f5108140aeb04014ef02b648f789 # v4.31.11
+ - uses: github/codeql-action/init@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
with:
languages: ${{ matrix.language }}
- - uses: github/codeql-action/autobuild@19b2f06db2b6f5108140aeb04014ef02b648f789 # v4.31.11
- - uses: github/codeql-action/analyze@19b2f06db2b6f5108140aeb04014ef02b648f789 # v4.31.11
+ - uses: github/codeql-action/autobuild@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
+ - uses: github/codeql-action/analyze@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
grype:
runs-on: ubuntu-latest
@@ -46,13 +46,13 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- - uses: anchore/scan-action@0d444ed77d83ee2ba7f5ced0d90d640a1281d762 # v7.3.0
+ - uses: anchore/scan-action@8d2fce09422cd6037e577f4130e9b925e9a37175 # v7.3.1
id: scan
with:
path: "."
fail-build: true
severity-cutoff: critical
- - uses: github/codeql-action/upload-sarif@19b2f06db2b6f5108140aeb04014ef02b648f789 # v4.31.11
+ - uses: github/codeql-action/upload-sarif@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
with:
sarif_file: ${{ steps.scan.outputs.sarif }}
@@ -73,7 +73,7 @@ jobs:
- name: Run govulncheck
run: |
govulncheck -C . -format sarif ./... > results.sarif
- - uses: github/codeql-action/upload-sarif@19b2f06db2b6f5108140aeb04014ef02b648f789 # v4.31.11
+ - uses: github/codeql-action/upload-sarif@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
with:
sarif_file: results.sarif
@@ -27,7 +27,7 @@ jobs:
go-version-file: go.mod
- uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0
with:
- version: "~> v2"
+ version: "nightly"
distribution: goreleaser-pro
args: build --snapshot --clean
env:
@@ -268,6 +268,7 @@ nix:
name: "Charm"
email: "charmcli@users.noreply.github.com"
license: fsl11Mit
+ formatter: nixfmt
skip_upload: "{{ with .Prerelease }}true{{ end }}"
extra_install: |-
installManPage ./manpages/crush.1.gz
@@ -26,6 +26,8 @@
need of a temporary directory. This directory does not need to be removed.
- **JSON tags**: Use snake_case for JSON field names
- **File permissions**: Use octal notation (0o755, 0o644) for file permissions
+- **Log messages**: Log messages must start with a capital letter (e.g., "Failed to save session" not "failed to save session")
+ - This is enforced by `task lint:log` which runs as part of `task lint`
- **Comments**: End comments in periods unless comments are at the end of the line.
## Testing with Mock Providers
@@ -7,7 +7,7 @@
</p>
<p align="center">Your new coding bestie, now available in your favourite terminal.<br />Your tools, your code, and your workflows, wired into your LLM of choice.</p>
-<p align="center">ไฝ ็ๆฐ็ผ็จไผไผด๏ผ็ฐๅจๅฐฑๅจไฝ ๆ็ฑ็็ป็ซฏไธญใ<br />ไฝ ็ๅทฅๅ
ทใไปฃ็ ๅๅทฅไฝๆต๏ผ้ฝไธๆจ้ๆฉ็ LLM ๆจกๅ็ดงๅฏ็ธ่ฟใ</p>
+<p align="center">็ป็ซฏ้็็ผ็จๆฐๆญๆกฃ๏ผ<br />ๆ ็ผๆฅๅ
ฅไฝ ็ๅทฅๅ
ทใไปฃ็ ไธๅทฅไฝๆต๏ผๅ
จ้ขๅ
ผๅฎนไธปๆต LLM ๆจกๅใ</p>
<p align="center"><img width="800" alt="Crush Demo" src="https://github.com/user-attachments/assets/58280caf-851b-470a-b6f7-d5c4ea8a1968" /></p>
@@ -23,10 +23,16 @@ tasks:
lint:
desc: Run base linters
cmds:
+ - task: lint:log
- golangci-lint run --path-mode=abs --config=".golangci.yml" --timeout=5m
env:
GOEXPERIMENT: null
+ lint:log:
+ desc: Check that log messages start with capital letters
+ cmds:
+ - ./scripts/check_log_capitalization.sh
+
lint:fix:
desc: Run base linters and fix issues
cmds:
@@ -147,5 +153,5 @@ tasks:
desc: Update Fantasy and Catwalk
cmds:
- go get charm.land/fantasy
- - go get github.com/charmbracelet/catwalk
+ - go get charm.land/catwalk
- go mod tidy
@@ -5,7 +5,8 @@ go 1.25.5
require (
charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66
charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e
- charm.land/fantasy v0.6.1
+ charm.land/catwalk v0.16.1
+ charm.land/fantasy v0.7.0
charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b
charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251205162909-7869489d8971
charm.land/log/v2 v2.0.0-20251110204020-529bb77f35da
@@ -19,7 +20,6 @@ require (
github.com/aymanbagabas/go-udiff v0.3.1
github.com/bmatcuk/doublestar/v4 v4.10.0
github.com/charlievieth/fastwalk v1.0.14
- github.com/charmbracelet/catwalk v0.15.0
github.com/charmbracelet/colorprofile v0.4.1
github.com/charmbracelet/fang v0.4.4
github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560
@@ -31,8 +31,10 @@ require (
github.com/charmbracelet/x/exp/ordered v0.1.0
github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff
github.com/charmbracelet/x/exp/strings v0.1.0
- github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b
+ github.com/charmbracelet/x/powernap v0.0.0-20260127155452-b72a9a918687
github.com/charmbracelet/x/term v0.2.2
+ github.com/clipperhouse/displaywidth v0.9.0
+ github.com/clipperhouse/uax29/v2 v2.5.0
github.com/denisbrodbeck/machineid v1.0.1
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
github.com/disintegration/imaging v1.6.2
@@ -76,12 +78,12 @@ require (
require (
cloud.google.com/go v0.116.0 // indirect
- cloud.google.com/go/auth v0.18.0 // indirect
+ cloud.google.com/go/auth v0.18.1 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
- github.com/RealAlexandreAI/json-repair v0.0.14 // indirect
+ github.com/RealAlexandreAI/json-repair v0.0.15 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.1 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect
@@ -102,13 +104,12 @@ require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 // indirect
github.com/charmbracelet/x/json v0.2.0 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
- github.com/clipperhouse/displaywidth v0.7.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
- github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/disintegration/gift v1.1.2 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
@@ -119,9 +120,9 @@ require (
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
- github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
+ github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
- github.com/goccy/go-yaml v1.19.0 // indirect
+ github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/jsonschema-go v0.3.0 // indirect
@@ -132,10 +133,10 @@ require (
github.com/gorilla/websocket v1.5.3 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
- github.com/kaptinlin/go-i18n v0.2.2 // indirect
- github.com/kaptinlin/jsonpointer v0.4.8 // indirect
- github.com/kaptinlin/jsonschema v0.6.6 // indirect
- github.com/kaptinlin/messageformat-go v0.4.7 // indirect
+ github.com/kaptinlin/go-i18n v0.2.3 // indirect
+ github.com/kaptinlin/jsonpointer v0.4.9 // indirect
+ github.com/kaptinlin/jsonschema v0.6.9 // indirect
+ github.com/kaptinlin/messageformat-go v0.4.9 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
@@ -168,12 +169,12 @@ require (
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/yuin/goldmark v1.7.8 // indirect
github.com/yuin/goldmark-emoji v1.0.5 // indirect
- go.opentelemetry.io/auto/sdk v1.1.0 // indirect
+ go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
- go.opentelemetry.io/otel v1.37.0 // indirect
- go.opentelemetry.io/otel/metric v1.37.0 // indirect
- go.opentelemetry.io/otel/trace v1.37.0 // indirect
+ go.opentelemetry.io/otel v1.39.0 // indirect
+ go.opentelemetry.io/otel/metric v1.39.0 // indirect
+ go.opentelemetry.io/otel/trace v1.39.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect
golang.org/x/crypto v0.47.0 // indirect
@@ -184,7 +185,7 @@ require (
golang.org/x/term v0.39.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/api v0.239.0 // indirect
- google.golang.org/genai v1.41.0 // indirect
+ google.golang.org/genai v1.44.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect
google.golang.org/grpc v1.76.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
@@ -2,8 +2,10 @@ charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66 h1:2BdJynsAW+8rv
charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66/go.mod h1:5AbN6cEd/47gkEf8TgiQ2O3RZ5QxMS14l9W+7F9fPC4=
charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e h1:tXwTmgGpwZT7ParKF5xbEQBVjM2e1uKhKi/GpfU3mYQ=
charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e/go.mod h1:pDM18flq3Z4njKZPA3zCvyVSSIJbMcoqlE82BdGUtL8=
-charm.land/fantasy v0.6.1 h1:v3pavSHpZ5xTw98TpNYoj6DRq4ksCBWwJiZeiG/mVIc=
-charm.land/fantasy v0.6.1/go.mod h1:Ifj41bNnIXJ1aF6sLKcS9y3MzWbDnObmcHrCaaHfpZ0=
+charm.land/catwalk v0.16.1 h1:4Z4uCxqdAaVHeSX5dDDOkOg8sm7krFqJSaNBMZhE7Ao=
+charm.land/catwalk v0.16.1/go.mod h1:kAdk/GjAJbl1AjRjmfU5c9lZfs7PeC3Uy9TgaVtlN64=
+charm.land/fantasy v0.7.0 h1:qsSKJF07B+mimpPaC61Zyu3N+A9l2Lbs6T3txlP5In8=
+charm.land/fantasy v0.7.0/go.mod h1:zv8Utaob4b9rSPp2ruH515rx7oN+l66gv6RshvwHnww=
charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b h1:A6IUUyChZDWP16RUdRJCfmYISAKWQGyIcfhZJUCViQ0=
charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b/go.mod h1:J3kVhY6oHXZq5f+8vC3hmDO95fEvbqj3z7xDwxrfzU8=
charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251205162909-7869489d8971 h1:xZFcNsJMiIDbFtWRyDmkKNk1sjojfaom4Zoe0cyH/8c=
@@ -14,8 +16,8 @@ charm.land/x/vcr v0.1.1 h1:PXCFMUG0rPtyk35rhfzYCJEduOzWXCIbrXTFq4OF/9Q=
charm.land/x/vcr v0.1.1/go.mod h1:eByq2gqzWvcct/8XE2XO5KznoWEBiXH56+y2gphbltM=
cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
-cloud.google.com/go/auth v0.18.0 h1:wnqy5hrv7p3k7cShwAU/Br3nzod7fxoqG+k0VZ+/Pk0=
-cloud.google.com/go/auth v0.18.0/go.mod h1:wwkPM1AgE1f2u6dG443MiWoD8C3BtOywNsUMcUTVDRo=
+cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs=
+cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
@@ -35,8 +37,8 @@ github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
-github.com/RealAlexandreAI/json-repair v0.0.14 h1:4kTqotVonDVTio5n2yweRUELVcNe2x518wl0bCsw0t0=
-github.com/RealAlexandreAI/json-repair v0.0.14/go.mod h1:GKJi5borR78O8c7HCVbgqjhoiVibZ6hJldxbc6dGrAI=
+github.com/RealAlexandreAI/json-repair v0.0.15 h1:AN8/yt8rcphwQrIs/FZeki+cKaIERUNr25zf1flirIs=
+github.com/RealAlexandreAI/json-repair v0.0.15/go.mod h1:GKJi5borR78O8c7HCVbgqjhoiVibZ6hJldxbc6dGrAI=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
@@ -92,12 +94,12 @@ github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICgnWlhAyg=
github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY=
github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 h1:rwLdEpG9wE6kL69KkEKDiWprO8pQOZHZXeod6+9K+mw=
github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904/go.mod h1:8TIYxZxsuCqqeJ0lga/b91tBwrbjoHDC66Sq5t8N2R4=
-github.com/charmbracelet/catwalk v0.15.0 h1:5oWJdvchTPfF7855A0n40+XbZQz4+vouZ/NhQ661JKI=
-github.com/charmbracelet/catwalk v0.15.0/go.mod h1:qg+Yl9oaZTkTvRscqbxfttzOFQ4v0pOT5XwC7b5O0NQ=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=
@@ -122,20 +124,20 @@ github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9
github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
-github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b h1:5ye9hzBKH623bMVz5auIuY6K21loCdxpRmFle2O9R/8=
-github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
+github.com/charmbracelet/x/powernap v0.0.0-20260127155452-b72a9a918687 h1:h1XMgTkpBt9kEJ+9DkARNBXEgaigUQ0cI2Bot7Awnt8=
+github.com/charmbracelet/x/powernap v0.0.0-20260127155452-b72a9a918687/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
-github.com/clipperhouse/displaywidth v0.7.0 h1:QNv1GYsnLX9QBrcWUtMlogpTXuM5FVnBwKWp1O5NwmE=
-github.com/clipperhouse/displaywidth v0.7.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
+github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
+github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
-github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
-github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
+github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
+github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls=
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
@@ -179,12 +181,12 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
-github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
-github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
+github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
-github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE=
-github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
+github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
+github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
@@ -224,14 +226,14 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA
github.com/jordanella/go-ansi-paintbrush v0.0.0-20240728195301-b7ad996ecf3d h1:on25kP+Sx7sxUMRQiA8gdcToAGet4DK/EIA30mXre+4=
github.com/jordanella/go-ansi-paintbrush v0.0.0-20240728195301-b7ad996ecf3d/go.mod h1:SV0W0APWP9MZ1/gfDQ/NzzTlWdIgYZ/ZbpN4d/UXRYw=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
-github.com/kaptinlin/go-i18n v0.2.2 h1:kebVCZme/BrCTqonh/J+VYCl1+Of5C18bvyn3DRPl5M=
-github.com/kaptinlin/go-i18n v0.2.2/go.mod h1:MiwkeHryBopAhC/M3zEwIM/2IN8TvTqJQswPw6kceqM=
-github.com/kaptinlin/jsonpointer v0.4.8 h1:HocHcXrOBfP/nUJw0YYjed/TlQvuCAY6uRs3Qok7F6g=
-github.com/kaptinlin/jsonpointer v0.4.8/go.mod h1:9y0LgXavlmVE5FSHShY5LRlURJJVhbyVJSRWkilrTqA=
-github.com/kaptinlin/jsonschema v0.6.6 h1:UmIF1amA5ijCGSk4tl4ViNlgYL4jzHHvY+Nd5cnkfDI=
-github.com/kaptinlin/jsonschema v0.6.6/go.mod h1:EbhSbdxZ4QjzIORdMWOrRXJeCHrLTJqXDA8JzNaeFc8=
-github.com/kaptinlin/messageformat-go v0.4.7 h1:HQ/OvFUSU7+fAHWkZnP2ug9y+A/ZyTE8j33jfWr8O3Q=
-github.com/kaptinlin/messageformat-go v0.4.7/go.mod h1:DusKpv8CIybczGvwIVn3j13hbR3psr5mOwhFudkiq1c=
+github.com/kaptinlin/go-i18n v0.2.3 h1:jyN/YOXXLcnGRBLdU+a8+6782B97fWE5aQqAHtvvk8Q=
+github.com/kaptinlin/go-i18n v0.2.3/go.mod h1:O+Ax4HkMO0Jt4OaP4E4WCx0PAADeWkwk8Jgt9bjAU1w=
+github.com/kaptinlin/jsonpointer v0.4.9 h1:o//bYf4PCvnMJIIX8bIg77KB6DO3wBPAabRyPRKh680=
+github.com/kaptinlin/jsonpointer v0.4.9/go.mod h1:9y0LgXavlmVE5FSHShY5LRlURJJVhbyVJSRWkilrTqA=
+github.com/kaptinlin/jsonschema v0.6.9 h1:N6bwMCadb0fA9CYINqQbtPhacIIjXmAjuYnJaWeI1bg=
+github.com/kaptinlin/jsonschema v0.6.9/go.mod h1:ZXZ4K5KrRmCCF1i6dgvBsQifl+WTb8XShKj0NpQNrz8=
+github.com/kaptinlin/messageformat-go v0.4.9 h1:FR5j5n4aL4nG0afKn9vvANrKxLu7HjmbhJnw5ogIwAQ=
+github.com/kaptinlin/messageformat-go v0.4.9/go.mod h1:qZzrGrlvWDz2KyyvN3dOWcK9PVSRV1BnfnNU+zB/RWc=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
@@ -371,22 +373,22 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
-go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
-go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
+go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
-go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
-go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
-go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
-go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
-go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
-go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
+go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
+go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
+go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
+go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
+go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
+go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
-go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
-go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
+go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
+go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
@@ -494,8 +496,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.239.0 h1:2hZKUnFZEy81eugPs4e2XzIJ5SOwQg0G82bpXD65Puo=
google.golang.org/api v0.239.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50=
-google.golang.org/genai v1.41.0 h1:ayXl75LjTmqTu0y94yr96d17gIb4zF8gWVzX2TgioEY=
-google.golang.org/genai v1.41.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
+google.golang.org/genai v1.44.0 h1:+nn8oXANzrpHsWxGfZz2IySq0cFPiepqFvgMFofK8vw=
+google.golang.org/genai v1.44.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b h1:zPKJod4w6F1+nRGDI9ubnXYhU9NSWoFAijkHkUXeTK8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
@@ -22,16 +22,18 @@ import (
"sync"
"time"
+ "charm.land/catwalk/pkg/catwalk"
"charm.land/fantasy"
"charm.land/fantasy/providers/anthropic"
"charm.land/fantasy/providers/bedrock"
"charm.land/fantasy/providers/google"
"charm.land/fantasy/providers/openai"
"charm.land/fantasy/providers/openrouter"
+ "charm.land/fantasy/providers/vercel"
"charm.land/lipgloss/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/agent/hyper"
"github.com/charmbracelet/crush/internal/agent/tools"
+ "github.com/charmbracelet/crush/internal/agent/tools/mcp"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/csync"
"github.com/charmbracelet/crush/internal/message"
@@ -167,6 +169,21 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
largeModel := a.largeModel.Get()
systemPrompt := a.systemPrompt.Get()
promptPrefix := a.systemPromptPrefix.Get()
+ var instructions strings.Builder
+
+ for _, server := range mcp.GetStates() {
+ if server.State != mcp.StateConnected {
+ continue
+ }
+ if s := server.Client.InitializeResult().Instructions; s != "" {
+ instructions.WriteString(s)
+ instructions.WriteString("\n\n")
+ }
+ }
+
+ if s := instructions.String(); s != "" {
+ systemPrompt += "\n\n<mcp-instructions>\n" + s + "\n</mcp-instructions>"
+ }
if len(agentTools) > 0 {
// Add Anthropic caching to the last tool.
@@ -372,20 +389,18 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
}
currentAssistant.AddFinish(finishReason, "", "")
sessionLock.Lock()
- updatedSession, getSessionErr := a.sessions.Get(genCtx, call.SessionID)
+ defer sessionLock.Unlock()
+
+ updatedSession, getSessionErr := a.sessions.Get(ctx, call.SessionID)
if getSessionErr != nil {
- sessionLock.Unlock()
return getSessionErr
}
a.updateSessionUsage(largeModel, &updatedSession, stepResult.Usage, a.openrouterCost(stepResult.ProviderMetadata))
- _, sessionErr := a.sessions.Save(genCtx, updatedSession)
- if sessionErr == nil {
- currentSession = updatedSession
- }
- sessionLock.Unlock()
+ _, sessionErr := a.sessions.Save(ctx, updatedSession)
if sessionErr != nil {
return sessionErr
}
+ currentSession = updatedSession
return a.messages.Update(genCtx, *currentAssistant)
},
StopWhen: []fantasy.StopCondition{
@@ -674,6 +689,9 @@ func (a *sessionAgent) getCacheControlOptions() fantasy.ProviderOptions {
bedrock.Name: &anthropic.ProviderCacheControlOptions{
CacheControl: anthropic.CacheControl{Type: "ephemeral"},
},
+ vercel.Name: &anthropic.ProviderCacheControlOptions{
+ CacheControl: anthropic.CacheControl{Type: "ephemeral"},
+ },
}
}
@@ -795,22 +813,22 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
resp, err := agent.Stream(ctx, streamCall)
if err == nil {
// We successfully generated a title with the small model.
- slog.Info("generated title with small model")
+ slog.Debug("Generated title with small model")
} else {
// It didn't work. Let's try with the big model.
- slog.Error("error generating title with small model; trying big model", "err", err)
+ slog.Error("Error generating title with small model; trying big model", "err", err)
model = largeModel
agent = newAgent(model.Model, titlePrompt, maxOutputTokens)
resp, err = agent.Stream(ctx, streamCall)
if err == nil {
- slog.Info("generated title with large model")
+ slog.Debug("Generated title with large model")
} else {
// Welp, the large model didn't work either. Use the default
// session name and return.
- slog.Error("error generating title with large model", "err", err)
+ slog.Error("Error generating title with large model", "err", err)
saveErr := a.sessions.UpdateTitleAndUsage(ctx, sessionID, defaultSessionName, 0, 0, 0)
if saveErr != nil {
- slog.Error("failed to save session title and usage", "error", saveErr)
+ slog.Error("Failed to save session title and usage", "error", saveErr)
}
return
}
@@ -819,10 +837,10 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
if resp == nil {
// Actually, we didn't get a response so we can't. Use the default
// session name and return.
- slog.Error("response is nil; can't generate title")
+ slog.Error("Response is nil; can't generate title")
saveErr := a.sessions.UpdateTitleAndUsage(ctx, sessionID, defaultSessionName, 0, 0, 0)
if saveErr != nil {
- slog.Error("failed to save session title and usage", "error", saveErr)
+ slog.Error("Failed to save session title and usage", "error", saveErr)
}
return
}
@@ -836,7 +854,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
title = strings.TrimSpace(title)
if title == "" {
- slog.Warn("empty title; using fallback")
+ slog.Debug("Empty title; using fallback")
title = defaultSessionName
}
@@ -871,7 +889,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
// concurrent session updates.
saveErr := a.sessions.UpdateTitleAndUsage(ctx, sessionID, title, promptTokens, completionTokens, cost)
if saveErr != nil {
- slog.Error("failed to save session title and usage", "error", saveErr)
+ slog.Error("Failed to save session title and usage", "error", saveErr)
return
}
}
@@ -905,7 +923,7 @@ func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session,
}
session.CompletionTokens = usage.OutputTokens
- session.PromptTokens = usage.InputTokens + usage.CacheCreationTokens
+ session.PromptTokens = usage.InputTokens + usage.CacheReadTokens
}
func (a *sessionAgent) Cancel(sessionID string) {
@@ -914,25 +932,25 @@ func (a *sessionAgent) Cancel(sessionID string) {
// fully completes (including error handling that may access the DB).
// The defer in processRequest will clean up the entry.
if cancel, ok := a.activeRequests.Get(sessionID); ok && cancel != nil {
- slog.Info("Request cancellation initiated", "session_id", sessionID)
+ slog.Debug("Request cancellation initiated", "session_id", sessionID)
cancel()
}
// Also check for summarize requests.
if cancel, ok := a.activeRequests.Get(sessionID + "-summarize"); ok && cancel != nil {
- slog.Info("Summarize cancellation initiated", "session_id", sessionID)
+ slog.Debug("Summarize cancellation initiated", "session_id", sessionID)
cancel()
}
if a.QueuedPrompts(sessionID) > 0 {
- slog.Info("Clearing queued prompts", "session_id", sessionID)
+ slog.Debug("Clearing queued prompts", "session_id", sessionID)
a.messageQueue.Del(sessionID)
}
}
func (a *sessionAgent) ClearQueue(sessionID string) {
if a.QueuedPrompts(sessionID) > 0 {
- slog.Info("Clearing queued prompts", "session_id", sessionID)
+ slog.Debug("Clearing queued prompts", "session_id", sessionID)
a.messageQueue.Del(sessionID)
}
}
@@ -1092,7 +1110,7 @@ func (a *sessionAgent) workaroundProviderMediaLimitations(messages []fantasy.Mes
if media, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentMedia](toolResult.Output); ok {
decoded, err := base64.StdEncoding.DecodeString(media.Data)
if err != nil {
- slog.Warn("failed to decode media data", "error", err)
+ slog.Warn("Failed to decode media data", "error", err)
textParts = append(textParts, part)
continue
}
@@ -52,13 +52,14 @@ var agenticFetchPromptTmpl []byte
func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (fantasy.AgentTool, error) {
if client == nil {
+ transport := http.DefaultTransport.(*http.Transport).Clone()
+ transport.MaxIdleConns = 100
+ transport.MaxIdleConnsPerHost = 10
+ transport.IdleConnTimeout = 90 * time.Second
+
client = &http.Client{
- Timeout: 30 * time.Second,
- Transport: &http.Transport{
- MaxIdleConns: 100,
- MaxIdleConnsPerHost: 10,
- IdleConnTimeout: 90 * time.Second,
- },
+ Timeout: 30 * time.Second,
+ Transport: transport,
}
}
@@ -168,7 +169,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
tools.NewGlobTool(tmpDir),
tools.NewGrepTool(tmpDir),
tools.NewSourcegraphTool(client),
- tools.NewViewTool(c.lspClients, c.permissions, tmpDir),
+ tools.NewViewTool(c.lspClients, c.permissions, c.filetracker, tmpDir),
}
agent := NewSessionAgent(SessionAgentOptions{
@@ -8,18 +8,19 @@ import (
"testing"
"time"
+ "charm.land/catwalk/pkg/catwalk"
"charm.land/fantasy"
"charm.land/fantasy/providers/anthropic"
"charm.land/fantasy/providers/openai"
"charm.land/fantasy/providers/openaicompat"
"charm.land/fantasy/providers/openrouter"
"charm.land/x/vcr"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/agent/prompt"
"github.com/charmbracelet/crush/internal/agent/tools"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/csync"
"github.com/charmbracelet/crush/internal/db"
+ "github.com/charmbracelet/crush/internal/filetracker"
"github.com/charmbracelet/crush/internal/history"
"github.com/charmbracelet/crush/internal/lsp"
"github.com/charmbracelet/crush/internal/message"
@@ -37,6 +38,7 @@ type fakeEnv struct {
messages message.Service
permissions permission.Service
history history.Service
+ filetracker *filetracker.Service
lspClients *csync.Map[string, *lsp.Client]
}
@@ -117,6 +119,7 @@ func testEnv(t *testing.T) fakeEnv {
permissions := permission.NewPermissionService(workingDir, true, []string{})
history := history.NewService(q, conn)
+ filetrackerService := filetracker.NewService(q)
lspClients := csync.NewMap[string, *lsp.Client]()
t.Cleanup(func() {
@@ -130,6 +133,7 @@ func testEnv(t *testing.T) fakeEnv {
messages,
permissions,
history,
+ &filetrackerService,
lspClients,
}
}
@@ -200,15 +204,15 @@ func coderAgent(r *vcr.Recorder, env fakeEnv, large, small fantasy.LanguageModel
allTools := []fantasy.AgentTool{
tools.NewBashTool(env.permissions, env.workingDir, cfg.Options.Attribution, modelName),
tools.NewDownloadTool(env.permissions, env.workingDir, r.GetDefaultClient()),
- tools.NewEditTool(env.lspClients, env.permissions, env.history, env.workingDir),
- tools.NewMultiEditTool(env.lspClients, env.permissions, env.history, env.workingDir),
+ tools.NewEditTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir),
+ tools.NewMultiEditTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir),
tools.NewFetchTool(env.permissions, env.workingDir, r.GetDefaultClient()),
tools.NewGlobTool(env.workingDir),
tools.NewGrepTool(env.workingDir),
tools.NewLsTool(env.permissions, env.workingDir, cfg.Tools.Ls),
tools.NewSourcegraphTool(r.GetDefaultClient()),
- tools.NewViewTool(env.lspClients, env.permissions, env.workingDir),
- tools.NewWriteTool(env.lspClients, env.permissions, env.history, env.workingDir),
+ tools.NewViewTool(env.lspClients, env.permissions, *env.filetracker, env.workingDir),
+ tools.NewWriteTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir),
}
return testSessionAgent(env, large, small, systemPrompt, allTools...), nil
@@ -15,13 +15,14 @@ import (
"slices"
"strings"
+ "charm.land/catwalk/pkg/catwalk"
"charm.land/fantasy"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/agent/hyper"
"github.com/charmbracelet/crush/internal/agent/prompt"
"github.com/charmbracelet/crush/internal/agent/tools"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/csync"
+ "github.com/charmbracelet/crush/internal/filetracker"
"github.com/charmbracelet/crush/internal/history"
"github.com/charmbracelet/crush/internal/log"
"github.com/charmbracelet/crush/internal/lsp"
@@ -38,6 +39,7 @@ import (
"charm.land/fantasy/providers/openai"
"charm.land/fantasy/providers/openaicompat"
"charm.land/fantasy/providers/openrouter"
+ "charm.land/fantasy/providers/vercel"
openaisdk "github.com/openai/openai-go/v2/option"
"github.com/qjebbs/go-jsons"
)
@@ -65,6 +67,7 @@ type coordinator struct {
messages message.Service
permissions permission.Service
history history.Service
+ filetracker filetracker.Service
lspClients *csync.Map[string, *lsp.Client]
currentAgent SessionAgent
@@ -80,6 +83,7 @@ func NewCoordinator(
messages message.Service,
permissions permission.Service,
history history.Service,
+ filetracker filetracker.Service,
lspClients *csync.Map[string, *lsp.Client],
) (Coordinator, error) {
c := &coordinator{
@@ -88,6 +92,7 @@ func NewCoordinator(
messages: messages,
permissions: permissions,
history: history,
+ filetracker: filetracker,
lspClients: lspClients,
agents: make(map[string]SessionAgent),
}
@@ -148,7 +153,7 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
mergedOptions, temp, topP, topK, freqPenalty, presPenalty := mergeCallOptions(model, providerCfg)
if providerCfg.OAuthToken != nil && providerCfg.OAuthToken.IsExpired() {
- slog.Info("Token needs to be refreshed", "provider", providerCfg.ID)
+ slog.Debug("Token needs to be refreshed", "provider", providerCfg.ID)
if err := c.refreshOAuth2Token(ctx, providerCfg); err != nil {
return nil, err
}
@@ -173,18 +178,18 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
if c.isUnauthorized(originalErr) {
switch {
case providerCfg.OAuthToken != nil:
- slog.Info("Received 401. Refreshing token and retrying", "provider", providerCfg.ID)
+ slog.Debug("Received 401. Refreshing token and retrying", "provider", providerCfg.ID)
if err := c.refreshOAuth2Token(ctx, providerCfg); err != nil {
return nil, originalErr
}
- slog.Info("Retrying request with refreshed OAuth token", "provider", providerCfg.ID)
+ slog.Debug("Retrying request with refreshed OAuth token", "provider", providerCfg.ID)
return run()
case strings.Contains(providerCfg.APIKeyTemplate, "$"):
- slog.Info("Received 401. Refreshing API Key template and retrying", "provider", providerCfg.ID)
+ slog.Debug("Received 401. Refreshing API Key template and retrying", "provider", providerCfg.ID)
if err := c.refreshApiKeyTemplate(ctx, providerCfg); err != nil {
return nil, originalErr
}
- slog.Info("Retrying request with refreshed API key", "provider", providerCfg.ID)
+ slog.Debug("Retrying request with refreshed API key", "provider", providerCfg.ID)
return run()
}
}
@@ -240,7 +245,20 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.
return options
}
- switch providerCfg.Type {
+ providerType := providerCfg.Type
+ if providerType == "hyper" {
+ if strings.Contains(model.CatwalkCfg.ID, "claude") {
+ providerType = anthropic.Name
+ } else if strings.Contains(model.CatwalkCfg.ID, "gpt") {
+ providerType = openai.Name
+ } else if strings.Contains(model.CatwalkCfg.ID, "gemini") {
+ providerType = google.Name
+ } else {
+ providerType = openaicompat.Name
+ }
+ }
+
+ switch providerType {
case openai.Name, azure.Name:
_, hasReasoningEffort := mergedOptions["reasoning_effort"]
if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" {
@@ -286,6 +304,18 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.
if err == nil {
options[openrouter.Name] = parsed
}
+ case vercel.Name:
+ _, hasReasoning := mergedOptions["reasoning"]
+ if !hasReasoning && model.ModelCfg.ReasoningEffort != "" {
+ mergedOptions["reasoning"] = map[string]any{
+ "enabled": true,
+ "effort": model.ModelCfg.ReasoningEffort,
+ }
+ }
+ parsed, err := vercel.ParseOptions(mergedOptions)
+ if err == nil {
+ options[vercel.Name] = parsed
+ }
case google.Name:
_, hasReasoning := mergedOptions["thinking_config"]
if !hasReasoning {
@@ -394,19 +424,19 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
tools.NewJobOutputTool(),
tools.NewJobKillTool(),
tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
- tools.NewEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
- tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
+ tools.NewEditTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
+ tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
tools.NewGlobTool(c.cfg.WorkingDir()),
tools.NewGrepTool(c.cfg.WorkingDir()),
tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
tools.NewSourcegraphTool(nil),
tools.NewTodosTool(c.sessions),
- tools.NewViewTool(c.lspClients, c.permissions, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...),
- tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
+ tools.NewViewTool(c.lspClients, c.permissions, c.filetracker, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...),
+ tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
)
- if len(c.cfg.LSP) > 0 {
+ if c.lspClients.Len() > 0 {
allTools = append(allTools, tools.NewDiagnosticsTool(c.lspClients), tools.NewReferencesTool(c.lspClients), tools.NewLSPRestartTool(c.lspClients))
}
@@ -425,7 +455,7 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
}
if len(agent.AllowedMCP) == 0 {
// No MCPs allowed
- slog.Debug("no MCPs allowed", "tool", tool.Name(), "agent", agent.Name)
+ slog.Debug("No MCPs allowed", "tool", tool.Name(), "agent", agent.Name)
break
}
@@ -588,6 +618,20 @@ func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[stri
return openrouter.New(opts...)
}
+func (c *coordinator) buildVercelProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
+ opts := []vercel.Option{
+ vercel.WithAPIKey(apiKey),
+ }
+ if c.cfg.Options.Debug {
+ httpClient := log.NewHTTPClient()
+ opts = append(opts, vercel.WithHTTPClient(httpClient))
+ }
+ if len(headers) > 0 {
+ opts = append(opts, vercel.WithHeaders(headers))
+ }
+ return vercel.New(opts...)
+}
+
func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers map[string]string, extraBody map[string]any, providerID string, isSubAgent bool) (fantasy.Provider, error) {
opts := []openaicompat.Option{
openaicompat.WithBaseURL(baseURL),
@@ -745,6 +789,8 @@ func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model con
return c.buildAnthropicProvider(baseURL, apiKey, headers)
case openrouter.Name:
return c.buildOpenrouterProvider(baseURL, apiKey, headers)
+ case vercel.Name:
+ return c.buildVercelProvider(baseURL, apiKey, headers)
case azure.Name:
return c.buildAzureProvider(baseURL, apiKey, headers, providerCfg.ExtraParams)
case bedrock.Name:
@@ -21,9 +21,9 @@ import (
"sync"
"time"
+ "charm.land/catwalk/pkg/catwalk"
"charm.land/fantasy"
"charm.land/fantasy/object"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/event"
)
@@ -49,7 +49,10 @@ var Enabled = sync.OnceValue(func() bool {
var Embedded = sync.OnceValue(func() catwalk.Provider {
var provider catwalk.Provider
if err := json.Unmarshal(embedded, &provider); err != nil {
- slog.Error("could not use embedded provider data", "err", err)
+ slog.Error("Could not use embedded provider data", "err", err)
+ }
+ if e := os.Getenv("HYPER_URL"); e != "" {
+ provider.APIEndpoint = e + "/api/v1/fantasy"
}
return provider
})
@@ -1 +1 @@
@@ -36,13 +36,14 @@ var downloadDescription []byte
func NewDownloadTool(permissions permission.Service, workingDir string, client *http.Client) fantasy.AgentTool {
if client == nil {
+ transport := http.DefaultTransport.(*http.Transport).Clone()
+ transport.MaxIdleConns = 100
+ transport.MaxIdleConnsPerHost = 10
+ transport.IdleConnTimeout = 90 * time.Second
+
client = &http.Client{
- Timeout: 5 * time.Minute, // Default 5 minute timeout for downloads
- Transport: &http.Transport{
- MaxIdleConns: 100,
- MaxIdleConnsPerHost: 10,
- IdleConnTimeout: 90 * time.Second,
- },
+ Timeout: 5 * time.Minute, // Default 5 minute timeout for downloads
+ Transport: transport,
}
}
return fantasy.NewParallelAgentTool(
@@ -56,10 +56,17 @@ type editContext struct {
ctx context.Context
permissions permission.Service
files history.Service
+ filetracker filetracker.Service
workingDir string
}
-func NewEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool {
+func NewEditTool(
+ lspClients *csync.Map[string, *lsp.Client],
+ permissions permission.Service,
+ files history.Service,
+ filetracker filetracker.Service,
+ workingDir string,
+) fantasy.AgentTool {
return fantasy.NewAgentTool(
EditToolName,
string(editDescription),
@@ -73,7 +80,7 @@ func NewEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss
var response fantasy.ToolResponse
var err error
- editCtx := editContext{ctx, permissions, files, workingDir}
+ editCtx := editContext{ctx, permissions, files, filetracker, workingDir}
if params.OldString == "" {
response, err = createNewFile(editCtx, params.FilePath, params.NewString, call)
@@ -168,8 +175,7 @@ func createNewFile(edit editContext, filePath, content string, call fantasy.Tool
slog.Error("Error creating file history version", "error", err)
}
- filetracker.RecordWrite(filePath)
- filetracker.RecordRead(filePath)
+ edit.filetracker.RecordRead(edit.ctx, sessionID, filePath)
return fantasy.WithResponseMetadata(
fantasy.NewTextResponse("File created: "+filePath),
@@ -195,12 +201,17 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool
return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
}
- if filetracker.LastReadTime(filePath).IsZero() {
+ sessionID := GetSessionFromContext(edit.ctx)
+ if sessionID == "" {
+ return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for deleting content")
+ }
+
+ lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, filePath)
+ if lastRead.IsZero() {
return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
}
- modTime := fileInfo.ModTime()
- lastRead := filetracker.LastReadTime(filePath)
+ modTime := fileInfo.ModTime().Truncate(time.Second)
if modTime.After(lastRead) {
return fantasy.NewTextErrorResponse(
fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
@@ -236,12 +247,6 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool
newContent = oldContent[:index] + oldContent[index+len(oldString):]
}
- sessionID := GetSessionFromContext(edit.ctx)
-
- if sessionID == "" {
- return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for deleting content")
- }
-
_, additions, removals := diff.GenerateDiff(
oldContent,
newContent,
@@ -301,8 +306,7 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool
slog.Error("Error creating file history version", "error", err)
}
- filetracker.RecordWrite(filePath)
- filetracker.RecordRead(filePath)
+ edit.filetracker.RecordRead(edit.ctx, sessionID, filePath)
return fantasy.WithResponseMetadata(
fantasy.NewTextResponse("Content deleted from file: "+filePath),
@@ -328,12 +332,17 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
}
- if filetracker.LastReadTime(filePath).IsZero() {
+ sessionID := GetSessionFromContext(edit.ctx)
+ if sessionID == "" {
+ return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for edit a file")
+ }
+
+ lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, filePath)
+ if lastRead.IsZero() {
return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
}
- modTime := fileInfo.ModTime()
- lastRead := filetracker.LastReadTime(filePath)
+ modTime := fileInfo.ModTime().Truncate(time.Second)
if modTime.After(lastRead) {
return fantasy.NewTextErrorResponse(
fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
@@ -369,11 +378,6 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
if oldContent == newContent {
return fantasy.NewTextErrorResponse("new content is the same as old content. No changes made."), nil
}
- sessionID := GetSessionFromContext(edit.ctx)
-
- if sessionID == "" {
- return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
- }
_, additions, removals := diff.GenerateDiff(
oldContent,
newContent,
@@ -433,8 +437,7 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
slog.Error("Error creating file history version", "error", err)
}
- filetracker.RecordWrite(filePath)
- filetracker.RecordRead(filePath)
+ edit.filetracker.RecordRead(edit.ctx, sessionID, filePath)
return fantasy.WithResponseMetadata(
fantasy.NewTextResponse("Content replaced in file: "+filePath),
@@ -23,13 +23,14 @@ var fetchDescription []byte
func NewFetchTool(permissions permission.Service, workingDir string, client *http.Client) fantasy.AgentTool {
if client == nil {
+ transport := http.DefaultTransport.(*http.Transport).Clone()
+ transport.MaxIdleConns = 100
+ transport.MaxIdleConnsPerHost = 10
+ transport.IdleConnTimeout = 90 * time.Second
+
client = &http.Client{
- Timeout: 30 * time.Second,
- Transport: &http.Transport{
- MaxIdleConns: 100,
- MaxIdleConnsPerHost: 10,
- IdleConnTimeout: 90 * time.Second,
- },
+ Timeout: 30 * time.Second,
+ Transport: transport,
}
}
@@ -135,12 +135,13 @@ func Close() error {
// Initialize initializes MCP clients based on the provided configuration.
func Initialize(ctx context.Context, permissions permission.Service, cfg *config.Config) {
+ slog.Info("Initializing MCP clients")
var wg sync.WaitGroup
// Initialize states for all configured MCPs
for name, m := range cfg.MCP {
if m.Disabled {
updateState(name, StateDisabled, nil, nil, Counts{})
- slog.Debug("skipping disabled mcp", "name", name)
+ slog.Debug("Skipping disabled MCP", "name", name)
continue
}
@@ -162,7 +163,7 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config
err = fmt.Errorf("panic: %v", v)
}
updateState(name, StateError, err, nil, Counts{})
- slog.Error("panic in mcp client initialization", "error", err, "name", name)
+ slog.Error("Panic in MCP client initialization", "error", err, "name", name)
}
}()
@@ -174,7 +175,7 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config
tools, err := getTools(ctx, session)
if err != nil {
- slog.Error("error listing tools", "error", err)
+ slog.Error("Error listing tools", "error", err)
updateState(name, StateError, err, nil, Counts{})
session.Close()
return
@@ -182,7 +183,7 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config
prompts, err := getPrompts(ctx, session)
if err != nil {
- slog.Error("error listing prompts", "error", err)
+ slog.Error("Error listing prompts", "error", err)
updateState(name, StateError, err, nil, Counts{})
session.Close()
return
@@ -277,7 +278,7 @@ func createSession(ctx context.Context, name string, m config.MCPConfig, resolve
transport, err := createTransport(mcpCtx, m, resolver)
if err != nil {
updateState(name, StateError, err, nil, Counts{})
- slog.Error("error creating mcp client", "error", err, "name", name)
+ slog.Error("Error creating MCP client", "error", err, "name", name)
cancel()
cancelTimer.Stop()
return nil, err
@@ -319,7 +320,7 @@ func createSession(ctx context.Context, name string, m config.MCPConfig, resolve
}
cancelTimer.Stop()
- slog.Info("MCP client initialized", "name", name)
+ slog.Debug("MCP client initialized", "name", name)
return session, nil
}
@@ -49,7 +49,7 @@ func GetPromptMessages(ctx context.Context, clientName, promptName string, args
func RefreshPrompts(ctx context.Context, name string) {
session, ok := sessions.Get(name)
if !ok {
- slog.Warn("refresh prompts: no session", "name", name)
+ slog.Warn("Refresh prompts: no session", "name", name)
return
}
@@ -111,7 +111,7 @@ func RunTool(ctx context.Context, name, toolName string, input string) (ToolResu
func RefreshTools(ctx context.Context, name string) {
session, ok := sessions.Get(name)
if !ok {
- slog.Warn("refresh tools: no session", "name", name)
+ slog.Warn("Refresh tools: no session", "name", name)
return
}
@@ -58,7 +58,13 @@ const MultiEditToolName = "multiedit"
//go:embed multiedit.md
var multieditDescription []byte
-func NewMultiEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool {
+func NewMultiEditTool(
+ lspClients *csync.Map[string, *lsp.Client],
+ permissions permission.Service,
+ files history.Service,
+ filetracker filetracker.Service,
+ workingDir string,
+) fantasy.AgentTool {
return fantasy.NewAgentTool(
MultiEditToolName,
string(multieditDescription),
@@ -81,7 +87,7 @@ func NewMultiEditTool(lspClients *csync.Map[string, *lsp.Client], permissions pe
var response fantasy.ToolResponse
var err error
- editCtx := editContext{ctx, permissions, files, workingDir}
+ editCtx := editContext{ctx, permissions, files, filetracker, workingDir}
// Handle file creation case (first edit has empty old_string)
if len(params.Edits) > 0 && params.Edits[0].OldString == "" {
response, err = processMultiEditWithCreation(editCtx, params, call)
@@ -210,8 +216,7 @@ func processMultiEditWithCreation(edit editContext, params MultiEditParams, call
slog.Error("Error creating file history version", "error", err)
}
- filetracker.RecordWrite(params.FilePath)
- filetracker.RecordRead(params.FilePath)
+ edit.filetracker.RecordRead(edit.ctx, sessionID, params.FilePath)
var message string
if len(failedEdits) > 0 {
@@ -247,14 +252,19 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call
return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil
}
+ sessionID := GetSessionFromContext(edit.ctx)
+ if sessionID == "" {
+ return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for editing file")
+ }
+
// Check if file was read before editing
- if filetracker.LastReadTime(params.FilePath).IsZero() {
+ lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, params.FilePath)
+ if lastRead.IsZero() {
return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
}
- // Check if file was modified since last read
- modTime := fileInfo.ModTime()
- lastRead := filetracker.LastReadTime(params.FilePath)
+ // Check if file was modified since last read.
+ modTime := fileInfo.ModTime().Truncate(time.Second)
if modTime.After(lastRead) {
return fantasy.NewTextErrorResponse(
fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
@@ -301,12 +311,6 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call
return fantasy.NewTextErrorResponse("no changes made - all edits resulted in identical content"), nil
}
- // Get session and message IDs
- sessionID := GetSessionFromContext(edit.ctx)
- if sessionID == "" {
- return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for editing file")
- }
-
// Generate diff and check permissions
_, additions, removals := diff.GenerateDiff(oldContent, currentContent, strings.TrimPrefix(params.FilePath, edit.workingDir))
@@ -369,8 +373,7 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call
slog.Error("Error creating file history version", "error", err)
}
- filetracker.RecordWrite(params.FilePath)
- filetracker.RecordRead(params.FilePath)
+ edit.filetracker.RecordRead(edit.ctx, sessionID, params.FilePath)
var message string
if len(failedEdits) > 0 {
@@ -6,10 +6,7 @@ import (
"path/filepath"
"testing"
- "github.com/charmbracelet/crush/internal/csync"
- "github.com/charmbracelet/crush/internal/filetracker"
"github.com/charmbracelet/crush/internal/history"
- "github.com/charmbracelet/crush/internal/lsp"
"github.com/charmbracelet/crush/internal/permission"
"github.com/charmbracelet/crush/internal/pubsub"
"github.com/stretchr/testify/require"
@@ -111,17 +108,6 @@ func TestMultiEditSequentialApplication(t *testing.T) {
err := os.WriteFile(testFile, []byte(content), 0o644)
require.NoError(t, err)
- // Mock components.
- lspClients := csync.NewMap[string, *lsp.Client]()
- permissions := &mockPermissionService{Broker: pubsub.NewBroker[permission.PermissionRequest]()}
- files := &mockHistoryService{Broker: pubsub.NewBroker[history.File]()}
-
- // Create multiedit tool.
- _ = NewMultiEditTool(lspClients, permissions, files, tmpDir)
-
- // Simulate reading the file first.
- filetracker.RecordRead(testFile)
-
// Manually test the sequential application logic.
currentContent := content
@@ -33,13 +33,14 @@ var sourcegraphDescription []byte
func NewSourcegraphTool(client *http.Client) fantasy.AgentTool {
if client == nil {
+ transport := http.DefaultTransport.(*http.Transport).Clone()
+ transport.MaxIdleConns = 100
+ transport.MaxIdleConnsPerHost = 10
+ transport.IdleConnTimeout = 90 * time.Second
+
client = &http.Client{
- Timeout: 30 * time.Second,
- Transport: &http.Transport{
- MaxIdleConns: 100,
- MaxIdleConnsPerHost: 10,
- IdleConnTimeout: 90 * time.Second,
- },
+ Timeout: 30 * time.Second,
+ Transport: transport,
}
}
return fantasy.NewParallelAgentTool(
@@ -47,7 +47,13 @@ const (
MaxLineLength = 2000
)
-func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, workingDir string, skillsPaths ...string) fantasy.AgentTool {
+func NewViewTool(
+ lspClients *csync.Map[string, *lsp.Client],
+ permissions permission.Service,
+ filetracker filetracker.Service,
+ workingDir string,
+ skillsPaths ...string,
+) fantasy.AgentTool {
return fantasy.NewAgentTool(
ViewToolName,
string(viewDescription),
@@ -74,13 +80,13 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss
isOutsideWorkDir := err != nil || strings.HasPrefix(relPath, "..")
isSkillFile := isInSkillsPath(absFilePath, skillsPaths)
+ sessionID := GetSessionFromContext(ctx)
+ if sessionID == "" {
+ return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing files outside working directory")
+ }
+
// Request permission for files outside working directory, unless it's a skill file.
if isOutsideWorkDir && !isSkillFile {
- sessionID := GetSessionFromContext(ctx)
- if sessionID == "" {
- return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing files outside working directory")
- }
-
granted, err := permissions.Request(ctx,
permission.CreatePermissionRequest{
SessionID: sessionID,
@@ -190,7 +196,7 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss
}
output += "\n</file>\n"
output += getDiagnostics(filePath, lspClients)
- filetracker.RecordRead(filePath)
+ filetracker.RecordRead(ctx, sessionID, filePath)
return fantasy.WithResponseMetadata(
fantasy.NewTextResponse(output),
ViewResponseMetadata{
@@ -18,13 +18,14 @@ var webFetchToolDescription []byte
// NewWebFetchTool creates a simple web fetch tool for sub-agents (no permissions needed).
func NewWebFetchTool(workingDir string, client *http.Client) fantasy.AgentTool {
if client == nil {
+ transport := http.DefaultTransport.(*http.Transport).Clone()
+ transport.MaxIdleConns = 100
+ transport.MaxIdleConnsPerHost = 10
+ transport.IdleConnTimeout = 90 * time.Second
+
client = &http.Client{
- Timeout: 30 * time.Second,
- Transport: &http.Transport{
- MaxIdleConns: 100,
- MaxIdleConnsPerHost: 10,
- IdleConnTimeout: 90 * time.Second,
- },
+ Timeout: 30 * time.Second,
+ Transport: transport,
}
}
@@ -16,13 +16,14 @@ var webSearchToolDescription []byte
// NewWebSearchTool creates a web search tool for sub-agents (no permissions needed).
func NewWebSearchTool(client *http.Client) fantasy.AgentTool {
if client == nil {
+ transport := http.DefaultTransport.(*http.Transport).Clone()
+ transport.MaxIdleConns = 100
+ transport.MaxIdleConnsPerHost = 10
+ transport.IdleConnTimeout = 90 * time.Second
+
client = &http.Client{
- Timeout: 30 * time.Second,
- Transport: &http.Transport{
- MaxIdleConns: 100,
- MaxIdleConnsPerHost: 10,
- IdleConnTimeout: 90 * time.Second,
- },
+ Timeout: 30 * time.Second,
+ Transport: transport,
}
}
@@ -44,7 +44,13 @@ type WriteResponseMetadata struct {
const WriteToolName = "write"
-func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool {
+func NewWriteTool(
+ lspClients *csync.Map[string, *lsp.Client],
+ permissions permission.Service,
+ files history.Service,
+ filetracker filetracker.Service,
+ workingDir string,
+) fantasy.AgentTool {
return fantasy.NewAgentTool(
WriteToolName,
string(writeDescription),
@@ -57,6 +63,11 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis
return fantasy.NewTextErrorResponse("content is required"), nil
}
+ sessionID := GetSessionFromContext(ctx)
+ if sessionID == "" {
+ return fantasy.ToolResponse{}, fmt.Errorf("session_id is required")
+ }
+
filePath := filepathext.SmartJoin(workingDir, params.FilePath)
fileInfo, err := os.Stat(filePath)
@@ -65,8 +76,8 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis
return fantasy.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
}
- modTime := fileInfo.ModTime()
- lastRead := filetracker.LastReadTime(filePath)
+ modTime := fileInfo.ModTime().Truncate(time.Second)
+ lastRead := filetracker.LastReadTime(ctx, sessionID, filePath)
if modTime.After(lastRead) {
return fantasy.NewTextErrorResponse(fmt.Sprintf("File %s has been modified since it was last read.\nLast modification: %s\nLast read: %s\n\nPlease read the file again before modifying it.",
filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))), nil
@@ -93,11 +104,6 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis
}
}
- sessionID := GetSessionFromContext(ctx)
- if sessionID == "" {
- return fantasy.ToolResponse{}, fmt.Errorf("session_id is required")
- }
-
diff, additions, removals := diff.GenerateDiff(
oldContent,
params.Content,
@@ -153,8 +159,7 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis
slog.Error("Error creating file history version", "error", err)
}
- filetracker.RecordWrite(filePath)
- filetracker.RecordRead(filePath)
+ filetracker.RecordRead(ctx, sessionID, filePath)
notifyLSPs(ctx, lspClients, params.FilePath)
@@ -15,14 +15,15 @@ import (
"time"
tea "charm.land/bubbletea/v2"
+ "charm.land/catwalk/pkg/catwalk"
"charm.land/fantasy"
"charm.land/lipgloss/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/agent"
"github.com/charmbracelet/crush/internal/agent/tools/mcp"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/csync"
"github.com/charmbracelet/crush/internal/db"
+ "github.com/charmbracelet/crush/internal/filetracker"
"github.com/charmbracelet/crush/internal/format"
"github.com/charmbracelet/crush/internal/history"
"github.com/charmbracelet/crush/internal/log"
@@ -53,6 +54,7 @@ type App struct {
Messages message.Service
History history.Service
Permissions permission.Service
+ FileTracker filetracker.Service
AgentCoordinator agent.Coordinator
@@ -87,6 +89,7 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
Messages: messages,
History: files,
Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests, allowedTools),
+ FileTracker: filetracker.NewService(q),
LSPClients: csync.NewMap[string, *lsp.Client](),
globalCtx: ctx,
@@ -101,15 +104,12 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
app.setupEvents()
// Initialize LSP clients in the background.
- app.initLSPClients(ctx)
+ go app.initLSPClients(ctx)
// Check for updates in the background.
go app.checkForUpdates(ctx)
- go func() {
- slog.Info("Initializing MCP clients")
- mcp.Initialize(ctx, app.Permissions, cfg)
- }()
+ go mcp.Initialize(ctx, app.Permissions, cfg)
// cleanup database upon app shutdown
app.cleanupFuncs = append(app.cleanupFuncs, conn.Close, mcp.Close)
@@ -132,7 +132,7 @@ func (app *App) Config() *config.Config {
// RunNonInteractive runs the application in non-interactive mode with the
// given prompt, printing to stdout.
-func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, largeModel, smallModel string, quiet bool) error {
+func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, largeModel, smallModel string, hideSpinner bool) error {
slog.Info("Running in non-interactive mode")
ctx, cancel := context.WithCancel(ctx)
@@ -149,6 +149,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
stdoutTTY bool
stderrTTY bool
stdinTTY bool
+ progress bool
)
if f, ok := output.(*os.File); ok {
@@ -156,8 +157,9 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
}
stderrTTY = term.IsTerminal(os.Stderr.Fd())
stdinTTY = term.IsTerminal(os.Stdin.Fd())
+ progress = app.config.Options.Progress == nil || *app.config.Options.Progress
- if !quiet && stderrTTY {
+ if !hideSpinner && stderrTTY {
t := styles.CurrentTheme()
// Detect background color to set the appropriate color for the
@@ -182,7 +184,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
// Helper function to stop spinner once.
stopSpinner := func() {
- if !quiet && spinner != nil {
+ if !hideSpinner && spinner != nil {
spinner.Stop()
spinner = nil
}
@@ -239,9 +241,10 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
messageEvents := app.Messages.Subscribe(ctx)
messageReadBytes := make(map[string]int)
+ var printed bool
defer func() {
- if stderrTTY {
+ if progress && stderrTTY {
_, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar)
}
@@ -251,7 +254,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
}()
for {
- if stderrTTY {
+ if progress && stderrTTY {
// HACK: Reinitialize the terminal progress bar on every iteration
// so it doesn't get hidden by the terminal due to inactivity.
_, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
@@ -262,7 +265,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
stopSpinner()
if result.err != nil {
if errors.Is(result.err, context.Canceled) || errors.Is(result.err, agent.ErrRequestCancelled) {
- slog.Info("Non-interactive: agent processing cancelled", "session_id", sess.ID)
+ slog.Debug("Non-interactive: agent processing cancelled", "session_id", sess.ID)
return nil
}
return fmt.Errorf("agent processing failed: %w", result.err)
@@ -288,7 +291,11 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
if readBytes == 0 {
part = strings.TrimLeft(part, " \t")
}
- fmt.Fprint(output, part)
+ // Ignore initial whitespace-only messages.
+ if printed || strings.TrimSpace(part) != "" {
+ printed = true
+ fmt.Fprint(output, part)
+ }
messageReadBytes[msg.ID] = len(content)
}
@@ -427,20 +434,20 @@ func setupSubscriber[T any](
select {
case event, ok := <-subCh:
if !ok {
- slog.Debug("subscription channel closed", "name", name)
+ slog.Debug("Subscription channel closed", "name", name)
return
}
var msg tea.Msg = event
select {
case outputCh <- msg:
case <-time.After(2 * time.Second):
- slog.Warn("message dropped due to slow consumer", "name", name)
+ slog.Debug("Message dropped due to slow consumer", "name", name)
case <-ctx.Done():
- slog.Debug("subscription cancelled", "name", name)
+ slog.Debug("Subscription cancelled", "name", name)
return
}
case <-ctx.Done():
- slog.Debug("subscription cancelled", "name", name)
+ slog.Debug("Subscription cancelled", "name", name)
return
}
}
@@ -460,6 +467,7 @@ func (app *App) InitCoderAgent(ctx context.Context) error {
app.Messages,
app.Permissions,
app.History,
+ app.FileTracker,
app.LSPClients,
)
if err != nil {
@@ -504,7 +512,7 @@ func (app *App) Subscribe(program *tea.Program) {
// Shutdown performs a graceful shutdown of the application.
func (app *App) Shutdown() {
start := time.Now()
- defer func() { slog.Info("Shutdown took " + time.Since(start).String()) }()
+ defer func() { slog.Debug("Shutdown took " + time.Since(start).String()) }()
// First, cancel all agents and wait for them to finish. This must complete
// before closing the DB so agents can finish writing their state.
@@ -1,43 +1,126 @@
package app
import (
+ "cmp"
"context"
"log/slog"
+ "os/exec"
+ "slices"
+ "sync"
"time"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/lsp"
+ powernapconfig "github.com/charmbracelet/x/powernap/pkg/config"
)
// initLSPClients initializes LSP clients.
func (app *App) initLSPClients(ctx context.Context) {
+ slog.Info("LSP clients initialization started")
+
+ manager := powernapconfig.NewManager()
+ manager.LoadDefaults()
+
+ var userConfiguredLSPs []string
for name, clientConfig := range app.config.LSP {
if clientConfig.Disabled {
slog.Info("Skipping disabled LSP client", "name", name)
+ manager.RemoveServer(name)
+ continue
+ }
+
+ // HACK: the user might have the command name in their config, instead
+ // of the actual name. This finds out these cases, and adjusts the name
+ // accordingly.
+ if _, ok := manager.GetServer(name); !ok {
+ for sname, server := range manager.GetServers() {
+ if server.Command == name {
+ name = sname
+ break
+ }
+ }
+ }
+ userConfiguredLSPs = append(userConfiguredLSPs, name)
+ manager.AddServer(name, &powernapconfig.ServerConfig{
+ Command: clientConfig.Command,
+ Args: clientConfig.Args,
+ Environment: clientConfig.Env,
+ FileTypes: clientConfig.FileTypes,
+ RootMarkers: clientConfig.RootMarkers,
+ InitOptions: clientConfig.InitOptions,
+ Settings: clientConfig.Options,
+ })
+ }
+
+ servers := manager.GetServers()
+ filtered := lsp.FilterMatching(app.config.WorkingDir(), servers)
+
+ for _, name := range userConfiguredLSPs {
+ if _, ok := filtered[name]; !ok {
+ updateLSPState(name, lsp.StateDisabled, nil, nil, 0)
+ }
+ }
+
+ var wg sync.WaitGroup
+ for name, server := range filtered {
+ if app.config.Options.AutoLSP != nil && !*app.config.Options.AutoLSP && !slices.Contains(userConfiguredLSPs, name) {
+ slog.Debug("Ignoring non user-define LSP client due to AutoLSP being disabled", "name", name)
continue
}
- go app.createAndStartLSPClient(ctx, name, clientConfig)
+ wg.Go(func() {
+ app.createAndStartLSPClient(
+ ctx, name,
+ toOurConfig(server, app.config.LSP[name]),
+ slices.Contains(userConfiguredLSPs, name),
+ )
+ })
+ }
+ wg.Wait()
+
+ if app.AgentCoordinator != nil {
+ if err := app.AgentCoordinator.UpdateModels(ctx); err != nil {
+ slog.Error("Failed to refresh tools after LSP startup", "error", err)
+ }
}
- slog.Info("LSP clients initialization started in background")
}
-// createAndStartLSPClient creates a new LSP client, initializes it, and starts its workspace watcher
-func (app *App) createAndStartLSPClient(ctx context.Context, name string, config config.LSPConfig) {
- slog.Debug("Creating LSP client", "name", name, "command", config.Command, "fileTypes", config.FileTypes, "args", config.Args)
+// toOurConfig merges powernap default config with user config.
+// If user config is zero value, it means no user override exists.
+func toOurConfig(in *powernapconfig.ServerConfig, user config.LSPConfig) config.LSPConfig {
+ return config.LSPConfig{
+ Command: in.Command,
+ Args: in.Args,
+ Env: in.Environment,
+ FileTypes: in.FileTypes,
+ RootMarkers: in.RootMarkers,
+ InitOptions: in.InitOptions,
+ Options: in.Settings,
+ Timeout: user.Timeout,
+ }
+}
- // Check if any root markers exist in the working directory (config now has defaults)
- if !lsp.HasRootMarkers(app.config.WorkingDir(), config.RootMarkers) {
- slog.Debug("Skipping LSP client: no root markers found", "name", name, "rootMarkers", config.RootMarkers)
- updateLSPState(name, lsp.StateDisabled, nil, nil, 0)
- return
+// createAndStartLSPClient creates a new LSP client, initializes it, and starts its workspace watcher.
+func (app *App) createAndStartLSPClient(ctx context.Context, name string, config config.LSPConfig, userConfigured bool) {
+ if !userConfigured {
+ if _, err := exec.LookPath(config.Command); err != nil {
+ slog.Warn("Default LSP config skipped: server not installed", "name", name, "error", err)
+ return
+ }
}
- // Update state to starting
+ slog.Debug("Creating LSP client", "name", name, "command", config.Command, "fileTypes", config.FileTypes, "args", config.Args)
+
+ // Update state to starting.
updateLSPState(name, lsp.StateStarting, nil, nil, 0)
// Create LSP client.
lspClient, err := lsp.New(ctx, name, config, app.config.Resolver())
if err != nil {
+ if !userConfigured {
+ slog.Warn("Default LSP config skipped due to error", "name", name, "error", err)
+ updateLSPState(name, lsp.StateDisabled, nil, nil, 0)
+ return
+ }
slog.Error("Failed to create LSP client for", "name", name, "error", err)
updateLSPState(name, lsp.StateError, err, nil, 0)
return
@@ -47,7 +130,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, config
lspClient.SetDiagnosticsCallback(updateLSPDiagnostics)
// Increase initialization timeout as some servers take more time to start.
- initCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
+ initCtx, cancel := context.WithTimeout(ctx, time.Duration(cmp.Or(config.Timeout, 30))*time.Second)
defer cancel()
// Initialize LSP client.
@@ -73,7 +156,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, config
updateLSPState(name, lsp.StateReady, nil, lspClient, 0)
}
- slog.Info("LSP client initialized", "name", name)
+ slog.Debug("LSP client initialized", "name", name)
// Add to map with mutex protection before starting goroutine
app.LSPClients.Set(name, lspClient)
@@ -3,7 +3,7 @@ package app
import (
"testing"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
+ "charm.land/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/config"
"github.com/stretchr/testify/require"
)
@@ -7,8 +7,8 @@ import (
"sort"
"strings"
+ "charm.land/catwalk/pkg/catwalk"
"charm.land/lipgloss/v2/tree"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/config"
"github.com/mattn/go-isatty"
"github.com/spf13/cobra"
@@ -93,12 +93,16 @@ crush -y
// Set up the TUI.
var env uv.Environ = os.Environ()
+ newUI := true
+ if v, err := strconv.ParseBool(env.Getenv("CRUSH_NEW_UI")); err == nil {
+ newUI = v
+ }
+
var model tea.Model
- if v, _ := strconv.ParseBool(env.Getenv("CRUSH_NEW_UI")); v {
+ if newUI {
slog.Info("New UI in control!")
com := common.DefaultCommon(app)
ui := ui.New(com)
- ui.QueryCapabilities = shouldQueryCapabilities(env)
model = ui
} else {
ui := tui.New(app)
@@ -180,12 +184,19 @@ func supportsProgressBar() bool {
}
func setupAppWithProgressBar(cmd *cobra.Command) (*app.App, error) {
- if supportsProgressBar() {
+ app, err := setupApp(cmd)
+ if err != nil {
+ return nil, err
+ }
+
+ // Check if progress bar is enabled in config (defaults to true if nil)
+ progressEnabled := app.Config().Options.Progress == nil || *app.Config().Options.Progress
+ if progressEnabled && supportsProgressBar() {
_, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
defer func() { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) }()
}
- return setupApp(cmd)
+ return app, nil
}
// setupApp handles the common setup logic for both interactive and non-interactive modes.
@@ -303,6 +314,7 @@ func createDotCrushDir(dir string) error {
return nil
}
+// TODO: Remove me after dropping the old TUI.
func shouldQueryCapabilities(env uv.Environ) bool {
const osVendorTypeApple = "Apple"
termType := env.Getenv("TERM")
@@ -8,6 +8,7 @@ import (
"os/signal"
"strings"
+ "charm.land/log/v2"
"github.com/charmbracelet/crush/internal/event"
"github.com/spf13/cobra"
)
@@ -29,9 +30,13 @@ crush run "What is this code doing?" <<< prrr.go
# Run in quiet mode (hide the spinner)
crush run --quiet "Generate a README for this project"
+
+# Run in verbose mode
+crush run --verbose "Generate a README for this project"
`,
RunE: func(cmd *cobra.Command, args []string) error {
quiet, _ := cmd.Flags().GetBool("quiet")
+ verbose, _ := cmd.Flags().GetBool("verbose")
largeModel, _ := cmd.Flags().GetString("model")
smallModel, _ := cmd.Flags().GetString("small-model")
@@ -49,6 +54,10 @@ crush run --quiet "Generate a README for this project"
return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively")
}
+ if verbose {
+ slog.SetDefault(slog.New(log.New(os.Stderr)))
+ }
+
prompt := strings.Join(args, " ")
prompt, err = MaybePrependStdin(prompt)
@@ -64,7 +73,7 @@ crush run --quiet "Generate a README for this project"
event.SetNonInteractive(true)
event.AppInitialized()
- return app.RunNonInteractive(ctx, os.Stdout, prompt, largeModel, smallModel, quiet)
+ return app.RunNonInteractive(ctx, os.Stdout, prompt, largeModel, smallModel, quiet || verbose)
},
PostRun: func(cmd *cobra.Command, args []string) {
event.AppExited()
@@ -73,6 +82,7 @@ crush run --quiet "Generate a README for this project"
func init() {
runCmd.Flags().BoolP("quiet", "q", false, "Hide spinner")
+ runCmd.Flags().BoolP("verbose", "v", false, "Show logs")
runCmd.Flags().StringP("model", "m", "", "Model to use. Accepts 'model' or 'provider/model' to disambiguate models with the same name across providers")
runCmd.Flags().String("small-model", "", "Small model to use. If not provided, uses the default small model for the provider")
}
@@ -189,20 +189,15 @@ body {
}
.chart-row {
- display: grid;
- grid-template-columns: repeat(2, 1fr);
+ display: flex;
+ flex-wrap: wrap;
gap: 1.5rem;
width: 100%;
}
.chart-row .chart-card {
- width: 100%;
-}
-
-@media (max-width: 1024px) {
- .chart-row {
- grid-template-columns: 1fr;
- }
+ flex: 1 1 300px;
+ max-width: calc((100% - 1.5rem) / 2);
}
.chart-card h2 {
@@ -28,7 +28,7 @@
</div>
<div class="header-info">
- Generated by {{.Username}} for {{.ProjectName}} in {{.GeneratedAt}}.
+ Generated by {{.Username}} for {{.ProjectName}} on {{.GeneratedAt}}.
</div>
<div class="stats-grid">
@@ -7,8 +7,8 @@ import (
"sync"
"sync/atomic"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
- "github.com/charmbracelet/catwalk/pkg/embedded"
+ "charm.land/catwalk/pkg/catwalk"
+ "charm.land/catwalk/pkg/embedded"
)
type catwalkClient interface {
@@ -7,7 +7,7 @@ import (
"os"
"testing"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
+ "charm.land/catwalk/pkg/catwalk"
"github.com/stretchr/testify/require"
)
@@ -14,7 +14,7 @@ import (
"strings"
"time"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
+ "charm.land/catwalk/pkg/catwalk"
hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
"github.com/charmbracelet/crush/internal/csync"
"github.com/charmbracelet/crush/internal/env"
@@ -187,13 +187,14 @@ type MCPConfig struct {
type LSPConfig struct {
Disabled bool `json:"disabled,omitempty" jsonschema:"description=Whether this LSP server is disabled,default=false"`
- Command string `json:"command,omitempty" jsonschema:"required,description=Command to execute for the LSP server,example=gopls"`
+ Command string `json:"command,omitempty" jsonschema:"description=Command to execute for the LSP server,example=gopls"`
Args []string `json:"args,omitempty" jsonschema:"description=Arguments to pass to the LSP server command"`
Env map[string]string `json:"env,omitempty" jsonschema:"description=Environment variables to set to the LSP server command"`
FileTypes []string `json:"filetypes,omitempty" jsonschema:"description=File types this LSP server handles,example=go,example=mod,example=rs,example=c,example=js,example=ts"`
RootMarkers []string `json:"root_markers,omitempty" jsonschema:"description=Files or directories that indicate the project root,example=go.mod,example=package.json,example=Cargo.toml"`
InitOptions map[string]any `json:"init_options,omitempty" jsonschema:"description=Initialization options passed to the LSP server during initialize request"`
Options map[string]any `json:"options,omitempty" jsonschema:"description=LSP server-specific settings passed during initialization"`
+ Timeout int `json:"timeout,omitempty" jsonschema:"description=Timeout in seconds for LSP server initialization,default=30,example=60,example=120"`
}
type TUIOptions struct {
@@ -203,6 +204,7 @@ type TUIOptions struct {
//
Completions Completions `json:"completions,omitzero" jsonschema:"description=Completions UI options"`
+ Transparent *bool `json:"transparent,omitempty" jsonschema:"description=Enable transparent background for the TUI interface,default=false"`
}
// Completions defines options for the completions UI.
@@ -257,6 +259,8 @@ type Options struct {
Attribution *Attribution `json:"attribution,omitempty" jsonschema:"description=Attribution settings for generated content"`
DisableMetrics bool `json:"disable_metrics,omitempty" jsonschema:"description=Disable sending metrics,default=false"`
InitializeAs string `json:"initialize_as,omitempty" jsonschema:"description=Name of the context file to create/update during project initialization,default=AGENTS.md,example=AGENTS.md,example=CRUSH.md,example=CLAUDE.md,example=docs/LLMs.md"`
+ AutoLSP *bool `json:"auto_lsp,omitempty" jsonschema:"description=Automatically setup LSPs based on root markers,default=true"`
+ Progress *bool `json:"progress,omitempty" jsonschema:"description=Show indeterminate progress updates during long operations,default=true"`
}
type MCPs map[string]MCPConfig
@@ -315,7 +319,7 @@ func (m MCPConfig) ResolvedHeaders() map[string]string {
var err error
m.Headers[e], err = resolver.ResolveValue(v)
if err != nil {
- slog.Error("error resolving header variable", "error", err, "variable", e, "value", v)
+ slog.Error("Error resolving header variable", "error", err, "variable", e, "value", v)
continue
}
}
@@ -346,7 +350,7 @@ type Agent struct {
}
type Tools struct {
- Ls ToolLs `json:"ls,omitzero"`
+ Ls ToolLs `json:"ls,omitempty"`
}
type ToolLs struct {
@@ -379,7 +383,7 @@ type Config struct {
Permissions *Permissions `json:"permissions,omitempty" jsonschema:"description=Permission settings for tool usage"`
- Tools Tools `json:"tools,omitzero" jsonschema:"description=Tool configurations"`
+ Tools Tools `json:"tools,omitempty" jsonschema:"description=Tool configurations"`
Agents map[string]Agent `json:"-"`
@@ -838,7 +842,7 @@ func resolveEnvs(envs map[string]string) []string {
var err error
envs[e], err = resolver.ResolveValue(v)
if err != nil {
- slog.Error("error resolving environment variable", "error", err, "variable", e, "value", v)
+ slog.Error("Error resolving environment variable", "error", err, "variable", e, "value", v)
continue
}
}
@@ -6,7 +6,7 @@ import (
"log/slog"
"testing"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
+ "charm.land/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/oauth"
"github.com/charmbracelet/crush/internal/oauth/copilot"
)
@@ -11,7 +11,7 @@ import (
"sync/atomic"
"time"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
+ "charm.land/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/agent/hyper"
xetag "github.com/charmbracelet/x/etag"
)
@@ -7,7 +7,7 @@ import (
"os"
"testing"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
+ "charm.land/catwalk/pkg/catwalk"
"github.com/stretchr/testify/require"
)
@@ -16,7 +16,7 @@ import (
"strings"
"testing"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
+ "charm.land/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/agent/hyper"
"github.com/charmbracelet/crush/internal/csync"
"github.com/charmbracelet/crush/internal/env"
@@ -62,6 +62,11 @@ func Load(workingDir, dataDir string, debug bool) (*Config, error) {
assignIfNil(&cfg.Options.TUI.Completions.MaxItems, items)
}
+ if isAppleTerminal() {
+ slog.Warn("Detected Apple Terminal, enabling transparent mode")
+ assignIfNil(&cfg.Options.TUI.Transparent, true)
+ }
+
// Load known providers, this loads the config from catwalk
providers, err := Providers(cfg)
if err != nil {
@@ -792,3 +797,5 @@ func GlobalSkillsDirs() []string {
filepath.Join(configBase, "agents", "skills"),
}
}
+
+func isAppleTerminal() bool { return os.Getenv("TERM_PROGRAM") == "Apple_Terminal" }
@@ -7,7 +7,7 @@ import (
"path/filepath"
"testing"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
+ "charm.land/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/csync"
"github.com/charmbracelet/crush/internal/env"
"github.com/stretchr/testify/assert"
@@ -15,8 +15,8 @@ import (
"sync"
"time"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
- "github.com/charmbracelet/catwalk/pkg/embedded"
+ "charm.land/catwalk/pkg/catwalk"
+ "charm.land/catwalk/pkg/embedded"
"github.com/charmbracelet/crush/internal/agent/hyper"
"github.com/charmbracelet/crush/internal/csync"
"github.com/charmbracelet/crush/internal/home"
@@ -5,7 +5,7 @@ import (
"os"
"testing"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
+ "charm.land/catwalk/pkg/catwalk"
"github.com/stretchr/testify/require"
)
@@ -7,7 +7,7 @@ import (
"sync"
"testing"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
+ "charm.land/catwalk/pkg/catwalk"
"github.com/stretchr/testify/require"
)
@@ -57,6 +57,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
if q.getFileByPathAndSessionStmt, err = db.PrepareContext(ctx, getFileByPathAndSession); err != nil {
return nil, fmt.Errorf("error preparing query GetFileByPathAndSession: %w", err)
}
+ if q.getFileReadStmt, err = db.PrepareContext(ctx, getFileRead); err != nil {
+ return nil, fmt.Errorf("error preparing query GetFileRead: %w", err)
+ }
if q.getHourDayHeatmapStmt, err = db.PrepareContext(ctx, getHourDayHeatmap); err != nil {
return nil, fmt.Errorf("error preparing query GetHourDayHeatmap: %w", err)
}
@@ -87,6 +90,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
if q.getUsageByModelStmt, err = db.PrepareContext(ctx, getUsageByModel); err != nil {
return nil, fmt.Errorf("error preparing query GetUsageByModel: %w", err)
}
+ if q.listAllUserMessagesStmt, err = db.PrepareContext(ctx, listAllUserMessages); err != nil {
+ return nil, fmt.Errorf("error preparing query ListAllUserMessages: %w", err)
+ }
if q.listFilesByPathStmt, err = db.PrepareContext(ctx, listFilesByPath); err != nil {
return nil, fmt.Errorf("error preparing query ListFilesByPath: %w", err)
}
@@ -105,6 +111,12 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
if q.listSessionsStmt, err = db.PrepareContext(ctx, listSessions); err != nil {
return nil, fmt.Errorf("error preparing query ListSessions: %w", err)
}
+ if q.listUserMessagesBySessionStmt, err = db.PrepareContext(ctx, listUserMessagesBySession); err != nil {
+ return nil, fmt.Errorf("error preparing query ListUserMessagesBySession: %w", err)
+ }
+ if q.recordFileReadStmt, err = db.PrepareContext(ctx, recordFileRead); err != nil {
+ return nil, fmt.Errorf("error preparing query RecordFileRead: %w", err)
+ }
if q.updateMessageStmt, err = db.PrepareContext(ctx, updateMessage); err != nil {
return nil, fmt.Errorf("error preparing query UpdateMessage: %w", err)
}
@@ -174,6 +186,11 @@ func (q *Queries) Close() error {
err = fmt.Errorf("error closing getFileByPathAndSessionStmt: %w", cerr)
}
}
+ if q.getFileReadStmt != nil {
+ if cerr := q.getFileReadStmt.Close(); cerr != nil {
+ err = fmt.Errorf("error closing getFileReadStmt: %w", cerr)
+ }
+ }
if q.getHourDayHeatmapStmt != nil {
if cerr := q.getHourDayHeatmapStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing getHourDayHeatmapStmt: %w", cerr)
@@ -224,6 +241,11 @@ func (q *Queries) Close() error {
err = fmt.Errorf("error closing getUsageByModelStmt: %w", cerr)
}
}
+ if q.listAllUserMessagesStmt != nil {
+ if cerr := q.listAllUserMessagesStmt.Close(); cerr != nil {
+ err = fmt.Errorf("error closing listAllUserMessagesStmt: %w", cerr)
+ }
+ }
if q.listFilesByPathStmt != nil {
if cerr := q.listFilesByPathStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing listFilesByPathStmt: %w", cerr)
@@ -254,6 +276,16 @@ func (q *Queries) Close() error {
err = fmt.Errorf("error closing listSessionsStmt: %w", cerr)
}
}
+ if q.listUserMessagesBySessionStmt != nil {
+ if cerr := q.listUserMessagesBySessionStmt.Close(); cerr != nil {
+ err = fmt.Errorf("error closing listUserMessagesBySessionStmt: %w", cerr)
+ }
+ }
+ if q.recordFileReadStmt != nil {
+ if cerr := q.recordFileReadStmt.Close(); cerr != nil {
+ err = fmt.Errorf("error closing recordFileReadStmt: %w", cerr)
+ }
+ }
if q.updateMessageStmt != nil {
if cerr := q.updateMessageStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing updateMessageStmt: %w", cerr)
@@ -319,6 +351,7 @@ type Queries struct {
getAverageResponseTimeStmt *sql.Stmt
getFileStmt *sql.Stmt
getFileByPathAndSessionStmt *sql.Stmt
+ getFileReadStmt *sql.Stmt
getHourDayHeatmapStmt *sql.Stmt
getMessageStmt *sql.Stmt
getRecentActivityStmt *sql.Stmt
@@ -329,12 +362,15 @@ type Queries struct {
getUsageByDayOfWeekStmt *sql.Stmt
getUsageByHourStmt *sql.Stmt
getUsageByModelStmt *sql.Stmt
+ listAllUserMessagesStmt *sql.Stmt
listFilesByPathStmt *sql.Stmt
listFilesBySessionStmt *sql.Stmt
listLatestSessionFilesStmt *sql.Stmt
listMessagesBySessionStmt *sql.Stmt
listNewFilesStmt *sql.Stmt
listSessionsStmt *sql.Stmt
+ listUserMessagesBySessionStmt *sql.Stmt
+ recordFileReadStmt *sql.Stmt
updateMessageStmt *sql.Stmt
updateSessionStmt *sql.Stmt
updateSessionTitleAndUsageStmt *sql.Stmt
@@ -355,6 +391,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
getAverageResponseTimeStmt: q.getAverageResponseTimeStmt,
getFileStmt: q.getFileStmt,
getFileByPathAndSessionStmt: q.getFileByPathAndSessionStmt,
+ getFileReadStmt: q.getFileReadStmt,
getHourDayHeatmapStmt: q.getHourDayHeatmapStmt,
getMessageStmt: q.getMessageStmt,
getRecentActivityStmt: q.getRecentActivityStmt,
@@ -365,12 +402,15 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
getUsageByDayOfWeekStmt: q.getUsageByDayOfWeekStmt,
getUsageByHourStmt: q.getUsageByHourStmt,
getUsageByModelStmt: q.getUsageByModelStmt,
+ listAllUserMessagesStmt: q.listAllUserMessagesStmt,
listFilesByPathStmt: q.listFilesByPathStmt,
listFilesBySessionStmt: q.listFilesBySessionStmt,
listLatestSessionFilesStmt: q.listLatestSessionFilesStmt,
listMessagesBySessionStmt: q.listMessagesBySessionStmt,
listNewFilesStmt: q.listNewFilesStmt,
listSessionsStmt: q.listSessionsStmt,
+ listUserMessagesBySessionStmt: q.listUserMessagesBySessionStmt,
+ recordFileReadStmt: q.recordFileReadStmt,
updateMessageStmt: q.updateMessageStmt,
updateSessionStmt: q.updateSessionStmt,
updateSessionTitleAndUsageStmt: q.updateSessionTitleAndUsageStmt,
@@ -107,6 +107,47 @@ func (q *Queries) GetMessage(ctx context.Context, id string) (Message, error) {
return i, err
}
+const listAllUserMessages = `-- name: ListAllUserMessages :many
+SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at, provider, is_summary_message
+FROM messages
+WHERE role = 'user'
+ORDER BY created_at DESC
+`
+
+func (q *Queries) ListAllUserMessages(ctx context.Context) ([]Message, error) {
+ rows, err := q.query(ctx, q.listAllUserMessagesStmt, listAllUserMessages)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []Message{}
+ for rows.Next() {
+ var i Message
+ if err := rows.Scan(
+ &i.ID,
+ &i.SessionID,
+ &i.Role,
+ &i.Parts,
+ &i.Model,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.FinishedAt,
+ &i.Provider,
+ &i.IsSummaryMessage,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
const listMessagesBySession = `-- name: ListMessagesBySession :many
SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at, provider, is_summary_message
FROM messages
@@ -148,6 +189,47 @@ func (q *Queries) ListMessagesBySession(ctx context.Context, sessionID string) (
return items, nil
}
+const listUserMessagesBySession = `-- name: ListUserMessagesBySession :many
+SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at, provider, is_summary_message
+FROM messages
+WHERE session_id = ? AND role = 'user'
+ORDER BY created_at DESC
+`
+
+func (q *Queries) ListUserMessagesBySession(ctx context.Context, sessionID string) ([]Message, error) {
+ rows, err := q.query(ctx, q.listUserMessagesBySessionStmt, listUserMessagesBySession, sessionID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []Message{}
+ for rows.Next() {
+ var i Message
+ if err := rows.Scan(
+ &i.ID,
+ &i.SessionID,
+ &i.Role,
+ &i.Parts,
+ &i.Model,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.FinishedAt,
+ &i.Provider,
+ &i.IsSummaryMessage,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
const updateMessage = `-- name: UpdateMessage :exec
UPDATE messages
SET
@@ -0,0 +1,20 @@
+-- +goose Up
+-- +goose StatementBegin
+CREATE TABLE IF NOT EXISTS read_files (
+ session_id TEXT NOT NULL CHECK (session_id != ''),
+ path TEXT NOT NULL CHECK (path != ''),
+ read_at INTEGER NOT NULL, -- Unix timestamp in seconds when file was last read
+ FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE,
+ PRIMARY KEY (path, session_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_read_files_session_id ON read_files (session_id);
+CREATE INDEX IF NOT EXISTS idx_read_files_path ON read_files (path);
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+DROP INDEX IF EXISTS idx_read_files_path;
+DROP INDEX IF EXISTS idx_read_files_session_id;
+DROP TABLE IF EXISTS read_files;
+-- +goose StatementEnd
@@ -31,6 +31,12 @@ type Message struct {
IsSummaryMessage int64 `json:"is_summary_message"`
}
+type ReadFile struct {
+ SessionID string `json:"session_id"`
+ Path string `json:"path"`
+ ReadAt int64 `json:"read_at"` // Unix timestamp when file was last read
+}
+
type Session struct {
ID string `json:"id"`
ParentSessionID sql.NullString `json:"parent_session_id"`
@@ -20,6 +20,7 @@ type Querier interface {
GetAverageResponseTime(ctx context.Context) (int64, error)
GetFile(ctx context.Context, id string) (File, error)
GetFileByPathAndSession(ctx context.Context, arg GetFileByPathAndSessionParams) (File, error)
+ GetFileRead(ctx context.Context, arg GetFileReadParams) (ReadFile, error)
GetHourDayHeatmap(ctx context.Context) ([]GetHourDayHeatmapRow, error)
GetMessage(ctx context.Context, id string) (Message, error)
GetRecentActivity(ctx context.Context) ([]GetRecentActivityRow, error)
@@ -30,12 +31,15 @@ type Querier interface {
GetUsageByDayOfWeek(ctx context.Context) ([]GetUsageByDayOfWeekRow, error)
GetUsageByHour(ctx context.Context) ([]GetUsageByHourRow, error)
GetUsageByModel(ctx context.Context) ([]GetUsageByModelRow, error)
+ ListAllUserMessages(ctx context.Context) ([]Message, error)
ListFilesByPath(ctx context.Context, path string) ([]File, error)
ListFilesBySession(ctx context.Context, sessionID string) ([]File, error)
ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error)
ListMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
ListNewFiles(ctx context.Context) ([]File, error)
ListSessions(ctx context.Context) ([]Session, error)
+ ListUserMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
+ RecordFileRead(ctx context.Context, arg RecordFileReadParams) error
UpdateMessage(ctx context.Context, arg UpdateMessageParams) error
UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error)
UpdateSessionTitleAndUsage(ctx context.Context, arg UpdateSessionTitleAndUsageParams) error
@@ -0,0 +1,57 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.30.0
+// source: read_files.sql
+
+package db
+
+import (
+ "context"
+)
+
+const getFileRead = `-- name: GetFileRead :one
+SELECT session_id, path, read_at FROM read_files
+WHERE session_id = ? AND path = ? LIMIT 1
+`
+
+type GetFileReadParams struct {
+ SessionID string `json:"session_id"`
+ Path string `json:"path"`
+}
+
+func (q *Queries) GetFileRead(ctx context.Context, arg GetFileReadParams) (ReadFile, error) {
+ row := q.queryRow(ctx, q.getFileReadStmt, getFileRead, arg.SessionID, arg.Path)
+ var i ReadFile
+ err := row.Scan(
+ &i.SessionID,
+ &i.Path,
+ &i.ReadAt,
+ )
+ return i, err
+}
+
+const recordFileRead = `-- name: RecordFileRead :exec
+INSERT INTO read_files (
+ session_id,
+ path,
+ read_at
+) VALUES (
+ ?,
+ ?,
+ strftime('%s', 'now')
+) ON CONFLICT(path, session_id) DO UPDATE SET
+ read_at = excluded.read_at
+`
+
+type RecordFileReadParams struct {
+ SessionID string `json:"session_id"`
+ Path string `json:"path"`
+}
+
+func (q *Queries) RecordFileRead(ctx context.Context, arg RecordFileReadParams) error {
+ _, err := q.exec(ctx, q.recordFileReadStmt, recordFileRead,
+ arg.SessionID,
+ arg.Path,
+ )
+ return err
+}
@@ -41,3 +41,15 @@ WHERE id = ?;
-- name: DeleteSessionMessages :exec
DELETE FROM messages
WHERE session_id = ?;
+
+-- name: ListUserMessagesBySession :many
+SELECT *
+FROM messages
+WHERE session_id = ? AND role = 'user'
+ORDER BY created_at DESC;
+
+-- name: ListAllUserMessages :many
+SELECT *
+FROM messages
+WHERE role = 'user'
+ORDER BY created_at DESC;
@@ -0,0 +1,15 @@
+-- name: RecordFileRead :exec
+INSERT INTO read_files (
+ session_id,
+ path,
+ read_at
+) VALUES (
+ ?,
+ ?,
+ strftime('%s', 'now')
+) ON CONFLICT(path, session_id) DO UPDATE SET
+ read_at = excluded.read_at;
+
+-- name: GetFileRead :one
+SELECT * FROM read_files
+WHERE session_id = ? AND path = ? LIMIT 1;
@@ -82,18 +82,18 @@ func send(event string, props ...any) {
}
// Error logs an error event to PostHog with the error type and message.
-func Error(err any, props ...any) {
+func Error(errToLog any, props ...any) {
if client == nil {
return
}
posthogErr := client.Enqueue(posthog.NewDefaultException(
time.Now(),
distinctId,
- reflect.TypeOf(err).String(),
- fmt.Sprintf("%v", err),
+ reflect.TypeOf(errToLog).String(),
+ fmt.Sprintf("%v", errToLog),
))
- if err != nil {
- slog.Error("Failed to enqueue PostHog error", "err", err, "props", props, "posthogErr", posthogErr)
+ if posthogErr != nil {
+ slog.Error("Failed to enqueue PostHog error", "err", errToLog, "props", props, "posthogErr", posthogErr)
return
}
}
@@ -0,0 +1,74 @@
+package event
+
+// These tests verify that the Error function correctly handles various
+// scenarios. These tests will not log anything.
+
+import (
+ "testing"
+)
+
+func TestError(t *testing.T) {
+ t.Run("returns early when client is nil", func(t *testing.T) {
+ // This test verifies that when the PostHog client is not initialized
+ // the Error function safely returns early without attempting to
+ // enqueue any events. This is important during initialization or when
+ // metrics are disabled, as we don't want the error reporting mechanism
+ // itself to cause panics.
+ originalClient := client
+ defer func() {
+ client = originalClient
+ }()
+
+ client = nil
+ Error("test error", "key", "value")
+ })
+
+ t.Run("handles nil client without panicking", func(t *testing.T) {
+ // This test covers various edge cases where the error value might be
+ // nil, a string, or an error type.
+ originalClient := client
+ defer func() {
+ client = originalClient
+ }()
+
+ client = nil
+ Error(nil)
+ Error("some error")
+ Error(newDefaultTestError("runtime error"), "key", "value")
+ })
+
+ t.Run("handles error with properties", func(t *testing.T) {
+ // This test verifies that the Error function can handle additional
+ // key-value properties that provide context about the error. These
+ // properties are typically passed when recovering from panics (i.e.,
+ // panic name, function name).
+ //
+ // Even with these additional properties, the function should handle
+ // them gracefully without panicking.
+ originalClient := client
+ defer func() {
+ client = originalClient
+ }()
+
+ client = nil
+ Error("test error",
+ "type", "test",
+ "severity", "high",
+ "source", "unit-test",
+ )
+ })
+}
+
+// newDefaultTestError creates a test error that mimics runtime panic
+// errors. This helps us testing that the Error function can handle various
+// error types, including those that might be passed from a panic recovery
+// scenario.
+func newDefaultTestError(s string) error {
+ return testError(s)
+}
+
+type testError string
+
+func (e testError) Error() string {
+ return string(e)
+}
@@ -1,70 +0,0 @@
-// Package filetracker tracks file read/write times to prevent editing files
-// that haven't been read, and to detect external modifications.
-//
-// TODO: Consider moving this to persistent storage (e.g., the database) to
-// preserve file access history across sessions.
-// We would need to make sure to handle the case where we reload a session and the underlying files did change.
-package filetracker
-
-import (
- "sync"
- "time"
-)
-
-// record tracks when a file was read/written.
-type record struct {
- path string
- readTime time.Time
- writeTime time.Time
-}
-
-var (
- records = make(map[string]record)
- recordMutex sync.RWMutex
-)
-
-// RecordRead records when a file was read.
-func RecordRead(path string) {
- recordMutex.Lock()
- defer recordMutex.Unlock()
-
- rec, exists := records[path]
- if !exists {
- rec = record{path: path}
- }
- rec.readTime = time.Now()
- records[path] = rec
-}
-
-// LastReadTime returns when a file was last read. Returns zero time if never
-// read.
-func LastReadTime(path string) time.Time {
- recordMutex.RLock()
- defer recordMutex.RUnlock()
-
- rec, exists := records[path]
- if !exists {
- return time.Time{}
- }
- return rec.readTime
-}
-
-// RecordWrite records when a file was written.
-func RecordWrite(path string) {
- recordMutex.Lock()
- defer recordMutex.Unlock()
-
- rec, exists := records[path]
- if !exists {
- rec = record{path: path}
- }
- rec.writeTime = time.Now()
- records[path] = rec
-}
-
-// Reset clears all file tracking records. Useful for testing.
-func Reset() {
- recordMutex.Lock()
- defer recordMutex.Unlock()
- records = make(map[string]record)
-}
@@ -0,0 +1,70 @@
+// Package filetracker provides functionality to track file reads in sessions.
+package filetracker
+
+import (
+ "context"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/charmbracelet/crush/internal/db"
+)
+
+// Service defines the interface for tracking file reads in sessions.
+type Service interface {
+ // RecordRead records when a file was read.
+ RecordRead(ctx context.Context, sessionID, path string)
+
+ // LastReadTime returns when a file was last read.
+ // Returns zero time if never read.
+ LastReadTime(ctx context.Context, sessionID, path string) time.Time
+}
+
+type service struct {
+ q *db.Queries
+}
+
+// NewService creates a new file tracker service.
+func NewService(q *db.Queries) Service {
+ return &service{q: q}
+}
+
+// RecordRead records when a file was read.
+func (s *service) RecordRead(ctx context.Context, sessionID, path string) {
+ if err := s.q.RecordFileRead(ctx, db.RecordFileReadParams{
+ SessionID: sessionID,
+ Path: relpath(path),
+ }); err != nil {
+ slog.Error("Error recording file read", "error", err, "file", path)
+ }
+}
+
+// LastReadTime returns when a file was last read.
+// Returns zero time if never read.
+func (s *service) LastReadTime(ctx context.Context, sessionID, path string) time.Time {
+ readFile, err := s.q.GetFileRead(ctx, db.GetFileReadParams{
+ SessionID: sessionID,
+ Path: relpath(path),
+ })
+ if err != nil {
+ return time.Time{}
+ }
+
+ return time.Unix(readFile.ReadAt, 0)
+}
+
+func relpath(path string) string {
+ path = filepath.Clean(path)
+ basepath, err := os.Getwd()
+ if err != nil {
+ slog.Warn("Error getting basepath", "error", err)
+ return path
+ }
+ relpath, err := filepath.Rel(basepath, path)
+ if err != nil {
+ slog.Warn("Error getting relpath", "error", err)
+ return path
+ }
+ return relpath
+}
@@ -0,0 +1,116 @@
+package filetracker
+
+import (
+ "context"
+ "testing"
+ "testing/synctest"
+ "time"
+
+ "github.com/charmbracelet/crush/internal/db"
+ "github.com/stretchr/testify/require"
+)
+
+type testEnv struct {
+ ctx context.Context
+ q *db.Queries
+ svc Service
+}
+
+func setupTest(t *testing.T) *testEnv {
+ t.Helper()
+
+ conn, err := db.Connect(t.Context(), t.TempDir())
+ require.NoError(t, err)
+ t.Cleanup(func() { conn.Close() })
+
+ q := db.New(conn)
+ return &testEnv{
+ ctx: t.Context(),
+ q: q,
+ svc: NewService(q),
+ }
+}
+
+func (e *testEnv) createSession(t *testing.T, sessionID string) {
+ t.Helper()
+ _, err := e.q.CreateSession(e.ctx, db.CreateSessionParams{
+ ID: sessionID,
+ Title: "Test Session",
+ })
+ require.NoError(t, err)
+}
+
+func TestService_RecordRead(t *testing.T) {
+ env := setupTest(t)
+
+ sessionID := "test-session-1"
+ path := "/path/to/file.go"
+ env.createSession(t, sessionID)
+
+ env.svc.RecordRead(env.ctx, sessionID, path)
+
+ lastRead := env.svc.LastReadTime(env.ctx, sessionID, path)
+ require.False(t, lastRead.IsZero(), "expected non-zero time after recording read")
+ require.WithinDuration(t, time.Now(), lastRead, 2*time.Second)
+}
+
+func TestService_LastReadTime_NotFound(t *testing.T) {
+ env := setupTest(t)
+
+ lastRead := env.svc.LastReadTime(env.ctx, "nonexistent-session", "/nonexistent/path")
+ require.True(t, lastRead.IsZero(), "expected zero time for unread file")
+}
+
+func TestService_RecordRead_UpdatesTimestamp(t *testing.T) {
+ env := setupTest(t)
+
+ sessionID := "test-session-2"
+ path := "/path/to/file.go"
+ env.createSession(t, sessionID)
+
+ env.svc.RecordRead(env.ctx, sessionID, path)
+ firstRead := env.svc.LastReadTime(env.ctx, sessionID, path)
+ require.False(t, firstRead.IsZero())
+
+ synctest.Test(t, func(t *testing.T) {
+ time.Sleep(100 * time.Millisecond)
+ synctest.Wait()
+ env.svc.RecordRead(env.ctx, sessionID, path)
+ secondRead := env.svc.LastReadTime(env.ctx, sessionID, path)
+
+ require.False(t, secondRead.Before(firstRead), "second read time should not be before first")
+ })
+}
+
+func TestService_RecordRead_DifferentSessions(t *testing.T) {
+ env := setupTest(t)
+
+ path := "/shared/file.go"
+ session1, session2 := "session-1", "session-2"
+ env.createSession(t, session1)
+ env.createSession(t, session2)
+
+ env.svc.RecordRead(env.ctx, session1, path)
+
+ lastRead1 := env.svc.LastReadTime(env.ctx, session1, path)
+ require.False(t, lastRead1.IsZero())
+
+ lastRead2 := env.svc.LastReadTime(env.ctx, session2, path)
+ require.True(t, lastRead2.IsZero(), "session 2 should not see session 1's read")
+}
+
+func TestService_RecordRead_DifferentPaths(t *testing.T) {
+ env := setupTest(t)
+
+ sessionID := "test-session-3"
+ path1, path2 := "/path/to/file1.go", "/path/to/file2.go"
+ env.createSession(t, sessionID)
+
+ env.svc.RecordRead(env.ctx, sessionID, path1)
+
+ lastRead1 := env.svc.LastReadTime(env.ctx, sessionID, path1)
+ require.False(t, lastRead1.IsZero())
+
+ lastRead2 := env.svc.LastReadTime(env.ctx, sessionID, path2)
+ require.True(t, lastRead2.IsZero(), "path2 should not be recorded")
+}
@@ -144,20 +144,20 @@ func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bo
}
if commonIgnorePatterns().MatchesPath(relPath) {
- slog.Debug("ignoring common pattern", "path", relPath)
+ slog.Debug("Ignoring common pattern", "path", relPath)
return true
}
parentDir := filepath.Dir(path)
ignoreParser := dl.getIgnore(parentDir)
if ignoreParser.MatchesPath(relPath) {
- slog.Debug("ignoring dir pattern", "path", relPath, "dir", parentDir)
+ slog.Debug("Ignoring dir pattern", "path", relPath, "dir", parentDir)
return true
}
// For directories, also check with trailing slash (gitignore convention)
if ignoreParser.MatchesPath(relPath + "/") {
- slog.Debug("ignoring dir pattern with slash", "path", relPath+"/", "dir", parentDir)
+ slog.Debug("Ignoring dir pattern with slash", "path", relPath+"/", "dir", parentDir)
return true
}
@@ -166,7 +166,7 @@ func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bo
}
if homeIgnore().MatchesPath(relPath) {
- slog.Debug("ignoring home dir pattern", "path", relPath)
+ slog.Debug("Ignoring home dir pattern", "path", relPath)
return true
}
@@ -177,7 +177,7 @@ func (dl *directoryLister) checkParentIgnores(path string) bool {
parent := filepath.Dir(filepath.Dir(path))
for parent != "." && path != "." {
if dl.getIgnore(parent).MatchesPath(path) {
- slog.Debug("ingoring parent dir pattern", "path", path, "dir", parent)
+ slog.Debug("Ignoring parent dir pattern", "path", path, "dir", parent)
return true
}
if parent == dl.rootPath {
@@ -210,7 +210,7 @@ func ListDirectory(initialPath string, ignorePatterns []string, depth, limit int
found := csync.NewSlice[string]()
dl := NewDirectoryLister(initialPath)
- slog.Debug("listing directory", "path", initialPath, "depth", depth, "limit", limit, "ignorePatterns", ignorePatterns)
+ slog.Debug("Listing directory", "path", initialPath, "depth", depth, "limit", limit, "ignorePatterns", ignorePatterns)
conf := fastwalk.Config{
Follow: true,
@@ -0,0 +1,129 @@
+package fsext
+
+import (
+ "os"
+ "strings"
+)
+
+func ParsePastedFiles(s string) []string {
+ s = strings.TrimSpace(s)
+
+ // NOTE: Rio on Windows adds NULL chars for some reason.
+ s = strings.ReplaceAll(s, "\x00", "")
+
+ switch {
+ case attemptStat(s):
+ return strings.Split(s, "\n")
+ case os.Getenv("WT_SESSION") != "":
+ return windowsTerminalParsePastedFiles(s)
+ default:
+ return unixParsePastedFiles(s)
+ }
+}
+
+func attemptStat(s string) bool {
+ for path := range strings.SplitSeq(s, "\n") {
+ if info, err := os.Stat(path); err != nil || info.IsDir() {
+ return false
+ }
+ }
+ return true
+}
+
+func windowsTerminalParsePastedFiles(s string) []string {
+ if strings.TrimSpace(s) == "" {
+ return nil
+ }
+
+ var (
+ paths []string
+ current strings.Builder
+ inQuotes = false
+ )
+ for i := range len(s) {
+ ch := s[i]
+
+ switch {
+ case ch == '"':
+ if inQuotes {
+ // End of quoted section
+ if current.Len() > 0 {
+ paths = append(paths, current.String())
+ current.Reset()
+ }
+ inQuotes = false
+ } else {
+ // Start of quoted section
+ inQuotes = true
+ }
+ case inQuotes:
+ current.WriteByte(ch)
+ case ch != ' ':
+ // Text outside quotes is not allowed
+ return nil
+ }
+ }
+
+ // Add any remaining content if quotes were properly closed
+ if current.Len() > 0 && !inQuotes {
+ paths = append(paths, current.String())
+ }
+
+ // If quotes were not closed, return empty (malformed input)
+ if inQuotes {
+ return nil
+ }
+
+ return paths
+}
+
+func unixParsePastedFiles(s string) []string {
+ if strings.TrimSpace(s) == "" {
+ return nil
+ }
+
+ var (
+ paths []string
+ current strings.Builder
+ escaped = false
+ )
+ for i := range len(s) {
+ ch := s[i]
+
+ switch {
+ case escaped:
+ // After a backslash, add the character as-is (including space)
+ current.WriteByte(ch)
+ escaped = false
+ case ch == '\\':
+ // Check if this backslash is at the end of the string
+ if i == len(s)-1 {
+ // Trailing backslash, treat as literal
+ current.WriteByte(ch)
+ } else {
+ // Start of escape sequence
+ escaped = true
+ }
+ case ch == ' ':
+ // Space separates paths (unless escaped)
+ if current.Len() > 0 {
+ paths = append(paths, current.String())
+ current.Reset()
+ }
+ default:
+ current.WriteByte(ch)
+ }
+ }
+
+ // Handle trailing backslash if present
+ if escaped {
+ current.WriteByte('\\')
+ }
+
+ // Add the last path if any
+ if current.Len() > 0 {
+ paths = append(paths, current.String())
+ }
+
+ return paths
+}
@@ -0,0 +1,149 @@
+package fsext
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestParsePastedFiles(t *testing.T) {
+ t.Run("WindowsTerminal", func(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected []string
+ }{
+ {
+ name: "single path",
+ input: `"C:\path\my-screenshot-one.png"`,
+ expected: []string{`C:\path\my-screenshot-one.png`},
+ },
+ {
+ name: "multiple paths no spaces",
+ input: `"C:\path\my-screenshot-one.png" "C:\path\my-screenshot-two.png" "C:\path\my-screenshot-three.png"`,
+ expected: []string{`C:\path\my-screenshot-one.png`, `C:\path\my-screenshot-two.png`, `C:\path\my-screenshot-three.png`},
+ },
+ {
+ name: "single with spaces",
+ input: `"C:\path\my screenshot one.png"`,
+ expected: []string{`C:\path\my screenshot one.png`},
+ },
+ {
+ name: "multiple paths with spaces",
+ input: `"C:\path\my screenshot one.png" "C:\path\my screenshot two.png" "C:\path\my screenshot three.png"`,
+ expected: []string{`C:\path\my screenshot one.png`, `C:\path\my screenshot two.png`, `C:\path\my screenshot three.png`},
+ },
+ {
+ name: "empty string",
+ input: "",
+ expected: nil,
+ },
+ {
+ name: "unclosed quotes",
+ input: `"C:\path\file.png`,
+ expected: nil,
+ },
+ {
+ name: "text outside quotes",
+ input: `"C:\path\file.png" some random text "C:\path\file2.png"`,
+ expected: nil,
+ },
+ {
+ name: "multiple spaces between paths",
+ input: `"C:\path\file1.png" "C:\path\file2.png"`,
+ expected: []string{`C:\path\file1.png`, `C:\path\file2.png`},
+ },
+ {
+ name: "just whitespace",
+ input: " ",
+ expected: nil,
+ },
+ {
+ name: "consecutive quoted sections",
+ input: `"C:\path1""C:\path2"`,
+ expected: []string{`C:\path1`, `C:\path2`},
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := windowsTerminalParsePastedFiles(tt.input)
+ require.Equal(t, tt.expected, result)
+ })
+ }
+ })
+
+ t.Run("Unix", func(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected []string
+ }{
+ {
+ name: "single path",
+ input: `/path/my-screenshot.png`,
+ expected: []string{"/path/my-screenshot.png"},
+ },
+ {
+ name: "multiple paths no spaces",
+ input: `/path/screenshot-one.png /path/screenshot-two.png /path/screenshot-three.png`,
+ expected: []string{"/path/screenshot-one.png", "/path/screenshot-two.png", "/path/screenshot-three.png"},
+ },
+ {
+ name: "sigle with spaces",
+ input: `/path/my\ screenshot\ one.png`,
+ expected: []string{"/path/my screenshot one.png"},
+ },
+ {
+ name: "multiple paths with spaces",
+ input: `/path/my\ screenshot\ one.png /path/my\ screenshot\ two.png /path/my\ screenshot\ three.png`,
+ expected: []string{"/path/my screenshot one.png", "/path/my screenshot two.png", "/path/my screenshot three.png"},
+ },
+ {
+ name: "empty string",
+ input: "",
+ expected: nil,
+ },
+ {
+ name: "double backslash escapes",
+ input: `/path/my\\file.png`,
+ expected: []string{"/path/my\\file.png"},
+ },
+ {
+ name: "trailing backslash",
+ input: `/path/file\`,
+ expected: []string{`/path/file\`},
+ },
+ {
+ name: "multiple consecutive escaped spaces",
+ input: `/path/file\ \ with\ \ many\ \ spaces.png`,
+ expected: []string{"/path/file with many spaces.png"},
+ },
+ {
+ name: "multiple unescaped spaces",
+ input: `/path/file1.png /path/file2.png`,
+ expected: []string{"/path/file1.png", "/path/file2.png"},
+ },
+ {
+ name: "just whitespace",
+ input: " ",
+ expected: nil,
+ },
+ {
+ name: "tab characters",
+ input: "/path/file1.png\t/path/file2.png",
+ expected: []string{"/path/file1.png\t/path/file2.png"},
+ },
+ {
+ name: "newlines in input",
+ input: "/path/file1.png\n/path/file2.png",
+ expected: []string{"/path/file1.png\n/path/file2.png"},
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := unixParsePastedFiles(tt.input)
+ require.Equal(t, tt.expected, result)
+ })
+ }
+ })
+}
@@ -12,7 +12,7 @@ var homedir, homedirErr = os.UserHomeDir()
func init() {
if homedirErr != nil {
- slog.Error("failed to get user home directory", "error", homedirErr)
+ slog.Error("Failed to get user home directory", "error", homedirErr)
}
}
@@ -13,10 +13,12 @@ import (
"sync/atomic"
"time"
+ "github.com/bmatcuk/doublestar/v4"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/csync"
"github.com/charmbracelet/crush/internal/fsext"
"github.com/charmbracelet/crush/internal/home"
+ powernapconfig "github.com/charmbracelet/x/powernap/pkg/config"
powernap "github.com/charmbracelet/x/powernap/pkg/lsp"
"github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
"github.com/charmbracelet/x/powernap/pkg/transport"
@@ -34,6 +36,9 @@ type Client struct {
client *powernap.Client
name string
+ // Working directory this LSP is scoped to.
+ workDir string
+
// File types this LSP server handles (e.g., .go, .rs, .py)
fileTypes []string
@@ -133,6 +138,7 @@ func (c *Client) createPowernapClient() error {
}
rootURI := string(protocol.URIFromPath(workDir))
+ c.workDir = workDir
command, err := c.resolver.ResolveValue(c.config.Command)
if err != nil {
@@ -305,25 +311,39 @@ type OpenFileInfo struct {
URI protocol.DocumentURI
}
-// HandlesFile checks if this LSP client handles the given file based on its extension.
+// HandlesFile checks if this LSP client handles the given file based on its
+// extension and whether it's within the working directory.
func (c *Client) HandlesFile(path string) bool {
- // If no file types are specified, handle all files (backward compatibility)
+ // Check if file is within working directory.
+ absPath, err := filepath.Abs(path)
+ if err != nil {
+ slog.Debug("Cannot resolve path", "name", c.name, "file", path, "error", err)
+ return false
+ }
+ relPath, err := filepath.Rel(c.workDir, absPath)
+ if err != nil || strings.HasPrefix(relPath, "..") {
+ slog.Debug("File outside workspace", "name", c.name, "file", path, "workDir", c.workDir)
+ return false
+ }
+
+ // If no file types are specified, handle all files (backward compatibility).
if len(c.fileTypes) == 0 {
return true
}
+ kind := powernap.DetectLanguage(path)
name := strings.ToLower(filepath.Base(path))
for _, filetype := range c.fileTypes {
suffix := strings.ToLower(filetype)
if !strings.HasPrefix(suffix, ".") {
suffix = "." + suffix
}
- if strings.HasSuffix(name, suffix) {
- slog.Debug("handles file", "name", c.name, "file", name, "filetype", filetype)
+ if strings.HasSuffix(name, suffix) || filetype == string(kind) {
+ slog.Debug("Handles file", "name", c.name, "file", name, "filetype", filetype, "kind", kind)
return true
}
}
- slog.Debug("doesn't handle file", "name", c.name, "file", name)
+ slog.Debug("Doesn't handle file", "name", c.name, "file", name)
return false
}
@@ -346,7 +366,7 @@ func (c *Client) OpenFile(ctx context.Context, filepath string) error {
}
// Notify the server about the opened document
- if err = c.client.NotifyDidOpenTextDocument(ctx, uri, string(DetectLanguageID(uri)), 1, string(content)); err != nil {
+ if err = c.client.NotifyDidOpenTextDocument(ctx, uri, string(powernap.DetectLanguage(filepath)), 1, string(content)); err != nil {
return err
}
@@ -557,18 +577,71 @@ func (c *Client) FindReferences(ctx context.Context, filepath string, line, char
return c.client.FindReferences(ctx, filepath, line-1, character-1, includeDeclaration)
}
-// HasRootMarkers checks if any of the specified root marker patterns exist in the given directory.
-// Uses glob patterns to match files, allowing for more flexible matching.
-func HasRootMarkers(dir string, rootMarkers []string) bool {
- if len(rootMarkers) == 0 {
- return true
+// FilterMatching gets a list of configs and only returns the ones with
+// matching root markers.
+func FilterMatching(dir string, servers map[string]*powernapconfig.ServerConfig) map[string]*powernapconfig.ServerConfig {
+ result := map[string]*powernapconfig.ServerConfig{}
+ if len(servers) == 0 {
+ return result
}
- for _, pattern := range rootMarkers {
- // Use fsext.GlobWithDoubleStar to find matches
- matches, _, err := fsext.GlobWithDoubleStar(pattern, dir, 1)
- if err == nil && len(matches) > 0 {
- return true
+
+ type serverPatterns struct {
+ server *powernapconfig.ServerConfig
+ patterns []string
+ }
+ normalized := make(map[string]serverPatterns, len(servers))
+ for name, server := range servers {
+ var patterns []string
+ for _, p := range server.RootMarkers {
+ if p == ".git" {
+ // ignore .git for discovery
+ continue
+ }
+ patterns = append(patterns, filepath.ToSlash(p))
+ }
+ if len(patterns) == 0 {
+ slog.Debug("ignoring lsp with no root markers", "name", name)
+ continue
}
+ normalized[name] = serverPatterns{server: server, patterns: patterns}
}
- return false
+
+ walker := fsext.NewFastGlobWalker(dir)
+ _ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
+ if err != nil {
+ return nil
+ }
+
+ if walker.ShouldSkip(path) {
+ if d.IsDir() {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+
+ relPath, err := filepath.Rel(dir, path)
+ if err != nil {
+ return nil
+ }
+ relPath = filepath.ToSlash(relPath)
+
+ for name, sp := range normalized {
+ for _, pattern := range sp.patterns {
+ matched, err := doublestar.Match(pattern, relPath)
+ if err != nil || !matched {
+ continue
+ }
+ result[name] = sp.server
+ delete(normalized, name)
+ break
+ }
+ }
+
+ if len(normalized) == 0 {
+ return filepath.SkipAll
+ }
+ return nil
+ })
+
+ return result
}
@@ -0,0 +1,111 @@
+package lsp
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ powernapconfig "github.com/charmbracelet/x/powernap/pkg/config"
+ "github.com/stretchr/testify/require"
+)
+
+func TestFilterMatching(t *testing.T) {
+ t.Parallel()
+
+ t.Run("matches servers with existing root markers", func(t *testing.T) {
+ t.Parallel()
+ tmpDir := t.TempDir()
+
+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test"), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "Cargo.toml"), []byte("[package]"), 0o644))
+
+ servers := map[string]*powernapconfig.ServerConfig{
+ "gopls": {RootMarkers: []string{"go.mod", "go.work"}},
+ "rust-analyzer": {RootMarkers: []string{"Cargo.toml"}},
+ "typescript-lsp": {RootMarkers: []string{"package.json", "tsconfig.json"}},
+ }
+
+ result := FilterMatching(tmpDir, servers)
+
+ require.Contains(t, result, "gopls")
+ require.Contains(t, result, "rust-analyzer")
+ require.NotContains(t, result, "typescript-lsp")
+ })
+
+ t.Run("returns empty for empty servers", func(t *testing.T) {
+ t.Parallel()
+ tmpDir := t.TempDir()
+
+ result := FilterMatching(tmpDir, map[string]*powernapconfig.ServerConfig{})
+
+ require.Empty(t, result)
+ })
+
+ t.Run("returns empty when no markers match", func(t *testing.T) {
+ t.Parallel()
+ tmpDir := t.TempDir()
+
+ servers := map[string]*powernapconfig.ServerConfig{
+ "gopls": {RootMarkers: []string{"go.mod"}},
+ "python": {RootMarkers: []string{"pyproject.toml"}},
+ }
+
+ result := FilterMatching(tmpDir, servers)
+
+ require.Empty(t, result)
+ })
+
+ t.Run("glob patterns work", func(t *testing.T) {
+ t.Parallel()
+ tmpDir := t.TempDir()
+
+ require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "src"), 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "src", "main.go"), []byte("package main"), 0o644))
+
+ servers := map[string]*powernapconfig.ServerConfig{
+ "gopls": {RootMarkers: []string{"**/*.go"}},
+ "python": {RootMarkers: []string{"**/*.py"}},
+ }
+
+ result := FilterMatching(tmpDir, servers)
+
+ require.Contains(t, result, "gopls")
+ require.NotContains(t, result, "python")
+ })
+
+ t.Run("servers with empty root markers are not included", func(t *testing.T) {
+ t.Parallel()
+ tmpDir := t.TempDir()
+
+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test"), 0o644))
+
+ servers := map[string]*powernapconfig.ServerConfig{
+ "gopls": {RootMarkers: []string{"go.mod"}},
+ "generic": {RootMarkers: []string{}},
+ }
+
+ result := FilterMatching(tmpDir, servers)
+
+ require.Contains(t, result, "gopls")
+ require.NotContains(t, result, "generic")
+ })
+
+ t.Run("stops early when all servers match", func(t *testing.T) {
+ t.Parallel()
+ tmpDir := t.TempDir()
+
+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test"), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "Cargo.toml"), []byte("[package]"), 0o644))
+
+ servers := map[string]*powernapconfig.ServerConfig{
+ "gopls": {RootMarkers: []string{"go.mod"}},
+ "rust-analyzer": {RootMarkers: []string{"Cargo.toml"}},
+ }
+
+ result := FilterMatching(tmpDir, servers)
+
+ require.Len(t, result, 2)
+ require.Contains(t, result, "gopls")
+ require.Contains(t, result, "rust-analyzer")
+ })
+}
@@ -1,132 +0,0 @@
-package lsp
-
-import (
- "path/filepath"
- "strings"
-
- "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
-)
-
-func DetectLanguageID(uri string) protocol.LanguageKind {
- ext := strings.ToLower(filepath.Ext(uri))
- switch ext {
- case ".abap":
- return protocol.LangABAP
- case ".bat":
- return protocol.LangWindowsBat
- case ".bib", ".bibtex":
- return protocol.LangBibTeX
- case ".clj":
- return protocol.LangClojure
- case ".coffee":
- return protocol.LangCoffeescript
- case ".c":
- return protocol.LangC
- case ".cpp", ".cxx", ".cc", ".c++":
- return protocol.LangCPP
- case ".cs":
- return protocol.LangCSharp
- case ".css":
- return protocol.LangCSS
- case ".d":
- return protocol.LangD
- case ".pas", ".pascal":
- return protocol.LangDelphi
- case ".diff", ".patch":
- return protocol.LangDiff
- case ".dart":
- return protocol.LangDart
- case ".dockerfile":
- return protocol.LangDockerfile
- case ".ex", ".exs":
- return protocol.LangElixir
- case ".erl", ".hrl":
- return protocol.LangErlang
- case ".fs", ".fsi", ".fsx", ".fsscript":
- return protocol.LangFSharp
- case ".gitcommit":
- return protocol.LangGitCommit
- case ".gitrebase":
- return protocol.LangGitRebase
- case ".go":
- return protocol.LangGo
- case ".groovy":
- return protocol.LangGroovy
- case ".hbs", ".handlebars":
- return protocol.LangHandlebars
- case ".hs":
- return protocol.LangHaskell
- case ".html", ".htm":
- return protocol.LangHTML
- case ".ini":
- return protocol.LangIni
- case ".java":
- return protocol.LangJava
- case ".js":
- return protocol.LangJavaScript
- case ".jsx":
- return protocol.LangJavaScriptReact
- case ".json":
- return protocol.LangJSON
- case ".tex", ".latex":
- return protocol.LangLaTeX
- case ".less":
- return protocol.LangLess
- case ".lua":
- return protocol.LangLua
- case ".makefile", "makefile":
- return protocol.LangMakefile
- case ".md", ".markdown":
- return protocol.LangMarkdown
- case ".m":
- return protocol.LangObjectiveC
- case ".mm":
- return protocol.LangObjectiveCPP
- case ".pl":
- return protocol.LangPerl
- case ".pm":
- return protocol.LangPerl6
- case ".php":
- return protocol.LangPHP
- case ".ps1", ".psm1":
- return protocol.LangPowershell
- case ".pug", ".jade":
- return protocol.LangPug
- case ".py":
- return protocol.LangPython
- case ".r":
- return protocol.LangR
- case ".cshtml", ".razor":
- return protocol.LangRazor
- case ".rb":
- return protocol.LangRuby
- case ".rs":
- return protocol.LangRust
- case ".scss":
- return protocol.LangSCSS
- case ".sass":
- return protocol.LangSASS
- case ".scala":
- return protocol.LangScala
- case ".shader":
- return protocol.LangShaderLab
- case ".sh", ".bash", ".zsh", ".ksh":
- return protocol.LangShellScript
- case ".sql":
- return protocol.LangSQL
- case ".swift":
- return protocol.LangSwift
- case ".ts":
- return protocol.LangTypeScript
- case ".tsx":
- return protocol.LangTypeScriptReact
- case ".xml":
- return protocol.LangXML
- case ".xsl":
- return protocol.LangXSL
- case ".yaml", ".yml":
- return protocol.LangYAML
- default:
- return protocol.LanguageKind("") // Unknown language
- }
-}
@@ -1,37 +0,0 @@
-package lsp
-
-import (
- "os"
- "path/filepath"
- "testing"
-
- "github.com/stretchr/testify/require"
-)
-
-func TestHasRootMarkers(t *testing.T) {
- t.Parallel()
-
- // Create a temporary directory for testing
- tmpDir := t.TempDir()
-
- // Test with empty root markers (should return true)
- require.True(t, HasRootMarkers(tmpDir, []string{}))
-
- // Test with non-existent markers
- require.False(t, HasRootMarkers(tmpDir, []string{"go.mod", "package.json"}))
-
- // Create a go.mod file
- goModPath := filepath.Join(tmpDir, "go.mod")
- err := os.WriteFile(goModPath, []byte("module test"), 0o644)
- require.NoError(t, err)
-
- // Test with existing marker
- require.True(t, HasRootMarkers(tmpDir, []string{"go.mod", "package.json"}))
-
- // Test with only non-existent markers
- require.False(t, HasRootMarkers(tmpDir, []string{"package.json", "Cargo.toml"}))
-
- // Test with glob patterns
- require.True(t, HasRootMarkers(tmpDir, []string{"*.mod"}))
- require.False(t, HasRootMarkers(tmpDir, []string{"*.json"}))
-}
@@ -8,11 +8,11 @@ import (
"strings"
"time"
+ "charm.land/catwalk/pkg/catwalk"
"charm.land/fantasy"
"charm.land/fantasy/providers/anthropic"
"charm.land/fantasy/providers/google"
"charm.land/fantasy/providers/openai"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
)
type MessageRole string
@@ -26,6 +26,8 @@ type Service interface {
Update(ctx context.Context, message Message) error
Get(ctx context.Context, id string) (Message, error)
List(ctx context.Context, sessionID string) ([]Message, error)
+ ListUserMessages(ctx context.Context, sessionID string) ([]Message, error)
+ ListAllUserMessages(ctx context.Context) ([]Message, error)
Delete(ctx context.Context, id string) error
DeleteSessionMessages(ctx context.Context, sessionID string) error
}
@@ -157,6 +159,36 @@ func (s *service) List(ctx context.Context, sessionID string) ([]Message, error)
return messages, nil
}
+func (s *service) ListUserMessages(ctx context.Context, sessionID string) ([]Message, error) {
+ dbMessages, err := s.q.ListUserMessagesBySession(ctx, sessionID)
+ if err != nil {
+ return nil, err
+ }
+ messages := make([]Message, len(dbMessages))
+ for i, dbMessage := range dbMessages {
+ messages[i], err = s.fromDBItem(dbMessage)
+ if err != nil {
+ return nil, err
+ }
+ }
+ return messages, nil
+}
+
+func (s *service) ListAllUserMessages(ctx context.Context) ([]Message, error) {
+ dbMessages, err := s.q.ListAllUserMessages(ctx)
+ if err != nil {
+ return nil, err
+ }
+ messages := make([]Message, len(dbMessages))
+ for i, dbMessage := range dbMessages {
+ messages[i], err = s.fromDBItem(dbMessage)
+ if err != nil {
+ return nil, err
+ }
+ }
+ return messages, nil
+}
+
func (s *service) fromDBItem(item db.Message) (Message, error) {
parts, err := unmarshalParts([]byte(item.Parts))
if err != nil {
@@ -203,7 +203,7 @@ func (s *service) List(ctx context.Context) ([]Session, error) {
func (s service) fromDBItem(item db.Session) Session {
todos, err := unmarshalTodos(item.Todos.String)
if err != nil {
- slog.Error("failed to unmarshal todos", "session_id", item.ID, "error", err)
+ slog.Error("Failed to unmarshal todos", "session_id", item.ID, "error", err)
}
return Session{
ID: item.ID,
@@ -1,6 +1,8 @@
package stringext
import (
+ "strings"
+
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
@@ -8,3 +10,13 @@ import (
func Capitalize(text string) string {
return cases.Title(language.English, cases.Compact).String(text)
}
+
+// NormalizeSpace normalizes whitespace in the given content string.
+// It replaces Windows-style line endings with Unix-style line endings,
+// converts tabs to four spaces, and trims leading and trailing whitespace.
+func NormalizeSpace(content string) string {
+ content = strings.ReplaceAll(content, "\r\n", "\n")
+ content = strings.ReplaceAll(content, "\t", " ")
+ content = strings.TrimSpace(content)
+ return content
+}
@@ -1,6 +1,7 @@
package editor
import (
+ "context"
"fmt"
"math/rand"
"net/http"
@@ -17,7 +18,6 @@ import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/crush/internal/app"
- "github.com/charmbracelet/crush/internal/filetracker"
"github.com/charmbracelet/crush/internal/fsext"
"github.com/charmbracelet/crush/internal/message"
"github.com/charmbracelet/crush/internal/session"
@@ -66,6 +66,7 @@ type editorCmp struct {
x, y int
app *app.App
session session.Session
+ sessionFileReads []string
textarea textarea.Model
attachments []message.Attachment
deleteMode bool
@@ -181,6 +182,9 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
var cmd tea.Cmd
var cmds []tea.Cmd
switch msg := msg.(type) {
+ case chat.SessionClearedMsg:
+ m.session = session.Session{}
+ m.sessionFileReads = nil
case tea.WindowSizeMsg:
return m, m.repositionCompletions
case filepicker.FilePickedMsg:
@@ -212,19 +216,27 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
m.completionsStartIndex = 0
}
absPath, _ := filepath.Abs(item.Path)
+
+ ctx := context.Background()
+
// Skip attachment if file was already read and hasn't been modified.
- lastRead := filetracker.LastReadTime(absPath)
- if !lastRead.IsZero() {
- if info, err := os.Stat(item.Path); err == nil && !info.ModTime().After(lastRead) {
- return m, nil
+ if m.session.ID != "" {
+ lastRead := m.app.FileTracker.LastReadTime(ctx, m.session.ID, absPath)
+ if !lastRead.IsZero() {
+ if info, err := os.Stat(item.Path); err == nil && !info.ModTime().After(lastRead) {
+ return m, nil
+ }
}
+ } else if slices.Contains(m.sessionFileReads, absPath) {
+ return m, nil
}
+
+ m.sessionFileReads = append(m.sessionFileReads, absPath)
content, err := os.ReadFile(item.Path)
if err != nil {
// if it fails, let the LLM handle it later.
return m, nil
}
- filetracker.RecordRead(absPath)
m.attachments = append(m.attachments, message.Attachment{
FilePath: item.Path,
FileName: filepath.Base(item.Path),
@@ -662,6 +674,9 @@ func (c *editorCmp) Bindings() []key.Binding {
// we need to move some functionality to the page level
func (c *editorCmp) SetSession(session session.Session) tea.Cmd {
c.session = session
+ for _, path := range c.sessionFileReads {
+ c.app.FileTracker.RecordRead(context.Background(), session.ID, path)
+ }
return nil
}
@@ -9,8 +9,8 @@ import (
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
+ "charm.land/catwalk/pkg/catwalk"
"charm.land/lipgloss/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/x/ansi"
"github.com/charmbracelet/x/exp/ordered"
"github.com/google/uuid"
@@ -8,7 +8,6 @@ import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/csync"
"github.com/charmbracelet/crush/internal/diff"
@@ -480,8 +479,6 @@ func (m *sidebarCmp) filesBlock() string {
func (m *sidebarCmp) lspBlock() string {
// Limit the number of LSPs shown
_, maxLSPs, _ := m.getDynamicLimits()
- lspConfigs := config.Get().LSP.Sorted()
- maxLSPs = min(len(lspConfigs), maxLSPs)
return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{
MaxWidth: m.getMaxWidth(),
@@ -550,7 +547,6 @@ func (s *sidebarCmp) currentModelBlock() string {
selectedModel := cfg.Models[agentCfg.Model]
model := config.Get().GetModelByType(agentCfg.Model)
- modelProvider := config.Get().GetProviderForModel(agentCfg.Model)
t := styles.CurrentTheme()
@@ -562,15 +558,14 @@ func (s *sidebarCmp) currentModelBlock() string {
}
if model.CanReason {
reasoningInfoStyle := t.S().Subtle.PaddingLeft(2)
- switch modelProvider.Type {
- case catwalk.TypeAnthropic:
+ if len(model.ReasoningLevels) == 0 {
formatter := cases.Title(language.English, cases.NoLower)
if selectedModel.Think {
parts = append(parts, reasoningInfoStyle.Render(formatter.String("Thinking on")))
} else {
parts = append(parts, reasoningInfoStyle.Render(formatter.String("Thinking off")))
}
- default:
+ } else {
reasoningEffort := model.DefaultReasoningEffort
if selectedModel.ReasoningEffort != "" {
reasoningEffort = selectedModel.ReasoningEffort
@@ -8,8 +8,8 @@ import (
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/spinner"
tea "charm.land/bubbletea/v2"
+ "charm.land/catwalk/pkg/catwalk"
"charm.land/lipgloss/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/agent"
hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
"github.com/charmbracelet/crush/internal/config"
@@ -10,10 +10,8 @@ import (
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/agent"
- "github.com/charmbracelet/crush/internal/agent/hyper"
"github.com/charmbracelet/crush/internal/agent/tools/mcp"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/csync"
@@ -364,7 +362,7 @@ func (c *commandDialogCmp) defaultCommands() []Command {
selectedModel := cfg.Models[agentCfg.Model]
// Anthropic models: thinking toggle
- if providerCfg.Type == catwalk.TypeAnthropic || providerCfg.Type == catwalk.Type(hyper.Name) {
+ if model.CanReason && len(model.ReasoningLevels) == 0 {
status := "Enable"
if selectedModel.Think {
status = "Disable"
@@ -7,7 +7,7 @@ import (
"strings"
tea "charm.land/bubbletea/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
+ "charm.land/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/tui/exp/list"
"github.com/charmbracelet/crush/internal/tui/styles"
@@ -9,7 +9,7 @@ import (
"testing"
tea "charm.land/bubbletea/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
+ "charm.land/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/log"
"github.com/charmbracelet/crush/internal/tui/exp/list"
@@ -9,8 +9,8 @@ import (
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/spinner"
tea "charm.land/bubbletea/v2"
+ "charm.land/catwalk/pkg/catwalk"
"charm.land/lipgloss/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/tui/components/core"
@@ -2,6 +2,8 @@ package lsp
import (
"fmt"
+ "maps"
+ "slices"
"strings"
"charm.land/lipgloss/v2"
@@ -35,32 +37,32 @@ func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOption
lspList = append(lspList, section, "")
}
- lspConfigs := config.Get().LSP.Sorted()
- if len(lspConfigs) == 0 {
+ // Get LSP states
+ lsps := slices.SortedFunc(maps.Values(app.GetLSPStates()), func(a, b app.LSPClientInfo) int {
+ return strings.Compare(a.Name, b.Name)
+ })
+ if len(lsps) == 0 {
lspList = append(lspList, t.S().Base.Foreground(t.Border).Render("None"))
return lspList
}
- // Get LSP states
- lspStates := app.GetLSPStates()
-
// Determine how many items to show
- maxItems := len(lspConfigs)
+ maxItems := len(lsps)
if opts.MaxItems > 0 {
- maxItems = min(opts.MaxItems, len(lspConfigs))
+ maxItems = min(opts.MaxItems, len(lsps))
}
- for i, l := range lspConfigs {
+ for i, info := range lsps {
if i >= maxItems {
break
}
- icon, description := iconAndDescription(l, t, lspStates)
+ icon, description := iconAndDescription(t, info)
// Calculate diagnostic counts if we have LSP clients
var extraContent string
if lspClients != nil {
- if client, ok := lspClients.Get(l.Name); ok {
+ if client, ok := lspClients.Get(info.Name); ok {
counts := client.GetDiagnosticCounts()
errs := []string{}
if counts.Error > 0 {
@@ -83,7 +85,7 @@ func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOption
core.Status(
core.StatusOpts{
Icon: icon.String(),
- Title: l.Name,
+ Title: info.Name,
Description: description,
ExtraContent: extraContent,
},
@@ -95,12 +97,7 @@ func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOption
return lspList
}
-func iconAndDescription(l config.LSP, t *styles.Theme, states map[string]app.LSPClientInfo) (lipgloss.Style, string) {
- if l.LSP.Disabled {
- return t.ItemOfflineIcon.Foreground(t.FgMuted), t.S().Subtle.Render("disabled")
- }
-
- info := states[l.Name]
+func iconAndDescription(t *styles.Theme, info app.LSPClientInfo) (lipgloss.Style, string) {
switch info.State {
case lsp.StateStarting:
return t.ItemBusyIcon, t.S().Subtle.Render("starting...")
@@ -327,6 +327,9 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
u, cmd = p.chat.Update(msg)
p.chat = u.(chat.MessageListCmp)
cmds = append(cmds, cmd)
+ u, cmd = p.editor.Update(msg)
+ p.editor = u.(editor.Editor)
+ cmds = append(cmds, cmd)
return p, tea.Batch(cmds...)
case filepicker.FilePickedMsg,
completions.CompletionsClosedMsg,
@@ -0,0 +1,98 @@
+package chat
+
+import (
+ "encoding/json"
+ "strings"
+
+ "github.com/charmbracelet/crush/internal/message"
+ "github.com/charmbracelet/crush/internal/stringext"
+ "github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// GenericToolMessageItem is a message item that represents an unknown tool call.
+type GenericToolMessageItem struct {
+ *baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*GenericToolMessageItem)(nil)
+
+// NewGenericToolMessageItem creates a new [GenericToolMessageItem].
+func NewGenericToolMessageItem(
+ sty *styles.Styles,
+ toolCall message.ToolCall,
+ result *message.ToolResult,
+ canceled bool,
+) ToolMessageItem {
+ return newBaseToolMessageItem(sty, toolCall, result, &GenericToolRenderContext{}, canceled)
+}
+
+// GenericToolRenderContext renders unknown/generic tool messages.
+type GenericToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (g *GenericToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+ cappedWidth := cappedMessageWidth(width)
+ name := genericPrettyName(opts.ToolCall.Name)
+
+ if opts.IsPending() {
+ return pendingTool(sty, name, opts.Anim)
+ }
+
+ var params map[string]any
+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil {
+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
+ }
+
+ var toolParams []string
+ if len(params) > 0 {
+ parsed, _ := json.Marshal(params)
+ toolParams = append(toolParams, string(parsed))
+ }
+
+ header := toolHeader(sty, opts.Status, name, cappedWidth, opts.Compact, toolParams...)
+ if opts.Compact {
+ return header
+ }
+
+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+ return joinToolParts(header, earlyState)
+ }
+
+ if !opts.HasResult() || opts.Result.Content == "" {
+ return header
+ }
+
+ bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
+
+ // Handle image data.
+ if opts.Result.Data != "" && strings.HasPrefix(opts.Result.MIMEType, "image/") {
+ body := sty.Tool.Body.Render(toolOutputImageContent(sty, opts.Result.Data, opts.Result.MIMEType))
+ return joinToolParts(header, body)
+ }
+
+ // Try to parse result as JSON for pretty display.
+ var result json.RawMessage
+ var body string
+ if err := json.Unmarshal([]byte(opts.Result.Content), &result); err == nil {
+ prettyResult, err := json.MarshalIndent(result, "", " ")
+ if err == nil {
+ body = sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.json", string(prettyResult), 0, bodyWidth, opts.ExpandedContent))
+ } else {
+ body = sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
+ }
+ } else if looksLikeMarkdown(opts.Result.Content) {
+ body = sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.md", opts.Result.Content, 0, bodyWidth, opts.ExpandedContent))
+ } else {
+ body = sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
+ }
+
+ return joinToolParts(header, body)
+}
+
+// genericPrettyName converts a snake_case or kebab-case tool name to a
+// human-readable title case name.
+func genericPrettyName(name string) string {
+ name = strings.ReplaceAll(name, "_", " ")
+ name = strings.ReplaceAll(name, "-", " ")
+ return stringext.Capitalize(name)
+}
@@ -7,8 +7,8 @@ import (
"time"
tea "charm.land/bubbletea/v2"
+ "charm.land/catwalk/pkg/catwalk"
"charm.land/lipgloss/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/message"
"github.com/charmbracelet/crush/internal/ui/anim"
@@ -18,9 +18,9 @@ import (
"github.com/charmbracelet/crush/internal/ui/styles"
)
-// this is the total width that is taken up by the border + padding
-// we also cap the width so text is readable to the maxTextWidth(120)
-const messageLeftPaddingTotal = 2
+// MessageLeftPaddingTotal is the total width that is taken up by the border +
+// padding. We also cap the width so text is readable to the maxTextWidth(120).
+const MessageLeftPaddingTotal = 2
// maxTextWidth is the maximum width text messages can be
const maxTextWidth = 120
@@ -38,7 +38,9 @@ type Animatable interface {
// Expandable is an interface for items that can be expanded or collapsed.
type Expandable interface {
- ToggleExpanded()
+ // ToggleExpanded toggles the expanded state of the item. It returns
+ // whether the item is now expanded.
+ ToggleExpanded() bool
}
// KeyEventHandler is an interface for items that can handle key events.
@@ -100,7 +102,7 @@ func (h *highlightableMessageItem) renderHighlighted(content string, width, heig
func (h *highlightableMessageItem) SetHighlight(startLine int, startCol int, endLine int, endCol int) {
// Adjust columns for the style's left inset (border + padding) since we
// highlight the content only.
- offset := messageLeftPaddingTotal
+ offset := MessageLeftPaddingTotal
h.startLine = startLine
h.startCol = max(0, startCol-offset)
h.endLine = endLine
@@ -205,7 +207,7 @@ func (a *AssistantInfoItem) ID() string {
// RawRender implements MessageItem.
func (a *AssistantInfoItem) RawRender(width int) string {
- innerWidth := max(0, width-messageLeftPaddingTotal)
+ innerWidth := max(0, width-MessageLeftPaddingTotal)
content, _, ok := a.getCachedRender(innerWidth)
if !ok {
content = a.renderContent(innerWidth)
@@ -245,7 +247,7 @@ func (a *AssistantInfoItem) renderContent(width int) string {
// cappedMessageWidth returns the maximum width for message content for readability.
func cappedMessageWidth(availableWidth int) int {
- return min(availableWidth-messageLeftPaddingTotal, maxTextWidth)
+ return min(availableWidth-MessageLeftPaddingTotal, maxTextWidth)
}
// ExtractMessageItems extracts [MessageItem]s from a [message.Message]. It
@@ -15,6 +15,7 @@ import (
"github.com/charmbracelet/crush/internal/diff"
"github.com/charmbracelet/crush/internal/fsext"
"github.com/charmbracelet/crush/internal/message"
+ "github.com/charmbracelet/crush/internal/stringext"
"github.com/charmbracelet/crush/internal/ui/anim"
"github.com/charmbracelet/crush/internal/ui/common"
"github.com/charmbracelet/crush/internal/ui/styles"
@@ -156,6 +157,8 @@ type baseToolMessageItem struct {
expandedContent bool
}
+var _ Expandable = (*baseToolMessageItem)(nil)
+
// newBaseToolMessageItem is the internal constructor for base tool message items.
func newBaseToolMessageItem(
sty *styles.Styles,
@@ -255,14 +258,7 @@ func NewToolMessageItem(
if strings.HasPrefix(toolCall.Name, "mcp_") {
item = NewMCPToolMessageItem(sty, toolCall, result, canceled)
} else {
- // TODO: Implement other tool items
- item = newBaseToolMessageItem(
- sty,
- toolCall,
- result,
- &DefaultToolRenderContext{},
- canceled,
- )
+ item = NewGenericToolMessageItem(sty, toolCall, result, canceled)
}
}
item.SetMessageID(messageID)
@@ -298,7 +294,7 @@ func (t *baseToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
// RawRender implements [MessageItem].
func (t *baseToolMessageItem) RawRender(width int) string {
- toolItemWidth := width - messageLeftPaddingTotal
+ toolItemWidth := width - MessageLeftPaddingTotal
if t.hasCappedWidth {
toolItemWidth = cappedMessageWidth(width)
}
@@ -404,18 +400,15 @@ func (t *baseToolMessageItem) SetSpinningFunc(fn SpinningFunc) {
}
// ToggleExpanded toggles the expanded state of the thinking box.
-func (t *baseToolMessageItem) ToggleExpanded() {
+func (t *baseToolMessageItem) ToggleExpanded() bool {
t.expandedContent = !t.expandedContent
t.clearCache()
+ return t.expandedContent
}
// HandleMouseClick implements MouseClickable.
func (t *baseToolMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
- if btn != ansi.MouseLeft {
- return false
- }
- t.ToggleExpanded()
- return true
+ return btn == ansi.MouseLeft
}
// HandleKeyEvent implements KeyEventHandler.
@@ -538,9 +531,7 @@ func toolHeader(sty *styles.Styles, status ToolStatus, name string, width int, n
// toolOutputPlainContent renders plain text with optional expansion support.
func toolOutputPlainContent(sty *styles.Styles, content string, width int, expanded bool) string {
- content = strings.ReplaceAll(content, "\r\n", "\n")
- content = strings.ReplaceAll(content, "\t", " ")
- content = strings.TrimSpace(content)
+ content = stringext.NormalizeSpace(content)
lines := strings.Split(content, "\n")
maxLines := responseContextHeight
@@ -573,8 +564,7 @@ func toolOutputPlainContent(sty *styles.Styles, content string, width int, expan
// toolOutputCodeContent renders code with syntax highlighting and line numbers.
func toolOutputCodeContent(sty *styles.Styles, path, content string, offset, width int, expanded bool) string {
- content = strings.ReplaceAll(content, "\r\n", "\n")
- content = strings.ReplaceAll(content, "\t", " ")
+ content = stringext.NormalizeSpace(content)
lines := strings.Split(content, "\n")
maxLines := responseContextHeight
@@ -598,19 +588,17 @@ func toolOutputCodeContent(sty *styles.Styles, path, content string, offset, wid
numFmt := fmt.Sprintf("%%%dd", maxDigits)
bodyWidth := width - toolBodyLeftPaddingTotal
- codeWidth := bodyWidth - maxDigits - 4 // -4 for line number padding
+ codeWidth := bodyWidth - maxDigits
var out []string
for i, ln := range highlightedLines {
lineNum := sty.Tool.ContentLineNumber.Render(fmt.Sprintf(numFmt, i+1+offset))
- if lipgloss.Width(ln) > codeWidth {
- ln = ansi.Truncate(ln, codeWidth, "โฆ")
- }
+ // Truncate accounting for padding that will be added.
+ ln = ansi.Truncate(ln, codeWidth-sty.Tool.ContentCodeLine.GetHorizontalPadding(), "โฆ")
codeLine := sty.Tool.ContentCodeLine.
Width(codeWidth).
- PaddingLeft(2).
Render(ln)
out = append(out, lipgloss.JoinHorizontal(lipgloss.Left, lineNum, codeLine))
@@ -619,7 +607,7 @@ func toolOutputCodeContent(sty *styles.Styles, path, content string, offset, wid
// Add truncation message if needed.
if len(lines) > maxLines && !expanded {
out = append(out, sty.Tool.ContentCodeTruncation.
- Width(bodyWidth).
+ Width(width).
Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)),
)
}
@@ -699,7 +687,7 @@ func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent stri
truncMsg := sty.Tool.DiffTruncation.
Width(bodyWidth).
Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
- formatted = truncMsg + "\n" + strings.Join(lines[:maxLines], "\n")
+ formatted = strings.Join(lines[:maxLines], "\n") + "\n" + truncMsg
}
return sty.Tool.Body.Render(formatted)
@@ -783,9 +771,7 @@ func roundedEnumerator(lPadding, width int) tree.Enumerator {
// toolOutputMarkdownContent renders markdown content with optional truncation.
func toolOutputMarkdownContent(sty *styles.Styles, content string, width int, expanded bool) string {
- content = strings.ReplaceAll(content, "\r\n", "\n")
- content = strings.ReplaceAll(content, "\t", " ")
- content = strings.TrimSpace(content)
+ content = stringext.NormalizeSpace(content)
// Cap width for readability.
if width > maxTextWidth {
@@ -1130,7 +1116,7 @@ func (t *baseToolMessageItem) formatViewResultForCopy() string {
var result strings.Builder
if lang != "" {
- result.WriteString(fmt.Sprintf("```%s\n", lang))
+ fmt.Fprintf(&result, "```%s\n", lang)
} else {
result.WriteString("```\n")
}
@@ -1166,7 +1152,7 @@ func (t *baseToolMessageItem) formatEditResultForCopy() string {
}
diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
- result.WriteString(fmt.Sprintf("Changes: +%d -%d\n", additions, removals))
+ fmt.Fprintf(&result, "Changes: +%d -%d\n", additions, removals)
result.WriteString("```diff\n")
result.WriteString(diffContent)
result.WriteString("\n```")
@@ -1200,7 +1186,7 @@ func (t *baseToolMessageItem) formatMultiEditResultForCopy() string {
}
diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
- result.WriteString(fmt.Sprintf("Changes: +%d -%d\n", additions, removals))
+ fmt.Fprintf(&result, "Changes: +%d -%d\n", additions, removals)
result.WriteString("```diff\n")
result.WriteString(diffContent)
result.WriteString("\n```")
@@ -1258,9 +1244,9 @@ func (t *baseToolMessageItem) formatWriteResultForCopy() string {
}
var result strings.Builder
- result.WriteString(fmt.Sprintf("File: %s\n", fsext.PrettyPath(params.FilePath)))
+ fmt.Fprintf(&result, "File: %s\n", fsext.PrettyPath(params.FilePath))
if lang != "" {
- result.WriteString(fmt.Sprintf("```%s\n", lang))
+ fmt.Fprintf(&result, "```%s\n", lang)
} else {
result.WriteString("```\n")
}
@@ -1283,13 +1269,13 @@ func (t *baseToolMessageItem) formatFetchResultForCopy() string {
var result strings.Builder
if params.URL != "" {
- result.WriteString(fmt.Sprintf("URL: %s\n", params.URL))
+ fmt.Fprintf(&result, "URL: %s\n", params.URL)
}
if params.Format != "" {
- result.WriteString(fmt.Sprintf("Format: %s\n", params.Format))
+ fmt.Fprintf(&result, "Format: %s\n", params.Format)
}
if params.Timeout > 0 {
- result.WriteString(fmt.Sprintf("Timeout: %ds\n", params.Timeout))
+ fmt.Fprintf(&result, "Timeout: %ds\n", params.Timeout)
}
result.WriteString("\n")
@@ -1311,10 +1297,10 @@ func (t *baseToolMessageItem) formatAgenticFetchResultForCopy() string {
var result strings.Builder
if params.URL != "" {
- result.WriteString(fmt.Sprintf("URL: %s\n", params.URL))
+ fmt.Fprintf(&result, "URL: %s\n", params.URL)
}
if params.Prompt != "" {
- result.WriteString(fmt.Sprintf("Prompt: %s\n\n", params.Prompt))
+ fmt.Fprintf(&result, "Prompt: %s\n\n", params.Prompt)
}
result.WriteString("```markdown\n")
@@ -1399,6 +1385,6 @@ func prettifyToolName(name string) string {
case tools.WriteToolName:
return "Write"
default:
- return name
+ return genericPrettyName(name)
}
}
@@ -0,0 +1,133 @@
+package common
+
+import (
+ "slices"
+ "strings"
+
+ tea "charm.land/bubbletea/v2"
+ "github.com/charmbracelet/colorprofile"
+ uv "github.com/charmbracelet/ultraviolet"
+ "github.com/charmbracelet/x/ansi"
+ xstrings "github.com/charmbracelet/x/exp/strings"
+)
+
+// Capabilities define different terminal capabilities supported.
+type Capabilities struct {
+ // Profile is the terminal color profile used to determine how colors are
+ // rendered.
+ Profile colorprofile.Profile
+ // Columns is the number of character columns in the terminal.
+ Columns int
+ // Rows is the number of character rows in the terminal.
+ Rows int
+ // PixelX is the width of the terminal in pixels.
+ PixelX int
+ // PixelY is the height of the terminal in pixels.
+ PixelY int
+ // KittyGraphics indicates whether the terminal supports the Kitty graphics
+ // protocol.
+ KittyGraphics bool
+ // SixelGraphics indicates whether the terminal supports Sixel graphics.
+ SixelGraphics bool
+ // Env is the terminal environment variables.
+ Env uv.Environ
+ // TerminalVersion is the terminal version string.
+ TerminalVersion string
+ // ReportFocusEvents indicates whether the terminal supports focus events.
+ ReportFocusEvents bool
+}
+
+// Update updates the capabilities based on the given message.
+func (c *Capabilities) Update(msg any) {
+ switch m := msg.(type) {
+ case tea.EnvMsg:
+ c.Env = uv.Environ(m)
+ case tea.ColorProfileMsg:
+ c.Profile = m.Profile
+ case tea.WindowSizeMsg:
+ c.Columns = m.Width
+ c.Rows = m.Height
+ case uv.WindowPixelSizeEvent:
+ c.PixelX = m.Width
+ c.PixelY = m.Height
+ case uv.KittyGraphicsEvent:
+ c.KittyGraphics = true
+ case uv.PrimaryDeviceAttributesEvent:
+ if slices.Contains(m, 4) {
+ c.SixelGraphics = true
+ }
+ case tea.TerminalVersionMsg:
+ c.TerminalVersion = m.Name
+ case uv.ModeReportEvent:
+ switch m.Mode {
+ case ansi.ModeFocusEvent:
+ c.ReportFocusEvents = modeSupported(m.Value)
+ }
+ }
+}
+
+// QueryCmd returns a [tea.Cmd] that queries the terminal for different
+// capabilities.
+func QueryCmd(env uv.Environ) tea.Cmd {
+ var sb strings.Builder
+ sb.WriteString(ansi.RequestPrimaryDeviceAttributes)
+
+ // Queries that should only be sent to "smart" normal terminals.
+ shouldQueryFor := shouldQueryCapabilities(env)
+ if shouldQueryFor {
+ sb.WriteString(ansi.RequestNameVersion)
+ // sb.WriteString(ansi.RequestModeFocusEvent) // TODO: re-enable when we need notifications.
+ sb.WriteString(ansi.WindowOp(14)) // Window size in pixels
+ kittyReq := ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24")
+ if _, isTmux := env.LookupEnv("TMUX"); isTmux {
+ kittyReq = ansi.TmuxPassthrough(kittyReq)
+ }
+ sb.WriteString(kittyReq)
+ }
+
+ return tea.Raw(sb.String())
+}
+
+// SupportsTrueColor returns true if the terminal supports true color.
+func (c Capabilities) SupportsTrueColor() bool {
+ return c.Profile == colorprofile.TrueColor
+}
+
+// SupportsKittyGraphics returns true if the terminal supports Kitty graphics.
+func (c Capabilities) SupportsKittyGraphics() bool {
+ return c.KittyGraphics
+}
+
+// SupportsSixelGraphics returns true if the terminal supports Sixel graphics.
+func (c Capabilities) SupportsSixelGraphics() bool {
+ return c.SixelGraphics
+}
+
+// CellSize returns the size of a single terminal cell in pixels.
+func (c Capabilities) CellSize() (width, height int) {
+ if c.Columns == 0 || c.Rows == 0 {
+ return 0, 0
+ }
+ return c.PixelX / c.Columns, c.PixelY / c.Rows
+}
+
+func modeSupported(v ansi.ModeSetting) bool {
+ return v.IsSet() || v.IsReset()
+}
+
+// kittyTerminals defines terminals supporting querying capabilities.
+var kittyTerminals = []string{"alacritty", "ghostty", "kitty", "rio", "wezterm"}
+
+func shouldQueryCapabilities(env uv.Environ) bool {
+ const osVendorTypeApple = "Apple"
+ termType := env.Getenv("TERM")
+ termProg, okTermProg := env.LookupEnv("TERM_PROGRAM")
+ _, okSSHTTY := env.LookupEnv("SSH_TTY")
+ if okTermProg && strings.Contains(termProg, osVendorTypeApple) {
+ return false
+ }
+ return (!okTermProg && !okSSHTTY) ||
+ (!strings.Contains(termProg, osVendorTypeApple) && !okSSHTTY) ||
+ // Terminals that do support XTVERSION.
+ xstrings.ContainsAnyOf(termType, kittyTerminals...)
+}
@@ -7,7 +7,7 @@ import (
"path/filepath"
tea "charm.land/bubbletea/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
+ "charm.land/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/commands"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/message"
@@ -10,7 +10,7 @@ import (
"charm.land/bubbles/v2/spinner"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
+ "charm.land/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/ui/common"
"github.com/charmbracelet/crush/internal/ui/styles"
@@ -9,8 +9,6 @@ import (
"charm.land/bubbles/v2/spinner"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
- "github.com/charmbracelet/crush/internal/agent/hyper"
"github.com/charmbracelet/crush/internal/commands"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/ui/common"
@@ -29,8 +27,9 @@ type CommandType uint
func (c CommandType) String() string { return []string{"System", "User", "MCP"}[c] }
const (
- sidebarCompactModeBreakpoint = 120
- defaultCommandsDialogMaxWidth = 70
+ sidebarCompactModeBreakpoint = 120
+ defaultCommandsDialogMaxHeight = 20
+ defaultCommandsDialogMaxWidth = 70
)
const (
@@ -241,8 +240,8 @@ func commandsRadioView(sty *styles.Styles, selected CommandType, hasUserCmds boo
// Draw implements [Dialog].
func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
t := c.com.Styles
- width := max(0, min(defaultCommandsDialogMaxWidth, area.Dx()))
- height := max(0, min(defaultDialogHeight, area.Dy()))
+ width := max(0, min(defaultCommandsDialogMaxWidth, area.Dx()-t.Dialog.View.GetHorizontalBorderSize()))
+ height := max(0, min(defaultCommandsDialogMaxHeight, area.Dy()-t.Dialog.View.GetVerticalBorderSize()))
if area.Dx() != c.windowWidth && c.selected == SystemCommands {
c.windowWidth = area.Dx()
// since some items in the list depend on width (e.g. toggle sidebar command),
@@ -405,7 +404,7 @@ func (c *Commands) defaultCommands() []*CommandItem {
selectedModel := cfg.Models[agentCfg.Model]
// Anthropic models: thinking toggle
- if providerCfg.Type == catwalk.TypeAnthropic || providerCfg.Type == catwalk.Type(hyper.Name) {
+ if model.CanReason && len(model.ReasoningLevels) == 0 {
status := "Enable"
if selectedModel.Think {
status = "Disable"
@@ -422,7 +421,7 @@ func (c *Commands) defaultCommands() []*CommandItem {
}
}
// Only show toggle compact mode command if window width is larger than compact breakpoint (120)
- if c.windowWidth > sidebarCompactModeBreakpoint && c.sessionID != "" {
+ if c.windowWidth >= sidebarCompactModeBreakpoint && c.sessionID != "" {
commands = append(commands, NewCommandItem(c.com.Styles, "toggle_sidebar", "Toggle Sidebar", "", ActionToggleCompactMode{}))
}
if c.sessionID != "" {
@@ -66,11 +66,11 @@ func (c *CommandItem) Shortcut() string {
// Render implements ListItem.
func (c *CommandItem) Render(width int) string {
- styles := ListIemStyles{
+ styles := ListItemStyles{
ItemBlurred: c.t.Dialog.NormalItem,
ItemFocused: c.t.Dialog.SelectedItem,
InfoTextBlurred: c.t.Base,
- InfoTextFocused: c.t.Subtle,
+ InfoTextFocused: c.t.Base,
}
return renderItem(styles, c.title, c.shortcut, c.focused, width, c.cache, &c.m)
}
@@ -29,7 +29,7 @@ type FilePicker struct {
imgEnc fimage.Encoding
imgPrevWidth, imgPrevHeight int
- cellSize fimage.CellSize
+ cellSizeW, cellSizeH int
fp filepicker.Model
help help.Model
@@ -47,6 +47,14 @@ type FilePicker struct {
}
}
+// CellSize returns the cell size used for image rendering.
+func (f *FilePicker) CellSize() fimage.CellSize {
+ return fimage.CellSize{
+ Width: f.cellSizeW,
+ Height: f.cellSizeH,
+ }
+}
+
var _ Dialog = (*FilePicker)(nil)
// NewFilePicker creates a new [FilePicker] dialog.
@@ -103,12 +111,12 @@ func NewFilePicker(com *common.Common) (*FilePicker, tea.Cmd) {
}
// SetImageCapabilities sets the image capabilities for the [FilePicker].
-func (f *FilePicker) SetImageCapabilities(caps *fimage.Capabilities) {
+func (f *FilePicker) SetImageCapabilities(caps *common.Capabilities) {
if caps != nil {
- if caps.SupportsKittyGraphics {
+ if caps.SupportsKittyGraphics() {
f.imgEnc = fimage.EncodingKitty
}
- f.cellSize = caps.CellSize()
+ f.cellSizeW, f.cellSizeH = caps.CellSize()
_, f.isTmux = caps.Env.LookupEnv("TMUX")
}
}
@@ -186,7 +194,7 @@ func (f *FilePicker) HandleMsg(msg tea.Msg) Action {
img, err := loadImage(selFile)
if err == nil {
cmds = append(cmds, tea.Sequence(
- f.imgEnc.Transmit(selFile, img, f.cellSize, f.imgPrevWidth, f.imgPrevHeight, f.isTmux),
+ f.imgEnc.Transmit(selFile, img, f.CellSize(), f.imgPrevWidth, f.imgPrevHeight, f.isTmux),
func() tea.Msg {
f.previewingImage = true
return nil
@@ -10,7 +10,7 @@ import (
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
+ "charm.land/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/ui/common"
"github.com/charmbracelet/crush/internal/uiutil"
@@ -251,8 +251,8 @@ func (m *Models) modelTypeRadioView() string {
// Draw implements [Dialog].
func (m *Models) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
t := m.com.Styles
- width := max(0, min(defaultModelsDialogMaxWidth, area.Dx()))
- height := max(0, min(defaultDialogHeight, area.Dy()))
+ width := max(0, min(defaultModelsDialogMaxWidth, area.Dx()-t.Dialog.View.GetHorizontalBorderSize()))
+ height := max(0, min(defaultDialogHeight, area.Dy()-t.Dialog.View.GetVerticalBorderSize()))
innerWidth := width - t.Dialog.View.GetHorizontalFrameSize()
heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
@@ -1,8 +1,8 @@
package dialog
import (
+ "charm.land/catwalk/pkg/catwalk"
"charm.land/lipgloss/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/ui/common"
"github.com/charmbracelet/crush/internal/ui/styles"
@@ -106,11 +106,11 @@ func (m *ModelItem) Render(width int) string {
if m.showProvider {
providerInfo = string(m.prov.Name)
}
- styles := ListIemStyles{
+ styles := ListItemStyles{
ItemBlurred: m.t.Dialog.NormalItem,
ItemFocused: m.t.Dialog.SelectedItem,
InfoTextBlurred: m.t.Base,
- InfoTextFocused: m.t.Subtle,
+ InfoTextFocused: m.t.Base,
}
return renderItem(styles, m.model.Name, providerInfo, m.focused, width, m.cache, &m.m)
}
@@ -9,8 +9,8 @@ import (
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/spinner"
tea "charm.land/bubbletea/v2"
+ "charm.land/catwalk/pkg/catwalk"
"charm.land/lipgloss/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/oauth"
"github.com/charmbracelet/crush/internal/ui/common"
@@ -6,7 +6,7 @@ import (
"time"
tea "charm.land/bubbletea/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
+ "charm.land/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/oauth/copilot"
"github.com/charmbracelet/crush/internal/ui/common"
@@ -6,7 +6,7 @@ import (
"time"
tea "charm.land/bubbletea/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
+ "charm.land/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/oauth/hyper"
"github.com/charmbracelet/crush/internal/ui/common"
@@ -48,7 +48,7 @@ const (
// layoutSpacingLines is the number of empty lines used for layout spacing.
layoutSpacingLines = 4
// minWindowWidth is the minimum window width before forcing fullscreen.
- minWindowWidth = 60
+ minWindowWidth = 77
// minWindowHeight is the minimum window height before forcing fullscreen.
minWindowHeight = 20
)
@@ -392,6 +392,7 @@ func (p *Permissions) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
} else {
availableHeight = maxHeight - fixedHeight
}
+ availableHeight = max(availableHeight, 3)
} else {
availableHeight = maxHeight - headerHeight - buttonsHeight - helpHeight - frameHeight
}
@@ -293,11 +293,11 @@ func (r *ReasoningItem) Render(width int) string {
if r.isCurrent {
info = "current"
}
- styles := ListIemStyles{
+ styles := ListItemStyles{
ItemBlurred: r.t.Dialog.NormalItem,
ItemFocused: r.t.Dialog.SelectedItem,
InfoTextBlurred: r.t.Base,
- InfoTextFocused: r.t.Subtle,
+ InfoTextFocused: r.t.Base,
}
return renderItem(styles, r.title, info, r.focused, width, r.cache, &r.m)
}
@@ -261,11 +261,11 @@ func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
rc.ViewStyle = t.Dialog.Sessions.DeletingView
rc.AddPart(t.Dialog.Sessions.DeletingMessage.Render("Delete this session?"))
case sessionsModeUpdating:
- rc.TitleStyle = t.Dialog.Sessions.UpdatingTitle
- rc.TitleGradientFromColor = t.Dialog.Sessions.UpdatingTitleGradientFromColor
- rc.TitleGradientToColor = t.Dialog.Sessions.UpdatingTitleGradientToColor
- rc.ViewStyle = t.Dialog.Sessions.UpdatingView
- message := t.Dialog.Sessions.UpdatingMessage.Render("Rename this session?")
+ rc.TitleStyle = t.Dialog.Sessions.RenamingingTitle
+ rc.TitleGradientFromColor = t.Dialog.Sessions.RenamingTitleGradientFromColor
+ rc.TitleGradientToColor = t.Dialog.Sessions.RenamingTitleGradientToColor
+ rc.ViewStyle = t.Dialog.Sessions.RenamingView
+ message := t.Dialog.Sessions.RenamingingMessage.Render("Rename this session?")
rc.AddPart(message)
item := s.selectedSessionItem()
if item == nil {
@@ -279,8 +279,8 @@ func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
start, end := s.list.VisibleItemIndices()
selectedIndex := s.list.Selected()
- titleStyle := t.Dialog.Sessions.UpdatingTitle
- dialogStyle := t.Dialog.Sessions.UpdatingView
+ titleStyle := t.Dialog.Sessions.RenamingingTitle
+ dialogStyle := t.Dialog.Sessions.RenamingView
inputStyle := t.Dialog.InputPrompt
// Adjust cursor position to account for dialog layout + message
@@ -76,7 +76,7 @@ func (s *SessionItem) Cursor() *tea.Cursor {
// Render returns the string representation of the session item.
func (s *SessionItem) Render(width int) string {
info := humanize.Time(time.Unix(s.UpdatedAt, 0))
- styles := ListIemStyles{
+ styles := ListItemStyles{
ItemBlurred: s.t.Dialog.NormalItem,
ItemFocused: s.t.Dialog.SelectedItem,
InfoTextBlurred: s.t.Subtle,
@@ -88,8 +88,8 @@ func (s *SessionItem) Render(width int) string {
styles.ItemBlurred = s.t.Dialog.Sessions.DeletingItemBlurred
styles.ItemFocused = s.t.Dialog.Sessions.DeletingItemFocused
case sessionsModeUpdating:
- styles.ItemBlurred = s.t.Dialog.Sessions.UpdatingItemBlurred
- styles.ItemFocused = s.t.Dialog.Sessions.UpdatingItemFocused
+ styles.ItemBlurred = s.t.Dialog.Sessions.RenamingItemBlurred
+ styles.ItemFocused = s.t.Dialog.Sessions.RenamingingItemFocused
if s.focused {
inputWidth := width - styles.InfoTextFocused.GetHorizontalFrameSize()
s.updateTitleInput.SetWidth(inputWidth)
@@ -101,14 +101,14 @@ func (s *SessionItem) Render(width int) string {
return renderItem(styles, s.Title, info, s.focused, width, s.cache, &s.m)
}
-type ListIemStyles struct {
+type ListItemStyles struct {
ItemBlurred lipgloss.Style
ItemFocused lipgloss.Style
InfoTextBlurred lipgloss.Style
InfoTextFocused lipgloss.Style
}
-func renderItem(t ListIemStyles, title string, info string, focused bool, width int, cache map[int]string, m *fuzzy.Match) string {
+func renderItem(t ListItemStyles, title string, info string, focused bool, width int, cache map[int]string, m *fuzzy.Match) string {
if cache == nil {
cache = make(map[int]string)
}
@@ -141,14 +141,14 @@ func renderItem(t ListIemStyles, title string, info string, focused bool, width
titleWidth := lipgloss.Width(title)
gap := strings.Repeat(" ", max(0, lineWidth-titleWidth-infoWidth))
content := title
- if matches := len(m.MatchedIndexes); matches > 0 {
+ if m != nil && len(m.MatchedIndexes) > 0 {
var lastPos int
parts := make([]string, 0)
ranges := matchedRanges(m.MatchedIndexes)
for _, rng := range ranges {
start, stop := bytePosToVisibleCharPos(title, rng)
if start > lastPos {
- parts = append(parts, title[lastPos:start])
+ parts = append(parts, ansi.Cut(title, lastPos, start))
}
// NOTE: We're using [ansi.Style] here instead of [lipglosStyle]
// because we can control the underline start and stop more
@@ -157,13 +157,13 @@ func renderItem(t ListIemStyles, title string, info string, focused bool, width
// with other style
parts = append(parts,
ansi.NewStyle().Underline(true).String(),
- title[start:stop+1],
+ ansi.Cut(title, start, stop+1),
ansi.NewStyle().Underline(false).String(),
)
lastPos = stop + 1
}
- if lastPos < len(title) {
- parts = append(parts, title[lastPos:])
+ if lastPos < ansi.StringWidth(title) {
+ parts = append(parts, ansi.Cut(title, lastPos, ansi.StringWidth(title)))
}
content = strings.Join(parts, "")
@@ -193,7 +193,7 @@ func sessionItems(t *styles.Styles, mode sessionsMode, sessions ...session.Sessi
item.updateTitleInput.SetVirtualCursor(false)
item.updateTitleInput.Prompt = ""
inputStyle := t.TextInput
- inputStyle.Focused.Placeholder = inputStyle.Focused.Placeholder.Foreground(t.FgHalfMuted)
+ inputStyle.Focused.Placeholder = t.Dialog.Sessions.RenamingPlaceholder
item.updateTitleInput.SetStyles(inputStyle)
item.updateTitleInput.Focus()
}
@@ -13,62 +13,12 @@ import (
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/crush/internal/uiutil"
- uv "github.com/charmbracelet/ultraviolet"
"github.com/charmbracelet/x/ansi"
"github.com/charmbracelet/x/ansi/kitty"
"github.com/disintegration/imaging"
paintbrush "github.com/jordanella/go-ansi-paintbrush"
)
-// Capabilities represents the capabilities of displaying images on the
-// terminal.
-type Capabilities struct {
- // Columns is the number of character columns in the terminal.
- Columns int
- // Rows is the number of character rows in the terminal.
- Rows int
- // PixelWidth is the width of the terminal in pixels.
- PixelWidth int
- // PixelHeight is the height of the terminal in pixels.
- PixelHeight int
- // SupportsKittyGraphics indicates whether the terminal supports the Kitty
- // graphics protocol.
- SupportsKittyGraphics bool
- // Env is the terminal environment variables.
- Env uv.Environ
-}
-
-// CellSize returns the size of a single terminal cell in pixels.
-func (c Capabilities) CellSize() CellSize {
- return CalculateCellSize(c.PixelWidth, c.PixelHeight, c.Columns, c.Rows)
-}
-
-// CalculateCellSize calculates the size of a single terminal cell in pixels
-// based on the terminal's pixel dimensions and character dimensions.
-func CalculateCellSize(pixelWidth, pixelHeight, charWidth, charHeight int) CellSize {
- if charWidth == 0 || charHeight == 0 {
- return CellSize{}
- }
-
- return CellSize{
- Width: pixelWidth / charWidth,
- Height: pixelHeight / charHeight,
- }
-}
-
-// RequestCapabilities is a [tea.Cmd] that requests the terminal to report
-// its image related capabilities to the program.
-func RequestCapabilities(env uv.Environ) tea.Cmd {
- winOpReq := ansi.WindowOp(14) // Window size in pixels
- // ID 31 is just a random ID used to detect Kitty graphics support.
- kittyReq := ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24")
- if _, isTmux := env.LookupEnv("TMUX"); isTmux {
- kittyReq = ansi.TmuxPassthrough(kittyReq)
- }
-
- return tea.Raw(winOpReq + kittyReq)
-}
-
// TransmittedMsg is a message indicating that an image has been transmitted to
// the terminal.
type TransmittedMsg struct {
@@ -218,7 +168,7 @@ func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows i
return chunk
},
}); err != nil {
- slog.Error("failed to encode image for kitty graphics", "err", err)
+ slog.Error("Failed to encode image for kitty graphics", "err", err)
return uiutil.InfoMsg{
Type: uiutil.InfoTypeError,
Msg: "failed to encode image",
@@ -5,6 +5,7 @@ import (
"strings"
"charm.land/lipgloss/v2"
+ "github.com/charmbracelet/crush/internal/stringext"
uv "github.com/charmbracelet/ultraviolet"
)
@@ -53,6 +54,8 @@ func Highlight(content string, area image.Rectangle, startLine, startCol, endLin
// HighlightBuffer highlights a region of text within the given content and
// region, returning a [uv.ScreenBuffer].
func HighlightBuffer(content string, area image.Rectangle, startLine, startCol, endLine, endCol int, highlighter Highlighter) *uv.ScreenBuffer {
+ content = stringext.NormalizeSpace(content)
+
if startLine < 0 || startCol < 0 {
return nil
}
@@ -75,30 +75,26 @@ func (l *List) Gap() int {
return l.gap
}
-// AtBottom returns whether the list is scrolled to the bottom.
+// AtBottom returns whether the list is showing the last item at the bottom.
func (l *List) AtBottom() bool {
+ const margin = 2
+
if len(l.items) == 0 {
return true
}
- // Calculate total height of all items from the bottom.
+ // Calculate the height from offsetIdx to the end.
var totalHeight int
- for i := len(l.items) - 1; i >= 0; i-- {
- item := l.getItem(i)
- totalHeight += item.height
- if l.gap > 0 && i < len(l.items)-1 {
- totalHeight += l.gap
- }
- if totalHeight >= l.height {
- // This is the expected bottom position.
- expectedIdx := i
- expectedLine := totalHeight - l.height
- return l.offsetIdx == expectedIdx && l.offsetLine >= expectedLine
+ for idx := l.offsetIdx; idx < len(l.items); idx++ {
+ item := l.getItem(idx)
+ itemHeight := item.height
+ if l.gap > 0 && idx > l.offsetIdx {
+ itemHeight += l.gap
}
+ totalHeight += itemHeight
}
- // All items fit in viewport - we're at bottom if at top.
- return l.offsetIdx == 0 && l.offsetLine == 0
+ return totalHeight-l.offsetLine-margin <= l.height
}
// SetReverse shows the list in reverse order.
@@ -121,6 +117,30 @@ func (l *List) Len() int {
return len(l.items)
}
+// lastOffsetItem returns the index and line offsets of the last item that can
+// be partially visible in the viewport.
+func (l *List) lastOffsetItem() (int, int, int) {
+ var totalHeight int
+ var idx int
+ for idx = len(l.items) - 1; idx >= 0; idx-- {
+ item := l.getItem(idx)
+ itemHeight := item.height
+ if l.gap > 0 && idx < len(l.items)-1 {
+ itemHeight += l.gap
+ }
+ totalHeight += itemHeight
+ if totalHeight > l.height {
+ break
+ }
+ }
+
+ // Calculate line offset within the item
+ lineOffset := max(totalHeight-l.height, 0)
+ idx = max(idx, 0)
+
+ return idx, lineOffset, totalHeight
+}
+
// getItem renders (if needed) and returns the item at the given index.
func (l *List) getItem(idx int) renderedItem {
if idx < 0 || idx >= len(l.items) {
@@ -171,44 +191,29 @@ func (l *List) ScrollBy(lines int) {
if lines > 0 {
// Scroll down
- // Calculate from the bottom how many lines needed to anchor the last
- // item to the bottom
- var totalLines int
- var lastItemIdx int // the last item that can be partially visible
- for i := len(l.items) - 1; i >= 0; i-- {
- item := l.getItem(i)
- totalLines += item.height
- if l.gap > 0 && i < len(l.items)-1 {
- totalLines += l.gap
- }
- if totalLines > l.height-1 {
- lastItemIdx = i
- break
- }
- }
-
- // Now scroll down by lines
- var item renderedItem
l.offsetLine += lines
- for {
- item = l.getItem(l.offsetIdx)
- totalHeight := item.height
+ currentItem := l.getItem(l.offsetIdx)
+ for l.offsetLine >= currentItem.height {
+ l.offsetLine -= currentItem.height
if l.gap > 0 {
- totalHeight += l.gap
- }
-
- if l.offsetIdx >= lastItemIdx || l.offsetLine < totalHeight {
- // Valid offset
- break
+ l.offsetLine -= l.gap
}
// Move to next item
- l.offsetLine -= totalHeight
l.offsetIdx++
+ if l.offsetIdx > len(l.items)-1 {
+ // Reached bottom
+ l.ScrollToBottom()
+ return
+ }
+ currentItem = l.getItem(l.offsetIdx)
}
- if l.offsetLine >= item.height {
- l.offsetLine = item.height
+ lastOffsetIdx, lastOffsetLine, _ := l.lastOffsetItem()
+ if l.offsetIdx > lastOffsetIdx || (l.offsetIdx == lastOffsetIdx && l.offsetLine > lastOffsetLine) {
+ // Clamp to bottom
+ l.offsetIdx = lastOffsetIdx
+ l.offsetLine = lastOffsetLine
}
} else if lines < 0 {
// Scroll up
@@ -408,24 +413,9 @@ func (l *List) ScrollToBottom() {
return
}
- // Scroll to the last item
- var totalHeight int
- for i := len(l.items) - 1; i >= 0; i-- {
- item := l.getItem(i)
- totalHeight += item.height
- if l.gap > 0 && i < len(l.items)-1 {
- totalHeight += l.gap
- }
- if totalHeight >= l.height {
- l.offsetIdx = i
- l.offsetLine = totalHeight - l.height
- break
- }
- }
- if totalHeight < l.height {
- // All items fit in the viewport
- l.ScrollToTop()
- }
+ lastOffsetIdx, lastOffsetLine, _ := l.lastOffsetItem()
+ l.offsetIdx = lastOffsetIdx
+ l.offsetLine = lastOffsetLine
}
// ScrollToSelected scrolls the list to the selected item.
@@ -2,6 +2,7 @@ package model
import (
"strings"
+ "time"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
@@ -11,8 +12,24 @@ import (
"github.com/charmbracelet/crush/internal/ui/list"
uv "github.com/charmbracelet/ultraviolet"
"github.com/charmbracelet/x/ansi"
+ "github.com/clipperhouse/displaywidth"
+ "github.com/clipperhouse/uax29/v2/words"
)
+// Constants for multi-click detection.
+const (
+ doubleClickThreshold = 400 * time.Millisecond // 0.4s is typical double-click threshold
+ clickTolerance = 2 // x,y tolerance for double/tripple click
+)
+
+// DelayedClickMsg is sent after the double-click threshold to trigger a
+// single-click action (like expansion) if no double-click occurred.
+type DelayedClickMsg struct {
+ ClickID int
+ ItemIdx int
+ X, Y int
+}
+
// Chat represents the chat UI model that handles chat interactions and
// messages.
type Chat struct {
@@ -33,6 +50,15 @@ type Chat struct {
mouseDragItem int // Current item index being dragged over
mouseDragX int // Current X in item content
mouseDragY int // Current Y in item
+
+ // Click tracking for double/triple clicks
+ lastClickTime time.Time
+ lastClickX int
+ lastClickY int
+ clickCount int
+
+ // Pending single click action (delayed to detect double-click)
+ pendingClickID int // Incremented on each click to invalidate old pending clicks
}
// NewChat creates a new instance of [Chat] that handles chat interactions and
@@ -66,6 +92,10 @@ func (m *Chat) Draw(scr uv.Screen, area uv.Rectangle) {
// SetSize sets the size of the chat view port.
func (m *Chat) SetSize(width, height int) {
m.list.SetSize(width, height)
+ // Anchor to bottom if we were at the bottom.
+ if m.list.AtBottom() {
+ m.list.ScrollToBottom()
+ }
}
// Len returns the number of items in the chat list.
@@ -408,6 +438,7 @@ func (m *Chat) MessageItem(id string) chat.MessageItem {
func (m *Chat) ToggleExpandedSelectedItem() {
if expandable, ok := m.list.SelectedItem().(chat.Expandable); ok {
expandable.ToggleExpanded()
+ m.list.ScrollToIndex(m.list.Selected())
}
}
@@ -422,35 +453,104 @@ func (m *Chat) HandleKeyMsg(key tea.KeyMsg) (bool, tea.Cmd) {
}
// HandleMouseDown handles mouse down events for the chat component.
-func (m *Chat) HandleMouseDown(x, y int) bool {
+// It detects single, double, and triple clicks for text selection.
+// Returns whether the click was handled and an optional command for delayed
+// single-click actions.
+func (m *Chat) HandleMouseDown(x, y int) (bool, tea.Cmd) {
if m.list.Len() == 0 {
- return false
+ return false, nil
}
itemIdx, itemY := m.list.ItemIndexAtPosition(x, y)
if itemIdx < 0 {
- return false
+ return false, nil
}
if !m.isSelectable(itemIdx) {
- return false
+ return false, nil
}
- m.mouseDown = true
- m.mouseDownItem = itemIdx
- m.mouseDownX = x
- m.mouseDownY = itemY
- m.mouseDragItem = itemIdx
- m.mouseDragX = x
- m.mouseDragY = itemY
+ // Increment pending click ID to invalidate any previous pending clicks.
+ m.pendingClickID++
+ clickID := m.pendingClickID
+
+ // Detect multi-click (double/triple)
+ now := time.Now()
+ if now.Sub(m.lastClickTime) <= doubleClickThreshold &&
+ abs(x-m.lastClickX) <= clickTolerance &&
+ abs(y-m.lastClickY) <= clickTolerance {
+ m.clickCount++
+ } else {
+ m.clickCount = 1
+ }
+ m.lastClickTime = now
+ m.lastClickX = x
+ m.lastClickY = y
// Select the item that was clicked
m.list.SetSelected(itemIdx)
- if clickable, ok := m.list.SelectedItem().(list.MouseClickable); ok {
- return clickable.HandleMouseClick(ansi.MouseButton1, x, itemY)
+ var cmd tea.Cmd
+
+ switch m.clickCount {
+ case 1:
+ // Single click - start selection and schedule delayed click action.
+ m.mouseDown = true
+ m.mouseDownItem = itemIdx
+ m.mouseDownX = x
+ m.mouseDownY = itemY
+ m.mouseDragItem = itemIdx
+ m.mouseDragX = x
+ m.mouseDragY = itemY
+
+ // Schedule delayed click action (e.g., expansion) after a short delay.
+ // If a double-click occurs, the clickID will be invalidated.
+ cmd = tea.Tick(doubleClickThreshold, func(t time.Time) tea.Msg {
+ return DelayedClickMsg{
+ ClickID: clickID,
+ ItemIdx: itemIdx,
+ X: x,
+ Y: itemY,
+ }
+ })
+ case 2:
+ // Double click - select word (no delayed action)
+ m.selectWord(itemIdx, x, itemY)
+ case 3:
+ // Triple click - select line (no delayed action)
+ m.selectLine(itemIdx, itemY)
+ m.clickCount = 0 // Reset after triple click
}
- return true
+ return true, cmd
+}
+
+// HandleDelayedClick handles a delayed single-click action (like expansion).
+// It only executes if the click ID matches (i.e., no double-click occurred)
+// and no text selection was made (drag to select).
+func (m *Chat) HandleDelayedClick(msg DelayedClickMsg) bool {
+ // Ignore if this click was superseded by a newer click (double/triple).
+ if msg.ClickID != m.pendingClickID {
+ return false
+ }
+
+ // Don't expand if user dragged to select text.
+ if m.HasHighlight() {
+ return false
+ }
+
+ // Execute the click action (e.g., expansion).
+ selectedItem := m.list.SelectedItem()
+ if clickable, ok := selectedItem.(list.MouseClickable); ok {
+ handled := clickable.HandleMouseClick(ansi.MouseButton1, msg.X, msg.Y)
+ // Toggle expansion if applicable.
+ if expandable, ok := selectedItem.(chat.Expandable); ok {
+ expandable.ToggleExpanded()
+ }
+ m.list.ScrollToIndex(m.list.Selected())
+ return handled
+ }
+
+ return false
}
// HandleMouseUp handles mouse up events for the chat component.
@@ -531,6 +631,11 @@ func (m *Chat) ClearMouse() {
m.mouseDown = false
m.mouseDownItem = -1
m.mouseDragItem = -1
+ m.lastClickTime = time.Time{}
+ m.lastClickX = 0
+ m.lastClickY = 0
+ m.clickCount = 0
+ m.pendingClickID++ // Invalidate any pending delayed click
}
// applyHighlightRange applies the current highlight range to the chat items.
@@ -608,3 +713,144 @@ func (m *Chat) getHighlightRange() (startItemIdx, startLine, startCol, endItemId
return startItemIdx, startLine, startCol, endItemIdx, endLine, endCol
}
+
+// selectWord selects the word at the given position within an item.
+func (m *Chat) selectWord(itemIdx, x, itemY int) {
+ item := m.list.ItemAt(itemIdx)
+ if item == nil {
+ return
+ }
+
+ // Get the rendered content for this item
+ var rendered string
+ if rr, ok := item.(list.RawRenderable); ok {
+ rendered = rr.RawRender(m.list.Width())
+ } else {
+ rendered = item.Render(m.list.Width())
+ }
+
+ lines := strings.Split(rendered, "\n")
+ if itemY < 0 || itemY >= len(lines) {
+ return
+ }
+
+ // Adjust x for the item's left padding (border + padding) to get content column.
+ // The mouse x is in viewport space, but we need content space for boundary detection.
+ offset := chat.MessageLeftPaddingTotal
+ contentX := x - offset
+ if contentX < 0 {
+ contentX = 0
+ }
+
+ line := ansi.Strip(lines[itemY])
+ startCol, endCol := findWordBoundaries(line, contentX)
+ if startCol == endCol {
+ // No word found at position, fallback to single click behavior
+ m.mouseDown = true
+ m.mouseDownItem = itemIdx
+ m.mouseDownX = x
+ m.mouseDownY = itemY
+ m.mouseDragItem = itemIdx
+ m.mouseDragX = x
+ m.mouseDragY = itemY
+ return
+ }
+
+ // Set selection to the word boundaries (convert back to viewport space).
+ // Keep mouseDown true so HandleMouseUp triggers the copy.
+ m.mouseDown = true
+ m.mouseDownItem = itemIdx
+ m.mouseDownX = startCol + offset
+ m.mouseDownY = itemY
+ m.mouseDragItem = itemIdx
+ m.mouseDragX = endCol + offset
+ m.mouseDragY = itemY
+}
+
+// selectLine selects the entire line at the given position within an item.
+func (m *Chat) selectLine(itemIdx, itemY int) {
+ item := m.list.ItemAt(itemIdx)
+ if item == nil {
+ return
+ }
+
+ // Get the rendered content for this item
+ var rendered string
+ if rr, ok := item.(list.RawRenderable); ok {
+ rendered = rr.RawRender(m.list.Width())
+ } else {
+ rendered = item.Render(m.list.Width())
+ }
+
+ lines := strings.Split(rendered, "\n")
+ if itemY < 0 || itemY >= len(lines) {
+ return
+ }
+
+ // Get line length (stripped of ANSI codes) and account for padding.
+ // SetHighlight will subtract the offset, so we need to add it here.
+ offset := chat.MessageLeftPaddingTotal
+ lineLen := ansi.StringWidth(lines[itemY])
+
+ // Set selection to the entire line.
+ // Keep mouseDown true so HandleMouseUp triggers the copy.
+ m.mouseDown = true
+ m.mouseDownItem = itemIdx
+ m.mouseDownX = 0
+ m.mouseDownY = itemY
+ m.mouseDragItem = itemIdx
+ m.mouseDragX = lineLen + offset
+ m.mouseDragY = itemY
+}
+
+// findWordBoundaries finds the start and end column of the word at the given column.
+// Returns (startCol, endCol) where endCol is exclusive.
+func findWordBoundaries(line string, col int) (startCol, endCol int) {
+ if line == "" || col < 0 {
+ return 0, 0
+ }
+
+ i := displaywidth.StringGraphemes(line)
+ for i.Next() {
+ }
+
+ // Segment the line into words using UAX#29.
+ lineCol := 0 // tracks the visited column widths
+ lastCol := 0 // tracks the start of the current token
+ iter := words.FromString(line)
+ for iter.Next() {
+ token := iter.Value()
+ tokenWidth := displaywidth.String(token)
+
+ graphemeStart := lineCol
+ graphemeEnd := lineCol + tokenWidth
+ lineCol += tokenWidth
+
+ // If clicked before this token, return the previous token boundaries.
+ if col < graphemeStart {
+ return lastCol, lastCol
+ }
+
+ // Update lastCol to the end of this token for next iteration.
+ lastCol = graphemeEnd
+
+ // If clicked within this token, return its boundaries.
+ if col >= graphemeStart && col < graphemeEnd {
+ // If clicked on whitespace, return empty selection.
+ if strings.TrimSpace(token) == "" {
+ return col, col
+ }
+ return graphemeStart, graphemeEnd
+ }
+ }
+
+ return col, col
+}
+
+// abs returns the absolute value of an integer.
+func abs(x int) int {
+ if x < 0 {
+ return -x
+ }
+ return x
+}
@@ -0,0 +1,184 @@
+package model
+
+import (
+ "context"
+ "log/slog"
+
+ tea "charm.land/bubbletea/v2"
+
+ "github.com/charmbracelet/crush/internal/message"
+)
+
+// promptHistoryLoadedMsg is sent when prompt history is loaded.
+type promptHistoryLoadedMsg struct {
+ messages []string
+}
+
+// loadPromptHistory loads user messages for history navigation.
+func (m *UI) loadPromptHistory() tea.Cmd {
+ return func() tea.Msg {
+ ctx := context.Background()
+ var messages []message.Message
+ var err error
+
+ if m.session != nil {
+ messages, err = m.com.App.Messages.ListUserMessages(ctx, m.session.ID)
+ } else {
+ messages, err = m.com.App.Messages.ListAllUserMessages(ctx)
+ }
+ if err != nil {
+ slog.Error("Failed to load prompt history", "error", err)
+ return promptHistoryLoadedMsg{messages: nil}
+ }
+
+ texts := make([]string, 0, len(messages))
+ for _, msg := range messages {
+ if text := msg.Content().Text; text != "" {
+ texts = append(texts, text)
+ }
+ }
+ return promptHistoryLoadedMsg{messages: texts}
+ }
+}
+
+// handleHistoryUp handles up arrow for history navigation.
+func (m *UI) handleHistoryUp(msg tea.Msg) tea.Cmd {
+ // Navigate to older history entry from cursor position (0,0).
+ if m.textarea.Length() == 0 || m.isAtEditorStart() {
+ if m.historyPrev() {
+ // we send this so that the textarea moves the view to the correct position
+ // without this the cursor will show up in the wrong place.
+ ta, cmd := m.textarea.Update(nil)
+ m.textarea = ta
+ return cmd
+ }
+ }
+
+ // First move cursor to start before entering history.
+ if m.textarea.Line() == 0 {
+ m.textarea.CursorStart()
+ return nil
+ }
+
+ // Let textarea handle normal cursor movement.
+ ta, cmd := m.textarea.Update(msg)
+ m.textarea = ta
+ return cmd
+}
+
+// handleHistoryDown handles down arrow for history navigation.
+func (m *UI) handleHistoryDown(msg tea.Msg) tea.Cmd {
+ // Navigate to newer history entry from end of text.
+ if m.isAtEditorEnd() {
+ if m.historyNext() {
+ // we send this so that the textarea moves the view to the correct position
+ // without this the cursor will show up in the wrong place.
+ ta, cmd := m.textarea.Update(nil)
+ m.textarea = ta
+ return cmd
+ }
+ }
+
+ // First move cursor to end before navigating history.
+ if m.textarea.Line() == max(m.textarea.LineCount()-1, 0) {
+ m.textarea.MoveToEnd()
+ ta, cmd := m.textarea.Update(nil)
+ m.textarea = ta
+ return cmd
+ }
+
+ // Let textarea handle normal cursor movement.
+ ta, cmd := m.textarea.Update(msg)
+ m.textarea = ta
+ return cmd
+}
+
+// handleHistoryEscape handles escape for exiting history navigation.
+func (m *UI) handleHistoryEscape(msg tea.Msg) tea.Cmd {
+ // Return to current draft when browsing history.
+ if m.promptHistory.index >= 0 {
+ m.promptHistory.index = -1
+ m.textarea.Reset()
+ m.textarea.InsertString(m.promptHistory.draft)
+ ta, cmd := m.textarea.Update(nil)
+ m.textarea = ta
+ return cmd
+ }
+
+ // Let textarea handle escape normally.
+ ta, cmd := m.textarea.Update(msg)
+ m.textarea = ta
+ return cmd
+}
+
+// updateHistoryDraft updates history state when text is modified.
+func (m *UI) updateHistoryDraft(oldValue string) {
+ if m.textarea.Value() != oldValue {
+ m.promptHistory.draft = m.textarea.Value()
+ m.promptHistory.index = -1
+ }
+}
+
+// historyPrev changes the text area content to the previous message in the history
+// it returns false if it could not find the previous message.
+func (m *UI) historyPrev() bool {
+ if len(m.promptHistory.messages) == 0 {
+ return false
+ }
+ if m.promptHistory.index == -1 {
+ m.promptHistory.draft = m.textarea.Value()
+ }
+ nextIndex := m.promptHistory.index + 1
+ if nextIndex >= len(m.promptHistory.messages) {
+ return false
+ }
+ m.promptHistory.index = nextIndex
+ m.textarea.Reset()
+ m.textarea.InsertString(m.promptHistory.messages[nextIndex])
+ m.textarea.MoveToBegin()
+ return true
+}
+
+// historyNext changes the text area content to the next message in the history
+// it returns false if it could not find the next message.
+func (m *UI) historyNext() bool {
+ if m.promptHistory.index < 0 {
+ return false
+ }
+ nextIndex := m.promptHistory.index - 1
+ if nextIndex < 0 {
+ m.promptHistory.index = -1
+ m.textarea.Reset()
+ m.textarea.InsertString(m.promptHistory.draft)
+ return true
+ }
+ m.promptHistory.index = nextIndex
+ m.textarea.Reset()
+ m.textarea.InsertString(m.promptHistory.messages[nextIndex])
+ return true
+}
+
+// historyReset resets the history, but does not clear the message
+// it just sets the current draft to empty and the position in the history.
+func (m *UI) historyReset() {
+ m.promptHistory.index = -1
+ m.promptHistory.draft = ""
+}
+
+// isAtEditorStart returns true if we are at the 0 line and 0 col in the textarea.
+func (m *UI) isAtEditorStart() bool {
+ return m.textarea.Line() == 0 && m.textarea.LineInfo().ColumnOffset == 0
+}
+
+// isAtEditorEnd returns true if we are in the last line and the last column in the textarea.
+func (m *UI) isAtEditorEnd() bool {
+ lineCount := m.textarea.LineCount()
+ if lineCount == 0 {
+ return true
+ }
+ if m.textarea.Line() != lineCount-1 {
+ return false
+ }
+ info := m.textarea.LineInfo()
+ return info.CharOffset >= info.CharWidth-1 || info.CharWidth == 0
+}
@@ -10,11 +10,16 @@ type KeyMap struct {
Newline key.Binding
AddImage key.Binding
MentionFile key.Binding
+ Commands key.Binding
// Attachments key maps
AttachmentDeleteMode key.Binding
Escape key.Binding
DeleteAllAttachments key.Binding
+
+ // History navigation
+ HistoryPrev key.Binding
+ HistoryNext key.Binding
}
Chat struct {
@@ -119,6 +124,10 @@ func DefaultKeyMap() KeyMap {
key.WithKeys("@"),
key.WithHelp("@", "mention file"),
)
+ km.Editor.Commands = key.NewBinding(
+ key.WithKeys("/"),
+ key.WithHelp("/", "commands"),
+ )
km.Editor.AttachmentDeleteMode = key.NewBinding(
key.WithKeys("ctrl+r"),
key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
@@ -131,6 +140,12 @@ func DefaultKeyMap() KeyMap {
key.WithKeys("r"),
key.WithHelp("ctrl+r+r", "delete all attachments"),
)
+ km.Editor.HistoryPrev = key.NewBinding(
+ key.WithKeys("up"),
+ )
+ km.Editor.HistoryNext = key.NewBinding(
+ key.WithKeys("down"),
+ )
km.Chat.NewSession = key.NewBinding(
key.WithKeys("ctrl+n"),
@@ -2,6 +2,8 @@ package model
import (
"fmt"
+ "maps"
+ "slices"
"strings"
"charm.land/lipgloss/v2"
@@ -21,16 +23,14 @@ type LSPInfo struct {
// lspInfo renders the LSP status section showing active LSP clients and their
// diagnostic counts.
func (m *UI) lspInfo(width, maxItems int, isSection bool) string {
- var lsps []LSPInfo
t := m.com.Styles
- lspConfigs := m.com.Config().LSP.Sorted()
- for _, cfg := range lspConfigs {
- state, ok := m.lspStates[cfg.Name]
- if !ok {
- continue
- }
+ states := slices.SortedFunc(maps.Values(m.lspStates), func(a, b app.LSPClientInfo) int {
+ return strings.Compare(a.Name, b.Name)
+ })
+ var lsps []LSPInfo
+ for _, state := range states {
client, ok := m.com.App.LSPClients.Get(state.Name)
if !ok {
continue
@@ -48,9 +48,11 @@ func (m *UI) updateInitializeView(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
// initializeProject starts project initialization and transitions to the landing view.
func (m *UI) initializeProject() tea.Cmd {
// clear the session
- m.newSession()
- cfg := m.com.Config()
var cmds []tea.Cmd
+ if cmd := m.newSession(); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ cfg := m.com.Config()
initialize := func() tea.Msg {
initPrompt, err := agent.InitializePrompt(*cfg)
@@ -68,8 +70,7 @@ func (m *UI) initializeProject() tea.Cmd {
// skipInitializeProject skips project initialization and transitions to the landing view.
func (m *UI) skipInitializeProject() tea.Cmd {
// TODO: initialize the project
- m.state = uiLanding
- m.focus = uiFocusEditor
+ m.setState(uiLanding, uiFocusEditor)
// mark the project as initialized
return m.markProjectInitialized
}
@@ -66,7 +66,8 @@ func queuePill(queue int, focused, panelFocused bool, t *styles.Styles) string {
triangles = triangles[:queue]
}
- content := fmt.Sprintf("%s %d Queued", strings.Join(triangles, ""), queue)
+ text := t.Base.Render(fmt.Sprintf("%d Queued", queue))
+ content := fmt.Sprintf("%s %s", strings.Join(triangles, ""), text)
return pillStyle(focused, panelFocused, t).Render(content)
}
@@ -5,7 +5,6 @@ import (
"fmt"
"charm.land/lipgloss/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/ui/common"
"github.com/charmbracelet/crush/internal/ui/logo"
uv "github.com/charmbracelet/ultraviolet"
@@ -28,14 +27,13 @@ func (m *UI) modelInfo(width int) string {
// Only check reasoning if model can reason
if model.CatwalkCfg.CanReason {
- switch providerConfig.Type {
- case catwalk.TypeAnthropic:
+ if model.ModelCfg.ReasoningEffort == "" {
if model.ModelCfg.Think {
reasoningInfo = "Thinking On"
} else {
reasoningInfo = "Thinking Off"
}
- default:
+ } else {
formatter := cases.Title(language.English, cases.NoLower)
reasoningEffort := cmp.Or(model.ModelCfg.ReasoningEffort, model.CatwalkCfg.DefaultReasoningEffort)
reasoningInfo = formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort))
@@ -22,13 +22,13 @@ import (
"charm.land/bubbles/v2/spinner"
"charm.land/bubbles/v2/textarea"
tea "charm.land/bubbletea/v2"
+ "charm.land/catwalk/pkg/catwalk"
"charm.land/lipgloss/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/agent/tools/mcp"
"github.com/charmbracelet/crush/internal/app"
"github.com/charmbracelet/crush/internal/commands"
"github.com/charmbracelet/crush/internal/config"
- "github.com/charmbracelet/crush/internal/filetracker"
+ "github.com/charmbracelet/crush/internal/fsext"
"github.com/charmbracelet/crush/internal/history"
"github.com/charmbracelet/crush/internal/home"
"github.com/charmbracelet/crush/internal/message"
@@ -41,7 +41,6 @@ import (
"github.com/charmbracelet/crush/internal/ui/common"
"github.com/charmbracelet/crush/internal/ui/completions"
"github.com/charmbracelet/crush/internal/ui/dialog"
- timage "github.com/charmbracelet/crush/internal/ui/image"
"github.com/charmbracelet/crush/internal/ui/logo"
"github.com/charmbracelet/crush/internal/ui/styles"
"github.com/charmbracelet/crush/internal/uiutil"
@@ -118,6 +117,9 @@ type UI struct {
session *session.Session
sessionFiles []SessionFile
+ // keeps track of read files while we don't have a session id
+ sessionFileReads []string
+
lastUserMessageTime int64
// The width and height of the terminal in cells.
@@ -125,6 +127,8 @@ type UI struct {
height int
layout layout
+ isTransparent bool
+
focus uiFocusState
state uiState
@@ -142,11 +146,11 @@ type UI struct {
// sendProgressBar instructs the TUI to send progress bar updates to the
// terminal.
- sendProgressBar bool
+ sendProgressBar bool
+ progressBarEnabled bool
- // QueryCapabilities instructs the TUI to query for the terminal version when it
- // starts.
- QueryCapabilities bool
+ // caps hold different terminal capabilities that we query for.
+ caps common.Capabilities
// Editor components
textarea textarea.Model
@@ -181,9 +185,6 @@ type UI struct {
// sidebarLogo keeps a cached version of the sidebar sidebarLogo.
sidebarLogo string
- // imgCaps stores the terminal image capabilities.
- imgCaps timage.Capabilities
-
// custom commands & mcp commands
customCommands []commands.CustomCommand
mcpPrompts []commands.MCPPrompt
@@ -210,6 +211,13 @@ type UI struct {
// mouse highlighting related state
lastClickTime time.Time
+
+ // Prompt history for up/down navigation through previous messages.
+ promptHistory struct {
+ messages []string
+ index int
+ draft string
+ }
}
// New creates a new instance of the [UI] model.
@@ -257,8 +265,6 @@ func New(com *common.Common) *UI {
com: com,
dialog: dialog.NewOverlay(),
keyMap: keyMap,
- focus: uiFocusNone,
- state: uiOnboarding,
textarea: ta,
chat: ch,
completions: comp,
@@ -270,25 +276,34 @@ func New(com *common.Common) *UI {
status := NewStatus(com, ui)
+ ui.setEditorPrompt(false)
+ ui.randomizePlaceholders()
+ ui.textarea.Placeholder = ui.readyPlaceholder
+ ui.status = status
+
+ // Initialize compact mode from config
+ ui.forceCompactMode = com.Config().Options.TUI.CompactMode
+
// set onboarding state defaults
ui.onboarding.yesInitializeSelected = true
+ desiredState := uiLanding
+ desiredFocus := uiFocusEditor
if !com.Config().IsConfigured() {
- ui.state = uiOnboarding
+ desiredState = uiOnboarding
} else if n, _ := config.ProjectNeedsInitialization(); n {
- ui.state = uiInitialize
- } else {
- ui.state = uiLanding
- ui.focus = uiFocusEditor
+ desiredState = uiInitialize
}
- ui.setEditorPrompt(false)
- ui.randomizePlaceholders()
- ui.textarea.Placeholder = ui.readyPlaceholder
- ui.status = status
+ // set initial state
+ ui.setState(desiredState, desiredFocus)
- // Initialize compact mode from config
- ui.forceCompactMode = com.Config().Options.TUI.CompactMode
+ opts := com.Config().Options
+
+ // disable indeterminate progress bar
+ ui.progressBarEnabled = opts.Progress == nil || *opts.Progress
+ // enable transparent mode
+ ui.isTransparent = opts.TUI.Transparent != nil && *opts.TUI.Transparent
return ui
}
@@ -296,9 +311,6 @@ func New(com *common.Common) *UI {
// Init initializes the UI model.
func (m *UI) Init() tea.Cmd {
var cmds []tea.Cmd
- if m.QueryCapabilities {
- cmds = append(cmds, tea.RequestTerminalVersion)
- }
if m.state == uiOnboarding {
if cmd := m.openModelsDialog(); cmd != nil {
cmds = append(cmds, cmd)
@@ -306,15 +318,25 @@ func (m *UI) Init() tea.Cmd {
}
// load the user commands async
cmds = append(cmds, m.loadCustomCommands())
+ // load prompt history async
+ cmds = append(cmds, m.loadPromptHistory())
return tea.Batch(cmds...)
}
+// setState changes the UI state and focus.
+func (m *UI) setState(state uiState, focus uiFocusState) {
+ m.state = state
+ m.focus = focus
+ // Changing the state may change layout, so update it.
+ m.updateLayoutAndSize()
+}
+
// loadCustomCommands loads the custom commands asynchronously.
func (m *UI) loadCustomCommands() tea.Cmd {
return func() tea.Msg {
customCommands, err := commands.LoadCustomCommands(m.com.Config())
if err != nil {
- slog.Error("failed to load custom commands", "error", err)
+ slog.Error("Failed to load custom commands", "error", err)
}
return userCommandsLoadedMsg{Commands: customCommands}
}
@@ -325,7 +347,7 @@ func (m *UI) loadMCPrompts() tea.Cmd {
return func() tea.Msg {
prompts, err := commands.LoadMCPPrompts()
if err != nil {
- slog.Error("failed to load mcp prompts", "error", err)
+ slog.Error("Failed to load MCP prompts", "error", err)
}
if prompts == nil {
// flag them as loaded even if there is none or an error
@@ -345,24 +367,20 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.updateLayoutAndSize()
}
}
+ // Update terminal capabilities
+ m.caps.Update(msg)
switch msg := msg.(type) {
case tea.EnvMsg:
// Is this Windows Terminal?
if !m.sendProgressBar {
m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
}
- m.imgCaps.Env = uv.Environ(msg)
- // Only query for image capabilities if the terminal is known to
- // support Kitty graphics protocol. This prevents character bleeding
- // on terminals that don't understand the APC escape sequences.
- if m.QueryCapabilities {
- cmds = append(cmds, timage.RequestCapabilities(m.imgCaps.Env))
- }
+ cmds = append(cmds, common.QueryCmd(uv.Environ(msg)))
case loadSessionMsg:
- m.state = uiChat
if m.forceCompactMode {
m.isCompact = true
}
+ m.setState(uiChat, m.focus)
m.session = msg.session
m.sessionFiles = msg.files
msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID)
@@ -381,6 +399,10 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.updateLayoutAndSize()
}
+ // Reload prompt history for the new session.
+ m.historyReset()
+ cmds = append(cmds, m.loadPromptHistory())
+ m.updateLayoutAndSize()
case sendMessageMsg:
cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...))
@@ -408,13 +430,20 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
commands.SetMCPPrompts(m.mcpPrompts)
}
+ case promptHistoryLoadedMsg:
+ m.promptHistory.messages = msg.messages
+ m.promptHistory.index = -1
+ m.promptHistory.draft = ""
+
case closeDialogMsg:
m.dialog.CloseFrontDialog()
case pubsub.Event[session.Session]:
if msg.Type == pubsub.DeletedEvent {
if m.session != nil && m.session.ID == msg.Payload.ID {
- m.newSession()
+ if cmd := m.newSession(); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
}
break
}
@@ -492,10 +521,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
- m.handleCompactMode(m.width, m.height)
m.updateLayoutAndSize()
- // XXX: We need to store cell dimensions for image rendering.
- m.imgCaps.Columns, m.imgCaps.Rows = msg.Width, msg.Height
case tea.KeyboardEnhancementsMsg:
m.keyenh = msg
if msg.SupportsKeyDisambiguation() {
@@ -504,20 +530,33 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case copyChatHighlightMsg:
cmds = append(cmds, m.copyChatHighlight())
+ case DelayedClickMsg:
+ // Handle delayed single-click action (e.g., expansion).
+ m.chat.HandleDelayedClick(msg)
case tea.MouseClickMsg:
// Pass mouse events to dialogs first if any are open.
if m.dialog.HasDialogs() {
m.dialog.Update(msg)
return m, tea.Batch(cmds...)
}
+
+ if cmd := m.handleClickFocus(msg); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+
switch m.state {
case uiChat:
x, y := msg.X, msg.Y
// Adjust for chat area position
x -= m.layout.main.Min.X
y -= m.layout.main.Min.Y
- if m.chat.HandleMouseDown(x, y) {
- m.lastClickTime = time.Now()
+ if !image.Pt(msg.X, msg.Y).In(m.layout.sidebar) {
+ if handled, cmd := m.chat.HandleMouseDown(x, y); handled {
+ m.lastClickTime = time.Now()
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ }
}
}
@@ -565,7 +604,6 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.dialog.Update(msg)
return m, tea.Batch(cmds...)
}
- const doubleClickThreshold = 500 * time.Millisecond
switch m.state {
case uiChat:
@@ -662,18 +700,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.completionsOpen {
m.completions.SetFiles(msg.Files)
}
- case uv.WindowPixelSizeEvent:
- // [timage.RequestCapabilities] requests the terminal to send a window
- // size event to help determine pixel dimensions.
- m.imgCaps.PixelWidth = msg.Width
- m.imgCaps.PixelHeight = msg.Height
case uv.KittyGraphicsEvent:
- // [timage.RequestCapabilities] sends a Kitty graphics query and this
- // captures the response. Any response means the terminal understands
- // the protocol.
- m.imgCaps.SupportsKittyGraphics = true
if !bytes.HasPrefix(msg.Payload, []byte("OK")) {
- slog.Warn("unexpected Kitty graphics response",
+ slog.Warn("Unexpected Kitty graphics response",
"response", string(msg.Payload),
"options", msg.Options)
}
@@ -820,11 +849,14 @@ func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
// if the message is a tool result it will update the corresponding tool call message
func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
var cmds []tea.Cmd
+ atBottom := m.chat.list.AtBottom()
+
existing := m.chat.MessageItem(msg.ID)
if existing != nil {
// message already exists, skip
return nil
}
+
switch msg.Role {
case message.User:
m.lastUserMessageTime = msg.CreatedAt
@@ -850,14 +882,18 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
}
}
m.chat.AppendMessages(items...)
- if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
- cmds = append(cmds, cmd)
+ if atBottom {
+ if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
}
if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
infoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, time.Unix(m.lastUserMessageTime, 0))
m.chat.AppendMessages(infoItem)
- if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
- cmds = append(cmds, cmd)
+ if atBottom {
+ if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
}
}
case message.Tool:
@@ -869,12 +905,35 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
}
if toolMsgItem, ok := toolItem.(chat.ToolMessageItem); ok {
toolMsgItem.SetResult(&tr)
+ if atBottom {
+ if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ }
}
}
}
return tea.Batch(cmds...)
}
+func (m *UI) handleClickFocus(msg tea.MouseClickMsg) (cmd tea.Cmd) {
+ switch {
+ case m.state != uiChat:
+ return nil
+ case image.Pt(msg.X, msg.Y).In(m.layout.sidebar):
+ return nil
+ case m.focus != uiFocusEditor && image.Pt(msg.X, msg.Y).In(m.layout.editor):
+ m.focus = uiFocusEditor
+ cmd = m.textarea.Focus()
+ m.chat.Blur()
+ case m.focus != uiFocusMain && image.Pt(msg.X, msg.Y).In(m.layout.main):
+ m.focus = uiFocusMain
+ m.textarea.Blur()
+ m.chat.Focus()
+ }
+ return cmd
+}
+
// updateSessionMessage updates an existing message in the current session in the chat
// when an assistant message is updated it may include updated tool calls as well
// that is why we need to handle creating/updating each tool call message too
@@ -1087,7 +1146,9 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
break
}
- m.newSession()
+ if cmd := m.newSession(); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
m.dialog.CloseDialog(dialog.CommandsID)
case dialog.ActionSummarize:
if m.isAgentBusy() {
@@ -1116,11 +1177,6 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
cmds = append(cmds, m.toggleCompactMode())
m.dialog.CloseDialog(dialog.CommandsID)
case dialog.ActionToggleThinking:
- if m.isAgentBusy() {
- cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
- break
- }
-
cmds = append(cmds, func() tea.Msg {
cfg := m.com.Config()
if cfg == nil {
@@ -1211,9 +1267,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
m.dialog.CloseDialog(dialog.ModelsID)
if isOnboarding {
- m.state = uiLanding
- m.focus = uiFocusEditor
-
+ m.setState(uiLanding, uiFocusEditor)
m.com.Config().SetupAgents()
if err := m.com.App.InitCoderAgent(context.TODO()); err != nil {
cmds = append(cmds, uiutil.ReportError(err))
@@ -1384,14 +1438,14 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
return true
}
case key.Matches(msg, m.keyMap.Chat.PillLeft):
- if m.state == uiChat && m.hasSession() && m.pillsExpanded {
+ if m.state == uiChat && m.hasSession() && m.pillsExpanded && m.focus != uiFocusEditor {
if cmd := m.switchPillSection(-1); cmd != nil {
cmds = append(cmds, cmd)
}
return true
}
case key.Matches(msg, m.keyMap.Chat.PillRight):
- if m.state == uiChat && m.hasSession() && m.pillsExpanded {
+ if m.state == uiChat && m.hasSession() && m.pillsExpanded && m.focus != uiFocusEditor {
if cmd := m.switchPillSection(1); cmd != nil {
cmds = append(cmds, cmd)
}
@@ -1493,8 +1547,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
}
m.randomizePlaceholders()
+ m.historyReset()
- return m.sendMessage(value, attachments...)
+ return tea.Batch(m.sendMessage(value, attachments...), m.loadPromptHistory())
case key.Matches(msg, m.keyMap.Chat.NewSession):
if !m.hasSession() {
break
@@ -1503,10 +1558,12 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
break
}
- m.newSession()
+ if cmd := m.newSession(); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
case key.Matches(msg, m.keyMap.Tab):
if m.state != uiLanding {
- m.focus = uiFocusMain
+ m.setState(m.state, uiFocusMain)
m.textarea.Blur()
m.chat.Focus()
m.chat.SetSelected(m.chat.Len() - 1)
@@ -1523,6 +1580,25 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
ta, cmd := m.textarea.Update(msg)
m.textarea = ta
cmds = append(cmds, cmd)
+ case key.Matches(msg, m.keyMap.Editor.HistoryPrev):
+ cmd := m.handleHistoryUp(msg)
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ case key.Matches(msg, m.keyMap.Editor.HistoryNext):
+ cmd := m.handleHistoryDown(msg)
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ case key.Matches(msg, m.keyMap.Editor.Escape):
+ cmd := m.handleHistoryEscape(msg)
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ case key.Matches(msg, m.keyMap.Editor.Commands) && m.textarea.Value() == "":
+ if cmd := m.openCommandsDialog(); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
default:
if handleGlobalKeys(msg) {
// Handle global keys first before passing to textarea.
@@ -1556,6 +1632,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
m.textarea = ta
cmds = append(cmds, cmd)
+ // Any text modification becomes the current draft.
+ m.updateHistoryDraft(curValue)
+
// After updating textarea, check if we need to filter completions.
// Skip filtering on the initial @ keystroke since items are loading async.
if m.completionsOpen && msg.String() != "@" {
@@ -1595,7 +1674,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
break
}
m.focus = uiFocusEditor
- m.newSession()
+ if cmd := m.newSession(); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
case key.Matches(msg, m.keyMap.Chat.Expand):
m.chat.ToggleExpandedSelectedItem()
case key.Matches(msg, m.keyMap.Chat.Up):
@@ -1809,7 +1890,9 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
func (m *UI) View() tea.View {
var v tea.View
v.AltScreen = true
- v.BackgroundColor = m.com.Styles.Background
+ if !m.isTransparent {
+ v.BackgroundColor = m.com.Styles.Background
+ }
v.MouseMode = tea.MouseModeCellMotion
v.WindowTitle = "crush " + home.Short(m.com.Config().WorkingDir())
@@ -1826,7 +1909,7 @@ func (m *UI) View() tea.View {
content = strings.Join(contentLines, "\n")
v.Content = content
- if m.sendProgressBar && m.isAgentBusy() {
+ if m.progressBarEnabled && m.sendProgressBar && m.isAgentBusy() {
// HACK: use a random percentage to prevent ghostty from hiding it
// after a timeout.
v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
@@ -1841,7 +1924,7 @@ func (m *UI) ShortHelp() []key.Binding {
k := &m.keyMap
tab := k.Tab
commands := k.Commands
- if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
+ if m.focus == uiFocusEditor && m.textarea.Value() == "" {
commands.SetHelp("/ or ctrl+p", "commands")
}
@@ -1917,7 +2000,7 @@ func (m *UI) FullHelp() [][]key.Binding {
hasAttachments := len(m.attachments.List()) > 0
hasSession := m.hasSession()
commands := k.Commands
- if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
+ if m.focus == uiFocusEditor && m.textarea.Value() == "" {
commands.SetHelp("/ or ctrl+p", "commands")
}
@@ -2053,29 +2136,26 @@ func (m *UI) toggleCompactMode() tea.Cmd {
return uiutil.ReportError(err)
}
- m.handleCompactMode(m.width, m.height)
m.updateLayoutAndSize()
return nil
}
-// handleCompactMode updates the UI state based on window size and compact mode setting.
-func (m *UI) handleCompactMode(newWidth, newHeight int) {
+// updateLayoutAndSize updates the layout and sizes of UI components.
+func (m *UI) updateLayoutAndSize() {
+ // Determine if we should be in compact mode
if m.state == uiChat {
if m.forceCompactMode {
m.isCompact = true
return
}
- if newWidth < compactModeWidthBreakpoint || newHeight < compactModeHeightBreakpoint {
+ if m.width < compactModeWidthBreakpoint || m.height < compactModeHeightBreakpoint {
m.isCompact = true
} else {
m.isCompact = false
}
}
-}
-// updateLayoutAndSize updates the layout and sizes of UI components.
-func (m *UI) updateLayoutAndSize() {
m.layout = m.generateLayout(m.width, m.height)
m.updateSize()
}
@@ -2120,7 +2200,7 @@ func (m *UI) generateLayout(w, h int) layout {
const landingHeaderHeight = 4
var helpKeyMap help.KeyMap = m
- if m.status.ShowingAll() {
+ if m.status != nil && m.status.ShowingAll() {
for _, row := range helpKeyMap.FullHelp() {
helpHeight = max(helpHeight, len(row))
}
@@ -2246,7 +2326,9 @@ func (m *UI) generateLayout(w, h int) layout {
if !layout.editor.Empty() {
// Add editor margins 1 top and bottom
- layout.editor.Min.Y += 1
+ if len(m.attachments.List()) == 0 {
+ layout.editor.Min.Y += 1
+ }
layout.editor.Max.Y -= 1
}
@@ -2393,21 +2475,27 @@ func (m *UI) insertFileCompletion(path string) tea.Cmd {
return func() tea.Msg {
absPath, _ := filepath.Abs(path)
- // Skip attachment if file was already read and hasn't been modified.
- lastRead := filetracker.LastReadTime(absPath)
- if !lastRead.IsZero() {
- if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
- return nil
+
+ if m.hasSession() {
+ // Skip attachment if file was already read and hasn't been modified.
+ lastRead := m.com.App.FileTracker.LastReadTime(context.Background(), m.session.ID, absPath)
+ if !lastRead.IsZero() {
+ if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
+ return nil
+ }
}
+ } else if slices.Contains(m.sessionFileReads, absPath) {
+ return nil
}
+ m.sessionFileReads = append(m.sessionFileReads, absPath)
+
// Add file as attachment.
content, err := os.ReadFile(path)
if err != nil {
// If it fails, let the LLM handle it later.
return nil
}
- filetracker.RecordRead(absPath)
return message.Attachment{
FilePath: path,
@@ -2524,7 +2612,6 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
if err != nil {
return uiutil.ReportError(err)
}
- m.state = uiChat
if m.forceCompactMode {
m.isCompact = true
}
@@ -2532,6 +2619,11 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
m.session = &newSession
cmds = append(cmds, m.loadSession(newSession.ID))
}
+ m.setState(uiChat, m.focus)
+ }
+
+ for _, path := range m.sessionFileReads {
+ m.com.App.FileTracker.RecordRead(context.Background(), m.session.ID, path)
}
// Capture session ID to avoid race with main goroutine updating m.session.
@@ -2732,7 +2824,7 @@ func (m *UI) openFilesDialog() tea.Cmd {
}
filePicker, cmd := dialog.NewFilePicker(m.com)
- filePicker.SetImageCapabilities(&m.imgCaps)
+ filePicker.SetImageCapabilities(&m.caps)
m.dialog.OpenDialog(filePicker)
return cmd
@@ -2772,21 +2864,24 @@ func (m *UI) handlePermissionNotification(notification permission.PermissionNoti
// newSession clears the current session state and prepares for a new session.
// The actual session creation happens when the user sends their first message.
-func (m *UI) newSession() {
+// Returns a command to reload prompt history.
+func (m *UI) newSession() tea.Cmd {
if !m.hasSession() {
- return
+ return nil
}
m.session = nil
m.sessionFiles = nil
- m.state = uiLanding
- m.focus = uiFocusEditor
+ m.sessionFileReads = nil
+ m.setState(uiLanding, uiFocusEditor)
m.textarea.Focus()
m.chat.Blur()
m.chat.ClearMessages()
m.pillsExpanded = false
m.promptQueue = 0
m.pillsView = ""
+ m.historyReset()
+ return m.loadPromptHistory()
}
// handlePasteMsg handles a paste message.
@@ -2817,34 +2912,53 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
}
}
- var cmd tea.Cmd
- path := strings.ReplaceAll(msg.Content, "\\ ", " ")
- // Try to get an image.
- path, err := filepath.Abs(strings.TrimSpace(path))
- if err != nil {
- m.textarea, cmd = m.textarea.Update(msg)
- return cmd
- }
+ // Attempt to parse pasted content as file paths. If possible to parse,
+ // all files exist and are valid, add as attachments.
+ // Otherwise, paste as text.
+ paths := fsext.ParsePastedFiles(msg.Content)
+ allExistsAndValid := func() bool {
+ for _, path := range paths {
+ if _, err := os.Stat(path); os.IsNotExist(err) {
+ return false
+ }
- // Check if file has an allowed image extension.
- isAllowedType := false
- lowerPath := strings.ToLower(path)
- for _, ext := range common.AllowedImageTypes {
- if strings.HasSuffix(lowerPath, ext) {
- isAllowedType = true
- break
+ lowerPath := strings.ToLower(path)
+ isValid := false
+ for _, ext := range common.AllowedImageTypes {
+ if strings.HasSuffix(lowerPath, ext) {
+ isValid = true
+ break
+ }
+ }
+ if !isValid {
+ return false
+ }
}
+ return true
}
- if !isAllowedType {
+ if !allExistsAndValid() {
+ var cmd tea.Cmd
m.textarea, cmd = m.textarea.Update(msg)
return cmd
}
+ var cmds []tea.Cmd
+ for _, path := range paths {
+ cmds = append(cmds, m.handleFilePathPaste(path))
+ }
+ return tea.Batch(cmds...)
+}
+
+// handleFilePathPaste handles a pasted file path.
+func (m *UI) handleFilePathPaste(path string) tea.Cmd {
return func() tea.Msg {
fileInfo, err := os.Stat(path)
if err != nil {
return uiutil.ReportError(err)
}
+ if fileInfo.IsDir() {
+ return uiutil.ReportWarn("Cannot attach a directory")
+ }
if fileInfo.Size() > common.MaxAttachmentSize {
return uiutil.ReportWarn("File is too big (>5mb)")
}
@@ -2,6 +2,7 @@ package styles
import (
"image/color"
+ "strings"
"charm.land/bubbles/v2/filepicker"
"charm.land/bubbles/v2/help"
@@ -379,13 +380,14 @@ type Styles struct {
DeletingTitleGradientToColor color.Color
// styles for when we are in update mode
- UpdatingView lipgloss.Style
- UpdatingItemFocused lipgloss.Style
- UpdatingItemBlurred lipgloss.Style
- UpdatingTitle lipgloss.Style
- UpdatingMessage lipgloss.Style
- UpdatingTitleGradientFromColor color.Color
- UpdatingTitleGradientToColor color.Color
+ RenamingView lipgloss.Style
+ RenamingingItemFocused lipgloss.Style
+ RenamingItemBlurred lipgloss.Style
+ RenamingingTitle lipgloss.Style
+ RenamingingMessage lipgloss.Style
+ RenamingTitleGradientFromColor color.Color
+ RenamingTitleGradientToColor color.Color
+ RenamingPlaceholder lipgloss.Style
}
}
@@ -1113,7 +1115,7 @@ func DefaultStyles() Styles {
// Content rendering - prepared styles that accept width parameter
s.Tool.ContentLine = s.Muted.Background(bgBaseLighter)
s.Tool.ContentTruncation = s.Muted.Background(bgBaseLighter)
- s.Tool.ContentCodeLine = s.Base.Background(bgBase)
+ s.Tool.ContentCodeLine = s.Base.Background(bgBase).PaddingLeft(2)
s.Tool.ContentCodeTruncation = s.Muted.Background(bgBase).PaddingLeft(2)
s.Tool.ContentCodeBg = bgBase
s.Tool.Body = base.PaddingLeft(2)
@@ -1295,15 +1297,16 @@ func DefaultStyles() Styles {
s.Dialog.Sessions.DeletingTitleGradientFromColor = red
s.Dialog.Sessions.DeletingTitleGradientToColor = s.Primary
s.Dialog.Sessions.DeletingItemBlurred = s.Dialog.NormalItem.Foreground(fgSubtle)
- s.Dialog.Sessions.DeletingItemFocused = s.Dialog.SelectedItem.Background(red)
+ s.Dialog.Sessions.DeletingItemFocused = s.Dialog.SelectedItem.Background(red).Foreground(charmtone.Butter)
- s.Dialog.Sessions.UpdatingTitle = s.Dialog.Title.Foreground(charmtone.Zest)
- s.Dialog.Sessions.UpdatingView = s.Dialog.View.BorderForeground(charmtone.Zest)
- s.Dialog.Sessions.UpdatingMessage = s.Base.Padding(1)
- s.Dialog.Sessions.UpdatingTitleGradientFromColor = charmtone.Zest
- s.Dialog.Sessions.UpdatingTitleGradientToColor = charmtone.Bok
- s.Dialog.Sessions.UpdatingItemBlurred = s.Dialog.NormalItem.Foreground(fgSubtle)
- s.Dialog.Sessions.UpdatingItemFocused = s.Dialog.SelectedItem.UnsetBackground().UnsetForeground()
+ s.Dialog.Sessions.RenamingingTitle = s.Dialog.Title.Foreground(charmtone.Zest)
+ s.Dialog.Sessions.RenamingView = s.Dialog.View.BorderForeground(charmtone.Zest)
+ s.Dialog.Sessions.RenamingingMessage = s.Base.Padding(1)
+ s.Dialog.Sessions.RenamingTitleGradientFromColor = charmtone.Zest
+ s.Dialog.Sessions.RenamingTitleGradientToColor = charmtone.Bok
+ s.Dialog.Sessions.RenamingItemBlurred = s.Dialog.NormalItem.Foreground(fgSubtle)
+ s.Dialog.Sessions.RenamingingItemFocused = s.Dialog.SelectedItem.UnsetBackground().UnsetForeground()
+ s.Dialog.Sessions.RenamingPlaceholder = base.Foreground(charmtone.Squid)
s.Status.Help = lipgloss.NewStyle().Padding(0, 1)
s.Status.SuccessIndicator = base.Foreground(bgSubtle).Background(green).Padding(0, 1).Bold(true).SetString("OKAY!")
@@ -1347,35 +1350,36 @@ func boolPtr(b bool) *bool { return &b }
func stringPtr(s string) *string { return &s }
func uintPtr(u uint) *uint { return &u }
func chromaStyle(style ansi.StylePrimitive) string {
- var s string
+ var s strings.Builder
if style.Color != nil {
- s = *style.Color
+ s.WriteString(*style.Color)
}
if style.BackgroundColor != nil {
- if s != "" {
- s += " "
+ if s.Len() > 0 {
+ s.WriteString(" ")
}
- s += "bg:" + *style.BackgroundColor
+ s.WriteString("bg:")
+ s.WriteString(*style.BackgroundColor)
}
if style.Italic != nil && *style.Italic {
- if s != "" {
- s += " "
+ if s.Len() > 0 {
+ s.WriteString(" ")
}
- s += "italic"
+ s.WriteString("italic")
}
if style.Bold != nil && *style.Bold {
- if s != "" {
- s += " "
+ if s.Len() > 0 {
+ s.WriteString(" ")
}
- s += "bold"
+ s.WriteString("bold")
}
if style.Underline != nil && *style.Underline {
- if s != "" {
- s += " "
+ if s.Len() > 0 {
+ s.WriteString(" ")
}
- s += "underline"
+ s.WriteString("underline")
}
- return s
+ return s.String()
}
@@ -92,10 +92,7 @@
}
},
"additionalProperties": false,
- "type": "object",
- "required": [
- "tools"
- ]
+ "type": "object"
},
"LSPConfig": {
"properties": {
@@ -159,13 +156,19 @@
"options": {
"type": "object",
"description": "LSP server-specific settings passed during initialization"
+ },
+ "timeout": {
+ "type": "integer",
+ "description": "Timeout in seconds for LSP server initialization",
+ "default": 30,
+ "examples": [
+ 60,
+ 120
+ ]
}
},
"additionalProperties": false,
- "type": "object",
- "required": [
- "command"
- ]
+ "type": "object"
},
"LSPs": {
"additionalProperties": {
@@ -435,6 +438,16 @@
"CLAUDE.md",
"docs/LLMs.md"
]
+ },
+ "auto_lsp": {
+ "type": "boolean",
+ "description": "Automatically setup LSPs based on root markers",
+ "default": true
+ },
+ "progress": {
+ "type": "boolean",
+ "description": "Show indeterminate progress updates during long operations",
+ "default": true
}
},
"additionalProperties": false,
@@ -637,6 +650,11 @@
"completions": {
"$ref": "#/$defs/Completions",
"description": "Completions UI options"
+ },
+ "transparent": {
+ "type": "boolean",
+ "description": "Enable transparent background for the TUI interface",
+ "default": false
}
},
"additionalProperties": false,
@@ -698,10 +716,7 @@
}
},
"additionalProperties": false,
- "type": "object",
- "required": [
- "ls"
- ]
+ "type": "object"
}
}
}
@@ -0,0 +1,5 @@
+#!/bin/bash
+if grep -rE 'slog\.(Error|Info|Warn|Debug|Fatal|Print|Println|Printf)\(["\"][a-z]' --include="*.go" . 2>/dev/null; then
+ echo "โ Log messages must start with a capital letter. Found lowercase logs above."
+ exit 1
+fi