diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json index 5b5e74252b831d49bdec16557311a8e39de71b16..8a4c239de977e86e38dbaf5f6f87061b58b44d2f 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -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 } ] } \ No newline at end of file diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index b90761035ecf5d9292c35b567c5b4a3d36efa1b9..7291604a5f34c4e1565d5c1a454860c6d25892da 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -30,11 +30,11 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: github/codeql-action/init@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 diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml index 0c3d5ce6d437a39471003018545d8546fa220ef6..a5a45d8fdeeaf8f0c1374366e7c1d34839c1acc5 100644 --- a/.github/workflows/snapshot.yml +++ b/.github/workflows/snapshot.yml @@ -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: diff --git a/.goreleaser.yml b/.goreleaser.yml index 784201677ed863e460818d98ac54e651bbfb7fee..0ba2b1eccdf6de70c3e39d9111074a84658bd2a3 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 7fab72afb836136020500b7f27e905f3dcfc72da..654f1cd0a7fe1cbb50a3026f86f31b68e04f8043 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index cd68cb962de3518cce6f86ac3513d388bf9bfcd0..6e167345dd92ffb7a4d56241e9da7258a7c89b97 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

Your new coding bestie, now available in your favourite terminal.
Your tools, your code, and your workflows, wired into your LLM of choice.

-

你的新编程伙伴,现在就在你最爱的终端中。
你的工具、代码和工作流,都与您选择的 LLM 模型紧密相连。

+

终端里的编程新搭档,
无缝接入你的工具、代码与工作流,全面兼容主流 LLM 模型。

Crush Demo

diff --git a/Taskfile.yaml b/Taskfile.yaml index 9ffe8923d6bbd92caf441d872726de48352b2faa..bff27387d6be353ccd02cf6437b4acafb30334c9 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -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 diff --git a/go.mod b/go.mod index 30c5613bf400e4568bf0662b6c340d371b1d4268..2358911b7f6c3633b82b14e589c5db14c02d15d6 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 5f1787b0a9e5372580a3a92dfbb43e2786e582bb..91d0707fd0a5d50c4d64a8c68b606747b743f4c0 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 084a7160815a0f418ba2e036bd886f7872fc3df8..29a4afc81dccf39c24b6864ea3e20db5a9c9028e 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -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\n" + s + "\n" + } 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 } diff --git a/internal/agent/agentic_fetch_tool.go b/internal/agent/agentic_fetch_tool.go index 89d3535720f8452111f12f4df4eb691e39253bed..9bf592413b07c651171d10785104294da8fb39a3 100644 --- a/internal/agent/agentic_fetch_tool.go +++ b/internal/agent/agentic_fetch_tool.go @@ -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{ diff --git a/internal/agent/common_test.go b/internal/agent/common_test.go index 3f4e8daddbd4de34e788bce59a9573c00d940252..4f96c3cfbb1728f533c71a7c05b7e1ab85975b45 100644 --- a/internal/agent/common_test.go +++ b/internal/agent/common_test.go @@ -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 diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index 71fd3b3375898aa8820bb9ebe70ae27eae494baf..7b346aa27ac661a44ad5ca5d48413e8ac0b969a3 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -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: diff --git a/internal/agent/hyper/provider.go b/internal/agent/hyper/provider.go index 03278ae99f87608c65263b0ffef7fb473cd58e31..8ba3a538e4a97b4691dff4eb9aba46f83b523912 100644 --- a/internal/agent/hyper/provider.go +++ b/internal/agent/hyper/provider.go @@ -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 }) diff --git a/internal/agent/hyper/provider.json b/internal/agent/hyper/provider.json index 5558750e38e35024615b41b71243888a1a1ebd6c..d2d0fc0d6edbce4e4e87626bcd2f09af4c9c8f14 100644 --- a/internal/agent/hyper/provider.json +++ b/internal/agent/hyper/provider.json @@ -1 +1 @@ -{"name":"Charm Hyper","id":"hyper","api_endpoint":"https://console.charm.land/api/v1/fantasy","type":"hyper","default_large_model_id":"claude-sonnet-4-5","default_small_model_id":"claude-3-5-haiku","models":[{"id":"Kimi-K2-0905","name":"Kimi K2 0905","cost_per_1m_in":0.55,"cost_per_1m_out":2.19,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0,"context_window":256000,"default_max_tokens":10000,"can_reason":true,"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"claude-3-5-haiku","name":"Claude 3.5 Haiku","cost_per_1m_in":0.7999999999999999,"cost_per_1m_out":4,"cost_per_1m_in_cached":1,"cost_per_1m_out_cached":0.08,"context_window":200000,"default_max_tokens":5000,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"claude-3-5-sonnet","name":"Claude 3.5 Sonnet (New)","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":5000,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"claude-3-7-sonnet","name":"Claude 3.7 Sonnet","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-haiku-4-5","name":"Claude 4.5 Haiku","cost_per_1m_in":1,"cost_per_1m_out":5,"cost_per_1m_in_cached":1.25,"cost_per_1m_out_cached":0.09999999999999999,"context_window":200000,"default_max_tokens":32000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4","name":"Claude Opus 4","cost_per_1m_in":15,"cost_per_1m_out":75,"cost_per_1m_in_cached":18.75,"cost_per_1m_out_cached":1.5,"context_window":200000,"default_max_tokens":32000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4-1","name":"Claude Opus 4.1","cost_per_1m_in":15,"cost_per_1m_out":75,"cost_per_1m_in_cached":18.75,"cost_per_1m_out_cached":1.5,"context_window":200000,"default_max_tokens":32000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4-5","name":"Claude Opus 4.5","cost_per_1m_in":5,"cost_per_1m_out":25,"cost_per_1m_in_cached":6.25,"cost_per_1m_out_cached":0.5,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-sonnet-4","name":"Claude Sonnet 4","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-sonnet-4-5","name":"Claude Sonnet 4.5","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"gemini-2.5-flash","name":"Gemini 2.5 Flash","cost_per_1m_in":0.3,"cost_per_1m_out":2.5,"cost_per_1m_in_cached":0.3833,"cost_per_1m_out_cached":0.075,"context_window":1048576,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"gemini-2.5-pro","name":"Gemini 2.5 Pro","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":1.625,"cost_per_1m_out_cached":0.31,"context_window":1048576,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"glm-4.6","name":"GLM-4.6","cost_per_1m_in":0.6,"cost_per_1m_out":2.2,"cost_per_1m_in_cached":0.11,"cost_per_1m_out_cached":0,"context_window":204800,"default_max_tokens":131072,"can_reason":true,"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"gpt-4.1","name":"GPT-4.1","cost_per_1m_in":2,"cost_per_1m_out":8,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.5,"context_window":1047576,"default_max_tokens":16384,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-4.1-mini","name":"GPT-4.1 Mini","cost_per_1m_in":0.39999999999999997,"cost_per_1m_out":1.5999999999999999,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.09999999999999999,"context_window":1047576,"default_max_tokens":16384,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-4.1-nano","name":"GPT-4.1 Nano","cost_per_1m_in":0.09999999999999999,"cost_per_1m_out":0.39999999999999997,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.024999999999999998,"context_window":1047576,"default_max_tokens":16384,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-4o","name":"GPT-4o","cost_per_1m_in":2.5,"cost_per_1m_out":10,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":1.25,"context_window":128000,"default_max_tokens":8192,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-4o-mini","name":"GPT-4o-mini","cost_per_1m_in":0.15,"cost_per_1m_out":0.6,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.075,"context_window":128000,"default_max_tokens":8192,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-5","name":"GPT-5","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5-codex","name":"GPT-5 Codex","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5-mini","name":"GPT-5 Mini","cost_per_1m_in":0.25,"cost_per_1m_out":2,"cost_per_1m_in_cached":0.025,"cost_per_1m_out_cached":0.025,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5-nano","name":"GPT-5 Nano","cost_per_1m_in":0.05,"cost_per_1m_out":0.4,"cost_per_1m_in_cached":0.005,"cost_per_1m_out_cached":0.005,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1","name":"GPT-5.1","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex","name":"GPT-5.1 Codex","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex-max","name":"GPT-5.1 Codex Max","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex-mini","name":"GPT-5.1 Codex Mini","cost_per_1m_in":0.25,"cost_per_1m_out":2,"cost_per_1m_in_cached":0.025,"cost_per_1m_out_cached":0.025,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.2","name":"GPT-5.2","cost_per_1m_in":1.75,"cost_per_1m_out":14,"cost_per_1m_in_cached":0.175,"cost_per_1m_out_cached":0.175,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"o3","name":"o3","cost_per_1m_in":2,"cost_per_1m_out":8,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.5,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"o3-mini","name":"o3 Mini","cost_per_1m_in":1.1,"cost_per_1m_out":4.4,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.55,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"o4-mini","name":"o4 Mini","cost_per_1m_in":1.1,"cost_per_1m_out":4.4,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.275,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"qwen3-coder-480b-a35b-instruct","name":"Qwen 3 480B Coder","cost_per_1m_in":0.82,"cost_per_1m_out":3.29,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0,"context_window":131072,"default_max_tokens":65536,"can_reason":false,"supports_attachments":false,"options":{}}]} \ No newline at end of file +{"name":"Charm Hyper","id":"hyper","api_endpoint":"https://console.charm.land/api/v1/fantasy","type":"hyper","default_large_model_id":"claude-opus-4-5","default_small_model_id":"claude-haiku-4-5","models":[{"id":"claude-haiku-4-5","name":"Claude Haiku 4.5","cost_per_1m_in":1,"cost_per_1m_out":5,"cost_per_1m_in_cached":1.25,"cost_per_1m_out_cached":0.09999999999999999,"context_window":200000,"default_max_tokens":32000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4-5","name":"Claude Opus 4.5","cost_per_1m_in":5,"cost_per_1m_out":25,"cost_per_1m_in_cached":6.25,"cost_per_1m_out_cached":0.5,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-sonnet-4-5","name":"Claude Sonnet 4.5","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"gemini-3-flash","name":"Gemini 3 Flash","cost_per_1m_in":0.5,"cost_per_1m_out":3,"cost_per_1m_in_cached":0.049999999999999996,"cost_per_1m_out_cached":0,"context_window":1000000,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gemini-3-pro-preview","name":"Gemini 3 Pro","cost_per_1m_in":2,"cost_per_1m_out":12,"cost_per_1m_in_cached":0.19999999999999998,"cost_per_1m_out_cached":0,"context_window":1000000,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"glm-4.6","name":"GLM 4.6","cost_per_1m_in":0.44999999999999996,"cost_per_1m_out":1.7999999999999998,"cost_per_1m_in_cached":0.11,"cost_per_1m_out_cached":0,"context_window":200000,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"glm-4.7","name":"GLM 4.7","cost_per_1m_in":0.43,"cost_per_1m_out":1.75,"cost_per_1m_in_cached":0.08,"cost_per_1m_out_cached":0,"context_window":202752,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"gpt-5.1-codex","name":"GPT 5.1 Codex","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex-max","name":"GPT 5.1 Codex Max","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex-mini","name":"GPT 5.1 Codex Mini","cost_per_1m_in":0.25,"cost_per_1m_out":2,"cost_per_1m_in_cached":0.025,"cost_per_1m_out_cached":0.025,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.2","name":"GPT 5.2","cost_per_1m_in":1.75,"cost_per_1m_out":14,"cost_per_1m_in_cached":0.175,"cost_per_1m_out_cached":0.175,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.2-codex","name":"GPT 5.2 Codex","cost_per_1m_in":1.75,"cost_per_1m_out":14,"cost_per_1m_in_cached":0.175,"cost_per_1m_out_cached":0.175,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"grok-4.1-fast-non-reasoning","name":"Grok 4.1 Fast Non Reasoning","cost_per_1m_in":0.2,"cost_per_1m_out":0.5,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.05,"context_window":2000000,"default_max_tokens":200000,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"grok-4.1-fast-reasoning","name":"Grok 4.1 Fast Reasoning","cost_per_1m_in":0.2,"cost_per_1m_out":0.5,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.05,"context_window":2000000,"default_max_tokens":200000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"grok-code-fast-1","name":"Grok Code Fast","cost_per_1m_in":0.2,"cost_per_1m_out":1.5,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.02,"context_window":256000,"default_max_tokens":20000,"can_reason":true,"supports_attachments":false,"options":{}},{"id":"kimi-k2-0905","name":"Kimi K2","cost_per_1m_in":0.55,"cost_per_1m_out":2.19,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0,"context_window":256000,"default_max_tokens":10000,"can_reason":true,"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"kimi-k2.5","name":"Kimi K2.5","cost_per_1m_in":0.6,"cost_per_1m_out":3,"cost_per_1m_in_cached":0.09999999999999999,"cost_per_1m_out_cached":0,"context_window":262114,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}}]} \ No newline at end of file diff --git a/internal/agent/tools/download.go b/internal/agent/tools/download.go index 8f3f224b9e5647911d3c7e1cc5a668eea18b1785..def4968cababe0ffabbd88d929a692394bb86b36 100644 --- a/internal/agent/tools/download.go +++ b/internal/agent/tools/download.go @@ -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( diff --git a/internal/agent/tools/edit.go b/internal/agent/tools/edit.go index 2c9b15abfe148fb881ee90f75f207c1134776281..74b84c784796a97db2f379cf61fb3eb8b18934d4 100644 --- a/internal/agent/tools/edit.go +++ b/internal/agent/tools/edit.go @@ -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), diff --git a/internal/agent/tools/fetch.go b/internal/agent/tools/fetch.go index fdb63f057958e5e5a67affe0783a452c27febf41..0129fc3a46d264007649be088d843c0ebbf76149 100644 --- a/internal/agent/tools/fetch.go +++ b/internal/agent/tools/fetch.go @@ -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, } } diff --git a/internal/agent/tools/mcp/init.go b/internal/agent/tools/mcp/init.go index e1e7d609efc86d0dcb510fa5963552f7d487a134..c37f238e6d915d265153518b6df27f07bb6e456e 100644 --- a/internal/agent/tools/mcp/init.go +++ b/internal/agent/tools/mcp/init.go @@ -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 } diff --git a/internal/agent/tools/mcp/prompts.go b/internal/agent/tools/mcp/prompts.go index 0bd6e665dd80dad90c844d7d31c61c506ea83803..ea208a57716d2a273fde1b6faa3988ca2e57b012 100644 --- a/internal/agent/tools/mcp/prompts.go +++ b/internal/agent/tools/mcp/prompts.go @@ -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 } diff --git a/internal/agent/tools/mcp/tools.go b/internal/agent/tools/mcp/tools.go index 779baa55d93bc54523bac81c5094bacee7fc68fb..65ef5a9d8b3e7304a49bd708ecdd53a3cc400b17 100644 --- a/internal/agent/tools/mcp/tools.go +++ b/internal/agent/tools/mcp/tools.go @@ -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 } diff --git a/internal/agent/tools/multiedit.go b/internal/agent/tools/multiedit.go index 0640228d23230e6a49d8e1405f371c099031fbf7..48736ebf311230a28b51702e0ddd3ff8df19b284 100644 --- a/internal/agent/tools/multiedit.go +++ b/internal/agent/tools/multiedit.go @@ -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 { diff --git a/internal/agent/tools/multiedit_test.go b/internal/agent/tools/multiedit_test.go index b6d575435e63dcd62a4dc9a7efb76cf13c14ad05..1ca2a6f7689e345ac944889f1f92284de0652f90 100644 --- a/internal/agent/tools/multiedit_test.go +++ b/internal/agent/tools/multiedit_test.go @@ -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 diff --git a/internal/agent/tools/sourcegraph.go b/internal/agent/tools/sourcegraph.go index 3cb22652a74554e036a0aaaa7a54b457955cbe2e..72ecf2d6edb924594bc0c8700d88b6d8db256b50 100644 --- a/internal/agent/tools/sourcegraph.go +++ b/internal/agent/tools/sourcegraph.go @@ -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( diff --git a/internal/agent/tools/view.go b/internal/agent/tools/view.go index 35865cf43f7c587d60764b3ed177374940bbe2dc..b26267fcef3b296babc3c9dbcee64336ef162b75 100644 --- a/internal/agent/tools/view.go +++ b/internal/agent/tools/view.go @@ -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\n" output += getDiagnostics(filePath, lspClients) - filetracker.RecordRead(filePath) + filetracker.RecordRead(ctx, sessionID, filePath) return fantasy.WithResponseMetadata( fantasy.NewTextResponse(output), ViewResponseMetadata{ diff --git a/internal/agent/tools/web_fetch.go b/internal/agent/tools/web_fetch.go index 8dc5376861db26ab2a11bac07775a654711c556b..91c326a7b8671d4cdff9b7b04329371075c5dc94 100644 --- a/internal/agent/tools/web_fetch.go +++ b/internal/agent/tools/web_fetch.go @@ -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, } } diff --git a/internal/agent/tools/web_search.go b/internal/agent/tools/web_search.go index 5ce9280c013cdd100f6d7734c969723b21e7e3bf..e441aeebad9d699bb1fa33330b2d70559ae868ff 100644 --- a/internal/agent/tools/web_search.go +++ b/internal/agent/tools/web_search.go @@ -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, } } diff --git a/internal/agent/tools/write.go b/internal/agent/tools/write.go index 8becaea3c08157897dcece7b3d5d4de5cb2ee929..c2f5c7d1c83efd0731e8623c1e9cbb98b9bfdd2f 100644 --- a/internal/agent/tools/write.go +++ b/internal/agent/tools/write.go @@ -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) diff --git a/internal/app/app.go b/internal/app/app.go index b186c1aeb4f7d0adbc3d0fd443b660952a4def52..219b66f3cb79abcb6f004d08a6dc07bd539198ec 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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. diff --git a/internal/app/lsp.go b/internal/app/lsp.go index 23a5447af92872223f91d3283cf6663aae0d1d07..a93fadbd1869f46bb153e19fa15428f74293b7fc 100644 --- a/internal/app/lsp.go +++ b/internal/app/lsp.go @@ -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) diff --git a/internal/app/provider_test.go b/internal/app/provider_test.go index c3acae64d1057f3bb8bd8f9a0cb6443dbe9731b7..8430211e0067810523a713a07a343ac546248830 100644 --- a/internal/app/provider_test.go +++ b/internal/app/provider_test.go @@ -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" ) diff --git a/internal/cmd/models.go b/internal/cmd/models.go index 3267469638ee83463e1785774d37c5d281d37de9..e2aa5c991d5cf49ba78dbff9d3f79c4f6493523d 100644 --- a/internal/cmd/models.go +++ b/internal/cmd/models.go @@ -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" diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 351c9d414dd28b596374cf3a99459a1098d3c41b..727e4741dbfc607161e425c6b597ed7e28723a1b 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -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") diff --git a/internal/cmd/run.go b/internal/cmd/run.go index e4d72b41be13684e28ca6c2b85b79bfdcea52fc7..50005a548bad0308bdca3a2afbe17503c1f86c56 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -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") } diff --git a/internal/cmd/stats/index.css b/internal/cmd/stats/index.css index b01c84442f6cbe1675f46ec02a65d801d0abed2d..0216f9f79bd6bd16f77a5fd0ec14e9c142815436 100644 --- a/internal/cmd/stats/index.css +++ b/internal/cmd/stats/index.css @@ -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 { diff --git a/internal/cmd/stats/index.html b/internal/cmd/stats/index.html index 4b25831f86c76f86bf405d3c9e77ddb2b7d1821e..b2822b132c6af919874523678a42ec32d3a76475 100644 --- a/internal/cmd/stats/index.html +++ b/internal/cmd/stats/index.html @@ -28,7 +28,7 @@
- Generated by {{.Username}} for {{.ProjectName}} in {{.GeneratedAt}}. + Generated by {{.Username}} for {{.ProjectName}} on {{.GeneratedAt}}.
diff --git a/internal/config/catwalk.go b/internal/config/catwalk.go index c3cc2eb69d47e1a85e35164fda09d0f73761b820..0c12c899c7ee34d6515410cccab13ac850a361a7 100644 --- a/internal/config/catwalk.go +++ b/internal/config/catwalk.go @@ -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 { diff --git a/internal/config/catwalk_test.go b/internal/config/catwalk_test.go index 55322b34eb7252f8cae75fb46996f45bd31abe5e..df6aea475811adfe3e4fb8935185842c7c81d145 100644 --- a/internal/config/catwalk_test.go +++ b/internal/config/catwalk_test.go @@ -7,7 +7,7 @@ import ( "os" "testing" - "github.com/charmbracelet/catwalk/pkg/catwalk" + "charm.land/catwalk/pkg/catwalk" "github.com/stretchr/testify/require" ) diff --git a/internal/config/config.go b/internal/config/config.go index eb8394e11972de4c91017a4b92e59ccee804ef0c..d5f3b8fb65b0d8d7f694fa3368d0263f4c3336a9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 } } diff --git a/internal/config/copilot.go b/internal/config/copilot.go index ee50bec43d6ce5754799adf4bfe99ba9b357d690..d72e7d5048ba4d31c88d7f7152a6b3a9510960a2 100644 --- a/internal/config/copilot.go +++ b/internal/config/copilot.go @@ -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" ) diff --git a/internal/config/hyper.go b/internal/config/hyper.go index 5fe6fc5a1ee54bd19902ef4c9cc6034a6b294b6f..6772f27b3bd3be136d001139a8505a7bb3fedef3 100644 --- a/internal/config/hyper.go +++ b/internal/config/hyper.go @@ -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" ) diff --git a/internal/config/hyper_test.go b/internal/config/hyper_test.go index 7141eaa1e97888b5ee6f84afc8e9658825547b46..e4b6ac8acdfcdeb19f0d600baf88a337d40c230d 100644 --- a/internal/config/hyper_test.go +++ b/internal/config/hyper_test.go @@ -7,7 +7,7 @@ import ( "os" "testing" - "github.com/charmbracelet/catwalk/pkg/catwalk" + "charm.land/catwalk/pkg/catwalk" "github.com/stretchr/testify/require" ) diff --git a/internal/config/load.go b/internal/config/load.go index 25139cb5f4b2ba8013525bfde025f04cb267d1b8..a651f4846307ed9729ba8a10835e98aece486dbd 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -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" } diff --git a/internal/config/load_test.go b/internal/config/load_test.go index 08c888318724104935b9e92403f09f54f8ae20a4..60a0b7379501a7d766b33c4828c644cdb390bada 100644 --- a/internal/config/load_test.go +++ b/internal/config/load_test.go @@ -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" diff --git a/internal/config/provider.go b/internal/config/provider.go index 253d6f658a567ed5302887ecb87415de0a89c504..6ca981e5a73cbf3e3472b05f55c7b911a4a857c3 100644 --- a/internal/config/provider.go +++ b/internal/config/provider.go @@ -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" diff --git a/internal/config/provider_empty_test.go b/internal/config/provider_empty_test.go index 7c37a9afb9694f0ea4352faee1b11d7e40d9480e..9bc62f5c3141d239aaadc3947dce539a4dcf4810 100644 --- a/internal/config/provider_empty_test.go +++ b/internal/config/provider_empty_test.go @@ -5,7 +5,7 @@ import ( "os" "testing" - "github.com/charmbracelet/catwalk/pkg/catwalk" + "charm.land/catwalk/pkg/catwalk" "github.com/stretchr/testify/require" ) diff --git a/internal/config/provider_test.go b/internal/config/provider_test.go index e8790e286c3ffc8db77edb0ef8353e54ad519458..283c18c8ab68c013dadf6f4fc8174f4947210f3a 100644 --- a/internal/config/provider_test.go +++ b/internal/config/provider_test.go @@ -7,7 +7,7 @@ import ( "sync" "testing" - "github.com/charmbracelet/catwalk/pkg/catwalk" + "charm.land/catwalk/pkg/catwalk" "github.com/stretchr/testify/require" ) diff --git a/internal/db/db.go b/internal/db/db.go index 81c3179e22f6768b2ffa2c5b4af2e10c385d5835..739c2087e1c1e125875d5006c86f85de37fed3be 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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, diff --git a/internal/db/messages.sql.go b/internal/db/messages.sql.go index f10b9d5e2c47ec90aec9dc0f206d4a157fa7f6b0..44e8bb366b3e864b6716d8ccefa301c86c915234 100644 --- a/internal/db/messages.sql.go +++ b/internal/db/messages.sql.go @@ -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 diff --git a/internal/db/migrations/20260127000000_add_read_files_table.sql b/internal/db/migrations/20260127000000_add_read_files_table.sql new file mode 100644 index 0000000000000000000000000000000000000000..1161f1992885fc66e309024a0d874565ea276229 --- /dev/null +++ b/internal/db/migrations/20260127000000_add_read_files_table.sql @@ -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 diff --git a/internal/db/models.go b/internal/db/models.go index 317e7c92e09c857ee610832e365af2c4ecc90181..a105074ab9e6320bd92b90121e7694b1f8cd1e5a 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -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"` diff --git a/internal/db/querier.go b/internal/db/querier.go index c70386690c6c42aca53a2b6682ddca0f3a0262ba..c233fd59f63f8b46d3e6d62e1c162f47d6d34e3f 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -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 diff --git a/internal/db/read_files.sql.go b/internal/db/read_files.sql.go new file mode 100644 index 0000000000000000000000000000000000000000..b18907c1f27a3c753b6b1a2cf1ca0563c3fd78d5 --- /dev/null +++ b/internal/db/read_files.sql.go @@ -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 +} diff --git a/internal/db/sql/messages.sql b/internal/db/sql/messages.sql index fc66b78c08b85c8fe1f7ec79985fb2edd4a03668..91d158eb1fb1d2280698ba09193a6298c7b129da 100644 --- a/internal/db/sql/messages.sql +++ b/internal/db/sql/messages.sql @@ -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; diff --git a/internal/db/sql/read_files.sql b/internal/db/sql/read_files.sql new file mode 100644 index 0000000000000000000000000000000000000000..f607312c2ba8660aa2c7030e415ce2ca7320cd6d --- /dev/null +++ b/internal/db/sql/read_files.sql @@ -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; diff --git a/internal/event/event.go b/internal/event/event.go index 674586b06bee03f22c1bd880a5bd39b740c75f66..10b054ce0b21fb3c0db441746827a20739963315 100644 --- a/internal/event/event.go +++ b/internal/event/event.go @@ -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 } } diff --git a/internal/event/event_test.go b/internal/event/event_test.go new file mode 100644 index 0000000000000000000000000000000000000000..7cd22248f19ca072853cd4270ae6fc36e4c124f5 --- /dev/null +++ b/internal/event/event_test.go @@ -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) +} diff --git a/internal/filetracker/filetracker.go b/internal/filetracker/filetracker.go deleted file mode 100644 index 534a19dacdc209f7ef2d9c5b107cb5f88a665ee5..0000000000000000000000000000000000000000 --- a/internal/filetracker/filetracker.go +++ /dev/null @@ -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) -} diff --git a/internal/filetracker/service.go b/internal/filetracker/service.go new file mode 100644 index 0000000000000000000000000000000000000000..8f080d124e49dfc32f43796194c09ac22beaa9f1 --- /dev/null +++ b/internal/filetracker/service.go @@ -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 +} diff --git a/internal/filetracker/service_test.go b/internal/filetracker/service_test.go new file mode 100644 index 0000000000000000000000000000000000000000..c7fb15090dd31e9591c5c3b9c2a256c839aea3f6 --- /dev/null +++ b/internal/filetracker/service_test.go @@ -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") +} diff --git a/internal/fsext/ls.go b/internal/fsext/ls.go index c22b960ad02a42bf6adac7768b7d99e55a9390ee..b541a4a0fedd78c866fa274fc183fabe4c833edd 100644 --- a/internal/fsext/ls.go +++ b/internal/fsext/ls.go @@ -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, diff --git a/internal/fsext/paste.go b/internal/fsext/paste.go new file mode 100644 index 0000000000000000000000000000000000000000..4996473acf41355e391ba6e9bf2547abfbbea9cb --- /dev/null +++ b/internal/fsext/paste.go @@ -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 +} diff --git a/internal/fsext/paste_test.go b/internal/fsext/paste_test.go new file mode 100644 index 0000000000000000000000000000000000000000..c1c4d4adfba0eca44586f55f2a23dd882038522e --- /dev/null +++ b/internal/fsext/paste_test.go @@ -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) + }) + } + }) +} diff --git a/internal/home/home.go b/internal/home/home.go index e44649235ff5bb24c8bb644ae90e9002add45237..80fb1ea2e01630597c2547eaa8e4e55150ec6976 100644 --- a/internal/home/home.go +++ b/internal/home/home.go @@ -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) } } diff --git a/internal/lsp/client.go b/internal/lsp/client.go index d2f4ab8c6f1f495ec836198d86621a9df279457b..6c0059250c062c01ab3d541f4b0ca55ebf0b0cb6 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -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 } diff --git a/internal/lsp/filtermatching_test.go b/internal/lsp/filtermatching_test.go new file mode 100644 index 0000000000000000000000000000000000000000..40c796916b73169b882404eecfb4625e7baaa85b --- /dev/null +++ b/internal/lsp/filtermatching_test.go @@ -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") + }) +} diff --git a/internal/lsp/language.go b/internal/lsp/language.go deleted file mode 100644 index 7d6a1517e849b6f09352447b2acb05539b3220af..0000000000000000000000000000000000000000 --- a/internal/lsp/language.go +++ /dev/null @@ -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 - } -} diff --git a/internal/lsp/rootmarkers_test.go b/internal/lsp/rootmarkers_test.go deleted file mode 100644 index 7b3a3c0905799865808b9b1ae0dff992e00ed34c..0000000000000000000000000000000000000000 --- a/internal/lsp/rootmarkers_test.go +++ /dev/null @@ -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"})) -} diff --git a/internal/message/content.go b/internal/message/content.go index 3fed1f06019c855d30af9d5583e6a7b63fcbd508..02f949334b688e4dd40c832d5f68d52523ac9953 100644 --- a/internal/message/content.go +++ b/internal/message/content.go @@ -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 diff --git a/internal/message/message.go b/internal/message/message.go index 04eb8252bbe9a68444eba81fc581c6b49231734b..6da8827b72227602dc36c39b6a2254aba18d2b0d 100644 --- a/internal/message/message.go +++ b/internal/message/message.go @@ -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 { diff --git a/internal/session/session.go b/internal/session/session.go index 905ee1cf1417b148019d9688985c1f5200209d69..0ef6cfe22bebbf35df48f0db1fbe00c6d128251b 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -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, diff --git a/internal/stringext/string.go b/internal/stringext/string.go index 03456db93bc148f7c77e52da3c493c94fa79624f..8be28ccc2096c3d54b9f3106ed30d584503acdf4 100644 --- a/internal/stringext/string.go +++ b/internal/stringext/string.go @@ -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 +} diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index ba832b415133305fccbefa37da6b749405feb2c6..575c23114a9115209db7a2a02e642fe5f2246541 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/internal/tui/components/chat/editor/editor.go @@ -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 } diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index b4db149946fe0a1f67c957eeb04da2966e1f5f28..3c91f9f41485b439b8c25ca0692c7265ccafb14a 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/internal/tui/components/chat/messages/messages.go @@ -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" diff --git a/internal/tui/components/chat/sidebar/sidebar.go b/internal/tui/components/chat/sidebar/sidebar.go index 9b3d52dadb9a7677bdb5db4b3a8360e7385775ba..40bc8821e0a3dc7c3dec62bbcde34a5241ec4aa7 100644 --- a/internal/tui/components/chat/sidebar/sidebar.go +++ b/internal/tui/components/chat/sidebar/sidebar.go @@ -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 diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index 517f6d0930c46cf3d2e9f656c22515de4e9785fd..886fe5e530978678246ab120b21e0f943018fd1a 100644 --- a/internal/tui/components/chat/splash/splash.go +++ b/internal/tui/components/chat/splash/splash.go @@ -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" diff --git a/internal/tui/components/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go index cde5b203ca985f81c390d02725ef04d11a5cd518..3c86c984561f96350b2b621c15ae14be9649ae36 100644 --- a/internal/tui/components/dialogs/commands/commands.go +++ b/internal/tui/components/dialogs/commands/commands.go @@ -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" diff --git a/internal/tui/components/dialogs/models/list.go b/internal/tui/components/dialogs/models/list.go index 581122525a89dd308bb57a30e6b15a4cd0896708..50469a132aab60c3e63a77d9169c47688d5d9151 100644 --- a/internal/tui/components/dialogs/models/list.go +++ b/internal/tui/components/dialogs/models/list.go @@ -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" diff --git a/internal/tui/components/dialogs/models/list_recent_test.go b/internal/tui/components/dialogs/models/list_recent_test.go index 9b738a4b17fbaa2de18de080a769cce41a676007..5afdde98502d3d26d46dce00ab1825ca07f36831 100644 --- a/internal/tui/components/dialogs/models/list_recent_test.go +++ b/internal/tui/components/dialogs/models/list_recent_test.go @@ -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" diff --git a/internal/tui/components/dialogs/models/models.go b/internal/tui/components/dialogs/models/models.go index b06b4b475a9ababbda9e0702fc5552b0959741ba..34f91d060cf7b7a7fd0a3a6fe678a23ed8439530 100644 --- a/internal/tui/components/dialogs/models/models.go +++ b/internal/tui/components/dialogs/models/models.go @@ -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" diff --git a/internal/tui/components/lsp/lsp.go b/internal/tui/components/lsp/lsp.go index f9118143cbfd9a7bf19aa569bc85448746debecd..3379c2c9acfd7e7e10d6e6777e2554d0b0db2144 100644 --- a/internal/tui/components/lsp/lsp.go +++ b/internal/tui/components/lsp/lsp.go @@ -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...") diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index 9a4b69f5507fbb62b7ee93df6326f94cf79d22ad..bb2eb755bf80995dd41d9ac564174de5b90262bb 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -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, diff --git a/internal/ui/chat/generic.go b/internal/ui/chat/generic.go new file mode 100644 index 0000000000000000000000000000000000000000..6b0ac433028daf7a06c57f85c7799250e9652f6f --- /dev/null +++ b/internal/ui/chat/generic.go @@ -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) +} diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index 45314347187e7018445d30753ffd05d24dbc716a..0c5668a20d52c5975dc63cb37da8090e9aa0ca7f 100644 --- a/internal/ui/chat/messages.go +++ b/internal/ui/chat/messages.go @@ -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 diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index e10d28e061e17c636dc9e1a6cfe364ca6f220d0e..f7702cc1fe516bb3dee7d57ce15fed050299019f 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -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) } } diff --git a/internal/ui/common/capabilities.go b/internal/ui/common/capabilities.go new file mode 100644 index 0000000000000000000000000000000000000000..6636976d7d4f86d9283be2db759b44f948ad40f5 --- /dev/null +++ b/internal/ui/common/capabilities.go @@ -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...) +} diff --git a/internal/ui/dialog/actions.go b/internal/ui/dialog/actions.go index b5db01692437dbee4b11b77da47b68f258b090e9..7c11cbd91b202cfc16e1988027f9eed657368620 100644 --- a/internal/ui/dialog/actions.go +++ b/internal/ui/dialog/actions.go @@ -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" diff --git a/internal/ui/dialog/api_key_input.go b/internal/ui/dialog/api_key_input.go index 65fe5cfb9cb14eb60f4399b0477d6cd071315750..0ca50b8fe7f8899f16aac8428caa796c5da89610 100644 --- a/internal/ui/dialog/api_key_input.go +++ b/internal/ui/dialog/api_key_input.go @@ -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" diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index 444492c9f71241bf812f0a96ac18d2118919e33d..0b0185b03a3c992ce55ff9164ceba6115260c174 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -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 != "" { diff --git a/internal/ui/dialog/commands_item.go b/internal/ui/dialog/commands_item.go index b1977545ded8e8eeb8fc1e59c5a0a31e18ce8610..89cd552f8ef5acfb326e9fbcae87b0a542b35022 100644 --- a/internal/ui/dialog/commands_item.go +++ b/internal/ui/dialog/commands_item.go @@ -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) } diff --git a/internal/ui/dialog/filepicker.go b/internal/ui/dialog/filepicker.go index ce4adcf8b2dc759f5eceff6ad0d7f6d1728fb7de..4b0b844e4ed869a4347af10e9d0b1b3c70a7d2f0 100644 --- a/internal/ui/dialog/filepicker.go +++ b/internal/ui/dialog/filepicker.go @@ -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 diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index 450ee8b99b75f13c1c9885281a1dfd1a0a3d9867..44ff42a23c5eb722e4baa764346f631292799b30 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -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 + diff --git a/internal/ui/dialog/models_item.go b/internal/ui/dialog/models_item.go index bfe30c0e3a04c24c71579bfbdbd06b576e1ad033..645b26e987b38baabd27338d43a19a4652144788 100644 --- a/internal/ui/dialog/models_item.go +++ b/internal/ui/dialog/models_item.go @@ -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) } diff --git a/internal/ui/dialog/oauth.go b/internal/ui/dialog/oauth.go index e4f7a521cacb51d215ca405883351558ed7179d6..6fbb039255144ad14b15a39f34942e504dea3f2c 100644 --- a/internal/ui/dialog/oauth.go +++ b/internal/ui/dialog/oauth.go @@ -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" diff --git a/internal/ui/dialog/oauth_copilot.go b/internal/ui/dialog/oauth_copilot.go index 4b671852d476578f94653393796056d630ba23a5..8afb0df23134bb9e820ae2385d6b9b6838e07d98 100644 --- a/internal/ui/dialog/oauth_copilot.go +++ b/internal/ui/dialog/oauth_copilot.go @@ -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" diff --git a/internal/ui/dialog/oauth_hyper.go b/internal/ui/dialog/oauth_hyper.go index bddf4d78ef2c920855f21e056e7ee48f985b0b68..d90c385db782478721fd3e9efa49a2984f34304d 100644 --- a/internal/ui/dialog/oauth_hyper.go +++ b/internal/ui/dialog/oauth_hyper.go @@ -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" diff --git a/internal/ui/dialog/permissions.go b/internal/ui/dialog/permissions.go index d877d7085afbe8920c96898ce029e059dfa59e46..daabc10b1aea0ee9db6c4e3608be62e7cfcbfd39 100644 --- a/internal/ui/dialog/permissions.go +++ b/internal/ui/dialog/permissions.go @@ -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 } diff --git a/internal/ui/dialog/reasoning.go b/internal/ui/dialog/reasoning.go index 4c5dad086bb01eb3dc12f2f6d379c87a5638d297..2a333f155cdc1499993f05411d7090793f74f54e 100644 --- a/internal/ui/dialog/reasoning.go +++ b/internal/ui/dialog/reasoning.go @@ -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) } diff --git a/internal/ui/dialog/sessions.go b/internal/ui/dialog/sessions.go index 4f607ab0e23d43b58eac7784abc3fed658d4bcba..227e060e6c6483644b4ad18bef00153bd4f6ca5f 100644 --- a/internal/ui/dialog/sessions.go +++ b/internal/ui/dialog/sessions.go @@ -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 diff --git a/internal/ui/dialog/sessions_item.go b/internal/ui/dialog/sessions_item.go index 87a2627daa3b63eca309feeb914ec80c33e2ef1f..2532e8c19a75ef061266afd42d688016ea0ab3c9 100644 --- a/internal/ui/dialog/sessions_item.go +++ b/internal/ui/dialog/sessions_item.go @@ -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() } diff --git a/internal/ui/image/image.go b/internal/ui/image/image.go index 6af76531ff5b542f180e38fb7db105e4a86b49b6..07039433dded1647646704959791dfcad7d3d69f 100644 --- a/internal/ui/image/image.go +++ b/internal/ui/image/image.go @@ -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", diff --git a/internal/ui/list/highlight.go b/internal/ui/list/highlight.go index fefe836d110b52496028d21071fffc5262189d92..631181db29ce5bc3a2087de30341342f0374b229 100644 --- a/internal/ui/list/highlight.go +++ b/internal/ui/list/highlight.go @@ -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 } diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go index a731a0a30023c451f2e1067e4e15ccb5e06ea177..33a5087c9ceae3f03bb2c8f78b2cc8089f87057c 100644 --- a/internal/ui/list/list.go +++ b/internal/ui/list/list.go @@ -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. diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index d009a261580eaed209c1fc15966f50f4a8b3e62d..723e97fb76c04d75922a5aec60d9afa970e41d97 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -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 +} diff --git a/internal/ui/model/history.go b/internal/ui/model/history.go new file mode 100644 index 0000000000000000000000000000000000000000..5d2284ab1756257cc06b76de4621849f1e3071ba --- /dev/null +++ b/internal/ui/model/history.go @@ -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 +} diff --git a/internal/ui/model/keys.go b/internal/ui/model/keys.go index 6e21e4dee0dbae1dffc124066b01185c7ebc9d3a..a42b1e7aa0ac9ac474de626b55ceb3a91824cdff 100644 --- a/internal/ui/model/keys.go +++ b/internal/ui/model/keys.go @@ -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"), diff --git a/internal/ui/model/lsp.go b/internal/ui/model/lsp.go index de33142d51c720265ad84d317b83f5a997f69fac..c46beb10083b420ec1353c8a2536d45093a899b4 100644 --- a/internal/ui/model/lsp.go +++ b/internal/ui/model/lsp.go @@ -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 diff --git a/internal/ui/model/onboarding.go b/internal/ui/model/onboarding.go index d18469ee822460e60544a304afebb37dac7fa0d9..1cd481f2f9a3625ba0ed8f12c8450265c0aa5ef0 100644 --- a/internal/ui/model/onboarding.go +++ b/internal/ui/model/onboarding.go @@ -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 } diff --git a/internal/ui/model/pills.go b/internal/ui/model/pills.go index 7662b10cc61c19b5333f7487747354341e35aa99..9199bc6deece64774343087bc596396b54272f4c 100644 --- a/internal/ui/model/pills.go +++ b/internal/ui/model/pills.go @@ -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) } diff --git a/internal/ui/model/sidebar.go b/internal/ui/model/sidebar.go index 7e6a61864a42f37ba7bf1c955b6844f4c488b942..7316025aaedad67688b226cf1c7c37314f3b7a30 100644 --- a/internal/ui/model/sidebar.go +++ b/internal/ui/model/sidebar.go @@ -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)) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index cd1ad42a0dc473c31b3ff280a7a224d64d0094c2..806ce0bcdf4bf0217f759aa97d361b1e60a824b7 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -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)") } diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 455658e7f4900196f7c03dcc1564ea734f780a64..45aa6dc998226469f800883fb4ff9452cb56481a 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -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() } diff --git a/schema.json b/schema.json index 6eeaa40c1865ebb5e46f70964f4eba69cf47013e..c8d2482079f294b6499810c34c312f0e1729d929 100644 --- a/schema.json +++ b/schema.json @@ -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" } } } diff --git a/scripts/check_log_capitalization.sh b/scripts/check_log_capitalization.sh new file mode 100755 index 0000000000000000000000000000000000000000..fa5f651dfb1a7dc53876018029599edd3479d94f --- /dev/null +++ b/scripts/check_log_capitalization.sh @@ -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